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/.gitignore b/.gitignore index 33e8bfc7ac..e1dd7726e1 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ package-lock.json /src/component-index.js .DS_Store +*.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index 589121dca6..c31eedf93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,288 @@ +Changes in [3.15.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.15.0) (2021-03-01) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.15.0-rc.1...v3.15.0) + +## Security notice + +matrix-react-sdk 3.15.0 fixes a low severity issue (CVE-2021-21320) where the +user content sandbox can be abused to trick users into opening unexpected +documents. The content is opened with a `blob` origin that cannot access Matrix +user data, so messages and secrets are not at risk. Thanks to @keerok for +responsibly disclosing this via Matrix's Security Disclosure Policy. + +## All changes + + * Upgrade to JS SDK 9.8.0 + +Changes in [3.15.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.15.0-rc.1) (2021-02-24) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.14.0...v3.15.0-rc.1) + + * Upgrade to JS SDK 9.8.0-rc.1 + * Translations update from Weblate + [\#5683](https://github.com/matrix-org/matrix-react-sdk/pull/5683) + * Fix object diffing when objects have different keys + [\#5681](https://github.com/matrix-org/matrix-react-sdk/pull/5681) + * Add if it's missing + [\#5673](https://github.com/matrix-org/matrix-react-sdk/pull/5673) + * Add email only if the verification is complete + [\#5629](https://github.com/matrix-org/matrix-react-sdk/pull/5629) + * Fix portrait videocalls + [\#5676](https://github.com/matrix-org/matrix-react-sdk/pull/5676) + * Tweak code block icon positions + [\#5643](https://github.com/matrix-org/matrix-react-sdk/pull/5643) + * Revert "Improve URL preview formatting and image upload thumbnail size" + [\#5677](https://github.com/matrix-org/matrix-react-sdk/pull/5677) + * Fix context menu leaving visible area + [\#5644](https://github.com/matrix-org/matrix-react-sdk/pull/5644) + * Jitsi conferences names, take 3 + [\#5675](https://github.com/matrix-org/matrix-react-sdk/pull/5675) + * Update isUserOnDarkTheme to take use_system_theme in account + [\#5670](https://github.com/matrix-org/matrix-react-sdk/pull/5670) + * Discard some dead code + [\#5665](https://github.com/matrix-org/matrix-react-sdk/pull/5665) + * Add developer tool to explore and edit settings + [\#5664](https://github.com/matrix-org/matrix-react-sdk/pull/5664) + * Use and create new room helpers + [\#5663](https://github.com/matrix-org/matrix-react-sdk/pull/5663) + * Clear message previews when the maximum limit is reached for history + [\#5661](https://github.com/matrix-org/matrix-react-sdk/pull/5661) + * VoIP virtual rooms, mk II + [\#5639](https://github.com/matrix-org/matrix-react-sdk/pull/5639) + * Disable chat effects when reduced motion preferred + [\#5660](https://github.com/matrix-org/matrix-react-sdk/pull/5660) + * Improve URL preview formatting and image upload thumbnail size + [\#5637](https://github.com/matrix-org/matrix-react-sdk/pull/5637) + * Fix border radius when the panel is collapsed + [\#5641](https://github.com/matrix-org/matrix-react-sdk/pull/5641) + * Use a more generic layout setting - useIRCLayout → layout + [\#5571](https://github.com/matrix-org/matrix-react-sdk/pull/5571) + * Remove redundant lockOrigin parameter from usercontent + [\#5657](https://github.com/matrix-org/matrix-react-sdk/pull/5657) + * Set ICE candidate pool size option + [\#5655](https://github.com/matrix-org/matrix-react-sdk/pull/5655) + * Prepare to encrypt when a call arrives + [\#5654](https://github.com/matrix-org/matrix-react-sdk/pull/5654) + * Use config for host signup branding + [\#5650](https://github.com/matrix-org/matrix-react-sdk/pull/5650) + * Use randomly generated conference names for Jitsi + [\#5649](https://github.com/matrix-org/matrix-react-sdk/pull/5649) + * Modified regex to account for an immediate new line after slash commands + [\#5647](https://github.com/matrix-org/matrix-react-sdk/pull/5647) + * Fix codeblock scrollbar color for non-Firefox + [\#5642](https://github.com/matrix-org/matrix-react-sdk/pull/5642) + * Fix codeblock scrollbar colors + [\#5630](https://github.com/matrix-org/matrix-react-sdk/pull/5630) + * Added loading and disabled the button while searching for server + [\#5634](https://github.com/matrix-org/matrix-react-sdk/pull/5634) + +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) diff --git a/babel.config.js b/babel.config.js index d5a97d56ce..0a3a34a391 100644 --- a/babel.config.js +++ b/babel.config.js @@ -3,12 +3,15 @@ module.exports = { "presets": [ ["@babel/preset-env", { "targets": [ - "last 2 Chrome versions", "last 2 Firefox versions", "last 2 Safari versions" + "last 2 Chrome versions", + "last 2 Firefox versions", + "last 2 Safari versions", + "last 2 Edge versions", ], }], "@babel/preset-typescript", "@babel/preset-flow", - "@babel/preset-react" + "@babel/preset-react", ], "plugins": [ ["@babel/plugin-proposal-decorators", {legacy: true}], @@ -18,6 +21,6 @@ module.exports = { "@babel/plugin-proposal-object-rest-spread", "@babel/plugin-transform-flow-comments", "@babel/plugin-syntax-dynamic-import", - "@babel/plugin-transform-runtime" - ] + "@babel/plugin-transform-runtime", + ], }; 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 c566575fbd..7ed1b272da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.12.1", + "version": "3.15.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", @@ -103,73 +101,76 @@ "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", "url": "^0.11.0", - "velocity-animate": "^1.5.2", + "velocity-animate": "^2.0.6", "what-input": "^5.2.10", "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..d894688cac 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -27,6 +27,9 @@ @import "./structures/_RoomView.scss"; @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; +@import "./structures/_SpacePanel.scss"; +@import "./structures/_SpaceRoomDirectory.scss"; +@import "./structures/_SpaceRoomView.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @@ -56,6 +59,7 @@ @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; +@import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @@ -71,6 +75,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"; @@ -88,6 +93,7 @@ @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; +@import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UploadConfirmDialog.scss"; @@ -106,6 +112,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"; @@ -210,6 +217,7 @@ @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; +@import "./views/settings/_SpellCheckLanguages.scss"; @import "./views/settings/_IntegrationManager.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @@ -230,6 +238,9 @@ @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.scss"; +@import "./views/spaces/_SpaceBasicSettings.scss"; +@import "./views/spaces/_SpaceCreateMenu.scss"; +@import "./views/spaces/_SpacePublicShare.scss"; @import "./views/terms/_InlineTermsAgreement.scss"; @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 168590502d..f1f27014ee 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -15,6 +15,7 @@ limitations under the License. */ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculations +$roomListCollapsedWidth: 68px; .mx_LeftPanel { background-color: $roomlist-bg-color; @@ -37,18 +38,12 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation // GroupFilterPanel handles its own CSS } - &:not(.mx_LeftPanel_hasGroupFilterPanel) { - .mx_LeftPanel_roomListContainer { - width: 100%; - } - } - // Note: The 'room list' in this context is actually everything that isn't the tag // panel, such as the menu options, breadcrumbs, filtering, etc .mx_LeftPanel_roomListContainer { - width: calc(100% - $groupFilterPanelWidth); background-color: $roomlist-bg-color; - + flex: 1 0 0; + min-width: 0; // Create another flexbox (this time a column) for the room list components display: flex; flex-direction: column; @@ -168,17 +163,10 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation // These styles override the defaults for the minimized (66px) layout &.mx_LeftPanel_minimized { min-width: unset; - - // We have to forcefully set the width to override the resizer's style attribute. - &.mx_LeftPanel_hasGroupFilterPanel { - width: calc(68px + $groupFilterPanelWidth) !important; - } - &:not(.mx_LeftPanel_hasGroupFilterPanel) { - width: 68px !important; - } + width: unset !important; .mx_LeftPanel_roomListContainer { - width: 68px; + width: $roomListCollapsedWidth; .mx_LeftPanel_userHeader { flex-direction: row; 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/_MainSplit.scss b/res/css/structures/_MainSplit.scss index ad1656efbb..8199121420 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -18,6 +18,7 @@ limitations under the License. display: flex; flex-direction: row; min-width: 0; + min-height: 0; height: 100%; } diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 5bf0d953f3..5515fe4060 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -160,3 +160,20 @@ limitations under the License. mask-position: center; } } + +.mx_RightPanel_scopeHeader { + margin: 24px; + text-align: center; + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + + .mx_BaseAvatar { + margin-right: 8px; + vertical-align: middle; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } +} 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..26382b55e8 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -20,35 +20,54 @@ limitations under the License. flex-direction: column; } + +@keyframes mx_RoomView_fileDropTarget_animation { + from { + opacity: 0; + } + to { + opacity: 0.95; + } +} + .mx_RoomView_fileDropTarget { min-width: 0px; width: 100%; + height: 100%; + font-size: $font-18px; text-align: center; pointer-events: none; - padding-left: 12px; - padding-right: 12px; - margin-left: -12px; + background-color: $primary-bg-color; + opacity: 0.95; - border-top-left-radius: 10px; - border-top-right-radius: 10px; - - background-color: $droptarget-bg-color; - border: 2px #e1dddd solid; - border-bottom: none; position: absolute; - top: 52px; - bottom: 0px; z-index: 3000; + + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + animation: mx_RoomView_fileDropTarget_animation; + animation-duration: 0.5s; } -.mx_RoomView_fileDropTargetLabel { - top: 50%; - width: 100%; - margin-top: -50px; - position: absolute; +@keyframes mx_RoomView_fileDropTarget_image_animation { + from { + width: 0px; + } + to { + width: 32px; + } +} + +.mx_RoomView_fileDropTarget_image { + animation: mx_RoomView_fileDropTarget_image_animation; + animation-duration: 0.5s; + margin-bottom: 16px; } .mx_RoomView_auxPanel { @@ -117,7 +136,6 @@ limitations under the License. } .mx_RoomView_body { - position: relative; //for .mx_RoomView_auxPanel_fullHeight display: flex; flex-direction: column; flex: 1; @@ -219,7 +237,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/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss new file mode 100644 index 0000000000..9937117086 --- /dev/null +++ b/res/css/structures/_SpacePanel.scss @@ -0,0 +1,349 @@ +/* +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. +*/ + +$topLevelHeight: 32px; +$nestedHeight: 24px; +$gutterSize: 17px; +$activeStripeSize: 4px; +$activeBorderTransparentGap: 2px; + +$activeBackgroundColor: $roomtile-selected-bg-color; +$activeBorderColor: $secondary-fg-color; + +.mx_SpacePanel { + flex: 0 0 auto; + background-color: $groupFilterPanel-bg-color; + padding: 0; + margin: 0; + + // Create another flexbox so the Panel fills the container + display: flex; + flex-direction: column; + overflow-y: auto; + + .mx_SpacePanel_spaceTreeWrapper { + flex: 1; + } + + .mx_SpacePanel_toggleCollapse { + flex: 0 0 auto; + width: 40px; + height: 40px; + mask-position: center; + mask-size: 32px; + mask-repeat: no-repeat; + margin-left: $gutterSize; + margin-bottom: 12px; + background-color: $roomlist-header-color; + mask-image: url('$(res)/img/element-icons/expand-space-panel.svg'); + + &.expanded { + transform: scaleX(-1); + } + } + + ul { + margin: 0; + list-style: none; + padding: 0; + padding-left: 16px; + } + + .mx_AutoHideScrollbar { + padding: 16px 12px 16px 0; + } + + .mx_SpaceButton_toggleCollapse { + cursor: pointer; + } + + .mx_SpaceItem.collapsed { + .mx_SpaceButton { + .mx_NotificationBadge { + right: -4px; + top: -4px; + } + } + + & > .mx_SpaceButton > .mx_SpaceButton_toggleCollapse { + transform: rotate(-90deg); + } + + & > .mx_SpaceTreeLevel { + display: none; + } + } + + .mx_SpaceItem:not(.hasSubSpaces) > .mx_SpaceButton { + margin-left: $gutterSize; + } + + .mx_SpaceButton { + border-radius: 8px; + position: relative; + margin-bottom: 2px; + display: flex; + align-items: center; + padding: 4px; + + &.mx_SpaceButton_active { + &:not(.mx_SpaceButton_narrow) .mx_SpaceButton_selectionWrapper { + background-color: $activeBackgroundColor; + border-radius: 8px; + } + + &.mx_SpaceButton_narrow { + .mx_BaseAvatar, .mx_SpaceButton_avatarPlaceholder { + border: 2px $activeBorderColor solid; + border-radius: 11px; + } + } + } + + .mx_SpaceButton_selectionWrapper { + display: flex; + flex: 1; + align-items: center; + } + + .mx_SpaceButton_name { + flex: 1; + margin-left: 8px; + white-space: nowrap; + display: block; + max-width: 150px; + text-overflow: ellipsis; + overflow: hidden; + padding-right: 8px; + font-size: $font-14px; + line-height: $font-18px; + } + + .mx_SpaceButton_toggleCollapse { + width: calc($gutterSize - $activeStripeSize); + margin-left: 1px; + height: 20px; + mask-position: center; + mask-size: 20px; + mask-repeat: no-repeat; + background-color: $roomlist-header-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + .mx_SpaceButton_icon { + width: $topLevelHeight; + min-width: $topLevelHeight; + height: $topLevelHeight; + border-radius: 8px; + position: relative; + + &::before { + position: absolute; + content: ''; + width: $topLevelHeight; + height: $topLevelHeight; + top: 0; + left: 0; + mask-position: center; + mask-repeat: no-repeat; + mask-size: 18px; + } + } + + &.mx_SpaceButton_home .mx_SpaceButton_icon { + background-color: #ffffff; + + &::before { + background-color: #3f3d3d; + mask-image: url('$(res)/img/element-icons/home.svg'); + } + } + + .mx_SpaceButton_avatarPlaceholder { + border: $activeBorderTransparentGap transparent solid; + padding: $activeBorderTransparentGap; + } + + &.mx_SpaceButton_new .mx_SpaceButton_icon { + background-color: $accent-color; + transition: all .1s ease-in-out; // TODO transition + + &::before { + background-color: #ffffff; + mask-image: url('$(res)/img/element-icons/plus.svg'); + transition: all .2s ease-in-out; // TODO transition + } + } + + &.mx_SpaceButton_newCancel .mx_SpaceButton_icon { + background-color: $icon-button-color; + + &::before { + transform: rotate(45deg); + } + } + + .mx_BaseAvatar { + /* moving the border-radius to this element from _image + element so we can add a border to it without the initials being displaced */ + overflow: hidden; + border: 2px transparent solid; + padding: $activeBorderTransparentGap; + + .mx_BaseAvatar_initial { + top: $activeBorderTransparentGap; + left: $activeBorderTransparentGap; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + } + + .mx_SpaceButton_menuButton { + width: 20px; + min-width: 20px; // yay flex + height: 20px; + margin-top: auto; + margin-bottom: auto; + position: relative; + display: none; + + &::before { + top: 2px; + left: 2px; + content: ''; + width: 16px; + height: 16px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/context-menu.svg'); + background: $primary-fg-color; + } + } + } + + .mx_SpacePanel_badgeContainer { + height: 16px; + // don't set width so that it takes no space when there is no badge to show + margin: auto 0; // vertically align + + // Create a flexbox to make aligning dot badges easier + display: flex; + align-items: center; + + .mx_NotificationBadge { + margin: 0 2px; // centering + } + + .mx_NotificationBadge_dot { + // make the smaller dot occupy the same width for centering + margin-left: 7px; + margin-right: 7px; + } + } + + &.collapsed { + .mx_SpaceButton { + .mx_SpacePanel_badgeContainer { + position: absolute; + right: 0px; + top: 2px; + } + } + } + + &:not(.collapsed) { + .mx_SpaceButton:hover, + .mx_SpaceButton:focus-within, + .mx_SpaceButton_hasMenuOpen { + // Hide the badge container on hover because it'll be a menu button + .mx_SpacePanel_badgeContainer { + width: 0; + height: 0; + display: none; + } + + .mx_SpaceButton_menuButton { + display: block; + } + } + } + + /* root space buttons are bigger and not indented */ + & > .mx_AutoHideScrollbar { + & > .mx_SpaceButton { + height: $topLevelHeight; + + &.mx_SpaceButton_active::before { + height: $topLevelHeight; + } + } + + & > ul { + padding-left: 0; + } + } +} + +.mx_SpacePanel_contextMenu { + .mx_SpacePanel_contextMenu_header { + margin: 12px 16px 12px; + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-18px; + } + + .mx_IconizedContextMenu_optionList .mx_AccessibleButton.mx_SpacePanel_contextMenu_inviteButton { + color: $accent-color; + + .mx_SpacePanel_iconInvite::before { + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + + .mx_SpacePanel_iconSettings::before { + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + + .mx_SpacePanel_iconLeave::before { + mask-image: url('$(res)/img/element-icons/leave.svg'); + } + + .mx_SpacePanel_iconHome::before { + mask-image: url('$(res)/img/element-icons/roomlist/home.svg'); + } + + .mx_SpacePanel_iconMembers::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_SpacePanel_iconPlus::before { + mask-image: url('$(res)/img/element-icons/plus.svg'); + } + + .mx_SpacePanel_iconExplore::before { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } +} + + +.mx_SpacePanel_sharePublicSpace { + margin: 0; +} diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss new file mode 100644 index 0000000000..5cb91820cf --- /dev/null +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -0,0 +1,231 @@ +/* +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_SpaceRoomDirectory_dialogWrapper > .mx_Dialog { + max-width: 960px; + height: 100%; +} + +.mx_SpaceRoomDirectory { + height: 100%; + margin-bottom: 12px; + color: $primary-fg-color; + word-break: break-word; + display: flex; + flex-direction: column; + + .mx_Dialog_title { + display: flex; + + .mx_BaseAvatar { + margin-right: 16px; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + + > div { + > h1 { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + margin: 0; + } + + > div { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + } + + .mx_Dialog_content { + // TODO fix scrollbar + //display: flex; + //flex-direction: column; + //height: calc(100% - 80px); + + .mx_AccessibleButton_kind_link { + padding: 0; + } + + .mx_SearchBox { + margin: 24px 0 28px; + } + + .mx_SpaceRoomDirectory_listHeader { + display: flex; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + + .mx_FormButton { + margin-bottom: 8px; + } + + > span { + margin: auto 0 0 auto; + } + } + } +} + +.mx_SpaceRoomDirectory_list { + margin-top: 8px; + + .mx_SpaceRoomDirectory_roomCount { + > h3 { + display: inline; + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + color: $primary-fg-color; + } + + > span { + margin-left: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-fg-color; + } + } + + .mx_SpaceRoomDirectory_subspace { + margin-top: 8px; + + .mx_SpaceRoomDirectory_subspace_info { + display: flex; + flex-direction: row; + align-items: center; + margin-bottom: 8px; + color: $secondary-fg-color; + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + + .mx_BaseAvatar { + margin-right: 12px; + vertical-align: middle; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + } + + .mx_SpaceRoomDirectory_actions { + text-align: right; + height: min-content; + margin-left: auto; + margin-right: 16px; + } + } + + .mx_SpaceRoomDirectory_subspace_children { + margin-left: 12px; + border-left: 2px solid $space-button-outline-color; + padding-left: 24px; + } + } + + .mx_SpaceRoomDirectory_roomTile { + padding: 16px; + border-radius: 8px; + border: 1px solid $space-button-outline-color; + margin: 8px 0 16px; + display: flex; + min-height: 76px; + box-sizing: border-box; + + &.mx_AccessibleButton:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + .mx_BaseAvatar { + margin-right: 16px; + margin-top: 6px; + } + + .mx_SpaceRoomDirectory_roomTile_info { + display: inline-block; + font-size: $font-15px; + flex-grow: 1; + height: min-content; + margin: auto 0; + + .mx_SpaceRoomDirectory_roomTile_name { + font-weight: $font-semi-bold; + line-height: $font-18px; + } + .mx_SpaceRoomDirectory_roomTile_topic { + line-height: $font-24px; + color: $secondary-fg-color; + } + } + + .mx_SpaceRoomDirectory_roomTile_memberCount { + position: relative; + margin: auto 0 auto 24px; + padding: 0 0 0 28px; + line-height: $font-24px; + display: inline-block; + width: 32px; + + &::before { + position: absolute; + content: ''; + width: 24px; + height: 24px; + top: 0; + left: 0; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + background-color: $secondary-fg-color; + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + + .mx_SpaceRoomDirectory_actions { + width: 180px; + text-align: right; + height: min-content; + margin: auto 0 auto 28px; + + .mx_AccessibleButton { + vertical-align: middle; + + & + .mx_AccessibleButton { + margin-left: 24px; + } + } + } + } + + .mx_SpaceRoomDirectory_actions { + .mx_SpaceRoomDirectory_actionsText { + font-weight: normal; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-fg-color; + } + + .mx_Checkbox { + display: inline-block; + } + } +} diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss new file mode 100644 index 0000000000..38310d39a9 --- /dev/null +++ b/res/css/structures/_SpaceRoomView.scss @@ -0,0 +1,336 @@ +/* +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. +*/ + +$SpaceRoomViewInnerWidth: 428px; + +.mx_SpaceRoomView { + .mx_MainSplit > div:first-child { + padding: 80px 60px; + flex-grow: 1; + + h1 { + margin: 0; + font-size: $font-24px; + font-weight: $font-semi-bold; + color: $primary-fg-color; + width: max-content; + } + + .mx_SpaceRoomView_description { + font-size: $font-15px; + color: $secondary-fg-color; + margin-top: 12px; + margin-bottom: 24px; + } + + .mx_SpaceRoomView_buttons { + display: block; + margin-top: 44px; + width: $SpaceRoomViewInnerWidth; + text-align: right; // button alignment right + + .mx_FormButton { + padding: 8px 22px; + margin-left: 16px; + } + } + + .mx_Field { + max-width: $SpaceRoomViewInnerWidth; + + & + .mx_Field { + margin-top: 28px; + } + } + + .mx_SpaceRoomView_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } + } + + .mx_SpaceRoomView_landing { + overflow-y: auto; + + > .mx_BaseAvatar_image, + > .mx_BaseAvatar > .mx_BaseAvatar_image { + border-radius: 12px; + } + + .mx_SpaceRoomView_landing_name { + margin: 24px 0 16px; + font-size: $font-15px; + color: $secondary-fg-color; + + > span { + display: inline-block; + } + + .mx_SpaceRoomView_landing_nameRow { + margin-top: 12px; + + > h1 { + display: inline-block; + } + } + + .mx_SpaceRoomView_landing_inviter { + .mx_BaseAvatar { + margin-right: 4px; + vertical-align: middle; + } + } + + .mx_SpaceRoomView_landing_memberCount { + position: relative; + margin-left: 24px; + padding: 0 0 0 28px; + line-height: $font-24px; + vertical-align: text-bottom; + + &::before { + position: absolute; + content: ''; + width: 24px; + height: 24px; + top: 0; + left: 0; + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + } + + .mx_SpaceRoomView_landing_topic { + font-size: $font-15px; + } + + .mx_SpaceRoomView_landing_joinButtons { + margin-top: 24px; + + .mx_FormButton { + padding: 8px 22px; + } + } + + .mx_SpaceRoomView_landing_adminButtons { + margin-top: 32px; + + .mx_AccessibleButton { + position: relative; + width: 160px; + height: 124px; + box-sizing: border-box; + padding: 72px 16px 0; + border-radius: 12px; + border: 1px solid $space-button-outline-color; + margin-right: 28px; + margin-bottom: 28px; + font-size: $font-14px; + display: inline-block; + vertical-align: bottom; + + &:last-child { + margin-right: 0; + } + + &:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + &::before, &::after { + position: absolute; + content: ""; + left: 16px; + top: 16px; + height: 40px; + width: 40px; + border-radius: 20px; + } + + &::after { + mask-position: center; + mask-size: 30px; + mask-repeat: no-repeat; + background: #ffffff; // white icon fill + } + + &.mx_SpaceRoomView_landing_inviteButton { + &::before { + background-color: $accent-color; + } + + &::after { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + + &.mx_SpaceRoomView_landing_addButton { + &::before { + background-color: #ac3ba8; + } + + &::after { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + } + + &.mx_SpaceRoomView_landing_createButton { + &::before { + background-color: #368bd6; + } + + &::after { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + } + + &.mx_SpaceRoomView_landing_settingsButton { + &::before { + background-color: #5c56f5; + } + + &::after { + mask-image: url('$(res)/img/element-icons/settings.svg'); + } + } + } + } + + .mx_SpaceRoomDirectory_list { + max-width: 600px; + + .mx_SpaceRoomDirectory_roomTile_actions { + display: none; + } + } + } + + .mx_SpaceRoomView_privateScope { + .mx_RadioButton { + width: $SpaceRoomViewInnerWidth; + border-radius: 8px; + border: 1px solid $space-button-outline-color; + padding: 16px 16px 16px 72px; + margin-top: 36px; + cursor: pointer; + box-sizing: border-box; + position: relative; + + > div:first-of-type { + // hide radio dot + display: none; + } + + .mx_RadioButton_content { + margin: 0; + + > h3 { + margin: 0 0 4px; + font-size: $font-15px; + font-weight: $font-semi-bold; + line-height: $font-18px; + } + + > div { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + + &::before { + content: ""; + position: absolute; + height: 32px; + width: 32px; + top: 24px; + left: 20px; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + } + + .mx_RadioButton_checked { + border-color: $accent-color; + + .mx_RadioButton_content { + > div { + color: $primary-fg-color; + } + } + + &::before { + background-color: $accent-color; + } + } + + .mx_SpaceRoomView_privateScope_justMeButton::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_SpaceRoomView_privateScope_meAndMyTeammatesButton::before { + mask-image: url('$(res)/img/element-icons/community-members.svg'); + } + } + + .mx_SpaceRoomView_inviteTeammates { + .mx_SpaceRoomView_inviteTeammates_buttons { + color: $secondary-fg-color; + margin-top: 28px; + + .mx_AccessibleButton { + position: relative; + display: inline-block; + padding-left: 32px; + line-height: 24px; // to center icons + + &::before { + content: ""; + position: absolute; + height: 24px; + width: 24px; + top: 0; + left: 0; + background-color: $secondary-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + & + .mx_AccessibleButton { + margin-left: 32px; + } + } + + .mx_SpaceRoomView_inviteTeammates_inviteDialogButton::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } + } + } +} diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 0673ebb058..3badb0850c 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -72,6 +72,7 @@ limitations under the License. position: relative; // to make default avatars work margin-right: 8px; height: 32px; // to remove the unknown 4px gap the browser puts below it + padding: 3px 0; // to align with and without using doubleName .mx_UserMenu_userAvatar { border-radius: 32px; // should match avatar size @@ -128,7 +129,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 +273,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/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index d911ac6dfe..204435995f 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -75,6 +75,11 @@ limitations under the License. background-color: $menu-selected-color; } + &.mx_AccessibleButton_disabled { + opacity: 0.5; + cursor: not-allowed; + } + img, .mx_IconizedContextMenu_icon { // icons width: 16px; min-width: 16px; diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.scss b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss new file mode 100644 index 0000000000..0c9d8e3840 --- /dev/null +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.scss @@ -0,0 +1,185 @@ +/* +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_AddExistingToSpaceDialog_wrapper { + .mx_Dialog { + display: flex; + flex-direction: column; + } +} + +.mx_AddExistingToSpaceDialog { + width: 480px; + color: $primary-fg-color; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + min-height: 0; + + .mx_Dialog_title { + display: flex; + + .mx_BaseAvatar { + display: inline-flex; + margin: 5px 16px 5px 5px; + vertical-align: middle; + } + + .mx_BaseAvatar_image { + border-radius: 8px; + margin: 0; + vertical-align: unset; + } + + > div { + > h1 { + font-weight: $font-semi-bold; + font-size: $font-18px; + line-height: $font-22px; + margin: 0; + } + + .mx_AddExistingToSpaceDialog_onlySpace { + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + } + } + + .mx_Dropdown_input { + border: none; + + > .mx_Dropdown_option { + padding-left: 0; + flex: unset; + height: unset; + color: $secondary-fg-color; + font-size: $font-15px; + line-height: $font-24px; + + .mx_BaseAvatar { + display: none; + } + } + + .mx_Dropdown_menu { + .mx_AddExistingToSpaceDialog_dropdownOptionActive { + color: $accent-color; + padding-right: 32px; + position: relative; + + &::before { + content: ''; + width: 20px; + height: 20px; + top: 8px; + right: 0; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + } + } + } + } + + .mx_SearchBox { + margin: 0; + } + + .mx_AddExistingToSpaceDialog_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_AddExistingToSpaceDialog_content { + .mx_AddExistingToSpaceDialog_noResults { + margin-top: 24px; + } + } + + .mx_AddExistingToSpaceDialog_section { + margin-top: 24px; + + > h3 { + margin: 0; + color: $secondary-fg-color; + font-size: $font-12px; + font-weight: $font-semi-bold; + line-height: $font-15px; + } + + .mx_AddExistingToSpaceDialog_entry { + display: flex; + margin-top: 12px; + + .mx_BaseAvatar { + margin-right: 12px; + } + + .mx_AddExistingToSpaceDialog_entry_name { + font-size: $font-15px; + line-height: 30px; + flex-grow: 1; + } + + .mx_FormButton { + min-width: 92px; + font-weight: normal; + box-sizing: border-box; + } + } + } + + .mx_AddExistingToSpaceDialog_section_spaces { + .mx_BaseAvatar_image { + border-radius: 8px; + } + } + + .mx_AddExistingToSpaceDialog_footer { + display: flex; + margin-top: 32px; + + > span { + flex-grow: 1; + font-size: $font-12px; + line-height: $font-15px; + + > * { + vertical-align: middle; + } + } + + .mx_AccessibleButton { + display: inline-block; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + } + } + + .mx_FormButton { + padding: 8px 22px; + } +} diff --git a/res/css/views/dialogs/_DevtoolsDialog.scss b/res/css/views/dialogs/_DevtoolsDialog.scss index 35cb6bc7ab..8fee740016 100644 --- a/res/css/views/dialogs/_DevtoolsDialog.scss +++ b/res/css/views/dialogs/_DevtoolsDialog.scss @@ -223,3 +223,54 @@ limitations under the License. content: ":"; } } + +.mx_DevTools_SettingsExplorer { + table { + width: 100%; + table-layout: fixed; + border-collapse: collapse; + + th { + // Colour choice: first one autocomplete gave me. + border-bottom: 1px solid $accent-color; + text-align: left; + } + + td, th { + width: 360px; // "feels right" number + + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + + td + td, th + th { + width: auto; + } + + tr:hover { + // Colour choice: first one autocomplete gave me. + background-color: $accent-color-50pct; + } + } + + .mx_DevTools_SettingsExplorer_mutable { + background-color: $accent-color; + } + + .mx_DevTools_SettingsExplorer_immutable { + background-color: $warning-color; + } + + .mx_DevTools_SettingsExplorer_edit { + float: right; + margin-right: 16px; + } + + .mx_DevTools_SettingsExplorer_warning { + border: 2px solid $warning-color; + border-radius: 4px; + padding: 4px; + margin-bottom: 8px; + } +} diff --git a/res/css/views/dialogs/_HostSignupDialog.scss b/res/css/views/dialogs/_HostSignupDialog.scss new file mode 100644 index 0000000000..ac4bc41951 --- /dev/null +++ b/res/css/views/dialogs/_HostSignupDialog.scss @@ -0,0 +1,143 @@ +/* +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; + // Ensure dialog borders are always white as the HostSignupDialog + // does not yet support dark mode or theming in general. + // In the future we might want to pass the theme to the called + // iframe, should some hosting provider have that need. + background-color: #ffffff; + + .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/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss new file mode 100644 index 0000000000..c1fa539e9b --- /dev/null +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -0,0 +1,55 @@ +/* +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_SpaceSettingsDialog { + width: 480px; + color: $primary-fg-color; + + .mx_SpaceSettings_errorText { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $notice-primary-color; + margin-bottom: 28px; + } + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-left: 16px; + } + + .mx_AccessibleButton_kind_danger { + margin-top: 28px; + } + + .mx_SpaceSettingsDialog_buttons { + display: flex; + margin-top: 64px; + + .mx_AccessibleButton { + display: inline-block; + } + + .mx_AccessibleButton_kind_link { + margin-left: auto; + } + } + + .mx_FormButton { + padding: 8px 22px; + } +} 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/_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/_AuxPanel.scss b/res/css/views/rooms/_AuxPanel.scss index 34ef5e01d4..17a6294bf0 100644 --- a/res/css/views/rooms/_AuxPanel.scss +++ b/res/css/views/rooms/_AuxPanel.scss @@ -17,7 +17,7 @@ limitations under the License. .m_RoomView_auxPanel_stateViews { padding: 5px; padding-left: 19px; - border-bottom: 1px solid #e5e5e5; + border-bottom: 1px solid $primary-hairline-color; } .m_RoomView_auxPanel_stateViews_span a { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 429ac7ed4b..5841cf2853 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; @@ -258,17 +257,13 @@ $left-gutter: 64px; display: inline-block; width: 14px; height: 14px; - top: 29px; + // This aligns the avatar with the last line of the + // message. We want to move it one line up - 2.2rem + top: -2.2rem; user-select: none; z-index: 1; } -.mx_EventTile_continuation .mx_EventTile_readAvatars, -.mx_EventTile_info .mx_EventTile_readAvatars, -.mx_EventTile_emote .mx_EventTile_readAvatars { - top: 7px; -} - .mx_EventTile_readAvatars .mx_BaseAvatar { position: absolute; display: inline-block; @@ -421,15 +416,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, @@ -447,8 +442,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) @@ -493,7 +487,6 @@ $left-gutter: 64px; // https://github.com/vector-im/vector-web/issues/754 overflow-x: overlay; overflow-y: visible; - max-height: 30vh; } code { @@ -502,6 +495,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 @@ -513,21 +522,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;
+    top: 8px;
+    right: 8px;
     width: 19px;
     height: 19px;
