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 2e2a404338..1c0a3d1254 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -1,56 +1,16 @@ # autogenerated file: run scripts/generate-eslint-error-ignore-file to update. -src/components/structures/RoomDirectory.js -src/components/structures/RoomStatusBar.js -src/components/structures/ScrollPanel.js -src/components/structures/SearchBox.js -src/components/structures/UploadBar.js -src/components/views/avatars/MemberAvatar.js -src/components/views/create_room/RoomAlias.js -src/components/views/dialogs/SetPasswordDialog.js -src/components/views/elements/AddressSelector.js -src/components/views/elements/DirectorySearchBox.js -src/components/views/elements/MemberEventListSummary.js -src/components/views/elements/UserSelector.js -src/components/views/globals/NewVersionBar.js -src/components/views/messages/MFileBody.js -src/components/views/messages/TextualBody.js -src/components/views/room_settings/ColorSettings.js -src/components/views/rooms/Autocomplete.js -src/components/views/rooms/AuxPanel.js -src/components/views/rooms/LinkPreviewWidget.js -src/components/views/rooms/MemberInfo.js -src/components/views/rooms/MemberList.js -src/components/views/rooms/RoomList.js -src/components/views/rooms/RoomPreviewBar.js -src/components/views/rooms/SearchResultTile.js -src/components/views/settings/ChangeAvatar.js -src/components/views/settings/ChangePassword.js -src/components/views/settings/DevicesPanel.js -src/components/views/settings/Notifications.js -src/HtmlUtils.js -src/ImageUtils.js src/Markdown.js -src/notifications/ContentRules.js -src/notifications/PushRuleVectorState.js -src/PlatformPeg.js -src/rageshake/rageshake.js -src/ratelimitedfunc.js -src/Rooms.js -src/Unread.js -src/utils/DecryptFile.js -src/utils/DirectoryUtils.js -src/utils/DMRoomMap.js -src/utils/FormattingUtils.js -src/utils/MultiInviter.js -src/utils/Receipt.js src/Velociraptor.js +src/components/structures/RoomDirectory.js +src/components/views/rooms/MemberList.js +src/ratelimitedfunc.js +src/utils/DMRoomMap.js +src/utils/MultiInviter.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/CHANGELOG.md b/CHANGELOG.md index e4a7ddc407..c87f1c62e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,830 @@ +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) + + * Upgrade JS SDK to 8.5.0 + * [Release] Fix templating for v1 jitsi widgets + [\#5306](https://github.com/matrix-org/matrix-react-sdk/pull/5306) + * [Release] Use new preparing event for widget communications + [\#5304](https://github.com/matrix-org/matrix-react-sdk/pull/5304) + +Changes in [3.6.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.6.0-rc.1) (2020-10-07) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0...v3.6.0-rc.1) + + * Upgrade JS SDK to 8.5.0-rc.1 + * Update from Weblate + [\#5297](https://github.com/matrix-org/matrix-react-sdk/pull/5297) + * Fix edited replies being wrongly treated as big emoji + [\#5295](https://github.com/matrix-org/matrix-react-sdk/pull/5295) + * Fix StopGapWidget infinitely recursing + [\#5294](https://github.com/matrix-org/matrix-react-sdk/pull/5294) + * Fix editing and redactions not updating the Reply Thread + [\#5281](https://github.com/matrix-org/matrix-react-sdk/pull/5281) + * Hide Jump to Read Receipt button for users who have not yet sent an RR + [\#5282](https://github.com/matrix-org/matrix-react-sdk/pull/5282) + * fix img tags not always being rendered correctly + [\#5279](https://github.com/matrix-org/matrix-react-sdk/pull/5279) + * Hopefully fix righhtpanel crash + [\#5293](https://github.com/matrix-org/matrix-react-sdk/pull/5293) + * Fix naive pinning limit and app tile widgetMessaging NPE + [\#5283](https://github.com/matrix-org/matrix-react-sdk/pull/5283) + * Show server errors from saving profile settings + [\#5272](https://github.com/matrix-org/matrix-react-sdk/pull/5272) + * Update copy for `redact` permission + [\#5273](https://github.com/matrix-org/matrix-react-sdk/pull/5273) + * Remove width limit on widgets + [\#5265](https://github.com/matrix-org/matrix-react-sdk/pull/5265) + * Fix call container avatar initial centering + [\#5280](https://github.com/matrix-org/matrix-react-sdk/pull/5280) + * Fix right panel for peeking rooms + [\#5268](https://github.com/matrix-org/matrix-react-sdk/pull/5268) + * Add support for dehydrated devices + [\#5239](https://github.com/matrix-org/matrix-react-sdk/pull/5239) + * Use Own Profile Store for the Profile Settings + [\#5277](https://github.com/matrix-org/matrix-react-sdk/pull/5277) + * null-guard defaultAvatarUrlForString + [\#5270](https://github.com/matrix-org/matrix-react-sdk/pull/5270) + * Choose first result on enter in the emoji picker + [\#5257](https://github.com/matrix-org/matrix-react-sdk/pull/5257) + * Fix room directory clipping links in the room's topic + [\#5276](https://github.com/matrix-org/matrix-react-sdk/pull/5276) + * Decorate failed e2ee downgrade attempts better + [\#5278](https://github.com/matrix-org/matrix-react-sdk/pull/5278) + * MELS use latest avatar rather than the first avatar + [\#5262](https://github.com/matrix-org/matrix-react-sdk/pull/5262) + * Fix Encryption Panel close button clashing with Base Card + [\#5261](https://github.com/matrix-org/matrix-react-sdk/pull/5261) + * Wrap canEncryptToAllUsers in a try/catch to handle server errors + [\#5275](https://github.com/matrix-org/matrix-react-sdk/pull/5275) + * Fix conditional on communities prototype room creation dialog + [\#5274](https://github.com/matrix-org/matrix-react-sdk/pull/5274) + * Fix ensureDmExists for encryption detection + [\#5271](https://github.com/matrix-org/matrix-react-sdk/pull/5271) + * Switch to using the Widget API SDK for widget messaging + [\#5171](https://github.com/matrix-org/matrix-react-sdk/pull/5171) + * Ensure package links exist when releasing + [\#5269](https://github.com/matrix-org/matrix-react-sdk/pull/5269) + * Fix the call preview when not in same room as the call + [\#5267](https://github.com/matrix-org/matrix-react-sdk/pull/5267) + * Make the hangup button do things for conference calls + [\#5223](https://github.com/matrix-org/matrix-react-sdk/pull/5223) + * Render Jitsi widget state events in a more obvious way + [\#5222](https://github.com/matrix-org/matrix-react-sdk/pull/5222) + * Make the PIP Jitsi look and feel like the 1:1 PIP + [\#5226](https://github.com/matrix-org/matrix-react-sdk/pull/5226) + * Trim range when formatting so that it excludes leading/trailing spaces + [\#5263](https://github.com/matrix-org/matrix-react-sdk/pull/5263) + * Fix button label on the Set Password Dialog + [\#5264](https://github.com/matrix-org/matrix-react-sdk/pull/5264) + * fix link to classic yarn's `yarn link` + [\#5259](https://github.com/matrix-org/matrix-react-sdk/pull/5259) + * Fix index mismatch between username colors styles and custom theming + [\#5256](https://github.com/matrix-org/matrix-react-sdk/pull/5256) + * Disable autocompletion on security key input during login + [\#5258](https://github.com/matrix-org/matrix-react-sdk/pull/5258) + * fix uninitialised state and eventlistener leak in RoomUpgradeWarningBar + [\#5255](https://github.com/matrix-org/matrix-react-sdk/pull/5255) + * Only set title when it changes + [\#5254](https://github.com/matrix-org/matrix-react-sdk/pull/5254) + * Convert CallHandler to typescript + [\#5248](https://github.com/matrix-org/matrix-react-sdk/pull/5248) + * Retry loading i18n language if it fails + [\#5209](https://github.com/matrix-org/matrix-react-sdk/pull/5209) + * Rework profile area for user and room settings to be more clear + [\#5243](https://github.com/matrix-org/matrix-react-sdk/pull/5243) + * Validation improve pattern for derived data + [\#5241](https://github.com/matrix-org/matrix-react-sdk/pull/5241) + Changes in [3.5.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.5.0) (2020-09-28) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.5.0-rc.1...v3.5.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/__test-utils__/environment.js b/__test-utils__/environment.js new file mode 100644 index 0000000000..9870c133a2 --- /dev/null +++ b/__test-utils__/environment.js @@ -0,0 +1,17 @@ +const BaseEnvironment = require("jest-environment-jsdom-sixteen"); + +class Environment extends BaseEnvironment { + constructor(config, options) { + super(Object.assign({}, config, { + globals: Object.assign({}, config.globals, { + // Explicitly specify the correct globals to workaround Jest bug + // https://github.com/facebook/jest/issues/7780 + Uint32Array: Uint32Array, + Uint8Array: Uint8Array, + ArrayBuffer: ArrayBuffer, + }), + }), options); + } +} + +module.exports = Environment; 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 e66d0aabcf..d4f931d811 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.5.0", + "version": "3.14.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.2", + "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", @@ -105,68 +106,75 @@ "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.2", - "@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": "^24.9.0", - "jest-canvas-mock": "^2.2.0", - "lolex": "^5.1.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", "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 aafd6e5297..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 { @@ -208,12 +217,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { border: 0; } -/* applied to side-panels and messagepanel when in RoomSettings */ -.mx_fadable { - opacity: 1; - transition: opacity 0.2s ease-in-out; -} - // These are magic constants which are excluded from tinting, to let themes // (which only have CSS, unlike skins) tell the app what their non-tinted // colourscheme is by inspecting the stylesheet DOM. @@ -262,7 +265,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { font-weight: 300; font-size: $font-15px; position: relative; - padding: 25px 30px 30px 30px; + padding: 24px; max-height: 80%; box-shadow: 2px 15px 30px 0 $dialog-shadow-color; border-radius: 8px; @@ -329,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; } @@ -354,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 { @@ -368,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 4d45b1076e..01b261ffbc 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"; @@ -26,7 +28,6 @@ @import "./structures/_ScrollPanel.scss"; @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; -@import "./structures/_TagPanel.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_UploadBar.scss"; @import "./structures/_UserMenu.scss"; @@ -44,18 +45,17 @@ @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/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @@ -69,20 +69,23 @@ @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/_SetMxIdDialog.scss"; -@import "./views/dialogs/_SetPasswordDialog.scss"; @import "./views/dialogs/_SettingsDialog.scss"; @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; @@ -90,6 +93,7 @@ @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"; @@ -103,17 +107,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"; @@ -122,6 +127,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"; @@ -138,6 +145,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"; @@ -182,6 +190,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"; @@ -226,8 +235,12 @@ @import "./views/settings/tabs/user/_SecurityUserSettingsTab.scss"; @import "./views/settings/tabs/user/_VoiceUserSettingsTab.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 3feb2565be..be1138cf5b 100644 --- a/res/css/structures/_CustomRoomTagPanel.scss +++ b/res/css/structures/_CustomRoomTagPanel.scss @@ -16,13 +16,8 @@ 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: $tagpanel-bg-color; + background-color: $groupFilterPanel-bg-color; max-height: 40vh; } diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_GroupFilterPanel.scss similarity index 80% rename from res/css/structures/_TagPanel.scss rename to res/css/structures/_GroupFilterPanel.scss index cdca1f0764..e5a8ef6df2 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_GroupFilterPanel.scss @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_TagPanel { +.mx_GroupFilterPanel { flex: 1; - background-color: $tagpanel-bg-color; + background-color: $groupFilterPanel-bg-color; cursor: pointer; display: flex; @@ -26,49 +26,49 @@ limitations under the License. min-height: 0; } -.mx_TagPanel_items_selected { +.mx_GroupFilterPanel_items_selected { cursor: pointer; } -.mx_TagPanel .mx_TagPanel_divider { +.mx_GroupFilterPanel .mx_GroupFilterPanel_divider { height: 0px; width: 90%; border: none; - border-bottom: 1px solid $tagpanel-divider-color; + border-bottom: 1px solid $groupFilterPanel-divider-color; } -.mx_TagPanel .mx_TagPanel_scroller { +.mx_GroupFilterPanel .mx_GroupFilterPanel_scroller { flex-grow: 1; width: 100%; } -.mx_TagPanel .mx_TagPanel_tagTileContainer { +.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer { display: flex; flex-direction: column; align-items: center; padding-top: 6px; } -.mx_TagPanel .mx_TagPanel_tagTileContainer > div { +.mx_GroupFilterPanel .mx_GroupFilterPanel_tagTileContainer > div { margin: 6px 0; } -.mx_TagPanel .mx_TagTile { +.mx_GroupFilterPanel .mx_TagTile { // opacity: 0.5; position: relative; } -.mx_TagPanel .mx_TagTile.mx_TagTile_prototype { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { padding: 3px; } -.mx_TagPanel .mx_TagTile:focus, -.mx_TagPanel .mx_TagTile:hover, -.mx_TagPanel .mx_TagTile.mx_TagTile_selected { +.mx_GroupFilterPanel .mx_TagTile:focus, +.mx_GroupFilterPanel .mx_TagTile:hover, +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected { // opacity: 1; } -.mx_TagPanel .mx_TagTile.mx_TagTile_selected_prototype { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected_prototype { background-color: $primary-bg-color; border-radius: 6px; } @@ -108,7 +108,7 @@ limitations under the License. } } -.mx_TagPanel .mx_TagTile_plus { +.mx_GroupFilterPanel .mx_TagTile_plus { margin-bottom: 12px; height: 32px; width: 32px; @@ -132,7 +132,7 @@ limitations under the License. } } -.mx_TagPanel .mx_TagTile.mx_TagTile_selected::before { +.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_selected::before { content: ''; height: 100%; background-color: $accent-color; @@ -142,7 +142,7 @@ limitations under the License. border-radius: 0 3px 3px 0; } -.mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus { +.mx_GroupFilterPanel .mx_TagTile.mx_AccessibleButton:focus { filter: none; } 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 5112d07c46..168590502d 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -14,29 +14,30 @@ See the License for the specific language governing permissions and limitations under the License. */ -$tagPanelWidth: 56px; // only applies in this file, used for calculations +$groupFilterPanelWidth: 56px; // only applies in this file, used for calculations .mx_LeftPanel { background-color: $roomlist-bg-color; min-width: 260px; max-width: 50%; - // Create a row-based flexbox for the TagPanel and the room list + // Create a row-based flexbox for the GroupFilterPanel and the room list display: flex; - .mx_LeftPanel_tagPanelContainer { + .mx_LeftPanel_GroupFilterPanelContainer { flex-grow: 0; flex-shrink: 0; - flex-basis: $tagPanelWidth; + flex-basis: $groupFilterPanelWidth; height: 100%; - // Create another flexbox so the TagPanel fills the container + // Create another flexbox so the GroupFilterPanel fills the container display: flex; + flex-direction: column; - // TagPanel handles its own CSS + // GroupFilterPanel handles its own CSS } - &:not(.mx_LeftPanel_hasTagPanel) { + &:not(.mx_LeftPanel_hasGroupFilterPanel) { .mx_LeftPanel_roomListContainer { width: 100%; } @@ -45,7 +46,7 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations // 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% - $tagPanelWidth); + width: calc(100% - $groupFilterPanelWidth); background-color: $roomlist-bg-color; // Create another flexbox (this time a column) for the room list components @@ -169,16 +170,21 @@ $tagPanelWidth: 56px; // only applies in this file, used for calculations min-width: unset; // We have to forcefully set the width to override the resizer's style attribute. - &.mx_LeftPanel_hasTagPanel { - width: calc(68px + $tagPanelWidth) !important; + &.mx_LeftPanel_hasGroupFilterPanel { + width: calc(68px + $groupFilterPanelWidth) !important; } - &:not(.mx_LeftPanel_hasTagPanel) { + &:not(.mx_LeftPanel_hasGroupFilterPanel) { width: 68px !important; } .mx_LeftPanel_roomListContainer { width: 68px; + .mx_LeftPanel_userHeader { + flex-direction: row; + justify-content: center; + } + .mx_LeftPanel_filterContainer { // Organize the flexbox into a centered column layout flex-direction: column; 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/_RoomDirectory.scss b/res/css/structures/_RoomDirectory.scss index e0814182f5..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; } @@ -133,6 +127,10 @@ limitations under the License. .mx_RoomDirectory_topic { cursor: initial; color: $light-fg-color; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; + overflow: hidden; } .mx_RoomDirectory_alias { 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/_TabbedView.scss b/res/css/structures/_TabbedView.scss index 4a4bb125a3..39a8ebed32 100644 --- a/res/css/structures/_TabbedView.scss +++ b/res/css/structures/_TabbedView.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_TabbedView { margin: 0; - padding: 0 0 0 58px; + padding: 0 0 0 16px; display: flex; flex-direction: column; position: absolute; @@ -25,6 +25,7 @@ limitations under the License. bottom: 0; left: 0; right: 0; + margin-top: 8px; } .mx_TabbedView_tabLabels { @@ -35,13 +36,13 @@ limitations under the License. } .mx_TabbedView_tabLabel { + display: flex; + align-items: center; vertical-align: text-top; cursor: pointer; - display: block; - border-radius: 3px; - font-size: $font-14px; - min-height: 24px; // use min-height instead of height to allow the label to overflow a bit - margin-bottom: 6px; + padding: 8px 0; + border-radius: 8px; + font-size: $font-13px; position: relative; } @@ -51,9 +52,8 @@ limitations under the License. } .mx_TabbedView_maskedIcon { - margin-left: 6px; - margin-right: 9px; - margin-top: 1px; + margin-left: 8px; + margin-right: 16px; width: 16px; height: 16px; display: inline-block; @@ -65,10 +65,9 @@ limitations under the License. mask-repeat: no-repeat; mask-size: 16px; width: 16px; - height: 22px; + height: 16px; mask-position: center; content: ''; - vertical-align: middle; } .mx_TabbedView_tabLabel_active .mx_TabbedView_maskedIcon::before { diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index fecac40e4e..2a4453df70 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -119,20 +119,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 +226,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 +272,9 @@ limitations under the License. .mx_UserMenu_iconHome::before { mask-image: url('$(res)/img/element-icons/roomlist/home.svg'); } + .mx_UserMenu_iconHosting::before { + mask-image: url('$(res)/img/element-icons/brands/element.svg'); + } .mx_UserMenu_iconBell::before { mask-image: url('$(res)/img/element-icons/notifications.svg'); diff --git a/res/css/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/_ServerConfig.scss b/res/css/views/auth/_ServerConfig.scss deleted file mode 100644 index a7e0057ab3..0000000000 --- a/res/css/views/auth/_ServerConfig.scss +++ /dev/null @@ -1,35 +0,0 @@ -/* -Copyright 2015, 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. -*/ - -.mx_ServerConfig_help:link { - opacity: 0.8; -} - -.mx_ServerConfig_error { - display: block; - color: $warning-color; -} - -.mx_ServerConfig_identityServer { - transform: scaleY(0); - transform-origin: top; - transition: transform 0.25s; - - &.mx_ServerConfig_identityServer_shown { - transform: scaleY(1); - } -} 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/extend.js b/res/css/views/context_menus/_CallContextMenu.scss similarity index 71% rename from src/extend.js rename to res/css/views/context_menus/_CallContextMenu.scss index 263d802ab6..55b73b0344 100644 --- a/src/extend.js +++ b/res/css/views/context_menus/_CallContextMenu.scss @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd +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. @@ -14,13 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -'use strict'; - -export default function(dest, src) { - for (const i in src) { - if (src.hasOwnProperty(i)) { - dest[i] = src[i]; - } - } - return dest; +.mx_CallContextMenu_item { + width: 205px; + height: 40px; + padding-left: 16px; + line-height: 40px; + vertical-align: center; } 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/_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..1378ac9053 --- /dev/null +++ b/res/css/views/dialogs/_HostSignupDialog.scss @@ -0,0 +1,138 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_HostSignupDialog { + width: 90vw; + max-width: 580px; + height: 80vh; + max-height: 600px; + + .mx_HostSignupDialog_info { + text-align: center; + + .mx_HostSignupDialog_content_top { + margin-bottom: 24px; + } + + .mx_HostSignupDialog_paragraphs { + text-align: left; + padding-left: 25%; + padding-right: 25%; + } + + .mx_HostSignupDialog_buttons { + margin-bottom: 24px; + display: flex; + justify-content: center; + + button { + padding: 12px; + margin: 0 16px; + } + } + + .mx_HostSignupDialog_footer { + display: flex; + justify-content: center; + align-items: baseline; + + img { + padding-right: 5px; + } + } + } + + iframe { + width: 100%; + height: 100%; + border: none; + background-color: #fff; + min-height: 540px; + } +} + +.mx_HostSignupDialog_text_dark { + color: $primary-fg-color; +} + +.mx_HostSignupDialog_text_light { + color: $secondary-fg-color; +} + +.mx_HostSignup_maximize_button { + mask: url('$(res)/img/feather-customised/maximise.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 14px; + height: 14px; + background-color: $dialog-close-fg-color; + cursor: pointer; + position: absolute; + top: 10px; + right: 10px; +} + +.mx_HostSignup_minimize_button { + mask: url('$(res)/img/feather-customised/minimise.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 14px; + height: 14px; + background-color: $dialog-close-fg-color; + cursor: pointer; + position: absolute; + top: 10px; + right: 25px; +} + +.mx_HostSignup_persisted { + width: 90vw; + max-width: 580px; + height: 80vh; + max-height: 600px; + top: 0; + left: 0; + position: fixed; + display: none; +} + +.mx_HostSignupDialog_minimized { + position: fixed; + bottom: 80px; + right: 26px; + width: 314px; + height: 217px; + overflow: hidden; + + &.mx_Dialog { + padding: 12px; + } + + .mx_Dialog_title { + text-align: left !important; + padding-left: 20px; + font-size: $font-15px; + } + + iframe { + width: 100%; + height: 100%; + border: none; + background-color: #fff; + } +} diff --git a/res/css/views/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/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index d4199a1e66..9bcde6e1e0 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -48,7 +48,6 @@ limitations under the License. white-space: nowrap; overflow: hidden; margin: 0 auto; - padding-left: 40px; padding-right: 80px; } 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/_SetMxIdDialog.scss b/res/css/views/dialogs/_SetMxIdDialog.scss deleted file mode 100644 index 1df34f3408..0000000000 --- a/res/css/views/dialogs/_SetMxIdDialog.scss +++ /dev/null @@ -1,50 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations 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_SetMxIdDialog .mx_Dialog_title { - padding-right: 40px; -} - -.mx_SetMxIdDialog_input_group { - display: flex; -} - -.mx_SetMxIdDialog_input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; - font-size: $font-15px; - width: 100%; - max-width: 280px; -} - -.mx_SetMxIdDialog_input.error, -.mx_SetMxIdDialog_input.error:focus { - border: 1px solid $warning-color; -} - -.mx_SetMxIdDialog_input_group .mx_Spinner { - height: 37px; - padding-left: 10px; - justify-content: flex-start; -} - -.mx_SetMxIdDialog .success { - color: $accent-color; -} diff --git a/res/css/views/dialogs/_SetPasswordDialog.scss b/res/css/views/dialogs/_SetPasswordDialog.scss deleted file mode 100644 index 1f99353298..0000000000 --- a/res/css/views/dialogs/_SetPasswordDialog.scss +++ /dev/null @@ -1,34 +0,0 @@ -/* -Copyright 2017 Vector Creations 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_SetPasswordDialog_change_password input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-fg-color; - background-color: $primary-bg-color; - font-size: $font-15px; - max-width: 280px; - margin-bottom: 10px; -} - -.mx_SetPasswordDialog_change_password_button { - margin-top: 68px; -} - -.mx_SetPasswordDialog .mx_Dialog_content { - margin-bottom: 0px; -} diff --git a/res/css/views/dialogs/_SettingsDialog.scss b/res/css/views/dialogs/_SettingsDialog.scss index ec813a1a07..6c4ed35c5a 100644 --- a/res/css/views/dialogs/_SettingsDialog.scss +++ b/res/css/views/dialogs/_SettingsDialog.scss @@ -36,7 +36,6 @@ limitations under the License. } .mx_Dialog_title { - text-align: center; margin-bottom: 24px; } } 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..2be15447f7 100644 --- a/res/css/views/messages/_MVideoBody.scss +++ b/res/css/views/messages/_MVideoBody.scss @@ -17,6 +17,7 @@ limitations under the License. span.mx_MVideoBody { video.mx_MVideoBody { max-width: 100%; - height: auto; + max-height: 300px; + border-radius: 4px; } } diff --git a/res/css/views/messages/_RedactedBody.scss b/res/css/views/messages/_RedactedBody.scss index e4ab0c0835..600ac0c6b7 100644 --- a/res/css/views/messages/_RedactedBody.scss +++ b/res/css/views/messages/_RedactedBody.scss @@ -30,7 +30,7 @@ limitations under the License. mask-size: contain; content: ''; position: absolute; - top: 2px; + top: 1px; left: 0; } } diff --git a/res/css/views/messages/_ViewSourceEvent.scss b/res/css/views/messages/_ViewSourceEvent.scss index 076932ee97..66825030e0 100644 --- a/res/css/views/messages/_ViewSourceEvent.scss +++ b/res/css/views/messages/_ViewSourceEvent.scss @@ -35,13 +35,13 @@ limitations under the License. mask-size: auto 12px; visibility: hidden; background-color: $accent-color; - mask-image: url('$(res)/img/feather-customised/widget/maximise.svg'); + mask-image: url('$(res)/img/feather-customised/maximise.svg'); } &.mx_ViewSourceEvent_expanded .mx_ViewSourceEvent_toggle { mask-position: 0 bottom; margin-bottom: 7px; - mask-image: url('$(res)/img/feather-customised/widget/minimise.svg'); + mask-image: url('$(res)/img/feather-customised/minimise.svg'); } &:hover .mx_ViewSourceEvent_toggle { diff --git a/res/css/views/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 26f846fe0a..9a5a59bda8 100644 --- a/res/css/views/right_panel/_BaseCard.scss +++ b/res/css/views/right_panel/_BaseCard.scss @@ -40,6 +40,7 @@ limitations under the License. width: 20px; margin: 12px; top: 0; + border-radius: 10px; &::before { content: ""; @@ -55,7 +56,6 @@ limitations under the License. } .mx_BaseCard_back { - border-radius: 4px; left: 0; &::before { @@ -66,7 +66,6 @@ limitations under the License. } .mx_BaseCard_close { - border-radius: 10px; right: 0; &::before { @@ -129,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 244e88ca3e..492ed95973 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -24,34 +24,69 @@ $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_AppsDrawer_hidden { - display: none; +.mx_AppsContainer_resizer { + margin-bottom: 8px; } .mx_AppsContainer { @@ -60,63 +95,74 @@ $MiniAppTileHeight: 200px; align-items: stretch; justify-content: center; height: 100%; - margin-bottom: 8px; -} + width: 100%; + flex: 1; + min-height: 0; -.mx_AppsDrawer_minimised .mx_AppsContainer { - // override the re-resizable inline styles - height: inherit !important; - min-height: inherit !important; -} + .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_AddWidget_button { - order: 2; - cursor: pointer; - padding: 0; - margin: -3px auto 5px 0; - color: $accent-color; - font-size: $font-12px; -} + .mx_ResizeHandle_horizontal { + position: relative; -.mx_AddWidget_button_full_width { - max-width: 960px; -} - -.mx_SetAppURLDialog_input { - border-radius: 3px; - border: 1px solid $input-border-color; - padding: 9px; - color: $primary-hairline-color; - background-color: $primary-bg-color; - font-size: $font-15px; -} - -.mx_AppTile { - max-width: 960px; - width: 50%; - border: 5px solid $widget-menu-bar-bg-color; - border-radius: 4px; - display: flex; - flex-direction: column; - - & + .mx_AppTile { - margin-left: 5px; + > div { + width: 0; + } } } +// TODO this should be 300px but that's too large +$MinWidth: 240px; + +.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%; + min-width: $MinWidth; + border: 8px solid $widget-menu-bar-bg-color; + border-left-width: 5px; + border-right-width: 5px; + display: flex; + flex-direction: column; + box-sizing: border-box; + background-color: $widget-menu-bar-bg-color; +} + .mx_AppTileFullWidth { - max-width: 960px; - 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 { - max-width: 960px; width: 100%; margin: 0; padding: 0; @@ -125,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 { @@ -150,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 { @@ -186,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 { @@ -249,75 +273,8 @@ $MiniAppTileHeight: 200px; display: block; } -.mx_AppTileMenuBarWidgetPadding { - margin-right: 5px; -} - -.mx_AppIconTile { - background-color: $lightbox-bg-color; - border: 1px solid rgba(0, 0, 0, 0); - width: 200px; - box-shadow: 0 4px 8px 0 rgba(0, 0, 0, 0.2); - transition: 0.3s; - border-radius: 3px; - margin: 5px; - display: inline-block; -} - -.mx_AppIconTile.mx_AppIconTile_active { - color: $accent-color; - border-color: $accent-color; -} - -.mx_AppIconTile:hover { - border: 1px solid $accent-color; - box-shadow: 0 0 10px 5px rgba(200, 200, 200, 0.5); -} - -.mx_AppIconTile_content { - padding: 2px 16px; - height: 60px; - overflow: hidden; -} - -.mx_AppIconTile_content h4 { - margin-top: 5px; - margin-bottom: 2px; -} - -.mx_AppIconTile_content p { - margin-top: 0; - margin-bottom: 5px; - font-size: smaller; -} - -.mx_AppIconTile_image { - padding: 10px; - max-width: 100px; - max-height: 100px; - width: auto; - height: auto; -} - -.mx_AppIconTile_imageContainer { - text-align: center; - width: 100%; - background-color: white; - border-radius: 3px 3px 0 0; - height: 155px; - display: flex; - justify-content: center; - align-items: center; -} - -form.mx_Custom_Widget_Form div { - margin-top: 10px; - margin-bottom: 10px; -} - .mx_AppPermissionWarning { text-align: center; - background-color: $widget-menu-bar-bg-color; display: flex; height: 100%; flex-direction: column; @@ -382,6 +339,10 @@ form.mx_Custom_Widget_Form div { 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 { @@ -409,10 +370,6 @@ form.mx_Custom_Widget_Form div { 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..42df3211de 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 { @@ -429,15 +420,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 +446,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 +491,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 +499,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 +526,42 @@ $left-gutter: 64px; } // Inserted adjacent to
 blocks, (See TextualBody)
