From bc2e7a7366bd24edc686ec5da567b1e5f4ca5b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Christian=20Gr=C3=BCnhage?= Date: Mon, 8 Mar 2021 17:02:04 +0100 Subject: [PATCH 001/166] chore: optimise Dockerfile So far, the Dockerfile built hydrogen on the server running the image, instead of building it during the building of the image. This blew up the image size immensely and caused node+yarn to run in the resulting image. This new Dockerfile builds hydrogen in a separate build stage and then moves the target directory into an nginx based container image, which takes care of serving the target webroot. The existing Dockerfile has been moved to Dockerfile-dev for usage as a development environment. The docs have been adjusted accordingly. Additionally, this switched from a fixed alpine version of the node image to the latest alpine version, and changed the container image references in the `FROM` statements to use the fully qualified references including the registry domain. --- .dockerignore | 2 ++ Dockerfile | 14 +++++++------ Dockerfile-dev | 7 +++++++ doc/docker.md | 56 +++++++++++++++++++++++++++++++++++++++++--------- 4 files changed, 63 insertions(+), 16 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile-dev diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..2c085d1d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +target diff --git a/Dockerfile b/Dockerfile index bc893b3e..6acd7d10 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ -FROM node:alpine3.12 +FROM docker.io/node:alpine as builder RUN apk add --no-cache git -COPY . /code -WORKDIR /code -RUN yarn install -EXPOSE 3000 -ENTRYPOINT ["yarn", "start"] +COPY . /app +WORKDIR /app +RUN yarn install \ + && yarn build + +FROM docker.io/nginx:alpine +COPY --from=builder /app/target /usr/share/nginx/html diff --git a/Dockerfile-dev b/Dockerfile-dev new file mode 100644 index 00000000..31877ee1 --- /dev/null +++ b/Dockerfile-dev @@ -0,0 +1,7 @@ +FROM docker.io/node:alpine +RUN apk add --no-cache git +COPY . /code +WORKDIR /code +RUN yarn install +EXPOSE 3000 +ENTRYPOINT ["yarn", "start"] diff --git a/doc/docker.md b/doc/docker.md index 92f33ae8..910938f0 100644 --- a/doc/docker.md +++ b/doc/docker.md @@ -1,22 +1,58 @@ +## Warning + Usage of docker is a third-party contribution and not actively tested, used or supported by the main developer(s). -Having said that, you can also use Docker to create a local dev environment. +Having said that, you can also use Docker to create a local dev environment or a production deployment. + +## Dev environment In this repository, create a Docker image: - docker build -t hydrogen . +``` +docker build -t hydrogen-dev -f Dockerfile-dev . +``` Then start up a container from that image: - docker run \ - --name hydrogen-dev \ - --publish 3000:3000 \ - --volume "$PWD":/code \ - --interactive \ - --tty \ - --rm \ - hydrogen +``` +docker run \ + --name hydrogen-dev \ + --publish 3000:3000 \ + --volume "$PWD":/code \ + --interactive \ + --tty \ + --rm \ + hydrogen-dev +``` Then point your browser to `http://localhost:3000`. You can see the server logs in the terminal where you started the container. To stop the container, simply hit `ctrl+c`. + +## Production deployment + +### Build or pull image + +In this repository, create a Docker image: + +``` +docker build -t hydrogen . +``` + +Or, pull the docker image from GitLab: + +``` +docker pull registry.gitlab.com/jcgruenhage/hydrogen-web +docker tag registry.gitlab.com/jcgruenhage/hydrogen-web hydrogen +``` + +### Start container image + +Then, start up a container from that image: + +``` +docker run \ + --name hydrogen \ + --publish 80:80 \ + hydrogen +``` From d7b3d50a13ff63f50c5aff93061bd811257d9821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Christian=20Gr=C3=BCnhage?= Date: Sun, 14 Mar 2021 09:44:49 +0100 Subject: [PATCH 002/166] chore: add GitLab CI definition This adds a GitLab CI definition which runs tests, and if they succeed, build an archive and container image. --- .gitlab-ci.yml | 61 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .gitlab-ci.yml diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 00000000..abfe0ffa --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,61 @@ +image: docker.io/alpine + +stages: + - test + - build + +.yarn-template: + image: docker.io/node + before_script: + - yarn install + cache: + paths: + - node_modules +test: + extends: .yarn-template + stage: test + script: + - yarn test + +build: + extends: .yarn-template + stage: build + script: + - yarn build + artifacts: + paths: + - target + +.docker-template: + image: docker.io/docker + stage: build + services: + - docker:dind + before_script: + - docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" $CI_REGISTRY + +docker-release: + extends: .docker-template + rules: + - if: '$CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/' + script: + - docker build --pull -t "${CI_REGISTRY_IMAGE}:latest" -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" . + - docker push "${CI_REGISTRY_IMAGE}:latest" + - docker push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" + +docker-tags: + extends: .docker-template + rules: + - if: '$CI_COMMIT_TAG && $CI_COMMIT_TAG !~ /^v\d+\.\d+\.\d+$/' + script: + - docker build --pull -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" . + - docker push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG}" + +docker-branches: + extends: .docker-template + rules: + - if: $CI_COMMIT_BRANCH + script: + - docker build --pull -t "${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}" . + - docker push "${CI_REGISTRY_IMAGE}:${CI_COMMIT_REF_SLUG}" + From eba4d8a28b3e64c397ea83a7250e41faf308e70c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 12:43:00 +0100 Subject: [PATCH 003/166] improve ergonomics for sub-templates in TemplateView - renames if to ifView - adds map and if that immediately take a sub template render function rather than having to call createTemplate --- src/platform/web/ui/general/TemplateView.js | 27 +++++++++++++------ src/platform/web/ui/login/LoginView.js | 2 +- .../web/ui/login/SessionPickerView.js | 8 +++--- .../web/ui/session/SessionStatusView.js | 6 ++--- .../ui/session/room/timeline/BaseMediaView.js | 2 +- .../web/ui/session/room/timeline/GapView.js | 2 +- .../settings/SessionBackupSettingsView.js | 4 +-- 7 files changed, 31 insertions(+), 20 deletions(-) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 14cb53ac..6b016b9b 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -296,11 +296,6 @@ class TemplateBuilder { return root; } - // sugar - createTemplate(render) { - return vm => new TemplateView(vm, render); - } - // map a value to a view, every time the value changes mapView(mapFn, viewCreator) { return this._addReplaceNodeBinding(mapFn, (prevNode) => { @@ -321,13 +316,29 @@ class TemplateBuilder { }); } - // creates a conditional subtemplate - if(fn, viewCreator) { + // Special case of mapView for a TemplateView. + // Always creates a TemplateView, if this is optional depending + // on mappedValue, use `if` or `mapView` + map(mapFn, renderFn) { + return this.mapView(mapFn, mappedValue => { + return new TemplateView(this._value, (t, vm) => { + return renderFn(mappedValue, t, vm); + }); + }); + } + + ifView(predicate, viewCreator) { return this.mapView( - value => !!fn(value), + value => !!predicate(value), enabled => enabled ? viewCreator(this._value) : null ); } + + // creates a conditional subtemplate + // use mapView if you need to map to a different view class + if(predicate, renderFn) { + return this.ifView(predicate, vm => new TemplateView(vm, renderFn)); + } } diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 48dbcdf1..0b15d42a 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -45,7 +45,7 @@ export class LoginView extends TemplateView { t.div({className: "logo"}), t.div({className: "LoginView form"}, [ t.h1([vm.i18n`Sign In`]), - t.if(vm => vm.error, t.createTemplate(t => t.div({className: "error"}, vm => vm.error))), + t.if(vm => vm.error, t => t.div({className: "error"}, vm => vm.error)), t.form({ onSubmit: evnt => { evnt.preventDefault(); diff --git a/src/platform/web/ui/login/SessionPickerView.js b/src/platform/web/ui/login/SessionPickerView.js index 279135ac..4bb69ee2 100644 --- a/src/platform/web/ui/login/SessionPickerView.js +++ b/src/platform/web/ui/login/SessionPickerView.js @@ -70,14 +70,14 @@ class SessionPickerItemView extends TemplateView { disabled: vm => vm.isClearing, onClick: () => vm.export(), }, "Export"); - const downloadExport = t.if(vm => vm.exportDataUrl, t.createTemplate((t, vm) => { + const downloadExport = t.if(vm => vm.exportDataUrl, (t, vm) => { return t.a({ href: vm.exportDataUrl, download: `brawl-session-${vm.id}.json`, onClick: () => setTimeout(() => vm.clearExport(), 100), }, "Download"); - })); - const errorMessage = t.if(vm => vm.error, t.createTemplate(t => t.p({className: "error"}, vm => vm.error))); + }); + const errorMessage = t.if(vm => vm.error, t => t.p({className: "error"}, vm => vm.error)); return t.li([ t.a({className: "session-info", href: vm.openUrl}, [ t.div({className: `avatar usercolor${vm.avatarColorNumber}`}, vm => vm.avatarInitials), @@ -118,7 +118,7 @@ export class SessionPickerView extends TemplateView { href: vm.cancelUrl }, vm.i18n`Sign In`) ]), - t.if(vm => vm.loadViewModel, vm => new SessionLoadStatusView(vm.loadViewModel)), + t.ifView(vm => vm.loadViewModel, () => new SessionLoadStatusView(vm.loadViewModel)), t.p(hydrogenGithubLink(t)) ]) ]); diff --git a/src/platform/web/ui/session/SessionStatusView.js b/src/platform/web/ui/session/SessionStatusView.js index 6a123ea9..fff25453 100644 --- a/src/platform/web/ui/session/SessionStatusView.js +++ b/src/platform/web/ui/session/SessionStatusView.js @@ -25,9 +25,9 @@ export class SessionStatusView extends TemplateView { }}, [ spinner(t, {hidden: vm => !vm.isWaiting}), t.p(vm => vm.statusLabel), - t.if(vm => vm.isConnectNowShown, t.createTemplate(t => t.button({className: "link", onClick: () => vm.connectNow()}, "Retry now"))), - t.if(vm => vm.isSecretStorageShown, t.createTemplate(t => t.a({href: vm.setupSessionBackupUrl}, "Go to settings"))), - t.if(vm => vm.canDismiss, t.createTemplate(t => t.div({className: "end"}, t.button({className: "dismiss", onClick: () => vm.dismiss()})))), + t.if(vm => vm.isConnectNowShown, t => t.button({className: "link", onClick: () => vm.connectNow()}, "Retry now")), + t.if(vm => vm.isSecretStorageShown, t => t.a({href: vm.setupSessionBackupUrl}, "Go to settings")), + t.if(vm => vm.canDismiss, t => t.div({className: "end"}, t.button({className: "dismiss", onClick: () => vm.dismiss()}))), ]); } } diff --git a/src/platform/web/ui/session/room/timeline/BaseMediaView.js b/src/platform/web/ui/session/room/timeline/BaseMediaView.js index 7dc538e6..b5b0ed4b 100644 --- a/src/platform/web/ui/session/room/timeline/BaseMediaView.js +++ b/src/platform/web/ui/session/room/timeline/BaseMediaView.js @@ -54,7 +54,7 @@ export class BaseMediaView extends TemplateView { } return renderMessage(t, vm, [ t.div({className: "media", style: `max-width: ${vm.width}px`}, children), - t.if(vm => vm.error, t.createTemplate((t, vm) => t.p({className: "error"}, vm.error))) + t.if(vm => vm.error, t => t.p({className: "error"}, vm.error)) ]); } } diff --git a/src/platform/web/ui/session/room/timeline/GapView.js b/src/platform/web/ui/session/room/timeline/GapView.js index 2b23ae3c..1e6e0af0 100644 --- a/src/platform/web/ui/session/room/timeline/GapView.js +++ b/src/platform/web/ui/session/room/timeline/GapView.js @@ -26,7 +26,7 @@ export class GapView extends TemplateView { return t.li({className}, [ spinner(t), t.div(vm.i18n`Loading more messages …`), - t.if(vm => vm.error, t.createTemplate(t => t.strong(vm => vm.error))) + t.if(vm => vm.error, t => t.strong(vm => vm.error)) ]); } } diff --git a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js index c1c0e455..3c4f60ed 100644 --- a/src/platform/web/ui/session/settings/SessionBackupSettingsView.js +++ b/src/platform/web/ui/session/settings/SessionBackupSettingsView.js @@ -67,11 +67,11 @@ function renderEnableFieldRow(t, vm, label, callback) { } function renderError(t) { - return t.if(vm => vm.error, t.createTemplate((t, vm) => { + return t.if(vm => vm.error, (t, vm) => { return t.div([ t.p({className: "error"}, vm => vm.i18n`Could not enable session backup: ${vm.error}.`), t.p(vm.i18n`Try double checking that you did not mix up your security key, security phrase and login password as explained above.`) ]) - })); + }); } From 5d71b655adf4a2044586e9dd69e4172c813a9d33 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 19:34:41 +0100 Subject: [PATCH 004/166] halt any fetch request while waiting for new service worker to activate this make updates apply instantly rather than sometimes being stalled for seconds or minutes. --- src/platform/web/Platform.js | 2 +- src/platform/web/dom/ServiceWorkerHandler.js | 46 +++++++++++++------- src/platform/web/dom/request/fetch.js | 9 +++- src/platform/web/service-worker.template.js | 37 +++++++++++----- 4 files changed, 64 insertions(+), 30 deletions(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index a1e8e52c..7682dfd3 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -107,7 +107,7 @@ export class Platform { this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); this.estimateStorageUsage = estimateStorageUsage; if (typeof fetch === "function") { - this.request = createFetchRequest(this.clock.createTimeout); + this.request = createFetchRequest(this.clock.createTimeout, this._serviceWorkerHandler); } else { this.request = xhrRequest; } diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index b05505ea..668c7d64 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -26,6 +26,7 @@ export class ServiceWorkerHandler { this._registration = null; this._registrationPromise = null; this._currentController = null; + this.haltRequests = false; } setNavigation(navigation) { @@ -39,10 +40,12 @@ export class ServiceWorkerHandler { this._registration = await navigator.serviceWorker.register(path); await navigator.serviceWorker.ready; this._currentController = navigator.serviceWorker.controller; - this._registrationPromise = null; - console.log("Service Worker registered"); this._registration.addEventListener("updatefound", this); - this._tryActivateUpdate(); + this._registrationPromise = null; + if (this._registration.waiting) { + this._proposeUpdate(); + } + console.log("Service Worker registered"); })(); } @@ -61,6 +64,10 @@ export class ServiceWorkerHandler { this._closeSessionIfNeeded(sessionId).finally(() => { event.source.postMessage({replyTo: data.id}); }); + } else if (data.type === "haltRequests") { + // this flag is read in fetch.js + this.haltRequests = true; + event.source.postMessage({replyTo: data.id}); } } @@ -82,15 +89,19 @@ export class ServiceWorkerHandler { } } - async _tryActivateUpdate() { - // we don't do confirm when the tab is hidden because it will block the event loop and prevent - // events from the service worker to be processed (like controllerchange when the visible tab applies the update). - if (!document.hidden && this._registration.waiting && this._registration.active) { - this._registration.waiting.removeEventListener("statechange", this); - const version = await this._sendAndWaitForReply("version", null, this._registration.waiting); - if (confirm(`Version ${version.version} (${version.buildHash}) is ready to install. Apply now?`)) { - this._registration.waiting.postMessage({type: "skipWaiting"}); // will trigger controllerchange event - } + async _proposeUpdate() { + if (document.hidden) { + return; + } + const version = await this._sendAndWaitForReply("version"); + if (confirm(`Version ${version.version} (${version.buildHash}) is available. Reload to apply?`)) { + // prevent any fetch requests from going to the service worker + // from any client, so that it is not kept active + // when calling skipWaiting on the new one + await this._sendAndWaitForReply("haltRequests"); + // only once all requests are blocked, ask the new + // service worker to skipWaiting + this._send("skipWaiting", null, this._registration.waiting); } } @@ -101,11 +112,14 @@ export class ServiceWorkerHandler { break; case "updatefound": this._registration.installing.addEventListener("statechange", this); - this._tryActivateUpdate(); break; - case "statechange": - this._tryActivateUpdate(); + case "statechange": { + if (event.target.state === "installed") { + this._proposeUpdate(); + event.target.removeEventListener("statechange", this); + } break; + } case "controllerchange": if (!this._currentController) { // Clients.claim() in the SW can trigger a controllerchange event @@ -115,7 +129,7 @@ export class ServiceWorkerHandler { } else { // active service worker changed, // refresh, so we can get all assets - // (and not some if we would not refresh) + // (and not only some if we would not refresh) // up to date from it document.location.reload(); } diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index dd3b7949..1222c41b 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -51,8 +51,15 @@ class RequestResult { } } -export function createFetchRequest(createTimeout) { +export function createFetchRequest(createTimeout, serviceWorkerHandler) { return function fetchRequest(url, requestOptions) { + if (serviceWorkerHandler?.haltRequests) { + // prevent any requests while waiting + // for the new service worker to get activated. + // Once this happens, the page will be reloaded + // by the serviceWorkerHandler so this is fine. + return new RequestResult(new Promise(() => {}), {}); + } // fetch doesn't do upload progress yet, delegate to xhr if (requestOptions?.uploadProgress) { return xhrRequest(url, requestOptions); diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index e3fa4651..396783e4 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -37,6 +37,13 @@ self.addEventListener('install', function(e) { })()); }); +self.addEventListener('activate', (event) => { + // on a first page load/sw install, + // start using the service worker on all pages straight away + self.clients.claim(); + event.waitUntil(purgeOldCaches()); +}); + async function purgeOldCaches() { // remove any caches we don't know about const keyList = await caches.keys(); @@ -60,15 +67,6 @@ async function purgeOldCaches() { } } -self.addEventListener('activate', (event) => { - event.waitUntil(Promise.all([ - purgeOldCaches(), - // on a first page load/sw install, - // start using the service worker on all pages straight away - self.clients.claim() - ])); -}); - self.addEventListener('fetch', (event) => { event.respondWith(handleRequest(event.request)); }); @@ -85,9 +83,11 @@ function isCacheableThumbnail(url) { } const baseURL = new URL(self.registration.scope); +let pendingFetchAbortController = new AbortController(); async function handleRequest(request) { try { const url = new URL(request.url); + // rewrite / to /index.html so it hits the cache if (url.origin === baseURL.origin && url.pathname === baseURL.pathname) { request = new Request(new URL("index.html", baseURL.href)); } @@ -96,15 +96,15 @@ async function handleRequest(request) { // use cors so the resource in the cache isn't opaque and uses up to 7mb // https://developers.google.com/web/tools/chrome-devtools/progressive-web-apps?utm_source=devtools#opaque-responses if (isCacheableThumbnail(url)) { - response = await fetch(request, {mode: "cors", credentials: "omit"}); + response = await fetch(request, {signal: pendingFetchAbortController.signal, mode: "cors", credentials: "omit"}); } else { - response = await fetch(request); + response = await fetch(request, {signal: pendingFetchAbortController.signal}); } await updateCache(request, response); } return response; } catch (err) { - if (!(err instanceof TypeError)) { + if (err.name !== "TypeError" && err.name !== "AbortError") { console.error("error in service worker", err); } throw err; @@ -172,6 +172,9 @@ self.addEventListener('message', (event) => { case "skipWaiting": self.skipWaiting(); break; + case "haltRequests": + event.waitUntil(haltRequests().then(() => reply())); + break; case "closeSession": event.waitUntil( closeSession(event.data.payload.sessionId, event.source.id) @@ -192,6 +195,16 @@ async function closeSession(sessionId, requestingClientId) { })); } +async function haltRequests() { + // first ask all clients to block sending any more requests + const clients = await self.clients.matchAll({type: "window"}); + await Promise.all(clients.map(client => { + return sendAndWaitForReply(client, "haltRequests"); + })); + // and only then abort the current requests + pendingFetchAbortController.abort(); +} + const pendingReplies = new Map(); let messageIdCounter = 0; function sendAndWaitForReply(client, type, payload) { From 017d3818eb874e91dabeb7243241246961ee5b76 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 19:49:06 +0100 Subject: [PATCH 005/166] always reply here --- src/platform/web/service-worker.template.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index 396783e4..ebb31cdb 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -173,12 +173,12 @@ self.addEventListener('message', (event) => { self.skipWaiting(); break; case "haltRequests": - event.waitUntil(haltRequests().then(() => reply())); + event.waitUntil(haltRequests().finally(() => reply())); break; case "closeSession": event.waitUntil( closeSession(event.data.payload.sessionId, event.source.id) - .then(() => reply()) + .finally(() => reply()) ); break; } From ffdec1607635a150d3dafb0885d7b1caf3254dbb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 19:58:50 +0100 Subject: [PATCH 006/166] don't show the update dialog if we open the app for the first time and don't have a service worker yet --- src/platform/web/dom/ServiceWorkerHandler.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index 668c7d64..f5bae8d0 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -42,7 +42,8 @@ export class ServiceWorkerHandler { this._currentController = navigator.serviceWorker.controller; this._registration.addEventListener("updatefound", this); this._registrationPromise = null; - if (this._registration.waiting) { + // do we have a new service worker waiting to activate? + if (this._registration.waiting && this._registration.active) { this._proposeUpdate(); } console.log("Service Worker registered"); From 5a4afc8f4b0441dfe8583989ac423e827bd49ae2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:22:02 +0100 Subject: [PATCH 007/166] some notes wrt to ideas for read receipts --- doc/impl-thoughts/READ-RECEIPTS.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 doc/impl-thoughts/READ-RECEIPTS.md diff --git a/doc/impl-thoughts/READ-RECEIPTS.md b/doc/impl-thoughts/READ-RECEIPTS.md new file mode 100644 index 00000000..b1149ba3 --- /dev/null +++ b/doc/impl-thoughts/READ-RECEIPTS.md @@ -0,0 +1,5 @@ +# Read receipts + +## UI + +For the expanding avatars, trimmed at 5 or so, we could use css grid and switch from the right most cell to a cell that covers the whole width when clicking. \ No newline at end of file From c2df9df6d469bb03325e038a6e8aaf6ded351979 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 19 Mar 2021 14:29:54 +0530 Subject: [PATCH 008/166] Fix overflowing account chooser label Signed-off-by: RMidhunSuresh --- src/platform/web/ui/css/login.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index db67e141..aefdac42 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -42,6 +42,8 @@ limitations under the License. .SessionPickerView li .user-id { flex: 1; + overflow: hidden; + text-overflow: ellipsis; } .SessionPickerView li .error { From 3d1ddccbd84f3e757adcea30adff6e1659b58966 Mon Sep 17 00:00:00 2001 From: RMidhunSuresh Date: Fri, 19 Mar 2021 23:28:50 +0530 Subject: [PATCH 009/166] Add previous name in name change announcement - Fixes issue 269 in which the announcement generated when a member changes their name would show as : changed their name to instead of: changed their name to Signed-off-by: RMidhunSuresh --- src/domain/session/room/timeline/tiles/RoomMemberTile.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/timeline/tiles/RoomMemberTile.js b/src/domain/session/room/timeline/tiles/RoomMemberTile.js index e60711c3..a4f0268d 100644 --- a/src/domain/session/room/timeline/tiles/RoomMemberTile.js +++ b/src/domain/session/room/timeline/tiles/RoomMemberTile.js @@ -33,7 +33,7 @@ export class RoomMemberTile extends SimpleTile { if (content.avatar_url !== prevContent.avatar_url) { return `${senderName} changed their avatar`; } else if (content.displayname !== prevContent.displayname) { - return `${senderName} changed their name to ${content.displayname}`; + return `${prevContent.displayname} changed their name to ${content.displayname}`; } } else if (membership === "join") { return `${targetName} joined the room`; From f496aff47832b0f67a7c574b0c18eab8d99cad4c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 22 Mar 2021 14:08:30 +0100 Subject: [PATCH 010/166] Set explicit height on lightbox container Without an explicit height defined on the container, Safari fails to expand the `.picture` content. On desktop this results in the image showing too small and at the top of the screen. On mobile the picture ends up with zero height and is completely hidden. This commit fixes the issue by defining a height of 100% on the `.lightbox` border box. Fixes: #278 Signed-off-by: Johannes Marbach --- src/platform/web/ui/css/layout.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 60c3eafa..9917ca74 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -117,6 +117,9 @@ main { left: 0; right: 0; z-index: 1; + /* Safari requires an explicit height on the container to prevent picture content from collapsing */ + box-sizing: border-box; + height: 100%; } .TimelinePanel { From 13bf4aadc635e3fa633ab4a66ed4c6c615836ebf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 12:32:11 +0100 Subject: [PATCH 011/166] some notes on how SSO should work --- doc/impl-thoughts/SSO.md | 54 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 doc/impl-thoughts/SSO.md diff --git a/doc/impl-thoughts/SSO.md b/doc/impl-thoughts/SSO.md new file mode 100644 index 00000000..2c84cd2c --- /dev/null +++ b/doc/impl-thoughts/SSO.md @@ -0,0 +1,54 @@ +Pseudo code of how SSO should work: + +```js +// 1. Starting SSO +const loginOptions = await sessionContainer.queryLogin("matrix.org"); +// every login option (the return type of loginOptions.password and loginOptions.sso.createLogin) +// that can be passed in to startWithLogin will implement a common LoginMethod interface that has: +// - a `homeserver` property (so the hsApi can be created for it before passing it into `login`) +// - a method `async login(hsApi, deviceName)` that returns loginData (device_id, user_id, access_token) + +// loginOptions goes to the LoginViewModel + +// if password login, mapped to PasswordLoginViewModel +if (loginOptions.password) { + sessionContainer.startWithLogin(loginOptions.password(username, password)); +} + +// if sso login, mapped to SSOLoginViewModel +if (loginOptions.sso) { + const {sso} = loginOptions; + // params contains everything needed to create a callback url: + // the homeserver, and optionally the provider + let provider = null; + if (sso.providers) { + // show button for each provider + // pick the first one as an example + provider = providers[0]; + } + // when sso button is clicked: + // store the homeserver for when we get redirected back after the sso flow + platform.settingsStorage.setString("sso_homeserver", loginOptions.homeserver); + // create the redirect url + const callbackUrl = urlCreator.createSSOCallbackURL(); // will just return the document url without any fragment + const redirectUrl = sso.createRedirectUrl(callbackUrl, provider); + // and open it + platform.openURL(redirectUrl); +} + +// 2. URLRouter, History & parseUrlPath will need to also take the query params into account, so hydrogen.element.io/?loginToken=abc can be converted into a navigation path of [{type: "sso", value: "abc"}] + +// 3. when "sso" is on the navigation path, a CompleteSSOLoginView is shown. +// It will use the same SessionLoadView(Model) as for password login once login is called. +// +// Also see RootViewModel._applyNavigation. +// +// Its view model will do something like: + +// need to retrieve ssoHomeserver url in localStorage +const ssoHomeserver = platform.settingsStorage.getString("sso_homeserver"); +// need to retrieve loginToken from query parameters +const loginToken = "..."; // passed in to view model constructor +const loginOptions = await sessionContainer.queryLogin(ssoHomeserver); +sessionContainer.startWithLogin(loginOptions.sso.createLogin(loginToken)); +``` From 2fb255d2ecce87a549a73b9f4aed9c151fc88bff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:40:24 +0100 Subject: [PATCH 012/166] dot down some notes wrt to push --- doc/impl-thoughts/PUSH.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 doc/impl-thoughts/PUSH.md diff --git a/doc/impl-thoughts/PUSH.md b/doc/impl-thoughts/PUSH.md new file mode 100644 index 00000000..204e76fd --- /dev/null +++ b/doc/impl-thoughts/PUSH.md @@ -0,0 +1,22 @@ +# Push Notifications + - we setup the app on the sygnal server, with an app_id (io.element.hydrogen.web), generating a key pair + - we create a web push subscription, passing the server pub key, and get `endpoint`, `p256dh` and `auth` back. We put `webpush_endpoint` and `auth` in the push data, and use `p256dh` as the push key? + - we call `POST /_matrix/client/r0/pushers/set` on the homeserver with the sygnal instance url. We pass the web push subscription as pusher data. + - the homeserver wants to send out a notification, calling sygnal on `POST /_matrix/push/v1/notify` with for each device the pusher data. + - we encrypt and send with the data in the data for each device in the notification + - this wakes up the service worker + - now we need to find which local session id this notification is for + +## Testing/development + + - set up local synapse + - set up local sygnal + - write pushkin + - configure "hydrogen" app in sygnal config with a webpush pushkin + - start writing service worker code in hydrogen (we'll need to enable it for local dev) + - try to get a notification through + +## Questions + + - do we use the `event_id_only` format? + - for e2ee rooms, are we fine with just showing "Bob sent you a message (in room if not DM)", or do we want to sync and show the actual message? perhaps former can be MVP. From 1b0f175b023ce305db6bb5fef5002e883c39a058 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:41:01 +0100 Subject: [PATCH 013/166] put web-specific parts of notifications (push and in-app) in platform --- src/platform/web/dom/NotificationService.js | 101 +++++++++++++++++++ src/platform/web/dom/ServiceWorkerHandler.js | 7 ++ 2 files changed, 108 insertions(+) create mode 100644 src/platform/web/dom/NotificationService.js diff --git a/src/platform/web/dom/NotificationService.js b/src/platform/web/dom/NotificationService.js new file mode 100644 index 00000000..20273d70 --- /dev/null +++ b/src/platform/web/dom/NotificationService.js @@ -0,0 +1,101 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class NotificationService { + constructor(serviceWorkerHandler, pushConfig) { + this._serviceWorkerHandler = serviceWorkerHandler; + this._pushConfig = pushConfig; + } + + async enablePush(pusherFactory, defaultPayload) { + const registration = await this._serviceWorkerHandler?.getRegistration(); + if (registration?.pushManager) { + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: this._pushConfig.applicationServerKey, + }); + const subscriptionData = subscription.toJSON(); + const pushkey = subscriptionData.keys.p256dh; + const data = { + endpoint: subscriptionData.endpoint, + auth: subscriptionData.keys.auth, + default_payload: defaultPayload + }; + return pusherFactory.httpPusher( + this._pushConfig.gatewayUrl, + this._pushConfig.appId, + pushkey, + data + ); + } + } + + async disablePush() { + const registration = await this._serviceWorkerHandler?.getRegistration(); + if (this.registration?.pushManager) { + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + } + } + + async isPushEnabled() { + const registration = await this._serviceWorkerHandler?.getRegistration(); + if (this.registration?.pushManager) { + const subscription = await registration.pushManager.getSubscription(); + return !!subscription; + } + return false; + } + + async supportsPush() { + if (!this._pushConfig) { + return false; + } + const registration = await this._serviceWorkerHandler?.getRegistration(); + return registration && "pushManager" in registration; + } + + async enableNotifications() { + if ("Notification" in window) { + return (await Notification.requestPermission()) === "granted"; + } + return false; + } + + async supportsNotifications() { + return "Notification" in window; + } + + async areNotificationsEnabled() { + if ("Notification" in window) { + return Notification.permission === "granted"; + } else { + return false; + } + } + + async showNotification(title, body = undefined) { + if ("Notification" in window) { + new Notification(title, {body}); + return; + } + // Chrome on Android does not support the Notification constructor + const registration = await this._serviceWorkerHandler?.getRegistration(); + registration?.showNotification(title, {body}); + } +} diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index f5bae8d0..447b5dc8 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -182,4 +182,11 @@ export class ServiceWorkerHandler { async preventConcurrentSessionAccess(sessionId) { return this._sendAndWaitForReply("closeSession", {sessionId}); } + + async getRegistration() { + if (this._registrationPromise) { + await this._registrationPromise; + } + return this._registration; + } } From d4fc08c06bac3ca69ba881a51868e8de5c5f2076 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:42:46 +0100 Subject: [PATCH 014/166] put pusher bits in separate class to enable and disable on the HS --- src/matrix/net/HomeServerApi.js | 4 ++++ src/matrix/push/Pusher.js | 35 +++++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 src/matrix/push/Pusher.js diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 8e7a110b..a6acaf74 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -254,6 +254,10 @@ export class HomeServerApi { uploadAttachment(blob, filename, options = null) { return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options); } + + setPusher(pusher, options = null) { + return this._post("/pushers/set", null, pusher, options); + } } export function tests() { diff --git a/src/matrix/push/Pusher.js b/src/matrix/push/Pusher.js new file mode 100644 index 00000000..a8ac1b21 --- /dev/null +++ b/src/matrix/push/Pusher.js @@ -0,0 +1,35 @@ +export class Pusher { + constructor(description) { + this._description = description; + } + + static httpPusher(host, appId, pushkey, data) { + return new Pusher({ + kind: "http", + append: true, // as pushkeys are shared between multiple users on one origin + data: Object.assign({}, data, {url: host + "/_matrix/push/v1/notify"}), + pushkey, + app_id: appId, + app_display_name: "Hydrogen", + device_display_name: "Hydrogen", + lang: "en" + }); + } + + static createDefaultPayload(sessionId) { + return {session_id: sessionId}; + } + + async enable(hsApi, log) { + await hsApi.setPusher(this._description, {log}).response(); + } + + async disable(hsApi, log) { + const deleteDescription = Object.assign({}, this._description, {kind: null}); + await hsApi.setPusher(deleteDescription, {log}).response(); + } + + serialize() { + return this._description; + } +} From f764323c80301250171326c8b4155408a5e15eb3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:43:40 +0100 Subject: [PATCH 015/166] fixup: notif service --- src/platform/web/Platform.js | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 7682dfd3..747855de 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -26,6 +26,7 @@ import {ConsoleLogger} from "../../logging/ConsoleLogger.js"; import {RootView} from "./ui/RootView.js"; import {Clock} from "./dom/Clock.js"; import {ServiceWorkerHandler} from "./dom/ServiceWorkerHandler.js"; +import {NotificationService} from "./dom/NotificationService.js"; import {History} from "./dom/History.js"; import {OnlineStatus} from "./dom/OnlineStatus.js"; import {Crypto} from "./dom/Crypto.js"; @@ -73,18 +74,18 @@ function relPath(path, basePath) { return "../".repeat(dirCount) + path; } -async function loadOlmWorker(paths) { - const workerPool = new WorkerPool(paths.worker, 4); +async function loadOlmWorker(config) { + const workerPool = new WorkerPool(config.worker, 4); await workerPool.init(); - const path = relPath(paths.olm.legacyBundle, paths.worker); + const path = relPath(config.olm.legacyBundle, config.worker); await workerPool.sendAll({type: "load_olm", path}); const olmWorker = new OlmWorker(workerPool); return olmWorker; } export class Platform { - constructor(container, paths, cryptoExtras = null, options = null) { - this._paths = paths; + constructor(container, config, cryptoExtras = null, options = null) { + this._config = config; this._container = container; this.settingsStorage = new SettingsStorage("hydrogen_setting_v1_"); this.clock = new Clock(); @@ -98,10 +99,11 @@ export class Platform { this.history = new History(); this.onlineStatus = new OnlineStatus(); this._serviceWorkerHandler = null; - if (paths.serviceWorker && "serviceWorker" in navigator) { + if (config.serviceWorker && "serviceWorker" in navigator) { this._serviceWorkerHandler = new ServiceWorkerHandler(); - this._serviceWorkerHandler.registerAndStart(paths.serviceWorker); + this._serviceWorkerHandler.registerAndStart(config.serviceWorker); } + this.notificationService = new NotificationService(this._serviceWorkerHandler, config.push); this.crypto = new Crypto(cryptoExtras); this.storageFactory = new StorageFactory(this._serviceWorkerHandler); this.sessionInfoStorage = new SessionInfoStorage("hydrogen_sessions_v1"); @@ -120,12 +122,12 @@ export class Platform { } loadOlm() { - return loadOlm(this._paths.olm); + return loadOlm(this._config.olm); } async loadOlmWorker() { if (!window.WebAssembly) { - return await loadOlmWorker(this._paths); + return await loadOlmWorker(this._config); } } @@ -150,7 +152,7 @@ export class Platform { if (navigator.msSaveBlob) { navigator.msSaveBlob(blobHandle.nativeBlob, filename); } else { - downloadInIframe(this._container, this._paths.downloadSandbox, blobHandle, filename); + downloadInIframe(this._container, this._config.downloadSandbox, blobHandle, filename); } } From 630e61a6743728431bc6ee2d4b47acf391b23618 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:44:16 +0100 Subject: [PATCH 016/166] support enabling/disabling push notifs on a session --- src/matrix/Session.js | 56 ++++++++++++++++++++++++++++++++++ src/matrix/SessionContainer.js | 1 + 2 files changed, 57 insertions(+) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c01574ea..ad52aec6 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -15,6 +15,7 @@ limitations under the License. */ import {Room} from "./room/Room.js"; +import {Pusher} from "./push/Pusher.js"; import { ObservableMap } from "../observable/index.js"; import {User} from "./User.js"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; @@ -38,6 +39,7 @@ import {SecretStorage} from "./ssss/SecretStorage.js"; import {ObservableValue} from "../observable/ObservableValue.js"; const PICKLE_KEY = "DEFAULT_KEY"; +const PUSHER_KEY = "pusher"; export class Session { // sessionInfo contains deviceId, userId and homeServer @@ -466,6 +468,60 @@ export class Session { get user() { return this._user; } + + enablePushNotifications(enable) { + if (enable) { + return this._enablePush(); + } else { + return this._disablePush(); + } + } + + async _enablePush() { + return this._platform.logger.run("enablePush", async log => { + const defaultPayload = Pusher.createDefaultPayload(this._sessionInfo.id); + const pusher = await this._platform.notificationService.enablePush(Pusher, defaultPayload); + if (!pusher) { + log.set("no_pusher", true); + return false; + } + await pusher.enable(this._hsApi, log); + // store pusher data, so we know we enabled it across reloads, + // and we can disable it without too much hassle + const txn = await this._storage.readWriteTxn([this._storage.storeNames.session]); + txn.session.set(PUSHER_KEY, pusher.serialize()); + await txn.complete(); + return true; + }); + } + + + async _disablePush() { + return this._platform.logger.run("disablePush", async log => { + await this._platform.notificationService.disablePush(); + const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); + const pusherData = await readTxn.session.get(PUSHER_KEY); + if (!pusherData) { + // we've disabled push in the notif service at least + return true; + } + const pusher = new Pusher(pusherData); + await pusher.disable(this._hsApi, log); + const txn = await this._storage.readWriteTxn([this._storage.storeNames.session]); + txn.session.remove(PUSHER_KEY); + await txn.complete(); + return true; + }); + } + + async arePushNotificationsEnabled() { + if (await this._platform.notificationService.isPushEnabled()) { + return false; + } + const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); + const pusherData = await readTxn.session.get(PUSHER_KEY); + return !!pusherData; + } } export function tests() { diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index efbf70e3..85a6826d 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -166,6 +166,7 @@ export class SessionContainer { this._storage = await this._platform.storageFactory.create(sessionInfo.id); // no need to pass access token to session const filteredSessionInfo = { + id: sessionInfo.id, deviceId: sessionInfo.deviceId, userId: sessionInfo.userId, homeServer: sessionInfo.homeServer, From 7b9904e423a3dd801f0bd6ae125d8c85103f4664 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:45:01 +0100 Subject: [PATCH 017/166] add UI in settings for push notifs status/enable/disable --- .../session/settings/SettingsViewModel.js | 27 ++++++++ .../web/ui/session/settings/SettingsView.js | 65 ++++++++++++++----- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 50aabd2c..ec171eea 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -17,6 +17,14 @@ limitations under the License. import {ViewModel} from "../../ViewModel.js"; import {SessionBackupViewModel} from "./SessionBackupViewModel.js"; +class PushNotificationStatus { + constructor() { + this.supported = null; + this.enabled = false; + this.updating = false; + } +} + function formatKey(key) { const partLength = 4; const partCount = Math.ceil(key.length / partLength); @@ -40,6 +48,7 @@ export class SettingsViewModel extends ViewModel { this.sentImageSizeLimit = null; this.minSentImageSizeLimit = 400; this.maxSentImageSizeLimit = 4000; + this.pushNotifications = new PushNotificationStatus(); } setSentImageSizeLimit(size) { @@ -56,6 +65,8 @@ export class SettingsViewModel extends ViewModel { async load() { this._estimate = await this.platform.estimateStorageUsage(); this.sentImageSizeLimit = await this.platform.settingsStorage.getInt("sentImageSizeLimit"); + this.pushNotifications.supported = await this.platform.notificationService.supportsPush(); + this.pushNotifications.enabled = await this._session.arePushNotificationsEnabled(); this.emitChange(""); } @@ -115,5 +126,21 @@ export class SettingsViewModel extends ViewModel { const logExport = await this.logger.export(); this.platform.saveFileAs(logExport.asBlob(), `hydrogen-logs-${this.platform.clock.now()}.json`); } + + async togglePushNotifications() { + this.pushNotifications.updating = true; + this.emitChange("pushNotifications.updating"); + try { + if (await this._session.enablePushNotifications(!this.pushNotifications.enabled)) { + this.pushNotifications.enabled = !this.pushNotifications.enabled; + if (this.pushNotifications.enabled) { + this.platform.notificationService.showNotification(this.i18n`Push notifications are now enabled`); + } + } + } finally { + this.pushNotifications.updating = false; + this.emitChange("pushNotifications.updating"); + } + } } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 02e57f5e..4d4b2aba 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -34,27 +34,60 @@ export class SettingsView extends TemplateView { ]); }; + const settingNodes = []; + + settingNodes.push( + t.h3("Session"), + row(vm.i18n`User ID`, vm.userId), + row(vm.i18n`Session ID`, vm.deviceId, "code"), + row(vm.i18n`Session key`, vm.fingerprintKey, "code") + ); + settingNodes.push( + t.h3("Session Backup"), + t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)) + ); + + settingNodes.push( + t.h3("Notifications"), + t.map(vm => vm.pushNotifications.supported, (supported, t) => { + if (supported === null) { + return t.p(vm.i18n`Loading…`); + } else if (supported) { + const label = vm => vm.pushNotifications.enabled ? + vm.i18n`Push notifications are enabled`: + vm.i18n`Push notifications are disabled`; + const buttonLabel = vm => vm.pushNotifications.enabled ? + vm.i18n`Disable`: + vm.i18n`Enable`; + return row(label, t.button({ + onClick: () => vm.togglePushNotifications(), + disabled: vm => vm.pushNotifications.updating + }, buttonLabel)); + } else { + return t.p(vm.i18n`Push notifications are not supported on this browser`); + } + }) + ); + + settingNodes.push( + t.h3("Preferences"), + row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), + ); + settingNodes.push( + t.h3("Application"), + row(vm.i18n`Version`, version), + row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), + row(vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), + t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", + t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), + ); + return t.main({className: "Settings middle"}, [ t.div({className: "middle-header"}, [ t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close settings`}), t.h2("Settings") ]), - t.div({className: "SettingsBody"}, [ - t.h3("Session"), - row(vm.i18n`User ID`, vm.userId), - row(vm.i18n`Session ID`, vm.deviceId, "code"), - row(vm.i18n`Session key`, vm.fingerprintKey, "code"), - t.h3("Session Backup"), - t.view(new SessionBackupSettingsView(vm.sessionBackupViewModel)), - t.h3("Preferences"), - row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), - t.h3("Application"), - row(vm.i18n`Version`, version), - row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), - row(vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), - t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", - t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), - ]) + t.div({className: "SettingsBody"}, settingNodes) ]); } From 8fcf7f8c7f01708c425eec77c1dba79814a293e1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:45:46 +0100 Subject: [PATCH 018/166] show notification when receiving push message --- src/platform/web/service-worker.template.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index ebb31cdb..f46150f6 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -185,6 +185,27 @@ self.addEventListener('message', (event) => { } }); +self.addEventListener('push', event => { + const n = event.data.json(); + console.log("got a push message", n); + let sender = n.sender_display_name || n.sender; + if (sender && n.event_id) { + let label; + if (n.room_name) { + label = `${sender} wrote you in ${n.room_name}`; + } else { + label = `${sender} wrote you`; + } + let body = n.content?.body; + self.registration.showNotification(label, { + body, + data: { + sessionId: n.session_id, + roomId: n.room_id, + } + }); + } +}); async function closeSession(sessionId, requestingClientId) { const clients = await self.clients.matchAll(); From 725098f262c61f9b0f80f88e047641d48508547e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:46:11 +0100 Subject: [PATCH 019/166] open client when clicking notification --- src/platform/web/service-worker.template.js | 27 +++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index f46150f6..0e28ac7a 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -185,6 +185,33 @@ self.addEventListener('message', (event) => { } }); +async function openClientFromNotif(event) { + const clientList = await self.clients.matchAll({type: "window"}); + const {sessionId, roomId} = event.notification.data; + const sessionHash = `#/session/${sessionId}`; + const roomHash = `${sessionHash}/room/${roomId}`; + const roomURL = `/${roomHash}`; + for (let i = 0; i < clientList.length; i++) { + const client = clientList[i]; + const url = new URL(client.url, baseURL); + if (url.hash.startsWith(sessionHash)) { + client.navigate(roomURL); + if ('focus' in client) { + await client.focus(); + } + return; + } + } + if (self.clients.openWindow) { + await self.clients.openWindow(roomURL); + } +} + +self.addEventListener('notificationclick', event => { + event.notification.close(); + event.waitUntil(openClientFromNotif(event)); +}); + self.addEventListener('push', event => { const n = event.data.json(); console.log("got a push message", n); From bddf6ba6ae9f9e88c043b924e128a2f64f8a174c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 18 Mar 2021 20:52:50 +0100 Subject: [PATCH 020/166] add example config for locally testing push notifs/service worker --- index.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/index.html b/index.html index 55e7f0b2..e5746572 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,13 @@ main(new Platform(document.body, { worker: "src/worker.js", downloadSandbox: "assets/download-sandbox.html", + // ln -s src/platform/web/service-worker.template.js sw.js + // serviceWorker: "sw.js", + // push: { + // appId: "io.element.hydrogen.web", + // gatewayUrl: "...", + // applicationServerKey: "...", + // }, olm: { wasm: "lib/olm/olm.wasm", legacyBundle: "lib/olm/olm_legacy.js", From dbddba3691f678b740c6aed37dcdcf1f15d569f5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:19:07 +0100 Subject: [PATCH 021/166] fix c/p errors when moving code over to notif service --- src/platform/web/dom/NotificationService.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/web/dom/NotificationService.js b/src/platform/web/dom/NotificationService.js index 20273d70..151ccaf0 100644 --- a/src/platform/web/dom/NotificationService.js +++ b/src/platform/web/dom/NotificationService.js @@ -45,7 +45,7 @@ export class NotificationService { async disablePush() { const registration = await this._serviceWorkerHandler?.getRegistration(); - if (this.registration?.pushManager) { + if (registration?.pushManager) { const subscription = await registration.pushManager.getSubscription(); if (subscription) { await subscription.unsubscribe(); @@ -55,7 +55,7 @@ export class NotificationService { async isPushEnabled() { const registration = await this._serviceWorkerHandler?.getRegistration(); - if (this.registration?.pushManager) { + if (registration?.pushManager) { const subscription = await registration.pushManager.getSubscription(); return !!subscription; } From 3313d0623a0050c4a8a4207cc28d6e646ed39b07 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:19:25 +0100 Subject: [PATCH 022/166] thinko with push checks --- src/matrix/Session.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index ad52aec6..735967bf 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -515,7 +515,7 @@ export class Session { } async arePushNotificationsEnabled() { - if (await this._platform.notificationService.isPushEnabled()) { + if (!await this._platform.notificationService.isPushEnabled()) { return false; } const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); From f92f3b2c21a7989be1862f27ff19c9f430d1dbf6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:19:44 +0100 Subject: [PATCH 023/166] copy push config in build script --- assets/config.json | 7 +++++++ scripts/build.mjs | 13 +++++++------ 2 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 assets/config.json diff --git a/assets/config.json b/assets/config.json new file mode 100644 index 00000000..ae46ccfd --- /dev/null +++ b/assets/config.json @@ -0,0 +1,7 @@ +{ + "push": { + "appId": "io.element.hydrogen.web", + "gatewayUrl": "https://matrix.org", + "applicationServerKey": "tbc" + } +} diff --git a/scripts/build.mjs b/scripts/build.mjs index dbbeabcd..6622f092 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -79,6 +79,7 @@ async function build({modernOnly}) { await assets.write(`worker.js`, await buildJsLegacy("src/platform/web/worker/main.js", ['src/platform/web/worker/polyfill.js'])); } // copy over non-theme assets + const baseConfig = JSON.parse(await fs.readFile(path.join(projectDir, "assets/config.json"), {encoding: "utf8"})); const downloadSandbox = "download-sandbox.html"; let downloadSandboxHtml = await fs.readFile(path.join(projectDir, `assets/${downloadSandbox}`)); await assets.write(downloadSandbox, downloadSandboxHtml); @@ -95,7 +96,7 @@ async function build({modernOnly}) { const globalHash = assets.hashForAll(); await buildServiceWorker(swSource, version, globalHash, assets); - await buildHtml(doc, version, globalHash, modernOnly, assets); + await buildHtml(doc, version, baseConfig, globalHash, modernOnly, assets); console.log(`built hydrogen ${version} (${globalHash}) successfully with ${assets.size} files`); } @@ -135,7 +136,7 @@ async function copyThemeAssets(themes, assets) { return assets; } -async function buildHtml(doc, version, globalHash, modernOnly, assets) { +async function buildHtml(doc, version, baseConfig, globalHash, modernOnly, assets) { // transform html file // change path to main.css to css bundle doc("link[rel=stylesheet]:not([title])").attr("href", assets.resolve(`hydrogen.css`)); @@ -145,7 +146,7 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) { findThemes(doc, (themeName, theme) => { theme.attr("href", assets.resolve(`themes/${themeName}/bundle.css`)); }); - const pathsJSON = JSON.stringify({ + const configJSON = JSON.stringify(Object.assign({}, baseConfig, { worker: assets.has("worker.js") ? assets.resolve(`worker.js`) : null, downloadSandbox: assets.resolve("download-sandbox.html"), serviceWorker: "sw.js", @@ -154,14 +155,14 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) { legacyBundle: assets.resolve("olm_legacy.js"), wasmBundle: assets.resolve("olm.js"), } - }); + })); const mainScripts = [ - `` + `` ]; if (!modernOnly) { mainScripts.push( ``, - `` + `` ); } doc("script#main").replaceWith(mainScripts.join("")); From d5b12fa7f98525a416b34cdf42d79b29c0879fe4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 22 Mar 2021 19:23:01 +0100 Subject: [PATCH 024/166] log endpoint hostname --- src/matrix/push/Pusher.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/matrix/push/Pusher.js b/src/matrix/push/Pusher.js index a8ac1b21..e12db164 100644 --- a/src/matrix/push/Pusher.js +++ b/src/matrix/push/Pusher.js @@ -21,6 +21,12 @@ export class Pusher { } async enable(hsApi, log) { + try { + let endpointDomain = new URL(this._description.data.endpoint).host; + log.set("endpoint", endpointDomain); + } catch { + log.set("endpoint", null); + } await hsApi.setPusher(this._description, {log}).response(); } From 76fdbbb2fea29670fc3c85442f2417ced9cd995a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 13:36:42 +0100 Subject: [PATCH 025/166] shorten this --- src/matrix/push/Pusher.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/matrix/push/Pusher.js b/src/matrix/push/Pusher.js index e12db164..cc370f82 100644 --- a/src/matrix/push/Pusher.js +++ b/src/matrix/push/Pusher.js @@ -22,8 +22,7 @@ export class Pusher { async enable(hsApi, log) { try { - let endpointDomain = new URL(this._description.data.endpoint).host; - log.set("endpoint", endpointDomain); + log.set("endpoint", new URL(this._description.data.endpoint).host); } catch { log.set("endpoint", null); } From a8ca82ca4d2a01baf16a2a0c79ce2da4a665c91b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 15:18:07 +0100 Subject: [PATCH 026/166] support running the service worker during local development --- index.html | 4 ++-- scripts/build.mjs | 15 ++++++++++++--- src/platform/web/service-worker.template.js | 6 +++--- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index e5746572..fb6934bb 100644 --- a/index.html +++ b/index.html @@ -23,8 +23,8 @@ import {Platform} from "./src/platform/web/Platform.js"; main(new Platform(document.body, { worker: "src/worker.js", - downloadSandbox: "assets/download-sandbox.html", - // ln -s src/platform/web/service-worker.template.js sw.js + downloadSandbox: "assets/download-sandbox.html", + // NOTE: uncomment this if you want the service worker for local development // serviceWorker: "sw.js", // push: { // appId: "io.element.hydrogen.web", diff --git a/scripts/build.mjs b/scripts/build.mjs index 6622f092..29a4bb54 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -270,12 +270,21 @@ async function buildServiceWorker(swSource, version, globalHash, assets) { hashedCachedOnRequestAssets.push(resolved); } } + + const replaceArrayInSource = (name, value) => { + const newSource = swSource.replace(`${name} = []`, `${name} = ${JSON.stringify(value)}`); + if (newSource === swSource) { + throw new Error(`${name} was not found in the service worker source`); + } + return newSource; + }; + // write service worker swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`); swSource = swSource.replace(`"%%GLOBAL_HASH%%"`, `"${globalHash}"`); - swSource = swSource.replace(`"%%UNHASHED_PRECACHED_ASSETS%%"`, JSON.stringify(unhashedPreCachedAssets)); - swSource = swSource.replace(`"%%HASHED_PRECACHED_ASSETS%%"`, JSON.stringify(hashedPreCachedAssets)); - swSource = swSource.replace(`"%%HASHED_CACHED_ON_REQUEST_ASSETS%%"`, JSON.stringify(hashedCachedOnRequestAssets)); + swSource = replaceArrayInSource("UNHASHED_PRECACHED_ASSETS", unhashedPreCachedAssets); + swSource = replaceArrayInSource("HASHED_PRECACHED_ASSETS", hashedPreCachedAssets); + swSource = replaceArrayInSource("HASHED_CACHED_ON_REQUEST_ASSETS", hashedCachedOnRequestAssets); // service worker should not have a hashed name as it is polled by the browser for updates await assets.writeUnhashed("sw.js", swSource); } diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.template.js index 0e28ac7a..7d545138 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.template.js @@ -17,9 +17,9 @@ limitations under the License. const VERSION = "%%VERSION%%"; const GLOBAL_HASH = "%%GLOBAL_HASH%%"; -const UNHASHED_PRECACHED_ASSETS = "%%UNHASHED_PRECACHED_ASSETS%%"; -const HASHED_PRECACHED_ASSETS = "%%HASHED_PRECACHED_ASSETS%%"; -const HASHED_CACHED_ON_REQUEST_ASSETS = "%%HASHED_CACHED_ON_REQUEST_ASSETS%%"; +const UNHASHED_PRECACHED_ASSETS = []; +const HASHED_PRECACHED_ASSETS = []; +const HASHED_CACHED_ON_REQUEST_ASSETS = []; const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`; const hashedCacheName = `hydrogen-assets`; const mediaThumbnailCacheName = `hydrogen-media-thumbnails-v2`; From 178790d816f2e71411cf107bd4ce790cef5fbed9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 15:20:33 +0100 Subject: [PATCH 027/166] symlink service worker for local dev, so its scope captures whole app also rename service worker (as it is not a template anymore) --- scripts/build.mjs | 2 +- .../web/{service-worker.template.js => service-worker.js} | 0 sw.js | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) rename src/platform/web/{service-worker.template.js => service-worker.js} (100%) create mode 120000 sw.js diff --git a/scripts/build.mjs b/scripts/build.mjs index 29a4bb54..ef043135 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -90,7 +90,7 @@ async function build({modernOnly}) { await buildManifest(assets); // all assets have been added, create a hash from all assets name to cache unhashed files like index.html assets.addToHashForAll("index.html", devHtml); - let swSource = await fs.readFile(path.join(projectDir, "src/platform/web/service-worker.template.js"), "utf8"); + let swSource = await fs.readFile(path.join(projectDir, "src/platform/web/service-worker.js"), "utf8"); assets.addToHashForAll("sw.js", swSource); const globalHash = assets.hashForAll(); diff --git a/src/platform/web/service-worker.template.js b/src/platform/web/service-worker.js similarity index 100% rename from src/platform/web/service-worker.template.js rename to src/platform/web/service-worker.js diff --git a/sw.js b/sw.js new file mode 120000 index 00000000..edcb3642 --- /dev/null +++ b/sw.js @@ -0,0 +1 @@ +src/platform/web/service-worker.js \ No newline at end of file From c9642cc98c67fe99c871f9f9f37aa92e46d06ba5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 15:22:37 +0100 Subject: [PATCH 028/166] add notes how to enable push for local dev --- index.html | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/index.html b/index.html index fb6934bb..d1a9db0d 100644 --- a/index.html +++ b/index.html @@ -26,11 +26,9 @@ downloadSandbox: "assets/download-sandbox.html", // NOTE: uncomment this if you want the service worker for local development // serviceWorker: "sw.js", - // push: { - // appId: "io.element.hydrogen.web", - // gatewayUrl: "...", - // applicationServerKey: "...", - // }, + // NOTE: provide push config if you want push notifs for local development + // see assets/config.json for what the config looks like + // push: {...}, olm: { wasm: "lib/olm/olm.wasm", legacyBundle: "lib/olm/olm_legacy.js", From 73c433ec3dc2f07ca6c797e773c1f7dc123e04c7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 23 Mar 2021 16:39:46 +0100 Subject: [PATCH 029/166] add public key for matrix.org sygnal instance --- assets/config.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/config.json b/assets/config.json index ae46ccfd..a98c5ba9 100644 --- a/assets/config.json +++ b/assets/config.json @@ -2,6 +2,6 @@ "push": { "appId": "io.element.hydrogen.web", "gatewayUrl": "https://matrix.org", - "applicationServerKey": "tbc" + "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" } } From 2de61c5928216dac87ef1b9cab8ff94fa08daec8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 24 Mar 2021 15:19:10 +0100 Subject: [PATCH 030/166] ask the new version to the new and not old service worker --- src/platform/web/dom/ServiceWorkerHandler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index 447b5dc8..cb105f0a 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -94,7 +94,7 @@ export class ServiceWorkerHandler { if (document.hidden) { return; } - const version = await this._sendAndWaitForReply("version"); + const version = await this._sendAndWaitForReply("version", null, this._registration.waiting); if (confirm(`Version ${version.version} (${version.buildHash}) is available. Reload to apply?`)) { // prevent any fetch requests from going to the service worker // from any client, so that it is not kept active From f91abe4301ca23ca6057bb6268dd0b77402a6828 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 24 Mar 2021 15:23:01 +0100 Subject: [PATCH 031/166] improve notifications shown - use event.waitUntil to prevent default notification - replace notifications for same room - replace notifications when receiving unread=0 with "Read messages" to prevent default notification - don't rely on client.url to figure out if a room is open as FF does not update this field on hash changes. --- src/platform/web/dom/ServiceWorkerHandler.js | 9 ++- src/platform/web/service-worker.js | 77 ++++++++++++++++++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index cb105f0a..d2dacc6b 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -60,7 +60,14 @@ export class ServiceWorkerHandler { resolve(data.payload); } } - if (data.type === "closeSession") { + if (data.type === "hasSessionOpen") { + const hasOpen = this._navigation.observe("session").get() === data.payload.sessionId; + event.source.postMessage({replyTo: data.id, payload: hasOpen}); + } else if (data.type === "hasRoomOpen") { + const hasSessionOpen = this._navigation.observe("session").get() === data.payload.sessionId; + const hasRoomOpen = this._navigation.observe("room").get() === data.payload.roomId; + event.source.postMessage({replyTo: data.id, payload: hasSessionOpen && hasRoomOpen}); + } else if (data.type === "closeSession") { const {sessionId} = data.payload; this._closeSessionIfNeeded(sessionId).finally(() => { event.source.postMessage({replyTo: data.id}); diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 7d545138..daef3640 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -185,6 +185,9 @@ self.addEventListener('message', (event) => { } }); +const NOTIF_TAG_MESSAGES_READ = "messages_read"; +const NOTIF_TAG_NEW_MESSAGE = "new_message"; + async function openClientFromNotif(event) { const clientList = await self.clients.matchAll({type: "window"}); const {sessionId, roomId} = event.notification.data; @@ -212,11 +215,32 @@ self.addEventListener('notificationclick', event => { event.waitUntil(openClientFromNotif(event)); }); -self.addEventListener('push', event => { - const n = event.data.json(); +async function handlePushNotification(n) { console.log("got a push message", n); + const sessionId = n.session_id; let sender = n.sender_display_name || n.sender; if (sender && n.event_id) { + const clientList = await self.clients.matchAll({type: "window"}); + const roomId = n.room_id; + const hasFocusedClientOnRoom = !!await findClient(async client => { + if (client.visibilityState === "visible" && client.focused) { + return await sendAndWaitForReply(client, "hasRoomOpen", {sessionId, roomId}); + } + }); + if (hasFocusedClientOnRoom) { + console.log("client is focused, room is open, don't show notif"); + return; + } + for (const client of clientList) { + // if the app is open and focused, don't show a notif when looking at the room already + if (client.visibilityState === "visible" && client.focused) { + const isRoomOpen = await sendAndWaitForReply(client, "hasRoomOpen", {sessionId, roomId}); + if (isRoomOpen) { + console.log("client is focused, room is open, don't show notif"); + return; + } + } + } let label; if (n.room_name) { label = `${sender} wrote you in ${n.room_name}`; @@ -224,14 +248,44 @@ self.addEventListener('push', event => { label = `${sender} wrote you`; } let body = n.content?.body; - self.registration.showNotification(label, { + // close any previous notifications for this room + const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE})); + const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId); + for (const notif of notifsForRoom) { + console.log("close previous notification for room"); + notif.close(); + } + console.log("showing new message notification"); + await self.registration.showNotification(label, { body, - data: { - sessionId: n.session_id, - roomId: n.room_id, - } + data: {sessionId, roomId}, + tag: NOTIF_TAG_NEW_MESSAGE }); + } else if (n.unread === 0) { + // hide the notifs + console.log("unread=0, close all notifs"); + const notifs = Array.from(await self.registration.getNotifications()); + for (const notif of notifs) { + if (notif.tag !== NOTIF_TAG_MESSAGES_READ) { + notif.close(); + } + } + const hasVisibleClient = !!await findClient(client => client.visibilityState === "visible"); + // ensure we always show a notification when no client is visible, see https://goo.gl/yqv4Q4 + if (!hasVisibleClient) { + const readNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_MESSAGES_READ})); + if (readNotifs.length === 0) { + await self.registration.showNotification("New messages that have since been read", { + tag: NOTIF_TAG_MESSAGES_READ, + data: {sessionId} + }); + } + } } +} + +self.addEventListener('push', event => { + event.waitUntil(handlePushNotification(event.data.json())); }); async function closeSession(sessionId, requestingClientId) { @@ -264,3 +318,12 @@ function sendAndWaitForReply(client, type, payload) { client.postMessage({type, id, payload}); return promise; } + +async function findClient(predicate) { + const clientList = await self.clients.matchAll({type: "window"}); + for (const client of clientList) { + if (await predicate(client)) { + return client; + } + } +} From 0b3f2a7fa05c473720344e8dba3dd27a25cdb12c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 24 Mar 2021 15:25:59 +0100 Subject: [PATCH 032/166] improve notification click handling - also here don't use client.url to figure out if a session is open as that doesn't work in FF - use tag to make sure we're dealing with the right type of notif - use findClient function --- src/platform/web/service-worker.js | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index daef3640..1aba7a96 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -189,23 +189,24 @@ const NOTIF_TAG_MESSAGES_READ = "messages_read"; const NOTIF_TAG_NEW_MESSAGE = "new_message"; async function openClientFromNotif(event) { - const clientList = await self.clients.matchAll({type: "window"}); + if (event.notification.tag !== NOTIF_TAG_NEW_MESSAGE) { + return; + } const {sessionId, roomId} = event.notification.data; const sessionHash = `#/session/${sessionId}`; const roomHash = `${sessionHash}/room/${roomId}`; const roomURL = `/${roomHash}`; - for (let i = 0; i < clientList.length; i++) { - const client = clientList[i]; - const url = new URL(client.url, baseURL); - if (url.hash.startsWith(sessionHash)) { - client.navigate(roomURL); - if ('focus' in client) { - await client.focus(); - } - return; + const clientWithSession = await findClient(async client => { + return await sendAndWaitForReply(client, "hasSessionOpen", {sessionId}); + }); + if (clientWithSession) { + console.log("notificationclick: client has session open, showing room there"); + clientWithSession.navigate(roomURL); + if ('focus' in clientWithSession) { + await clientWithSession.focus(); } - } - if (self.clients.openWindow) { + } else if (self.client.openWindow) { + console.log("notificationclick: no client found with session open, opening new window"); await self.clients.openWindow(roomURL); } } From 165532be302430c7908d0015badf061e34d94a5a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 00:12:57 +0100 Subject: [PATCH 033/166] add badge icon to notifs --- scripts/build.mjs | 9 +++++++++ src/platform/web/service-worker.js | 2 ++ 2 files changed, 11 insertions(+) diff --git a/scripts/build.mjs b/scripts/build.mjs index ef043135..4d29c9db 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -278,6 +278,13 @@ async function buildServiceWorker(swSource, version, globalHash, assets) { } return newSource; }; + const replaceStringInSource = (name, value) => { + const newSource = swSource.replace(new RegExp(`${name}\\s=\\s"[^"]*"`), `${name} = ${JSON.stringify(value)}`); + if (newSource === swSource) { + throw new Error(`${name} was not found in the service worker source`); + } + return newSource; + }; // write service worker swSource = swSource.replace(`"%%VERSION%%"`, `"${version}"`); @@ -285,6 +292,8 @@ async function buildServiceWorker(swSource, version, globalHash, assets) { swSource = replaceArrayInSource("UNHASHED_PRECACHED_ASSETS", unhashedPreCachedAssets); swSource = replaceArrayInSource("HASHED_PRECACHED_ASSETS", hashedPreCachedAssets); swSource = replaceArrayInSource("HASHED_CACHED_ON_REQUEST_ASSETS", hashedCachedOnRequestAssets); + swSource = replaceStringInSource("NOTIFICATION_BADGE_ICON", assets.resolve("icon.png")); + // service worker should not have a hashed name as it is polled by the browser for updates await assets.writeUnhashed("sw.js", swSource); } diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 1aba7a96..d7d0c34f 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -20,6 +20,7 @@ const GLOBAL_HASH = "%%GLOBAL_HASH%%"; const UNHASHED_PRECACHED_ASSETS = []; const HASHED_PRECACHED_ASSETS = []; const HASHED_CACHED_ON_REQUEST_ASSETS = []; +const NOTIFICATION_BADGE_ICON = "assets/icon.png"; const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`; const hashedCacheName = `hydrogen-assets`; const mediaThumbnailCacheName = `hydrogen-media-thumbnails-v2`; @@ -261,6 +262,7 @@ async function handlePushNotification(n) { body, data: {sessionId, roomId}, tag: NOTIF_TAG_NEW_MESSAGE + badge: NOTIFICATION_BADGE_ICON }); } else if (n.unread === 0) { // hide the notifs From bc763e2a1984f79b2a0183f0c253abda8c973376 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:01:25 +0100 Subject: [PATCH 034/166] fix typo --- src/platform/web/service-worker.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index d7d0c34f..b01ad5ff 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -206,7 +206,7 @@ async function openClientFromNotif(event) { if ('focus' in clientWithSession) { await clientWithSession.focus(); } - } else if (self.client.openWindow) { + } else if (self.clients.openWindow) { console.log("notificationclick: no client found with session open, opening new window"); await self.clients.openWindow(roomURL); } From a70a38f481dd3b41b458e8517b0e9fc3c9625554 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:03:44 +0100 Subject: [PATCH 035/166] focus can throw on Android, wrap it in a try/catch --- src/platform/web/service-worker.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index b01ad5ff..3a68d125 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -204,7 +204,9 @@ async function openClientFromNotif(event) { console.log("notificationclick: client has session open, showing room there"); clientWithSession.navigate(roomURL); if ('focus' in clientWithSession) { - await clientWithSession.focus(); + try { + await clientWithSession.focus(); + } catch (err) { console.error(err); } // I've had this throw on me on Android } } else if (self.clients.openWindow) { console.log("notificationclick: no client found with session open, opening new window"); From f98369c4d6e37f43a18a7160ea3907b47ecfd023 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:07:42 +0100 Subject: [PATCH 036/166] remove obsolete code from refactoring before --- src/platform/web/service-worker.js | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 3a68d125..ebfddf71 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -224,7 +224,6 @@ async function handlePushNotification(n) { const sessionId = n.session_id; let sender = n.sender_display_name || n.sender; if (sender && n.event_id) { - const clientList = await self.clients.matchAll({type: "window"}); const roomId = n.room_id; const hasFocusedClientOnRoom = !!await findClient(async client => { if (client.visibilityState === "visible" && client.focused) { @@ -235,16 +234,6 @@ async function handlePushNotification(n) { console.log("client is focused, room is open, don't show notif"); return; } - for (const client of clientList) { - // if the app is open and focused, don't show a notif when looking at the room already - if (client.visibilityState === "visible" && client.focused) { - const isRoomOpen = await sendAndWaitForReply(client, "hasRoomOpen", {sessionId, roomId}); - if (isRoomOpen) { - console.log("client is focused, room is open, don't show notif"); - return; - } - } - } let label; if (n.room_name) { label = `${sender} wrote you in ${n.room_name}`; From e54a70768462a84b5f21df68bd9d7f40862e3d3c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:08:38 +0100 Subject: [PATCH 037/166] rework notifications - we don't close them when receiving a push message without event_id as we always need to have a notification open after a push message and replacing them with a generic one like we did is worse than just leaving it open - after the second notification for a room, we just show "New messages" and you don't get binged again for new messages after that. - You will still have a notification for every room, and on Android you will just see the one for the last room as it only shows one notification at a time. --- src/platform/web/service-worker.js | 66 +++++++++++++++--------------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index ebfddf71..081c9053 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -186,7 +186,6 @@ self.addEventListener('message', (event) => { } }); -const NOTIF_TAG_MESSAGES_READ = "messages_read"; const NOTIF_TAG_NEW_MESSAGE = "new_message"; async function openClientFromNotif(event) { @@ -234,48 +233,51 @@ async function handlePushNotification(n) { console.log("client is focused, room is open, don't show notif"); return; } - let label; - if (n.room_name) { - label = `${sender} wrote you in ${n.room_name}`; - } else { - label = `${sender} wrote you`; - } - let body = n.content?.body; - // close any previous notifications for this room const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE})); const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId); - for (const notif of notifsForRoom) { - console.log("close previous notification for room"); - notif.close(); + const nonMultiNotifsForRoom = newMessageNotifs.filter(n => !n.data.multi); + const roomName = n.room_name || n.room_alias; + const hasMultiNotification = notifsForRoom.some(n => n.data.multi); + let notifsToClose; + let multi = false; + let label; + let body; + if (hasMultiNotification) { + console.log("already have a multi message, don't do anything"); + return; + } else if (nonMultiNotifsForRoom.length) { + notifsToClose = nonMultiNotifsForRoom; + console.log("showing multi message notification"); + multi = true; + label = roomName || sender; + body = "New messages"; + } else { + console.log("showing new message notification"); + if (roomName && roomName !== sender) { + label = `${sender} in ${roomName}`; + } else { + label = sender; + } + body = n.content?.body || "New message"; } - console.log("showing new message notification"); + // close any previous notifications for this room await self.registration.showNotification(label, { body, - data: {sessionId, roomId}, - tag: NOTIF_TAG_NEW_MESSAGE + data: {sessionId, roomId, multi}, + tag: NOTIF_TAG_NEW_MESSAGE, badge: NOTIFICATION_BADGE_ICON }); - } else if (n.unread === 0) { - // hide the notifs - console.log("unread=0, close all notifs"); - const notifs = Array.from(await self.registration.getNotifications()); - for (const notif of notifs) { - if (notif.tag !== NOTIF_TAG_MESSAGES_READ) { + if (notifsToClose) { + for (const notif of notifsToClose) { + console.log("close previous notification"); notif.close(); } } - const hasVisibleClient = !!await findClient(client => client.visibilityState === "visible"); - // ensure we always show a notification when no client is visible, see https://goo.gl/yqv4Q4 - if (!hasVisibleClient) { - const readNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_MESSAGES_READ})); - if (readNotifs.length === 0) { - await self.registration.showNotification("New messages that have since been read", { - tag: NOTIF_TAG_MESSAGES_READ, - data: {sessionId} - }); - } - } } + // we could consider hiding previous notifications here based on the unread count + // (although we can't really figure out which notifications to hide) and also hiding + // notifications makes it hard to ensure we always show a notification after a push message + // when no client is visible, see https://goo.gl/yqv4Q4 } self.addEventListener('push', event => { From 889ca0550664ecb356b7415cac9efaf1e74f549d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:11:05 +0100 Subject: [PATCH 038/166] log when we get a click from a notif without a tag like when the browser decides to show "site got updated in the background" notif in response to a unread=0 push message. --- src/platform/web/service-worker.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 081c9053..cef18f6c 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -190,6 +190,7 @@ const NOTIF_TAG_NEW_MESSAGE = "new_message"; async function openClientFromNotif(event) { if (event.notification.tag !== NOTIF_TAG_NEW_MESSAGE) { + console.log("clicked notif with tag", event.notification.tag); return; } const {sessionId, roomId} = event.notification.data; From b3680af3425ef405790458711e02e27ed023f4e2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 10:23:43 +0100 Subject: [PATCH 039/166] move comment back to right place, and explain we we do it after --- src/platform/web/service-worker.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index cef18f6c..c859afc4 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -261,13 +261,16 @@ async function handlePushNotification(n) { } body = n.content?.body || "New message"; } - // close any previous notifications for this room await self.registration.showNotification(label, { body, data: {sessionId, roomId, multi}, tag: NOTIF_TAG_NEW_MESSAGE, badge: NOTIFICATION_BADGE_ICON }); + // close any previous notifications for this room + // AFTER showing the new notification as on Android + // where we can only show 1 notification, this creates + // a smoother transition if (notifsToClose) { for (const notif of notifsToClose) { console.log("close previous notification"); From 07fc49e37102da73928b6224420638998b9ea1e6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 11:13:54 +0100 Subject: [PATCH 040/166] Never close notifs unless clicking them as all browsers only seem to show the last one anyway --- src/platform/web/service-worker.js | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index c859afc4..7001600a 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -236,18 +236,16 @@ async function handlePushNotification(n) { } const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE})); const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId); - const nonMultiNotifsForRoom = newMessageNotifs.filter(n => !n.data.multi); - const roomName = n.room_name || n.room_alias; const hasMultiNotification = notifsForRoom.some(n => n.data.multi); - let notifsToClose; + const hasSingleNotifsForRoom = newMessageNotifs.some(n => !n.data.multi); + const roomName = n.room_name || n.room_alias; let multi = false; let label; let body; if (hasMultiNotification) { console.log("already have a multi message, don't do anything"); return; - } else if (nonMultiNotifsForRoom.length) { - notifsToClose = nonMultiNotifsForRoom; + } else if (hasSingleNotifsForRoom) { console.log("showing multi message notification"); multi = true; label = roomName || sender; @@ -267,16 +265,6 @@ async function handlePushNotification(n) { tag: NOTIF_TAG_NEW_MESSAGE, badge: NOTIFICATION_BADGE_ICON }); - // close any previous notifications for this room - // AFTER showing the new notification as on Android - // where we can only show 1 notification, this creates - // a smoother transition - if (notifsToClose) { - for (const notif of notifsToClose) { - console.log("close previous notification"); - notif.close(); - } - } } // we could consider hiding previous notifications here based on the unread count // (although we can't really figure out which notifications to hide) and also hiding From c06b9cd88650b2af50cf74b49ea33d36753b7573 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 11:25:45 +0100 Subject: [PATCH 041/166] release v0.1.40 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 67ff2c19..657721c7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.39", + "version": "0.1.40", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From cd615265f851156fd6f952c8b5e76a79af1a42e6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 18:08:47 +0100 Subject: [PATCH 042/166] support overriding imports for customizations --- doc/SKINNING.md | 12 +++++++++ scripts/build.mjs | 62 +++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 doc/SKINNING.md diff --git a/doc/SKINNING.md b/doc/SKINNING.md new file mode 100644 index 00000000..bb5034ee --- /dev/null +++ b/doc/SKINNING.md @@ -0,0 +1,12 @@ +# Skinning + +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). diff --git a/scripts/build.mjs b/scripts/build.mjs index 4d29c9db..1b931f37 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -50,12 +50,16 @@ const cssSrcDir = path.join(projectDir, "src/platform/web/ui/css/"); const parameters = new commander.Command(); parameters .option("--modern-only", "don't make a legacy build") + .option("--override-imports ", "pass in a file to override import paths, see doc/SKINNING.md") parameters.parse(process.argv); -async function build({modernOnly}) { +async function build({modernOnly, overrideImports}) { // get version number const version = JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).version; - + let importOverridesMap; + if (overrideImports) { + importOverridesMap = await readImportOverrides(overrideImports); + } const devHtml = await fs.readFile(path.join(projectDir, "index.html"), "utf8"); const doc = cheerio.load(devHtml); const themes = []; @@ -70,12 +74,12 @@ async function build({modernOnly}) { // copy olm assets const olmAssets = await copyFolder(path.join(projectDir, "lib/olm/"), assets.directory); assets.addSubMap(olmAssets); - await assets.write(`hydrogen.js`, await buildJs("src/main.js", ["src/platform/web/Platform.js"])); + await assets.write(`hydrogen.js`, await buildJs("src/main.js", ["src/platform/web/Platform.js"], importOverridesMap)); if (!modernOnly) { await assets.write(`hydrogen-legacy.js`, await buildJsLegacy("src/main.js", [ 'src/platform/web/legacy-polyfill.js', 'src/platform/web/LegacyPlatform.js' - ])); + ], importOverridesMap)); await assets.write(`worker.js`, await buildJsLegacy("src/platform/web/worker/main.js", ['src/platform/web/worker/polyfill.js'])); } // copy over non-theme assets @@ -177,11 +181,15 @@ async function buildHtml(doc, version, baseConfig, globalHash, modernOnly, asset await assets.writeUnhashed("index.html", doc.html()); } -async function buildJs(mainFile, extraFiles = []) { +async function buildJs(mainFile, extraFiles, importOverrides) { // create js bundle + const plugins = [multi(), removeJsComments({comments: "none"})]; + if (importOverrides) { + plugins.push(overridesAsRollupPlugin(importOverrides)); + } const bundle = await rollup({ input: extraFiles.concat(mainFile), - plugins: [multi(), removeJsComments({comments: "none"})] + plugins }); const {output} = await bundle.generate({ format: 'es', @@ -192,7 +200,7 @@ async function buildJs(mainFile, extraFiles = []) { return code; } -async function buildJsLegacy(mainFile, extraFiles = []) { +async function buildJsLegacy(mainFile, extraFiles, importOverrides) { // compile down to whatever IE 11 needs const babelPlugin = babel.babel({ babelHelpers: 'bundled', @@ -212,13 +220,18 @@ async function buildJsLegacy(mainFile, extraFiles = []) { ] ] }); + const plugins = [multi(), commonjs()]; + if (importOverrides) { + plugins.push(overridesAsRollupPlugin(importOverrides)); + } + plugins.push(nodeResolve(), babelPlugin); // create js bundle const rollupConfig = { // important the extraFiles come first, // so polyfills are available in the global scope // if needed for the mainfile input: extraFiles.concat(mainFile), - plugins: [multi(), commonjs(), nodeResolve(), babelPlugin] + plugins }; const bundle = await rollup(rollupConfig); const {output} = await bundle.generate({ @@ -488,4 +501,37 @@ class AssetMap { } } +async function readImportOverrides(filename) { + const json = await fs.readFile(filename, "utf8"); + const mapping = new Map(Object.entries(JSON.parse(json))); + return { + basedir: path.dirname(path.resolve(filename))+path.sep, + mapping + }; +} + +function overridesAsRollupPlugin(importOverrides) { + const {mapping, basedir} = importOverrides; + return { + name: "rewrite-imports", + resolveId (source, importer) { + let file; + if (source.startsWith(path.sep)) { + file = source; + } else { + file = path.join(path.dirname(importer), source); + } + if (file.startsWith(basedir)) { + const searchPath = file.substr(basedir.length); + const replacingPath = mapping.get(searchPath); + if (replacingPath) { + console.info(`replacing ${searchPath} with ${replacingPath}`); + return path.join(basedir, replacingPath); + } + } + return null; + } + }; +} + build(parameters).catch(err => console.error(err)); From 2a0045bed7f7e97f5f5fa3247835951f22ea81ba Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 25 Mar 2021 18:54:10 +0100 Subject: [PATCH 043/166] support override main css file --- doc/SKINNING.md | 12 +++++++++++- scripts/build.mjs | 12 ++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/doc/SKINNING.md b/doc/SKINNING.md index bb5034ee..5f1c735d 100644 --- a/doc/SKINNING.md +++ b/doc/SKINNING.md @@ -1,4 +1,4 @@ -# Skinning +# 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: @@ -10,3 +10,13 @@ Any source file can be replaced at build time by mapping the path in a JSON file The paths are relative to the location of the mapping file, but the mapping file should be in a parent directory of the files you want to replace. You should see a "replacing x with y" line (twice actually, for the normal and legacy build). + +# Injecting CSS + +You can override the location of the main css file with the `--override-css ` option to the build script. The default is `src/platform/web/ui/css/main.css`, which you probably want to import from your custom css file like so: + +```css +@import url('src/platform/web/ui/css/main.css'); + +/* additions */ +``` diff --git a/scripts/build.mjs b/scripts/build.mjs index 1b931f37..44d58fe6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -51,9 +51,10 @@ const parameters = new commander.Command(); parameters .option("--modern-only", "don't make a legacy build") .option("--override-imports ", "pass in a file to override import paths, see doc/SKINNING.md") + .option("--override-css
", "pass in an alternative main css file") parameters.parse(process.argv); -async function build({modernOnly, overrideImports}) { +async function build({modernOnly, overrideImports, overrideCss}) { // get version number const version = JSON.parse(await fs.readFile(path.join(projectDir, "package.json"), "utf8")).version; let importOverridesMap; @@ -90,7 +91,7 @@ async function build({modernOnly, overrideImports}) { // creates the directories where the theme css bundles are placed in, // and writes to assets, so the build bundles can translate them, so do it first await copyThemeAssets(themes, assets); - await buildCssBundles(buildCssLegacy, themes, assets); + await buildCssBundles(buildCssLegacy, themes, assets, overrideCss); await buildManifest(assets); // all assets have been added, create a hash from all assets name to cache unhashed files like index.html assets.addToHashForAll("index.html", devHtml); @@ -311,8 +312,11 @@ async function buildServiceWorker(swSource, version, globalHash, assets) { await assets.writeUnhashed("sw.js", swSource); } -async function buildCssBundles(buildFn, themes, assets) { - const bundleCss = await buildFn(path.join(cssSrcDir, "main.css")); +async function buildCssBundles(buildFn, themes, assets, mainCssFile = null) { + if (!mainCssFile) { + mainCssFile = path.join(cssSrcDir, "main.css"); + } + const bundleCss = await buildFn(mainCssFile); await assets.write(`hydrogen.css`, bundleCss); for (const theme of themes) { const themeRelPath = `themes/${theme}/`; From 14ed5fd1e863c3916906e5256df7415e3386ff3c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 16 Mar 2021 19:34:34 +0100 Subject: [PATCH 044/166] Manually adapt UI when keyboard shows or hides on mobile Safari Mobile Safari seems to be the only browser that does *not* resize the viewport when the keyboard shows and hides. Instead the window is moved to make room for the keyboard which moves content at the top off screen. This uses the VisualViewport API to manually resize the `SessionView` in response to keyboard display events. Additionally, if a DOM element exists that has the `bottom-aligned-scroll` CSS class, its scroll position is retained. Currently this only applies to the `Timeline`. Note that the VisualViewport API was only introduced with iOS 13. According to [statista.com], versions below 13 made up for 19% of all iOS users in summer 2020, with the share continuing to fall off. As a result, this seems like an acceptable workaround. Fixes: #181 [statista.com]: https://www.statista.com/statistics/565270/apple-devices-ios-version-share-worldwide/ Signed-off-by: Johannes Marbach --- scripts/build.mjs | 6 +- src/platform/web/Platform.js | 56 ++++++++++++++++++- src/platform/web/dom/download.js | 5 +- src/platform/web/ui/css/layout.css | 7 +++ .../web/ui/session/room/TimelineList.js | 2 +- 5 files changed, 69 insertions(+), 7 deletions(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 44d58fe6..8e3eb6c6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -351,7 +351,11 @@ async function buildCssLegacy(entryPath, urlMapper = null) { const preCss = await fs.readFile(entryPath, "utf8"); const options = [ postcssImport, - cssvariables(), + cssvariables({ + preserve: (declaration) => { + return declaration.value.indexOf("var(--ios-") == 0; + } + }), autoprefixer({overrideBrowserslist: ["IE 11"], grid: "no-autoplace"}), flexbugsFixes() ]; diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 747855de..49b9df35 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -35,6 +35,7 @@ import {WorkerPool} from "./dom/WorkerPool.js"; import {BlobHandle} from "./dom/BlobHandle.js"; import {hasReadPixelPermission, ImageHandle, VideoHandle} from "./dom/ImageHandle.js"; import {downloadInIframe} from "./dom/download.js"; +import {Disposables} from "../../utils/Disposables.js"; function addScript(src) { return new Promise(function (resolve, reject) { @@ -83,6 +84,44 @@ async function loadOlmWorker(config) { return olmWorker; } +// needed for mobile Safari which shifts the layout viewport up without resizing it +// when the keyboard shows (see https://bugs.webkit.org/show_bug.cgi?id=141832) +function adaptUIOnVisualViewportResize(container) { + if (!window.visualViewport) { + return; + } + const handler = () => { + const sessionView = container.querySelector('.SessionView'); + if (!sessionView) { + return; + } + + const scrollable = container.querySelector('.bottom-aligned-scroll'); + let scrollTopBefore, heightBefore, heightAfter; + + if (scrollable) { + scrollTopBefore = scrollable.scrollTop; + heightBefore = scrollable.offsetHeight; + } + + // Ideally we'd use window.visualViewport.offsetTop but that seems to occasionally lag + // behind (last tested on iOS 14.4 simulator) so we have to compute the offset manually + const offsetTop = sessionView.offsetTop + sessionView.offsetHeight - window.visualViewport.height; + + container.style.setProperty('--ios-viewport-height', window.visualViewport.height.toString() + 'px'); + container.style.setProperty('--ios-viewport-top', offsetTop.toString() + 'px'); + + if (scrollable) { + heightAfter = scrollable.offsetHeight; + scrollable.scrollTop = scrollTopBefore + heightBefore - heightAfter; + } + }; + window.visualViewport.addEventListener('resize', handler); + return () => { + window.visualViewport.removeEventListener('resize', handler); + }; +} + export class Platform { constructor(container, config, cryptoExtras = null, options = null) { this._config = config; @@ -115,6 +154,10 @@ export class Platform { } const isIE11 = !!window.MSInputMethodContext && !!document.documentMode; this.isIE11 = isIE11; + // From https://stackoverflow.com/questions/9038625/detect-if-device-is-ios/9039885 + const isIOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) && !window.MSStream; + this.isIOS = isIOS; + this._disposables = new Disposables(); } get updateService() { @@ -135,6 +178,13 @@ export class Platform { if (this.isIE11) { this._container.className += " legacy"; } + if (this.isIOS) { + this._container.className += " ios"; + const disposable = adaptUIOnVisualViewportResize(this._container); + if (disposable) { + this._disposables.track(disposable); + } + } window.__hydrogenViewModel = vm; const view = new RootView(vm); this._container.appendChild(view.mount()); @@ -152,7 +202,7 @@ export class Platform { if (navigator.msSaveBlob) { navigator.msSaveBlob(blobHandle.nativeBlob, filename); } else { - downloadInIframe(this._container, this._config.downloadSandbox, blobHandle, filename); + downloadInIframe(this._container, this._config.downloadSandbox, blobHandle, filename, this.isIOS); } } @@ -201,4 +251,8 @@ export class Platform { get version() { return window.HYDROGEN_VERSION; } + + dispose() { + this._disposables.dispose(); + } } diff --git a/src/platform/web/dom/download.js b/src/platform/web/dom/download.js index 41d3c29d..2476d3df 100644 --- a/src/platform/web/dom/download.js +++ b/src/platform/web/dom/download.js @@ -14,10 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -// From https://stackoverflow.com/questions/9038625/detect-if-device-is-ios/9039885 -const isIOS = /iPad|iPhone|iPod/.test(navigator.platform) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1) && !window.MSStream; - -export async function downloadInIframe(container, iframeSrc, blobHandle, filename) { +export async function downloadInIframe(container, iframeSrc, blobHandle, filename, isIOS) { let iframe = container.querySelector("iframe.downloadSandbox"); if (!iframe) { iframe = document.createElement("iframe"); diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 9917ca74..765bff3c 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -54,6 +54,13 @@ main { min-width: 0; } +/* resize and reposition session view to account for mobile Safari which shifts +the layout viewport up without resizing it when the keyboard shows */ +.hydrogen.ios .SessionView { + height: var(--ios-viewport-height, 100%); + top: var(--ios-viewport-top, 0); +} + /* hide back button in middle section by default */ .middle .close-middle { display: none; } /* mobile layout */ diff --git a/src/platform/web/ui/session/room/TimelineList.js b/src/platform/web/ui/session/room/TimelineList.js index 414649be..791e23a3 100644 --- a/src/platform/web/ui/session/room/TimelineList.js +++ b/src/platform/web/ui/session/room/TimelineList.js @@ -40,7 +40,7 @@ function viewClassForEntry(entry) { export class TimelineList extends ListView { constructor(viewModel) { const options = { - className: "Timeline", + className: "Timeline bottom-aligned-scroll", list: viewModel.tiles, } super(options, entry => { From 6bdf7f1ee9657d64c995252beba1a9350958d928 Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Tue, 30 Mar 2021 17:43:03 +0100 Subject: [PATCH 045/166] Allow specifying a custom defaultHomeServer --- assets/config.json | 3 ++- src/domain/RootViewModel.js | 2 +- src/platform/web/Platform.js | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/assets/config.json b/assets/config.json index a98c5ba9..703ae1e6 100644 --- a/assets/config.json +++ b/assets/config.json @@ -3,5 +3,6 @@ "appId": "io.element.hydrogen.web", "gatewayUrl": "https://matrix.org", "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" - } + }, + "defaultHomeServer": "matrix.org" } diff --git a/src/domain/RootViewModel.js b/src/domain/RootViewModel.js index cb22bee2..f6e566e3 100644 --- a/src/domain/RootViewModel.js +++ b/src/domain/RootViewModel.js @@ -99,7 +99,7 @@ export class RootViewModel extends ViewModel { _showLogin() { this._setSection(() => { this._loginViewModel = new LoginViewModel(this.childOptions({ - defaultHomeServer: "https://matrix.org", + defaultHomeServer: this.platform.config["defaultHomeServer"], createSessionContainer: this._createSessionContainer, ready: sessionContainer => { // we don't want to load the session container again, diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index 747855de..549d49c3 100644 --- a/src/platform/web/Platform.js +++ b/src/platform/web/Platform.js @@ -125,6 +125,10 @@ export class Platform { return loadOlm(this._config.olm); } + get config() { + return this._config; + } + async loadOlmWorker() { if (!window.WebAssembly) { return await loadOlmWorker(this._config); From 3767060632db6bc3963d24f034735c75aec099de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 31 Mar 2021 16:34:37 +0200 Subject: [PATCH 046/166] fix reloading or opening wrong page when clicking notif --- src/platform/web/service-worker.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 7001600a..335930bb 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -196,12 +196,13 @@ async function openClientFromNotif(event) { const {sessionId, roomId} = event.notification.data; const sessionHash = `#/session/${sessionId}`; const roomHash = `${sessionHash}/room/${roomId}`; - const roomURL = `/${roomHash}`; const clientWithSession = await findClient(async client => { return await sendAndWaitForReply(client, "hasSessionOpen", {sessionId}); }); if (clientWithSession) { console.log("notificationclick: client has session open, showing room there"); + // just change the hash, so the page doesn't refresh on / vs /index.html + const roomURL = new URL(roomHash, clientWithSession.url).href; clientWithSession.navigate(roomURL); if ('focus' in clientWithSession) { try { @@ -210,6 +211,7 @@ async function openClientFromNotif(event) { } } else if (self.clients.openWindow) { console.log("notificationclick: no client found with session open, opening new window"); + const roomURL = new URL(`./${roomHash}`, baseURL).href; await self.clients.openWindow(roomURL); } } From 8894329fa3598342605e861117fff323466786c0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 31 Mar 2021 16:51:25 +0200 Subject: [PATCH 047/166] don't use url to open room as it still refreshes the page on chrome even though only the hash is different --- src/platform/web/dom/ServiceWorkerHandler.js | 2 ++ src/platform/web/service-worker.js | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/platform/web/dom/ServiceWorkerHandler.js b/src/platform/web/dom/ServiceWorkerHandler.js index d2dacc6b..dd2c755f 100644 --- a/src/platform/web/dom/ServiceWorkerHandler.js +++ b/src/platform/web/dom/ServiceWorkerHandler.js @@ -76,6 +76,8 @@ export class ServiceWorkerHandler { // this flag is read in fetch.js this.haltRequests = true; event.source.postMessage({replyTo: data.id}); + } else if (data.type === "openRoom") { + this._navigation.push("room", data.payload.roomId); } } diff --git a/src/platform/web/service-worker.js b/src/platform/web/service-worker.js index 335930bb..29b124d9 100644 --- a/src/platform/web/service-worker.js +++ b/src/platform/web/service-worker.js @@ -201,9 +201,8 @@ async function openClientFromNotif(event) { }); if (clientWithSession) { console.log("notificationclick: client has session open, showing room there"); - // just change the hash, so the page doesn't refresh on / vs /index.html - const roomURL = new URL(roomHash, clientWithSession.url).href; - clientWithSession.navigate(roomURL); + // use a message rather than clientWithSession.navigate here as this refreshes the page on chrome + clientWithSession.postMessage({type: "openRoom", payload: {roomId}}); if ('focus' in clientWithSession) { try { await clientWithSession.focus(); From 3f36ea04f4e26de8303adfb7405abd9f9b4084bb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 31 Mar 2021 17:29:16 +0200 Subject: [PATCH 048/166] release v0.1.41 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 657721c7..eb414a9d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.40", + "version": "0.1.41", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From 110f0821f05a56708a4926a106635395b5ffa37c Mon Sep 17 00:00:00 2001 From: Will Hunt Date: Wed, 31 Mar 2021 17:01:49 +0100 Subject: [PATCH 049/166] Add to index.html --- index.html | 1 + 1 file changed, 1 insertion(+) diff --git a/index.html b/index.html index d1a9db0d..0b266ff3 100644 --- a/index.html +++ b/index.html @@ -24,6 +24,7 @@ main(new Platform(document.body, { worker: "src/worker.js", downloadSandbox: "assets/download-sandbox.html", + defaultHomeServer: "matrix.org", // NOTE: uncomment this if you want the service worker for local development // serviceWorker: "sw.js", // NOTE: provide push config if you want push notifs for local development From 010b782a9665aadbde0e93b169f3311b9272bee3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 1 Apr 2021 14:59:18 +0200 Subject: [PATCH 050/166] allow t.map render fn to not return a dom node --- src/platform/web/ui/general/TemplateView.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 6b016b9b..e82a492e 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -322,7 +322,11 @@ class TemplateBuilder { map(mapFn, renderFn) { return this.mapView(mapFn, mappedValue => { return new TemplateView(this._value, (t, vm) => { - return renderFn(mappedValue, t, vm); + const rootNode = renderFn(mappedValue, t, vm); + if (!rootNode) { + return document.createComment("map placeholder"); + } + return rootNode; }); }); } From c06659c0be271f7484d51ff1330673562deb059f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 1 Apr 2021 14:59:46 +0200 Subject: [PATCH 051/166] support checking if pusher is still present on server --- src/matrix/Session.js | 12 ++++++++++++ src/matrix/net/HomeServerApi.js | 4 ++++ src/matrix/push/Pusher.js | 10 ++++++++++ 3 files changed, 26 insertions(+) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 735967bf..0ddf44ae 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -522,6 +522,18 @@ export class Session { const pusherData = await readTxn.session.get(PUSHER_KEY); return !!pusherData; } + + async checkPusherEnabledOnHomeServer() { + const readTxn = await this._storage.readTxn([this._storage.storeNames.session]); + const pusherData = await readTxn.session.get(PUSHER_KEY); + if (!pusherData) { + return false; + } + const myPusher = new Pusher(pusherData); + const serverPushersData = await this._hsApi.getPushers().response(); + const serverPushers = (serverPushersData?.pushers || []).map(data => new Pusher(data)); + return serverPushers.some(p => p.equals(myPusher)); + } } export function tests() { diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index a6acaf74..f8dfa517 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -258,6 +258,10 @@ export class HomeServerApi { setPusher(pusher, options = null) { return this._post("/pushers/set", null, pusher, options); } + + getPushers(options = null) { + return this._get("/pushers", null, null, options); + } } export function tests() { diff --git a/src/matrix/push/Pusher.js b/src/matrix/push/Pusher.js index cc370f82..99baeae6 100644 --- a/src/matrix/push/Pusher.js +++ b/src/matrix/push/Pusher.js @@ -37,4 +37,14 @@ export class Pusher { serialize() { return this._description; } + + equals(pusher) { + if (this._description.app_id !== pusher._description.app_id) { + return false; + } + if (this._description.pushkey !== pusher._description.pushkey) { + return false; + } + return JSON.stringify(this._description.data) === JSON.stringify(pusher._description.data); + } } From 10e9e7388f7d7943281cb5bbf05d10bbe33376c4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 1 Apr 2021 15:01:04 +0200 Subject: [PATCH 052/166] expose checking pusher on homeserver in setting --- .../session/settings/SettingsViewModel.js | 16 ++++++++++++++ .../web/ui/session/settings/SettingsView.js | 21 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index ec171eea..221e39da 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -22,6 +22,8 @@ class PushNotificationStatus { this.supported = null; this.enabled = false; this.updating = false; + this.enabledOnServer = null; + this.serverError = null; } } @@ -129,6 +131,8 @@ export class SettingsViewModel extends ViewModel { async togglePushNotifications() { this.pushNotifications.updating = true; + this.pushNotifications.enabledOnServer = null; + this.pushNotifications.serverError = null; this.emitChange("pushNotifications.updating"); try { if (await this._session.enablePushNotifications(!this.pushNotifications.enabled)) { @@ -142,5 +146,17 @@ export class SettingsViewModel extends ViewModel { this.emitChange("pushNotifications.updating"); } } + + async checkPushEnabledOnServer() { + this.pushNotifications.enabledOnServer = null; + this.pushNotifications.serverError = null; + try { + this.pushNotifications.enabledOnServer = await this._session.checkPusherEnabledOnHomeServer(); + this.emitChange("pushNotifications.enabledOnServer"); + } catch (err) { + this.pushNotifications.serverError = err; + this.emitChange("pushNotifications.serverError"); + } + } } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 4d4b2aba..b4a47bea 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -66,6 +66,27 @@ export class SettingsView extends TemplateView { } else { return t.p(vm.i18n`Push notifications are not supported on this browser`); } + }), + t.if(vm => vm.pushNotifications.supported && vm.pushNotifications.enabled, t => { + return t.div([ + t.p([ + "If you think push notifications are not being delivered, ", + t.button({className: "link", onClick: () => vm.checkPushEnabledOnServer()}, "check"), + " if they got disabled on the server" + ]), + t.map(vm => vm.pushNotifications.enabledOnServer, (enabled, t) => { + if (enabled === true) { + return t.p("Push notifications are still enabled on the server, so everything should be working. Sometimes notifications can get dropped if they can't be delivered within a given time."); + } else if (enabled === false) { + return t.p("Push notifications have been disabled on the server, likely due to a bug. Please re-enable them by clicking Disable and then Enable again above."); + } + }), + t.map(vm => vm.pushNotifications.serverError, (err, t) => { + if (err) { + return t.p("Couln't not check on server: " + err.message); + } + }) + ]); }) ); From 1ac36cbd23072d27bdc43467a6a3c9b8672048b9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 1 Apr 2021 15:04:21 +0200 Subject: [PATCH 053/166] release v0.1.42 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index eb414a9d..7e9fb3a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.41", + "version": "0.1.42", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From 97030f7ef301a0600e53e98f883de0f79efd15f0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 1 Apr 2021 17:44:55 +0200 Subject: [PATCH 054/166] add https to homeserver if not already a valid url --- src/matrix/SessionContainer.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 85a6826d..57752309 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -43,6 +43,14 @@ export const LoginFailure = createEnum( "Unknown", ); +function normalizeHomeserver(homeServer) { + try { + return new URL(homeServer).origin; + } catch (err) { + return new URL(`https://${homeServer}`).origin; + } +} + export class SessionContainer { constructor({platform, olmPromise, workerPromise}) { this._platform = platform; @@ -96,6 +104,7 @@ export class SessionContainer { } await this._platform.logger.run("login", async log => { this._status.set(LoadStatus.Login); + homeServer = normalizeHomeserver(homeServer); const clock = this._platform.clock; let sessionInfo; try { From ca9663d2f59c6b0ee66a32aa70955b92b9aa7380 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Apr 2021 14:37:46 +0200 Subject: [PATCH 055/166] opt-out for unread count notifications, which cause bogus notifs --- src/platform/web/dom/NotificationService.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/platform/web/dom/NotificationService.js b/src/platform/web/dom/NotificationService.js index 151ccaf0..86b2ca69 100644 --- a/src/platform/web/dom/NotificationService.js +++ b/src/platform/web/dom/NotificationService.js @@ -32,6 +32,9 @@ export class NotificationService { const data = { endpoint: subscriptionData.endpoint, auth: subscriptionData.keys.auth, + // don't deliver unread count push messages + // as we don't want to show a notification in this case + events_only: true, default_payload: defaultPayload }; return pusherFactory.httpPusher( From 141cf95eacbd59d75378aa438221a64cc9582fcd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Apr 2021 14:38:48 +0200 Subject: [PATCH 056/166] some minor TODOs for template view improvements --- src/platform/web/ui/general/TemplateView.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index e82a492e..85345c53 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -41,11 +41,13 @@ function objHasFns(obj) { export class TemplateView { constructor(value, render = undefined) { this._value = value; + // TODO: can avoid this if we have a separate class for inline templates vs class template views this._render = render; this._eventListeners = null; this._bindings = null; this._subViews = null; this._root = null; + // TODO: can avoid this if we adopt the handleEvent pattern in our EventListener this._boundUpdateFromValue = null; } @@ -324,6 +326,8 @@ class TemplateBuilder { return new TemplateView(this._value, (t, vm) => { const rootNode = renderFn(mappedValue, t, vm); if (!rootNode) { + // TODO: this will confuse mapView which assumes that + // a comment node means there is no view to clean up return document.createComment("map placeholder"); } return rootNode; From d94aeff98c5ed5847c18c64f75eb77a3562afb7f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Apr 2021 14:46:01 +0200 Subject: [PATCH 057/166] release v0.1.43 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7e9fb3a3..8a91f611 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.42", + "version": "0.1.43", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From f5db6babf254db69cf582ad069667a33fc3d57d6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 10:27:50 +0200 Subject: [PATCH 058/166] forgot to change the type of the hs field, so it still nags --- src/platform/web/ui/login/LoginView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/login/LoginView.js b/src/platform/web/ui/login/LoginView.js index 0b15d42a..683bf42d 100644 --- a/src/platform/web/ui/login/LoginView.js +++ b/src/platform/web/ui/login/LoginView.js @@ -35,7 +35,7 @@ export class LoginView extends TemplateView { }); const homeserver = t.input({ id: "homeserver", - type: "url", + type: "text", placeholder: vm.i18n`Your matrix homeserver`, value: vm.defaultHomeServer, disabled From 6f37c232f7023c7f2922225ed7f93a1af1dd2bac Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 12:56:24 +0200 Subject: [PATCH 059/166] Don't cache members that haven't been written yet - fixes #271 --- src/matrix/room/timeline/persistence/MemberWriter.js | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index 9143acc9..dc6f621d 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -91,9 +91,6 @@ export class MemberWriter { }); if (memberEvent) { member = RoomMember.fromMemberEvent(this._roomId, memberEvent); - // adding it to the cache, but not storing it for now; - // we'll do that when we get to the event - this._cache.set(member); } } return member; @@ -222,5 +219,14 @@ export function tests() { assert.equal(change.member.membership, "join"); assert.equal(txn.members.get(alice).displayName, "Alice"); }, + "newly joined member causes a change with lookup done first": async assert => { + const event = createMemberEvent("join", alice, "Alice"); + const writer = new MemberWriter(roomId); + const txn = createStorage(); + const member = await writer.lookupSenderMember(event, [event], txn); + assert(member); + const change = await writer.writeTimelineMemberEvent(event, txn); + assert(change); + }, }; } From f67ccc18f4f9fef56a6ad674ef269ce281634ec2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 12:57:10 +0200 Subject: [PATCH 060/166] take most recent member rather than first in timeline for inline lookup noticed this while inspecting the code, looks related to #269 --- .../room/timeline/persistence/MemberWriter.js | 30 +++++++++++++++---- .../room/timeline/persistence/SyncWriter.js | 2 +- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index dc6f621d..73da64cd 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -73,7 +73,8 @@ export class MemberWriter { } } - async lookupMember(userId, timelineEvents, txn) { + async lookupSenderMember(event, timelineEvents, txn) { + const userId = event.sender; let member = this._cache.get(userId); if (!member) { const memberData = await txn.roomMembers.get(this._roomId, userId); @@ -83,12 +84,21 @@ export class MemberWriter { } } if (!member) { + let memberEvent; // sometimes the member event isn't included in state, but rather in the timeline, - // even if it is not the first event in the timeline. In this case, go look for the - // first occurence - const memberEvent = timelineEvents.find(e => { - return e.type === MEMBER_EVENT_TYPE && e.state_key === userId; - }); + // even if it is not the first event in the timeline. In this case, go look for + // the last one before the event + let foundEvent = false; + for (let i = timelineEvents.length - 1; i >= 0; i -= 1) { + const e = timelineEvents[i]; + if (!foundEvent && e.event_id === event.event_id) { + foundEvent = true; + } + if (foundEvent && e.type === MEMBER_EVENT_TYPE && e.state_key === userId) { + memberEvent = e; + break; + } + } if (memberEvent) { member = RoomMember.fromMemberEvent(this._roomId, memberEvent); } @@ -228,5 +238,13 @@ export function tests() { const change = await writer.writeTimelineMemberEvent(event, txn); assert(change); }, + "lookupSenderMember returns closest member in the past": async assert => { + const event1 = createMemberEvent("join", alice, "Alice"); + const event2 = createMemberEvent("join", alice, "Alies"); + const writer = new MemberWriter(roomId); + const txn = createStorage(); + const member = await writer.lookupSenderMember(event2, [event1, event2], txn); + assert.equal(member.displayName, "Alies"); + }, }; } diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index a7675993..1f251fce 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -162,7 +162,7 @@ export class SyncWriter { // store event in timeline currentKey = currentKey.nextKey(); const entry = createEventEntry(currentKey, this._roomId, event); - let member = await this._memberWriter.lookupMember(event.sender, events, txn); + let member = await this._memberWriter.lookupSenderMember(event, events, txn); if (member) { entry.displayName = member.displayName; entry.avatarUrl = member.avatarUrl; From 813be758d7b69e7f42d5fbf99bd8df6a8b0888a8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 15:33:21 +0200 Subject: [PATCH 061/166] we should prefer to not pick the event itself if it's a member event but still fall back to that if it's a new join --- .../room/timeline/persistence/MemberWriter.js | 41 +++++++++++++------ .../room/timeline/persistence/SyncWriter.js | 2 +- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index 73da64cd..db649f68 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -73,8 +73,7 @@ export class MemberWriter { } } - async lookupSenderMember(event, timelineEvents, txn) { - const userId = event.sender; + async lookupMember(userId, event, timelineEvents, txn) { let member = this._cache.get(userId); if (!member) { const memberData = await txn.roomMembers.get(this._roomId, userId); @@ -84,23 +83,38 @@ export class MemberWriter { } } if (!member) { - let memberEvent; // sometimes the member event isn't included in state, but rather in the timeline, // even if it is not the first event in the timeline. In this case, go look for - // the last one before the event + // the last one before the event, or if none is found, + // the least recent matching member event in the timeline. + // The latter is needed because of new joins picking up their own display name let foundEvent = false; + let memberEventBefore; + let firstMemberEvent; for (let i = timelineEvents.length - 1; i >= 0; i -= 1) { const e = timelineEvents[i]; - if (!foundEvent && e.event_id === event.event_id) { - foundEvent = true; + let matchingEvent; + if (e.type === MEMBER_EVENT_TYPE && e.state_key === userId) { + matchingEvent = e; + firstMemberEvent = matchingEvent; } - if (foundEvent && e.type === MEMBER_EVENT_TYPE && e.state_key === userId) { - memberEvent = e; + if (!foundEvent) { + if (e.event_id === event.event_id) { + foundEvent = true; + } + } else if (matchingEvent) { + memberEventBefore = matchingEvent; break; } } - if (memberEvent) { - member = RoomMember.fromMemberEvent(this._roomId, memberEvent); + // first see if we found a member event before the event we're looking up the sender for + if (memberEventBefore) { + member = RoomMember.fromMemberEvent(this._roomId, memberEventBefore); + } + // and only if we didn't, fall back to the first member event, + // regardless of where it is positioned relative to the lookup event + else if (firstMemberEvent) { + member = RoomMember.fromMemberEvent(this._roomId, firstMemberEvent); } } return member; @@ -233,17 +247,18 @@ export function tests() { const event = createMemberEvent("join", alice, "Alice"); const writer = new MemberWriter(roomId); const txn = createStorage(); - const member = await writer.lookupSenderMember(event, [event], txn); + const member = await writer.lookupMember(event.sender, event, [event], txn); assert(member); const change = await writer.writeTimelineMemberEvent(event, txn); assert(change); }, - "lookupSenderMember returns closest member in the past": async assert => { + "lookupMember returns closest member in the past": async assert => { const event1 = createMemberEvent("join", alice, "Alice"); const event2 = createMemberEvent("join", alice, "Alies"); + const event3 = createMemberEvent("join", alice, "Alys"); const writer = new MemberWriter(roomId); const txn = createStorage(); - const member = await writer.lookupSenderMember(event2, [event1, event2], txn); + const member = await writer.lookupMember(event3.sender, event3, [event1, event2, event3], txn); assert.equal(member.displayName, "Alies"); }, }; diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 1f251fce..4913ac53 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -162,7 +162,7 @@ export class SyncWriter { // store event in timeline currentKey = currentKey.nextKey(); const entry = createEventEntry(currentKey, this._roomId, event); - let member = await this._memberWriter.lookupSenderMember(event, events, txn); + let member = await this._memberWriter.lookupMember(event.sender, event, events, txn); if (member) { entry.displayName = member.displayName; entry.avatarUrl = member.avatarUrl; From 863f659774c6e216181aee1e535c17877e99baca Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 16:30:46 +0200 Subject: [PATCH 062/166] fall back to bare userid for local echo profile --- src/matrix/room/Room.js | 2 +- src/matrix/room/members/RoomMember.js | 4 ++++ src/matrix/room/timeline/Timeline.js | 11 +++++++++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 7326e32f..91cc3ceb 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -678,7 +678,7 @@ export class Room extends EventEmitter { if (this._roomEncryption) { this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline)); } - await this._timeline.load(this._user, log); + await this._timeline.load(this._user, this._summary.data.membership, log); return this._timeline; }); } diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js index 4013f556..f810a9fe 100644 --- a/src/matrix/room/members/RoomMember.js +++ b/src/matrix/room/members/RoomMember.js @@ -23,6 +23,10 @@ export class RoomMember { this._data = data; } + static fromUserId(roomId, userId, membership) { + return new RoomMember({roomId, userId, membership}); + } + static fromMemberEvent(roomId, memberEvent) { const userId = memberEvent?.state_key; if (typeof userId !== "string") { diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 2cccdf83..7743ddb8 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -45,10 +45,17 @@ export class Timeline { } /** @package */ - async load(user, log) { + async load(user, membership, log) { const txn = await this._storage.readTxn(this._timelineReader.readTxnStores.concat(this._storage.storeNames.roomMembers)); const memberData = await txn.roomMembers.get(this._roomId, user.id); - this._ownMember = new RoomMember(memberData); + if (memberData) { + this._ownMember = new RoomMember(memberData); + } else { + // this should never happen, as our own join into the room would have + // made us receive our own member event, but just to be on the safe side and not crash, + // fall back to bare user id + this._ownMember = RoomMember.fromUserId(this._roomId, user.id, membership); + } // it should be fine to not update the local entries, // as they should only populate once the view subscribes to it // if they are populated already, the sender profile would be empty From d8d97c40f7f1104e76fb638bb4c92ac9fe7026e4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 18:35:30 +0200 Subject: [PATCH 063/166] show start time milliseconds in log viewer --- scripts/logviewer/main.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/logviewer/main.js b/scripts/logviewer/main.js index e7574fae..51a5760a 100644 --- a/scripts/logviewer/main.js +++ b/scripts/logviewer/main.js @@ -82,12 +82,13 @@ 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(itemStart(item)).toString()]), + 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) + ""]), From f976eeaf13d0d2c735c501406a6e469f880508d5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 18:36:09 +0200 Subject: [PATCH 064/166] rename SortedArray.replace to update --- src/matrix/room/timeline/Timeline.js | 4 +++- src/observable/list/SortedArray.js | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 7743ddb8..5c9091df 100644 --- a/src/matrix/room/timeline/Timeline.js +++ b/src/matrix/room/timeline/Timeline.js @@ -76,7 +76,9 @@ export class Timeline { replaceEntries(entries) { for (const entry of entries) { - this._remoteEntries.replace(entry); + // this will use the comparator and thus + // check for equality using the compare method in BaseEntry + this._remoteEntries.update(entry); } } diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js index 6d3e90c6..193661cf 100644 --- a/src/observable/list/SortedArray.js +++ b/src/observable/list/SortedArray.js @@ -41,11 +41,11 @@ export class SortedArray extends BaseObservableList { } } - replace(item) { + update(item, updateParams = null) { const idx = this.indexOf(item); if (idx !== -1) { this._items[idx] = item; - this.emitUpdate(idx, item, null); + this.emitUpdate(idx, item, updateParams); } } From 9e697c6cbe25c59a41f38676299b4f5f60e9ab66 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 18:36:26 +0200 Subject: [PATCH 065/166] don't add the pending event again if removed already, fixes #289 --- src/matrix/room/sending/SendQueue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 2d74d93e..959a3803 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -36,7 +36,7 @@ export class SendQueue { const pendingEvent = new PendingEvent({ data, remove: () => this._removeEvent(pendingEvent), - emitUpdate: () => this._pendingEvents.set(pendingEvent), + emitUpdate: () => this._pendingEvents.update(pendingEvent), attachments }); return pendingEvent; From bdc8451c4a085b8f34c077be91fd60be636adebd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 18:39:52 +0200 Subject: [PATCH 066/166] better logging --- src/matrix/room/sending/SendQueue.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 959a3803..4ad5e527 100644 --- a/src/matrix/room/sending/SendQueue.js +++ b/src/matrix/room/sending/SendQueue.js @@ -118,7 +118,7 @@ export class SendQueue { } if (idx !== -1) { const pendingEvent = this._pendingEvents.get(idx); - parentLog.log({l: "removeRemoteEcho", id: pendingEvent.remoteId}); + parentLog.log({l: "removeRemoteEcho", queueIndex: pendingEvent.queueIndex, remoteId: event.event_id, txnId}); txn.pendingEvents.remove(pendingEvent.roomId, pendingEvent.queueIndex); removed.push(pendingEvent); } From 6802f0720e66d4babead774532d72c929a2e4442 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 8 Apr 2021 18:44:57 +0200 Subject: [PATCH 067/166] release v0.1.44 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8a91f611..7239fe73 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.43", + "version": "0.1.44", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From 13f20cdd26dd47cc0ccd38aa3d71667ee2bfb2e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 10:37:43 +0200 Subject: [PATCH 068/166] wait for running key share operation in encrypt --- src/matrix/e2ee/RoomEncryption.js | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index ddb6e4c4..88bacbf4 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -46,6 +46,7 @@ export class RoomEncryption { this._clock = clock; this._isFlushingRoomKeyShares = false; this._lastKeyPreShareTime = null; + this._keySharePromise = null; this._disposed = false; } @@ -265,13 +266,26 @@ export class RoomEncryption { return; } this._lastKeyPreShareTime = this._clock.createMeasure(); - const roomKeyMessage = await this._megolmEncryption.ensureOutboundSession(this._room.id, this._encryptionParams); - if (roomKeyMessage) { - await log.wrap("share key", log => this._shareNewRoomKey(roomKeyMessage, hsApi, log)); + try { + this._keySharePromise = (async () => { + const roomKeyMessage = await this._megolmEncryption.ensureOutboundSession(this._room.id, this._encryptionParams); + if (roomKeyMessage) { + await log.wrap("share key", log => this._shareNewRoomKey(roomKeyMessage, hsApi, log)); + } + })(); + await this._keySharePromise; + } finally { + this._keySharePromise = null; } } async encrypt(type, content, hsApi, log) { + // ensureMessageKeyIsShared is still running, + // wait for it to create and share a key if needed + if (this._keySharePromise) { + log.set("waitForRunningKeyShare", true); + await this._keySharePromise; + } const megolmResult = await log.wrap("megolm encrypt", () => this._megolmEncryption.encrypt(this._room.id, type, content, this._encryptionParams)); if (megolmResult.roomKeyMessage) { log.wrapDetached("share key", log => this._shareNewRoomKey(megolmResult.roomKeyMessage, hsApi, log)); From e17fb24bd8a5b8c964e413d4a678fe5f53ca9320 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 10:37:55 +0200 Subject: [PATCH 069/166] also await sharing the key in encrypt --- src/matrix/e2ee/RoomEncryption.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index 88bacbf4..721be2d0 100644 --- a/src/matrix/e2ee/RoomEncryption.js +++ b/src/matrix/e2ee/RoomEncryption.js @@ -288,7 +288,7 @@ export class RoomEncryption { } const megolmResult = await log.wrap("megolm encrypt", () => this._megolmEncryption.encrypt(this._room.id, type, content, this._encryptionParams)); if (megolmResult.roomKeyMessage) { - log.wrapDetached("share key", log => this._shareNewRoomKey(megolmResult.roomKeyMessage, hsApi, log)); + await log.wrap("share key", log => this._shareNewRoomKey(megolmResult.roomKeyMessage, hsApi, log)); } return { type: ENCRYPTED_TYPE, From 23417480230a97ad6e7f5f8dd9feda88d5e3db32 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 14:09:48 +0200 Subject: [PATCH 070/166] add some tests to timeout code --- src/platform/web/dom/request/fetch.js | 4 +- src/platform/web/dom/request/timeout.js | 51 ------------ src/utils/timeout.js | 100 ++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 52 deletions(-) delete mode 100644 src/platform/web/dom/request/timeout.js create mode 100644 src/utils/timeout.js diff --git a/src/platform/web/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index 1222c41b..66f1a148 100644 --- a/src/platform/web/dom/request/fetch.js +++ b/src/platform/web/dom/request/fetch.js @@ -19,7 +19,7 @@ import { AbortError, ConnectionError } from "../../../../matrix/error.js"; -import {abortOnTimeout} from "./timeout.js"; +import {abortOnTimeout} from "../../../../utils/timeout.js"; import {addCacheBuster} from "./common.js"; import {xhrRequest} from "./xhr.js"; @@ -121,6 +121,8 @@ export function createFetchRequest(createTimeout, serviceWorkerHandler) { return {status, body}; }, err => { if (err.name === "AbortError") { + // map DOMException with name AbortError to our own AbortError type + // as we don't want DOMExceptions in the protocol layer. throw new AbortError(); } else if (err instanceof TypeError) { // Network errors are reported as TypeErrors, see diff --git a/src/platform/web/dom/request/timeout.js b/src/platform/web/dom/request/timeout.js deleted file mode 100644 index 030e4150..00000000 --- a/src/platform/web/dom/request/timeout.js +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2020 Bruno Windels -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 { - AbortError, - ConnectionError -} from "../../../../matrix/error.js"; - - -export function abortOnTimeout(createTimeout, timeoutAmount, requestResult, responsePromise) { - const timeout = createTimeout(timeoutAmount); - // abort request if timeout finishes first - let timedOut = false; - timeout.elapsed().then( - () => { - timedOut = true; - requestResult.abort(); - }, - () => {} // ignore AbortError when timeout is aborted - ); - // abort timeout if request finishes first - return responsePromise.then( - response => { - timeout.abort(); - return response; - }, - err => { - timeout.abort(); - // map error to TimeoutError - if (err.name === "AbortError" && timedOut) { - throw new ConnectionError(`Request timed out after ${timeoutAmount}ms`, true); - } else { - throw err; - } - } - ); -} diff --git a/src/utils/timeout.js b/src/utils/timeout.js new file mode 100644 index 00000000..886d6a50 --- /dev/null +++ b/src/utils/timeout.js @@ -0,0 +1,100 @@ +/* +Copyright 2020 Bruno Windels +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 {ConnectionError} from "../matrix/error.js"; + + +export function abortOnTimeout(createTimeout, timeoutAmount, requestResult, responsePromise) { + const timeout = createTimeout(timeoutAmount); + // abort request if timeout finishes first + let timedOut = false; + timeout.elapsed().then( + () => { + timedOut = true; + requestResult.abort(); + }, + () => {} // ignore AbortError when timeout is aborted + ); + // abort timeout if request finishes first + return responsePromise.then( + response => { + timeout.abort(); + return response; + }, + err => { + timeout.abort(); + // map error to TimeoutError + if (err.name === "AbortError" && timedOut) { + throw new ConnectionError(`Request timed out after ${timeoutAmount}ms`, true); + } else { + throw err; + } + } + ); +} + +// because impunity only takes one entrypoint currently, +// these tests aren't run by yarn test as that does not +// include platform specific code, +// and this file is only included by platform specific code, +// see how to run in package.json and replace src/main.js with this file. +import {Clock} from "../mocks/Clock.js"; +import {AbortError} from "../matrix/error.js"; +export function tests() { + function createRequest() { + let request = { + abort() { + this.aborted = true; + this.reject(new AbortError()); + } + }; + request.responsePromise = new Promise((resolve, reject) => { + request.resolve = resolve; + request.reject = reject; + }); + return request; + } + + return { + "ConnectionError on timeout": async assert => { + const clock = new Clock(); + const request = createRequest(); + const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.responsePromise); + clock.elapse(10000); + await assert.rejects(promise, ConnectionError); + assert(request.aborted); + }, + "Abort is canceled once response resolves": async assert => { + const clock = new Clock(); + const request = createRequest(); + const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.responsePromise); + request.resolve(5); + clock.elapse(10000); + assert(!request.aborted); + assert.equal(await promise, 5); + }, + "AbortError from request is not mapped to ConnectionError": async assert => { + const clock = new Clock(); + const request = createRequest(); + const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.responsePromise); + request.reject(new AbortError()); + assert(!request.aborted); + assert.rejects(promise, AbortError); + } + } + +} From 2b1f4866a98c125c5c159a89b5fa98405aee2e2b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 14:25:19 +0200 Subject: [PATCH 071/166] map unexpected fetch AbortError to ConnectionError, so doesn't stop sync --- src/matrix/net/HomeServerApi.js | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index f8dfa517..b93dac91 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -47,7 +47,23 @@ class RequestWrapper { }, err => { // if this._requestResult is still set, the abort error came not from calling abort here if (err.name === "AbortError" && this._requestResult) { - const err = new Error(`Unexpectedly aborted, see #187.`); + // The service worker sometimes (only on Firefox, on long, large request, + // perhaps it has its own timeout?) aborts the request, see #187. + // When it happens, the best thing to do seems to be to retry. + // + // In the service worker, we will also actively abort requests when trying to + // get a new service worker to activate, as the service worker will only be replaced + // when there are no more (fetch) events for the current one to handle. + // + // In that case, the request function (in fetch.js) will check + // the haltRequests flag on the service worker handler, and it will + // actually not do any requests, as that would break the update process. + // + // So it is OK to return a timeout ConnectionError here. + // If we're updating the service worker, the /versions polling will + // actually be blocked at the fetch level because haltRequests is set. + // And for #187, retrying is the right thing to do. + const err = new ConnectionError(`Service worker aborted, either updating or hit #187.`, true); log?.catch(err); throw err; } else { From c36e812360e12a6bdc1be953a275d3d5efa0753f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 15:15:28 +0200 Subject: [PATCH 072/166] move RequestWrapper to own file and add tests, improve comments, and and don't use timeout connection error as that's not what happens if aborted request from service worker is reported as TypeError either. --- src/matrix/net/HomeServerApi.js | 117 ++--------------------- src/matrix/net/HomeServerRequest.js | 139 ++++++++++++++++++++++++++++ src/matrix/net/common.js | 20 ++++ src/mocks/Request.js | 41 ++++++++ 4 files changed, 208 insertions(+), 109 deletions(-) create mode 100644 src/matrix/net/HomeServerRequest.js create mode 100644 src/mocks/Request.js diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index b93dac91..cbcb55ab 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -15,100 +15,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {HomeServerError, ConnectionError} from "../error.js"; -import {encodeQueryParams} from "./common.js"; - -class RequestWrapper { - constructor(method, url, requestResult, log) { - this._log = log; - this._requestResult = requestResult; - this._promise = requestResult.response().then(response => { - log?.set("status", response.status); - // ok? - if (response.status >= 200 && response.status < 300) { - log?.finish(); - return response.body; - } else { - if (response.status >= 500) { - const err = new ConnectionError(`Internal Server Error`); - log?.catch(err); - throw err; - } else if (response.status >= 400 && !response.body?.errcode) { - const err = new ConnectionError(`HTTP error status ${response.status} without errcode in body, assume this is a load balancer complaining the server is offline.`); - log?.catch(err); - throw err; - } else { - const err = new HomeServerError(method, url, response.body, response.status); - log?.set("errcode", err.errcode); - log?.catch(err); - throw err; - } - } - }, err => { - // if this._requestResult is still set, the abort error came not from calling abort here - if (err.name === "AbortError" && this._requestResult) { - // The service worker sometimes (only on Firefox, on long, large request, - // perhaps it has its own timeout?) aborts the request, see #187. - // When it happens, the best thing to do seems to be to retry. - // - // In the service worker, we will also actively abort requests when trying to - // get a new service worker to activate, as the service worker will only be replaced - // when there are no more (fetch) events for the current one to handle. - // - // In that case, the request function (in fetch.js) will check - // the haltRequests flag on the service worker handler, and it will - // actually not do any requests, as that would break the update process. - // - // So it is OK to return a timeout ConnectionError here. - // If we're updating the service worker, the /versions polling will - // actually be blocked at the fetch level because haltRequests is set. - // And for #187, retrying is the right thing to do. - const err = new ConnectionError(`Service worker aborted, either updating or hit #187.`, true); - log?.catch(err); - throw err; - } else { - if (err.name === "ConnectionError") { - log?.set("timeout", err.isTimeout); - } - log?.catch(err); - throw err; - } - }); - } - - abort() { - if (this._requestResult) { - this._log?.set("aborted", true); - this._requestResult.abort(); - // to mark that it was on purpose in above rejection handler - this._requestResult = null; - } - } - - response() { - return this._promise; - } -} - -function encodeBody(body) { - if (body.nativeBlob && body.mimeType) { - const blob = body; - return { - mimeType: blob.mimeType, - body: blob, // will be unwrapped in request fn - length: blob.size - }; - } else if (typeof body === "object") { - const json = JSON.stringify(body); - return { - mimeType: "application/json", - body: json, - length: body.length - }; - } else { - throw new Error("Unknown body type: " + body); - } -} +import {encodeQueryParams, encodeBody} from "./common.js"; +import {HomeServerRequest} from "./HomeServerRequest.js"; export class HomeServerApi { constructor({homeServer, accessToken, request, createTimeout, reconnector}) { @@ -159,10 +67,10 @@ export class HomeServerApi { format: "json" // response format }); - const wrapper = new RequestWrapper(method, url, requestResult, log); + const hsRequest = new HomeServerRequest(method, url, requestResult, log); if (this._reconnector) { - wrapper.response().catch(err => { + hsRequest.response().catch(err => { // Some endpoints such as /sync legitimately time-out // (which is also reported as a ConnectionError) and will re-attempt, // but spinning up the reconnector in this case is ok, @@ -173,7 +81,7 @@ export class HomeServerApi { }); } - return wrapper; + return hsRequest; } _unauthedRequest(method, url, queryParams, body, options) { @@ -280,22 +188,13 @@ export class HomeServerApi { } } -export function tests() { - function createRequestMock(result) { - return function() { - return { - abort() {}, - response() { - return Promise.resolve(result); - } - } - } - } +import {Request as MockRequest} from "../../mocks/Request.js"; +export function tests() { return { "superficial happy path for GET": async assert => { const hsApi = new HomeServerApi({ - request: createRequestMock({body: 42, status: 200}), + request: () => new MockRequest().respond(200, 42), homeServer: "https://hs.tld" }); const result = await hsApi._get("foo", null, null, null).response(); diff --git a/src/matrix/net/HomeServerRequest.js b/src/matrix/net/HomeServerRequest.js new file mode 100644 index 00000000..c059c5a6 --- /dev/null +++ b/src/matrix/net/HomeServerRequest.js @@ -0,0 +1,139 @@ +/* +Copyright 2020 Bruno Windels +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 {HomeServerError, ConnectionError} from "../error.js"; + +export class HomeServerRequest { + constructor(method, url, sourceRequest, log) { + this._log = log; + this._sourceRequest = sourceRequest; + this._promise = sourceRequest.response().then(response => { + log?.set("status", response.status); + // ok? + if (response.status >= 200 && response.status < 300) { + log?.finish(); + return response.body; + } else { + if (response.status >= 500) { + const err = new ConnectionError(`Internal Server Error`); + log?.catch(err); + throw err; + } else if (response.status >= 400 && !response.body?.errcode) { + const err = new ConnectionError(`HTTP error status ${response.status} without errcode in body, assume this is a load balancer complaining the server is offline.`); + log?.catch(err); + throw err; + } else { + const err = new HomeServerError(method, url, response.body, response.status); + log?.set("errcode", err.errcode); + log?.catch(err); + throw err; + } + } + }, err => { + // if this._sourceRequest is still set, the abort error came not from calling abort here + if (err.name === "AbortError" && this._sourceRequest) { + // The service worker sometimes (only on Firefox, on long, large request, + // perhaps it has its own timeout?) aborts the request, see #187. + // When it happens, the best thing to do seems to be to retry. + // + // In the service worker, we will also actively abort requests + // (this may surface in the app as a TypeError, which already gets mapped + // to a ConnectionError in the request function, or an AbortError, + // depending on the browser) + // when trying to get a new service worker to activate, + // as the service worker will only be replaced when there are + // no more (fetch) events for the current one to handle. + // + // In that case, the request function (in fetch.js) will check + // the haltRequests flag on the service worker handler, and it will + // actually not do any requests, as that would break the update process. + // + // So it is OK to return a ConnectionError here. + // If we're updating the service worker, the /versions polling will + // actually be blocked at the fetch level because haltRequests is set. + // And for #187, retrying is the right thing to do. + const err = new ConnectionError(`Service worker aborted, either updating or hit #187.`); + log?.catch(err); + throw err; + } else { + if (err.name === "ConnectionError") { + log?.set("timeout", err.isTimeout); + } + log?.catch(err); + throw err; + } + }); + } + + abort() { + if (this._sourceRequest) { + this._log?.set("aborted", true); + this._sourceRequest.abort(); + // to mark that it was on purpose in above rejection handler + this._sourceRequest = null; + } + } + + response() { + return this._promise; + } +} + +import {Request as MockRequest} from "../../mocks/Request.js"; +import {AbortError} from "../error.js"; + +export function tests() { + return { + "Response is passed through": async assert => { + const request = new MockRequest(); + const hsRequest = new HomeServerRequest("GET", "https://hs.tld/foo", request); + request.respond(200, "foo"); + assert.equal(await hsRequest.response(), "foo"); + }, + "Unexpected AbortError is mapped to ConnectionError": async assert => { + const request = new MockRequest(); + const hsRequest = new HomeServerRequest("GET", "https://hs.tld/foo", request); + request.reject(new AbortError()); + await assert.rejects(hsRequest.response(), ConnectionError); + }, + "500 response is mapped to ConnectionError": async assert => { + const request = new MockRequest(); + const hsRequest = new HomeServerRequest("GET", "https://hs.tld/foo", request); + request.respond(500); + await assert.rejects(hsRequest.response(), ConnectionError); + }, + "4xx response is mapped to HomeServerError": async assert => { + const request = new MockRequest(); + const hsRequest = new HomeServerRequest("GET", "https://hs.tld/foo", request); + request.respond(400, {errcode: "FOO"}); + await assert.rejects(hsRequest.response(), HomeServerError); + }, + "4xx response without errcode is mapped to ConnectionError": async assert => { + const request = new MockRequest(); + const hsRequest = new HomeServerRequest("GET", "https://hs.tld/foo", request); + request.respond(400); + await assert.rejects(hsRequest.response(), ConnectionError); + }, + "Other errors are passed through": async assert => { + class MyError extends Error {} + const request = new MockRequest(); + const hsRequest = new HomeServerRequest("GET", "https://hs.tld/foo", request); + request.reject(new MyError()); + await assert.rejects(hsRequest.response(), MyError); + }, + }; +} diff --git a/src/matrix/net/common.js b/src/matrix/net/common.js index c7a06351..889042ae 100644 --- a/src/matrix/net/common.js +++ b/src/matrix/net/common.js @@ -26,3 +26,23 @@ export function encodeQueryParams(queryParams) { }) .join("&"); } + +export function encodeBody(body) { + if (body.nativeBlob && body.mimeType) { + const blob = body; + return { + mimeType: blob.mimeType, + body: blob, // will be unwrapped in request fn + length: blob.size + }; + } else if (typeof body === "object") { + const json = JSON.stringify(body); + return { + mimeType: "application/json", + body: json, + length: body.length + }; + } else { + throw new Error("Unknown body type: " + body); + } +} diff --git a/src/mocks/Request.js b/src/mocks/Request.js new file mode 100644 index 00000000..da8693b1 --- /dev/null +++ b/src/mocks/Request.js @@ -0,0 +1,41 @@ +/* +Copyright 2020 Bruno Windels + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {AbortError} from "../utils/error.js"; + +export class Request { + constructor() { + this._responsePromise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + this.aborted = false; + } + + respond(status, body) { + this.resolve({status, body}); + return this; + } + + abort() { + this.aborted = true; + this.reject(new AbortError()); + } + + response() { + return this._responsePromise; + } +} From c604c31032decbf0a048eef88db7f530b6aebc80 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 15:16:43 +0200 Subject: [PATCH 073/166] use external mock for request in timeout tests --- src/utils/timeout.js | 35 +++++++++++------------------------ 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/src/utils/timeout.js b/src/utils/timeout.js index 886d6a50..6bfc0d7e 100644 --- a/src/utils/timeout.js +++ b/src/utils/timeout.js @@ -52,45 +52,32 @@ export function abortOnTimeout(createTimeout, timeoutAmount, requestResult, resp // include platform specific code, // and this file is only included by platform specific code, // see how to run in package.json and replace src/main.js with this file. -import {Clock} from "../mocks/Clock.js"; +import {Clock as MockClock} from "../mocks/Clock.js"; +import {Request as MockRequest} from "../mocks/Request.js"; import {AbortError} from "../matrix/error.js"; export function tests() { - function createRequest() { - let request = { - abort() { - this.aborted = true; - this.reject(new AbortError()); - } - }; - request.responsePromise = new Promise((resolve, reject) => { - request.resolve = resolve; - request.reject = reject; - }); - return request; - } - return { "ConnectionError on timeout": async assert => { - const clock = new Clock(); - const request = createRequest(); - const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.responsePromise); + const clock = new MockClock(); + const request = new MockRequest(); + const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.response()); clock.elapse(10000); await assert.rejects(promise, ConnectionError); assert(request.aborted); }, "Abort is canceled once response resolves": async assert => { - const clock = new Clock(); - const request = createRequest(); - const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.responsePromise); + const clock = new MockClock(); + const request = new MockRequest(); + const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.response()); request.resolve(5); clock.elapse(10000); assert(!request.aborted); assert.equal(await promise, 5); }, "AbortError from request is not mapped to ConnectionError": async assert => { - const clock = new Clock(); - const request = createRequest(); - const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.responsePromise); + const clock = new MockClock(); + const request = new MockRequest(); + const promise = abortOnTimeout(clock.createTimeout, 10000, request, request.response()); request.reject(new AbortError()); assert(!request.aborted); assert.rejects(promise, AbortError); From 606e30fed27723980eb4efb879ace97fb2a56a25 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 15:24:10 +0200 Subject: [PATCH 074/166] make comment easier to read --- src/matrix/net/HomeServerRequest.js | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/matrix/net/HomeServerRequest.js b/src/matrix/net/HomeServerRequest.js index c059c5a6..97728c28 100644 --- a/src/matrix/net/HomeServerRequest.js +++ b/src/matrix/net/HomeServerRequest.js @@ -44,27 +44,28 @@ export class HomeServerRequest { } } }, err => { - // if this._sourceRequest is still set, the abort error came not from calling abort here + // if this._sourceRequest is still set, + // the abort error came not from calling abort here if (err.name === "AbortError" && this._sourceRequest) { // The service worker sometimes (only on Firefox, on long, large request, // perhaps it has its own timeout?) aborts the request, see #187. // When it happens, the best thing to do seems to be to retry. // - // In the service worker, we will also actively abort requests + // In the service worker, we will also actively abort all + // ongoing requests when trying to get a new service worker to activate // (this may surface in the app as a TypeError, which already gets mapped // to a ConnectionError in the request function, or an AbortError, - // depending on the browser) - // when trying to get a new service worker to activate, - // as the service worker will only be replaced when there are - // no more (fetch) events for the current one to handle. + // depending on the browser), as the service worker will only be + // replaced when there are no more (fetch) events for the + // current one to handle. // // In that case, the request function (in fetch.js) will check - // the haltRequests flag on the service worker handler, and it will - // actually not do any requests, as that would break the update process. + // the haltRequests flag on the service worker handler, and + // block any new requests, as that would break the update process. // // So it is OK to return a ConnectionError here. // If we're updating the service worker, the /versions polling will - // actually be blocked at the fetch level because haltRequests is set. + // be blocked at the fetch level because haltRequests is set. // And for #187, retrying is the right thing to do. const err = new ConnectionError(`Service worker aborted, either updating or hit #187.`); log?.catch(err); From 4b19e3c498fa850d54817ffdbbfabf22fda5eb94 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 16:27:25 +0200 Subject: [PATCH 075/166] remove unused member --- src/matrix/SessionContainer.js | 3 +-- src/matrix/net/HomeServerApi.js | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 57752309..4e17e010 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -109,7 +109,7 @@ export class SessionContainer { let sessionInfo; try { const request = this._platform.request; - const hsApi = new HomeServerApi({homeServer, request, createTimeout: clock.createTimeout}); + const hsApi = new HomeServerApi({homeServer, request}); const loginData = await hsApi.passwordLogin(username, password, "Hydrogen", {log}).response(); const sessionId = this.createNewSessionId(); sessionInfo = { @@ -169,7 +169,6 @@ export class SessionContainer { accessToken: sessionInfo.accessToken, request: this._platform.request, reconnector: this._reconnector, - createTimeout: clock.createTimeout }); this._sessionId = sessionInfo.id; this._storage = await this._platform.storageFactory.create(sessionInfo.id); diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index cbcb55ab..051bb44a 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -19,13 +19,12 @@ import {encodeQueryParams, encodeBody} from "./common.js"; import {HomeServerRequest} from "./HomeServerRequest.js"; export class HomeServerApi { - constructor({homeServer, accessToken, request, createTimeout, reconnector}) { + constructor({homeServer, accessToken, request, reconnector}) { // store these both in a closure somehow so it's harder to get at in case of XSS? // one could change the homeserver as well so the token gets sent there, so both must be protected from read/write this._homeserver = homeServer; this._accessToken = accessToken; this._requestFn = request; - this._createTimeout = createTimeout; this._reconnector = reconnector; } From b852feeb9cbb65b2072084bd38f628f5ac34d3df Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 16:28:14 +0200 Subject: [PATCH 076/166] ConnectionError isn't throw from start, but sets sync.error --- src/matrix/SessionContainer.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 4e17e010..5191e745 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -234,19 +234,18 @@ export class SessionContainer { } async _waitForFirstSync() { - try { - this._sync.start(); - this._status.set(LoadStatus.FirstSync); - } catch (err) { - // swallow ConnectionError here and continue, - // as the reconnector above will call - // sync.start again to retry in this case - if (!(err instanceof ConnectionError)) { - throw err; - } - } + this._sync.start(); + this._status.set(LoadStatus.FirstSync); // only transition into Ready once the first sync has succeeded - this._waitForFirstSyncHandle = this._sync.status.waitFor(s => s === SyncStatus.Syncing || s === SyncStatus.Stopped); + this._waitForFirstSyncHandle = this._sync.status.waitFor(s => { + if (s === SyncStatus.Stopped) { + // keep waiting if there is a ConnectionError + // as the reconnector above will call + // sync.start again to retry in this case + return this._sync.error.name !== "ConnectionError"; + } + return s === SyncStatus.Syncing; + }); try { await this._waitForFirstSyncHandle.promise; if (this._sync.status.get() === SyncStatus.Stopped) { From ba38ce7976c8bac43a06a7e8a776c7b358bee1b8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 16:30:53 +0200 Subject: [PATCH 077/166] use canonical way of check error type --- src/matrix/SessionContainer.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 5191e745..55acd4e7 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -21,7 +21,6 @@ import {Reconnector, ConnectionStatus} from "./net/Reconnector.js"; import {ExponentialRetryDelay} from "./net/ExponentialRetryDelay.js"; import {MediaRepository} from "./net/MediaRepository.js"; import {RequestScheduler} from "./net/RequestScheduler.js"; -import {HomeServerError, ConnectionError, AbortError} from "./error.js"; import {Sync, SyncStatus} from "./Sync.js"; import {Session} from "./Session.js"; @@ -124,7 +123,7 @@ export class SessionContainer { await this._platform.sessionInfoStorage.add(sessionInfo); } catch (err) { this._error = err; - if (err instanceof HomeServerError) { + if (err.name === "HomeServerError") { if (err.errcode === "M_FORBIDDEN") { this._loginFailure = LoginFailure.Credentials; } else { @@ -132,7 +131,7 @@ export class SessionContainer { } log.set("loginFailure", this._loginFailure); this._status.set(LoadStatus.LoginFailed); - } else if (err instanceof ConnectionError) { + } else if (err.name === "ConnectionError") { this._loginFailure = LoginFailure.Connection; this._status.set(LoadStatus.LoginFailed); } else { @@ -253,7 +252,7 @@ export class SessionContainer { } } catch (err) { // if dispose is called from stop, bail out - if (err instanceof AbortError) { + if (err.name === "AbortError") { return; } throw err; From d414fb6b94ef5f08d10f2a6876731f6c3dede2d4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 19:50:22 +0200 Subject: [PATCH 078/166] sync can also stop without an error --- src/matrix/SessionContainer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index 55acd4e7..07c4a870 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -241,13 +241,13 @@ export class SessionContainer { // keep waiting if there is a ConnectionError // as the reconnector above will call // sync.start again to retry in this case - return this._sync.error.name !== "ConnectionError"; + return this._sync.error?.name !== "ConnectionError"; } return s === SyncStatus.Syncing; }); try { await this._waitForFirstSyncHandle.promise; - if (this._sync.status.get() === SyncStatus.Stopped) { + if (this._sync.status.get() === SyncStatus.Stopped && this._sync.error) { throw this._sync.error; } } catch (err) { From 050d60381286ccdbb75477e30c8ba1b669a08aff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 9 Apr 2021 20:01:00 +0200 Subject: [PATCH 079/166] release v0.1.45 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7239fe73..456a07ee 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.44", + "version": "0.1.45", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From 16fda4dde0dc7bb509c12347448f6cbca7de7aa2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Apr 2021 14:59:01 +0200 Subject: [PATCH 080/166] white background for transparent avatar images --- src/platform/web/ui/css/themes/element/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 4c41ad97..c5667b53 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -39,7 +39,7 @@ limitations under the License. .avatar { border-radius: 100%; - background: #3D88FA; + background: #fff; color: white; } From 357ce21678dabfdc599bb613cb1f6394b6322605 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Apr 2021 15:09:45 +0200 Subject: [PATCH 081/166] extract base class from Template view to select update mechanism --- src/platform/web/ui/general/BaseUpdateView.js | 58 +++++++++++++++++++ src/platform/web/ui/general/TemplateView.js | 39 ++----------- 2 files changed, 64 insertions(+), 33 deletions(-) create mode 100644 src/platform/web/ui/general/BaseUpdateView.js diff --git a/src/platform/web/ui/general/BaseUpdateView.js b/src/platform/web/ui/general/BaseUpdateView.js new file mode 100644 index 00000000..4f346499 --- /dev/null +++ b/src/platform/web/ui/general/BaseUpdateView.js @@ -0,0 +1,58 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class BaseUpdateView { + constructor(value) { + this._value = value; + // TODO: can avoid this if we adopt the handleEvent pattern in our EventListener + this._boundUpdateFromValue = null; + } + + mount(options) { + const parentProvidesUpdates = options && options.parentProvidesUpdates; + if (!parentProvidesUpdates) { + this._subscribe(); + } + } + + unmount() { + this._unsubscribe(); + } + + get value() { + return this._value; + } + + _updateFromValue(changedProps) { + this.update(this._value, changedProps); + } + + _subscribe() { + if (typeof this._value?.on === "function") { + this._boundUpdateFromValue = this._updateFromValue.bind(this); + this._value.on("change", this._boundUpdateFromValue); + } + } + + _unsubscribe() { + if (this._boundUpdateFromValue) { + if (typeof this._value.off === "function") { + this._value.off("change", this._boundUpdateFromValue); + } + this._boundUpdateFromValue = null; + } + } +} diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 85345c53..8b0da9df 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -16,6 +16,7 @@ limitations under the License. import { setAttribute, text, isChildren, classNames, TAG_NAMES, HTML_NS } from "./html.js"; import {errorToDOM} from "./error.js"; +import {BaseUpdateView} from "./BaseUpdateView.js"; function objHasFns(obj) { for(const value of Object.values(obj)) { @@ -38,37 +39,15 @@ function objHasFns(obj) { - add subviews inside the template */ // TODO: should we rename this to BoundView or something? As opposed to StaticView ... -export class TemplateView { +export class TemplateView extends BaseUpdateView { constructor(value, render = undefined) { - this._value = value; + super(value); // TODO: can avoid this if we have a separate class for inline templates vs class template views this._render = render; this._eventListeners = null; this._bindings = null; this._subViews = null; this._root = null; - // TODO: can avoid this if we adopt the handleEvent pattern in our EventListener - this._boundUpdateFromValue = null; - } - - get value() { - return this._value; - } - - _subscribe() { - if (typeof this._value?.on === "function") { - this._boundUpdateFromValue = this._updateFromValue.bind(this); - this._value.on("change", this._boundUpdateFromValue); - } - } - - _unsubscribe() { - if (this._boundUpdateFromValue) { - if (typeof this._value.off === "function") { - this._value.off("change", this._boundUpdateFromValue); - } - this._boundUpdateFromValue = null; - } } _attach() { @@ -96,17 +75,15 @@ export class TemplateView { } else { throw new Error("no render function passed in, or overriden in subclass"); } - const parentProvidesUpdates = options && options.parentProvidesUpdates; - if (!parentProvidesUpdates) { - this._subscribe(); - } + // takes care of update being called when needed + super.mount(options); this._attach(); return this._root; } unmount() { this._detach(); - this._unsubscribe(); + super.unmount(); if (this._subViews) { for (const v of this._subViews) { v.unmount(); @@ -118,10 +95,6 @@ export class TemplateView { return this._root; } - _updateFromValue(changedProps) { - this.update(this._value, changedProps); - } - update(value) { this._value = value; if (this._bindings) { From c85b2ca3c9a415bf2697f86ff4955a3ec31b6abb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Apr 2021 15:11:05 +0200 Subject: [PATCH 082/166] allow manually updating subviews in templates w/ parentProvidesUpdates --- src/platform/web/ui/general/TemplateView.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index 8b0da9df..f72e3383 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -131,6 +131,14 @@ export class TemplateView extends BaseUpdateView { this._subViews.splice(idx, 1); } } + + updateSubViews(value, props) { + if (this._subViews) { + for (const v of this._subViews) { + v.update(value, props); + } + } + } } // what is passed to render @@ -260,10 +268,10 @@ class TemplateBuilder { // this insert a view, and is not a view factory for `if`, so returns the root element to insert in the template // you should not call t.view() and not use the result (e.g. attach the result to the template DOM tree). - view(view) { + view(view, mountOptions = undefined) { let root; try { - root = view.mount(); + root = view.mount(mountOptions); } catch (err) { return errorToDOM(err); } From 766ce4e21701c34109e3cde787aec975b9caeda4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Apr 2021 15:12:14 +0200 Subject: [PATCH 083/166] create AvatarView and renderStaticAvatar (for timeline) and use it in RoomTileView, we make some efforts to only have one update listener for the entire list, because by default a subview would listen on the view model --- src/platform/web/ui/avatar.js | 119 ++++++++++++++++++ src/platform/web/ui/common.js | 19 --- .../web/ui/session/leftpanel/RoomTileView.js | 12 +- src/platform/web/ui/session/room/RoomView.js | 4 +- .../web/ui/session/room/timeline/common.js | 4 +- 5 files changed, 132 insertions(+), 26 deletions(-) create mode 100644 src/platform/web/ui/avatar.js diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js new file mode 100644 index 00000000..c68d5496 --- /dev/null +++ b/src/platform/web/ui/avatar.js @@ -0,0 +1,119 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {tag, text, classNames} from "./general/html.js"; +import {BaseUpdateView} from "./general/BaseUpdateView.js"; + +/* +optimization to not use a sub view when changing between img and text +because there can be many many instances of this view +*/ + +export class AvatarView extends BaseUpdateView { + /** + * @param {ViewModel} value view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} + * @param {Number} size + */ + constructor(value, size) { + super(value); + this._root = null; + this._avatarUrl = null; + this._avatarTitle = null; + this._avatarLetter = null; + this._size = size; + } + + _avatarUrlChanged() { + if (this.value.avatarUrl !== this._avatarUrl) { + this._avatarUrl = this.value.avatarUrl; + return true; + } + return false; + } + + _avatarTitleChanged() { + if (this.value.avatarTitle !== this._avatarTitle) { + this._avatarTitle = this.value.avatarTitle; + return true; + } + return false; + } + + _avatarLetterChanged() { + if (this.value.avatarLetter !== this._avatarLetter) { + this._avatarLetter = this.value.avatarLetter; + return true; + } + return false; + } + + mount(options) { + this._avatarUrlChanged(); + this._avatarLetterChanged(); + this._avatarTitleChanged(); + this._root = renderStaticAvatar(this.value, this._size); + // takes care of update being called when needed + super.mount(options); + return this._root; + } + + root() { + return this._root; + } + + update(vm) { + // important to always call _...changed for every prop + if (this._avatarUrlChanged()) { + // avatarColorNumber won't change, it's based on room/user id + const bgColorClass = `usercolor${vm.avatarColorNumber}`; + if (vm.avatarUrl) { + this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); + this._root.classList.remove(bgColorClass); + } else { + this._root.replaceChild(text(vm.avatarLetter), this._root.firstChild); + this._root.classList.add(bgColorClass); + } + } + const hasAvatar = !!vm.avatarUrl; + if (this._avatarTitleChanged() && hasAvatar) { + const img = this._root.firstChild; + img.setAttribute("title", vm.avatarTitle); + } + if (this._avatarLetterChanged() && !hasAvatar) { + this._root.firstChild.textContent = vm.avatarLetter; + } + } +} + +/** + * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} + * @param {Number} size + * @return {Element} + */ +export function renderStaticAvatar(vm, size) { + const hasAvatar = !!vm.avatarUrl; + const avatarClasses = classNames({ + avatar: true, + [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, + }); + const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); + return tag.div({className: avatarClasses}, [avatarContent]); +} + +function renderImg(vm, size) { + const sizeStr = size.toString(); + return tag.img({src: vm.avatarUrl, width: sizeStr, height: sizeStr, title: vm.avatarTitle}); +} diff --git a/src/platform/web/ui/common.js b/src/platform/web/ui/common.js index f5b71198..9fbafcdf 100644 --- a/src/platform/web/ui/common.js +++ b/src/platform/web/ui/common.js @@ -31,22 +31,3 @@ export function spinner(t, extraClasses = undefined) { } } -/** - * @param {TemplateBuilder} t - * @param {Object} vm view model with {avatarUrl, avatarColorNumber, avatarTitle, avatarLetter} - * @param {Number} size - * @return {Element} - */ -export function renderAvatar(t, vm, size) { - const hasAvatar = !!vm.avatarUrl; - const avatarClasses = { - avatar: true, - [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, - }; - // TODO: handle updates from default to img or reverse - const sizeStr = size.toString(); - const avatarContent = hasAvatar ? - t.img({src: vm => vm.avatarUrl, width: sizeStr, height: sizeStr, title: vm => vm.avatarTitle}) : - vm => vm.avatarLetter; - return t.div({className: avatarClasses}, [avatarContent]); -} diff --git a/src/platform/web/ui/session/leftpanel/RoomTileView.js b/src/platform/web/ui/session/leftpanel/RoomTileView.js index fde02c25..84b38b62 100644 --- a/src/platform/web/ui/session/leftpanel/RoomTileView.js +++ b/src/platform/web/ui/session/leftpanel/RoomTileView.js @@ -16,7 +16,7 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; -import {renderAvatar} from "../../common.js"; +import {AvatarView} from "../../avatar.js"; export class RoomTileView extends TemplateView { render(t, vm) { @@ -26,12 +26,12 @@ export class RoomTileView extends TemplateView { }; return t.li({"className": classes}, [ t.a({href: vm.url}, [ - renderAvatar(t, vm, 32), + t.view(new AvatarView(vm, 32), {parentProvidesUpdates: true}), t.div({className: "description"}, [ t.div({className: {"name": true, unread: vm => vm.isUnread}}, vm => vm.name), t.div({ className: { - "badge": true, + badge: true, highlighted: vm => vm.isHighlighted, hidden: vm => !vm.badgeCount } @@ -40,4 +40,10 @@ export class RoomTileView extends TemplateView { ]) ]); } + + update(value, props) { + super.update(value); + // update the AvatarView as we told it to not subscribe itself with parentProvidesUpdates + this.updateSubViews(value, props); + } } diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 65f464d9..b445dcac 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -19,7 +19,7 @@ import {TemplateView} from "../../general/TemplateView.js"; import {TimelineList} from "./TimelineList.js"; import {TimelineLoadingView} from "./TimelineLoadingView.js"; import {MessageComposer} from "./MessageComposer.js"; -import {renderAvatar} from "../../common.js"; +import {AvatarView} from "../../avatar.js"; export class RoomView extends TemplateView { render(t, vm) { @@ -27,7 +27,7 @@ export class RoomView extends TemplateView { t.div({className: "TimelinePanel"}, [ t.div({className: "RoomHeader middle-header"}, [ t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), - renderAvatar(t, vm, 32), + t.view(new AvatarView(vm, 32)), t.div({className: "room-description"}, [ t.h2(vm => vm.name), ]), diff --git a/src/platform/web/ui/session/room/timeline/common.js b/src/platform/web/ui/session/room/timeline/common.js index 0d1e30ee..22bcd6b1 100644 --- a/src/platform/web/ui/session/room/timeline/common.js +++ b/src/platform/web/ui/session/room/timeline/common.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {renderAvatar} from "../../../common.js"; +import {renderStaticAvatar} from "../../../avatar.js"; export function renderMessage(t, vm, children) { const classes = { @@ -28,7 +28,7 @@ export function renderMessage(t, vm, children) { }; const profile = t.div({className: "profile"}, [ - renderAvatar(t, vm, 30), + renderStaticAvatar(vm, 30), t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.displayName) ]); children = [profile].concat(children); From 33f1ba686c41bd56e45f800e77957123e1692ceb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Apr 2021 15:14:02 +0200 Subject: [PATCH 084/166] add warning when rendering outside of render fn for templates --- src/platform/web/ui/general/TemplateView.js | 34 +++++++++++++++------ 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/platform/web/ui/general/TemplateView.js b/src/platform/web/ui/general/TemplateView.js index f72e3383..fd27eafd 100644 --- a/src/platform/web/ui/general/TemplateView.js +++ b/src/platform/web/ui/general/TemplateView.js @@ -68,12 +68,16 @@ export class TemplateView extends BaseUpdateView { mount(options) { const builder = new TemplateBuilder(this); - if (this._render) { - this._root = this._render(builder, this._value); - } else if (this.render) { // overriden in subclass - this._root = this.render(builder, this._value); - } else { - throw new Error("no render function passed in, or overriden in subclass"); + try { + if (this._render) { + this._root = this._render(builder, this._value); + } else if (this.render) { // overriden in subclass + this._root = this.render(builder, this._value); + } else { + throw new Error("no render function passed in, or overriden in subclass"); + } + } finally { + builder.close(); } // takes care of update being called when needed super.mount(options); @@ -145,6 +149,18 @@ export class TemplateView extends BaseUpdateView { class TemplateBuilder { constructor(templateView) { this._templateView = templateView; + this._closed = false; + } + + close() { + this._closed = true; + } + + _addBinding(fn) { + if (this._closed) { + console.trace("Adding a binding after render will likely cause memory leaks"); + } + this._templateView._addBinding(fn); } get _value() { @@ -164,7 +180,7 @@ class TemplateBuilder { setAttribute(node, name, newValue); } }; - this._templateView._addBinding(binding); + this._addBinding(binding); binding(); } @@ -184,7 +200,7 @@ class TemplateBuilder { } }; - this._templateView._addBinding(binding); + this._addBinding(binding); return node; } @@ -240,7 +256,7 @@ class TemplateBuilder { node = newNode; } }; - this._templateView._addBinding(binding); + this._addBinding(binding); return node; } From 76e65c0bfd42f45c9d4cf30bff29464a49872b61 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 15 Apr 2021 14:47:52 +0200 Subject: [PATCH 085/166] move rollup to devdeps --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 456a07ee..7d072939 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,11 @@ "devDependencies": { "@babel/core": "^7.11.1", "@babel/preset-env": "^7.11.0", + "rollup": "^2.26.4", "@rollup/plugin-babel": "^5.1.0", "@rollup/plugin-multi-entry": "^4.0.0", + "@rollup/plugin-commonjs": "^15.0.0", + "@rollup/plugin-node-resolve": "^9.0.0", "autoprefixer": "^10.0.1", "cheerio": "^1.0.0-rc.3", "commander": "^6.0.0", @@ -45,9 +48,6 @@ "xxhashjs": "^0.2.2" }, "dependencies": { - "rollup": "^2.26.4", - "@rollup/plugin-commonjs": "^15.0.0", - "@rollup/plugin-node-resolve": "^9.0.0", "aes-js": "^3.1.2", "another-json": "^0.2.0", "base64-arraybuffer": "^0.2.0", From 03d92b687ef05f4da08c9e1b1cc783dd50ea1522 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 13:02:33 +0200 Subject: [PATCH 086/166] doc with design rationale --- doc/invites.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/doc/invites.md b/doc/invites.md index ae90f76b..ec24c7f8 100644 --- a/doc/invites.md +++ b/doc/invites.md @@ -3,4 +3,118 @@ - invite_state doesn't update over /sync - can we reuse room summary? need to clear when joining - rely on filter operator to split membership=join from membership=invite? - - + + - invite_state comes once, and then not again + - only state (no heroes for example, but we do get the members) + - wants: + - different class to represent invited room, with accept or reject method? + - make it somewhat easy to render just joined rooms (rely on filter and still put them all in the same observable map) + - make the transition from invite to joined smooth + - reuse room summary logic? + + InvitedRoom + isDM + isEncrypted + name + + timestamp + accept() + reject() + JoiningRoom + to store intent of room you joined through directory, invite, or just /join roomid + also joining is retried when coming back online + + forget() + Room + + so, also taking into account that other types of room we might not want to expose through session.rooms will have invites, + perhaps it is best to expose invites through a different observable collection. You can always join/concat them to show in + the same list. + + How do we handle a smooth UI transition when accepting an invite though? + For looking at the room itself: + - we would attach to the Invite event emitter, and we can have a property "joined" that we would update. Then you know you can go look for the room (or even allow to access the room through a property?) + - so this way the view model can know when to switch and signal the view + For the room list: + - the new Room will be added at exactly the same moment the Invite is removed, + so it should already be fairly smooth whether they are rendered in the same list or not. + + How will we locate the Invite/Room during sync when we go from invite => join? + - have both adhere to sync target api (e.g. prepareSync, ...) and look in invite map + if room id is not found in room map in session.getroom. + - how do we remove the invite when join? + - we ca + Where to store? + - room summaries? + - do we have an interest in keeping the raw events? + - room versions will add another layer of indirection to the room summaries (or will it? once you've upgraded the room, we don't care too much anymore about the details of the old room? hmmm, we do care about whether it is encrypted or not... we need everything to be able to show the timeline in any case) + + + Invite => accept() => Room (ends up in session.rooms) + (.type) => Space (ends up in session.spaces) + Invite: + - isEncrypted + - isDM + - type + - id + - name + - avatarUrl + - timestamp + - joinRule (to say wheter you cannot join this room again if you reject) + + + + new "memberships": + joining (when we want to join/are joining but haven't received remote echo yet) + leaving (needed?) + + maybe it's slightly overkill to persist the intent of joining or leaving a room, + but I do want a way to local echo joining a room, + so that it immediately appears in the room list when clicking join in the room directory / from a url ... how would we sort these rooms though? we can always add another collection, but I'm not sure invites should be treated the same, they can already local echo on the invite object itself. + + + since invites don't update, we could, in sync when processing a new join just set a flag on the roomsyncstate if a room is newly created and in writeSync/afterSync check if there is a `session.invites.get(id)` and call `writeSync/afterSync` on it as well. We need to handle leave => invite as well. So don't check for invites only if it is a new room, but also if membership is leave + + transitions are: + invite => join + invite => leave + invite => ban + join => left + join => ban + leave => invite + leave => join + leave => ban + ban => leave + none => invite + none => join + none => ban + + kick should keep the room & timeline visible (even in room list, until you archive?) + leave should close the room. So explicit archive() step on room ? + + Room => leave() => ArchivedRoom (just a Room loaded from archived_room_summaries) => .forget() + => .forget() + + Room receives leave membership + - if sender === state_key, we left, and we archive the room (remove it from the room list, but keep it in storage) + - if sender !== state_key, we got kicked, and we write the membership but don't archive so it stays in the room list until you call archive/forget on the room + when calling room.leave(), do you have to call archive() or forget() after as well? or rather param of leave and stored intent? sounds like non-atomical operation to me ... + we should be able to archive or forget before leave remote echo arrives + + if two stores, this could mean we could have both an invite and a room with kicked state for a given room id? + + we should avoid key collisions between `session.invites` and `session.rooms` (also `session.archivedRooms` once supported?) in any case, + because if we join them to display in one list, things get complicated. + + avoiding key collisions can happen both with 1 or multiple stores for different room states and is just a matter + of carefully removing one state representation before adding another one. + so a kicked or left room would disappear from session.rooms when an invite is synced? + this would prevent you from seeing the old timeline for example, and if you reject, the old state would come back? + + +# Decisions + - we expose session.invites separate from session.rooms because they are of a different type. + This way, you only have methods on the object that make sense (accept on Room does not make sense, like Invite.openTimeline doesn't make sense) + - we store invites (and likely also archived rooms) in a different store, so that we don't have to clear/add properties where they both differ when transitioning. Also, this gives us the possibility to show the timeline on a room that you have previously joined, as the room summary and invite can exist at the same time. (need to resolve key collision question though for this) + - we want to keep kicked rooms in the room list until explicitly archived + - room id collisions between invites and rooms, can we implement a strategy to prefer invites in the join operator? From 7c4a6fbe4bd3145df6071956dac113663229f40f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 13:02:50 +0200 Subject: [PATCH 087/166] invite store --- src/matrix/storage/common.js | 1 + src/matrix/storage/idb/Transaction.js | 5 +++ src/matrix/storage/idb/schema.js | 8 ++++- src/matrix/storage/idb/stores/InviteStore.js | 33 ++++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 src/matrix/storage/idb/stores/InviteStore.js diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js index 3e9aca4f..438cf6b3 100644 --- a/src/matrix/storage/common.js +++ b/src/matrix/storage/common.js @@ -18,6 +18,7 @@ export const STORE_NAMES = Object.freeze([ "session", "roomState", "roomSummary", + "invites", "roomMembers", "timelineEvents", "timelineFragments", diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js index 8d5ba232..162f821f 100644 --- a/src/matrix/storage/idb/Transaction.js +++ b/src/matrix/storage/idb/Transaction.js @@ -19,6 +19,7 @@ import {StorageError} from "../common.js"; import {Store} from "./Store.js"; import {SessionStore} from "./stores/SessionStore.js"; import {RoomSummaryStore} from "./stores/RoomSummaryStore.js"; +import {InviteStore} from "./stores/InviteStore.js"; import {TimelineEventStore} from "./stores/TimelineEventStore.js"; import {RoomStateStore} from "./stores/RoomStateStore.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js"; @@ -64,6 +65,10 @@ export class Transaction { return this._store("roomSummary", idbStore => new RoomSummaryStore(idbStore)); } + get invites() { + return this._store("invites", idbStore => new InviteStore(idbStore)); + } + get timelineFragments() { return this._store("timelineFragments", idbStore => new TimelineFragmentStore(idbStore)); } diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js index 4d0d45ac..7cf100aa 100644 --- a/src/matrix/storage/idb/schema.js +++ b/src/matrix/storage/idb/schema.js @@ -11,7 +11,8 @@ export const schema = [ migrateSession, createE2EEStores, migrateEncryptionFlag, - createAccountDataStore + createAccountDataStore, + createInviteStore ]; // TODO: how to deal with git merge conflicts of this array? @@ -103,3 +104,8 @@ async function migrateEncryptionFlag(db, txn) { function createAccountDataStore(db) { db.createObjectStore("accountData", {keyPath: "type"}); } + +// v7 +function createInviteStore(db) { + db.createObjectStore("invites", {keyPath: "roomId"}); +} diff --git a/src/matrix/storage/idb/stores/InviteStore.js b/src/matrix/storage/idb/stores/InviteStore.js new file mode 100644 index 00000000..a3c7517a --- /dev/null +++ b/src/matrix/storage/idb/stores/InviteStore.js @@ -0,0 +1,33 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export class InviteStore { + constructor(inviteStore) { + this._inviteStore = inviteStore; + } + + getAll() { + return this._inviteStore.selectAll(); + } + + set(invite) { + return this._inviteStore.put(invite); + } + + remove(roomId) { + this._store.delete(roomId); + } +} From 81a35639ba57a41ae2f5f5744b3c45a37558a050 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 16:11:21 +0200 Subject: [PATCH 088/166] add Invite class calculating the room name, avatar, etc ... with empty accept and reject methods for now --- src/fixtures/matrix/invites/dm.js | 52 ++++++ src/fixtures/matrix/invites/room.js | 59 +++++++ src/logging/NullLogger.js | 2 +- src/matrix/Session.js | 1 + src/matrix/room/Invite.js | 252 ++++++++++++++++++++++++++++ src/matrix/room/RoomSummary.js | 4 +- 6 files changed, 367 insertions(+), 3 deletions(-) create mode 100644 src/fixtures/matrix/invites/dm.js create mode 100644 src/fixtures/matrix/invites/room.js create mode 100644 src/matrix/room/Invite.js diff --git a/src/fixtures/matrix/invites/dm.js b/src/fixtures/matrix/invites/dm.js new file mode 100644 index 00000000..cc63ddce --- /dev/null +++ b/src/fixtures/matrix/invites/dm.js @@ -0,0 +1,52 @@ +const inviteFixture = { + "invite_state": { + "events": [ + { + "type": "m.room.create", + "state_key": "", + "content": { + "creator": "@alice:hs.tld", + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.encryption", + "state_key": "", + "content": { + "algorithm": "m.megolm.v1.aes-sha2" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.join_rules", + "state_key": "", + "content": { + "join_rule": "invite" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.member", + "state_key": "@alice:hs.tld", + "content": { + "avatar_url": "mxc://hs.tld/def456", + "displayname": "Alice", + "membership": "join" + }, + "sender": "@alice:hs.tld" + }, + { + "content": { + "avatar_url": "mxc://hs.tld/abc123", + "displayname": "Bob", + "is_direct": true, + "membership": "invite" + }, + "sender": "@alice:hs.tld", + "state_key": "@bob:hs.tld", + "type": "m.room.member", + } + ] + } + }; +export default inviteFixture; diff --git a/src/fixtures/matrix/invites/room.js b/src/fixtures/matrix/invites/room.js new file mode 100644 index 00000000..41835d42 --- /dev/null +++ b/src/fixtures/matrix/invites/room.js @@ -0,0 +1,59 @@ +const inviteFixture = { + "invite_state": { + "events": [ + { + "type": "m.room.create", + "state_key": "", + "content": { + "creator": "@alice:hs.tld", + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.join_rules", + "state_key": "", + "content": { + "join_rule": "invite" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.member", + "state_key": "@alice:hs.tld", + "content": { + "avatar_url": "mxc://hs.tld/def456", + "displayname": "Alice", + "membership": "join" + }, + "sender": "@alice:hs.tld" + }, + { + "type": "m.room.name", + "state_key": "", + "content": { + "name": "Invite example" + }, + "sender": "@alice:hs.tld" + }, + { + "content": { + "avatar_url": "mxc://hs.tld/abc123", + "displayname": "Bob", + "membership": "invite" + }, + "sender": "@alice:hs.tld", + "state_key": "@bob:hs.tld", + "type": "m.room.member", + }, + { + "content": { + "url": "mxc://hs.tld/roomavatar" + }, + "sender": "@alice:hs.tld", + "state_key": "", + "type": "m.room.avatar", + } + ] + } +}; +export default inviteFixture; \ No newline at end of file diff --git a/src/logging/NullLogger.js b/src/logging/NullLogger.js index 1860e697..614dc291 100644 --- a/src/logging/NullLogger.js +++ b/src/logging/NullLogger.js @@ -50,7 +50,7 @@ export class NullLogger { } } -class NullLogItem { +export class NullLogItem { wrap(_, callback) { return callback(this); } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 0ddf44ae..c510bfb8 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -15,6 +15,7 @@ limitations under the License. */ import {Room} from "./room/Room.js"; +import {Invite} from "./room/Invite.js"; import {Pusher} from "./push/Pusher.js"; import { ObservableMap } from "../observable/index.js"; import {User} from "./User.js"; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js new file mode 100644 index 00000000..6215498a --- /dev/null +++ b/src/matrix/room/Invite.js @@ -0,0 +1,252 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {EventEmitter} from "../../utils/EventEmitter.js"; +import {SummaryData, processStateEvent} from "./RoomSummary.js"; +import {Heroes} from "./members/Heroes.js"; +import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; + +export class Invite extends EventEmitter { + constructor({roomId, user, hsApi, emitCollectionRemove, clock}) { + super(); + this._roomId = roomId; + this._user = user; + this._hsApi = hsApi; + this._emitCollectionRemove = emitCollectionRemove; + this._clock = clock; + this._inviteData = null; + } + + get id() { + return this._roomId; + } + + get name() { + return this._inviteData.name || this._inviteData.canonicalAlias; + } + + get isDirectMessage() { + return this._inviteData.isDirectMessage; + } + + get avatarUrl() { + return this._inviteData.avatarUrl; + } + + get timestamp() { + return this._inviteData.timestamp; + } + + get isEncrypted() { + return this._inviteData.isEncrypted; + } + + get inviter() { + return this._inviter; + } + + get joinRule() { + return this._inviteData.joinRule; + } + + async accept() { + + } + + async reject() { + + } + + load(inviteData) { + this._inviteData = inviteData; + this._inviter = inviteData.inviter ? new RoomMember(inviteData.inviter) : null; + } + + async writeSync(membership, roomResponse, txn, log) { + if (membership === "invite") { + return log.wrap("new invite", async log => { + log.set("id", this.id); + const inviteState = roomResponse["invite_state"]?.events; + if (!Array.isArray(inviteState)) { + return null; + } + const summaryData = this._createSummaryData(inviteState); + let heroes; + if (!summaryData.name && !summaryData.canonicalAlias) { + heroes = await this._createHeroes(inviteState); + } + const myInvite = this._getMyInvite(inviteState); + if (!myInvite) { + return null; + } + const inviter = this._getInviter(myInvite, inviteState); + const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes); + txn.invites.set(inviteData); + return {inviteData, inviter}; + }); + } else { + return log.wrap("remove invite", log => { + log.set("id", this.id); + log.set("membership", membership); + txn.invites.remove(this.id); + return null; + }); + } + } + + afterSync(changes, room) { + if (changes) { + this._inviteData = changes.inviteData; + this._inviter = changes.inviter; + // emit update/add + } else { + this._emitCollectionRemove(this); + this.emit("change"); + } + } + + _createData(inviteState, myInvite, inviter, summaryData, heroes) { + const name = heroes ? heroes.roomName : summaryData.name; + const avatarUrl = heroes ? heroes.roomAvatarUrl : summaryData.avatarUrl; + return { + roomId: this.id, + isEncrypted: !!summaryData.encryption, + isDirectMessage: this._isDirectMessage(myInvite), +// type: + name, + avatarUrl, + canonicalAlias: summaryData.canonicalAlias, + timestamp: this._clock.now(), + joinRule: this._getJoinRule(inviteState), + inviter: inviter?.serialize(), + }; + } + + _isDirectMessage(myInvite) { + return !!(myInvite?.content?.is_direct); + } + + _createSummaryData(inviteState) { + return inviteState.reduce(processStateEvent, new SummaryData(null, this.id)); + } + + async _createHeroes(inviteState) { + const members = inviteState.filter(e => e.type === MEMBER_EVENT_TYPE); + const otherMembers = members.filter(e => e.state_key !== this._user.id); + const memberChanges = otherMembers.reduce((map, e) => { + const member = RoomMember.fromMemberEvent(this.id, e); + map.set(member.userId, new MemberChange(member, null)); + return map; + }, new Map()); + const otherUserIds = otherMembers.map(e => e.state_key); + const heroes = new Heroes(this.id); + const changes = await heroes.calculateChanges(otherUserIds, memberChanges, null); + // we don't get an actual lazy-loading m.heroes summary on invites, + // so just count the members by hand + const countSummary = new SummaryData(null, this.id); + countSummary.joinCount = members.reduce((sum, e) => sum + (e.content?.membership === "join" ? 1 : 0), 0); + countSummary.inviteCount = members.reduce((sum, e) => sum + (e.content?.membership === "invite" ? 1 : 0), 0); + heroes.applyChanges(changes, countSummary); + return heroes; + } + + _getMyInvite(inviteState) { + return inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === this._user.id); + } + + _getInviter(myInvite, inviteState) { + const inviterMemberEvent = inviteState.find(e => e.type === MEMBER_EVENT_TYPE && e.state_key === myInvite.sender); + if (inviterMemberEvent) { + return RoomMember.fromMemberEvent(this.id, inviterMemberEvent); + } + } + + _getJoinRule(inviteState) { + const event = inviteState.find(e => e.type === "m.room.join_rules"); + if (event) { + return event.content?.join_rule; + } + return null; + } +} + +import {NullLogItem} from "../../logging/NullLogger.js"; +import {Clock as MockClock} from "../../mocks/Clock.js"; +import {default as roomInviteFixture} from "../../fixtures/matrix/invites/room.js"; +import {default as dmInviteFixture} from "../../fixtures/matrix/invites/dm.js"; + +export function tests() { + + function createStorage() { + const invitesMap = new Map(); + return { + invitesMap, + invites: { + set(invite) { + invitesMap.set(invite.roomId, invite); + } + } + } + } + + const roomId = "!123:hs.tld"; + const aliceAvatarUrl = "mxc://hs.tld/def456"; + const roomAvatarUrl = "mxc://hs.tld/roomavatar"; + + return { + "invite for room has correct fields": async assert => { + const invite = new Invite({ + roomId, + clock: new MockClock(1001), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", roomInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + assert.equal(invite.name, "Invite example"); + assert.equal(invite.avatarUrl, roomAvatarUrl); + assert.equal(invite.joinRule, "invite"); + assert.equal(invite.timestamp, 1001); + assert.equal(invite.isEncrypted, false); + assert.equal(invite.isDirectMessage, false); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, + "invite for encrypted DM has correct fields": async assert => { + const invite = new Invite({ + roomId, + clock: new MockClock(1003), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + assert.equal(invite.name, "Alice"); + assert.equal(invite.avatarUrl, aliceAvatarUrl); + assert.equal(invite.timestamp, 1003); + assert.equal(invite.isEncrypted, true); + assert.equal(invite.isDirectMessage, true); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, + } +} diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index 759b275a..d385c0a3 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -85,7 +85,7 @@ function processRoomAccountData(data, event) { return data; } -function processStateEvent(data, event) { +export function processStateEvent(data, event) { if (event.type === "m.room.encryption") { const algorithm = event.content?.algorithm; if (!data.encryption && algorithm === MEGOLM_ALGORITHM) { @@ -148,7 +148,7 @@ function updateSummary(data, summary) { return data; } -class SummaryData { +export class SummaryData { constructor(copy, roomId) { this.roomId = copy ? copy.roomId : roomId; this.name = copy ? copy.name : null; From 39c772300a087e466b10d4db813c8ffd60fc2308 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 16:21:35 +0200 Subject: [PATCH 089/166] fix indenting --- src/matrix/storage/idb/stores/InviteStore.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/matrix/storage/idb/stores/InviteStore.js b/src/matrix/storage/idb/stores/InviteStore.js index a3c7517a..41a2351a 100644 --- a/src/matrix/storage/idb/stores/InviteStore.js +++ b/src/matrix/storage/idb/stores/InviteStore.js @@ -15,17 +15,17 @@ limitations under the License. */ export class InviteStore { - constructor(inviteStore) { - this._inviteStore = inviteStore; - } + constructor(inviteStore) { + this._inviteStore = inviteStore; + } - getAll() { - return this._inviteStore.selectAll(); - } + getAll() { + return this._inviteStore.selectAll(); + } - set(invite) { - return this._inviteStore.put(invite); - } + set(invite) { + return this._inviteStore.put(invite); + } remove(roomId) { this._store.delete(roomId); From de125441d151d45f83c81a65c39ffa2a3791f24d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:34:45 +0200 Subject: [PATCH 090/166] add write/load test for Invite --- src/matrix/room/Invite.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 6215498a..967241d2 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -248,5 +248,25 @@ export function tests() { assert.equal(invite.inviter.displayName, "Alice"); assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); }, + "load persisted invite has correct fields": async assert => { + const writeInvite = new Invite({ + roomId, + clock: new MockClock(1003), + user: {id: "@bob:hs.tld"} + }); + const txn = createStorage(); + await writeInvite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); + const invite = new Invite({roomId}); + invite.load(txn.invitesMap.get(roomId)); + assert.equal(invite.name, "Alice"); + assert.equal(invite.avatarUrl, aliceAvatarUrl); + assert.equal(invite.timestamp, 1003); + assert.equal(invite.isEncrypted, true); + assert.equal(invite.isDirectMessage, true); + assert(invite.inviter); + assert.equal(invite.inviter.userId, "@alice:hs.tld"); + assert.equal(invite.inviter.displayName, "Alice"); + assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); + }, } } From a072426e07759db6652b5f62b90ca6b6f2dcfa32 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:39:46 +0200 Subject: [PATCH 091/166] integrate invites into the sync lifecycle and expose them on session --- src/matrix/Session.js | 37 +++++++++++++++--- src/matrix/Sync.js | 68 ++++++++++++++++++++++++++++++---- src/matrix/room/Invite.js | 2 +- src/matrix/room/Room.js | 9 +++-- src/matrix/room/RoomSummary.js | 18 +++++++++ 5 files changed, 117 insertions(+), 17 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c510bfb8..b18898bf 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -1,5 +1,6 @@ /* Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -53,6 +54,8 @@ export class Session { this._sessionInfo = sessionInfo; this._rooms = new ObservableMap(); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); + this._invites = new ObservableMap(); + this._inviteRemoveCallback = invite => this._invites.remove(invite.id); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._olm = olm; @@ -281,9 +284,10 @@ export class Session { const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); // load rooms const rooms = await txn.roomSummary.getAll(); - await Promise.all(rooms.map(summary => { + await Promise.all(rooms.map(async summary => { const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); - return log.wrap("room", log => room.load(summary, txn, log)); + await log.wrap("room", log => room.load(summary, txn, log)); + this._rooms.add(room.id, room); })); } @@ -361,7 +365,7 @@ export class Session { /** @internal */ createRoom(roomId, pendingEvents) { - const room = new Room({ + return new Room({ roomId, getSyncToken: this._getSyncToken, storage: this._storage, @@ -373,8 +377,31 @@ export class Session { createRoomEncryption: this._createRoomEncryption, platform: this._platform }); - this._rooms.add(roomId, room); - return room; + } + + /** @internal */ + addRoomAfterSync(room) { + this._rooms.add(room.id, room); + } + + get invites() { + return this._invites; + } + + /** @internal */ + createInvite(roomId) { + return new Invite({ + roomId, + hsApi: this._hsApi, + emitCollectionRemove: this._inviteRemoveCallback, + user: this._user, + clock: this._platform.clock, + }); + } + + /** @internal */ + addInviteAfterSync(invite) { + this._invites.add(invite.id, invite); } async obtainSyncLock(syncResponse) { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 73ff0207..7e64d2f8 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -1,6 +1,6 @@ /* Copyright 2020 Bruno Windels -Copyright 2020 The Matrix.org Foundation C.I.C. +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. @@ -191,7 +191,8 @@ export class Sync { const isInitialSync = !syncToken; const sessionState = new SessionSyncProcessState(); - const roomStates = this._parseRoomsResponse(response.rooms, isInitialSync); + const inviteStates = this._parseInvites(response.rooms); + const roomStates = this._parseRoomsResponse(response.rooms, inviteStates, isInitialSync); try { // take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing @@ -205,6 +206,10 @@ export class Sync { try { sessionState.changes = await log.wrap("session", log => this._session.writeSync( response, syncFilterId, sessionState.preparation, syncTxn, log)); + await Promise.all(inviteStates.map(async is => { + is.changes = await log.wrap("invite", log => is.invite.writeSync( + is.membership, is.roomResponse, syncTxn, log)); + })); await Promise.all(roomStates.map(async rs => { rs.changes = await log.wrap("room", log => rs.room.writeSync( rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); @@ -228,9 +233,19 @@ export class Sync { log.wrap("after", log => { log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); + // emit invite related events after txn has been closed + for(let is of inviteStates) { + log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); + if (is.isNewInvite) { + this._session.addInviteAfterSync(is.invite); + } + } // emit room related events after txn has been closed for(let rs of roomStates) { log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); + if (rs.isNewRoom) { + this._session.addRoomAfterSync(rs.room); + } } }); @@ -267,7 +282,7 @@ export class Sync { if (!isRoomInResponse) { let room = this._session.rooms.get(roomId); if (room) { - roomStates.push(new RoomSyncProcessState(room, {}, room.membership)); + roomStates.push(new RoomSyncProcessState(room, false, null, {}, room.membership)); } } } @@ -276,7 +291,7 @@ export class Sync { await Promise.all(roomStates.map(async rs => { const newKeys = newKeysByRoom?.get(rs.room.id); rs.preparation = await log.wrap("room", log => rs.room.prepareSync( - rs.roomResponse, rs.membership, newKeys, prepareTxn, log), log.level.Detail); + rs.roomResponse, rs.membership, rs.invite, newKeys, prepareTxn, log), log.level.Detail); })); // This is needed for safari to not throw TransactionInactiveErrors on the syncTxn. See docs/INDEXEDDB.md @@ -288,6 +303,7 @@ export class Sync { return this._storage.readWriteTxn([ storeNames.session, storeNames.roomSummary, + storeNames.invites, storeNames.roomState, storeNames.roomMembers, storeNames.timelineEvents, @@ -307,10 +323,10 @@ export class Sync { ]); } - _parseRoomsResponse(roomsSection, isInitialSync) { + _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { const roomStates = []; if (roomsSection) { - // don't do "invite", "leave" for now + // don't do "leave" for now const allMemberships = ["join"]; for(const membership of allMemberships) { const membershipSection = roomsSection[membership]; @@ -321,11 +337,20 @@ export class Sync { if (isInitialSync && timelineIsEmpty(roomResponse)) { continue; } + let isNewRoom = false; let room = this._session.rooms.get(roomId); if (!room) { room = this._session.createRoom(roomId); + isNewRoom = true; } - roomStates.push(new RoomSyncProcessState(room, roomResponse, membership)); + const invite = this._session.invites.get(roomId); + // if there is an existing invite, add a process state for it + // so its writeSync and afterSync will run and remove the invite + if (invite) { + inviteStates.push(new InviteSyncProcessState(invite, false, membership, null)); + } + roomStates.push(new RoomSyncProcessState( + room, isNewRoom, invite, roomResponse, membership)); } } } @@ -333,6 +358,21 @@ export class Sync { return roomStates; } + _parseInvites(invites, roomsSection) { + const inviteStates = []; + if (roomsSection.invite) { + for (const [roomId, roomResponse] of Object.entries(roomsSection.invite)) { + let invite = this._session.invites.get(roomId); + let isNewInvite = false; + if (!invite) { + invite = this._session.createInvite(roomId); + isNewInvite = true; + } + inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, "invite", roomResponse)); + } + } + return inviteStates; + } stop() { if (this._status.get() === SyncStatus.Stopped) { @@ -360,11 +400,23 @@ class SessionSyncProcessState { } class RoomSyncProcessState { - constructor(room, roomResponse, membership) { + constructor(room, isNewRoom, invite, roomResponse, membership) { this.room = room; + this.isNewRoom = isNewRoom; + this.invite = invite; this.roomResponse = roomResponse; this.membership = membership; this.preparation = null; this.changes = null; } } + +class InviteSyncProcessState { + constructor(invite, isNewInvite, membership, roomResponse) { + this.invite = invite; + this.isNewInvite = isNewInvite; + this.membership = membership; + this.roomResponse = roomResponse; + this.changes = null; + } +} diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 967241d2..58f598a4 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -107,7 +107,7 @@ export class Invite extends EventEmitter { } } - afterSync(changes, room) { + afterSync(changes) { if (changes) { this._inviteData = changes.inviteData; this._inviter = changes.inviter; diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 91cc3ceb..1d4b650e 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -189,12 +189,15 @@ export class Room extends EventEmitter { return retryEntries; } - async prepareSync(roomResponse, membership, newKeys, txn, log) { + async prepareSync(roomResponse, membership, invite, newKeys, txn, log) { log.set("id", this.id); if (newKeys) { log.set("newKeys", newKeys.length); } - const summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership) + let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); + if (invite) { + summaryChanges = summaryChanges.applyInvite(invite); + } let roomEncryption = this._roomEncryption; // encryption is enabled in this sync if (!roomEncryption && summaryChanges.encryption) { @@ -379,7 +382,7 @@ export class Room extends EventEmitter { * Can be used to do longer running operations that resulted from the last sync, * like network operations. */ - async afterSyncCompleted(changes, log) { + async afterSyncCompleted(changes, isNewRoom, log) { log.set("id", this.id); if (this._roomEncryption) { await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, null, log); diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js index d385c0a3..88b2c45b 100644 --- a/src/matrix/room/RoomSummary.js +++ b/src/matrix/room/RoomSummary.js @@ -148,6 +148,18 @@ function updateSummary(data, summary) { return data; } +function applyInvite(data, invite) { + if (data.isDirectMessage !== invite.isDirectMessage) { + data = data.cloneIfNeeded(); + data.isDirectMessage = invite.isDirectMessage; + } + if (data.dmUserId !== invite.inviter?.userId) { + data = data.cloneIfNeeded(); + data.dmUserId = invite.inviter?.userId; + } + return data; +} + export class SummaryData { constructor(copy, roomId) { this.roomId = copy ? copy.roomId : roomId; @@ -166,6 +178,8 @@ export class SummaryData { this.notificationCount = copy ? copy.notificationCount : 0; this.highlightCount = copy ? copy.highlightCount : 0; this.tags = copy ? copy.tags : null; + this.isDirectMessage = copy ? copy.isDirectMessage : false; + this.dmUserId = copy ? copy.dmUserId : null; this.cloned = copy ? true : false; } @@ -202,6 +216,10 @@ export class SummaryData { return applySyncResponse(this, roomResponse, membership); } + applyInvite(invite) { + return applyInvite(this, invite); + } + get needsHeroes() { return !this.name && !this.canonicalAlias && this.heroes && this.heroes.length > 0; } From d1dc4c9e9c36f48cc583bcef66e6e940816ed518 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:56:53 +0200 Subject: [PATCH 092/166] remove extra layer of log items --- src/matrix/room/Invite.js | 49 ++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 26 deletions(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 58f598a4..a9846ccc 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -77,33 +77,30 @@ export class Invite extends EventEmitter { async writeSync(membership, roomResponse, txn, log) { if (membership === "invite") { - return log.wrap("new invite", async log => { - log.set("id", this.id); - const inviteState = roomResponse["invite_state"]?.events; - if (!Array.isArray(inviteState)) { - return null; - } - const summaryData = this._createSummaryData(inviteState); - let heroes; - if (!summaryData.name && !summaryData.canonicalAlias) { - heroes = await this._createHeroes(inviteState); - } - const myInvite = this._getMyInvite(inviteState); - if (!myInvite) { - return null; - } - const inviter = this._getInviter(myInvite, inviteState); - const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes); - txn.invites.set(inviteData); - return {inviteData, inviter}; - }); - } else { - return log.wrap("remove invite", log => { - log.set("id", this.id); - log.set("membership", membership); - txn.invites.remove(this.id); + log.set("id", this.id); + log.set("add", true); + const inviteState = roomResponse["invite_state"]?.events; + if (!Array.isArray(inviteState)) { return null; - }); + } + const summaryData = this._createSummaryData(inviteState); + let heroes; + if (!summaryData.name && !summaryData.canonicalAlias) { + heroes = await this._createHeroes(inviteState); + } + const myInvite = this._getMyInvite(inviteState); + if (!myInvite) { + return null; + } + const inviter = this._getInviter(myInvite, inviteState); + const inviteData = this._createData(inviteState, myInvite, inviter, summaryData, heroes); + txn.invites.set(inviteData); + return {inviteData, inviter}; + } else { + log.set("id", this.id); + log.set("membership", membership); + txn.invites.remove(this.id); + return null; } } From 09ac503e220c55f74127182030bc947abec251ee Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:57:03 +0200 Subject: [PATCH 093/166] add test for removing invite --- src/matrix/room/Invite.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index a9846ccc..f71344cd 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -194,6 +194,9 @@ export function tests() { invites: { set(invite) { invitesMap.set(invite.roomId, invite); + }, + remove(roomId) { + invitesMap.delete(roomId); } } } @@ -265,5 +268,26 @@ export function tests() { assert.equal(invite.inviter.displayName, "Alice"); assert.equal(invite.inviter.avatarUrl, aliceAvatarUrl); }, + "syncing with membership from invite removes the invite": async assert => { + let removedEmitted = false; + const invite = new Invite({ + roomId, + clock: new MockClock(1003), + user: {id: "@bob:hs.tld"}, + emitCollectionRemove: emittingInvite => { + assert.equal(emittingInvite, invite); + removedEmitted = true; + } + }); + const txn = createStorage(); + const changes = await invite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); + assert.equal(txn.invitesMap.get(roomId).roomId, roomId); + invite.afterSync(changes); + const joinChanges = await invite.writeSync("join", null, txn, new NullLogItem()); + assert(!removedEmitted); + invite.afterSync(joinChanges); + assert.equal(txn.invitesMap.get(roomId), undefined); + assert(removedEmitted); + } } } From 4560e0e4916c136a3f314feeb7b3f1efe5f85b3c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 17:57:17 +0200 Subject: [PATCH 094/166] split sync lifecycle steps out in different methods to keep it smaller --- src/matrix/Sync.js | 97 ++++++++++++++++++++++++---------------------- 1 file changed, 51 insertions(+), 46 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 7e64d2f8..8b81d18e 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -197,57 +197,17 @@ export class Sync { try { // take a lock on olm sessions used in this sync so sending a message doesn't change them while syncing sessionState.lock = await log.wrap("obtainSyncLock", () => this._session.obtainSyncLock(response)); - await log.wrap("prepare", log => this._prepareSessionAndRooms(sessionState, roomStates, response, log)); + await log.wrap("prepare", log => this._prepareSync(sessionState, roomStates, response, log)); await log.wrap("afterPrepareSync", log => Promise.all(roomStates.map(rs => { return rs.room.afterPrepareSync(rs.preparation, log); }))); - await log.wrap("write", async log => { - const syncTxn = await this._openSyncTxn(); - try { - sessionState.changes = await log.wrap("session", log => this._session.writeSync( - response, syncFilterId, sessionState.preparation, syncTxn, log)); - await Promise.all(inviteStates.map(async is => { - is.changes = await log.wrap("invite", log => is.invite.writeSync( - is.membership, is.roomResponse, syncTxn, log)); - })); - await Promise.all(roomStates.map(async rs => { - rs.changes = await log.wrap("room", log => rs.room.writeSync( - rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); - })); - } catch(err) { - // avoid corrupting state by only - // storing the sync up till the point - // the exception occurred - try { - syncTxn.abort(); - } catch (abortErr) { - log.set("couldNotAbortTxn", true); - } - throw err; - } - await syncTxn.complete(); - }); + await log.wrap("write", async log => this._writeSync( + sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log)); } finally { sessionState.dispose(); } - - log.wrap("after", log => { - log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); - // emit invite related events after txn has been closed - for(let is of inviteStates) { - log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); - if (is.isNewInvite) { - this._session.addInviteAfterSync(is.invite); - } - } - // emit room related events after txn has been closed - for(let rs of roomStates) { - log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); - if (rs.isNewRoom) { - this._session.addRoomAfterSync(rs.room); - } - } - }); + // sync txn comitted, emit updates and apply changes to in-memory state + log.wrap("after", log => this._afterSync(sessionState, inviteStates, roomStates, log)); const toDeviceEvents = response.to_device?.events; return { @@ -267,7 +227,7 @@ export class Sync { ]); } - async _prepareSessionAndRooms(sessionState, roomStates, response, log) { + async _prepareSync(sessionState, roomStates, response, log) { const prepareTxn = await this._openPrepareSyncTxn(); sessionState.preparation = await log.wrap("session", log => this._session.prepareSync( response, sessionState.lock, prepareTxn, log)); @@ -298,6 +258,51 @@ export class Sync { await prepareTxn.complete(); } + async _writeSync(sessionState, inviteStates, roomStates, response, syncFilterId, isInitialSync, log) { + const syncTxn = await this._openSyncTxn(); + try { + sessionState.changes = await log.wrap("session", log => this._session.writeSync( + response, syncFilterId, sessionState.preparation, syncTxn, log)); + await Promise.all(inviteStates.map(async is => { + is.changes = await log.wrap("invite", log => is.invite.writeSync( + is.membership, is.roomResponse, syncTxn, log)); + })); + await Promise.all(roomStates.map(async rs => { + rs.changes = await log.wrap("room", log => rs.room.writeSync( + rs.roomResponse, isInitialSync, rs.preparation, syncTxn, log)); + })); + } catch(err) { + // avoid corrupting state by only + // storing the sync up till the point + // the exception occurred + try { + syncTxn.abort(); + } catch (abortErr) { + log.set("couldNotAbortTxn", true); + } + throw err; + } + await syncTxn.complete(); + } + + _afterSync(sessionState, inviteStates, roomStates, log) { + log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); + // emit invite related events after txn has been closed + for(let is of inviteStates) { + log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); + if (is.isNewInvite) { + this._session.addInviteAfterSync(is.invite); + } + } + // emit room related events after txn has been closed + for(let rs of roomStates) { + log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); + if (rs.isNewRoom) { + this._session.addRoomAfterSync(rs.room); + } + } + } + _openSyncTxn() { const storeNames = this._storage.storeNames; return this._storage.readWriteTxn([ From 5876e5200b519d2fac8868e4dcd1a7bb915a8a8a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 18:03:27 +0200 Subject: [PATCH 095/166] don't need this for now --- src/matrix/room/Room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 1d4b650e..02b19a18 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -382,7 +382,7 @@ export class Room extends EventEmitter { * Can be used to do longer running operations that resulted from the last sync, * like network operations. */ - async afterSyncCompleted(changes, isNewRoom, log) { + async afterSyncCompleted(changes, log) { log.set("id", this.id); if (this._roomEncryption) { await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, null, log); From ee98eaa640ba14e869560008ea601c1bf1817947 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 18:09:48 +0200 Subject: [PATCH 096/166] restore invites when loading the session --- src/matrix/Session.js | 13 +++++++++++++ src/matrix/room/Invite.js | 5 +++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index b18898bf..8eb0efbf 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -258,6 +258,7 @@ export class Session { const txn = await this._storage.readTxn([ this._storage.storeNames.session, this._storage.storeNames.roomSummary, + this._storage.storeNames.invites, this._storage.storeNames.roomMembers, this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineFragments, @@ -289,6 +290,13 @@ export class Session { await log.wrap("room", log => room.load(summary, txn, log)); this._rooms.add(room.id, room); })); + // load invites + const invites = await txn.invites.getAll(); + await Promise.all(invites.map(async inviteData => { + const invite = this.createInvite(inviteData.roomId); + log.wrap("invite", log => invite.load(inviteData, log)); + this._invites.add(invite.id, invite); + })); } dispose() { @@ -583,6 +591,11 @@ export function tests() { getAll() { return Promise.resolve([]); } + }, + invites: { + getAll() { + return Promise.resolve([]); + } } }; }, diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index f71344cd..56304cfd 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -70,7 +70,8 @@ export class Invite extends EventEmitter { } - load(inviteData) { + load(inviteData, log) { + log.set("id", this.id); this._inviteData = inviteData; this._inviter = inviteData.inviter ? new RoomMember(inviteData.inviter) : null; } @@ -257,7 +258,7 @@ export function tests() { const txn = createStorage(); await writeInvite.writeSync("invite", dmInviteFixture, txn, new NullLogItem()); const invite = new Invite({roomId}); - invite.load(txn.invitesMap.get(roomId)); + invite.load(txn.invitesMap.get(roomId), new NullLogItem()); assert.equal(invite.name, "Alice"); assert.equal(invite.avatarUrl, aliceAvatarUrl); assert.equal(invite.timestamp, 1003); From bb1c64e1acfea1fed8ca9b92bbe874b18d4e0520 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 19:01:40 +0200 Subject: [PATCH 097/166] add left panel tile view model for invites and track in-progress state in Invite so it is shared by left panel and open invite --- .../session/leftpanel/BaseTileViewModel.js | 89 +++++++++++++++++++ .../session/leftpanel/InviteTileViewModel.js | 55 ++++++++++++ .../session/leftpanel/RoomTileViewModel.js | 69 +++----------- src/matrix/Session.js | 2 + src/matrix/room/Invite.js | 56 ++++++++++-- 5 files changed, 205 insertions(+), 66 deletions(-) create mode 100644 src/domain/session/leftpanel/BaseTileViewModel.js create mode 100644 src/domain/session/leftpanel/InviteTileViewModel.js diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js new file mode 100644 index 00000000..7b2a3258 --- /dev/null +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -0,0 +1,89 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; + +const KIND_ORDER = ["invite", "room"]; + +export class BaseTileViewModel extends ViewModel { + constructor(options) { + super(options); + this._isOpen = false; + this._hidden = false; + if (options.isOpen) { + this.open(); + } + } + + get hidden() { + return this._hidden; + } + + set hidden(value) { + if (value !== this._hidden) { + this._hidden = value; + this.emitChange("hidden"); + } + } + + close() { + if (this._isOpen) { + this._isOpen = false; + this.emitChange("isOpen"); + } + } + + open() { + if (!this._isOpen) { + this._isOpen = true; + this.emitChange("isOpen"); + } + } + + get isOpen() { + return this._isOpen; + } + + compare(other) { + if (other.kind !== this.kind) { + return KIND_ORDER.indexOf(this.kind) - KIND_ORDER.indexOf(other.kind); + } + return 0; + } + + // Avatar view model contract + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._avatarSource) + } + + get avatarUrl() { + if (this._avatarSource.avatarUrl) { + const size = 32 * this.platform.devicePixelRatio; + return this._room.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop"); + } + return null; + } + + get avatarTitle() { + return this.name; + } +} diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js new file mode 100644 index 00000000..5c3082c0 --- /dev/null +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -0,0 +1,55 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseTileViewModel} from "./BaseTileViewModel.js"; + +export class InviteTileViewModel extends BaseTileViewModel { + constructor(options) { + super(options); + const {invite} = options; + this._invite = invite; + this._url = this.urlCreator.openRoomActionUrl(this._invite.id); + } + + get busy() { + return this._invite.accepting || this._invite.rejecting; + } + + get kind() { + return "invite"; + } + + get url() { + return this._url; + } + + compare(other) { + const parentComparison = super.compare(other); + if (parentComparison !== 0) { + return parentComparison; + } + return this._invite.timestamp - other._invite.timestamp; + } + + get name() { + return this._invite.name; + } + + get _avatarSource() { + return this._invite; + } +} diff --git a/src/domain/session/leftpanel/RoomTileViewModel.js b/src/domain/session/leftpanel/RoomTileViewModel.js index 1858fce7..50164b18 100644 --- a/src/domain/session/leftpanel/RoomTileViewModel.js +++ b/src/domain/session/leftpanel/RoomTileViewModel.js @@ -15,51 +15,23 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; -import {ViewModel} from "../../ViewModel.js"; +import {BaseTileViewModel} from "./BaseTileViewModel.js"; function isSortedAsUnread(vm) { return vm.isUnread || (vm.isOpen && vm._wasUnreadWhenOpening); } -export class RoomTileViewModel extends ViewModel { +export class RoomTileViewModel extends BaseTileViewModel { constructor(options) { super(options); const {room} = options; this._room = room; - this._isOpen = false; this._wasUnreadWhenOpening = false; - this._hidden = false; this._url = this.urlCreator.openRoomActionUrl(this._room.id); - if (options.isOpen) { - this.open(); - } } - get hidden() { - return this._hidden; - } - - set hidden(value) { - if (value !== this._hidden) { - this._hidden = value; - this.emitChange("hidden"); - } - } - - close() { - if (this._isOpen) { - this._isOpen = false; - this.emitChange("isOpen"); - } - } - - open() { - if (!this._isOpen) { - this._isOpen = true; - this._wasUnreadWhenOpening = this._room.isUnread; - this.emitChange("isOpen"); - } + get kind() { + return "room"; } get url() { @@ -67,6 +39,10 @@ export class RoomTileViewModel extends ViewModel { } compare(other) { + const parentComparison = super.compare(other); + if (parentComparison !== 0) { + return parentComparison; + } /* put unread rooms first then put rooms with a timestamp first, and sort by name @@ -110,10 +86,6 @@ export class RoomTileViewModel extends ViewModel { return timeDiff; } - get isOpen() { - return this._isOpen; - } - get isUnread() { return this._room.isUnread; } @@ -122,27 +94,6 @@ export class RoomTileViewModel extends ViewModel { return this._room.name || this.i18n`Empty Room`; } - // Avatar view model contract - get avatarLetter() { - return avatarInitials(this.name); - } - - get avatarColorNumber() { - return getIdentifierColorNumber(this._room.id) - } - - get avatarUrl() { - if (this._room.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; - return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop"); - } - return null; - } - - get avatarTitle() { - return this.name; - } - get badgeCount() { return this._room.notificationCount; } @@ -150,4 +101,8 @@ export class RoomTileViewModel extends ViewModel { get isHighlighted() { return this._room.highlightCount !== 0; } + + get _avatarSource() { + return this._room; + } } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 8eb0efbf..d762eac2 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -56,6 +56,7 @@ export class Session { this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); this._invites = new ObservableMap(); this._inviteRemoveCallback = invite => this._invites.remove(invite.id); + this._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._olm = olm; @@ -402,6 +403,7 @@ export class Session { roomId, hsApi: this._hsApi, emitCollectionRemove: this._inviteRemoveCallback, + emitCollectionUpdate: this._inviteUpdateCallback, user: this._user, clock: this._platform.clock, }); diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 56304cfd..3a59594e 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -20,14 +20,19 @@ import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; export class Invite extends EventEmitter { - constructor({roomId, user, hsApi, emitCollectionRemove, clock}) { + constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, clock}) { super(); this._roomId = roomId; this._user = user; this._hsApi = hsApi; this._emitCollectionRemove = emitCollectionRemove; + this._emitCollectionUpdate = emitCollectionUpdate; this._clock = clock; this._inviteData = null; + this._accepting = false; + this._rejecting = false; + this._accepted = false; + this._rejected = false; } get id() { @@ -63,11 +68,34 @@ export class Invite extends EventEmitter { } async accept() { - + this._accepting = true; + this._emitChange("accepting"); } async reject() { + this._rejecting = true; + this._emitChange("rejecting"); + } + get accepting() { + return this._accepting; + } + + get accepted() { + return this._accepted; + } + + get rejecting() { + return this._rejecting; + } + + get rejected() { + return this._rejected; + } + + _emitChange(params) { + this.emit("change"); + this._emitCollectionUpdate(params); } load(inviteData, log) { @@ -101,18 +129,28 @@ export class Invite extends EventEmitter { log.set("id", this.id); log.set("membership", membership); txn.invites.remove(this.id); - return null; + return {removed: true, membership}; } } afterSync(changes) { if (changes) { - this._inviteData = changes.inviteData; - this._inviter = changes.inviter; - // emit update/add - } else { - this._emitCollectionRemove(this); - this.emit("change"); + if (changes.removed) { + this._accepting = false; + this._rejecting = false; + if (changes.membership === "join") { + this._accepted = true; + } else { + this._rejected = true; + } + this._emitCollectionRemove(this); + this.emit("change"); + } else { + this._inviteData = changes.inviteData; + this._inviter = changes.inviter; + // sync will add the invite to the collection by + // calling session.addInviteAfterSync + } } } From 5ce138539b272dd275b793747dc7b1481d854873 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 20 Apr 2021 19:02:45 +0200 Subject: [PATCH 098/166] network calls to accept and reject invite --- src/matrix/net/HomeServerApi.js | 8 ++++++++ src/matrix/room/Invite.js | 2 ++ 2 files changed, 10 insertions(+) diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 051bb44a..77e02d76 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -185,6 +185,14 @@ export class HomeServerApi { getPushers(options = null) { return this._get("/pushers", null, null, options); } + + join(roomId, options = null) { + return this._post(`/rooms/${encodeURIComponent(roomId)}/join`, null, null, options); + } + + leave(roomId, options = null) { + return this._post(`/rooms/${encodeURIComponent(roomId)}/leave`, null, null, options); + } } import {Request as MockRequest} from "../../mocks/Request.js"; diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 3a59594e..3508d111 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -70,11 +70,13 @@ export class Invite extends EventEmitter { async accept() { this._accepting = true; this._emitChange("accepting"); + await this._hsApi.join(this._roomId).response(); } async reject() { this._rejecting = true; this._emitChange("rejecting"); + await this._hsApi.leave(this._roomId).response(); } get accepting() { From 465e0c191f6bc553405ab81543c054025096c0cc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:31:55 +0200 Subject: [PATCH 099/166] add logging to accepting or rejecting an invite --- src/matrix/Session.js | 2 +- src/matrix/room/Invite.js | 34 +++++++++++++++++++--------------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index d762eac2..c19fc006 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -405,7 +405,7 @@ export class Session { emitCollectionRemove: this._inviteRemoveCallback, emitCollectionUpdate: this._inviteUpdateCallback, user: this._user, - clock: this._platform.clock, + platform: this._platform, }); } diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 3508d111..6aeb1d04 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -20,14 +20,14 @@ import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; export class Invite extends EventEmitter { - constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, clock}) { + constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, platform}) { super(); this._roomId = roomId; this._user = user; this._hsApi = hsApi; this._emitCollectionRemove = emitCollectionRemove; this._emitCollectionUpdate = emitCollectionUpdate; - this._clock = clock; + this._platform = platform; this._inviteData = null; this._accepting = false; this._rejecting = false; @@ -67,16 +67,20 @@ export class Invite extends EventEmitter { return this._inviteData.joinRule; } - async accept() { - this._accepting = true; - this._emitChange("accepting"); - await this._hsApi.join(this._roomId).response(); + async accept(log = null) { + await this._platform.logger.wrapOrRun(log, "acceptInvite", async log => { + this._accepting = true; + this._emitChange("accepting"); + await this._hsApi.join(this._roomId, {log}).response(); + }); } - async reject() { - this._rejecting = true; - this._emitChange("rejecting"); - await this._hsApi.leave(this._roomId).response(); + async reject(log = null) { + await this._platform.logger.wrapOrRun(log, "rejectInvite", async log => { + this._rejecting = true; + this._emitChange("rejecting"); + await this._hsApi.leave(this._roomId, {log}).response(); + }); } get accepting() { @@ -167,7 +171,7 @@ export class Invite extends EventEmitter { name, avatarUrl, canonicalAlias: summaryData.canonicalAlias, - timestamp: this._clock.now(), + timestamp: this._platform.clock.now(), joinRule: this._getJoinRule(inviteState), inviter: inviter?.serialize(), }; @@ -251,7 +255,7 @@ export function tests() { "invite for room has correct fields": async assert => { const invite = new Invite({ roomId, - clock: new MockClock(1001), + platform: {clock: new MockClock(1001)}, user: {id: "@bob:hs.tld"} }); const txn = createStorage(); @@ -272,7 +276,7 @@ export function tests() { "invite for encrypted DM has correct fields": async assert => { const invite = new Invite({ roomId, - clock: new MockClock(1003), + platform: {clock: new MockClock(1003)}, user: {id: "@bob:hs.tld"} }); const txn = createStorage(); @@ -292,7 +296,7 @@ export function tests() { "load persisted invite has correct fields": async assert => { const writeInvite = new Invite({ roomId, - clock: new MockClock(1003), + platform: {clock: new MockClock(1003)}, user: {id: "@bob:hs.tld"} }); const txn = createStorage(); @@ -313,7 +317,7 @@ export function tests() { let removedEmitted = false; const invite = new Invite({ roomId, - clock: new MockClock(1003), + platform: {clock: new MockClock(1003)}, user: {id: "@bob:hs.tld"}, emitCollectionRemove: emittingInvite => { assert.equal(emittingInvite, invite); From a5e629459338bdf90d814b62831e6ca1cb9a64e9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:32:13 +0200 Subject: [PATCH 100/166] test accepted/rejected fields in invite tests --- src/matrix/room/Invite.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 6aeb1d04..1335b46e 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -332,6 +332,8 @@ export function tests() { assert(!removedEmitted); invite.afterSync(joinChanges); assert.equal(txn.invitesMap.get(roomId), undefined); + assert.equal(invite.rejected, false); + assert.equal(invite.accepted, true); assert(removedEmitted); } } From ad5d7fc9f031c4613a036fcc96787b8fec23088c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:32:37 +0200 Subject: [PATCH 101/166] add note collection emit should happen first in Invite --- src/matrix/room/Invite.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 1335b46e..f9547828 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -149,6 +149,9 @@ export class Invite extends EventEmitter { } else { this._rejected = true; } + // important to remove before emitting change + // so code checking session.invites.get(id) won't + // find the invite anymore on update this._emitCollectionRemove(this); this.emit("change"); } else { From 9c19fa5c63050a23a3e494fbab79966f677837d1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:33:08 +0200 Subject: [PATCH 102/166] utility to remove room from path (with our without grid) which will be used when rejecting an invite --- src/domain/navigation/Navigation.js | 16 +++++ src/domain/navigation/index.js | 99 ++++++++++++++++++++++++++++- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index fa1c7142..9d059ec9 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -147,6 +147,22 @@ class Path { return this._segments.find(s => s.type === type); } + replace(segment) { + const index = this._segments.findIndex(s => s.type === segment.type); + if (index !== -1) { + const parent = this._segments[index - 1]; + if (this._allowsChild(parent, segment)) { + const child = this._segments[index + 1]; + if (!child || this._allowsChild(segment, child)) { + const newSegments = this._segments.slice(); + newSegments[index] = segment; + return new Path(newSegments, this._allowsChild); + } + } + } + return null; + } + get segments() { return this._segments; } diff --git a/src/domain/navigation/index.js b/src/domain/navigation/index.js index 44f81026..5de73aef 100644 --- a/src/domain/navigation/index.js +++ b/src/domain/navigation/index.js @@ -43,6 +43,30 @@ function allowsChild(parent, child) { } } +export function removeRoomFromPath(path, roomId) { + const rooms = path.get("rooms"); + let roomIdGridIndex = -1; + // first delete from rooms segment + if (rooms) { + roomIdGridIndex = rooms.value.indexOf(roomId); + if (roomIdGridIndex !== -1) { + const idsWithoutRoom = rooms.value.slice(); + idsWithoutRoom[roomIdGridIndex] = ""; + path = path.replace(new Segment("rooms", idsWithoutRoom)); + } + } + const room = path.get("room"); + // then from room (which occurs with or without rooms) + if (room && room.value === roomId) { + if (roomIdGridIndex !== -1) { + path = path.with(new Segment("empty-grid-tile", roomIdGridIndex)); + } else { + path = path.until("session"); + } + } + return path; +} + function roomsSegmentWithRoom(rooms, roomId, path) { if(!rooms.value.includes(roomId)) { const emptyGridTile = path.get("empty-grid-tile"); @@ -243,6 +267,79 @@ export function tests() { assert.equal(segments.length, 1); assert.equal(segments[0].type, "session"); assert.strictEqual(segments[0].value, true); - } + }, + "remove active room from grid path turns it into empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "b"); + assert.equal(newPath.segments.length, 3); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "rooms"); + assert.deepEqual(newPath.segments[1].value, ["a", "", "c"]); + assert.equal(newPath.segments[2].type, "empty-grid-tile"); + assert.equal(newPath.segments[2].value, 1); + }, + "remove inactive room from grid path": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", "c"]), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "a"); + assert.equal(newPath.segments.length, 3); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "rooms"); + assert.deepEqual(newPath.segments[1].value, ["", "b", "c"]); + assert.equal(newPath.segments[2].type, "room"); + assert.equal(newPath.segments[2].value, "b"); + }, + "remove inactive room from grid path with empty tile": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("rooms", ["a", "b", ""]), + new Segment("empty-grid-tile", 3) + ]); + const newPath = removeRoomFromPath(path, "b"); + assert.equal(newPath.segments.length, 3); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "rooms"); + assert.deepEqual(newPath.segments[1].value, ["a", "", ""]); + assert.equal(newPath.segments[2].type, "empty-grid-tile"); + assert.equal(newPath.segments[2].value, 3); + }, + "remove active room": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "b"); + assert.equal(newPath.segments.length, 1); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + }, + "remove inactive room doesn't do anything": assert => { + const nav = new Navigation(allowsChild); + const path = nav.pathFrom([ + new Segment("session", 1), + new Segment("room", "b") + ]); + const newPath = removeRoomFromPath(path, "a"); + assert.equal(newPath.segments.length, 2); + assert.equal(newPath.segments[0].type, "session"); + assert.equal(newPath.segments[0].value, 1); + assert.equal(newPath.segments[1].type, "room"); + assert.equal(newPath.segments[1].value, "b"); + }, + } } From a91a584201c104980bf1fa07faffac83b3117c9b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:33:45 +0200 Subject: [PATCH 103/166] not used anymore --- src/domain/session/room/RoomViewModel.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 43eeb75c..feff2561 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -86,12 +86,6 @@ export class RoomViewModel extends ViewModel { } } - // called from view to close room - // parent vm will dispose this vm - close() { - this._closeCallback(); - } - // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { From 16f275ca34821ae46c7147c41dad28138080cad8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:34:35 +0200 Subject: [PATCH 104/166] make pass-through props smaller --- src/domain/session/room/RoomViewModel.js | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index feff2561..a99718be 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -34,10 +34,6 @@ export class RoomViewModel extends ViewModel { this._closeUrl = this.urlCreator.urlUntilSegment("session"); } - get closeUrl() { - return this._closeUrl; - } - async load() { this._room.on("change", this._onRoomChange); try { @@ -92,21 +88,11 @@ export class RoomViewModel extends ViewModel { this.emitChange("name"); } - get name() { - return this._room.name || this.i18n`Empty Room`; - } - - get id() { - return this._room.id; - } - - get timelineViewModel() { - return this._timelineVM; - } - - get isEncrypted() { - return this._room.isEncrypted; - } + get closeUrl() { return this._closeUrl; } + get name() { return this._room.name || this.i18n`Empty Room`; } + get id() { return this._room.id; } + get timelineViewModel() { return this._timelineVM; } + get isEncrypted() { return this._room.isEncrypted; } get error() { if (this._timelineError) { From 0cc95f5083308c9873db96e240ee047247f288e3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:45:51 +0200 Subject: [PATCH 105/166] first draft of InviteViewModel --- src/domain/session/room/InviteViewModel.js | 144 +++++++++++++++++++++ src/domain/session/room/README.md | 9 ++ src/domain/session/room/RoomViewModel.js | 1 + src/matrix/Session.js | 4 + 4 files changed, 158 insertions(+) create mode 100644 src/domain/session/room/InviteViewModel.js create mode 100644 src/domain/session/room/README.md diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js new file mode 100644 index 00000000..2b43feb0 --- /dev/null +++ b/src/domain/session/room/InviteViewModel.js @@ -0,0 +1,144 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; + +export class InviteViewModel extends ViewModel { + constructor(options) { + super(options); + const {invite, mediaRepository, closeCallback} = options; + this._invite = invite; + this._mediaRepository = mediaRepository; + this._closeCallback = closeCallback; + this._onInviteChange = this._onInviteChange.bind(this); + this._error = null; + this._closeUrl = this.urlCreator.urlUntilSegment("session"); + this._invite.on("change", this._onInviteChange); + this._inviter = null; + if (this._invite.inviter && ! this._invite.isDirectMessage) { + this._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform); + } + } + + get kind() { return "invite"; } + get closeUrl() { return this._closeUrl; } + get name() { return this._invite.name; } + get id() { return this._invite.id; } + get isEncrypted() { return this._invite.isEncrypted; } + get isDirectMessage() { return this._invite.isDirectMessage; } + get inviter() { return this._inviter; } + get busy() { return this._invite.accepting || this._invite.rejecting; } + + get error() { + if (this._error) { + return `Something went wrong: ${this._error.message}`; + } + return ""; + } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._invite.id) + } + + get avatarUrl() { + if (this._invite.avatarUrl) { + const size = 32 * this.platform.devicePixelRatio; + return this._mediaRepository.mxcUrlThumbnail(this._invite.avatarUrl, size, size, "crop"); + } + return null; + } + + get avatarTitle() { + return this.name; + } + + focus() {} + + async accept() { + try { + await this._invite.accept(); + } catch (err) { + this._error = err; + this.emitChange("error"); + } + } + + async reject() { + try { + await this._invite.reject(); + } catch (err) { + this._error = err; + this.emitChange("error"); + } + } + + _onInviteChange() { + if (this._invite.accepted || this._invite.rejected) { + // close invite if rejected, or open room if accepted. + // Done with a callback rather than manipulating the nav, + // as closing the invite changes the nav path depending whether + // we're in a grid view, and opening the room doesn't change + // the nav path because the url is the same for an + // invite and the room. + this._closeCallback(this._invite.accepted); + } else { + this.emitChange(); + } + } + + dispose() { + super.dispose(); + this._invite.off("change", this._onInviteChange); + } +} + +class RoomMemberViewModel { + constructor(member, mediaRepository, platform) { + this._member = member; + this._mediaRepository = mediaRepository; + this._platform = platform; + } + + get name() { + return this._member.name; + } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._member.userId) + } + + get avatarUrl() { + if (this._member.avatarUrl) { + const size = 32 * this.platform.devicePixelRatio; + return this._mediaRepository.mxcUrlThumbnail(this._member.avatarUrl, size, size, "crop"); + } + return null; + } + + get avatarTitle() { + return this.name; + } +} diff --git a/src/domain/session/room/README.md b/src/domain/session/room/README.md new file mode 100644 index 00000000..28fa7173 --- /dev/null +++ b/src/domain/session/room/README.md @@ -0,0 +1,9 @@ +# "Room" view models + +InviteViewModel and RoomViewModel are interchangebly used as "room view model": + - SessionViewModel.roomViewModel can be an instance of either + - RoomGridViewModel.roomViewModelAt(i) can return an instance of either + +This is because they are accessed by the same url and need to transition into each other, in these two locations. Having two methods, especially in RoomGridViewModel would have been more cumbersome, even though this is not in line with how different view models are exposed in SessionViewModel. + +They share an `id` and `kind` property, the latter can be used to differentiate them from the view. diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index a99718be..6a48f9d9 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -88,6 +88,7 @@ export class RoomViewModel extends ViewModel { this.emitChange("name"); } + get kind() { return "room"; } get closeUrl() { return this._closeUrl; } get name() { return this._room.name || this.i18n`Empty Room`; } get id() { return this._room.id; } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index c19fc006..11bca80b 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -507,6 +507,10 @@ export class Session { return this._user; } + get mediaRepository() { + return this._mediaRepository; + } + enablePushNotifications(enable) { if (enable) { return this._enablePush(); From 7e2870acef9855b2fb72a5a4ddfe69c0317a9e43 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:47:39 +0200 Subject: [PATCH 106/166] wire up InviteViewModel in Session/RoomGridViewModel and: - switch to room once accepted - close invite if rejected --- src/domain/session/RoomGridViewModel.js | 39 +++++++++---- src/domain/session/SessionViewModel.js | 67 +++++++++++++++++----- src/platform/web/ui/session/SessionView.js | 17 +++--- 3 files changed, 89 insertions(+), 34 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index b9b62153..535d454a 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -32,10 +32,11 @@ export class RoomGridViewModel extends ViewModel { this._width = options.width; this._height = options.height; - this._createRoomViewModel = options.createRoomViewModel; + this._createRoomOrInviteViewModel = options.createRoomOrInviteViewModel; this._selectedIndex = 0; this._viewModels = []; + this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); this._setupNavigation(); } @@ -63,6 +64,24 @@ export class RoomGridViewModel extends ViewModel { // initial focus for a room is set by initializeRoomIdsAndTransferVM } + _replaceInviteWithRoom(roomId) { + const index = this._viewModels.findIndex(vm => vm?.id === roomId); + if (index === -1) { + return; + } + this._viewModels[index] = this.disposeTracked(this._viewModels[index]); + // this will create a RoomViewModel because the invite is already + // removed from the collection (see Invite.afterSync) + const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); + if (roomVM) { + this._viewModels[index] = this.track(roomVM); + if (this.focusIndex === index) { + roomVM.focus(); + } + } + this.emitChange(); + } + roomViewModelAt(i) { return this._viewModels[i]; } @@ -128,7 +147,7 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[i] = this.disposeTracked(vm); } if (newId) { - const newVM = this._createRoomViewModel(newId); + const newVM = this._createRoomOrInviteViewModel(newId, this._replaceInviteWithRoom); if (newVM) { this._viewModels[i] = this.track(newVM); } @@ -211,7 +230,7 @@ export function tests() { "initialize with duplicate set of rooms": assert => { const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -228,7 +247,7 @@ export function tests() { "transfer room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: () => assert.fail("no vms should be created"), + createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -242,7 +261,7 @@ export function tests() { "reject transfer for non-matching room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -256,7 +275,7 @@ export function tests() { "created & released room view model is not disposed": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -270,7 +289,7 @@ export function tests() { "transfered & released room view model is not disposed": assert => { const navigation = createNavigationForRoom([undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: () => assert.fail("no vms should be created"), + createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -285,7 +304,7 @@ export function tests() { "try release non-existing room view model is": assert => { const navigation = createNavigationForEmptyTile([undefined, "b"], 3); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -297,7 +316,7 @@ export function tests() { "initial focus is set to empty tile": assert => { const navigation = createNavigationForEmptyTile(["a"], 1); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -309,7 +328,7 @@ export function tests() { "change room ids after creation": assert => { const navigation = createNavigationForRoom(["a", "b"], "a"); const gridVM = new RoomGridViewModel({ - createRoomViewModel: id => new RoomVMMock(id), + createRoomOrInviteViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 2f7e341e..4992c9e2 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -15,8 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {removeRoomFromPath} from "../navigation/index.js"; import {LeftPanelViewModel} from "./leftpanel/LeftPanelViewModel.js"; import {RoomViewModel} from "./room/RoomViewModel.js"; +import {InviteViewModel} from "./room/InviteViewModel.js"; import {LightboxViewModel} from "./room/LightboxViewModel.js"; import {SessionStatusViewModel} from "./SessionStatusViewModel.js"; import {RoomGridViewModel} from "./RoomGridViewModel.js"; @@ -39,6 +41,8 @@ export class SessionViewModel extends ViewModel { this._settingsViewModel = null; this._currentRoomViewModel = null; this._gridViewModel = null; + this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); + this._createRoomOrInviteViewModel = this._createRoomOrInviteViewModel.bind(this); this._setupNavigation(); } @@ -84,15 +88,8 @@ export class SessionViewModel extends ViewModel { this._sessionStatusViewModel.start(); } - get activeSection() { - if (this._currentRoomViewModel) { - return this._currentRoomViewModel.id; - } else if (this._gridViewModel) { - return "roomgrid"; - } else if (this._settingsViewModel) { - return "settings"; - } - return "placeholder"; + get activeMiddleViewModel() { + return this._currentRoomViewModel || this._gridViewModel || this._settingsViewModel; } get roomGridViewModel() { @@ -127,7 +124,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, - createRoomViewModel: roomId => this._createRoomViewModel(roomId), + createRoomOrInviteViewModel: this._createRoomOrInviteViewModel, }))); if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); @@ -138,6 +135,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel.setRoomIds(roomIds); } } else if (this._gridViewModel && !roomIds) { + // closing grid, try to show focused room in grid if (currentRoomId) { const vm = this._gridViewModel.releaseRoomViewModel(currentRoomId.value); if (vm) { @@ -152,7 +150,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.disposeTracked(this._gridViewModel); } if (changed) { - this.emitChange("activeSection"); + this.emitChange("activeMiddleViewModel"); } } @@ -169,11 +167,50 @@ export class SessionViewModel extends ViewModel { return roomVM; } + _createInviteViewModel(roomId, replaceInviteWithRoom) { + const invite = this._sessionContainer.session.invites.get(roomId); + if (!invite) { + return null; + } + return new InviteViewModel(this.childOptions({ + invite, + mediaRepository: this._sessionContainer.session.mediaRepository, + closeCallback: accepted => this._closeInvite(roomId, accepted, replaceInviteWithRoom), + })); + } + + _createRoomOrInviteViewModel(roomId, replaceInviteWithRoom) { + const inviteVM = this._createInviteViewModel(roomId, replaceInviteWithRoom); + if (inviteVM) { + return inviteVM; + } + return this._createRoomViewModel(roomId); + } + + _closeInvite(roomId, accepted, replaceInviteWithRoom) { + if (accepted) { + replaceInviteWithRoom(roomId); + } else { + // close invite + this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); + } + } + + _replaceInviteWithRoom(roomId) { + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + const roomVM = this._createRoomViewModel(roomId); + if (roomVM) { + this._currentRoomViewModel = this.track(roomVM); + } + this.emitChange("activeMiddleViewModel"); + } + _updateRoom(roomId) { if (!roomId) { + // closing invite or room view? if (this._currentRoomViewModel) { this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - this.emitChange("currentRoom"); + this.emitChange("activeMiddleViewModel"); } return; } @@ -182,11 +219,11 @@ export class SessionViewModel extends ViewModel { return; } this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomViewModel(roomId); + const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); } - this.emitChange("currentRoom"); + this.emitChange("activeMiddleViewModel"); } _updateSettings(settingsOpen) { @@ -199,7 +236,7 @@ export class SessionViewModel extends ViewModel { }))); this._settingsViewModel.load(); } - this.emitChange("activeSection"); + this.emitChange("activeMiddleViewModel"); } _updateLightbox(eventId) { diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index fa7a492a..e05d97ad 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -34,16 +34,15 @@ export class SessionView extends TemplateView { }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), t.view(new LeftPanelView(vm.leftPanelViewModel)), - t.mapView(vm => vm.activeSection, activeSection => { - switch (activeSection) { - case "roomgrid": - return new RoomGridView(vm.roomGridViewModel); - case "placeholder": - return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); - case "settings": - return new SettingsView(vm.settingsViewModel); - default: //room id + t.mapView(vm => vm.activeMiddleViewModel, () => { + if (vm.roomGridViewModel) { + return new RoomGridView(vm.roomGridViewModel); + } else if (vm.settingsViewModel) { + return new SettingsView(vm.settingsViewModel); + } else if (vm.currentRoomViewModel) { return new RoomView(vm.currentRoomViewModel); + } else { + return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); } }), t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) From 9961d3e4fc7e4311f5c507756d296f93f1a13101 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:48:27 +0200 Subject: [PATCH 107/166] unused code --- src/domain/session/SessionViewModel.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 4992c9e2..25e13162 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -108,10 +108,6 @@ export class SessionViewModel extends ViewModel { return this._settingsViewModel; } - get roomList() { - return this._roomList; - } - get currentRoomViewModel() { return this._currentRoomViewModel; } From 0dc796b8637be57ee49d6f07917eaa08aa56ce0d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 15:48:41 +0200 Subject: [PATCH 108/166] spacing --- src/platform/web/ui/session/RoomGridView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index f2a73068..685dcb99 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -30,7 +30,7 @@ export class RoomGridView extends TemplateView { [`tile${i}`]: true, "focused": vm => vm.focusIndex === i }, - },t.mapView(vm => vm.roomViewModelAt(i), roomVM => { + }, t.mapView(vm => vm.roomViewModelAt(i), roomVM => { if (roomVM) { return new RoomView(roomVM); } else { From 1d3a2aca0e155afbf5a76270b31b2b29a2c12447 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:23:49 +0200 Subject: [PATCH 109/166] add 'get' method to common observable map api --- src/observable/map/ApplyMap.js | 4 ++++ src/observable/map/BaseObservableMap.js | 5 +++++ src/observable/map/FilteredMap.js | 7 +++++++ 3 files changed, 16 insertions(+) diff --git a/src/observable/map/ApplyMap.js b/src/observable/map/ApplyMap.js index 1a2976ac..feab968c 100644 --- a/src/observable/map/ApplyMap.js +++ b/src/observable/map/ApplyMap.js @@ -82,4 +82,8 @@ export class ApplyMap extends BaseObservableMap { get size() { return this._source.size; } + + get(key) { + return this._source.get(key); + } } diff --git a/src/observable/map/BaseObservableMap.js b/src/observable/map/BaseObservableMap.js index 61de18dc..79df21f6 100644 --- a/src/observable/map/BaseObservableMap.js +++ b/src/observable/map/BaseObservableMap.js @@ -49,4 +49,9 @@ export class BaseObservableMap extends BaseObservable { get size() { throw new Error("unimplemented"); } + + // eslint-disable-next-line no-unused-vars + get(key) { + throw new Error("unimplemented"); + } } diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index e164aae3..290dcea6 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -128,6 +128,13 @@ export class FilteredMap extends BaseObservableMap { }); return count; } + + get(key) { + const value = this._source.get(key); + if (value && this._filter(value, key)) { + return value; + } + } } class FilterIterator { From f596b34cacee2d2bf1382f43baa2750fb12acdb5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:24:07 +0200 Subject: [PATCH 110/166] add very basic join observable map, joining several maps into one will be used to join invites and rooms into one map --- src/observable/index.js | 5 ++ src/observable/map/JoinedMap.js | 112 ++++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/observable/map/JoinedMap.js diff --git a/src/observable/index.js b/src/observable/index.js index eb6f1579..351c25b8 100644 --- a/src/observable/index.js +++ b/src/observable/index.js @@ -17,6 +17,7 @@ limitations under the License. import {SortedMapList} from "./list/SortedMapList.js"; import {FilteredMap} from "./map/FilteredMap.js"; import {MappedMap} from "./map/MappedMap.js"; +import {JoinedMap} from "./map/JoinedMap.js"; import {BaseObservableMap} from "./map/BaseObservableMap.js"; // re-export "root" (of chain) collections export { ObservableArray } from "./list/ObservableArray.js"; @@ -38,5 +39,9 @@ Object.assign(BaseObservableMap.prototype, { filterValues(filter) { return new FilteredMap(this, filter); + }, + + join(...otherMaps) { + return new JoinedMap([this].concat(otherMaps)); } }); diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js new file mode 100644 index 00000000..b50a524a --- /dev/null +++ b/src/observable/map/JoinedMap.js @@ -0,0 +1,112 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {BaseObservableMap} from "./BaseObservableMap.js"; + +export class JoinedMap extends BaseObservableMap { + constructor(sources) { + super(); + this._sources = sources; + } + + onAdd(key, value) { + this.emitAdd(key, value); + } + + onRemove(key, value) { + this.emitRemove(key, value); + } + + onUpdate(key, value, params) { + this.emitUpdate(key, value, params); + } + + onSubscribeFirst() { + this._subscriptions = this._sources.map(source => source.subscribe(this)); + super.onSubscribeFirst(); + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + for (const s of this._subscriptions) { + s(); + } + } + + onReset() { + this.emitReset(); + } + + [Symbol.iterator]() { + return new JoinedIterator(this._sources); + } + + get size() { + return this._sources.reduce((sum, s) => sum + s.size, 0); + } + + get(key) { + for (const s of this._sources) { + const value = s.get(key); + if (value) { + return value; + } + } + return null; + } +} + +class JoinedIterator { + constructor(sources) { + this._sources = sources; + this._sourceIndex = -1; + this._currentIterator = null; + } + + next() { + let result; + while (!result) { + if (!this._currentIterator) { + this._sourceIndex += 1; + if (this._sources.length <= this._sourceIndex) { + return {done: true}; + } + this._currentIterator = this._sources[this._sourceIndex][Symbol.iterator](); + } + const sourceResult = this._currentIterator.next(); + if (sourceResult.done) { + this._currentIterator = null; + continue; + } else { + result = sourceResult; + } + } + return result; + } +} + +export function tests() { + return { + "joined iterator": assert => { + const it = new JoinedIterator([[1, 2], [3, 4]]); + assert.equal(it.next().value, 1); + assert.equal(it.next().value, 2); + assert.equal(it.next().value, 3); + assert.equal(it.next().value, 4); + assert.equal(it.next().done, true); + } + }; +} From bfb7f58a3dfd9d5f0e34b36421b23c270f6ea663 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:25:59 +0200 Subject: [PATCH 111/166] add very basic invite view --- src/platform/web/ui/session/RoomGridView.js | 7 +++- src/platform/web/ui/session/SessionView.js | 5 +++ .../web/ui/session/room/InviteView.js | 40 +++++++++++++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 src/platform/web/ui/session/room/InviteView.js diff --git a/src/platform/web/ui/session/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index 685dcb99..043137fb 100644 --- a/src/platform/web/ui/session/RoomGridView.js +++ b/src/platform/web/ui/session/RoomGridView.js @@ -15,6 +15,7 @@ limitations under the License. */ import {RoomView} from "./room/RoomView.js"; +import {InviteView} from "./room/InviteView.js"; import {TemplateView} from "../general/TemplateView.js"; import {StaticView} from "../general/StaticView.js"; @@ -32,7 +33,11 @@ export class RoomGridView extends TemplateView { }, }, t.mapView(vm => vm.roomViewModelAt(i), roomVM => { if (roomVM) { - return new RoomView(roomVM); + if (roomVM.kind === "invite") { + return new InviteView(roomVM); + } else { + return new RoomView(roomVM); + } } else { return new StaticView(t => t.div({className: "room-placeholder"}, [ t.h2({className: "focused"}, vm.i18n`Select a room on the left`), diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index e05d97ad..c7abd4db 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -17,6 +17,7 @@ limitations under the License. import {LeftPanelView} from "./leftpanel/LeftPanelView.js"; import {RoomView} from "./room/RoomView.js"; +import {InviteView} from "./room/InviteView.js"; import {LightboxView} from "./room/LightboxView.js"; import {TemplateView} from "../general/TemplateView.js"; import {StaticView} from "../general/StaticView.js"; @@ -40,7 +41,11 @@ export class SessionView extends TemplateView { } else if (vm.settingsViewModel) { return new SettingsView(vm.settingsViewModel); } else if (vm.currentRoomViewModel) { + if (vm.currentRoomViewModel.kind === "invite") { + return new InviteView(vm.currentRoomViewModel); + } else { return new RoomView(vm.currentRoomViewModel); + } } else { return new StaticView(t => t.div({className: "room-placeholder"}, t.h2(vm.i18n`Choose a room on the left side.`))); } diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js new file mode 100644 index 00000000..8fdf6cf7 --- /dev/null +++ b/src/platform/web/ui/session/room/InviteView.js @@ -0,0 +1,40 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView} from "../../general/TemplateView.js"; +// import {TimelineList} from "./TimelineList.js"; +// import {TimelineLoadingView} from "./TimelineLoadingView.js"; +// import {MessageComposer} from "./MessageComposer.js"; +import {renderStaticAvatar} from "../../avatar.js"; + +export class InviteView extends TemplateView { + render(t, vm) { + return t.main({className: "InviteView middle"}, [ + t.div({className: "TimelinePanel"}, [ + t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close invite`}), + renderStaticAvatar(vm, 32), + t.div({className: "room-description"}, [ + t.h2(vm => vm.name), + ]), + ]), + t.div({className: "RoomView_error"}, vm => vm.error), + t.div(`You were invited into this room!`) + ]) + ]); + } +} From 16df40331b6d3e172db2320aaa12cebfc54f2c16 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:26:10 +0200 Subject: [PATCH 112/166] fix refactor mistake in sync --- src/matrix/Sync.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 8b81d18e..9fb57948 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -363,7 +363,7 @@ export class Sync { return roomStates; } - _parseInvites(invites, roomsSection) { + _parseInvites(roomsSection) { const inviteStates = []; if (roomsSection.invite) { for (const [roomId, roomResponse] of Object.entries(roomsSection.invite)) { From 1cf49688d65d91c52def50e253956e285bd220b7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:26:34 +0200 Subject: [PATCH 113/166] show invites at the top of the room list --- src/domain/session/SessionViewModel.js | 1 + .../session/leftpanel/LeftPanelViewModel.js | 64 +++++++++++-------- .../ui/session/leftpanel/InviteTileView.js | 37 +++++++++++ .../web/ui/session/leftpanel/LeftPanelView.js | 11 +++- 4 files changed, 86 insertions(+), 27 deletions(-) create mode 100644 src/platform/web/ui/session/leftpanel/InviteTileView.js diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 25e13162..45b97c3e 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -36,6 +36,7 @@ export class SessionViewModel extends ViewModel { session: sessionContainer.session, }))); this._leftPanelViewModel = this.track(new LeftPanelViewModel(this.childOptions({ + invites: this._sessionContainer.session.invites, rooms: this._sessionContainer.session.rooms }))); this._settingsViewModel = null; diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index de70245a..c9a8b436 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -17,37 +17,51 @@ limitations under the License. import {ViewModel} from "../../ViewModel.js"; import {RoomTileViewModel} from "./RoomTileViewModel.js"; +import {InviteTileViewModel} from "./InviteTileViewModel.js"; import {RoomFilter} from "./RoomFilter.js"; import {ApplyMap} from "../../../observable/map/ApplyMap.js"; export class LeftPanelViewModel extends ViewModel { constructor(options) { super(options); - const {rooms} = options; - this._roomTileViewModels = rooms.mapValues((room, emitChange) => { - const isOpen = this.navigation.path.get("room")?.value === room.id; - const vm = new RoomTileViewModel(this.childOptions({ - isOpen, - room, - emitChange - })); - // need to also update the current vm here as - // we can't call `_open` from the ctor as the map - // is only populated when the view subscribes. - if (isOpen) { - this._currentTileVM?.close(); - this._currentTileVM = vm; - } - return vm; - }); - this._roomListFilterMap = new ApplyMap(this._roomTileViewModels); - this._roomList = this._roomListFilterMap.sortValues((a, b) => a.compare(b)); + const {rooms, invites} = options; + this._tileViewModelsMap = this._mapTileViewModels(rooms, invites); + this._tileViewModelsFilterMap = new ApplyMap(this._tileViewModelsMap); + this._tileViewModels = this._tileViewModelsFilterMap.sortValues((a, b) => a.compare(b)); this._currentTileVM = null; this._setupNavigation(); this._closeUrl = this.urlCreator.urlForSegment("session"); this._settingsUrl = this.urlCreator.urlForSegment("settings"); } + _mapTileViewModels(rooms, invites) { + const roomTileViewModels = rooms.mapValues((room, emitChange) => { + const isOpen = this.navigation.path.get("room")?.value === room.id; + const vm = new RoomTileViewModel(this.childOptions({isOpen, room, emitChange})); + if (isOpen) { + this._updateCurrentVM(vm); + } + return vm; + }); + const inviteTileViewModels = invites.mapValues((invite, emitChange) => { + const isOpen = this.navigation.path.get("room")?.value === invite.id; + const vm = new InviteTileViewModel(this.childOptions({isOpen, invite, emitChange})); + if (isOpen) { + this._updateCurrentVM(vm); + } + return vm; + }); + return roomTileViewModels.join(inviteTileViewModels); + } + + _updateCurrentVM(vm) { + // need to also update the current vm here as + // we can't call `_open` from the ctor as the map + // is only populated when the view subscribes. + this._currentTileVM?.close(); + this._currentTileVM = vm; + } + get closeUrl() { return this._closeUrl; } @@ -75,7 +89,7 @@ export class LeftPanelViewModel extends ViewModel { this._currentTileVM?.close(); this._currentTileVM = null; if (roomId) { - this._currentTileVM = this._roomTileViewModels.get(roomId); + this._currentTileVM = this._tileViewModelsMap.get(roomId); this._currentTileVM?.open(); } } @@ -102,13 +116,13 @@ export class LeftPanelViewModel extends ViewModel { } } - get roomList() { - return this._roomList; + get tileViewModels() { + return this._tileViewModels; } clearFilter() { - this._roomListFilterMap.setApply(null); - this._roomListFilterMap.applyOnce((roomId, vm) => vm.hidden = false); + this._tileViewModelsFilterMap.setApply(null); + this._tileViewModelsFilterMap.applyOnce((roomId, vm) => vm.hidden = false); } setFilter(query) { @@ -117,7 +131,7 @@ export class LeftPanelViewModel extends ViewModel { this.clearFilter(); } else { const filter = new RoomFilter(query); - this._roomListFilterMap.setApply((roomId, vm) => { + this._tileViewModelsFilterMap.setApply((roomId, vm) => { vm.hidden = !filter.matches(vm); }); } diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js new file mode 100644 index 00000000..1ac8b40e --- /dev/null +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -0,0 +1,37 @@ +/* +Copyright 2020 Bruno Windels +Copyright 2020, 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {TemplateView} from "../../general/TemplateView.js"; +import {renderStaticAvatar} from "../../avatar.js"; + +export class InviteTileView extends TemplateView { + render(t, vm) { + const classes = { + "active": vm => vm.isOpen, + "hidden": vm => vm.hidden + }; + return t.li({"className": classes}, [ + t.a({href: vm.url}, [ + renderStaticAvatar(vm, 32), + t.div({className: "description"}, [ + t.div({className: "name"}, vm.name), + t.div({className: "badge highlighted"}, "!"), + ]) + ]) + ]); + } +} diff --git a/src/platform/web/ui/session/leftpanel/LeftPanelView.js b/src/platform/web/ui/session/leftpanel/LeftPanelView.js index a681b326..5b56fa4a 100644 --- a/src/platform/web/ui/session/leftpanel/LeftPanelView.js +++ b/src/platform/web/ui/session/leftpanel/LeftPanelView.js @@ -17,6 +17,7 @@ limitations under the License. import {ListView} from "../../general/ListView.js"; import {TemplateView} from "../../general/TemplateView.js"; import {RoomTileView} from "./RoomTileView.js"; +import {InviteTileView} from "./InviteTileView.js"; class FilterField extends TemplateView { render(t, options) { @@ -84,9 +85,15 @@ export class LeftPanelView extends TemplateView { t.view(new ListView( { className: "RoomList", - list: vm.roomList, + list: vm.tileViewModels, }, - roomTileVM => new RoomTileView(roomTileVM) + tileVM => { + if (tileVM.kind === "invite") { + return new InviteTileView(tileVM); + } else { + return new RoomTileView(tileVM); + } + } )) ]); } From 76933e51aa1ce785cd41661e9fbfc9b4ff5e44e1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:48:40 +0200 Subject: [PATCH 114/166] fix default avatar color being broken in room list --- src/domain/session/leftpanel/BaseTileViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index 7b2a3258..ac9ea3f9 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -72,7 +72,7 @@ export class BaseTileViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._avatarSource) + return getIdentifierColorNumber(this._avatarSource.id) } get avatarUrl() { From ac312cbdc713d57b192fb75ab8ff601bb84c6095 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:49:59 +0200 Subject: [PATCH 115/166] fix c/p error in invite store --- src/matrix/storage/idb/stores/InviteStore.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/storage/idb/stores/InviteStore.js b/src/matrix/storage/idb/stores/InviteStore.js index 41a2351a..b0eefe60 100644 --- a/src/matrix/storage/idb/stores/InviteStore.js +++ b/src/matrix/storage/idb/stores/InviteStore.js @@ -28,6 +28,6 @@ export class InviteStore { } remove(roomId) { - this._store.delete(roomId); + this._inviteStore.delete(roomId); } } From b4ad6142f4d03cf7d27c872013c3a535cdcca47d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:50:22 +0200 Subject: [PATCH 116/166] show spinner in room list while accepting or rejecting invite --- src/platform/web/ui/css/themes/element/theme.css | 2 +- src/platform/web/ui/session/leftpanel/InviteTileView.js | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index c5667b53..a053f07e 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -276,7 +276,7 @@ a.button-action { } .RoomList .description { - align-items: baseline; + align-items: center; } .RoomList .name.unread { diff --git a/src/platform/web/ui/session/leftpanel/InviteTileView.js b/src/platform/web/ui/session/leftpanel/InviteTileView.js index 1ac8b40e..09b9401f 100644 --- a/src/platform/web/ui/session/leftpanel/InviteTileView.js +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -17,6 +17,7 @@ limitations under the License. import {TemplateView} from "../../general/TemplateView.js"; import {renderStaticAvatar} from "../../avatar.js"; +import {spinner} from "../../common.js"; export class InviteTileView extends TemplateView { render(t, vm) { @@ -29,7 +30,13 @@ export class InviteTileView extends TemplateView { renderStaticAvatar(vm, 32), t.div({className: "description"}, [ t.div({className: "name"}, vm.name), - t.div({className: "badge highlighted"}, "!"), + t.map(vm => vm.busy, busy => { + if (busy) { + return spinner(t); + } else { + return t.div({className: "badge highlighted"}, "!"); + } + }) ]) ]) ]); From 35992ac2fc76b3666deeeb66d243d9f02e16ca82 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 17:50:41 +0200 Subject: [PATCH 117/166] show very basic buttons to accept or reject invite --- src/platform/web/ui/session/room/InviteView.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js index 8fdf6cf7..ae241be2 100644 --- a/src/platform/web/ui/session/room/InviteView.js +++ b/src/platform/web/ui/session/room/InviteView.js @@ -33,7 +33,11 @@ export class InviteView extends TemplateView { ]), ]), t.div({className: "RoomView_error"}, vm => vm.error), - t.div(`You were invited into this room!`) + t.div([ + t.p(`You were invited into this room!`), + t.p(t.button({onClick: () => vm.accept()}, vm.i18n`Accept`)), + t.p(t.button({onClick: () => vm.reject()}, vm.i18n`Reject`)), + ]) ]) ]); } From 20f4474eb6a725fd6e8faf520a57048d5723577b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 21 Apr 2021 18:13:29 +0200 Subject: [PATCH 118/166] missing semicolon --- src/domain/session/leftpanel/BaseTileViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index ac9ea3f9..6e074caa 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -72,7 +72,7 @@ export class BaseTileViewModel extends ViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._avatarSource.id) + return getIdentifierColorNumber(this._avatarSource.id); } get avatarUrl() { From 4e3127c4cf023f34a2324a5cc2c6db17543edc4e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 13:28:14 +0200 Subject: [PATCH 119/166] handle key collisions in JoinedMap --- src/observable/map/JoinedMap.js | 214 ++++++++++++++++++++++++++++---- 1 file changed, 191 insertions(+), 23 deletions(-) diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js index b50a524a..7d099136 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.js @@ -22,27 +22,33 @@ export class JoinedMap extends BaseObservableMap { this._sources = sources; } - onAdd(key, value) { - this.emitAdd(key, value); + onAdd(source, key, value) { + if (!this._isKeyAtSourceOccluded(source, key)) { + const occludingValue = this._getValueFromOccludedSources(source, key); + if (occludingValue !== undefined) { + // adding a value that will occlude another one should + // first emit a remove + this.emitRemove(key, occludingValue); + } + this.emitAdd(key, value); + } } - onRemove(key, value) { - this.emitRemove(key, value); + onRemove(source, key, value) { + if (!this._isKeyAtSourceOccluded(source, key)) { + this.emitRemove(key, value); + const occludedValue = this._getValueFromOccludedSources(source, key); + if (occludedValue !== undefined) { + // removing a value that so far occluded another one should + // emit an add for the occluded value after the removal + this.emitAdd(key, occludedValue); + } + } } - onUpdate(key, value, params) { - this.emitUpdate(key, value, params); - } - - onSubscribeFirst() { - this._subscriptions = this._sources.map(source => source.subscribe(this)); - super.onSubscribeFirst(); - } - - onUnsubscribeLast() { - super.onUnsubscribeLast(); - for (const s of this._subscriptions) { - s(); + onUpdate(source, key, value, params) { + if (!this._isKeyAtSourceOccluded(source, key)) { + this.emitUpdate(key, value, params); } } @@ -50,6 +56,49 @@ export class JoinedMap extends BaseObservableMap { this.emitReset(); } + onSubscribeFirst() { + this._subscriptions = this._sources.map(source => new SourceSubscriptionHandler(source, this).subscribe()); + super.onSubscribeFirst(); + } + + _isKeyAtSourceOccluded(source, key) { + // sources that come first in the sources array can + // hide the keys in later sources, to prevent events + // being emitted for the same key and different values, + // so check the key is not present in earlier sources + const index = this._sources.indexOf(source); + for (let i = 0; i < index; i += 1) { + if (this._sources[i].get(key) !== undefined) { + return true; + } + } + return false; + } + + // get the value that the given source and key occlude, if any + _getValueFromOccludedSources(source, key) { + // sources that come first in the sources array can + // hide the keys in later sources, to prevent events + // being emitted for the same key and different values, + // so check the key is not present in earlier sources + const index = this._sources.indexOf(source); + for (let i = index + 1; i < this._sources.length; i += 1) { + const source = this._sources[i]; + const occludedValue = source.get(key); + if (occludedValue !== undefined) { + return occludedValue; + } + } + return undefined; + } + + onUnsubscribeLast() { + super.onUnsubscribeLast(); + for (const s of this._subscriptions) { + s.dispose(); + } + } + [Symbol.iterator]() { return new JoinedIterator(this._sources); } @@ -74,6 +123,7 @@ class JoinedIterator { this._sources = sources; this._sourceIndex = -1; this._currentIterator = null; + this._encounteredKeys = new Set(); } next() { @@ -91,22 +141,140 @@ class JoinedIterator { this._currentIterator = null; continue; } else { - result = sourceResult; + const key = sourceResult.value[0]; + if (!this._encounteredKeys.has(key)) { + this._encounteredKeys.add(key); + result = sourceResult; + } } } return result; } } +class SourceSubscriptionHandler { + constructor(source, joinedMap) { + this._source = source; + this._joinedMap = joinedMap; + this._subscription = null; + } + + subscribe() { + this._source.subscribe(this); + return this; + } + + dispose() { + this._subscription = this._subscription(); + } + + onAdd(key, value) { + this._joinedMap.onAdd(this._source, key, value); + } + + onRemove(key, value) { + this._joinedMap.onRemove(this._source, key, value); + } + + onUpdate(key, value, params) { + this._joinedMap.onUpdate(this._source, key, value, params); + } + + onReset() { + this._joinedMap.onReset(this._source); + } +} + + +import { ObservableMap } from "./ObservableMap.js"; + export function tests() { + + function observeMap(map) { + const events = []; + map.subscribe({ + onAdd(key, value) { events.push({type: "add", key, value}); }, + onRemove(key, value) { events.push({type: "remove", key, value}); }, + onUpdate(key, value, params) { events.push({type: "update", key, value, params}); } + }); + return events; + } + return { "joined iterator": assert => { - const it = new JoinedIterator([[1, 2], [3, 4]]); - assert.equal(it.next().value, 1); - assert.equal(it.next().value, 2); - assert.equal(it.next().value, 3); - assert.equal(it.next().value, 4); + const firstKV = ["a", 1]; + const secondKV = ["b", 2]; + const thirdKV = ["c", 3]; + const it = new JoinedIterator([[firstKV, secondKV], [thirdKV]]); + assert.equal(it.next().value, firstKV); + assert.equal(it.next().value, secondKV); + assert.equal(it.next().value, thirdKV); assert.equal(it.next().done, true); + }, + "prevent key collision during iteration": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + second.add("a", 2); + second.add("b", 3); + first.add("a", 1); + const it = join[Symbol.iterator](); + assert.deepEqual(it.next().value, ["a", 1]); + assert.deepEqual(it.next().value, ["b", 3]); + assert.equal(it.next().done, true); + }, + "adding occluded key doesn't emit add": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + const events = observeMap(join); + first.add("a", 1); + second.add("a", 2); + assert.equal(events.length, 1); + assert.equal(events[0].type, "add"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 1); + }, + "updating occluded key doesn't emit update": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + first.add("a", 1); + second.add("a", 2); + const events = observeMap(join); + second.update("a", 3); + assert.equal(events.length, 0); + }, + "removal of occluding key emits add after remove": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + first.add("a", 1); + second.add("a", 2); + const events = observeMap(join); + first.remove("a"); + assert.equal(events.length, 2); + assert.equal(events[0].type, "remove"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 1); + assert.equal(events[1].type, "add"); + assert.equal(events[1].key, "a"); + assert.equal(events[1].value, 2); + }, + "adding occluding key emits remove first": assert => { + const first = new ObservableMap(); + const second = new ObservableMap(); + const join = new JoinedMap([first, second]); + second.add("a", 2); + const events = observeMap(join); + first.add("a", 1); + assert.equal(events.length, 2); + assert.equal(events[0].type, "remove"); + assert.equal(events[0].key, "a"); + assert.equal(events[0].value, 2); + assert.equal(events[1].type, "add"); + assert.equal(events[1].key, "a"); + assert.equal(events[1].value, 1); } }; } From ff4abbc5ba0ba546feb7e1164b50095d1bd6426c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:18:07 +0200 Subject: [PATCH 120/166] make dispose not fail --- src/observable/map/JoinedMap.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js index 7d099136..e5d0caa7 100644 --- a/src/observable/map/JoinedMap.js +++ b/src/observable/map/JoinedMap.js @@ -20,6 +20,7 @@ export class JoinedMap extends BaseObservableMap { constructor(sources) { super(); this._sources = sources; + this._subscriptions = null; } onAdd(source, key, value) { @@ -160,7 +161,7 @@ class SourceSubscriptionHandler { } subscribe() { - this._source.subscribe(this); + this._subscription = this._source.subscribe(this); return this; } From ec0de15da60524df83cb4942a6a38352377ec10d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:21:29 +0200 Subject: [PATCH 121/166] handle overlap with existing timeline when rejoining room --- src/matrix/room/Room.js | 3 +- .../room/timeline/persistence/SyncWriter.js | 31 +++++++++++++++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 02b19a18..f75b1148 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -248,8 +248,9 @@ export class Room extends EventEmitter { /** @package */ async writeSync(roomResponse, isInitialSync, {summaryChanges, decryptChanges, roomEncryption, retryEntries}, txn, log) { log.set("id", this.id); + const isRejoin = summaryChanges.membership === "join" && this._summary.data.membership === "leave"; const {entries: newEntries, newLiveKey, memberChanges} = - await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, txn, log), log.level.Detail); + await log.wrap("syncWriter", log => this._syncWriter.writeSync(roomResponse, isRejoin, txn, log), log.level.Detail); let allEntries = newEntries; if (decryptChanges) { const decryption = await log.wrap("decryptChanges", log => decryptChanges.write(txn, log)); diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js index 4913ac53..dc2344e5 100644 --- a/src/matrix/room/timeline/persistence/SyncWriter.js +++ b/src/matrix/room/timeline/persistence/SyncWriter.js @@ -190,6 +190,26 @@ export class SyncWriter { return currentKey; } + async _handleRejoinOverlap(timeline, txn, log) { + if (this._lastLiveKey) { + const {fragmentId} = this._lastLiveKey; + const [lastEvent] = await txn.timelineEvents.lastEvents(this._roomId, fragmentId, 1); + if (lastEvent) { + const lastEventId = lastEvent.event.event_id; + const {events} = timeline; + const index = events.findIndex(event => event.event_id === lastEventId); + if (index !== -1) { + log.set("overlap_event_id", lastEventId); + return { + limited: false, + events: events.slice(index + 1) + }; + } + } + } + return timeline; + } + /** * @type {SyncWriterResult} * @property {Array} entries new timeline entries written @@ -197,12 +217,19 @@ export class SyncWriter { * @property {Map} memberChanges member changes in the processed sync ny user id * * @param {Object} roomResponse [description] + * @param {boolean} isRejoin whether the room was rejoined in the sync being processed * @param {Transaction} txn * @return {SyncWriterResult} */ - async writeSync(roomResponse, txn, log) { + async writeSync(roomResponse, isRejoin, txn, log) { const entries = []; - const {timeline} = roomResponse; + let {timeline} = roomResponse; + // we have rejoined the room after having synced it before, + // check for overlap with the last synced event + log.set("isRejoin", isRejoin); + if (isRejoin) { + timeline = await this._handleRejoinOverlap(timeline, txn, log); + } const memberChanges = new Map(); // important this happens before _writeTimeline so // members are available in the transaction From 2e9ddf9c2c86e8f5119f305dd995a7fafc3cd95c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:22:47 +0200 Subject: [PATCH 122/166] prevent key collisions between rooms and invites before creating tile vm --- .../session/leftpanel/LeftPanelViewModel.js | 21 ++++++++----------- src/matrix/room/Invite.js | 4 ++++ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index c9a8b436..a1a577a9 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -35,23 +35,20 @@ export class LeftPanelViewModel extends ViewModel { } _mapTileViewModels(rooms, invites) { - const roomTileViewModels = rooms.mapValues((room, emitChange) => { - const isOpen = this.navigation.path.get("room")?.value === room.id; - const vm = new RoomTileViewModel(this.childOptions({isOpen, room, emitChange})); + // join is not commutative, invites will take precedence over rooms + return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { + const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id; + let vm; + if (roomOrInvite.isInvite) { + vm = new InviteTileViewModel(this.childOptions({isOpen, invite: roomOrInvite, emitChange})); + } else { + vm = new RoomTileViewModel(this.childOptions({isOpen, room: roomOrInvite, emitChange})); + } if (isOpen) { this._updateCurrentVM(vm); } return vm; }); - const inviteTileViewModels = invites.mapValues((invite, emitChange) => { - const isOpen = this.navigation.path.get("room")?.value === invite.id; - const vm = new InviteTileViewModel(this.childOptions({isOpen, invite, emitChange})); - if (isOpen) { - this._updateCurrentVM(vm); - } - return vm; - }); - return roomTileViewModels.join(inviteTileViewModels); } _updateCurrentVM(vm) { diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index f9547828..db6306d6 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -35,6 +35,10 @@ export class Invite extends EventEmitter { this._rejected = false; } + get isInvite() { + return true; + } + get id() { return this._roomId; } From d289a44624c40637c479c5e96e5fd385740766a6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:23:29 +0200 Subject: [PATCH 123/166] also sync leave now that we can handle it --- src/matrix/Sync.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 9fb57948..5f8d4e06 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -331,8 +331,7 @@ export class Sync { _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { const roomStates = []; if (roomsSection) { - // don't do "leave" for now - const allMemberships = ["join"]; + const allMemberships = ["join", "leave"]; for(const membership of allMemberships) { const membershipSection = roomsSection[membership]; if (membershipSection) { From b77d0f75ce23f56e0bfeeb669a940cb4a6ad7779 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 22 Apr 2021 17:23:41 +0200 Subject: [PATCH 124/166] can just use vm here --- src/platform/web/ui/session/room/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index b445dcac..a218964b 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -38,7 +38,7 @@ export class RoomView extends TemplateView { new TimelineList(timelineViewModel) : new TimelineLoadingView(vm); // vm is just needed for i18n }), - t.view(new MessageComposer(this.value.composerViewModel)), + t.view(new MessageComposer(vm.composerViewModel)), ]) ]); } From b9f145caa42ed2a4459349ab321307569ac7b4d0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:04:10 +0200 Subject: [PATCH 125/166] emit events for rooms before invites so the room is already there when removing the invite --- src/matrix/Sync.js | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 5f8d4e06..c8454dd6 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -287,18 +287,19 @@ export class Sync { _afterSync(sessionState, inviteStates, roomStates, log) { log.wrap("session", log => this._session.afterSync(sessionState.changes, log), log.level.Detail); + // emit room related events after txn has been closed + for(let rs of roomStates) { + log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); + if (rs.isNewRoom) { + this._session.addRoomAfterSync(rs.room); + } + } // emit invite related events after txn has been closed for(let is of inviteStates) { log.wrap("invite", () => is.invite.afterSync(is.changes), log.level.Detail); if (is.isNewInvite) { this._session.addInviteAfterSync(is.invite); } - } - // emit room related events after txn has been closed - for(let rs of roomStates) { - log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); - if (rs.isNewRoom) { - this._session.addRoomAfterSync(rs.room); } } } From 847738a76c97e2c712aeab42c97318b1c73a0d7f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:05:14 +0200 Subject: [PATCH 126/166] set invite on left room so we can detect a vm refresh is needed --- src/matrix/Session.js | 24 ++++++++++++++++-------- src/matrix/Sync.js | 13 ++++++++++--- src/matrix/room/Room.js | 24 ++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 11bca80b..99ed5035 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -284,20 +284,28 @@ export class Session { } } const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); - // load rooms - const rooms = await txn.roomSummary.getAll(); - await Promise.all(rooms.map(async summary => { - const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); - await log.wrap("room", log => room.load(summary, txn, log)); - this._rooms.add(room.id, room); - })); // load invites const invites = await txn.invites.getAll(); - await Promise.all(invites.map(async inviteData => { + const inviteLoadPromise = Promise.all(invites.map(async inviteData => { const invite = this.createInvite(inviteData.roomId); log.wrap("invite", log => invite.load(inviteData, log)); this._invites.add(invite.id, invite); })); + // load rooms + const rooms = await txn.roomSummary.getAll(); + const roomLoadPromise = Promise.all(rooms.map(async summary => { + const room = this.createRoom(summary.roomId, pendingEventsByRoomId.get(summary.roomId)); + await log.wrap("room", log => room.load(summary, txn, log)); + this._rooms.add(room.id, room); + })); + // load invites and rooms in parallel + await Promise.all([inviteLoadPromise, roomLoadPromise]); + for (const [roomId, invite] of this.invites) { + const room = this.rooms.get(roomId); + if (room) { + room.setInvite(invite); + } + } } dispose() { diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index c8454dd6..8c8e3423 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -300,6 +300,10 @@ export class Sync { if (is.isNewInvite) { this._session.addInviteAfterSync(is.invite); } + // if we haven't archived or forgotten the (left) room yet, + // notify there is an invite now, so we can update the UI + if (is.room) { + is.room.setInvite(is.invite); } } } @@ -352,7 +356,7 @@ export class Sync { // if there is an existing invite, add a process state for it // so its writeSync and afterSync will run and remove the invite if (invite) { - inviteStates.push(new InviteSyncProcessState(invite, false, membership, null)); + inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null)); } roomStates.push(new RoomSyncProcessState( room, isNewRoom, invite, roomResponse, membership)); @@ -373,7 +377,9 @@ export class Sync { invite = this._session.createInvite(roomId); isNewInvite = true; } - inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, "invite", roomResponse)); + const room = this._session.rooms.get(roomId); + // TODO let the room know there is an invite now, so + inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse)); } } return inviteStates; @@ -417,9 +423,10 @@ class RoomSyncProcessState { } class InviteSyncProcessState { - constructor(invite, isNewInvite, membership, roomResponse) { + constructor(invite, isNewInvite, room, membership, roomResponse) { this.invite = invite; this.isNewInvite = isNewInvite; + this.room = room; this.membership = membership; this.roomResponse = roomResponse; this.changes = null; diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index f75b1148..8564f860 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -54,6 +54,7 @@ export class Room extends EventEmitter { this._getSyncToken = getSyncToken; this._platform = platform; this._observedEvents = null; + this._invite = null; } async _eventIdsToEntries(eventIds, txn) { @@ -344,6 +345,10 @@ export class Room extends EventEmitter { } let emitChange = false; if (summaryChanges) { + // if we joined the room, we can't have an invite anymore + if (summaryChanges.membership === "join" && this._summary.data.membership !== "join") { + this._invite = null; + } this._summary.applyChanges(summaryChanges); if (!this._summary.data.needsHeroes) { this._heroes = null; @@ -427,6 +432,14 @@ export class Room extends EventEmitter { } } + /** @internal */ + setInvite(invite) { + // called when an invite comes in for this room + // (e.g. when we're in membership leave and haven't been archived or forgotten yet) + this._invite = invite; + this._emitUpdate(); + } + /** @public */ sendEvent(eventType, content, attachments, log = null) { this._platform.logger.wrapOrRun(log, "send", log => { @@ -589,6 +602,17 @@ export class Room extends EventEmitter { return this._summary.data.membership; } + /** + * The invite for this room, if any. + * This will only be set if you've left a room, and + * don't archive or forget it, and then receive an invite + * for it again + * @return {Invite?} + */ + get invite() { + return this._invite; + } + enableSessionBackup(sessionBackup) { this._roomEncryption?.enableSessionBackup(sessionBackup); // TODO: do we really want to do this every time you open the app? From 827075bc37e88b1cc7f18ba92d6799d20708532c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:05:46 +0200 Subject: [PATCH 127/166] only apply the invite when joining --- src/matrix/room/Room.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 8564f860..6038ae19 100644 --- a/src/matrix/room/Room.js +++ b/src/matrix/room/Room.js @@ -196,7 +196,7 @@ export class Room extends EventEmitter { log.set("newKeys", newKeys.length); } let summaryChanges = this._summary.data.applySyncResponse(roomResponse, membership); - if (invite) { + if (membership === "join" && invite) { summaryChanges = summaryChanges.applyInvite(invite); } let roomEncryption = this._roomEncryption; From f2d7f5e4dad8751141e14fc63a66f4945840ca49 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 23 Apr 2021 18:06:38 +0200 Subject: [PATCH 128/166] refresh vm when left room receives invite clean up room vm switching in the process --- src/domain/session/RoomGridViewModel.js | 27 +++--- src/domain/session/SessionViewModel.js | 98 ++++++++++------------ src/domain/session/room/InviteViewModel.js | 6 +- src/domain/session/room/RoomViewModel.js | 13 ++- 4 files changed, 70 insertions(+), 74 deletions(-) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 535d454a..05c55041 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -32,11 +32,10 @@ export class RoomGridViewModel extends ViewModel { this._width = options.width; this._height = options.height; - this._createRoomOrInviteViewModel = options.createRoomOrInviteViewModel; - + this._createRoomViewModel = options.createRoomViewModel; this._selectedIndex = 0; this._viewModels = []; - this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); + this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); this._setupNavigation(); } @@ -64,7 +63,7 @@ export class RoomGridViewModel extends ViewModel { // initial focus for a room is set by initializeRoomIdsAndTransferVM } - _replaceInviteWithRoom(roomId) { + _refreshRoomViewModel(roomId) { const index = this._viewModels.findIndex(vm => vm?.id === roomId); if (index === -1) { return; @@ -72,7 +71,7 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[index] = this.disposeTracked(this._viewModels[index]); // this will create a RoomViewModel because the invite is already // removed from the collection (see Invite.afterSync) - const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); + const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); if (roomVM) { this._viewModels[index] = this.track(roomVM); if (this.focusIndex === index) { @@ -147,7 +146,7 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[i] = this.disposeTracked(vm); } if (newId) { - const newVM = this._createRoomOrInviteViewModel(newId, this._replaceInviteWithRoom); + const newVM = this._createRoomViewModel(newId, this._refreshRoomViewModel); if (newVM) { this._viewModels[i] = this.track(newVM); } @@ -230,7 +229,7 @@ export function tests() { "initialize with duplicate set of rooms": assert => { const navigation = createNavigationForRoom(["c", "a", "b", undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -247,7 +246,7 @@ export function tests() { "transfer room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), + createRoomViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -261,7 +260,7 @@ export function tests() { "reject transfer for non-matching room view model": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -275,7 +274,7 @@ export function tests() { "created & released room view model is not disposed": assert => { const navigation = createNavigationForRoom(["a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -289,7 +288,7 @@ export function tests() { "transfered & released room view model is not disposed": assert => { const navigation = createNavigationForRoom([undefined, "a"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: () => assert.fail("no vms should be created"), + createRoomViewModel: () => assert.fail("no vms should be created"), navigation, width: 3, height: 2, @@ -304,7 +303,7 @@ export function tests() { "try release non-existing room view model is": assert => { const navigation = createNavigationForEmptyTile([undefined, "b"], 3); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -316,7 +315,7 @@ export function tests() { "initial focus is set to empty tile": assert => { const navigation = createNavigationForEmptyTile(["a"], 1); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, @@ -328,7 +327,7 @@ export function tests() { "change room ids after creation": assert => { const navigation = createNavigationForRoom(["a", "b"], "a"); const gridVM = new RoomGridViewModel({ - createRoomOrInviteViewModel: id => new RoomVMMock(id), + createRoomViewModel: id => new RoomVMMock(id), navigation, width: 3, height: 2, diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 45b97c3e..6bf334b5 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -42,8 +42,8 @@ export class SessionViewModel extends ViewModel { this._settingsViewModel = null; this._currentRoomViewModel = null; this._gridViewModel = null; - this._replaceInviteWithRoom = this._replaceInviteWithRoom.bind(this); - this._createRoomOrInviteViewModel = this._createRoomOrInviteViewModel.bind(this); + this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); + this._createRoomViewModel = this._createRoomViewModel.bind(this); this._setupNavigation(); } @@ -121,7 +121,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, - createRoomOrInviteViewModel: this._createRoomOrInviteViewModel, + createRoomViewModel: this._createRoomViewModel, }))); if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); @@ -138,7 +138,7 @@ export class SessionViewModel extends ViewModel { if (vm) { this._currentRoomViewModel = this.track(vm); } else { - const newVM = this._createRoomViewModel(currentRoomId.value); + const newVM = this._createRoomViewModel(currentRoomId.value, this._refreshRoomViewModel); if (newVM) { this._currentRoomViewModel = this.track(newVM); } @@ -151,72 +151,62 @@ export class SessionViewModel extends ViewModel { } } - _createRoomViewModel(roomId) { - const room = this._sessionContainer.session.rooms.get(roomId); - if (!room) { - return null; - } - const roomVM = new RoomViewModel(this.childOptions({ - room, - ownUserId: this._sessionContainer.session.user.id, - })); - roomVM.load(); - return roomVM; - } - - _createInviteViewModel(roomId, replaceInviteWithRoom) { + /** + * @param {string} roomId + * @param {function} refreshRoomViewModel passed in as an argument, because the grid needs a different impl of this + * @return {RoomViewModel | InviteViewModel} + */ + _createRoomViewModel(roomId, refreshRoomViewModel) { const invite = this._sessionContainer.session.invites.get(roomId); - if (!invite) { - return null; - } - return new InviteViewModel(this.childOptions({ - invite, - mediaRepository: this._sessionContainer.session.mediaRepository, - closeCallback: accepted => this._closeInvite(roomId, accepted, replaceInviteWithRoom), - })); - } - - _createRoomOrInviteViewModel(roomId, replaceInviteWithRoom) { - const inviteVM = this._createInviteViewModel(roomId, replaceInviteWithRoom); - if (inviteVM) { - return inviteVM; - } - return this._createRoomViewModel(roomId); - } - - _closeInvite(roomId, accepted, replaceInviteWithRoom) { - if (accepted) { - replaceInviteWithRoom(roomId); + if (invite) { + console.log("got invite"); + return new InviteViewModel(this.childOptions({ + invite, + mediaRepository: this._sessionContainer.session.mediaRepository, + refreshRoomViewModel, + })); } else { - // close invite - this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); + const room = this._sessionContainer.session.rooms.get(roomId); + if (room) { + console.log("got room"); + const roomVM = new RoomViewModel(this.childOptions({ + room, + ownUserId: this._sessionContainer.session.user.id, + refreshRoomViewModel + })); + roomVM.load(); + return roomVM; + } } + return null; } - _replaceInviteWithRoom(roomId) { + /** refresh the room view model after an internal change that needs + to change between invite, room or none state */ + _refreshRoomViewModel(roomId) { this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomViewModel(roomId); + const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); + } else { + // close room id + this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); } this.emitChange("activeMiddleViewModel"); } _updateRoom(roomId) { - if (!roomId) { - // closing invite or room view? - if (this._currentRoomViewModel) { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - this.emitChange("activeMiddleViewModel"); - } - return; - } - // already open? + // opening a room and already open? if (this._currentRoomViewModel?.id === roomId) { + console.log("bailing out"); return; } - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomOrInviteViewModel(roomId, this._replaceInviteWithRoom); + // close if needed + if (this._currentRoomViewModel) { + this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); + } + // and try opening again + const roomVM = this._createRoomViewModel(roomId, this._refreshRoomViewModel); if (roomVM) { this._currentRoomViewModel = this.track(roomVM); } diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 2b43feb0..31171b88 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -21,10 +21,10 @@ import {ViewModel} from "../../ViewModel.js"; export class InviteViewModel extends ViewModel { constructor(options) { super(options); - const {invite, mediaRepository, closeCallback} = options; + const {invite, mediaRepository, refreshRoomViewModel} = options; this._invite = invite; this._mediaRepository = mediaRepository; - this._closeCallback = closeCallback; + this._refreshRoomViewModel = refreshRoomViewModel; this._onInviteChange = this._onInviteChange.bind(this); this._error = null; this._closeUrl = this.urlCreator.urlUntilSegment("session"); @@ -99,7 +99,7 @@ export class InviteViewModel extends ViewModel { // we're in a grid view, and opening the room doesn't change // the nav path because the url is the same for an // invite and the room. - this._closeCallback(this._invite.accepted); + this._refreshRoomViewModel(this.id); } else { this.emitChange(); } diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 6a48f9d9..e3e40c2a 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -22,9 +22,10 @@ import {ViewModel} from "../../ViewModel.js"; export class RoomViewModel extends ViewModel { constructor(options) { super(options); - const {room, ownUserId} = options; + const {room, ownUserId, refreshRoomViewModel} = options; this._room = room; this._ownUserId = ownUserId; + this._refreshRoomViewModel = refreshRoomViewModel; this._timelineVM = null; this._onRoomChange = this._onRoomChange.bind(this); this._timelineError = null; @@ -65,7 +66,7 @@ export class RoomViewModel extends ViewModel { } catch (err) { if (err.name !== "AbortError") { throw err; - } + } } } @@ -85,7 +86,13 @@ export class RoomViewModel extends ViewModel { // room doesn't tell us yet which fields changed, // so emit all fields originating from summary _onRoomChange() { - this.emitChange("name"); + // if there is now an invite on this (left) room, + // show the invite view by refreshing the view model + if (this._room.invite) { + this._refreshRoomViewModel(this.id); + } else { + this.emitChange("name"); + } } get kind() { return "room"; } From 47b2eb0bdb61989acd3f75e7b84ad93e6de79643 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 26 Apr 2021 10:41:21 +0200 Subject: [PATCH 129/166] don't create a room when rejecting an invite --- src/matrix/Sync.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 8c8e3423..9265d894 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -348,7 +348,8 @@ export class Sync { } let isNewRoom = false; let room = this._session.rooms.get(roomId); - if (!room) { + // don't create a room for a rejected invite + if (!room && membership === "join") { room = this._session.createRoom(roomId); isNewRoom = true; } @@ -358,8 +359,10 @@ export class Sync { if (invite) { inviteStates.push(new InviteSyncProcessState(invite, false, null, membership, null)); } - roomStates.push(new RoomSyncProcessState( - room, isNewRoom, invite, roomResponse, membership)); + if (room) { + roomStates.push(new RoomSyncProcessState( + room, isNewRoom, invite, roomResponse, membership)); + } } } } From bd748549f7c84d53a2baac3bd2f0b30280768e1c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 26 Apr 2021 10:45:27 +0200 Subject: [PATCH 130/166] fix updates in Invite not firing on collection --- src/matrix/room/Invite.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index db6306d6..1c8f1bcc 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -105,7 +105,7 @@ export class Invite extends EventEmitter { _emitChange(params) { this.emit("change"); - this._emitCollectionUpdate(params); + this._emitCollectionUpdate(this, params); } load(inviteData, log) { From 55b576a299ceaffe57bd763bba6cd617d0633bef Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:28:09 +0200 Subject: [PATCH 131/166] rename .TimelinePanel to .RoomView_body --- src/platform/web/ui/css/layout.css | 6 +++--- src/platform/web/ui/css/timeline.css | 5 +---- src/platform/web/ui/session/room/RoomView.js | 14 +++++++------- 3 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 9917ca74..e47f7777 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -122,8 +122,8 @@ main { height: 100%; } -.TimelinePanel { - flex: 3; +.RoomView_body { + flex: 1; min-height: 0; min-width: 0; display: flex; @@ -131,7 +131,7 @@ main { height: 100%; } -.TimelinePanel .Timeline, .TimelinePanel .TimelineLoadingView { +.RoomView_body .Timeline, .RoomView_body .TimelineLoadingView { flex: 1 0 0; } diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index 8a766a54..5d082c08 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -15,7 +15,7 @@ limitations under the License. */ -.TimelinePanel ul { +.RoomView_body ul { overflow-y: auto; overscroll-behavior: contain; list-style: none; @@ -23,9 +23,6 @@ limitations under the License. margin: 0; } -.TimelinePanel li { -} - .message-container { flex: 0 1 auto; /* first try break-all, then break-word, which isn't supported everywhere */ diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index a218964b..470e940d 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -24,14 +24,14 @@ import {AvatarView} from "../../avatar.js"; export class RoomView extends TemplateView { render(t, vm) { return t.main({className: "RoomView middle"}, [ - t.div({className: "TimelinePanel"}, [ - t.div({className: "RoomHeader middle-header"}, [ - t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), - t.view(new AvatarView(vm, 32)), - t.div({className: "room-description"}, [ - t.h2(vm => vm.name), - ]), + t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), + t.view(new AvatarView(vm, 32)), + t.div({className: "room-description"}, [ + t.h2([vm => vm.name, vm => vm.membership]), ]), + ]), + t.div({className: "RoomView_body"}, [ t.div({className: "RoomView_error"}, vm => vm.error), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? From ab8111f847ac558f8f509508fc5317fc713d04bb Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:28:25 +0200 Subject: [PATCH 132/166] make all middle containers column flexboxes, not just the room view --- src/platform/web/ui/css/layout.css | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index e47f7777..7b4e8e86 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -98,12 +98,8 @@ main { min-height: 0; /* make popups relative to this element so changing the left panel width doesn't affect their position */ position: relative; -} - -.RoomView { - min-width: 0; - min-height: 0; display: flex; + flex-direction: column; } .SessionStatusView { From bb6664a9a82583fc00d2563d824505592746cda2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:31:47 +0200 Subject: [PATCH 133/166] fix room header height --- src/platform/web/ui/css/themes/element/theme.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index a053f07e..f12ca768 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -406,7 +406,7 @@ a { .middle-header { box-sizing: border-box; - height: 58px; /* 12 + 36 + 12 to align with filter field + margin */ + flex: 0 0 56px; /* 12 + 32 + 12 to align with filter field + margin */ background: white; padding: 0 16px; border-bottom: 1px solid rgba(245, 245, 245, 0.90); From 9b9e6fceda7521d672abb86f3b2fb097c5eec849 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 10:32:06 +0200 Subject: [PATCH 134/166] refine action buttons style --- src/platform/web/ui/css/themes/element/theme.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index f12ca768..d2ec276a 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -106,6 +106,11 @@ a.button-action { background-color: #03B381; border-radius: 8px; color: white; + font-weight: bold; +} + +.button-action.primary:disabled { + color: #fffa; } .button-action.primary.destructive { From aaf4e79a73b1797f252207cb0b2c8c19d95db866 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 11:01:33 +0200 Subject: [PATCH 135/166] make media repo available on invite --- src/domain/session/leftpanel/BaseTileViewModel.js | 2 +- src/matrix/Session.js | 1 + src/matrix/room/Invite.js | 7 ++++++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index 6e074caa..d4ca3293 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -78,7 +78,7 @@ export class BaseTileViewModel extends ViewModel { get avatarUrl() { if (this._avatarSource.avatarUrl) { const size = 32 * this.platform.devicePixelRatio; - return this._room.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop"); + return this._avatarSource.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop"); } return null; } diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 99ed5035..a9076169 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -412,6 +412,7 @@ export class Session { hsApi: this._hsApi, emitCollectionRemove: this._inviteRemoveCallback, emitCollectionUpdate: this._inviteUpdateCallback, + mediaRepository: this._mediaRepository, user: this._user, platform: this._platform, }); diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 1c8f1bcc..4ce74ea8 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -20,13 +20,14 @@ import {Heroes} from "./members/Heroes.js"; import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "./members/RoomMember.js"; export class Invite extends EventEmitter { - constructor({roomId, user, hsApi, emitCollectionRemove, emitCollectionUpdate, platform}) { + constructor({roomId, user, hsApi, mediaRepository, emitCollectionRemove, emitCollectionUpdate, platform}) { super(); this._roomId = roomId; this._user = user; this._hsApi = hsApi; this._emitCollectionRemove = emitCollectionRemove; this._emitCollectionUpdate = emitCollectionUpdate; + this._mediaRepository = mediaRepository; this._platform = platform; this._inviteData = null; this._accepting = false; @@ -103,6 +104,10 @@ export class Invite extends EventEmitter { return this._rejected; } + get mediaRepository() { + return this._mediaRepository; + } + _emitChange(params) { this.emit("change"); this._emitCollectionUpdate(this, params); From 86e8b27cb3043d56b16d988d153d5d65da4332dc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 11:01:51 +0200 Subject: [PATCH 136/166] fix middle-shown to use new active vm --- src/platform/web/ui/session/SessionView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/SessionView.js b/src/platform/web/ui/session/SessionView.js index c7abd4db..214db2a3 100644 --- a/src/platform/web/ui/session/SessionView.js +++ b/src/platform/web/ui/session/SessionView.js @@ -30,7 +30,7 @@ export class SessionView extends TemplateView { return t.div({ className: { "SessionView": true, - "middle-shown": vm => vm.activeSection !== "placeholder" + "middle-shown": vm => !!vm.activeMiddleViewModel }, }, [ t.view(new SessionStatusView(vm.sessionStatusViewModel)), From fd454f1e2044ca34349d5d2309f5a169bda8f4ce Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:26:40 +0200 Subject: [PATCH 137/166] don't expose joinRule prop, as it is protocol specific --- src/matrix/room/Invite.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js index 4ce74ea8..d34ffa8e 100644 --- a/src/matrix/room/Invite.js +++ b/src/matrix/room/Invite.js @@ -68,8 +68,12 @@ export class Invite extends EventEmitter { return this._inviter; } - get joinRule() { - return this._inviteData.joinRule; + get isPublic() { + return this._inviteData.joinRule === "public"; + } + + get canonicalAlias() { + return this._inviteData.canonicalAlias; } async accept(log = null) { @@ -276,7 +280,7 @@ export function tests() { invite.afterSync(changes); assert.equal(invite.name, "Invite example"); assert.equal(invite.avatarUrl, roomAvatarUrl); - assert.equal(invite.joinRule, "invite"); + assert.equal(invite.isPublic, false); assert.equal(invite.timestamp, 1001); assert.equal(invite.isEncrypted, false); assert.equal(invite.isDirectMessage, false); From 87defbfb5186191e6c4463a72f162222d2cb7646 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:27:14 +0200 Subject: [PATCH 138/166] sort invites most recent first --- src/domain/session/leftpanel/InviteTileViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/InviteTileViewModel.js b/src/domain/session/leftpanel/InviteTileViewModel.js index 5c3082c0..10c84628 100644 --- a/src/domain/session/leftpanel/InviteTileViewModel.js +++ b/src/domain/session/leftpanel/InviteTileViewModel.js @@ -42,7 +42,7 @@ export class InviteTileViewModel extends BaseTileViewModel { if (parentComparison !== 0) { return parentComparison; } - return this._invite.timestamp - other._invite.timestamp; + return other._invite.timestamp - this._invite.timestamp; } get name() { From b6573258fb3e6e42afab232103d990535c89f3fc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:42:12 +0200 Subject: [PATCH 139/166] always show inviter if present --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 31171b88..fe629dc4 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -30,7 +30,7 @@ export class InviteViewModel extends ViewModel { this._closeUrl = this.urlCreator.urlUntilSegment("session"); this._invite.on("change", this._onInviteChange); this._inviter = null; - if (this._invite.inviter && ! this._invite.isDirectMessage) { + if (this._invite.inviter) { this._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform); } } From f902f255a42f596d9a6cefab06ce00093437df33 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:42:37 +0200 Subject: [PATCH 140/166] add roomDescription property --- src/domain/session/room/InviteViewModel.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index fe629dc4..377c7ab5 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -33,6 +33,7 @@ export class InviteViewModel extends ViewModel { if (this._invite.inviter) { this._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform); } + this._roomDescription = this._createRoomDescription(); } get kind() { return "invite"; } @@ -67,6 +68,24 @@ export class InviteViewModel extends ViewModel { return null; } + _createRoomDescription() { + const parts = []; + if (this._invite.isPublic) { + parts.push("Public room"); + } else { + parts.push("Private room"); + } + + if (this._invite.canonicalAlias) { + parts.push(this._invite.canonicalAlias); + } + return parts.join(" • ") + } + + get roomDescription() { + return this._roomDescription; + } + get avatarTitle() { return this.name; } From 781235cb071ef4765579a2532c8e590114c0815d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:43:13 +0200 Subject: [PATCH 141/166] scale up room avatar --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 377c7ab5..bc052cd9 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -62,7 +62,7 @@ export class InviteViewModel extends ViewModel { get avatarUrl() { if (this._invite.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; + const size = 128 * this.platform.devicePixelRatio; return this._mediaRepository.mxcUrlThumbnail(this._invite.avatarUrl, size, size, "crop"); } return null; From 15fba7a07d4240da29eaf044899eb992c82cee91 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 14:43:26 +0200 Subject: [PATCH 142/166] missing semicolon --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index bc052cd9..a7807e9f 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -146,7 +146,7 @@ class RoomMemberViewModel { } get avatarColorNumber() { - return getIdentifierColorNumber(this._member.userId) + return getIdentifierColorNumber(this._member.userId); } get avatarUrl() { From a95df54978064a1795734988b39fa9d7ea567084 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:01:20 +0200 Subject: [PATCH 143/166] scale down inviter avatar --- src/domain/session/room/InviteViewModel.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index a7807e9f..b31f79ad 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -151,7 +151,7 @@ class RoomMemberViewModel { get avatarUrl() { if (this._member.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; + const size = 24 * this._platform.devicePixelRatio; return this._mediaRepository.mxcUrlThumbnail(this._member.avatarUrl, size, size, "crop"); } return null; From 32d9f6e83086537fe4b8f447b7955457b762372c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:01:35 +0200 Subject: [PATCH 144/166] expose inviter user id --- src/domain/session/room/InviteViewModel.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index b31f79ad..57691a34 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -137,6 +137,10 @@ class RoomMemberViewModel { this._platform = platform; } + get id() { + return this._member.userId; + } + get name() { return this._member.name; } From 799ea50c68f0acba7fa7ceb58124a4d848010dcd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:02:00 +0200 Subject: [PATCH 145/166] remove obsolete logging --- src/domain/session/SessionViewModel.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 6bf334b5..1e59a9d5 100644 --- a/src/domain/session/SessionViewModel.js +++ b/src/domain/session/SessionViewModel.js @@ -159,7 +159,6 @@ export class SessionViewModel extends ViewModel { _createRoomViewModel(roomId, refreshRoomViewModel) { const invite = this._sessionContainer.session.invites.get(roomId); if (invite) { - console.log("got invite"); return new InviteViewModel(this.childOptions({ invite, mediaRepository: this._sessionContainer.session.mediaRepository, @@ -168,7 +167,6 @@ export class SessionViewModel extends ViewModel { } else { const room = this._sessionContainer.session.rooms.get(roomId); if (room) { - console.log("got room"); const roomVM = new RoomViewModel(this.childOptions({ room, ownUserId: this._sessionContainer.session.user.id, @@ -198,7 +196,6 @@ export class SessionViewModel extends ViewModel { _updateRoom(roomId) { // opening a room and already open? if (this._currentRoomViewModel?.id === roomId) { - console.log("bailing out"); return; } // close if needed From 1f3612228301737b88b69d2f0fbd56c69dcdc7a2 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:02:29 +0200 Subject: [PATCH 146/166] remove debugging code --- src/platform/web/ui/session/room/RoomView.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 470e940d..327af046 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -28,7 +28,7 @@ export class RoomView extends TemplateView { t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close room`}), t.view(new AvatarView(vm, 32)), t.div({className: "room-description"}, [ - t.h2([vm => vm.name, vm => vm.membership]), + t.h2(vm => vm.name), ]), ]), t.div({className: "RoomView_body"}, [ From 34cca2ba0f791daedc770f00e2c4e52da223030f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:02:43 +0200 Subject: [PATCH 147/166] add invite view to view gallery --- src/platform/web/ui/view-gallery.html | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/src/platform/web/ui/view-gallery.html b/src/platform/web/ui/view-gallery.html index 46b69e16..7887d44f 100644 --- a/src/platform/web/ui/view-gallery.html +++ b/src/platform/web/ui/view-gallery.html @@ -74,5 +74,54 @@ })); document.getElementById("session-loading").appendChild(view.mount()); +

Invite DM view

+
+ +

Invite Room view

+
+ From 441bb4d5d7d77caa0913386c5170ea0adfb2e38d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:03:13 +0200 Subject: [PATCH 148/166] keep .room-placeholder display settings separate from .middle --- src/platform/web/ui/css/layout.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/platform/web/ui/css/layout.css b/src/platform/web/ui/css/layout.css index 7b4e8e86..b4089d9b 100644 --- a/src/platform/web/ui/css/layout.css +++ b/src/platform/web/ui/css/layout.css @@ -98,6 +98,9 @@ main { min-height: 0; /* make popups relative to this element so changing the left panel width doesn't affect their position */ position: relative; +} + +.middle { display: flex; flex-direction: column; } From fbe255f8374a8e9aab0d2d9e38f08bdaf068318f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:04:01 +0200 Subject: [PATCH 149/166] support extra classes in static avatar --- src/platform/web/ui/avatar.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index c68d5496..4e502de9 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -103,12 +103,15 @@ export class AvatarView extends BaseUpdateView { * @param {Number} size * @return {Element} */ -export function renderStaticAvatar(vm, size) { +export function renderStaticAvatar(vm, size, extraClasses = undefined) { const hasAvatar = !!vm.avatarUrl; - const avatarClasses = classNames({ + let avatarClasses = classNames({ avatar: true, [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, }); + if (extraClasses) { + avatarClasses += ` ${extraClasses}`; + } const avatarContent = hasAvatar ? renderImg(vm, size) : text(vm.avatarLetter); return tag.div({className: avatarClasses}, [avatarContent]); } From a9838fed06320c5f023867e8d7675494e9779be5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:09:29 +0200 Subject: [PATCH 150/166] don't set a default avatar size in the css, makes it easier to change it --- src/platform/web/ui/css/avatar.css | 1 - src/platform/web/ui/css/themes/element/theme.css | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 4c7d1074..8aa482a9 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -16,7 +16,6 @@ limitations under the License. */ .avatar { - --avatar-size: 32px; width: var(--avatar-size); height: var(--avatar-size); overflow: hidden; diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index d2ec276a..28cee2f7 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -250,6 +250,7 @@ a.button-action { /* make scrollbar hit right edge of parent */ padding-right: 8px; margin-right: -8px; + --avatar-size: 32px; } .RoomList > li { @@ -434,6 +435,10 @@ a { background-position-x: 10px; } +.RoomHeader { + --avatar-size: 32px; +} + .RoomHeader .topic { font-size: 14rem; } From f4f153ac4b05af04831eb5027b24c0046c7a641a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:09:45 +0200 Subject: [PATCH 151/166] don't seems to throw the letter off-center more than anything else apart for some rare cases ... shrug --- src/platform/web/ui/css/avatar.css | 1 - 1 file changed, 1 deletion(-) diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 8aa482a9..04d837e6 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -24,7 +24,6 @@ limitations under the License. line-height: var(--avatar-size); font-size: calc(var(--avatar-size) * 0.6); text-align: center; - letter-spacing: calc(var(--avatar-size) * -0.05); speak: none; } From c47b27428b8a6acd17efcca8561d980542dd5b3f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:10:12 +0200 Subject: [PATCH 152/166] implement invite view + styling --- .../web/ui/css/themes/element/theme.css | 77 +++++++++++++++++++ .../web/ui/session/room/InviteView.js | 60 +++++++++++---- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 28cee2f7..53c09dd8 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -92,12 +92,17 @@ limitations under the License. display: block; } +.button-action { + cursor: pointer; +} + a.button-action { text-decoration: none; text-align: center; display: block; } + .button-action.secondary { color: #03B381; } @@ -832,3 +837,75 @@ button.link { background-color: #03B381; color: white; } + +.InviteView_body { + display: flex; + justify-content: space-around; + align-items: center; + flex: 1; + overflow: auto; +} + +.InviteView_invite { + display: flex; + width: 100%; + max-width: 400px; + flex-direction: column; + padding: 0 24px; +} + +.InviteView_roomProfile { + display: grid; + gap: 4px; + grid-template: + "avatar name" auto + "avatar description" 1fr / + 72px 1fr; + align-self: center; + margin-bottom: 24px; +} + +.InviteView_roomProfile h3 { + grid-area: name; + margin: 0; +} + +.InviteView_roomDescription { + grid-area: description; + font-size: 1.2rem; + margin: 0; + color: #777; +} + +.InviteView_roomAvatar { + grid-area: avatar; + --avatar-size: 64px; +} + +.InviteView_dmAvatar { + align-self: center; + --avatar-size: 128px; +} + +.InviteView_inviter { + text-align: center; + margin: 24px 0px; +} + +.InviteView_inviter .avatar { + --avatar-size: 24px; + display: inline-block; + vertical-align: middle; + margin-right: 4px; +} + +.InviteView_buttonRow { + margin: 10px auto; + max-width: 200px; + width: 100%; +} + +.InviteView_buttonRow button { + display: block; + width: 100%; +} diff --git a/src/platform/web/ui/session/room/InviteView.js b/src/platform/web/ui/session/room/InviteView.js index ae241be2..1d1e7db4 100644 --- a/src/platform/web/ui/session/room/InviteView.js +++ b/src/platform/web/ui/session/room/InviteView.js @@ -16,27 +16,57 @@ limitations under the License. */ import {TemplateView} from "../../general/TemplateView.js"; -// import {TimelineList} from "./TimelineList.js"; -// import {TimelineLoadingView} from "./TimelineLoadingView.js"; -// import {MessageComposer} from "./MessageComposer.js"; import {renderStaticAvatar} from "../../avatar.js"; export class InviteView extends TemplateView { render(t, vm) { + let inviteNodes = []; + if (vm.isDirectMessage) { + inviteNodes.push(renderStaticAvatar(vm, 128, "InviteView_dmAvatar")); + } + let inviterNodes; + if (vm.isDirectMessage) { + inviterNodes = [t.strong(vm.name), ` (${vm.inviter?.id}) wants to chat with you.`]; + } else if (vm.inviter) { + inviterNodes = [renderStaticAvatar(vm.inviter, 24), t.strong(vm.inviter.name), ` (${vm.inviter.id}) invited you.`]; + } else { + inviterNodes = `You were invited to join.`; + } + inviteNodes.push(t.p({className: "InviteView_inviter"}, inviterNodes)); + if (!vm.isDirectMessage) { + inviteNodes.push(t.div({className: "InviteView_roomProfile"}, [ + renderStaticAvatar(vm, 64, "InviteView_roomAvatar"), + t.h3(vm.name), + t.p({className: "InviteView_roomDescription"}, vm.roomDescription) + ])); + } + return t.main({className: "InviteView middle"}, [ - t.div({className: "TimelinePanel"}, [ - t.div({className: "RoomHeader middle-header"}, [ - t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close invite`}), - renderStaticAvatar(vm, 32), - t.div({className: "room-description"}, [ - t.h2(vm => vm.name), - ]), + t.div({className: "RoomHeader middle-header"}, [ + t.a({className: "button-utility close-middle", href: vm.closeUrl, title: vm.i18n`Close invite`}), + renderStaticAvatar(vm, 32), + t.div({className: "room-description"}, [ + t.h2(vm => vm.name), ]), - t.div({className: "RoomView_error"}, vm => vm.error), - t.div([ - t.p(`You were invited into this room!`), - t.p(t.button({onClick: () => vm.accept()}, vm.i18n`Accept`)), - t.p(t.button({onClick: () => vm.reject()}, vm.i18n`Reject`)), + ]), + t.if(vm => vm.error, t => t.div({className: "RoomView_error"}, vm => vm.error)), + t.div({className: "InviteView_body"}, [ + t.div({className: "InviteView_invite"}, [ + ...inviteNodes, + t.div({className: "InviteView_buttonRow"}, + t.button({ + className: "button-action primary", + disabled: vm => vm.busy, + onClick: () => vm.accept() + }, vm.i18n`Accept`) + ), + t.div({className: "InviteView_buttonRow"}, + t.button({ + className: "button-action primary destructive", + disabled: vm => vm.busy, + onClick: () => vm.reject() + }, vm.i18n`Reject`) + ), ]) ]) ]); From 396ec4dfd96ff545d684b2cbdbe1cf3c28ad4ddc Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 15:33:12 +0200 Subject: [PATCH 153/166] also in grid remove room id from nav path when refreshing room vm fails --- src/domain/session/RoomGridViewModel.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index 05c55041..ce31e22c 100644 --- a/src/domain/session/RoomGridViewModel.js +++ b/src/domain/session/RoomGridViewModel.js @@ -15,6 +15,7 @@ limitations under the License. */ import {ViewModel} from "../ViewModel.js"; +import {removeRoomFromPath} from "../navigation/index.js"; function dedupeSparse(roomIds) { return roomIds.map((id, idx) => { @@ -77,6 +78,9 @@ export class RoomGridViewModel extends ViewModel { if (this.focusIndex === index) { roomVM.focus(); } + } else { + // close room id + this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); } this.emitChange(); } From 025320b83e9b0c245003bb3ef0e77e5fbd536570 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 16:15:20 +0200 Subject: [PATCH 154/166] fix filtered map --- src/observable/map/FilteredMap.js | 70 +++++++++++++++++-------------- 1 file changed, 39 insertions(+), 31 deletions(-) diff --git a/src/observable/map/FilteredMap.js b/src/observable/map/FilteredMap.js index 290dcea6..71b7bbeb 100644 --- a/src/observable/map/FilteredMap.js +++ b/src/observable/map/FilteredMap.js @@ -28,26 +28,29 @@ export class FilteredMap extends BaseObservableMap { setFilter(filter) { this._filter = filter; - this.update(); + if (this._subscription) { + this._reapplyFilter(); + } } /** * reapply the filter */ - update() { - // TODO: need to check if we have a subscriber already? If not, we really should not iterate the source? + _reapplyFilter(silent = false) { if (this._filter) { - const hadFilterBefore = !!this._included; + const oldIncluded = this._included; this._included = this._included || new Map(); for (const [key, value] of this._source) { const isIncluded = this._filter(value, key); - const wasIncluded = hadFilterBefore ? this._included.get(key) : true; this._included.set(key, isIncluded); - this._emitForUpdate(wasIncluded, isIncluded, key, value); + if (!silent) { + const wasIncluded = oldIncluded ? oldIncluded.get(key) : true; + this._emitForUpdate(wasIncluded, isIncluded, key, value); + } } } else { // no filter // did we have a filter before? - if (this._included) { + if (this._included && !silent) { // add any non-included items again for (const [key, value] of this._source) { if (!this._included.get(key)) { @@ -100,7 +103,7 @@ export class FilteredMap extends BaseObservableMap { onSubscribeFirst() { this._subscription = this._source.subscribe(this); - this.update(); + this._reapplyFilter(true); super.onSubscribeFirst(); } @@ -111,7 +114,7 @@ export class FilteredMap extends BaseObservableMap { } onReset() { - this.update(); + this._reapplyFilter(); this.emitReset(); } @@ -140,7 +143,7 @@ export class FilteredMap extends BaseObservableMap { class FilterIterator { constructor(map, _included) { this._included = _included; - this._sourceIterator = map.entries(); + this._sourceIterator = map[Symbol.iterator](); } next() { @@ -150,7 +153,7 @@ class FilterIterator { if (sourceResult.done) { return sourceResult; } - const key = sourceResult.value[1]; + const key = sourceResult.value[0]; if (this._included.get(key)) { return sourceResult; } @@ -158,26 +161,31 @@ class FilterIterator { } } -// import {ObservableMap} from "./ObservableMap.js"; -// export function tests() { -// return { -// "filter preloaded list": assert => { -// const source = new ObservableMap(); -// source.add("one", 1); -// source.add("two", 2); -// source.add("three", 3); -// const odds = Array.from(new FilteredMap(source, x => x % 2 !== 0)); -// assert.equal(odds.length, 2); +import {ObservableMap} from "./ObservableMap.js"; +export function tests() { + return { + "filter preloaded list": assert => { + const source = new ObservableMap(); + source.add("one", 1); + source.add("two", 2); + source.add("three", 3); + const oddNumbers = new FilteredMap(source, x => x % 2 !== 0); + // can only iterate after subscribing + oddNumbers.subscribe({}); + assert.equal(oddNumbers.size, 2); + const it = oddNumbers[Symbol.iterator](); + assert.deepEqual(it.next().value, ["one", 1]); + assert.deepEqual(it.next().value, ["three", 3]); + assert.equal(it.next().done, true); + }, + // "filter added values": assert => { -// }, -// "filter added values": assert => { + // }, + // "filter removed values": assert => { -// }, -// "filter removed values": assert => { + // }, + // "filter changed values": assert => { -// }, -// "filter changed values": assert => { - -// }, -// } -// } + // }, + } +} From 1c5b953026f8676ed00fe8c4fdc1faeef0512f0d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 27 Apr 2021 16:16:07 +0200 Subject: [PATCH 155/166] hide non-joined rooms in left panel for now until we support archiving/forgetting the room --- src/domain/session/leftpanel/LeftPanelViewModel.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index a1a577a9..dd9c89ac 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -35,8 +35,9 @@ export class LeftPanelViewModel extends ViewModel { } _mapTileViewModels(rooms, invites) { + const joinedRooms = rooms.filterValues(room => room.membership === "join"); // join is not commutative, invites will take precedence over rooms - return invites.join(rooms).mapValues((roomOrInvite, emitChange) => { + return invites.join(joinedRooms).mapValues((roomOrInvite, emitChange) => { const isOpen = this.navigation.path.get("room")?.value === roomOrInvite.id; let vm; if (roomOrInvite.isInvite) { From ef6f10c5a2c7636cb598c1027cfad4d179906918 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 09:53:44 +0200 Subject: [PATCH 156/166] test for Path.replace --- src/domain/navigation/Navigation.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/domain/navigation/Navigation.js b/src/domain/navigation/Navigation.js index 9d059ec9..3167475f 100644 --- a/src/domain/navigation/Navigation.js +++ b/src/domain/navigation/Navigation.js @@ -245,6 +245,17 @@ export function tests() { const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); assert.equal(path.get("foo").value, 5); assert.equal(path.get("bar").value, 6); + }, + "path.replace success": assert => { + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const newPath = path.replace(new Segment("foo", 1)); + assert.equal(newPath.get("foo").value, 1); + assert.equal(newPath.get("bar").value, 6); + }, + "path.replace not found": assert => { + const path = new Path([new Segment("foo", 5), new Segment("bar", 6)], () => true); + const newPath = path.replace(new Segment("baz", 1)); + assert.equal(newPath, null); } }; } From 15dfb6c20244cbb8edb459f5d13343f73757def1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 10:05:21 +0200 Subject: [PATCH 157/166] clarify --- src/domain/session/room/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/session/room/README.md b/src/domain/session/room/README.md index 28fa7173..adb673eb 100644 --- a/src/domain/session/room/README.md +++ b/src/domain/session/room/README.md @@ -6,4 +6,4 @@ InviteViewModel and RoomViewModel are interchangebly used as "room view model": This is because they are accessed by the same url and need to transition into each other, in these two locations. Having two methods, especially in RoomGridViewModel would have been more cumbersome, even though this is not in line with how different view models are exposed in SessionViewModel. -They share an `id` and `kind` property, the latter can be used to differentiate them from the view. +They share an `id` and `kind` property, the latter can be used to differentiate them from the view, and a `focus` method. From e85087401960f961f328bcce64f92d6e1df8b589 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 10:05:34 +0200 Subject: [PATCH 158/166] clarify order --- src/matrix/Sync.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 9265d894..d018900e 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -291,6 +291,9 @@ export class Sync { for(let rs of roomStates) { log.wrap("room", log => rs.room.afterSync(rs.changes, log), log.level.Detail); if (rs.isNewRoom) { + // important to add the room before removing the invite, + // so the room will be found if looking for it when the invite + // is removed this._session.addRoomAfterSync(rs.room); } } From b23ec5ef702216d11f40c7e7e9fba1c93eea0ec3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 10:08:11 +0200 Subject: [PATCH 159/166] remove obsolete comment --- src/matrix/Sync.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index d018900e..cf31f3c2 100644 --- a/src/matrix/Sync.js +++ b/src/matrix/Sync.js @@ -384,7 +384,6 @@ export class Sync { isNewInvite = true; } const room = this._session.rooms.get(roomId); - // TODO let the room know there is an invite now, so inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse)); } } From 870ed3f211c7e94dfee3808ac49ab48898120122 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 11:40:24 +0200 Subject: [PATCH 160/166] add avatar size now there is no default anymore --- src/platform/web/ui/css/login.css | 1 + 1 file changed, 1 insertion(+) diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index aefdac42..5d9e2b21 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -19,6 +19,7 @@ limitations under the License. .SessionPickerView { padding: 0.4em; + --avatar-size: 32px; } .SessionPickerView ul { From 04a1d8990700c167eaa75e5ccf90d4812654a41a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 11:42:02 +0200 Subject: [PATCH 161/166] release v0.1.46 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7d072939..f670a7be 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.45", + "version": "0.1.46", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": { From bfaeffcb510891852bd39e183dcfc00ef7d4ac4e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 13:05:26 +0200 Subject: [PATCH 162/166] fix memory leak in settings view by using nested template builder --- .../web/ui/session/settings/SettingsView.js | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index b4a47bea..725f0e2b 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -27,7 +27,7 @@ export class SettingsView extends TemplateView { ]); } - const row = (label, content, extraClass = "") => { + const row = (t, label, content, extraClass = "") => { return t.div({className: `row ${extraClass}`}, [ t.div({className: "label"}, label), t.div({className: "content"}, content), @@ -38,9 +38,9 @@ export class SettingsView extends TemplateView { settingNodes.push( t.h3("Session"), - row(vm.i18n`User ID`, vm.userId), - row(vm.i18n`Session ID`, vm.deviceId, "code"), - row(vm.i18n`Session key`, vm.fingerprintKey, "code") + row(t, vm.i18n`User ID`, vm.userId), + row(t, vm.i18n`Session ID`, vm.deviceId, "code"), + row(t, vm.i18n`Session key`, vm.fingerprintKey, "code") ); settingNodes.push( t.h3("Session Backup"), @@ -59,7 +59,7 @@ export class SettingsView extends TemplateView { const buttonLabel = vm => vm.pushNotifications.enabled ? vm.i18n`Disable`: vm.i18n`Enable`; - return row(label, t.button({ + return row(t, label, t.button({ onClick: () => vm.togglePushNotifications(), disabled: vm => vm.pushNotifications.updating }, buttonLabel)); @@ -92,13 +92,13 @@ export class SettingsView extends TemplateView { settingNodes.push( t.h3("Preferences"), - row(vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), + row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), ); settingNodes.push( t.h3("Application"), - row(vm.i18n`Version`, version), - row(vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), - row(vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), + row(t, vm.i18n`Version`, version), + row(t, vm.i18n`Storage usage`, vm => `${vm.storageUsage} / ${vm.storageQuota}`), + row(t, vm.i18n`Debug logs`, t.button({onClick: () => vm.exportLogs()}, "Export")), t.p(["Debug logs contain application usage data including your username, the IDs or aliases of the rooms or groups you have visited, the usernames of other users and the names of files you send. They do not contain messages. For more information, review our ", t.a({href: "https://element.io/privacy", target: "_blank", rel: "noopener"}, "privacy policy"), "."]), ); From b35b6ec4964e7e456055625dfff8852a51e78b48 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 13:45:43 +0200 Subject: [PATCH 163/166] work around --avatar-size being compiled away in build --- src/platform/web/ui/avatar.js | 1 + src/platform/web/ui/css/avatar.css | 41 +++++++++++++++---- src/platform/web/ui/css/login.css | 1 - .../web/ui/css/themes/element/theme.css | 12 ------ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 4e502de9..596a8a2c 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -107,6 +107,7 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { const hasAvatar = !!vm.avatarUrl; let avatarClasses = classNames({ avatar: true, + [`size-${size}`]: true, [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, }); if (extraClasses) { diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 04d837e6..15f2222b 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -15,23 +15,48 @@ See the License for the specific language governing permissions and limitations under the License. */ -.avatar { +.hydrogen { + --avatar-size: 32px; +} + +.hydrogen .avatar { width: var(--avatar-size); height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); overflow: hidden; flex-shrink: 0; user-select: none; - line-height: var(--avatar-size); - font-size: calc(var(--avatar-size) * 0.6); text-align: center; speak: none; } -.avatar.large { - --avatar-size: 40px; -} - -.avatar img { +.hydrogen .avatar img { width: 100%; height: 100%; } + +/* work around postcss-css-variables limitations and repeat variable usage */ +.hydrogen .avatar.size-128 { + --avatar-size: 128px; + width: var(--avatar-size); + height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); +} + +.hydrogen .avatar.size-64 { + --avatar-size: 64px; + width: var(--avatar-size); + height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); +} + +.hydrogen .avatar.size-24 { + --avatar-size: 24px; + width: var(--avatar-size); + height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); +} diff --git a/src/platform/web/ui/css/login.css b/src/platform/web/ui/css/login.css index 5d9e2b21..aefdac42 100644 --- a/src/platform/web/ui/css/login.css +++ b/src/platform/web/ui/css/login.css @@ -19,7 +19,6 @@ limitations under the License. .SessionPickerView { padding: 0.4em; - --avatar-size: 32px; } .SessionPickerView ul { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 53c09dd8..f38ba82b 100644 --- a/src/platform/web/ui/css/themes/element/theme.css +++ b/src/platform/web/ui/css/themes/element/theme.css @@ -255,7 +255,6 @@ a.button-action { /* make scrollbar hit right edge of parent */ padding-right: 8px; margin-right: -8px; - --avatar-size: 32px; } .RoomList > li { @@ -440,10 +439,6 @@ a { background-position-x: 10px; } -.RoomHeader { - --avatar-size: 32px; -} - .RoomHeader .topic { font-size: 14rem; } @@ -533,10 +528,6 @@ ul.Timeline > li.messageStatus .message-container > p { align-items: center; } -.message-container .avatar { - --avatar-size: 25px; -} - .TextMessageView { width: 100%; } @@ -879,12 +870,10 @@ button.link { .InviteView_roomAvatar { grid-area: avatar; - --avatar-size: 64px; } .InviteView_dmAvatar { align-self: center; - --avatar-size: 128px; } .InviteView_inviter { @@ -893,7 +882,6 @@ button.link { } .InviteView_inviter .avatar { - --avatar-size: 24px; display: inline-block; vertical-align: middle; margin-right: 4px; From e1d4c75d1849f00dcb81a14ee3e3c31cad3b71a7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 14:10:54 +0200 Subject: [PATCH 164/166] pass avatar sizes from view to view model rather than repeating there and risk a mismatch resulting in too large/small image --- src/domain/avatar.js | 8 ++++++++ .../session/leftpanel/BaseTileViewModel.js | 10 +++------- src/domain/session/room/InviteViewModel.js | 18 +++++------------- src/domain/session/room/RoomViewModel.js | 10 +++------- .../session/room/timeline/tiles/MessageTile.js | 9 +++------ src/platform/web/ui/avatar.js | 12 ++++++------ 6 files changed, 28 insertions(+), 39 deletions(-) diff --git a/src/domain/avatar.js b/src/domain/avatar.js index f94ba3b2..5b32020b 100644 --- a/src/domain/avatar.js +++ b/src/domain/avatar.js @@ -47,3 +47,11 @@ function hashCode(str) { export function getIdentifierColorNumber(id) { return (hashCode(id) % 8) + 1; } + +export function getAvatarHttpUrl(avatarUrl, cssSize, platform, mediaRepository) { + if (avatarUrl) { + const imageSize = cssSize * platform.devicePixelRatio; + return mediaRepository.mxcUrlThumbnail(avatarUrl, imageSize, imageSize, "crop"); + } + return null; +} diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js index d4ca3293..360f2b39 100644 --- a/src/domain/session/leftpanel/BaseTileViewModel.js +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {ViewModel} from "../../ViewModel.js"; const KIND_ORDER = ["invite", "room"]; @@ -75,12 +75,8 @@ export class BaseTileViewModel extends ViewModel { return getIdentifierColorNumber(this._avatarSource.id); } - get avatarUrl() { - if (this._avatarSource.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; - return this._avatarSource.mediaRepository.mxcUrlThumbnail(this._avatarSource.avatarUrl, size, size, "crop"); - } - return null; + avatarUrl(size) { + return getAvatarHttpUrl(this._avatarSource.avatarUrl, size, this.platform, this._avatarSource.mediaRepository); } get avatarTitle() { diff --git a/src/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js index 57691a34..15fcb5a5 100644 --- a/src/domain/session/room/InviteViewModel.js +++ b/src/domain/session/room/InviteViewModel.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {ViewModel} from "../../ViewModel.js"; export class InviteViewModel extends ViewModel { @@ -60,12 +60,8 @@ export class InviteViewModel extends ViewModel { return getIdentifierColorNumber(this._invite.id) } - get avatarUrl() { - if (this._invite.avatarUrl) { - const size = 128 * this.platform.devicePixelRatio; - return this._mediaRepository.mxcUrlThumbnail(this._invite.avatarUrl, size, size, "crop"); - } - return null; + avatarUrl(size) { + return getAvatarHttpUrl(this._invite.avatarUrl, size, this.platform, this._mediaRepository); } _createRoomDescription() { @@ -153,12 +149,8 @@ class RoomMemberViewModel { return getIdentifierColorNumber(this._member.userId); } - get avatarUrl() { - if (this._member.avatarUrl) { - const size = 24 * this._platform.devicePixelRatio; - return this._mediaRepository.mxcUrlThumbnail(this._member.avatarUrl, size, size, "crop"); - } - return null; + avatarUrl(size) { + return getAvatarHttpUrl(this._member.avatarUrl, size, this._platform, this._mediaRepository); } get avatarTitle() { diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index e3e40c2a..63aa6811 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -16,7 +16,7 @@ limitations under the License. */ import {TimelineViewModel} from "./timeline/TimelineViewModel.js"; -import {avatarInitials, getIdentifierColorNumber} from "../../avatar.js"; +import {avatarInitials, getIdentifierColorNumber, getAvatarHttpUrl} from "../../avatar.js"; import {ViewModel} from "../../ViewModel.js"; export class RoomViewModel extends ViewModel { @@ -120,12 +120,8 @@ export class RoomViewModel extends ViewModel { return getIdentifierColorNumber(this._room.id) } - get avatarUrl() { - if (this._room.avatarUrl) { - const size = 32 * this.platform.devicePixelRatio; - return this._room.mediaRepository.mxcUrlThumbnail(this._room.avatarUrl, size, size, "crop"); - } - return null; + avatarUrl(size) { + return getAvatarHttpUrl(this._room.avatarUrl, size, this.platform, this._room.mediaRepository); } get avatarTitle() { diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js index fe566814..f85f0fca 100644 --- a/src/domain/session/room/timeline/tiles/MessageTile.js +++ b/src/domain/session/room/timeline/tiles/MessageTile.js @@ -15,7 +15,7 @@ limitations under the License. */ import {SimpleTile} from "./SimpleTile.js"; -import {getIdentifierColorNumber, avatarInitials} from "../../../../avatar.js"; +import {getIdentifierColorNumber, avatarInitials, getAvatarHttpUrl} from "../../../../avatar.js"; export class MessageTile extends SimpleTile { constructor(options) { @@ -50,11 +50,8 @@ export class MessageTile extends SimpleTile { return getIdentifierColorNumber(this._entry.sender); } - get avatarUrl() { - if (this._entry.avatarUrl) { - return this._mediaRepository.mxcUrlThumbnail(this._entry.avatarUrl, 30, 30, "crop"); - } - return null; + avatarUrl(size) { + return getAvatarHttpUrl(this._entry.avatarUrl, size, this.platform, this._mediaRepository); } get avatarLetter() { diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js index 596a8a2c..8845f887 100644 --- a/src/platform/web/ui/avatar.js +++ b/src/platform/web/ui/avatar.js @@ -37,8 +37,8 @@ export class AvatarView extends BaseUpdateView { } _avatarUrlChanged() { - if (this.value.avatarUrl !== this._avatarUrl) { - this._avatarUrl = this.value.avatarUrl; + if (this.value.avatarUrl(this._size) !== this._avatarUrl) { + this._avatarUrl = this.value.avatarUrl(this._size); return true; } return false; @@ -79,7 +79,7 @@ export class AvatarView extends BaseUpdateView { if (this._avatarUrlChanged()) { // avatarColorNumber won't change, it's based on room/user id const bgColorClass = `usercolor${vm.avatarColorNumber}`; - if (vm.avatarUrl) { + if (vm.avatarUrl(this._size)) { this._root.replaceChild(renderImg(vm, this._size), this._root.firstChild); this._root.classList.remove(bgColorClass); } else { @@ -87,7 +87,7 @@ export class AvatarView extends BaseUpdateView { this._root.classList.add(bgColorClass); } } - const hasAvatar = !!vm.avatarUrl; + const hasAvatar = !!vm.avatarUrl(this._size); if (this._avatarTitleChanged() && hasAvatar) { const img = this._root.firstChild; img.setAttribute("title", vm.avatarTitle); @@ -104,7 +104,7 @@ export class AvatarView extends BaseUpdateView { * @return {Element} */ export function renderStaticAvatar(vm, size, extraClasses = undefined) { - const hasAvatar = !!vm.avatarUrl; + const hasAvatar = !!vm.avatarUrl(size); let avatarClasses = classNames({ avatar: true, [`size-${size}`]: true, @@ -119,5 +119,5 @@ export function renderStaticAvatar(vm, size, extraClasses = undefined) { function renderImg(vm, size) { const sizeStr = size.toString(); - return tag.img({src: vm.avatarUrl, width: sizeStr, height: sizeStr, title: vm.avatarTitle}); + return tag.img({src: vm.avatarUrl(size), width: sizeStr, height: sizeStr, title: vm.avatarTitle}); } From ad3b7f7f0008790780d8e6e9590b40339a4a65b1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 14:12:35 +0200 Subject: [PATCH 165/166] also add avatar size for timeline --- src/platform/web/ui/css/avatar.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/platform/web/ui/css/avatar.css b/src/platform/web/ui/css/avatar.css index 15f2222b..6e68236f 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -53,6 +53,14 @@ limitations under the License. font-size: calc(var(--avatar-size) * 0.6); } +.hydrogen .avatar.size-30 { + --avatar-size: 30px; + width: var(--avatar-size); + height: var(--avatar-size); + line-height: var(--avatar-size); + font-size: calc(var(--avatar-size) * 0.6); +} + .hydrogen .avatar.size-24 { --avatar-size: 24px; width: var(--avatar-size); From 9312f7176448fe2e2a24e1176ee2c0ddb247674d Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 28 Apr 2021 14:16:50 +0200 Subject: [PATCH 166/166] release v0.1.47 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f670a7be..b6d1e90a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.46", + "version": "0.1.47", "description": "A javascript matrix client prototype, trying to minize RAM usage by offloading as much as possible to IndexedDB", "main": "index.js", "directories": {