-    mask-image: url($copy-button-url);
     background-color: $message-action-bar-fg-color;
 }
+.mx_EventTile_buttonBottom {
+    top: 33px;
+}
+.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..903fabc8fd 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;
         }
@@ -105,16 +105,9 @@ $left-gutter: 64px;
         }
 
         .mx_EventTile_readAvatars {
-            top: 27px;
-        }
-
-        &.mx_EventTile_continuation .mx_EventTile_readAvatars,
-        &.mx_EventTile_emote .mx_EventTile_readAvatars {
-            top: 5px;
-        }
-
-        &.mx_EventTile_info .mx_EventTile_readAvatars {
-            top: 4px;
+            // This aligns the avatar with the last line of the
+            // message. We want to move it one line up - 2rem
+            top: -2rem;
         }
 
         .mx_EventTile_content .markdown-body {
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/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss
index 182c280217..3f7f83d334 100644
--- a/res/css/views/rooms/_MemberInfo.scss
+++ b/res/css/views/rooms/_MemberInfo.scss
@@ -19,6 +19,7 @@ limitations under the License.
     flex-direction: column;
     flex: 1;
     overflow-y: auto;
+    margin-top: 8px;
 }
 
 .mx_MemberInfo_name {
diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss
index 1e3506e371..075e9ff585 100644
--- a/res/css/views/rooms/_MemberList.scss
+++ b/res/css/views/rooms/_MemberList.scss
@@ -44,6 +44,12 @@ limitations under the License.
     .mx_AutoHideScrollbar {
         flex: 1 1 0;
     }
+
+    .mx_RightPanel_scopeHeader {
+        // vertically align with position on other right panel cards
+        // to prevent it bouncing as user navigates right panel
+        margin-top: -8px;
+    }
 }
 
 .mx_GroupMemberList_query,
diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss
index 71c0db947e..dea1b58741 100644
--- a/res/css/views/rooms/_MessageComposer.scss
+++ b/res/css/views/rooms/_MessageComposer.scss
@@ -227,18 +227,6 @@ limitations under the License.
     mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
 }
 
-.mx_MessageComposer_hangup::before {
-    mask-image: url('$(res)/img/element-icons/call/hangup.svg');
-}
-
-.mx_MessageComposer_voicecall::before {
-    mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
-}
-
-.mx_MessageComposer_videocall::before {
-    mask-image: url('$(res)/img/element-icons/call/video-call.svg');
-}
-
 .mx_MessageComposer_emoji::before {
     mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg');
 }
@@ -247,6 +235,32 @@ limitations under the License.
     mask-image: url('$(res)/img/element-icons/room/composer/sticker.svg');
 }
 
+.mx_MessageComposer_sendMessage {
+    cursor: pointer;
+    position: relative;
+    margin-right: 6px;
+    width: 32px;
+    height: 32px;
+    border-radius: 100%;
+    background-color: $button-bg-color;
+
+    &::before {
+        position: absolute;
+        height: 16px;
+        width: 16px;
+        top: 8px;
+        left: 9px;
+
+        mask-image: url('$(res)/img/element-icons/send-message.svg');
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+
+        background-color: $button-fg-color;
+        content: '';
+    }
+}
+
 .mx_MessageComposer_formatting {
     cursor: pointer;
     margin: 0 11px;
diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss
index a23a44906f..387d1588a3 100644
--- a/res/css/views/rooms/_RoomHeader.scss
+++ b/res/css/views/rooms/_RoomHeader.scss
@@ -252,6 +252,19 @@ limitations under the License.
     mask-image: url('$(res)/img/element-icons/room/search-inset.svg');
 }
 
+.mx_RoomHeader_voiceCallButton::before {
+    mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+
+    // The call button SVG is padded slightly differently, so match it up to the size
+    // of the other icons
+    mask-size: 20px;
+    mask-position: center;
+}
+
+.mx_RoomHeader_videoCallButton::before {
+    mask-image: url('$(res)/img/element-icons/call/video-call.svg');
+}
+
 .mx_RoomHeader_showPanel {
     height: 16px;
 }
diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss
index 66e1b827d0..d49ed4b736 100644
--- a/res/css/views/rooms/_RoomList.scss
+++ b/res/css/views/rooms/_RoomList.scss
@@ -19,7 +19,10 @@ limitations under the License.
 }
 
 .mx_RoomList_iconPlus::before {
-    mask-image: url('$(res)/img/element-icons/roomlist/plus.svg');
+    mask-image: url('$(res)/img/element-icons/roomlist/plus-circle.svg');
+}
+.mx_RoomList_iconHash::before {
+    mask-image: url('$(res)/img/element-icons/roomlist/hash-circle.svg');
 }
 .mx_RoomList_iconExplore::before {
     mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
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/css/views/settings/_SpellCheckLanguages.scss b/res/css/views/settings/_SpellCheckLanguages.scss
new file mode 100644
index 0000000000..bb322c983f
--- /dev/null
+++ b/res/css/views/settings/_SpellCheckLanguages.scss
@@ -0,0 +1,35 @@
+/*
+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_ExistingSpellCheckLanguage {
+    display: flex;
+    align-items: center;
+    margin-bottom: 5px;
+}
+
+.mx_ExistingSpellCheckLanguage_language {
+    flex: 1;
+    margin-right: 10px;
+}
+
+.mx_GeneralUserSettingsTab_spellCheckLanguageInput {
+    margin-top: 1em;
+    margin-bottom: 1em;
+}
+
+.mx_SpellCheckLanguages {
+    @mixin mx_Settings_fullWidthField;
+}
diff --git a/res/css/views/spaces/_SpaceBasicSettings.scss b/res/css/views/spaces/_SpaceBasicSettings.scss
new file mode 100644
index 0000000000..204ccab2b7
--- /dev/null
+++ b/res/css/views/spaces/_SpaceBasicSettings.scss
@@ -0,0 +1,86 @@
+/*
+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_SpaceBasicSettings {
+    .mx_Field {
+        margin: 32px 0;
+    }
+
+    .mx_SpaceBasicSettings_avatarContainer {
+        display: flex;
+        margin-top: 24px;
+
+        .mx_SpaceBasicSettings_avatar {
+            position: relative;
+            height: 80px;
+            width: 80px;
+            background-color: $tertiary-fg-color;
+            border-radius: 16px;
+        }
+
+        img.mx_SpaceBasicSettings_avatar {
+            width: 80px;
+            height: 80px;
+            object-fit: cover;
+            border-radius: 16px;
+        }
+
+        // only show it when the button is a div and not an img (has avatar)
+        div.mx_SpaceBasicSettings_avatar {
+            cursor: pointer;
+
+            &::before {
+                content: "";
+                position: absolute;
+                height: 80px;
+                width: 80px;
+                top: 0;
+                left: 0;
+                background-color: #ffffff; // white icon fill
+                mask-repeat: no-repeat;
+                mask-position: center;
+                mask-size: 20px;
+                mask-image: url('$(res)/img/element-icons/camera.svg');
+            }
+        }
+
+        > input[type="file"] {
+            display: none;
+        }
+
+        > .mx_AccessibleButton_kind_link {
+            display: inline-block;
+            padding: 0;
+            margin: auto 16px;
+            color: #368bd6;
+        }
+
+        > .mx_SpaceBasicSettings_avatar_remove {
+            color: $notice-primary-color;
+        }
+    }
+
+    .mx_FormButton {
+        padding: 8px 22px;
+        margin-left: auto;
+        display: block;
+        width: min-content;
+    }
+
+    .mx_AccessibleButton_disabled {
+        cursor: not-allowed;
+    }
+}
diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss
new file mode 100644
index 0000000000..2a11ec9f23
--- /dev/null
+++ b/res/css/views/spaces/_SpaceCreateMenu.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.
+*/
+
+// TODO: the space panel currently does not have a fixed width,
+// just the headers at each level have a max-width of 150px
+// so this will look slightly off for now. We should probably use css grid for the whole main layout...
+$spacePanelWidth: 200px;
+
+.mx_SpaceCreateMenu_wrapper {
+    // background blur everything except SpacePanel
+    .mx_ContextualMenu_background {
+        background-color: $dialog-backdrop-color;
+        opacity: 0.6;
+        left: $spacePanelWidth;
+    }
+
+    .mx_ContextualMenu {
+        padding: 24px;
+        width: 480px;
+        box-sizing: border-box;
+        background-color: $primary-bg-color;
+
+        > div {
+            > h2 {
+                font-weight: $font-semi-bold;
+                font-size: $font-18px;
+                margin-top: 4px;
+            }
+
+            > p {
+                font-size: $font-15px;
+                color: $secondary-fg-color;
+                margin: 0;
+            }
+        }
+
+        .mx_SpaceCreateMenuType {
+            position: relative;
+            padding: 16px 32px 16px 72px;
+            width: 432px;
+            box-sizing: border-box;
+            border-radius: 8px;
+            border: 1px solid $input-darker-bg-color;
+            font-size: $font-15px;
+            margin: 20px 0;
+
+            > h3 {
+                font-weight: $font-semi-bold;
+                margin: 0 0 4px;
+            }
+
+            > span {
+                color: $secondary-fg-color;
+            }
+
+            &::before {
+                position: absolute;
+                content: '';
+                width: 32px;
+                height: 32px;
+                top: 24px;
+                left: 20px;
+                mask-position: center;
+                mask-repeat: no-repeat;
+                mask-size: 32px;
+                background-color: $tertiary-fg-color;
+            }
+
+            &:hover {
+                border-color: $accent-color;
+
+                &::before {
+                    background-color: $accent-color;
+                }
+
+                > span {
+                    color: $primary-fg-color;
+                }
+            }
+        }
+
+        .mx_SpaceCreateMenuType_public::before {
+            mask-image: url('$(res)/img/globe.svg');
+            mask-size: 26px;
+        }
+        .mx_SpaceCreateMenuType_private::before {
+            mask-image: url('$(res)/img/element-icons/lock.svg');
+        }
+
+        .mx_SpaceCreateMenu_back {
+            width: 28px;
+            height: 28px;
+            position: relative;
+            background-color: $theme-button-bg-color;
+            border-radius: 14px;
+            margin-bottom: 12px;
+
+            &::before {
+                content: "";
+                position: absolute;
+                height: 28px;
+                width: 28px;
+                top: 0;
+                left: 0;
+                background-color: $muted-fg-color;
+                transform: rotate(90deg);
+                mask-repeat: no-repeat;
+                mask-position: 2px 3px;
+                mask-size: 24px;
+                mask-image: url('$(res)/img/feather-customised/chevron-down.svg');
+            }
+        }
+
+        .mx_FormButton {
+            padding: 8px 22px;
+            margin-left: auto;
+            display: block;
+            width: min-content;
+        }
+
+        .mx_AccessibleButton_disabled {
+            cursor: not-allowed;
+        }
+    }
+}
diff --git a/res/css/views/spaces/_SpacePublicShare.scss b/res/css/views/spaces/_SpacePublicShare.scss
new file mode 100644
index 0000000000..9ba0549ae3
--- /dev/null
+++ b/res/css/views/spaces/_SpacePublicShare.scss
@@ -0,0 +1,60 @@
+/*
+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_SpacePublicShare {
+    .mx_AccessibleButton {
+        border: 1px solid $space-button-outline-color;
+        box-sizing: border-box;
+        border-radius: 8px;
+        padding: 12px 24px 12px 52px;
+        margin-top: 16px;
+        width: $SpaceRoomViewInnerWidth;
+        font-size: $font-15px;
+        line-height: $font-24px;
+        position: relative;
+        display: flex;
+
+        > span {
+            color: #368bd6;
+            margin-left: auto;
+        }
+
+        &:hover {
+            background-color: rgba(141, 151, 165, 0.1);
+        }
+
+        &::before {
+            content: "";
+            position: absolute;
+            width: 30px;
+            height: 30px;
+            mask-repeat: no-repeat;
+            mask-size: contain;
+            mask-position: center;
+            background: $muted-fg-color;
+            left: 12px;
+            top: 9px;
+        }
+
+        &.mx_SpacePublicShare_shareButton::before {
+            mask-image: url('$(res)/img/element-icons/link.svg');
+        }
+
+        &.mx_SpacePublicShare_inviteButton::before {
+            mask-image: url('$(res)/img/element-icons/room/invite.svg');
+        }
+    }
+}
diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss
index 931410dba3..3e473a80b2 100644
--- a/res/css/views/voip/_VideoFeed.scss
+++ b/res/css/views/voip/_VideoFeed.scss
@@ -16,6 +16,7 @@ limitations under the License.
 
 .mx_VideoFeed_remote {
     width: 100%;
+    max-height: 100%;
     background-color: #000;
     z-index: 50;
 }
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/element-icons/expand-space-panel.svg b/res/img/element-icons/expand-space-panel.svg
new file mode 100644
index 0000000000..11232acd58
--- /dev/null
+++ b/res/img/element-icons/expand-space-panel.svg
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/res/img/element-icons/link.svg b/res/img/element-icons/link.svg
new file mode 100644
index 0000000000..ab3d54b838
--- /dev/null
+++ b/res/img/element-icons/link.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/lock.svg b/res/img/element-icons/lock.svg
new file mode 100644
index 0000000000..06fe52a391
--- /dev/null
+++ b/res/img/element-icons/lock.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/plus.svg b/res/img/element-icons/plus.svg
new file mode 100644
index 0000000000..ea1972237d
--- /dev/null
+++ b/res/img/element-icons/plus.svg
@@ -0,0 +1,3 @@
+
+    
+
diff --git a/res/img/element-icons/room/invite.svg b/res/img/element-icons/room/invite.svg
index 655f9f118a..d2ecb837b2 100644
--- a/res/img/element-icons/room/invite.svg
+++ b/res/img/element-icons/room/invite.svg
@@ -1,3 +1,3 @@
-
+
 
 
diff --git a/res/img/element-icons/roomlist/hash-circle.svg b/res/img/element-icons/roomlist/hash-circle.svg
new file mode 100644
index 0000000000..924b22cf32
--- /dev/null
+++ b/res/img/element-icons/roomlist/hash-circle.svg
@@ -0,0 +1,7 @@
+
+    
+        
+    
+    
+    
+
diff --git a/res/img/element-icons/roomlist/plus-circle.svg b/res/img/element-icons/roomlist/plus-circle.svg
new file mode 100644
index 0000000000..251ded225c
--- /dev/null
+++ b/res/img/element-icons/roomlist/plus-circle.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/send-message.svg b/res/img/element-icons/send-message.svg
new file mode 100644
index 0000000000..ce35bf8bc8
--- /dev/null
+++ b/res/img/element-icons/send-message.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/img/upload-big.svg b/res/img/upload-big.svg
index 6099c2e976..9a6a265fdb 100644
--- a/res/img/upload-big.svg
+++ b/res/img/upload-big.svg
@@ -1,19 +1,3 @@
-
-
-    
-    icons_upload_drop
-    Created with bin/sketchtool.
-    
-    
-        
-            
-                
-                    
-                    
-                    
-                
-                
-            
-        
-    
+
+
 
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index 08fe2e9f57..0de5e69782 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -123,6 +123,7 @@ $roomsublist-divider-color: $primary-fg-color;
 $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
+$space-button-outline-color: rgba(141, 151, 165, 0.2);
 
 $roomtile-preview-color: $secondary-fg-color;
 $roomtile-default-badge-bg-color: #61708b;
@@ -258,6 +259,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-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index 3e3c299af9..8c5f20178b 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -120,6 +120,7 @@ $roomsublist-divider-color: $primary-fg-color;
 $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
+$space-button-outline-color: rgba(141, 151, 165, 0.2);
 
 $roomtile-preview-color: #9e9e9e;
 $roomtile-default-badge-bg-color: #61708b;
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 085d6d7f10..3ba10a68ea 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -187,6 +187,7 @@ $roomsublist-divider-color: $primary-fg-color;
 $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
+$space-button-outline-color: #E3E8F0;
 
 $roomtile-preview-color: #9e9e9e;
 $roomtile-default-badge-bg-color: #61708b;
@@ -237,7 +238,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..b6906d16be 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -67,9 +67,6 @@ $groupFilterPanel-bg-color: rgba(232, 232, 232, 0.77);
 // used by RoomDirectory permissions
 $plinth-bg-color: $secondary-accent-color;
 
-// used by RoomDropTarget
-$droptarget-bg-color: rgba(255,255,255,0.5);
-
 // used by AddressSelector
 $selected-color: $secondary-accent-color;
 
@@ -181,6 +178,7 @@ $roomsublist-divider-color: $primary-fg-color;
 $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%);
 
 $groupFilterPanel-divider-color: $roomlist-header-color;
+$space-button-outline-color: #E3E8F0;
 
 $roomtile-preview-color: $secondary-fg-color;
 $roomtile-default-badge-bg-color: #61708b;
@@ -237,6 +235,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/_mods.scss b/res/themes/light/css/_mods.scss
index 30aaeedf8f..fbca58dfb1 100644
--- a/res/themes/light/css/_mods.scss
+++ b/res/themes/light/css/_mods.scss
@@ -16,6 +16,10 @@
         backdrop-filter: blur($groupFilterPanel-background-blur-amount);
     }
 
+    .mx_SpacePanel {
+        backdrop-filter: blur($groupFilterPanel-background-blur-amount);
+    }
+
     .mx_LeftPanel .mx_LeftPanel_roomListContainer {
         backdrop-filter: blur($roomlist-background-blur-amount);
     }
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 2a28c8e43f..4aa6df5488 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -37,6 +37,8 @@ import CountlyAnalytics from "../CountlyAnalytics";
 import UserActivity from "../UserActivity";
 import {ModalWidgetStore} from "../stores/ModalWidgetStore";
 import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
+import VoipUserMapper from "../VoipUserMapper";
+import {SpaceStoreClass} from "../stores/SpaceStore";
 
 declare global {
     interface Window {
@@ -66,6 +68,8 @@ declare global {
         mxCountlyAnalytics: typeof CountlyAnalytics;
         mxUserActivity: UserActivity;
         mxModalWidgetStore: ModalWidgetStore;
+        mxVoipUserMapper: VoipUserMapper;
+        mxSpaceStore: SpaceStoreClass;
     }
 
     interface Document {
diff --git a/src/Avatar.ts b/src/Avatar.ts
index 60bdfdcf75..e2557e21a8 100644
--- a/src/Avatar.ts
+++ b/src/Avatar.ts
@@ -165,6 +165,9 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
         return explicitRoomAvatar;
     }
 
+    // space rooms cannot be DMs so skip the rest
+    if (room.isSpaceRoom()) return null;
+
     let otherMember = null;
     const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
     if (otherUserId) {
diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts
index c301aa6a10..9d7077097b 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;
 
@@ -130,6 +131,14 @@ export default abstract class BasePlatform {
         hideUpdateToast();
     }
 
+    /**
+     * Return true if platform supports multi-language
+     * spell-checking, otherwise false.
+     */
+    supportsMultiLanguageSpellCheck(): boolean {
+        return false;
+    }
+
     /**
      * Returns true if the platform supports displaying
      * notifications, otherwise false.
@@ -239,6 +248,16 @@ export default abstract class BasePlatform {
 
     setLanguage(preferredLangs: string[]) {}
 
+    setSpellCheckLanguages(preferredLangs: string[]) {}
+
+    getSpellCheckLanguages(): Promise | null {
+        return null;
+    }
+
+    getAvailableSpellCheckLanguages(): Promise | null {
+        return null;
+    }
+
     protected getSSOCallbackUrl(fragmentAfterLogin: string): URL {
         const url = new URL(window.location.href);
         url.hash = fragmentAfterLogin || "";
@@ -258,6 +277,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 f09133d020..8621f441de 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 { randomUppercaseString, randomLowercaseString } 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 && (
@@ -393,6 +472,10 @@ export default class CallHandler {
                         // 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,9 +626,11 @@ 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 timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now();
+        console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " seconds");
         const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId);
 
         this.calls.set(roomId, call);
@@ -530,9 +658,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 +676,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 +702,15 @@ 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;
+                    }
+
+                    if (this.getCallForRoom(room.roomId)) {
+                        Modal.createTrackedDialog('Call Handler', 'Existing Call with user', ErrorDialog, {
+                            title: _t('Already in call'),
+                            description: _t("You're already in a call with this person."),
+                        });
                         return;
                     }
 
@@ -571,7 +721,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 +736,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 +767,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':
@@ -715,8 +871,9 @@ export default class CallHandler {
             // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification
             confId = base32.stringify(Buffer.from(roomId), { pad: false });
         } else {
-            // Create a random human readable conference ID
-            confId = `JitsiConference${generateHumanReadableId()}`;
+            // Create a random conference ID
+            const random = randomUppercaseString(1) + randomLowercaseString(23);
+            confId = 'Jitsi' + random;
         }
 
         let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth});
@@ -732,6 +889,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/ObjectUtils.js b/src/ObjectUtils.js
deleted file mode 100644
index 24dfe61d68..0000000000
--- a/src/ObjectUtils.js
+++ /dev/null
@@ -1,113 +0,0 @@
-/*
-Copyright 2016 OpenMarket Ltd
-Copyright 2019 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.
-*/
-
-/**
- * For two objects of the form { key: [val1, val2, val3] }, work out the added/removed
- * values. Entirely new keys will result in the entire value array being added.
- * @param {Object} before
- * @param {Object} after
- * @return {Object[]} An array of objects with the form:
- * { key: $KEY, val: $VALUE, place: "add|del" }
- */
-export function getKeyValueArrayDiffs(before, after) {
-    const results = [];
-    const delta = {};
-    Object.keys(before).forEach(function(beforeKey) {
-        delta[beforeKey] = delta[beforeKey] || 0; // init to 0 initially
-        delta[beforeKey]--; // keys present in the past have -ve values
-    });
-    Object.keys(after).forEach(function(afterKey) {
-        delta[afterKey] = delta[afterKey] || 0; // init to 0 initially
-        delta[afterKey]++; // keys present in the future have +ve values
-    });
-
-    Object.keys(delta).forEach(function(muxedKey) {
-        switch (delta[muxedKey]) {
-            case 1: // A new key in after
-                after[muxedKey].forEach(function(afterVal) {
-                    results.push({ place: "add", key: muxedKey, val: afterVal });
-                });
-                break;
-            case -1: // A before key was removed
-                before[muxedKey].forEach(function(beforeVal) {
-                    results.push({ place: "del", key: muxedKey, val: beforeVal });
-                });
-                break;
-            case 0: {// A mix of added/removed keys
-                // compare old & new vals
-                const itemDelta = {};
-                before[muxedKey].forEach(function(beforeVal) {
-                    itemDelta[beforeVal] = itemDelta[beforeVal] || 0;
-                    itemDelta[beforeVal]--;
-                });
-                after[muxedKey].forEach(function(afterVal) {
-                    itemDelta[afterVal] = itemDelta[afterVal] || 0;
-                    itemDelta[afterVal]++;
-                });
-
-                Object.keys(itemDelta).forEach(function(item) {
-                    if (itemDelta[item] === 1) {
-                        results.push({ place: "add", key: muxedKey, val: item });
-                    } else if (itemDelta[item] === -1) {
-                        results.push({ place: "del", key: muxedKey, val: item });
-                    } else {
-                        // itemDelta of 0 means it was unchanged between before/after
-                    }
-                });
-                break;
-            }
-            default:
-                console.error("Calculated key delta of " + delta[muxedKey] + " - this should never happen!");
-                break;
-        }
-    });
-
-    return results;
-}
-
-/**
- * Shallow-compare two objects for equality: each key and value must be identical
- * @param {Object} objA First object to compare against the second
- * @param {Object} objB Second object to compare against the first
- * @return {boolean} whether the two objects have same key=values
- */
-export function shallowEqual(objA, objB) {
-    if (objA === objB) {
-        return true;
-    }
-
-    if (typeof objA !== 'object' || objA === null ||
-          typeof objB !== 'object' || objB === null) {
-        return false;
-    }
-
-    const keysA = Object.keys(objA);
-    const keysB = Object.keys(objB);
-
-    if (keysA.length !== keysB.length) {
-        return false;
-    }
-
-    for (let i = 0; i < keysA.length; i++) {
-        const key = keysA[i];
-        if (!objB.hasOwnProperty(key) || objA[key] !== objB[key]) {
-            return false;
-        }
-    }
-
-    return true;
-}
diff --git a/src/PhasedRollOut.js b/src/PhasedRollOut.js
deleted file mode 100644
index b17ed37974..0000000000
--- a/src/PhasedRollOut.js
+++ /dev/null
@@ -1,51 +0,0 @@
-/*
-Copyright 2018 New Vector Ltd
-
-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 SdkConfig from './SdkConfig';
-import {hashCode} from './utils/FormattingUtils';
-
-export function phasedRollOutExpiredForUser(username, feature, now, rollOutConfig = SdkConfig.get().phasedRollOut) {
-    if (!rollOutConfig) {
-        console.log(`no phased rollout configuration, so enabling ${feature}`);
-        return true;
-    }
-    const featureConfig = rollOutConfig[feature];
-    if (!featureConfig) {
-        console.log(`${feature} doesn't have phased rollout configured, so enabling`);
-        return true;
-    }
-    if (!Number.isFinite(featureConfig.offset) || !Number.isFinite(featureConfig.period)) {
-        console.error(`phased rollout of ${feature} is misconfigured, ` +
-            `offset and/or period are not numbers, so disabling`, featureConfig);
-        return false;
-    }
-
-    const hash = hashCode(username);
-    //ms -> min, enable users at minute granularity
-    const bucketRatio = 1000 * 60;
-    const bucketCount = featureConfig.period / bucketRatio;
-    const userBucket = hash % bucketCount;
-    const userMs = userBucket * bucketRatio;
-    const enableAt = featureConfig.offset + userMs;
-    const result = now >= enableAt;
-    const bucketStr = `(bucket ${userBucket}/${bucketCount})`;
-    if (result) {
-        console.log(`${feature} enabled for ${username} ${bucketStr}`);
-    } else {
-        console.log(`${feature} will be enabled for ${username} in ${Math.ceil((enableAt - now)/1000)}s ${bucketStr}`);
-    }
-    return result;
-}
diff --git a/src/RoomInvite.js b/src/RoomInvite.js
index 06d3fb04e8..503411d2b3 100644
--- a/src/RoomInvite.js
+++ b/src/RoomInvite.js
@@ -22,7 +22,7 @@ import MultiInviter from './utils/MultiInviter';
 import Modal from './Modal';
 import * as sdk from './';
 import { _t } from './languageHandler';
-import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog";
+import InviteDialog, {KIND_DM, KIND_INVITE, KIND_SPACE_INVITE} from "./components/views/dialogs/InviteDialog";
 import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog";
 import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore";
 
@@ -50,10 +50,13 @@ export function showStartChatInviteDialog(initialText) {
 }
 
 export function showRoomInviteDialog(roomId) {
+    const isSpace = MatrixClientPeg.get()?.getRoom(roomId)?.isSpaceRoom();
     // This dialog handles the room creation internally - we don't need to worry about it.
-    const InviteDialog = sdk.getComponent("dialogs.InviteDialog");
     Modal.createTrackedDialog(
-        'Invite Users', '', InviteDialog, {kind: KIND_INVITE, roomId},
+        "Invite Users", isSpace ? "Space" : "Room", InviteDialog, {
+            kind: isSpace ? KIND_SPACE_INVITE : KIND_INVITE,
+            roomId,
+        },
         /*className=*/null, /*isPriority=*/false, /*isStatic=*/true,
     );
 }
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/SecurityManager.ts b/src/SecurityManager.ts
index 220320470a..03cbe88c22 100644
--- a/src/SecurityManager.ts
+++ b/src/SecurityManager.ts
@@ -98,11 +98,27 @@ async function getSecretStorageKey(
     { keys: keyInfos }: { keys: Record },
     ssssItemName,
 ): Promise<[string, Uint8Array]> {
-    const keyInfoEntries = Object.entries(keyInfos);
-    if (keyInfoEntries.length > 1) {
-        throw new Error("Multiple storage key requests not implemented");
+    const cli = MatrixClientPeg.get();
+    let keyId = await cli.getDefaultSecretStorageKeyId();
+    let keyInfo;
+    if (keyId) {
+        // use the default SSSS key if set
+        keyInfo = keyInfos[keyId];
+        if (!keyInfo) {
+            // if the default key is not available, pretend the default key
+            // isn't set
+            keyId = undefined;
+        }
+    }
+    if (!keyId) {
+        // if no default SSSS key is set, fall back to a heuristic of using the
+        // only available key, if only one key is set
+        const keyInfoEntries = Object.entries(keyInfos);
+        if (keyInfoEntries.length > 1) {
+            throw new Error("Multiple storage key requests not implemented");
+        }
+        [keyId, keyInfo] = keyInfoEntries[0];
     }
-    const [keyId, keyInfo] = keyInfoEntries[0];
 
     // Check the in-memory cache
     if (isCachingAllowed() && secretStorageKeys[keyId]) {
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/Velociraptor.js b/src/Velociraptor.js
index ce52f60dbd..2da54babe5 100644
--- a/src/Velociraptor.js
+++ b/src/Velociraptor.js
@@ -118,25 +118,10 @@ export default class Velociraptor extends React.Component {
                     domNode.style.visibility = restingStyle.visibility;
                 });
 
-            /*
-            console.log("enter:",
-                        JSON.stringify(transitionOpts[i-1]),
-                        "->",
-                        JSON.stringify(restingStyle));
-            */
-        } else if (node === null) {
-            // Velocity stores data on elements using the jQuery .data()
-            // method, and assumes you'll be using jQuery's .remove() to
-            // remove the element, but we don't use jQuery, so we need to
-            // blow away the element's data explicitly otherwise it will leak.
-            // This uses Velocity's internal jQuery compatible wrapper.
-            // See the bug at
-            // https://github.com/julianshapiro/velocity/issues/300
-            // and the FAQ entry, "Preventing memory leaks when
-            // creating/destroying large numbers of elements"
-            // (https://github.com/julianshapiro/velocity/issues/47)
-            const domNode = ReactDom.findDOMNode(this.nodes[k]);
-            if (domNode) Velocity.Utilities.removeData(domNode);
+            // console.log("enter:",
+            //             JSON.stringify(transitionOpts[i-1]),
+            //             "->",
+            //             JSON.stringify(restingStyle));
         }
         this.nodes[k] = node;
     }
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/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx
index 0bb169abf8..9a7c1d1f0a 100644
--- a/src/accessibility/context_menu/MenuItem.tsx
+++ b/src/accessibility/context_menu/MenuItem.tsx
@@ -19,14 +19,23 @@ limitations under the License.
 import React from "react";
 
 import AccessibleButton from "../../components/views/elements/AccessibleButton";