-.mx_EventTile_copyButton {
+.mx_EventTile_button {
     position: absolute;
     display: inline-block;
     visibility: hidden;
     cursor: pointer;
     top: 6px;
-    right: 6px;
+    right: 12px;
     width: 19px;
     height: 19px;
-    mask-image: url($copy-button-url);
     background-color: $message-action-bar-fg-color;
 }
+.mx_EventTile_buttonBottom {
+    top: 31px;
+}
+.mx_EventTile_copyButton {
+    mask-image: url($copy-button-url);
+}
+.mx_EventTile_collapseButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($collapse-button-url);
+}
+.mx_EventTile_expandButton {
+    mask-size: 75%;
+    mask-position: center;
+    mask-repeat: no-repeat;
+    mask-image: url($expand-button-url);
+}
 
 .mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_copyButton,
-.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton {
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_copyButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_collapseButton,
+.mx_EventTile_body .mx_EventTile_pre_container:focus-within .mx_EventTile_expandButton,
+.mx_EventTile_body .mx_EventTile_pre_container:hover .mx_EventTile_expandButton {
     visibility: visible;
 }
 
diff --git a/res/css/views/rooms/_GroupLayout.scss b/res/css/views/rooms/_GroupLayout.scss
index 2b447be44a..543e6ed685 100644
--- a/res/css/views/rooms/_GroupLayout.scss
+++ b/res/css/views/rooms/_GroupLayout.scss
@@ -20,7 +20,7 @@ $left-gutter: 64px;
 .mx_GroupLayout {
     .mx_EventTile {
         > .mx_SenderProfile {
-            line-height: $font-17px;
+            line-height: $font-20px;
             padding-left: $left-gutter;
         }
 
@@ -34,11 +34,11 @@ $left-gutter: 64px;
 
         .mx_MessageTimestamp {
             position: absolute;
-            width: 46px; /* 8 + 30 (avatar) + 8 */
+            width: $MessageTimestamp_width;
         }
 
         .mx_EventTile_line, .mx_EventTile_reply {
-            padding-top: 3px;
+            padding-top: 1px;
             padding-bottom: 3px;
             line-height: $font-22px;
         }
diff --git a/res/css/views/rooms/_IRCLayout.scss b/res/css/views/rooms/_IRCLayout.scss
index 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/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss
index 022cf3ed28..5310bd3bbb 100644
--- a/res/css/views/rooms/_LinkPreviewWidget.scss
+++ b/res/css/views/rooms/_LinkPreviewWidget.scss
@@ -19,6 +19,8 @@ limitations under the License.
     margin-right: 15px;
     margin-bottom: 15px;
     display: flex;
+    flex-direction: column;
+    max-width: 360px;
     border-left: 4px solid $preview-widget-bar-color;
     color: $preview-widget-fg-color;
 }
@@ -55,6 +57,9 @@ limitations under the License.
     cursor: pointer;
     width: 18px;
     height: 18px;
+    padding: 0px 5px 5px 5px;
+    margin-left: auto;
+    margin-right: 0px;
 
     img {
         flex: 0 0 40px;
diff --git a/res/css/views/rooms/_MemberInfo.scss b/res/css/views/rooms/_MemberInfo.scss
index fb082843f1..182c280217 100644
--- a/res/css/views/rooms/_MemberInfo.scss
+++ b/res/css/views/rooms/_MemberInfo.scss
@@ -70,7 +70,7 @@ limitations under the License.
 }
 
 .mx_MemberInfo_avatar {
-    background: $tagpanel-bg-color;
+    background: $groupFilterPanel-bg-color;
     margin-bottom: 16px;
 }
 
diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss
index 2366667c95..1e3506e371 100644
--- a/res/css/views/rooms/_MemberList.scss
+++ b/res/css/views/rooms/_MemberList.scss
@@ -46,6 +46,11 @@ limitations under the License.
     }
 }
 
+.mx_GroupMemberList_query,
+.mx_GroupRoomList_query {
+    flex: 0 0 auto;
+}
+
 .mx_MemberList_chevron {
     position: absolute;
     right: 35px;
@@ -59,10 +64,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 +73,6 @@ limitations under the License.
     }
 }
 
-.mx_MemberList_query {
-    height: 16px;
-}
-
 .mx_MemberList_wrapper {
     padding: 10px;
 }
@@ -96,17 +95,27 @@ limitations under the License.
 }
 
 .mx_MemberList_invite span {
-    background-image: url('$(res)/img/element-icons/room/invite.svg');
-    background-repeat: no-repeat;
-    background-position: center left;
-    background-size: 20px;
-    padding: 8px 0 8px 25px;
+    padding: 8px 0;
+    display: inline-flex;
+
+    &::before {
+        content: '';
+        display: inline-block;
+        background-color: $button-fg-color;
+        mask-image: url('$(res)/img/element-icons/room/invite.svg');
+        mask-position: center;
+        mask-repeat: no-repeat;
+        mask-size: 20px;
+        width: 20px;
+        height: 20px;
+        margin-right: 5px;
+    }
 }
 
-.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/_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..a23a44906f 100644
--- a/res/css/views/rooms/_RoomHeader.scss
+++ b/res/css/views/rooms/_RoomHeader.scss
@@ -241,6 +241,13 @@ 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');
 }
diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss
index 78e7307bc0..66e1b827d0 100644
--- a/res/css/views/rooms/_RoomList.scss
+++ b/res/css/views/rooms/_RoomList.scss
@@ -24,6 +24,9 @@ limitations under the License.
 .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 +36,6 @@ limitations under the License.
 
     div:first-child {
         font-weight: $font-semi-bold;
-        margin-bottom: 8px;
     }
 
     .mx_AccessibleButton {
@@ -41,6 +43,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 +58,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/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss
index 52a0ee95d7..a350605ab1 100644
--- a/res/css/views/settings/_AvatarSetting.scss
+++ b/res/css/views/settings/_AvatarSetting.scss
@@ -16,6 +16,7 @@ limitations under the License.
 
 .mx_AvatarSetting_avatar {
     width: 90px;
+    min-width: 90px; // so it doesn't get crushed by the flexbox in languages with longer words
     height: 90px;
     margin-top: 8px;
     position: relative;
@@ -84,6 +85,7 @@ limitations under the License.
     .mx_AvatarSetting_avatarPlaceholder {
         display: block;
         height: 90px;
+        width: inherit;
         border-radius: 90px;
         cursor: pointer;
     }
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/toasts/_AnalyticsToast.scss b/res/css/views/toasts/_AnalyticsToast.scss
new file mode 100644
index 0000000000..fdbe7f1c76
--- /dev/null
+++ b/res/css/views/toasts/_AnalyticsToast.scss
@@ -0,0 +1,27 @@
+/*
+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_AnalyticsToast {
+    .mx_AccessibleButton_kind_danger {
+        background: none;
+        color: $accent-color;
+    }
+
+    .mx_AccessibleButton_kind_primary {
+        background: $accent-color;
+        color: #ffffff;
+    }
+}
diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss
index 650302b7e1..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;
@@ -59,7 +58,7 @@ limitations under the License.
             display: flex;
             direction: row;
 
-            img {
+            img, .mx_BaseAvatar_initial {
                 margin: 8px;
             }
 
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..931410dba3 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,22 @@ 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%;
     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/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/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 f713e57d73..655f9f118a 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/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/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 331b5f4692..a878aa3cdd 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -39,7 +39,7 @@ $info-plinth-fg-color: #888;
 
 $preview-bar-bg-color: $header-panel-bg-color;
 
-$tagpanel-bg-color: rgba(38, 39, 43, 0.82);
+$groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82);
 $inverted-bg-color: $base-color;
 
 // used by AddressSelector
@@ -98,7 +98,7 @@ $roomheader-color: $text-primary-color;
 $roomheader-bg-color: $bg-color;
 $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.3);
 $roomheader-addroom-fg-color: $text-primary-color;
-$tagpanel-button-color: $header-panel-text-primary-color;
+$groupFilterPanel-button-color: $header-panel-text-primary-color;
 $groupheader-button-color: $header-panel-text-primary-color;
 $rightpanel-button-color: $header-panel-text-primary-color;
 $icon-button-color: #8E99A4;
@@ -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,9 @@ $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%);
 
-$tagpanel-divider-color: $roomlist-header-color;
+$groupFilterPanel-divider-color: $roomlist-header-color;
 
 $roomtile-preview-color: $secondary-fg-color;
 $roomtile-default-badge-bg-color: #61708b;
@@ -131,6 +135,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;
@@ -187,7 +192,7 @@ $reaction-row-button-selected-border-color: $accent-color;
 
 $kbd-border-color: #000000;
 
-$tooltip-timeline-bg-color: $tagpanel-bg-color;
+$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
 $tooltip-timeline-fg-color: #ffffff;
 
 $interactive-tooltip-bg-color: $base-color;
@@ -202,7 +207,7 @@ $appearance-tab-border-color: $room-highlight-color;
 
 // blur amounts for left left panel (only for element theme, used in _mods.scss)
 $roomlist-background-blur-amount: 60px;
-$tagpanel-background-blur-amount: 30px;
+$groupFilterPanel-background-blur-amount: 30px;
 
 $composer-shadow-color: rgba(0, 0, 0, 0.28);
 
@@ -212,7 +217,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 +258,12 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
 // markdown overrides:
 .mx_EventTile_content .markdown-body pre:hover {
     border-color: #808080 !important; // inverted due to rules below
+    scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // copied from light theme due to inversion below
+    // the code above works only in Firefox, this is for other browsers
+    // see https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color
+    &::-webkit-scrollbar-thumb {
+        background-color: rgba(0, 0, 0, 0.2); // copied from light theme due to inversion below
+    }
 }
 .mx_EventTile_content .markdown-body {
     pre, code {
@@ -272,6 +283,10 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28);
             background-color: #080808;
         }
     }
+
+    blockquote {
+        color: #919191;
+    }
 }
 
 // diff highlight colors
diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss
index 6d9dc7352c..f9695018e4 100644
--- a/res/themes/dark/css/dark.scss
+++ b/res/themes/dark/css/dark.scss
@@ -3,7 +3,7 @@
 @import "../../light/css/_fonts.scss";
 @import "../../light/css/_light.scss";
 // important this goes before _mods,
-// as $tagpanel-background-blur-amount and
+// as $groupFilterPanel-background-blur-amount and
 // $roomlist-background-blur-amount
 // are overridden in _dark.scss
 @import "_dark.scss";
diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index 14ce264bc0..3e3c299af9 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -37,8 +37,8 @@ $info-plinth-fg-color: #888;
 
 $preview-bar-bg-color: $header-panel-bg-color;
 
-$tagpanel-bg-color: $base-color;
-$inverted-bg-color: $tagpanel-bg-color;
+$groupFilterPanel-bg-color: $base-color;
+$inverted-bg-color: $groupFilterPanel-bg-color;
 
 // used by AddressSelector
 $selected-color: $room-highlight-color;
@@ -95,7 +95,7 @@ $topleftmenu-color: $text-primary-color;
 $roomheader-color: $text-primary-color;
 $roomheader-addroom-bg-color: #3c4556; // $search-placeholder-color at 0.5 opacity
 $roomheader-addroom-fg-color: $text-primary-color;
-$tagpanel-button-color: $header-panel-text-primary-color;
+$groupFilterPanel-button-color: $header-panel-text-primary-color;
 $groupheader-button-color: $header-panel-text-primary-color;
 $rightpanel-button-color: $header-panel-text-primary-color;
 $icon-button-color: $header-panel-text-primary-color;
@@ -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,9 @@ $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%);
 
-$tagpanel-divider-color: $roomlist-header-color;
+$groupFilterPanel-divider-color: $roomlist-header-color;
 
 $roomtile-preview-color: #9e9e9e;
 $roomtile-default-badge-bg-color: #61708b;
@@ -126,6 +130,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;
@@ -182,7 +187,7 @@ $reaction-row-button-selected-border-color: $accent-color;
 
 $kbd-border-color: #000000;
 
-$tooltip-timeline-bg-color: $tagpanel-bg-color;
+$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
 $tooltip-timeline-fg-color: #ffffff;
 
 $interactive-tooltip-bg-color: $base-color;
@@ -203,7 +208,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 b030fb7423..a740ba155c 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -67,8 +67,8 @@ $preview-bar-bg-color: #f7f7f7;
 $secondary-accent-color: #f2f5f8;
 $tertiary-accent-color: #d3efe1;
 
-$tagpanel-bg-color: #27303a;
-$inverted-bg-color: $tagpanel-bg-color;
+$groupFilterPanel-bg-color: #27303a;
+$inverted-bg-color: $groupFilterPanel-bg-color;
 
 // used by RoomDirectory permissions
 $plinth-bg-color: $secondary-accent-color;
@@ -162,7 +162,7 @@ $roomheader-color: #45474a;
 $roomheader-bg-color: $primary-bg-color;
 $roomheader-addroom-bg-color: #91a1c0;
 $roomheader-addroom-fg-color: $accent-fg-color;
-$tagpanel-button-color: #91a1c0;
+$groupFilterPanel-button-color: #91a1c0;
 $groupheader-button-color: #91a1c0;
 $rightpanel-button-color: #91a1c0;
 $icon-button-color: #91a1c0;
@@ -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,9 @@ $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%);
 
-$tagpanel-divider-color: $roomlist-header-color;
+$groupFilterPanel-divider-color: $roomlist-header-color;
 
 $roomtile-preview-color: #9e9e9e;
 $roomtile-default-badge-bg-color: #61708b;
@@ -208,6 +212,7 @@ $panel-divider-color: #dee1f3;
 // ********************
 
 $widget-menu-bar-bg-color: $secondary-accent-color;
+$widget-body-bg-color: #fff;
 
 // ********************
 
@@ -232,7 +237,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
-
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
@@ -305,7 +311,7 @@ $reaction-row-button-selected-border-color: $accent-color;
 
 $kbd-border-color: $reaction-row-button-border-color;
 
-$tooltip-timeline-bg-color: $tagpanel-bg-color;
+$tooltip-timeline-bg-color: $groupFilterPanel-bg-color;
 $tooltip-timeline-fg-color: #ffffff;
 
 $interactive-tooltip-bg-color: #27303a;
@@ -326,7 +332,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-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss
index 6bb46e8a67..1b9254d100 100644
--- a/res/themes/light-custom/css/_custom.scss
+++ b/res/themes/light-custom/css/_custom.scss
@@ -49,7 +49,7 @@ $roomtile-selected-bg-color: var(--roomlist-highlights-color);
 //
 // --sidebar-color
 $interactive-tooltip-bg-color: var(--sidebar-color);
-$tagpanel-bg-color: var(--sidebar-color);
+$groupFilterPanel-bg-color: var(--sidebar-color);
 $tooltip-timeline-bg-color: var(--sidebar-color);
 $dialog-backdrop-color: var(--sidebar-color-50pct);
 $roomlist-button-bg-color: var(--sidebar-color-15pct);
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 140783212d..1c89d83c01 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -62,7 +62,7 @@ $preview-bar-bg-color: #f7f7f7;
 $secondary-accent-color: #f2f5f8;
 $tertiary-accent-color: #d3efe1;
 
-$tagpanel-bg-color: rgba(232, 232, 232, 0.77);
+$groupFilterPanel-bg-color: rgba(232, 232, 232, 0.77);
 
 // used by RoomDirectory permissions
 $plinth-bg-color: $secondary-accent-color;
@@ -156,7 +156,7 @@ $roomheader-color: #45474a;
 $roomheader-bg-color: $primary-bg-color;
 $roomheader-addroom-bg-color: rgba(92, 100, 112, 0.2);
 $roomheader-addroom-fg-color: #5c6470;
-$tagpanel-button-color: #91A1C0;
+$groupFilterPanel-button-color: #91A1C0;
 $groupheader-button-color: #91A1C0;
 $rightpanel-button-color: #91A1C0;
 $icon-button-color: #C1C6CD;
@@ -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,9 @@ $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%);
 
-$tagpanel-divider-color: $roomlist-header-color;
+$groupFilterPanel-divider-color: $roomlist-header-color;
 
 $roomtile-preview-color: $secondary-fg-color;
 $roomtile-default-badge-bg-color: #61708b;
@@ -208,6 +212,7 @@ $pinned-color: $notice-secondary-color;
 // ********************
 
 $widget-menu-bar-bg-color: $secondary-accent-color;
+$widget-body-bg-color: #FFF;
 
 // ********************
 
@@ -232,6 +237,8 @@ $event-redacted-border-color: #cccccc;
 $event-timestamp-color: #acacac;
 
 $copy-button-url: "$(res)/img/feather-customised/clipboard.svg";
+$collapse-button-url: "$(res)/img/feather-customised/minimise.svg";
+$expand-button-url: "$(res)/img/feather-customised/maximise.svg";
 
 // e2e
 $e2e-verified-color: #76cfa5; // N.B. *NOT* the same as $accent-color
@@ -320,7 +327,7 @@ $appearance-tab-border-color: $input-darker-bg-color;
 
 // blur amounts for left left panel (only for element theme, used in _mods.scss)
 $roomlist-background-blur-amount: 40px;
-$tagpanel-background-blur-amount: 20px;
+$groupFilterPanel-background-blur-amount: 20px;
 
 $composer-shadow-color: rgba(0, 0, 0, 0.04);
 
@@ -330,7 +337,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 9a59acba8e..30aaeedf8f 100644
--- a/res/themes/light/css/_mods.scss
+++ b/res/themes/light/css/_mods.scss
@@ -6,14 +6,14 @@
 
 @supports (backdrop-filter: none) {
     .mx_LeftPanel {
-        background-image: var(--avatar-url);
+        background-image: var(--avatar-url, unset);
         background-repeat: no-repeat;
         background-size: cover;
         background-position: left top;
     }
 
-    .mx_TagPanel {
-        backdrop-filter: blur($tagpanel-background-blur-amount);
+    .mx_GroupFilterPanel {
+        backdrop-filter: blur($groupFilterPanel-background-blur-amount);
     }
 
     .mx_LeftPanel .mx_LeftPanel_roomListContainer {
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 91b91de90d..28f22780a2 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first
 import * as ModernizrStatic from "modernizr";
 import ContentMessages from "../ContentMessages";
 import { IMatrixClientPeg } from "../MatrixClientPeg";
@@ -31,6 +32,12 @@ import type {Renderer} from "react-dom";
 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";
 
 declare global {
     interface Window {
@@ -54,12 +61,25 @@ declare global {
         mxNotifier: typeof Notifier;
         mxRightPanelStore: RightPanelStore;
         mxWidgetStore: WidgetStore;
+        mxWidgetLayoutStore: WidgetLayoutStore;
         mxCallHandler: CallHandler;
+        mxAnalytics: Analytics;
+        mxCountlyAnalytics: typeof CountlyAnalytics;
+        mxUserActivity: UserActivity;
+        mxModalWidgetStore: ModalWidgetStore;
+        mxVoipUserMapper: VoipUserMapper;
     }
 
     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 {
@@ -89,4 +109,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/Analytics.js b/src/Analytics.tsx
similarity index 74%
rename from src/Analytics.js
rename to src/Analytics.tsx
index 135cc2eb7a..212bfd3757 100644
--- a/src/Analytics.js
+++ b/src/Analytics.tsx
@@ -17,7 +17,7 @@ limitations under the License.
 
 import React from 'react';
 
-import { getCurrentLanguage, _t, _td } from './languageHandler';
+import {getCurrentLanguage, _t, _td, IVariables} from './languageHandler';
 import PlatformPeg from './PlatformPeg';
 import SdkConfig from './SdkConfig';
 import Modal from './Modal';
@@ -27,7 +27,7 @@ const hashRegex = /#\/(groups?|room|user|settings|register|login|forgot_password
 const hashVarRegex = /#\/(group|room|user)\/.*$/;
 
 // Remove all but the first item in the hash path. Redact unexpected hashes.
-function getRedactedHash(hash) {
+function getRedactedHash(hash: string): string {
     // Don't leak URLs we aren't expecting - they could contain tokens/PII
     const match = hashRegex.exec(hash);
     if (!match) {
@@ -44,7 +44,7 @@ function getRedactedHash(hash) {
 
 // Return the current origin, path and hash separated with a `/`. This does
 // not include query parameters.
-function getRedactedUrl() {
+function getRedactedUrl(): string {
     const { origin, hash } = window.location;
     let { pathname } = window.location;
 
@@ -56,7 +56,25 @@ function getRedactedUrl() {
     return origin + pathname + getRedactedHash(hash);
 }
 
-const customVariables = {
+interface IData {
+    /* eslint-disable camelcase */
+    gt_ms?: string;
+    e_c?: string;
+    e_a?: string;
+    e_n?: string;
+    e_v?: string;
+    ping?: string;
+    /* eslint-enable camelcase */
+}
+
+interface IVariable {
+    id: number;
+    expl: string; // explanation
+    example: string; // example value
+    getTextVariables?(): IVariables; // object to pass as 2nd argument to `_t`
+}
+
+const customVariables: Record = {
     // The Matomo installation at https://matomo.riot.im is currently configured
     // with a limit of 10 custom variables.
     'App Platform': {
@@ -120,7 +138,7 @@ const customVariables = {
     },
 };
 
-function whitelistRedact(whitelist, str) {
+function whitelistRedact(whitelist: string[], str: string): string {
     if (whitelist.includes(str)) return str;
     return '';
 }
@@ -130,7 +148,7 @@ const CREATION_TS_KEY = "mx_Riot_Analytics_cts";
 const VISIT_COUNT_KEY = "mx_Riot_Analytics_vc";
 const LAST_VISIT_TS_KEY = "mx_Riot_Analytics_lvts";
 
-function getUid() {
+function getUid(): string {
     try {
         let data = localStorage && localStorage.getItem(UID_KEY);
         if (!data && localStorage) {
@@ -145,32 +163,36 @@ function getUid() {
 
 const HEARTBEAT_INTERVAL = 30 * 1000; // seconds
 
-class Analytics {
+export class Analytics {
+    private baseUrl: URL = null;
+    private siteId: string = null;
+    private visitVariables: Record = {}; // {[id: number]: [name: string, value: string]}
+    private firstPage = true;
+    private heartbeatIntervalID: number = null;
+
+    private readonly creationTs: string;
+    private readonly lastVisitTs: string;
+    private readonly visitCount: string;
+
     constructor() {
-        this.baseUrl = null;
-        this.siteId = null;
-        this.visitVariables = {};
-
-        this.firstPage = true;
-        this._heartbeatIntervalID = null;
-
         this.creationTs = localStorage && localStorage.getItem(CREATION_TS_KEY);
         if (!this.creationTs && localStorage) {
-            localStorage.setItem(CREATION_TS_KEY, this.creationTs = new Date().getTime());
+            localStorage.setItem(CREATION_TS_KEY, this.creationTs = String(new Date().getTime()));
         }
 
         this.lastVisitTs = localStorage && localStorage.getItem(LAST_VISIT_TS_KEY);
-        this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || 0;
+        this.visitCount = localStorage && localStorage.getItem(VISIT_COUNT_KEY) || "0";
+        this.visitCount = String(parseInt(this.visitCount, 10) + 1); // increment
         if (localStorage) {
-            localStorage.setItem(VISIT_COUNT_KEY, parseInt(this.visitCount, 10) + 1);
+            localStorage.setItem(VISIT_COUNT_KEY, this.visitCount);
         }
     }
 
-    get disabled() {
+    public get disabled() {
         return !this.baseUrl;
     }
 
-    canEnable() {
+    public canEnable() {
         const config = SdkConfig.get();
         return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId;
     }
@@ -179,67 +201,67 @@ class Analytics {
      * Enable Analytics if initialized but disabled
      * otherwise try and initalize, no-op if piwik config missing
      */
-    async enable() {
+    public async enable() {
         if (!this.disabled) return;
         if (!this.canEnable()) return;
         const config = SdkConfig.get();
 
         this.baseUrl = new URL("piwik.php", config.piwik.url);
         // set constants
-        this.baseUrl.searchParams.set("rec", 1); // rec is required for tracking
+        this.baseUrl.searchParams.set("rec", "1"); // rec is required for tracking
         this.baseUrl.searchParams.set("idsite", config.piwik.siteId); // rec is required for tracking
-        this.baseUrl.searchParams.set("apiv", 1); // API version to use
-        this.baseUrl.searchParams.set("send_image", 0); // we want a 204, not a tiny GIF
+        this.baseUrl.searchParams.set("apiv", "1"); // API version to use
+        this.baseUrl.searchParams.set("send_image", "0"); // we want a 204, not a tiny GIF
         // set user parameters
         this.baseUrl.searchParams.set("_id", getUid()); // uuid
         this.baseUrl.searchParams.set("_idts", this.creationTs); // first ts
-        this.baseUrl.searchParams.set("_idvc", parseInt(this.visitCount, 10)+ 1); // visit count
+        this.baseUrl.searchParams.set("_idvc", this.visitCount); // visit count
         if (this.lastVisitTs) {
             this.baseUrl.searchParams.set("_viewts", this.lastVisitTs); // last visit ts
         }
 
         const platform = PlatformPeg.get();
-        this._setVisitVariable('App Platform', platform.getHumanReadableName());
+        this.setVisitVariable('App Platform', platform.getHumanReadableName());
         try {
-            this._setVisitVariable('App Version', await platform.getAppVersion());
+            this.setVisitVariable('App Version', await platform.getAppVersion());
         } catch (e) {
-            this._setVisitVariable('App Version', 'unknown');
+            this.setVisitVariable('App Version', 'unknown');
         }
 
-        this._setVisitVariable('Chosen Language', getCurrentLanguage());
+        this.setVisitVariable('Chosen Language', getCurrentLanguage());
 
         const hostname = window.location.hostname;
         if (hostname === 'riot.im') {
-            this._setVisitVariable('Instance', window.location.pathname);
+            this.setVisitVariable('Instance', window.location.pathname);
         } else if (hostname.endsWith('.element.io')) {
-            this._setVisitVariable('Instance', hostname.replace('.element.io', ''));
+            this.setVisitVariable('Instance', hostname.replace('.element.io', ''));
         }
 
         let installedPWA = "unknown";
         try {
             // Known to work at least for desktop Chrome
-            installedPWA = window.matchMedia('(display-mode: standalone)').matches;
+            installedPWA = String(window.matchMedia('(display-mode: standalone)').matches);
         } catch (e) { }
-        this._setVisitVariable('Installed PWA', installedPWA);
+        this.setVisitVariable('Installed PWA', installedPWA);
 
         let touchInput = "unknown";
         try {
             // MDN claims broad support across browsers
-            touchInput = window.matchMedia('(pointer: coarse)').matches;
+            touchInput = String(window.matchMedia('(pointer: coarse)').matches);
         } catch (e) { }
-        this._setVisitVariable('Touch Input', touchInput);
+        this.setVisitVariable('Touch Input', touchInput);
 
         // start heartbeat
-        this._heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
+        this.heartbeatIntervalID = window.setInterval(this.ping.bind(this), HEARTBEAT_INTERVAL);
     }
 
     /**
      * Disable Analytics, stop the heartbeat and clear identifiers from localStorage
      */
-    disable() {
+    public disable() {
         if (this.disabled) return;
         this.trackEvent('Analytics', 'opt-out');
-        window.clearInterval(this._heartbeatIntervalID);
+        window.clearInterval(this.heartbeatIntervalID);
         this.baseUrl = null;
         this.visitVariables = {};
         localStorage.removeItem(UID_KEY);
@@ -248,7 +270,7 @@ class Analytics {
         localStorage.removeItem(LAST_VISIT_TS_KEY);
     }
 
-    async _track(data) {
+    private async _track(data: IData) {
         if (this.disabled) return;
 
         const now = new Date();
@@ -264,13 +286,13 @@ class Analytics {
             s: now.getSeconds(),
         };
 
-        const url = new URL(this.baseUrl);
+        const url = new URL(this.baseUrl.toString()); // copy
         for (const key in params) {
             url.searchParams.set(key, params[key]);
         }
 
         try {
-            await window.fetch(url, {
+            await window.fetch(url.toString(), {
                 method: "GET",
                 mode: "no-cors",
                 cache: "no-cache",
@@ -281,14 +303,14 @@ class Analytics {
         }
     }
 
-    ping() {
+    public ping() {
         this._track({
-            ping: 1,
+            ping: "1",
         });
-        localStorage.setItem(LAST_VISIT_TS_KEY, new Date().getTime()); // update last visit ts
+        localStorage.setItem(LAST_VISIT_TS_KEY, String(new Date().getTime())); // update last visit ts
     }
 
-    trackPageChange(generationTimeMs) {
+    public trackPageChange(generationTimeMs?: number) {
         if (this.disabled) return;
         if (this.firstPage) {
             // De-duplicate first page
@@ -303,11 +325,11 @@ class Analytics {
         }
 
         this._track({
-            gt_ms: generationTimeMs,
+            gt_ms: String(generationTimeMs),
         });
     }
 
-    trackEvent(category, action, name, value) {
+    public trackEvent(category: string, action: string, name?: string, value?: string) {
         if (this.disabled) return;
         this._track({
             e_c: category,
@@ -317,12 +339,12 @@ class Analytics {
         });
     }
 
-    _setVisitVariable(key, value) {
+    private setVisitVariable(key: keyof typeof customVariables, value: string) {
         if (this.disabled) return;
         this.visitVariables[customVariables[key].id] = [key, value];
     }
 
-    setLoggedIn(isGuest, homeserverUrl, identityServerUrl) {
+    public setLoggedIn(isGuest: boolean, homeserverUrl: string) {
         if (this.disabled) return;
 
         const config = SdkConfig.get();
@@ -330,16 +352,16 @@ class Analytics {
 
         const whitelistedHSUrls = config.piwik.whitelistedHSUrls || [];
 
-        this._setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
-        this._setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
+        this.setVisitVariable('User Type', isGuest ? 'Guest' : 'Logged In');
+        this.setVisitVariable('Homeserver URL', whitelistRedact(whitelistedHSUrls, homeserverUrl));
     }
 
-    setBreadcrumbs(state) {
+    public setBreadcrumbs(state: boolean) {
         if (this.disabled) return;
-        this._setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
+        this.setVisitVariable('Breadcrumbs', state ? 'enabled' : 'disabled');
     }
 
-    showDetailsModal = () => {
+    public showDetailsModal = () => {
         let rows = [];
         if (!this.disabled) {
             rows = Object.values(this.visitVariables);
@@ -360,7 +382,7 @@ class Analytics {
                     'e.g. ',
                     {},
                     {
-                        CurrentPageURL: getRedactedUrl(),
+                        CurrentPageURL: getRedactedUrl,
                     },
                 ),
             },
@@ -401,7 +423,7 @@ class Analytics {
     };
 }
 
-if (!global.mxAnalytics) {
-    global.mxAnalytics = new Analytics();
+if (!window.mxAnalytics) {
+    window.mxAnalytics = new Analytics();
 }
-export default global.mxAnalytics;
+export default window.mxAnalytics;
diff --git a/src/Avatar.js b/src/Avatar.ts
similarity index 84%
rename from src/Avatar.js
rename to src/Avatar.ts
index d76ea6f2c4..60bdfdcf75 100644
--- a/src/Avatar.js
+++ b/src/Avatar.ts
@@ -14,14 +14,19 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-'use strict';
+import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
+import {RoomMember} from "matrix-js-sdk/src/models/room-member";
+import {User} from "matrix-js-sdk/src/models/user";
+import {Room} from "matrix-js-sdk/src/models/room";
+
 import {MatrixClientPeg} from './MatrixClientPeg';
 import DMRoomMap from './utils/DMRoomMap';
-import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo";
+
+export type ResizeMethod = "crop" | "scale";
 
 // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
-export function avatarUrlForMember(member, width, height, resizeMethod) {
-    let url;
+export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) {
+    let url: string;
     if (member && member.getAvatarUrl) {
         url = member.getAvatarUrl(
             MatrixClientPeg.get().getHomeserverUrl(),
@@ -41,7 +46,7 @@ export function avatarUrlForMember(member, width, height, resizeMethod) {
     return url;
 }
 
-export function avatarUrlForUser(user, width, height, resizeMethod) {
+export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) {
     const url = getHttpUriForMxc(
         MatrixClientPeg.get().getHomeserverUrl(), user.avatarUrl,
         Math.floor(width * window.devicePixelRatio),
@@ -54,14 +59,14 @@ export function avatarUrlForUser(user, width, height, resizeMethod) {
     return url;
 }
 
-function isValidHexColor(color) {
+function isValidHexColor(color: string): boolean {
     return typeof color === "string" &&
-        (color.length === 7 || color.lengh === 9) &&
+        (color.length === 7 || color.length === 9) &&
         color.charAt(0) === "#" &&
         !color.substr(1).split("").some(c => isNaN(parseInt(c, 16)));
 }
 
-function urlForColor(color) {
+function urlForColor(color: string): string {
     const size = 40;
     const canvas = document.createElement("canvas");
     canvas.width = size;
@@ -79,9 +84,10 @@ function urlForColor(color) {
 // XXX: Ideally we'd clear this cache when the theme changes
 // but since this function is at global scope, it's a bit
 // hard to install a listener here, even if there were a clear event to listen to
-const colorToDataURLCache = new Map();
+const colorToDataURLCache = new Map();
 
-export function defaultAvatarUrlForString(s) {
+export function defaultAvatarUrlForString(s: string): string {
+    if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
     const defaultColors = ['#0DBD8B', '#368bd6', '#ac3ba8'];
     let total = 0;
     for (let i = 0; i < s.length; ++i) {
@@ -112,7 +118,7 @@ export function defaultAvatarUrlForString(s) {
  * @param {string} name
  * @return {string} the first letter
  */
-export function getInitialLetter(name) {
+export function getInitialLetter(name: string): string {
     if (!name) {
         // XXX: We should find out what causes the name to sometimes be falsy.
         console.trace("`name` argument to `getInitialLetter` not supplied");
@@ -145,7 +151,7 @@ export function getInitialLetter(name) {
     return firstChar.toUpperCase();
 }
 
-export function avatarUrlForRoom(room, width, height, resizeMethod) {
+export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
     if (!room) return null; // null-guard
 
     const explicitRoomAvatar = room.getAvatarUrl(
diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts
index 4d06c5df73..d0d5e60ce8 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;
@@ -244,15 +251,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 +279,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 +323,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 +362,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 5b368016b6..f73424fd4d 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,13 +75,94 @@ 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, 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 { randomString } from "matrix-js-sdk/src/randomstring";
 
-// until we ts-ify the js-sdk voip code
-type Call = any;
+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',
+    Ringback = 'ringbackAudio',
+    CallEnd = 'callendAudio',
+    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
+// is concerned).
+export enum PlaceCallType {
+    Voice = 'voice',
+    Video = 'video',
+    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 audioPromises = 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) {
@@ -93,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.
@@ -106,22 +194,138 @@ 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);
     }
 
-    getCallForRoom(roomId: string): Call {
+    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 {
         return this.calls.get(roomId) || null;
     }
 
     getAnyActiveCall() {
         for (const call of this.calls.values()) {
-            if (call.state !== "ended") {
+            if (call.state !== CallState.Ended) {
                 return call;
             }
         }
         return null;
     }
 
-    play(audioId: string) {
+    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?
         const audio = document.getElementById(audioId) as HTMLMediaElement;
@@ -150,7 +354,7 @@ export default class CallHandler {
         }
     }
 
-    pause(audioId: string) {
+    pause(audioId: AudioID) {
         // TODO: Attach an invisible element for this instead
         // which listens?
         const audio = document.getElementById(audioId) as HTMLMediaElement;
@@ -164,9 +368,30 @@ export default class CallHandler {
         }
     }
 
-    private setCallListeners(call: Call) {
-        call.on("error", (err) => {
+    private matchesCallForThisRoom(call: MatrixCall) {
+        // 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 mappedRoomId = CallHandler.roomIdForCall(call);
+
+        const callForThisRoom = this.getCallForRoom(mappedRoomId);
+        return callForThisRoom && call.callId === callForThisRoom.callId;
+    }
+
+    private setCallListeners(call: MatrixCall) {
+        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
@@ -180,74 +405,160 @@ export default class CallHandler {
                 description: err.message,
             });
         });
-        call.on("hangup", () => {
-            this.removeCallForRoom(call.roomId);
+        call.on(CallEvent.Hangup, () => {
+            if (!this.matchesCallForThisRoom(call)) return;
+
+            Analytics.trackEvent('voip', 'callHangup');
+
+            this.removeCallForRoom(mappedRoomId);
         });
-        // map web rtc states to dummy UI state
-        // ringing|ringback|connected|ended|busy|stop_ringback|stop_ringing
-        call.on("state", (newState, oldState) => {
-            if (newState === "ringing") {
-                this.setCallState(call, call.roomId, "ringing");
-                this.pause("ringbackAudio");
-            } else if (newState === "invite_sent") {
-                this.setCallState(call, call.roomId, "ringback");
-                this.play("ringbackAudio");
-            } else if (newState === "ended" && oldState === "connected") {
-                this.removeCallForRoom(call.roomId);
-                this.pause("ringbackAudio");
-                this.play("callendAudio");
-            } else if (newState === "ended" && oldState === "invite_sent" &&
-                    (call.hangupParty === "remote" ||
-                    (call.hangupParty === "local" && call.hangupReason === "invite_timeout")
-                    )) {
-                this.setCallState(call, call.roomId, "busy");
-                this.pause("ringbackAudio");
-                this.play("busyAudio");
-                Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, {
-                    title: _t('Call Timeout'),
-                    description: _t('The remote side failed to pick up') + '.',
-                });
-            } else if (oldState === "invite_sent") {
-                this.setCallState(call, call.roomId, "stop_ringback");
-                this.pause("ringbackAudio");
-            } else if (oldState === "ringing") {
-                this.setCallState(call, call.roomId, "stop_ringing");
-                this.pause("ringbackAudio");
-            } else if (newState === "connected") {
-                this.setCallState(call, call.roomId, "connected");
-                this.pause("ringbackAudio");
+        call.on(CallEvent.State, (newState: CallState, oldState: CallState) => {
+            if (!this.matchesCallForThisRoom(call)) return;
+
+            this.setCallState(call, newState);
+
+            switch (oldState) {
+                case CallState.Ringing:
+                    this.pause(AudioID.Ring);
+                    break;
+                case CallState.InviteSent:
+                    this.pause(AudioID.Ringback);
+                    break;
             }
+
+            switch (newState) {
+                case CallState.Ringing:
+                    this.play(AudioID.Ring);
+                    break;
+                case CallState.InviteSent:
+                    this.play(AudioID.Ringback);
+                    break;
+                case CallState.Ended:
+                {
+                    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);
+                        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 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) => {
+            if (!this.matchesCallForThisRoom(call)) return;
+
+            console.log(`Call ID ${call.callId} is being replaced by call ID ${newCall.callId}`);
+
+            if (call.state === CallState.Ringing) {
+                this.pause(AudioID.Ring);
+            } else if (call.state === CallState.InviteSent) {
+                this.pause(AudioID.Ringback);
+            }
+
+            this.calls.set(mappedRoomId, newCall);
+            this.setCallListeners(newCall);
+            this.setCallState(newCall, newCall.state);
         });
     }
 
-    private setCallState(call: Call, roomId: string, status: string) {
-        console.log(
-            `Call state in ${roomId} changed to ${status} (${call ? call.call_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 (call) {
-            this.calls.set(roomId, call);
-        } else {
-            this.calls.delete(roomId);
+        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}, `,
+            );
+        }
+    }
 
-        if (status === "ringing") {
-            this.play("ringAudio");
-        } else if (call && call.call_state === "ringing") {
-            this.pause("ringAudio");
-        }
+    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 ${mappedRoomId} changed to ${status}`,
+        );
 
-        if (call) {
-            call.call_state = status;
-        }
         dis.dispatch({
             action: 'call_state',
-            room_id: roomId,
+            room_id: mappedRoomId,
             state: status,
         });
     }
 
     private removeCallForRoom(roomId: string) {
-        this.setCallState(null, roomId, null);
+        this.calls.delete(roomId);
     }
 
     private showICEFallbackPrompt() {
@@ -279,45 +590,94 @@ export default class CallHandler {
         }, null, true);
     }
 
-    private onAction = (payload: ActionPayload) => {
-        const placeCall = (newCall) => {
-            this.setCallListeners(newCall);
-            if (payload.type === 'voice') {
-                newCall.placeVoiceCall();
-            } else if (payload.type === 'video') {
-                newCall.placeVideoCall(
-                    payload.remote_element,
-                    payload.local_element,
-                );
-            } else if (payload.type === 'screensharing') {
-                const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
-                if (screenCapErrorString) {
-                    this.removeCallForRoom(newCall.roomId);
-                    console.log("Can't capture screen: " + screenCapErrorString);
-                    Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, {
-                        title: _t('Unable to capture screen'),
-                        description: screenCapErrorString,
-                    });
-                    return;
-                }
-                newCall.placeScreenSharingCall(
-                    payload.remote_element,
-                    payload.local_element,
-                );
-            } else {
-                console.error("Unknown conf call type: %s", payload.type);
-            }
+    private showMediaCaptureError(call: MatrixCall) {
+        let title;
+        let description;
+
+        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, + ) { + 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 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') { + call.placeVideoCall( + remoteElement, + localElement, + ); + } else if (type === PlaceCallType.ScreenSharing) { + const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); + if (screenCapErrorString) { + this.removeCallForRoom(roomId); + console.log("Can't capture screen: " + screenCapErrorString); + Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { + title: _t('Unable to capture screen'), + description: screenCapErrorString, + }); + return; + } + + call.placeScreenSharingCall( + remoteElement, + localElement, + async () : Promise => { + const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); + const [source] = await finished; + return source; + }); + } else { + console.error("Unknown conf call type: " + type); + } + } + + private onAction = (payload: ActionPayload) => { 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. @@ -329,9 +689,18 @@ 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; } @@ -342,9 +711,9 @@ export default class CallHandler { }); return; } else if (members.length === 2) { - console.info("Place %s call in %s", payload.type, payload.room_id); - const call = Matrix.createNewMatrixCall(MatrixClientPeg.get(), payload.room_id); - placeCall(call); + 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 dis.dispatch({ action: "place_conference_call", @@ -357,58 +726,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; + const call = payload.call as MatrixCall; + + 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); - this.setCallState(call, call.roomId, "ringing"); + + // 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(); - 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': - if (!this.calls.get(payload.room_id)) { + case 'answer': { + if (!this.calls.has(payload.room_id)) { return; // no call to answer } - this.calls.get(payload.room_id).answer(); - this.setCallState(this.calls.get(payload.room_id), payload.room_id, "connected"); + + 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', @@ -439,7 +862,7 @@ export default class CallHandler { confId = base32.stringify(Buffer.from(roomId), { pad: false }); } else { // Create a random human readable conference ID - confId = `JitsiConference${generateHumanReadableId()}`; + confId = `JitsiConference${randomString(32)}`; } let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); @@ -455,6 +878,7 @@ export default class CallHandler { isAudioOnly: type === 'voice', domain: jitsiDomain, auth: jitsiAuth, + roomName: room.name, }; const widgetId = ( diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index eb8fff0eb1..bec36d49f6 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from "react"; -import extend from './extend'; import dis from './dispatcher/dispatcher'; import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClient} from "matrix-js-sdk/src/client"; @@ -32,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; @@ -369,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() { @@ -480,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: { @@ -493,11 +497,11 @@ 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) => { - extend(content.info, imageInfo); + Object.assign(content.info, imageInfo); resolve(); }, (e) => { console.error(e); @@ -510,7 +514,7 @@ export default class ContentMessages { } else if (file.type.indexOf('video/') === 0) { content.msgtype = 'm.video'; infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => { - extend(content.info, videoInfo); + Object.assign(content.info, videoInfo); resolve(); }, (e) => { content.msgtype = 'm.file'; @@ -564,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/DateUtils.js b/src/DateUtils.ts similarity index 85% rename from src/DateUtils.js rename to src/DateUtils.ts index 108697238c..9b1edf0775 100644 --- a/src/DateUtils.js +++ b/src/DateUtils.ts @@ -17,7 +17,7 @@ limitations under the License. import { _t } from './languageHandler'; -function getDaysArray() { +function getDaysArray(): string[] { return [ _t('Sun'), _t('Mon'), @@ -29,7 +29,7 @@ function getDaysArray() { ]; } -function getMonthsArray() { +function getMonthsArray(): string[] { return [ _t('Jan'), _t('Feb'), @@ -46,11 +46,11 @@ function getMonthsArray() { ]; } -function pad(n) { +function pad(n: number): string { return (n < 10 ? '0' : '') + n; } -function twelveHourTime(date, showSeconds=false) { +function twelveHourTime(date: Date, showSeconds = false): string { let hours = date.getHours() % 12; const minutes = pad(date.getMinutes()); const ampm = date.getHours() >= 12 ? _t('PM') : _t('AM'); @@ -62,7 +62,7 @@ function twelveHourTime(date, showSeconds=false) { return `${hours}:${minutes}${ampm}`; } -export function formatDate(date, showTwelveHour=false) { +export function formatDate(date: Date, showTwelveHour = false): string { const now = new Date(); const days = getDaysArray(); const months = getMonthsArray(); @@ -86,7 +86,7 @@ export function formatDate(date, showTwelveHour=false) { return formatFullDate(date, showTwelveHour); } -export function formatFullDateNoTime(date) { +export function formatFullDateNoTime(date: Date): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s', { @@ -97,7 +97,7 @@ export function formatFullDateNoTime(date) { }); } -export function formatFullDate(date, showTwelveHour=false) { +export function formatFullDate(date: Date, showTwelveHour = false): string { const days = getDaysArray(); const months = getMonthsArray(); return _t('%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s', { @@ -109,14 +109,14 @@ export function formatFullDate(date, showTwelveHour=false) { }); } -export function formatFullTime(date, showTwelveHour=false) { +export function formatFullTime(date: Date, showTwelveHour = false): string { if (showTwelveHour) { return twelveHourTime(date, true); } return pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()); } -export function formatTime(date, showTwelveHour=false) { +export function formatTime(date: Date, showTwelveHour = false): string { if (showTwelveHour) { return twelveHourTime(date); } @@ -124,7 +124,7 @@ export function formatTime(date, showTwelveHour=false) { } const MILLIS_IN_DAY = 86400000; -export function wantsDateSeparator(prevEventDate, nextEventDate) { +export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { if (!nextEventDate || !prevEventDate) { return false; } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index f991d2df5d..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"; @@ -53,7 +57,7 @@ const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, 'i'); const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; -const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; +export const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; /* * Return true if the given string contains emoji @@ -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.js b/src/Lifecycle.ts similarity index 62% rename from src/Lifecycle.js rename to src/Lifecycle.ts index 3a48de5eef..7780d4c87a 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.ts @@ -17,9 +17,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +// @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 { 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 {MatrixClientPeg} from './MatrixClientPeg'; +import {IMatrixClientCreds, MatrixClientPeg} from './MatrixClientPeg'; +import SecurityCustomisations from "./customisations/Security"; import EventIndexPeg from './indexing/EventIndexPeg'; import createMatrixClient from './utils/createMatrixClient'; import Analytics from './Analytics'; @@ -41,50 +46,57 @@ 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"; +interface ILoadSessionOpts { + enableGuest?: boolean; + guestHsUrl?: string; + guestIsUrl?: string; + ignoreGuest?: boolean; + defaultDeviceDisplayName?: string; + fragmentQueryParams?: Record; +} + /** * Called at startup, to attempt to build a logged-in Matrix session. It tries * a number of things: * - * * 1. if we have a guest access token in the fragment query params, it uses * that. - * * 2. if an access token is stored in local storage (from a previous session), * it uses that. - * * 3. it attempts to auto-register as a guest user. * * If any of steps 1-4 are successful, it will call {_doSetLoggedIn}, which in * turn will raise on_logged_in and will_start_client events. * - * @param {object} opts - * - * @param {object} opts.fragmentQueryParams: string->string map of the + * @param {object} [opts] + * @param {object} [opts.fragmentQueryParams]: string->string map of the * query-parameters extracted from the #-fragment of the starting URI. - * - * @param {boolean} opts.enableGuest: set to true to enable guest access tokens - * and auto-guest registrations. - * - * @params {string} opts.guestHsUrl: homeserver URL. Only used if enableGuest is - * true; defines the HS to register against. - * - * @params {string} opts.guestIsUrl: homeserver URL. Only used if enableGuest is - * true; defines the IS to use. - * - * @params {bool} opts.ignoreGuest: If the stored session is a guest account, ignore - * it and don't load it. - * + * @param {boolean} [opts.enableGuest]: set to true to enable guest access + * tokens and auto-guest registrations. + * @param {string} [opts.guestHsUrl]: homeserver URL. Only used if enableGuest + * is true; defines the HS to register against. + * @param {string} [opts.guestIsUrl]: homeserver URL. Only used if enableGuest + * is true; defines the IS to use. + * @param {bool} [opts.ignoreGuest]: If the stored session is a guest account, + * ignore it and don't load it. + * @param {string} [opts.defaultDeviceDisplayName]: Default display name to use + * when registering as a guest. * @returns {Promise} a promise which resolves when the above process completes. * Resolves to `true` if we ended up starting a session, or `false` if we * failed. */ -export async function loadSession(opts) { +export async function loadSession(opts: ILoadSessionOpts = {}): Promise { try { let enableGuest = opts.enableGuest || false; const guestHsUrl = opts.guestHsUrl; @@ -97,12 +109,13 @@ export async function loadSession(opts) { enableGuest = false; } - if (enableGuest && + if ( + enableGuest && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token - ) { + ) { console.log("Using guest access credentials"); - return _doSetLoggedIn({ + return doSetLoggedIn({ userId: fragmentQueryParams.guest_user_id, accessToken: fragmentQueryParams.guest_access_token, homeserverUrl: guestHsUrl, @@ -110,7 +123,7 @@ export async function loadSession(opts) { guest: true, }, true).then(() => true); } - const success = await _restoreFromLocalStorage({ + const success = await restoreFromLocalStorage({ ignoreGuest: Boolean(opts.ignoreGuest), }); if (success) { @@ -118,7 +131,7 @@ export async function loadSession(opts) { } if (enableGuest) { - return _registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); + return registerAsGuest(guestHsUrl, guestIsUrl, defaultDeviceDisplayName); } // fall back to welcome screen @@ -129,7 +142,7 @@ export async function loadSession(opts) { // need to show the general failure dialog. Instead, just go back to welcome. return false; } - return _handleLoadSessionFailure(e); + return handleLoadSessionFailure(e); } } @@ -137,20 +150,13 @@ export async function loadSession(opts) { * 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() { - 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() { - 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]; } /** @@ -158,12 +164,17 @@ export function getStoredSessionIsGuest() { * 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 */ -export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { +export function attemptTokenLogin( + queryParams: Record, + defaultDeviceDisplayName?: string, + fragmentAfterLogin?: string, +): Promise { if (!queryParams.loginToken) { return Promise.resolve(false); } @@ -172,6 +183,12 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { 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); } @@ -184,19 +201,41 @@ export function attemptTokenLogin(queryParams, defaultDeviceDisplayName) { }, ).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; }); } -export function handleInvalidStoreError(e) { - if (e.reason === Matrix.InvalidStoreError.TOGGLED_LAZY_LOADING) { +export function handleInvalidStoreError(e: InvalidStoreError): Promise { + if (e.reason === InvalidStoreError.TOGGLED_LAZY_LOADING) { return Promise.resolve().then(() => { const lazyLoadEnabled = e.value; if (lazyLoadEnabled) { @@ -229,7 +268,11 @@ export function handleInvalidStoreError(e) { } } -function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { +function registerAsGuest( + hsUrl: string, + isUrl: string, + defaultDeviceDisplayName: string, +): Promise { console.log(`Doing guest login on ${hsUrl}`); // create a temporary MatrixClient to do the login @@ -243,7 +286,7 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { }, }).then((creds) => { console.log(`Registered as guest: ${creds.user_id}`); - return _doSetLoggedIn({ + return doSetLoggedIn({ userId: creds.user_id, deviceId: creds.device_id, accessToken: creds.access_token, @@ -257,15 +300,42 @@ function _registerAsGuest(hsUrl, isUrl, defaultDeviceDisplayName) { }); } +export interface IStoredSession { + hsUrl: string; + isUrl: 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() { +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"); @@ -277,7 +347,43 @@ export function getLocalStorageSessionVars() { 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 @@ -290,14 +396,18 @@ export function getLocalStorageSessionVars() { // 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) { - const ignoreGuest = opts.ignoreGuest; +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) { @@ -305,22 +415,32 @@ async function _restoreFromLocalStorage(opts) { 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"); } + const freshLogin = sessionStorage.getItem("mx_fresh_login") === "true"; + sessionStorage.removeItem("mx_fresh_login"); + console.log(`Restoring session for ${userId}`); - await _doSetLoggedIn({ + await doSetLoggedIn({ userId: userId, deviceId: deviceId, - accessToken: accessToken, + accessToken: decryptedAccessToken as string, homeserverUrl: hsUrl, identityServerUrl: isUrl, guest: isGuest, pickleKey: pickleKey, + freshLogin: freshLogin, }, false); return true; } else { @@ -329,7 +449,7 @@ async function _restoreFromLocalStorage(opts) { } } -async function _handleLoadSessionFailure(e) { +async function handleLoadSessionFailure(e: Error): Promise { console.error("Unable to load session", e); const SessionRestoreErrorDialog = @@ -342,7 +462,7 @@ async function _handleLoadSessionFailure(e) { const [success] = await modal.finished; if (success) { // user clicked continue. - await _clearStorage(); + await clearStorage(); return false; } @@ -363,11 +483,12 @@ async function _handleLoadSessionFailure(e) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -export async function setLoggedIn(credentials) { +export async function setLoggedIn(credentials: IMatrixClientCreds): Promise { + credentials.freshLogin = true; stopMatrixClient(); const pickleKey = credentials.userId && credentials.deviceId - ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) - : null; + ? await PlatformPeg.get().createPickleKey(credentials.userId, credentials.deviceId) + : null; if (pickleKey) { console.log("Created pickle key"); @@ -375,7 +496,7 @@ export async function setLoggedIn(credentials) { console.log("Pickle key not created"); } - return _doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true); + return doSetLoggedIn(Object.assign({}, credentials, {pickleKey}), true); } /** @@ -393,7 +514,7 @@ export async function setLoggedIn(credentials) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -export function hydrateSession(credentials) { +export function hydrateSession(credentials: IMatrixClientCreds): Promise { const oldUserId = MatrixClientPeg.get().getUserId(); const oldDeviceId = MatrixClientPeg.get().getDeviceId(); @@ -406,7 +527,7 @@ export function hydrateSession(credentials) { console.warn("Clearing all data: Old session belongs to a different user/session"); } - return _doSetLoggedIn(credentials, overwrite); + return doSetLoggedIn(credentials, overwrite); } /** @@ -418,7 +539,10 @@ export function hydrateSession(credentials) { * * @returns {Promise} promise which resolves to the new MatrixClient once it has been started */ -async function _doSetLoggedIn(credentials, clearStorage) { +async function doSetLoggedIn( + credentials: IMatrixClientCreds, + clearStorageEnabled: boolean, +): Promise { credentials.guest = Boolean(credentials.guest); const softLogout = isSoftLogout(); @@ -429,6 +553,7 @@ async function _doSetLoggedIn(credentials, clearStorage) { " guest: " + credentials.guest + " hs: " + credentials.homeserverUrl + " softLogout: " + softLogout, + " freshLogin: " + credentials.freshLogin, ); // This is dispatched to indicate that the user is still in the process of logging in @@ -440,8 +565,8 @@ async function _doSetLoggedIn(credentials, clearStorage) { // (dis.dispatch uses `setTimeout`, which does not guarantee ordering.) dis.dispatch({action: 'on_logging_in'}, true); - if (clearStorage) { - await _clearStorage(); + if (clearStorageEnabled) { + await clearStorage(); } const results = await StorageManager.checkConsistency(); @@ -449,32 +574,31 @@ async function _doSetLoggedIn(credentials, clearStorage) { // 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); + MatrixClientPeg.replaceUsingCreds(credentials); + const client = MatrixClientPeg.get(); + + if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { + // If we just logged in, try to rehydrate a device instead of using a + // new device. If it succeeds, we'll get a new device ID, so make sure + // we persist that ID to localStorage + const newDeviceId = await client.rehydrateDevice(); + if (newDeviceId) { + credentials.deviceId = newDeviceId; + } + + delete credentials.freshLogin; + } + if (localStorage) { try { - _persistCredentialsToLocalStorage(credentials); - - // The user registered as a PWLU (PassWord-Less User), the generated password - // is cached here such that the user can change it at a later time. - if (credentials.password) { - // Update SessionStore - dis.dispatch({ - action: 'cached_password', - cachedPassword: credentials.password, - }); - } + await persistCredentials(credentials); + // make sure we don't think that it's a fresh login any more + sessionStorage.removeItem("mx_fresh_login"); } catch (e) { console.warn("Error using local storage: can't persist session!", e); } @@ -482,15 +606,13 @@ async function _doSetLoggedIn(credentials, clearStorage) { console.warn("No local storage available: can't persist session!"); } - MatrixClientPeg.replaceUsingCreds(credentials); - dis.dispatch({ action: 'on_logged_in' }); await startMatrixClient(/*startSyncing=*/!softLogout); - return MatrixClientPeg.get(); + return client; } -function _showStorageEvictedDialog() { +function showStorageEvictedDialog(): Promise { const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { @@ -503,18 +625,55 @@ function _showStorageEvictedDialog() { // `instanceof`. Babel 7 supports this natively in their class handling. class AbortLoginAndRebuildStorage extends Error { } -function _persistCredentialsToLocalStorage(credentials) { +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)); - if (credentials.pickleKey) { - localStorage.setItem("mx_has_pickle_key", true); + // 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."); } @@ -529,6 +688,8 @@ function _persistCredentialsToLocalStorage(credentials) { localStorage.setItem("mx_device_id", credentials.deviceId); } + SecurityCustomisations.persistCredentials?.(credentials); + console.log(`Session persisted for ${credentials.userId}`); } @@ -537,14 +698,18 @@ let _isLoggingOut = false; /** * Logs the current session out and transitions to the logged-out state */ -export function logout() { +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; } @@ -566,7 +731,7 @@ export function logout() { ); } -export function softLogout() { +export function softLogout(): void { if (!MatrixClientPeg.get()) return; // Track that we've detected and trapped a soft logout. This helps prevent other @@ -587,11 +752,11 @@ export function softLogout() { // DO NOT CALL LOGOUT. A soft logout preserves data, logout does not. } -export function isSoftLogout() { +export function isSoftLogout(): boolean { return localStorage.getItem("mx_soft_logout") === "true"; } -export function isLoggingOut() { +export function isLoggingOut(): boolean { return _isLoggingOut; } @@ -601,7 +766,7 @@ export function isLoggingOut() { * @param {boolean} startSyncing True (default) to actually start * syncing the client. */ -async function startMatrixClient(startSyncing=true) { +async function startMatrixClient(startSyncing = true): Promise { console.log(`Lifecycle: Starting MatrixClient`); // dispatch this before starting the matrix client: it's used @@ -619,6 +784,7 @@ async function startMatrixClient(startSyncing=true) { 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 @@ -660,21 +826,22 @@ async function startMatrixClient(startSyncing=true) { * Stops a running client and all related services, and clears persistent * storage. Used after a session has been logged out. */ -export async function onLoggedOut() { +export async function onLoggedOut(): Promise { _isLoggingOut = false; // Ensure that we dispatch a view change **before** stopping the client so // so that React components unmount first. This avoids React soft crashes // that can occur when components try to use a null client. dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); - await _clearStorage({deleteEverything: true}); + await clearStorage({deleteEverything: true}); + LifecycleCustomisations.onLoggedOutAndStorageCleared?.(); } /** * @param {object} opts Options for how to clear storage. * @returns {Promise} promise which resolves once the stores have been cleared */ -async function _clearStorage(opts: {deleteEverything: boolean}) { +async function clearStorage(opts?: { deleteEverything?: boolean }): Promise { Analytics.disable(); if (window.localStorage) { @@ -683,6 +850,10 @@ async function _clearStorage(opts: {deleteEverything: boolean}) { window.localStorage.clear(); + try { + await StorageManager.idbDelete("account", "mx_access_token"); + } catch (e) {} + // now restore those invites if (!opts?.deleteEverything) { pendingInvites.forEach(i => { @@ -712,8 +883,9 @@ async function _clearStorage(opts: {deleteEverything: boolean}) { * @param {boolean} unsetClient True (default) to abandon the client * on MatrixClientPeg after stopping. */ -export function stopMatrixClient(unsetClient=true) { +export function stopMatrixClient(unsetClient = true): void { Notifier.stop(); + CallHandler.sharedInstance().stop(); UserActivity.sharedInstance().stop(); TypingStore.sharedInstance().reset(); Presence.stop(); diff --git a/src/Login.js b/src/Login.ts similarity index 52% rename from src/Login.js rename to src/Login.ts index 04805b4af9..aecc0493c7 100644 --- a/src/Login.js +++ b/src/Login.ts @@ -18,35 +18,94 @@ See the License for the specific language governing permissions and limitations under the License. */ +// @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 { MatrixClient } from "matrix-js-sdk/src/client"; +import { IMatrixClientCreds } from "./MatrixClientPeg"; +import SecurityCustomisations from "./customisations/Security"; + +interface ILoginOptions { + defaultDeviceDisplayName?: string; +} + +// TODO: Move this to JS SDK +interface IPasswordFlow { + type: "m.login.password"; +} + +export enum IdentityProviderBrand { + Gitlab = "org.matrix.gitlab", + Github = "org.matrix.github", + Apple = "org.matrix.apple", + Google = "org.matrix.google", + Facebook = "org.matrix.facebook", + Twitter = "org.matrix.twitter", +} + +export interface IIdentityProvider { + id: string; + name: string; + icon?: string; + brand?: IdentityProviderBrand | string; +} + +export interface ISSOFlow { + type: "m.login.sso" | "m.login.cas"; + "org.matrix.msc2858.identity_providers": IIdentityProvider[]; // Unstable prefix for MSC2858 +} + +export type LoginFlow = ISSOFlow | IPasswordFlow; + +// TODO: Move this to JS SDK +/* eslint-disable camelcase */ +interface ILoginParams { + identifier?: string; + password?: string; + token?: string; + device_id?: string; + initial_device_display_name?: string; +} +/* eslint-enable camelcase */ export default class Login { - constructor(hsUrl, isUrl, fallbackHsUrl, opts) { - this._hsUrl = hsUrl; - this._isUrl = isUrl; - this._fallbackHsUrl = fallbackHsUrl; - this._currentFlowIndex = 0; - this._flows = []; - this._defaultDeviceDisplayName = opts.defaultDeviceDisplayName; - this._tempClient = null; // memoize + private hsUrl: string; + private isUrl: string; + private fallbackHsUrl: string; + // TODO: Flows need a type in JS SDK + private flows: Array; + private defaultDeviceDisplayName: string; + private tempClient: MatrixClient; + + constructor( + hsUrl: string, + isUrl: string, + fallbackHsUrl?: string, + opts?: ILoginOptions, + ) { + this.hsUrl = hsUrl; + this.isUrl = isUrl; + this.fallbackHsUrl = fallbackHsUrl; + this.flows = []; + this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName; + this.tempClient = null; // memoize } - getHomeserverUrl() { - return this._hsUrl; + public getHomeserverUrl(): string { + return this.hsUrl; } - getIdentityServerUrl() { - return this._isUrl; + public getIdentityServerUrl(): string { + return this.isUrl; } - setHomeserverUrl(hsUrl) { - this._tempClient = null; // clear memoization - this._hsUrl = hsUrl; + public setHomeserverUrl(hsUrl: string): void { + this.tempClient = null; // clear memoization + this.hsUrl = hsUrl; } - setIdentityServerUrl(isUrl) { - this._tempClient = null; // clear memoization - this._isUrl = isUrl; + public setIdentityServerUrl(isUrl: string): void { + this.tempClient = null; // clear memoization + this.isUrl = isUrl; } /** @@ -54,40 +113,27 @@ export default class Login { * requests. * @returns {MatrixClient} */ - createTemporaryClient() { - if (this._tempClient) return this._tempClient; // use memoization - return this._tempClient = Matrix.createClient({ - baseUrl: this._hsUrl, - idBaseUrl: this._isUrl, + public createTemporaryClient(): MatrixClient { + if (this.tempClient) return this.tempClient; // use memoization + return this.tempClient = Matrix.createClient({ + baseUrl: this.hsUrl, + idBaseUrl: this.isUrl, }); } - getFlows() { - const self = this; + public async getFlows(): Promise> { const client = this.createTemporaryClient(); - return client.loginFlows().then(function(result) { - self._flows = result.flows; - self._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 self._flows; - }); + const { flows } = await client.loginFlows(); + this.flows = flows; + return this.flows; } - chooseFlow(flowIndex) { - this._currentFlowIndex = flowIndex; - } - - getCurrentFlowStep() { - // 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; - } - - loginViaPassword(username, phoneCountry, phoneNumber, pass) { - const self = this; - + public loginViaPassword( + username: string, + phoneCountry: string, + phoneNumber: string, + password: string, + ): Promise { const isEmail = username.indexOf("@") > 0; let identifier; @@ -113,14 +159,14 @@ export default class Login { } const loginParams = { - password: pass, - identifier: identifier, - initial_device_display_name: this._defaultDeviceDisplayName, + password, + identifier, + initial_device_display_name: this.defaultDeviceDisplayName, }; const tryFallbackHs = (originalError) => { return sendLoginRequest( - self._fallbackHsUrl, this._isUrl, 'm.login.password', loginParams, + this.fallbackHsUrl, this.isUrl, 'm.login.password', loginParams, ).catch((fallbackError) => { console.log("fallback HS login failed", fallbackError); // throw the original error @@ -130,11 +176,11 @@ export default class Login { let originalLoginError = null; return sendLoginRequest( - self._hsUrl, self._isUrl, 'm.login.password', loginParams, + this.hsUrl, this.isUrl, 'm.login.password', loginParams, ).catch((error) => { originalLoginError = error; if (error.httpStatus === 403) { - if (self._fallbackHsUrl) { + if (this.fallbackHsUrl) { return tryFallbackHs(originalLoginError); } } @@ -154,11 +200,16 @@ export default class Login { * @param {string} hsUrl the base url of the Homeserver used to log in. * @param {string} isUrl the base url of the default identity server * @param {string} loginType the type of login to do - * @param {object} loginParams the parameters for the login + * @param {ILoginParams} loginParams the parameters for the login * * @returns {MatrixClientCreds} */ -export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { +export async function sendLoginRequest( + hsUrl: string, + isUrl: string, + loginType: string, + loginParams: ILoginParams, +): Promise { const client = Matrix.createClient({ baseUrl: hsUrl, idBaseUrl: isUrl, @@ -179,11 +230,15 @@ export async function sendLoginRequest(hsUrl, isUrl, loginType, loginParams) { } } - return { + const creds: IMatrixClientCreds = { homeserverUrl: hsUrl, identityServerUrl: isUrl, userId: data.user_id, deviceId: data.device_id, accessToken: data.access_token, }; + + SecurityCustomisations.examineLoginResponse?.(data, creds); + + return creds; } 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 9589130e7f..98ca446532 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -17,6 +17,7 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { ICreateClientOpts } from 'matrix-js-sdk/src/matrix'; import {MatrixClient} from 'matrix-js-sdk/src/client'; import {MemoryStore} from 'matrix-js-sdk/src/store/memory'; import * as utils from 'matrix-js-sdk/src/utils'; @@ -31,17 +32,19 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; -import { crossSigningCallbacks } from './SecurityManager'; +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; identityServerUrl: string; userId: string; - deviceId: string; + deviceId?: string; accessToken: string; - guest: boolean; + guest?: boolean; pickleKey?: string; + freshLogin?: boolean; } // TODO: Move this to the js-sdk @@ -98,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 @@ -148,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 { @@ -157,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); @@ -192,6 +213,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { this.matrixClient.setCryptoTrustCrossSignedDevices( !SettingsStore.getValue('e2ee.manuallyVerifyAllSessions'), ); + await tryToUnlockSecretStorageWithDehydrationKey(this.matrixClient); StorageManager.setCryptoInitialised(true); } } catch (e) { @@ -247,8 +269,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } private createClient(creds: IMatrixClientCreds): void { - // TODO: Make these opts typesafe with the js-sdk - const opts = { + const opts: ICreateClientOpts = { baseUrl: creds.homeserverUrl, idBaseUrl: creds.identityServerUrl, accessToken: creds.accessToken, @@ -258,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, @@ -271,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 0a36813961..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; } @@ -132,7 +132,7 @@ export class ModalManager { public createTrackedDialogAsync( analyticsAction: string, analyticsInfo: string, - ...rest: Parameters + ...rest: Parameters ) { Analytics.trackEvent('Modal', analyticsAction, analyticsInfo); return this.createDialogAsync(...rest); @@ -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 2643de1abc..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: @@ -218,7 +220,7 @@ export const Notifier = { // calculated value. It is determined based upon whether or not the master rule is enabled // and other flags. Setting it here would cause a circular reference. - Analytics.trackEvent('Notifier', 'Set Enabled', enable); + Analytics.trackEvent('Notifier', 'Set Enabled', String(enable)); // make sure that we persist the current setting audio_enabled setting // before changing anything @@ -287,7 +289,7 @@ export const Notifier = { setPromptHidden: function(hidden: boolean, persistent = true) { this.toolbarHidden = hidden; - Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); + Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', String(hidden)); hideNotificationsToast(); @@ -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/Presence.js b/src/Presence.ts similarity index 65% rename from src/Presence.js rename to src/Presence.ts index 42bca35f96..660bb0ac94 100644 --- a/src/Presence.js +++ b/src/Presence.ts @@ -19,30 +19,34 @@ limitations under the License. import {MatrixClientPeg} from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; import Timer from './utils/Timer'; +import {ActionPayload} from "./dispatcher/payloads"; - // Time in ms after that a user is considered as unavailable/away +// Time in ms after that a user is considered as unavailable/away const UNAVAILABLE_TIME_MS = 3 * 60 * 1000; // 3 mins -const PRESENCE_STATES = ["online", "offline", "unavailable"]; + +enum State { + Online = "online", + Offline = "offline", + Unavailable = "unavailable", +} class Presence { - constructor() { - this._activitySignal = null; - this._unavailableTimer = null; - this._onAction = this._onAction.bind(this); - this._dispatcherRef = null; - } + private unavailableTimer: Timer = null; + private dispatcherRef: string = null; + private state: State = null; + /** * Start listening the user activity to evaluate his presence state. * Any state change will be sent to the homeserver. */ - async start() { - this._unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); + public async start() { + this.unavailableTimer = new Timer(UNAVAILABLE_TIME_MS); // the user_activity_start action starts the timer - this._dispatcherRef = dis.register(this._onAction); - while (this._unavailableTimer) { + this.dispatcherRef = dis.register(this.onAction); + while (this.unavailableTimer) { try { - await this._unavailableTimer.finished(); - this.setState("unavailable"); + await this.unavailableTimer.finished(); + this.setState(State.Unavailable); } catch (e) { /* aborted, stop got called */ } } } @@ -50,14 +54,14 @@ class Presence { /** * Stop tracking user activity */ - stop() { - if (this._dispatcherRef) { - dis.unregister(this._dispatcherRef); - this._dispatcherRef = null; + public stop() { + if (this.dispatcherRef) { + dis.unregister(this.dispatcherRef); + this.dispatcherRef = null; } - if (this._unavailableTimer) { - this._unavailableTimer.abort(); - this._unavailableTimer = null; + if (this.unavailableTimer) { + this.unavailableTimer.abort(); + this.unavailableTimer = null; } } @@ -65,14 +69,14 @@ class Presence { * Get the current presence state. * @returns {string} the presence state (see PRESENCE enum) */ - getState() { + public getState() { return this.state; } - _onAction(payload) { + private onAction = (payload: ActionPayload) => { if (payload.action === 'user_activity') { - this.setState("online"); - this._unavailableTimer.restart(); + this.setState(State.Online); + this.unavailableTimer.restart(); } } @@ -81,13 +85,11 @@ class Presence { * If the state has changed, the homeserver will be notified. * @param {string} newState the new presence state (see PRESENCE enum) */ - async setState(newState) { + private async setState(newState: State) { if (newState === this.state) { return; } - if (PRESENCE_STATES.indexOf(newState) === -1) { - throw new Error("Bad presence state: " + newState); - } + const oldState = this.state; this.state = newState; diff --git a/src/Registration.js b/src/Registration.js index 9c0264c067..0df2ec3eb3 100644 --- a/src/Registration.js +++ b/src/Registration.js @@ -24,7 +24,6 @@ import dis from './dispatcher/dispatcher'; import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; -// import {MatrixClientPeg} from './MatrixClientPeg'; // Regex for what a "safe" or "Matrix-looking" localpart would be. // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514 @@ -44,70 +43,27 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/; */ export async function startAnyRegistrationFlow(options) { if (options === undefined) options = {}; - // look for an ILAG compatible flow. We define this as one - // which has only dummy or recaptcha flows. In practice it - // would support any stage InteractiveAuth supports, just not - // ones like email & msisdn which require the user to supply - // the relevant details in advance. We err on the side of - // caution though. - - // XXX: ILAG is disabled for now, - // see https://github.com/vector-im/element-web/issues/8222 - - // const flows = await _getRegistrationFlows(); - // const hasIlagFlow = flows.some((flow) => { - // return flow.stages.every((stage) => { - // return ['m.login.dummy', 'm.login.recaptcha', 'm.login.terms'].includes(stage); - // }); - // }); - - // if (hasIlagFlow) { - // dis.dispatch({ - // action: 'view_set_mxid', - // go_home_on_cancel: options.go_home_on_cancel, - // }); - //} else { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { - hasCancelButton: true, - quitOnly: true, - title: _t("Sign In or Create Account"), - description: _t("Use your account or create a new one to continue."), - button: _t("Create Account"), - extraButtons: [ - , - ], - onFinished: (proceed) => { - if (proceed) { - dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); - } else if (options.go_home_on_cancel) { - dis.dispatch({action: 'view_home_page'}); - } else if (options.go_welcome_on_cancel) { - dis.dispatch({action: 'view_welcome_page'}); - } - }, - }); - //} + const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); + const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { + hasCancelButton: true, + quitOnly: true, + title: _t("Sign In or Create Account"), + description: _t("Use your account or create a new one to continue."), + button: _t("Create Account"), + extraButtons: [ + , + ], + onFinished: (proceed) => { + if (proceed) { + dis.dispatch({action: 'start_registration', screenAfterLogin: options.screen_after}); + } else if (options.go_home_on_cancel) { + dis.dispatch({action: 'view_home_page'}); + } else if (options.go_welcome_on_cancel) { + dis.dispatch({action: 'view_welcome_page'}); + } + }, + }); } - -// async function _getRegistrationFlows() { -// try { -// await MatrixClientPeg.get().register( -// null, -// null, -// undefined, -// {}, -// {}, -// ); -// console.log("Register request succeeded when it should have returned 401!"); -// } catch (e) { -// if (e.httpStatus === 401) { -// return e.data.flows; -// } -// throw e; -// } -// throw new Error("Register request succeeded when it should have returned 401!"); -// } diff --git a/src/Roles.js b/src/Roles.ts similarity index 87% rename from src/Roles.js rename to src/Roles.ts index 7cc3c880d7..b4be97fdce 100644 --- a/src/Roles.js +++ b/src/Roles.ts @@ -13,9 +13,10 @@ 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 { _t } from './languageHandler'; -export function levelRoleMap(usersDefault) { +export function levelRoleMap(usersDefault: number) { return { undefined: _t('Default'), 0: _t('Restricted'), @@ -25,7 +26,7 @@ export function levelRoleMap(usersDefault) { }; } -export function textualPowerLevel(level, usersDefault) { +export function textualPowerLevel(level: number, usersDefault: number): string { const LEVEL_ROLE_MAP = levelRoleMap(usersDefault); if (LEVEL_ROLE_MAP[level]) { return LEVEL_ROLE_MAP[level]; diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 7eb7f5dbb2..06d3fb04e8 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -40,11 +40,11 @@ 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, ); } diff --git a/src/RoomListSorter.js b/src/RoomListSorter.js deleted file mode 100644 index 0ff37a6af2..0000000000 --- a/src/RoomListSorter.js +++ /dev/null @@ -1,31 +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. -*/ - -'use strict'; - -function tsOfNewestEvent(room) { - if (room.timeline.length) { - return room.timeline[room.timeline.length - 1].getTs(); - } else { - return Number.MAX_SAFE_INTEGER; - } -} - -export function mostRecentActivityFirst(roomList) { - return roomList.sort(function(a, b) { - return tsOfNewestEvent(b) - tsOfNewestEvent(a); - }); -} 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.js b/src/SecurityManager.ts similarity index 57% rename from src/SecurityManager.js rename to src/SecurityManager.ts index f6b9c993d0..220320470a 100644 --- a/src/SecurityManager.js +++ b/src/SecurityManager.ts @@ -14,26 +14,38 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; +import { MatrixClient } from 'matrix-js-sdk/src/client'; import Modal from './Modal'; import * as sdk from './index'; import {MatrixClientPeg} from './MatrixClientPeg'; import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; -import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; +import { encodeBase64 } from "matrix-js-sdk/src/crypto/olmlib"; import { isSecureBackupRequired } from './utils/WellKnownUtils'; import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; +import SettingsStore from "./settings/SettingsStore"; +import SecurityCustomisations from "./customisations/Security"; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times // during the same single operation. Use `accessSecretStorage` below to scope a // single secret storage operation, as it will clear the cached keys once the // operation ends. -let secretStorageKeys = {}; +let secretStorageKeys: Record = {}; +let secretStorageKeyInfo: Record = {}; let secretStorageBeingAccessed = false; -function isCachingAllowed() { +let nonInteractive = false; + +let dehydrationCache: { + key?: Uint8Array, + keyInfo?: ISecretStorageKeyInfo, +} = {}; + +function isCachingAllowed(): boolean { return secretStorageBeingAccessed; } @@ -44,7 +56,7 @@ function isCachingAllowed() { * * @returns {bool} */ -export function isSecretStorageBeingAccessed() { +export function isSecretStorageBeingAccessed(): boolean { return secretStorageBeingAccessed; } @@ -54,7 +66,7 @@ export class AccessCancelledError extends Error { } } -async function confirmToDismiss() { +async function confirmToDismiss(): Promise { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const [sure] = await Modal.createDialog(QuestionDialog, { title: _t("Cancel entering passphrase?"), @@ -66,7 +78,26 @@ async function confirmToDismiss() { return !sure; } -async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { +function makeInputToKey( + keyInfo: ISecretStorageKeyInfo, +): (keyParams: { passphrase: string, recoveryKey: string }) => Promise { + return async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + keyInfo.passphrase.salt, + keyInfo.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; +} + +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"); @@ -78,17 +109,25 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { return [keyId, secretStorageKeys[keyId]]; } - const inputToKey = async ({ passphrase, recoveryKey }) => { - if (passphrase) { - return deriveKey( - passphrase, - keyInfo.passphrase.salt, - keyInfo.passphrase.iterations, - ); - } else { - return decodeRecoveryKey(recoveryKey); + if (dehydrationCache.key) { + if (await MatrixClientPeg.get().checkSecretStorageKey(dehydrationCache.key, keyInfo)) { + cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key); + return [keyId, dehydrationCache.key]; } - }; + } + + const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Using key from security customisations (secret storage)") + cacheSecretStorageKey(keyId, keyInfo, keyFromCustomisations); + return [keyId, keyFromCustomisations]; + } + + if (nonInteractive) { + throw new Error("Could not unlock non-interactively"); + } + + const inputToKey = makeInputToKey(keyInfo); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", AccessSecretStorageDialog, /* props= */ @@ -118,24 +157,79 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); // Save to cache to avoid future prompts in the current session - cacheSecretStorageKey(keyId, key); + cacheSecretStorageKey(keyId, keyInfo, key); return [keyId, key]; } -function cacheSecretStorageKey(keyId, key) { +export async function getDehydrationKey( + keyInfo: ISecretStorageKeyInfo, + checkFunc: (Uint8Array) => void, +): Promise { + const keyFromCustomisations = SecurityCustomisations.getSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Using key from security customisations (dehydration)") + return keyFromCustomisations; + } + + const inputToKey = makeInputToKey(keyInfo); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + /* props= */ + { + keyInfo, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + try { + checkFunc(key); + return true; + } catch (e) { + return false; + } + }, + }, + /* className= */ null, + /* isPriorityModal= */ false, + /* isStaticModal= */ false, + /* options= */ { + onBeforeClose: async (reason) => { + if (reason === "backgroundClick") { + return confirmToDismiss(); + } + return true; + }, + }, + ); + const [input] = await finished; + if (!input) { + throw new AccessCancelledError(); + } + const key = await inputToKey(input); + + // need to copy the key because rehydration (unpickling) will clobber it + dehydrationCache = {key: new Uint8Array(key), keyInfo}; + + return key; +} + +function cacheSecretStorageKey( + keyId: string, + keyInfo: ISecretStorageKeyInfo, + key: Uint8Array, +): void { if (isCachingAllowed()) { secretStorageKeys[keyId] = key; + secretStorageKeyInfo[keyId] = keyInfo; } } -const onSecretRequested = async function({ - user_id: userId, - device_id: deviceId, - request_id: requestId, - name, - device_trust: deviceTrust, -}) { +async function onSecretRequested( + userId: string, + deviceId: string, + requestId: string, + name: string, + deviceTrust: IDeviceTrustLevel, +): Promise { console.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust); const client = MatrixClientPeg.get(); if (userId !== client.getUserId()) { @@ -170,15 +264,16 @@ const onSecretRequested = async function({ return key && encodeBase64(key); } console.warn("onSecretRequested didn't recognise the secret named ", name); -}; +} -export const crossSigningCallbacks = { +export const crossSigningCallbacks: ICryptoCallbacks = { getSecretStorageKey, cacheSecretStorageKey, onSecretRequested, + getDehydrationKey, }; -export async function promptForBackupPassphrase() { +export async function promptForBackupPassphrase(): Promise { let key; const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { @@ -228,7 +323,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f /* priority = */ false, /* static = */ true, /* options = */ { - onBeforeClose(reason) { + onBeforeClose: async (reason) => { // If Secure Backup is required, you cannot leave the modal. if (reason === "backgroundClick") { return !isSecureBackupRequired(); @@ -262,16 +357,86 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f await cli.bootstrapSecretStorage({ getKeyBackupPassphrase: promptForBackupPassphrase, }); + + const keyId = Object.keys(secretStorageKeys)[0]; + if (keyId && SettingsStore.getValue("feature_dehydration")) { + let dehydrationKeyInfo = {}; + if (secretStorageKeyInfo[keyId] && secretStorageKeyInfo[keyId].passphrase) { + dehydrationKeyInfo = { passphrase: secretStorageKeyInfo[keyId].passphrase }; + } + console.log("Setting dehydration key"); + await cli.setDehydrationKey(secretStorageKeys[keyId], dehydrationKeyInfo, "Backup device"); + } else if (!keyId) { + console.warn("Not setting dehydration key: no SSSS key found"); + } else { + console.log("Not setting dehydration key: feature disabled"); + } } // `return await` needed here to ensure `finally` block runs after the // inner operation completes. return await func(); + } catch (e) { + SecurityCustomisations.catchAccessSecretStorageError?.(e); + console.error(e); } finally { // Clear secret storage key cache now that work is complete secretStorageBeingAccessed = false; if (!isCachingAllowed()) { secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } +} + +// FIXME: this function name is a bit of a mouthful +export async function tryToUnlockSecretStorageWithDehydrationKey( + client: MatrixClient, +): Promise { + const key = dehydrationCache.key; + let restoringBackup = false; + if (key && await client.isSecretStorageReady()) { + console.log("Trying to set up cross-signing using dehydration key"); + secretStorageBeingAccessed = true; + nonInteractive = true; + try { + await client.checkOwnCrossSigningTrust(); + + // we also need to set a new dehydrated device to replace the + // device we rehydrated + let dehydrationKeyInfo = {}; + if (dehydrationCache.keyInfo && dehydrationCache.keyInfo.passphrase) { + dehydrationKeyInfo = { passphrase: dehydrationCache.keyInfo.passphrase }; + } + await client.setDehydrationKey(key, dehydrationKeyInfo, "Backup device"); + + // and restore from backup + const backupInfo = await client.getKeyBackupVersion(); + if (backupInfo) { + restoringBackup = true; + // don't await, because this can take a long time + client.restoreKeyBackupWithSecretStorage(backupInfo) + .finally(() => { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + }); + } + } finally { + dehydrationCache = {}; + // the secret storage cache is needed for restoring from backup, so + // don't clear it yet if we're restoring from backup + if (!restoringBackup) { + secretStorageBeingAccessed = false; + nonInteractive = false; + if (!isCachingAllowed()) { + secretStorageKeys = {}; + secretStorageKeyInfo = {}; + } + } } } } diff --git a/src/SendHistoryManager.js b/src/SendHistoryManager.ts similarity index 62% rename from src/SendHistoryManager.js rename to src/SendHistoryManager.ts index d9955727a4..e9268ad642 100644 --- a/src/SendHistoryManager.js +++ b/src/SendHistoryManager.ts @@ -16,12 +16,21 @@ limitations under the License. */ import {clamp} from "lodash"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; + +import {SerializedPart} from "./editor/parts"; +import EditorModel from "./editor/model"; + +interface IHistoryItem { + parts: SerializedPart[]; + replyEventId?: string; +} export default class SendHistoryManager { - history: Array = []; + history: Array = []; prefix: string; - lastIndex: number = 0; // used for indexing the storage - currentIndex: number = 0; // used for indexing the loaded validated history Array + lastIndex = 0; // used for indexing the storage + currentIndex = 0; // used for indexing the loaded validated history Array constructor(roomId: string, prefix: string) { this.prefix = prefix + roomId; @@ -32,8 +41,7 @@ export default class SendHistoryManager { while (itemJSON = sessionStorage.getItem(`${this.prefix}[${index}]`)) { try { - const serializedParts = JSON.parse(itemJSON); - this.history.push(serializedParts); + this.history.push(JSON.parse(itemJSON)); } catch (e) { console.warn("Throwing away unserialisable history", e); break; @@ -45,15 +53,22 @@ export default class SendHistoryManager { this.currentIndex = this.lastIndex + 1; } - save(editorModel: Object) { - const serializedParts = editorModel.serializeParts(); - this.history.push(serializedParts); - this.currentIndex = this.history.length; - this.lastIndex += 1; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(serializedParts)); + static createItem(model: EditorModel, replyEvent?: MatrixEvent): IHistoryItem { + return { + parts: model.serializeParts(), + replyEventId: replyEvent ? replyEvent.getId() : undefined, + }; } - getItem(offset: number): ?HistoryItem { + save(editorModel: EditorModel, replyEvent?: MatrixEvent) { + const item = SendHistoryManager.createItem(editorModel, replyEvent); + this.history.push(item); + this.currentIndex = this.history.length; + this.lastIndex += 1; + sessionStorage.setItem(`${this.prefix}[${this.lastIndex}]`, JSON.stringify(item)); + } + + getItem(offset: number): IHistoryItem { this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1); return this.history[this.currentIndex]; } 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 34d40bf1fd..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" @@ -198,59 +199,30 @@ function textForRelatedGroupsEvent(ev) { function textForServerACLEvent(ev) { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const prevContent = ev.getPrevContent(); - const changes = []; const current = ev.getContent(); const prev = { deny: Array.isArray(prevContent.deny) ? prevContent.deny : [], allow: Array.isArray(prevContent.allow) ? prevContent.allow : [], allow_ip_literals: !(prevContent.allow_ip_literals === false), }; + let text = ""; if (prev.deny.length === 0 && prev.allow.length === 0) { - text = `${senderDisplayName} set server ACLs for this room: `; + text = _t("%(senderDisplayName)s set the server ACLs for this room.", {senderDisplayName}); } else { - text = `${senderDisplayName} changed the server ACLs for this room: `; + text = _t("%(senderDisplayName)s changed the server ACLs for this room.", {senderDisplayName}); } if (!Array.isArray(current.allow)) { current.allow = []; } - /* If we know for sure everyone is banned, don't bother showing the diff view */ + + // If we know for sure everyone is banned, mark the room as obliterated if (current.allow.length === 0) { - return text + "🎉 All servers are banned from participating! This room can no longer be used."; + return text + " " + _t("🎉 All servers are banned from participating! This room can no longer be used."); } - if (!Array.isArray(current.deny)) { - current.deny = []; - } - - const bannedServers = current.deny.filter((srv) => typeof(srv) === 'string' && !prev.deny.includes(srv)); - const unbannedServers = prev.deny.filter((srv) => typeof(srv) === 'string' && !current.deny.includes(srv)); - const allowedServers = current.allow.filter((srv) => typeof(srv) === 'string' && !prev.allow.includes(srv)); - const unallowedServers = prev.allow.filter((srv) => typeof(srv) === 'string' && !current.allow.includes(srv)); - - if (bannedServers.length > 0) { - changes.push(`Servers matching ${bannedServers.join(", ")} are now banned.`); - } - - if (unbannedServers.length > 0) { - changes.push(`Servers matching ${unbannedServers.join(", ")} were removed from the ban list.`); - } - - if (allowedServers.length > 0) { - changes.push(`Servers matching ${allowedServers.join(", ")} are now allowed.`); - } - - if (unallowedServers.length > 0) { - changes.push(`Servers matching ${unallowedServers.join(", ")} were removed from the allowed list.`); - } - - if (prev.allow_ip_literals !== current.allow_ip_literals) { - const allowban = current.allow_ip_literals ? "allowed" : "banned"; - changes.push(`Participating from a server using an IP literal hostname is now ${allowban}.`); - } - - return text + changes.join(" "); + return text; } function textForMessageEvent(ev) { @@ -329,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}); @@ -345,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? @@ -466,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 @@ -488,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(); @@ -574,6 +569,7 @@ const handlers = { 'm.call.invite': textForCallInviteEvent, 'm.call.answer': textForCallAnswerEvent, 'm.call.hangup': textForCallHangupEvent, + 'm.call.reject': textForCallRejectEvent, }; const stateHandlers = { @@ -593,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/UserActivity.js b/src/UserActivity.ts similarity index 61% rename from src/UserActivity.js rename to src/UserActivity.ts index 0174aebaf5..606075ec7c 100644 --- a/src/UserActivity.js +++ b/src/UserActivity.ts @@ -38,26 +38,23 @@ const RECENTLY_ACTIVE_THRESHOLD_MS = 2 * 60 * 1000; * see doc on the userActive* functions for what these mean. */ export default class UserActivity { - constructor(windowObj, documentObj) { - this._window = windowObj; - this._document = documentObj; + private readonly activeNowTimeout: Timer; + private readonly activeRecentlyTimeout: Timer; + private attachedActiveNowTimers: Timer[] = []; + private attachedActiveRecentlyTimers: Timer[] = []; + private lastScreenX = 0; + private lastScreenY = 0; - this._attachedActiveNowTimers = []; - this._attachedActiveRecentlyTimers = []; - this._activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); - this._activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS); - this._onUserActivity = this._onUserActivity.bind(this); - this._onWindowBlurred = this._onWindowBlurred.bind(this); - this._onPageVisibilityChanged = this._onPageVisibilityChanged.bind(this); - this.lastScreenX = 0; - this.lastScreenY = 0; + constructor(private readonly window: Window, private readonly document: Document) { + this.activeNowTimeout = new Timer(CURRENTLY_ACTIVE_THRESHOLD_MS); + this.activeRecentlyTimeout = new Timer(RECENTLY_ACTIVE_THRESHOLD_MS); } static sharedInstance() { - if (global.mxUserActivity === undefined) { - global.mxUserActivity = new UserActivity(window, document); + if (window.mxUserActivity === undefined) { + window.mxUserActivity = new UserActivity(window, document); } - return global.mxUserActivity; + return window.mxUserActivity; } /** @@ -69,8 +66,8 @@ export default class UserActivity { * later on when the user does become active. * @param {Timer} timer the timer to use */ - timeWhileActiveNow(timer) { - this._timeWhile(timer, this._attachedActiveNowTimers); + public timeWhileActiveNow(timer: Timer) { + this.timeWhile(timer, this.attachedActiveNowTimers); if (this.userActiveNow()) { timer.start(); } @@ -85,14 +82,14 @@ export default class UserActivity { * later on when the user does become active. * @param {Timer} timer the timer to use */ - timeWhileActiveRecently(timer) { - this._timeWhile(timer, this._attachedActiveRecentlyTimers); + public timeWhileActiveRecently(timer: Timer) { + this.timeWhile(timer, this.attachedActiveRecentlyTimers); if (this.userActiveRecently()) { timer.start(); } } - _timeWhile(timer, attachedTimers) { + private timeWhile(timer: Timer, attachedTimers: Timer[]) { // important this happens first const index = attachedTimers.indexOf(timer); if (index === -1) { @@ -112,36 +109,36 @@ export default class UserActivity { /** * Start listening to user activity */ - start() { - this._document.addEventListener('mousedown', this._onUserActivity); - this._document.addEventListener('mousemove', this._onUserActivity); - this._document.addEventListener('keydown', this._onUserActivity); - this._document.addEventListener("visibilitychange", this._onPageVisibilityChanged); - this._window.addEventListener("blur", this._onWindowBlurred); - this._window.addEventListener("focus", this._onUserActivity); + public start() { + this.document.addEventListener('mousedown', this.onUserActivity); + this.document.addEventListener('mousemove', this.onUserActivity); + this.document.addEventListener('keydown', this.onUserActivity); + this.document.addEventListener("visibilitychange", this.onPageVisibilityChanged); + this.window.addEventListener("blur", this.onWindowBlurred); + this.window.addEventListener("focus", this.onUserActivity); // can't use document.scroll here because that's only the document // itself being scrolled. Need to use addEventListener's useCapture. // also this needs to be the wheel event, not scroll, as scroll is // fired when the view scrolls down for a new message. - this._window.addEventListener('wheel', this._onUserActivity, { - passive: true, capture: true, + this.window.addEventListener('wheel', this.onUserActivity, { + passive: true, + capture: true, }); } /** * Stop tracking user activity */ - stop() { - this._document.removeEventListener('mousedown', this._onUserActivity); - this._document.removeEventListener('mousemove', this._onUserActivity); - this._document.removeEventListener('keydown', this._onUserActivity); - this._window.removeEventListener('wheel', this._onUserActivity, { - passive: true, capture: true, + public stop() { + this.document.removeEventListener('mousedown', this.onUserActivity); + this.document.removeEventListener('mousemove', this.onUserActivity); + this.document.removeEventListener('keydown', this.onUserActivity); + this.window.removeEventListener('wheel', this.onUserActivity, { + capture: true, }); - - this._document.removeEventListener("visibilitychange", this._onPageVisibilityChanged); - this._window.removeEventListener("blur", this._onWindowBlurred); - this._window.removeEventListener("focus", this._onUserActivity); + this.document.removeEventListener("visibilitychange", this.onPageVisibilityChanged); + this.window.removeEventListener("blur", this.onWindowBlurred); + this.window.removeEventListener("focus", this.onUserActivity); } /** @@ -151,8 +148,8 @@ export default class UserActivity { * user's attention at any given moment. * @returns {boolean} true if user is currently 'active' */ - userActiveNow() { - return this._activeNowTimeout.isRunning(); + public userActiveNow() { + return this.activeNowTimeout.isRunning(); } /** @@ -163,27 +160,27 @@ export default class UserActivity { * (or they may have gone to make tea and left the window focused). * @returns {boolean} true if user has been active recently */ - userActiveRecently() { - return this._activeRecentlyTimeout.isRunning(); + public userActiveRecently() { + return this.activeRecentlyTimeout.isRunning(); } - _onPageVisibilityChanged(e) { - if (this._document.visibilityState === "hidden") { - this._activeNowTimeout.abort(); - this._activeRecentlyTimeout.abort(); + private onPageVisibilityChanged = e => { + if (this.document.visibilityState === "hidden") { + this.activeNowTimeout.abort(); + this.activeRecentlyTimeout.abort(); } else { - this._onUserActivity(e); + this.onUserActivity(e); } - } + }; - _onWindowBlurred() { - this._activeNowTimeout.abort(); - this._activeRecentlyTimeout.abort(); - } + private onWindowBlurred = () => { + this.activeNowTimeout.abort(); + this.activeRecentlyTimeout.abort(); + }; - _onUserActivity(event) { + private onUserActivity = (event: MouseEvent) => { // ignore anything if the window isn't focused - if (!this._document.hasFocus()) return; + if (!this.document.hasFocus()) return; if (event.screenX && event.type === "mousemove") { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { @@ -195,25 +192,25 @@ export default class UserActivity { } dis.dispatch({action: 'user_activity'}); - if (!this._activeNowTimeout.isRunning()) { - this._activeNowTimeout.start(); + if (!this.activeNowTimeout.isRunning()) { + this.activeNowTimeout.start(); dis.dispatch({action: 'user_activity_start'}); - this._runTimersUntilTimeout(this._attachedActiveNowTimers, this._activeNowTimeout); + UserActivity.runTimersUntilTimeout(this.attachedActiveNowTimers, this.activeNowTimeout); } else { - this._activeNowTimeout.restart(); + this.activeNowTimeout.restart(); } - if (!this._activeRecentlyTimeout.isRunning()) { - this._activeRecentlyTimeout.start(); + if (!this.activeRecentlyTimeout.isRunning()) { + this.activeRecentlyTimeout.start(); - this._runTimersUntilTimeout(this._attachedActiveRecentlyTimers, this._activeRecentlyTimeout); + UserActivity.runTimersUntilTimeout(this.attachedActiveRecentlyTimers, this.activeRecentlyTimeout); } else { - this._activeRecentlyTimeout.restart(); + this.activeRecentlyTimeout.restart(); } - } + }; - async _runTimersUntilTimeout(attachedTimers, timeout) { + private static async runTimersUntilTimeout(attachedTimers: Timer[], timeout: Timer) { attachedTimers.forEach((t) => t.start()); try { await timeout.finished(); 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/WhoIsTyping.js b/src/WhoIsTyping.ts similarity index 72% rename from src/WhoIsTyping.js rename to src/WhoIsTyping.ts index d11cddf487..a8ca425ea8 100644 --- a/src/WhoIsTyping.js +++ b/src/WhoIsTyping.ts @@ -14,19 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {Room} from "matrix-js-sdk/src/models/room"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; + import {MatrixClientPeg} from "./MatrixClientPeg"; import { _t } from './languageHandler'; -export function usersTypingApartFromMeAndIgnored(room) { - return usersTyping( - room, [MatrixClientPeg.get().credentials.userId].concat(MatrixClientPeg.get().getIgnoredUsers()), - ); +export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] { + return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers())); } -export function usersTypingApartFromMe(room) { - return usersTyping( - room, [MatrixClientPeg.get().credentials.userId], - ); +export function usersTypingApartFromMe(room: Room): RoomMember[] { + return usersTyping(room, [MatrixClientPeg.get().getUserId()]); } /** @@ -34,15 +33,11 @@ export function usersTypingApartFromMe(room) { * to exclude, return a list of user objects who are typing. * @param {Room} room: room object to get users from. * @param {string[]} exclude: list of user mxids to exclude. - * @returns {string[]} list of user objects who are typing. + * @returns {RoomMember[]} list of user objects who are typing. */ -export function usersTyping(room, exclude) { +export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] { const whoIsTyping = []; - if (exclude === undefined) { - exclude = []; - } - const memberKeys = Object.keys(room.currentState.members); for (let i = 0; i < memberKeys.length; ++i) { const userId = memberKeys[i]; @@ -57,20 +52,21 @@ export function usersTyping(room, exclude) { return whoIsTyping; } -export function whoIsTypingString(whoIsTyping, limit) { +export function whoIsTypingString(whoIsTyping: RoomMember[], limit: number): string { let othersCount = 0; if (whoIsTyping.length > limit) { othersCount = whoIsTyping.length - limit + 1; } + if (whoIsTyping.length === 0) { return ''; } else if (whoIsTyping.length === 1) { return _t('%(displayName)s is typing …', {displayName: whoIsTyping[0].name}); } - const names = whoIsTyping.map(function(m) { - return m.name; - }); - if (othersCount>=1) { + + const names = whoIsTyping.map(m => m.name); + + if (othersCount >= 1) { return _t('%(names)s and %(count)s others are typing …', { names: names.slice(0, limit - 1).join(', '), count: othersCount, 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 b1dbb56a01..b49a90d175 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -166,7 +166,8 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn const onKeyDownHandler = useCallback((ev) => { let handled = false; - if (handleHomeEnd) { + // Don't interfere with input default keydown behaviour + if (handleHomeEnd && ev.target.tagName !== "INPUT") { // check if we actually have any items switch (ev.key) { case Key.HOME: @@ -204,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/Toolbar.tsx b/src/accessibility/Toolbar.tsx index cc2a1769c7..e756d948e5 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -28,6 +28,9 @@ interface IProps extends Omit, "onKeyDown"> { const Toolbar: React.FC = ({children, ...props}) => { const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const target = ev.target as HTMLElement; + // Don't interfere with input default keydown behaviour + if (target.tagName === "INPUT") return; + let handled = true; // HOME and END are handled by RovingTabIndexProvider diff --git a/src/actions/TagOrderActions.ts b/src/actions/TagOrderActions.ts index c203172874..021cd11b55 100644 --- a/src/actions/TagOrderActions.ts +++ b/src/actions/TagOrderActions.ts @@ -17,14 +17,14 @@ limitations under the License. import Analytics from '../Analytics'; import { asyncAction } from './actionCreators'; -import TagOrderStore from '../stores/TagOrderStore'; +import GroupFilterOrderStore from '../stores/GroupFilterOrderStore'; import { AsyncActionPayload } from "../dispatcher/payloads"; import { MatrixClient } from "matrix-js-sdk/src/client"; export default class TagOrderActions { /** * Creates an action thunk that will do an asynchronous request to - * move a tag in TagOrderStore to destinationIx. + * move a tag in GroupFilterOrderStore to destinationIx. * * @param {MatrixClient} matrixClient the matrix client to set the * account data on. @@ -36,8 +36,8 @@ export default class TagOrderActions { */ public static moveTag(matrixClient: MatrixClient, tag: string, destinationIx: number): AsyncActionPayload { // Only commit tags if the state is ready, i.e. not null - let tags = TagOrderStore.getOrderedTags(); - let removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + let tags = GroupFilterOrderStore.getOrderedTags(); + let removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || []; if (!tags) { return; } @@ -47,7 +47,7 @@ export default class TagOrderActions { removedTags = removedTags.filter((t) => t !== tag); - const storeId = TagOrderStore.getStoreId(); + const storeId = GroupFilterOrderStore.getStoreId(); return asyncAction('TagOrderActions.moveTag', () => { Analytics.trackEvent('TagOrderActions', 'commitTagOrdering'); @@ -83,8 +83,8 @@ export default class TagOrderActions { */ public static removeTag(matrixClient: MatrixClient, tag: string): AsyncActionPayload { // Don't change tags, just removedTags - const tags = TagOrderStore.getOrderedTags(); - const removedTags = TagOrderStore.getRemovedTagsAccountData() || []; + const tags = GroupFilterOrderStore.getOrderedTags(); + const removedTags = GroupFilterOrderStore.getRemovedTagsAccountData() || []; if (removedTags.includes(tag)) { // Return a thunk that doesn't do anything, we don't even need @@ -94,7 +94,7 @@ export default class TagOrderActions { removedTags.push(tag); - const storeId = TagOrderStore.getStoreId(); + const storeId = GroupFilterOrderStore.getStoreId(); return asyncAction('TagOrderActions.removeTag', () => { Analytics.trackEvent('TagOrderActions', 'removeTag'); 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 00aad2a0ce..84cb58536a 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -32,6 +32,7 @@ import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import { getSecureBackupSetupMethods, isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; +import SecurityCustomisations from "../../../../customisations/Security"; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; @@ -99,7 +100,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._passphraseField = createRef(); - this._fetchBackupInfo(); + MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + if (this.state.accountPassword) { // If we have an account password in memory, let's simplify and // assume it means password auth is also supported for device @@ -110,13 +112,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._queryKeyUploadAuth(); } - MatrixClientPeg.get().on('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + this._getInitialPhase(); } componentWillUnmount() { MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); } + _getInitialPhase() { + const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.(); + if (keyFromCustomisations) { + console.log("Created key via customisations, jumping to bootstrap step"); + this._recoveryKey = { + privateKey: keyFromCustomisations, + }; + this._bootstrapSecretStorage(); + return; + } + + this._fetchBackupInfo(); + } + async _fetchBackupInfo() { try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); @@ -219,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, @@ -454,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 >
@@ -472,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 >
@@ -493,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}
@@ -575,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/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 884f77aba5..3073397fba 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -390,15 +390,16 @@ export class ContextMenu extends React.PureComponent { } // 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 +409,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/TagPanel.js b/src/components/structures/GroupFilterPanel.js similarity index 86% rename from src/components/structures/TagPanel.js rename to src/components/structures/GroupFilterPanel.js index 135b2a1c5c..96aa1ba728 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/GroupFilterPanel.js @@ -16,7 +16,7 @@ limitations under the License. */ import React from 'react'; -import TagOrderStore from '../../stores/TagOrderStore'; +import GroupFilterOrderStore from '../../stores/GroupFilterOrderStore'; import GroupActions from '../../actions/GroupActions'; @@ -31,7 +31,7 @@ import AutoHideScrollbar from "./AutoHideScrollbar"; import SettingsStore from "../../settings/SettingsStore"; import UserTagTile from "../views/elements/UserTagTile"; -class TagPanel extends React.Component { +class GroupFilterPanel extends React.Component { static contextType = MatrixClientContext; state = { @@ -44,13 +44,13 @@ class TagPanel extends React.Component { this.context.on("Group.myMembership", this._onGroupMyMembership); this.context.on("sync", this._onClientSync); - this._tagOrderStoreToken = TagOrderStore.addListener(() => { + this._groupFilterOrderStoreToken = GroupFilterOrderStore.addListener(() => { if (this.unmounted) { return; } this.setState({ - orderedTags: TagOrderStore.getOrderedTags() || [], - selectedTags: TagOrderStore.getSelectedTags(), + orderedTags: GroupFilterOrderStore.getOrderedTags() || [], + selectedTags: GroupFilterOrderStore.getSelectedTags(), }); }); // This could be done by anything with a matrix client @@ -61,8 +61,8 @@ class TagPanel extends React.Component { this.unmounted = true; this.context.removeListener("Group.myMembership", this._onGroupMyMembership); this.context.removeListener("sync", this._onClientSync); - if (this._tagOrderStoreToken) { - this._tagOrderStoreToken.remove(); + if (this._groupFilterOrderStoreToken) { + this._groupFilterOrderStoreToken.remove(); } } @@ -98,7 +98,7 @@ class TagPanel extends React.Component { return (

-
+
); } @@ -117,8 +117,8 @@ class TagPanel extends React.Component { }); const itemsSelected = this.state.selectedTags.length > 0; - const classes = classNames('mx_TagPanel', { - mx_TagPanel_items_selected: itemsSelected, + const classes = classNames('mx_GroupFilterPanel', { + mx_GroupFilterPanel_items_selected: itemsSelected, }); let createButton = ( @@ -141,7 +141,7 @@ class TagPanel extends React.Component { return
{ (provided, snapshot) => (
{ this.renderGlobalIcon() } @@ -168,4 +168,4 @@ class TagPanel extends React.Component {
; } } -export default TagPanel; +export default GroupFilterPanel; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 5dadba983a..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

`); @@ -620,7 +620,7 @@ export default class GroupView extends React.Component { profileForm: newProfileForm, // Indicate that FlairStore needs to be poked to show this change - // in TagTile (TagPanel), Flair and GroupTile (MyGroups). + // in TagTile (GroupFilterPanel), Flair and GroupTile (MyGroups). avatarChanged: true, }); }).catch((e) => { @@ -649,7 +649,6 @@ export default class GroupView extends React.Component { editing: false, summary: null, }); - dis.dispatch({action: 'panel_disable'}); this._initGroupStore(this.props.groupId); if (this.state.avatarChanged) { @@ -870,10 +869,7 @@ export default class GroupView extends React.Component { { _t('Add rooms to this community') }
) :
; - const roomDetailListClassName = classnames({ - "mx_fadable": true, - "mx_fadable_faded": this.state.editing, - }); + return

@@ -884,9 +880,7 @@ export default class GroupView extends React.Component {

{ this.state.groupRoomsLoading ? : - + }
; } 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 090a64904c..4445ff3ff8 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -16,7 +16,7 @@ limitations under the License. import * as React from "react"; import { createRef } from "react"; -import TagPanel from "./TagPanel"; +import GroupFilterPanel from "./GroupFilterPanel"; import CustomRoomTagPanel from "./CustomRoomTagPanel"; import classNames from "classnames"; import dis from "../../dispatcher/dispatcher"; @@ -38,6 +38,7 @@ 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"; interface IProps { isMinimized: boolean; @@ -46,7 +47,7 @@ interface IProps { interface IState { showBreadcrumbs: boolean; - showTagPanel: boolean; + showGroupFilterPanel: boolean; } // List of CSS classes which should be included in keyboard navigation within the room list @@ -60,7 +61,7 @@ const cssClasses = [ export default class LeftPanel extends React.Component { private listContainerRef: React.RefObject = createRef(); - private tagPanelWatcherRef: string; + private groupFilterPanelWatcherRef: string; private bgImageWatcherRef: string; private focusedElement = null; private isDoingStickyHeaders = false; @@ -70,7 +71,7 @@ export default class LeftPanel extends React.Component { this.state = { showBreadcrumbs: BreadcrumbsStore.instance.visible, - showTagPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), + showGroupFilterPanel: SettingsStore.getValue('TagPanel.enableTagPanel'), }; BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -78,8 +79,8 @@ export default class LeftPanel extends React.Component { OwnProfileStore.instance.on(UPDATE_EVENT, this.onBackgroundImageUpdate); this.bgImageWatcherRef = SettingsStore.watchSetting( "RoomList.backgroundImage", null, this.onBackgroundImageUpdate); - this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { - this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); + this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { + this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); }); // We watch the middle panel because we don't actually get resized, the middle panel does. @@ -88,7 +89,7 @@ export default class LeftPanel extends React.Component { } public componentWillUnmount() { - SettingsStore.unwatchSetting(this.tagPanelWatcherRef); + SettingsStore.unwatchSetting(this.groupFilterPanelWatcherRef); SettingsStore.unwatchSetting(this.bgImageWatcherRef); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); @@ -119,8 +120,11 @@ export default class LeftPanel extends React.Component { if (settingBgMxc) { avatarUrl = MatrixClientPeg.get().mxcUrlToHttp(settingBgMxc, avatarSize, avatarSize); } + const avatarUrlProp = `url(${avatarUrl})`; - if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { + if (!avatarUrl) { + document.body.style.removeProperty("--avatar-url"); + } else if (document.body.style.getPropertyValue("--avatar-url") !== avatarUrlProp) { document.body.style.setProperty("--avatar-url", avatarUrlProp); } }; @@ -139,7 +143,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 @@ -210,10 +214,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) { @@ -375,9 +388,9 @@ export default class LeftPanel extends React.Component { } public render(): React.ReactNode { - const tagPanel = !this.state.showTagPanel ? null : ( -
- + const groupFilterPanel = !this.state.showGroupFilterPanel ? null : ( +
+ {SettingsStore.getValue("feature_custom_tags") ? : null}
); @@ -385,7 +398,6 @@ export default class LeftPanel extends React.Component { const roomList = { const containerClasses = classNames({ "mx_LeftPanel": true, - "mx_LeftPanel_hasTagPanel": !!tagPanel, + "mx_LeftPanel_hasGroupFilterPanel": !!groupFilterPanel, "mx_LeftPanel_minimized": this.props.isMinimized, }); @@ -405,7 +417,7 @@ export default class LeftPanel extends React.Component { return (
- {tagPanel} + {groupFilterPanel}
+ { !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 4dc2080895..c76cd7cee7 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -21,13 +21,12 @@ 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'; import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; -import sessionStore from '../../stores/SessionStore'; import {MatrixClientPeg, IMatrixClientCreds} from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; @@ -41,10 +40,6 @@ import HomePage from "./HomePage"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PlatformPeg from "../../PlatformPeg"; import { DefaultTagID } from "../../stores/room-list/models"; -import { - showToast as showSetPasswordToast, - hideToast as hideSetPasswordToast, -} from "../../toasts/SetPasswordToast"; import { showToast as showServerLimitToast, hideToast as hideServerLimitToast, @@ -57,6 +52,9 @@ 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'; // 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. @@ -76,9 +74,6 @@ interface IProps { viaServers?: string[]; hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; - middleDisabled: boolean; - leftDisabled: boolean; - rightDisabled: boolean; // eslint-disable-next-line camelcase page_type: string; autoJoin: boolean; @@ -95,6 +90,7 @@ interface IProps { currentUserId?: string; currentGroupId?: string; currentGroupIsNew?: boolean; + justRegistered?: boolean; } interface IUsageLimit { @@ -105,10 +101,6 @@ interface IUsageLimit { } interface IState { - mouseDown?: { - x: number; - y: number; - }; syncErrorData?: { error: { data: IUsageLimit; @@ -149,16 +141,13 @@ class LoggedInView extends React.Component { protected readonly _matrixClient: MatrixClient; protected readonly _roomView: React.RefObject; protected readonly _resizeContainer: React.RefObject; - protected readonly _sessionStore: sessionStore; - protected readonly _sessionStoreToken: { remove: () => void }; - protected readonly _compactLayoutWatcherRef: string; + protected compactLayoutWatcherRef: string; protected resizer: Resizer; constructor(props, context) { super(props, context); this.state = { - mouseDown: undefined, syncErrorData: undefined, // use compact timeline view useCompactLayout: SettingsStore.getValue('useCompactLayout'), @@ -169,24 +158,6 @@ class LoggedInView extends React.Component { CallMediaHandler.loadDevices(); - document.addEventListener('keydown', this._onNativeKeyDown, false); - - this._sessionStore = sessionStore; - this._sessionStoreToken = this._sessionStore.addListener( - this._setStateFromSessionStore, - ); - this._setStateFromSessionStore(); - - 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(); @@ -194,6 +165,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(); @@ -204,10 +193,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); - if (this._sessionStoreToken) { - this._sessionStoreToken.remove(); - } + SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); this.resizer.detach(); } @@ -228,46 +214,38 @@ class LoggedInView extends React.Component { return this._roomView.current.canResetTimeline(); }; - _setStateFromSessionStore = () => { - if (this._sessionStore.getCachedPassword()) { - showSetPasswordToast(); - } else { - hideSetPasswordToast(); - } - }; - _createResizer() { - const classNames = { - handle: "mx_ResizeHandle", - vertical: "mx_ResizeHandle_vertical", - reverse: "mx_ResizeHandle_reverse", - }; - const collapseConfig = { + let size; + let collapsed; + const collapseConfig: ICollapseConfig = { toggleSize: 260 - 50, - onCollapsed: (collapsed) => { - if (collapsed) { + onCollapsed: (_collapsed) => { + collapsed = _collapsed; + if (_collapsed) { dis.dispatch({action: "hide_left_panel"}, true); window.localStorage.setItem("mx_lhs_size", '0'); } else { dis.dispatch({action: "show_left_panel"}, true); } }, - onResized: (size) => { - window.localStorage.setItem("mx_lhs_size", '' + size); + onResized: (_size) => { + size = _size; this.props.resizeNotifier.notifyLeftHandleResized(); }, onResizeStart: () => { this.props.resizeNotifier.startResizing(); }, onResizeStop: () => { + if (!collapsed) window.localStorage.setItem("mx_lhs_size", '' + size); this.props.resizeNotifier.stopResizing(); }, }; - const resizer = new Resizer( - this._resizeContainer.current, - CollapseDistributor, - collapseConfig); - resizer.setClassNames(classNames); + const resizer = new Resizer(this._resizeContainer.current, CollapseDistributor, collapseConfig); + resizer.setClassNames({ + handle: "mx_ResizeHandle", + vertical: "mx_ResizeHandle_vertical", + reverse: "mx_ResizeHandle_reverse", + }); return resizer; } @@ -424,6 +402,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: @@ -449,6 +428,14 @@ class LoggedInView extends React.Component { handled = true; } break; + case Key.F: + if (ctrlCmdOnly && SettingsStore.getValue("ctrlFForSearch")) { + dis.dispatch({ + action: 'focus_search', + }); + handled = true; + } + break; case Key.BACKTICK: // Ideally this would be CTRL+P for "Profile", but that's // taken by the print dialog. CTRL+I for "Information" @@ -468,6 +455,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) { @@ -542,8 +539,8 @@ class LoggedInView extends React.Component { // Could be "GroupTile +groupId:domain" const draggableId = result.draggableId.split(' ').pop(); - // Dispatch synchronously so that the TagPanel receives an - // optimistic update from TagOrderStore before the previous + // Dispatch synchronously so that the GroupFilterPanel receives an + // optimistic update from GroupFilterOrderStore before the previous // state is shown. dis.dispatch(TagOrderActions.moveTag( this._matrixClient, @@ -574,48 +571,6 @@ class LoggedInView extends React.Component { ), true); }; - _onMouseDown = (ev) => { - // When the panels are disabled, clicking on them results in a mouse event - // which bubbles to certain elements in the tree. When this happens, close - // any settings page that is currently open (user/room/group). - if (this.props.leftDisabled && this.props.rightDisabled) { - const targetClasses = new Set(ev.target.className.split(' ')); - if ( - targetClasses.has('mx_MatrixChat') || - targetClasses.has('mx_MatrixChat_middlePanel') || - targetClasses.has('mx_RoomView') - ) { - this.setState({ - mouseDown: { - x: ev.pageX, - y: ev.pageY, - }, - }); - } - } - }; - - _onMouseUp = (ev) => { - if (!this.state.mouseDown) return; - - const deltaX = ev.pageX - this.state.mouseDown.x; - const deltaY = ev.pageY - this.state.mouseDown.y; - const distance = Math.sqrt((deltaX * deltaX) + (deltaY + deltaY)); - const maxRadius = 5; // People shouldn't be straying too far, hopefully - - // Note: we track how far the user moved their mouse to help - // combat against https://github.com/vector-im/element-web/issues/7158 - - if (distance < maxRadius) { - // This is probably a real click, and not a drag - dis.dispatch({ action: 'close_settings' }); - } - - // Always clear the mouseDown state to ensure we don't accidentally - // use stale values due to the mouseDown checks. - this.setState({mouseDown: null}); - }; - render() { const RoomView = sdk.getComponent('structures.RoomView'); const UserView = sdk.getComponent('structures.UserView'); @@ -635,7 +590,6 @@ class LoggedInView extends React.Component { oobData={this.props.roomOobData} viaServers={this.props.viaServers} key={this.props.currentRoomId || 'roomview'} - disabled={this.props.middleDisabled} resizeNotifier={this.props.resizeNotifier} />; break; @@ -649,7 +603,7 @@ class LoggedInView extends React.Component { break; case PageTypes.HomePage: - pageElement = ; + pageElement = ; break; case PageTypes.UserView: @@ -683,8 +637,6 @@ class LoggedInView extends React.Component { onKeyDown={this._onReactKeyDown} className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers} - onMouseDown={this._onMouseDown} - onMouseUp={this._onMouseUp} > @@ -697,6 +649,7 @@ class LoggedInView extends React.Component {
+ ); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 19418df414..5045e44182 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 } from "../../MatrixClientPeg"; +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,7 +47,6 @@ 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 {_t, _td, getCurrentLanguage} from '../../languageHandler'; @@ -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,44 @@ 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'; /** 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. @@ -181,9 +182,6 @@ interface IState { currentUserId?: string; // this is persisted as mx_lhs_size, loaded in LoggedInView collapseLhs: boolean; - leftDisabled: boolean; - middleDisabled: boolean; - // the right panel's disabled state is tracked in its store. // Parameters used in the registration dance with the IS // eslint-disable-next-line camelcase register_client_secret?: string; @@ -202,6 +200,7 @@ interface IState { roomOobData?: object; viaServers?: string[]; pendingInitialSync?: boolean; + justRegistered?: boolean; } export default class MatrixChat extends React.PureComponent { @@ -220,6 +219,7 @@ export default class MatrixChat extends React.PureComponent { private screenAfterLogin?: IScreen; private windowWidth: number; private pageChanging: boolean; + private tokenLogin?: boolean; private accountPassword?: string; private accountPasswordTimer?: NodeJS.Timeout; private focusComposer: boolean; @@ -236,8 +236,6 @@ export default class MatrixChat extends React.PureComponent { this.state = { view: Views.LOADING, collapseLhs: false, - leftDisabled: false, - middleDisabled: false, hideToSRUsers: false, @@ -290,7 +288,7 @@ export default class MatrixChat extends React.PureComponent { // When the session loads it'll be detected as soft logged out and a dispatch // will be sent out to say that, triggering this MatrixChat to show the soft // logout page. - Lifecycle.loadSession({}); + Lifecycle.loadSession(); } this.accountPassword = null; @@ -327,13 +325,21 @@ export default class MatrixChat extends React.PureComponent { Lifecycle.attemptTokenLogin( this.props.realQueryParams, this.props.defaultDeviceDisplayName, - ).then((loggedIn) => { - if (loggedIn) { + this.getFragmentAfterLogin(), + ).then(async (loggedIn) => { + if (this.props.realQueryParams?.loginToken) { + // remove the loginToken from the URL regardless this.props.onTokenLoginCompleted(); + } - // don't do anything else until the page reloads - just stay in - // the 'loading' state. - return; + if (loggedIn) { + this.tokenLogin = true; + + // Create and start the client + await Lifecycle.restoreFromLocalStorage({ + ignoreGuest: true, + }); + return this.postLoginSetup(); } // if the user has followed a login or register link, don't reanimate @@ -354,6 +360,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 @@ -368,6 +411,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); @@ -420,6 +464,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 @@ -478,6 +524,7 @@ export default class MatrixChat extends React.PureComponent { } const newState = { currentUserId: null, + justRegistered: false, }; Object.assign(newState, state); this.setState(newState); @@ -559,11 +606,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, @@ -594,7 +636,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(); @@ -623,9 +665,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"); @@ -650,8 +689,9 @@ export default class MatrixChat extends React.PureComponent { } case Action.ViewRoomDirectory: { const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); - Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, - 'mx_RoomDirectory_dialogWrapper', false, true); + 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(); @@ -668,16 +708,13 @@ export default class MatrixChat extends React.PureComponent { this.viewWelcome(); break; case 'view_home_page': - this.viewHome(); - break; - case 'view_set_mxid': - this.setMxId(payload); + 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); @@ -713,16 +750,13 @@ export default class MatrixChat extends React.PureComponent { this.state.resizeNotifier.notifyLeftHandleResized(); }); break; - case 'panel_disable': { - this.setState({ - leftDisabled: payload.leftDisabled || payload.sideDisabled || false, - middleDisabled: payload.middleDisabled || false, - // We don't track the right panel being disabled here - it's tracked in the store. - }); + 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 && @@ -766,7 +800,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); @@ -810,35 +849,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 @@ -958,10 +968,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'); @@ -985,36 +996,6 @@ export default class MatrixChat extends React.PureComponent { }); } - private setMxId(payload) { - const SetMxIdDialog = sdk.getComponent('views.dialogs.SetMxIdDialog'); - const close = Modal.createTrackedDialog('Set MXID', '', SetMxIdDialog, { - homeserverUrl: MatrixClientPeg.get().getHomeserverUrl(), - onFinished: (submitted, credentials) => { - if (!submitted) { - dis.dispatch({ - action: 'cancel_after_sync_prepared', - }); - if (payload.go_home_on_cancel) { - dis.dispatch({ - action: 'view_home_page', - }); - } - return; - } - MatrixClientPeg.setJustRegisteredUserId(credentials.user_id); - this.onRegistered(credentials); - }, - onDifferentServerClicked: (ev) => { - dis.dispatch({action: 'start_registration'}); - close(); - }, - onLoginClick: (ev) => { - dis.dispatch({action: 'start_login'}); - close(); - }, - }).close; - } - private async createRoom(defaultPublic = false) { const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); if (communityId) { @@ -1134,9 +1115,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"); @@ -1225,7 +1206,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 @@ -1238,7 +1219,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(); @@ -1246,9 +1227,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() { @@ -1266,12 +1256,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'}); } } } @@ -1376,8 +1362,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); @@ -1386,21 +1372,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(); @@ -1411,6 +1388,7 @@ export default class MatrixChat extends React.PureComponent { title: _t('Signed Out'), description: _t('For security, this session has been signed out. Please sign in again.'), }); + dis.dispatch({ action: 'logout', }); @@ -1440,6 +1418,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) { @@ -1581,6 +1560,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', @@ -1597,7 +1584,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 { @@ -1627,6 +1614,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 @@ -1645,14 +1635,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 @@ -1676,10 +1658,16 @@ export default class MatrixChat extends React.PureComponent { // TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149 let threepidInvite: IThreepidInvite; + // if we landed here from a 3PID invite, persist it if (params.signurl && params.email) { threepidInvite = ThreepidInviteStore.instance .storeInvite(roomString, params as IThreepidInviteWireFormat); } + // otherwise check that this room doesn't already have a known invite + if (!threepidInvite) { + const invites = ThreepidInviteStore.instance.getInvites(); + threepidInvite = invites.find(invite => invite.roomId === roomString); + } // on our URLs there might be a ?via=matrix.org or similar to help // joins to the room succeed. We'll pass these through as an array @@ -1814,23 +1802,15 @@ export default class MatrixChat extends React.PureComponent { this.showScreen("forgot_password"); }; - onRegisterFlowComplete = (credentials: object, password: string) => { + onRegisterFlowComplete = (credentials: IMatrixClientCreds, password: string) => { return this.onUserCompletedLoginFlow(credentials, password); }; // returns a promise which resolves to the new MatrixClient - onRegistered(credentials: object) { + onRegistered(credentials: IMatrixClientCreds) { 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) { @@ -1905,7 +1885,7 @@ export default class MatrixChat extends React.PureComponent { * Note: SSO users (and any others using token login) currently do not pass through * this, as they instead jump straight into the app after `attemptTokenLogin`. */ - onUserCompletedLoginFlow = async (credentials: object, password: string) => { + onUserCompletedLoginFlow = async (credentials: IMatrixClientCreds, password: string) => { this.accountPassword = password; // self-destruct the password after 5mins if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); @@ -1916,40 +1896,7 @@ export default class MatrixChat extends React.PureComponent { // Create and start the client await Lifecycle.setLoggedIn(credentials); - - const cli = MatrixClientPeg.get(); - const cryptoEnabled = cli.isCryptoEnabled(); - if (!cryptoEnabled) { - this.onLoggedIn(); - } - - const promisesList = [this.firstSyncPromise.promise]; - if (cryptoEnabled) { - // wait for the client to finish downloading cross-signing keys for us so we - // know whether or not we have keys set up on this account - promisesList.push(cli.downloadKeys([cli.getUserId()])); - } - - // Now update the state to say we're waiting for the first sync to complete rather - // than for the login to finish. - this.setState({ pendingInitialSync: true }); - - await Promise.all(promisesList); - - if (!cryptoEnabled) { - this.setState({ pendingInitialSync: false }); - return; - } - - const crossSigningIsSetUp = cli.getStoredCrossSigningForUser(cli.getUserId()); - if (crossSigningIsSetUp) { - this.setStateForNewView({ view: Views.COMPLETE_SECURITY }); - } else if (await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")) { - this.setStateForNewView({ view: Views.E2E_SETUP }); - } else { - this.onLoggedIn(); - } - this.setState({ pendingInitialSync: false }); + await this.postLoginSetup(); }; // complete security / e2e setup has finished @@ -1993,15 +1940,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. @@ -2065,6 +2006,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 6c6d8700a5..d66049d3a5 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -1,9 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 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. @@ -20,7 +17,6 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import classNames from 'classnames'; import {Room} from "matrix-js-sdk/src/models/room"; import * as sdk from '../../index'; @@ -34,7 +30,6 @@ 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() { @@ -162,7 +157,7 @@ export default class RightPanel extends React.Component { } onRoomStateMember(ev, state, member) { - if (member.roomId !== this.props.room.roomId) { + if (!this.props.room || member.roomId !== this.props.room.roomId) { return; } // redraw the badge on the membership list @@ -190,7 +185,7 @@ export default class RightPanel extends React.Component { } } - 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. @@ -202,25 +197,21 @@ export default class RightPanel extends React.Component { dis.dispatch({ action: "view_home_page", }); + } 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. + // 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: this.state.phase === RightPanelPhases.EncryptionPanel ? 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'); @@ -258,7 +249,7 @@ export default class RightPanel extends React.Component { user={this.state.member} room={this.props.room} key={roomId || this.state.member.userId} - onClose={this.onCloseUserInfo} + onClose={this.onClose} phase={this.state.phase} verificationRequest={this.state.verificationRequest} verificationRequestPromise={this.state.verificationRequestPromise} @@ -274,7 +265,7 @@ export default class RightPanel extends React.Component { user={this.state.member} groupId={this.props.groupId} key={this.state.member.userId} - onClose={this.onCloseUserInfo} />; + onClose={this.onClose} />; break; case RightPanelPhases.GroupRoomInfo: @@ -301,14 +292,8 @@ export default class RightPanel extends React.Component { break; } - const classes = classNames("mx_RightPanel", "mx_fadable", { - "collapsed": this.props.collapsed, - "mx_fadable_faded": this.props.disabled, - "dark-panel": true, - }); - return ( -