diff --git a/.eslintignore b/.eslintignore index c4f7298047..e453170087 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,4 +1,4 @@ src/component-index.js test/end-to-end-tests/node_modules/ -test/end-to-end-tests/riot/ +test/end-to-end-tests/element/ test/end-to-end-tests/synapse/ diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index aa2a6b7f0b..1c0a3d1254 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,37 +1,16 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. -src/ImageUtils.js src/Markdown.js -src/Rooms.js -src/Unread.js src/Velociraptor.js src/components/structures/RoomDirectory.js -src/components/structures/ScrollPanel.js -src/components/structures/UploadBar.js -src/components/views/elements/AddressSelector.js -src/components/views/elements/DirectorySearchBox.js -src/components/views/messages/MFileBody.js -src/components/views/messages/TextualBody.js -src/components/views/rooms/AuxPanel.js -src/components/views/rooms/LinkPreviewWidget.js src/components/views/rooms/MemberList.js -src/components/views/rooms/RoomPreviewBar.js -src/components/views/settings/ChangeAvatar.js -src/components/views/settings/DevicesPanel.js -src/components/views/settings/Notifications.js -src/rageshake/rageshake.js src/ratelimitedfunc.js src/utils/DMRoomMap.js -src/utils/DecryptFile.js -src/utils/DirectoryUtils.js src/utils/MultiInviter.js -src/utils/Receipt.js test/components/structures/MessagePanel-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js test/mock-clock.js -test/notifications/ContentRules-test.js -test/notifications/PushRuleVectorState-test.js src/component-index.js test/end-to-end-tests/node_modules/ -test/end-to-end-tests/riot/ +test/end-to-end-tests/element/ test/end-to-end-tests/synapse/ 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 4a22954c3f..c31eedf93b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,810 @@ +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) + + * Upgrade to JS SDK 9.5.1 + +Changes in [3.12.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.12.0) (2021-01-18) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.12.0-rc.1...v3.12.0) + + * Upgrade to JS SDK 9.5.0 + * Fix incoming call box on dark theme + [\#5543](https://github.com/matrix-org/matrix-react-sdk/pull/5543) + +Changes in [3.12.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.12.0-rc.1) (2021-01-13) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.1...v3.12.0-rc.1) + + * Upgrade to JS SDK 9.5.0-rc.1 + * Fix soft crash on soft logout page + [\#5539](https://github.com/matrix-org/matrix-react-sdk/pull/5539) + * Translations update from Weblate + [\#5538](https://github.com/matrix-org/matrix-react-sdk/pull/5538) + * Run TypeScript tests + [\#5537](https://github.com/matrix-org/matrix-react-sdk/pull/5537) + * Add a basic widget explorer to devtools (per-room) + [\#5528](https://github.com/matrix-org/matrix-react-sdk/pull/5528) + * Add to security key field + [\#5534](https://github.com/matrix-org/matrix-react-sdk/pull/5534) + * Fix avatar upload prompt/tooltip floating wrong and permissions + [\#5526](https://github.com/matrix-org/matrix-react-sdk/pull/5526) + * Add a dialpad UI for PSTN lookup + [\#5523](https://github.com/matrix-org/matrix-react-sdk/pull/5523) + * Basic call transfer initiation support + [\#5494](https://github.com/matrix-org/matrix-react-sdk/pull/5494) + * Fix #15988 + [\#5524](https://github.com/matrix-org/matrix-react-sdk/pull/5524) + * Bump node-notifier from 8.0.0 to 8.0.1 + [\#5520](https://github.com/matrix-org/matrix-react-sdk/pull/5520) + * Use TypeScript source for development, swap to build during release + [\#5503](https://github.com/matrix-org/matrix-react-sdk/pull/5503) + * Look for emoji in the body that will be displayed + [\#5517](https://github.com/matrix-org/matrix-react-sdk/pull/5517) + * Bump ini from 1.3.5 to 1.3.7 + [\#5486](https://github.com/matrix-org/matrix-react-sdk/pull/5486) + * Recognise `*.element.io` links as Element permalinks + [\#5514](https://github.com/matrix-org/matrix-react-sdk/pull/5514) + * Fixes for call UI + [\#5509](https://github.com/matrix-org/matrix-react-sdk/pull/5509) + * Add a snowfall chat effect (with /snowfall command) + [\#5511](https://github.com/matrix-org/matrix-react-sdk/pull/5511) + * fireworks effect + [\#5507](https://github.com/matrix-org/matrix-react-sdk/pull/5507) + * Don't play call end sound for calls that never started + [\#5506](https://github.com/matrix-org/matrix-react-sdk/pull/5506) + * Add /tableflip slash command + [\#5485](https://github.com/matrix-org/matrix-react-sdk/pull/5485) + * Import from src in IncomingCallBox.tsx + [\#5504](https://github.com/matrix-org/matrix-react-sdk/pull/5504) + * Social Login support both https and mxc icons + [\#5499](https://github.com/matrix-org/matrix-react-sdk/pull/5499) + * Fix padding in confirmation email registration prompt + [\#5501](https://github.com/matrix-org/matrix-react-sdk/pull/5501) + * Fix room list help prompt alignment + [\#5500](https://github.com/matrix-org/matrix-react-sdk/pull/5500) + +Changes in [3.11.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.1) (2020-12-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.0...v3.11.1) + + * Upgrade JS SDK to 9.4.1 + +Changes in [3.11.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.0) (2020-12-21) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.0-rc.2...v3.11.0) + + * Upgrade JS SDK to 9.4.0 + * [Release] Look for emoji in the body that will be displayed + [\#5519](https://github.com/matrix-org/matrix-react-sdk/pull/5519) + * [Release] Recognise `*.element.io` links as Element permalinks + [\#5516](https://github.com/matrix-org/matrix-react-sdk/pull/5516) + * [Release] Fixes for call UI + [\#5513](https://github.com/matrix-org/matrix-react-sdk/pull/5513) + * [RELEASE] Add a snowfall chat effect (with /snowfall command) + [\#5512](https://github.com/matrix-org/matrix-react-sdk/pull/5512) + * [Release] Fix padding in confirmation email registration prompt + [\#5502](https://github.com/matrix-org/matrix-react-sdk/pull/5502) + +Changes in [3.11.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.0-rc.2) (2020-12-16) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.11.0-rc.1...v3.11.0-rc.2) + + * Upgrade JS SDK to 9.4.0-rc.2 + +Changes in [3.11.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.11.0-rc.1) (2020-12-16) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.10.0...v3.11.0-rc.1) + + * Upgrade JS SDK to 9.4.0-rc.1 + * Translations update from Weblate + [\#5497](https://github.com/matrix-org/matrix-react-sdk/pull/5497) + * Unregister from the dispatcher in CallHandler + [\#5495](https://github.com/matrix-org/matrix-react-sdk/pull/5495) + * Better adhere to MSC process + [\#5496](https://github.com/matrix-org/matrix-react-sdk/pull/5496) + * Use random pickle key on all platforms + [\#5483](https://github.com/matrix-org/matrix-react-sdk/pull/5483) + * Fix mx_MemberList icons + [\#5492](https://github.com/matrix-org/matrix-react-sdk/pull/5492) + * Convert InviteDialog to TypeScript + [\#5491](https://github.com/matrix-org/matrix-react-sdk/pull/5491) + * Add keyboard shortcut for emoji reactions + [\#5425](https://github.com/matrix-org/matrix-react-sdk/pull/5425) + * Run chat effects on events sent by widgets too + [\#5488](https://github.com/matrix-org/matrix-react-sdk/pull/5488) + * Fix being unable to pin widgets + [\#5487](https://github.com/matrix-org/matrix-react-sdk/pull/5487) + * Line 1 / 2 Support + [\#5468](https://github.com/matrix-org/matrix-react-sdk/pull/5468) + * Remove impossible labs feature: sending hidden read receipts + [\#5484](https://github.com/matrix-org/matrix-react-sdk/pull/5484) + * Fix height of Remote Video in call + [\#5456](https://github.com/matrix-org/matrix-react-sdk/pull/5456) + * Add UI for hold functionality + [\#5446](https://github.com/matrix-org/matrix-react-sdk/pull/5446) + * Allow SearchBox to expand to fill width + [\#5411](https://github.com/matrix-org/matrix-react-sdk/pull/5411) + * Use room alias in generated permalink for rooms + [\#5451](https://github.com/matrix-org/matrix-react-sdk/pull/5451) + * Only show confetti if the current room is receiving an appropriate event + [\#5482](https://github.com/matrix-org/matrix-react-sdk/pull/5482) + * Throttle RoomState.members handler to improve performance + [\#5481](https://github.com/matrix-org/matrix-react-sdk/pull/5481) + * Handle manual hs urls better for the server picker + [\#5477](https://github.com/matrix-org/matrix-react-sdk/pull/5477) + * Add Olm as a dev dependency for types + [\#5479](https://github.com/matrix-org/matrix-react-sdk/pull/5479) + * Hide Invite to this room CTA if no permission + [\#5476](https://github.com/matrix-org/matrix-react-sdk/pull/5476) + * Fix width of underline in server picker dialog + [\#5478](https://github.com/matrix-org/matrix-react-sdk/pull/5478) + * Fix confetti room unread state check + [\#5475](https://github.com/matrix-org/matrix-react-sdk/pull/5475) + * Show confetti in a chat room on command or emoji + [\#5140](https://github.com/matrix-org/matrix-react-sdk/pull/5140) + * Fix inverted settings default value + [\#5391](https://github.com/matrix-org/matrix-react-sdk/pull/5391) + * Improve usability of the Server Picker Dialog + [\#5474](https://github.com/matrix-org/matrix-react-sdk/pull/5474) + * Fix typos in some strings + [\#5473](https://github.com/matrix-org/matrix-react-sdk/pull/5473) + * Bump highlight.js from 10.1.2 to 10.4.1 + [\#5472](https://github.com/matrix-org/matrix-react-sdk/pull/5472) + * Remove old app test script path + [\#5471](https://github.com/matrix-org/matrix-react-sdk/pull/5471) + * add support for giving reason when redacting + [\#5260](https://github.com/matrix-org/matrix-react-sdk/pull/5260) + * Add support for Netlify to fetchdep script + [\#5469](https://github.com/matrix-org/matrix-react-sdk/pull/5469) + * Nest other layers inside on automation + [\#5467](https://github.com/matrix-org/matrix-react-sdk/pull/5467) + * Rebrand various CI scripts and modules + [\#5466](https://github.com/matrix-org/matrix-react-sdk/pull/5466) + * Add more widget sanity checking + [\#5462](https://github.com/matrix-org/matrix-react-sdk/pull/5462) + * Fix React complaining about unknown DOM props + [\#5465](https://github.com/matrix-org/matrix-react-sdk/pull/5465) + * Jump to home page when leaving a room + [\#5464](https://github.com/matrix-org/matrix-react-sdk/pull/5464) + * Fix SSO buttons for Social Logins + [\#5463](https://github.com/matrix-org/matrix-react-sdk/pull/5463) + * Social Login and login delight tweaks + [\#5426](https://github.com/matrix-org/matrix-react-sdk/pull/5426) + +Changes in [3.10.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0) (2020-12-07) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.10.0-rc.1...v3.10.0) + + * Upgrade to JS SDK 9.3.0 + +Changes in [3.10.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.10.0-rc.1) (2020-12-02) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0...v3.10.0-rc.1) + + * Upgrade to JS SDK 9.3.0-rc.1 + * Translations update from Weblate + [\#5461](https://github.com/matrix-org/matrix-react-sdk/pull/5461) + * Fix VoIP call plinth on dark theme + [\#5460](https://github.com/matrix-org/matrix-react-sdk/pull/5460) + * Add sanity checking around widget pinning + [\#5459](https://github.com/matrix-org/matrix-react-sdk/pull/5459) + * Update i18n for Appearance User Settings + [\#5457](https://github.com/matrix-org/matrix-react-sdk/pull/5457) + * Only show 'answered elsewhere' if we tried to answer too + [\#5455](https://github.com/matrix-org/matrix-react-sdk/pull/5455) + * Fixed Avatar for 3PID invites + [\#5442](https://github.com/matrix-org/matrix-react-sdk/pull/5442) + * Slightly better error if we can't capture user media + [\#5449](https://github.com/matrix-org/matrix-react-sdk/pull/5449) + * Make it possible in-code to hide rooms from the room list + [\#5445](https://github.com/matrix-org/matrix-react-sdk/pull/5445) + * Fix the stickerpicker + [\#5447](https://github.com/matrix-org/matrix-react-sdk/pull/5447) + * Add live password validation to change password dialog + [\#5436](https://github.com/matrix-org/matrix-react-sdk/pull/5436) + * LaTeX rendering in element-web using KaTeX + [\#5244](https://github.com/matrix-org/matrix-react-sdk/pull/5244) + * Add lifecycle customisation point after logout + [\#5448](https://github.com/matrix-org/matrix-react-sdk/pull/5448) + * Simplify UserMenu for Guests as they can't use most of the options + [\#5421](https://github.com/matrix-org/matrix-react-sdk/pull/5421) + * Fix known issues with modal widgets + [\#5444](https://github.com/matrix-org/matrix-react-sdk/pull/5444) + * Fix existing widgets not having approved capabilities for their function + [\#5443](https://github.com/matrix-org/matrix-react-sdk/pull/5443) + * Use the WidgetDriver to run OIDC requests + [\#5440](https://github.com/matrix-org/matrix-react-sdk/pull/5440) + * Add a customisation point for widget permissions and fix amnesia issues + [\#5439](https://github.com/matrix-org/matrix-react-sdk/pull/5439) + * Fix Widget event notification text including spurious space + [\#5441](https://github.com/matrix-org/matrix-react-sdk/pull/5441) + * Move call listener out of MatrixChat + [\#5438](https://github.com/matrix-org/matrix-react-sdk/pull/5438) + * New Look in-Call View + [\#5432](https://github.com/matrix-org/matrix-react-sdk/pull/5432) + * Support arbitrary widgets sticking to the screen + sending stickers + [\#5435](https://github.com/matrix-org/matrix-react-sdk/pull/5435) + * Auth typescripting and validation tweaks + [\#5433](https://github.com/matrix-org/matrix-react-sdk/pull/5433) + * Add new widget API actions for changing rooms and sending/receiving events + [\#5385](https://github.com/matrix-org/matrix-react-sdk/pull/5385) + * Revert room header click behaviour to opening room settings + [\#5434](https://github.com/matrix-org/matrix-react-sdk/pull/5434) + * Add option to send/edit a message with Ctrl + Enter / Command + Enter + [\#5160](https://github.com/matrix-org/matrix-react-sdk/pull/5160) + * Add Analytics instrumentation to the Homepage + [\#5409](https://github.com/matrix-org/matrix-react-sdk/pull/5409) + * Fix encrypted video playback in Chrome-based browsers + [\#5430](https://github.com/matrix-org/matrix-react-sdk/pull/5430) + * Add border-radius for video + [\#5333](https://github.com/matrix-org/matrix-react-sdk/pull/5333) + * Push name to the end, near text, in IRC layout + [\#5166](https://github.com/matrix-org/matrix-react-sdk/pull/5166) + * Disable notifications for the room you have recently been active in + [\#5325](https://github.com/matrix-org/matrix-react-sdk/pull/5325) + * Search through the list of unfiltered rooms rather than the rooms in the + state which are already filtered by the search text + [\#5331](https://github.com/matrix-org/matrix-react-sdk/pull/5331) + * Lighten blockquote colour in dark mode + [\#5353](https://github.com/matrix-org/matrix-react-sdk/pull/5353) + * Specify community description img must be mxc urls + [\#5364](https://github.com/matrix-org/matrix-react-sdk/pull/5364) + * Add keyboard shortcut to close the current conversation + [\#5253](https://github.com/matrix-org/matrix-react-sdk/pull/5253) + * Redirect user home from auth screens if they are already logged in + [\#5423](https://github.com/matrix-org/matrix-react-sdk/pull/5423) + +Changes in [3.9.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0) (2020-11-23) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.9.0-rc.1...v3.9.0) + + * Upgrade JS SDK to 9.2.0 + * [Release] Fix encrypted video playback in Chrome-based browsers + [\#5431](https://github.com/matrix-org/matrix-react-sdk/pull/5431) + +Changes in [3.9.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.9.0-rc.1) (2020-11-18) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0...v3.9.0-rc.1) + + * Upgrade JS SDK to 9.2.0-rc.1 + * Translations update from Weblate + [\#5429](https://github.com/matrix-org/matrix-react-sdk/pull/5429) + * Fix message search summary text + [\#5428](https://github.com/matrix-org/matrix-react-sdk/pull/5428) + * Shrink new room intro top margin to half for encryption bubble tile + [\#5427](https://github.com/matrix-org/matrix-react-sdk/pull/5427) + * Small delight tweaks to improve rough corners in the app + [\#5418](https://github.com/matrix-org/matrix-react-sdk/pull/5418) + * Fix DM logic to always pick a more reliable DM room + [\#5424](https://github.com/matrix-org/matrix-react-sdk/pull/5424) + * Update styling of the Analytics toast + [\#5408](https://github.com/matrix-org/matrix-react-sdk/pull/5408) + * Fix vertical centering of the Homepage and button layout + [\#5420](https://github.com/matrix-org/matrix-react-sdk/pull/5420) + * Fix BaseAvatar sometimes messing up and duplicating the url + [\#5422](https://github.com/matrix-org/matrix-react-sdk/pull/5422) + * Disable buttons when required by MSC2790 + [\#5412](https://github.com/matrix-org/matrix-react-sdk/pull/5412) + * Fix drag drop file to upload for Safari + [\#5414](https://github.com/matrix-org/matrix-react-sdk/pull/5414) + * Fix poorly i18n'd string + [\#5416](https://github.com/matrix-org/matrix-react-sdk/pull/5416) + * Fix the feedback not closing without feedback/countly + [\#5417](https://github.com/matrix-org/matrix-react-sdk/pull/5417) + * Fix New Room Intro invite to this room button + [\#5419](https://github.com/matrix-org/matrix-react-sdk/pull/5419) + * Change how we expose Role in User Info and hide in DMs + [\#5413](https://github.com/matrix-org/matrix-react-sdk/pull/5413) + * Disallow sending of empty messages + [\#5390](https://github.com/matrix-org/matrix-react-sdk/pull/5390) + * hide some validation tooltips if fields are valid. + [\#5403](https://github.com/matrix-org/matrix-react-sdk/pull/5403) + * Improvements around new room empty space interactions + [\#5398](https://github.com/matrix-org/matrix-react-sdk/pull/5398) + * Implement call hold + [\#5366](https://github.com/matrix-org/matrix-react-sdk/pull/5366) + * Fix Skeleton UI showing up when not intended. + [\#5407](https://github.com/matrix-org/matrix-react-sdk/pull/5407) + * Close context menu when user clicks the Home button + [\#5406](https://github.com/matrix-org/matrix-react-sdk/pull/5406) + * Skip e2ee warn logout prompt if user has no megolm sessions to lose + [\#5410](https://github.com/matrix-org/matrix-react-sdk/pull/5410) + * Allow country names to be translated + [\#5405](https://github.com/matrix-org/matrix-react-sdk/pull/5405) + * Support thirdparty lookup for phone numbers + [\#5396](https://github.com/matrix-org/matrix-react-sdk/pull/5396) + * Change "Password" to "New Password" + [\#5371](https://github.com/matrix-org/matrix-react-sdk/pull/5371) + * Add customisation point for dehydration key + [\#5397](https://github.com/matrix-org/matrix-react-sdk/pull/5397) + * Rebrand Riot -> Element in the permalink classes + [\#5386](https://github.com/matrix-org/matrix-react-sdk/pull/5386) + * Invite / Create DM UX tweaks + [\#5387](https://github.com/matrix-org/matrix-react-sdk/pull/5387) + * Tweaks to toasts and post-registration landing + [\#5383](https://github.com/matrix-org/matrix-react-sdk/pull/5383) + +Changes in [3.8.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0) (2020-11-09) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.8.0-rc.1...v3.8.0) + + * Upgrade JS SDK to 9.1.0 + +Changes in [3.8.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.8.0-rc.1) (2020-11-04) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.1...v3.8.0-rc.1) + + * Upgrade JS SDK to 9.1.0-rc.1 + * Log when saving profile + [\#5394](https://github.com/matrix-org/matrix-react-sdk/pull/5394) + * Translations update from Weblate + [\#5395](https://github.com/matrix-org/matrix-react-sdk/pull/5395) + * Hide prompt to add email for notifications if 3pid ui feature is off + [\#5392](https://github.com/matrix-org/matrix-react-sdk/pull/5392) + * Fix room list message preview copy for hangup events + [\#5388](https://github.com/matrix-org/matrix-react-sdk/pull/5388) + * Track UISIs as Countly Events + [\#5382](https://github.com/matrix-org/matrix-react-sdk/pull/5382) + * Don't let users accidentally redact ACL events + [\#5384](https://github.com/matrix-org/matrix-react-sdk/pull/5384) + * Two more easy files to remove from eslintignore + [\#5378](https://github.com/matrix-org/matrix-react-sdk/pull/5378) + * Fix Widget OpenID Permissions for realsies + [\#5381](https://github.com/matrix-org/matrix-react-sdk/pull/5381) + * Fix regression with OpenID permissions on widgets + [\#5380](https://github.com/matrix-org/matrix-react-sdk/pull/5380) + * Fix room directory events happening in the wrong order for Funnels + [\#5379](https://github.com/matrix-org/matrix-react-sdk/pull/5379) + * Remove a couple more files from eslintignore + [\#5377](https://github.com/matrix-org/matrix-react-sdk/pull/5377) + * Fix countly method bindings and errors + [\#5376](https://github.com/matrix-org/matrix-react-sdk/pull/5376) + * Fix a bunch of silly lint errors + [\#5375](https://github.com/matrix-org/matrix-react-sdk/pull/5375) + * Typescript: ImageUtils + [\#5374](https://github.com/matrix-org/matrix-react-sdk/pull/5374) + * Convert AuxPanel to TypeScript + [\#5373](https://github.com/matrix-org/matrix-react-sdk/pull/5373) + * Only pass metrics if they exist otherwise Countly will be unhappy! + [\#5372](https://github.com/matrix-org/matrix-react-sdk/pull/5372) + * Fix CountlyAnalytics NPE on MatrixClientPeg + [\#5370](https://github.com/matrix-org/matrix-react-sdk/pull/5370) + * fix CountlyAnalytics canEnable on wrong target + [\#5369](https://github.com/matrix-org/matrix-react-sdk/pull/5369) + * Initial Countly work + [\#5365](https://github.com/matrix-org/matrix-react-sdk/pull/5365) + * Fix videos not playing in non-encrypted rooms + [\#5368](https://github.com/matrix-org/matrix-react-sdk/pull/5368) + * Fix custom tag layout which regressed in #5309 + [\#5367](https://github.com/matrix-org/matrix-react-sdk/pull/5367) + * Watch replyToEvent at RoomView to prevent races + [\#5360](https://github.com/matrix-org/matrix-react-sdk/pull/5360) + * Add a UI Feature flag for room history settings + [\#5362](https://github.com/matrix-org/matrix-react-sdk/pull/5362) + * Hide inline images when preference disabled + [\#5361](https://github.com/matrix-org/matrix-react-sdk/pull/5361) + * Fix React warning by moving handler to each button + [\#5359](https://github.com/matrix-org/matrix-react-sdk/pull/5359) + * Do not preload encrypted videos|images unless autoplay or thumbnailing is on + [\#5352](https://github.com/matrix-org/matrix-react-sdk/pull/5352) + * Fix theme variable passed to Jitsi + [\#5357](https://github.com/matrix-org/matrix-react-sdk/pull/5357) + * docs: added comment explanation + [\#5349](https://github.com/matrix-org/matrix-react-sdk/pull/5349) + * Modal Widgets - MSC2790 + [\#5252](https://github.com/matrix-org/matrix-react-sdk/pull/5252) + * Widgets fixes + [\#5350](https://github.com/matrix-org/matrix-react-sdk/pull/5350) + * Fix User Menu avatar colouring being based on wrong string + [\#5348](https://github.com/matrix-org/matrix-react-sdk/pull/5348) + * Support 'answered elsewhere' + [\#5345](https://github.com/matrix-org/matrix-react-sdk/pull/5345) + +Changes in [3.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.1) (2020-10-28) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0...v3.7.1) + + * Upgrade JS SDK to 9.0.1 + * [Release] Fix theme variable passed to Jitsi + [\#5358](https://github.com/matrix-org/matrix-react-sdk/pull/5358) + * [Release] Widget fixes + [\#5351](https://github.com/matrix-org/matrix-react-sdk/pull/5351) + +Changes in [3.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0) (2020-10-26) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0-rc.2...v3.7.0) + + * Upgrade JS SDK to 9.0.0 + +Changes in [3.7.0-rc.2](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0-rc.2) (2020-10-21) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.7.0-rc.1...v3.7.0-rc.2) + + * Fix JS SDK dependency to use 9.0.0-rc.1 as intended + +Changes in [3.7.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.7.0-rc.1) (2020-10-21) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.1...v3.7.0-rc.1) + + * Upgrade JS SDK to 9.0.0-rc.1 + * Update Weblate URL + [\#5346](https://github.com/matrix-org/matrix-react-sdk/pull/5346) + * Translations update from Weblate + [\#5347](https://github.com/matrix-org/matrix-react-sdk/pull/5347) + * Left Panel Widget support + [\#5247](https://github.com/matrix-org/matrix-react-sdk/pull/5247) + * Pinned widgets work + [\#5266](https://github.com/matrix-org/matrix-react-sdk/pull/5266) + * Convert resizer to Typescript + [\#5343](https://github.com/matrix-org/matrix-react-sdk/pull/5343) + * Hide filtering microcopy when left panel is minimized + [\#5338](https://github.com/matrix-org/matrix-react-sdk/pull/5338) + * Skip editor confirmation of upgrades + [\#5344](https://github.com/matrix-org/matrix-react-sdk/pull/5344) + * Spec compliance, /search doesn't have to return results + [\#5337](https://github.com/matrix-org/matrix-react-sdk/pull/5337) + * Fix excessive hosting link padding + [\#5336](https://github.com/matrix-org/matrix-react-sdk/pull/5336) + * Adjust for new widget messaging APIs + [\#5341](https://github.com/matrix-org/matrix-react-sdk/pull/5341) + * Fix case where sublist context menu missed an update + [\#5339](https://github.com/matrix-org/matrix-react-sdk/pull/5339) + * Add analytics to VoIP + [\#5340](https://github.com/matrix-org/matrix-react-sdk/pull/5340) + * Fix Jitsi OpenIDC auth + [\#5334](https://github.com/matrix-org/matrix-react-sdk/pull/5334) + * Support rejecting calls + [\#5324](https://github.com/matrix-org/matrix-react-sdk/pull/5324) + * Don't show admin tooling if we're not in the room + [\#5330](https://github.com/matrix-org/matrix-react-sdk/pull/5330) + * Show Integrations error if iframe failed to load too + [\#5328](https://github.com/matrix-org/matrix-react-sdk/pull/5328) + * Add security customisation points + [\#5327](https://github.com/matrix-org/matrix-react-sdk/pull/5327) + * Discard all mx_fadable legacy cruft which is totally useless + [\#5326](https://github.com/matrix-org/matrix-react-sdk/pull/5326) + * Fix background-image: url(null) for backdrop filter + [\#5319](https://github.com/matrix-org/matrix-react-sdk/pull/5319) + * Make the ACL update message less noisy + [\#5316](https://github.com/matrix-org/matrix-react-sdk/pull/5316) + * Fix aspect ratio of avatar before clicking Save + [\#5318](https://github.com/matrix-org/matrix-react-sdk/pull/5318) + * Don't supply popout widgets with widget parameters + [\#5323](https://github.com/matrix-org/matrix-react-sdk/pull/5323) + * Changed rainbow algorithm + [\#5301](https://github.com/matrix-org/matrix-react-sdk/pull/5301) + * Renamed TagPanel and TagOrderStore + [\#5309](https://github.com/matrix-org/matrix-react-sdk/pull/5309) + * Fix/clarify boolean logic for reaction previews + [\#5321](https://github.com/matrix-org/matrix-react-sdk/pull/5321) + * Support glare for VoIP calls + [\#5311](https://github.com/matrix-org/matrix-react-sdk/pull/5311) + * Round of Typescript conversions + [\#5314](https://github.com/matrix-org/matrix-react-sdk/pull/5314) + * Fix broken rendering of Room Create when showHiddenEvents enabled + [\#5317](https://github.com/matrix-org/matrix-react-sdk/pull/5317) + * Improve LHS resize performance and tidy stale props&classes + [\#5313](https://github.com/matrix-org/matrix-react-sdk/pull/5313) + * event-index: Pass the user/device id pair when initializing the event index. + [\#5312](https://github.com/matrix-org/matrix-react-sdk/pull/5312) + * Fix various aspects of (jitsi) widgets + [\#5315](https://github.com/matrix-org/matrix-react-sdk/pull/5315) + * Fix rogue (partial) call bar + [\#5310](https://github.com/matrix-org/matrix-react-sdk/pull/5310) + * Rewrite call state machine + [\#5308](https://github.com/matrix-org/matrix-react-sdk/pull/5308) + * Convert `src/SecurityManager.js` to TypeScript + [\#5307](https://github.com/matrix-org/matrix-react-sdk/pull/5307) + * Fix templating for v1 jitsi widgets + [\#5305](https://github.com/matrix-org/matrix-react-sdk/pull/5305) + * Use new preparing event for widget communications + [\#5303](https://github.com/matrix-org/matrix-react-sdk/pull/5303) + * Fix parsing issue in event tile preview for appearance tab + [\#5302](https://github.com/matrix-org/matrix-react-sdk/pull/5302) + * Track replyToEvent along with Cider state & history + [\#5284](https://github.com/matrix-org/matrix-react-sdk/pull/5284) + * Roving Tab Index should not interfere with inputs + [\#5299](https://github.com/matrix-org/matrix-react-sdk/pull/5299) + * Visual tweaks from 2020-10-06 polishing + [\#5298](https://github.com/matrix-org/matrix-react-sdk/pull/5298) + * Convert auth lifecycle to TS, remove dead ILAG code + [\#5296](https://github.com/matrix-org/matrix-react-sdk/pull/5296) + +Changes in [3.6.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.1) (2020-10-20) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0...v3.6.1) + + * [Release] Adjust for new widget messaging APIs + [\#5342](https://github.com/matrix-org/matrix-react-sdk/pull/5342) + * [Release] Fix Jitsi OpenIDC auth + [\#5335](https://github.com/matrix-org/matrix-react-sdk/pull/5335) + Changes in [3.6.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0) (2020-10-12) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.6.0-rc.1...v3.6.0) diff --git a/README.md b/README.md index 4db02418ba..73afe34df0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ are currently filed against vector-im/element-web rather than this project). Translation Status ================== -[![Translation status](https://translate.riot.im/widgets/element-web/-/multi-auto.svg)](https://translate.riot.im/engage/element-web/?utm_source=widget) +[![Translation status](https://translate.element.io/widgets/element-web/-/multi-auto.svg)](https://translate.element.io/engage/element-web/?utm_source=widget) Developer Guide =============== 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/docs/widget-layouts.md b/docs/widget-layouts.md new file mode 100644 index 0000000000..e7f72e2001 --- /dev/null +++ b/docs/widget-layouts.md @@ -0,0 +1,60 @@ +# Widget layout support + +Rooms can have a default widget layout to auto-pin certain widgets, make the container different +sizes, etc. These are defined through the `io.element.widgets.layout` state event (empty state key). + +Full example content: +```json5 +{ + "widgets": { + "first-widget-id": { + "container": "top", + "index": 0, + "width": 60, + "height": 40 + }, + "second-widget-id": { + "container": "right" + } + } +} +``` + +As shown, there are two containers possible for widgets. These containers have different behaviour +and interpret the other options differently. + +## `top` container + +This is the "App Drawer" or any pinned widgets in a room. This is by far the most versatile container +though does introduce potential usability issues upon members of the room (widgets take up space and +therefore fewer messages can be shown). + +The `index` for a widget determines which order the widgets show up in from left to right. Widgets +without an `index` will show up as the rightmost widgets. Tiebreaks (same `index` or multiple defined +without an `index`) are resolved by comparing widget IDs. A maximum of 3 widgets can be in the top +container - any which exceed this will be ignored (placed into the `right` container). Smaller numbers +represent leftmost widgets. + +The `width` is relative width within the container in percentage points. This will be clamped to a +range of 0-100 (inclusive). The widgets will attempt to scale to relative proportions when more than +100% space is allocated. For example, if 3 widgets are defined at 40% width each then the client will +attempt to show them at 33% width each. + +Note that the client may impose minimum widths on the widgets, such as a 10% minimum to avoid pinning +hidden widgets. In general, widgets defined in the 30-70% range each will be free of these restrictions. + +The `height` is not in fact applied per-widget but is recorded per-widget for potential future +capabilities in future containers. The top container will take the tallest `height` and use that for +the height of the whole container, and thus all widgets in that container. The `height` is relative +to the container, like with `width`, meaning that 100% will consume as much space as the client is +willing to sacrifice to the widget container. Like with `width`, the client may impose minimums to avoid +the container being uselessly small. Heights in the 30-100% range are generally acceptable. The height +is also clamped to be within 0-100, inclusive. + +## `right` container + +This is the default container and has no special configuration. Widgets which overflow from the top +container will be put in this container instead. Putting a widget in the right container does not +automatically show it - it only mentions that widgets should not be in another container. + +The behaviour of this container may change in the future. diff --git a/package.json b/package.json index 3f073ce59c..7ed1b272da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.6.0", + "version": "3.15.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -27,11 +27,12 @@ "matrix-gen-i18n": "scripts/gen-i18n.js", "matrix-prune-i18n": "scripts/prune-i18n.js" }, - "main": "./lib/index.js", - "typings": "./lib/index.d.ts", + "main": "./src/index.js", "matrix_src_main": "./src/index.js", + "matrix_lib_main": "./lib/index.js", + "matrix_lib_typings": "./lib/index.d.ts", "scripts": { - "prepare": "yarn build", + "prepublishOnly": "yarn build", "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", @@ -50,49 +51,49 @@ "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", - "test:e2e": "./test/end-to-end-tests/run.sh --riot-url http://localhost:8080" + "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.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.3", + "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", @@ -100,75 +101,80 @@ "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", - "react-test-renderer": "^16.13.1", - "rimraf": "^2.7.1", - "stylelint": "^9.10.1", - "stylelint-config-standard": "^18.3.0", + "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", + "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": [ - "/test/**/*-test.js" + "/test/**/*-test.[jt]s" ], "setupFiles": [ "jest-canvas-mock" diff --git a/release.sh b/release.sh index e2cefcbe74..4742f00dea 100755 --- a/release.sh +++ b/release.sh @@ -32,9 +32,7 @@ do echo "Upgrading $i to $latestver..." yarn add -E $i@$latestver git add -u - # The `-e` flag opens the editor and gives you a chance to check - # the upgrade for correctness. - git commit -m "Upgrade $i to $latestver" -e + git commit -m "Upgrade $i to $latestver" fi fi done diff --git a/res/css/_common.scss b/res/css/_common.scss index 666129af34..6e9d252659 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -17,9 +17,15 @@ limitations under the License. */ @import "./_font-sizes.scss"; +@import "./_font-weights.scss"; $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; } @@ -59,6 +65,10 @@ pre, code { color: $accent-color; } +.text-muted { + color: $muted-fg-color; +} + b { // On Firefox, the default weight for `` is `bolder` which results in no bold // effect since we only have specific weights of our fonts available. @@ -165,7 +175,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border: 1px solid rgba($primary-fg-color, .1); // these things should probably not be defined globally margin: 9px; - flex: 0 0 auto; } .mx_textinput { @@ -323,6 +332,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_title { font-size: $font-22px; + font-weight: $font-semi-bold; line-height: $font-36px; color: $dialog-title-fg-color; } @@ -348,8 +358,8 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { background-color: $dialog-close-fg-color; cursor: pointer; position: absolute; - top: 4px; - right: 0px; + top: 10px; + right: 0; } .mx_Dialog_content { @@ -362,6 +372,11 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { .mx_Dialog_buttons { margin-top: 20px; text-align: right; + + .mx_Dialog_buttons_additive { + // The consumer is responsible for positioning their elements. + float: left; + } } /* XXX: Our button style are a mess: buttons that happen to appear in dialogs get special styles applied diff --git a/res/css/_components.scss b/res/css/_components.scss index 18cf0c52d8..d894688cac 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -9,10 +9,12 @@ @import "./structures/_CustomRoomTagPanel.scss"; @import "./structures/_FilePanel.scss"; @import "./structures/_GenericErrorPage.scss"; +@import "./structures/_GroupFilterPanel.scss"; @import "./structures/_GroupView.scss"; @import "./structures/_HeaderButtons.scss"; @import "./structures/_HomePage.scss"; @import "./structures/_LeftPanel.scss"; +@import "./structures/_LeftPanelWidget.scss"; @import "./structures/_MainSplit.scss"; @import "./structures/_MatrixChat.scss"; @import "./structures/_MyGroups.scss"; @@ -25,8 +27,10 @@ @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/_GroupFilterPanel.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_UserMenu.scss"; @@ -44,18 +48,18 @@ @import "./views/auth/_InteractiveAuthEntryComponents.scss"; @import "./views/auth/_LanguageSelector.scss"; @import "./views/auth/_PassphraseField.scss"; -@import "./views/auth/_ServerConfig.scss"; -@import "./views/auth/_ServerTypeSelector.scss"; @import "./views/auth/_Welcome.scss"; @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_PulsedAvatar.scss"; +@import "./views/avatars/_WidgetAvatar.scss"; +@import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; -@import "./views/context_menus/_WidgetContextMenu.scss"; +@import "./views/dialogs/_AddExistingToSpaceDialog.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @@ -69,25 +73,32 @@ @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @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"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; +@import "./views/dialogs/_ModalWidgetDialog.scss"; @import "./views/dialogs/_NewSessionReviewDialog.scss"; +@import "./views/dialogs/_RegistrationEmailPromptDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomSettingsDialogBridges.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @import "./views/dialogs/_ServerOfflineDialog.scss"; +@import "./views/dialogs/_ServerPickerDialog.scss"; @import "./views/dialogs/_SetEmailDialog.scss"; @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"; @import "./views/dialogs/_UserSettingsDialog.scss"; +@import "./views/dialogs/_WidgetCapabilitiesPromptDialog.scss"; @import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.scss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.scss"; @@ -101,17 +112,18 @@ @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"; @import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_Field.scss"; @import "./views/elements/_FormButton.scss"; -@import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; @import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_ManageIntegsButton.scss"; +@import "./views/elements/_MiniAvatarUploader.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_QRCode.scss"; @@ -120,6 +132,8 @@ @import "./views/elements/_RichText.scss"; @import "./views/elements/_RoleButton.scss"; @import "./views/elements/_RoomAliasField.scss"; +@import "./views/elements/_SSOButtons.scss"; +@import "./views/elements/_ServerPicker.scss"; @import "./views/elements/_Slider.scss"; @import "./views/elements/_Spinner.scss"; @import "./views/elements/_StyledCheckbox.scss"; @@ -136,6 +150,7 @@ @import "./views/groups/_GroupUserSettings.scss"; @import "./views/messages/_CreateEvent.scss"; @import "./views/messages/_DateSeparator.scss"; +@import "./views/messages/_EventTileBubble.scss"; @import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MImageBody.scss"; @@ -179,6 +194,7 @@ @import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MessageComposer.scss"; @import "./views/rooms/_MessageComposerFormatBar.scss"; +@import "./views/rooms/_NewRoomIntro.scss"; @import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventsPanel.scss"; @@ -201,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"; @@ -221,9 +238,16 @@ @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"; @import "./views/verification/_VerificationShowSas.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; -@import "./views/voip/_VideoView.scss"; +@import "./views/voip/_DialPad.scss"; +@import "./views/voip/_DialPadContextMenu.scss"; +@import "./views/voip/_DialPadModal.scss"; +@import "./views/voip/_VideoFeed.scss"; diff --git a/res/css/structures/_CustomRoomTagPanel.scss b/res/css/structures/_CustomRoomTagPanel.scss index 96813cccea..be1138cf5b 100644 --- a/res/css/structures/_CustomRoomTagPanel.scss +++ b/res/css/structures/_CustomRoomTagPanel.scss @@ -16,11 +16,6 @@ limitations under the License. // TODO: Update design for custom tags to match new designs -.mx_LeftPanel_tagPanelContainer { - display: flex; - flex-direction: column; -} - .mx_CustomRoomTagPanel { background-color: $groupFilterPanel-bg-color; max-height: 40vh; diff --git a/res/css/structures/_HomePage.scss b/res/css/structures/_HomePage.scss index 04527bff48..9f72213d1a 100644 --- a/res/css/structures/_HomePage.scss +++ b/res/css/structures/_HomePage.scss @@ -26,9 +26,10 @@ limitations under the License. .mx_HomePage_default { text-align: center; + display: flex; .mx_HomePage_default_wrapper { - padding: 25vh 0 12px; + margin: auto; } img { @@ -50,56 +51,54 @@ limitations under the License. color: $muted-fg-color; } + .mx_MiniAvatarUploader { + margin: 0 auto; + } + .mx_HomePage_default_buttons { - margin: 80px auto 0; + margin: 60px auto 0; width: fit-content; .mx_AccessibleButton { padding: 73px 8px 15px; // top: 20px top padding + 40px icon + 13px margin - width: 104px; // 120px - 2* 8px - margin: 0 39px; // 55px - 2* 8px + width: 160px; + height: 132px; + margin: 20px; position: relative; display: inline-block; border-radius: 8px; vertical-align: top; word-break: break-word; + box-sizing: border-box; font-weight: 600; font-size: $font-15px; line-height: $font-20px; - color: $muted-fg-color; - - &:hover { - color: $accent-color; - background: rgba($accent-color, 0.06); - - &::before { - background-color: $accent-color; - } - } + color: #fff; // on all themes + background-color: $accent-color; &::before { top: 20px; - left: 40px; // (120px-40px)/2 + left: 60px; // (160px-40px)/2 width: 40px; height: 40px; content: ''; position: absolute; - background-color: $muted-fg-color; + background-color: #fff; // on all themes mask-repeat: no-repeat; mask-size: contain; } &.mx_HomePage_button_sendDm::before { - mask-image: url('$(res)/img/feather-customised/message-circle.svg'); + mask-image: url('$(res)/img/element-icons/feedback.svg'); } &.mx_HomePage_button_explore::before { - mask-image: url('$(res)/img/feather-customised/explore.svg'); + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } &.mx_HomePage_button_createGroup::before { - mask-image: url('$(res)/img/feather-customised/group.svg'); + mask-image: url('$(res)/img/element-icons/community-members.svg'); } } } diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 885dd77a84..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; @@ -32,22 +33,17 @@ $groupFilterPanelWidth: 56px; // only applies in this file, used for calculation // Create another flexbox so the GroupFilterPanel fills the container display: flex; + flex-direction: column; // 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; @@ -167,17 +163,15 @@ $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; + justify-content: center; + } .mx_LeftPanel_filterContainer { // Organize the flexbox into a centered column layout diff --git a/res/css/structures/_LeftPanelWidget.scss b/res/css/structures/_LeftPanelWidget.scss new file mode 100644 index 0000000000..6e2d99bb37 --- /dev/null +++ b/res/css/structures/_LeftPanelWidget.scss @@ -0,0 +1,145 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_LeftPanelWidget { + // largely based on RoomSublist + margin-left: 8px; + margin-bottom: 4px; + + .mx_LeftPanelWidget_headerContainer { + display: flex; + align-items: center; + + height: 24px; + color: $roomlist-header-color; + margin-top: 4px; + + .mx_LeftPanelWidget_stickable { + flex: 1; + max-width: 100%; + + display: flex; + align-items: center; + } + + .mx_LeftPanelWidget_headerText { + flex: 1; + max-width: calc(100% - 16px); + line-height: $font-16px; + font-size: $font-13px; + font-weight: 600; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + + .mx_LeftPanelWidget_collapseBtn { + display: inline-block; + position: relative; + width: 14px; + height: 14px; + margin-right: 6px; + + &::before { + content: ''; + width: 18px; + height: 18px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $roomlist-header-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + + &.mx_LeftPanelWidget_collapseBtn_collapsed::before { + transform: rotate(-90deg); + } + } + } + } + + .mx_LeftPanelWidget_resizeBox { + position: relative; + + display: flex; + flex-direction: column; + overflow: visible; // let the resize handle out + } + + .mx_AppTileFullWidth { + flex: 1 0 0; + overflow: hidden; + // need this to be flex otherwise the overflow hidden from above + // sometimes vertically centers the clipped list ... no idea why it would do this + // as the box model should be top aligned. Happens in both FF and Chromium + display: flex; + flex-direction: column; + box-sizing: border-box; + + mask-image: linear-gradient(0deg, transparent, black 4px); + } + + .mx_LeftPanelWidget_resizerHandle { + cursor: ns-resize; + border-radius: 3px; + + // Override styles from library + width: unset !important; + height: 4px !important; + + position: absolute; + top: -24px !important; // override from library - puts it in the margin-top of the headerContainer + + // 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_LeftPanelWidget_resizerHandle { + opacity: 0.8; + background-color: $primary-fg-color; + } + + .mx_LeftPanelWidget_maximizeButton { + margin-left: 8px; + margin-right: 7px; + position: relative; + width: 24px; + height: 24px; + border-radius: 32px; + + &::before { + content: ''; + width: 16px; + height: 16px; + position: absolute; + top: 4px; + left: 4px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/feather-customised/maximise.svg'); + background: $muted-fg-color; + } + } +} + +.mx_LeftPanelWidget_maximizeButtonTooltip { + margin-top: -3px; +} diff --git a/res/css/structures/_MatrixChat.scss b/res/css/structures/_MatrixChat.scss index f4e46a8e94..812a7f8472 100644 --- a/res/css/structures/_MatrixChat.scss +++ b/res/css/structures/_MatrixChat.scss @@ -79,7 +79,6 @@ limitations under the License. height: 100%; } -.mx_MatrixChat > .mx_LeftPanel2:hover + .mx_ResizeHandle_horizontal, .mx_MatrixChat > .mx_ResizeHandle_horizontal:hover { position: relative; 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/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index cd4390ee5c..5bf2aee3ae 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -19,57 +19,6 @@ limitations under the License. min-height: 50px; } -/* position the indicator in the same place horizontally as .mx_EventTile_avatar. */ -.mx_RoomStatusBar_indicator { - padding-left: 17px; - padding-right: 12px; - margin-left: -73px; - margin-top: 15px; - float: left; - width: 24px; - text-align: center; -} - -.mx_RoomStatusBar_callBar { - height: 50px; - line-height: $font-50px; -} - -.mx_RoomStatusBar_placeholderIndicator span { - color: $primary-fg-color; - opacity: 0.5; - position: relative; - top: -4px; - /* - animation-duration: 1s; - animation-name: bounce; - animation-direction: alternate; - animation-iteration-count: infinite; - */ -} - -.mx_RoomStatusBar_placeholderIndicator span:nth-child(1) { - animation-delay: 0.3s; -} -.mx_RoomStatusBar_placeholderIndicator span:nth-child(2) { - animation-delay: 0.6s; -} -.mx_RoomStatusBar_placeholderIndicator span:nth-child(3) { - animation-delay: 0.9s; -} - -@keyframes bounce { - from { - opacity: 0.5; - top: 0; - } - - to { - opacity: 0.2; - top: -3px; - } -} - .mx_RoomStatusBar_typingIndicatorAvatars { width: 52px; margin-top: -1px; @@ -153,16 +102,6 @@ limitations under the License. display: block; } -.mx_RoomStatusBar_isAlone { - height: 50px; - line-height: $font-50px; - - color: $primary-fg-color; - opacity: 0.5; - overflow-y: hidden; - display: block; -} - .mx_MatrixChat_useCompactLayout { .mx_RoomStatusBar { min-height: 40px; @@ -172,11 +111,6 @@ limitations under the License. margin-top: 10px; } - .mx_RoomStatusBar_callBar { - height: 40px; - line-height: $font-40px; - } - .mx_RoomStatusBar_typingBar { height: 40px; line-height: $font-40px; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 572c7166d2..36bf96359b 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -219,7 +219,7 @@ hr.mx_RoomView_myReadMarker { position: relative; top: -1px; z-index: 1; - transition: width 400ms easeInSine 1s, opacity 400ms easeInSine 1s; + transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; width: 99%; opacity: 1; } diff --git a/res/css/structures/_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 fecac40e4e..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 @@ -119,20 +120,16 @@ limitations under the License. } &.mx_UserMenu_minimized { - .mx_UserMenu_userHeader { - .mx_UserMenu_row { - justify-content: center; - } + padding-right: 0px; - .mx_UserMenu_userAvatarContainer { - margin-right: 0; - } + .mx_UserMenu_userAvatarContainer { + margin-right: 0px; } } } .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 @@ -230,6 +227,30 @@ limitations under the License. align-items: center; justify-content: center; } + + &.mx_UserMenu_contextMenu_guestPrompts, + &.mx_UserMenu_contextMenu_hostingLink { + padding-top: 0; + } + + &.mx_UserMenu_contextMenu_guestPrompts { + display: inline-block; + + > span { + font-weight: 600; + display: block; + + & + span { + margin-top: 8px; + } + } + + .mx_AccessibleButton_kind_link { + font-weight: normal; + font-size: inherit; + padding: 0; + } + } } .mx_IconizedContextMenu_icon { @@ -252,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/structures/auth/_Login.scss b/res/css/structures/auth/_Login.scss index 02436833a2..9c98ca3a1c 100644 --- a/res/css/structures/auth/_Login.scss +++ b/res/css/structures/auth/_Login.scss @@ -18,7 +18,7 @@ limitations under the License. .mx_Login_submit { @mixin mx_DialogButton; width: 100%; - margin-top: 35px; + margin-top: 24px; margin-bottom: 24px; box-sizing: border-box; text-align: center; @@ -33,12 +33,6 @@ limitations under the License. cursor: default; } -.mx_AuthBody a.mx_Login_sso_link:link, -.mx_AuthBody a.mx_Login_sso_link:hover, -.mx_AuthBody a.mx_Login_sso_link:visited { - color: $button-primary-fg-color; -} - .mx_Login_loader { display: inline; position: relative; @@ -87,10 +81,13 @@ limitations under the License. } .mx_Login_underlinedServerName { + width: max-content; border-bottom: 1px dashed $accent-color; } div.mx_AccessibleButton_kind_link.mx_Login_forgot { + display: block; + margin: 0 auto; // style it as a link font-size: inherit; padding: 0; diff --git a/res/css/views/auth/_AuthBody.scss b/res/css/views/auth/_AuthBody.scss index 0ba0d10e06..90dca32e48 100644 --- a/res/css/views/auth/_AuthBody.scss +++ b/res/css/views/auth/_AuthBody.scss @@ -34,7 +34,11 @@ limitations under the License. h3 { font-size: $font-14px; font-weight: 600; - color: $authpage-primary-color; + color: $authpage-secondary-color; + } + + h3.mx_AuthBody_centered { + text-align: center; } a:link, @@ -96,12 +100,6 @@ limitations under the License. } } -.mx_AuthBody_editServerDetails { - padding-left: 1em; - font-size: $font-12px; - font-weight: normal; -} - .mx_AuthBody_fieldRow { display: flex; margin-bottom: 10px; @@ -146,6 +144,14 @@ limitations under the License. display: block; text-align: center; width: 100%; + + > a { + font-weight: $font-semi-bold; + } +} + +.mx_SSOButtons + .mx_AuthBody_changeFlow { + margin-top: 24px; } .mx_AuthBody_spinner { 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 05cddf2c48..ffaad3cd7a 100644 --- a/res/css/views/auth/_InteractiveAuthEntryComponents.scss +++ b/res/css/views/auth/_InteractiveAuthEntryComponents.scss @@ -14,6 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_InteractiveAuthEntryComponents_emailWrapper { + padding-right: 100px; + position: relative; + margin-top: 32px; + margin-bottom: 32px; + + &::before, &::after { + position: absolute; + width: 116px; + height: 116px; + content: ""; + right: -10px; + } + + &::before { + background-color: rgba(244, 246, 250, 0.91); + border-radius: 50%; + top: -20px; + } + + &::after { + background-image: url('$(res)/img/element-icons/email-prompt.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + top: -25px; + } +} + .mx_InteractiveAuthEntryComponents_msisdnWrapper { text-align: center; } @@ -54,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/_ServerTypeSelector.scss b/res/css/views/auth/_ServerTypeSelector.scss deleted file mode 100644 index fbd3d2655d..0000000000 --- a/res/css/views/auth/_ServerTypeSelector.scss +++ /dev/null @@ -1,69 +0,0 @@ -/* -Copyright 2019 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. -*/ - -.mx_ServerTypeSelector { - display: flex; - margin-bottom: 28px; -} - -.mx_ServerTypeSelector_type { - margin: 0 5px; -} - -.mx_ServerTypeSelector_type:first-child { - margin-left: 0; -} - -.mx_ServerTypeSelector_type:last-child { - margin-right: 0; -} - -.mx_ServerTypeSelector_label { - text-align: center; - font-weight: 600; - color: $authpage-primary-color; - margin: 8px 0; -} - -.mx_ServerTypeSelector_type .mx_AccessibleButton { - padding: 10px; - border: 1px solid $input-border-color; - border-radius: 4px; -} - -.mx_ServerTypeSelector_type.mx_ServerTypeSelector_type_selected .mx_AccessibleButton { - border-color: $input-valid-border-color; -} - -.mx_ServerTypeSelector_logo { - display: flex; - justify-content: center; - height: 18px; - margin-bottom: 12px; - font-weight: 600; - color: $authpage-primary-color; -} - -.mx_ServerTypeSelector_logo > div { - display: flex; - width: 70%; - align-items: center; - justify-content: space-evenly; -} - -.mx_ServerTypeSelector_description { - font-size: $font-10px; -} 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/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index 1a1e14e7ac..cbddd97e18 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -41,7 +41,7 @@ limitations under the License. .mx_BaseAvatar_image { object-fit: cover; - border-radius: 40px; + border-radius: 125px; vertical-align: top; background-color: $avatar-bg-color; } diff --git a/src/dispatcher/payloads/AppTileActionPayload.ts b/res/css/views/avatars/_WidgetAvatar.scss similarity index 72% rename from src/dispatcher/payloads/AppTileActionPayload.ts rename to res/css/views/avatars/_WidgetAvatar.scss index 3cdb0f8c1f..8e5cfb54d8 100644 --- a/src/dispatcher/payloads/AppTileActionPayload.ts +++ b/res/css/views/avatars/_WidgetAvatar.scss @@ -14,10 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ActionPayload } from "../payloads"; -import { Action } from "../actions"; - -export interface AppTileActionPayload extends ActionPayload { - action: Action.AppTileDelete | Action.AppTileRevoke; - widgetId: string; +.mx_WidgetAvatar { + border-radius: 4px; } diff --git a/src/resizer/index.js b/res/css/views/context_menus/_CallContextMenu.scss similarity index 70% rename from src/resizer/index.js rename to res/css/views/context_menus/_CallContextMenu.scss index 1fd8f4da46..55b73b0344 100644 --- a/src/resizer/index.js +++ b/res/css/views/context_menus/_CallContextMenu.scss @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2020 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. @@ -15,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -export FixedDistributor from "./distributors/fixed"; -export CollapseDistributor from "./distributors/collapse"; -export Resizer from "./resizer"; +.mx_CallContextMenu_item { + width: 205px; + height: 40px; + padding-left: 16px; + line-height: 40px; + vertical-align: center; +} 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/context_menus/_WidgetContextMenu.scss b/res/css/views/context_menus/_WidgetContextMenu.scss deleted file mode 100644 index 60b7b93f99..0000000000 --- a/res/css/views/context_menus/_WidgetContextMenu.scss +++ /dev/null @@ -1,36 +0,0 @@ -/* -Copyright 2019 The Matrix.org Foundaction 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_WidgetContextMenu { - padding: 6px; - - .mx_WidgetContextMenu_option { - padding: 3px 6px 3px 6px; - cursor: pointer; - white-space: nowrap; - } - - .mx_WidgetContextMenu_separator { - margin-top: 0; - margin-bottom: 0; - border-bottom-style: none; - border-left-style: none; - border-right-style: none; - border-top-style: solid; - border-top-width: 1px; - border-color: $menu-border-color; - } -} 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/_FeedbackDialog.scss b/res/css/views/dialogs/_FeedbackDialog.scss new file mode 100644 index 0000000000..fd225dd882 --- /dev/null +++ b/res/css/views/dialogs/_FeedbackDialog.scss @@ -0,0 +1,121 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_FeedbackDialog { + hr { + margin: 24px 0; + border-color: $input-border-color; + } + + .mx_Dialog_content { + margin-bottom: 24px; + + > h2 { + margin-bottom: 32px; + } + } + + .mx_FeedbackDialog_section { + position: relative; + padding-left: 52px; + + > p { + color: $tertiary-fg-color; + } + + .mx_AccessibleButton_kind_link { + padding: 0; + font-size: inherit; + } + + a, .mx_AccessibleButton_kind_link { + color: $accent-color; + text-decoration: underline; + } + + &::before, &::after { + content: ""; + position: absolute; + width: 40px; + height: 40px; + left: 0; + top: 0; + } + + &::before { + background-color: $icon-button-color; + border-radius: 20px; + } + + &::after { + background: $avatar-initial-color; // TODO + mask-position: center; + mask-size: 24px; + mask-repeat: no-repeat; + } + } + + .mx_FeedbackDialog_reportBug { + &::after { + mask-image: url('$(res)/img/feather-customised/bug.svg'); + } + } + + .mx_FeedbackDialog_rateApp { + .mx_RadioButton { + display: inline-flex; + font-size: 20px; + transition: font-size 1s, border .5s; + border-radius: 50%; + border: 2px solid transparent; + margin-top: 12px; + margin-bottom: 24px; + vertical-align: top; + cursor: pointer; + + input[type="radio"] + div { + display: none; + } + + .mx_RadioButton_content { + background: $icon-button-color; + width: 40px; + height: 40px; + text-align: center; + line-height: 40px; + border-radius: 20px; + margin: 5px; + } + + .mx_RadioButton_spacer { + display: none; + } + + & + .mx_RadioButton { + margin-left: 16px; + } + } + + .mx_RadioButton_checked { + font-size: 24px; + border-color: $accent-color; + } + + &::after { + mask-image: url('$(res)/img/element-icons/feedback.svg'); + } + } +} 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/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index b9063f46b9..d8ff56663a 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -27,37 +27,29 @@ limitations under the License. padding-left: 8px; overflow-x: hidden; overflow-y: auto; + display: flex; + flex-wrap: wrap; .mx_InviteDialog_userTile { + margin: 6px 6px 0 0; display: inline-block; - float: left; - position: relative; - top: 7px; + min-width: max-content; // prevent manipulation by flexbox } - // Using a textarea for this element, to circumvent autofill - // Mostly copied from AddressPickerDialog - textarea, - textarea:focus { - height: 34px; - line-height: $font-34px; + // Mostly copied from AddressPickerDialog; overrides bunch of our default text input styles + > input[type="text"] { + margin: 6px 0 !important; + height: 24px; + line-height: $font-24px; font-size: $font-14px; padding-left: 12px; - margin: 0 !important; border: 0 !important; outline: 0 !important; resize: none; - overflow: hidden; box-sizing: border-box; - word-wrap: nowrap; - - // Roughly fill about 2/5ths of the available space. This is to try and 'fill' the - // remaining space after a bunch of pills, but is a bit hacky. Ideally we'd have - // support for "fill remaining width", but traditional tricks don't work with what - // we're pushing into this "field". Flexbox just makes things worse. The theory is - // that users won't need more than about 2/5ths of the input to find the person - // they're looking for. - width: 40%; + min-width: 40%; + flex: 1 !important; + color: $primary-fg-color !important; } } @@ -148,6 +140,10 @@ limitations under the License. } } + .mx_InviteDialog_roomTile_nameStack { + display: inline-block; + } + .mx_InviteDialog_roomTile_name { font-weight: 600; font-size: $font-14px; diff --git a/src/utils/NamingUtils.ts b/res/css/views/dialogs/_ModalWidgetDialog.scss similarity index 53% rename from src/utils/NamingUtils.ts rename to res/css/views/dialogs/_ModalWidgetDialog.scss index 44ccb9b030..aa2dd0d395 100644 --- a/src/utils/NamingUtils.ts +++ b/res/css/views/dialogs/_ModalWidgetDialog.scss @@ -14,16 +14,29 @@ See the License for the specific language governing permissions and limitations under the License. */ -import * as projectNameGenerator from "project-name-generator"; +.mx_ModalWidgetDialog { + .mx_ModalWidgetDialog_warning { + margin-bottom: 24px; -/** - * Generates a human readable identifier. This should not be used for anything - * which needs secure/cryptographic random: just a level uniquness that is offered - * by something like Date.now(). - * @returns {string} The randomly generated ID - */ -export function generateHumanReadableId(): string { - return projectNameGenerator({words: 3}).raw.map(w => { - return w[0].toUpperCase() + w.substring(1).toLowerCase(); - }).join(''); + > img { + vertical-align: middle; + margin-right: 8px; + } + } + + .mx_ModalWidgetDialog_buttons { + float: right; + margin-top: 24px; + + .mx_AccessibleButton + .mx_AccessibleButton { + margin-left: 8px; + } + } + + iframe { + width: 100%; + height: 450px; + border: 0; + border-radius: 8px; + } } diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss similarity index 73% rename from src/components/views/avatars/PulsedAvatar.tsx rename to res/css/views/dialogs/_RegistrationEmailPromptDialog.scss index b4e876b9f6..31fc6d7a04 100644 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ b/res/css/views/dialogs/_RegistrationEmailPromptDialog.scss @@ -14,15 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +.mx_RegistrationEmailPromptDialog { + width: 417px; -interface IProps { + .mx_Dialog_content { + margin-bottom: 24px; + color: $tertiary-fg-color; + } + + .mx_Dialog_primary { + width: 100%; + } } - -const PulsedAvatar: React.FC = (props) => { - return
- {props.children} -
; -}; - -export default PulsedAvatar; diff --git a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss index a1793cc75e..c97a3b69b7 100644 --- a/res/css/views/dialogs/_RoomSettingsDialogBridges.scss +++ b/res/css/views/dialogs/_RoomSettingsDialogBridges.scss @@ -89,24 +89,18 @@ limitations under the License. } } - .mx_showMore { - display: block; - text-align: left; - margin-top: 10px; - } - .metadata { color: $muted-fg-color; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; margin-bottom: 0; - } - - .metadata.visible { overflow-y: visible; text-overflow: ellipsis; white-space: normal; + padding: 0; + + > li { + padding: 0; + border: 0; + } } } } diff --git a/res/css/views/dialogs/_ServerPickerDialog.scss b/res/css/views/dialogs/_ServerPickerDialog.scss new file mode 100644 index 0000000000..b01b49d7af --- /dev/null +++ b/res/css/views/dialogs/_ServerPickerDialog.scss @@ -0,0 +1,78 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ServerPickerDialog { + width: 468px; + box-sizing: border-box; + + .mx_Dialog_content { + margin-bottom: 0; + + > p { + color: $secondary-fg-color; + font-size: $font-14px; + margin: 16px 0; + + &:first-of-type { + margin-bottom: 40px; + } + + &:last-of-type { + margin: 0 24px 24px; + } + } + + > h4 { + font-size: $font-15px; + font-weight: $font-semi-bold; + color: $secondary-fg-color; + margin-left: 8px; + } + + > a { + color: $accent-color; + margin-left: 8px; + } + } + + .mx_ServerPickerDialog_otherHomeserverRadio { + input[type="radio"] + div { + margin-top: auto; + margin-bottom: auto; + } + } + + .mx_ServerPickerDialog_otherHomeserver { + border-top: none; + border-left: none; + border-right: none; + border-radius: unset; + + > input { + padding-left: 0; + } + + > label { + margin-left: 0; + } + } + + .mx_AccessibleButton_kind_primary { + width: calc(100% - 64px); + margin: 0 8px; + padding: 15px 18px; + } +} 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/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss new file mode 100644 index 0000000000..176919b84c --- /dev/null +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -0,0 +1,75 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + + +.mx_WidgetCapabilitiesPromptDialog { + .text-muted { + font-size: $font-12px; + } + + .mx_Dialog_content { + margin-bottom: 16px; + } + + .mx_WidgetCapabilitiesPromptDialog_cap { + margin-top: 20px; + font-size: $font-15px; + line-height: $font-15px; + + .mx_WidgetCapabilitiesPromptDialog_byline { + color: $muted-fg-color; + margin-left: 26px; + font-size: $font-12px; + line-height: $font-12px; + } + } + + .mx_Dialog_buttons { + margin-top: 40px; // double normal + } + + .mx_SettingsFlag { + line-height: calc($font-14px + 7px + 7px); // 7px top & bottom padding + color: $muted-fg-color; + font-size: $font-12px; + + .mx_ToggleSwitch { + display: inline-block; + vertical-align: middle; + margin-right: 8px; + + // downsize the switch + ball + width: $font-32px; + height: $font-15px; + + + &.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { + left: calc(100% - $font-15px); + } + + .mx_ToggleSwitch_ball { + width: $font-15px; + height: $font-15px; + border-radius: $font-15px; + } + } + + .mx_SettingsFlag_label { + display: inline-block; + vertical-align: middle; + } + } +} diff --git a/res/css/views/elements/_AccessibleButton.scss b/res/css/views/elements/_AccessibleButton.scss index 96269cea43..9c26f8f120 100644 --- a/res/css/views/elements/_AccessibleButton.scss +++ b/res/css/views/elements/_AccessibleButton.scss @@ -25,7 +25,7 @@ limitations under the License. .mx_AccessibleButton_hasKind { padding: 7px 18px; text-align: center; - border-radius: 4px; + border-radius: 8px; display: inline-block; font-size: $font-14px; } 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/_IconButton.scss b/res/css/views/elements/_IconButton.scss deleted file mode 100644 index d8ebbeb65e..0000000000 --- a/res/css/views/elements/_IconButton.scss +++ /dev/null @@ -1,55 +0,0 @@ -/* -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. -*/ - -.mx_IconButton { - width: 32px; - height: 32px; - border-radius: 100%; - background-color: $accent-bg-color; - // don't shrink or grow if in a flex container - flex: 0 0 auto; - - &.mx_AccessibleButton_disabled { - background-color: none; - - &::before { - background-color: lightgrey; - } - } - - &:hover { - opacity: 90%; - } - - &::before { - content: ""; - display: block; - width: 100%; - height: 100%; - mask-repeat: no-repeat; - mask-position: center; - mask-size: 55%; - background-color: $accent-color; - } - - &.mx_IconButton_icon_check::before { - mask-image: url('$(res)/img/feather-customised/check.svg'); - } - - &.mx_IconButton_icon_edit::before { - mask-image: url('$(res)/img/feather-customised/edit.svg'); - } -} diff --git a/res/css/views/elements/_MiniAvatarUploader.scss b/res/css/views/elements/_MiniAvatarUploader.scss new file mode 100644 index 0000000000..698184a095 --- /dev/null +++ b/res/css/views/elements/_MiniAvatarUploader.scss @@ -0,0 +1,66 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_MiniAvatarUploader { + position: relative; + width: min-content; + + // this isn't a floating tooltip so override some things to not need to bother with z-index and floating + .mx_Tooltip { + display: inline-block; + position: absolute; + z-index: unset; + width: max-content; + left: 72px; + top: 0; + } + + &::before, &::after { + content: ''; + position: absolute; + + height: 26px; + width: 26px; + + right: -6px; + bottom: -6px; + } + + &::before { + background-color: $primary-bg-color; + border-radius: 50%; + z-index: 1; + } + + &::after { + background-color: $secondary-fg-color; + mask-position: center; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/camera.svg'); + mask-size: 16px; + z-index: 2; + } + + &.mx_MiniAvatarUploader_busy::after { + background: url("$(res)/img/spinner.gif") no-repeat center; + background-size: 80%; + mask: unset; + } +} + +.mx_MiniAvatarUploader_input { + display: none; +} diff --git a/res/css/views/elements/_SSOButtons.scss b/res/css/views/elements/_SSOButtons.scss new file mode 100644 index 0000000000..e02816780f --- /dev/null +++ b/res/css/views/elements/_SSOButtons.scss @@ -0,0 +1,74 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_SSOButtons { + display: flex; + flex-wrap: wrap; + justify-content: center; + + .mx_SSOButtons_row { + & + .mx_SSOButtons_row { + margin-top: 16px; + } + } + + .mx_SSOButton { + position: relative; + width: 100%; + 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; + position: absolute; + left: 8px; + top: 4px; + } + } + + .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 + height: 50px; // 48px + 1px border on all sides + min-width: 50px; // prevent crushing by the flexbox + padding: 12px; + + > img { + left: 12px; + top: 12px; + } + + & + .mx_SSOButton_mini { + margin-left: 16px; + } + } +} diff --git a/res/css/views/elements/_ServerPicker.scss b/res/css/views/elements/_ServerPicker.scss new file mode 100644 index 0000000000..188eb5d655 --- /dev/null +++ b/res/css/views/elements/_ServerPicker.scss @@ -0,0 +1,88 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ServerPicker { + margin-bottom: 14px; + border-bottom: 1px solid rgba(141, 151, 165, 0.2); + display: grid; + grid-template-columns: auto min-content; + grid-template-rows: auto auto auto; + font-size: $font-14px; + line-height: $font-20px; + + > h3 { + font-weight: $font-semi-bold; + margin: 0 0 20px; + grid-column: 1; + grid-row: 1; + } + + .mx_ServerPicker_help { + width: 20px; + height: 20px; + background-color: $icon-button-color; + border-radius: 10px; + grid-column: 2; + grid-row: 1; + margin-left: auto; + text-align: center; + color: #ffffff; + font-size: 16px; + position: relative; + + &::before { + content: ''; + width: 24px; + height: 24px; + position: absolute; + top: -2px; + left: -2px; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + mask-image: url('$(res)/img/element-icons/i.svg'); + background: #ffffff; + } + } + + .mx_ServerPicker_server { + color: $authpage-primary-color; + grid-column: 1; + grid-row: 2; + margin-bottom: 16px; + } + + .mx_ServerPicker_change { + padding: 0; + font-size: inherit; + grid-column: 2; + grid-row: 2; + } + + .mx_ServerPicker_desc { + margin-top: -12px; + color: $tertiary-fg-color; + grid-column: 1 / 2; + grid-row: 3; + margin-bottom: 16px; + } +} + +.mx_ServerPicker_helpDialog { + .mx_Dialog_content { + width: 456px; + } +} diff --git a/res/css/views/messages/_CreateEvent.scss b/res/css/views/messages/_CreateEvent.scss index d45645863f..cb2bf841dd 100644 --- a/res/css/views/messages/_CreateEvent.scss +++ b/res/css/views/messages/_CreateEvent.scss @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,25 +15,8 @@ limitations under the License. */ .mx_CreateEvent { - background-color: $info-plinth-bg-color; - padding-left: 20px; - padding-right: 20px; - padding-top: 10px; - padding-bottom: 10px; -} - -.mx_CreateEvent_image { - float: left; - margin-right: 20px; - width: 72px; - height: 34px; - - background-color: $primary-fg-color; - mask: url('$(res)/img/room-continuation.svg'); - mask-repeat: no-repeat; - mask-position: center; -} - -.mx_CreateEvent_header { - font-weight: bold; + &::before { + background-color: $composer-e2e-icon-color; + mask-image: url('$(res)/img/element-icons/chat-bubbles.svg'); + } } diff --git a/res/css/views/messages/_EventTileBubble.scss b/res/css/views/messages/_EventTileBubble.scss new file mode 100644 index 0000000000..e0f5d521cb --- /dev/null +++ b/res/css/views/messages/_EventTileBubble.scss @@ -0,0 +1,60 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_EventTileBubble { + background-color: $dark-panel-bg-color; + padding: 10px; + border-radius: 8px; + margin: 10px auto; + max-width: 75%; + box-sizing: border-box; + display: grid; + grid-template-columns: 24px minmax(0, 1fr) min-content; + + &::before, &::after { + position: relative; + grid-column: 1; + grid-row: 1 / 3; + width: 16px; + height: 16px; + content: ""; + top: 0; + bottom: 0; + left: 0; + right: 0; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + margin-top: 4px; + } + + .mx_EventTileBubble_title, .mx_EventTileBubble_subtitle { + overflow-wrap: break-word; + } + + .mx_EventTileBubble_title { + font-weight: 600; + font-size: $font-15px; + grid-column: 2; + grid-row: 1; + } + + .mx_EventTileBubble_subtitle { + font-size: $font-12px; + grid-column: 2; + grid-row: 2; + } +} diff --git a/res/css/views/messages/_MJitsiWidgetEvent.scss b/res/css/views/messages/_MJitsiWidgetEvent.scss index 3e51e89744..bea8651543 100644 --- a/res/css/views/messages/_MJitsiWidgetEvent.scss +++ b/res/css/views/messages/_MJitsiWidgetEvent.scss @@ -15,41 +15,8 @@ limitations under the License. */ .mx_MJitsiWidgetEvent { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) min-content; - &::before { - grid-column: 1; - grid-row: 1 / 3; - width: 16px; - height: 16px; - content: ""; - top: 0; - bottom: 0; - left: 0; - right: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; background-color: $composer-e2e-icon-color; // XXX: Variable abuse - margin-top: 4px; mask-image: url('$(res)/img/element-icons/call/video-call.svg'); } - - .mx_MJitsiWidgetEvent_title { - font-weight: 600; - font-size: $font-15px; - grid-column: 2; - grid-row: 1; - } - - .mx_MJitsiWidgetEvent_subtitle { - grid-column: 2; - grid-row: 2; - } - - .mx_MJitsiWidgetEvent_title, - .mx_MJitsiWidgetEvent_subtitle { - overflow-wrap: break-word; - } } diff --git a/res/css/views/messages/_MVideoBody.scss b/res/css/views/messages/_MVideoBody.scss index 3b05c53f34..ac3491bc8f 100644 --- a/res/css/views/messages/_MVideoBody.scss +++ b/res/css/views/messages/_MVideoBody.scss @@ -18,5 +18,6 @@ span.mx_MVideoBody { video.mx_MVideoBody { max-width: 100%; height: auto; + border-radius: 4px; } } diff --git a/res/css/views/messages/_RedactedBody.scss b/res/css/views/messages/_RedactedBody.scss index e4ab0c0835..600ac0c6b7 100644 --- a/res/css/views/messages/_RedactedBody.scss +++ b/res/css/views/messages/_RedactedBody.scss @@ -30,7 +30,7 @@ limitations under the License. mask-size: contain; content: ''; position: absolute; - top: 2px; + top: 1px; left: 0; } } diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index 076932ee97..66825030e0 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -35,13 +35,13 @@ limitations under the License. mask-size: auto 12px; visibility: hidden; background-color: $accent-color; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); } &.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle { mask-position: 0 bottom; margin-bottom: 7px; - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); + mask-image: url('$(res)/img/feather-customised/minimise.svg'); } &:hover .mx_ViewSourceEvent_toggle { diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index 09c78ae5b4..4faa4b594f 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -15,28 +15,6 @@ limitations under the License. */ .mx_cryptoEvent { - display: grid; - grid-template-columns: 24px minmax(0, 1fr) min-content; - - &.mx_cryptoEvent_icon::before, - &.mx_cryptoEvent_icon::after { - grid-column: 1; - grid-row: 1 / 3; - width: 16px; - height: 16px; - content: ""; - top: 0; - bottom: 0; - left: 0; - right: 0; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/e2e/normal.svg'); - background-color: $composer-e2e-icon-color; - margin-top: 4px; - } - // white infill for the transparency &.mx_cryptoEvent_icon::before { background-color: #ffffff; @@ -46,6 +24,11 @@ limitations under the License. mask-size: 90%; } + &.mx_cryptoEvent_icon::after { + mask-image: url('$(res)/img/e2e/normal.svg'); + background-color: $composer-e2e-icon-color; + } + &.mx_cryptoEvent_icon_verified::after { mask-image: url("$(res)/img/e2e/verified.svg"); background-color: $accent-color; @@ -56,25 +39,6 @@ limitations under the License. background-color: $notice-primary-color; } - .mx_cryptoEvent_title, .mx_cryptoEvent_subtitle, .mx_cryptoEvent_state { - overflow-wrap: break-word; - } - - .mx_cryptoEvent_title { - font-weight: 600; - font-size: $font-15px; - grid-column: 2; - grid-row: 1; - } - - .mx_cryptoEvent_subtitle { - grid-column: 2; - grid-row: 2; - } - - .mx_cryptoEvent_state, .mx_cryptoEvent_subtitle { - font-size: $font-12px; - } .mx_cryptoEvent_state, .mx_cryptoEvent_buttons { grid-column: 3; @@ -92,5 +56,7 @@ limitations under the License. margin: auto 0; text-align: center; color: $notice-secondary-color; + overflow-wrap: break-word; + font-size: $font-12px; } } diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss index 3ff3b52531..9a5a59bda8 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -128,6 +128,13 @@ limitations under the License. mask-size: 20px; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } + + &.mx_AccessibleButton_disabled { + padding-right: 12px; + &::after { + content: unset; + } + } } } diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 0031d3a64c..36882f4e8b 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -110,28 +110,107 @@ limitations under the License. .mx_RoomSummaryCard_appsGroup { .mx_RoomSummaryCard_Button { - padding-left: 12px; + // this button is special so we have to override some of the original styling + // as we will be applying it in its children + padding: 0; + height: auto; color: $tertiary-fg-color; - span { - color: $primary-fg-color; + .mx_RoomSummaryCard_icon_app { + padding: 10px 48px 10px 12px; // based on typical mx_RoomSummaryCard_Button padding + text-overflow: ellipsis; + overflow: hidden; + + .mx_BaseAvatar_image { + vertical-align: top; + margin-right: 12px; + } + + span { + color: $primary-fg-color; + } } - img { - vertical-align: top; - margin-right: 12px; - border-radius: 4px; + .mx_RoomSummaryCard_app_pinToggle, + .mx_RoomSummaryCard_app_options { + position: absolute; + top: 0; + height: 100%; // to give bigger interactive zone + width: 24px; + padding: 12px 4px; + box-sizing: border-box; + min-width: 24px; // prevent flexbox crushing + + &:hover { + &::after { + content: ''; + position: absolute; + height: 24px; + width: 24px; + top: 8px; // equal to padding-top of parent + left: 0; + border-radius: 12px; + background-color: rgba(141, 151, 165, 0.1); + } + } + + &::before { + content: ''; + position: absolute; + height: 16px; + width: 16px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: 16px; + background-color: $icon-button-color; + } + } + + .mx_RoomSummaryCard_app_pinToggle { + right: 24px; + + &::before { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + } + } + + .mx_RoomSummaryCard_app_options { + right: 48px; + display: none; + + &::before { + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + } + } + + &.mx_RoomSummaryCard_Button_pinned { + &::after { + opacity: 0.2; + } + + .mx_RoomSummaryCard_app_pinToggle::before { + background-color: $accent-color; + } + } + + &:hover { + .mx_RoomSummaryCard_icon_app { + padding-right: 72px; + } + + .mx_RoomSummaryCard_app_options { + display: unset; + } } &::before { content: unset; } - } - .mx_RoomSummaryCard_icon_app_pinned::after { - mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); - background-color: $accent-color; - transform: unset; + &::after { + top: 8px; // re-align based on the height change + pointer-events: none; // pass through to the real button + } } } diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index f20c9b7868..87420ae4e7 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -173,26 +173,12 @@ limitations under the License. margin: 6px 0; - .mx_IconButton, .mx_Spinner { - margin-left: 20px; - width: 16px; - height: 16px; - - &::before { - mask-size: 80%; - } - } - .mx_UserInfo_roleDescription { display: flex; justify-content: center; align-items: center; // try to make it the same height as the dropdown margin: 11px 0 12px 0; - - .mx_IconButton { - margin-left: 6px; - } } .mx_Field { diff --git a/res/css/views/right_panel/_WidgetCard.scss b/res/css/views/right_panel/_WidgetCard.scss index 315fd5213c..a90e744a5a 100644 --- a/res/css/views/right_panel/_WidgetCard.scss +++ b/res/css/views/right_panel/_WidgetCard.scss @@ -24,34 +24,35 @@ limitations under the License. border: 0; } - &.mx_WidgetCard_noEdit { - .mx_AccessibleButton_kind_secondary { - margin: 0 12px; + .mx_BaseCard_header { + display: inline-flex; - &:first-child { - // expand the Pin to room primary action - flex-grow: 1; - } + & > h2 { + margin-right: 0; + flex-grow: 1; } - } - .mx_WidgetCard_optionsButton { - position: relative; - height: 18px; - width: 26px; - - &::before { - content: ""; - position: absolute; - width: 20px; + .mx_WidgetCard_optionsButton { + position: relative; + margin-right: 44px; height: 20px; - top: 6px; - left: 20px; - mask-repeat: no-repeat; - mask-position: center; - mask-size: contain; - mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); - background-color: $secondary-fg-color; + width: 20px; + min-width: 20px; // prevent crushing by the flexbox + padding: 0; + + &::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 0; + left: 4px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + background-color: $secondary-fg-color; + } } } } diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 6e3ffbe5f0..492ed95973 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -24,76 +24,142 @@ $MiniAppTileHeight: 200px; flex-direction: column; overflow: hidden; + .mx_AppsContainer_resizerHandleContainer { + width: 100%; + height: 10px; + margin-top: -3px; // move it up so the interactions are slightly more comfortable + display: block; + position: relative; + } + .mx_AppsContainer_resizerHandle { cursor: ns-resize; - border-radius: 3px; - // Override styles from library - width: unset !important; - height: 4px !important; + // Override styles from library, making the whole area the target area + width: 100% !important; + height: 100% !important; // This is positioned directly below frame position: absolute; - bottom: -8px !important; // override from library + 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; + // We then render the pill handle in an ::after to keep it in the handle's + // area without being a massive line across the screen + &::after { + content: ''; + position: absolute; + border-radius: 3px; + + // The combination of these two should make the pill 4px high + top: 6px; + bottom: 0; + + // Together, these make the bar 64px wide + // These are also overridden from the library + left: calc(50% - 32px); + right: calc(50% - 32px); + } } &:hover { - .mx_AppsContainer_resizerHandle { + .mx_AppsContainer_resizerHandle::after { opacity: 0.8; background: $primary-fg-color; } + + .mx_ResizeHandle_horizontal::before { + position: absolute; + left: 3px; + top: 50%; + transform: translate(0, -50%); + + height: 64px; // to match width of the ones on roomlist + width: 4px; + border-radius: 4px; + + content: ''; + + background-color: $primary-fg-color; + opacity: 0.8; + } } } +.mx_AppsContainer_resizer { + margin-bottom: 8px; +} + .mx_AppsContainer { display: flex; flex-direction: row; align-items: stretch; justify-content: center; height: 100%; - margin-bottom: 8px; + width: 100%; + flex: 1; + min-height: 0; + + .mx_AppTile:first-of-type { + border-left-width: 8px; + border-radius: 10px 0 0 10px; + } + .mx_AppTile:last-of-type { + border-right-width: 8px; + border-radius: 0 10px 10px 0; + } + + .mx_ResizeHandle_horizontal { + position: relative; + + > div { + width: 0; + } + } } -.mx_AppsDrawer_minimised .mx_AppsContainer { - // override the re-resizable inline styles - height: inherit !important; - min-height: inherit !important; -} +// TODO this should be 300px but that's too large +$MinWidth: 240px; -.mx_AddWidget_button { - order: 2; - cursor: pointer; - padding: 0; - margin: -3px auto 5px 0; - color: $accent-color; - font-size: $font-12px; +.mx_AppsDrawer_2apps .mx_AppTile { + width: 50%; + + &:nth-child(3) { + flex-grow: 1; + width: 0 !important; + min-width: $MinWidth !important; + } +} +.mx_AppsDrawer_3apps .mx_AppTile { + width: 33%; + + &:nth-child(3) { + flex-grow: 1; + width: 0 !important; + min-width: $MinWidth !important; + } } .mx_AppTile { width: 50%; - border: 5px solid $widget-menu-bar-bg-color; - border-radius: 4px; + min-width: $MinWidth; + border: 8px solid $widget-menu-bar-bg-color; + border-left-width: 5px; + border-right-width: 5px; display: flex; flex-direction: column; - - & + .mx_AppTile { - margin-left: 5px; - } + box-sizing: border-box; + background-color: $widget-menu-bar-bg-color; } .mx_AppTileFullWidth { - width: 100%; + width: 100% !important; // to override the inline style set by the resizer margin: 0; padding: 0; border: 5px solid $widget-menu-bar-bg-color; border-radius: 8px; display: flex; flex-direction: column; + background-color: $widget-menu-bar-bg-color; } .mx_AppTile_mini { @@ -105,12 +171,6 @@ $MiniAppTileHeight: 200px; height: $MiniAppTileHeight; } -.mx_AppTile.mx_AppTile_minimised, -.mx_AppTileFullWidth.mx_AppTile_minimised, -.mx_AppTile_mini.mx_AppTile_minimised { - height: 14px; -} - .mx_AppTile .mx_AppTile_persistedWrapper, .mx_AppTileFullWidth .mx_AppTile_persistedWrapper, .mx_AppTile_mini .mx_AppTile_persistedWrapper { @@ -130,19 +190,20 @@ $MiniAppTileHeight: 200px; flex-direction: row; align-items: center; justify-content: space-between; - cursor: pointer; width: 100%; -} - -.mx_AppTileMenuBar_expanded { - padding-bottom: 5px; + padding-top: 2px; + padding-bottom: 8px; } .mx_AppTileMenuBarTitle { - display: flex; - flex-direction: row; - align-items: center; - pointer-events: none; + line-height: 20px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + .mx_WidgetAvatar { + margin-right: 12px; + } } .mx_AppTileMenuBarTitle > :last-child { @@ -166,37 +227,20 @@ $MiniAppTileHeight: 200px; margin: 0 3px; } -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_minimise { - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); - background-color: $accent-color; -} - -.mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_maximise { - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); - background-color: $accent-color; -} - .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_popout { mask-image: url('$(res)/img/feather-customised/widget/external-link.svg'); } .mx_AppTileMenuBar_iconButton.mx_AppTileMenuBar_iconButton_menu { - mask-image: url('$(res)/img/icon_context.svg'); -} - -.mx_AppTileMenuBarWidgetDelete { - filter: none; -} - -.mx_AppTileMenuBarWidget:hover { - border: 1px solid $primary-fg-color; - border-radius: 2px; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); } .mx_AppTileBody { height: 100%; width: 100%; overflow: hidden; + border-radius: 8px; + background-color: $widget-body-bg-color; } .mx_AppTileBody_mini { @@ -231,7 +275,6 @@ $MiniAppTileHeight: 200px; .mx_AppPermissionWarning { text-align: center; - background-color: $widget-menu-bar-bg-color; display: flex; height: 100%; flex-direction: column; @@ -296,6 +339,10 @@ $MiniAppTileHeight: 200px; font-weight: bold; position: relative; height: 100%; + + // match bg of border so that the cut corners have the right fill + background-color: $widget-body-bg-color !important; + border-radius: 8px; } .mx_AppLoading .mx_Spinner { @@ -323,10 +370,6 @@ $MiniAppTileHeight: 200px; display: none; } -.mx_AppsDrawer_minimised .mx_AppsContainer_resizerHandle { - display: none; -} - /* Avoid apptile iframes capturing mouse event focus when resizing */ .mx_AppsDrawer_resizing iframe { pointer-events: none; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 3b9a491db5..5841cf2853 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -25,17 +25,8 @@ $left-gutter: 64px; position: relative; } -.mx_EventTile_bubble { - background-color: $dark-panel-bg-color; - padding: 10px; - border-radius: 5px; - margin: 10px auto; - max-width: 75%; - box-sizing: border-box; -} - .mx_EventTile.mx_EventTile_info { - padding-top: 0px; + padding-top: 1px; } .mx_EventTile_avatar { @@ -46,7 +37,7 @@ $left-gutter: 64px; } .mx_EventTile.mx_EventTile_info .mx_EventTile_avatar { - top: $font-8px; + top: $font-6px; left: $left-gutter; } @@ -83,7 +74,6 @@ $left-gutter: 64px; margin-left: 5px; display: inline-block; vertical-align: top; - height: 16px; overflow: hidden; user-select: none; @@ -131,9 +121,10 @@ $left-gutter: 64px; grid-template-columns: 1fr 100px; .mx_EventTile_line { - margin-right: 0px; + margin-right: 0; grid-column: 1 / 3; - padding: 0; + // override default padding of mx_EventTile_line so that we can be centered + padding: 0 !important; } .mx_EventTile_msgOption { @@ -266,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; @@ -429,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, @@ -455,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) @@ -501,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 { @@ -510,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 @@ -521,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 958d718b11..792c2f1f58 100644
--- a/res/css/views/rooms/_IRCLayout.scss
+++ b/res/css/views/rooms/_IRCLayout.scss
@@ -186,6 +186,7 @@ $irc-line-height: $font-18px;
                 overflow: hidden;
                 text-overflow: ellipsis;
                 min-width: var(--name-width);
+                text-align: end;
             }
         }
     }
@@ -206,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 f00907aeef..075e9ff585 100644
--- a/res/css/views/rooms/_MemberList.scss
+++ b/res/css/views/rooms/_MemberList.scss
@@ -44,6 +44,17 @@ 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,
+.mx_GroupRoomList_query {
+    flex: 0 0 auto;
 }
 
 .mx_MemberList_chevron {
@@ -59,10 +70,8 @@ limitations under the License.
     flex: 1 1 0px;
 }
 
-.mx_MemberList_query,
-.mx_GroupMemberList_query,
-.mx_GroupRoomList_query {
-    flex: 1 1 0;
+.mx_MemberList_query {
+    height: 16px;
 
     // stricter rule to override the one in _common.scss
     &[type="text"] {
@@ -70,10 +79,6 @@ limitations under the License.
     }
 }
 
-.mx_MemberList_query {
-    height: 16px;
-}
-
 .mx_MemberList_wrapper {
     padding: 10px;
 }
@@ -113,10 +118,10 @@ limitations under the License.
     }
 }
 
-.mx_MemberList_inviteCommunity span {
-    background-image: url('$(res)/img/icon-invite-people.svg');
+.mx_MemberList_inviteCommunity span::before {
+    mask-image: url('$(res)/img/icon-invite-people.svg');
 }
 
-.mx_MemberList_addRoomToCommunity span {
-    background-image: url('$(res)/img/icons-room-add.svg');
+.mx_MemberList_addRoomToCommunity span::before {
+    mask-image: url('$(res)/img/icons-room-add.svg');
 }
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/_NewRoomIntro.scss b/res/css/views/rooms/_NewRoomIntro.scss
new file mode 100644
index 0000000000..4322ba341c
--- /dev/null
+++ b/res/css/views/rooms/_NewRoomIntro.scss
@@ -0,0 +1,67 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_NewRoomIntro {
+    margin: 40px 0 48px 64px;
+
+    .mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) {
+        &::before, &::after {
+            content: unset;
+        }
+    }
+
+    .mx_AccessibleButton_kind_link {
+        padding: 0;
+        font-size: inherit;
+    }
+
+    .mx_NewRoomIntro_buttons {
+        margin-top: 28px;
+
+        .mx_AccessibleButton {
+            line-height: $font-24px;
+
+            &::before {
+                content: '';
+                display: inline-block;
+                background-color: $button-fg-color;
+                mask-position: center;
+                mask-repeat: no-repeat;
+                mask-size: 20px;
+                width: 20px;
+                height: 20px;
+                margin-right: 5px;
+                vertical-align: text-bottom;
+            }
+        }
+
+        .mx_NewRoomIntro_inviteButton::before {
+            mask-image: url('$(res)/img/element-icons/room/invite.svg');
+        }
+    }
+
+    > h2 {
+        margin-top: 24px;
+        font-size: $font-24px;
+        font-weight: 600;
+    }
+
+    > p {
+        margin: 0;
+        font-size: $font-15px;
+        color: $secondary-fg-color;
+    }
+}
diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss
index d240877507..387d1588a3 100644
--- a/res/css/views/rooms/_RoomHeader.scss
+++ b/res/css/views/rooms/_RoomHeader.scss
@@ -241,10 +241,30 @@ limitations under the License.
     width: 26px;
 }
 
+.mx_RoomHeader_appsButton::before {
+    mask-image: url('$(res)/img/element-icons/room/apps.svg');
+}
+.mx_RoomHeader_appsButton_highlight::before {
+    background-color: $accent-color;
+}
+
 .mx_RoomHeader_searchButton::before {
     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 78e7307bc0..d49ed4b736 100644
--- a/res/css/views/rooms/_RoomList.scss
+++ b/res/css/views/rooms/_RoomList.scss
@@ -19,11 +19,17 @@ 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');
 }
+.mx_RoomList_iconDialpad::before {
+    mask-image: url('$(res)/img/element-icons/roomlist/dialpad.svg');
+}
 
 .mx_RoomList_explorePrompt {
     margin: 4px 12px 4px;
@@ -33,7 +39,6 @@ limitations under the License.
 
     div:first-child {
         font-weight: $font-semi-bold;
-        margin-bottom: 8px;
     }
 
     .mx_AccessibleButton {
@@ -41,6 +46,9 @@ limitations under the License.
         position: relative;
         padding: 0 0 0 24px;
         font-size: inherit;
+        margin-top: 8px;
+        display: block;
+        text-align: start;
 
         &::before {
             content: '';
@@ -53,6 +61,13 @@ limitations under the License.
             mask-position: center;
             mask-size: contain;
             mask-repeat: no-repeat;
+        }
+
+        &.mx_RoomList_explorePrompt_startChat::before {
+            mask-image: url('$(res)/img/element-icons/feedback.svg');
+        }
+
+        &.mx_RoomList_explorePrompt_explore::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 543940fb78..92a475694e 100644
--- a/res/css/views/rooms/_RoomSublist.scss
+++ b/res/css/views/rooms/_RoomSublist.scss
@@ -59,10 +59,6 @@ limitations under the License.
                 width: calc(100% - 22px);
             }
 
-            &.mx_RoomSublist_headerContainer_stickyBottom {
-                bottom: 0;
-            }
-
             // We don't have a top style because the top is dependent on the room list header's
             // height, and is therefore calculated in JS.
             // The class, mx_RoomSublist_headerContainer_stickyTop, is applied though.
@@ -201,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
@@ -211,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 {
@@ -387,3 +381,22 @@ limitations under the License.
 .mx_RoomSublist_addRoomTooltip {
     margin-top: -3px;
 }
+
+.mx_RoomSublist_skeletonUI {
+    position: relative;
+    margin-left: 4px;
+    height: 288px;
+
+    &::before {
+        background: $roomsublist-skeleton-ui-bg;
+
+        width: 100%;
+        height: 100%;
+
+        content: '';
+        position: absolute;
+        mask-repeat: repeat-y;
+        mask-size: auto 48px;
+        mask-image: url('$(res)/img/element-icons/roomlist/skeleton-ui.svg');
+    }
+}
diff --git a/res/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss
index d99276b70a..da86797f42 100644
--- a/res/css/views/rooms/_Stickers.scss
+++ b/res/css/views/rooms/_Stickers.scss
@@ -16,9 +16,13 @@
         border-bottom: none;
     }
 
+    .mx_AppTileMenuBar {
+        padding: 0;
+    }
+
     iframe {
         // Sticker picker depends on the fixed height previously used for all tiles
-        height: 273px;
+        height: 283px; // height of the popout minus the AppTile menu bar
     }
 }
 
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/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss
index 732cbedf02..4cbcb8e708 100644
--- a/res/css/views/settings/_ProfileSettings.scss
+++ b/res/css/views/settings/_ProfileSettings.scss
@@ -14,6 +14,12 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+.mx_ProfileSettings_controls_topic {
+    & > textarea {
+        resize: vertical;
+    }
+}
+
 .mx_ProfileSettings_profile {
     display: flex;
 }
diff --git a/res/css/views/auth/_ServerConfig.scss b/res/css/views/settings/_SpellCheckLanguages.scss
similarity index 57%
rename from res/css/views/auth/_ServerConfig.scss
rename to res/css/views/settings/_SpellCheckLanguages.scss
index a7e0057ab3..bb322c983f 100644
--- a/res/css/views/auth/_ServerConfig.scss
+++ b/res/css/views/settings/_SpellCheckLanguages.scss
@@ -1,6 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
-Copyright 2019 The Matrix.org Foundation C.I.C.
+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.
@@ -15,21 +14,22 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_ServerConfig_help:link {
-    opacity: 0.8;
+.mx_ExistingSpellCheckLanguage {
+    display: flex;
+    align-items: center;
+    margin-bottom: 5px;
 }
 
-.mx_ServerConfig_error {
-    display: block;
-    color: $warning-color;
+.mx_ExistingSpellCheckLanguage_language {
+    flex: 1;
+    margin-right: 10px;
 }
 
-.mx_ServerConfig_identityServer {
-    transform: scaleY(0);
-    transform-origin: top;
-    transition: transform 0.25s;
-
-    &.mx_ServerConfig_identityServer_shown {
-        transform: scaleY(1);
-    }
+.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/src/RoomListSorter.js b/res/css/views/toasts/_AnalyticsToast.scss
similarity index 57%
rename from src/RoomListSorter.js
rename to res/css/views/toasts/_AnalyticsToast.scss
index 0ff37a6af2..fdbe7f1c76 100644
--- a/src/RoomListSorter.js
+++ b/res/css/views/toasts/_AnalyticsToast.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,18 +14,14 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
+.mx_AnalyticsToast {
+    .mx_AccessibleButton_kind_danger {
+        background: none;
+        color: $accent-color;
+    }
 
-function tsOfNewestEvent(room) {
-    if (room.timeline.length) {
-        return room.timeline[room.timeline.length - 1].getTs();
-    } else {
-        return Number.MAX_SAFE_INTEGER;
+    .mx_AccessibleButton_kind_primary {
+        background: $accent-color;
+        color: #ffffff;
     }
 }
-
-export function mostRecentActivityFirst(roomList) {
-    return roomList.sort(function(a, b) {
-        return tsOfNewestEvent(b) - tsOfNewestEvent(a);
-    });
-}
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
index 759797ae7b..8262075559 100644
--- a/res/css/views/voip/_CallContainer.scss
+++ b/res/css/views/voip/_CallContainer.scss
@@ -18,10 +18,7 @@ limitations under the License.
     position: absolute;
     right: 20px;
     bottom: 72px;
-    border-radius: 8px;
-    overflow: hidden;
     z-index: 100;
-    box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
 
     // Disable pointer events for Jitsi widgets to function. Direct
     // calls have their own cursor and behaviour, but we need to make
@@ -33,11 +30,11 @@ limitations under the License.
         pointer-events: initial; // restore pointer events so the user can leave/interact
         cursor: pointer;
 
-        .mx_VideoView {
+        .mx_CallView_video {
             width: 350px;
         }
 
-        .mx_VideoView_localVideoFeed {
+        .mx_VideoFeed_local {
             border-radius: 8px;
             overflow: hidden;
         }
@@ -49,8 +46,10 @@ limitations under the License.
 
     .mx_IncomingCallBox {
         min-width: 250px;
-        background-color: $primary-bg-color;
+        background-color: $voipcall-plinth-color;
         padding: 8px;
+        box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
+        border-radius: 8px;
 
         pointer-events: initial; // restore pointer events so the user can accept/decline
         cursor: pointer;
diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss
index f6f3d40308..7eb329594a 100644
--- a/res/css/views/voip/_CallView.scss
+++ b/res/css/views/voip/_CallView.scss
@@ -15,80 +15,357 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_CallView_voice {
-    background-color: $accent-color;
-    color: $accent-fg-color;
-    cursor: pointer;
-    padding: 6px;
-    font-weight: bold;
-
+.mx_CallView {
     border-radius: 8px;
-    min-width: 200px;
+    background-color: $voipcall-plinth-color;
+    padding-left: 8px;
+    padding-right: 8px;
+    // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place
+    pointer-events: initial;
+}
 
-    display: flex;
-    align-items: center;
+.mx_CallView_large {
+    padding-bottom: 10px;
+    margin: 5px 5px 5px 18px;
 
-    img {
-        margin: 4px;
-        margin-right: 10px;
-    }
-
-    > div {
-        display: flex;
-        flex-direction: column;
-        // Hacky vertical align
-        padding-top: 3px;
-    }
-
-    > div > p,
-    > div > h1 {
-        padding: 0;
-        margin: 0;
-        font-size: $font-13px;
-        line-height: $font-15px;
-    }
-
-    > div > p {
-        font-weight: bold;
-    }
-
-    > * {
-        flex-grow: 0;
-        flex-shrink: 0;
+    .mx_CallView_voice {
+        height: 360px;
     }
 }
 
-.mx_CallView_hangup {
+.mx_CallView_pip {
+    width: 320px;
+    padding-bottom: 8px;
+    margin-top: 10px;
+    box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08);
+    border-radius: 8px;
+
+    .mx_CallView_voice {
+        height: 180px;
+    }
+
+    .mx_CallView_callControls {
+        bottom: 0px;
+    }
+
+    .mx_CallView_callControls_button {
+        &::before {
+            width: 36px;
+            height: 36px;
+        }
+    }
+
+    .mx_CallView_voice_holdText {
+        padding-top: 10px;
+        padding-bottom: 25px;
+    }
+}
+
+.mx_CallView_voice {
+    position: relative;
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    background-color: $inverted-bg-color;
+    border-radius: 8px;
+}
+
+.mx_CallView_voice_avatarsContainer {
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: center;
+    div {
+        margin-left: 12px;
+        margin-right: 12px;
+    }
+}
+
+.mx_CallView_voice_hold {
+    // This masks the avatar image so when it's blurred, the edge is still crisp
+    .mx_CallView_voice_avatarContainer {
+        border-radius: 2000px;
+        overflow: hidden;
+        position: relative;
+    }
+}
+
+.mx_CallView_voice_holdText {
+    height: 20px;
+    padding-top: 20px;
+    padding-bottom: 15px;
+    color: $accent-fg-color;
+    .mx_AccessibleButton_hasKind {
+        padding: 0px;
+        font-weight: bold;
+    }
+}
+
+.mx_CallView_video {
+    width: 100%;
+    position: relative;
+    z-index: 30;
+    border-radius: 8px;
+    overflow: hidden;
+}
+
+.mx_CallView_video_hold {
+    overflow: hidden;
+
+    // we keep these around in the DOM: it saved wiring them up again when the call
+    // is resumed and keeps the container the right size
+    .mx_VideoFeed {
+        visibility: hidden;
+    }
+}
+
+.mx_CallView_video_holdBackground {
     position: absolute;
+    width: 100%;
+    height: 100%;
+    left: 0;
+    right: 0;
+    background-repeat: no-repeat;
+    background-size: cover;
+    background-position: center;
+    filter: blur(20px);
+    &::after {
+        content: '';
+        display: block;
+        position: absolute;
+        width: 100%;
+        height: 100%;
+        left: 0;
+        right: 0;
+        background-color: rgba(0, 0, 0, 0.6);
+    }
+}
 
-    right: 8px;
-    bottom: 10px;
+.mx_CallView_video_holdContent {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    transform: translate(-50%, -50%);
+    font-weight: bold;
+    color: $accent-fg-color;
+    text-align: center;
 
-    height: 35px;
-    width: 35px;
+    &::before {
+        display: block;
+        margin-left: auto;
+        margin-right: auto;
+        content: '';
+        width: 40px;
+        height: 40px;
+        background-image: url('$(res)/img/voip/paused.svg');
+        background-position: center;
+        background-size: cover;
+    }
+    .mx_CallView_pip &::before {
+        width: 30px;
+        height: 30px;
+    }
+    .mx_AccessibleButton_hasKind {
+        padding: 0px;
+    }
+}
 
-    border-radius: 35px;
+.mx_CallView_header {
+    height: 44px;
+    display: flex;
+    flex-direction: row;
+    align-items: center;
+    justify-content: left;
+}
 
-    background-color: $notice-primary-color;
+.mx_CallView_header_callType {
+    font-size: 1.2rem;
+    font-weight: bold;
+    vertical-align: middle;
+}
 
-    z-index: 101;
+.mx_CallView_header_secondaryCallInfo {
+    &::before {
+        content: '·';
+        margin-left: 6px;
+        margin-right: 6px;
+    }
+}
 
+.mx_CallView_header_controls {
+    margin-left: auto;
+}
+
+.mx_CallView_header_button {
+    display: inline-block;
+    vertical-align: middle;
     cursor: pointer;
 
     &::before {
         content: '';
-        position: absolute;
-
+        display: inline-block;
         height: 20px;
         width: 20px;
-
-        top: 6.5px;
-        left: 7.5px;
-
-        mask: url('$(res)/img/hangup.svg');
+        vertical-align: middle;
+        background-color: $secondary-fg-color;
+        mask-repeat: no-repeat;
         mask-size: contain;
-        background-size: contain;
-
-        background-color: $primary-fg-color;
+        mask-position: center;
     }
 }
+
+.mx_CallView_header_button_fullscreen {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/fullscreen.svg');
+    }
+}
+
+.mx_CallView_header_button_expand {
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/expand.svg');
+    }
+}
+
+.mx_CallView_header_callInfo {
+    margin-left: 12px;
+    margin-right: 16px;
+}
+
+.mx_CallView_header_roomName {
+    font-weight: bold;
+    font-size: 12px;
+    line-height: initial;
+    height: 15px;
+}
+
+.mx_CallView_secondaryCall_roomName {
+    margin-left: 4px;
+}
+
+.mx_CallView_header_callTypeSmall {
+    font-size: 12px;
+    color: $secondary-fg-color;
+    line-height: initial;
+    height: 15px;
+    overflow: hidden;
+    white-space: nowrap;
+    text-overflow: ellipsis;
+    max-width: 240px;
+}
+
+.mx_CallView_header_phoneIcon {
+    display: inline-block;
+    margin-right: 6px;
+    height: 16px;
+    width: 16px;
+    vertical-align: middle;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        vertical-align: top;
+
+        height: 16px;
+        width: 16px;
+        background-color: $warning-color;
+        mask-repeat: no-repeat;
+        mask-size: contain;
+        mask-position: center;
+        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+    }
+}
+
+.mx_CallView_callControls {
+    position: absolute;
+    display: flex;
+    justify-content: center;
+    bottom: 5px;
+    width: 100%;
+    opacity: 1;
+    transition: opacity 0.5s;
+}
+
+.mx_CallView_callControls_hidden {
+    opacity: 0.001; // opacity 0 can cause a re-layout
+    pointer-events: none;
+}
+
+.mx_CallView_callControls_button {
+    cursor: pointer;
+    margin-left: 8px;
+    margin-right: 8px;
+
+
+    &::before {
+        content: '';
+        display: inline-block;
+
+        height: 48px;
+        width: 48px;
+
+        background-repeat: no-repeat;
+        background-size: contain;
+        background-position: center;
+    }
+}
+
+.mx_CallView_callControls_dialpad {
+    margin-right: auto;
+    &::before {
+        background-image: url('$(res)/img/voip/dialpad.svg');
+    }
+}
+
+.mx_CallView_callControls_button_dialpad_hidden {
+    margin-right: auto;
+    cursor: initial;
+}
+
+.mx_CallView_callControls_button_micOn {
+    &::before {
+        background-image: url('$(res)/img/voip/mic-on.svg');
+    }
+}
+
+.mx_CallView_callControls_button_micOff {
+    &::before {
+        background-image: url('$(res)/img/voip/mic-off.svg');
+    }
+}
+
+.mx_CallView_callControls_button_vidOn {
+    &::before {
+        background-image: url('$(res)/img/voip/vid-on.svg');
+    }
+}
+
+.mx_CallView_callControls_button_vidOff {
+    &::before {
+        background-image: url('$(res)/img/voip/vid-off.svg');
+    }
+}
+
+.mx_CallView_callControls_button_hangup {
+    &::before {
+        background-image: url('$(res)/img/voip/hangup.svg');
+    }
+}
+
+.mx_CallView_callControls_button_more {
+    margin-left: auto;
+    &::before {
+        background-image: url('$(res)/img/voip/more.svg');
+    }
+}
+
+.mx_CallView_callControls_button_more_hidden {
+    margin-left: auto;
+    cursor: initial;
+}
+
+.mx_CallView_callControls_button_invisible {
+    visibility: hidden;
+    pointer-events: none;
+    position: absolute;
+}
diff --git a/res/css/views/voip/_DialPad.scss b/res/css/views/voip/_DialPad.scss
new file mode 100644
index 0000000000..0c7bff0ce8
--- /dev/null
+++ b/res/css/views/voip/_DialPad.scss
@@ -0,0 +1,62 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_DialPad {
+    display: grid;
+    grid-template-columns: repeat(3, 1fr);
+    gap: 16px;
+}
+
+.mx_DialPad_button {
+    width: 40px;
+    height: 40px;
+    background-color: $theme-button-bg-color;
+    border-radius: 40px;
+    font-size: 18px;
+    font-weight: 600;
+    text-align: center;
+    vertical-align: middle;
+    line-height: 40px;
+}
+
+.mx_DialPad_deleteButton, .mx_DialPad_dialButton {
+    &::before {
+        content: '';
+        display: inline-block;
+        height: 40px;
+        width: 40px;
+        vertical-align: middle;
+        mask-repeat: no-repeat;
+        mask-size: 20px;
+        mask-position: center;
+        background-color: $primary-bg-color;
+    }
+}
+
+.mx_DialPad_deleteButton {
+    background-color: $notice-primary-color;
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/delete.svg');
+        mask-position: 9px; // delete icon is right-heavy so have to be slightly to the left to look centered
+    }
+}
+
+.mx_DialPad_dialButton {
+    background-color: $accent-color;
+    &::before {
+        mask-image: url('$(res)/img/element-icons/call/voice-call.svg');
+    }
+}
diff --git a/res/css/views/voip/_DialPadContextMenu.scss b/res/css/views/voip/_DialPadContextMenu.scss
new file mode 100644
index 0000000000..520f51cf93
--- /dev/null
+++ b/res/css/views/voip/_DialPadContextMenu.scss
@@ -0,0 +1,47 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_DialPadContextMenu_header {
+    margin-top: 12px;
+    margin-left: 12px;
+    margin-right: 12px;
+}
+
+.mx_DialPadContextMenu_title {
+    color: $muted-fg-color;
+    font-size: 12px;
+    font-weight: 600;
+}
+
+.mx_DialPadContextMenu_dialled {
+    height: 1em;
+    font-size: 18px;
+    font-weight: 600;
+}
+
+.mx_DialPadContextMenu_dialPad {
+    margin: 16px;
+}
+
+.mx_DialPadContextMenu_horizSep {
+    position: relative;
+    &::before {
+        content: '';
+        position: absolute;
+        width: 100%;
+        border-bottom: 1px solid $input-darker-bg-color;
+    }
+}
diff --git a/res/css/views/voip/_DialPadModal.scss b/res/css/views/voip/_DialPadModal.scss
new file mode 100644
index 0000000000..f9d7673a38
--- /dev/null
+++ b/res/css/views/voip/_DialPadModal.scss
@@ -0,0 +1,74 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_Dialog_dialPadWrapper .mx_Dialog {
+    padding: 0px;
+}
+
+.mx_DialPadModal {
+    width: 192px;
+    height: 368px;
+}
+
+.mx_DialPadModal_header {
+    margin-top: 12px;
+    margin-left: 12px;
+    margin-right: 12px;
+}
+
+.mx_DialPadModal_title {
+    color: $muted-fg-color;
+    font-size: 12px;
+    font-weight: 600;
+}
+
+.mx_DialPadModal_cancel {
+    float: right;
+    mask: url('$(res)/img/feather-customised/cancel.svg');
+    mask-repeat: no-repeat;
+    mask-position: center;
+    mask-size: cover;
+    width: 14px;
+    height: 14px;
+    background-color: $dialog-close-fg-color;
+    cursor: pointer;
+}
+
+.mx_DialPadModal_field {
+    border: none;
+    margin: 0px;
+}
+
+.mx_DialPadModal_field input {
+    font-size: 18px;
+    font-weight: 600;
+}
+
+.mx_DialPadModal_dialPad {
+    margin-left: 16px;
+    margin-right: 16px;
+    margin-top: 16px;
+}
+
+.mx_DialPadModal_horizSep {
+    position: relative;
+    &::before {
+        content: '';
+        position: absolute;
+        width: 100%;
+        border-bottom: 1px solid $input-darker-bg-color;
+    }
+}
diff --git a/res/css/views/voip/_VideoView.scss b/res/css/views/voip/_VideoFeed.scss
similarity index 63%
rename from res/css/views/voip/_VideoView.scss
rename to res/css/views/voip/_VideoFeed.scss
index feb60f4763..3e473a80b2 100644
--- a/res/css/views/voip/_VideoView.scss
+++ b/res/css/views/voip/_VideoFeed.scss
@@ -1,5 +1,5 @@
 /*
-Copyright 2015, 2016 OpenMarket Ltd
+Copyright 2015, 2016, 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
 you may not use this file except in compliance with the License.
@@ -14,36 +14,23 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-.mx_VideoView {
-    width: 100%;
-    position: relative;
-    z-index: 30;
-}
-
-.mx_VideoView video {
-    width: 100%;
-}
-
-.mx_VideoView_remoteVideoFeed {
+.mx_VideoFeed_remote {
     width: 100%;
+    max-height: 100%;
     background-color: #000;
     z-index: 50;
 }
 
-.mx_VideoView_localVideoFeed {
+.mx_VideoFeed_local {
     width: 25%;
     height: 25%;
     position: absolute;
-    left: 10px;
-    bottom: 10px;
+    right: 10px;
+    top: 10px;
     z-index: 100;
+    border-radius: 4px;
 }
 
-.mx_VideoView_localVideoFeed video {
-    width: auto;
-    height: 100%;
-}
-
-.mx_VideoView_localVideoFeed.mx_VideoView_localVideoFeed_flipped video {
+.mx_VideoFeed_mirror {
     transform: scale(-1, 1);
 }
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/call/delete.svg b/res/img/element-icons/call/delete.svg
new file mode 100644
index 0000000000..133bdad4ca
--- /dev/null
+++ b/res/img/element-icons/call/delete.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/call/expand.svg b/res/img/element-icons/call/expand.svg
new file mode 100644
index 0000000000..91ef4d8a76
--- /dev/null
+++ b/res/img/element-icons/call/expand.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/call/video-muted.svg b/res/img/element-icons/call/video-muted.svg
deleted file mode 100644
index d2aea71d11..0000000000
--- a/res/img/element-icons/call/video-muted.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/res/img/element-icons/call/voice-muted.svg b/res/img/element-icons/call/voice-muted.svg
deleted file mode 100644
index 32abafb04a..0000000000
--- a/res/img/element-icons/call/voice-muted.svg
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
-
-
diff --git a/res/img/element-icons/call/voice-unmuted.svg b/res/img/element-icons/call/voice-unmuted.svg
deleted file mode 100644
index e664080217..0000000000
--- a/res/img/element-icons/call/voice-unmuted.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
diff --git a/res/img/element-icons/camera.svg b/res/img/element-icons/camera.svg
new file mode 100644
index 0000000000..92d1f91dec
--- /dev/null
+++ b/res/img/element-icons/camera.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/chat-bubbles.svg b/res/img/element-icons/chat-bubbles.svg
new file mode 100644
index 0000000000..ac9db61f29
--- /dev/null
+++ b/res/img/element-icons/chat-bubbles.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/email-prompt.svg b/res/img/element-icons/email-prompt.svg
new file mode 100644
index 0000000000..19b8f82449
--- /dev/null
+++ b/res/img/element-icons/email-prompt.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
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/feedback.svg b/res/img/element-icons/feedback.svg
new file mode 100644
index 0000000000..3ee20d18d9
--- /dev/null
+++ b/res/img/element-icons/feedback.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/element-icons/i.svg b/res/img/element-icons/i.svg
new file mode 100644
index 0000000000..6674f1ed8d
--- /dev/null
+++ b/res/img/element-icons/i.svg
@@ -0,0 +1,3 @@
+
+
+
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/apps.svg b/res/img/element-icons/room/apps.svg
new file mode 100644
index 0000000000..c90704752c
--- /dev/null
+++ b/res/img/element-icons/room/apps.svg
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/res/img/element-icons/room/default_app.svg b/res/img/element-icons/room/default_app.svg
index 08734170df..baf9bc37fa 100644
--- a/res/img/element-icons/room/default_app.svg
+++ b/res/img/element-icons/room/default_app.svg
@@ -1,11 +1,21 @@
-
-
-
-
-
-
-
-
-
-
+
+    
+        
+        
+        
+        
+        
+        
+        
+        
+    
+    
+        
+            
+            
+        
+        
+            
+        
+    
 
diff --git a/res/img/element-icons/room/default_cal.svg b/res/img/element-icons/room/default_cal.svg
index 5bced115cf..fc440b4553 100644
--- a/res/img/element-icons/room/default_cal.svg
+++ b/res/img/element-icons/room/default_cal.svg
@@ -1,6 +1,6 @@
-
-
-
-
-
+
+    
+    
+    
+    
 
diff --git a/res/img/element-icons/room/default_clock.svg b/res/img/element-icons/room/default_clock.svg
index cc21716d15..c7f453aadd 100644
--- a/res/img/element-icons/room/default_clock.svg
+++ b/res/img/element-icons/room/default_clock.svg
@@ -1,5 +1,5 @@
-
-
-
-
+
+    
+    
+    
 
diff --git a/res/img/element-icons/room/default_doc.svg b/res/img/element-icons/room/default_doc.svg
index 93e7507be3..aff393ffd5 100644
--- a/res/img/element-icons/room/default_doc.svg
+++ b/res/img/element-icons/room/default_doc.svg
@@ -1,4 +1,4 @@
 
-    
-    
+    
+    
 
diff --git a/res/img/element-icons/room/default_video.svg b/res/img/element-icons/room/default_video.svg
new file mode 100644
index 0000000000..022f1f43b1
--- /dev/null
+++ b/res/img/element-icons/room/default_video.svg
@@ -0,0 +1,5 @@
+
+    
+    
+    
+
diff --git a/res/img/element-icons/room/in-call.svg b/res/img/element-icons/room/in-call.svg
deleted file mode 100644
index 0e574faa84..0000000000
--- a/res/img/element-icons/room/in-call.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/res/img/element-icons/room/integrations.svg b/res/img/element-icons/room/integrations.svg
deleted file mode 100644
index 3a39506411..0000000000
--- a/res/img/element-icons/room/integrations.svg
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
-
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/dialpad.svg b/res/img/element-icons/roomlist/dialpad.svg
new file mode 100644
index 0000000000..b51d4a4dc9
--- /dev/null
+++ b/res/img/element-icons/roomlist/dialpad.svg
@@ -0,0 +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/roomlist/skeleton-ui.svg b/res/img/element-icons/roomlist/skeleton-ui.svg
new file mode 100644
index 0000000000..e95692536c
--- /dev/null
+++ b/res/img/element-icons/roomlist/skeleton-ui.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
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/element-icons/warning-badge.svg b/res/img/element-icons/warning-badge.svg
new file mode 100644
index 0000000000..ac5991f221
--- /dev/null
+++ b/res/img/element-icons/warning-badge.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/res/img/feather-customised/bug.svg b/res/img/feather-customised/bug.svg
new file mode 100644
index 0000000000..babc4fed0e
--- /dev/null
+++ b/res/img/feather-customised/bug.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/hangup.svg b/res/img/hangup.svg
deleted file mode 100644
index be038d2b30..0000000000
--- a/res/img/hangup.svg
+++ /dev/null
@@ -1,15 +0,0 @@
-
-
-    
-    Fill 72 + Path 98
-    Created with Sketch.
-    
-    
-        
-            
-                
-                
-            
-        
-    
-
\ No newline at end of file
diff --git a/res/img/icon_context.svg b/res/img/icon_context.svg
deleted file mode 100644
index 600c5bbd1d..0000000000
--- a/res/img/icon_context.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
diff --git a/res/img/room-continuation.svg b/res/img/room-continuation.svg
deleted file mode 100644
index dc7e15462a..0000000000
--- a/res/img/room-continuation.svg
+++ /dev/null
@@ -1,6 +0,0 @@
-
-
-
-
-
-
diff --git a/res/img/voip/dialpad.svg b/res/img/voip/dialpad.svg
new file mode 100644
index 0000000000..79c9ba1612
--- /dev/null
+++ b/res/img/voip/dialpad.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/hangup.svg b/res/img/voip/hangup.svg
new file mode 100644
index 0000000000..dfb20bd519
--- /dev/null
+++ b/res/img/voip/hangup.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/mic-off.svg b/res/img/voip/mic-off.svg
new file mode 100644
index 0000000000..6409f1fd07
--- /dev/null
+++ b/res/img/voip/mic-off.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/mic-on.svg b/res/img/voip/mic-on.svg
new file mode 100644
index 0000000000..3493b3c581
--- /dev/null
+++ b/res/img/voip/mic-on.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/more.svg b/res/img/voip/more.svg
new file mode 100644
index 0000000000..7990f6bcff
--- /dev/null
+++ b/res/img/voip/more.svg
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/paused.svg b/res/img/voip/paused.svg
new file mode 100644
index 0000000000..a967bf8ddf
--- /dev/null
+++ b/res/img/voip/paused.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/res/img/voip/vid-off.svg b/res/img/voip/vid-off.svg
new file mode 100644
index 0000000000..199d97ab97
--- /dev/null
+++ b/res/img/voip/vid-off.svg
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/voip/vid-on.svg b/res/img/voip/vid-on.svg
new file mode 100644
index 0000000000..d8146d01d3
--- /dev/null
+++ b/res/img/voip/vid-on.svg
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index 6e0c9acdfe..0de5e69782 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -108,6 +108,9 @@ $eventtile-meta-color: $roomtopic-color;
 $header-divider-color: $header-panel-text-primary-color;
 $composer-e2e-icon-color: $header-panel-text-primary-color;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #21262c;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
@@ -117,8 +120,10 @@ $roomlist-filter-active-bg-color: $bg-color;
 $roomlist-bg-color: rgba(33, 38, 44, 0.90);
 $roomlist-header-color: $tertiary-fg-color;
 $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;
@@ -131,6 +136,7 @@ $notice-secondary-color: $roomlist-header-color;
 $panel-divider-color: transparent;
 
 $widget-menu-bar-bg-color: $header-panel-bg-color;
+$widget-body-bg-color: rgba(141, 151, 165, 0.2);
 
 // event tile lifecycle
 $event-sending-color: $text-secondary-color;
@@ -212,7 +218,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-color;
@@ -253,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 {
@@ -272,6 +284,10 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
             background-color: #080808;
         }
     }
+
+    blockquote {
+        color: #919191;
+    }
 }
 
 // diff highlight colors
diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index efde7b3747..8c5f20178b 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -105,6 +105,9 @@ $eventtile-meta-color: $roomtopic-color;
 $header-divider-color: $header-panel-text-primary-color;
 $composer-e2e-icon-color: $header-panel-text-primary-color;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #f2f5f8;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
@@ -114,8 +117,10 @@ $roomlist-filter-active-bg-color: $roomlist-button-bg-color;
 $roomlist-bg-color: $header-panel-bg-color;
 
 $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;
@@ -126,6 +131,7 @@ $roomtile-selected-bg-color: #1A1D23;
 $panel-divider-color: $header-panel-border-color;
 
 $widget-menu-bar-bg-color: $header-panel-bg-color;
+$widget-body-bg-color: #1A1D23;
 
 // event tile lifecycle
 $event-sending-color: $text-secondary-color;
@@ -203,7 +209,7 @@ $composer-shadow-color: tranparent;
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-color;
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index f77226cbca..3ba10a68ea 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -172,6 +172,9 @@ $eventtile-meta-color: $roomtopic-color;
 $composer-e2e-icon-color: #91a1c0;
 $header-divider-color: #91a1c0;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #f2f5f8;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
@@ -181,8 +184,10 @@ $roomlist-filter-active-bg-color: $roomlist-button-bg-color;
 $roomlist-bg-color: $header-panel-bg-color;
 $roomlist-header-color: $primary-fg-color;
 $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;
@@ -208,6 +213,7 @@ $panel-divider-color: #dee1f3;
 // ********************
 
 $widget-menu-bar-bg-color: $secondary-accent-color;
+$widget-body-bg-color: #fff;
 
 // ********************
 
@@ -232,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
@@ -326,7 +333,7 @@ $composer-shadow-color: tranparent;
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-color;
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index d137373bd5..76bf2ddc21 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -166,6 +166,9 @@ $eventtile-meta-color: $roomtopic-color;
 $composer-e2e-icon-color: #91A1C0;
 $header-divider-color: #91A1C0;
 
+// this probably shouldn't have it's own colour
+$voipcall-plinth-color: #f2f5f8;
+
 // ********************
 
 $theme-button-bg-color: #e3e8f0;
@@ -175,8 +178,10 @@ $roomlist-filter-active-bg-color: #ffffff;
 $roomlist-bg-color: rgba(245, 245, 245, 0.90);
 $roomlist-header-color: $tertiary-fg-color;
 $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;
@@ -208,6 +213,7 @@ $pinned-color: $notice-secondary-color;
 // ********************
 
 $widget-menu-bar-bg-color: $secondary-accent-color;
+$widget-body-bg-color: #FFF;
 
 // ********************
 
@@ -232,6 +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
@@ -330,7 +338,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.04);
     /* align images in buttons (eg spinners) */
     vertical-align: middle;
     border: 0px;
-    border-radius: 4px;
+    border-radius: 8px;
     font-family: $font-family;
     font-size: $font-14px;
     color: $button-fg-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/scripts/ci/Dockerfile b/scripts/ci/Dockerfile
index c153d11cc7..3fdd0d7bf6 100644
--- a/scripts/ci/Dockerfile
+++ b/scripts/ci/Dockerfile
@@ -1,8 +1,7 @@
 # Update on docker hub with the following commands in the directory of this file:
-# docker build -t matrixdotorg/riotweb-ci-e2etests-env:latest .
-# docker log
-# docker push matrixdotorg/riotweb-ci-e2etests-env:latest
-FROM node:10
+# docker build -t vectorim/element-web-ci-e2etests-env:latest .
+# docker push vectorim/element-web-ci-e2etests-env:latest
+FROM node:14-buster
 RUN apt-get update
 RUN apt-get -y install build-essential python3-dev libffi-dev python-pip python-setuptools sqlite3 libssl-dev python-virtualenv libjpeg-dev libxslt1-dev uuid-runtime
 # dependencies for chrome (installed by puppeteer)
diff --git a/scripts/ci/riot-unit-tests.sh b/scripts/ci/app-tests.sh
similarity index 56%
rename from scripts/ci/riot-unit-tests.sh
rename to scripts/ci/app-tests.sh
index 337c0fe6c3..97e54dce66 100755
--- a/scripts/ci/riot-unit-tests.sh
+++ b/scripts/ci/app-tests.sh
@@ -2,11 +2,11 @@
 #
 # script which is run by the CI build (after `yarn test`).
 #
-# clones riot-web develop and runs the tests against our version of react-sdk.
+# clones element-web develop and runs the tests against our version of react-sdk.
 
 set -ev
 
-scripts/ci/layered-riot-web.sh
-cd ../riot-web
+scripts/ci/layered.sh
+cd element-web
 yarn build:genfiles # so the tests can run. Faster version of `build`
 yarn test
diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh
index 7a62c03b12..edb8870d8e 100755
--- a/scripts/ci/end-to-end-tests.sh
+++ b/scripts/ci/end-to-end-tests.sh
@@ -2,7 +2,7 @@
 #
 # script which is run by the CI build (after `yarn test`).
 #
-# clones riot-web develop and runs the tests against our version of react-sdk.
+# clones element-web develop and runs the tests against our version of react-sdk.
 
 set -ev
 
@@ -14,20 +14,20 @@ handle_error() {
 trap 'handle_error' ERR
 
 echo "--- Building Element"
-scripts/ci/layered-riot-web.sh
-cd ../riot-web
-riot_web_dir=`pwd`
+scripts/ci/layered.sh
+cd element-web
+element_web_dir=`pwd`
 CI_PACKAGE=true yarn build
-cd ../matrix-react-sdk
+cd ..
 # run end to end tests
 pushd test/end-to-end-tests
-ln -s $riot_web_dir riot/riot-web
+ln -s $element_web_dir element/element-web
 # PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true ./install.sh
 # CHROME_PATH=$(which google-chrome-stable) ./run.sh
 echo "--- Install synapse & other dependencies"
 ./install.sh
-# install static webserver to server symlinked local copy of riot
-./riot/install-webserver.sh
+# install static webserver to server symlinked local copy of element
+./element/install-webserver.sh
 rm -r logs || true
 mkdir logs
 echo "+++ Running end-to-end tests"
diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh
index 14b5fc5393..bbda74ef9d 100755
--- a/scripts/ci/install-deps.sh
+++ b/scripts/ci/install-deps.sh
@@ -7,7 +7,6 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk
 pushd matrix-js-sdk
 yarn link
 yarn install $@
-yarn build
 popd
 
 yarn link matrix-js-sdk
diff --git a/scripts/ci/layered-riot-web.sh b/scripts/ci/layered-riot-web.sh
deleted file mode 100755
index f58794b451..0000000000
--- a/scripts/ci/layered-riot-web.sh
+++ /dev/null
@@ -1,31 +0,0 @@
-#!/bin/bash
-
-# Creates an environment similar to one that riot-web would expect for
-# development. This means going one directory up (and assuming we're in
-# a directory like /workdir/matrix-react-sdk) and putting riot-web and
-# the js-sdk there.
-
-cd ../  # Assume we're at something like /workdir/matrix-react-sdk
-
-# Set up the js-sdk first
-matrix-react-sdk/scripts/fetchdep.sh matrix-org matrix-js-sdk
-pushd matrix-js-sdk
-yarn link
-yarn install
-popd
-
-# Now set up the react-sdk
-pushd matrix-react-sdk
-yarn link matrix-js-sdk
-yarn link
-yarn install
-popd
-
-# Finally, set up riot-web
-matrix-react-sdk/scripts/fetchdep.sh vector-im riot-web
-pushd riot-web
-yarn link matrix-js-sdk
-yarn link matrix-react-sdk
-yarn install
-yarn build:res
-popd
diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh
new file mode 100755
index 0000000000..039f90c7df
--- /dev/null
+++ b/scripts/ci/layered.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+# Creates a layered environment with the full repo for the app and SDKs cloned
+# and linked.
+
+# Note that this style is different from the recommended developer setup: this
+# file nests js-sdk and element-web inside react-sdk, while the local
+# development setup places them all at the same level. We are nesting them here
+# because some CI systems do not allow moving to a directory above the checkout
+# for the primary repo (react-sdk in this case).
+
+# Set up the js-sdk first
+scripts/fetchdep.sh matrix-org matrix-js-sdk
+pushd matrix-js-sdk
+yarn link
+yarn install
+popd
+
+# Now set up the react-sdk
+yarn link matrix-js-sdk
+yarn link
+yarn install
+yarn reskindex
+
+# Finally, set up element-web
+scripts/fetchdep.sh vector-im element-web
+pushd element-web
+yarn link matrix-js-sdk
+yarn link matrix-react-sdk
+yarn install
+yarn build:res
+popd
diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh
index 0142305797..850eef25ec 100755
--- a/scripts/fetchdep.sh
+++ b/scripts/fetchdep.sh
@@ -34,7 +34,7 @@ elif [[ "${#BUILDKITE_BRANCH_ARRAY[@]}" == "2" ]]; then
 fi
 # Try the target branch of the push or PR.
 clone $deforg $defrepo $BUILDKITE_PULL_REQUEST_BASE_BRANCH
-# Try the current branch from Jenkins.
-clone $deforg $defrepo `"echo $GIT_BRANCH" | sed -e 's/^origin\///'`
+# Try HEAD which is the branch name in Netlify (not BRANCH which is pull/xxxx/head for PR builds)
+clone $deforg $defrepo $HEAD
 # Use the default branch as the last resort.
 clone $deforg $defrepo $defbranch
diff --git a/scripts/reskindex.js b/scripts/reskindex.js
index 9fb0e1a7c0..12310b77c1 100755
--- a/scripts/reskindex.js
+++ b/scripts/reskindex.js
@@ -1,29 +1,30 @@
 #!/usr/bin/env node
-var fs = require('fs');
-var path = require('path');
-var glob = require('glob');
-var args = require('minimist')(process.argv);
-var chokidar = require('chokidar');
+const fs = require('fs');
+const path = require('path');
+const glob = require('glob');
+const util = require('util');
+const args = require('minimist')(process.argv);
+const chokidar = require('chokidar');
 
-var componentIndex = path.join('src', 'component-index.js');
-var componentIndexTmp = componentIndex+".tmp";
-var componentsDir = path.join('src', 'components');
-var componentJsGlob = '**/*.js';
-var componentTsGlob = '**/*.tsx';
-var prevFiles = [];
+const componentIndex = path.join('src', 'component-index.js');
+const componentIndexTmp = componentIndex+".tmp";
+const componentsDir = path.join('src', 'components');
+const componentJsGlob = '**/*.js';
+const componentTsGlob = '**/*.tsx';
+let prevFiles = [];
 
-function reskindex() {
-    var jsFiles = glob.sync(componentJsGlob, {cwd: componentsDir}).sort();
-    var tsFiles = glob.sync(componentTsGlob, {cwd: componentsDir}).sort();
-    var files = [...tsFiles, ...jsFiles];
+async function reskindex() {
+    const jsFiles = glob.sync(componentJsGlob, {cwd: componentsDir}).sort();
+    const tsFiles = glob.sync(componentTsGlob, {cwd: componentsDir}).sort();
+    const files = [...tsFiles, ...jsFiles];
     if (!filesHaveChanged(files, prevFiles)) {
         return;
     }
     prevFiles = files;
 
-    var header = args.h || args.header;
+    const header = args.h || args.header;
 
-    var strm = fs.createWriteStream(componentIndexTmp);
+    const strm = fs.createWriteStream(componentIndexTmp);
 
     if (header) {
        strm.write(fs.readFileSync(header));
@@ -38,11 +39,11 @@ function reskindex() {
     strm.write(" */\n\n");
     strm.write("let components = {};\n");
 
-    for (var i = 0; i < files.length; ++i) {
-        var file = files[i].replace('.js', '').replace('.tsx', '');
+    for (let i = 0; i < files.length; ++i) {
+        const file = files[i].replace('.js', '').replace('.tsx', '');
 
-        var moduleName = (file.replace(/\//g, '.'));
-        var importName = moduleName.replace(/\./g, "$");
+        const moduleName = (file.replace(/\//g, '.'));
+        const importName = moduleName.replace(/\./g, "$");
 
         strm.write("import " + importName + " from './components/" + file + "';\n");
         strm.write(importName + " && (components['"+moduleName+"'] = " + importName + ");");
@@ -51,9 +52,10 @@ function reskindex() {
     }
 
     strm.write("export {components};\n");
-    strm.end();
+    // Ensure the file has been fully written to disk before proceeding
+    await util.promisify(strm.end);
     fs.rename(componentIndexTmp, componentIndex, function(err) {
-        if(err) {
+        if (err) {
             console.error("Error moving new index into place: " + err);
         } else {
             console.log('Reskindex: completed');
@@ -67,7 +69,7 @@ function filesHaveChanged(files, prevFiles) {
         return true;
     }
     // Check for name changes
-    for (var i = 0; i < files.length; i++) {
+    for (let i = 0; i < files.length; i++) {
         if (prevFiles[i] !== files[i]) {
             return true;
         }
@@ -81,7 +83,7 @@ if (!args.w) {
     return;
 }
 
-var watchDebouncer = null;
+let watchDebouncer = null;
 chokidar.watch(path.join(componentsDir, componentJsGlob)).on('all', (event, path) => {
     if (path === componentIndex) return;
     if (watchDebouncer) clearTimeout(watchDebouncer);
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index ed28a5c479..4aa6df5488 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -33,7 +33,12 @@ import RightPanelStore from "../stores/RightPanelStore";
 import WidgetStore from "../stores/WidgetStore";
 import CallHandler from "../CallHandler";
 import {Analytics} from "../Analytics";
+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 {
@@ -57,14 +62,26 @@ declare global {
         mxNotifier: typeof Notifier;
         mxRightPanelStore: RightPanelStore;
         mxWidgetStore: WidgetStore;
+        mxWidgetLayoutStore: WidgetLayoutStore;
         mxCallHandler: CallHandler;
         mxAnalytics: Analytics;
+        mxCountlyAnalytics: typeof CountlyAnalytics;
         mxUserActivity: UserActivity;
+        mxModalWidgetStore: ModalWidgetStore;
+        mxVoipUserMapper: VoipUserMapper;
+        mxSpaceStore: SpaceStoreClass;
     }
 
     interface Document {
         // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess
         hasStorageAccess?: () => Promise;
+
+        // Safari & IE11 only have this prefixed: we used prefixed versions
+        // previously so let's continue to support them for now
+        webkitExitFullscreen(): Promise;
+        msExitFullscreen(): Promise;
+        readonly webkitFullscreenElement: Element | null;
+        readonly msFullscreenElement: Element | null;
     }
 
     interface Navigator {
@@ -94,4 +111,20 @@ declare global {
     interface HTMLAudioElement {
         type?: string;
     }
+
+    interface Element {
+        // Safari & IE11 only have this prefixed: we used prefixed versions
+        // previously so let's continue to support them for now
+        webkitRequestFullScreen(options?: FullscreenOptions): Promise;
+        msRequestFullscreen(options?: FullscreenOptions): Promise;
+    }
+
+    interface Error {
+        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/fileName
+        fileName?: string;
+        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/lineNumber
+        lineNumber?: number;
+        // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/columnNumber
+        columnNumber?: number;
+    }
 }
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 4d06c5df73..9d7077097b 100644
--- a/src/BasePlatform.ts
+++ b/src/BasePlatform.ts
@@ -18,15 +18,19 @@ limitations under the License.
 */
 
 import {MatrixClient} from "matrix-js-sdk/src/client";
+import {encodeUnpaddedBase64} from "matrix-js-sdk/src/crypto/olmlib";
 import dis from './dispatcher/dispatcher';
 import BaseEventIndexManager from './indexing/BaseEventIndexManager';
 import {ActionPayload} from "./dispatcher/payloads";
 import {CheckUpdatesPayload} from "./dispatcher/payloads/CheckUpdatesPayload";
 import {Action} from "./dispatcher/actions";
 import {hideToast as hideUpdateToast} from "./toasts/UpdateToast";
+import {MatrixClientPeg} from "./MatrixClientPeg";
+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",
@@ -53,7 +57,7 @@ export default abstract class BasePlatform {
         this.startUpdateCheck = this.startUpdateCheck.bind(this);
     }
 
-    abstract async getConfig(): Promise<{}>;
+    abstract getConfig(): Promise<{}>;
 
     abstract getDefaultDeviceDisplayName(): string;
 
@@ -105,6 +109,9 @@ export default abstract class BasePlatform {
      * @param newVersion the version string to check
      */
     protected shouldShowUpdate(newVersion: string): boolean {
+        // If the user registered on this client in the last 24 hours then do not show them the update toast
+        if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false;
+
         try {
             const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY));
             return newVersion !== version || Date.now() > deferUntil;
@@ -124,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.
@@ -233,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 || "";
@@ -244,15 +269,19 @@ export default abstract class BasePlatform {
      * @param {MatrixClient} mxClient the matrix client using which we should start the flow
      * @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO.
      * @param {string} fragmentAfterLogin the hash to pass to the app during sso callback.
+     * @param {string} idpId The ID of the Identity Provider being targeted, optional.
      */
-    startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string) {
+    startSingleSignOn(mxClient: MatrixClient, loginType: "sso" | "cas", fragmentAfterLogin: string, idpId?: string) {
         // persist hs url and is url for when the user is returned to the app with the login token
         localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl());
         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); // redirect to SSO
+        window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO
     }
 
     onKeyDown(ev: KeyboardEvent): boolean {
@@ -268,7 +297,40 @@ export default abstract class BasePlatform {
      *     pickle key has been stored.
      */
     async getPickleKey(userId: string, deviceId: string): Promise {
-        return null;
+        if (!window.crypto || !window.crypto.subtle) {
+            return null;
+        }
+        let data;
+        try {
+            data = await idbLoad("pickleKey", [userId, deviceId]);
+        } catch (e) {}
+        if (!data) {
+            return null;
+        }
+        if (!data.encrypted || !data.iv || !data.cryptoKey) {
+            console.error("Badly formatted pickle key");
+            return null;
+        }
+
+        const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
+        for (let i = 0; i < userId.length; i++) {
+            additionalData[i] = userId.charCodeAt(i);
+        }
+        additionalData[userId.length] = 124; // "|"
+        for (let i = 0; i < deviceId.length; i++) {
+            additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
+        }
+
+        try {
+            const key = await crypto.subtle.decrypt(
+                {name: "AES-GCM", iv: data.iv, additionalData}, data.cryptoKey,
+                data.encrypted,
+            );
+            return encodeUnpaddedBase64(key);
+        } catch (e) {
+            console.error("Error decrypting pickle key");
+            return null;
+        }
     }
 
     /**
@@ -279,7 +341,37 @@ export default abstract class BasePlatform {
      *     support storing pickle keys.
      */
     async createPickleKey(userId: string, deviceId: string): Promise {
-        return null;
+        if (!window.crypto || !window.crypto.subtle) {
+            return null;
+        }
+        const crypto = window.crypto;
+        const randomArray = new Uint8Array(32);
+        crypto.getRandomValues(randomArray);
+        const cryptoKey = await crypto.subtle.generateKey(
+            {name: "AES-GCM", length: 256}, false, ["encrypt", "decrypt"],
+        );
+        const iv = new Uint8Array(32);
+        crypto.getRandomValues(iv);
+
+        const additionalData = new Uint8Array(userId.length + deviceId.length + 1);
+        for (let i = 0; i < userId.length; i++) {
+            additionalData[i] = userId.charCodeAt(i);
+        }
+        additionalData[userId.length] = 124; // "|"
+        for (let i = 0; i < deviceId.length; i++) {
+            additionalData[userId.length + 1 + i] = deviceId.charCodeAt(i);
+        }
+
+        const encrypted = await crypto.subtle.encrypt(
+            {name: "AES-GCM", iv, additionalData}, cryptoKey, randomArray,
+        );
+
+        try {
+            await idbSave("pickleKey", [userId, deviceId], {encrypted, iv, cryptoKey});
+        } catch (e) {
+            return null;
+        }
+        return encodeUnpaddedBase64(randomArray);
     }
 
     /**
@@ -288,5 +380,8 @@ export default abstract class BasePlatform {
      * @param {string} userId the device ID that the pickle key is for.
      */
     async destroyPickleKey(userId: string, deviceId: string): Promise {
+        try {
+            await idbDelete("pickleKey", [userId, deviceId]);
+        } catch (e) {}
     }
 }
diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx
index fc24feb90f..8621f441de 100644
--- a/src/CallHandler.tsx
+++ b/src/CallHandler.tsx
@@ -59,13 +59,11 @@ import {MatrixClientPeg} from './MatrixClientPeg';
 import PlatformPeg from './PlatformPeg';
 import Modal from './Modal';
 import { _t } from './languageHandler';
-// @ts-ignore - XXX: tsc doesn't like this: our js-sdk imports are complex so this isn't surprising
-import Matrix from 'matrix-js-sdk';
+import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call';
 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";
@@ -77,7 +75,27 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
 import WidgetStore from "./stores/WidgetStore";
 import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
 import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
-import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty } from "matrix-js-sdk/lib/webrtc/call";
+import { MatrixCall, CallErrorCode, CallState, CallEvent, CallParty, CallType } from "matrix-js-sdk/src/webrtc/call";
+import Analytics from './Analytics';
+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 VoipUserMapper from './VoipUserMapper';
+import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
+import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
+
+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',
@@ -86,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
@@ -96,9 +137,32 @@ export enum PlaceCallType {
     ScreenSharing = 'screensharing',
 }
 
+function getRemoteAudioElement(): HTMLAudioElement {
+    // this needs to be somewhere at the top of the DOM which
+    // always exists to avoid audio interruptions.
+    // Might as well just use DOM.
+    const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement;
+    if (!remoteAudioElement) {
+        console.error(
+            "Failed to find remoteAudio element - cannot play audio!" +
+            "You need to add an  to the DOM.",
+        );
+        return null;
+    }
+    return remoteAudioElement;
+}
+
 export default class CallHandler {
-    private calls = new Map();
+    private calls = new Map(); // roomId -> call
     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) {
@@ -108,8 +172,17 @@ export default class CallHandler {
         return window.mxCallHandler;
     }
 
-    constructor() {
-        dis.register(this.onAction);
+    /*
+     * 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): string {
+        if (!call) return null;
+        return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId;
+    }
+
+    start() {
+        this.dispatcherRef = dis.register(this.onAction);
         // add empty handlers for media actions, otherwise the media keys
         // end up causing the audio elements with our ring/ringback etc
         // audio clips in to play.
@@ -121,6 +194,100 @@ export default class CallHandler {
             navigator.mediaSession.setActionHandler('previoustrack', function() {});
             navigator.mediaSession.setActionHandler('nexttrack', function() {});
         }
+
+        if (SettingsStore.getValue(UIFeature.Voip)) {
+            MatrixClientPeg.get().on('Call.incoming', this.onCallIncoming);
+        }
+
+        this.checkProtocols(CHECK_PROTOCOLS_ATTEMPTS);
+    }
+
+    stop() {
+        const cli = MatrixClientPeg.get();
+        if (cli) {
+            cli.removeListener('Call.incoming', this.onCallIncoming);
+        }
+        if (this.dispatcherRef !== null) {
+            dis.unregister(this.dispatcherRef);
+            this.dispatcherRef = null;
+        }
+    }
+
+    private async checkProtocols(maxTries) {
+        try {
+            const protocols = await MatrixClientPeg.get().getThirdpartyProtocols();
+
+            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 protocol support and no retries remain: assuming no support", e);
+            } else {
+                console.log("Failed to check for protocol support: will retry", e);
+                this.pstnSupportCheckTimer = setTimeout(() => {
+                    this.checkProtocols(maxTries - 1);
+                }, 10000);
+            }
+        }
+    }
+
+    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
+        // we get an immediate hangup, we don't get a stuck call)
+        dis.dispatch({
+            action: 'incoming_call',
+            call: call,
+        }, true);
     }
 
     getCallForRoom(roomId: string): MatrixCall {
@@ -136,6 +303,28 @@ export default class CallHandler {
         return null;
     }
 
+    getAllActiveCalls() {
+        const activeCalls = [];
+
+        for (const call of this.calls.values()) {
+            if (call.state !== CallState.Ended && call.state !== CallState.Ringing) {
+                activeCalls.push(call);
+            }
+        }
+        return activeCalls;
+    }
+
+    getAllActiveCallsNotInRoom(notInThisRoomId) {
+        const callsNotInThatRoom = [];
+
+        for (const [roomId, call] of this.calls.entries()) {
+            if (roomId !== notInThisRoomId && call.state !== CallState.Ended) {
+                callsNotInThatRoom.push(call);
+            }
+        }
+        return callsNotInThatRoom;
+    }
+
     play(audioId: AudioID) {
         // TODO: Attach an invisible element for this instead
         // which listens?
@@ -183,15 +372,26 @@ export default class CallHandler {
         // We don't allow placing more than one call per room, but that doesn't mean there
         // can't be more than one, eg. in a glare situation. This checks that the given call
         // is the call we consider 'the' call for its room.
-        const callForThisRoom = this.getCallForRoom(call.roomId);
+        const mappedRoomId = CallHandler.roomIdForCall(call);
+
+        const callForThisRoom = this.getCallForRoom(mappedRoomId);
         return callForThisRoom && call.callId === callForThisRoom.callId;
     }
 
     private setCallListeners(call: MatrixCall) {
-        call.on(CallEvent.Error, (err) => {
+        const mappedRoomId = CallHandler.roomIdForCall(call);
+
+        call.on(CallEvent.Error, (err: CallError) => {
             if (!this.matchesCallForThisRoom(call)) return;
 
+            Analytics.trackEvent('voip', 'callError', 'error', err.toString());
             console.error("Call error:", err);
+
+            if (err.code === CallErrorCode.NoUserMedia) {
+                this.showMediaCaptureError(call);
+                return;
+            }
+
             if (
                 MatrixClientPeg.get().getTurnServers().length === 0 &&
                 SettingsStore.getValue("fallbackICEServerAllowed") === null
@@ -208,7 +408,9 @@ export default class CallHandler {
         call.on(CallEvent.Hangup, () => {
             if (!this.matchesCallForThisRoom(call)) return;
 
-            this.removeCallForRoom(call.roomId);
+            Analytics.trackEvent('voip', 'callHangup');
+
+            this.removeCallForRoom(mappedRoomId);
         });
         call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
             if (!this.matchesCallForThisRoom(call)) return;
@@ -232,19 +434,48 @@ export default class CallHandler {
                     this.play(AudioID.Ringback);
                     break;
                 case CallState.Ended:
-                    this.removeCallForRoom(call.roomId);
+                {
+                    Analytics.trackEvent('voip', 'callEnded', 'hangupReason', call.hangupReason);
+                    this.removeCallForRoom(mappedRoomId);
                     if (oldState === CallState.InviteSent && (
                         call.hangupParty === CallParty.Remote ||
                         (call.hangupParty === CallParty.Local && call.hangupReason === CallErrorCode.InviteTimeout)
                     )) {
                         this.play(AudioID.Busy);
-                        Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
-                            title: _t('Call Timeout'),
-                            description: _t('The remote side failed to pick up') + '.',
+                        let title;
+                        let description;
+                        if (call.hangupReason === CallErrorCode.UserHangup) {
+                            title = _t("Call Declined");
+                            description = _t("The other party declined the call.");
+                        } else if (call.hangupReason === CallErrorCode.InviteTimeout) {
+                            title = _t("Call Failed");
+                            // XXX: full stop appended as some relic here, but these
+                            // strings need proper input from design anyway, so let's
+                            // not change this string until we have a proper one.
+                            description = _t('The remote side failed to pick up') + '.';
+                        } else {
+                            title = _t("Call Failed");
+                            description = _t("The call could not be established");
+                        }
+
+                        Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
+                            title, description,
                         });
-                    } else {
+                    } else if (
+                        call.hangupReason === CallErrorCode.AnsweredElsewhere && oldState === CallState.Connecting
+                    ) {
+                        Modal.createTrackedDialog('Call Handler', 'Call Failed', ErrorDialog, {
+                            title: _t("Answered Elsewhere"),
+                            description: _t("The call was answered on another device."),
+                        });
+                    } else if (oldState !== CallState.Fledgling && oldState !== CallState.Ringing) {
+                        // don't play the end-call sound for calls that never got off the ground
                         this.play(AudioID.CallEnd);
                     }
+
+                    this.logCallStats(call, mappedRoomId);
+                    break;
+                }
             }
         });
         call.on(CallEvent.Replaced, (newCall: MatrixCall) => {
@@ -258,20 +489,70 @@ export default class CallHandler {
                 this.pause(AudioID.Ringback);
             }
 
-            this.calls.set(newCall.roomId, newCall);
+            this.calls.set(mappedRoomId, newCall);
             this.setCallListeners(newCall);
             this.setCallState(newCall, newCall.state);
         });
     }
 
+    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);
+    }
+
     private setCallState(call: MatrixCall, status: CallState) {
+        const mappedRoomId = CallHandler.roomIdForCall(call);
+
         console.log(
-            `Call state in ${call.roomId} changed to ${status}`,
+            `Call state in ${mappedRoomId} changed to ${status}`,
         );
 
         dis.dispatch({
             action: 'call_state',
-            room_id: call.roomId,
+            room_id: mappedRoomId,
             state: status,
         });
     }
@@ -309,14 +590,56 @@ export default class CallHandler {
         }, null, true);
     }
 
+    private showMediaCaptureError(call: MatrixCall) {
+        let title;
+        let description;
 
-    private placeCall(
+        if (call.type === CallType.Voice) {
+            title = _t("Unable to access microphone");
+            description = 
+ {_t( + "Call failed because microphone could not be accessed. " + + "Check that a microphone is plugged in and set up correctly.", + )} +
; + } else if (call.type === CallType.Video) { + title = _t("Unable to access webcam / microphone"); + description =
+ {_t("Call failed because webcam or microphone could not be accessed. Check that:")} +
    +
  • {_t("A microphone and webcam are plugged in and set up correctly")}
  • +
  • {_t("Permission is granted to use the webcam")}
  • +
  • {_t("No other application is using the webcam")}
  • +
+
; + } + + Modal.createTrackedDialog('Media capture failed', '', ErrorDialog, { + title, description, + }, null, true); + } + + private async placeCall( roomId: string, type: PlaceCallType, localElement: HTMLVideoElement, remoteElement: HTMLVideoElement, ) { - const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), roomId); + Analytics.trackEvent('voip', 'placeCall', 'type', type); + CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); + + 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); + this.setCallListeners(call); + this.setCallAudioElement(call); + + this.setActiveCallRoomId(roomId); + if (type === PlaceCallType.Voice) { call.placeVoiceCall(); } else if (type === 'video') { @@ -335,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); } } @@ -345,12 +676,10 @@ export default class CallHandler { switch (payload.action) { case 'place_call': { - if (this.getAnyActiveCall()) { - Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { - title: _t('Existing Call'), - description: _t('You are already in a call.'), - }); - return; // don't allow >1 call to be placed. + // We might be using managed hybrid widgets + if (isManagedHybridWidgetEnabled()) { + addManagedHybridWidget(payload.room_id); + return; } // if the runtime env doesn't do VoIP, whine. @@ -362,9 +691,26 @@ export default class CallHandler { return; } + // don't allow > 2 calls to be placed. + if (this.getAllActiveCalls().length > 1) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Too Many Calls'), + description: _t("You've reached the maximum number of simultaneous calls."), + }); + return; + } + 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; } @@ -375,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 @@ -390,57 +736,112 @@ 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': { - if (this.getAnyActiveCall()) { - // ignore multiple incoming calls. in future, we may want a line-1/line-2 setup. - // we avoid rejecting with "busy" in case the user wants to answer it on a different device. - // in future we could signal a "local busy" as a warning to the caller. - // see https://github.com/vector-im/vector-web/issues/1964 - return; - } - // if the runtime env doesn't do VoIP, stop here. if (!MatrixClientPeg.get().supportsVoip()) { return; } const call = payload.call as MatrixCall; - this.calls.set(call.roomId, call) + + const mappedRoomId = CallHandler.roomIdForCall(call); + if (this.getCallForRoom(mappedRoomId)) { + // ignore multiple incoming calls to the same room + return; + } + + 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': + case 'reject': if (!this.calls.get(payload.room_id)) { return; // no call to hangup } - this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false) - this.removeCallForRoom(payload.room_id); + if (payload.action === 'reject') { + this.calls.get(payload.room_id).reject(); + } else { + this.calls.get(payload.room_id).hangup(CallErrorCode.UserHangup, false); + } + // don't remove the call yet: let the hangup event handler do it (otherwise it will throw + // the hangup event away) break; - case 'answer': + case 'answer': { if (!this.calls.has(payload.room_id)) { return; // no call to answer } - this.calls.get(payload.room_id).answer(); + + if (this.getAllActiveCalls().length > 1) { + Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { + title: _t('Too Many Calls'), + description: _t("You've reached the maximum number of simultaneous calls."), + }); + return; + } + + const call = this.calls.get(payload.room_id); + call.answer(); + this.setCallAudioElement(call); + this.setActiveCallRoomId(payload.room_id); + CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); dis.dispatch({ action: "view_room", room_id: payload.room_id, }); break; + } } } + setActiveCallRoomId(activeCallRoomId: string) { + logger.info("Setting call in room " + activeCallRoomId + " active"); + + for (const [roomId, call] of this.calls.entries()) { + if (call.state === CallState.Ended) continue; + + if (roomId === activeCallRoomId) { + call.setRemoteOnHold(false); + } else { + logger.info("Holding call in room " + roomId + " because another call is being set active"); + call.setRemoteOnHold(true); + } + } + } + + /** + * @returns true if we are currently in any call where we haven't put the remote party on hold + */ + hasAnyUnheldCall() { + for (const call of this.calls.values()) { + if (call.state === CallState.Ended) continue; + if (!call.isRemoteOnHold()) return true; + } + + return false; + } + private async startCallApp(roomId: string, type: string) { dis.dispatch({ action: 'appsDrawer', @@ -470,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}); @@ -487,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 cba8671143..bec36d49f6 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -31,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner"; // Polyfill for Canvas.toBlob API using Canvas.toDataURL import "blueimp-canvas-to-blob"; import { Action } from "./dispatcher/actions"; +import CountlyAnalytics from "./CountlyAnalytics"; const MAX_WIDTH = 800; const MAX_HEIGHT = 600; @@ -368,10 +369,13 @@ export default class ContentMessages { private mediaConfig: IMediaConfig = null; sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) { - return MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + const startTime = CountlyAnalytics.getTimestamp(); + const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, {msgtype: "m.sticker"}); + return prom; } getUploadLimit() { @@ -479,6 +483,7 @@ export default class ContentMessages { } private sendContentToRoom(file: File, roomId: string, matrixClient: MatrixClient, promBefore: Promise) { + const startTime = CountlyAnalytics.getTimestamp(); const content: IContent = { body: file.name || 'Attachment', info: { @@ -492,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) => { @@ -563,7 +568,9 @@ export default class ContentMessages { return promBefore; }).then(function() { if (upload.canceled) throw new UploadCanceledError(); - return matrixClient.sendMessage(roomId, content); + const prom = matrixClient.sendMessage(roomId, content); + CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, false, content); + return prom; }, function(err) { error = err; if (!upload.canceled) { diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts new file mode 100644 index 0000000000..974c08df18 --- /dev/null +++ b/src/CountlyAnalytics.ts @@ -0,0 +1,973 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {randomString} from "matrix-js-sdk/src/randomstring"; + +import {getCurrentLanguage} from './languageHandler'; +import PlatformPeg from './PlatformPeg'; +import SdkConfig from './SdkConfig'; +import {MatrixClientPeg} from "./MatrixClientPeg"; +import {sleep} from "./utils/promise"; +import RoomViewStore from "./stores/RoomViewStore"; + +// polyfill textencoder if necessary +import * as TextEncodingUtf8 from 'text-encoding-utf-8'; +let TextEncoder = window.TextEncoder; +if (!TextEncoder) { + TextEncoder = TextEncodingUtf8.TextEncoder; +} + +const INACTIVITY_TIME = 20; // seconds +const HEARTBEAT_INTERVAL = 5_000; // ms +const SESSION_UPDATE_INTERVAL = 60; // seconds +const MAX_PENDING_EVENTS = 1000; + +enum Orientation { + Landscape = "landscape", + Portrait = "portrait", +} + +/* eslint-disable camelcase */ +interface IMetrics { + _resolution?: string; + _app_version?: string; + _density?: number; + _ua?: string; + _locale?: string; +} + +interface IEvent { + key: string; + count: number; + sum?: number; + dur?: number; + segmentation?: Record; + timestamp?: number; // TODO should we use the timestamp when we start or end for the event timestamp + hour?: unknown; + dow?: unknown; +} + +interface IViewEvent extends IEvent { + key: "[CLY]_view"; +} + +interface IOrientationEvent extends IEvent { + key: "[CLY]_orientation"; + segmentation: { + mode: Orientation; + }; +} + +interface IStarRatingEvent extends IEvent { + key: "[CLY]_star_rating"; + segmentation: { + // we just care about collecting feedback, no need to associate with a feedback widget + widget_id?: string; + contactMe?: boolean; + email?: string; + rating: 1 | 2 | 3 | 4 | 5; + comment: string; + }; +} + +type Value = string | number | boolean; + +interface IOperationInc { + "$inc": number; +} +interface IOperationMul { + "$mul": number; +} +interface IOperationMax { + "$max": number; +} +interface IOperationMin { + "$min": number; +} +interface IOperationSetOnce { + "$setOnce": Value; +} +interface IOperationPush { + "$push": Value | Value[]; +} +interface IOperationAddToSet { + "$addToSet": Value | Value[]; +} +interface IOperationPull { + "$pull": Value | Value[]; +} + +type Operation = + IOperationInc | + IOperationMul | + IOperationMax | + IOperationMin | + IOperationSetOnce | + IOperationPush | + IOperationAddToSet | + IOperationPull; + +interface IUserDetails { + name?: string; + username?: string; + email?: string; + organization?: string; + phone?: string; + picture?: string; + gender?: string; + byear?: number; + custom?: Record; // `.` and `$` will be stripped out +} + +interface ICrash { + _resolution?: string; + _app_version: string; + + _ram_current?: number; + _ram_total?: number; + _disk_current?: number; + _disk_total?: number; + _orientation?: Orientation; + + _online?: boolean; + _muted?: boolean; + _background?: boolean; + _view?: string; + + _name?: string; + _error: string; + _nonfatal?: boolean; + _logs?: string; + _run?: number; + + _custom?: Record; +} + +interface IParams { + // APP_KEY of an app for which to report + app_key: string; + // User identifier + device_id: string; + + // Should provide value 1 to indicate session start + begin_session?: number; + // JSON object as string to provide metrics to track with the user + metrics?: string; + // Provides session duration in seconds, can be used as heartbeat to update current sessions duration, recommended time every 60 seconds + session_duration?: number; + // Should provide value 1 to indicate session end + end_session?: number; + + // 10 digit UTC timestamp for recording past data. + timestamp?: number; + // current user local hour (0 - 23) + hour?: number; + // day of the week (0-sunday, 1 - monday, ... 6 - saturday) + dow?: number; + + // JSON array as string containing event objects + events?: string; // IEvent[] + // JSON object as string containing information about users + user_details?: string; + + // provide when changing device ID, so server would merge the data + old_device_id?: string; + + // See ICrash + crash?: string; +} + +interface IRoomSegments extends Record { + room_id: string; // hashed + num_users: number; + is_encrypted: boolean; + is_public: boolean; +} + +interface ISendMessageEvent extends IEvent { + key: "send_message"; + dur: number; // how long it to send (until remote echo) + segmentation: IRoomSegments & { + is_edit: boolean; + is_reply: boolean; + msgtype: string; + format?: string; + }; +} + +interface IRoomDirectoryEvent extends IEvent { + key: "room_directory"; +} + +interface IRoomDirectoryDoneEvent extends IEvent { + key: "room_directory_done"; + dur: number; // time spent in the room directory modal +} + +interface IRoomDirectorySearchEvent extends IEvent { + key: "room_directory_search"; + sum: number; // number of search results + segmentation: { + query_length: number; + query_num_words: number; + }; +} + +interface IStartCallEvent extends IEvent { + key: "start_call"; + segmentation: IRoomSegments & { + is_video: boolean; + is_jitsi: boolean; + }; +} + +interface IJoinCallEvent extends IEvent { + key: "join_call"; + segmentation: IRoomSegments & { + is_video: boolean; + is_jitsi: boolean; + }; +} + +interface IBeginInviteEvent extends IEvent { + key: "begin_invite"; + segmentation: IRoomSegments; +} + +interface ISendInviteEvent extends IEvent { + key: "send_invite"; + sum: number; // quantity that was invited + segmentation: IRoomSegments; +} + +interface ICreateRoomEvent extends IEvent { + key: "create_room"; + dur: number; // how long it took to create (until remote echo) + segmentation: { + room_id: string; // hashed + num_users: number; + is_encrypted: boolean; + is_public: boolean; + } +} + +interface IJoinRoomEvent extends IEvent { + key: "join_room"; + dur: number; // how long it took to join (until remote echo) + segmentation: { + room_id: string; // hashed + num_users: number; + is_encrypted: boolean; + is_public: boolean; + type: "room_directory" | "slash_command" | "link" | "invite"; + }; +} +/* eslint-enable camelcase */ + +const hashHex = async (input: string): Promise => { + const buf = new TextEncoder().encode(input); + const digestBuf = await window.crypto.subtle.digest("sha-256", buf); + return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join(""); +}; + +const knownScreens = new Set([ + "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", + "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", +]); + +interface IViewData { + name: string; + url: string; + meta: Record; +} + +// Apply fn to all hash path parts after the 1st one +async function getViewData(anonymous = true): Promise { + const rand = randomString(8); + const { origin, hash } = window.location; + let { pathname } = window.location; + + // Redact paths which could contain unexpected PII + if (origin.startsWith('file://')) { + pathname = `//`; // XXX: inject rand because Count.ly doesn't like X->X transitions + } + + let [_, screen, ...parts] = hash.split("/"); + + if (!knownScreens.has(screen)) { + screen = ``; + } + + for (let i = 0; i < parts.length; i++) { + parts[i] = anonymous ? `` : await hashHex(parts[i]); + } + + const hashStr = `${_}/${screen}/${parts.join("/")}`; + const url = origin + pathname + hashStr; + + const meta = {}; + + let name = "$/" + hash; + switch (screen) { + case "room": { + name = "view_room"; + const roomId = RoomViewStore.getRoomId(); + name += " " + parts[0]; // XXX: workaround Count.ly missing X->X transitions + meta["room_id"] = parts[0]; + Object.assign(meta, getRoomStats(roomId)); + break; + } + } + + return { name, url, meta }; +} + +const getRoomStats = (roomId: string) => { + const cli = MatrixClientPeg.get(); + const room = cli?.getRoom(roomId); + + return { + "num_users": room?.getJoinedMemberCount(), + "is_encrypted": cli?.isRoomEncrypted(roomId), + // eslint-disable-next-line camelcase + "is_public": room?.currentState.getStateEvents("m.room.join_rules", "")?.getContent()?.join_rule === "public", + } +} + +// async wrapper for regex-powered String.prototype.replace +const strReplaceAsync = async (str: string, regex: RegExp, fn: (...args: string[]) => Promise) => { + const promises: Promise[] = []; + // dry-run to calculate the replace values + str.replace(regex, (...args: string[]) => { + promises.push(fn(...args)); + return ""; + }); + const values = await Promise.all(promises); + return str.replace(regex, () => values.shift()); +}; + +export default class CountlyAnalytics { + private baseUrl: URL = null; + private appKey: string = null; + private userKey: string = null; + private anonymous: boolean; + private appPlatform: string; + private appVersion = "unknown"; + + private initTime = CountlyAnalytics.getTimestamp(); + private firstPage = true; + private heartbeatIntervalId: NodeJS.Timeout; + private activityIntervalId: NodeJS.Timeout; + private trackTime = true; + private lastBeat: number; + private storedDuration = 0; + private lastView: string; + private lastViewTime = 0; + private lastViewStoredDuration = 0; + private sessionStarted = false; + private heartbeatEnabled = false; + private inactivityCounter = 0; + private pendingEvents: IEvent[] = []; + + private static internalInstance = new CountlyAnalytics(); + + public static get instance(): CountlyAnalytics { + return CountlyAnalytics.internalInstance; + } + + public get disabled() { + return !this.baseUrl; + } + + public canEnable() { + const config = SdkConfig.get(); + return Boolean(navigator.doNotTrack !== "1" && config?.countly?.url && config?.countly?.appKey); + } + + private async changeUserKey(userKey: string, merge = false) { + const oldUserKey = this.userKey; + this.userKey = userKey; + if (oldUserKey && merge) { + await this.request({ old_device_id: oldUserKey }); + } + } + + public async enable(anonymous = true) { + if (!this.disabled && this.anonymous === anonymous) return; + if (!this.canEnable()) return; + + if (!this.disabled) { + // flush request queue as our userKey is going to change, no need to await it + this.request(); + } + + const config = SdkConfig.get(); + this.baseUrl = new URL("/i", config.countly.url); + this.appKey = config.countly.appKey; + + this.anonymous = anonymous; + if (anonymous) { + await this.changeUserKey(randomString(64)) + } else { + await this.changeUserKey(await hashHex(MatrixClientPeg.get().getUserId()), true); + } + + const platform = PlatformPeg.get(); + this.appPlatform = platform.getHumanReadableName(); + try { + this.appVersion = await platform.getAppVersion(); + } catch (e) { + console.warn("Failed to get app version, using 'unknown'"); + } + + // start heartbeat + this.heartbeatIntervalId = setInterval(this.heartbeat.bind(this), HEARTBEAT_INTERVAL); + this.trackSessions(); + this.trackErrors(); + } + + public async disable() { + if (this.disabled) return; + await this.track("Opt-Out" ); + this.endSession(); + window.clearInterval(this.heartbeatIntervalId); + window.clearTimeout(this.activityIntervalId) + this.baseUrl = null; + // remove listeners bound in trackSessions() + window.removeEventListener("beforeunload", this.endSession); + window.removeEventListener("unload", this.endSession); + window.removeEventListener("visibilitychange", this.onVisibilityChange); + window.removeEventListener("mousemove", this.onUserActivity); + window.removeEventListener("click", this.onUserActivity); + window.removeEventListener("keydown", this.onUserActivity); + window.removeEventListener("scroll", this.onUserActivity); + } + + public reportFeedback(rating: 1 | 2 | 3 | 4 | 5, comment: string) { + this.track("[CLY]_star_rating", { rating, comment }, null, {}, true); + } + + public trackPageChange(generationTimeMs?: number) { + if (this.disabled) return; + // TODO use generationTimeMs + this.trackPageView(); + } + + private async trackPageView() { + this.reportViewDuration(); + + await sleep(0); // XXX: we sleep here because otherwise we get the old hash and not the new one + const viewData = await getViewData(this.anonymous); + + const page = viewData.name; + this.lastView = page; + this.lastViewTime = CountlyAnalytics.getTimestamp(); + const segments = { + ...viewData.meta, + name: page, + visit: 1, + domain: window.location.hostname, + view: viewData.url, + segment: this.appPlatform, + start: this.firstPage, + }; + + if (this.firstPage) { + this.firstPage = false; + } + + this.track("[CLY]_view", segments); + } + + public static getTimestamp() { + return Math.floor(new Date().getTime() / 1000); + } + + // store the last ms timestamp returned + // we do this to prevent the ts from ever decreasing in the case of system time changing + private lastMsTs = 0; + + private getMsTimestamp() { + const ts = new Date().getTime(); + if (this.lastMsTs >= ts) { + // increment ts as to keep our data points well-ordered + this.lastMsTs++; + } else { + this.lastMsTs = ts; + } + return this.lastMsTs; + } + + public async recordError(err: Error | string, fatal = false) { + if (this.disabled || this.anonymous) return; + + let error = ""; + if (typeof err === "object") { + if (typeof err.stack !== "undefined") { + error = err.stack; + } else { + if (typeof err.name !== "undefined") { + error += err.name + ":"; + } + if (typeof err.message !== "undefined") { + error += err.message + "\n"; + } + if (typeof err.fileName !== "undefined") { + error += "in " + err.fileName + "\n"; + } + if (typeof err.lineNumber !== "undefined") { + error += "on " + err.lineNumber; + } + if (typeof err.columnNumber !== "undefined") { + error += ":" + err.columnNumber; + } + } + } else { + error = err + ""; + } + + // sanitize the error from identifiers + error = await strReplaceAsync(error, /([!@+#]).+?:[\w:.]+/g, async (substring: string, glyph: string) => { + return glyph + await hashHex(substring.substring(1)); + }); + + const metrics = this.getMetrics(); + const ob: ICrash = { + _resolution: metrics?._resolution, + _error: error, + _app_version: this.appVersion, + _run: CountlyAnalytics.getTimestamp() - this.initTime, + _nonfatal: !fatal, + _view: this.lastView, + }; + + if (typeof navigator.onLine !== "undefined") { + ob._online = navigator.onLine; + } + + ob._background = document.hasFocus(); + + this.request({ crash: JSON.stringify(ob) }); + } + + private trackErrors() { + //override global uncaught error handler + window.onerror = (msg, url, line, col, err) => { + if (typeof err !== "undefined") { + this.recordError(err, false); + } else { + let error = ""; + if (typeof msg !== "undefined") { + error += msg + "\n"; + } + if (typeof url !== "undefined") { + error += "at " + url; + } + if (typeof line !== "undefined") { + error += ":" + line; + } + if (typeof col !== "undefined") { + error += ":" + col; + } + error += "\n"; + + try { + const stack = []; + // eslint-disable-next-line no-caller + let f = arguments.callee.caller; + while (f) { + stack.push(f.name); + f = f.caller; + } + error += stack.join("\n"); + } catch (ex) { + //silent error + } + this.recordError(error, false); + } + }; + + window.addEventListener('unhandledrejection', (event) => { + this.recordError(new Error(`Unhandled rejection (reason: ${event.reason?.stack || event.reason}).`), true); + }); + } + + private heartbeat() { + const args: Pick = {}; + + // extend session if needed + if (this.sessionStarted && this.trackTime) { + const last = CountlyAnalytics.getTimestamp(); + if (last - this.lastBeat >= SESSION_UPDATE_INTERVAL) { + args.session_duration = last - this.lastBeat; + this.lastBeat = last; + } + } + + // process event queue + if (this.pendingEvents.length > 0 || args.session_duration) { + this.request(args); + } + } + + private async request( + args: Omit + & Partial> = {}, + ) { + const request: IParams = { + app_key: this.appKey, + device_id: this.userKey, + ...this.getTimeParams(), + ...args, + }; + + if (this.pendingEvents.length > 0) { + const EVENT_BATCH_SIZE = 10; + const events = this.pendingEvents.splice(0, EVENT_BATCH_SIZE); + request.events = JSON.stringify(events); + } + + const params = new URLSearchParams(request as {}); + + try { + await window.fetch(this.baseUrl.toString(), { + method: "POST", + mode: "no-cors", + cache: "no-cache", + redirect: "follow", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params, + }); + } catch (e) { + console.error("Analytics error: ", e); + } + } + + private getTimeParams(): Pick { + const date = new Date(); + return { + timestamp: this.getMsTimestamp(), + hour: date.getHours(), + dow: date.getDay(), + }; + } + + private queue(args: Omit & Partial>) { + const {count = 1, ...rest} = args; + const ev = { + ...this.getTimeParams(), + ...rest, + count, + platform: this.appPlatform, + app_version: this.appVersion, + } + + this.pendingEvents.push(ev); + if (this.pendingEvents.length > MAX_PENDING_EVENTS) { + this.pendingEvents.shift(); + } + } + + private getOrientation = (): Orientation => { + return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait; + }; + + private reportOrientation = () => { + this.track("[CLY]_orientation", { + mode: this.getOrientation(), + }); + }; + + private startTime() { + if (!this.trackTime) { + this.trackTime = true; + this.lastBeat = CountlyAnalytics.getTimestamp() - this.storedDuration; + this.lastViewTime = CountlyAnalytics.getTimestamp() - this.lastViewStoredDuration; + this.lastViewStoredDuration = 0; + } + } + + private stopTime() { + if (this.trackTime) { + this.trackTime = false; + this.storedDuration = CountlyAnalytics.getTimestamp() - this.lastBeat; + this.lastViewStoredDuration = CountlyAnalytics.getTimestamp() - this.lastViewTime; + } + } + + private getMetrics(): IMetrics { + if (this.anonymous) return undefined; + const metrics: IMetrics = {}; + + // getting app version + metrics._app_version = this.appVersion; + metrics._ua = navigator.userAgent; + + // getting resolution + if (screen.width && screen.height) { + metrics._resolution = `${screen.width}x${screen.height}`; + } + + // getting density ratio + if (window.devicePixelRatio) { + metrics._density = window.devicePixelRatio; + } + + // getting locale + metrics._locale = getCurrentLanguage(); + + return metrics; + } + + private async beginSession(heartbeat = true) { + if (!this.sessionStarted) { + this.reportOrientation(); + window.addEventListener("resize", this.reportOrientation); + + this.lastBeat = CountlyAnalytics.getTimestamp(); + this.sessionStarted = true; + this.heartbeatEnabled = heartbeat; + + const userDetails: IUserDetails = { + custom: { + "home_server": MatrixClientPeg.get() && MatrixClientPeg.getHomeserverName(), // TODO hash? + "anonymous": this.anonymous, + }, + }; + + const request: Parameters[0] = { + begin_session: 1, + user_details: JSON.stringify(userDetails), + } + + const metrics = this.getMetrics(); + if (metrics) { + request.metrics = JSON.stringify(metrics); + } + + await this.request(request); + } + } + + private reportViewDuration() { + if (this.lastView) { + this.track("[CLY]_view", { + name: this.lastView, + }, null, { + dur: this.trackTime ? CountlyAnalytics.getTimestamp() - this.lastViewTime : this.lastViewStoredDuration, + }); + this.lastView = null; + } + } + + private endSession = () => { + if (this.sessionStarted) { + window.removeEventListener("resize", this.reportOrientation) + + this.reportViewDuration(); + this.request({ + end_session: 1, + session_duration: CountlyAnalytics.getTimestamp() - this.lastBeat, + }); + } + this.sessionStarted = false; + }; + + private onVisibilityChange = () => { + if (document.hidden) { + this.stopTime(); + } else { + this.startTime(); + } + }; + + private onUserActivity = () => { + if (this.inactivityCounter >= INACTIVITY_TIME) { + this.startTime(); + } + this.inactivityCounter = 0; + }; + + private trackSessions() { + this.beginSession(); + this.startTime(); + + window.addEventListener("beforeunload", this.endSession); + window.addEventListener("unload", this.endSession); + window.addEventListener("visibilitychange", this.onVisibilityChange); + window.addEventListener("mousemove", this.onUserActivity); + window.addEventListener("click", this.onUserActivity); + window.addEventListener("keydown", this.onUserActivity); + window.addEventListener("scroll", this.onUserActivity); + + this.activityIntervalId = setInterval(() => { + this.inactivityCounter++; + if (this.inactivityCounter >= INACTIVITY_TIME) { + this.stopTime(); + } + }, 60_000); + } + + public trackBeginInvite(roomId: string) { + this.track("begin_invite", {}, roomId); + } + + public trackSendInvite(startTime: number, roomId: string, qty: number) { + this.track("send_invite", {}, roomId, { + dur: CountlyAnalytics.getTimestamp() - startTime, + sum: qty, + }); + } + + public async trackRoomCreate(startTime: number, roomId: string) { + if (this.disabled) return; + + let endTime = CountlyAnalytics.getTimestamp(); + const cli = MatrixClientPeg.get(); + if (!cli.getRoom(roomId)) { + await new Promise(resolve => { + const handler = (room) => { + if (room.roomId === roomId) { + cli.off("Room", handler); + resolve(); + } + }; + cli.on("Room", handler); + }); + endTime = CountlyAnalytics.getTimestamp(); + } + + this.track("create_room", {}, roomId, { + dur: endTime - startTime, + }); + } + + public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) { + this.track("join_room", { type }, roomId, { + dur: CountlyAnalytics.getTimestamp() - startTime, + }); + } + + public async trackSendMessage( + startTime: number, + // eslint-disable-next-line camelcase + sendPromise: Promise<{event_id: string}>, + roomId: string, + isEdit: boolean, + isReply: boolean, + content: {format?: string, msgtype: string}, + ) { + if (this.disabled) return; + const cli = MatrixClientPeg.get(); + const room = cli.getRoom(roomId); + + const eventId = (await sendPromise).event_id; + let endTime = CountlyAnalytics.getTimestamp(); + + if (!room.findEventById(eventId)) { + await new Promise(resolve => { + const handler = (ev) => { + if (ev.getId() === eventId) { + room.off("Room.localEchoUpdated", handler); + resolve(); + } + }; + + room.on("Room.localEchoUpdated", handler); + }); + endTime = CountlyAnalytics.getTimestamp(); + } + + this.track("send_message", { + is_edit: isEdit, + is_reply: isReply, + msgtype: content.msgtype, + format: content.format, + }, roomId, { + dur: endTime - startTime, + }); + } + + public trackStartCall(roomId: string, isVideo = false, isJitsi = false) { + this.track("start_call", { + is_video: isVideo, + is_jitsi: isJitsi, + }, roomId); + } + + public trackJoinCall(roomId: string, isVideo = false, isJitsi = false) { + this.track("join_call", { + is_video: isVideo, + is_jitsi: isJitsi, + }, roomId); + } + + public trackRoomDirectoryBegin() { + this.track("room_directory"); + } + + public trackRoomDirectory(startTime: number) { + this.track("room_directory_done", {}, null, { + dur: CountlyAnalytics.getTimestamp() - startTime, + }); + } + + public trackRoomDirectorySearch(numResults: number, query: string) { + this.track("room_directory_search", { + query_length: query.length, + query_num_words: query.split(" ").length, + }, null, { + sum: numResults, + }); + } + + public async track( + key: E["key"], + segments?: Omit, + roomId?: string, + args?: Partial>, + anonymous = false, + ) { + if (this.disabled && !anonymous) return; + + let segmentation = segments || {}; + + if (roomId) { + segmentation = { + room_id: await hashHex(roomId), + ...getRoomStats(roomId), + ...segments, + }; + } + + this.queue({ + key, + count: 1, + segmentation, + ...args, + }); + + // if this event can be sent anonymously and we are disabled then dispatch it right away + if (this.disabled && anonymous) { + await this.request({ device_id: randomString(64) }); + } + } +} + +// expose on window for easy access from the console +window.mxCountlyAnalytics = CountlyAnalytics; diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index c503247bf7..7d6b049914 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -27,6 +27,10 @@ import _linkifyString from 'linkifyjs/string'; import classNames from 'classnames'; import EMOJIBASE_REGEX from 'emojibase-regex'; import url from 'url'; +import katex from 'katex'; +import { AllHtmlEntities } from 'html-entities'; +import SettingsStore from './settings/SettingsStore'; +import cheerio from 'cheerio'; import {MatrixClientPeg} from './MatrixClientPeg'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; @@ -159,7 +163,7 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to attribs.target = '_blank'; // by default const transformed = tryTransformPermalinkToLocalHref(attribs.href); - if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.VECTOR_URL_PATTERN)) { + if (transformed !== attribs.href || attribs.href.match(linkifyMatrix.ELEMENT_URL_PATTERN)) { attribs.href = transformed; delete attribs.target; } @@ -171,7 +175,10 @@ const transformTags: IExtendedSanitizeOptions["transformTags"] = { // custom to // Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag // because transformTags is used _before_ we filter by allowedSchemesByTag and // we don't want to allow images with `https?` `src`s. - if (!attribs.src || !attribs.src.startsWith('mxc://')) { + // We also drop inline images (as if they were not present at all) when the "show + // images" preference is disabled. Future work might expose some UI to reveal them + // like standalone image events have. + if (!attribs.src || !attribs.src.startsWith('mxc://') || !SettingsStore.getValue("showImages")) { return { tagName, attribs: {}}; } attribs.src = MatrixClientPeg.get().mxcUrlToHttp( @@ -236,7 +243,8 @@ const sanitizeHtmlParams: IExtendedSanitizeOptions = { allowedAttributes: { // custom ones first: font: ['color', 'data-mx-bg-color', 'data-mx-color', 'style'], // custom to matrix - span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix + span: ['data-mx-maths', 'data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style'], // custom to matrix + div: ['data-mx-maths'], a: ['href', 'name', 'target', 'rel'], // remote target: custom to matrix img: ['src', 'width', 'height', 'alt', 'title'], ol: ['start'], @@ -410,18 +418,36 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts if (isHtmlMessage) { isDisplayedWithHtml = true; safeBody = sanitizeHtml(formattedBody, sanitizeParams); + + 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')), + { + throwOnError: false, + displayMode: e.name == 'div', + output: "htmlAndMathml", + }); + }); + safeBody = phtml.html(); + } } } finally { delete sanitizeParams.textFilter; } + const contentBody = isDisplayedWithHtml ? safeBody : strippedBody; if (opts.returnString) { - return isDisplayedWithHtml ? safeBody : strippedBody; + return contentBody; } let emojiBody = false; if (!opts.disableBigEmoji && bodyHasEmoji) { - let contentBodyTrimmed = strippedBody !== undefined ? strippedBody.trim() : ''; + let contentBodyTrimmed = contentBody !== undefined ? contentBody.trim() : ''; // Ignore spaces in body text. Emojis with spaces in between should // still be counted as purely emoji messages. @@ -511,7 +537,6 @@ export function checkBlockNode(node: Node) { case "H6": case "PRE": case "BLOCKQUOTE": - case "DIV": case "P": case "UL": case "OL": @@ -524,6 +549,9 @@ export function checkBlockNode(node: Node) { case "TH": case "TD": return true; + case "DIV": + // don't treat math nodes as block nodes for deserializing + return !(node as HTMLElement).hasAttribute("data-mx-maths"); default: return false; } 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/ImageUtils.js b/src/ImageUtils.ts similarity index 90% rename from src/ImageUtils.js rename to src/ImageUtils.ts index c0f7b94b81..9bfab37193 100644 --- a/src/ImageUtils.js +++ b/src/ImageUtils.ts @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +Copyright 2015, 2016, 2020 Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,8 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - /** * Returns the actual height that an image of dimensions (fullWidth, fullHeight) * will occupy if resized to fit inside a thumbnail bounding box of size @@ -30,11 +28,11 @@ limitations under the License. * consume in the timeline, when performing scroll offset calcuations * (e.g. scroll locking) */ -export function thumbHeight(fullWidth, fullHeight, thumbWidth, thumbHeight) { +export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) { if (!fullWidth || !fullHeight) { // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // log this because it's spammy - return undefined; + return null; } if (fullWidth < thumbWidth && fullHeight < thumbHeight) { // no scaling needs to be applied diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 6293de063d..7780d4c87a 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -21,6 +21,7 @@ limitations under the License. import Matrix from 'matrix-js-sdk'; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import {decryptAES, encryptAES} from "matrix-js-sdk/src/crypto/aes"; import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; import SecurityCustomisations from "./customisations/Security"; @@ -45,8 +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"; @@ -144,20 +150,13 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise * Gets the user ID of the persisted session, if one exists. This does not validate * that the user's credentials still work, just that they exist and that a user ID * is associated with them. The session is not loaded. - * @returns {String} The persisted session's owner, if an owner exists. Null otherwise. + * @returns {[String, bool]} The persisted session's owner and whether the stored + * session is for a guest user, if an owner exists. If there is no stored session, + * return [null, null]. */ -export function getStoredSessionOwner(): string { - const {hsUrl, userId, accessToken} = getLocalStorageSessionVars(); - return hsUrl && userId && accessToken ? userId : null; -} - -/** - * @returns {bool} True if the stored session is for a guest user or false if it is - * for a real user. If there is no stored session, return null. - */ -export function getStoredSessionIsGuest(): boolean { - const sessVars = getLocalStorageSessionVars(); - return sessVars.hsUrl && sessVars.userId && sessVars.accessToken ? sessVars.isGuest : null; +export async function getStoredSessionOwner(): Promise<[string, boolean]> { + const {hsUrl, userId, hasAccessToken, isGuest} = await getStoredSessionVars(); + return hsUrl && userId && hasAccessToken ? [userId, isGuest] : [null, null]; } /** @@ -165,7 +164,8 @@ export function getStoredSessionIsGuest(): 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 @@ -173,6 +173,7 @@ export function getStoredSessionIsGuest(): boolean { export function attemptTokenLogin( queryParams: Record, defaultDeviceDisplayName?: string, + fragmentAfterLogin?: string, ): Promise { if (!queryParams.loginToken) { return Promise.resolve(false); @@ -182,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); } @@ -194,15 +201,35 @@ export function attemptTokenLogin( }, ).then(function(creds) { console.log("Logged in with token"); - return clearStorage().then(() => { - persistCredentialsToLocalStorage(creds); + return clearStorage().then(async () => { + await persistCredentials(creds); // remember that we just logged in sessionStorage.setItem("mx_fresh_login", String(true)); 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; }); } @@ -273,24 +300,42 @@ function registerAsGuest( }); } -export interface ILocalStorageSession { +export interface IStoredSession { hsUrl: string; isUrl: string; - accessToken: string; + hasAccessToken: boolean; + accessToken: string | object; userId: string; deviceId: string; isGuest: boolean; } /** - * Retrieves information about the stored session in localstorage. The session + * Retrieves information about the stored session from the browser's storage. The session * may not be valid, as it is not tested for consistency here. * @returns {Object} Information about the session - see implementation for variables. */ -export function getLocalStorageSessionVars(): ILocalStorageSession { +export async function getStoredSessionVars(): Promise { const hsUrl = localStorage.getItem(HOMESERVER_URL_KEY); const isUrl = localStorage.getItem(ID_SERVER_URL_KEY); - const accessToken = localStorage.getItem("mx_access_token"); + let accessToken; + try { + accessToken = await StorageManager.idbLoad("account", "mx_access_token"); + } catch (e) {} + if (!accessToken) { + accessToken = localStorage.getItem("mx_access_token"); + if (accessToken) { + try { + // try to migrate access token to IndexedDB if we can + await StorageManager.idbSave("account", "mx_access_token", accessToken); + localStorage.removeItem("mx_access_token"); + } catch (e) {} + } + } + // if we pre-date storing "mx_has_access_token", but we retrieved an access + // token, then we should say we have an access token + const hasAccessToken = + (localStorage.getItem("mx_has_access_token") === "true") || !!accessToken; const userId = localStorage.getItem("mx_user_id"); const deviceId = localStorage.getItem("mx_device_id"); @@ -302,7 +347,43 @@ export function getLocalStorageSessionVars(): ILocalStorageSession { isGuest = localStorage.getItem("matrix-is-guest") === "true"; } - return {hsUrl, isUrl, accessToken, userId, deviceId, isGuest}; + return {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest}; +} + +// The pickle key is a string of unspecified length and format. For AES, we +// need a 256-bit Uint8Array. So we HKDF the pickle key to generate the AES +// key. The AES key should be zeroed after it is used. +async function pickleKeyToAesKey(pickleKey: string): Promise { + const pickleKeyBuffer = new Uint8Array(pickleKey.length); + for (let i = 0; i < pickleKey.length; i++) { + pickleKeyBuffer[i] = pickleKey.charCodeAt(i); + } + const hkdfKey = await window.crypto.subtle.importKey( + "raw", pickleKeyBuffer, "HKDF", false, ["deriveBits"], + ); + pickleKeyBuffer.fill(0); + return new Uint8Array(await window.crypto.subtle.deriveBits( + { + name: "HKDF", hash: "SHA-256", + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 + salt: new Uint8Array(32), info: new Uint8Array(0), + }, + hkdfKey, + 256, + )); +} + +async function abortLogin() { + const signOut = await showStorageEvictedDialog(); + if (signOut) { + await clearStorage(); + // This error feels a bit clunky, but we want to make sure we don't go any + // further and instead head back to sign in. + throw new AbortLoginAndRebuildStorage( + "Aborting login in progress because of storage inconsistency", + ); + } } // returns a promise which resolves to true if a session is found in @@ -315,14 +396,18 @@ export function getLocalStorageSessionVars(): ILocalStorageSession { // 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) { return false; } - const {hsUrl, isUrl, accessToken, userId, deviceId, isGuest} = getLocalStorageSessionVars(); + const {hsUrl, isUrl, hasAccessToken, accessToken, userId, deviceId, isGuest} = await getStoredSessionVars(); + + if (hasAccessToken && !accessToken) { + abortLogin(); + } if (accessToken && userId && hsUrl) { if (ignoreGuest && isGuest) { @@ -330,9 +415,15 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis return false; } + let decryptedAccessToken = accessToken; const pickleKey = await PlatformPeg.get().getPickleKey(userId, deviceId); if (pickleKey) { console.log("Got pickle key"); + if (typeof accessToken !== "string") { + const encrKey = await pickleKeyToAesKey(pickleKey); + decryptedAccessToken = await decryptAES(accessToken, encrKey, "access_token"); + encrKey.fill(0); + } } else { console.log("No pickle key available"); } @@ -344,7 +435,7 @@ async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): Promis await doSetLoggedIn({ userId: userId, deviceId: deviceId, - accessToken: accessToken, + accessToken: decryptedAccessToken as string, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: isGuest, @@ -483,15 +574,7 @@ async function doSetLoggedIn( // crypto store, we'll be generally confused when handling encrypted data. // Show a modal recommending a full reset of storage. if (results.dataInLocalStorage && results.cryptoInited && !results.dataInCryptoStore) { - const signOut = await showStorageEvictedDialog(); - if (signOut) { - await clearStorage(); - // This error feels a bit clunky, but we want to make sure we don't go any - // further and instead head back to sign in. - throw new AbortLoginAndRebuildStorage( - "Aborting login in progress because of storage inconsistency", - ); - } + await abortLogin(); } Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); @@ -513,7 +596,7 @@ async function doSetLoggedIn( if (localStorage) { try { - persistCredentialsToLocalStorage(credentials); + await persistCredentials(credentials); // make sure we don't think that it's a fresh login any more sessionStorage.removeItem("mx_fresh_login"); } catch (e) { @@ -542,18 +625,55 @@ function showStorageEvictedDialog(): Promise { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error { } -function persistCredentialsToLocalStorage(credentials: IMatrixClientCreds): void { +async function persistCredentials(credentials: IMatrixClientCreds): Promise { localStorage.setItem(HOMESERVER_URL_KEY, credentials.homeserverUrl); if (credentials.identityServerUrl) { localStorage.setItem(ID_SERVER_URL_KEY, credentials.identityServerUrl); } localStorage.setItem("mx_user_id", credentials.userId); - localStorage.setItem("mx_access_token", credentials.accessToken); localStorage.setItem("mx_is_guest", JSON.stringify(credentials.guest)); + // store whether we expect to find an access token, to detect the case + // where IndexedDB is blown away + if (credentials.accessToken) { + localStorage.setItem("mx_has_access_token", "true"); + } else { + localStorage.deleteItem("mx_has_access_token"); + } + if (credentials.pickleKey) { + let encryptedAccessToken; + try { + // try to encrypt the access token using the pickle key + const encrKey = await pickleKeyToAesKey(credentials.pickleKey); + encryptedAccessToken = await encryptAES(credentials.accessToken, encrKey, "access_token"); + encrKey.fill(0); + } catch (e) { + console.warn("Could not encrypt access token", e); + } + try { + // save either the encrypted access token, or the plain access + // token if we were unable to encrypt (e.g. if the browser doesn't + // have WebCrypto). + await StorageManager.idbSave( + "account", "mx_access_token", + encryptedAccessToken || credentials.accessToken, + ); + } catch (e) { + // if we couldn't save to indexedDB, fall back to localStorage. We + // store the access token unencrypted since localStorage only saves + // strings. + localStorage.setItem("mx_access_token", credentials.accessToken); + } localStorage.setItem("mx_has_pickle_key", String(true)); } else { + try { + await StorageManager.idbSave( + "account", "mx_access_token", credentials.accessToken, + ); + } catch (e) { + localStorage.setItem("mx_access_token", credentials.accessToken); + } if (localStorage.getItem("mx_has_pickle_key")) { console.error("Expected a pickle key, but none provided. Encryption may not work."); } @@ -580,12 +700,16 @@ let _isLoggingOut = false; */ export function logout(): void { if (!MatrixClientPeg.get()) return; + if (!CountlyAnalytics.instance.disabled) { + // user has logged out, fall back to anonymous + CountlyAnalytics.instance.enable(/* anonymous = */ true); + } if (MatrixClientPeg.get().isGuest()) { // logout doesn't work for guest sessions - // Also we sometimes want to re-log in a guest session - // if we abort the login - onLoggedOut(); + // Also we sometimes want to re-log in a guest session if we abort the login. + // defer until next tick because it calls a synchronous dispatch and we are likely here from a dispatch. + setImmediate(() => onLoggedOut()); return; } @@ -660,6 +784,7 @@ async function startMatrixClient(startSyncing = true): Promise { DMRoomMap.makeShared().start(); IntegrationManagers.sharedInstance().startWatching(); ActiveWidgetStore.start(); + CallHandler.sharedInstance().start(); // Start Mjolnir even though we haven't checked the feature flag yet. Starting // the thing just wastes CPU cycles, but should result in no actual functionality @@ -709,6 +834,7 @@ export async function onLoggedOut(): Promise { dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); await clearStorage({deleteEverything: true}); + LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); } /** @@ -724,6 +850,10 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { @@ -755,6 +885,7 @@ async function clearStorage(opts?: { deleteEverything?: boolean }): Promise; + private flows: Array; private defaultDeviceDisplayName: string; private tempClient: MatrixClient; @@ -63,7 +85,6 @@ export default class Login { this.hsUrl = hsUrl; this.isUrl = isUrl; this.fallbackHsUrl = fallbackHsUrl; - this.currentFlowIndex = 0; this.flows = []; this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName; this.tempClient = null; // memoize @@ -100,27 +121,13 @@ export default class Login { }); } - public async getFlows(): Promise> { + public async getFlows(): Promise> { const client = this.createTemporaryClient(); const { flows } = await client.loginFlows(); this.flows = flows; - this.currentFlowIndex = 0; - // technically the UI should display options for all flows for the - // user to then choose one, so return all the flows here. return this.flows; } - public chooseFlow(flowIndex): void { - this.currentFlowIndex = flowIndex; - } - - public getCurrentFlowStep(): string { - // technically the flow can have multiple steps, but no one does this - // for login so we can ignore it. - const flowStep = this.flows[this.currentFlowIndex]; - return flowStep ? flowStep.type : null; - } - public loginViaPassword( username: string, phoneCountry: string, diff --git a/src/Markdown.js b/src/Markdown.js index 492450e87d..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']; @@ -23,6 +23,11 @@ const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; function is_allowed_html_tag(node) { + if (node.literal != null && + node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) { + return true; + } + // Regex won't work for tags with attrs, but we only // allow anyway. const matches = /^<\/?(.*)>$/.exec(node.literal); @@ -30,6 +35,7 @@ function is_allowed_html_tag(node) { const tag = matches[1]; return ALLOWED_HTML_TAGS.indexOf(tag) > -1; } + return false; } diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 5bb10dfa89..98ca446532 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -34,6 +34,7 @@ import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; import { crossSigningCallbacks, tryToUnlockSecretStorageWithDehydrationKey } from './SecurityManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; +import SecurityCustomisations from "./customisations/Security"; export interface IMatrixClientCreds { homeserverUrl: string; @@ -100,6 +101,12 @@ export interface IMatrixClientPeg { */ currentUserIsJustRegistered(): boolean; + /** + * If the current user has been registered by this device then this + * returns a boolean of whether it was within the last N hours given. + */ + userRegisteredWithinLastHours(hours: number): boolean; + /** * Replace this MatrixClientPeg's client with a client instance that has * homeserver / identity server URLs and active credentials @@ -150,6 +157,9 @@ class _MatrixClientPeg implements IMatrixClientPeg { public setJustRegisteredUserId(uid: string): void { this.justRegisteredUserId = uid; + if (uid) { + window.localStorage.setItem("mx_registration_time", String(new Date().getTime())); + } } public currentUserIsJustRegistered(): boolean { @@ -159,6 +169,15 @@ class _MatrixClientPeg implements IMatrixClientPeg { ); } + public userRegisteredWithinLastHours(hours: number): boolean { + try { + const date = new Date(window.localStorage.getItem("mx_registration_time")); + return ((new Date().getTime() - date.getTime()) / 36e5) <= hours; + } catch (e) { + return false; + } + } + public replaceUsingCreds(creds: IMatrixClientCreds): void { this.currentClientCreds = creds; this.createClient(creds); @@ -260,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, @@ -273,7 +296,10 @@ class _MatrixClientPeg implements IMatrixClientPeg { // These are always installed regardless of the labs flag so that // cross-signing features can toggle on without reloading and also be // accessed immediately after login. - Object.assign(opts.cryptoCallbacks, crossSigningCallbacks); + const customisedCallbacks = { + getDehydrationKey: SecurityCustomisations.getDehydrationKey, + }; + Object.assign(opts.cryptoCallbacks, crossSigningCallbacks, customisedCallbacks); this.matrixClient = createMatrixClient(opts); diff --git a/src/Modal.tsx b/src/Modal.tsx index b0f6ef988e..ab582b9b22 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -28,7 +28,7 @@ import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; const STATIC_DIALOG_CONTAINER_ID = "mx_Dialog_StaticContainer"; -interface IModal { +export interface IModal { elem: React.ReactNode; className?: string; beforeClosePromise?: Promise; @@ -38,7 +38,7 @@ interface IModal { close(...args: T): void; } -interface IHandle { +export interface IHandle { finished: Promise; close(...args: T): void; } @@ -147,6 +147,15 @@ export class ModalManager { return this.appendDialogAsync(...rest); } + public closeCurrentModal(reason: string) { + const modal = this.getCurrentModal(); + if (!modal) { + return; + } + modal.closeReason = reason; + modal.close(); + } + private buildModal( prom: Promise, props?: IProps, diff --git a/src/Notifier.ts b/src/Notifier.ts index 1899896f9b..6460be20ad 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -34,6 +34,8 @@ import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; import {SettingLevel} from "./settings/SettingLevel"; import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; +import RoomViewStore from "./stores/RoomViewStore"; +import UserActivity from "./UserActivity"; /* * Dispatches: @@ -376,6 +378,11 @@ export const Notifier = { const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions && actions.notify) { + if (RoomViewStore.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently()) { + // don't bother notifying as user was recently active in this room + return; + } + if (this.isEnabled()) { this._displayPopupNotification(ev, room); } 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/PasswordReset.js b/src/PasswordReset.js index 9472ddc633..b38a9de960 100644 --- a/src/PasswordReset.js +++ b/src/PasswordReset.js @@ -40,10 +40,6 @@ export default class PasswordReset { this.identityServerDomain = identityUrl ? identityUrl.split("://")[1] : null; } - doesServerRequireIdServerParam() { - return this.client.doesServerRequireIdServerParam(); - } - /** * Attempt to reset the user's password. This will trigger a side-effect of * sending an email to the provided email address. @@ -78,9 +74,6 @@ export default class PasswordReset { sid: this.sessionId, client_secret: this.clientSecret, }; - if (await this.doesServerRequireIdServerParam()) { - creds.id_server = this.identityServerDomain; - } try { await this.client.setPassword({ 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 7eb7f5dbb2..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"; @@ -40,20 +40,23 @@ export function inviteMultipleToRoom(roomId, addrs) { return inviter.invite(addrs).then(states => Promise.resolve({states, inviter})); } -export function showStartChatInviteDialog() { +export function showStartChatInviteDialog(initialText) { // This dialog handles the room creation internally - we don't need to worry about it. const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( - 'Start DM', '', InviteDialog, {kind: KIND_DM}, + 'Start DM', '', InviteDialog, {kind: KIND_DM, initialText}, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, ); } 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/Rooms.js b/src/Rooms.js index 3da2b9bc14..955498faaa 100644 --- a/src/Rooms.js +++ b/src/Rooms.js @@ -21,6 +21,9 @@ import {MatrixClientPeg} from './MatrixClientPeg'; * if any. This could be the canonical alias if one exists, otherwise * an alias selected arbitrarily but deterministically from the list * of aliases. Otherwise return null; + * + * @param {Object} room The room object + * @returns {string} A display alias for the given room */ export function getDisplayAliasForRoom(room) { return room.getCanonicalAlias() || room.getAltAliases()[0]; diff --git a/src/Searching.js b/src/Searching.js index b1507e6a49..f65b8920b3 100644 --- a/src/Searching.js +++ b/src/Searching.js @@ -360,7 +360,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven let oldestEventFrom = previousSearchResult.oldestEventFrom; response.highlights = previousSearchResult.highlights; - if (localEvents && serverEvents) { + if (localEvents && serverEvents && serverEvents.results) { // This is a first search call, combine the events from the server and // the local index. Note where our oldest event came from, we shall // fetch the next batch of events from the other source. @@ -379,7 +379,7 @@ function combineEvents(previousSearchResult, localEvents = undefined, serverEven oldestEventFrom = "local"; } combineEventSources(previousSearchResult, response, localEvents.results, cachedEvents); - } else if (serverEvents) { + } else if (serverEvents && serverEvents.results) { // This is a pagination call fetching more events from the server, // meaning that our oldest event was in the local index. // Change the source of the oldest event if our server event is older @@ -454,7 +454,7 @@ function combineResponses(previousSearchResult, localEvents = undefined, serverE return response; } -function restoreEncryptionInfo(searchResultSlice) { +function restoreEncryptionInfo(searchResultSlice = []) { for (let i = 0; i < searchResultSlice.length; i++) { const timeline = searchResultSlice[i].context.getTimeline(); @@ -517,7 +517,7 @@ async function combinedPagination(searchResult) { }, }; - const oldResultCount = searchResult.results.length; + const oldResultCount = searchResult.results ? searchResult.results.length : 0; // Let the client process the combined result. const result = client._processRoomEventsSearch(searchResult, response); 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/Skinner.js b/src/Skinner.js index 87c5a7be7f..d17bc1782a 100644 --- a/src/Skinner.js +++ b/src/Skinner.js @@ -50,8 +50,8 @@ class Skinner { return null; } - // components have to be functions. - const validType = typeof comp === 'function'; + // components have to be functions or forwardRef objects with a render function. + const validType = typeof comp === 'function' || comp.render; if (!validType) { throw new Error(`Not a valid component: ${name} (type = ${typeof(comp)}).`); } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index a6481d5b95..6b5f261374 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -46,6 +46,9 @@ import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from import SdkConfig from "./SdkConfig"; 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 { @@ -77,6 +80,7 @@ export const CommandCategories = { "actions": _td("Actions"), "admin": _td("Admin"), "advanced": _td("Advanced"), + "effects": _td("Effects"), "other": _td("Other"), }; @@ -163,6 +167,32 @@ export const Commands = [ }, category: CommandCategories.messages, }), + new Command({ + command: 'tableflip', + args: '', + description: _td('Prepends (╯°□°)╯︵ ┻━┻ to a plain-text message'), + runFn: function(roomId, args) { + let message = '(╯°□°)╯︵ ┻━┻'; + if (args) { + message = message + ' ' + args; + } + return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + }, + category: CommandCategories.messages, + }), + new Command({ + command: 'unflip', + args: '', + description: _td('Prepends ┬──┬ ノ( ゜-゜ノ) to a plain-text message'), + runFn: function(roomId, args) { + let message = '┬──┬ ノ( ゜-゜ノ)'; + if (args) { + message = message + ' ' + args; + } + return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + }, + category: CommandCategories.messages, + }), new Command({ command: 'lenny', args: '', @@ -517,6 +547,7 @@ export const Commands = [ action: 'view_room', room_alias: roomAlias, auto_join: true, + _type: "slash_command", // instrumentation }); return success(); } else if (params[0][0] === '!') { @@ -531,6 +562,7 @@ export const Commands = [ }, via_servers: viaServers, // for the rejoin button auto_join: true, + _type: "slash_command", // instrumentation }); return success(); } else if (isPermalink) { @@ -555,6 +587,7 @@ export const Commands = [ const dispatch = { action: 'view_room', auto_join: true, + _type: "slash_command", // instrumentation }; if (entity[0] === '!') dispatch["room_id"] = entity; @@ -998,14 +1031,27 @@ export const Commands = [ description: _td("Opens chat with the given user"), args: "", runFn: function(roomId, userId) { - if (!userId || !userId.startsWith("@") || !userId.includes(":")) { + // easter-egg for now: look up phone numbers through the thirdparty API + // (very dumb phone number detection...) + const isPhoneNumber = userId && /^\+?[0123456789]+$/.test(userId); + if (!userId || (!userId.startsWith("@") || !userId.includes(":")) && !isPhoneNumber) { return reject(this.getUsage()); } return success((async () => { + if (isPhoneNumber) { + 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"); + } + userId = results[0].userid; + } + + const roomId = await ensureDMExists(MatrixClientPeg.get(), userId); + dis.dispatch({ action: 'view_room', - room_id: await ensureDMExists(MatrixClientPeg.get(), userId), + room_id: roomId, }); })()); }, @@ -1039,6 +1085,50 @@ export const Commands = [ }, category: CommandCategories.actions, }), + new Command({ + command: "holdcall", + description: _td("Places the call in the current room on hold"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const call = CallHandler.sharedInstance().getCallForRoom(roomId); + if (!call) { + return reject("No active call in this room"); + } + call.setRemoteOnHold(true); + return success(); + }, + }), + new Command({ + command: "unholdcall", + description: _td("Takes the call in the current room off hold"), + category: CommandCategories.other, + runFn: function(roomId, args) { + const call = CallHandler.sharedInstance().getCallForRoom(roomId); + if (!call) { + return reject("No active call in this room"); + } + call.setRemoteOnHold(false); + 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 @@ -1049,6 +1139,30 @@ export const Commands = [ category: CommandCategories.messages, hideCompletionAfterSpace: true, }), + + ...CHAT_EFFECTS.map((effect) => { + return new Command({ + command: effect.command, + description: effect.description(), + args: '', + runFn: function(roomId, args) { + return success((async () => { + if (!args) { + args = effect.fallbackMessage(); + MatrixClientPeg.get().sendEmoteMessage(roomId, args); + } else { + const content = { + msgtype: effect.msgType, + body: args, + }; + MatrixClientPeg.get().sendMessage(roomId, content); + } + dis.dispatch({action: `effects.${effect.command}`}); + })()); + }, + category: CommandCategories.effects, + }) + }), ]; // build a map from names and aliases to the Command objects. @@ -1066,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/TextForEvent.js b/src/TextForEvent.js index 9589299e72..3afe41d216 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -19,6 +19,7 @@ import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; import SettingsStore from "./settings/SettingsStore"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; +import {WIDGET_LAYOUT_EVENT_TYPE} from "./stores/widgets/WidgetLayoutStore"; function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" @@ -300,14 +301,27 @@ function textForCallHangupEvent(event) { reason = _t('(not supported by this browser)'); } else if (eventContent.reason) { if (eventContent.reason === "ice_failed") { + // We couldn't establish a connection at all reason = _t('(could not connect media)'); + } else if (eventContent.reason === "ice_timeout") { + // We established a connection but it died + reason = _t('(connection failed)'); + } else if (eventContent.reason === "user_media_failed") { + // The other side couldn't open capture devices + reason = _t("(their device couldn't start the camera / microphone)"); + } else if (eventContent.reason === "unknown_error") { + // An error code the other side doesn't have a way to express + // (as opposed to an error code they gave but we don't know about, + // in which case we show the error code) + reason = _t("(an error occurred)"); } else if (eventContent.reason === "invite_timeout") { reason = _t('(no answer)'); - } else if (eventContent.reason === "user hangup") { + } else if (eventContent.reason === "user hangup" || eventContent.reason === "user_hangup") { // workaround for https://github.com/vector-im/element-web/issues/5178 // it seems Android randomly sets a reason of "user hangup" which is // interpreted as an error code :( // https://github.com/vector-im/riot-android/issues/2623 + // Also the correct hangup code as of VoIP v1 (with underscore) reason = ''; } else { reason = _t('(unknown failure: %(reason)s)', {reason: eventContent.reason}); @@ -316,6 +330,11 @@ function textForCallHangupEvent(event) { return _t('%(senderName)s ended the call.', {senderName}) + ' ' + reason; } +function textForCallRejectEvent(event) { + const senderName = event.sender ? event.sender.name : _t('Someone'); + return _t('%(senderName)s declined the call.', {senderName}); +} + function textForCallInviteEvent(event) { const senderName = event.sender ? event.sender.name : _t('Someone'); // FIXME: Find a better way to determine this from the event? @@ -437,7 +456,7 @@ function textForWidgetEvent(event) { let widgetName = name || prevName || type || prevType || ''; // Apply sentence case to widget name if (widgetName && widgetName.length > 0) { - widgetName = widgetName[0].toUpperCase() + widgetName.slice(1) + ' '; + widgetName = widgetName[0].toUpperCase() + widgetName.slice(1); } // If the widget was removed, its content should be {}, but this is sufficiently @@ -459,6 +478,11 @@ function textForWidgetEvent(event) { } } +function textForWidgetLayoutEvent(event) { + const senderName = event.sender?.name || event.getSender(); + return _t("%(senderName)s has updated the widget layout", {senderName}); +} + function textForMjolnirEvent(event) { const senderName = event.getSender(); const {entity: prevEntity} = event.getPrevContent(); @@ -545,6 +569,7 @@ const handlers = { 'm.call.invite': textForCallInviteEvent, 'm.call.answer': textForCallAnswerEvent, 'm.call.hangup': textForCallHangupEvent, + 'm.call.reject': textForCallRejectEvent, }; const stateHandlers = { @@ -564,6 +589,7 @@ const stateHandlers = { // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) 'im.vector.modular.widgets': textForWidgetEvent, + [WIDGET_LAYOUT_EVENT_TYPE]: textForWidgetLayoutEvent, }; // Add all the Mjolnir stuff to the renderer diff --git a/src/Unread.js b/src/Unread.js index cf131cac00..ddf225ac64 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -16,12 +16,14 @@ limitations under the License. import {MatrixClientPeg} from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; -import * as sdk from "./index"; import {haveTileForEvent} from "./components/views/rooms/EventTile"; /** * Returns true iff this event arriving in a room should affect the room's * count of unread messages + * + * @param {Object} ev The event + * @returns {boolean} True if the given event should affect the unread message count */ export function eventTriggersUnreadCount(ev) { if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { 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 new file mode 100644 index 0000000000..d919615349 --- /dev/null +++ b/src/VoipUserMapper.ts @@ -0,0 +1,110 @@ +/* +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 { ensureVirtualRoomExists, findDMForUser } from './createRoom'; +import { MatrixClientPeg } from "./MatrixClientPeg"; +import DMRoomMap from "./utils/DMRoomMap"; +import CallHandler, { VIRTUAL_ROOM_EVENT_TYPE } from './CallHandler'; +import { Room } from 'matrix-js-sdk/src/models/room'; + +// Functions for mapping virtual users & rooms. Currently the only lookup +// is sip virtual: there could be others in the future. + +export default class VoipUserMapper { + private virtualRoomIdCache = new Set(); + + public static sharedInstance(): VoipUserMapper { + if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper(); + return window.mxVoipUserMapper; + } + + private async userToVirtualUser(userId: string): Promise { + const results = await CallHandler.sharedInstance().sipVirtualLookup(userId); + if (results.length === 0) return null; + return results[0].userid; + } + + public async getOrCreateVirtualRoomForRoom(roomId: string):Promise { + const userId = DMRoomMap.shared().getUserIdForRoomId(roomId); + if (!userId) return null; + + const virtualUser = await this.userToVirtualUser(userId); + if (!virtualUser) return null; + + const virtualRoomId = await ensureVirtualRoomExists(MatrixClientPeg.get(), virtualUser, roomId); + MatrixClientPeg.get().setRoomAccountData(virtualRoomId, VIRTUAL_ROOM_EVENT_TYPE, { + native_room: roomId, + }); + + return virtualRoomId; + } + + 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; + } + + public isVirtualRoom(room: Room):boolean { + if (this.nativeRoomForVirtualRoom(room.roomId)) return true; + + 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); + } + } +} diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 58d8124122..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)"), }, ], @@ -257,6 +263,12 @@ const shortcuts: Record = { key: Key.SLASH, }], description: _td("Toggle this dialog"), + }, { + keybinds: [{ + modifiers: [CMD_OR_CTRL, Modifiers.ALT], + key: Key.H, + }], + description: _td("Go to Home View"), }, ], diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 434b931296..b49a90d175 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -205,7 +205,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn // onFocus should be called when the index gained focus in any manner // isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` // ref should be passed to a DOM node which will be used for DOM compareDocumentPosition -export const useRovingTabIndex = (inputRef: Ref): [FocusHandler, boolean, Ref] => { +export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => { const context = useContext(RovingTabIndexContext); let ref = useRef(null); 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/async-components/views/dialogs/security/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js index ab39a094db..863ee2b427 100644 --- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js @@ -95,7 +95,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const blob = new Blob([this._keyBackupInfo.recovery_key], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + FileSaver.saveAs(blob, 'security-key.txt'); this.setState({ downloaded: true, @@ -238,7 +238,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { )}

{_t( "We'll store an encrypted copy of your keys on our server. " + - "Secure your backup with a recovery passphrase.", + "Secure your backup with a Security Phrase.", )}

{_t("For maximum security, this should be different from your account password.")}

@@ -252,10 +252,10 @@ export default class CreateKeyBackupDialog extends React.PureComponent { onValidate={this._onPassPhraseValidate} fieldRef={this._passphraseField} autoFocus={true} - label={_td("Enter a recovery passphrase")} - labelEnterPassword={_td("Enter a recovery passphrase")} - labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")} - labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")} + label={_td("Enter a Security Phrase")} + labelEnterPassword={_td("Enter a Security Phrase")} + labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")} + labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")} /> @@ -270,7 +270,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
{_t("Advanced")} - {_t("Set up with a recovery key")} + {_t("Set up with a Security Key")}
; @@ -310,7 +310,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( - "Please enter your recovery passphrase a second time to confirm.", + "Please enter your Security Phrase a second time to confirm.", )}

@@ -319,7 +319,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent { onChange={this._onPassPhraseConfirmChange} value={this.state.passPhraseConfirm} className="mx_CreateKeyBackupDialog_passPhraseInput" - placeholder={_t("Repeat your recovery passphrase...")} + placeholder={_t("Repeat your Security Phrase...")} autoFocus={true} />
@@ -338,15 +338,15 @@ export default class CreateKeyBackupDialog extends React.PureComponent { _renderPhaseShowKey() { return

{_t( - "Your recovery key is a safety net - you can use it to restore " + - "access to your encrypted messages if you forget your recovery passphrase.", + "Your Security Key is a safety net - you can use it to restore " + + "access to your encrypted messages if you forget your Security Phrase.", )}

{_t( "Keep a copy of it somewhere secure, like a password manager or even a safe.", )}

- {_t("Your recovery key")} + {_t("Your Security Key")}
@@ -369,12 +369,12 @@ export default class CreateKeyBackupDialog extends React.PureComponent { let introText; if (this.state.copied) { introText = _t( - "Your recovery key has been copied to your clipboard, paste it to:", + "Your Security Key has been copied to your clipboard, paste it to:", {}, {b: s => {s}}, ); } else if (this.state.downloaded) { introText = _t( - "Your recovery key is in your Downloads folder.", + "Your Security Key is in your Downloads folder.", {}, {b: s => {s}}, ); } @@ -433,14 +433,14 @@ export default class CreateKeyBackupDialog extends React.PureComponent { _titleForPhase(phase) { switch (phase) { case PHASE_PASSPHRASE: - return _t('Secure your backup with a recovery passphrase'); + return _t('Secure your backup with a Security Phrase'); case PHASE_PASSPHRASE_CONFIRM: - return _t('Confirm your recovery passphrase'); + return _t('Confirm your Security Phrase'); case PHASE_OPTOUT_CONFIRM: return _t('Warning!'); case PHASE_SHOWKEY: case PHASE_KEEPITSAFE: - return _t('Make a copy of your recovery key'); + return _t('Make a copy of your Security Key'); case PHASE_BACKINGUP: return _t('Starting backup...'); case PHASE_DONE: diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index 6819742388..84cb58536a 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -235,7 +235,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const blob = new Blob([this._recoveryKey.encodedPrivateKey], { type: 'text/plain;charset=us-ascii', }); - FileSaver.saveAs(blob, 'recovery-key.txt'); + FileSaver.saveAs(blob, 'security-key.txt'); this.setState({ downloaded: true, @@ -470,6 +470,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { value={CREATE_STORAGE_OPTION_KEY} name="keyPassphrase" checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY} + onChange={this._onKeyPassphraseChange} outlined >
@@ -488,6 +489,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { value={CREATE_STORAGE_OPTION_PASSPHRASE} name="keyPassphrase" checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE} + onChange={this._onKeyPassphraseChange} outlined >
@@ -509,7 +511,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", )}

-
+
{optionKey} {optionPassphrase}
@@ -591,10 +593,10 @@ export default class CreateSecretStorageDialog extends React.PureComponent { onValidate={this._onPassPhraseValidate} fieldRef={this._passphraseField} autoFocus={true} - label={_td("Enter a recovery passphrase")} - labelEnterPassword={_td("Enter a recovery passphrase")} - labelStrongPassword={_td("Great! This recovery passphrase looks strong enough.")} - labelAllowedButUnsafe={_td("Great! This recovery passphrase looks strong enough.")} + label={_td("Enter a Security Phrase")} + labelEnterPassword={_td("Enter a Security Phrase")} + labelStrongPassword={_td("Great! This Security Phrase looks strong enough.")} + labelAllowedButUnsafe={_td("Great! This Security Phrase looks strong enough.")} />
diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js index 9f5045635d..8c09cc6d16 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js @@ -58,7 +58,7 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { ; const newMethodDetected =

{_t( - "A new recovery passphrase and key for Secure Messages have been detected.", + "A new Security Phrase and key for Secure Messages have been detected.", )}

; const hackWarning =

{_t( diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js index cda353e717..b60e6fd3cb 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js @@ -56,7 +56,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { >

{_t( - "This session has detected that your recovery passphrase and key " + + "This session has detected that your Security Phrase and key " + "for Secure Messages have been removed.", )}

{_t( 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 884f77aba5..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 return {left, top, chevronOffset}; }; -// Placement method for to position context menu right-aligned and flowing to the left of elementRect -export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None) => { +// Placement method for to position context menu right-aligned and flowing to the left of elementRect, +// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?) +export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; const buttonRight = elementRect.right + window.pageXOffset; @@ -408,16 +410,52 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None menuOptions.right = window.innerWidth - buttonRight; // Align the menu vertically on whichever side of the button has more space available. if (buttonBottom < window.innerHeight / 2) { - menuOptions.top = buttonBottom; + menuOptions.top = buttonBottom + vPadding; } else { - menuOptions.bottom = window.innerHeight - buttonTop; + menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; } return menuOptions; }; -export const useContextMenu = (): [boolean, RefObject, () => void, () => void, (val: boolean) => void] => { - const button = useRef(null); +// Placement method for to position context menu right-aligned and flowing to the left of elementRect +// and always above elementRect +export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { + const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; + + const buttonRight = elementRect.right + window.pageXOffset; + const buttonBottom = elementRect.bottom + window.pageYOffset; + const buttonTop = elementRect.top + window.pageYOffset; + // Align the right edge of the menu to the right edge of the button + menuOptions.right = window.innerWidth - buttonRight; + // Align the menu vertically on whichever side of the button has more space available. + if (buttonBottom < window.innerHeight / 2) { + menuOptions.top = buttonBottom + vPadding; + } else { + menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; + } + + return menuOptions; +}; + +// Placement method for to position context menu right-aligned and flowing to the right of elementRect +// and always above elementRect +export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { + const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; + + const buttonLeft = elementRect.left + window.pageXOffset; + const buttonTop = elementRect.top + window.pageYOffset; + // Align the left edge of the menu to the left edge of the button + menuOptions.left = buttonLeft; + // Align the menu vertically above the menu + menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; + + return menuOptions; +}; + +type ContextMenuTuple = [boolean, RefObject, () => void, () => void, (val: boolean) => void]; +export const useContextMenu = (): ContextMenuTuple => { + const button = useRef(null); const [isOpen, setIsOpen] = useState(false); const open = () => { setIsOpen(true); 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/GroupView.js b/src/components/structures/GroupView.js index 482b9f6da2..bbc4187298 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -47,7 +47,7 @@ const LONG_DESC_PLACEHOLDER = _td( some important links

- You can even use 'img' tags + You can even add images with Matrix URLs

`); diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index a42032c9fe..68bb4322e6 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -15,20 +15,83 @@ limitations under the License. */ import * as React from "react"; +import {useContext, useState} from "react"; import AutoHideScrollbar from './AutoHideScrollbar'; -import { getHomePageUrl } from "../../utils/pages"; -import { _t } from "../../languageHandler"; +import {getHomePageUrl} from "../../utils/pages"; +import {_t} from "../../languageHandler"; import SdkConfig from "../../SdkConfig"; import * as sdk from "../../index"; import dis from "../../dispatcher/dispatcher"; -import { Action } from "../../dispatcher/actions"; +import {Action} from "../../dispatcher/actions"; +import BaseAvatar from "../views/avatars/BaseAvatar"; +import {OwnProfileStore} from "../../stores/OwnProfileStore"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import {UPDATE_EVENT} from "../../stores/AsyncStore"; +import {useEventEmitter} from "../../hooks/useEventEmitter"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import MiniAvatarUploader, {AVATAR_SIZE} from "../views/elements/MiniAvatarUploader"; +import Analytics from "../../Analytics"; +import CountlyAnalytics from "../../CountlyAnalytics"; -const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'}); -const onClickExplore = () => dis.fire(Action.ViewRoomDirectory); -const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'}); +const onClickSendDm = () => { + Analytics.trackEvent('home_page', 'button', 'dm'); + CountlyAnalytics.instance.track("home_page_button", { button: "dm" }); + dis.dispatch({action: 'view_create_chat'}); +}; -const HomePage = () => { +const onClickExplore = () => { + Analytics.trackEvent('home_page', 'button', 'room_directory'); + CountlyAnalytics.instance.track("home_page_button", { button: "room_directory" }); + dis.fire(Action.ViewRoomDirectory); +}; + +const onClickNewRoom = () => { + Analytics.trackEvent('home_page', 'button', 'create_room'); + CountlyAnalytics.instance.track("home_page_button", { button: "create_room" }); + dis.dispatch({action: 'view_create_room'}); +}; + +interface IProps { + justRegistered?: boolean; +} + +const getOwnProfile = (userId: string) => ({ + displayName: OwnProfileStore.instance.displayName || userId, + avatarUrl: OwnProfileStore.instance.getHttpAvatarUrl(AVATAR_SIZE), +}); + +const UserWelcomeTop = () => { + const cli = useContext(MatrixClientContext); + const userId = cli.getUserId(); + const [ownProfile, setOwnProfile] = useState(getOwnProfile(userId)); + useEventEmitter(OwnProfileStore.instance, UPDATE_EVENT, () => { + setOwnProfile(getOwnProfile(userId)); + }); + + return
+ cli.setAvatarUrl(url)} + > + + + +

{ _t("Welcome %(name)s", { name: ownProfile.displayName }) }

+

{ _t("Now, let's help you get started") }

+
; +}; + +const HomePage: React.FC = ({ justRegistered = false }) => { const config = SdkConfig.get(); const pageUrl = getHomePageUrl(config); @@ -37,18 +100,27 @@ const HomePage = () => { return ; } - const brandingConfig = config.branding; - let logoUrl = "themes/element/img/logos/element-logo.svg"; - if (brandingConfig && brandingConfig.authHeaderLogoUrl) { - logoUrl = brandingConfig.authHeaderLogoUrl; + let introSection; + if (justRegistered) { + introSection = ; + } else { + const brandingConfig = config.branding; + let logoUrl = "themes/element/img/logos/element-logo.svg"; + if (brandingConfig && brandingConfig.authHeaderLogoUrl) { + logoUrl = brandingConfig.authHeaderLogoUrl; + } + + introSection = + {config.brand} +

{ _t("Welcome to %(appName)s", { appName: config.brand }) }

+

{ _t("Liberate your communication") }

+
; } - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + return
- {config.brand -

{ _t("Welcome to %(appName)s", { appName: config.brand || "Element" }) }

-

{ _t("Liberate your communication") }

+ { introSection }
{ _t("Send a Direct Message") } 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 262d12a700..82dd9443cc 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -38,6 +38,8 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; 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; @@ -142,7 +144,7 @@ export default class LeftPanel extends React.Component { const bottomEdge = list.offsetHeight + list.scrollTop; const sublists = list.querySelectorAll(".mx_RoomSublist"); - const headerRightMargin = 16; // calculated from margins and widths to align with non-sticky tiles + const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles const headerStickyWidth = list.clientWidth - headerRightMargin; // We track which styles we want on a target before making the changes to avoid @@ -213,10 +215,19 @@ export default class LeftPanel extends React.Component { if (!header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); } + + const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); + const newBottom = `${offset}px`; + if (header.style.bottom !== newBottom) { + header.style.bottom = newBottom; + } } else { if (header.classList.contains("mx_RoomSublist_headerContainer_stickyBottom")) { header.classList.remove("mx_RoomSublist_headerContainer_stickyBottom"); } + if (header.style.bottom) { + header.style.removeProperty('bottom'); + } } if (style.stickyTop || style.stickyBottom) { @@ -378,17 +389,23 @@ 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, }); @@ -408,7 +424,7 @@ export default class LeftPanel extends React.Component { return (
- {groupFilterPanel} + {leftLeftPanel}
+ { !this.props.isMinimized && }
); diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx new file mode 100644 index 0000000000..e88af282ba --- /dev/null +++ b/src/components/structures/LeftPanelWidget.tsx @@ -0,0 +1,149 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {useContext, useEffect, useMemo} from "react"; +import {Resizable} from "re-resizable"; +import classNames from "classnames"; + +import AccessibleButton from "../views/elements/AccessibleButton"; +import {useRovingTabIndex} from "../../accessibility/RovingTabIndex"; +import {Key} from "../../Keyboard"; +import {useLocalStorageState} from "../../hooks/useLocalStorageState"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils"; +import {useAccountData} from "../../hooks/useAccountData"; +import AppTile from "../views/elements/AppTile"; +import {useSettingValue} from "../../hooks/useSettings"; + +interface IProps { + onResize(): void; +} + +const MIN_HEIGHT = 100; +const MAX_HEIGHT = 500; // or 50% of the window height +const INITIAL_HEIGHT = 280; + +const LeftPanelWidget: React.FC = ({ onResize }) => { + const cli = useContext(MatrixClientContext); + + const mWidgetsEvent = useAccountData>(cli, "m.widgets"); + const leftPanelWidgetId = useSettingValue("Widgets.leftPanel"); + const app = useMemo(() => { + if (!mWidgetsEvent || !leftPanelWidgetId) return null; + const widgetConfig = Object.values(mWidgetsEvent).find(w => w.id === leftPanelWidgetId); + if (!widgetConfig) return null; + + return WidgetUtils.makeAppConfig( + widgetConfig.state_key, + widgetConfig.content, + widgetConfig.sender, + null, + widgetConfig.id); + }, [mWidgetsEvent, leftPanelWidgetId]); + + const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT); + const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true); + useEffect(onResize, [expanded, onResize]); + + const [onFocus, isActive, ref] = useRovingTabIndex(); + const tabIndex = isActive ? 0 : -1; + + if (!app) return null; + + let content; + if (expanded) { + content = { + setHeight(height + d.height); + }} + handleWrapperClass="mx_LeftPanelWidget_resizerHandles" + handleClasses={{top: "mx_LeftPanelWidget_resizerHandle"}} + className="mx_LeftPanelWidget_resizeBox" + enable={{ top: true }} + > + + ; + } + + return
+
{ + switch (ev.key) { + case Key.ARROW_LEFT: + ev.stopPropagation(); + setExpanded(false); + break; + case Key.ARROW_RIGHT: { + ev.stopPropagation(); + setExpanded(true); + break; + } + } + }} + > +
+ { + setExpanded(e => !e); + }} + > + + { WidgetUtils.getWidgetName(app) } + + + {/* Code for the maximise button for once we have full screen widgets */} + {/* { + }} + className="mx_LeftPanelWidget_maximizeButton" + tooltipClassName="mx_LeftPanelWidget_maximizeButtonTooltip" + title={_t("Maximize")} + />*/} +
+
+ + { content } +
; +}; + +export default LeftPanelWidget; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 15ea20618e..4e768bd9e5 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -21,7 +21,7 @@ import * as PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { DragDropContext } from 'react-beautiful-dnd'; -import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent} from '../../Keyboard'; +import {Key, isOnlyCtrlOrCmdKeyEvent, isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isMac} from '../../Keyboard'; import PageTypes from '../../PageTypes'; import CallMediaHandler from '../../CallMediaHandler'; import { fixupColorFonts } from '../../utils/FontManager'; @@ -52,6 +52,10 @@ import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import Modal from "../../Modal"; +import { ICollapseConfig } from "../../resizer/distributors/collapse"; +import HostSignupContainer from '../views/host_signup/HostSignupContainer'; +import { IOpts } from "../../createRoom"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -87,6 +91,8 @@ interface IProps { currentUserId?: string; currentGroupId?: string; currentGroupIsNew?: boolean; + justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; } interface IUsageLimit { @@ -103,7 +109,9 @@ interface IState { errcode: string; }; }; + usageLimitDismissed: boolean; usageLimitEventContent?: IUsageLimit; + usageLimitEventTs?: number; useCompactLayout: boolean; } @@ -137,7 +145,7 @@ class LoggedInView extends React.Component { protected readonly _matrixClient: MatrixClient; protected readonly _roomView: React.RefObject; protected readonly _resizeContainer: React.RefObject; - protected readonly _compactLayoutWatcherRef: string; + protected compactLayoutWatcherRef: string; protected resizer: Resizer; constructor(props, context) { @@ -147,6 +155,7 @@ class LoggedInView extends React.Component { syncErrorData: undefined, // use compact timeline view useCompactLayout: SettingsStore.getValue('useCompactLayout'), + usageLimitDismissed: false, }; // stash the MatrixClient in case we log out before we are unmounted @@ -154,18 +163,6 @@ class LoggedInView extends React.Component { CallMediaHandler.loadDevices(); - document.addEventListener('keydown', this._onNativeKeyDown, false); - - this._updateServerNoticeEvents(); - - this._matrixClient.on("accountData", this.onAccountData); - this._matrixClient.on("sync", this.onSync); - this._matrixClient.on("RoomState.events", this.onRoomStateEvents); - - this._compactLayoutWatcherRef = SettingsStore.watchSetting( - "useCompactLayout", null, this.onCompactLayoutChanged, - ); - fixupColorFonts(); this._roomView = React.createRef(); @@ -173,6 +170,24 @@ class LoggedInView extends React.Component { } componentDidMount() { + document.addEventListener('keydown', this._onNativeKeyDown, false); + + this._updateServerNoticeEvents(); + + this._matrixClient.on("accountData", this.onAccountData); + this._matrixClient.on("sync", this.onSync); + // Call `onSync` with the current state as well + this.onSync( + this._matrixClient.getSyncState(), + null, + this._matrixClient.getSyncStateData(), + ); + this._matrixClient.on("RoomState.events", this.onRoomStateEvents); + + this.compactLayoutWatcherRef = SettingsStore.watchSetting( + "useCompactLayout", null, this.onCompactLayoutChanged, + ); + this.resizer = this._createResizer(); this.resizer.attach(); this._loadResizerPreferences(); @@ -183,7 +198,7 @@ class LoggedInView extends React.Component { this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); - SettingsStore.unwatchSetting(this._compactLayoutWatcherRef); + SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); this.resizer.detach(); } @@ -205,16 +220,20 @@ class LoggedInView extends React.Component { }; _createResizer() { - const classNames = { - handle: "mx_ResizeHandle", - vertical: "mx_ResizeHandle_vertical", - reverse: "mx_ResizeHandle_reverse", - }; let size; - const collapseConfig = { - toggleSize: 260 - 50, - onCollapsed: (collapsed) => { - if (collapsed) { + let collapsed; + const collapseConfig: ICollapseConfig = { + // TODO: the space panel currently does not have a fixed width, + // just the headers at each level have a max-width of 150px + // Taking 222px for the space panel for now, + // so this will look slightly off for now, + // depending on the depth of your space tree. + // To fix this, we'll need to turn toggleSize + // into a callback so it can be measured when starting the resize operation + toggleSize: 222 + 68, + onCollapsed: (_collapsed) => { + collapsed = _collapsed; + if (_collapsed) { dis.dispatch({action: "hide_left_panel"}, true); window.localStorage.setItem("mx_lhs_size", '0'); } else { @@ -229,12 +248,19 @@ class LoggedInView extends React.Component { this.props.resizeNotifier.startResizing(); }, onResizeStop: () => { - window.localStorage.setItem("mx_lhs_size", '' + size); + if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size); this.props.resizeNotifier.stopResizing(); }, + isItemCollapsed: domNode => { + return domNode.classList.contains("mx_LeftPanel_minimized"); + }, }; const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig); - resizer.setClassNames(classNames); + resizer.setClassNames({ + handle: "mx_ResizeHandle", + vertical: "mx_ResizeHandle_vertical", + reverse: "mx_ResizeHandle_reverse", + }); return resizer; } @@ -291,14 +317,27 @@ class LoggedInView extends React.Component { } }; + private onUsageLimitDismissed = () => { + this.setState({ + usageLimitDismissed: true, + }); + } + _calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { usageLimitEventContent = syncError.error.data; } - if (usageLimitEventContent) { - showServerLimitToast(usageLimitEventContent.limit_type, usageLimitEventContent.admin_contact, error); + // usageLimitDismissed is true when the user has explicitly hidden the toast + // and it will be reset to false if a *new* usage alert comes in. + if (usageLimitEventContent && this.state.usageLimitDismissed) { + showServerLimitToast( + usageLimitEventContent.limit_type, + this.onUsageLimitDismissed, + usageLimitEventContent.admin_contact, + error, + ); } else { hideServerLimitToast(); } @@ -309,10 +348,12 @@ class LoggedInView extends React.Component { if (!serverNoticeList) return []; const events = []; + let pinnedEventTs = 0; for (const room of serverNoticeList) { const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", ""); if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue; + pinnedEventTs = pinStateEvent.getTs(); const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM); for (const eventId of pinnedEventIds) { @@ -322,6 +363,11 @@ class LoggedInView extends React.Component { } } + if (pinnedEventTs && this.state.usageLimitEventTs > pinnedEventTs) { + // We've processed a newer event than this one, so ignore it. + return; + } + const usageLimitEvent = events.find((e) => { return ( e && e.getType() === 'm.room.message' && @@ -330,7 +376,12 @@ class LoggedInView extends React.Component { }); const usageLimitEventContent = usageLimitEvent && usageLimitEvent.getContent(); this._calculateServerLimitToast(this.state.syncErrorData, usageLimitEventContent); - this.setState({ usageLimitEventContent }); + this.setState({ + usageLimitEventContent, + usageLimitEventTs: pinnedEventTs, + // This is a fresh toast, we can show toasts again + usageLimitDismissed: false, + }); }; _onPaste = (ev) => { @@ -391,6 +442,7 @@ class LoggedInView extends React.Component { const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); const hasModifier = ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey; const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT; + const modKey = isMac ? ev.metaKey : ev.ctrlKey; switch (ev.key) { case Key.PAGE_UP: @@ -416,6 +468,14 @@ class LoggedInView extends React.Component { handled = true; } break; + case Key.F: + if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) { + dis.dispatch({ + action: 'focus_search', + }); + handled = true; + } + break; case Key.BACKTICK: // Ideally this would be CTRL+P for "Profile", but that's // taken by the print dialog. CTRL+I for "Information" @@ -435,6 +495,16 @@ class LoggedInView extends React.Component { } break; + case Key.H: + if (ev.altKey && modKey) { + dis.dispatch({ + action: 'view_home_page', + }); + Modal.closeCurrentModal("homeKeyboardShortcut"); + handled = true; + } + break; + case Key.ARROW_UP: case Key.ARROW_DOWN: if (ev.altKey && !ev.ctrlKey && !ev.metaKey) { @@ -561,6 +631,7 @@ class LoggedInView extends React.Component { viaServers={this.props.viaServers} key={this.props.currentRoomId || 'roomview'} resizeNotifier={this.props.resizeNotifier} + justCreatedOpts={this.props.roomJustCreatedOpts} />; break; @@ -573,7 +644,7 @@ class LoggedInView extends React.Component { break; case PageTypes.HomePage: - pageElement = ; + pageElement = ; break; case PageTypes.UserView: @@ -619,6 +690,7 @@ class LoggedInView extends React.Component {
+ ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index f7665fea8a..1700b627db 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -29,11 +29,11 @@ import 'focus-visible'; import 'what-input'; import Analytics from "../../Analytics"; +import CountlyAnalytics from "../../CountlyAnalytics"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; import { MatrixClientPeg, IMatrixClientCreds } from "../../MatrixClientPeg"; import PlatformPeg from "../../PlatformPeg"; import SdkConfig from "../../SdkConfig"; -import * as RoomListSorter from "../../RoomListSorter"; import dis from "../../dispatcher/dispatcher"; import Notifier from '../../Notifier'; @@ -47,9 +47,8 @@ import * as Lifecycle from '../../Lifecycle'; // LifecycleStore is not used but does listen to and dispatch actions import '../../stores/LifecycleStore'; import PageTypes from '../../PageTypes'; -import { getHomePageUrl } from '../../utils/pages'; -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"; @@ -61,7 +60,7 @@ import DMRoomMap from '../../utils/DMRoomMap'; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from '../../settings/watchers/FontWatcher'; import { storeRoomAliasInCache } from '../../RoomAliasCache'; -import { defer, IDeferred } from "../../utils/promise"; +import { defer, IDeferred, sleep } from "../../utils/promise"; import ToastStore from "../../stores/ToastStore"; import * as StorageManager from "../../utils/StorageManager"; import type LoggedInViewType from "./LoggedInView"; @@ -81,42 +80,46 @@ import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityProt import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore"; 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 { // a special initial state which is only used at startup, while we are // trying to re-animate a matrix client or register as a guest. - LOADING = 0, + LOADING, // we are showing the welcome view - WELCOME = 1, + WELCOME, // we are showing the login view - LOGIN = 2, + LOGIN, // we are showing the registration view - REGISTER = 3, - - // completing the registration flow - POST_REGISTRATION = 4, + REGISTER, // showing the 'forgot password' view - FORGOT_PASSWORD = 5, + FORGOT_PASSWORD, // showing flow to trust this new device with cross-signing - COMPLETE_SECURITY = 6, + COMPLETE_SECURITY, // flow to setup SSSS / cross-signing on this account - E2E_SETUP = 7, + E2E_SETUP, - // we are logged in with an active matrix client. - LOGGED_IN = 8, + // we are logged in with an active matrix client. The logged_in state also + // includes guests users as they too are logged in at the client level. + LOGGED_IN, // We are logged out (invalid token) but have our local state again. The user // should log back in to rehydrate the client. - SOFT_LOGOUT = 9, + SOFT_LOGOUT, } +const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"]; + // Actions that are redirected through the onboarding process prior to being // re-dispatched. NOTE: some actions are non-trivial and would require // re-factoring to be included in this list in future. @@ -143,6 +146,8 @@ interface IRoomInfo { oob_data?: object; via_servers?: string[]; threepid_invite?: IThreepidInvite; + + justCreatedOpts?: IOpts; } /* eslint-enable camelcase */ @@ -199,6 +204,8 @@ interface IState { roomOobData?: object; viaServers?: string[]; pendingInitialSync?: boolean; + justRegistered?: boolean; + roomJustCreatedOpts?: IOpts; } export default class MatrixChat extends React.PureComponent { @@ -217,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; @@ -322,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 @@ -349,6 +365,43 @@ export default class MatrixChat extends React.PureComponent { if (SettingsStore.getValue("analyticsOptIn")) { Analytics.enable(); } + 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 @@ -363,6 +416,7 @@ export default class MatrixChat extends React.PureComponent { if (this.shouldTrackPageChange(prevState, this.state)) { const durationMs = this.stopPageChangeTimer(); Analytics.trackPageChange(durationMs); + CountlyAnalytics.instance.trackPageChange(durationMs); } if (this.focusComposer) { dis.fire(Action.FocusComposer); @@ -415,6 +469,8 @@ export default class MatrixChat extends React.PureComponent { } else { dis.dispatch({action: "view_welcome_page"}); } + } else if (SettingsStore.getValue("analyticsOptIn")) { + CountlyAnalytics.instance.enable(/* anonymous = */ false); } }); // Note we don't catch errors from this: we catch everything within @@ -473,6 +529,7 @@ export default class MatrixChat extends React.PureComponent { } const newState = { currentUserId: null, + justRegistered: false, }; Object.assign(newState, state); this.setState(newState); @@ -554,11 +611,6 @@ export default class MatrixChat extends React.PureComponent { ThemeController.isLogin = true; this.themeWatcher.recheck(); break; - case 'start_post_registration': - this.setState({ - view: Views.POST_REGISTRATION, - }); - break; case 'start_password_recovery': this.setStateForNewView({ view: Views.FORGOT_PASSWORD, @@ -589,7 +641,7 @@ export default class MatrixChat extends React.PureComponent { MatrixClientPeg.get().leave(payload.room_id).then(() => { modal.close(); if (this.state.currentRoomId === payload.room_id) { - dis.dispatch({action: 'view_next_room'}); + dis.dispatch({action: 'view_home_page'}); } }, (err) => { modal.close(); @@ -618,9 +670,6 @@ export default class MatrixChat extends React.PureComponent { } break; } - case 'view_next_room': - this.viewNextRoom(1); - break; case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); @@ -644,9 +693,17 @@ export default class MatrixChat extends React.PureComponent { break; } case Action.ViewRoomDirectory: { - const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); - Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, - '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(); @@ -663,13 +720,13 @@ export default class MatrixChat extends React.PureComponent { this.viewWelcome(); break; case 'view_home_page': - this.viewHome(); + this.viewHome(payload.justRegistered); break; case 'view_start_chat_or_reuse': this.chatCreateOrReuse(payload.user_id); break; case 'view_create_chat': - showStartChatInviteDialog(); + showStartChatInviteDialog(payload.initialText || ""); break; case 'view_invite': showRoomInviteDialog(payload.roomId); @@ -705,8 +762,13 @@ export default class MatrixChat extends React.PureComponent { this.state.resizeNotifier.notifyLeftHandleResized(); }); break; + case Action.OpenDialPad: + Modal.createTrackedDialog('Dial pad', '', DialPadModal, {}, "mx_Dialog_dialPadWrapper"); + 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 && @@ -750,7 +812,12 @@ export default class MatrixChat extends React.PureComponent { SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, true); SettingsStore.setValue("showCookieBar", null, SettingLevel.DEVICE, false); hideAnalyticsToast(); - Analytics.enable(); + if (Analytics.canEnable()) { + Analytics.enable(); + } + if (CountlyAnalytics.instance.canEnable()) { + CountlyAnalytics.instance.enable(/* anonymous = */ false); + } break; case 'reject_cookies': SettingsStore.setValue("analyticsOptIn", null, SettingLevel.DEVICE, false); @@ -794,35 +861,6 @@ export default class MatrixChat extends React.PureComponent { this.notifyNewScreen('register'); } - // TODO: Move to RoomViewStore - private viewNextRoom(roomIndexDelta: number) { - const allRooms = RoomListSorter.mostRecentActivityFirst( - MatrixClientPeg.get().getRooms(), - ); - // If there are 0 rooms or 1 room, view the home page because otherwise - // if there are 0, we end up trying to index into an empty array, and - // if there is 1, we end up viewing the same room. - if (allRooms.length < 2) { - dis.dispatch({ - action: 'view_home_page', - }); - return; - } - let roomIndex = -1; - for (let i = 0; i < allRooms.length; ++i) { - if (allRooms[i].roomId === this.state.currentRoomId) { - roomIndex = i; - break; - } - } - roomIndex = (roomIndex + roomIndexDelta) % allRooms.length; - if (roomIndex < 0) roomIndex = allRooms.length - 1; - dis.dispatch({ - action: 'view_room', - room_id: allRooms[roomIndex].roomId, - }); - } - // switch view to the given room // // @param {Object} roomInfo Object containing data about the room to be joined @@ -896,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); }); @@ -942,10 +981,11 @@ export default class MatrixChat extends React.PureComponent { this.themeWatcher.recheck(); } - private viewHome() { + private viewHome(justRegistered = false) { // The home page requires the "logged in" view, so we'll set that. this.setStateForNewView({ view: Views.LOGGED_IN, + justRegistered, }); this.setPage(PageTypes.HomePage); this.notifyNewScreen('home'); @@ -1041,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 = []; @@ -1050,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.") } )); } @@ -1063,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 } ), @@ -1081,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, + }); } }, }); @@ -1088,9 +1138,9 @@ export default class MatrixChat extends React.PureComponent { private forgetRoom(roomId: string) { MatrixClientPeg.get().forget(roomId).then(() => { - // Switch to another room view if we're currently viewing the historical room + // Switch to home page if we're currently viewing the forgotten room if (this.state.currentRoomId === roomId) { - dis.dispatch({ action: "view_next_room" }); + dis.dispatch({ action: "view_home_page" }); } }).catch((err) => { const errCode = err.errcode || _td("unknown error code"); @@ -1179,7 +1229,7 @@ export default class MatrixChat extends React.PureComponent { if (welcomeUserRoom === null) { // We didn't redirect to the welcome user room, so show // the homepage. - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({action: 'view_home_page', justRegistered: true}); } } else if (ThreepidInviteStore.instance.pickBestInvite()) { // The user has a 3pid invite pending - show them that @@ -1192,7 +1242,7 @@ export default class MatrixChat extends React.PureComponent { } else { // The user has just logged in after registering, // so show the homepage. - dis.dispatch({action: 'view_home_page'}); + dis.dispatch({action: 'view_home_page', justRegistered: true}); } } else { this.showScreenAfterLogin(); @@ -1200,9 +1250,18 @@ export default class MatrixChat extends React.PureComponent { StorageManager.tryPersistStorage(); - if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) { + // defer the following actions by 30 seconds to not throw them at the user immediately + await sleep(30); + if (SettingsStore.getValue("showCookieBar") && + (Analytics.canEnable() || CountlyAnalytics.instance.canEnable()) + ) { 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() { @@ -1220,12 +1279,8 @@ export default class MatrixChat extends React.PureComponent { } else { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'view_welcome_page'}); - } else if (getHomePageUrl(this.props.config)) { - dis.dispatch({action: 'view_home_page'}); } else { - this.firstSyncPromise.promise.then(() => { - dis.dispatch({action: 'view_next_room'}); - }); + dis.dispatch({action: 'view_home_page'}); } } } @@ -1330,8 +1385,8 @@ export default class MatrixChat extends React.PureComponent { this.firstSyncComplete = true; this.firstSyncPromise.resolve(); - if (Notifier.shouldShowPrompt()) { - showNotificationsToast(); + if (Notifier.shouldShowPrompt() && !MatrixClientPeg.userRegisteredWithinLastHours(24)) { + showNotificationsToast(false); } dis.fire(Action.FocusComposer); @@ -1340,21 +1395,12 @@ export default class MatrixChat extends React.PureComponent { }); }); - if (SettingsStore.getValue(UIFeature.Voip)) { - cli.on('Call.incoming', function(call) { - // we dispatch this synchronously to make sure that the event - // handlers on the call are set up immediately (so that if - // we get an immediate hangup, we don't get a stuck call) - dis.dispatch({ - action: 'incoming_call', - call: call, - }, true); - }); - } - 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(); @@ -1365,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', }); @@ -1394,6 +1441,7 @@ export default class MatrixChat extends React.PureComponent { const dft = new DecryptionFailureTracker((total, errorCode) => { Analytics.trackEvent('E2E', 'Decryption failure', errorCode, total); + CountlyAnalytics.instance.track("decryption_failure", { errorCode }, null, { sum: total }); }, (errorCode) => { // Map JS-SDK error codes to tracker codes for aggregation switch (errorCode) { @@ -1535,6 +1583,14 @@ export default class MatrixChat extends React.PureComponent { } showScreen(screen: string, params?: {[key: string]: any}) { + const cli = MatrixClientPeg.get(); + const isLoggedOutOrGuest = !cli || cli.isGuest(); + if (!isLoggedOutOrGuest && AUTH_SCREENS.includes(screen)) { + // user is logged in and landing on an auth page which will uproot their session, redirect them home instead + dis.dispatch({ action: "view_home_page" }); + return; + } + if (screen === 'register') { dis.dispatch({ action: 'start_registration', @@ -1551,7 +1607,7 @@ export default class MatrixChat extends React.PureComponent { params: params, }); } else if (screen === 'soft_logout') { - if (MatrixClientPeg.get() && MatrixClientPeg.get().getUserId() && !Lifecycle.isSoftLogout()) { + if (cli.getUserId() && !Lifecycle.isSoftLogout()) { // Logged in - visit a room this.viewLastRoom(); } else { @@ -1581,6 +1637,9 @@ export default class MatrixChat extends React.PureComponent { action: 'require_registration', }); } else if (screen === 'directory') { + if (this.state.view === Views.WELCOME) { + CountlyAnalytics.instance.track("onboarding_room_directory"); + } dis.fire(Action.ViewRoomDirectory); } else if (screen === "start_sso" || screen === "start_cas") { // TODO if logged in, skip SSO @@ -1599,14 +1658,6 @@ export default class MatrixChat extends React.PureComponent { dis.dispatch({ action: 'view_my_groups', }); - } else if (screen === 'complete_security') { - dis.dispatch({ - action: 'start_complete_security', - }); - } else if (screen === 'post_registration') { - dis.dispatch({ - action: 'start_post_registration', - }); } else if (screen.indexOf('room/') === 0) { // Rooms can have the following formats: // #room_alias:domain or !opaque_id:domain @@ -1630,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 @@ -1777,14 +1834,6 @@ export default class MatrixChat extends React.PureComponent { return Lifecycle.setLoggedIn(credentials); } - onFinishPostRegistration = () => { - // Don't confuse this with "PageType" which is the middle window to show - this.setState({ - view: Views.LOGGED_IN, - }); - this.showScreen("settings"); - }; - onSendEvent(roomId: string, event: MatrixEvent) { const cli = MatrixClientPeg.get(); if (!cli) { @@ -1870,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 @@ -1947,15 +1963,9 @@ export default class MatrixChat extends React.PureComponent { ); - } else if (this.state.view === Views.POST_REGISTRATION) { - // needs to be before normal PageTypes as you are logged in technically - const PostRegistration = sdk.getComponent('structures.auth.PostRegistration'); - view = ( - - ); } else if (this.state.view === Views.LOGGED_IN) { // store errors stop the client syncing and require user intervention, so we'll // be showing a dialog. Don't show anything else. @@ -2019,6 +2029,7 @@ export default class MatrixChat extends React.PureComponent { onLoginClick={this.onLoginClick} onServerConfigChange={this.onServerConfigChange} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} + fragmentAfterLogin={fragmentAfterLogin} {...this.getServerProperties()} /> ); diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index e2e3592536..161227a139 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -23,13 +23,17 @@ 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"; import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer"; +import DMRoomMap from "../../utils/DMRoomMap"; +import NewRoomIntro from "../views/rooms/NewRoomIntro"; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = ['m.sticker', 'm.room.message']; @@ -133,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); @@ -205,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) { @@ -222,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"), @@ -610,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} /> @@ -808,7 +821,7 @@ export default class MessagePanel extends React.Component { } let ircResizer = null; - if (this.props.useIRCLayout) { + if (this.props.layout == Layout.IRC) { ircResizer = a.concat(b), []); // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one const ev = this.events[this.events.length - 1]; + + let summaryText; + const roomId = ev.getRoomId(); + const creator = ev.sender ? ev.sender.name : ev.getSender(); + if (DMRoomMap.shared().getUserIdForRoomId(roomId)) { + summaryText = _t("%(creator)s created this DM.", { creator }); + } else { + summaryText = _t("%(creator)s created and configured the room.", { creator }); + } + + ret.push(); + ret.push( { eventTiles } , diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index 2889afc1fc..b4eb6c187b 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -39,7 +39,7 @@ class NotificationPanel extends React.Component { const emptyState = (

{_t('You’re all caught up')}

-

{_t('You have no visible notifications in this room.')}

+

{_t('You have no visible notifications.')}

); let content; diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 41f4d83743..3d9df2e927 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -24,13 +24,16 @@ import dis from '../../dispatcher/dispatcher'; import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; -import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases"; +import { + RightPanelPhases, + RIGHT_PANEL_PHASES_NO_ARGS, + RIGHT_PANEL_SPACE_PHASES, +} from "../../stores/RightPanelStorePhases"; import RightPanelStore from "../../stores/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import {Action} from "../../dispatcher/actions"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import WidgetCard from "../views/right_panel/WidgetCard"; -import defaultDispatcher from "../../dispatcher/dispatcher"; export default class RightPanel extends React.Component { static get propTypes() { @@ -80,6 +83,8 @@ export default class RightPanel extends React.Component { return RightPanelPhases.GroupMemberList; } return rps.groupPanelPhase; + } else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { + return RightPanelPhases.SpaceMemberList; } else if (userForPanel) { // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state // from its props and some from a store, except if the contents of the store changes @@ -100,9 +105,8 @@ export default class RightPanel extends React.Component { return rps.roomPanelPhase; } return RightPanelPhases.RoomMemberInfo; - } else { - return rps.roomPanelPhase; } + return rps.roomPanelPhase; } componentDidMount() { @@ -182,11 +186,12 @@ export default class RightPanel extends React.Component { verificationRequest: payload.verificationRequest, verificationRequestPromise: payload.verificationRequestPromise, widgetId: payload.widgetId, + space: payload.space, }); } } - onCloseUserInfo = () => { + 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 97e1f82a77..7387e1aac0 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -33,6 +33,7 @@ import SettingsStore from "../../settings/SettingsStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupStore from "../../stores/GroupStore"; import FlairStore from "../../stores/FlairStore"; +import CountlyAnalytics from "../../CountlyAnalytics"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; @@ -43,12 +44,16 @@ function track(action) { export default class RoomDirectory extends React.Component { static propTypes = { + initialText: PropTypes.string, onFinished: PropTypes.func.isRequired, }; constructor(props) { super(props); + CountlyAnalytics.instance.trackRoomDirectoryBegin(); + this.startTime = CountlyAnalytics.getTimestamp(); + const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; this.state = { publicRooms: [], @@ -57,7 +62,7 @@ export default class RoomDirectory extends React.Component { error: null, instanceId: undefined, roomServer: MatrixClientPeg.getHomeserverName(), - filterString: null, + filterString: this.props.initialText || "", selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes") ? selectedCommunityId : null, @@ -198,6 +203,11 @@ export default class RoomDirectory extends React.Component { return; } + if (this.state.filterString) { + const count = data.total_room_count_estimate || data.chunk.length; + CountlyAnalytics.instance.trackRoomDirectorySearch(count, this.state.filterString); + } + this.nextBatch = data.next_batch; this.setState((s) => { s.publicRooms.push(...(data.chunk || [])); @@ -407,7 +417,7 @@ export default class RoomDirectory extends React.Component { }; onCreateRoomClick = room => { - this.props.onFinished(); + this.onFinished(); dis.dispatch({ action: 'view_create_room', public: true, @@ -419,11 +429,12 @@ export default class RoomDirectory extends React.Component { } showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { - this.props.onFinished(); + this.onFinished(); const payload = { action: 'view_room', auto_join: autoJoin, should_peek: shouldPeek, + _type: "room_directory", // instrumentation }; if (room) { // Don't let the user view a room they won't be able to either @@ -466,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"; @@ -476,7 +487,11 @@ export default class RoomDirectory extends React.Component { let previewButton; let joinOrViewButton; - if (room.world_readable && !hasJoinedRoom) { + // Element Web currently does not allow guests to join rooms, so we + // instead show them preview buttons for all rooms. If the room is not + // world readable, a modal will appear asking you to register first. If + // it is readable, the preview appears as normal. + if (!hasJoinedRoom && (room.world_readable || isGuest)) { previewButton = ( this.onPreviewClick(ev, room)}>{_t("Preview")} ); @@ -485,7 +500,7 @@ export default class RoomDirectory extends React.Component { joinOrViewButton = ( this.onViewClick(ev, room)}>{_t("View")} ); - } else if (!isGuest || room.guest_can_join) { + } else if (!isGuest) { joinOrViewButton = ( this.onJoinClick(ev, room)}>{_t("Join")} ); @@ -508,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) => { @@ -575,6 +615,11 @@ export default class RoomDirectory extends React.Component { } }; + onFinished = () => { + CountlyAnalytics.instance.trackRoomDirectory(this.startTime); + this.props.onFinished(); + }; + render() { const Loader = sdk.getComponent("elements.Spinner"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -586,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 @@ -597,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 = {dropdown}
; @@ -693,7 +738,7 @@ export default class RoomDirectory extends React.Component {
diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 526aecddd7..a64e40bc65 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -148,7 +148,7 @@ export default class RoomSearch extends React.PureComponent { onBlur={this.onBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder={_t("Search")} + placeholder={_t("Filter")} autoComplete="off" /> ); @@ -164,7 +164,7 @@ export default class RoomSearch extends React.PureComponent { if (this.props.isMinimized) { icon = ( diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index e390be6979..c1c4ad6292 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -18,13 +18,11 @@ import React from 'react'; import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import { _t, _td } from '../../languageHandler'; -import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; import {Action} from "../../dispatcher/actions"; -import { CallState, CallType } from 'matrix-js-sdk/lib/webrtc/call'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -41,16 +39,6 @@ export default class RoomStatusBar extends React.Component { static propTypes = { // the room this statusbar is representing. room: PropTypes.object.isRequired, - // This is true when the user is alone in the room, but has also sent a message. - // Used to suggest to the user to invite someone - sentMessageAndIsAlone: PropTypes.bool, - - // The active call in the room, if any (means we show the call bar - // along with the status of the call) - callState: PropTypes.string, - - // The type of the call in progress, or null if no call is in progress - callType: PropTypes.string, // true if the room is being peeked at. This affects components that shouldn't // logically be shown when peeking, such as a prompt to invite people to a room. @@ -68,10 +56,6 @@ export default class RoomStatusBar extends React.Component { // 'you are alone' bar onInviteClick: PropTypes.func, - // callback for when the user clicks on the 'stop warning me' button in the - // 'you are alone' bar - onStopWarningClick: PropTypes.func, - // callback for when we do something that changes the size of the // status bar. This is used to trigger a re-layout in the parent // component. @@ -122,12 +106,6 @@ export default class RoomStatusBar extends React.Component { }); }; - _showCallBar() { - return (this.props.callState && - (this.props.callState !== CallState.Ended && this.props.callState !== CallState.Ringing) - ); - } - _onResendAllClick = () => { Resend.resendUnsentEvents(this.props.room); dis.fire(Action.FocusComposer); @@ -159,10 +137,7 @@ export default class RoomStatusBar extends React.Component { // changed - so we use '0' to indicate normal size, and other values to // indicate other sizes. _getSize() { - if (this._shouldShowConnectionError() || - this._showCallBar() || - this.props.sentMessageAndIsAlone - ) { + if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; } else if (this.state.unsentMessages.length > 0) { return STATUS_BAR_EXPANDED_LARGE; @@ -170,22 +145,6 @@ export default class RoomStatusBar extends React.Component { return STATUS_BAR_HIDDEN; } - // return suitable content for the image on the left of the status bar. - _getIndicator() { - if (this._showCallBar()) { - const TintableSvg = sdk.getComponent("elements.TintableSvg"); - return ( - - ); - } - - if (this._shouldShowConnectionError()) { - return null; - } - - return null; - } - _shouldShowConnectionError() { // no conn bar trumps the "some not sent" msg since you can't resend without // a connection! @@ -276,25 +235,6 @@ export default class RoomStatusBar extends React.Component {
; } - _getCallStatusText() { - switch (this.props.callState) { - case CallState.CreateOffer: - case CallState.InviteSent: - return _t('Calling...'); - case CallState.Connecting: - case CallState.CreateAnswer: - return _t('Call connecting...'); - case CallState.Connected: - return _t('Active call'); - case CallState.WaitLocalMedia: - if (this.props.callType === CallType.Video) { - return _t('Starting camera...'); - } else { - return _t('Starting microphone...'); - } - } - } - // return suitable content for the main (text) part of the status bar. _getContent() { if (this._shouldShowConnectionError()) { @@ -317,44 +257,14 @@ export default class RoomStatusBar extends React.Component { return this._getUnsentMessageContent(); } - if (this._showCallBar()) { - return ( -
- { this._getCallStatusText() } -
- ); - } - - // If you're alone in the room, and have sent a message, suggest to invite someone - if (this.props.sentMessageAndIsAlone && !this.props.isPeeking) { - return ( -
- { _t("There's no one else here! Would you like to invite others " + - "or stop warning about the empty room?", - {}, - { - 'inviteText': (sub) => - { sub }, - 'nowarnText': (sub) => - { sub }, - }, - ) } -
- ); - } - return null; } render() { const content = this._getContent(); - const indicator = this._getIndicator(); return (
-
- { indicator } -
{ content }
diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 2952568e2f..1961779d0e 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -21,43 +21,42 @@ limitations under the License. // - Search results component // - Drag and drop -import React, {createRef} from 'react'; +import React, { createRef } from 'react'; import classNames from 'classnames'; -import {Room} from "matrix-js-sdk/src/models/room"; -import {MatrixEvent} from "matrix-js-sdk/src/models/event"; -import {EventSubscription} from "fbemitter"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventSubscription } from "fbemitter"; import shouldHideEvent from '../../shouldHideEvent'; -import {_t} from '../../languageHandler'; -import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; +import { _t } from '../../languageHandler'; +import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; import * as sdk from '../../index'; -import CallHandler from '../../CallHandler'; +import CallHandler, { PlaceCallType } from '../../CallHandler'; import dis from '../../dispatcher/dispatcher'; import Tinter from '../../Tinter'; import rateLimitedFunc from '../../ratelimitedfunc'; -import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; -import eventSearch, {searchPagination} from '../../Searching'; -import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; +import eventSearch, { searchPagination } from '../../Searching'; +import { isOnlyCtrlOrCmdIgnoreShiftKeyEvent, Key } from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore from "../../settings/SettingsStore"; +import {Layout} from "../../settings/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; import RightPanelStore from "../../stores/RightPanelStore"; -import {haveTileForEvent} from "../views/rooms/EventTile"; +import { haveTileForEvent } from "../views/rooms/EventTile"; import RoomContext from "../../contexts/RoomContext"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils'; -import {Action} from "../../dispatcher/actions"; -import {SettingLevel} from "../../settings/SettingLevel"; -import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; -import {IMatrixClientCreds} from "../../MatrixClientPeg"; +import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; +import { Action } from "../../dispatcher/actions"; +import { SettingLevel } from "../../settings/SettingLevel"; +import { IMatrixClientCreds } from "../../MatrixClientPeg"; import ScrollPanel from "./ScrollPanel"; import TimelinePanel from "./TimelinePanel"; import ErrorBoundary from "../views/elements/ErrorBoundary"; @@ -68,10 +67,21 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; -import TintableSvg from "../views/elements/TintableSvg"; -import {XOR} from "../../@types/common"; +import { XOR } from "../../@types/common"; import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; -import { CallState, CallType, MatrixCall } from "matrix-js-sdk/lib/webrtc/call"; +import EffectsOverlay from "../views/elements/EffectsOverlay"; +import { containsEmoji } from '../../effects/utils'; +import { CHAT_EFFECTS } from '../../effects'; +import { CallState, MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import WidgetStore from "../../stores/WidgetStore"; +import { UPDATE_EVENT } from "../../stores/AsyncStore"; +import Notifier from "../../Notifier"; +import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; +import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; +import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; +import { objectHasDiff } from "../../utils/objects"; +import SpaceRoomView from "./SpaceRoomView"; +import { IOpts } from "../../createRoom"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -106,6 +116,7 @@ interface IProps { autoJoin?: boolean; resizeNotifier: ResizeNotifier; + justCreatedOpts?: IOpts; // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) onRegistered?(credentials: IMatrixClientCreds): void; @@ -127,6 +138,7 @@ export interface IState { initialEventPixelOffset?: number; // Whether to highlight the event scrolled to isInitialEventHighlighted?: boolean; + replyToEvent?: MatrixEvent; forwardingEvent?: MatrixEvent; numUnreadMessages: number; draggingFile: boolean; @@ -145,7 +157,6 @@ export interface IState { guestsCanJoin: boolean; canPeek: boolean; showApps: boolean; - isAlone: boolean; isPeeking: boolean; showingPinned: boolean; showReadReceipts: boolean; @@ -174,12 +185,13 @@ export interface IState { }; canReact: boolean; canReply: boolean; - useIRCLayout: boolean; + layout: Layout; matrixClientIsReady: boolean; showUrlPreview?: boolean; e2eStatus?: E2EStatus; rejecting?: boolean; rejectError?: Error; + hasPinnedWidgets?: boolean; } export default class RoomView extends React.Component { @@ -217,7 +229,6 @@ export default class RoomView extends React.Component { guestsCanJoin: false, canPeek: false, showApps: false, - isAlone: false, isPeeking: false, showingPinned: false, showReadReceipts: true, @@ -229,7 +240,7 @@ 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(), }; @@ -246,22 +257,33 @@ export default class RoomView extends React.Component { this.context.on("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); + this.context.on("Event.decrypted", this.onEventDecrypted); + this.context.on("event", this.onEvent); // Start listening for RoomViewStore updates this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); - WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate); + WidgetEchoStore.on(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, this.onReadReceiptsChange); - this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); + this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, this.onLayoutChange); } - // TODO: [REACT-WARNING] Move into constructor - // eslint-disable-next-line camelcase - UNSAFE_componentWillMount() { - this.onRoomViewStoreUpdate(true); + private onWidgetStoreUpdate = () => { + if (this.state.room) { + this.checkWidgets(this.state.room); + } } + private checkWidgets = (room) => { + this.setState({ + hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top).length > 0, + showApps: this.shouldShowApps(room), + }); + }; + private onReadReceiptsChange = () => { this.setState({ showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), @@ -298,6 +320,7 @@ export default class RoomView extends React.Component { joining: RoomViewStore.isJoining(), initialEventId: RoomViewStore.getInitialEventId(), isInitialEventHighlighted: RoomViewStore.isInitialEventHighlighted(), + replyToEvent: RoomViewStore.getQuotingEvent(), forwardingEvent: RoomViewStore.getForwardingEvent(), // we should only peek once we have a ready client shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), @@ -395,11 +418,17 @@ export default class RoomView extends React.Component { } private onWidgetEchoStoreUpdate = () => { + if (!this.state.room) return; this.setState({ + hasPinnedWidgets: WidgetLayoutStore.instance.getContainerWidgets(this.state.room, Container.Top).length > 0, showApps: this.shouldShowApps(this.state.room), }); }; + private onWidgetLayoutChange = () => { + this.onWidgetEchoStoreUpdate(); // we cheat here by calling the thing that matters + }; + private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) @@ -465,7 +494,7 @@ export default class RoomView extends React.Component { } private shouldShowApps(room: Room) { - if (!BROWSER_SUPPORTS_SANDBOX) return false; + if (!BROWSER_SUPPORTS_SANDBOX || !room) return false; // Check if user has previously chosen to hide the app drawer for this // room. If so, do not show apps @@ -474,10 +503,15 @@ export default class RoomView extends React.Component { // This is confusing, but it means to say that we default to the tray being // hidden unless the user clicked to open it. - return hideWidgetDrawer === "false"; + const isManuallyShown = hideWidgetDrawer === "false"; + + const widgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); + return widgets.length > 0 || isManuallyShown; } componentDidMount() { + this.onRoomViewStoreUpdate(true); + const call = this.getCallForRoom(); const callState = call ? call.state : null; this.setState({ @@ -489,13 +523,10 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.on("middlePanelResized", this.onResize); } this.onResize(); - - document.addEventListener("keydown", this.onNativeKeyDown); } shouldComponentUpdate(nextProps, nextState) { - return (!ObjectUtils.shallowEqual(this.props, nextProps) || - !ObjectUtils.shallowEqual(this.state, nextState)); + return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState)); } componentDidUpdate() { @@ -566,6 +597,8 @@ export default class RoomView extends React.Component { this.context.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged); this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); + this.context.removeListener("Event.decrypted", this.onEventDecrypted); + this.context.removeListener("event", this.onEvent); } window.removeEventListener('beforeunload', this.onPageUnload); @@ -573,8 +606,6 @@ export default class RoomView extends React.Component { this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); } - document.removeEventListener("keydown", this.onNativeKeyDown); - // Remove RoomStore listener if (this.roomStoreToken) { this.roomStoreToken.remove(); @@ -584,7 +615,15 @@ export default class RoomView extends React.Component { this.rightPanelStoreToken.remove(); } - WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate); + WidgetEchoStore.removeListener(UPDATE_EVENT, this.onWidgetEchoStoreUpdate); + WidgetStore.instance.removeListener(UPDATE_EVENT, this.onWidgetStoreUpdate); + + if (this.state.room) { + WidgetLayoutStore.instance.off( + WidgetLayoutStore.emissionForRoom(this.state.room), + this.onWidgetLayoutChange, + ); + } if (this.showReadReceiptsWatchRef) { SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); @@ -602,7 +641,7 @@ export default class RoomView extends React.Component { private onLayoutChange = () => { this.setState({ - useIRCLayout: SettingsStore.getValue("useIRCLayout"), + layout: SettingsStore.getValue("layout"), }); }; @@ -622,33 +661,6 @@ export default class RoomView extends React.Component { } }; - // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire - private onNativeKeyDown = ev => { - let handled = false; - const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - - switch (ev.key) { - case Key.D: - if (ctrlCmdOnly) { - this.onMuteAudioClick(); - handled = true; - } - break; - - case Key.E: - if (ctrlCmdOnly) { - this.onMuteVideoClick(); - handled = true; - } - break; - } - - if (handled) { - ev.stopPropagation(); - ev.preventDefault(); - } - }; - private onReactKeyDown = ev => { let handled = false; @@ -683,9 +695,8 @@ export default class RoomView extends React.Component { private onAction = payload => { switch (payload.action) { - case 'message_send_failed': case 'message_sent': - this.checkIfAlone(this.state.room); + this.checkDesktopNotifications(); break; case 'post_sticker_message': this.injectSticker( @@ -754,6 +765,9 @@ export default class RoomView extends React.Component { }); } break; + case 'focus_search': + this.onSearchClick(); + break; } }; @@ -795,6 +809,30 @@ export default class RoomView extends React.Component { } }; + private onEventDecrypted = (ev) => { + if (ev.isDecryptionFailure()) return; + this.handleEffects(ev); + }; + + private onEvent = (ev) => { + if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; + this.handleEffects(ev); + }; + + private handleEffects = (ev) => { + if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all + if (ev.getRoomId() !== this.state.room.roomId) return; // not for us + + const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); + if (!notifState.isUnread) return; + + CHAT_EFFECTS.forEach(effect => { + if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) { + dis.dispatch({action: `effects.${effect.command}`}); + } + }); + }; + private onRoomName = (room: Room) => { if (this.state.room && room.roomId == this.state.room.roomId) { this.forceUpdate(); @@ -817,12 +855,17 @@ export default class RoomView extends React.Component { // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). private onRoomLoaded = (room: Room) => { + // Attach a widget store listener only when we get a room + WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); + this.onWidgetLayoutChange(); // provoke an update + this.calculatePeekRules(room); this.updatePreviewUrlVisibility(room); this.loadMembersIfJoined(room); this.calculateRecommendedVersion(room); this.updateE2EStatus(room); this.updatePermissions(room); + this.checkWidgets(room); }; private async calculateRecommendedVersion(room: Room) { @@ -878,6 +921,15 @@ export default class RoomView extends React.Component { if (!room || room.roomId !== this.state.roomId) { return; } + + // Detach the listener if the room is changing for some reason + if (this.state.room) { + WidgetLayoutStore.instance.off( + WidgetLayoutStore.emissionForRoom(this.state.room), + this.onWidgetLayoutChange, + ); + } + this.setState({ room: room, }, () => { @@ -1002,33 +1054,17 @@ export default class RoomView extends React.Component { } // rate limited because a power level change will emit an event for every member in the room. - private updateRoomMembers = rateLimitedFunc((dueToMember) => { + private updateRoomMembers = rateLimitedFunc(() => { this.updateDMState(); - - let memberCountInfluence = 0; - if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) { - // A member got invited, but the room hasn't detected that change yet. Influence the member - // count by 1 to counteract this. - memberCountInfluence = 1; - } - this.checkIfAlone(this.state.room, memberCountInfluence); - this.updateE2EStatus(this.state.room); }, 500); - private checkIfAlone(room: Room, countInfluence?: number) { - let warnedAboutLonelyRoom = false; - if (localStorage) { - warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId)); + private checkDesktopNotifications() { + const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); + // if they are not alone prompt the user about notifications so they don't miss replies + if (memberCount > 1 && Notifier.shouldShowPrompt()) { + showNotificationsToast(true); } - if (warnedAboutLonelyRoom) { - if (this.state.isAlone) this.setState({isAlone: false}); - return; - } - - let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount(); - if (countInfluence) joinedOrInvitedMemberCount += countInfluence; - this.setState({isAlone: joinedOrInvitedMemberCount === 1}); } private updateDMState() { @@ -1063,14 +1099,6 @@ export default class RoomView extends React.Component { action: 'view_invite', roomId: this.state.room.roomId, }); - this.setState({isAlone: false}); // there's a good chance they'll invite someone - }; - - private onStopAloneWarningClick = () => { - if (localStorage) { - localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true)); - } - this.setState({isAlone: false}); }; private onJoinButtonClicked = () => { @@ -1092,6 +1120,7 @@ export default class RoomView extends React.Component { dis.dispatch({ action: 'join_room', opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, + _type: "unknown", // TODO: instrumentation }); return Promise.resolve(); }); @@ -1118,16 +1147,9 @@ export default class RoomView extends React.Component { ev.dataTransfer.dropEffect = 'none'; - const items = [...ev.dataTransfer.items]; - if (items.length >= 1) { - const isDraggingFiles = items.every(function(item) { - return item.kind == 'file'; - }); - - if (isDraggingFiles) { - this.setState({ draggingFile: true }); - ev.dataTransfer.dropEffect = 'copy'; - } + if (ev.dataTransfer.types.includes("Files") || ev.dataTransfer.types.includes("application/x-moz-file")) { + this.setState({ draggingFile: true }); + ev.dataTransfer.dropEffect = 'copy'; } }; @@ -1258,7 +1280,7 @@ export default class RoomView extends React.Component { } if (!this.state.searchResults.next_batch) { - if (this.state.searchResults.results.length == 0) { + if (!this.state.searchResults?.results?.length) { ret.push(
  • { _t("No results") }

  • , @@ -1282,7 +1304,7 @@ export default class RoomView extends React.Component { let lastRoomId; - for (let i = this.state.searchResults.results.length - 1; i >= 0; i--) { + for (let i = (this.state.searchResults?.results?.length || 0) - 1; i >= 0; i--) { const result = this.state.searchResults.results[i]; const mxEv = result.context.getEvent(); @@ -1333,13 +1355,18 @@ export default class RoomView extends React.Component { SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned); }; - private onSettingsClick = () => { + private onCallPlaced = (type: PlaceCallType) => { dis.dispatch({ - action: Action.SetRightPanelPhase, - phase: RightPanelPhases.RoomSummary, + action: 'place_call', + type: type, + room_id: this.state.room.roomId, }); }; + private onSettingsClick = () => { + dis.dispatch({ action: "open_room_settings" }); + }; + private onCancelClick = () => { console.log("updateTint from onCancelClick"); this.updateTint(); @@ -1352,6 +1379,13 @@ export default class RoomView extends React.Component { dis.fire(Action.FocusComposer); }; + private onAppsClick = () => { + dis.dispatch({ + action: "appsDrawer", + show: !this.state.showApps, + }); + }; + private onLeaveClick = () => { dis.dispatch({ action: 'leave_room', @@ -1366,12 +1400,12 @@ export default class RoomView extends React.Component { }); }; - private onRejectButtonClicked = ev => { + private onRejectButtonClicked = () => { this.setState({ rejecting: true, }); this.context.leave(this.state.roomId).then(() => { - dis.dispatch({ action: 'view_next_room' }); + dis.dispatch({ action: 'view_home_page' }); this.setState({ rejecting: false, }); @@ -1405,7 +1439,7 @@ export default class RoomView extends React.Component { await this.context.setIgnoredUsers(ignoredUsers); await this.context.leave(this.state.roomId); - dis.dispatch({ action: 'view_next_room' }); + dis.dispatch({ action: 'view_home_page' }); this.setState({ rejecting: false, }); @@ -1426,7 +1460,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. @@ -1689,7 +1723,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 ( @@ -1761,12 +1795,8 @@ export default class RoomView extends React.Component { isStatusAreaExpanded = this.state.statusBarVisible; statusBar = ; @@ -1822,7 +1852,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 } @@ -1844,6 +1874,18 @@ export default class RoomView extends React.Component { ); } + if (this.state.room?.isSpaceRoom()) { + return ; + } + const auxPanel = ( { showApps={this.state.showApps} e2eStatus={this.state.e2eStatus} resizeNotifier={this.props.resizeNotifier} + replyToEvent={this.state.replyToEvent} permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} />; } @@ -1887,56 +1930,6 @@ export default class RoomView extends React.Component { }; } - if (activeCall) { - let zoomButton; let videoMuteButton; - - if (activeCall.type === CallType.Video) { - zoomButton = ( -
    - -
    - ); - - videoMuteButton = -
    - -
    ; - } - const voiceMuteButton = -
    - -
    ; - - // wrap the existing status bar into a 'callStatusBar' which adds more knobs. - statusBar = -
    - { voiceMuteButton } - { videoMuteButton } - { zoomButton } - { statusBar } -
    ; - } - // if we have search results, we keep the messagepanel (so that it preserves its // scroll state), but hide it. let searchResultsPanel; @@ -1944,7 +1937,7 @@ export default class RoomView extends React.Component { if (this.state.searchResults) { // show searching spinner - if (this.state.searchResults.results === undefined) { + if (this.state.searchResults.count === undefined) { searchResultsPanel = (
    ); @@ -1975,8 +1968,8 @@ export default class RoomView extends React.Component { const messagePanelClassNames = classNames( "mx_RoomView_messagePanel", { - "mx_IRCLayout": this.state.useIRCLayout, - "mx_GroupLayout": !this.state.useIRCLayout, + "mx_IRCLayout": this.state.layout == Layout.IRC, + "mx_GroupLayout": this.state.layout == Layout.Group, }); // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); @@ -1999,7 +1992,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; @@ -2038,9 +2031,14 @@ export default class RoomView extends React.Component { mx_RoomView_inCall: Boolean(activeCall), }); + const showChatEffects = SettingsStore.getValue('showChatEffects'); + return (
    + {showChatEffects && this.roomView.current && + + } { onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} + onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} + appsShown={this.state.showApps} + onCallPlaced={this.onCallPlaced} />
    diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 99a3da2565..744400df3c 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -704,7 +704,7 @@ export default class ScrollPanel extends React.Component { if (itemlist.style.height !== newHeight) { itemlist.style.height = newHeight; } - if (sn.scrollTop !== sn.scrollHeight){ + if (sn.scrollTop !== sn.scrollHeight) { sn.scrollTop = sn.scrollHeight; } debuglog("updateHeight to", newHeight); 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 8bbc66bf40..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); @@ -715,26 +716,22 @@ class TimelinePanel extends React.Component { } this.lastRMSentEventId = this.state.readMarkerEventId; - const roomId = this.props.timelineSet.room.roomId; - const hiddenRR = !SettingsStore.getValue("sendReadReceipts", roomId); - debuglog('TimelinePanel: Sending Read Markers for ', this.props.timelineSet.room.roomId, 'rm', this.state.readMarkerEventId, lastReadEvent ? 'rr ' + lastReadEvent.getId() : '', - ' hidden:' + hiddenRR, ); MatrixClientPeg.get().setRoomReadMarkers( this.props.timelineSet.room.roomId, this.state.readMarkerEventId, lastReadEvent, // Could be null, in which case no RR is sent - {hidden: hiddenRR}, + {}, ).catch((e) => { // /read_markers API is not implemented on this HS, fallback to just RR if (e.errcode === 'M_UNRECOGNIZED' && lastReadEvent) { return MatrixClientPeg.get().sendReadReceipt( lastReadEvent, - {hidden: hiddenRR}, + {}, ).catch((e) => { console.error(e); this.lastRRSentEventId = undefined; @@ -1446,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/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 84473031fa..513cca82c3 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -55,11 +55,11 @@ export default class ToastContainer extends React.Component<{}, IState> { let toast; if (totalCount !== 0) { const topToast = this.state.toasts[0]; - const {title, icon, key, component, props} = topToast; + const {title, icon, key, component, className, props} = topToast; const toastClasses = classNames("mx_Toast_toast", { "mx_Toast_hasIcon": icon, [`mx_Toast_icon_${icon}`]: icon, - }); + }, className); let countIndicator; if (isStacked || this.state.countSeen > 0) { diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 0865764c5a..16cc4cb987 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -86,7 +86,9 @@ export default class UploadBar extends React.Component { } // MUST use var name 'count' for pluralization to kick in - const uploadText = _t("Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)}); + const uploadText = _t( + "Uploading %(filename)s and %(count)s others", {filename: upload.fileName, count: (uploads.length - 1)}, + ); return (
    diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 81d25e6a0c..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 RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog"; +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 {ButtonEvent} from "../views/elements/AccessibleButton"; +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()}); }; @@ -186,15 +206,32 @@ export default class UserMenu extends React.Component { ev.preventDefault(); ev.stopPropagation(); - Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); + Modal.createTrackedDialog('Feedback Dialog', '', FeedbackDialog); this.setState({contextMenuPosition: null}); // also close the menu }; - private onSignOutClick = (ev: ButtonEvent) => { + private onSignOutClick = async (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); - Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); + const cli = MatrixClientPeg.get(); + if (!cli || !cli.isCryptoEnabled() || !(await cli.exportRoomKeys())?.length) { + // log out without user prompt if they have no local megolm sessions + dis.dispatch({action: 'logout'}); + } else { + Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); + } + + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onSignInClick = () => { + dis.dispatch({ action: 'start_login' }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onRegisterClick = () => { + dis.dispatch({ action: 'start_registration' }); this.setState({contextMenuPosition: null}); // also close the menu }; @@ -203,6 +240,7 @@ export default class UserMenu extends React.Component { ev.stopPropagation(); defaultDispatcher.dispatch({action: 'view_home_page'}); + this.setState({contextMenuPosition: null}); // also close the menu }; private onCommunitySettingsClick = (ev: ButtonEvent) => { @@ -253,26 +291,40 @@ export default class UserMenu extends React.Component { const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); - let hostingLink; - const signupLink = getHostingLink("user-context-menu"); - if (signupLink) { - hostingLink = ( -
    - {_t( - "Upgrade to your own domain", {}, - { - a: sub => ( - {sub} - ), - }, - )} + let topSection; + const hostSignupConfig: IHostSignupConfig = SdkConfig.get().hostSignup; + if (MatrixClientPeg.get().isGuest()) { + topSection = ( +
    + {_t("Got an account? Sign in", {}, { + a: sub => ( + + {sub} + + ), + })} + {_t("New here? Create an account", {}, { + 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; @@ -414,6 +466,20 @@ export default class UserMenu extends React.Component { ) + } else if (MatrixClientPeg.get().isGuest()) { + primaryOptionList = ( + + + { homeButton } + this.onSettingsOpen(e, null)} + /> + { feedbackButton } + + + ); } const classes = classNames({ @@ -443,7 +509,7 @@ export default class UserMenu extends React.Component { />
    - {hostingLink} + {topSection} {primaryOptionList} {secondarySection} ; @@ -452,7 +518,8 @@ export default class UserMenu extends React.Component { public render() { const avatarSize = 32; // should match border-radius of the avatar - const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); + const userId = MatrixClientPeg.get().getUserId(); + const displayName = OwnProfileStore.instance.displayName || userId; const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); @@ -465,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} @@ -507,7 +583,7 @@ export default class UserMenu extends React.Component {
    diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 3fa2713a35..5a39fe9fd9 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -21,15 +21,14 @@ import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; -import SdkConfig from "../../../SdkConfig"; import PasswordReset from "../../../PasswordReset"; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import ServerPicker from "../../views/elements/ServerPicker"; // Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; // Show the forgot password inputs const PHASE_FORGOT = 1; // Email is in the process of being sent @@ -61,9 +60,14 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", - serverRequiresIdServer: null, }; + constructor(props) { + super(props); + + CountlyAnalytics.instance.track("onboarding_forgot_password_begin"); + } + componentDidMount() { this.reset = null; this._checkServerLiveliness(this.props.serverConfig); @@ -86,12 +90,8 @@ export default class ForgotPassword extends React.Component { serverConfig.isUrl, ); - const pwReset = new PasswordReset(serverConfig.hsUrl, serverConfig.isUrl); - const serverRequiresIdServer = await pwReset.doesServerRequireIdServerParam(); - this.setState({ serverIsAlive: true, - serverRequiresIdServer, }); } catch (e) { this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); @@ -170,20 +170,6 @@ export default class ForgotPassword extends React.Component { }); }; - onServerDetailsNextPhaseClick = async () => { - this.setState({ - phase: PHASE_FORGOT, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); @@ -198,24 +184,6 @@ export default class ForgotPassword extends React.Component { }); } - renderServerDetails() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } - - return ; - } - renderForgot() { const Field = sdk.getComponent('elements.Field'); @@ -239,57 +207,13 @@ export default class ForgotPassword extends React.Component { ); } - let yourMatrixAccountText = _t('Your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - yourMatrixAccountText = _t('Your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - // If custom URLs are allowed, wire up the server details edit link. - let editLink = null; - if (!SdkConfig.get()['disable_custom_urls']) { - editLink = - {_t('Change')} - ; - } - - if (!this.props.serverConfig.isUrl && this.state.serverRequiresIdServer) { - return
    -

    - {yourMatrixAccountText} - {editLink} -

    - {_t( - "No identity server is configured: " + - "add one in server settings to reset your password.", - )} - - {_t('Sign in instead')} - -
    ; - } - return
    {errorText} {serverDeadSection} -

    - {yourMatrixAccountText} - {editLink} -

    +
    CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")} />
    CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword_blur")} + autoComplete="new-password" /> CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_newPassword2_blur")} + autoComplete="new-password" />
    {_t( @@ -367,9 +299,6 @@ export default class ForgotPassword extends React.Component { let resetPasswordJsx; switch (this.state.phase) { - case PHASE_SERVER_DETAILS: - resetPasswordJsx = this.renderServerDetails(); - break; case PHASE_FORGOT: resetPasswordJsx = this.renderForgot(); break; diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.tsx similarity index 55% rename from src/components/structures/auth/Login.js rename to src/components/structures/auth/Login.tsx index 118eed59e3..a217f1b4d9 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd +Copyright 2015, 2016, 2017, 2018, 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. @@ -16,32 +14,27 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ReactNode} from 'react'; +import {MatrixError} from "matrix-js-sdk/src/http-api"; + import {_t, _td} from '../../../languageHandler'; import * as sdk from '../../../index'; -import Login from '../../../Login'; +import Login, {ISSOFlow, LoginFlow} from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import AuthPage from "../../views/auth/AuthPage"; -import SSOButton from "../../views/elements/SSOButton"; import PlatformPeg from '../../../PlatformPeg'; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; - -// For validating phone numbers without country codes -const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; - -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate login flow(s) for the server -const PHASE_LOGIN = 1; - -// Enable phases for login -const PHASES_ENABLED = true; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import {IMatrixClientCreds} from "../../../MatrixClientPeg"; +import PasswordLogin from "../../views/auth/PasswordLogin"; +import InlineSpinner from "../../views/elements/InlineSpinner"; +import Spinner from "../../views/elements/Spinner"; +import SSOButtons from "../../views/elements/SSOButtons"; +import ServerPicker from "../../views/elements/ServerPicker"; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. @@ -54,64 +47,80 @@ _td("Invalid base_url for m.identity_server"); _td("Identity server URL does not appear to be a valid identity server"); _td("General failure"); +interface IProps { + serverConfig: ValidatedServerConfig; + // If true, the component will consider itself busy. + busy?: boolean; + isSyncing?: boolean; + // Secondary HS which we try to log into if the user is using + // the default HS but login fails. Useful for migrating to a + // different homeserver without confusing users. + fallbackHsUrl?: string; + defaultDeviceDisplayName?: string; + fragmentAfterLogin?: string; + + // Called when the user has logged in. Params: + // - The object returned by the login API + // - The user's password, if applicable, (may be cached in memory for a + // short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(data: IMatrixClientCreds, password: string): void; + + // login shouldn't know or care how registration, password recovery, etc is done. + onRegisterClick(): void; + onForgotPasswordClick?(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} + +interface IState { + busy: boolean; + busyLoggingIn?: boolean; + errorText?: ReactNode; + loginIncorrect: boolean; + // can we attempt to log in or are there validation errors? + canTryLogin: boolean; + + flows?: LoginFlow[]; + + // used for preserving form values when changing homeserver + username: string; + phoneCountry?: string; + phoneNumber: string; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; +} + /* * A wire component which glues together login UI components and Login logic */ -export default class LoginComponent extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - The object returned by the login API - // - The user's password, if applicable, (may be cached in memory for a - // short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, +export default class LoginComponent extends React.PureComponent { + private unmounted = false; + private loginLogic: Login; - // If true, the component will consider itself busy. - busy: PropTypes.bool, - - // Secondary HS which we try to log into if the user is using - // the default HS but login fails. Useful for migrating to a - // different homeserver without confusing users. - fallbackHsUrl: PropTypes.string, - - defaultDeviceDisplayName: PropTypes.string, - - // login shouldn't know or care how registration, password recovery, - // etc is done. - onRegisterClick: PropTypes.func.isRequired, - onForgotPasswordClick: PropTypes.func, - onServerConfigChange: PropTypes.func.isRequired, - - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - isSyncing: PropTypes.bool, - }; + private readonly stepRendererMap: Record ReactNode>; constructor(props) { super(props); - this._unmounted = false; - this.state = { busy: false, busyLoggingIn: null, errorText: null, loginIncorrect: false, - canTryLogin: true, // can we attempt to log in or are there validation errors? + canTryLogin: true, + + flows: null, - // used for preserving form values when changing homeserver username: "", phoneCountry: null, phoneNumber: "", - // Phase of the overall login dialog. - phase: PHASE_LOGIN, - // The current login flow, such as password, SSO, etc. - currentFlow: null, // we need to load the flows from the server - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", @@ -119,23 +128,25 @@ export default class LoginComponent extends React.Component { // map from login step type to a function which will render a control // letting you do that login type - this._stepRendererMap = { - 'm.login.password': this._renderPasswordStep, + this.stepRendererMap = { + 'm.login.password': this.renderPasswordStep, // CAS and SSO are the same thing, modulo the url we link to - 'm.login.cas': () => this._renderSsoStep("cas"), - 'm.login.sso': () => this._renderSsoStep("sso"), + 'm.login.cas': () => this.renderSsoStep("cas"), + 'm.login.sso': () => this.renderSsoStep("sso"), }; + + CountlyAnalytics.instance.track("onboarding_login_begin"); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { - this._initLoginLogic(); + this.initLoginLogic(this.props.serverConfig); } componentWillUnmount() { - this._unmounted = true; + this.unmounted = true; } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -145,16 +156,9 @@ export default class LoginComponent extends React.Component { newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Ensure that we end up actually logging in to the right place - this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); + this.initLoginLogic(newProps.serverConfig); } - onPasswordLoginError = errorText => { - this.setState({ - errorText, - loginIncorrect: Boolean(errorText), - }); - }; - isBusy = () => this.state.busy || this.props.busy; onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { @@ -191,13 +195,13 @@ export default class LoginComponent extends React.Component { loginIncorrect: false, }); - this._loginLogic.loginViaPassword( + this.loginLogic.loginViaPassword( username, phoneCountry, phoneNumber, password, ).then((data) => { this.setState({serverIsAlive: true}); // it must be, we logged in. this.props.onLoggedIn(data, password); }, (error) => { - if (this._unmounted) { + if (this.unmounted) { return; } let errorText; @@ -209,21 +213,23 @@ export default class LoginComponent extends React.Component { } else if (error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + error.data.admin_contact, + { + 'monthly_active_user': _td( + "This homeserver has hit its Monthly Active User limit.", + ), + '': _td( + "This homeserver has exceeded one of its resource limits.", + ), + }, + ); const errorDetail = messageForResourceLimitError( error.data.limit_type, - error.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + error.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); errorText = (
    {errorTop}
    @@ -250,7 +256,7 @@ export default class LoginComponent extends React.Component { } } else { // other errors, not specific to doing a password login - errorText = this._errorTextFromError(error); + errorText = this.errorTextFromError(error); } this.setState({ @@ -288,7 +294,7 @@ export default class LoginComponent extends React.Component { // the busy state. In the case of a full MXID that resolves to the same // HS as Element's default HS though, there may not be any server change. // To avoid this trap, we clear busy here. For cases where the server - // actually has changed, `_initLoginLogic` will be called and manages + // actually has changed, `initLoginLogic` will be called and manages // busy state for its own liveness check. this.setState({ busy: false, @@ -301,7 +307,7 @@ export default class LoginComponent extends React.Component { message = e.translatedMessage; } - let errorText = message; + let errorText: ReactNode = message; let discoveryState = {}; if (AutoDiscoveryUtils.isLivelinessError(e)) { errorText = this.state.errorText; @@ -327,21 +333,6 @@ export default class LoginComponent extends React.Component { }); }; - onPhoneNumberBlur = phoneNumber => { - // Validate the phone number entered - if (!PHONE_NUMBER_REGEX.test(phoneNumber)) { - this.setState({ - errorText: _t('The phone number entered looks invalid'), - canTryLogin: false, - }); - } else { - this.setState({ - errorText: null, - canTryLogin: true, - }); - } - }; - onRegisterClick = ev => { ev.preventDefault(); ev.stopPropagation(); @@ -349,14 +340,16 @@ export default class LoginComponent extends React.Component { }; onTryRegisterClick = ev => { - const step = this._getCurrentFlowStep(); - if (step === 'm.login.sso' || step === 'm.login.cas') { - // If we're showing SSO it means that registration is also probably disabled, - // so intercept the click and instead pretend the user clicked 'Sign in with SSO'. + 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. + if (ssoFlow && !hasPasswordFlow) { ev.preventDefault(); ev.stopPropagation(); - const ssoKind = step === 'm.login.sso' ? 'sso' : 'cas'; - PlatformPeg.get().startSingleSignOn(this._loginLogic.createTemporaryClient(), ssoKind, + const ssoKind = ssoFlow.type === 'm.login.sso' ? 'sso' : 'cas'; + PlatformPeg.get().startSingleSignOn(this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin); } else { // Don't intercept - just go through to the register page @@ -364,24 +357,7 @@ export default class LoginComponent extends React.Component { } }; - onServerDetailsNextPhaseClick = () => { - this.setState({ - phase: PHASE_LOGIN, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - - async _initLoginLogic(hsUrl, isUrl) { - hsUrl = hsUrl || this.props.serverConfig.hsUrl; - isUrl = isUrl || this.props.serverConfig.isUrl; - + private async initLoginLogic({hsUrl, isUrl}: ValidatedServerConfig) { let isDefaultServer = false; if (this.props.serverConfig.isDefault && hsUrl === this.props.serverConfig.hsUrl @@ -394,11 +370,10 @@ export default class LoginComponent extends React.Component { const loginLogic = new Login(hsUrl, isUrl, fallbackHsUrl, { defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, }); - this._loginLogic = loginLogic; + this.loginLogic = loginLogic; this.setState({ busy: true, - currentFlow: null, // reset flow loginIncorrect: false, }); @@ -422,42 +397,26 @@ export default class LoginComponent extends React.Component { busy: false, ...AutoDiscoveryUtils.authComponentStateForError(e), }); - if (this.state.serverErrorIsFatal) { - // Server is dead: show server details prompt instead - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - return; - } } loginLogic.getFlows().then((flows) => { // look for a flow where we understand all of the steps. - for (let i = 0; i < flows.length; i++ ) { - if (!this._isSupportedFlow(flows[i])) { - continue; - } + const supportedFlows = flows.filter(this.isSupportedFlow); - // we just pick the first flow where we support all the - // steps. (we don't have a UI for multiple logins so let's skip - // that for now). - loginLogic.chooseFlow(i); + if (supportedFlows.length > 0) { this.setState({ - currentFlow: this._getCurrentFlowStep(), + flows: supportedFlows, }); return; } - // we got to the end of the list without finding a suitable - // flow. + + // we got to the end of the list without finding a suitable flow. this.setState({ - errorText: _t( - "This homeserver doesn't offer any login flows which are " + - "supported by this client.", - ), + errorText: _t("This homeserver doesn't offer any login flows which are supported by this client."), }); }, (err) => { this.setState({ - errorText: this._errorTextFromError(err), + errorText: this.errorTextFromError(err), loginIncorrect: false, canTryLogin: false, }); @@ -468,28 +427,24 @@ export default class LoginComponent extends React.Component { }); } - _isSupportedFlow(flow) { + private isSupportedFlow = (flow: LoginFlow): boolean => { // technically the flow can have multiple steps, but no one does this // for login and loginLogic doesn't support it so we can ignore it. - if (!this._stepRendererMap[flow.type]) { + if (!this.stepRendererMap[flow.type]) { console.log("Skipping flow", flow, "due to unsupported login type", flow.type); return false; } return true; - } + }; - _getCurrentFlowStep() { - return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; - } - - _errorTextFromError(err) { + private errorTextFromError(err: MatrixError): ReactNode { let errCode = err.errcode; if (!errCode && err.httpStatus) { errCode = "HTTP " + err.httpStatus; } - let errorText = _t("Error: Problem communicating with the given homeserver.") + - (errCode ? " (" + errCode + ")" : ""); + let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " + + "please try again later.") + (errCode ? " (" + errCode + ")" : ""); if (err.cors === 'rejected') { if (window.location.protocol === 'https:' && @@ -499,29 +454,27 @@ export default class LoginComponent extends React.Component { errorText = { _t("Can't connect to homeserver via HTTP when an HTTPS URL is in your browser bar. " + "Either use HTTPS or enable unsafe scripts.", {}, - { - 'a': (sub) => { - return - { sub } - ; - }, + { + 'a': (sub) => { + return + { sub } + ; }, - ) } + }) } ; } else { errorText = { _t("Can't connect to homeserver - please check your connectivity, ensure your " + "homeserver's SSL certificate is trusted, and that a browser extension " + "is not blocking requests.", {}, - { - 'a': (sub) => - - { sub } - , - }, - ) } + { + 'a': (sub) => + + { sub } + , + }) } ; } } @@ -529,121 +482,63 @@ export default class LoginComponent extends React.Component { return errorText; } - renderServerComponent() { - const ServerConfig = sdk.getComponent("auth.ServerConfig"); + renderLoginComponentForFlows() { + if (!this.state.flows) return null; - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } + // this is the ideal order we want to show the flows in + const order = [ + "m.login.password", + "m.login.sso", + ]; - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS) { - return null; - } - - const serverDetailsProps = {}; - if (PHASES_ENABLED) { - serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; - serverDetailsProps.submitText = _t("Next"); - serverDetailsProps.submitClass = "mx_Login_submit"; - } - - return ; + const flows = order.map(type => this.state.flows.find(flow => flow.type === type)).filter(Boolean); + return + { flows.map(flow => { + const stepRenderer = this.stepRendererMap[flow.type]; + return { stepRenderer() } + }) } + } - renderLoginComponentForStep() { - if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { - return null; - } - - const step = this.state.currentFlow; - - if (!step) { - return null; - } - - const stepRenderer = this._stepRendererMap[step]; - - if (stepRenderer) { - return stepRenderer(); - } - - return null; - } - - _renderPasswordStep = () => { - const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); - - let onEditServerDetailsClick = null; - // If custom URLs are allowed, wire up the server details edit link. - if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { - onEditServerDetailsClick = this.onEditServerDetailsClick; - } - + private renderPasswordStep = () => { return ( ); }; - _renderSsoStep = loginType => { - const SignInToText = sdk.getComponent('views.auth.SignInToText'); + private renderSsoStep = loginType => { + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow; - let onEditServerDetailsClick = null; - // If custom URLs are allowed, wire up the server details edit link. - if (PHASES_ENABLED && !SdkConfig.get()['disable_custom_urls']) { - onEditServerDetailsClick = this.onEditServerDetailsClick; - } - // XXX: This link does *not* have a target="_blank" because single sign-on relies on - // redirecting the user back to a URI once they're logged in. On the web, this means - // we use the same window and redirect back to Element. On Electron, this actually - // opens the SSO page in the Electron app itself due to - // https://github.com/electron/electron/issues/8841 and so happens to work. - // If this bug gets fixed, it will break SSO since it will open the SSO page in the - // user's browser, let them log into their SSO provider, then redirect their browser - // to vector://vector which, of course, will not work. return ( -
    - - - -
    + flow.type === "m.login.password")} + /> ); }; render() { - const Loader = sdk.getComponent("elements.Spinner"); - const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); const loader = this.isBusy() && !this.state.busyLoggingIn ? -
    : null; +
    : null; const errorText = this.state.errorText; @@ -683,9 +578,11 @@ export default class LoginComponent extends React.Component {
    ; } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( - - { _t('Create account') } - + + {_t("New? Create account", {}, { + a: sub => { sub }, + })} + ); } @@ -699,8 +596,11 @@ export default class LoginComponent extends React.Component { { errorTextSection } { serverDeadSection } - { this.renderServerComponent() } - { this.renderLoginComponentForStep() } + + { this.renderLoginComponentForFlows() } { footer } diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js deleted file mode 100644 index aa36de6596..0000000000 --- a/src/components/structures/auth/PostRegistration.js +++ /dev/null @@ -1,77 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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 React from 'react'; -import PropTypes from 'prop-types'; -import * as sdk from '../../../index'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import { _t } from '../../../languageHandler'; -import AuthPage from "../../views/auth/AuthPage"; - -export default class PostRegistration extends React.Component { - static propTypes = { - onComplete: PropTypes.func.isRequired, - }; - - state = { - avatarUrl: null, - errorString: null, - busy: false, - }; - - componentDidMount() { - // There is some assymetry between ChangeDisplayName and ChangeAvatar, - // as ChangeDisplayName will auto-get the name but ChangeAvatar expects - // the URL to be passed to you (because it's also used for room avatars). - const cli = MatrixClientPeg.get(); - this.setState({busy: true}); - const self = this; - cli.getProfileInfo(cli.credentials.userId).then(function(result) { - self.setState({ - avatarUrl: MatrixClientPeg.get().mxcUrlToHttp(result.avatar_url), - busy: false, - }); - }, function(error) { - self.setState({ - errorString: _t("Failed to fetch avatar URL"), - busy: false, - }); - }); - } - - render() { - const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); - const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - return ( - - - -
    - { _t('Set a display name:') } - - { _t('Upload an avatar:') } - - - { this.state.errorString } -
    -
    -
    - ); - } -} diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.tsx similarity index 54% rename from src/components/structures/auth/Registration.js rename to src/components/structures/auth/Registration.tsx index 630e04da9c..095f3d3433 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.tsx @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,109 +15,128 @@ limitations under the License. */ import Matrix from 'matrix-js-sdk'; -import React from 'react'; -import PropTypes from 'prop-types'; +import React, {ReactNode} from 'react'; +import {MatrixClient} from "matrix-js-sdk/src/client"; + import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; -import * as ServerType from '../../views/auth/ServerTypeSelector'; import AutoDiscoveryUtils, {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import * as Lifecycle from '../../../Lifecycle'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; -import Login from "../../../Login"; +import Login, {ISSOFlow} from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; +import SSOButtons from "../../views/elements/SSOButtons"; +import ServerPicker from '../../views/elements/ServerPicker'; -// Phases -// Show controls to configure server details -const PHASE_SERVER_DETAILS = 0; -// Show the appropriate registration flow(s) for the server -const PHASE_REGISTRATION = 1; +interface IProps { + serverConfig: ValidatedServerConfig; + defaultDeviceDisplayName: string; + email?: string; + brand?: string; + clientSecret?: string; + sessionId?: string; + idSid?: string; + fragmentAfterLogin?: string; -// Enable phases for registration -const PHASES_ENABLED = true; + // Called when the user has logged in. Params: + // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken + // - The user's password, if available and applicable (may be cached in memory + // for a short time so the user is not required to re-enter their password + // for operations like uploading cross-signing keys). + onLoggedIn(params: { + userId: string; + deviceId: string + homeserverUrl: string; + identityServerUrl?: string; + accessToken: string; + }, password: string): void; + makeRegistrationUrl(params: { + /* eslint-disable camelcase */ + client_secret: string; + hs_url: string; + is_url?: string; + session_id: string; + /* eslint-enable camelcase */ + }): void; + // registration shouldn't know or care how login is done. + onLoginClick(): void; + onServerConfigChange(config: ValidatedServerConfig): void; +} -export default class Registration extends React.Component { - static propTypes = { - // Called when the user has logged in. Params: - // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken - // - The user's password, if available and applicable (may be cached in memory - // for a short time so the user is not required to re-enter their password - // for operations like uploading cross-signing keys). - onLoggedIn: PropTypes.func.isRequired, +interface IState { + busy: boolean; + errorText?: ReactNode; + // true if we're waiting for the user to complete + // We remember the values entered by the user because + // the registration form will be unmounted during the + // course of registration, but if there's an error we + // want to bring back the registration form with the + // values the user entered still in it. We can keep + // them in this component's state since this component + // persist for the duration of the registration process. + formVals: Record; + // user-interactive auth + // If we've been given a session ID, we're resuming + // straight back into UI auth + doingUIAuth: boolean; + // If set, we've registered but are not going to log + // the user in to their new account automatically. + completedNoSignin: boolean; + flows: { + stages: string[]; + }[]; + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; - clientSecret: PropTypes.string, - sessionId: PropTypes.string, - makeRegistrationUrl: PropTypes.func.isRequired, - idSid: PropTypes.string, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - brand: PropTypes.string, - email: PropTypes.string, - // registration shouldn't know or care how login is done. - onLoginClick: PropTypes.func.isRequired, - onServerConfigChange: PropTypes.func.isRequired, - defaultDeviceDisplayName: PropTypes.string, - }; + // Our matrix client - part of state because we can't render the UI auth + // component without it. + matrixClient?: MatrixClient; + // The user ID we've just registered + registeredUsername?: string; + // if a different user ID to the one we just registered is logged in, + // this is the user ID that's logged in. + differentLoggedInUserId?: string; + // the SSO flow definition, this is fetched from /login as that's the only + // place it is exposed. + ssoFlow?: ISSOFlow; +} + +export default class Registration extends React.Component { + loginLogic: Login; constructor(props) { super(props); - const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig); this.state = { busy: false, errorText: null, - // We remember the values entered by the user because - // the registration form will be unmounted during the - // course of registration, but if there's an error we - // want to bring back the registration form with the - // values the user entered still in it. We can keep - // them in this component's state since this component - // persist for the duration of the registration process. formVals: { email: this.props.email, }, - // true if we're waiting for the user to complete - // user-interactive auth - // If we've been given a session ID, we're resuming - // straight back into UI auth doingUIAuth: Boolean(this.props.sessionId), - serverType, - // Phase of the overall registration dialog. - phase: PHASE_REGISTRATION, flows: null, - // If set, we've registered but are not going to log - // the user in to their new account automatically. completedNoSignin: false, - - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", - - // Our matrix client - part of state because we can't render the UI auth - // component without it. - matrixClient: null, - - // whether the HS requires an ID server to register with a threepid - serverRequiresIdServer: null, - - // The user ID we've just registered - registeredUsername: null, - - // if a different user ID to the one we just registered is logged in, - // this is the user ID that's logged in. - differentLoggedInUserId: null, }; + + const {hsUrl, isUrl} = this.props.serverConfig; + this.loginLogic = new Login(hsUrl, isUrl, null, { + defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used + }); } componentDidMount() { - this._unmounted = false; - this._replaceClient(); + this.replaceClient(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event @@ -129,63 +145,10 @@ export default class Registration extends React.Component { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; - this._replaceClient(newProps.serverConfig); - - // Handle cases where the user enters "https://matrix.org" for their server - // from the advanced option - we should default to FREE at that point. - const serverType = ServerType.getTypeFromServerConfig(newProps.serverConfig); - if (serverType !== this.state.serverType) { - // Reset the phase to default phase for the server type. - this.setState({ - serverType, - phase: this.getDefaultPhaseForServerType(serverType), - }); - } + this.replaceClient(newProps.serverConfig); } - getDefaultPhaseForServerType(type) { - switch (type) { - case ServerType.FREE: { - // Move directly to the registration phase since the server - // details are fixed. - return PHASE_REGISTRATION; - } - case ServerType.PREMIUM: - case ServerType.ADVANCED: - return PHASE_SERVER_DETAILS; - } - } - - onServerTypeChange = type => { - this.setState({ - serverType: type, - }); - - // When changing server types, set the HS / IS URLs to reasonable defaults for the - // the new type. - switch (type) { - case ServerType.FREE: { - const { serverConfig } = ServerType.TYPES.FREE; - this.props.onServerConfigChange(serverConfig); - break; - } - case ServerType.PREMIUM: - // We can accept whatever server config was the default here as this essentially - // acts as a slightly different "custom server"/ADVANCED option. - break; - case ServerType.ADVANCED: - // Use the default config from the config - this.props.onServerConfigChange(SdkConfig.get()["validated_server_config"]); - break; - } - - // Reset the phase to default phase for the server type. - this.setState({ - phase: this.getDefaultPhaseForServerType(type), - }); - }; - - async _replaceClient(serverConfig) { + private async replaceClient(serverConfig: ValidatedServerConfig) { this.setState({ errorText: null, serverDeadError: null, @@ -194,7 +157,6 @@ export default class Registration extends React.Component { // the UI auth component while we don't have a matrix client) busy: true, }); - if (!serverConfig) serverConfig = this.props.serverConfig; // Do a liveliness check on the URLs try { @@ -222,16 +184,20 @@ export default class Registration extends React.Component { idBaseUrl: isUrl, }); - let serverRequiresIdServer = true; + this.loginLogic.setHomeserverUrl(hsUrl); + this.loginLogic.setIdentityServerUrl(isUrl); + + let ssoFlow: ISSOFlow; try { - serverRequiresIdServer = await cli.doesServerRequireIdServerParam(); + const loginFlows = await this.loginLogic.getFlows(); + ssoFlow = loginFlows.find(f => f.type === "m.login.sso" || f.type === "m.login.cas") as ISSOFlow; } catch (e) { - console.log("Unable to determine is server needs id_server param", e); + console.error("Failed to get login flows to check for SSO support", e); } this.setState({ matrixClient: cli, - serverRequiresIdServer, + ssoFlow, busy: false, }); const showGenericError = (e) => { @@ -246,7 +212,7 @@ export default class Registration extends React.Component { // do SSO instead. If we've already started the UI Auth process though, we don't // need to. if (!this.state.doingUIAuth) { - await this._makeRegisterRequest(null); + await this.makeRegisterRequest(null); // This should never succeed since we specified no auth object. console.log("Expecting 401 from register request but got success!"); } @@ -259,26 +225,16 @@ export default class Registration extends React.Component { // At this point registration is pretty much disabled, but before we do that let's // quickly check to see if the server supports SSO instead. If it does, we'll send // the user off to the login page to figure their account out. - try { - const loginLogic = new Login(hsUrl, isUrl, null, { - defaultDeviceDisplayName: "Element login check", // We shouldn't ever be used + if (ssoFlow) { + // Redirect to login page - server probably expects SSO only + dis.dispatch({action: 'start_login'}); + } else { + this.setState({ + serverErrorIsFatal: true, // fatal because user cannot continue on this server + errorText: _t("Registration has been disabled on this homeserver."), + // add empty flows array to get rid of spinner + flows: [], }); - const flows = await loginLogic.getFlows(); - const hasSsoFlow = flows.find(f => f.type === 'm.login.sso' || f.type === 'm.login.cas'); - if (hasSsoFlow) { - // Redirect to login page - server probably expects SSO only - dis.dispatch({action: 'start_login'}); - } else { - this.setState({ - serverErrorIsFatal: true, // fatal because user cannot continue on this server - errorText: _t("Registration has been disabled on this homeserver."), - // add empty flows array to get rid of spinner - flows: [], - }); - } - } catch (e) { - console.error("Failed to get login flows to check for SSO support", e); - showGenericError(e); } } else { console.log("Unable to query for supported registration methods.", e); @@ -287,7 +243,7 @@ export default class Registration extends React.Component { } } - onFormSubmit = formVals => { + private onFormSubmit = formVals => { this.setState({ errorText: "", busy: true, @@ -296,7 +252,7 @@ export default class Registration extends React.Component { }); }; - _requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { + private requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { return this.state.matrixClient.requestRegisterEmailToken( emailAddress, clientSecret, @@ -310,28 +266,26 @@ export default class Registration extends React.Component { ); } - _onUIAuthFinished = async (success, response, extra) => { + private onUIAuthFinished = async (success, response, extra) => { if (!success) { let msg = response.message || response.toString(); // can we give a better error message? if (response.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const errorTop = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - 'monthly_active_user': _td( - "This homeserver has hit its Monthly Active User limit.", - ), - '': _td( - "This homeserver has exceeded one of its resource limits.", - ), - }); + response.data.admin_contact, + { + 'monthly_active_user': _td("This homeserver has hit its Monthly Active User limit."), + '': _td("This homeserver has exceeded one of its resource limits."), + }, + ); const errorDetail = messageForResourceLimitError( response.data.limit_type, - response.data.admin_contact, { - '': _td( - "Please contact your service administrator to continue using this service.", - ), - }); + response.data.admin_contact, + { + '': _td("Please contact your service administrator to continue using this service."), + }, + ); msg =

    {errorTop}

    {errorDetail}

    @@ -339,11 +293,13 @@ export default class Registration extends React.Component { } else if (response.required_stages && response.required_stages.indexOf('m.login.msisdn') > -1) { let msisdnAvailable = false; for (const flow of response.available_flows) { - msisdnAvailable |= flow.stages.indexOf('m.login.msisdn') > -1; + msisdnAvailable = msisdnAvailable || flow.stages.includes('m.login.msisdn'); } if (!msisdnAvailable) { msg = _t('This server does not support authentication with a phone number.'); } + } else if (response.errcode === "M_USER_IN_USE") { + msg = _t("That username already exists, please try another."); } this.setState({ busy: false, @@ -358,6 +314,10 @@ export default class Registration extends React.Component { const newState = { doingUIAuth: false, registeredUsername: response.user_id, + differentLoggedInUserId: null, + completedNoSignin: false, + // we're still busy until we get unmounted: don't show the registration form again + busy: true, }; // The user came in through an email validation link. To avoid overwriting @@ -365,15 +325,12 @@ export default class Registration extends React.Component { // isn't a guest user since we'll usually have set a guest user session before // starting the registration process. This isn't perfect since it's possible // the user had a separate guest session they didn't actually mean to replace. - const sessionOwner = Lifecycle.getStoredSessionOwner(); - const sessionIsGuest = Lifecycle.getStoredSessionIsGuest(); + const [sessionOwner, sessionIsGuest] = await Lifecycle.getStoredSessionOwner(); if (sessionOwner && !sessionIsGuest && sessionOwner !== response.userId) { console.log( `Found a session for ${sessionOwner} but ${response.userId} has just registered.`, ); newState.differentLoggedInUserId = sessionOwner; - } else { - newState.differentLoggedInUserId = null; } if (response.access_token) { @@ -385,9 +342,7 @@ export default class Registration extends React.Component { accessToken: response.access_token, }, this.state.formVals.password); - this._setupPushers(); - // we're still busy until we get unmounted: don't show the registration form again - newState.busy = true; + this.setupPushers(); } else { newState.busy = false; newState.completedNoSignin = true; @@ -396,7 +351,7 @@ export default class Registration extends React.Component { this.setState(newState); }; - _setupPushers() { + private setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -419,38 +374,23 @@ export default class Registration extends React.Component { }); } - onLoginClick = ev => { + private onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; - onGoToFormClicked = ev => { + private onGoToFormClicked = ev => { ev.preventDefault(); ev.stopPropagation(); - this._replaceClient(); + this.replaceClient(this.props.serverConfig); this.setState({ busy: false, doingUIAuth: false, - phase: PHASE_REGISTRATION, }); }; - onServerDetailsNextPhaseClick = async () => { - this.setState({ - phase: PHASE_REGISTRATION, - }); - }; - - onEditServerDetailsClick = ev => { - ev.preventDefault(); - ev.stopPropagation(); - this.setState({ - phase: PHASE_SERVER_DETAILS, - }); - }; - - _makeRegisterRequest = auth => { + private makeRegisterRequest = auth => { // We inhibit login if we're trying to register with an email address: this // avoids a lot of complex race conditions that can occur if we try to log // the user in one one or both of the tabs they might end up with after @@ -466,13 +406,15 @@ export default class Registration extends React.Component { username: this.state.formVals.username, password: this.state.formVals.password, initial_device_display_name: this.props.defaultDeviceDisplayName, + auth: undefined, + inhibit_login: undefined, }; if (auth) registerParams.auth = auth; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; return this.state.matrixClient.registerRequest(registerParams); }; - _getUIAuthInputs() { + private getUIAuthInputs() { return { emailAddress: this.state.formVals.email, phoneCountry: this.state.formVals.phoneCountry, @@ -483,7 +425,7 @@ export default class Registration extends React.Component { // Links to the login page shown after registration is completed are routed through this // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) - _onLoginClickWithCheck = async ev => { + private onLoginClickWithCheck = async ev => { ev.preventDefault(); const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); @@ -493,72 +435,7 @@ export default class Registration extends React.Component { } }; - renderServerComponent() { - const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); - const ServerConfig = sdk.getComponent("auth.ServerConfig"); - const ModularServerConfig = sdk.getComponent("auth.ModularServerConfig"); - - if (SdkConfig.get()['disable_custom_urls']) { - return null; - } - - // If we're on a different phase, we only show the server type selector, - // which is always shown if we allow custom URLs at all. - // (if there's a fatal server error, we need to show the full server - // config as the user may need to change servers to resolve the error). - if (PHASES_ENABLED && this.state.phase !== PHASE_SERVER_DETAILS && !this.state.serverErrorIsFatal) { - return
    - -
    ; - } - - const serverDetailsProps = {}; - if (PHASES_ENABLED) { - serverDetailsProps.onAfterSubmit = this.onServerDetailsNextPhaseClick; - serverDetailsProps.submitText = _t("Next"); - serverDetailsProps.submitClass = "mx_Login_submit"; - } - - let serverDetails = null; - switch (this.state.serverType) { - case ServerType.FREE: - break; - case ServerType.PREMIUM: - serverDetails = ; - break; - case ServerType.ADVANCED: - serverDetails = ; - break; - } - - return
    - - {serverDetails} -
    ; - } - - renderRegisterComponent() { - if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { - return null; - } - + private renderRegisterComponent() { const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); const Spinner = sdk.getComponent('elements.Spinner'); const RegistrationForm = sdk.getComponent('auth.RegistrationForm'); @@ -566,10 +443,10 @@ export default class Registration extends React.Component { if (this.state.matrixClient && this.state.doingUIAuth) { return
    ; } else if (this.state.flows.length) { - let onEditServerDetailsClick = null; - // If custom URLs are allowed and we haven't selected the Free server type, wire - // up the server details edit link. - if ( - PHASES_ENABLED && - !SdkConfig.get()['disable_custom_urls'] && - this.state.serverType !== ServerType.FREE - ) { - onEditServerDetailsClick = this.onEditServerDetailsClick; + let ssoSection; + if (this.state.ssoFlow) { + let continueWithSection; + const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || []; + // when there is only a single (or 0) providers we show a wide button with `Continue with X` text + if (providers.length > 1) { + // i18n: ssoButtons is a placeholder to help translators understand context + continueWithSection =

    + { _t("Continue with %(ssoButtons)s", { ssoButtons: "" }).trim() } +

    ; + } + + // i18n: ssoButtons & usernamePassword are placeholders to help translators understand context + ssoSection = + { continueWithSection } + +

    + { _t("%(ssoButtons)s Or %(usernamePassword)s", { ssoButtons: "", usernamePassword: ""}).trim() } +

    +
    ; } - return ; + return + { ssoSection } + + ; } } @@ -634,13 +528,15 @@ export default class Registration extends React.Component { ); } - const signIn = - { _t('Sign in instead') } - ; + const signIn = + {_t("Already have an account? Sign in here", {}, { + a: sub => { sub }, + })} + ; // Only show the 'go back' button if you're not looking at the form let goBack; - if ((PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) || this.state.doingUIAuth) { + if (this.state.doingUIAuth) { goBack = { _t('Go back') } ; @@ -658,7 +554,7 @@ export default class Registration extends React.Component { loggedInUserId: this.state.differentLoggedInUserId, }, )}

    -

    +

    {_t("Continue with previous account")}

    ; @@ -667,7 +563,7 @@ export default class Registration extends React.Component { regDoneText =

    {_t( "Log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

    ; } else { @@ -677,7 +573,7 @@ export default class Registration extends React.Component { regDoneText =

    {_t( "You can now close this window or log in to your new account.", {}, { - a: (sub) => {sub}, + a: (sub) => {sub}, }, )}

    ; } @@ -687,10 +583,15 @@ export default class Registration extends React.Component {
    ; } else { body =
    -

    { _t('Create your account') }

    +

    { _t('Create account') }

    { errorText } { serverDeadSection } - { this.renderServerComponent() } + { this.renderRegisterComponent() } { goBack } { signIn } diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.js index 6d090936e5..3e7264dfec 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.js @@ -120,9 +120,9 @@ export default class SetupEncryptionBody extends React.Component { const store = SetupEncryptionStore.sharedInstance(); let recoveryKeyPrompt; if (store.keyInfo && keyHasPassphrase(store.keyInfo)) { - recoveryKeyPrompt = _t("Use Recovery Key or Passphrase"); + recoveryKeyPrompt = _t("Use Security Key or Phrase"); } else if (store.keyInfo) { - recoveryKeyPrompt = _t("Use Recovery Key"); + recoveryKeyPrompt = _t("Use Security Key"); } let useRecoveryKeyButton; diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.js index a539c8c9ee..a7fe340457 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.js @@ -24,8 +24,8 @@ import Modal from '../../../Modal'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; -import SSOButton from "../../views/elements/SSOButton"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; +import SSOButtons from "../../views/elements/SSOButtons"; const LOGIN_VIEW = { LOADING: 1, @@ -72,9 +72,12 @@ export default class SoftLogout extends React.Component { this._initLogin(); - MatrixClientPeg.get().countSessionsNeedingBackup().then(remaining => { - this.setState({keyBackupNeeded: remaining > 0}); - }); + const cli = MatrixClientPeg.get(); + if (cli.isCryptoEnabled()) { + cli.countSessionsNeedingBackup().then(remaining => { + this.setState({ keyBackupNeeded: remaining > 0 }); + }); + } } onClearAll = () => { @@ -101,10 +104,11 @@ export default class SoftLogout extends React.Component { // Note: we don't use the existing Login class because it is heavily flow-based. We don't // care about login flows here, unless it is the single flow we support. const client = MatrixClientPeg.get(); - const loginViews = (await client.loginFlows()).flows.map(f => FLOWS_TO_VIEWS[f.type]); + const flows = (await client.loginFlows()).flows; + const loginViews = flows.map(f => FLOWS_TO_VIEWS[f.type]); const chosenView = loginViews.filter(f => !!f)[0] || LOGIN_VIEW.UNSUPPORTED; - this.setState({loginView: chosenView}); + this.setState({ flows, loginView: chosenView }); } onPasswordChange = (ev) => { @@ -240,13 +244,18 @@ export default class SoftLogout extends React.Component { introText = _t("Sign in and regain access to your account."); } // else we already have a message and should use it (key backup warning) + const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"; + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType); + return (

    {introText}

    - flow.type === "m.login.password")} />
    ); diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index 783d519621..e2d7d594fa 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -17,6 +17,7 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; +import CountlyAnalytics from "../../../CountlyAnalytics"; const DIV_ID = 'mx_recaptcha'; @@ -45,6 +46,8 @@ export default class CaptchaForm extends React.Component { this._captchaWidgetId = null; this._recaptchaContainer = createRef(); + + CountlyAnalytics.instance.track("onboarding_grecaptcha_begin"); } componentDidMount() { @@ -99,10 +102,16 @@ export default class CaptchaForm extends React.Component { console.log("Loaded recaptcha script."); try { this._renderRecaptcha(DIV_ID); + // clear error if re-rendered + this.setState({ + errorText: null, + }); + CountlyAnalytics.instance.track("onboarding_grecaptcha_loaded"); } catch (e) { this.setState({ errorText: e.toString(), }); + CountlyAnalytics.instance.track("onboarding_grecaptcha_error", { error: e.toString() }); } } diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index 37b1967c48..3296b574a4 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -123,7 +123,7 @@ export default class CountryDropdown extends React.Component { const options = displayedCountries.map((country) => { return
    { this._flagImgForIso2(country.iso2) } - { country.name } (+{ country.prefix }) + { _t(country.name) } (+{ country.prefix })
    ; }); diff --git a/src/components/views/auth/CustomServerDialog.js b/src/components/views/auth/CustomServerDialog.js deleted file mode 100644 index 138f8c4689..0000000000 --- a/src/components/views/auth/CustomServerDialog.js +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; - -export default class CustomServerDialog extends React.Component { - render() { - const brand = SdkConfig.get().brand; - return ( -
    -
    - { _t("Custom Server Options") } -
    -
    -

    {_t( - "You can use the custom server options to sign into other " + - "Matrix servers by specifying a different homeserver URL. This " + - "allows you to use %(brand)s with an existing Matrix account on a " + - "different homeserver.", - { brand }, - )}

    -
    -
    - -
    -
    - ); - } -} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 47263c1e21..7dc1976641 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -18,7 +18,6 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import url from 'url'; import classnames from 'classnames'; import * as sdk from '../../../index'; @@ -26,6 +25,7 @@ import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; import Spinner from "../elements/Spinner"; +import CountlyAnalytics from "../../../CountlyAnalytics"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -189,6 +189,7 @@ export class RecaptchaAuthEntry extends React.Component { } _onCaptchaResponse = response => { + CountlyAnalytics.instance.track("onboarding_grecaptcha_submit"); this.props.submitAuthDict({ type: RecaptchaAuthEntry.LOGIN_TYPE, response: response, @@ -297,6 +298,8 @@ export class TermsAuthEntry extends React.Component { toggledPolicies: initToggles, policies: pickedPolicies, }; + + CountlyAnalytics.instance.track("onboarding_terms_begin"); } @@ -326,8 +329,12 @@ export class TermsAuthEntry extends React.Component { allChecked = allChecked && checked; } - if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); - else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); + if (allChecked) { + this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); + CountlyAnalytics.instance.track("onboarding_terms_complete"); + } else { + this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); + } }; render() { @@ -413,12 +420,12 @@ export class EmailIdentityAuthEntry extends React.Component { return ; } else { return ( -
    -

    { _t("An email has been sent to %(emailAddress)s", - { emailAddress: (sub) => { this.props.inputs.emailAddress } }, +

    +

    { _t("A confirmation email has been sent to %(emailAddress)s", + { emailAddress: (sub) => { this.props.inputs.emailAddress } }, ) }

    -

    { _t("Please check your email to continue registration.") }

    +

    { _t("Open the link in the email to continue registration.") }

    ); } @@ -492,17 +499,11 @@ export class MsisdnAuthEntry extends React.Component { }); try { - const requiresIdServerParam = - await this.props.matrixClient.doesServerRequireIdServerParam(); let result; if (this._submitUrl) { result = await this.props.matrixClient.submitMsisdnTokenOtherUrl( this._submitUrl, this._sid, this.props.clientSecret, this.state.token, ); - } else if (requiresIdServerParam) { - result = await this.props.matrixClient.submitMsisdnToken( - this._sid, this.props.clientSecret, this.state.token, - ); } else { throw new Error("The registration with MSISDN flow is misconfigured"); } @@ -511,12 +512,6 @@ export class MsisdnAuthEntry extends React.Component { sid: this._sid, client_secret: this.props.clientSecret, }; - if (requiresIdServerParam) { - const idServerParsedUrl = url.parse( - this.props.matrixClient.getIdentityServerUrl(), - ); - creds.id_server = idServerParsedUrl.host; - } this.props.submitAuthDict({ type: MsisdnAuthEntry.LOGIN_TYPE, // TODO: Remove `threepid_creds` once servers support proper UIA @@ -614,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, }; } @@ -623,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); }; @@ -661,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} +
    +
    ; } } @@ -715,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/ModularServerConfig.js b/src/components/views/auth/ModularServerConfig.js deleted file mode 100644 index 28fd16379d..0000000000 --- a/src/components/views/auth/ModularServerConfig.js +++ /dev/null @@ -1,124 +0,0 @@ -/* -Copyright 2019 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 React from 'react'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import SdkConfig from "../../../SdkConfig"; -import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; -import * as ServerType from '../../views/auth/ServerTypeSelector'; -import ServerConfig from "./ServerConfig"; - -const MODULAR_URL = 'https://element.io/matrix-services' + - '?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication'; - -// TODO: TravisR - Can this extend ServerConfig for most things? - -/* - * Configure the Modular server name. - * - * This is a variant of ServerConfig with only the HS field and different body - * text that is specific to the Modular case. - */ -export default class ModularServerConfig extends ServerConfig { - static propTypes = ServerConfig.propTypes; - - async validateAndApplyServer(hsUrl, isUrl) { - // Always try and use the defaults first - const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; - if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(defaultConfig); - return defaultConfig; - } - - this.setState({ - hsUrl, - isUrl, - busy: true, - errorText: "", - }); - - try { - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(result); - return result; - } catch (e) { - console.error(e); - let message = _t("Unable to validate homeserver/identity server"); - if (e.translatedMessage) { - message = e.translatedMessage; - } - this.setState({ - busy: false, - errorText: message, - }); - - return null; - } - } - - async validateServer() { - // TODO: Do we want to support .well-known lookups here? - // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to - // find their homeserver without demanding they use "https://matrix.org" - return this.validateAndApplyServer(this.state.hsUrl, ServerType.TYPES.PREMIUM.identityServerUrl); - } - - render() { - const Field = sdk.getComponent('elements.Field'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const submitButton = this.props.submitText - ? {this.props.submitText} - : null; - - return ( -
    -

    {_t("Your server")}

    - {_t( - "Enter the location of your Element Matrix Services homeserver. It may use your own " + - "domain name or be a subdomain of element.io.", - {}, { - a: sub => - {sub} - , - }, - )} - -
    - -
    - {submitButton} - -
    - ); - } -} diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index b420ed0872..e240ad61ca 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -21,9 +21,9 @@ import zxcvbn from "zxcvbn"; import SdkConfig from "../../../SdkConfig"; import withValidation, {IFieldState, IValidationResult} from "../elements/Validation"; import {_t, _td} from "../../../languageHandler"; -import Field from "../elements/Field"; +import Field, {IInputProps} from "../elements/Field"; -interface IProps { +interface IProps extends Omit { autoFocus?: boolean; id?: string; className?: string; diff --git a/src/components/views/auth/PasswordLogin.js b/src/components/views/auth/PasswordLogin.js deleted file mode 100644 index 3bd9b557bc..0000000000 --- a/src/components/views/auth/PasswordLogin.js +++ /dev/null @@ -1,354 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019 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 React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import SdkConfig from '../../../SdkConfig'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import AccessibleButton from "../elements/AccessibleButton"; - -/** - * A pure UI component which displays a username/password form. - */ -export default class PasswordLogin extends React.Component { - static propTypes = { - onSubmit: PropTypes.func.isRequired, // fn(username, password) - onError: PropTypes.func, - onEditServerDetailsClick: PropTypes.func, - onForgotPasswordClick: PropTypes.func, // fn() - initialUsername: PropTypes.string, - initialPhoneCountry: PropTypes.string, - initialPhoneNumber: PropTypes.string, - initialPassword: PropTypes.string, - onUsernameChanged: PropTypes.func, - onPhoneCountryChanged: PropTypes.func, - onPhoneNumberChanged: PropTypes.func, - onPasswordChanged: PropTypes.func, - loginIncorrect: PropTypes.bool, - disableSubmit: PropTypes.bool, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - busy: PropTypes.bool, - }; - - static defaultProps = { - onError: function() {}, - onEditServerDetailsClick: null, - onUsernameChanged: function() {}, - onUsernameBlur: function() {}, - onPasswordChanged: function() {}, - onPhoneCountryChanged: function() {}, - onPhoneNumberChanged: function() {}, - onPhoneNumberBlur: function() {}, - initialUsername: "", - initialPhoneCountry: "", - initialPhoneNumber: "", - initialPassword: "", - loginIncorrect: false, - disableSubmit: false, - }; - - static LOGIN_FIELD_EMAIL = "login_field_email"; - static LOGIN_FIELD_MXID = "login_field_mxid"; - static LOGIN_FIELD_PHONE = "login_field_phone"; - - constructor(props) { - super(props); - this.state = { - username: this.props.initialUsername, - password: this.props.initialPassword, - phoneCountry: this.props.initialPhoneCountry, - phoneNumber: this.props.initialPhoneNumber, - loginType: PasswordLogin.LOGIN_FIELD_MXID, - }; - - this.onForgotPasswordClick = this.onForgotPasswordClick.bind(this); - this.onSubmitForm = this.onSubmitForm.bind(this); - this.onUsernameChanged = this.onUsernameChanged.bind(this); - this.onUsernameBlur = this.onUsernameBlur.bind(this); - this.onLoginTypeChange = this.onLoginTypeChange.bind(this); - this.onPhoneCountryChanged = this.onPhoneCountryChanged.bind(this); - this.onPhoneNumberChanged = this.onPhoneNumberChanged.bind(this); - this.onPhoneNumberBlur = this.onPhoneNumberBlur.bind(this); - this.onPasswordChanged = this.onPasswordChanged.bind(this); - this.isLoginEmpty = this.isLoginEmpty.bind(this); - } - - onForgotPasswordClick(ev) { - ev.preventDefault(); - ev.stopPropagation(); - this.props.onForgotPasswordClick(); - } - - onSubmitForm(ev) { - ev.preventDefault(); - - let username = ''; // XXX: Synapse breaks if you send null here: - let phoneCountry = null; - let phoneNumber = null; - let error; - - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - username = this.state.username; - if (!username) { - error = _t('The email field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_MXID: - username = this.state.username; - if (!username) { - error = _t('The username field must not be blank.'); - } - break; - case PasswordLogin.LOGIN_FIELD_PHONE: - phoneCountry = this.state.phoneCountry; - phoneNumber = this.state.phoneNumber; - if (!phoneNumber) { - error = _t('The phone number field must not be blank.'); - } - break; - } - - if (error) { - this.props.onError(error); - return; - } - - if (!this.state.password) { - this.props.onError(_t('The password field must not be blank.')); - return; - } - - this.props.onSubmit( - username, - phoneCountry, - phoneNumber, - this.state.password, - ); - } - - onUsernameChanged(ev) { - this.setState({username: ev.target.value}); - this.props.onUsernameChanged(ev.target.value); - } - - onUsernameBlur(ev) { - this.props.onUsernameBlur(ev.target.value); - } - - onLoginTypeChange(ev) { - const loginType = ev.target.value; - this.props.onError(null); // send a null error to clear any error messages - this.setState({ - loginType: loginType, - username: "", // Reset because email and username use the same state - }); - } - - onPhoneCountryChanged(country) { - this.setState({ - phoneCountry: country.iso2, - phonePrefix: country.prefix, - }); - this.props.onPhoneCountryChanged(country.iso2); - } - - onPhoneNumberChanged(ev) { - this.setState({phoneNumber: ev.target.value}); - this.props.onPhoneNumberChanged(ev.target.value); - } - - onPhoneNumberBlur(ev) { - this.props.onPhoneNumberBlur(ev.target.value); - } - - onPasswordChanged(ev) { - this.setState({password: ev.target.value}); - this.props.onPasswordChanged(ev.target.value); - } - - renderLoginField(loginType, autoFocus) { - const Field = sdk.getComponent('elements.Field'); - - const classes = {}; - - switch (loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_MXID: - classes.error = this.props.loginIncorrect && !this.state.username; - return ; - case PasswordLogin.LOGIN_FIELD_PHONE: { - const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - classes.error = this.props.loginIncorrect && !this.state.phoneNumber; - - const phoneCountry = ; - - return ; - } - } - } - - isLoginEmpty() { - switch (this.state.loginType) { - case PasswordLogin.LOGIN_FIELD_EMAIL: - case PasswordLogin.LOGIN_FIELD_MXID: - return !this.state.username; - case PasswordLogin.LOGIN_FIELD_PHONE: - return !this.state.phoneCountry || !this.state.phoneNumber; - } - } - - render() { - const Field = sdk.getComponent('elements.Field'); - const SignInToText = sdk.getComponent('views.auth.SignInToText'); - - let forgotPasswordJsx; - - if (this.props.onForgotPasswordClick) { - forgotPasswordJsx = - {_t('Not sure of your password? Set a new one', {}, { - a: sub => ( - - {sub} - - ), - })} - ; - } - - const pwFieldClass = classNames({ - error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field - }); - - // If login is empty, autoFocus login, otherwise autoFocus password. - // this is for when auto server discovery remounts us when the user tries to tab from username to password - const autoFocusPassword = !this.isLoginEmpty(); - const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); - - let loginType; - if (!SdkConfig.get().disable_3pid_login) { - loginType = ( -
    - - - - - - -
    - ); - } - - return ( -
    - -
    - {loginType} - {loginField} - - {forgotPasswordJsx} - { !this.props.busy && } - -
    - ); - } -} diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx new file mode 100644 index 0000000000..b2a3d62f55 --- /dev/null +++ b/src/components/views/auth/PasswordLogin.tsx @@ -0,0 +1,485 @@ +/* +Copyright 2015, 2016, 2017, 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. +*/ + +import React from 'react'; +import classNames from 'classnames'; + +import { _t } from '../../../languageHandler'; +import SdkConfig from '../../../SdkConfig'; +import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; +import AccessibleButton from "../elements/AccessibleButton"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import withValidation from "../elements/Validation"; +import * as Email from "../../../email"; +import Field from "../elements/Field"; +import CountryDropdown from "./CountryDropdown"; + +// For validating phone numbers without country codes +const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; + +interface IProps { + username: string; // also used for email address + phoneCountry: string; + phoneNumber: string; + + serverConfig: ValidatedServerConfig; + loginIncorrect?: boolean; + disableSubmit?: boolean; + busy?: boolean; + + onSubmit(username: string, phoneCountry: void, phoneNumber: void, password: string): void; + onSubmit(username: void, phoneCountry: string, phoneNumber: string, password: string): void; + onUsernameChanged?(username: string): void; + onUsernameBlur?(username: string): void; + onPhoneCountryChanged?(phoneCountry: string): void; + onPhoneNumberChanged?(phoneNumber: string): void; + onForgotPasswordClick?(): void; +} + +interface IState { + fieldValid: Partial>; + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, + password: "", +} + +enum LoginField { + Email = "login_field_email", + MatrixId = "login_field_mxid", + Phone = "login_field_phone", + Password = "login_field_phone", +} + +/* + * A pure UI component which displays a username/password form. + * The email/username/phone fields are fully-controlled, the password field is not. + */ +export default class PasswordLogin extends React.PureComponent { + static defaultProps = { + onUsernameChanged: function() {}, + onUsernameBlur: function() {}, + onPhoneCountryChanged: function() {}, + onPhoneNumberChanged: function() {}, + loginIncorrect: false, + disableSubmit: false, + }; + + constructor(props) { + super(props); + this.state = { + // Field error codes by field ID + fieldValid: {}, + loginType: LoginField.MatrixId, + password: "", + }; + } + + private onForgotPasswordClick = ev => { + ev.preventDefault(); + ev.stopPropagation(); + this.props.onForgotPasswordClick(); + }; + + private onSubmitForm = async ev => { + ev.preventDefault(); + + const allFieldsValid = await this.verifyFieldsBeforeSubmit(); + if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); + return; + } + + let username = ''; // XXX: Synapse breaks if you send null here: + let phoneCountry = null; + let phoneNumber = null; + + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + username = this.props.username; + break; + case LoginField.Phone: + phoneCountry = this.props.phoneCountry; + phoneNumber = this.props.phoneNumber; + break; + } + + this.props.onSubmit(username, phoneCountry, phoneNumber, this.state.password); + }; + + private onUsernameChanged = ev => { + this.props.onUsernameChanged(ev.target.value); + }; + + private onUsernameFocus = () => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_focus"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_focus"); + } + }; + + private onUsernameBlur = ev => { + if (this.state.loginType === LoginField.MatrixId) { + CountlyAnalytics.instance.track("onboarding_login_mxid_blur"); + } else { + CountlyAnalytics.instance.track("onboarding_login_email_blur"); + } + this.props.onUsernameBlur(ev.target.value); + }; + + private onLoginTypeChange = ev => { + const loginType = ev.target.value; + this.setState({ loginType }); + this.props.onUsernameChanged(""); // Reset because email and username use the same state + CountlyAnalytics.instance.track("onboarding_login_type_changed", { loginType }); + }; + + private onPhoneCountryChanged = country => { + this.props.onPhoneCountryChanged(country.iso2); + }; + + private onPhoneNumberChanged = ev => { + this.props.onPhoneNumberChanged(ev.target.value); + }; + + private onPhoneNumberFocus = () => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_focus"); + }; + + private onPhoneNumberBlur = ev => { + CountlyAnalytics.instance.track("onboarding_login_phone_number_blur"); + }; + + private onPasswordChanged = ev => { + this.setState({password: ev.target.value}); + }; + + private async verifyFieldsBeforeSubmit() { + // Blur the active element if any, so we first run its blur validation, + // which is less strict than the pass we're about to do below for all fields. + const activeElement = document.activeElement as HTMLElement; + if (activeElement) { + activeElement.blur(); + } + + const fieldIDsInDisplayOrder = [ + this.state.loginType, + LoginField.Password, + ]; + + // Run all fields with stricter validation that no longer allows empty + // values for required fields. + for (const fieldID of fieldIDsInDisplayOrder) { + const field = this[fieldID]; + if (!field) { + continue; + } + // We must wait for these validations to finish before queueing + // up the setState below so our setState goes in the queue after + // all the setStates from these validate calls (that's how we + // know they've finished). + await field.validate({ allowEmpty: false }); + } + + // 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)); + + if (this.allFieldsValid()) { + return true; + } + + const invalidField = this.findFirstInvalidField(fieldIDsInDisplayOrder); + + if (!invalidField) { + return true; + } + + // Focus the first invalid field and show feedback in the stricter mode + // that no longer allows empty values for required fields. + invalidField.focus(); + invalidField.validate({ allowEmpty: false, focused: true }); + return false; + } + + private allFieldsValid() { + const keys = Object.keys(this.state.fieldValid); + for (let i = 0; i < keys.length; ++i) { + if (!this.state.fieldValid[keys[i]]) { + return false; + } + } + return true; + } + + private findFirstInvalidField(fieldIDs: LoginField[]) { + for (const fieldID of fieldIDs) { + if (!this.state.fieldValid[fieldID] && this[fieldID]) { + return this[fieldID]; + } + } + return null; + } + + private markFieldValid(fieldID: LoginField, valid: boolean) { + const { fieldValid } = this.state; + fieldValid[fieldID] = valid; + this.setState({ + fieldValid, + }); + } + + private validateUsernameRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter username"), + }, + ], + }); + + private onUsernameValidate = async (fieldState) => { + const result = await this.validateUsernameRules(fieldState); + this.markFieldValid(LoginField.MatrixId, result.valid); + return result; + }; + + private validateEmailRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter email address"), + }, { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t("Doesn't look like a valid email address"), + }, + ], + }); + + private onEmailValidate = async (fieldState) => { + const result = await this.validateEmailRules(fieldState); + this.markFieldValid(LoginField.Email, result.valid); + return result; + }; + + private validatePhoneNumberRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter phone number"), + }, { + key: "number", + test: ({ value }) => !value || PHONE_NUMBER_REGEX.test(value), + invalid: () => _t("That phone number doesn't look quite right, please check and try again"), + }, + ], + }); + + private onPhoneNumberValidate = async (fieldState) => { + const result = await this.validatePhoneNumberRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + }; + + private validatePasswordRules = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => _t("Enter password"), + }, + ], + }); + + private onPasswordValidate = async (fieldState) => { + const result = await this.validatePasswordRules(fieldState); + this.markFieldValid(LoginField.Password, result.valid); + return result; + } + + private renderLoginField(loginType: IState["loginType"], autoFocus: boolean) { + const classes = { + error: false, + }; + + switch (loginType) { + case LoginField.Email: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.Email] = field} + />; + case LoginField.MatrixId: + classes.error = this.props.loginIncorrect && !this.props.username; + return this[LoginField.MatrixId] = field} + />; + case LoginField.Phone: { + classes.error = this.props.loginIncorrect && !this.props.phoneNumber; + + const phoneCountry = ; + + return this[LoginField.Password] = field} + />; + } + } + } + + private isLoginEmpty() { + switch (this.state.loginType) { + case LoginField.Email: + case LoginField.MatrixId: + return !this.props.username; + case LoginField.Phone: + return !this.props.phoneCountry || !this.props.phoneNumber; + } + } + + render() { + let forgotPasswordJsx; + + if (this.props.onForgotPasswordClick) { + forgotPasswordJsx = + {_t("Forgot password?")} + ; + } + + const pwFieldClass = classNames({ + error: this.props.loginIncorrect && !this.isLoginEmpty(), // only error password if error isn't top field + }); + + // If login is empty, autoFocus login, otherwise autoFocus password. + // this is for when auto server discovery remounts us when the user tries to tab from username to password + const autoFocusPassword = !this.isLoginEmpty(); + const loginField = this.renderLoginField(this.state.loginType, !autoFocusPassword); + + let loginType; + if (!SdkConfig.get().disable_3pid_login) { + loginType = ( +
    + + + + + + +
    + ); + } + + return ( +
    +
    + {loginType} + {loginField} + this[LoginField.Password] = field} + /> + {forgotPasswordJsx} + { !this.props.busy && } + +
    + ); + } +} diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.tsx similarity index 60% rename from src/components/views/auth/RegistrationForm.js rename to src/components/views/auth/RegistrationForm.tsx index c07486d3bd..e42ed88f99 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.tsx @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; + import * as sdk from '../../../index'; import * as Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; @@ -29,34 +27,60 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration'; import withValidation from '../elements/Validation'; import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; import PassphraseField from "./PassphraseField"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import Field from '../elements/Field'; +import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog'; -const FIELD_EMAIL = 'field_email'; -const FIELD_PHONE_NUMBER = 'field_phone_number'; -const FIELD_USERNAME = 'field_username'; -const FIELD_PASSWORD = 'field_password'; -const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; +enum RegistrationField { + Email = "field_email", + PhoneNumber = "field_phone_number", + Username = "field_username", + Password = "field_password", + PasswordConfirm = "field_password_confirm", +} const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. +interface IProps { + // Values pre-filled in the input boxes when the component loads + defaultEmail?: string; + defaultPhoneCountry?: string; + defaultPhoneNumber?: string; + defaultUsername?: string; + defaultPassword?: string; + flows: { + stages: string[]; + }[]; + serverConfig: ValidatedServerConfig; + canSubmit?: boolean; + + onRegisterClick(params: { + username: string; + password: string; + email?: string; + phoneCountry?: string; + phoneNumber?: string; + }): Promise; + onEditServerDetailsClick?(): void; +} + +interface IState { + // Field error codes by field ID + fieldValid: Partial>; + // The ISO2 country code selected in the phone number entry + phoneCountry: string; + username: string; + email: string; + phoneNumber: string; + password: string; + passwordConfirm: string; + passwordComplexity?: number; +} + /* * A pure UI component which displays a registration form. */ -export default class RegistrationForm extends React.Component { - static propTypes = { - // Values pre-filled in the input boxes when the component loads - defaultEmail: PropTypes.string, - defaultPhoneCountry: PropTypes.string, - defaultPhoneNumber: PropTypes.string, - defaultUsername: PropTypes.string, - defaultPassword: PropTypes.string, - onRegisterClick: PropTypes.func.isRequired, // onRegisterClick(Object) => ?Promise - onEditServerDetailsClick: PropTypes.func, - flows: PropTypes.arrayOf(PropTypes.object).isRequired, - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - canSubmit: PropTypes.bool, - serverRequiresIdServer: PropTypes.bool, - }; - +export default class RegistrationForm extends React.PureComponent { static defaultProps = { onValidationChange: console.error, canSubmit: true, @@ -66,9 +90,7 @@ export default class RegistrationForm extends React.Component { super(props); this.state = { - // Field error codes by field ID fieldValid: {}, - // The ISO2 country code selected in the phone number entry phoneCountry: this.props.defaultPhoneCountry, username: this.props.defaultUsername || "", email: this.props.defaultEmail || "", @@ -77,57 +99,53 @@ export default class RegistrationForm extends React.Component { passwordConfirm: this.props.defaultPassword || "", passwordComplexity: null, }; + + CountlyAnalytics.instance.track("onboarding_registration_begin"); } - onSubmit = async ev => { + private onSubmit = async ev => { ev.preventDefault(); + ev.persist(); if (!this.props.canSubmit) return; const allFieldsValid = await this.verifyFieldsBeforeSubmit(); if (!allFieldsValid) { + CountlyAnalytics.instance.track("onboarding_registration_submit_failed"); return; } - const self = this; if (this.state.email === '') { - const haveIs = Boolean(this.props.serverConfig.isUrl); - - let desc; - if (this.props.serverRequiresIdServer && !haveIs) { - desc = _t( - "No identity server is configured so you cannot add an email address in order to " + - "reset your password in the future.", - ); - } else if (this._showEmail()) { - desc = _t( - "If you don't specify an email address, you won't be able to reset your password. " + - "Are you sure?", - ); + if (this.showEmail()) { + CountlyAnalytics.instance.track("onboarding_registration_submit_warn"); + Modal.createTrackedDialog("Email prompt dialog", '', RegistrationEmailPromptDialog, { + onFinished: async (confirmed: boolean, email?: string) => { + if (confirmed) { + this.setState({ + email, + }, () => { + this.doSubmit(ev); + }); + } + }, + }); } else { // user can't set an e-mail so don't prompt them to - self._doSubmit(ev); + this.doSubmit(ev); return; } - - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - Modal.createTrackedDialog('If you don\'t specify an email address...', '', QuestionDialog, { - title: _t("Warning!"), - description: desc, - button: _t("Continue"), - onFinished(confirmed) { - if (confirmed) { - self._doSubmit(ev); - } - }, - }); } else { - self._doSubmit(ev); + this.doSubmit(ev); } }; - _doSubmit(ev) { + private doSubmit(ev) { const email = this.state.email.trim(); + + CountlyAnalytics.instance.track("onboarding_registration_submit_ok", { + email: !!email, + }); + const promise = this.props.onRegisterClick({ username: this.state.username.trim(), password: this.state.password.trim(), @@ -144,20 +162,20 @@ export default class RegistrationForm extends React.Component { } } - async verifyFieldsBeforeSubmit() { + private async verifyFieldsBeforeSubmit() { // Blur the active element if any, so we first run its blur validation, // which is less strict than the pass we're about to do below for all fields. - const activeElement = document.activeElement; + const activeElement = document.activeElement as HTMLElement; if (activeElement) { activeElement.blur(); } const fieldIDsInDisplayOrder = [ - FIELD_USERNAME, - FIELD_PASSWORD, - FIELD_PASSWORD_CONFIRM, - FIELD_EMAIL, - FIELD_PHONE_NUMBER, + RegistrationField.Username, + RegistrationField.Password, + RegistrationField.PasswordConfirm, + RegistrationField.Email, + RegistrationField.PhoneNumber, ]; // Run all fields with stricter validation that no longer allows empty @@ -176,7 +194,7 @@ export default class RegistrationForm extends React.Component { // 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; @@ -198,7 +216,7 @@ export default class RegistrationForm extends React.Component { /** * @returns {boolean} true if all fields were valid last time they were validated. */ - allFieldsValid() { + private allFieldsValid() { const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { if (!this.state.fieldValid[keys[i]]) { @@ -208,7 +226,7 @@ export default class RegistrationForm extends React.Component { return true; } - findFirstInvalidField(fieldIDs) { + private findFirstInvalidField(fieldIDs: RegistrationField[]) { for (const fieldID of fieldIDs) { if (!this.state.fieldValid[fieldID] && this[fieldID]) { return this[fieldID]; @@ -217,7 +235,7 @@ export default class RegistrationForm extends React.Component { return null; } - markFieldValid(fieldID, valid) { + private markFieldValid(fieldID: RegistrationField, valid: boolean) { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ @@ -225,25 +243,26 @@ export default class RegistrationForm extends React.Component { }); } - onEmailChange = ev => { + private onEmailChange = ev => { this.setState({ email: ev.target.value, }); }; - onEmailValidate = async fieldState => { + private onEmailValidate = async fieldState => { const result = await this.validateEmailRules(fieldState); - this.markFieldValid(FIELD_EMAIL, result.valid); + this.markFieldValid(RegistrationField.Email, result.valid); return result; }; - validateEmailRules = withValidation({ + private validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), + hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.email.identity') || !!value; }, invalid: () => _t("Enter email address (required on this homeserver)"), }, @@ -255,29 +274,29 @@ export default class RegistrationForm extends React.Component { ], }); - onPasswordChange = ev => { + private onPasswordChange = ev => { this.setState({ password: ev.target.value, }); }; - onPasswordValidate = result => { - this.markFieldValid(FIELD_PASSWORD, result.valid); + private onPasswordValidate = result => { + this.markFieldValid(RegistrationField.Password, result.valid); }; - onPasswordConfirmChange = ev => { + private onPasswordConfirmChange = ev => { this.setState({ passwordConfirm: ev.target.value, }); }; - onPasswordConfirmValidate = async fieldState => { + private onPasswordConfirmValidate = async fieldState => { const result = await this.validatePasswordConfirmRules(fieldState); - this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); + this.markFieldValid(RegistrationField.PasswordConfirm, result.valid); return result; }; - validatePasswordConfirmRules = withValidation({ + private validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", @@ -286,65 +305,66 @@ export default class RegistrationForm extends React.Component { }, { key: "match", - test({ value }) { + test(this: RegistrationForm, { value }) { return !value || value === this.state.password; }, invalid: () => _t("Passwords don't match"), }, - ], + ], }); - onPhoneCountryChange = newVal => { + private onPhoneCountryChange = newVal => { this.setState({ phoneCountry: newVal.iso2, - phonePrefix: newVal.prefix, }); }; - onPhoneNumberChange = ev => { + private onPhoneNumberChange = ev => { this.setState({ phoneNumber: ev.target.value, }); }; - onPhoneNumberValidate = async fieldState => { + private onPhoneNumberValidate = async fieldState => { const result = await this.validatePhoneNumberRules(fieldState); - this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); + this.markFieldValid(RegistrationField.PhoneNumber, result.valid); return result; }; - validatePhoneNumberRules = withValidation({ + private validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), + hideDescriptionIfValid: true, rules: [ { key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; + test(this: RegistrationForm, { value, allowEmpty }) { + return allowEmpty || !this.authStepIsRequired('m.login.msisdn') || !!value; }, invalid: () => _t("Enter phone number (required on this homeserver)"), }, { key: "email", test: ({ value }) => !value || phoneNumberLooksValid(value), - invalid: () => _t("Doesn't look like a valid phone number"), + invalid: () => _t("That phone number doesn't look quite right, please check and try again"), }, ], }); - onUsernameChange = ev => { + private onUsernameChange = ev => { this.setState({ username: ev.target.value, }); }; - onUsernameValidate = async fieldState => { + private onUsernameValidate = async fieldState => { const result = await this.validateUsernameRules(fieldState); - this.markFieldValid(FIELD_USERNAME, result.valid); + this.markFieldValid(RegistrationField.Username, result.valid); return result; }; - validateUsernameRules = withValidation({ + private validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), + hideDescriptionIfValid: true, rules: [ { key: "required", @@ -365,7 +385,7 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is required */ - _authStepIsRequired(step) { + private authStepIsRequired(step: string) { return this.props.flows.every((flow) => { return flow.stages.includes(step); }); @@ -377,86 +397,80 @@ export default class RegistrationForm extends React.Component { * @param {string} step A stage name to check * @returns {boolean} Whether it is used */ - _authStepIsUsed(step) { + private authStepIsUsed(step: string) { return this.props.flows.some((flow) => { return flow.stages.includes(step); }); } - _showEmail() { - const haveIs = Boolean(this.props.serverConfig.isUrl); - if ( - (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.email.identity') - ) { + private showEmail() { + if (!this.authStepIsUsed('m.login.email.identity')) { return false; } return true; } - _showPhoneNumber() { + private showPhoneNumber() { const threePidLogin = !SdkConfig.get().disable_3pid_login; - const haveIs = Boolean(this.props.serverConfig.isUrl); - if ( - !threePidLogin || - (this.props.serverRequiresIdServer && !haveIs) || - !this._authStepIsUsed('m.login.msisdn') - ) { + if (!threePidLogin || !this.authStepIsUsed('m.login.msisdn')) { return false; } return true; } - renderEmail() { - if (!this._showEmail()) { + private renderEmail() { + if (!this.showEmail()) { return null; } - const Field = sdk.getComponent('elements.Field'); - const emailPlaceholder = this._authStepIsRequired('m.login.email.identity') ? + const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ? _t("Email") : _t("Email (optional)"); return this[FIELD_EMAIL] = field} + ref={field => this[RegistrationField.Email] = field} type="text" label={emailPlaceholder} value={this.state.email} onChange={this.onEmailChange} onValidate={this.onEmailValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email_blur")} />; } - renderPassword() { + private renderPassword() { return this[FIELD_PASSWORD] = field} + fieldRef={field => this[RegistrationField.Password] = field} minScore={PASSWORD_MIN_SCORE} value={this.state.password} onChange={this.onPasswordChange} onValidate={this.onPasswordValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_password_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_password_blur")} />; } renderPasswordConfirm() { - const Field = sdk.getComponent('elements.Field'); return this[FIELD_PASSWORD_CONFIRM] = field} + ref={field => this[RegistrationField.PasswordConfirm] = field} type="password" autoComplete="new-password" - label={_t("Confirm")} + label={_t("Confirm password")} value={this.state.passwordConfirm} onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_passwordConfirm_blur")} />; } renderPhoneNumber() { - if (!this._showPhoneNumber()) { + if (!this.showPhoneNumber()) { return null; } const CountryDropdown = sdk.getComponent('views.auth.CountryDropdown'); - const Field = sdk.getComponent('elements.Field'); - const phoneLabel = this._authStepIsRequired('m.login.msisdn') ? + const phoneLabel = this.authStepIsRequired('m.login.msisdn') ? _t("Phone") : _t("Phone (optional)"); const phoneCountry = ; return this[FIELD_PHONE_NUMBER] = field} + ref={field => this[RegistrationField.PhoneNumber] = field} type="text" label={phoneLabel} value={this.state.phoneNumber} @@ -477,86 +491,49 @@ export default class RegistrationForm extends React.Component { } renderUsername() { - const Field = sdk.getComponent('elements.Field'); return this[FIELD_USERNAME] = field} + ref={field => this[RegistrationField.Username] = field} type="text" autoFocus={true} label={_t("Username")} + placeholder={_t("Username").toLocaleLowerCase()} value={this.state.username} onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} + onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_username_focus")} + onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_username_blur")} />; } render() { - let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - yourMatrixAccountText = _t('Create your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - let editLink = null; - if (this.props.onEditServerDetailsClick) { - editLink = - {_t('Change')} - ; - } - const registerButton = ( ); let emailHelperText = null; - if (this._showEmail()) { - if (this._showPhoneNumber()) { + if (this.showEmail()) { + if (this.showPhoneNumber()) { emailHelperText =
    - {_t( - "Set an email for account recovery. " + - "Use email or phone to optionally be discoverable by existing contacts.", - )} + { + _t("Add an email to be able to reset your password.") + } { + _t("Use email or phone to optionally be discoverable by existing contacts.") + }
    ; } else { emailHelperText =
    - {_t( - "Set an email for account recovery. " + - "Use email to optionally be discoverable by existing contacts.", - )} + { + _t("Add an email to be able to reset your password.") + } { + _t("Use email to optionally be discoverable by existing contacts.") + }
    ; } } - const haveIs = Boolean(this.props.serverConfig.isUrl); - let noIsText = null; - if (this.props.serverRequiresIdServer && !haveIs) { - noIsText =
    - {_t( - "No identity server is configured so you cannot add an email address in order to " + - "reset your password in the future.", - )} -
    ; - } return (
    -

    - {yourMatrixAccountText} - {editLink} -

    {this.renderUsername()} @@ -570,7 +547,6 @@ export default class RegistrationForm extends React.Component { {this.renderPhoneNumber()}
    { emailHelperText } - { noIsText } { registerButton }
    diff --git a/src/components/views/auth/ServerConfig.js b/src/components/views/auth/ServerConfig.js deleted file mode 100644 index ee6f57a521..0000000000 --- a/src/components/views/auth/ServerConfig.js +++ /dev/null @@ -1,288 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 New Vector 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. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import Modal from '../../../Modal'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; -import SdkConfig from "../../../SdkConfig"; -import { createClient } from 'matrix-js-sdk/src/matrix'; -import classNames from 'classnames'; - -/* - * A pure UI component which displays the HS and IS to use. - */ - -export default class ServerConfig extends React.PureComponent { - static propTypes = { - onServerConfigChange: PropTypes.func.isRequired, - - // The current configuration that the user is expecting to change. - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - - delayTimeMs: PropTypes.number, // time to wait before invoking onChanged - - // Called after the component calls onServerConfigChange - onAfterSubmit: PropTypes.func, - - // Optional text for the submit button. If falsey, no button will be shown. - submitText: PropTypes.string, - - // Optional class for the submit button. Only applies if the submit button - // is to be rendered. - submitClass: PropTypes.string, - - // Whether the flow this component is embedded in requires an identity - // server when the homeserver says it will need one. Default false. - showIdentityServerIfRequiredByHomeserver: PropTypes.bool, - }; - - static defaultProps = { - onServerConfigChange: function() {}, - delayTimeMs: 0, - }; - - constructor(props) { - super(props); - - this.state = { - busy: false, - errorText: "", - hsUrl: props.serverConfig.hsUrl, - isUrl: props.serverConfig.isUrl, - showIdentityServer: false, - }; - } - - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase - if (newProps.serverConfig.hsUrl === this.state.hsUrl && - newProps.serverConfig.isUrl === this.state.isUrl) return; - - this.validateAndApplyServer(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); - } - - async validateServer() { - // TODO: Do we want to support .well-known lookups here? - // If for some reason someone enters "matrix.org" for a URL, we could do a lookup to - // find their homeserver without demanding they use "https://matrix.org" - const result = this.validateAndApplyServer(this.state.hsUrl, this.state.isUrl); - if (!result) { - return result; - } - - // If the UI flow this component is embedded in requires an identity - // server when the homeserver says it will need one, check first and - // reveal this field if not already shown. - // XXX: This a backward compatibility path for homeservers that require - // an identity server to be passed during certain flows. - // See also https://github.com/matrix-org/synapse/pull/5868. - if ( - this.props.showIdentityServerIfRequiredByHomeserver && - !this.state.showIdentityServer && - await this.isIdentityServerRequiredByHomeserver() - ) { - this.setState({ - showIdentityServer: true, - }); - return null; - } - - return result; - } - - async validateAndApplyServer(hsUrl, isUrl) { - // Always try and use the defaults first - const defaultConfig: ValidatedServerConfig = SdkConfig.get()["validated_server_config"]; - if (defaultConfig.hsUrl === hsUrl && defaultConfig.isUrl === isUrl) { - this.setState({ - hsUrl: defaultConfig.hsUrl, - isUrl: defaultConfig.isUrl, - busy: false, - errorText: "", - }); - this.props.onServerConfigChange(defaultConfig); - return defaultConfig; - } - - this.setState({ - hsUrl, - isUrl, - busy: true, - errorText: "", - }); - - try { - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl); - this.setState({busy: false, errorText: ""}); - this.props.onServerConfigChange(result); - return result; - } catch (e) { - console.error(e); - - const stateForError = AutoDiscoveryUtils.authComponentStateForError(e); - if (!stateForError.isFatalError) { - this.setState({ - busy: false, - }); - // carry on anyway - const result = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl, isUrl, true); - this.props.onServerConfigChange(result); - return result; - } else { - let message = _t("Unable to validate homeserver/identity server"); - if (e.translatedMessage) { - message = e.translatedMessage; - } - this.setState({ - busy: false, - errorText: message, - }); - - return null; - } - } - } - - async isIdentityServerRequiredByHomeserver() { - // XXX: We shouldn't have to create a whole new MatrixClient just to - // check if the homeserver requires an identity server... Should it be - // extracted to a static utils function...? - return createClient({ - baseUrl: this.state.hsUrl, - }).doesServerRequireIdServerParam(); - } - - onHomeserverBlur = (ev) => { - this._hsTimeoutId = this._waitThenInvoke(this._hsTimeoutId, () => { - this.validateServer(); - }); - }; - - onHomeserverChange = (ev) => { - const hsUrl = ev.target.value; - this.setState({ hsUrl }); - }; - - onIdentityServerBlur = (ev) => { - this._isTimeoutId = this._waitThenInvoke(this._isTimeoutId, () => { - this.validateServer(); - }); - }; - - onIdentityServerChange = (ev) => { - const isUrl = ev.target.value; - this.setState({ isUrl }); - }; - - onSubmit = async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - const result = await this.validateServer(); - if (!result) return; // Do not continue. - - if (this.props.onAfterSubmit) { - this.props.onAfterSubmit(); - } - }; - - _waitThenInvoke(existingTimeoutId, fn) { - if (existingTimeoutId) { - clearTimeout(existingTimeoutId); - } - return setTimeout(fn.bind(this), this.props.delayTimeMs); - } - - showHelpPopup = () => { - const CustomServerDialog = sdk.getComponent('auth.CustomServerDialog'); - Modal.createTrackedDialog('Custom Server Dialog', '', CustomServerDialog); - }; - - _renderHomeserverSection() { - const Field = sdk.getComponent('elements.Field'); - return
    - {_t("Enter your custom homeserver URL What does this mean?", {}, { - a: sub => - {sub} - , - })} - -
    ; - } - - _renderIdentityServerSection() { - const Field = sdk.getComponent('elements.Field'); - const classes = classNames({ - "mx_ServerConfig_identityServer": true, - "mx_ServerConfig_identityServer_shown": this.state.showIdentityServer, - }); - return
    - {_t("Enter your custom identity server URL What does this mean?", {}, { - a: sub => - {sub} - , - })} - -
    ; - } - - render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const errorText = this.state.errorText - ? {this.state.errorText} - : null; - - const submitButton = this.props.submitText - ? {this.props.submitText} - : null; - - return ( -
    -

    {_t("Other servers")}

    - {errorText} - {this._renderHomeserverSection()} - {this._renderIdentityServerSection()} - {submitButton} -
    - ); - } -} diff --git a/src/components/views/auth/ServerTypeSelector.js b/src/components/views/auth/ServerTypeSelector.js deleted file mode 100644 index 71e7ac7f0e..0000000000 --- a/src/components/views/auth/ServerTypeSelector.js +++ /dev/null @@ -1,153 +0,0 @@ -/* -Copyright 2019 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 React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; -import classnames from 'classnames'; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; -import {makeType} from "../../../utils/TypeUtils"; - -const MODULAR_URL = 'https://element.io/matrix-services' + - '?utm_source=element-web&utm_medium=web&utm_campaign=element-web-authentication'; - -export const FREE = 'Free'; -export const PREMIUM = 'Premium'; -export const ADVANCED = 'Advanced'; - -export const TYPES = { - FREE: { - id: FREE, - label: () => _t('Free'), - logo: () => , - description: () => _t('Join millions for free on the largest public server'), - serverConfig: makeType(ValidatedServerConfig, { - hsUrl: "https://matrix-client.matrix.org", - hsName: "matrix.org", - hsNameIsDifferent: false, - isUrl: "https://vector.im", - }), - }, - PREMIUM: { - id: PREMIUM, - label: () => _t('Premium'), - logo: () => , - description: () => _t('Premium hosting for organisations Learn more', {}, { - a: sub => - {sub} - , - }), - identityServerUrl: "https://vector.im", - }, - ADVANCED: { - id: ADVANCED, - label: () => _t('Advanced'), - logo: () =>
    - - {_t('Other')} -
    , - description: () => _t('Find other public servers or use a custom server'), - }, -}; - -export function getTypeFromServerConfig(config) { - const {hsUrl} = config; - if (!hsUrl) { - return null; - } else if (hsUrl === TYPES.FREE.serverConfig.hsUrl) { - return FREE; - } else if (new URL(hsUrl).hostname.endsWith('.modular.im')) { - // This is an unlikely case to reach, as Modular defaults to hiding the - // server type selector. - return PREMIUM; - } else { - return ADVANCED; - } -} - -export default class ServerTypeSelector extends React.PureComponent { - static propTypes = { - // The default selected type. - selected: PropTypes.string, - // Handler called when the selected type changes. - onChange: PropTypes.func.isRequired, - }; - - constructor(props) { - super(props); - - const { - selected, - } = props; - - this.state = { - selected, - }; - } - - updateSelectedType(type) { - if (this.state.selected === type) { - return; - } - this.setState({ - selected: type, - }); - if (this.props.onChange) { - this.props.onChange(type); - } - } - - onClick = (e) => { - e.stopPropagation(); - const type = e.currentTarget.dataset.id; - this.updateSelectedType(type); - }; - - render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - - const serverTypes = []; - for (const type of Object.values(TYPES)) { - const { id, label, logo, description } = type; - const classes = classnames( - "mx_ServerTypeSelector_type", - `mx_ServerTypeSelector_type_${id}`, - { - "mx_ServerTypeSelector_type_selected": id === this.state.selected, - }, - ); - - serverTypes.push(
    -
    - {label()} -
    - -
    - {logo()} -
    -
    - {description()} -
    -
    -
    ); - } - - return
    - {serverTypes} -
    ; - } -} diff --git a/src/components/views/auth/SignInToText.js b/src/components/views/auth/SignInToText.js deleted file mode 100644 index 7564096b7d..0000000000 --- a/src/components/views/auth/SignInToText.js +++ /dev/null @@ -1,62 +0,0 @@ -/* -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. -*/ - -import React from 'react'; -import {_t} from "../../../languageHandler"; -import * as sdk from "../../../index"; -import PropTypes from "prop-types"; -import {ValidatedServerConfig} from "../../../utils/AutoDiscoveryUtils"; - -export default class SignInToText extends React.PureComponent { - static propTypes = { - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - onEditServerDetailsClick: PropTypes.func, - }; - - render() { - let signInToText = _t('Sign in to your Matrix account on %(serverName)s', { - serverName: this.props.serverConfig.hsName, - }); - if (this.props.serverConfig.hsNameIsDifferent) { - const TextWithTooltip = sdk.getComponent("elements.TextWithTooltip"); - - signInToText = _t('Sign in to your Matrix account on ', {}, { - 'underlinedServerName': () => { - return ; - }, - }); - } - - let editLink = null; - if (this.props.onEditServerDetailsClick) { - editLink = - {_t('Change')} - ; - } - - return

    - {signInToText} - {editLink} -

    ; - } -} diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 21032f4f1a..0205f4e0b9 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -23,11 +23,18 @@ import AuthPage from "./AuthPage"; import {_td} from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; import {UIFeature} from "../../../settings/UIFeature"; +import CountlyAnalytics from "../../../CountlyAnalytics"; // translatable strings for Welcome pages _td("Sign in with SSO"); export default class Welcome extends React.PureComponent { + constructor(props) { + super(props); + + CountlyAnalytics.instance.track("onboarding_welcome"); + } + render() { const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); const LanguageSelector = sdk.getComponent('auth.LanguageSelector'); diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 245c50576a..799a559263 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -51,7 +51,8 @@ const calculateUrls = (url, urls) => { _urls = urls || []; if (url) { - _urls.unshift(url); // put in urls[0] + // copy urls and put url first + _urls = [url, ..._urls]; } } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index cbdae765f7..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) @@ -35,6 +35,7 @@ interface IProps { height?: number; resizeMethod?: ResizeMethod; viewAvatarOnClick?: boolean; + onClick?(): void; } interface IState { @@ -130,7 +131,7 @@ export default class RoomAvatar extends React.Component { }; public render() { - const {room, oobData, viewAvatarOnClick, ...otherProps} = this.props; + const {room, oobData, viewAvatarOnClick, onClick, ...otherProps} = this.props; const roomName = room ? room.name : oobData.name; @@ -139,7 +140,7 @@ export default class RoomAvatar extends React.Component { name={roomName} idName={room ? room.roomId : null} urls={this.state.urls} - onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : null} + onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick} /> ); } diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx new file mode 100644 index 0000000000..04cfce7670 --- /dev/null +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -0,0 +1,58 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {ComponentProps, useContext} from 'react'; +import classNames from 'classnames'; +import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; + +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {IApp} from "../../../stores/WidgetStore"; +import BaseAvatar, {BaseAvatarType} from "./BaseAvatar"; + +interface IProps extends Omit, "name" | "url" | "urls"> { + app: IApp; +} + +const WidgetAvatar: React.FC = ({ app, className, width = 20, height = 20, ...props }) => { + const cli = useContext(MatrixClientContext); + + let iconUrls = [require("../../../../res/img/element-icons/room/default_app.svg")]; + // heuristics for some better icons until Widgets support their own icons + if (app.type.includes("jitsi")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_video.svg")]; + } else if (app.type.includes("meeting") || app.type.includes("calendar")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_cal.svg")]; + } else if (app.type.includes("pad") || app.type.includes("doc") || app.type.includes("calc")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_doc.svg")]; + } else if (app.type.includes("clock")) { + iconUrls = [require("../../../../res/img/element-icons/room/default_clock.svg")]; + } + + return ( + + ) +}; + +export default WidgetAvatar; diff --git a/src/components/views/context_menus/CallContextMenu.tsx b/src/components/views/context_menus/CallContextMenu.tsx new file mode 100644 index 0000000000..3557976326 --- /dev/null +++ b/src/components/views/context_menus/CallContextMenu.tsx @@ -0,0 +1,77 @@ +/* +Copyright 2020 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 React from 'react'; +import PropTypes from 'prop-types'; +import { _t } from '../../../languageHandler'; +import { ContextMenu, IProps as IContextMenuProps, MenuItem } from '../../structures/ContextMenu'; +import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import CallHandler from '../../../CallHandler'; +import InviteDialog, { KIND_CALL_TRANSFER } from '../dialogs/InviteDialog'; +import Modal from '../../../Modal'; + +interface IProps extends IContextMenuProps { + call: MatrixCall; +} + +export default class CallContextMenu extends React.Component { + static propTypes = { + // js-sdk User object. Not required because it might not exist. + user: PropTypes.object, + }; + + constructor(props) { + super(props); + } + + onHoldClick = () => { + this.props.call.setRemoteOnHold(true); + this.props.onFinished(); + } + + onUnholdClick = () => { + CallHandler.sharedInstance().setActiveCallRoomId(this.props.call.roomId); + + this.props.onFinished(); + } + + onTransferClick = () => { + Modal.createTrackedDialog( + 'Transfer Call', '', InviteDialog, {kind: KIND_CALL_TRANSFER, call: this.props.call}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); + this.props.onFinished(); + } + + render() { + const holdUnholdCaption = this.props.call.isRemoteOnHold() ? _t("Resume") : _t("Hold"); + const handler = this.props.call.isRemoteOnHold() ? this.onUnholdClick : this.onHoldClick; + + let transferItem; + if (this.props.call.opponentCanBeTransferred()) { + transferItem = + {_t("Transfer")} + ; + } + + return + + {holdUnholdCaption} + + {transferItem} + ; + } +} diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx new file mode 100644 index 0000000000..e3aed0179b --- /dev/null +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -0,0 +1,59 @@ +/* +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 { _t } from '../../../languageHandler'; +import { ContextMenu, IProps as IContextMenuProps } from '../../structures/ContextMenu'; +import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; +import Dialpad from '../voip/DialPad'; + +interface IProps extends IContextMenuProps { + call: MatrixCall; +} + +interface IState { + value: string; +} + +export default class DialpadContextMenu extends React.Component { + constructor(props) { + super(props); + + this.state = { + value: '', + } + } + + onDigitPress = (digit) => { + this.props.call.sendDtmfDigit(digit); + this.setState({value: this.state.value + digit}); + } + + render() { + return +
    +
    + {_t("Dial pad")} +
    +
    {this.state.value}
    +
    +
    +
    + +
    + ; + } +} diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index d760c8defa..6b871e4f24 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -31,6 +31,7 @@ import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; import {MenuItem} from "../../structures/ContextMenu"; +import {EventType} from "matrix-js-sdk/src/@types/event"; function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; @@ -72,7 +73,10 @@ export default class MessageContextMenu extends React.Component { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); - const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId); + // We explicitly decline to show the redact option on ACL events as it has a potential + // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 + const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) + && this.props.mxEvent.getType() !== EventType.RoomServerAcl; let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality @@ -145,7 +149,7 @@ export default class MessageContextMenu extends React.Component { onRedactClick = () => { const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed) => { + onFinished: async (proceed, reason) => { if (!proceed) return; const cli = MatrixClientPeg.get(); @@ -153,6 +157,8 @@ export default class MessageContextMenu extends React.Component { await cli.redactEvent( this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), + undefined, + reason ? { reason } : {}, ); } catch (e) { const code = e.errcode || e.statusCode; diff --git a/src/components/views/context_menus/WidgetContextMenu.js b/src/components/views/context_menus/WidgetContextMenu.js deleted file mode 100644 index 6ed32daa5c..0000000000 --- a/src/components/views/context_menus/WidgetContextMenu.js +++ /dev/null @@ -1,142 +0,0 @@ -/* -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. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import {_t} from '../../../languageHandler'; -import {MenuItem} from "../../structures/ContextMenu"; - -export default class WidgetContextMenu extends React.Component { - static propTypes = { - onFinished: PropTypes.func, - - // Callback for when the revoke button is clicked. Required. - onRevokeClicked: PropTypes.func.isRequired, - - // Callback for when the unpin button is clicked. If absent, unpin will be hidden. - onUnpinClicked: PropTypes.func, - - // Callback for when the snapshot button is clicked. Button not shown - // without a callback. - onSnapshotClicked: PropTypes.func, - - // Callback for when the reload button is clicked. Button not shown - // without a callback. - onReloadClicked: PropTypes.func, - - // Callback for when the edit button is clicked. Button not shown - // without a callback. - onEditClicked: PropTypes.func, - - // Callback for when the delete button is clicked. Button not shown - // without a callback. - onDeleteClicked: PropTypes.func, - }; - - proxyClick(fn) { - fn(); - if (this.props.onFinished) this.props.onFinished(); - } - - // XXX: It's annoying that our context menus require us to hit onFinished() to close :( - - onEditClicked = () => { - this.proxyClick(this.props.onEditClicked); - }; - - onReloadClicked = () => { - this.proxyClick(this.props.onReloadClicked); - }; - - onSnapshotClicked = () => { - this.proxyClick(this.props.onSnapshotClicked); - }; - - onDeleteClicked = () => { - this.proxyClick(this.props.onDeleteClicked); - }; - - onRevokeClicked = () => { - this.proxyClick(this.props.onRevokeClicked); - }; - - onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked); - - render() { - const options = []; - - if (this.props.onEditClicked) { - options.push( - - {_t("Edit")} - , - ); - } - - if (this.props.onUnpinClicked) { - options.push( - - {_t("Unpin")} - , - ); - } - - if (this.props.onReloadClicked) { - options.push( - - {_t("Reload")} - , - ); - } - - if (this.props.onSnapshotClicked) { - options.push( - - {_t("Take picture")} - , - ); - } - - if (this.props.onDeleteClicked) { - options.push( - - {_t("Remove for everyone")} - , - ); - } - - // Push this last so it appears last. It's always present. - options.push( - - {_t("Remove for me")} - , - ); - - // Put separators between the options - if (options.length > 1) { - const length = options.length; - for (let i = 0; i < length - 1; i++) { - const sep =
    ; - - // Insert backwards so the insertions don't affect our math on where to place them. - // We also use our cached length to avoid worrying about options.length changing - options.splice(length - 1 - i, 0, sep); - } - } - - return
    {options}
    ; - } -} diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx new file mode 100644 index 0000000000..c1af86eae6 --- /dev/null +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -0,0 +1,178 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {useContext} from "react"; +import {MatrixCapabilities} from "matrix-widget-api"; + +import IconizedContextMenu, {IconizedContextMenuOption, IconizedContextMenuOptionList} from "./IconizedContextMenu"; +import {ChevronFace} from "../../structures/ContextMenu"; +import {_t} from "../../../languageHandler"; +import {IApp} from "../../../stores/WidgetStore"; +import WidgetUtils from "../../../utils/WidgetUtils"; +import {WidgetMessagingStore} from "../../../stores/widgets/WidgetMessagingStore"; +import RoomContext from "../../../contexts/RoomContext"; +import dis from "../../../dispatcher/dispatcher"; +import SettingsStore from "../../../settings/SettingsStore"; +import Modal from "../../../Modal"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import {WidgetType} from "../../../widgets/WidgetType"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; + +interface IProps extends React.ComponentProps { + app: IApp; + userWidget?: boolean; + showUnpin?: boolean; + // override delete handler + onDeleteClick?(): void; +} + +const WidgetContextMenu: React.FC = ({ + onFinished, + app, + userWidget, + onDeleteClick, + showUnpin, + ...props +}) => { + const cli = useContext(MatrixClientContext); + const {room, roomId} = useContext(RoomContext); + + const widgetMessaging = WidgetMessagingStore.instance.getMessagingForId(app.id); + const canModify = userWidget || WidgetUtils.canUserModifyWidgets(roomId); + + let unpinButton; + if (showUnpin) { + const onUnpinClick = () => { + WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right); + onFinished(); + }; + + unpinButton = ; + } + + let editButton; + if (canModify && WidgetUtils.isManagedByManager(app)) { + const onEditClick = () => { + WidgetUtils.editWidget(room, app); + onFinished(); + }; + + editButton = ; + } + + let snapshotButton; + if (widgetMessaging?.hasCapability(MatrixCapabilities.Screenshots)) { + const onSnapshotClick = () => { + widgetMessaging?.takeScreenshot().then(data => { + dis.dispatch({ + action: 'picture_snapshot', + file: data.screenshot, + }); + }).catch(err => { + console.error("Failed to take screenshot: ", err); + }); + onFinished(); + }; + + snapshotButton = ; + } + + let deleteButton; + if (onDeleteClick || canModify) { + const onDeleteClickDefault = () => { + // Show delete confirmation dialog + Modal.createTrackedDialog('Delete Widget', '', QuestionDialog, { + title: _t("Delete Widget"), + description: _t( + "Deleting a widget removes it for all users in this room." + + " Are you sure you want to delete this widget?"), + button: _t("Delete widget"), + onFinished: (confirmed) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(roomId, app.id); + }, + }); + onFinished(); + }; + + deleteButton = ; + } + + let isAllowedWidget = SettingsStore.getValue("allowedWidgets", roomId)[app.eventId]; + if (isAllowedWidget === undefined) { + isAllowedWidget = app.creatorUserId === cli.getUserId(); + } + + const isLocalWidget = WidgetType.JITSI.matches(app.type); + let revokeButton; + if (!userWidget && !isLocalWidget && isAllowedWidget) { + const onRevokeClick = () => { + console.info("Revoking permission for widget to load: " + app.eventId); + const current = SettingsStore.getValue("allowedWidgets", roomId); + current[app.eventId] = false; + const level = SettingsStore.firstSupportedLevel("allowedWidgets"); + SettingsStore.setValue("allowedWidgets", roomId, level, current).catch(err => { + console.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); + onFinished(); + }; + + revokeButton = ; + } + + const pinnedWidgets = WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top); + const widgetIndex = pinnedWidgets.findIndex(widget => widget.id === app.id); + + let moveLeftButton; + if (showUnpin && widgetIndex > 0) { + const onClick = () => { + WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, -1); + onFinished(); + }; + + moveLeftButton = ; + } + + let moveRightButton; + if (showUnpin && widgetIndex < pinnedWidgets.length - 1) { + const onClick = () => { + WidgetLayoutStore.instance.moveWithinContainer(room, Container.Top, app, 1); + onFinished(); + }; + + moveRightButton = ; + } + + return + + { editButton } + { revokeButton } + { deleteButton } + { snapshotButton } + { moveLeftButton } + { moveRightButton } + { unpinButton } + + ; +}; + +export default WidgetContextMenu; + 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/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js index 3106df1d5b..2216f9a93a 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -23,15 +23,17 @@ import { _t } from '../../../languageHandler'; */ export default class ConfirmRedactDialog extends React.Component { render() { - const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); + const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog'); return ( - - + ); } } 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 a0c5375843..814378bb51 100644 --- a/src/components/views/dialogs/DevtoolsDialog.js +++ b/src/components/views/dialogs/DevtoolsDialog.js @@ -32,6 +32,12 @@ import { PHASE_STARTED, PHASE_CANCELLED, } 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}; @@ -701,6 +707,377 @@ class VerificationExplorer extends React.Component { } } +class WidgetExplorer extends React.Component { + static getLabel() { + return _t("Active Widgets"); + } + + constructor(props) { + super(props); + + this.state = { + query: '', + editWidget: null, // set to an IApp when editing + }; + } + + onWidgetStoreUpdate = () => { + this.forceUpdate(); + }; + + onQueryChange = (query) => { + this.setState({query}); + }; + + onEditWidget = (widget) => { + this.setState({editWidget: widget}); + }; + + onBack = () => { + const widgets = WidgetStore.instance.getApps(this.props.room.roomId); + if (this.state.editWidget && widgets.includes(this.state.editWidget)) { + this.setState({editWidget: null}); + } else { + this.props.onBack(); + } + }; + + componentDidMount() { + WidgetStore.instance.on(UPDATE_EVENT, this.onWidgetStoreUpdate); + } + + componentWillUnmount() { + WidgetStore.instance.off(UPDATE_EVENT, this.onWidgetStoreUpdate); + } + + render() { + const room = this.props.room; + + const editWidget = this.state.editWidget; + const widgets = WidgetStore.instance.getApps(room.roomId); + if (editWidget && widgets.includes(editWidget)) { + const allState = Array.from(Array.from(room.currentState.events.values()).map(e => e.values())) + .reduce((p, c) => {p.push(...c); return p;}, []); + const stateEv = allState.find(ev => ev.getId() === editWidget.eventId); + if (!stateEv) { // "should never happen" + return
    + {_t("There was an error finding this widget.")} +
    + +
    +
    ; + } + return ; + } + + return (
    +
    + + {widgets.map(w => { + return ; + })} + +
    +
    + +
    +
    ); + } +} + +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, @@ -708,6 +1085,8 @@ const Entries = [ AccountDataExplorer, 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/FeedbackDialog.js b/src/components/views/dialogs/FeedbackDialog.js new file mode 100644 index 0000000000..cbe26af6cc --- /dev/null +++ b/src/components/views/dialogs/FeedbackDialog.js @@ -0,0 +1,138 @@ +/* +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 React, {useState} from 'react'; +import QuestionDialog from './QuestionDialog'; +import { _t } from '../../../languageHandler'; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import CountlyAnalytics from "../../../CountlyAnalytics"; +import SdkConfig from "../../../SdkConfig"; +import Modal from "../../../Modal"; +import BugReportDialog from "./BugReportDialog"; +import InfoDialog from "./InfoDialog"; +import StyledRadioGroup from "../elements/StyledRadioGroup"; + +const existingIssuesUrl = "https://github.com/vector-im/element-web/issues" + + "?q=is%3Aopen+is%3Aissue+sort%3Areactions-%2B1-desc"; +const newIssueUrl = "https://github.com/vector-im/element-web/issues/new"; + + +export default (props) => { + const [rating, setRating] = useState(""); + const [comment, setComment] = useState(""); + + const onDebugLogsLinkClick = () => { + props.onFinished(); + Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); + }; + + const hasFeedback = CountlyAnalytics.instance.canEnable(); + const onFinished = (sendFeedback) => { + if (hasFeedback && sendFeedback) { + CountlyAnalytics.instance.reportFeedback(parseInt(rating, 10), comment); + Modal.createTrackedDialog('Feedback sent', '', InfoDialog, { + title: _t('Feedback sent'), + description: _t('Thank you!'), + }); + } + props.onFinished(); + }; + + const brand = SdkConfig.get().brand; + + let countlyFeedbackSection; + if (hasFeedback) { + countlyFeedbackSection = +
    +
    +

    {_t("Rate %(brand)s", { brand })}

    + +

    {_t("Tell us below how you feel about %(brand)s so far.", { brand })}

    +

    {_t("Please go into as much detail as you like, so we can track down the problem.")}

    + + + + { + setComment(ev.target.value); + }} + /> +
    +
    ; + } + + let subheading; + if (hasFeedback) { + subheading = ( +

    {_t("There are two ways you can provide feedback and help us improve %(brand)s.", { brand })}

    + ); + } + + return ( + { subheading } + +
    +

    {_t("Report a bug")}

    +

    { + _t("Please view existing bugs on Github first. " + + "No match? Start a new one.", {}, { + existingIssuesLink: (sub) => { + return { sub }; + }, + newIssueLink: (sub) => { + return { sub }; + }, + }) + }

    +

    { + _t("PRO TIP: If you start a bug, please submit debug logs " + + "to help us track down the problem.", {}, { + debugLogsLink: sub => ( + {sub} + ), + }) + }

    +
    + { countlyFeedbackSection } + } + button={hasFeedback ? _t("Send feedback") : _t("Go back")} + buttonDisabled={hasFeedback && rating === ""} + onFinished={onFinished} + />); +}; 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 && +