+import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
 
 interface IProps extends React.ComponentProps {
     label?: string;
+    tooltip?: string;
 }
 
 // Semantic component for representing a role=menuitem
-export const MenuItem: React.FC = ({children, label, ...props}) => {
+export const MenuItem: React.FC = ({children, label, tooltip, ...props}) => {
     const ariaLabel = props["aria-label"] || label;
+
+    if (tooltip) {
+        return 
+            { children }
+        ;
+    }
+
     return (
         
             { children }
diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx
index 32eea55b0b..7fc01daef9 100644
--- a/src/autocomplete/UserProvider.tsx
+++ b/src/autocomplete/UserProvider.tsx
@@ -155,6 +155,7 @@ export default class UserProvider extends AutocompleteProvider {
 
         const currentUserId = MatrixClientPeg.get().credentials.userId;
         this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId);
+        this.users = this.users.concat(this.room.getMembersWithMembership("invite"));
 
         this.users = sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20);
 
diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx
index aab7701f26..726ff547ff 100644
--- a/src/components/structures/ContextMenu.tsx
+++ b/src/components/structures/ContextMenu.tsx
@@ -76,6 +76,7 @@ export interface IProps extends IPosition {
     hasBackground?: boolean;
     // whether this context menu should be focus managed. If false it must handle itself
     managed?: boolean;
+    wrapperClassName?: string;
 
     // Function to be called on menu close
     onFinished();
@@ -299,7 +300,7 @@ export class ContextMenu extends React.PureComponent {
             // such that it does not leave the (padded) window.
             if (contextMenuRect) {
                 const padding = 10;
-                adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height + padding);
+                adjusted = Math.min(position.top, document.body.clientHeight - contextMenuRect.height - padding);
             }
 
             position.top = adjusted;
@@ -365,7 +366,7 @@ export class ContextMenu extends React.PureComponent {
 
         return (
             
{ } // Placement method for to position context menu to right of elementRect with chevronOffset -export const toRightOf = (elementRect: DOMRect, chevronOffset = 12) => { +export const toRightOf = (elementRect: Pick, chevronOffset = 12) => { const left = elementRect.right + window.pageXOffset + 3; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; top -= chevronOffset + 8; // where 8 is half the height of the chevron 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/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 4445ff3ff8..82dd9443cc 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -39,6 +39,7 @@ import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomListNumResults from "../views/rooms/RoomListNumResults"; import LeftPanelWidget from "./LeftPanelWidget"; +import SpacePanel from "../views/spaces/SpacePanel"; interface IProps { isMinimized: boolean; @@ -388,12 +389,19 @@ export default class LeftPanel extends React.Component { } public render(): React.ReactNode { - const groupFilterPanel = !this.state.showGroupFilterPanel ? null : ( -
- - {SettingsStore.getValue("feature_custom_tags") ? : null} -
- ); + let leftLeftPanel; + // Currently TagPanel.enableTagPanel is disabled when Legacy Communities are disabled so for now + // ignore it and force the rendering of SpacePanel if that Labs flag is enabled. + if (SettingsStore.getValue("feature_spaces")) { + leftLeftPanel = ; + } else if (this.state.showGroupFilterPanel) { + leftLeftPanel = ( +
+ + {SettingsStore.getValue("feature_custom_tags") ? : null} +
+ ); + } const roomList = { const containerClasses = classNames({ "mx_LeftPanel": true, - "mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel, "mx_LeftPanel_minimized": this.props.isMinimized, }); @@ -417,7 +424,7 @@ export default class LeftPanel extends React.Component { return (
- {groupFilterPanel} + {leftLeftPanel}
+ ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 62c729c422..1700b627db 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -48,7 +48,7 @@ import * as Lifecycle from '../../Lifecycle'; import '../../stores/LifecycleStore'; import PageTypes from '../../PageTypes'; -import createRoom from "../../createRoom"; +import createRoom, {IOpts} from "../../createRoom"; import {_t, _td, getCurrentLanguage} from '../../languageHandler'; import SettingsStore from "../../settings/SettingsStore"; import ThemeController from "../../settings/controllers/ThemeController"; @@ -81,6 +81,9 @@ 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'; +import SpaceStore from "../../stores/SpaceStore"; +import SpaceRoomDirectory from "./SpaceRoomDirectory"; /** constants for MatrixChat.state.view */ export enum Views { @@ -143,6 +146,8 @@ interface IRoomInfo { oob_data?: object; via_servers?: string[]; threepid_invite?: IThreepidInvite; + + justCreatedOpts?: IOpts; } /* eslint-enable camelcase */ @@ -200,6 +205,7 @@ interface IState { viaServers?: string[]; pendingInitialSync?: boolean; justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; } export default class MatrixChat extends React.PureComponent { @@ -218,6 +224,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 +330,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 +368,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) { @@ -642,10 +693,17 @@ export default class MatrixChat extends React.PureComponent { break; } case Action.ViewRoomDirectory: { - const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); - Modal.createTrackedDialog('Room directory', '', RoomDirectory, { - initialText: payload.initialText, - }, 'mx_RoomDirectory_dialogWrapper', false, true); + if (SpaceStore.instance.activeSpace) { + Modal.createTrackedDialog("Space room directory", "", SpaceRoomDirectory, { + space: SpaceStore.instance.activeSpace, + initialText: payload.initialText, + }, "mx_SpaceRoomDirectory_dialogWrapper", false, true); + } else { + const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); + Modal.createTrackedDialog('Room directory', '', RoomDirectory, { + initialText: payload.initialText, + }, 'mx_RoomDirectory_dialogWrapper', false, true); + } // View the welcome or home page if we need something to look at this.viewSomethingBehindModal(); @@ -709,6 +767,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 && @@ -874,6 +934,7 @@ export default class MatrixChat extends React.PureComponent { roomOobData: roomInfo.oob_data, viaServers: roomInfo.via_servers, ready: true, + roomJustCreatedOpts: roomInfo.justCreatedOpts, }, () => { this.notifyNewScreen('room/' + presentedId, replaceLast); }); @@ -1020,6 +1081,7 @@ export default class MatrixChat extends React.PureComponent { private leaveRoomWarnings(roomId: string) { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); + const isSpace = roomToLeave?.isSpaceRoom(); // Show a warning if there are additional complications. const joinRules = roomToLeave.currentState.getStateEvents('m.room.join_rules', ''); const warnings = []; @@ -1029,7 +1091,9 @@ export default class MatrixChat extends React.PureComponent { warnings.push(( {' '/* Whitespace, otherwise the sentences get smashed together */ } - { _t("This room is not public. You will not be able to rejoin without an invite.") } + { isSpace + ? _t("This space is not public. You will not be able to rejoin without an invite.") + : _t("This room is not public. You will not be able to rejoin without an invite.") } )); } @@ -1042,11 +1106,14 @@ export default class MatrixChat extends React.PureComponent { const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); - Modal.createTrackedDialog('Leave room', '', QuestionDialog, { - title: _t("Leave room"), + const isSpace = roomToLeave?.isSpaceRoom(); + Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { + title: isSpace ? _t("Leave space") : _t("Leave room"), description: ( - { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } + { isSpace + ? _t("Are you sure you want to leave the space '%(spaceName)s'?", {spaceName: roomToLeave.name}) + : _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } { warnings } ), @@ -1060,6 +1127,10 @@ export default class MatrixChat extends React.PureComponent { const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); d.finally(() => modal.close()); + dis.dispatch({ + action: "after_leave_room", + room_id: roomId, + }); } }, }); @@ -1186,6 +1257,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 +1398,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 +1411,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 +1681,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 +1919,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 +1963,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 +203,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'); @@ -243,6 +238,13 @@ export default class RightPanel extends React.Component { panel = ; } break; + case RightPanelPhases.SpaceMemberList: + panel = ; + break; case RightPanelPhases.GroupMemberList: if (this.props.groupId) { @@ -255,12 +257,13 @@ export default class RightPanel extends React.Component { break; case RightPanelPhases.RoomMemberInfo: + case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.EncryptionPanel: panel = ; break; @@ -276,7 +280,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 = { @@ -236,8 +241,9 @@ export default class RoomView extends React.Component { statusBarVisible: false, canReact: false, canReply: false, - useIRCLayout: SettingsStore.getValue("useIRCLayout"), + layout: SettingsStore.getValue("layout"), matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), + dragCounter: 0, }; this.dispatcherRef = dis.register(this.onAction); @@ -264,13 +270,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 +512,8 @@ export default class RoomView extends React.Component { } componentDidMount() { + this.onRoomViewStoreUpdate(true); + const call = this.getCallForRoom(); const callState = call ? call.state : null; this.setState({ @@ -526,8 +528,7 @@ export default class RoomView extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - return (!ObjectUtils.shallowEqual(this.props, nextProps) || - !ObjectUtils.shallowEqual(this.state, nextState)); + return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState)); } componentDidUpdate() { @@ -536,8 +537,8 @@ export default class RoomView extends React.Component { if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); - roomView.addEventListener('dragleave', this.onDragLeaveOrEnd); - roomView.addEventListener('dragend', this.onDragLeaveOrEnd); + roomView.addEventListener('dragenter', this.onDragEnter); + roomView.addEventListener('dragleave', this.onDragLeave); } } @@ -581,8 +582,8 @@ export default class RoomView extends React.Component { const roomView = this.roomView.current; roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); - roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); - roomView.removeEventListener('dragend', this.onDragLeaveOrEnd); + roomView.removeEventListener('dragenter', this.onDragEnter); + roomView.removeEventListener('dragleave', this.onDragLeave); } dis.unregister(this.dispatcherRef); if (this.context) { @@ -642,7 +643,7 @@ export default class RoomView extends React.Component { private onLayoutChange = () => { this.setState({ - useIRCLayout: SettingsStore.getValue("useIRCLayout"), + layout: SettingsStore.getValue("layout"), }); }; @@ -766,6 +767,9 @@ export default class RoomView extends React.Component { }); } break; + case 'focus_search': + this.onSearchClick(); + break; } }; @@ -1139,6 +1143,31 @@ export default class RoomView extends React.Component { this.updateTopUnreadMessagesBar(); }; + private onDragEnter = ev => { + ev.stopPropagation(); + ev.preventDefault(); + + this.setState({ + dragCounter: this.state.dragCounter + 1, + draggingFile: true, + }); + }; + + private onDragLeave = ev => { + ev.stopPropagation(); + ev.preventDefault(); + + this.setState({ + dragCounter: this.state.dragCounter - 1, + }); + + if (this.state.dragCounter === 0) { + this.setState({ + draggingFile: false, + }); + } + }; + private onDragOver = ev => { ev.stopPropagation(); ev.preventDefault(); @@ -1146,7 +1175,6 @@ export default class RoomView extends React.Component { ev.dataTransfer.dropEffect = 'none'; if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { - this.setState({ draggingFile: true }); ev.dataTransfer.dropEffect = 'copy'; } }; @@ -1157,14 +1185,12 @@ export default class RoomView extends React.Component { ContentMessages.sharedInstance().sendContentListToRoom( ev.dataTransfer.files, this.state.room.roomId, this.context, ); - this.setState({ draggingFile: false }); dis.fire(Action.FocusComposer); - }; - private onDragLeaveOrEnd = ev => { - ev.stopPropagation(); - ev.preventDefault(); - this.setState({ draggingFile: false }); + this.setState({ + draggingFile: false, + dragCounter: this.state.dragCounter - 1, + }); }; private injectSticker(url, info, text) { @@ -1353,6 +1379,14 @@ export default class RoomView extends React.Component { SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned); }; + private onCallPlaced = (type: PlaceCallType) => { + dis.dispatch({ + action: 'place_call', + type: type, + room_id: this.state.room.roomId, + }); + }; + private onSettingsClick = () => { dis.dispatch({ action: "open_room_settings" }); }; @@ -1390,7 +1424,7 @@ export default class RoomView extends React.Component { }); }; - private onRejectButtonClicked = ev => { + private onRejectButtonClicked = () => { this.setState({ rejecting: true, }); @@ -1450,7 +1484,7 @@ export default class RoomView extends React.Component { } }; - private onRejectThreepidInviteButtonClicked = ev => { + private onRejectThreepidInviteButtonClicked = () => { // We can reject 3pid invites in the same way that we accept them, // using /leave rather than /join. In the short term though, we // just ignore them. @@ -1713,7 +1747,7 @@ export default class RoomView extends React.Component { } const myMembership = this.state.room.getMyMembership(); - if (myMembership == 'invite') { + if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself if (this.state.joining || this.state.rejecting) { return ( @@ -1758,6 +1792,19 @@ export default class RoomView extends React.Component { } } + let fileDropTarget = null; + if (this.state.draggingFile) { + fileDropTarget = ( +
+ + { _t("Drop file here to upload") } +
+ ); + } + // We have successfully loaded this room, and are not previewing. // Display the "normal" room view. @@ -1842,7 +1889,7 @@ export default class RoomView extends React.Component { room={this.state.room} /> ); - if (!this.state.canPeek) { + if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { return (
{ previewBar } @@ -1864,12 +1911,23 @@ export default class RoomView extends React.Component { ); } + if (this.state.room?.isSpaceRoom()) { + return ; + } + const auxPanel = ( { 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 +2028,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; @@ -2032,11 +2090,13 @@ export default class RoomView extends React.Component { e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} appsShown={this.state.showApps} + onCallPlaced={this.onCallPlaced} />
{auxPanel}
+ {fileDropTarget} {topUnreadMessagesBar} {jumpToBottom} {messagePanel} diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx new file mode 100644 index 0000000000..06df6a528e --- /dev/null +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -0,0 +1,576 @@ +/* +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, {useMemo, useRef, useState} from "react"; +import Room from "matrix-js-sdk/src/models/room"; +import MatrixEvent from "matrix-js-sdk/src/models/event"; +import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; + +import {MatrixClientPeg} from "../../MatrixClientPeg"; +import dis from "../../dispatcher/dispatcher"; +import {_t} from "../../languageHandler"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import BaseDialog from "../views/dialogs/BaseDialog"; +import FormButton from "../views/elements/FormButton"; +import SearchBox from "./SearchBox"; +import RoomAvatar from "../views/avatars/RoomAvatar"; +import RoomName from "../views/elements/RoomName"; +import {useAsyncMemo} from "../../hooks/useAsyncMemo"; +import {shouldShowSpaceSettings} from "../../utils/space"; +import {EnhancedMap} from "../../utils/maps"; +import StyledCheckbox from "../views/elements/StyledCheckbox"; +import AutoHideScrollbar from "./AutoHideScrollbar"; +import BaseAvatar from "../views/avatars/BaseAvatar"; + +interface IProps { + space: Room; + initialText?: string; + onFinished(): void; +} + +/* eslint-disable camelcase */ +export interface ISpaceSummaryRoom { + canonical_alias?: string; + aliases: string[]; + avatar_url?: string; + guest_can_join: boolean; + name?: string; + num_joined_members: number + room_id: string; + topic?: string; + world_readable: boolean; + num_refs: number; + room_type: string; +} + +export interface ISpaceSummaryEvent { + room_id: string; + event_id: string; + origin_server_ts: number; + type: string; + state_key: string; + content: { + order?: string; + auto_join?: boolean; + via?: string; + }; +} +/* eslint-enable camelcase */ + +interface ISubspaceProps { + space: ISpaceSummaryRoom; + event?: MatrixEvent; + editing?: boolean; + onPreviewClick?(): void; + queueAction?(action: IAction): void; + onJoinClick?(): void; +} + +const SubSpace: React.FC = ({ + space, + editing, + event, + queueAction, + onJoinClick, + onPreviewClick, + children, +}) => { + const name = space.name || space.canonical_alias || space.aliases?.[0] || _t("Unnamed Space"); + + const evContent = event?.getContent(); + const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join); + const [removed, _setRemoved] = useState(!evContent?.via); + + const cli = MatrixClientPeg.get(); + const cliRoom = cli.getRoom(space.room_id); + const myMembership = cliRoom?.getMyMembership(); + + // TODO DRY code + let actions; + if (editing && queueAction) { + if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) { + const setAutoJoin = () => { + _setAutoJoin(v => { + queueAction({ + event, + removed, + autoJoin: !v, + }); + return !v; + }); + }; + + const setRemoved = () => { + _setRemoved(v => { + queueAction({ + event, + removed: !v, + autoJoin, + }); + return !v; + }); + }; + + if (removed) { + actions = + + ; + } else { + actions = + + + ; + } + } else { + actions = + { _t("No permissions")} + ; + } + // TODO confirm remove from space click behaviour here + } else { + if (myMembership === "join") { + actions = + { _t("You're in this space")} + ; + } else if (onJoinClick) { + actions = + + { _t("Preview") } + + + + } + } + + let url: string; + if (space.avatar_url) { + url = MatrixClientPeg.get().mxcUrlToHttp( + space.avatar_url, + Math.floor(24 * window.devicePixelRatio), + Math.floor(24 * window.devicePixelRatio), + "crop", + ); + } + + return
+
+ + { name } + +
+ { actions } +
+
+
+ { children } +
+
+}; + +interface IAction { + event: MatrixEvent; + removed: boolean; + autoJoin: boolean; +} + +interface IRoomTileProps { + room: ISpaceSummaryRoom; + event?: MatrixEvent; + editing?: boolean; + onPreviewClick(): void; + queueAction?(action: IAction): void; + onJoinClick?(): void; +} + +const RoomTile = ({ room, event, editing, queueAction, onPreviewClick, onJoinClick }: IRoomTileProps) => { + const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("Unnamed Room"); + + const evContent = event?.getContent(); + const [autoJoin, _setAutoJoin] = useState(evContent?.auto_join); + const [removed, _setRemoved] = useState(!evContent?.via); + + const cli = MatrixClientPeg.get(); + const cliRoom = cli.getRoom(room.room_id); + const myMembership = cliRoom?.getMyMembership(); + + let actions; + if (editing && queueAction) { + if (event && cli.getRoom(event.getRoomId())?.currentState.maySendStateEvent(event.getType(), cli.getUserId())) { + const setAutoJoin = () => { + _setAutoJoin(v => { + queueAction({ + event, + removed, + autoJoin: !v, + }); + return !v; + }); + }; + + const setRemoved = () => { + _setRemoved(v => { + queueAction({ + event, + removed: !v, + autoJoin, + }); + return !v; + }); + }; + + if (removed) { + actions = + + ; + } else { + actions = + + + ; + } + } else { + actions = + { _t("No permissions")} + ; + } + // TODO confirm remove from space click behaviour here + } else { + if (myMembership === "join") { + actions = + { _t("You're in this room")} + ; + } else if (onJoinClick) { + actions = + + { _t("Preview") } + + + + } + } + + let url: string; + if (room.avatar_url) { + url = cli.mxcUrlToHttp( + room.avatar_url, + Math.floor(32 * window.devicePixelRatio), + Math.floor(32 * window.devicePixelRatio), + "crop", + ); + } + + const content = + + +
+
+ { name } +
+
+ { room.topic } +
+
+
+ { room.num_joined_members } +
+ +
+ { actions } +
+
; + + if (editing) { + return
+ { content } +
+ } + + return + { content } + ; +}; + +export const showRoom = (room: ISpaceSummaryRoom, viaServers?: string[], autoJoin = false) => { + // Don't let the user view a room they won't be able to either peek or join: + // fail earlier so they don't have to click back to the directory. + if (MatrixClientPeg.get().isGuest()) { + if (!room.world_readable && !room.guest_can_join) { + dis.dispatch({ action: "require_registration" }); + return; + } + } + + const roomAlias = getDisplayAliasForRoom(room) || undefined; + dis.dispatch({ + action: "view_room", + auto_join: autoJoin, + should_peek: true, + _type: "room_directory", // instrumentation + room_alias: roomAlias, + room_id: room.room_id, + via_servers: viaServers, + oob_data: { + avatarUrl: room.avatar_url, + // XXX: This logic is duplicated from the JS SDK which would normally decide what the name is. + name: room.name || roomAlias || _t("Unnamed room"), + }, + }); +}; + +interface IHierarchyLevelProps { + spaceId: string; + rooms: Map; + editing?: boolean; + relations: EnhancedMap; + parents: Set; + queueAction?(action: IAction): void; + onPreviewClick(roomId: string): void; + onRemoveFromSpaceClick?(roomId: string): void; + onJoinClick?(roomId: string): void; +} + +export const HierarchyLevel = ({ + spaceId, + rooms, + editing, + relations, + parents, + onPreviewClick, + onJoinClick, + queueAction, +}: IHierarchyLevelProps) => { + const cli = MatrixClientPeg.get(); + const space = cli.getRoom(spaceId); + // TODO respect order + const [subspaces, childRooms] = relations.get(spaceId)?.reduce((result, roomId: string) => { + if (!rooms.has(roomId)) return result; // TODO wat + result[rooms.get(roomId).room_type === RoomType.Space ? 0 : 1].push(roomId); + return result; + }, [[], []]) || [[], []]; + + // Don't render this subspace if it has no rooms we can show + // TODO this is broken - as a space may have subspaces we still need to show + // if (!childRooms.length) return null; + + const userId = cli.getUserId(); + + const newParents = new Set(parents).add(spaceId); + return + { + childRooms.map(roomId => ( + { + onPreviewClick(roomId); + }} + onJoinClick={onJoinClick ? () => { + onJoinClick(roomId); + } : undefined} + /> + )) + } + + { + subspaces.filter(roomId => !newParents.has(roomId)).map(roomId => ( + { + onPreviewClick(roomId); + }} + onJoinClick={() => { + onJoinClick(roomId); + }} + > + + + )) + } + +}; + +const SpaceRoomDirectory: React.FC = ({ space, initialText = "", onFinished }) => { + // TODO pagination + const cli = MatrixClientPeg.get(); + const [query, setQuery] = useState(initialText); + const [isEditing, setIsEditing] = useState(false); + + const onCreateRoomClick = () => { + dis.dispatch({ + action: 'view_create_room', + public: true, + }); + onFinished(); + }; + + // stored within a ref as we don't need to re-render when it changes + const pendingActions = useRef(new Map()); + + let adminButton; + if (shouldShowSpaceSettings(cli, space)) { // TODO this is an imperfect test + const onManageButtonClicked = () => { + setIsEditing(true); + }; + + const onSaveButtonClicked = () => { + // TODO setBusy + pendingActions.current.forEach(({event, autoJoin, removed}) => { + const content = { + ...event.getContent(), + auto_join: autoJoin, + }; + + if (removed) { + delete content["via"]; + } + + cli.sendStateEvent(event.getRoomId(), event.getType(), content, event.getStateKey()); + }); + setIsEditing(false); + }; + + if (isEditing) { + adminButton = + + { _t("All users join by default") } + ; + } else { + adminButton = ; + } + } + + const [rooms, relations, viaMap] = useAsyncMemo(async () => { + try { + const data = await cli.getSpaceSummary(space.roomId); + + const parentChildRelations = new EnhancedMap(); + const viaMap = new EnhancedMap>(); + data.events.map((ev: ISpaceSummaryEvent) => { + if (ev.type === EventType.SpaceChild) { + parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key); + } + if (Array.isArray(ev.content["via"])) { + const set = viaMap.getOrCreate(ev.state_key, new Set()); + ev.content["via"].forEach(via => set.add(via)); + } + }); + + return [data.rooms, parentChildRelations, viaMap]; + } catch (e) { + console.error(e); // TODO + } + + return []; + }, [space], []); + + const roomsMap = useMemo(() => { + if (!rooms) return null; + const lcQuery = query.toLowerCase(); + + const filteredRooms = rooms.filter(r => { + return r.room_type === RoomType.Space // always include spaces to allow filtering of sub-space rooms + || r.name?.toLowerCase().includes(lcQuery) + || r.topic?.toLowerCase().includes(lcQuery); + }); + + return new Map(filteredRooms.map(r => [r.room_id, r])); + // const root = rooms.get(space.roomId); + }, [rooms, query]); + + const title = + +
+

{ _t("Explore rooms") }

+
+
+
; + const explanation = + _t("If you can't find the room you're looking for, ask for an invite or Create a new room.", null, + {a: sub => { + return {sub}; + }}, + ); + + let content; + if (roomsMap) { + content = + { + pendingActions.current.set(action.event.room_id, action); + }} + onPreviewClick={roomId => { + showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), false); + onFinished(); + }} + onJoinClick={(roomId) => { + showRoom(roomsMap.get(roomId), Array.from(viaMap.get(roomId) || []), true); + onFinished(); + }} + /> + ; + } + + // TODO loading state/error state + return ( + +
+ { explanation } + + + +
+ { adminButton } +
+ { content } +
+
+ ); +}; + +export default SpaceRoomDirectory; + +// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom +// but works with the objects we get from the public room list +function getDisplayAliasForRoom(room: ISpaceSummaryRoom) { + return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); +} diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx new file mode 100644 index 0000000000..5c91efc1c0 --- /dev/null +++ b/src/components/structures/SpaceRoomView.tsx @@ -0,0 +1,604 @@ +/* +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, {RefObject, useContext, useRef, useState} from "react"; +import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; +import {Room} from "matrix-js-sdk/src/models/room"; + +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import RoomAvatar from "../views/avatars/RoomAvatar"; +import {_t} from "../../languageHandler"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import RoomName from "../views/elements/RoomName"; +import RoomTopic from "../views/elements/RoomTopic"; +import InlineSpinner from "../views/elements/InlineSpinner"; +import FormButton from "../views/elements/FormButton"; +import {inviteMultipleToRoom, showRoomInviteDialog} from "../../RoomInvite"; +import {useRoomMembers} from "../../hooks/useRoomMembers"; +import createRoom, {IOpts, Preset} from "../../createRoom"; +import Field from "../views/elements/Field"; +import {useEventEmitter} from "../../hooks/useEventEmitter"; +import StyledRadioGroup from "../views/elements/StyledRadioGroup"; +import withValidation from "../views/elements/Validation"; +import * as Email from "../../email"; +import defaultDispatcher from "../../dispatcher/dispatcher"; +import {Action} from "../../dispatcher/actions"; +import ResizeNotifier from "../../utils/ResizeNotifier" +import MainSplit from './MainSplit'; +import ErrorBoundary from "../views/elements/ErrorBoundary"; +import {ActionPayload} from "../../dispatcher/payloads"; +import RightPanel from "./RightPanel"; +import RightPanelStore from "../../stores/RightPanelStore"; +import {EventSubscription} from "fbemitter"; +import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; +import {SetRightPanelPhasePayload} from "../../dispatcher/payloads/SetRightPanelPhasePayload"; +import {useStateArray} from "../../hooks/useStateArray"; +import SpacePublicShare from "../views/spaces/SpacePublicShare"; +import {showAddExistingRooms, showCreateNewRoom, shouldShowSpaceSettings, showSpaceSettings} from "../../utils/space"; +import {HierarchyLevel, ISpaceSummaryEvent, ISpaceSummaryRoom, showRoom} from "./SpaceRoomDirectory"; +import {useAsyncMemo} from "../../hooks/useAsyncMemo"; +import {EnhancedMap} from "../../utils/maps"; +import AutoHideScrollbar from "./AutoHideScrollbar"; +import MemberAvatar from "../views/avatars/MemberAvatar"; +import {useStateToggle} from "../../hooks/useStateToggle"; + +interface IProps { + space: Room; + justCreatedOpts?: IOpts; + resizeNotifier: ResizeNotifier; + onJoinButtonClicked(): void; + onRejectButtonClicked(): void; +} + +interface IState { + phase: Phase; + showRightPanel: boolean; +} + +enum Phase { + Landing, + PublicCreateRooms, + PublicShare, + PrivateScope, + PrivateInvite, + PrivateCreateRooms, + PrivateExistingRooms, +} + +const RoomMemberCount = ({ room, children }) => { + const members = useRoomMembers(room); + const count = members.length; + + if (children) return children(count); + return count; +}; + +const useMyRoomMembership = (room: Room) => { + const [membership, setMembership] = useState(room.getMyMembership()); + useEventEmitter(room, "Room.myMembership", () => { + setMembership(room.getMyMembership()); + }); + return membership; +}; + +const SpaceLanding = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { + const cli = useContext(MatrixClientContext); + const myMembership = useMyRoomMembership(space); + const joinRule = space.getJoinRule(); + const userId = cli.getUserId(); + + let joinButtons; + if (myMembership === "invite") { + joinButtons =
+ + + {_t("Decline")} + +
; + } else if (myMembership !== "join" && joinRule === "public") { + joinButtons =
+ +
; + } + + let inviteButton; + if (myMembership === "join" && space.canInvite(userId)) { + inviteButton = ( + { + showRoomInviteDialog(space.roomId); + }}> + { _t("Invite people") } + + ); + } + + const canAddRooms = myMembership === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId); + + const [_, forceUpdate] = useStateToggle(false); // TODO + + let addRoomButtons; + if (canAddRooms) { + addRoomButtons = + { + const [added] = await showAddExistingRooms(cli, space); + if (added) { + forceUpdate(); + } + }}> + { _t("Add existing rooms & spaces") } + + { + showCreateNewRoom(cli, space); + }}> + { _t("Create a new room") } + + ; + } + + let settingsButton; + if (shouldShowSpaceSettings(cli, space)) { + settingsButton = { + showSpaceSettings(cli, space); + }}> + { _t("Settings") } + ; + } + + const [loading, roomsMap, relations, numRooms] = useAsyncMemo(async () => { + try { + const data = await cli.getSpaceSummary(space.roomId, undefined, myMembership !== "join"); + + const parentChildRelations = new EnhancedMap(); + data.events.map((ev: ISpaceSummaryEvent) => { + if (ev.type === EventType.SpaceChild) { + parentChildRelations.getOrCreate(ev.room_id, []).push(ev.state_key); + } + }); + + const roomsMap = new Map(data.rooms.map(r => [r.room_id, r])); + const numRooms = data.rooms.filter(r => r.room_type !== RoomType.Space).length; + return [false, roomsMap, parentChildRelations, numRooms]; + } catch (e) { + console.error(e); // TODO + } + + return [false]; + }, [space, _], [true]); + + let previewRooms; + if (roomsMap) { + previewRooms = +
+

