diff --git a/.eslintrc.js b/.eslintrc.js index bc2a142c2d..99695b7a03 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,6 +22,8 @@ module.exports = { "files": ["src/**/*.{ts,tsx}"], "extends": ["matrix-org/ts"], "rules": { + // We're okay being explicit at the moment + "@typescript-eslint/no-empty-interface": "off", // We disable this while we're transitioning "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c460b4f81..c87f1c62e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,216 @@ +Changes in [3.14.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.14.0) (2021-02-16) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.14.0-rc.1...v3.14.0) + + * Upgrade to JS SDK 9.7.0 + * [Release] Use config for host signup branding + [\#5651](https://github.com/matrix-org/matrix-react-sdk/pull/5651) + +Changes in [3.14.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.14.0-rc.1) (2021-02-10) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.13.1...v3.14.0-rc.1) + + * Upgrade to JS SDK 9.7.0-rc.1 + * Translations update from Weblate + [\#5636](https://github.com/matrix-org/matrix-react-sdk/pull/5636) + * Add host signup modal with iframe + [\#5450](https://github.com/matrix-org/matrix-react-sdk/pull/5450) + * Fix duplication of codeblock elements + [\#5633](https://github.com/matrix-org/matrix-react-sdk/pull/5633) + * Handle undefined call stats + [\#5632](https://github.com/matrix-org/matrix-react-sdk/pull/5632) + * Avoid delayed displaying of sources in source picker + [\#5631](https://github.com/matrix-org/matrix-react-sdk/pull/5631) + * Give breadcrumbs toolbar an accessibility label. + [\#5628](https://github.com/matrix-org/matrix-react-sdk/pull/5628) + * Fix the %s in logs + [\#5627](https://github.com/matrix-org/matrix-react-sdk/pull/5627) + * Fix jumpy notifications settings UI + [\#5625](https://github.com/matrix-org/matrix-react-sdk/pull/5625) + * Improve displaying of code blocks + [\#5559](https://github.com/matrix-org/matrix-react-sdk/pull/5559) + * Fix desktop Matrix screen sharing and add a screen/window picker + [\#5525](https://github.com/matrix-org/matrix-react-sdk/pull/5525) + * Call "MatrixClientPeg.get()" only once in method "findOverrideMuteRule" + [\#5498](https://github.com/matrix-org/matrix-react-sdk/pull/5498) + * Close current modal when session is logged out + [\#5616](https://github.com/matrix-org/matrix-react-sdk/pull/5616) + * Switch room explorer list to CSS grid + [\#5551](https://github.com/matrix-org/matrix-react-sdk/pull/5551) + * Improve SSO login start screen and 3pid invite handling somewhat + [\#5622](https://github.com/matrix-org/matrix-react-sdk/pull/5622) + * Don't jump to bottom on reaction + [\#5621](https://github.com/matrix-org/matrix-react-sdk/pull/5621) + * Fix several profile settings oddities + [\#5620](https://github.com/matrix-org/matrix-react-sdk/pull/5620) + * Add option to hide the stickers button in the composer + [\#5530](https://github.com/matrix-org/matrix-react-sdk/pull/5530) + * Fix confusing right panel button behaviour + [\#5598](https://github.com/matrix-org/matrix-react-sdk/pull/5598) + * Fix jumping timestamp if hovering a message with e2e indicator bar + [\#5601](https://github.com/matrix-org/matrix-react-sdk/pull/5601) + * Fix avatar and trash alignment + [\#5614](https://github.com/matrix-org/matrix-react-sdk/pull/5614) + * Fix z-index of stickerpicker + [\#5617](https://github.com/matrix-org/matrix-react-sdk/pull/5617) + * Fix permalink via parsing for rooms + [\#5615](https://github.com/matrix-org/matrix-react-sdk/pull/5615) + * Fix "Terms and Conditions" checkbox alignment + [\#5613](https://github.com/matrix-org/matrix-react-sdk/pull/5613) + * Fix flair height after accent changes + [\#5611](https://github.com/matrix-org/matrix-react-sdk/pull/5611) + * Iterate Social Logins work around edge cases and branding + [\#5609](https://github.com/matrix-org/matrix-react-sdk/pull/5609) + * Lock widget room ID when added + [\#5607](https://github.com/matrix-org/matrix-react-sdk/pull/5607) + * Better errors for SSO failures + [\#5605](https://github.com/matrix-org/matrix-react-sdk/pull/5605) + * Increase language search bar width + [\#5549](https://github.com/matrix-org/matrix-react-sdk/pull/5549) + * Scroll to bottom on message_sent + [\#5565](https://github.com/matrix-org/matrix-react-sdk/pull/5565) + * Fix new rooms being titled 'Empty Room' + [\#5587](https://github.com/matrix-org/matrix-react-sdk/pull/5587) + * Fix saving the collapsed state of the left panel + [\#5593](https://github.com/matrix-org/matrix-react-sdk/pull/5593) + * Fix app-url hint in the e2e-test run script output + [\#5600](https://github.com/matrix-org/matrix-react-sdk/pull/5600) + * Fix RoomView re-mounting breaking peeking + [\#5602](https://github.com/matrix-org/matrix-react-sdk/pull/5602) + * Tweak a few room ID checks + [\#5592](https://github.com/matrix-org/matrix-react-sdk/pull/5592) + * Remove pills from event permalinks with text + [\#5575](https://github.com/matrix-org/matrix-react-sdk/pull/5575) + +Changes in [3.13.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.13.1) (2021-02-04) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.13.0...v3.13.1) + + * [Release] Fix z-index of stickerpicker + [\#5618](https://github.com/matrix-org/matrix-react-sdk/pull/5618) + +Changes in [3.13.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.13.0) (2021-02-03) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.13.0-rc.1...v3.13.0) + + * Upgrade to JS SDK 9.6.0 + * [Release] Fix flair height after accent changes + [\#5612](https://github.com/matrix-org/matrix-react-sdk/pull/5612) + * [Release] Iterate Social Logins work around edge cases and branding + [\#5610](https://github.com/matrix-org/matrix-react-sdk/pull/5610) + * [Release] Lock widget room ID when added + [\#5608](https://github.com/matrix-org/matrix-react-sdk/pull/5608) + * [Release] Better errors for SSO failures + [\#5606](https://github.com/matrix-org/matrix-react-sdk/pull/5606) + * [Release] Fix RoomView re-mounting breaking peeking + [\#5603](https://github.com/matrix-org/matrix-react-sdk/pull/5603) + +Changes in [3.13.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.13.0-rc.1) (2021-01-29) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.12.1...v3.13.0-rc.1) + + * Upgrade to JS SDK 9.6.0-rc.1 + * Translations update from Weblate + [\#5597](https://github.com/matrix-org/matrix-react-sdk/pull/5597) + * Support managed hybrid widgets from config + [\#5596](https://github.com/matrix-org/matrix-react-sdk/pull/5596) + * Add managed hybrid call widgets when supported + [\#5594](https://github.com/matrix-org/matrix-react-sdk/pull/5594) + * Tweak mobile guide toast copy + [\#5595](https://github.com/matrix-org/matrix-react-sdk/pull/5595) + * Improve SSO auth flow + [\#5578](https://github.com/matrix-org/matrix-react-sdk/pull/5578) + * Add optional mobile guide toast + [\#5586](https://github.com/matrix-org/matrix-react-sdk/pull/5586) + * Fix invisible text after logging out in the dark theme + [\#5588](https://github.com/matrix-org/matrix-react-sdk/pull/5588) + * Fix escape for cancelling replies + [\#5591](https://github.com/matrix-org/matrix-react-sdk/pull/5591) + * Update widget-api to beta.12 + [\#5589](https://github.com/matrix-org/matrix-react-sdk/pull/5589) + * Add commands for DM conversion + [\#5540](https://github.com/matrix-org/matrix-react-sdk/pull/5540) + * Run a UI refresh over the OIDC Exchange confirmation dialog + [\#5580](https://github.com/matrix-org/matrix-react-sdk/pull/5580) + * Allow stickerpickers the legacy "visibility" capability + [\#5581](https://github.com/matrix-org/matrix-react-sdk/pull/5581) + * Hide local video if it is muted + [\#5529](https://github.com/matrix-org/matrix-react-sdk/pull/5529) + * Don't use name width in reply thread for IRC layout + [\#5518](https://github.com/matrix-org/matrix-react-sdk/pull/5518) + * Update code_style.md + [\#5554](https://github.com/matrix-org/matrix-react-sdk/pull/5554) + * Fix Czech capital letters like ŠČŘ... + [\#5569](https://github.com/matrix-org/matrix-react-sdk/pull/5569) + * Add optional search shortcut + [\#5548](https://github.com/matrix-org/matrix-react-sdk/pull/5548) + * Fix Sudden 'find a room' UI shows up when the only room moves to favourites + [\#5584](https://github.com/matrix-org/matrix-react-sdk/pull/5584) + * Increase PersistedElement's z-index + [\#5568](https://github.com/matrix-org/matrix-react-sdk/pull/5568) + * Remove check that prevents Jitsi widgets from being unpinned + [\#5582](https://github.com/matrix-org/matrix-react-sdk/pull/5582) + * Fix Jitsi widgets causing localized tile crashes + [\#5583](https://github.com/matrix-org/matrix-react-sdk/pull/5583) + * Log candidates for calls + [\#5573](https://github.com/matrix-org/matrix-react-sdk/pull/5573) + * Upgrade deps 2021-01 + [\#5579](https://github.com/matrix-org/matrix-react-sdk/pull/5579) + * Fix "Continuing without email" dialog bug + [\#5566](https://github.com/matrix-org/matrix-react-sdk/pull/5566) + * Require registration for verification actions + [\#5574](https://github.com/matrix-org/matrix-react-sdk/pull/5574) + * Don't play the hangup sound when the call is answered from elsewhere + [\#5572](https://github.com/matrix-org/matrix-react-sdk/pull/5572) + * Move to newer base image for end-to-end tests + [\#5570](https://github.com/matrix-org/matrix-react-sdk/pull/5570) + * Update widgets in the room upon join + [\#5564](https://github.com/matrix-org/matrix-react-sdk/pull/5564) + * Update AuxPanel and related buttons when widgets change or on reload + [\#5563](https://github.com/matrix-org/matrix-react-sdk/pull/5563) + * Add VoIP user mapper + [\#5560](https://github.com/matrix-org/matrix-react-sdk/pull/5560) + * Improve styling of SSO Buttons for multiple IdPs + [\#5558](https://github.com/matrix-org/matrix-react-sdk/pull/5558) + * Fixes for the general tab in the room dialog + [\#5522](https://github.com/matrix-org/matrix-react-sdk/pull/5522) + * fix issue 16226 to allow switching back to default HS. + [\#5561](https://github.com/matrix-org/matrix-react-sdk/pull/5561) + * Support room-defined widget layouts + [\#5553](https://github.com/matrix-org/matrix-react-sdk/pull/5553) + * Change a bunch of strings from Recovery Key/Phrase to Security Key/Phrase + [\#5533](https://github.com/matrix-org/matrix-react-sdk/pull/5533) + * Give a bigger target area to AppsDrawer vertical resizer + [\#5557](https://github.com/matrix-org/matrix-react-sdk/pull/5557) + * Fix minimized left panel avatar alignment + [\#5493](https://github.com/matrix-org/matrix-react-sdk/pull/5493) + * Ensure component index has been written before renaming + [\#5556](https://github.com/matrix-org/matrix-react-sdk/pull/5556) + * Fixed continue button while selecting home-server + [\#5552](https://github.com/matrix-org/matrix-react-sdk/pull/5552) + * Wire up MSC2931 widget navigation + [\#5527](https://github.com/matrix-org/matrix-react-sdk/pull/5527) + * Various fixes for Bridge Info page (MSC2346) + [\#5454](https://github.com/matrix-org/matrix-react-sdk/pull/5454) + * Use room-specific listeners for message preview and community prototype + [\#5547](https://github.com/matrix-org/matrix-react-sdk/pull/5547) + * Fix some misc. React warnings when viewing timeline + [\#5546](https://github.com/matrix-org/matrix-react-sdk/pull/5546) + * Use device storage for allowed widgets if account data not supported + [\#5544](https://github.com/matrix-org/matrix-react-sdk/pull/5544) + * Fix incoming call box on dark theme + [\#5542](https://github.com/matrix-org/matrix-react-sdk/pull/5542) + * Convert DMRoomMap to typescript + [\#5541](https://github.com/matrix-org/matrix-react-sdk/pull/5541) + * Add in-call dialpad for DTMF sending + [\#5532](https://github.com/matrix-org/matrix-react-sdk/pull/5532) + +Changes in [3.12.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.12.1) (2021-01-26) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.12.0...v3.12.1) + + * Upgrade to JS SDK 9.5.1 + Changes in [3.12.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.12.0) (2021-01-18) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.12.0-rc.1...v3.12.0) diff --git a/code_style.md b/code_style.md index fe04d2cc3d..5747540a76 100644 --- a/code_style.md +++ b/code_style.md @@ -35,12 +35,6 @@ General Style - lowerCamelCase for functions and variables. - Single line ternary operators are fine. - UPPER_SNAKE_CASE for constants -- Single quotes for strings by default, for consistency with most JavaScript styles: - - ```javascript - "bad" // Bad - 'good' // Good - ``` - Use parentheses or `` ` `` instead of `\` for line continuation where ever possible - Open braces on the same line (consistent with Node): @@ -162,7 +156,14 @@ ECMAScript - Be careful mixing arrow functions and regular functions, eg. if one function in a promise chain is an arrow function, they probably all should be. - Apart from that, newer ES features should be used whenever the author deems them to be appropriate. -- Flow annotations are welcome and encouraged. + +TypeScript +---------- +- TypeScript is preferred over the use of JavaScript +- It's desirable to convert existing JavaScript files to TypeScript. TypeScript conversions should be done in small + chunks without functional changes to ease the review process. +- Use full type definitions for function parameters and return values. +- Avoid `any` types and `any` casts React ----- @@ -201,6 +202,8 @@ React this.state = { counter: 0 }; } ``` +- Prefer class components over function components and hooks (not a strict rule though) + - Think about whether your component really needs state: are you duplicating information in component state that could be derived from the model? diff --git a/package.json b/package.json index 1316b26030..d4f931d811 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.12.0", + "version": "3.14.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -54,48 +54,46 @@ "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080" }, "dependencies": { - "@babel/runtime": "^7.10.5", - "await-lock": "^2.0.1", - "blueimp-canvas-to-blob": "^3.27.0", + "@babel/runtime": "^7.12.5", + "await-lock": "^2.1.0", + "blueimp-canvas-to-blob": "^3.28.0", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", - "cheerio": "^1.0.0-rc.3", + "cheerio": "^1.0.0-rc.5", "classnames": "^2.2.6", - "commonmark": "^0.29.1", + "commonmark": "^0.29.3", "counterpart": "^0.18.6", - "diff-dom": "^4.1.6", + "diff-dom": "^4.2.2", "diff-match-patch": "^1.0.5", - "emojibase-data": "^5.0.1", - "emojibase-regex": "^4.0.1", + "emojibase-data": "^5.1.1", + "emojibase-regex": "^4.1.1", "escape-html": "^1.0.3", - "file-saver": "^1.3.8", - "filesize": "3.6.1", + "file-saver": "^2.0.5", + "filesize": "6.1.0", "flux": "2.1.1", - "focus-visible": "^5.1.0", - "fuse.js": "^2.7.4", + "focus-visible": "^5.2.0", "gfm.css": "^1.1.2", "glob-to-regexp": "^0.4.1", - "highlight.js": "^10.1.2", - "html-entities": "^1.3.1", - "is-ip": "^2.0.0", + "highlight.js": "^10.5.0", + "html-entities": "^1.4.0", + "is-ip": "^3.1.0", "katex": "^0.12.0", "linkifyjs": "^2.1.9", - "lodash": "^4.17.19", + "lodash": "^4.17.20", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "0.1.0-beta.11", + "matrix-widget-api": "^0.1.0-beta.13", "minimist": "^1.2.5", - "pako": "^1.0.11", - "parse5": "^5.1.1", + "pako": "^2.0.3", + "parse5": "^6.0.1", "png-chunks-extract": "^1.0.0", - "project-name-generator": "^2.1.7", "prop-types": "^15.7.2", "qrcode": "^1.4.4", - "qs": "^6.9.4", - "re-resizable": "^6.5.4", - "react": "^16.13.1", + "qs": "^6.9.6", + "re-resizable": "^6.9.0", + "react": "^16.14.0", "react-beautiful-dnd": "^4.0.1", - "react-dom": "^16.13.1", - "react-focus-lock": "^2.4.1", + "react-dom": "^16.14.0", + "react-focus-lock": "^2.5.0", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", "rfc4648": "^1.4.0", @@ -108,68 +106,71 @@ "zxcvbn": "^4.4.2" }, "devDependencies": { - "@babel/cli": "^7.10.5", - "@babel/core": "^7.10.5", - "@babel/parser": "^7.11.0", - "@babel/plugin-proposal-class-properties": "^7.10.4", - "@babel/plugin-proposal-decorators": "^7.10.5", - "@babel/plugin-proposal-export-default-from": "^7.10.4", - "@babel/plugin-proposal-numeric-separator": "^7.10.4", - "@babel/plugin-proposal-object-rest-spread": "^7.10.4", - "@babel/plugin-transform-flow-comments": "^7.10.4", - "@babel/plugin-transform-runtime": "^7.10.5", - "@babel/preset-env": "^7.10.4", - "@babel/preset-flow": "^7.10.4", - "@babel/preset-react": "^7.10.4", - "@babel/preset-typescript": "^7.10.4", - "@babel/register": "^7.10.5", - "@babel/traverse": "^7.11.0", - "@peculiar/webcrypto": "^1.1.3", - "@types/classnames": "^2.2.10", + "@babel/cli": "^7.12.10", + "@babel/core": "^7.12.10", + "@babel/parser": "^7.12.11", + "@babel/plugin-proposal-class-properties": "^7.12.1", + "@babel/plugin-proposal-decorators": "^7.12.12", + "@babel/plugin-proposal-export-default-from": "^7.12.1", + "@babel/plugin-proposal-numeric-separator": "^7.12.7", + "@babel/plugin-proposal-object-rest-spread": "^7.12.1", + "@babel/plugin-transform-flow-comments": "^7.12.1", + "@babel/plugin-transform-runtime": "^7.12.10", + "@babel/preset-env": "^7.12.11", + "@babel/preset-flow": "^7.12.1", + "@babel/preset-react": "^7.12.10", + "@babel/preset-typescript": "^7.12.7", + "@babel/register": "^7.12.10", + "@babel/traverse": "^7.12.12", + "@peculiar/webcrypto": "^1.1.4", + "@sinonjs/fake-timers": "^7.0.2", + "@types/classnames": "^2.2.11", "@types/counterpart": "^0.18.1", "@types/flux": "^3.1.9", "@types/jest": "^26.0.20", "@types/linkifyjs": "^2.1.3", - "@types/lodash": "^4.14.158", + "@types/lodash": "^4.14.168", "@types/modernizr": "^3.5.3", - "@types/node": "^12.12.51", + "@types/node": "^14.14.22", "@types/pako": "^1.0.1", - "@types/qrcode": "^1.3.4", + "@types/qrcode": "^1.3.5", "@types/react": "^16.9", - "@types/react-dom": "^16.9.8", + "@types/react-dom": "^16.9.10", "@types/react-transition-group": "^4.4.0", - "@types/sanitize-html": "^1.23.3", + "@types/sanitize-html": "^1.27.0", "@types/zxcvbn": "^4.4.0", - "@typescript-eslint/eslint-plugin": "^3.7.0", - "@typescript-eslint/parser": "^3.7.0", + "@typescript-eslint/eslint-plugin": "^4.14.0", + "@typescript-eslint/parser": "^4.14.0", "babel-eslint": "^10.1.0", - "babel-jest": "^24.9.0", - "chokidar": "^3.4.1", - "concurrently": "^4.1.2", + "babel-jest": "^26.6.3", + "chokidar": "^3.5.1", + "concurrently": "^5.3.0", "enzyme": "^3.11.0", - "enzyme-adapter-react-16": "^1.15.2", - "eslint": "7.5.0", - "eslint-config-matrix-org": "^0.1.2", + "enzyme-adapter-react-16": "^1.15.6", + "eslint": "7.18.0", + "eslint-config-matrix-org": "^0.2.0", "eslint-plugin-babel": "^5.3.1", - "eslint-plugin-flowtype": "^2.50.3", - "eslint-plugin-react": "^7.20.3", - "eslint-plugin-react-hooks": "^2.5.1", - "glob": "^5.0.15", - "jest": "^26.5.2", + "eslint-plugin-flowtype": "^5.2.0", + "eslint-plugin-react": "^7.22.0", + "eslint-plugin-react-hooks": "^4.2.0", + "glob": "^7.1.6", + "jest": "^26.6.3", "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom-sixteen": "^1.0.3", - "lolex": "^5.1.2", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", - "react-test-renderer": "^16.13.1", - "rimraf": "^2.7.1", - "stylelint": "^9.10.1", - "stylelint-config-standard": "^18.3.0", + "react-test-renderer": "^16.14.0", + "rimraf": "^3.0.2", + "stylelint": "^13.9.0", + "stylelint-config-standard": "^20.0.0", "stylelint-scss": "^3.18.0", - "typescript": "^3.9.7", + "typescript": "^4.1.3", "walk": "^2.3.14" }, + "resolutions": { + "**/@types/react": "^16.14" + }, "jest": { "testEnvironment": "./__test-utils__/environment.js", "testMatch": [ diff --git a/res/css/_common.scss b/res/css/_common.scss index 87336a1c03..6e9d252659 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -21,6 +21,11 @@ limitations under the License. $hover-transition: 0.08s cubic-bezier(.46, .03, .52, .96); // quadratic +$EventTile_e2e_state_indicator_width: 4px; + +$MessageTimestamp_width: 46px; /* 8 + 30 (avatar) + 8 */ +$MessageTimestamp_width_hover: calc($MessageTimestamp_width - 2 * $EventTile_e2e_state_indicator_width); + :root { font-size: 10px; } diff --git a/res/css/_components.scss b/res/css/_components.scss index a6e1f81583..006bac09c9 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -71,6 +71,7 @@ @import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_FeedbackDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; +@import "./views/dialogs/_HostSignupDialog.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; @import "./views/dialogs/_KeyboardShortcutsDialog.scss"; @@ -106,6 +107,7 @@ @import "./views/elements/_AddressTile.scss"; @import "./views/elements/_DesktopBuildsNotice.scss"; @import "./views/elements/_DirectorySearchBox.scss"; +@import "./views/elements/_DesktopCapturerSourcePicker.scss"; @import "./views/elements/_Dropdown.scss"; @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; diff --git a/res/css/structures/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss index 4df651d7b6..6e2d99bb37 100644 --- a/res/css/structures/_LeftPanelWidget.scss +++ b/res/css/structures/_LeftPanelWidget.scss @@ -134,7 +134,7 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); background: $muted-fg-color; } } diff --git a/res/css/structures/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index 29e6fecd34..89cb21b7a6 100644 --- a/res/css/structures/_RoomDirectory.scss +++ b/res/css/structures/_RoomDirectory.scss @@ -64,28 +64,23 @@ limitations under the License. } .mx_RoomDirectory_table { - font-size: $font-12px; color: $primary-fg-color; - width: 100%; + display: grid; + font-size: $font-12px; + grid-template-columns: max-content auto max-content max-content max-content; + row-gap: 24px; text-align: left; - table-layout: fixed; + width: 100%; } .mx_RoomDirectory_roomAvatar { - width: 32px; - padding-right: 14px; - vertical-align: top; -} - -.mx_RoomDirectory_roomDescription { - padding-bottom: 16px; + padding: 2px 14px 0 0; } .mx_RoomDirectory_roomMemberCount { + align-self: center; color: $light-fg-color; - width: 60px; - padding: 0 10px; - text-align: center; + padding: 3px 10px 0; &::before { background-color: $light-fg-color; @@ -105,8 +100,7 @@ limitations under the License. } .mx_RoomDirectory_join, .mx_RoomDirectory_preview { - width: 80px; - text-align: center; + align-self: center; white-space: nowrap; } diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 572c7166d2..36bf96359b 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -219,7 +219,7 @@ hr.mx_RoomView_myReadMarker { position: relative; top: -1px; z-index: 1; - transition: width 400ms easeInSine 1s, opacity 400ms easeInSine 1s; + transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; width: 99%; opacity: 1; } diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 0673ebb058..2a4453df70 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -128,7 +128,7 @@ limitations under the License. } .mx_UserMenu_contextMenu { - width: 247px; + width: 258px; // These override the styles already present on the user menu rather than try to // define a new menu. They are specifically for the stacked menu when a community @@ -272,6 +272,9 @@ limitations under the License. .mx_UserMenu_iconHome::before { mask-image: url('$(res)/img/element-icons/roomlist/home.svg'); } + .mx_UserMenu_iconHosting::before { + mask-image: url('$(res)/img/element-icons/brands/element.svg'); + } .mx_UserMenu_iconBell::before { mask-image: url('$(res)/img/element-icons/notifications.svg'); diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 8f0c758e7a..90dca32e48 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -34,7 +34,7 @@ limitations under the License. h3 { font-size: $font-14px; font-weight: 600; - color: $authpage-primary-color; + color: $authpage-secondary-color; } h3.mx_AuthBody_centered { diff --git a/res/css/views/auth/_AuthHeader.scss b/res/css/views/auth/_AuthHeader.scss index b1372affee..13d5195160 100644 --- a/res/css/views/auth/_AuthHeader.scss +++ b/res/css/views/auth/_AuthHeader.scss @@ -18,7 +18,7 @@ limitations under the License. display: flex; flex-direction: column; width: 206px; - padding: 25px 40px; + padding: 25px 25px; box-sizing: border-box; } diff --git a/res/css/views/auth/_AuthHeaderLogo.scss b/res/css/views/auth/_AuthHeaderLogo.scss index 917dcabf67..86f0313b68 100644 --- a/res/css/views/auth/_AuthHeaderLogo.scss +++ b/res/css/views/auth/_AuthHeaderLogo.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_AuthHeaderLogo { margin-top: 15px; flex: 1; - padding: 0 10px; + padding: 0 25px; } .mx_AuthHeaderLogo img { diff --git a/res/css/views/auth/_InteractiveAuthEntryComponents.scss b/res/css/views/auth/_InteractiveAuthEntryComponents.scss index 5df73b139f..ffaad3cd7a 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -83,7 +83,10 @@ limitations under the License. } .mx_InteractiveAuthEntryComponents_termsPolicy { - display: block; + display: flex; + flex-direction: row; + justify-content: start; + align-items: center; } .mx_InteractiveAuthEntryComponents_passwordSection { diff --git a/res/css/views/auth/_LanguageSelector.scss b/res/css/views/auth/_LanguageSelector.scss index 781561f876..885ee7f30d 100644 --- a/res/css/views/auth/_LanguageSelector.scss +++ b/res/css/views/auth/_LanguageSelector.scss @@ -23,6 +23,7 @@ limitations under the License. font-size: $font-14px; font-weight: 600; color: $authpage-lang-color; + width: auto; } .mx_AuthBody_language .mx_Dropdown_arrow { diff --git a/res/css/views/auth/_Welcome.scss b/res/css/views/auth/_Welcome.scss index f0e2b3de33..894174d6e2 100644 --- a/res/css/views/auth/_Welcome.scss +++ b/res/css/views/auth/_Welcome.scss @@ -18,7 +18,6 @@ limitations under the License. display: flex; flex-direction: column; align-items: center; - &.mx_WelcomePage_registrationDisabled { .mx_ButtonCreateAccount { display: none; @@ -27,6 +26,6 @@ limitations under the License. } .mx_Welcome .mx_AuthBody_language { - width: 120px; + width: 160px; margin-bottom: 10px; } diff --git a/res/css/views/dialogs/_HostSignupDialog.scss b/res/css/views/dialogs/_HostSignupDialog.scss new file mode 100644 index 0000000000..1378ac9053 --- /dev/null +++ b/res/css/views/dialogs/_HostSignupDialog.scss @@ -0,0 +1,138 @@ +/* +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. +*/ + +.mx_HostSignupDialog { + width: 90vw; + max-width: 580px; + height: 80vh; + max-height: 600px; + + .mx_HostSignupDialog_info { + text-align: center; + + .mx_HostSignupDialog_content_top { + margin-bottom: 24px; + } + + .mx_HostSignupDialog_paragraphs { + text-align: left; + padding-left: 25%; + padding-right: 25%; + } + + .mx_HostSignupDialog_buttons { + margin-bottom: 24px; + display: flex; + justify-content: center; + + button { + padding: 12px; + margin: 0 16px; + } + } + + .mx_HostSignupDialog_footer { + display: flex; + justify-content: center; + align-items: baseline; + + img { + padding-right: 5px; + } + } + } + + iframe { + width: 100%; + height: 100%; + border: none; + background-color: #fff; + min-height: 540px; + } +} + +.mx_HostSignupDialog_text_dark { + color: $primary-fg-color; +} + +.mx_HostSignupDialog_text_light { + color: $secondary-fg-color; +} + +.mx_HostSignup_maximize_button { + mask: url('$(res)/img/feather-customised/maximise.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 14px; + height: 14px; + background-color: $dialog-close-fg-color; + cursor: pointer; + position: absolute; + top: 10px; + right: 10px; +} + +.mx_HostSignup_minimize_button { + mask: url('$(res)/img/feather-customised/minimise.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 14px; + height: 14px; + background-color: $dialog-close-fg-color; + cursor: pointer; + position: absolute; + top: 10px; + right: 25px; +} + +.mx_HostSignup_persisted { + width: 90vw; + max-width: 580px; + height: 80vh; + max-height: 600px; + top: 0; + left: 0; + position: fixed; + display: none; +} + +.mx_HostSignupDialog_minimized { + position: fixed; + bottom: 80px; + right: 26px; + width: 314px; + height: 217px; + overflow: hidden; + + &.mx_Dialog { + padding: 12px; + } + + .mx_Dialog_title { + text-align: left !important; + padding-left: 20px; + font-size: $font-15px; + } + + iframe { + width: 100%; + height: 100%; + border: none; + background-color: #fff; + } +} diff --git a/res/css/views/elements/_DesktopCapturerSourcePicker.scss b/res/css/views/elements/_DesktopCapturerSourcePicker.scss new file mode 100644 index 0000000000..69dde5925e --- /dev/null +++ b/res/css/views/elements/_DesktopCapturerSourcePicker.scss @@ -0,0 +1,72 @@ +/* +Copyright 2021 Šimon Brandner + +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. +*/ + +.mx_desktopCapturerSourcePicker { + overflow: hidden; +} + +.mx_desktopCapturerSourcePicker_tabLabels { + display: flex; + padding: 0 0 8px 0; +} + +.mx_desktopCapturerSourcePicker_tabLabel, +.mx_desktopCapturerSourcePicker_tabLabel_selected { + width: 100%; + text-align: center; + border-radius: 8px; + padding: 8px 0; + font-size: $font-13px; +} + +.mx_desktopCapturerSourcePicker_tabLabel_selected { + background-color: $tab-label-active-bg-color; + color: $tab-label-active-fg-color; +} + +.mx_desktopCapturerSourcePicker_panel { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: flex-start; + height: 500px; + overflow: overlay; +} + +.mx_desktopCapturerSourcePicker_stream_button { + display: flex; + flex-direction: column; + margin: 8px; + border-radius: 4px; +} + +.mx_desktopCapturerSourcePicker_stream_button:hover, +.mx_desktopCapturerSourcePicker_stream_button:focus { + background: $roomtile-selected-bg-color; +} + +.mx_desktopCapturerSourcePicker_stream_thumbnail { + margin: 4px; + width: 312px; +} + +.mx_desktopCapturerSourcePicker_stream_name { + margin: 0 4px; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + width: 312px; +} diff --git a/res/css/views/elements/_SSOButtons.scss b/res/css/views/elements/_SSOButtons.scss index c61247655c..e02816780f 100644 --- a/res/css/views/elements/_SSOButtons.scss +++ b/res/css/views/elements/_SSOButtons.scss @@ -28,8 +28,14 @@ limitations under the License. .mx_SSOButton { position: relative; width: 100%; - padding-left: 32px; - padding-right: 32px; + padding: 7px 32px; + text-align: center; + border-radius: 8px; + display: inline-block; + font-size: $font-14px; + font-weight: $font-semi-bold; + border: 1px solid $input-border-color; + color: $primary-fg-color; > img { object-fit: contain; @@ -39,6 +45,16 @@ limitations under the License. } } + .mx_SSOButton_default { + color: $button-primary-bg-color; + background-color: $button-secondary-bg-color; + border-color: $button-primary-bg-color; + } + .mx_SSOButton_default.mx_SSOButton_primary { + color: $button-primary-fg-color; + background-color: $button-primary-bg-color; + } + .mx_SSOButton_mini { box-sizing: border-box; width: 50px; // 48px + 1px border on all sides diff --git a/res/css/views/elements/_ServerPicker.scss b/res/css/views/elements/_ServerPicker.scss index ae1e445a9f..188eb5d655 100644 --- a/res/css/views/elements/_ServerPicker.scss +++ b/res/css/views/elements/_ServerPicker.scss @@ -59,7 +59,7 @@ limitations under the License. } .mx_ServerPicker_server { - color: $primary-fg-color; + color: $authpage-primary-color; grid-column: 1; grid-row: 2; margin-bottom: 16px; diff --git a/res/css/views/messages/_MVideoBody.scss b/res/css/views/messages/_MVideoBody.scss index ac3491bc8f..2be15447f7 100644 --- a/res/css/views/messages/_MVideoBody.scss +++ b/res/css/views/messages/_MVideoBody.scss @@ -17,7 +17,7 @@ limitations under the License. span.mx_MVideoBody { video.mx_MVideoBody { max-width: 100%; - height: auto; + max-height: 300px; border-radius: 4px; } } diff --git a/res/css/views/messages/_RedactedBody.scss b/res/css/views/messages/_RedactedBody.scss index e4ab0c0835..600ac0c6b7 100644 --- a/res/css/views/messages/_RedactedBody.scss +++ b/res/css/views/messages/_RedactedBody.scss @@ -30,7 +30,7 @@ limitations under the License. mask-size: contain; content: ''; position: absolute; - top: 2px; + top: 1px; left: 0; } } diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index 076932ee97..66825030e0 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -35,13 +35,13 @@ limitations under the License. mask-size: auto 12px; visibility: hidden; background-color: $accent-color; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); } &.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle { mask-position: 0 bottom; margin-bottom: 7px; - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); + mask-image: url('$(res)/img/feather-customised/minimise.svg'); } &:hover .mx_ViewSourceEvent_toggle { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index e2cff70841..4db16217e7 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -26,7 +26,7 @@ $left-gutter: 64px; } .mx_EventTile.mx_EventTile_info { - padding-top: 0px; + padding-top: 1px; } .mx_EventTile_avatar { @@ -37,7 +37,7 @@ $left-gutter: 64px; } .mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { - top: $font-8px; + top: $font-6px; left: $left-gutter; } @@ -74,7 +74,6 @@ $left-gutter: 64px; margin-left: 5px; display: inline-block; vertical-align: top; - height: 16px; overflow: hidden; user-select: none; @@ -415,15 +414,15 @@ $left-gutter: 64px; } .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line { - border-left: $e2e-verified-color 4px solid; + border-left: $e2e-verified-color $EventTile_e2e_state_indicator_width solid; } .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line { - border-left: $e2e-unverified-color 4px solid; + border-left: $e2e-unverified-color $EventTile_e2e_state_indicator_width solid; } .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line { - border-left: $e2e-unknown-color 4px solid; + border-left: $e2e-unknown-color $EventTile_e2e_state_indicator_width solid; } .mx_EventTile:hover.mx_EventTile_verified.mx_EventTile_info .mx_EventTile_line, @@ -441,8 +440,7 @@ $left-gutter: 64px; .mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line > a > .mx_MessageTimestamp, .mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line > a > .mx_MessageTimestamp { - left: 3px; - width: auto; + width: $MessageTimestamp_width_hover; } // Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies) @@ -487,7 +485,6 @@ $left-gutter: 64px; // https://github.com/vector-im/vector-web/issues/754 overflow-x: overlay; overflow-y: visible; - max-height: 30vh; } code { @@ -496,6 +493,22 @@ $left-gutter: 64px; } } +.mx_EventTile_lineNumbers { + float: left; + margin: 0 0.5em 0 -1.5em; + color: gray; +} + +.mx_EventTile_lineNumber { + text-align: right; + display: block; + padding-left: 1em; +} + +.mx_EventTile_collapsedCodeBlock { + max-height: 30vh; +} + .mx_EventTile:hover .mx_EventTile_body pre, .mx_EventTile.focus-visible:focus-within .mx_EventTile_body pre { border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter @@ -507,21 +520,42 @@ $left-gutter: 64px; } // Inserted adjacent to
 blocks, (See TextualBody)
-.mx_EventTile_copyButton {
+.mx_EventTile_button {
     position: absolute;
     display: inline-block;
     visibility: hidden;
     cursor: pointer;
     top: 6px;
-    right: 6px;
+    right: 12px;
     width: 19px;
     height: 19px;
-    mask-image: url($copy-button-url);
     background-color: $message-action-bar-fg-color;
 }
+.mx_EventTile_buttonBottom {
+    top: 31px;
+}
+.mx_EventTile_copyButton {
+    mask-image: url($copy-button-url);
+}
+.mx_EventTile_collapseButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($collapse-button-url);
+}
+.mx_EventTile_expandButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($expand-button-url);
+}
 
 .mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_copyButton,
-.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton {
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_expandButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_expandButton {
     visibility: visible;
 }
 
diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss
index 2b447be44a..543e6ed685 100644
--- a/res/css/views/rooms/_GroupLayout.scss
+++ b/res/css/views/rooms/_GroupLayout.scss
@@ -20,7 +20,7 @@ $left-gutter: 64px;
 .mx_GroupLayout {
     .mx_EventTile {
         > .mx_SenderProfile {
-            line-height: $font-17px;
+            line-height: $font-20px;
             padding-left: $left-gutter;
         }
 
@@ -34,11 +34,11 @@ $left-gutter: 64px;
 
         .mx_MessageTimestamp {
             position: absolute;
-            width: 46px; /* 8 + 30 (avatar) + 8 */
+            width: $MessageTimestamp_width;
         }
 
         .mx_EventTile_line, .mx_EventTile_reply {
-            padding-top: 3px;
+            padding-top: 1px;
             padding-bottom: 3px;
             line-height: $font-22px;
         }
diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss
index ece547d02b..792c2f1f58 100644
--- a/res/css/views/rooms/_IRCLayout.scss
+++ b/res/css/views/rooms/_IRCLayout.scss
@@ -207,6 +207,17 @@ $irc-line-height: $font-18px;
             width: unset;
             max-width: var(--name-width);
         }
+
+        .mx_SenderProfile_hover {
+            background: transparent;
+
+            > span {
+                > .mx_SenderProfile_name,
+                > .mx_SenderProfile_aux {
+                    min-width: inherit;
+                }
+            }
+        }
     }
 
     .mx_ProfileResizer {
diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss
index 022cf3ed28..5310bd3bbb 100644
--- a/res/css/views/rooms/_LinkPreviewWidget.scss
+++ b/res/css/views/rooms/_LinkPreviewWidget.scss
@@ -19,6 +19,8 @@ limitations under the License.
     margin-right: 15px;
     margin-bottom: 15px;
     display: flex;
+    flex-direction: column;
+    max-width: 360px;
     border-left: 4px solid $preview-widget-bar-color;
     color: $preview-widget-fg-color;
 }
@@ -55,6 +57,9 @@ limitations under the License.
     cursor: pointer;
     width: 18px;
     height: 18px;
+    padding: 0px 5px 5px 5px;
+    margin-left: auto;
+    margin-right: 0px;
 
     img {
         flex: 0 0 40px;
diff --git a/res/css/views/rooms/_RoomSublist.scss b/res/css/views/rooms/_RoomSublist.scss
index 27c7c7d0f7..92a475694e 100644
--- a/res/css/views/rooms/_RoomSublist.scss
+++ b/res/css/views/rooms/_RoomSublist.scss
@@ -197,6 +197,9 @@ limitations under the License.
 
         .mx_RoomSublist_resizerHandles {
             flex: 0 0 4px;
+            display: flex;
+            justify-content: center;
+            width: 100%;
         }
 
         // Class name comes from the ResizableBox component
@@ -207,17 +210,12 @@ limitations under the License.
             border-radius: 3px;
 
             // Override styles from library
-            width: unset !important;
+            max-width: 64px;
             height: 4px !important; // Update RESIZE_HANDLE_HEIGHT if this changes
 
             // This is positioned directly below the 'show more' button.
-            position: absolute;
+            position: relative !important;
             bottom: 0 !important; // override from library
-
-            // Together, these make the bar 64px wide
-            // These are also overridden from the library
-            left: calc(50% - 32px) !important;
-            right: calc(50% - 32px) !important;
         }
 
         &:hover, &.mx_RoomSublist_hasMenuOpen {
diff --git a/res/css/views/settings/_Notifications.scss b/res/css/views/settings/_Notifications.scss
index e6d09b9a2a..77a7bc5b68 100644
--- a/res/css/views/settings/_Notifications.scss
+++ b/res/css/views/settings/_Notifications.scss
@@ -64,6 +64,7 @@ limitations under the License.
 
 .mx_UserNotifSettings_notifTable {
     display: table;
+    position: relative;
 }
 
 .mx_UserNotifSettings_notifTable .mx_Spinner {
diff --git a/res/img/element-icons/brands/apple.svg b/res/img/element-icons/brands/apple.svg
new file mode 100644
index 0000000000..308c3c5d5a
--- /dev/null
+++ b/res/img/element-icons/brands/apple.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/brands/element.svg b/res/img/element-icons/brands/element.svg
new file mode 100644
index 0000000000..6861de0955
--- /dev/null
+++ b/res/img/element-icons/brands/element.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/facebook.svg b/res/img/element-icons/brands/facebook.svg
new file mode 100644
index 0000000000..2742785424
--- /dev/null
+++ b/res/img/element-icons/brands/facebook.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/github.svg b/res/img/element-icons/brands/github.svg
new file mode 100644
index 0000000000..503719520b
--- /dev/null
+++ b/res/img/element-icons/brands/github.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/brands/gitlab.svg b/res/img/element-icons/brands/gitlab.svg
new file mode 100644
index 0000000000..df84c41e21
--- /dev/null
+++ b/res/img/element-icons/brands/gitlab.svg
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/google.svg b/res/img/element-icons/brands/google.svg
new file mode 100644
index 0000000000..1b0b19ae5b
--- /dev/null
+++ b/res/img/element-icons/brands/google.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/res/img/element-icons/brands/twitter.svg b/res/img/element-icons/brands/twitter.svg
new file mode 100644
index 0000000000..43eb825a59
--- /dev/null
+++ b/res/img/element-icons/brands/twitter.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/feather-customised/widget/maximise.svg b/res/img/feather-customised/maximise.svg
similarity index 100%
rename from res/img/feather-customised/widget/maximise.svg
rename to res/img/feather-customised/maximise.svg
diff --git a/res/img/feather-customised/widget/minimise.svg b/res/img/feather-customised/minimise.svg
similarity index 100%
rename from res/img/feather-customised/widget/minimise.svg
rename to res/img/feather-customised/minimise.svg
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index 08fe2e9f57..a878aa3cdd 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -258,6 +258,12 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
 // markdown overrides:
 .mx_EventTile_content .markdown-body pre:hover {
     border-color: #808080 !important; // inverted due to rules below
+    scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // copied from light theme due to inversion below
+    // the code above works only in Firefox, this is for other browsers
+    // see https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color
+    &::-webkit-scrollbar-thumb {
+        background-color: rgba(0, 0, 0, 0.2); // copied from light theme due to inversion below
+    }
 }
 .mx_EventTile_content .markdown-body {
     pre, code {
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 085d6d7f10..a740ba155c 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -237,7 +237,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
-
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 4cfeeae05e..1c89d83c01 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -237,6 +237,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
diff --git a/scripts/ci/Dockerfile b/scripts/ci/Dockerfile
index 5351291f29..3fdd0d7bf6 100644
--- a/scripts/ci/Dockerfile
+++ b/scripts/ci/Dockerfile
@@ -1,8 +1,7 @@
 # Update on docker hub with the following commands in the directory of this file:
 # docker build -t vectorim/element-web-ci-e2etests-env:latest .
-# docker log
 # docker push vectorim/element-web-ci-e2etests-env:latest
-FROM node:10
+FROM node:14-buster
 RUN apt-get update
 RUN apt-get -y install build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime
 # dependencies for chrome (installed by puppeteer)
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 2a28c8e43f..28f22780a2 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -37,6 +37,7 @@ import CountlyAnalytics from "../CountlyAnalytics";
 import UserActivity from "../UserActivity";
 import {ModalWidgetStore} from "../stores/ModalWidgetStore";
 import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
+import VoipUserMapper from "../VoipUserMapper";
 
 declare global {
     interface Window {
@@ -66,6 +67,7 @@ declare global {
         mxCountlyAnalytics: typeof CountlyAnalytics;
         mxUserActivity: UserActivity;
         mxModalWidgetStore: ModalWidgetStore;
+        mxVoipUserMapper: VoipUserMapper;
     }
 
     interface Document {
diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts
index c301aa6a10..d0d5e60ce8 100644
--- a/src/BasePlatform.ts
+++ b/src/BasePlatform.ts
@@ -30,6 +30,7 @@ import {idbLoad, idbSave, idbDelete} from "./utils/StorageManager";
 
 export const SSO_HOMESERVER_URL_KEY = "mx_sso_hs_url";
 export const SSO_ID_SERVER_URL_KEY = "mx_sso_is_url";
+export const SSO_IDP_ID_KEY = "mx_sso_idp_id";
 
 export enum UpdateCheckStatus {
     Checking = "CHECKING",
@@ -56,7 +57,7 @@ export default abstract class BasePlatform {
         this.startUpdateCheck = this.startUpdateCheck.bind(this);
     }
 
-    abstract async getConfig(): Promise<{}>;
+    abstract getConfig(): Promise<{}>;
 
     abstract getDefaultDeviceDisplayName(): string;
 
@@ -258,6 +259,9 @@ export default abstract class BasePlatform {
         if (mxClient.getIdentityServerUrl()) {
             localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl());
         }
+        if (idpId) {
+            localStorage.setItem(SSO_IDP_ID_KEY, idpId);
+        }
         const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin);
         window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
     }
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
index bc57f25813..f73424fd4d 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -64,7 +64,6 @@ import dis from './dispatcher/dispatcher';
 import WidgetUtils from './utils/WidgetUtils';
 import WidgetEchoStore from './stores/WidgetEchoStore';
 import SettingsStore from './settings/SettingsStore';
-import {generateHumanReadableId} from "./utils/NamingUtils";
 import {Jitsi} from "./widgets/Jitsi";
 import {WidgetType} from "./widgets/WidgetType";
 import {SettingLevel} from "./settings/SettingLevel";
@@ -82,10 +81,21 @@ import CountlyAnalytics from "./CountlyAnalytics";
 import {UIFeature} from "./settings/UIFeature";
 import { CallError } from "matrix-js-sdk/src/webrtc/call";
 import { logger } from 'matrix-js-sdk/src/logger';
+import DesktopCapturerSourcePicker from "./components/views/elements/DesktopCapturerSourcePicker"
 import { Action } from './dispatcher/actions';
-import { roomForVirtualRoom, getOrCreateVirtualRoomForRoom } from './VoipUserMapper';
+import VoipUserMapper from './VoipUserMapper';
+import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
+import { randomString } from "matrix-js-sdk/src/randomstring";
 
-const CHECK_PSTN_SUPPORT_ATTEMPTS = 3;
+export const PROTOCOL_PSTN = 'm.protocol.pstn';
+export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn';
+export const PROTOCOL_SIP_NATIVE = 'im.vector.protocol.sip_native';
+export const PROTOCOL_SIP_VIRTUAL = 'im.vector.protocol.sip_virtual';
+
+const CHECK_PROTOCOLS_ATTEMPTS = 3;
+// Event type for room account data and room creation content used to mark rooms as virtual rooms
+// (and store the ID of their native room)
+export const VIRTUAL_ROOM_EVENT_TYPE = 'im.vector.is_virtual_room';
 
 enum AudioID {
     Ring = 'ringAudio',
@@ -94,6 +104,29 @@ enum AudioID {
     Busy = 'busyAudio',
 }
 
+interface ThirdpartyLookupResponseFields {
+    /* eslint-disable camelcase */
+
+    // im.vector.sip_native
+    virtual_mxid?: string;
+    is_virtual?: boolean;
+
+    // im.vector.sip_virtual
+    native_mxid?: string;
+    is_native?: boolean;
+
+    // common
+    lookup_success?: boolean;
+
+    /* eslint-enable camelcase */
+}
+
+interface ThirdpartyLookupResponse {
+    userid: string,
+    protocol: string,
+    fields: ThirdpartyLookupResponseFields,
+}
+
 // Unlike 'CallType' in js-sdk, this one includes screen sharing
 // (because a screen sharing call is only a screen sharing call to the caller,
 // to the callee it's just a video call, at least as far as the current impl
@@ -124,7 +157,12 @@ export default class CallHandler {
     private audioPromises = new Map>();
     private dispatcherRef: string = null;
     private supportsPstnProtocol = null;
+    private pstnSupportPrefixed = null; // True if the server only support the prefixed pstn protocol
+    private supportsSipNativeVirtual = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
     private pstnSupportCheckTimer: NodeJS.Timeout; // number actually because we're in the browser
+    // For rooms we've been invited to, true if they're from virtual user, false if we've checked and they aren't.
+    private invitedRoomsAreVirtual = new Map();
+    private invitedRoomCheckInProgress = false;
 
     static sharedInstance() {
         if (!window.mxCallHandler) {
@@ -138,9 +176,9 @@ export default class CallHandler {
      * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
      * if a voip_mxid_translate_pattern is set in the config)
      */
-    public static roomIdForCall(call: MatrixCall) {
+    public static roomIdForCall(call: MatrixCall): string {
         if (!call) return null;
-        return roomForVirtualRoom(call.roomId) || call.roomId;
+        return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
     }
 
     start() {
@@ -161,7 +199,7 @@ export default class CallHandler {
             MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
         }
 
-        this.checkForPstnSupport(CHECK_PSTN_SUPPORT_ATTEMPTS);
+        this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
     }
 
     stop() {
@@ -175,33 +213,73 @@ export default class CallHandler {
         }
     }
 
-    private async checkForPstnSupport(maxTries) {
+    private async checkProtocols(maxTries) {
         try {
             const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
-            if (protocols['im.vector.protocol.pstn'] !== undefined) {
-                this.supportsPstnProtocol = protocols['im.vector.protocol.pstn'];
-            } else if (protocols['m.protocol.pstn'] !== undefined) {
-                this.supportsPstnProtocol = protocols['m.protocol.pstn'];
+
+            if (protocols[PROTOCOL_PSTN] !== undefined) {
+                this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN]);
+                if (this.supportsPstnProtocol) this.pstnSupportPrefixed = false;
+            } else if (protocols[PROTOCOL_PSTN_PREFIXED] !== undefined) {
+                this.supportsPstnProtocol = Boolean(protocols[PROTOCOL_PSTN_PREFIXED]);
+                if (this.supportsPstnProtocol) this.pstnSupportPrefixed = true;
             } else {
                 this.supportsPstnProtocol = null;
             }
+
             dis.dispatch({action: Action.PstnSupportUpdated});
+
+            if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
+                this.supportsSipNativeVirtual = Boolean(
+                    protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
+                );
+            }
+
+            dis.dispatch({action: Action.VirtualRoomSupportUpdated});
         } catch (e) {
             if (maxTries === 1) {
-                console.log("Failed to check for pstn protocol support and no retries remain: assuming no support", e);
+                console.log("Failed to check for protocol support and no retries remain: assuming no support", e);
             } else {
-                console.log("Failed to check for pstn protocol support: will retry", e);
+                console.log("Failed to check for protocol support: will retry", e);
                 this.pstnSupportCheckTimer = setTimeout(() => {
-                    this.checkForPstnSupport(maxTries - 1);
+                    this.checkProtocols(maxTries - 1);
                 }, 10000);
             }
         }
     }
 
-    getSupportsPstnProtocol() {
+    public getSupportsPstnProtocol() {
         return this.supportsPstnProtocol;
     }
 
+    public getSupportsVirtualRooms() {
+        return this.supportsPstnProtocol;
+    }
+
+    public pstnLookup(phoneNumber: string): Promise {
+        return MatrixClientPeg.get().getThirdpartyUser(
+            this.pstnSupportPrefixed ? PROTOCOL_PSTN_PREFIXED : PROTOCOL_PSTN, {
+                'm.id.phone': phoneNumber,
+            },
+        );
+    }
+
+    public sipVirtualLookup(nativeMxid: string): Promise {
+        return MatrixClientPeg.get().getThirdpartyUser(
+            PROTOCOL_SIP_VIRTUAL, {
+                'native_mxid': nativeMxid,
+            },
+        );
+    }
+
+    public sipNativeLookup(virtualMxid: string): Promise {
+        return MatrixClientPeg.get().getThirdpartyUser(
+            PROTOCOL_SIP_NATIVE, {
+                'virtual_mxid': virtualMxid,
+            },
+        );
+    }
+
     private onCallIncoming = (call) => {
         // we dispatch this synchronously to make sure that the event
         // handlers on the call are set up immediately (so that if
@@ -356,6 +434,7 @@ export default class CallHandler {
                     this.play(AudioID.Ringback);
                     break;
                 case CallState.Ended:
+                {
                     Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
                     this.removeCallForRoom(mappedRoomId);
                     if (oldState === CallState.InviteSent && (
@@ -389,10 +468,14 @@ export default class CallHandler {
                             title: _t("Answered Elsewhere"),
                             description: _t("The call was answered on another device."),
                         });
-                    } else if (oldState !== CallState.Fledgling) {
+                    } else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
                         // don't play the end-call sound for calls that never got off the ground
                         this.play(AudioID.CallEnd);
                     }
+
+                    this.logCallStats(call, mappedRoomId);
+                    break;
+                }
             }
         });
         call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
@@ -412,6 +495,49 @@ export default class CallHandler {
         });
     }
 
+    private async logCallStats(call: MatrixCall, mappedRoomId: string) {
+        const stats = await call.getCurrentCallStats();
+        logger.debug(
+            `Call completed. Call ID: ${call.callId}, virtual room ID: ${call.roomId}, ` +
+            `user-facing room ID: ${mappedRoomId}, direction: ${call.direction}, ` +
+            `our Party ID: ${call.ourPartyId}, hangup party: ${call.hangupParty}, ` +
+            `hangup reason: ${call.hangupReason}`,
+        );
+        if (!stats) {
+            logger.debug(
+                "Call statistics are undefined. The call has " +
+                "probably failed before a peerConn was established",
+            );
+            return;
+        }
+        logger.debug("Local candidates:");
+        for (const cand of stats.filter(item => item.type === 'local-candidate')) {
+            const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
+            logger.debug(
+                `${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
+                `protocol: ${cand.protocol}, relay protocol: ${cand.relayProtocol}, network type: ${cand.networkType}`,
+            );
+        }
+        logger.debug("Remote candidates:");
+        for (const cand of stats.filter(item => item.type === 'remote-candidate')) {
+            const address = cand.address || cand.ip; // firefox uses 'address', chrome uses 'ip'
+            logger.debug(
+                `${cand.id} - type: ${cand.candidateType}, address: ${address}, port: ${cand.port}, ` +
+                `protocol: ${cand.protocol}`,
+            );
+        }
+        logger.debug("Candidate pairs:");
+        for (const pair of stats.filter(item => item.type === 'candidate-pair')) {
+            logger.debug(
+                `${pair.localCandidateId} / ${pair.remoteCandidateId} - state: ${pair.state}, ` +
+                `nominated: ${pair.nominated}, ` +
+                `requests sent ${pair.requestsSent}, requests received  ${pair.requestsReceived},  ` +
+                `responses received: ${pair.responsesReceived}, responses sent: ${pair.responsesSent}, ` +
+                `bytes received: ${pair.bytesReceived}, bytes sent: ${pair.bytesSent}, `,
+            );
+        }
+    }
+
     private setCallAudioElement(call: MatrixCall) {
         const audioElement = getRemoteAudioElement();
         if (audioElement) call.setRemoteAudioElement(audioElement);
@@ -500,7 +626,7 @@ export default class CallHandler {
         Analytics.trackEvent('voip', 'placeCall', 'type', type);
         CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
 
-        const mappedRoomId = (await getOrCreateVirtualRoomForRoom(roomId)) || roomId;
+        const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId;
         logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
 
         const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
@@ -530,9 +656,17 @@ export default class CallHandler {
                 });
                 return;
             }
-            call.placeScreenSharingCall(remoteElement, localElement);
+
+            call.placeScreenSharingCall(
+                remoteElement,
+                localElement,
+                async () : Promise => {
+                    const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
+                    const [source] = await finished;
+                    return source;
+                });
         } else {
-            console.error("Unknown conf call type: %s", type);
+            console.error("Unknown conf call type: " + type);
         }
     }
 
@@ -540,6 +674,12 @@ export default class CallHandler {
         switch (payload.action) {
             case 'place_call':
                 {
+                    // We might be using managed hybrid widgets
+                    if (isManagedHybridWidgetEnabled()) {
+                        addManagedHybridWidget(payload.room_id);
+                        return;
+                    }
+
                     // if the runtime env doesn't do VoIP, whine.
                     if (!MatrixClientPeg.get().supportsVoip()) {
                         Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, {
@@ -560,7 +700,7 @@ export default class CallHandler {
 
                     const room = MatrixClientPeg.get().getRoom(payload.room_id);
                     if (!room) {
-                        console.error("Room %s does not exist.", payload.room_id);
+                        console.error(`Room ${payload.room_id} does not exist.`);
                         return;
                     }
 
@@ -571,7 +711,7 @@ export default class CallHandler {
                         });
                         return;
                     } else if (members.length === 2) {
-                        console.info("Place %s call in %s", payload.type, payload.room_id);
+                        console.info(`Place ${payload.type} call in ${payload.room_id}`);
 
                         this.placeCall(payload.room_id, payload.type, payload.local_element, payload.remote_element);
                     } else { // > 2
@@ -586,17 +726,17 @@ export default class CallHandler {
                 }
                 break;
             case 'place_conference_call':
-                console.info("Place conference call in %s", payload.room_id);
+                console.info("Place conference call in " + payload.room_id);
                 Analytics.trackEvent('voip', 'placeConferenceCall');
                 CountlyAnalytics.instance.trackStartCall(payload.room_id, payload.type === PlaceCallType.Video, true);
                 this.startCallApp(payload.room_id, payload.type);
                 break;
             case 'end_conference':
-                console.info("Terminating conference call in %s", payload.room_id);
+                console.info("Terminating conference call in " + payload.room_id);
                 this.terminateCallApp(payload.room_id);
                 break;
             case 'hangup_conference':
-                console.info("Leaving conference call in %s", payload.room_id);
+                console.info("Leaving conference call in "+ payload.room_id);
                 this.hangupCallApp(payload.room_id);
                 break;
             case 'incoming_call':
@@ -617,6 +757,12 @@ export default class CallHandler {
                     Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
                     this.calls.set(mappedRoomId, call)
                     this.setCallListeners(call);
+
+                    // get ready to send encrypted events in the room, so if the user does answer
+                    // the call, we'll be ready to send. NB. This is the protocol-level room ID not
+                    // the mapped one: that's where we'll send the events.
+                    const cli = MatrixClientPeg.get();
+                    cli.prepareToEncrypt(cli.getRoom(call.roomId));
                 }
                 break;
             case 'hangup':
@@ -716,7 +862,7 @@ export default class CallHandler {
             confId = base32.stringify(Buffer.from(roomId), { pad: false });
         } else {
             // Create a random human readable conference ID
-            confId = `JitsiConference${generateHumanReadableId()}`;
+            confId = `JitsiConference${randomString(32)}`;
         }
 
         let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
@@ -732,6 +878,7 @@ export default class CallHandler {
             isAudioOnly: type === 'voice',
             domain: jitsiDomain,
             auth: jitsiAuth,
+            roomName: room.name,
         };
 
         const widgetId = (
diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx
index 5409a606de..bec36d49f6 100644
--- a/src/ContentMessages.tsx
+++ b/src/ContentMessages.tsx
@@ -497,7 +497,7 @@ export default class ContentMessages {
             content.info.mimetype = file.type;
         }
 
-        const prom = new Promise((resolve) => {
+        const prom = new Promise((resolve) => {
             if (file.type.indexOf('image/') === 0) {
                 content.msgtype = 'm.image';
                 infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts
index b4727bc88b..974c08df18 100644
--- a/src/CountlyAnalytics.ts
+++ b/src/CountlyAnalytics.ts
@@ -840,7 +840,7 @@ export default class CountlyAnalytics {
         let endTime = CountlyAnalytics.getTimestamp();
         const cli = MatrixClientPeg.get();
         if (!cli.getRoom(roomId)) {
-            await new Promise(resolve => {
+            await new Promise(resolve => {
                 const handler = (room) => {
                     if (room.roomId === roomId) {
                         cli.off("Room", handler);
@@ -880,7 +880,7 @@ export default class CountlyAnalytics {
         let endTime = CountlyAnalytics.getTimestamp();
 
         if (!room.findEventById(eventId)) {
-            await new Promise(resolve => {
+            await new Promise(resolve => {
                 const handler = (ev) => {
                     if (ev.getId() === eventId) {
                         room.off("Room.localEchoUpdated", handler);
diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx
index 637c0a2696..7d6b049914 100644
--- a/src/HtmlUtils.tsx
+++ b/src/HtmlUtils.tsx
@@ -422,6 +422,8 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
             if (SettingsStore.getValue("feature_latex_maths")) {
                 const phtml = cheerio.load(safeBody,
                     { _useHtmlParser2: true, decodeEntities: false })
+                // @ts-ignore - The types for `replaceWith` wrongly expect
+                // Cheerio instance to be returned.
                 phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
                     return katex.renderToString(
                         AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.js
index fbdb6812ee..d3bfee2380 100644
--- a/src/IdentityAuthClient.js
+++ b/src/IdentityAuthClient.js
@@ -165,6 +165,7 @@ export default class IdentityAuthClient {
             });
             const [confirmed] = await finished;
             if (confirmed) {
+                // eslint-disable-next-line react-hooks/rules-of-hooks
                 useDefaultIdentityServer();
             } else {
                 throw new AbortedIdentityActionError(
diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts
index f87af1a791..7780d4c87a 100644
--- a/src/Lifecycle.ts
+++ b/src/Lifecycle.ts
@@ -46,11 +46,13 @@ import {IntegrationManagers} from "./integrations/IntegrationManagers";
 import {Mjolnir} from "./mjolnir/Mjolnir";
 import DeviceListener from "./DeviceListener";
 import {Jitsi} from "./widgets/Jitsi";
-import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform";
+import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY, SSO_IDP_ID_KEY} from "./BasePlatform";
 import ThreepidInviteStore from "./stores/ThreepidInviteStore";
 import CountlyAnalytics from "./CountlyAnalytics";
 import CallHandler from './CallHandler';
 import LifecycleCustomisations from "./customisations/Lifecycle";
+import ErrorDialog from "./components/views/dialogs/ErrorDialog";
+import {_t} from "./languageHandler";
 
 const HOMESERVER_URL_KEY = "mx_hs_url";
 const ID_SERVER_URL_KEY = "mx_is_url";
@@ -162,7 +164,8 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> {
  *     query-parameters extracted from the real query-string of the starting
  *     URI.
  *
- * @param {String} defaultDeviceDisplayName
+ * @param {string} defaultDeviceDisplayName
+ * @param {string} fragmentAfterLogin path to go to after a successful login, only used for "Try again"
  *
  * @returns {Promise} promise which resolves to true if we completed the token
  *    login, else false
@@ -170,6 +173,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> {
 export function attemptTokenLogin(
     queryParams: Record,
     defaultDeviceDisplayName?: string,
+    fragmentAfterLogin?: string,
 ): Promise {
     if (!queryParams.loginToken) {
         return Promise.resolve(false);
@@ -179,6 +183,12 @@ export function attemptTokenLogin(
     const identityServer = localStorage.getItem(SSO_ID_SERVER_URL_KEY);
     if (!homeserver) {
         console.warn("Cannot log in with token: can't determine HS URL to use");
+        Modal.createTrackedDialog("SSO", "Unknown HS", ErrorDialog, {
+            title: _t("We couldn't log you in"),
+            description: _t("We asked the browser to remember which homeserver you use to let you sign in, " +
+                "but unfortunately your browser has forgotten it. Go to the sign in page and try again."),
+            button: _t("Try again"),
+        });
         return Promise.resolve(false);
     }
 
@@ -198,8 +208,28 @@ export function attemptTokenLogin(
             return true;
         });
     }).catch((err) => {
-        console.error("Failed to log in with login token: " + err + " " +
-                      err.data);
+        Modal.createTrackedDialog("SSO", "Token Rejected", ErrorDialog, {
+            title: _t("We couldn't log you in"),
+            description: err.name === "ConnectionError"
+                ? _t("Your homeserver was unreachable and was not able to log you in. Please try again. " +
+                    "If this continues, please contact your homeserver administrator.")
+                : _t("Your homeserver rejected your log in attempt. " +
+                    "This could be due to things just taking too long. Please try again. " +
+                    "If this continues, please contact your homeserver administrator."),
+            button: _t("Try again"),
+            onFinished: tryAgain => {
+                if (tryAgain) {
+                    const cli = Matrix.createClient({
+                        baseUrl: homeserver,
+                        idBaseUrl: identityServer,
+                    });
+                    const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined;
+                    PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId);
+                }
+            },
+        });
+        console.error("Failed to log in with login token:");
+        console.error(err);
         return false;
     });
 }
@@ -366,7 +396,7 @@ async function abortLogin() {
 //      The plan is to gradually move the localStorage access done here into
 //      SessionStore to avoid bugs where the view becomes out-of-sync with
 //      localStorage (e.g. isGuest etc.)
-async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise {
+export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promise {
     const ignoreGuest = opts?.ignoreGuest;
 
     if (!localStorage) {
diff --git a/src/Login.ts b/src/Login.ts
index 6493b244e0..aecc0493c7 100644
--- a/src/Login.ts
+++ b/src/Login.ts
@@ -33,10 +33,20 @@ interface IPasswordFlow {
     type: "m.login.password";
 }
 
+export enum IdentityProviderBrand {
+    Gitlab = "org.matrix.gitlab",
+    Github = "org.matrix.github",
+    Apple = "org.matrix.apple",
+    Google = "org.matrix.google",
+    Facebook = "org.matrix.facebook",
+    Twitter = "org.matrix.twitter",
+}
+
 export interface IIdentityProvider {
     id: string;
     name: string;
     icon?: string;
+    brand?: IdentityProviderBrand | string;
 }
 
 export interface ISSOFlow {
diff --git a/src/Markdown.js b/src/Markdown.js
index dc4d442aff..f670bded12 100644
--- a/src/Markdown.js
+++ b/src/Markdown.js
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import commonmark from 'commonmark';
+import * as commonmark from 'commonmark';
 import {escape} from "lodash";
 
 const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u'];
diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts
index bb4be663b6..98ca446532 100644
--- a/src/MatrixClientPeg.ts
+++ b/src/MatrixClientPeg.ts
@@ -279,6 +279,10 @@ class _MatrixClientPeg implements IMatrixClientPeg {
             timelineSupport: true,
             forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer'),
             fallbackICEServerAllowed: !!SettingsStore.getValue('fallbackICEServerAllowed'),
+            // Gather up to 20 ICE candidates when a call arrives: this should be more than we'd
+            // ever normally need, so effectively this should make all the gathering happen when
+            // the call arrives.
+            iceCandidatePoolSize: 20,
             verificationMethods: [
                 verificationMethods.SAS,
                 SHOW_QR_CODE_METHOD,
diff --git a/src/RoomNotifs.js b/src/RoomNotifs.js
index a86c521ac4..600655f635 100644
--- a/src/RoomNotifs.js
+++ b/src/RoomNotifs.js
@@ -202,12 +202,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) {
 }
 
 function findOverrideMuteRule(roomId) {
-    if (!MatrixClientPeg.get().pushRules ||
-        !MatrixClientPeg.get().pushRules['global'] ||
-        !MatrixClientPeg.get().pushRules['global'].override) {
+    const cli = MatrixClientPeg.get();
+    if (!cli.pushRules ||
+        !cli.pushRules['global'] ||
+        !cli.pushRules['global'].override) {
         return null;
     }
-    for (const rule of MatrixClientPeg.get().pushRules['global'].override) {
+    for (const rule of cli.pushRules['global'].override) {
         if (isRuleForRoom(roomId, rule)) {
             if (isMuteRule(rule) && rule.enabled) {
                 return rule;
diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx
index 79c21c4af5..6b5f261374 100644
--- a/src/SlashCommands.tsx
+++ b/src/SlashCommands.tsx
@@ -48,6 +48,7 @@ import SettingsStore from "./settings/SettingsStore";
 import {UIFeature} from "./settings/UIFeature";
 import {CHAT_EFFECTS} from "./effects"
 import CallHandler from "./CallHandler";
+import {guessAndSetDMRoom} from "./Rooms";
 
 // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816
 interface HTMLInputEvent extends Event {
@@ -1039,9 +1040,7 @@ export const Commands = [
 
             return success((async () => {
                 if (isPhoneNumber) {
-                    const results = await MatrixClientPeg.get().getThirdpartyUser('im.vector.protocol.pstn', {
-                        'm.id.phone': userId,
-                    });
+                    const results = await CallHandler.sharedInstance().pstnLookup(this.state.value);
                     if (!results || results.length === 0 || !results[0].userid) {
                         throw new Error("Unable to find Matrix ID for phone number");
                     }
@@ -1112,6 +1111,24 @@ export const Commands = [
             return success();
         },
     }),
+    new Command({
+        command: "converttodm",
+        description: _td("Converts the room to a DM"),
+        category: CommandCategories.other,
+        runFn: function(roomId, args) {
+            const room = MatrixClientPeg.get().getRoom(roomId);
+            return success(guessAndSetDMRoom(room, true));
+        },
+    }),
+    new Command({
+        command: "converttoroom",
+        description: _td("Converts the DM to a room"),
+        category: CommandCategories.other,
+        runFn: function(roomId, args) {
+            const room = MatrixClientPeg.get().getRoom(roomId);
+            return success(guessAndSetDMRoom(room, false));
+        },
+    }),
 
     // Command definitions for autocompletion ONLY:
     // /me is special because its not handled by SlashCommands.js and is instead done inside the Composer classes
@@ -1163,7 +1180,7 @@ export function parseCommandString(input: string) {
     input = input.replace(/\s+$/, '');
     if (input[0] !== '/') return {}; // not a command
 
-    const bits = input.match(/^(\S+?)(?: +((.|\n)*))?$/);
+    const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/);
     let cmd;
     let args;
     if (bits) {
diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts
index a4f5822065..d919615349 100644
--- a/src/VoipUserMapper.ts
+++ b/src/VoipUserMapper.ts
@@ -14,66 +14,97 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-import { ensureDMExists, findDMForUser } from './createRoom';
+import { ensureVirtualRoomExists, findDMForUser } from './createRoom';
 import { MatrixClientPeg } from "./MatrixClientPeg";
 import DMRoomMap from "./utils/DMRoomMap";
-import SdkConfig from "./SdkConfig";
+import CallHandler, { VIRTUAL_ROOM_EVENT_TYPE } from './CallHandler';
+import { Room } from 'matrix-js-sdk/src/models/room';
 
-// Functions for mapping users & rooms for the voip_mxid_translate_pattern
-// config option
+// Functions for mapping virtual users & rooms. Currently the only lookup
+// is sip virtual: there could be others in the future.
 
-export function voipUserMapperEnabled(): boolean {
-    return SdkConfig.get()['voip_mxid_translate_pattern'] !== undefined;
-}
+export default class VoipUserMapper {
+    private virtualRoomIdCache = new Set();
 
-// only exported for tests
-export function userToVirtualUser(userId: string, templateString?: string): string {
-    if (templateString === undefined) templateString = SdkConfig.get()['voip_mxid_translate_pattern'];
-    if (!templateString) return null;
-    return templateString.replace('${mxid}', encodeURIComponent(userId).replace(/%/g, '=').toLowerCase());
-}
+    public static sharedInstance(): VoipUserMapper {
+        if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
+        return window.mxVoipUserMapper;
+    }
 
-// only exported for tests
-export function virtualUserToUser(userId: string, templateString?: string): string {
-    if (templateString === undefined) templateString = SdkConfig.get()['voip_mxid_translate_pattern'];
-    if (!templateString) return null;
+    private async userToVirtualUser(userId: string): Promise {
+        const results = await CallHandler.sharedInstance().sipVirtualLookup(userId);
+        if (results.length === 0) return null;
+        return results[0].userid;
+    }
 
-    const regexString = templateString.replace('${mxid}', '(.+)');
+    public async getOrCreateVirtualRoomForRoom(roomId: string):Promise {
+        const userId = DMRoomMap.shared().getUserIdForRoomId(roomId);
+        if (!userId) return null;
 
-    const match = userId.match('^' + regexString + '$');
-    if (!match) return null;
+        const virtualUser = await this.userToVirtualUser(userId);
+        if (!virtualUser) return null;
 
-    return decodeURIComponent(match[1].replace(/=/g, '%'));
-}
+        const virtualRoomId = await ensureVirtualRoomExists(MatrixClientPeg.get(), virtualUser, roomId);
+        MatrixClientPeg.get().setRoomAccountData(virtualRoomId, VIRTUAL_ROOM_EVENT_TYPE, {
+            native_room: roomId,
+        });
 
-async function getOrCreateVirtualRoomForUser(userId: string):Promise {
-    const virtualUser = userToVirtualUser(userId);
-    if (!virtualUser) return null;
+        return virtualRoomId;
+    }
 
-    return await ensureDMExists(MatrixClientPeg.get(), virtualUser);
-}
+    public nativeRoomForVirtualRoom(roomId: string):string {
+        const virtualRoom = MatrixClientPeg.get().getRoom(roomId);
+        if (!virtualRoom) return null;
+        const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
+        if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
+        return virtualRoomEvent.getContent()['native_room'] || null;
+    }
 
-export async function getOrCreateVirtualRoomForRoom(roomId: string):Promise {
-    const user = DMRoomMap.shared().getUserIdForRoomId(roomId);
-    if (!user) return null;
-    return getOrCreateVirtualRoomForUser(user);
-}
+    public isVirtualRoom(room: Room):boolean {
+        if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
 
-export function roomForVirtualRoom(roomId: string):string {
-    const virtualUser = DMRoomMap.shared().getUserIdForRoomId(roomId);
-    if (!virtualUser) return null;
-    const realUser = virtualUserToUser(virtualUser);
-    const room = findDMForUser(MatrixClientPeg.get(), realUser);
-    if (room) {
-        return room.roomId;
-    } else {
-        return null;
+        if (this.virtualRoomIdCache.has(room.roomId)) return true;
+
+        // also look in the create event for the claimed native room ID, which is the only
+        // way we can recognise a virtual room we've created when it first arrives down
+        // our stream. We don't trust this in general though, as it could be faked by an
+        // inviter: our main source of truth is the DM state.
+        const roomCreateEvent = room.currentState.getStateEvents("m.room.create", "");
+        if (!roomCreateEvent || !roomCreateEvent.getContent()) return false;
+        // we only look at this for rooms we created (so inviters can't just cause rooms
+        // to be invisible)
+        if (roomCreateEvent.getSender() !== MatrixClientPeg.get().getUserId()) return false;
+        const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE];
+        return Boolean(claimedNativeRoomId);
+    }
+
+    public async onNewInvitedRoom(invitedRoom: Room) {
+        const inviterId = invitedRoom.getDMInviter();
+        console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
+        const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
+        if (result.length === 0) {
+            return true;
+        }
+
+        if (result[0].fields.is_virtual) {
+            const nativeUser = result[0].userid;
+            const nativeRoom = findDMForUser(MatrixClientPeg.get(), nativeUser);
+            if (nativeRoom) {
+                // It's a virtual room with a matching native room, so set the room account data. This
+                // will make sure we know where how to map calls and also allow us know not to display
+                // it in the future.
+                MatrixClientPeg.get().setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, {
+                    native_room: nativeRoom.roomId,
+                });
+                // also auto-join the virtual room if we have a matching native room
+                // (possibly we should only join if we've also joined the native room, then we'd also have
+                // to make sure we joined virtual rooms on joining a native one)
+                MatrixClientPeg.get().joinRoom(invitedRoom.roomId);
+            }
+
+            // also put this room in the virtual room ID cache so isVirtualRoom return the right answer
+            // in however long it takes for the echo of setAccountData to come down the sync
+            this.virtualRoomIdCache.add(invitedRoom.roomId);
+        }
     }
 }
-
-export function isVirtualRoom(roomId: string):boolean {
-    const virtualUser = DMRoomMap.shared().getUserIdForRoomId(roomId);
-    if (!virtualUser) return null;
-    const realUser = virtualUserToUser(virtualUser);
-    return Boolean(realUser);
-}
diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx
index 48d0eb2ab1..7a0ba58c97 100644
--- a/src/accessibility/KeyboardShortcuts.tsx
+++ b/src/accessibility/KeyboardShortcuts.tsx
@@ -168,6 +168,12 @@ const shortcuts: Record = {
                 key: Key.U,
             }],
             description: _td("Upload a file"),
+        }, {
+            keybinds: [{
+                modifiers: [CMD_OR_CTRL],
+                key: Key.F,
+            }],
+            description: _td("Search (must be enabled)"),
         },
     ],
 
diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js
index 4836b0f554..0e4df4621d 100644
--- a/src/components/structures/FilePanel.js
+++ b/src/components/structures/FilePanel.js
@@ -45,7 +45,7 @@ class FilePanel extends React.Component {
     };
 
     onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => {
-        if (room.roomId !== this.props.roomId) return;
+        if (room?.roomId !== this.props?.roomId) return;
         if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return;
 
         if (ev.isBeingDecrypted()) {
diff --git a/src/components/structures/HostSignupAction.tsx b/src/components/structures/HostSignupAction.tsx
new file mode 100644
index 0000000000..9cf84a9379
--- /dev/null
+++ b/src/components/structures/HostSignupAction.tsx
@@ -0,0 +1,56 @@
+/*
+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 React from "react";
+import {
+    IconizedContextMenuOption,
+    IconizedContextMenuOptionList,
+} from "../views/context_menus/IconizedContextMenu";
+import { _t } from "../../languageHandler";
+import { HostSignupStore } from "../../stores/HostSignupStore";
+import SdkConfig from "../../SdkConfig";
+
+interface IProps {}
+
+interface IState {}
+
+export default class HostSignupAction extends React.PureComponent {
+    private openDialog = async () => {
+        await HostSignupStore.instance.setHostSignupActive(true);
+    }
+
+    public render(): React.ReactNode {
+        const hostSignupConfig = SdkConfig.get().hostSignup;
+        if (!hostSignupConfig?.brand) {
+            return null;
+        }
+
+        return (
+            
+                
+            
+        );
+    }
+}
diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js
index c8fcd7e9ca..ac7049ed88 100644
--- a/src/components/structures/InteractiveAuth.js
+++ b/src/components/structures/InteractiveAuth.js
@@ -177,7 +177,14 @@ export default class InteractiveAuthComponent extends React.Component {
             stageState: stageState,
             errorText: stageState.error,
         }, () => {
-            if (oldStage != stageType) this._setFocus();
+            if (oldStage !== stageType) {
+                this._setFocus();
+            } else if (
+                !stageState.error && this._stageComponent.current &&
+                this._stageComponent.current.attemptFailed
+            ) {
+                this._stageComponent.current.attemptFailed();
+            }
         });
     };
 
diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx
index 4daec76d08..e88af282ba 100644
--- a/src/components/structures/LeftPanelWidget.tsx
+++ b/src/components/structures/LeftPanelWidget.tsx
@@ -56,7 +56,7 @@ const LeftPanelWidget: React.FC = ({ onResize }) => {
 
     const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
     const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
-    useEffect(onResize, [expanded]);
+    useEffect(onResize, [expanded, onResize]);
 
     const [onFocus, isActive, ref] = useRovingTabIndex();
     const tabIndex = isActive ? 0 : -1;
diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx
index 70ec2b7033..c76cd7cee7 100644
--- a/src/components/structures/LoggedInView.tsx
+++ b/src/components/structures/LoggedInView.tsx
@@ -54,6 +54,7 @@ import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPa
 import { IThreepidInvite } from "../../stores/ThreepidInviteStore";
 import Modal from "../../Modal";
 import { ICollapseConfig } from "../../resizer/distributors/collapse";
+import HostSignupContainer from '../views/host_signup/HostSignupContainer';
 
 // We need to fetch each pinned message individually (if we don't already have it)
 // so each pinned message may trigger a request. Limit the number per room for sanity.
@@ -215,10 +216,12 @@ class LoggedInView extends React.Component {
 
     _createResizer() {
         let size;
+        let collapsed;
         const collapseConfig: ICollapseConfig = {
             toggleSize: 260 - 50,
-            onCollapsed: (collapsed) => {
-                if (collapsed) {
+            onCollapsed: (_collapsed) => {
+                collapsed = _collapsed;
+                if (_collapsed) {
                     dis.dispatch({action: "hide_left_panel"}, true);
                     window.localStorage.setItem("mx_lhs_size", '0');
                 } else {
@@ -233,7 +236,7 @@ class LoggedInView extends React.Component {
                 this.props.resizeNotifier.startResizing();
             },
             onResizeStop: () => {
-                window.localStorage.setItem("mx_lhs_size", '' + size);
+                if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size);
                 this.props.resizeNotifier.stopResizing();
             },
         };
@@ -425,6 +428,14 @@ class LoggedInView extends React.Component {
                     handled = true;
                 }
                 break;
+            case Key.F:
+                if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) {
+                    dis.dispatch({
+                        action: 'focus_search',
+                    });
+                    handled = true;
+                }
+                break;
             case Key.BACKTICK:
                 // Ideally this would be CTRL+P for "Profile", but that's
                 // taken by the print dialog. CTRL+I for "Information"
@@ -638,6 +649,7 @@ class LoggedInView extends React.Component {
                 
                 
                 
+                
             
         );
     }
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx
index 62c729c422..5045e44182 100644
--- a/src/components/structures/MatrixChat.tsx
+++ b/src/components/structures/MatrixChat.tsx
@@ -81,6 +81,7 @@ import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from
 import {UIFeature} from "../../settings/UIFeature";
 import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore";
 import DialPadModal from "../views/voip/DialPadModal";
+import { showToast as showMobileGuideToast } from '../../toasts/MobileGuideToast';
 
 /** constants for MatrixChat.state.view */
 export enum Views {
@@ -218,6 +219,7 @@ export default class MatrixChat extends React.PureComponent {
     private screenAfterLogin?: IScreen;
     private windowWidth: number;
     private pageChanging: boolean;
+    private tokenLogin?: boolean;
     private accountPassword?: string;
     private accountPasswordTimer?: NodeJS.Timeout;
     private focusComposer: boolean;
@@ -323,13 +325,21 @@ export default class MatrixChat extends React.PureComponent {
             Lifecycle.attemptTokenLogin(
                 this.props.realQueryParams,
                 this.props.defaultDeviceDisplayName,
-            ).then((loggedIn) => {
-                if (loggedIn) {
+                this.getFragmentAfterLogin(),
+            ).then(async (loggedIn) => {
+                if (this.props.realQueryParams?.loginToken) {
+                    // remove the loginToken from the URL regardless
                     this.props.onTokenLoginCompleted();
+                }
 
-                    // don't do anything else until the page reloads - just stay in
-                    // the 'loading' state.
-                    return;
+                if (loggedIn) {
+                    this.tokenLogin = true;
+
+                    // Create and start the client
+                    await Lifecycle.restoreFromLocalStorage({
+                        ignoreGuest: true,
+                    });
+                    return this.postLoginSetup();
                 }
 
                 // if the user has followed a login or register link, don't reanimate
@@ -353,6 +363,42 @@ export default class MatrixChat extends React.PureComponent {
         CountlyAnalytics.instance.enable(/* anonymous = */ true);
     }
 
+    private async postLoginSetup() {
+        const cli = MatrixClientPeg.get();
+        const cryptoEnabled = cli.isCryptoEnabled();
+        if (!cryptoEnabled) {
+            this.onLoggedIn();
+        }
+
+        const promisesList = [this.firstSyncPromise.promise];
+        if (cryptoEnabled) {
+            // wait for the client to finish downloading cross-signing keys for us so we
+            // know whether or not we have keys set up on this account
+            promisesList.push(cli.downloadKeys([cli.getUserId()]));
+        }
+
+        // Now update the state to say we're waiting for the first sync to complete rather
+        // than for the login to finish.
+        this.setState({ pendingInitialSync: true });
+
+        await Promise.all(promisesList);
+
+        if (!cryptoEnabled) {
+            this.setState({ pendingInitialSync: false });
+            return;
+        }
+
+        const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
+        if (crossSigningIsSetUp) {
+            this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
+        } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
+            this.setStateForNewView({ view: Views.E2E_SETUP });
+        } else {
+            this.onLoggedIn();
+        }
+        this.setState({ pendingInitialSync: false });
+    }
+
     // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage
     // eslint-disable-next-line camelcase
     UNSAFE_componentWillUpdate(props, state) {
@@ -709,6 +755,8 @@ export default class MatrixChat extends React.PureComponent {
                 break;
             case 'on_logged_in':
                 if (
+                    // Skip this handling for token login as that always calls onLoggedIn itself
+                    !this.tokenLogin &&
                     !Lifecycle.isSoftLogout() &&
                     this.state.view !== Views.LOGIN &&
                     this.state.view !== Views.REGISTER &&
@@ -1186,6 +1234,11 @@ export default class MatrixChat extends React.PureComponent {
         ) {
             showAnalyticsToast(this.props.config.piwik?.policyUrl);
         }
+        if (SdkConfig.get().mobileGuideToast) {
+            // The toast contains further logic to detect mobile platforms,
+            // check if it has been dismissed before, etc.
+            showMobileGuideToast();
+        }
     }
 
     private showScreenAfterLogin() {
@@ -1322,6 +1375,9 @@ export default class MatrixChat extends React.PureComponent {
         cli.on('Session.logged_out', function(errObj) {
             if (Lifecycle.isLoggingOut()) return;
 
+            // A modal might have been open when we were logged out by the server
+            Modal.closeCurrentModal('Session.logged_out');
+
             if (errObj.httpStatus === 401 && errObj.data && errObj.data['soft_logout']) {
                 console.warn("Soft logout issued by server - avoiding data deletion");
                 Lifecycle.softLogout();
@@ -1332,6 +1388,7 @@ export default class MatrixChat extends React.PureComponent {
                 title: _t('Signed Out'),
                 description: _t('For security, this session has been signed out. Please sign in again.'),
             });
+
             dis.dispatch({
                 action: 'logout',
             });
@@ -1601,10 +1658,16 @@ export default class MatrixChat extends React.PureComponent {
             // TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149
 
             let threepidInvite: IThreepidInvite;
+            // if we landed here from a 3PID invite, persist it
             if (params.signurl && params.email) {
                 threepidInvite = ThreepidInviteStore.instance
                     .storeInvite(roomString, params as IThreepidInviteWireFormat);
             }
+            // otherwise check that this room doesn't already have a known invite
+            if (!threepidInvite) {
+                const invites = ThreepidInviteStore.instance.getInvites();
+                threepidInvite = invites.find(invite => invite.roomId === roomString);
+            }
 
             // on our URLs there might be a ?via=matrix.org or similar to help
             // joins to the room succeed. We'll pass these through as an array
@@ -1833,40 +1896,7 @@ export default class MatrixChat extends React.PureComponent {
 
         // Create and start the client
         await Lifecycle.setLoggedIn(credentials);
-
-        const cli = MatrixClientPeg.get();
-        const cryptoEnabled = cli.isCryptoEnabled();
-        if (!cryptoEnabled) {
-            this.onLoggedIn();
-        }
-
-        const promisesList = [this.firstSyncPromise.promise];
-        if (cryptoEnabled) {
-            // wait for the client to finish downloading cross-signing keys for us so we
-            // know whether or not we have keys set up on this account
-            promisesList.push(cli.downloadKeys([cli.getUserId()]));
-        }
-
-        // Now update the state to say we're waiting for the first sync to complete rather
-        // than for the login to finish.
-        this.setState({ pendingInitialSync: true });
-
-        await Promise.all(promisesList);
-
-        if (!cryptoEnabled) {
-            this.setState({ pendingInitialSync: false });
-            return;
-        }
-
-        const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId());
-        if (crossSigningIsSetUp) {
-            this.setStateForNewView({ view: Views.COMPLETE_SECURITY });
-        } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) {
-            this.setStateForNewView({ view: Views.E2E_SETUP });
-        } else {
-            this.onLoggedIn();
-        }
-        this.setState({ pendingInitialSync: false });
+        await this.postLoginSetup();
     };
 
     // complete security / e2e setup has finished
@@ -1910,6 +1940,7 @@ export default class MatrixChat extends React.PureComponent {
                 
             );
         } else if (this.state.view === Views.LOGGED_IN) {
diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js
index 375545f819..161227a139 100644
--- a/src/components/structures/MessagePanel.js
+++ b/src/components/structures/MessagePanel.js
@@ -23,9 +23,11 @@ import classNames from 'classnames';
 import shouldHideEvent from '../../shouldHideEvent';
 import {wantsDateSeparator} from '../../DateUtils';
 import * as sdk from '../../index';
+import dis from "../../dispatcher/dispatcher";
 
 import {MatrixClientPeg} from '../../MatrixClientPeg';
 import SettingsStore from '../../settings/SettingsStore';
+import {Layout, LayoutPropType} from "../../settings/Layout";
 import {_t} from "../../languageHandler";
 import {haveTileForEvent} from "../views/rooms/EventTile";
 import {textForEvent} from "../../TextForEvent";
@@ -135,14 +137,13 @@ export default class MessagePanel extends React.Component {
         // whether to show reactions for an event
         showReactions: PropTypes.bool,
 
-        // whether to use the irc layout
-        useIRCLayout: PropTypes.bool,
+        // which layout to use
+        layout: LayoutPropType,
 
         // whether or not to show flair at all
         enableFlair: PropTypes.bool,
     };
 
-    // Force props to be loaded for useIRCLayout
     constructor(props) {
         super(props);
 
@@ -207,11 +208,13 @@ export default class MessagePanel extends React.Component {
 
     componentDidMount() {
         this._isMounted = true;
+        this.dispatcherRef = dis.register(this.onAction);
     }
 
     componentWillUnmount() {
         this._isMounted = false;
         SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
+        dis.unregister(this.dispatcherRef);
     }
 
     componentDidUpdate(prevProps, prevState) {
@@ -224,6 +227,14 @@ export default class MessagePanel extends React.Component {
         }
     }
 
+    onAction = (payload) => {
+        switch (payload.action) {
+            case "scroll_to_bottom":
+                this.scrollToBottom();
+                break;
+        }
+    }
+
     onShowTypingNotificationsChange = () => {
         this.setState({
             showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
@@ -612,7 +623,7 @@ export default class MessagePanel extends React.Component {
                         isSelectedEvent={highlight}
                         getRelationsForEvent={this.props.getRelationsForEvent}
                         showReactions={this.props.showReactions}
-                        useIRCLayout={this.props.useIRCLayout}
+                        layout={this.props.layout}
                         enableFlair={this.props.enableFlair}
                     />
                 
@@ -810,7 +821,7 @@ export default class MessagePanel extends React.Component {
         }
 
         let ircResizer = null;
-        if (this.props.useIRCLayout) {
+        if (this.props.layout == Layout.IRC) {
             ircResizer =  {
+    onClose = () => {
         // XXX: There are three different ways of 'closing' this panel depending on what state
         // things are in... this knows far more than it should do about the state of the rest
         // of the app and is generally a bit silly.
@@ -198,31 +197,21 @@ export default class RightPanel extends React.Component {
             dis.dispatch({
                 action: "view_home_page",
             });
-        } else if (this.state.phase === RightPanelPhases.EncryptionPanel &&
+        } else if (
+            this.state.phase === RightPanelPhases.EncryptionPanel &&
             this.state.verificationRequest && this.state.verificationRequest.pending
         ) {
             // When the user clicks close on the encryption panel cancel the pending request first if any
             this.state.verificationRequest.cancel();
         } else {
-            // Otherwise we have got our user from RoomViewStore which means we're being shown
-            // within a room/group, so go back to the member panel if we were in the encryption panel,
-            // or the member list if we were in the member panel... phew.
-            const isEncryptionPhase = this.state.phase === RightPanelPhases.EncryptionPanel;
+            // the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
             dis.dispatch({
-                action: Action.ViewUser,
-                member: isEncryptionPhase ? this.state.member : null,
+                action: Action.ToggleRightPanel,
+                type: this.props.groupId ? "group" : "room",
             });
         }
     };
 
-    onClose = () => {
-        // the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here
-        defaultDispatcher.dispatch({
-            action: Action.ToggleRightPanel,
-            type: this.props.groupId ? "group" : "room",
-        });
-    };
-
     render() {
         const MemberList = sdk.getComponent('rooms.MemberList');
         const UserInfo = sdk.getComponent('right_panel.UserInfo');
@@ -260,7 +249,7 @@ export default class RightPanel extends React.Component {
                     user={this.state.member}
                     room={this.props.room}
                     key={roomId || this.state.member.userId}
-                    onClose={this.onCloseUserInfo}
+                    onClose={this.onClose}
                     phase={this.state.phase}
                     verificationRequest={this.state.verificationRequest}
                     verificationRequestPromise={this.state.verificationRequestPromise}
@@ -276,7 +265,7 @@ export default class RightPanel extends React.Component {
                     user={this.state.member}
                     groupId={this.props.groupId}
                     key={this.state.member.userId}
-                    onClose={this.onCloseUserInfo} />;
+                    onClose={this.onClose} />;
                 break;
 
             case RightPanelPhases.GroupRoomInfo:
diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js
index 9ddacaf829..7387e1aac0 100644
--- a/src/components/structures/RoomDirectory.js
+++ b/src/components/structures/RoomDirectory.js
@@ -477,7 +477,7 @@ export default class RoomDirectory extends React.Component {
         dis.dispatch(payload);
     }
 
-    getRow(room) {
+    createRoomCells(room) {
         const client = MatrixClientPeg.get();
         const clientRoom = client.getRoom(room.room_id);
         const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
@@ -523,31 +523,56 @@ export default class RoomDirectory extends React.Component {
                                 MatrixClientPeg.get().getHomeserverUrl(),
                                 room.avatar_url, 32, 32, "crop",
                             );
-        return (
-             this.onRoomClicked(room, ev)}
                 // cancel onMouseDown otherwise shift-clicking highlights text
                 onMouseDown={(ev) => {ev.preventDefault();}}
+                className="mx_RoomDirectory_roomAvatar"
             >
-                
-                    
-                
-                
-                    
{ name }
  -
{ ev.stopPropagation(); } } - dangerouslySetInnerHTML={{ __html: topic }} /> -
{ get_display_alias_for_room(room) }
- - - { room.num_joined_members } - - {previewButton} - {joinOrViewButton} - - ); + +
, +
this.onRoomClicked(room, ev)} + // cancel onMouseDown otherwise shift-clicking highlights text + onMouseDown={(ev) => {ev.preventDefault();}} + className="mx_RoomDirectory_roomDescription" + > +
{ name }
  +
{ ev.stopPropagation(); } } + dangerouslySetInnerHTML={{ __html: topic }} + /> +
{ get_display_alias_for_room(room) }
+
, +
this.onRoomClicked(room, ev)} + // cancel onMouseDown otherwise shift-clicking highlights text + onMouseDown={(ev) => {ev.preventDefault();}} + className="mx_RoomDirectory_roomMemberCount" + > + { room.num_joined_members } +
, +
this.onRoomClicked(room, ev)} + // cancel onMouseDown otherwise shift-clicking highlights text + onMouseDown={(ev) => {ev.preventDefault();}} + className="mx_RoomDirectory_preview" + > + {previewButton} +
, +
this.onRoomClicked(room, ev)} + // cancel onMouseDown otherwise shift-clicking highlights text + onMouseDown={(ev) => {ev.preventDefault();}} + className="mx_RoomDirectory_join" + > + {joinOrViewButton} +
, + ]; } collectScrollPanel = (element) => { @@ -606,7 +631,8 @@ export default class RoomDirectory extends React.Component { } else if (this.state.protocolsLoading) { content = ; } else { - const rows = (this.state.publicRooms || []).map(room => this.getRow(room)); + const cells = (this.state.publicRooms || []) + .reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],); // we still show the scrollpanel, at least for now, because // otherwise we don't fetch more because we don't get a fill // request from the scrollpanel because there isn't one @@ -617,14 +643,12 @@ export default class RoomDirectory extends React.Component { } let scrollpanel_content; - if (rows.length === 0 && !this.state.loading) { + if (cells.length === 0 && !this.state.loading) { scrollpanel_content = { _t('No rooms to show') }; } else { - scrollpanel_content = - - { rows } - -
; + scrollpanel_content =
+ { cells } +
; } const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); content = { statusBarVisible: false, canReact: false, canReply: false, - useIRCLayout: SettingsStore.getValue("useIRCLayout"), + layout: SettingsStore.getValue("layout"), matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), }; @@ -264,13 +265,7 @@ export default class RoomView extends React.Component { this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, this.onReadReceiptsChange); - this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); - } - - // TODO: [REACT-WARNING] Move into constructor - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() { - this.onRoomViewStoreUpdate(true); + this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange); } private onWidgetStoreUpdate = () => { @@ -512,6 +507,8 @@ export default class RoomView extends React.Component { } componentDidMount() { + this.onRoomViewStoreUpdate(true); + const call = this.getCallForRoom(); const callState = call ? call.state : null; this.setState({ @@ -642,7 +639,7 @@ export default class RoomView extends React.Component { private onLayoutChange = () => { this.setState({ - useIRCLayout: SettingsStore.getValue("useIRCLayout"), + layout: SettingsStore.getValue("layout"), }); }; @@ -766,6 +763,9 @@ export default class RoomView extends React.Component { }); } break; + case 'focus_search': + this.onSearchClick(); + break; } }; @@ -1946,8 +1946,8 @@ export default class RoomView extends React.Component { const messagePanelClassNames = classNames( "mx_RoomView_messagePanel", { - "mx_IRCLayout": this.state.useIRCLayout, - "mx_GroupLayout": !this.state.useIRCLayout, + "mx_IRCLayout": this.state.layout == Layout.IRC, + "mx_GroupLayout": this.state.layout == Layout.Group, }); // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); @@ -1970,7 +1970,7 @@ export default class RoomView extends React.Component { permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} resizeNotifier={this.props.resizeNotifier} showReactions={true} - useIRCLayout={this.state.useIRCLayout} + layout={this.state.layout} />); let topUnreadMessagesBar = null; diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 6bc35eb2a4..21f9f3f5d6 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -20,7 +20,6 @@ import * as React from "react"; import {_t} from '../../languageHandler'; import * as sdk from "../../index"; import AutoHideScrollbar from './AutoHideScrollbar'; -import { ReactNode } from "react"; /** * Represents a tab for the TabbedView. diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 27a384ddb2..e8da5c42d0 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -18,6 +18,7 @@ limitations under the License. */ import SettingsStore from "../../settings/SettingsStore"; +import {LayoutPropType} from "../../settings/Layout"; import React, {createRef} from 'react'; import ReactDOM from "react-dom"; import PropTypes from 'prop-types'; @@ -111,8 +112,8 @@ class TimelinePanel extends React.Component { // whether to show reactions for an event showReactions: PropTypes.bool, - // whether to use the irc layout - useIRCLayout: PropTypes.bool, + // which layout to use + layout: LayoutPropType, } // a map from room id to read marker event timestamp @@ -1442,7 +1443,7 @@ class TimelinePanel extends React.Component { getRelationsForEvent={this.getRelationsForEvent} editState={this.state.editState} showReactions={this.props.showReactions} - useIRCLayout={this.props.useIRCLayout} + layout={this.props.layout} enableFlair={SettingsStore.getValue(UIFeature.Flair)} /> ); diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 08bd472225..7f96e2d142 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -1,5 +1,5 @@ /* -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. @@ -28,7 +28,6 @@ import Modal from "../../Modal"; import LogoutDialog from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import {getCustomTheme} from "../../theme"; -import {getHostingLink} from "../../utils/HostingLink"; import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import {getHomePageUrl} from "../../utils/pages"; @@ -51,6 +50,8 @@ import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog"; import {UIFeature} from "../../settings/UIFeature"; +import HostSignupAction from "./HostSignupAction"; +import {IHostSignupConfig} from "../views/dialogs/HostSignupDialogTypes"; interface IProps { isMinimized: boolean; @@ -272,7 +273,7 @@ export default class UserMenu extends React.Component { const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); let topSection; - const signupLink = getHostingLink("user-context-menu"); + const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup; if (MatrixClientPeg.get().isGuest()) { topSection = (
@@ -292,24 +293,19 @@ export default class UserMenu extends React.Component { })}
) - } else if (signupLink) { - topSection = ( -
- {_t( - "Upgrade to your own domain", {}, - { - a: sub => ( - {sub} - ), - }, - )} -
- ); + } else if (hostSignupConfig) { + if (hostSignupConfig && hostSignupConfig.url) { + // If hostSignup.domains is set to a non-empty array, only show + // dialog if the user is on the domain or a subdomain. + const hostSignupDomains = hostSignupConfig.domains || []; + const mxDomain = MatrixClientPeg.get().getDomain(); + const validDomains = hostSignupDomains.filter(d => (d === mxDomain || mxDomain.endsWith(`.${d}`))); + if (!hostSignupConfig.domains || validDomains.length > 0) { + topSection =
+ +
; + } + } } let homeButton = null; diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.js index 6df8158002..d97a972718 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.js @@ -24,6 +24,7 @@ export default class E2eSetup extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, accountPassword: PropTypes.string, + tokenLogin: PropTypes.bool, }; render() { @@ -33,6 +34,7 @@ export default class E2eSetup extends React.Component { diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 606aeb44ab..a217f1b4d9 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -340,8 +340,8 @@ export default class LoginComponent extends React.PureComponent }; onTryRegisterClick = ev => { - const hasPasswordFlow = this.state.flows.find(flow => flow.type === "m.login.password"); - const ssoFlow = this.state.flows.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas"); + const hasPasswordFlow = this.state.flows?.find(flow => flow.type === "m.login.password"); + const ssoFlow = this.state.flows?.find(flow => flow.type === "m.login.sso" || flow.type === "m.login.cas"); // If has no password flow but an SSO flow guess that the user wants to register with SSO. // TODO: instead hide the Register button if registration is disabled by checking with the server, // has no specific errCode currently and uses M_FORBIDDEN. diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 60e57afc98..7dc1976641 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -609,8 +609,12 @@ export class SSOAuthEntry extends React.Component { this.props.authSessionId, ); + this._popupWindow = null; + window.addEventListener("message", this._onReceiveMessage); + this.state = { phase: SSOAuthEntry.PHASE_PREAUTH, + attemptFailed: false, }; } @@ -618,12 +622,35 @@ export class SSOAuthEntry extends React.Component { this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); } + componentWillUnmount() { + window.removeEventListener("message", this._onReceiveMessage); + if (this._popupWindow) { + this._popupWindow.close(); + this._popupWindow = null; + } + } + + attemptFailed = () => { + this.setState({ + attemptFailed: true, + }); + }; + + _onReceiveMessage = event => { + if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) { + if (this._popupWindow) { + this._popupWindow.close(); + this._popupWindow = null; + } + } + }; + onStartAuthClick = () => { // Note: We don't use PlatformPeg's startSsoAuth functions because we almost // certainly will need to open the thing in a new tab to avoid losing application // context. - window.open(this._ssoUrl, '_blank'); + this._popupWindow = window.open(this._ssoUrl, "_blank"); this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); }; @@ -656,10 +683,28 @@ export class SSOAuthEntry extends React.Component { ); } - return
- {cancelButton} - {continueButton} -
; + let errorSection; + if (this.props.errorText) { + errorSection = ( +
+ { this.props.errorText } +
+ ); + } else if (this.state.attemptFailed) { + errorSection = ( +
+ { _t("Something went wrong in confirming your identity. Cancel and try again.") } +
+ ); + } + + return + { errorSection } +
+ {cancelButton} + {continueButton} +
+
; } } @@ -710,8 +755,7 @@ export class FallbackAuthEntry extends React.Component { this.props.loginType, this.props.authSessionId, ); - this._popupWindow = window.open(url); - this._popupWindow.opener = null; + this._popupWindow = window.open(url, "_blank"); }; _onReceiveMessage = event => { diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 84e583c3a5..b2a3d62f55 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -196,7 +196,7 @@ export default class PasswordLogin extends React.PureComponent { // Validation and state updates are async, so we need to wait for them to complete // first. Queue a `setState` callback and wait for it to resolve. - await new Promise(resolve => this.setState({}, resolve)); + await new Promise(resolve => this.setState({}, resolve)); if (this.allFieldsValid()) { return true; diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index a0c7ab7b4f..e42ed88f99 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -194,7 +194,7 @@ export default class RegistrationForm extends React.PureComponent this.setState({}, resolve)); + await new Promise(resolve => this.setState({}, resolve)); if (this.allFieldsValid()) { return true; diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index acebdcd854..3bfa635adf 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -50,6 +50,10 @@ export default class ErrorDialog extends React.Component { button: null, }; + onClick = () => { + this.props.onFinished(true); + }; + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( @@ -64,7 +68,7 @@ export default class ErrorDialog extends React.Component { { this.props.description || _t('An error has occurred.') }
-
diff --git a/src/components/views/dialogs/HostSignupDialog.tsx b/src/components/views/dialogs/HostSignupDialog.tsx new file mode 100644 index 0000000000..45a03b7cf0 --- /dev/null +++ b/src/components/views/dialogs/HostSignupDialog.tsx @@ -0,0 +1,291 @@ +/* +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 React from "react"; +import AccessibleButton from "../elements/AccessibleButton"; +import Modal from "../../../Modal"; +import PersistedElement from "../elements/PersistedElement"; +import QuestionDialog from './QuestionDialog'; +import SdkConfig from "../../../SdkConfig"; +import classNames from "classnames"; +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { HostSignupStore } from "../../../stores/HostSignupStore"; +import { OwnProfileStore } from "../../../stores/OwnProfileStore"; +import { + IHostSignupConfig, + IPostmessage, + IPostmessageResponseData, + PostmessageAction, +} from "./HostSignupDialogTypes"; + +const HOST_SIGNUP_KEY = "host_signup"; + +interface IProps {} + +interface IState { + completed: boolean; + error: string; + minimized: boolean; +} + +export default class HostSignupDialog extends React.PureComponent { + private iframeRef: React.RefObject = React.createRef(); + private readonly config: IHostSignupConfig; + + constructor(props: IProps) { + super(props); + + this.state = { + completed: false, + error: null, + minimized: false, + }; + + this.config = SdkConfig.get().hostSignup; + } + + private messageHandler = async (message: IPostmessage) => { + if (!this.config.url.startsWith(message.origin)) { + return; + } + switch (message.data.action) { + case PostmessageAction.HostSignupAccountDetailsRequest: + this.onAccountDetailsRequest(); + break; + case PostmessageAction.Maximize: + this.setState({ + minimized: false, + }); + break; + case PostmessageAction.Minimize: + this.setState({ + minimized: true, + }); + break; + case PostmessageAction.SetupComplete: + this.setState({ + completed: true, + }); + break; + case PostmessageAction.CloseDialog: + return this.closeDialog(); + } + } + + private maximizeDialog = () => { + this.setState({ + minimized: false, + }); + // Send this action to the iframe so it can act accordingly + this.sendMessage({ + action: PostmessageAction.Maximize, + }); + } + + private minimizeDialog = () => { + this.setState({ + minimized: true, + }); + // Send this action to the iframe so it can act accordingly + this.sendMessage({ + action: PostmessageAction.Minimize, + }); + } + + private closeDialog = async () => { + window.removeEventListener("message", this.messageHandler); + // Ensure we destroy the host signup persisted element + PersistedElement.destroyElement("host_signup"); + // Finally clear the flag in + return HostSignupStore.instance.setHostSignupActive(false); + } + + private onCloseClick = async () => { + if (this.state.completed) { + // We're done, close + return this.closeDialog(); + } else { + Modal.createDialog( + QuestionDialog, + { + title: _t("Confirm abort of host creation"), + description: _t( + "Are you sure you wish to abort creation of the host? The process cannot be continued.", + ), + button: _t("Abort"), + onFinished: result => { + if (result) { + return this.closeDialog(); + } + }, + }, + ); + } + } + + private sendMessage = (message: IPostmessageResponseData) => { + this.iframeRef.current.contentWindow.postMessage(message, this.config.url); + } + + private async sendAccountDetails() { + const openIdToken = await MatrixClientPeg.get().getOpenIdToken(); + if (!openIdToken || !openIdToken.access_token) { + console.warn("Failed to connect to homeserver for OpenID token.") + this.setState({ + completed: true, + error: _t("Failed to connect to your homeserver. Please close this dialog and try again."), + }); + return; + } + this.sendMessage({ + action: PostmessageAction.HostSignupAccountDetails, + account: { + accessToken: await MatrixClientPeg.get().getAccessToken(), + name: OwnProfileStore.instance.displayName, + openIdToken: openIdToken.access_token, + serverName: await MatrixClientPeg.get().getDomain(), + userLocalpart: await MatrixClientPeg.get().getUserIdLocalpart(), + termsAccepted: true, + }, + }); + } + + private onAccountDetailsDialogFinished = async (result) => { + if (result) { + return this.sendAccountDetails(); + } + return this.closeDialog(); + } + + private onAccountDetailsRequest = () => { + const textComponent = ( + <> +

+ {_t("Continuing temporarily allows the %(hostSignupBrand)s setup process to access your " + + "account to fetch verified email addresses. This data is not stored.", { + hostSignupBrand: this.config.brand, + })} +

+

+ {_t("Learn more in our , and .", + {}, + { + cookiePolicyLink: () => ( + + {_t("Cookie Policy")} + + ), + privacyPolicyLink: () => ( + + {_t("Privacy Policy")} + + ), + termsOfServiceLink: () => ( + + {_t("Terms of Service")} + + ), + }, + )} +

+ + ); + Modal.createDialog( + QuestionDialog, + { + title: _t("You should know"), + description: textComponent, + button: _t("Continue"), + onFinished: this.onAccountDetailsDialogFinished, + }, + ); + } + + public componentDidMount() { + window.addEventListener("message", this.messageHandler); + } + + public componentWillUnmount() { + if (HostSignupStore.instance.isHostSignupActive) { + // Run the close dialog actions if we're still active, otherwise good to go + return this.closeDialog(); + } + } + + public render(): React.ReactNode { + return ( +
+ +
+
+ {this.state.minimized && +
+
+ {_t("%(hostSignupBrand)s Setup", { + hostSignupBrand: this.config.brand, + })} +
+ +
+ } + {!this.state.minimized && +
+ + +
+ } + {this.state.error && +
+ {this.state.error} +
+ } + {!this.state.error && +