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/.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}" + 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/assets/config.json b/assets/config.json new file mode 100644 index 00000000..703ae1e6 --- /dev/null +++ b/assets/config.json @@ -0,0 +1,8 @@ +{ + "push": { + "appId": "io.element.hydrogen.web", + "gatewayUrl": "https://matrix.org", + "applicationServerKey": "BC-gpSdVHEXhvHSHS0AzzWrQoukv2BE7KzpoPO_FfPacqOo3l1pdqz7rSgmB04pZCWaHPz7XRe6fjLaC-WPDopM" + }, + "defaultHomeServer": "matrix.org" +} diff --git a/doc/SKINNING.md b/doc/SKINNING.md new file mode 100644 index 00000000..5f1c735d --- /dev/null +++ b/doc/SKINNING.md @@ -0,0 +1,22 @@ +# Replacing javascript files + +Any source file can be replaced at build time by mapping the path in a JSON file passed in to the build command, e.g. `yarn build --override-imports customizations.json`. The file should be written like so: + +```json +{ + "src/platform/web/ui/session/room/timeline/TextMessageView.js": "src/platform/web/ui/session/room/timeline/MyTextMessageView.js" +} +``` +The paths are relative to the location of the mapping file, but the mapping file should be in a parent directory of the files you want to replace. + +You should see a "replacing x with y" line (twice actually, for the normal and legacy build). + +# Injecting CSS + +You can override the location of the main css file with the `--override-css ` option to the build script. The default is `src/platform/web/ui/css/main.css`, which you probably want to import from your custom css file like so: + +```css +@import url('src/platform/web/ui/css/main.css'); + +/* additions */ +``` diff --git a/doc/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 +``` 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. 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 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)); +``` 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? diff --git a/index.html b/index.html index 55e7f0b2..0b266ff3 100644 --- a/index.html +++ b/index.html @@ -23,7 +23,13 @@ import {Platform} from "./src/platform/web/Platform.js"; main(new Platform(document.body, { worker: "src/worker.js", - downloadSandbox: "assets/download-sandbox.html", + 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 + // see assets/config.json for what the config looks like + // push: {...}, olm: { wasm: "lib/olm/olm.wasm", legacyBundle: "lib/olm/olm_legacy.js", diff --git a/package.json b/package.json index 67ff2c19..b6d1e90a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydrogen-web", - "version": "0.1.39", + "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": { @@ -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", diff --git a/scripts/build.mjs b/scripts/build.mjs index dbbeabcd..8e3eb6c6 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -50,12 +50,17 @@ 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") + .option("--override-css
", "pass in an alternative main css file") parameters.parse(process.argv); -async function build({modernOnly}) { +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; + 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,32 +75,33 @@ 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 + 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); // 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); - 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(); 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 +141,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 +151,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 +160,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("")); @@ -176,11 +182,15 @@ async function buildHtml(doc, version, globalHash, modernOnly, assets) { 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', @@ -191,7 +201,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', @@ -211,13 +221,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({ @@ -269,18 +284,39 @@ 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; + }; + 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}"`); 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); + 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); } -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}/`; @@ -315,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() ]; @@ -469,4 +509,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)); 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) + ""]), 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/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/navigation/Navigation.js b/src/domain/navigation/Navigation.js index fa1c7142..3167475f 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; } @@ -229,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); } }; } 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"); + }, + } } diff --git a/src/domain/session/RoomGridViewModel.js b/src/domain/session/RoomGridViewModel.js index b9b62153..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) => { @@ -33,9 +34,9 @@ export class RoomGridViewModel extends ViewModel { this._width = options.width; this._height = options.height; this._createRoomViewModel = options.createRoomViewModel; - this._selectedIndex = 0; this._viewModels = []; + this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); this._setupNavigation(); } @@ -63,6 +64,27 @@ export class RoomGridViewModel extends ViewModel { // initial focus for a room is set by initializeRoomIdsAndTransferVM } + _refreshRoomViewModel(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._createRoomViewModel(roomId, this._refreshRoomViewModel); + if (roomVM) { + this._viewModels[index] = this.track(roomVM); + if (this.focusIndex === index) { + roomVM.focus(); + } + } else { + // close room id + this.navigation.applyPath(removeRoomFromPath(this.navigation.path, roomId)); + } + this.emitChange(); + } + roomViewModelAt(i) { return this._viewModels[i]; } @@ -128,7 +150,7 @@ export class RoomGridViewModel extends ViewModel { this._viewModels[i] = this.disposeTracked(vm); } if (newId) { - const newVM = this._createRoomViewModel(newId); + const newVM = this._createRoomViewModel(newId, this._refreshRoomViewModel); if (newVM) { this._viewModels[i] = this.track(newVM); } diff --git a/src/domain/session/SessionViewModel.js b/src/domain/session/SessionViewModel.js index 2f7e341e..1e59a9d5 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"; @@ -34,11 +36,14 @@ 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; this._currentRoomViewModel = null; this._gridViewModel = null; + this._refreshRoomViewModel = this._refreshRoomViewModel.bind(this); + this._createRoomViewModel = this._createRoomViewModel.bind(this); this._setupNavigation(); } @@ -84,15 +89,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() { @@ -111,10 +109,6 @@ export class SessionViewModel extends ViewModel { return this._settingsViewModel; } - get roomList() { - return this._roomList; - } - get currentRoomViewModel() { return this._currentRoomViewModel; } @@ -127,7 +121,7 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.track(new RoomGridViewModel(this.childOptions({ width: 3, height: 2, - createRoomViewModel: roomId => this._createRoomViewModel(roomId), + createRoomViewModel: this._createRoomViewModel, }))); if (this._gridViewModel.initializeRoomIdsAndTransferVM(roomIds, this._currentRoomViewModel)) { this._currentRoomViewModel = this.untrack(this._currentRoomViewModel); @@ -138,12 +132,13 @@ 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) { 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); } @@ -152,41 +147,67 @@ export class SessionViewModel extends ViewModel { this._gridViewModel = this.disposeTracked(this._gridViewModel); } if (changed) { - this.emitChange("activeSection"); + this.emitChange("activeMiddleViewModel"); } } - _createRoomViewModel(roomId) { - const room = this._sessionContainer.session.rooms.get(roomId); - if (!room) { - return null; + /** + * @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 new InviteViewModel(this.childOptions({ + invite, + mediaRepository: this._sessionContainer.session.mediaRepository, + refreshRoomViewModel, + })); + } else { + const room = this._sessionContainer.session.rooms.get(roomId); + if (room) { + const roomVM = new RoomViewModel(this.childOptions({ + room, + ownUserId: this._sessionContainer.session.user.id, + refreshRoomViewModel + })); + roomVM.load(); + return roomVM; + } } - const roomVM = new RoomViewModel(this.childOptions({ - room, - ownUserId: this._sessionContainer.session.user.id, - })); - roomVM.load(); - return roomVM; + return null; + } + + /** 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, 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) { - if (this._currentRoomViewModel) { - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - this.emitChange("currentRoom"); - } - return; - } - // already open? + // opening a room and already open? if (this._currentRoomViewModel?.id === roomId) { return; } - this._currentRoomViewModel = this.disposeTracked(this._currentRoomViewModel); - const roomVM = this._createRoomViewModel(roomId); + // 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); } - this.emitChange("currentRoom"); + this.emitChange("activeMiddleViewModel"); } _updateSettings(settingsOpen) { @@ -199,7 +220,7 @@ export class SessionViewModel extends ViewModel { }))); this._settingsViewModel.load(); } - this.emitChange("activeSection"); + this.emitChange("activeMiddleViewModel"); } _updateLightbox(eventId) { diff --git a/src/domain/session/leftpanel/BaseTileViewModel.js b/src/domain/session/leftpanel/BaseTileViewModel.js new file mode 100644 index 00000000..360f2b39 --- /dev/null +++ b/src/domain/session/leftpanel/BaseTileViewModel.js @@ -0,0 +1,85 @@ +/* +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, getAvatarHttpUrl} 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.id); + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._avatarSource.avatarUrl, size, this.platform, this._avatarSource.mediaRepository); + } + + 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..10c84628 --- /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 other._invite.timestamp - this._invite.timestamp; + } + + get name() { + return this._invite.name; + } + + get _avatarSource() { + return this._invite; + } +} diff --git a/src/domain/session/leftpanel/LeftPanelViewModel.js b/src/domain/session/leftpanel/LeftPanelViewModel.js index de70245a..dd9c89ac 100644 --- a/src/domain/session/leftpanel/LeftPanelViewModel.js +++ b/src/domain/session/leftpanel/LeftPanelViewModel.js @@ -17,37 +17,49 @@ 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 joinedRooms = rooms.filterValues(room => room.membership === "join"); + // join is not commutative, invites will take precedence over rooms + return invites.join(joinedRooms).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; + }); + } + + _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 +87,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 +114,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 +129,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/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/domain/session/room/InviteViewModel.js b/src/domain/session/room/InviteViewModel.js new file mode 100644 index 00000000..15fcb5a5 --- /dev/null +++ b/src/domain/session/room/InviteViewModel.js @@ -0,0 +1,159 @@ +/* +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, getAvatarHttpUrl} from "../../avatar.js"; +import {ViewModel} from "../../ViewModel.js"; + +export class InviteViewModel extends ViewModel { + constructor(options) { + super(options); + const {invite, mediaRepository, refreshRoomViewModel} = options; + this._invite = invite; + this._mediaRepository = mediaRepository; + this._refreshRoomViewModel = refreshRoomViewModel; + 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._inviter = new RoomMemberViewModel(this._invite.inviter, mediaRepository, this.platform); + } + this._roomDescription = this._createRoomDescription(); + } + + 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) + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._invite.avatarUrl, size, this.platform, this._mediaRepository); + } + + _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; + } + + 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._refreshRoomViewModel(this.id); + } 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 id() { + return this._member.userId; + } + + get name() { + return this._member.name; + } + + get avatarLetter() { + return avatarInitials(this.name); + } + + get avatarColorNumber() { + return getIdentifierColorNumber(this._member.userId); + } + + avatarUrl(size) { + return getAvatarHttpUrl(this._member.avatarUrl, size, this._platform, this._mediaRepository); + } + + 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..adb673eb --- /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, and a `focus` method. diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js index 43eeb75c..63aa6811 100644 --- a/src/domain/session/room/RoomViewModel.js +++ b/src/domain/session/room/RoomViewModel.js @@ -16,15 +16,16 @@ 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 { 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; @@ -34,10 +35,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 { @@ -69,7 +66,7 @@ export class RoomViewModel extends ViewModel { } catch (err) { if (err.name !== "AbortError") { throw err; - } + } } } @@ -86,33 +83,24 @@ 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() { - 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 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 kind() { return "room"; } + 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) { @@ -132,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/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`; diff --git a/src/domain/session/settings/SettingsViewModel.js b/src/domain/session/settings/SettingsViewModel.js index 50aabd2c..221e39da 100644 --- a/src/domain/session/settings/SettingsViewModel.js +++ b/src/domain/session/settings/SettingsViewModel.js @@ -17,6 +17,16 @@ 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; + this.enabledOnServer = null; + this.serverError = null; + } +} + function formatKey(key) { const partLength = 4; const partCount = Math.ceil(key.length / partLength); @@ -40,6 +50,7 @@ export class SettingsViewModel extends ViewModel { this.sentImageSizeLimit = null; this.minSentImageSizeLimit = 400; this.maxSentImageSizeLimit = 4000; + this.pushNotifications = new PushNotificationStatus(); } setSentImageSizeLimit(size) { @@ -56,6 +67,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 +128,35 @@ 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.pushNotifications.enabledOnServer = null; + this.pushNotifications.serverError = null; + 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"); + } + } + + 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/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 c01574ea..a9076169 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. @@ -15,6 +16,8 @@ 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"; import {DeviceMessageHandler} from "./DeviceMessageHandler.js"; @@ -38,6 +41,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 @@ -50,6 +54,9 @@ 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._inviteUpdateCallback = (invite, params) => this._invites.update(invite.id, params); this._user = new User(sessionInfo.userId); this._deviceMessageHandler = new DeviceMessageHandler({storage}); this._olm = olm; @@ -252,6 +259,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, @@ -276,12 +284,28 @@ export class Session { } } const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); + // load invites + const invites = await txn.invites.getAll(); + 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(); - await Promise.all(rooms.map(summary => { + const roomLoadPromise = 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); })); + // 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() { @@ -358,7 +382,7 @@ export class Session { /** @internal */ createRoom(roomId, pendingEvents) { - const room = new Room({ + return new Room({ roomId, getSyncToken: this._getSyncToken, storage: this._storage, @@ -370,8 +394,33 @@ 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, + emitCollectionUpdate: this._inviteUpdateCallback, + mediaRepository: this._mediaRepository, + user: this._user, + platform: this._platform, + }); + } + + /** @internal */ + addInviteAfterSync(invite) { + this._invites.add(invite.id, invite); } async obtainSyncLock(syncResponse) { @@ -466,6 +515,76 @@ export class Session { get user() { return this._user; } + + get mediaRepository() { + return this._mediaRepository; + } + + 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; + } + + 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() { @@ -487,6 +606,11 @@ export function tests() { getAll() { return Promise.resolve([]); } + }, + invites: { + getAll() { + return Promise.resolve([]); + } } }; }, diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index efbf70e3..07c4a870 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"; @@ -43,6 +42,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,11 +103,12 @@ 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 { 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 = { @@ -115,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 { @@ -123,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 { @@ -160,12 +168,12 @@ 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); // no need to pass access token to session const filteredSessionInfo = { + id: sessionInfo.id, deviceId: sessionInfo.deviceId, userId: sessionInfo.userId, homeServer: sessionInfo.homeServer, @@ -225,27 +233,26 @@ 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) { + if (this._sync.status.get() === SyncStatus.Stopped && this._sync.error) { throw this._sync.error; } } catch (err) { // if dispose is called from stop, bail out - if (err instanceof AbortError) { + if (err.name === "AbortError") { return; } throw err; diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js index 73ff0207..cf31f3c2 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,48 +191,23 @@ 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 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(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 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); - } - }); + // 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 { @@ -252,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)); @@ -267,7 +242,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,18 +251,72 @@ 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 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 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) { + // 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); + } + } + // 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); + } + // 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); + } + } + } + _openSyncTxn() { const storeNames = this._storage.storeNames; return this._storage.readWriteTxn([ storeNames.session, storeNames.roomSummary, + storeNames.invites, storeNames.roomState, storeNames.roomMembers, storeNames.timelineEvents, @@ -307,11 +336,10 @@ export class Sync { ]); } - _parseRoomsResponse(roomsSection, isInitialSync) { + _parseRoomsResponse(roomsSection, inviteStates, isInitialSync) { const roomStates = []; if (roomsSection) { - // don't do "invite", "leave" for now - const allMemberships = ["join"]; + const allMemberships = ["join", "leave"]; for(const membership of allMemberships) { const membershipSection = roomsSection[membership]; if (membershipSection) { @@ -321,11 +349,23 @@ export class Sync { if (isInitialSync && timelineIsEmpty(roomResponse)) { continue; } + 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; + } + 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, null, membership, null)); + } + if (room) { + roomStates.push(new RoomSyncProcessState( + room, isNewRoom, invite, roomResponse, membership)); } - roomStates.push(new RoomSyncProcessState(room, roomResponse, membership)); } } } @@ -333,6 +373,22 @@ export class Sync { return roomStates; } + _parseInvites(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; + } + const room = this._session.rooms.get(roomId); + inviteStates.push(new InviteSyncProcessState(invite, isNewInvite, room, "invite", roomResponse)); + } + } + return inviteStates; + } stop() { if (this._status.get() === SyncStatus.Stopped) { @@ -360,11 +416,24 @@ 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, 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/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js index ddb6e4c4..721be2d0 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,16 +266,29 @@ 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)); + await log.wrap("share key", log => this._shareNewRoomKey(megolmResult.roomKeyMessage, hsApi, log)); } return { type: ENCRYPTED_TYPE, diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 8e7a110b..77e02d76 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -15,93 +15,16 @@ 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) { - const err = new Error(`Unexpectedly aborted, see #187.`); - 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}) { + 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; } @@ -143,10 +66,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, @@ -157,7 +80,7 @@ export class HomeServerApi { }); } - return wrapper; + return hsRequest; } _unauthedRequest(method, url, queryParams, body, options) { @@ -254,24 +177,31 @@ export class HomeServerApi { uploadAttachment(blob, filename, options = null) { return this._authedRequest("POST", `${this._homeserver}/_matrix/media/r0/upload`, {filename}, blob, options); } -} -export function tests() { - function createRequestMock(result) { - return function() { - return { - abort() {}, - response() { - return Promise.resolve(result); - } - } - } + setPusher(pusher, options = null) { + return this._post("/pushers/set", null, pusher, options); } + 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"; + +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..97728c28 --- /dev/null +++ b/src/matrix/net/HomeServerRequest.js @@ -0,0 +1,140 @@ +/* +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 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), 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 + // 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 + // 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/matrix/push/Pusher.js b/src/matrix/push/Pusher.js new file mode 100644 index 00000000..99baeae6 --- /dev/null +++ b/src/matrix/push/Pusher.js @@ -0,0 +1,50 @@ +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) { + try { + log.set("endpoint", new URL(this._description.data.endpoint).host); + } catch { + log.set("endpoint", null); + } + 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; + } + + 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); + } +} diff --git a/src/matrix/room/Invite.js b/src/matrix/room/Invite.js new file mode 100644 index 00000000..d34ffa8e --- /dev/null +++ b/src/matrix/room/Invite.js @@ -0,0 +1,356 @@ +/* +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, 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; + this._rejecting = false; + this._accepted = false; + this._rejected = false; + } + + get isInvite() { + return true; + } + + 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 isPublic() { + return this._inviteData.joinRule === "public"; + } + + get canonicalAlias() { + return this._inviteData.canonicalAlias; + } + + 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(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() { + return this._accepting; + } + + get accepted() { + return this._accepted; + } + + get rejecting() { + return this._rejecting; + } + + get rejected() { + return this._rejected; + } + + get mediaRepository() { + return this._mediaRepository; + } + + _emitChange(params) { + this.emit("change"); + this._emitCollectionUpdate(this, params); + } + + load(inviteData, log) { + log.set("id", this.id); + this._inviteData = inviteData; + this._inviter = inviteData.inviter ? new RoomMember(inviteData.inviter) : null; + } + + async writeSync(membership, roomResponse, txn, log) { + if (membership === "invite") { + 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 {removed: true, membership}; + } + } + + afterSync(changes) { + if (changes) { + if (changes.removed) { + this._accepting = false; + this._rejecting = false; + if (changes.membership === "join") { + this._accepted = true; + } 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 { + this._inviteData = changes.inviteData; + this._inviter = changes.inviter; + // sync will add the invite to the collection by + // calling session.addInviteAfterSync + } + } + } + + _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._platform.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); + }, + remove(roomId) { + invitesMap.delete(roomId); + } + } + } + } + + 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, + platform: {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.isPublic, false); + 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, + platform: {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); + }, + "load persisted invite has correct fields": async assert => { + const writeInvite = new Invite({ + roomId, + platform: {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), new NullLogItem()); + 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); + }, + "syncing with membership from invite removes the invite": async assert => { + let removedEmitted = false; + const invite = new Invite({ + roomId, + platform: {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.equal(invite.rejected, false); + assert.equal(invite.accepted, true); + assert(removedEmitted); + } + } +} diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js index 7326e32f..6038ae19 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) { @@ -189,12 +190,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 (membership === "join" && invite) { + summaryChanges = summaryChanges.applyInvite(invite); + } let roomEncryption = this._roomEncryption; // encryption is enabled in this sync if (!roomEncryption && summaryChanges.encryption) { @@ -245,8 +249,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)); @@ -340,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; @@ -423,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 => { @@ -585,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? @@ -678,7 +706,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/RoomSummary.js b/src/matrix/room/RoomSummary.js index 759b275a..88b2c45b 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,19 @@ function updateSummary(data, summary) { return data; } -class SummaryData { +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; this.name = copy ? copy.name : null; @@ -166,6 +178,8 @@ 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 @@ 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; } 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/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js index 2d74d93e..4ad5e527 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; @@ -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); } diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js index 2cccdf83..5c9091df 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 @@ -69,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/matrix/room/timeline/persistence/MemberWriter.js b/src/matrix/room/timeline/persistence/MemberWriter.js index 9143acc9..db649f68 100644 --- a/src/matrix/room/timeline/persistence/MemberWriter.js +++ b/src/matrix/room/timeline/persistence/MemberWriter.js @@ -73,7 +73,7 @@ export class MemberWriter { } } - async lookupMember(userId, timelineEvents, txn) { + async lookupMember(userId, event, timelineEvents, txn) { let member = this._cache.get(userId); if (!member) { const memberData = await txn.roomMembers.get(this._roomId, userId); @@ -84,16 +84,37 @@ export class MemberWriter { } if (!member) { // 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; - }); - 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); + // even if it is not the first event in the timeline. In this case, go look for + // 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]; + let matchingEvent; + if (e.type === MEMBER_EVENT_TYPE && e.state_key === userId) { + matchingEvent = e; + firstMemberEvent = matchingEvent; + } + if (!foundEvent) { + if (e.event_id === event.event_id) { + foundEvent = true; + } + } else if (matchingEvent) { + memberEventBefore = matchingEvent; + break; + } + } + // 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; @@ -222,5 +243,23 @@ 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.lookupMember(event.sender, event, [event], txn); + assert(member); + const change = await writer.writeTimelineMemberEvent(event, txn); + assert(change); + }, + "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.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 a7675993..dc2344e5 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.lookupMember(event.sender, event, events, txn); if (member) { entry.displayName = member.displayName; entry.avatarUrl = member.avatarUrl; @@ -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 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..b0eefe60 --- /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._inviteStore.delete(roomId); + } +} 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; + } +} 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/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); } } 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..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(); } @@ -128,12 +131,19 @@ 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 { constructor(map, _included) { this._included = _included; - this._sourceIterator = map.entries(); + this._sourceIterator = map[Symbol.iterator](); } next() { @@ -143,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; } @@ -151,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 => { - -// }, -// } -// } + // }, + } +} diff --git a/src/observable/map/JoinedMap.js b/src/observable/map/JoinedMap.js new file mode 100644 index 00000000..e5d0caa7 --- /dev/null +++ b/src/observable/map/JoinedMap.js @@ -0,0 +1,281 @@ +/* +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; + this._subscriptions = null; + } + + 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(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(source, key, value, params) { + if (!this._isKeyAtSourceOccluded(source, key)) { + this.emitUpdate(key, value, params); + } + } + + onReset() { + 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); + } + + 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; + this._encounteredKeys = new Set(); + } + + 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 { + 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._subscription = 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 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); + } + }; +} diff --git a/src/platform/web/Platform.js b/src/platform/web/Platform.js index a1e8e52c..fea17ba1 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"; @@ -34,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) { @@ -73,18 +75,56 @@ 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; } +// 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, 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,21 +138,26 @@ 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"); 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; } 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() { @@ -120,12 +165,16 @@ export class Platform { } loadOlm() { - return loadOlm(this._paths.olm); + return loadOlm(this._config.olm); + } + + get config() { + return this._config; } async loadOlmWorker() { if (!window.WebAssembly) { - return await loadOlmWorker(this._paths); + return await loadOlmWorker(this._config); } } @@ -133,6 +182,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()); @@ -150,7 +206,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, this.isIOS); } } @@ -199,4 +255,8 @@ export class Platform { get version() { return window.HYDROGEN_VERSION; } + + dispose() { + this._disposables.dispose(); + } } diff --git a/src/platform/web/dom/NotificationService.js b/src/platform/web/dom/NotificationService.js new file mode 100644 index 00000000..86b2ca69 --- /dev/null +++ b/src/platform/web/dom/NotificationService.js @@ -0,0 +1,104 @@ +/* +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, + // 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( + this._pushConfig.gatewayUrl, + this._pushConfig.appId, + pushkey, + data + ); + } + } + + async disablePush() { + const registration = await this._serviceWorkerHandler?.getRegistration(); + if (registration?.pushManager) { + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + await subscription.unsubscribe(); + } + } + } + + async isPushEnabled() { + const registration = await this._serviceWorkerHandler?.getRegistration(); + if (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 b05505ea..dd2c755f 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,13 @@ 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; + // do we have a new service worker waiting to activate? + if (this._registration.waiting && this._registration.active) { + this._proposeUpdate(); + } + console.log("Service Worker registered"); })(); } @@ -56,11 +60,24 @@ 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}); }); + } else if (data.type === "haltRequests") { + // 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); } } @@ -82,15 +99,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", 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 + // 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 +122,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 +139,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(); } @@ -167,4 +191,11 @@ export class ServiceWorkerHandler { async preventConcurrentSessionAccess(sessionId) { return this._sendAndWaitForReply("closeSession", {sessionId}); } + + async getRegistration() { + if (this._registrationPromise) { + await this._registrationPromise; + } + return this._registration; + } } 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/dom/request/fetch.js b/src/platform/web/dom/request/fetch.js index dd3b7949..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"; @@ -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); @@ -114,6 +121,8 @@ export function createFetchRequest(createTimeout) { 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/platform/web/service-worker.template.js b/src/platform/web/service-worker.js similarity index 54% rename from src/platform/web/service-worker.template.js rename to src/platform/web/service-worker.js index e3fa4651..29b124d9 100644 --- a/src/platform/web/service-worker.template.js +++ b/src/platform/web/service-worker.js @@ -17,9 +17,10 @@ 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 NOTIFICATION_BADGE_ICON = "assets/icon.png"; const unhashedCacheName = `hydrogen-assets-${GLOBAL_HASH}`; const hashedCacheName = `hydrogen-assets`; const mediaThumbnailCacheName = `hydrogen-media-thumbnails-v2`; @@ -37,6 +38,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 +68,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 +84,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 +97,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,16 +173,109 @@ self.addEventListener('message', (event) => { case "skipWaiting": self.skipWaiting(); break; + case "haltRequests": + event.waitUntil(haltRequests().finally(() => reply())); + break; case "closeSession": event.waitUntil( closeSession(event.data.payload.sessionId, event.source.id) - .then(() => reply()) + .finally(() => reply()) ); break; } } }); +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; + const sessionHash = `#/session/${sessionId}`; + const roomHash = `${sessionHash}/room/${roomId}`; + const clientWithSession = await findClient(async client => { + return await sendAndWaitForReply(client, "hasSessionOpen", {sessionId}); + }); + if (clientWithSession) { + console.log("notificationclick: client has session open, showing room there"); + // 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(); + } 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"); + const roomURL = new URL(`./${roomHash}`, baseURL).href; + await self.clients.openWindow(roomURL); + } +} + +self.addEventListener('notificationclick', event => { + event.notification.close(); + event.waitUntil(openClientFromNotif(event)); +}); + +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 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; + } + const newMessageNotifs = Array.from(await self.registration.getNotifications({tag: NOTIF_TAG_NEW_MESSAGE})); + const notifsForRoom = newMessageNotifs.filter(n => n.data.roomId === roomId); + const hasMultiNotification = notifsForRoom.some(n => n.data.multi); + 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 (hasSingleNotifsForRoom) { + 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"; + } + await self.registration.showNotification(label, { + body, + data: {sessionId, roomId, multi}, + tag: NOTIF_TAG_NEW_MESSAGE, + badge: NOTIFICATION_BADGE_ICON + }); + } + // 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 => { + event.waitUntil(handlePushNotification(event.data.json())); +}); async function closeSession(sessionId, requestingClientId) { const clients = await self.clients.matchAll(); @@ -192,6 +286,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) { @@ -203,3 +307,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; + } + } +} diff --git a/src/platform/web/ui/avatar.js b/src/platform/web/ui/avatar.js new file mode 100644 index 00000000..8845f887 --- /dev/null +++ b/src/platform/web/ui/avatar.js @@ -0,0 +1,123 @@ +/* +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._size) !== this._avatarUrl) { + this._avatarUrl = this.value.avatarUrl(this._size); + 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._size)) { + 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(this._size); + 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, extraClasses = undefined) { + const hasAvatar = !!vm.avatarUrl(size); + let avatarClasses = classNames({ + avatar: true, + [`size-${size}`]: true, + [`usercolor${vm.avatarColorNumber}`]: !hasAvatar, + }); + if (extraClasses) { + avatarClasses += ` ${extraClasses}`; + } + 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(size), 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/css/avatar.css b/src/platform/web/ui/css/avatar.css index 4c7d1074..6e68236f 100644 --- a/src/platform/web/ui/css/avatar.css +++ b/src/platform/web/ui/css/avatar.css @@ -15,25 +15,56 @@ 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; - letter-spacing: calc(var(--avatar-size) * -0.05); 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-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); + 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/layout.css b/src/platform/web/ui/css/layout.css index 60c3eafa..9670afad 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 */ @@ -100,10 +107,9 @@ main { position: relative; } -.RoomView { - min-width: 0; - min-height: 0; +.middle { display: flex; + flex-direction: column; } .SessionStatusView { @@ -117,10 +123,13 @@ 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 { - flex: 3; +.RoomView_body { + flex: 1; min-height: 0; min-width: 0; display: flex; @@ -128,7 +137,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/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 { diff --git a/src/platform/web/ui/css/themes/element/theme.css b/src/platform/web/ui/css/themes/element/theme.css index 4c41ad97..f38ba82b 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; } @@ -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; } @@ -106,6 +111,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 { @@ -276,7 +286,7 @@ a.button-action { } .RoomList .description { - align-items: baseline; + align-items: center; } .RoomList .name.unread { @@ -406,7 +416,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); @@ -518,10 +528,6 @@ ul.Timeline > li.messageStatus .message-container > p { align-items: center; } -.message-container .avatar { - --avatar-size: 25px; -} - .TextMessageView { width: 100%; } @@ -822,3 +828,72 @@ 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; +} + +.InviteView_dmAvatar { + align-self: center; +} + +.InviteView_inviter { + text-align: center; + margin: 24px 0px; +} + +.InviteView_inviter .avatar { + 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/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/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 14cb53ac..fd27eafd 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,35 +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; - 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() { @@ -87,24 +68,26 @@ export class TemplateView { 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"); - } - const parentProvidesUpdates = options && options.parentProvidesUpdates; - if (!parentProvidesUpdates) { - this._subscribe(); + 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); this._attach(); return this._root; } unmount() { this._detach(); - this._unsubscribe(); + super.unmount(); if (this._subViews) { for (const v of this._subViews) { v.unmount(); @@ -116,10 +99,6 @@ export class TemplateView { return this._root; } - _updateFromValue(changedProps) { - this.update(this._value, changedProps); - } - update(value) { this._value = value; if (this._bindings) { @@ -156,12 +135,32 @@ export class TemplateView { 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 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() { @@ -181,7 +180,7 @@ class TemplateBuilder { setAttribute(node, name, newValue); } }; - this._templateView._addBinding(binding); + this._addBinding(binding); binding(); } @@ -201,7 +200,7 @@ class TemplateBuilder { } }; - this._templateView._addBinding(binding); + this._addBinding(binding); return node; } @@ -257,7 +256,7 @@ class TemplateBuilder { node = newNode; } }; - this._templateView._addBinding(binding); + this._addBinding(binding); return node; } @@ -285,10 +284,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); } @@ -296,11 +295,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 +315,35 @@ 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) => { + 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; + }); + }); + } + + 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..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 @@ -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/RoomGridView.js b/src/platform/web/ui/session/RoomGridView.js index f2a73068..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"; @@ -30,9 +31,13 @@ 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); + 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/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/SessionView.js b/src/platform/web/ui/session/SessionView.js index fa7a492a..214db2a3 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"; @@ -29,21 +30,24 @@ 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)), 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) { + 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.`))); } }), t.mapView(vm => vm.lightboxViewModel, lightboxViewModel => lightboxViewModel ? new LightboxView(lightboxViewModel) : null) 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..09b9401f --- /dev/null +++ b/src/platform/web/ui/session/leftpanel/InviteTileView.js @@ -0,0 +1,44 @@ +/* +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"; +import {spinner} from "../../common.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.map(vm => vm.busy, busy => { + if (busy) { + return spinner(t); + } else { + return 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); + } + } )) ]); } 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/InviteView.js b/src/platform/web/ui/session/room/InviteView.js new file mode 100644 index 00000000..1d1e7db4 --- /dev/null +++ b/src/platform/web/ui/session/room/InviteView.js @@ -0,0 +1,74 @@ +/* +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 {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: "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.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`) + ), + ]) + ]) + ]); + } +} diff --git a/src/platform/web/ui/session/room/RoomView.js b/src/platform/web/ui/session/room/RoomView.js index 65f464d9..327af046 100644 --- a/src/platform/web/ui/session/room/RoomView.js +++ b/src/platform/web/ui/session/room/RoomView.js @@ -19,26 +19,26 @@ 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) { 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`}), - renderAvatar(t, 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), ]), + ]), + t.div({className: "RoomView_body"}, [ t.div({className: "RoomView_error"}, vm => vm.error), t.mapView(vm => vm.timelineViewModel, timelineViewModel => { return timelineViewModel ? 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)), ]) ]); } 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 => { 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/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); 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.`) ]) - })); + }); } diff --git a/src/platform/web/ui/session/settings/SettingsView.js b/src/platform/web/ui/session/settings/SettingsView.js index 02e57f5e..725f0e2b 100644 --- a/src/platform/web/ui/session/settings/SettingsView.js +++ b/src/platform/web/ui/session/settings/SettingsView.js @@ -27,34 +27,88 @@ 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), ]); }; + const settingNodes = []; + + settingNodes.push( + t.h3("Session"), + 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"), + 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(t, 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`); + } + }), + 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); + } + }) + ]); + }) + ); + + settingNodes.push( + t.h3("Preferences"), + row(t, vm.i18n`Scale down images when sending`, this._imageCompressionRange(t, vm)), + ); + settingNodes.push( + t.h3("Application"), + 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"), "."]), + ); + 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) ]); } 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

+
+ diff --git a/src/utils/timeout.js b/src/utils/timeout.js new file mode 100644 index 00000000..6bfc0d7e --- /dev/null +++ b/src/utils/timeout.js @@ -0,0 +1,87 @@ +/* +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 as MockClock} from "../mocks/Clock.js"; +import {Request as MockRequest} from "../mocks/Request.js"; +import {AbortError} from "../matrix/error.js"; +export function tests() { + return { + "ConnectionError on timeout": async assert => { + 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 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 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); + } + } + +} 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