{ myMembership === "join" ? _t("Rooms") : _t("Default Rooms")}

+ { numRooms } +
+ { + showRoom(roomsMap.get(roomId), [], false); // TODO + }} + /> +
; + } else if (loading) { + previewRooms = ; + } else { + previewRooms =

{_t("Your server does not support showing space hierarchies.")}

; + } + + return
+ +
+ + {(name) => { + const tags = { name: () =>
+

{ name }

+ + {(count) => count > 0 ? ( + { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomMemberList, + refireParams: { space }, + }); + }} + > + { _t("%(count)s members", { count }) } + + ) : null} + +
}; + if (myMembership === "invite") { + const inviteSender = space.getMember(userId)?.events.member?.getSender(); + const inviter = inviteSender && space.getMember(inviteSender); + + if (inviteSender) { + return _t(" invited you to ", {}, { + name: tags.name, + inviter: () => inviter + ? + + { inviter.name } + + : + { inviteSender } + , + }) as JSX.Element; + } else { + return _t("You have been invited to ", {}, tags) as JSX.Element; + } + } else if (shouldShowSpaceSettings(cli, space)) { + if (space.getJoinRule() === "public") { + return _t("Your public space ", {}, tags) as JSX.Element; + } else { + return _t("Your private space ", {}, tags) as JSX.Element; + } + } + return _t("Welcome to ", {}, tags) as JSX.Element; + }} +
+
+
+ +
+ { joinButtons } +
+ { inviteButton } + { addRoomButtons } + { settingsButton } +
+ + { previewRooms } +
; +}; + +const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const placeholders = [_t("General"), _t("Random"), _t("Support")]; + // TODO vary default prefills for "Just Me" spaces + const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "roomName" + i; + return setRoomName(i, ev.target.value)} + />; + }); + + const onNextClick = async () => { + setError(""); + setBusy(true); + try { + await Promise.all(roomNames.map(name => name.trim()).filter(Boolean).map(name => { + return createRoom({ + createOpts: { + preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, + name, + }, + spinner: false, + encryption: false, + andView: false, + inlineErrors: true, + parentSpace: space, + }); + })); + onFinished(); + } catch (e) { + console.error("Failed to create initial space rooms", e); + setError(_t("Failed to create initial space rooms")); + } + setBusy(false); + }; + + let onClick = onFinished; + let buttonLabel = _t("Skip for now"); + if (roomNames.some(name => name.trim())) { + onClick = onNextClick; + buttonLabel = busy ? _t("Creating rooms...") : _t("Next") + } + + return
+

{ title }

+
{ description }
+ + { error &&
{ error }
} + { fields } + +
+ +
+
; +}; + +const SpaceSetupPublicShare = ({ space, onFinished }) => { + return
+

{ _t("Share your public space") }

+
{ _t("At the moment only you can see it.") }
+ + + +
+ +
+
; +}; + +const SpaceSetupPrivateScope = ({ onFinished }) => { + const [option, setOption] = useState(null); + + return
+

{ _t("Who are you working with?") }

+
{ _t("Ensure the right people have access to the space.") }
+ + +

{ _t("Just Me") }

+
{ _t("A private space just for you") }
+ , + }, { + value: "meAndMyTeammates", + className: "mx_SpaceRoomView_privateScope_meAndMyTeammatesButton", + label: +

{ _t("Me and my teammates") }

+
{ _t("A private space for you and your teammates") }
+
, + }, + ]} + /> + +
+ onFinished(option !== "justMe")} /> +
+
; +}; + +const validateEmailRules = withValidation({ + rules: [{ + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }], +}); + +const SpaceSetupPrivateInvite = ({ space, onFinished }) => { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const numFields = 3; + const fieldRefs: RefObject[] = [useRef(), useRef(), useRef()]; + const [emailAddresses, setEmailAddress] = useStateArray(numFields, ""); + const fields = new Array(numFields).fill(0).map((_, i) => { + const name = "emailAddress" + i; + return setEmailAddress(i, ev.target.value)} + ref={fieldRefs[i]} + onValidate={validateEmailRules} + />; + }); + + const onNextClick = async () => { + setError(""); + for (let i = 0; i < fieldRefs.length; i++) { + const fieldRef = fieldRefs[i]; + const valid = await fieldRef.current.validate({ allowEmpty: true }); + + if (valid === false) { // true/null are allowed + fieldRef.current.focus(); + fieldRef.current.validate({ allowEmpty: true, focused: true }); + return; + } + } + + setBusy(true); + const targetIds = emailAddresses.map(name => name.trim()).filter(Boolean); + try { + const result = await inviteMultipleToRoom(space.roomId, targetIds); + + const failedUsers = Object.keys(result.states).filter(a => result.states[a] === "error"); + if (failedUsers.length > 0) { + console.log("Failed to invite users to space: ", result); + setError(_t("Failed to invite the following users to your space: %(csvUsers)s", { + csvUsers: failedUsers.join(", "), + })); + } else { + onFinished(); + } + } catch (err) { + console.error("Failed to invite users to space: ", err); + setError(_t("We couldn't invite those users. Please check the users you want to invite and try again.")); + } + setBusy(false); + }; + + return
+

{ _t("Invite your teammates") }

+
{ _t("Ensure the right people have access to the space.") }
+ + { error &&
{ error }
} + { fields } + +
+ showRoomInviteDialog(space.roomId)} + > + { _t("Invite by username") } + +
+ +
+ {_t("Skip for now")} + +
+
; +}; + +export default class SpaceRoomView extends React.PureComponent { + static contextType = MatrixClientContext; + + private readonly creator: string; + private readonly dispatcherRef: string; + private readonly rightPanelStoreToken: EventSubscription; + + constructor(props, context) { + super(props, context); + + let phase = Phase.Landing; + + this.creator = this.props.space.currentState.getStateEvents(EventType.RoomCreate, "")?.getSender(); + const showSetup = this.props.justCreatedOpts && this.context.getUserId() === this.creator; + + if (showSetup) { + phase = this.props.justCreatedOpts.createOpts.preset === Preset.PublicChat + ? Phase.PublicCreateRooms : Phase.PrivateScope; + } + + this.state = { + phase, + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + }; + + this.dispatcherRef = defaultDispatcher.register(this.onAction); + this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); + } + + componentWillUnmount() { + defaultDispatcher.unregister(this.dispatcherRef); + this.rightPanelStoreToken.remove(); + } + + private onRightPanelStoreUpdate = () => { + this.setState({ + showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, + }); + }; + + private onAction = (payload: ActionPayload) => { + if (payload.action !== Action.ViewUser && payload.action !== "view_3pid_invite") return; + + if (payload.action === Action.ViewUser && payload.member) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberInfo, + refireParams: { + space: this.props.space, + member: payload.member, + }, + }); + } else if (payload.action === "view_3pid_invite" && payload.event) { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.Space3pidMemberInfo, + refireParams: { + space: this.props.space, + event: payload.event, + }, + }); + } else { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.SpaceMemberList, + refireParams: { space: this.props.space }, + }); + } + }; + + private renderBody() { + switch (this.state.phase) { + case Phase.Landing: + return ; + + case Phase.PublicCreateRooms: + return this.setState({ phase: Phase.PublicShare })} + />; + case Phase.PublicShare: + return this.setState({ phase: Phase.Landing })} + />; + + case Phase.PrivateScope: + return { + this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateCreateRooms }); + }} + />; + case Phase.PrivateInvite: + return this.setState({ phase: Phase.PrivateCreateRooms })} + />; + case Phase.PrivateCreateRooms: + return this.setState({ phase: Phase.Landing })} + />; + } + } + + render() { + const rightPanel = this.state.showRightPanel && this.state.phase === Phase.Landing + ? + : null; + + return
+ + + { this.renderBody() } + + +
; + } +} 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..6bc1f70ba1 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'; @@ -25,7 +26,6 @@ import {EventTimeline} from "matrix-js-sdk"; import * as Matrix from "matrix-js-sdk"; import { _t } from '../../languageHandler'; import {MatrixClientPeg} from "../../MatrixClientPeg"; -import * as ObjectUtils from "../../ObjectUtils"; import UserActivity from "../../UserActivity"; import Modal from "../../Modal"; import dis from "../../dispatcher/dispatcher"; @@ -36,6 +36,7 @@ import shouldHideEvent from '../../shouldHideEvent'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; import {haveTileForEvent} from "../views/rooms/EventTile"; import {UIFeature} from "../../settings/UIFeature"; +import {objectHasDiff} from "../../utils/objects"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -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 @@ -260,7 +261,7 @@ class TimelinePanel extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - if (!ObjectUtils.shallowEqual(this.props, nextProps)) { + if (objectHasDiff(this.props, nextProps)) { if (DEBUG) { console.group("Timeline.shouldComponentUpdate: props change"); console.log("props before:", this.props); @@ -270,7 +271,7 @@ class TimelinePanel extends React.Component { return true; } - if (!ObjectUtils.shallowEqual(this.state, nextState)) { + if (objectHasDiff(this.state, nextState)) { if (DEBUG) { console.group("Timeline.shouldComponentUpdate: state change"); console.log("state before:", this.state); @@ -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..b31a5f4b8e 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. @@ -15,27 +15,30 @@ limitations under the License. */ import React, { createRef } from "react"; +import { Room } from "matrix-js-sdk/src/models/room"; +import classNames from "classnames"; +import * as fbEmitter from "fbemitter"; + import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; +import dis from "../../dispatcher/dispatcher"; import { ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { _t } from "../../languageHandler"; import { ContextMenuButton } from "./ContextMenu"; -import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog"; +import { USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB } from "../views/dialogs/UserSettingsDialog"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import FeedbackDialog from "../views/dialogs/FeedbackDialog"; 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"; +import { getHomePageUrl } from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import BaseAvatar from '../views/avatars/BaseAvatar'; -import classNames from "classnames"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { SettingLevel } from "../../settings/SettingLevel"; import IconizedContextMenu, { @@ -43,14 +46,16 @@ import IconizedContextMenu, { IconizedContextMenuOptionList, } from "../views/context_menus/IconizedContextMenu"; import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; -import * as fbEmitter from "fbemitter"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import { showCommunityInviteDialog } from "../../RoomInvite"; -import dis from "../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog"; -import {UIFeature} from "../../settings/UIFeature"; +import { UIFeature } from "../../settings/UIFeature"; +import HostSignupAction from "./HostSignupAction"; +import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes"; +import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; +import RoomName from "../views/elements/RoomName"; interface IProps { isMinimized: boolean; @@ -61,6 +66,7 @@ type PartialDOMRect = Pick; interface IState { contextMenuPosition: PartialDOMRect; isDarkTheme: boolean; + selectedSpace?: Room; } export default class UserMenu extends React.Component { @@ -78,6 +84,9 @@ export default class UserMenu extends React.Component { }; OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); + if (SettingsStore.getValue("feature_spaces")) { + SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); + } } private get hasHomePage(): boolean { @@ -95,6 +104,9 @@ export default class UserMenu extends React.Component { if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); this.tagStoreRef.remove(); + if (SettingsStore.getValue("feature_spaces")) { + SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); + } } private onTagStoreUpdate = () => { @@ -102,11 +114,15 @@ export default class UserMenu extends React.Component { }; private isUserOnDarkTheme(): boolean { - const theme = SettingsStore.getValue("theme"); - if (theme.startsWith("custom-")) { - return getCustomTheme(theme.substring("custom-".length)).is_dark; + if (SettingsStore.getValue("use_system_theme")) { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } else { + const theme = SettingsStore.getValue("theme"); + if (theme.startsWith("custom-")) { + return getCustomTheme(theme.substring("custom-".length)).is_dark; + } + return theme === "dark"; } - return theme === "dark"; } private onProfileUpdate = async () => { @@ -115,6 +131,10 @@ export default class UserMenu extends React.Component { this.forceUpdate(); }; + private onSelectedSpaceUpdate = async (selectedSpace?: Room) => { + this.setState({ selectedSpace }); + }; + private onThemeChanged = () => { this.setState({isDarkTheme: this.isUserOnDarkTheme()}); }; @@ -272,7 +292,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 +312,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; @@ -517,7 +532,16 @@ export default class UserMenu extends React.Component { {/* masked image in CSS */} ); - if (prototypeCommunityName) { + if (this.state.selectedSpace) { + name = ( +
+ {displayName} + + {(roomName) => {roomName}} + +
+ ); + } else if (prototypeCommunityName) { name = (
{prototypeCommunityName} 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 a9fd363763..96fc39a437 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -343,8 +343,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/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 98d69a63e7..952b9d4cb6 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -13,7 +13,7 @@ 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 React, {ComponentProps} from 'react'; import Room from 'matrix-js-sdk/src/models/room'; import {getHttpUriForMxc} from 'matrix-js-sdk/src/content-repo'; @@ -24,7 +24,7 @@ import Modal from '../../../Modal'; import * as Avatar from '../../../Avatar'; import {ResizeMethod} from "../../../Avatar"; -interface IProps { +interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx new file mode 100644 index 0000000000..66efaefd9d --- /dev/null +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -0,0 +1,208 @@ +/* +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, {useState} from "react"; +import classNames from "classnames"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixClient} from "matrix-js-sdk/src/client"; + +import {_t} from '../../../languageHandler'; +import {IDialogProps} from "./IDialogProps"; +import BaseDialog from "./BaseDialog"; +import FormButton from "../elements/FormButton"; +import Dropdown from "../elements/Dropdown"; +import SearchBox from "../../structures/SearchBox"; +import SpaceStore from "../../../stores/SpaceStore"; +import RoomAvatar from "../avatars/RoomAvatar"; +import {getDisplayAliasForRoom} from "../../../Rooms"; +import AccessibleButton from "../elements/AccessibleButton"; +import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; +import {allSettled} from "../../../utils/promise"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import {calculateRoomVia} from "../../../utils/permalinks/Permalinks"; +import StyledCheckbox from "../elements/StyledCheckbox"; + +interface IProps extends IDialogProps { + matrixClient: MatrixClient; + space: Room; + onCreateRoomClick(cli: MatrixClient, space: Room): void; +} + +const Entry = ({ room, checked, onChange }) => { + return
+ + { room.name } + onChange(e.target.checked)} checked={checked} /> +
; +}; + +const AddExistingToSpaceDialog: React.FC = ({ matrixClient: cli, space, onCreateRoomClick, onFinished }) => { + const [query, setQuery] = useState(""); + const lcQuery = query.toLowerCase(); + + const [selectedSpace, setSelectedSpace] = useState(space); + const [selectedToAdd, setSelectedToAdd] = useState(new Set()); + + const existingSubspaces = SpaceStore.instance.getChildSpaces(space.roomId); + const existingSubspacesSet = new Set(existingSubspaces); + const spaces = SpaceStore.instance.getSpaces().filter(s => { + return !existingSubspacesSet.has(s) // not already in space + && space !== s // not the top-level space + && selectedSpace !== s // not the selected space + && s.name.toLowerCase().includes(lcQuery); // contains query + }); + + const existingRooms = SpaceStore.instance.getChildRooms(space.roomId); + const existingRoomsSet = new Set(existingRooms); + const rooms = cli.getVisibleRooms().filter(room => { + return !existingRoomsSet.has(room) // not already in space + && room.name.toLowerCase().includes(lcQuery) // contains query + && !DMRoomMap.shared().getUserIdForRoomId(room.roomId); // not a DM + }); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + let spaceOptionSection; + if (existingSubspacesSet.size > 0) { + const options = [space, ...existingSubspaces].map((space) => { + const classes = classNames("mx_AddExistingToSpaceDialog_dropdownOption", { + mx_AddExistingToSpaceDialog_dropdownOptionActive: space === selectedSpace, + }); + return
+ + { space.name || getDisplayAliasForRoom(space) || space.roomId } +
; + }); + + spaceOptionSection = ( + { + setSelectedSpace(existingSubspaces.find(space => space.roomId === key) || space); + }} + value={selectedSpace.roomId} + label={_t("Space selection")} + > + { options } + + ); + } else { + spaceOptionSection =
+ { space.name || getDisplayAliasForRoom(space) || space.roomId } +
; + } + + const title = + +
+

{ _t("Add existing spaces/rooms") }

+ { spaceOptionSection } +
+
; + + return + { error &&
{ error }
} + + + + { spaces.length > 0 ? ( +
+

{ _t("Spaces") }

+ { spaces.map(space => { + return { + if (checked) { + selectedToAdd.add(space); + } else { + selectedToAdd.delete(space); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + />; + }) } +
+ ) : null } + + { rooms.length > 0 ? ( +
+

{ _t("Rooms") }

+ { rooms.map(room => { + return { + if (checked) { + selectedToAdd.add(room); + } else { + selectedToAdd.delete(room); + } + setSelectedToAdd(new Set(selectedToAdd)); + }} + />; + }) } +
+ ) : undefined } + + { spaces.length + rooms.length < 1 ? + { _t("No results") } + : undefined } +
+ +
+ +
{ _t("Don't want to add an existing room?") }
+ onCreateRoomClick(cli, space)} kind="link"> + { _t("Create a new room") } + +
+ + { + setBusy(true); + try { + await allSettled(Array.from(selectedToAdd).map((room) => + SpaceStore.instance.addRoomToSpace(space, room.roomId, calculateRoomVia(room)))); + onFinished(true); + } catch (e) { + console.error("Failed to add rooms to space", e); + setError(_t("Failed to add rooms to space")); + } + setBusy(false); + }} + /> +
+
; +}; + +export default AddExistingToSpaceDialog; + diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index 2b6bb5e187..0771b0ec45 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -17,6 +17,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import {Room} from "matrix-js-sdk/src/models/room"; + import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import withValidation from '../elements/Validation'; @@ -30,6 +32,7 @@ export default class CreateRoomDialog extends React.Component { static propTypes = { onFinished: PropTypes.func.isRequired, defaultPublic: PropTypes.bool, + parentSpace: PropTypes.instanceOf(Room), }; constructor(props) { @@ -85,6 +88,10 @@ export default class CreateRoomDialog extends React.Component { opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } + if (this.props.parentSpace) { + opts.parentSpace = this.props.parentSpace; + } + return opts; } diff --git a/src/components/views/dialogs/DevtoolsDialog.js b/src/components/views/dialogs/DevtoolsDialog.js index 7d4e3b462f..814378bb51 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.js @@ -34,6 +34,10 @@ import { } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import WidgetStore from "../../../stores/WidgetStore"; import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import {SETTINGS} from "../../../settings/Settings"; +import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore"; +import Modal from "../../../Modal"; +import ErrorDialog from "./ErrorDialog"; class GenericEditor extends React.PureComponent { // static propTypes = {onBack: PropTypes.func.isRequired}; @@ -794,6 +798,286 @@ class WidgetExplorer extends React.Component { } } +class SettingsExplorer extends React.Component { + static getLabel() { + return _t("Settings Explorer"); + } + + constructor(props) { + super(props); + + this.state = { + query: '', + editSetting: null, // set to a setting ID when editing + viewSetting: null, // set to a setting ID when exploring in detail + + explicitValues: null, // stringified JSON for edit view + explicitRoomValues: null, // stringified JSON for edit view + }; + } + + onQueryChange = (ev) => { + this.setState({query: ev.target.value}); + }; + + onExplValuesEdit = (ev) => { + this.setState({explicitValues: ev.target.value}); + }; + + onExplRoomValuesEdit = (ev) => { + this.setState({explicitRoomValues: ev.target.value}); + }; + + onBack = () => { + if (this.state.editSetting) { + this.setState({editSetting: null}); + } else if (this.state.viewSetting) { + this.setState({viewSetting: null}); + } else { + this.props.onBack(); + } + }; + + onViewClick = (ev, settingId) => { + ev.preventDefault(); + this.setState({viewSetting: settingId}); + }; + + onEditClick = (ev, settingId) => { + ev.preventDefault(); + this.setState({ + editSetting: settingId, + explicitValues: this.renderExplicitSettingValues(settingId, null), + explicitRoomValues: this.renderExplicitSettingValues(settingId, this.props.room.roomId), + }); + }; + + onSaveClick = async () => { + try { + const settingId = this.state.editSetting; + const parsedExplicit = JSON.parse(this.state.explicitValues); + const parsedExplicitRoom = JSON.parse(this.state.explicitRoomValues); + for (const level of Object.keys(parsedExplicit)) { + console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`); + try { + const val = parsedExplicit[level]; + await SettingsStore.setValue(settingId, null, level, val); + } catch (e) { + console.warn(e); + } + } + const roomId = this.props.room.roomId; + for (const level of Object.keys(parsedExplicit)) { + console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`); + try { + const val = parsedExplicitRoom[level]; + await SettingsStore.setValue(settingId, roomId, level, val); + } catch (e) { + console.warn(e); + } + } + this.setState({ + viewSetting: settingId, + editSetting: null, + }); + } catch (e) { + Modal.createTrackedDialog('Devtools - Failed to save settings', '', ErrorDialog, { + title: _t("Failed to save settings"), + description: e.message, + }); + } + }; + + renderSettingValue(val) { + // Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us + const toStringTypes = ['boolean', 'number']; + if (toStringTypes.includes(typeof(val))) { + return val.toString(); + } else { + return JSON.stringify(val); + } + } + + renderExplicitSettingValues(setting, roomId) { + const vals = {}; + for (const level of LEVEL_ORDER) { + try { + vals[level] = SettingsStore.getValueAt(level, setting, roomId, true, true); + if (vals[level] === undefined) { + vals[level] = null; + } + } catch (e) { + console.warn(e); + } + } + return JSON.stringify(vals, null, 4); + } + + renderCanEditLevel(roomId, level) { + const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level); + const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable'; + return {canEdit.toString()}; + } + + render() { + const room = this.props.room; + + if (!this.state.viewSetting && !this.state.editSetting) { + // view all settings + const allSettings = Object.keys(SETTINGS) + .filter(n => this.state.query ? n.toLowerCase().includes(this.state.query.toLowerCase()) : true); + return ( +
+
+ + + + + + + + + + + {allSettings.map(i => ( + + + + + + ))} + +
{_t("Setting ID")}{_t("Value")}{_t("Value in this room")}
+ this.onViewClick(e, i)}> + {i} + + this.onEditClick(e, i)} + className='mx_DevTools_SettingsExplorer_edit' + > + ✏ + + + {this.renderSettingValue(SettingsStore.getValue(i))} + + + {this.renderSettingValue(SettingsStore.getValue(i, room.roomId))} + +
+
+
+ +
+
+ ); + } else if (this.state.editSetting) { + return ( +
+
+

{_t("Setting:")} {this.state.editSetting}

+ +
+ {_t("Caution:")} {_t( + "This UI does NOT check the types of the values. Use at your own risk.", + )} +
+ +
+ {_t("Setting definition:")} +
{JSON.stringify(SETTINGS[this.state.editSetting], null, 4)}
+
+ +
+ + + + + + + + + + {LEVEL_ORDER.map(lvl => ( + + + {this.renderCanEditLevel(null, lvl)} + {this.renderCanEditLevel(room.roomId, lvl)} + + ))} + +
{_t("Level")}{_t("Settable at global")}{_t("Settable at room")}
{lvl}
+
+ +
+ +
+ +
+ +
+ +
+
+ + +
+
+ ); + } else if (this.state.viewSetting) { + return ( +
+
+

{_t("Setting:")} {this.state.viewSetting}

+ +
+ {_t("Setting definition:")} +
{JSON.stringify(SETTINGS[this.state.viewSetting], null, 4)}
+
+ +
+ {_t("Value:")}  + {this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting))} +
+ +
+ {_t("Value in this room:")}  + {this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting, room.roomId))} +
+ +
+ {_t("Values at explicit levels:")} +
{this.renderExplicitSettingValues(this.state.viewSetting, null)}
+
+ +
+ {_t("Values at explicit levels in this room:")} +
{this.renderExplicitSettingValues(this.state.viewSetting, room.roomId)}
+
+ +
+
+ + +
+
+ ); + } + } +} + const Entries = [ SendCustomEvent, RoomStateExplorer, @@ -802,6 +1086,7 @@ const Entries = [ ServersInRoomList, VerificationExplorer, WidgetExplorer, + SettingsExplorer, ]; export default class DevtoolsDialog extends React.PureComponent { 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 && +