diff --git a/.stylelintrc.js b/.stylelintrc.js index c044b19a63..0bdea3cccd 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -11,7 +11,8 @@ module.exports = { "length-zero-no-unit": null, "rule-empty-line-before": null, "color-hex-length": null, - "max-empty-lines": null, + "max-empty-lines": 1, + "no-eol-whitespace": true, "number-no-trailing-zeros": null, "number-leading-zero": null, "selector-list-comma-newline-after": null, diff --git a/CHANGELOG.md b/CHANGELOG.md index f133398724..e6fa6c3c80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,93 @@ +Changes in [3.33.0](https://github.com/vector-im/element-desktop/releases/tag/v3.33.0) (2021-10-25) +=================================================================================================== + +## ✨ Features + * Convert the "Cryptography" settings panel to an HTML table to assist screen reader users. ([\#6968](https://github.com/matrix-org/matrix-react-sdk/pull/6968)). Contributed by [andybalaam](https://github.com/andybalaam). + * Swap order of private space creation and tweak copy ([\#6967](https://github.com/matrix-org/matrix-react-sdk/pull/6967)). Fixes vector-im/element-web#18768 and vector-im/element-web#18768. + * Add spacing to Room settings - Notifications subsection ([\#6962](https://github.com/matrix-org/matrix-react-sdk/pull/6962)). Contributed by [CicadaCinema](https://github.com/CicadaCinema). + * Use HTML tables for some tabular user interface areas, to assist with screen reader use ([\#6955](https://github.com/matrix-org/matrix-react-sdk/pull/6955)). Contributed by [andybalaam](https://github.com/andybalaam). + * Fix space invite edge cases ([\#6884](https://github.com/matrix-org/matrix-react-sdk/pull/6884)). Fixes vector-im/element-web#19010 vector-im/element-web#17345 and vector-im/element-web#19010. + * Allow options to cascade kicks/bans throughout spaces ([\#6829](https://github.com/matrix-org/matrix-react-sdk/pull/6829)). Fixes vector-im/element-web#18969 and vector-im/element-web#18969. + * Make public space alias field mandatory again ([\#6921](https://github.com/matrix-org/matrix-react-sdk/pull/6921)). Fixes vector-im/element-web#19003 and vector-im/element-web#19003. + * Add progress bar to restricted room upgrade dialog ([\#6919](https://github.com/matrix-org/matrix-react-sdk/pull/6919)). Fixes vector-im/element-web#19146 and vector-im/element-web#19146. + * Add customisation point for visibility of invites and room creation ([\#6922](https://github.com/matrix-org/matrix-react-sdk/pull/6922)). Fixes vector-im/element-web#19331 and vector-im/element-web#19331. + * Inhibit `Unable to get validated threepid` error during UIA ([\#6928](https://github.com/matrix-org/matrix-react-sdk/pull/6928)). Fixes vector-im/element-web#18883 and vector-im/element-web#18883. + * Tweak room list skeleton UI height and behaviour ([\#6926](https://github.com/matrix-org/matrix-react-sdk/pull/6926)). Fixes vector-im/element-web#18231 vector-im/element-web#16581 and vector-im/element-web#18231. + * If public room creation fails, retry without publishing it ([\#6872](https://github.com/matrix-org/matrix-react-sdk/pull/6872)). Fixes vector-im/element-web#19194 and vector-im/element-web#19194. Contributed by [AndrewFerr](https://github.com/AndrewFerr). + * Iterate invite your teammates to Space view ([\#6925](https://github.com/matrix-org/matrix-react-sdk/pull/6925)). Fixes vector-im/element-web#18772 and vector-im/element-web#18772. + * Make placeholder more grey when no input ([\#6840](https://github.com/matrix-org/matrix-react-sdk/pull/6840)). Fixes vector-im/element-web#17243 and vector-im/element-web#17243. Contributed by [wlach](https://github.com/wlach). + * Respect tombstones in locally known rooms for Space children ([\#6906](https://github.com/matrix-org/matrix-react-sdk/pull/6906)). Fixes vector-im/element-web#19246 vector-im/element-web#19256 and vector-im/element-web#19246. + * Improve emoji shortcodes generated from annotations ([\#6907](https://github.com/matrix-org/matrix-react-sdk/pull/6907)). Fixes vector-im/element-web#19304 and vector-im/element-web#19304. + * Hide kick & ban options in UserInfo when looking at own profile ([\#6911](https://github.com/matrix-org/matrix-react-sdk/pull/6911)). Fixes vector-im/element-web#19066 and vector-im/element-web#19066. + * Add progress bar to Community to Space migration tool ([\#6887](https://github.com/matrix-org/matrix-react-sdk/pull/6887)). Fixes vector-im/element-web#19216 and vector-im/element-web#19216. + +## 🐛 Bug Fixes + * Fix leave space cancel button exploding ([\#6966](https://github.com/matrix-org/matrix-react-sdk/pull/6966)). + * Fix edge case behaviour of the space join spinner for guests ([\#6972](https://github.com/matrix-org/matrix-react-sdk/pull/6972)). Fixes vector-im/element-web#19359 and vector-im/element-web#19359. + * Convert emoticon to emoji at the end of a line on send even if the cursor isn't there ([\#6965](https://github.com/matrix-org/matrix-react-sdk/pull/6965)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix text overflows button on Home page ([\#6898](https://github.com/matrix-org/matrix-react-sdk/pull/6898)). Fixes vector-im/element-web#19180 and vector-im/element-web#19180. Contributed by [oliver-pham](https://github.com/oliver-pham). + * Space Room View should react to join rule changes down /sync ([\#6945](https://github.com/matrix-org/matrix-react-sdk/pull/6945)). Fixes vector-im/element-web#19390 and vector-im/element-web#19390. + * Hide leave section button if user isn't in the room e.g peeking ([\#6920](https://github.com/matrix-org/matrix-react-sdk/pull/6920)). Fixes vector-im/element-web#17410 and vector-im/element-web#17410. + * Fix bug where room list would get stuck showing no rooms ([\#6939](https://github.com/matrix-org/matrix-react-sdk/pull/6939)). Fixes vector-im/element-web#19373 and vector-im/element-web#19373. + * Update room settings dialog title when room name changes ([\#6916](https://github.com/matrix-org/matrix-react-sdk/pull/6916)). Fixes vector-im/element-web#17480 and vector-im/element-web#17480. Contributed by [psrpinto](https://github.com/psrpinto). + * Fix editing losing emote-ness and rainbow-ness of messages ([\#6931](https://github.com/matrix-org/matrix-react-sdk/pull/6931)). Fixes vector-im/element-web#19350 and vector-im/element-web#19350. + * Remove semicolon from notifications panel ([\#6930](https://github.com/matrix-org/matrix-react-sdk/pull/6930)). Contributed by [robintown](https://github.com/robintown). + * Prevent profile image in left panel's backdrop from being selected ([\#6924](https://github.com/matrix-org/matrix-react-sdk/pull/6924)). Contributed by [rom4nik](https://github.com/rom4nik). + * Validate that the phone number verification field is filled before allowing user to submit ([\#6918](https://github.com/matrix-org/matrix-react-sdk/pull/6918)). Fixes vector-im/element-web#19316 and vector-im/element-web#19316. Contributed by [VFermat](https://github.com/VFermat). + * Updated how save button becomes disabled in room settings to listen for all fields instead of the most recent ([\#6917](https://github.com/matrix-org/matrix-react-sdk/pull/6917)). Contributed by [LoganArnett](https://github.com/LoganArnett). + * Use FocusLock around ContextMenus to simplify focus management ([\#6311](https://github.com/matrix-org/matrix-react-sdk/pull/6311)). Fixes vector-im/element-web#19259 and vector-im/element-web#19259. + * Fix space hierarchy pagination ([\#6908](https://github.com/matrix-org/matrix-react-sdk/pull/6908)). Fixes vector-im/element-web#19276 and vector-im/element-web#19276. + * Fix spaces keyboard shortcuts not working for last space ([\#6909](https://github.com/matrix-org/matrix-react-sdk/pull/6909)). Fixes vector-im/element-web#19255 and vector-im/element-web#19255. + * Use fallback avatar only for DMs with 2 people. ([\#6895](https://github.com/matrix-org/matrix-react-sdk/pull/6895)). Fixes vector-im/element-web#18747 and vector-im/element-web#18747. Contributed by [andybalaam](https://github.com/andybalaam). + +Changes in [3.33.0-rc.2](https://github.com/vector-im/element-desktop/releases/tag/v3.33.0-rc.2) (2021-10-20) +============================================================================================================= + +## 🐛 Bug Fixes + * Fix conflicting CSS on syntax highlighted blocks ([\#6991](https://github.com/matrix-org/matrix-react-sdk/pull/6991)). Fixes vector-im/element-web#19445 + +Changes in [3.33.0-rc.1](https://github.com/vector-im/element-desktop/releases/tag/v3.33.0-rc.1) (2021-10-19) +============================================================================================================= + +## ✨ Features + * Swap order of private space creation and tweak copy ([\#6967](https://github.com/matrix-org/matrix-react-sdk/pull/6967)). Fixes vector-im/element-web#18768 and vector-im/element-web#18768. + * Add spacing to Room settings - Notifications subsection ([\#6962](https://github.com/matrix-org/matrix-react-sdk/pull/6962)). Contributed by [CicadaCinema](https://github.com/CicadaCinema). + * Convert the "Cryptography" settings panel to an HTML to assist screen reader users. ([\#6968](https://github.com/matrix-org/matrix-react-sdk/pull/6968)). Contributed by [andybalaam](https://github.com/andybalaam). + * Use HTML tables for some tabular user interface areas, to assist with screen reader use ([\#6955](https://github.com/matrix-org/matrix-react-sdk/pull/6955)). Contributed by [andybalaam](https://github.com/andybalaam). + * Fix space invite edge cases ([\#6884](https://github.com/matrix-org/matrix-react-sdk/pull/6884)). Fixes vector-im/element-web#19010 vector-im/element-web#17345 and vector-im/element-web#19010. + * Allow options to cascade kicks/bans throughout spaces ([\#6829](https://github.com/matrix-org/matrix-react-sdk/pull/6829)). Fixes vector-im/element-web#18969 and vector-im/element-web#18969. + * Make public space alias field mandatory again ([\#6921](https://github.com/matrix-org/matrix-react-sdk/pull/6921)). Fixes vector-im/element-web#19003 and vector-im/element-web#19003. + * Add progress bar to restricted room upgrade dialog ([\#6919](https://github.com/matrix-org/matrix-react-sdk/pull/6919)). Fixes vector-im/element-web#19146 and vector-im/element-web#19146. + * Add customisation point for visibility of invites and room creation ([\#6922](https://github.com/matrix-org/matrix-react-sdk/pull/6922)). Fixes vector-im/element-web#19331 and vector-im/element-web#19331. + * Inhibit `Unable to get validated threepid` error during UIA ([\#6928](https://github.com/matrix-org/matrix-react-sdk/pull/6928)). Fixes vector-im/element-web#18883 and vector-im/element-web#18883. + * Tweak room list skeleton UI height and behaviour ([\#6926](https://github.com/matrix-org/matrix-react-sdk/pull/6926)). Fixes vector-im/element-web#18231 vector-im/element-web#16581 and vector-im/element-web#18231. + * If public room creation fails, retry without publishing it ([\#6872](https://github.com/matrix-org/matrix-react-sdk/pull/6872)). Fixes vector-im/element-web#19194 and vector-im/element-web#19194. Contributed by [AndrewFerr](https://github.com/AndrewFerr). + * Iterate invite your teammates to Space view ([\#6925](https://github.com/matrix-org/matrix-react-sdk/pull/6925)). Fixes vector-im/element-web#18772 and vector-im/element-web#18772. + * Make placeholder more grey when no input ([\#6840](https://github.com/matrix-org/matrix-react-sdk/pull/6840)). Fixes vector-im/element-web#17243 and vector-im/element-web#17243. Contributed by [wlach](https://github.com/wlach). + * Respect tombstones in locally known rooms for Space children ([\#6906](https://github.com/matrix-org/matrix-react-sdk/pull/6906)). Fixes vector-im/element-web#19246 vector-im/element-web#19256 and vector-im/element-web#19246. + * Improve emoji shortcodes generated from annotations ([\#6907](https://github.com/matrix-org/matrix-react-sdk/pull/6907)). Fixes vector-im/element-web#19304 and vector-im/element-web#19304. + * Hide kick & ban options in UserInfo when looking at own profile ([\#6911](https://github.com/matrix-org/matrix-react-sdk/pull/6911)). Fixes vector-im/element-web#19066 and vector-im/element-web#19066. + * Add progress bar to Community to Space migration tool ([\#6887](https://github.com/matrix-org/matrix-react-sdk/pull/6887)). Fixes vector-im/element-web#19216 and vector-im/element-web#19216. + +## 🐛 Bug Fixes + * Fix leave space cancel button exploding ([\#6966](https://github.com/matrix-org/matrix-react-sdk/pull/6966)). + * Fix edge case behaviour of the space join spinner for guests ([\#6972](https://github.com/matrix-org/matrix-react-sdk/pull/6972)). Fixes vector-im/element-web#19359 and vector-im/element-web#19359. + * Convert emoticon to emoji at the end of a line on send even if the cursor isn't there ([\#6965](https://github.com/matrix-org/matrix-react-sdk/pull/6965)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix text overflows button on Home page ([\#6898](https://github.com/matrix-org/matrix-react-sdk/pull/6898)). Fixes vector-im/element-web#19180 and vector-im/element-web#19180. Contributed by [oliver-pham](https://github.com/oliver-pham). + * Space Room View should react to join rule changes down /sync ([\#6945](https://github.com/matrix-org/matrix-react-sdk/pull/6945)). Fixes vector-im/element-web#19390 and vector-im/element-web#19390. + * Hide leave section button if user isn't in the room e.g peeking ([\#6920](https://github.com/matrix-org/matrix-react-sdk/pull/6920)). Fixes vector-im/element-web#17410 and vector-im/element-web#17410. + * Fix bug where room list would get stuck showing no rooms ([\#6939](https://github.com/matrix-org/matrix-react-sdk/pull/6939)). Fixes vector-im/element-web#19373 and vector-im/element-web#19373. + * Update room settings dialog title when room name changes ([\#6916](https://github.com/matrix-org/matrix-react-sdk/pull/6916)). Fixes vector-im/element-web#17480 and vector-im/element-web#17480. Contributed by [psrpinto](https://github.com/psrpinto). + * Fix editing losing emote-ness and rainbow-ness of messages ([\#6931](https://github.com/matrix-org/matrix-react-sdk/pull/6931)). Fixes vector-im/element-web#19350 and vector-im/element-web#19350. + * Remove semicolon from notifications panel ([\#6930](https://github.com/matrix-org/matrix-react-sdk/pull/6930)). Contributed by [robintown](https://github.com/robintown). + * Prevent profile image in left panel's backdrop from being selected ([\#6924](https://github.com/matrix-org/matrix-react-sdk/pull/6924)). Contributed by [rom4nik](https://github.com/rom4nik). + * Validate that the phone number verification field is filled before allowing user to submit ([\#6918](https://github.com/matrix-org/matrix-react-sdk/pull/6918)). Fixes vector-im/element-web#19316 and vector-im/element-web#19316. Contributed by [VFermat](https://github.com/VFermat). + * Updated how save button becomes disabled in room settings to listen for all fields instead of the most recent ([\#6917](https://github.com/matrix-org/matrix-react-sdk/pull/6917)). Contributed by [LoganArnett](https://github.com/LoganArnett). + * Use FocusLock around ContextMenus to simplify focus management ([\#6311](https://github.com/matrix-org/matrix-react-sdk/pull/6311)). Fixes vector-im/element-web#19259 and vector-im/element-web#19259. + * Fix space hierarchy pagination ([\#6908](https://github.com/matrix-org/matrix-react-sdk/pull/6908)). Fixes vector-im/element-web#19276 and vector-im/element-web#19276. + * Fix spaces keyboard shortcuts not working for last space ([\#6909](https://github.com/matrix-org/matrix-react-sdk/pull/6909)). Fixes vector-im/element-web#19255 and vector-im/element-web#19255. + * Use fallback avatar only for DMs with 2 people. ([\#6895](https://github.com/matrix-org/matrix-react-sdk/pull/6895)). Fixes vector-im/element-web#18747 and vector-im/element-web#18747. Contributed by [andybalaam](https://github.com/andybalaam). + Changes in [3.32.1](https://github.com/vector-im/element-desktop/releases/tag/v3.32.1) (2021-10-12) =================================================================================================== diff --git a/package.json b/package.json index 02eb6f4c05..55b06cac8d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.32.1", + "version": "3.33.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { diff --git a/res/css/_common.scss b/res/css/_common.scss index d90893b8ed..1284a5c499 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -141,12 +141,6 @@ input[type=search]::-webkit-search-results-decoration { input::placeholder, textarea::placeholder { opacity: initial; - font-weight: 400; -} -input::-moz-placeholder, -textarea::-moz-placeholder { - opacity: .6; - font-weight: 400; } input[type=text], input[type=password], textarea { diff --git a/res/css/_components.scss b/res/css/_components.scss index 26e36b8cdd..73e25d314f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -200,10 +200,10 @@ @import "./views/right_panel/_EncryptionInfo.scss"; @import "./views/right_panel/_PinnedMessagesCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss"; +@import "./views/right_panel/_ThreadPanel.scss"; @import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_VerificationPanel.scss"; @import "./views/right_panel/_WidgetCard.scss"; -@import "./views/right_panel/_ThreadPanel.scss"; @import "./views/room_settings/_AliasSettings.scss"; @import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_Autocomplete.scss"; diff --git a/res/css/structures/_CreateRoom.scss b/res/css/structures/_CreateRoom.scss index 3d23ccc4b2..78e6881b10 100644 --- a/res/css/structures/_CreateRoom.scss +++ b/res/css/structures/_CreateRoom.scss @@ -34,4 +34,3 @@ limitations under the License. .mx_CreateRoom_description { width: 330px; } - diff --git a/res/css/structures/_LeftPanel.scss b/res/css/structures/_LeftPanel.scss index 5ddea244f3..a658005821 100644 --- a/res/css/structures/_LeftPanel.scss +++ b/res/css/structures/_LeftPanel.scss @@ -43,8 +43,6 @@ $roomListCollapsedWidth: 68px; } } - - .mx_LeftPanel { background-color: $roomlist-bg-color; // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index fd9c4a14fc..50fa304bd6 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -32,7 +32,6 @@ limitations under the License. position: relative; } - @keyframes mx_RoomView_fileDropTarget_animation { from { opacity: 0; @@ -112,7 +111,6 @@ limitations under the License. max-width: 1920px !important; } - .mx_RoomView .mx_MainSplit { flex: 1 1 0; } diff --git a/res/css/structures/_SpaceHierarchy.scss b/res/css/structures/_SpaceHierarchy.scss index a5d589f9c2..5735ef016d 100644 --- a/res/css/structures/_SpaceHierarchy.scss +++ b/res/css/structures/_SpaceHierarchy.scss @@ -203,7 +203,8 @@ limitations under the License. grid-row: 1; grid-column: 2; - .mx_InfoTooltip { + .mx_InfoTooltip, + .mx_SpaceHierarchy_roomTile_joined { display: inline; margin-left: 12px; color: $tertiary-content; @@ -222,6 +223,25 @@ limitations under the License. } } } + + .mx_SpaceHierarchy_roomTile_joined { + position: relative; + padding-left: 16px; + + &::before { + content: ''; + width: 20px; + height: 20px; + top: -2px; + left: -4px; + position: absolute; + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + background-color: $accent-color; + mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); + } + } } .mx_SpaceHierarchy_roomTile_info { @@ -268,6 +288,11 @@ limitations under the License. visibility: visible; } } + + &.mx_SpaceHierarchy_joining .mx_AccessibleButton { + visibility: visible; + padding: 4px 18px; + } } li.mx_SpaceHierarchy_roomTileWrapper { diff --git a/res/css/structures/_SpacePanel.scss b/res/css/structures/_SpacePanel.scss index f590de18c1..4be9d49120 100644 --- a/res/css/structures/_SpacePanel.scss +++ b/res/css/structures/_SpacePanel.scss @@ -348,7 +348,6 @@ $activeBorderColor: $secondary-content; } } - .mx_SpacePanel_sharePublicSpace { margin: 0; } diff --git a/res/css/structures/_SpaceRoomView.scss b/res/css/structures/_SpaceRoomView.scss index e6394525c5..51b5244c5f 100644 --- a/res/css/structures/_SpaceRoomView.scss +++ b/res/css/structures/_SpaceRoomView.scss @@ -380,45 +380,6 @@ $SpaceRoomViewInnerWidth: 428px; } } - .mx_SpaceRoomView_betaWarning { - padding: 12px 12px 12px 54px; - position: relative; - font-size: $font-15px; - line-height: $font-24px; - width: 432px; - border-radius: 8px; - background-color: $info-plinth-bg-color; - color: $secondary-content; - box-sizing: border-box; - - > h3 { - font-weight: $font-semi-bold; - font-size: inherit; - line-height: inherit; - margin: 0; - } - - > p { - font-size: inherit; - line-height: inherit; - margin: 0; - } - - &::before { - mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); - mask-position: center; - mask-repeat: no-repeat; - mask-size: contain; - content: ''; - width: 20px; - height: 20px; - position: absolute; - top: 14px; - left: 14px; - background-color: $secondary-content; - } - } - .mx_SpaceRoomView_inviteTeammates { // XXX remove this when spaces leaves Beta .mx_SpaceRoomView_inviteTeammates_betaDisclaimer { diff --git a/res/css/views/dialogs/_JoinRuleDropdown.scss b/res/css/views/dialogs/_JoinRuleDropdown.scss index 91691cf53b..19209e9536 100644 --- a/res/css/views/dialogs/_JoinRuleDropdown.scss +++ b/res/css/views/dialogs/_JoinRuleDropdown.scss @@ -64,4 +64,3 @@ limitations under the License. mask-size: contain; } } - diff --git a/res/css/views/dialogs/_MessageEditHistoryDialog.scss b/res/css/views/dialogs/_MessageEditHistoryDialog.scss index 4574344a28..f60bbc9589 100644 --- a/res/css/views/dialogs/_MessageEditHistoryDialog.scss +++ b/res/css/views/dialogs/_MessageEditHistoryDialog.scss @@ -64,4 +64,3 @@ limitations under the License. padding: 0 8px; } } - diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index 9bcde6e1e0..cad83e2a42 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -58,4 +58,3 @@ limitations under the License. mask-size: 36px; mask-position: center; } - diff --git a/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss b/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss index 941c8cb807..05e7f5c2e4 100644 --- a/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss +++ b/res/css/views/dialogs/_RoomUpgradeWarningDialog.scss @@ -50,4 +50,3 @@ limitations under the License. vertical-align: middle; } } - diff --git a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss index 176919b84c..8786defed3 100644 --- a/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss +++ b/res/css/views/dialogs/_WidgetCapabilitiesPromptDialog.scss @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ - .mx_WidgetCapabilitiesPromptDialog { .text-muted { font-size: $font-12px; @@ -55,7 +54,6 @@ limitations under the License. width: $font-32px; height: $font-15px; - &.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { left: calc(100% - $font-15px); } diff --git a/res/css/views/elements/_Dropdown.scss b/res/css/views/elements/_Dropdown.scss index 1acac70e42..e1e265f701 100644 --- a/res/css/views/elements/_Dropdown.scss +++ b/res/css/views/elements/_Dropdown.scss @@ -130,4 +130,3 @@ input.mx_Dropdown_option:focus { margin-left: 5px; margin-bottom: 5px; } - diff --git a/res/css/views/elements/_EditableItemList.scss b/res/css/views/elements/_EditableItemList.scss index f089fa3dc2..8987510a18 100644 --- a/res/css/views/elements/_EditableItemList.scss +++ b/res/css/views/elements/_EditableItemList.scss @@ -61,4 +61,3 @@ limitations under the License. .mx_EditableItemList_label { margin-bottom: 5px; } - diff --git a/res/css/views/elements/_Slider.scss b/res/css/views/elements/_Slider.scss index 730da021bd..3cfc14ab46 100644 --- a/res/css/views/elements/_Slider.scss +++ b/res/css/views/elements/_Slider.scss @@ -58,7 +58,6 @@ limitations under the License. height: $slider-selection-dot-size; background-color: $slider-selection-color; border-radius: 50%; - box-shadow: 0 0 6px lightgrey; z-index: 10; } diff --git a/res/css/views/elements/_StyledCheckbox.scss b/res/css/views/elements/_StyledCheckbox.scss index e2d61c033b..1467474b05 100644 --- a/res/css/views/elements/_StyledCheckbox.scss +++ b/res/css/views/elements/_StyledCheckbox.scss @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ - .mx_Checkbox { $size: $font-16px; $border-size: $font-1-5px; diff --git a/res/css/views/elements/_TooltipButton.scss b/res/css/views/elements/_TooltipButton.scss index 0c85dac818..5b7c0ce14d 100644 --- a/res/css/views/elements/_TooltipButton.scss +++ b/res/css/views/elements/_TooltipButton.scss @@ -49,4 +49,3 @@ limitations under the License. text-align: start; line-height: 17px !important; } - diff --git a/res/css/views/messages/_MImageReplyBody.scss b/res/css/views/messages/_MImageReplyBody.scss index 70c53f8c9c..3207443d65 100644 --- a/res/css/views/messages/_MImageReplyBody.scss +++ b/res/css/views/messages/_MImageReplyBody.scss @@ -34,4 +34,3 @@ limitations under the License. } } } - diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index b400a933ae..ad986575a2 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -39,7 +39,6 @@ limitations under the License. background-color: $notice-primary-color; } - .mx_cryptoEvent_state, .mx_cryptoEvent_buttons { grid-column: 3; grid-row: 1 / 3; diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index c137bb7677..e08a11cd36 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -83,7 +83,7 @@ limitations under the License. } .mx_RoomSummaryCard_e2ee_warning { - background-color: #ff4b55; + background-color: #ff5b55; &::before { mask-image: url('$(res)/img/e2e/warning.svg'); } diff --git a/res/css/views/right_panel/_ThreadPanel.scss b/res/css/views/right_panel/_ThreadPanel.scss index d06981a715..06137196a3 100644 --- a/res/css/views/right_panel/_ThreadPanel.scss +++ b/res/css/views/right_panel/_ThreadPanel.scss @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ - .mx_ThreadPanel { display: flex; flex-direction: column; diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 9a09d96bc9..a015ef29a7 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -223,7 +223,6 @@ limitations under the License. display: flex; margin: 8px 0; - &.mx_UserInfo_device_verified { .mx_UserInfo_device_trusted { color: $accent-color; @@ -267,7 +266,6 @@ limitations under the License. margin: 16px 0 8px; } - .mx_VerificationShowSas { .mx_AccessibleButton + .mx_AccessibleButton { margin: 8px 0; // space between buttons diff --git a/res/css/views/right_panel/_VerificationPanel.scss b/res/css/views/right_panel/_VerificationPanel.scss index 95856a5d69..0db93f58cc 100644 --- a/res/css/views/right_panel/_VerificationPanel.scss +++ b/res/css/views/right_panel/_VerificationPanel.scss @@ -23,7 +23,6 @@ limitations under the License. } } - .mx_UserInfo { .mx_EncryptionPanel_cancel { mask: url('$(res)/img/feather-customised/cancel.svg'); diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index cfcb0c48a2..1276b13fde 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -365,7 +365,6 @@ $MinWidth: 240px; to { opacity: 1; } } - .mx_AppLoading iframe { display: none; } diff --git a/res/css/views/rooms/_EditMessageComposer.scss b/res/css/views/rooms/_EditMessageComposer.scss index bf3c7c9b42..136ae0d4eb 100644 --- a/res/css/views/rooms/_EditMessageComposer.scss +++ b/res/css/views/rooms/_EditMessageComposer.scss @@ -24,7 +24,6 @@ limitations under the License. margin: -7px -10px -5px -10px; overflow: visible !important; // override mx_EventTile_content - .mx_BasicMessageComposer_input { border-radius: 4px; border: solid 1px $primary-hairline-color; diff --git a/res/css/views/rooms/_EventBubbleTile.scss b/res/css/views/rooms/_EventBubbleTile.scss index 2a419530d8..1c8f4230b6 100644 --- a/res/css/views/rooms/_EventBubbleTile.scss +++ b/res/css/views/rooms/_EventBubbleTile.scss @@ -232,7 +232,7 @@ limitations under the License. .mx_EditMessageComposer_buttons { position: static; padding: 0; - margin: 0; + margin: 8px 0 0; background: transparent; } @@ -263,7 +263,6 @@ limitations under the License. } } - .mx_EventTile_readAvatars { position: absolute; right: -110px; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 6e950dea84..90fd3e9203 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -401,7 +401,6 @@ $left-gutter: 64px; cursor: pointer; } - .mx_EventTile_e2eIcon { position: relative; width: 14px; @@ -486,7 +485,7 @@ $left-gutter: 64px; pre, code { font-family: $monospace-font-family !important; - background-color: $header-panel-bg-color; + background-color: $codeblock-background-color; } pre code > * { @@ -581,7 +580,6 @@ $left-gutter: 64px; color: inherit; } - /* Make h1 and h2 the same size as h3. */ .mx_EventTile_content .markdown-body h1, .mx_EventTile_content .markdown-body h2 { @@ -613,7 +611,6 @@ $left-gutter: 64px; /* end of overrides */ - .mx_EventTile_keyRequestInfo { font-size: $font-12px; } @@ -731,8 +728,6 @@ $left-gutter: 64px; } } - - .mx_ThreadView { display: flex; flex-direction: column; diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 62cbdb910a..d824e8105e 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -247,7 +247,6 @@ limitations under the License. } } - .mx_MessageComposer_upload::before { mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); } diff --git a/res/css/views/rooms/_MessageComposerFormatBar.scss b/res/css/views/rooms/_MessageComposerFormatBar.scss index b02afac079..ce7aed2dee 100644 --- a/res/css/views/rooms/_MessageComposerFormatBar.scss +++ b/res/css/views/rooms/_MessageComposerFormatBar.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_MessageComposerFormatBar { display: none; - width: calc(32px * 5); + width: calc(32px * 6); height: 32px; position: absolute; cursor: pointer; @@ -87,6 +87,11 @@ limitations under the License. .mx_MessageComposerFormatBar_buttonIconCode::after { mask-image: url('$(res)/img/element-icons/room/format-bar/code.svg'); } + + .mx_MessageComposerFormatBar_buttonIconInsertLink::after { + mask-image: url('$(res)/img/element-icons/link.svg'); + mask-size: 18px; + } } .mx_MessageComposerFormatBar_buttonTooltip { diff --git a/res/css/views/rooms/_ReplyPreview.scss b/res/css/views/rooms/_ReplyPreview.scss index 70a820e412..eb0233108b 100644 --- a/res/css/views/rooms/_ReplyPreview.scss +++ b/res/css/views/rooms/_ReplyPreview.scss @@ -52,4 +52,3 @@ limitations under the License. } } } - diff --git a/res/css/views/rooms/_ReplyTile.scss b/res/css/views/rooms/_ReplyTile.scss index 3ef6491ec9..a03f0b38cf 100644 --- a/res/css/views/rooms/_ReplyTile.scss +++ b/res/css/views/rooms/_ReplyTile.scss @@ -78,7 +78,8 @@ limitations under the License. // Hack to cut content in
 tags too
         .mx_EventTile_pre_container > pre {
-            overflow: hidden;
+            overflow-x: scroll;
+            overflow-y: hidden;
             text-overflow: ellipsis;
             display: -webkit-box;
             -webkit-box-orient: vertical;
diff --git a/res/css/views/rooms/_SearchBar.scss b/res/css/views/rooms/_SearchBar.scss
index e08168a122..234363245a 100644
--- a/res/css/views/rooms/_SearchBar.scss
+++ b/res/css/views/rooms/_SearchBar.scss
@@ -68,4 +68,3 @@ limitations under the License.
         cursor: pointer;
     }
 }
-
diff --git a/res/css/views/rooms/_SendMessageComposer.scss b/res/css/views/rooms/_SendMessageComposer.scss
index 4b7eb54188..c7e6ea6a6e 100644
--- a/res/css/views/rooms/_SendMessageComposer.scss
+++ b/res/css/views/rooms/_SendMessageComposer.scss
@@ -47,4 +47,3 @@ limitations under the License.
         }
     }
 }
-
diff --git a/res/css/views/settings/_E2eAdvancedPanel.scss b/res/css/views/settings/_E2eAdvancedPanel.scss
index 9e32685d12..3f180e6fcd 100644
--- a/res/css/views/settings/_E2eAdvancedPanel.scss
+++ b/res/css/views/settings/_E2eAdvancedPanel.scss
@@ -17,4 +17,3 @@ limitations under the License.
 .mx_E2eAdvancedPanel_settingLongDescription {
     margin-right: 150px;
 }
-
diff --git a/res/css/views/settings/_ThemeChoicePanel.scss b/res/css/views/settings/_ThemeChoicePanel.scss
index 39b73e7837..a335b6e68e 100644
--- a/res/css/views/settings/_ThemeChoicePanel.scss
+++ b/res/css/views/settings/_ThemeChoicePanel.scss
@@ -85,4 +85,3 @@ limitations under the License.
         }
     }
 }
-
diff --git a/res/css/views/voip/CallView/_CallViewButtons.scss b/res/css/views/voip/CallView/_CallViewButtons.scss
index 8e343f0ff3..d9396ac3ee 100644
--- a/res/css/views/voip/CallView/_CallViewButtons.scss
+++ b/res/css/views/voip/CallView/_CallViewButtons.scss
@@ -35,7 +35,6 @@ limitations under the License.
         margin-left: 2px;
         margin-right: 2px;
 
-
         &::before {
             content: '';
             display: inline-block;
@@ -48,7 +47,6 @@ limitations under the License.
             background-position: center;
         }
 
-
         &.mx_CallViewButtons_dialpad::before {
             background-image: url('$(res)/img/voip/dialpad.svg');
         }
diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss
index aa0aa4e2a6..088486bdf6 100644
--- a/res/css/views/voip/_CallView.scss
+++ b/res/css/views/voip/_CallView.scss
@@ -200,7 +200,6 @@ limitations under the License.
     }
 }
 
-
 .mx_CallView_presenting {
     opacity: 1;
     transition: opacity 0.5s;
diff --git a/res/img/element-icons/message/thread.svg b/res/img/element-icons/message/thread.svg
index b4a7cc0066..cd1271b513 100644
--- a/res/img/element-icons/message/thread.svg
+++ b/res/img/element-icons/message/thread.svg
@@ -1,4 +1 @@
-
-
-
-
+
\ No newline at end of file
diff --git a/res/img/element-icons/room/default_cal.svg b/res/img/element-icons/room/default_cal.svg
index fc440b4553..65bf98a42d 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/warning-badge.svg b/res/img/element-icons/warning-badge.svg
index 1c8da9aa8e..09e0944bdb 100644
--- a/res/img/element-icons/warning-badge.svg
+++ b/res/img/element-icons/warning-badge.svg
@@ -28,5 +28,5 @@
   
+     style="fill:#ff5b55;fill-opacity:1" />
 
diff --git a/res/img/feather-customised/bug.svg b/res/img/feather-customised/bug.svg
index babc4fed0e..8939f8be11 100644
--- a/res/img/feather-customised/bug.svg
+++ b/res/img/feather-customised/bug.svg
@@ -1,3 +1,3 @@
 
-
+
 
diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss
index ca06d38b1f..865eb4de43 100644
--- a/res/themes/dark/css/_dark.scss
+++ b/res/themes/dark/css/_dark.scss
@@ -165,6 +165,9 @@ $button-link-bg-color: transparent;
 // Toggle switch
 $togglesw-off-color: $room-highlight-color;
 
+// Slider
+$slider-background-color: $quinary-content;
+
 $progressbar-fg-color: $accent-color;
 $progressbar-bg-color: $system;
 
@@ -209,6 +212,8 @@ $appearance-tab-border-color: $room-highlight-color;
 
 $composer-shadow-color: rgba(0, 0, 0, 0.28);
 
+$codeblock-background-color: #2a3039;
+
 // Bubble tiles
 $eventbubble-self-bg: #14322E;
 $eventbubble-others-bg: $event-selected-color;
diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss
index 0fe9f22dde..5fa0e903ba 100644
--- a/res/themes/legacy-dark/css/_legacy-dark.scss
+++ b/res/themes/legacy-dark/css/_legacy-dark.scss
@@ -221,6 +221,8 @@ $appearance-tab-border-color: $room-highlight-color;
 
 $composer-shadow-color: tranparent;
 
+$codeblock-background-color: #2a3039;
+
 // Bubble tiles
 $eventbubble-self-bg: #14322E;
 $eventbubble-others-bg: $event-selected-color;
diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss
index 09dd46d151..7fe5649787 100644
--- a/res/themes/legacy-light/css/_legacy-light.scss
+++ b/res/themes/legacy-light/css/_legacy-light.scss
@@ -334,6 +334,8 @@ $appearance-tab-border-color: $input-darker-bg-color;
 
 $composer-shadow-color: tranparent;
 
+$codeblock-background-color: $header-panel-bg-color;
+
 // Bubble tiles
 $eventbubble-self-bg: #F0FBF8;
 $eventbubble-others-bg: $system;
diff --git a/res/themes/light-high-contrast/css/_light-high-contrast.scss b/res/themes/light-high-contrast/css/_light-high-contrast.scss
index bb5fa16056..f1f4387a06 100644
--- a/res/themes/light-high-contrast/css/_light-high-contrast.scss
+++ b/res/themes/light-high-contrast/css/_light-high-contrast.scss
@@ -1,11 +1,12 @@
 //// Reference: https://www.figma.com/file/RnLKnv09glhxGIZtn8zfmh/UI-Themes-%26-Accessibility?node-id=321%3A65847
 $accent: #268075;
 $alert: #D62C25;
-$notice-primary-color: #D61C25;
 $links: #0A6ECA;
 $secondary-content: #5E6266;
-$tertiary-content: #5E6266;   // Same as secondary
-$quaternary-content: #5E6266; // Same as secondary
+$tertiary-content: $secondary-content;
+$quaternary-content: $secondary-content;
+$quinary-content: $secondary-content;
+$roomlist-button-bg-color: rgba(141, 151, 165, 0.2);
 
 $username-variant1-color: #0A6ECA;
 $username-variant2-color: #AC3BA8;
@@ -18,9 +19,13 @@ $username-variant8-color: #3E810A;
 
 $accent-color: $accent;
 $accent-color-50pct: rgba($accent-color, 0.5);
+$accent-color-alt: $links;
+$input-border-color: $secondary-content;
 $input-darker-bg-color: $quinary-content;
+$input-darker-fg-color: $secondary-content;
 $input-lighter-fg-color: $input-darker-fg-color;
 $input-valid-border-color: $accent-color;
+$input-focused-border-color: $accent-color;
 $button-bg-color: $accent-color;
 $resend-button-divider-color: $input-darker-bg-color;
 $icon-button-color: $quaternary-content;
@@ -41,12 +46,14 @@ $voice-record-stop-border-color: $quinary-content;
 $voice-record-icon-color: $tertiary-content;
 $appearance-tab-border-color: $input-darker-bg-color;
 $eventbubble-reply-color: $quaternary-content;
+$notice-primary-color: $alert;
 $warning-color: $notice-primary-color; // red
 $pinned-unread-color: $notice-primary-color;
 $button-danger-bg-color: $notice-primary-color;
 $mention-user-pill-bg-color: $warning-color;
 $input-invalid-border-color: $warning-color;
 $event-highlight-fg-color: $warning-color;
+$roomtopic-color: $secondary-content;
 
 @define-mixin mx_DialogButton_danger {
     background-color: $accent-color;
@@ -64,3 +71,38 @@ $event-highlight-fg-color: $warning-color;
     color: $accent-color;
     text-decoration: none;
 }
+
+.mx_AccessibleButton {
+    margin-left: 4px;
+}
+
+.mx_AccessibleButton:focus {
+    outline: 2px solid $accent-color;
+    outline-offset: 2px;
+}
+
+.mx_BasicMessageComposer .mx_BasicMessageComposer_inputEmpty > :first-child::before {
+    color: $secondary-content;
+    opacity: 1 !important;
+}
+
+.mx_TextualEvent {
+    color: $secondary-content;
+    opacity: 1 !important;
+}
+
+.mx_Dialog, .mx_MatrixChat_wrapper {
+    :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=text]::placeholder,
+    :not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=search]::placeholder,
+    .mx_textinput input::placeholder {
+        color: $input-darker-fg-color !important;
+    }
+}
+
+.mx_UserMenu_contextMenu .mx_UserMenu_contextMenu_header .mx_UserMenu_contextMenu_themeButton {
+    background-color: $roomlist-button-bg-color !important;
+}
+
+.mx_FontScalingPanel_fontSlider {
+    background-color: $roomlist-button-bg-color !important;
+}
diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss
index 1733831b95..ef4ab0115d 100644
--- a/res/themes/light/css/_light.scss
+++ b/res/themes/light/css/_light.scss
@@ -35,7 +35,7 @@ $space-nav: rgba($tertiary-content, 0.15);
 // try to use these colors when possible
 $accent-color: $accent;
 $accent-bg-color: rgba(3, 179, 129, 0.16);
-$notice-primary-color: #ff4b55;
+$notice-primary-color: $alert;
 $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
 $header-panel-bg-color: #f3f8fd;
 
@@ -318,8 +318,8 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
 
 // These two don't change between themes. They are the $warning-color, but we don't
 // want custom themes to affect them by accident.
-$voice-record-stop-symbol-color: #ff4b55;
-$voice-record-live-circle-color: #ff4b55;
+$voice-record-stop-symbol-color: #ff5b55;
+$voice-record-live-circle-color: #ff5b55;
 
 $voice-record-stop-border-color: $quinary-content;
 $voice-record-icon-color: $tertiary-content;
@@ -333,6 +333,8 @@ $appearance-tab-border-color: $input-darker-bg-color;
 }
 $composer-shadow-color: rgba(0, 0, 0, 0.04);
 
+$codeblock-background-color: $header-panel-bg-color;
+
 // Bubble tiles
 $eventbubble-self-bg: #F0FBF8;
 $eventbubble-others-bg: $system;
diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts
index 38f237b9c3..a9d8e9547f 100644
--- a/src/@types/global.d.ts
+++ b/src/@types/global.d.ts
@@ -99,6 +99,7 @@ declare global {
         mxSkinner?: Skinner;
         mxOnRecaptchaLoaded?: () => void;
         electron?: Electron;
+        mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise;
     }
 
     interface DesktopCapturerSource {
diff --git a/src/Modal.tsx b/src/Modal.tsx
index 1e84078ddb..a802e36d96 100644
--- a/src/Modal.tsx
+++ b/src/Modal.tsx
@@ -18,7 +18,7 @@ limitations under the License.
 import React from 'react';
 import ReactDOM from 'react-dom';
 import classNames from 'classnames';
-import { defer } from "matrix-js-sdk/src/utils";
+import { defer, sleep } from "matrix-js-sdk/src/utils";
 
 import Analytics from './Analytics';
 import dis from './dispatcher/dispatcher';
@@ -332,7 +332,10 @@ export class ModalManager {
         return this.priorityModal ? this.priorityModal : (this.modals[0] || this.staticModal);
     }
 
-    private reRender() {
+    private async reRender() {
+        // await next tick because sometimes ReactDOM can race with itself and cause the modal to wrongly stick around
+        await sleep(0);
+
         if (this.modals.length === 0 && !this.priorityModal && !this.staticModal) {
             // If there is no modal to render, make all of Element available
             // to screen reader users again
diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts
index 83115bcbf8..5914c8799e 100644
--- a/src/SecurityManager.ts
+++ b/src/SecurityManager.ts
@@ -32,6 +32,10 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces
 import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
 import SettingsStore from "./settings/SettingsStore";
 import SecurityCustomisations from "./customisations/Security";
+import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
+
+import { logger } from "matrix-js-sdk/src/logger";
+import { ComponentType } from "react";
 
 // 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
@@ -335,7 +339,9 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
             // This dialog calls bootstrap itself after guiding the user through
             // passphrase creation.
             const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
-                import("./async-components/views/dialogs/security/CreateSecretStorageDialog"),
+                import(
+                    "./async-components/views/dialogs/security/CreateSecretStorageDialog"
+                ) as unknown as Promise>,
                 {
                     forceReset,
                 },
diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx
index b97f5bde06..b5734d7bfc 100644
--- a/src/SlashCommands.tsx
+++ b/src/SlashCommands.tsx
@@ -1013,14 +1013,14 @@ export const Commands = [
     new Command({
         command: "msg",
         description: _td("Sends a message to the given user"),
-        args: " ",
+        args: " []",
         runFn: function(roomId, args) {
             if (args) {
                 // matches the first whitespace delimited group and then the rest of the string
                 const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
                 if (matches) {
                     const [userId, msg] = matches.slice(1);
-                    if (msg && userId && userId.startsWith("@") && userId.includes(":")) {
+                    if (userId && userId.startsWith("@") && userId.includes(":")) {
                         return success((async () => {
                             const cli = MatrixClientPeg.get();
                             const roomId = await ensureDMExists(cli, userId);
@@ -1028,7 +1028,9 @@ export const Commands = [
                                 action: 'view_room',
                                 room_id: roomId,
                             });
-                            cli.sendTextMessage(roomId, msg);
+                            if (msg) {
+                                cli.sendTextMessage(roomId, msg);
+                            }
                         })());
                     }
                 }
diff --git a/src/Terms.ts b/src/Terms.ts
index 325a132daa..4b1480f536 100644
--- a/src/Terms.ts
+++ b/src/Terms.ts
@@ -180,7 +180,7 @@ export async function startTermsFlow(
     return Promise.all(agreePromises);
 }
 
-export function dialogTermsInteractionCallback(
+export async function dialogTermsInteractionCallback(
     policiesAndServicePairs: {
         service: Service;
         policies: { [policy: string]: Policy };
@@ -188,21 +188,18 @@ export function dialogTermsInteractionCallback(
     agreedUrls: string[],
     extraClassNames?: string,
 ): Promise {
-    return new Promise((resolve, reject) => {
-        logger.log("Terms that need agreement", policiesAndServicePairs);
-        // FIXME: Using an import will result in test failures
-        const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");
+    logger.log("Terms that need agreement", policiesAndServicePairs);
+    // FIXME: Using an import will result in test failures
+    const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");
 
-        Modal.createTrackedDialog('Terms of Service', '', TermsDialog, {
-            policiesAndServicePairs,
-            agreedUrls,
-            onFinished: (done, agreedUrls) => {
-                if (!done) {
-                    reject(new TermsNotSignedError());
-                    return;
-                }
-                resolve(agreedUrls);
-            },
-        }, classNames("mx_TermsDialog", extraClassNames));
-    });
+    const { finished } = Modal.createTrackedDialog<[boolean, string[]]>('Terms of Service', '', TermsDialog, {
+        policiesAndServicePairs,
+        agreedUrls,
+    }, classNames("mx_TermsDialog", extraClassNames));
+
+    const [done, _agreedUrls] = await finished;
+    if (!done) {
+        throw new TermsNotSignedError();
+    }
+    return _agreedUrls;
 }
diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx
index 13348b2992..63ecaed642 100644
--- a/src/TextForEvent.tsx
+++ b/src/TextForEvent.tsx
@@ -13,6 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 See the License for the specific language governing permissions and
 limitations under the License.
 */
+
 import React from 'react';
 import { MatrixEvent } from "matrix-js-sdk/src/models/event";
 import { logger } from "matrix-js-sdk/src/logger";
@@ -28,7 +29,11 @@ import { RightPanelPhases } from './stores/RightPanelStorePhases';
 import { Action } from './dispatcher/actions';
 import defaultDispatcher from './dispatcher/dispatcher';
 import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials";
+import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
 import { MatrixClientPeg } from "./MatrixClientPeg";
+import { ROOM_SECURITY_TAB } from "./components/views/dialogs/RoomSettingsDialog";
 
 // These functions are frequently used just to check whether an event has
 // any text to display at all. For this reason they return deferred values
@@ -201,17 +206,38 @@ function textForTombstoneEvent(ev: MatrixEvent): () => string | null {
     return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName });
 }
 
-function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
+const onViewJoinRuleSettingsClick = () => {
+    defaultDispatcher.dispatch({
+        action: "open_room_settings",
+        initial_tab_id: ROOM_SECURITY_TAB,
+    });
+};
+
+function textForJoinRulesEvent(ev: MatrixEvent, allowJSX: boolean): () => string | JSX.Element | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     switch (ev.getContent().join_rule) {
-        case "public":
+        case JoinRule.Public:
             return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', {
                 senderDisplayName,
             });
-        case "invite":
+        case JoinRule.Invite:
             return () => _t('%(senderDisplayName)s made the room invite only.', {
                 senderDisplayName,
             });
+        case JoinRule.Restricted:
+            if (allowJSX) {
+                return () => 
+                    { _t('%(senderDisplayName)s changed who can join this room. View settings.', {
+                        senderDisplayName,
+                    }, {
+                        "a": (sub) => 
+                            { sub }
+                        ,
+                    }) }
+                ;
+            }
+
+            return () => _t('%(senderDisplayName)s changed who can join this room.', { senderDisplayName });
         default:
             // The spec supports "knock" and "private", however nothing implements these.
             return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', {
@@ -224,9 +250,9 @@ function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
 function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
     const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
     switch (ev.getContent().guest_access) {
-        case "can_join":
+        case GuestAccess.CanJoin:
             return () => _t('%(senderDisplayName)s has allowed guests to join the room.', { senderDisplayName });
-        case "forbidden":
+        case GuestAccess.Forbidden:
             return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', { senderDisplayName });
         default:
             // There's no other options we can expect, however just for safety's sake we'll do this.
@@ -312,11 +338,11 @@ function textForMessageEvent(ev: MatrixEvent): () => string | null {
  || redactedBecauseUserId });
             }
         }
-        if (ev.getContent().msgtype === "m.emote") {
+        if (ev.getContent().msgtype === MsgType.Emote) {
             message = "* " + senderDisplayName + " " + message;
-        } else if (ev.getContent().msgtype === "m.image") {
+        } else if (ev.getContent().msgtype === MsgType.Image) {
             message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName });
-        } else if (ev.getType() == "m.sticker") {
+        } else if (ev.getType() == EventType.Sticker) {
             message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName });
         } else {
             // in this case, parse it as a plain text message
@@ -396,15 +422,15 @@ function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
 function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
     const senderName = event.sender ? event.sender.name : event.getSender();
     switch (event.getContent().history_visibility) {
-        case 'invited':
+        case HistoryVisibility.Invited:
             return () => _t('%(senderName)s made future room history visible to all room members, '
                 + 'from the point they are invited.', { senderName });
-        case 'joined':
+        case HistoryVisibility.Joined:
             return () => _t('%(senderName)s made future room history visible to all room members, '
                 + 'from the point they joined.', { senderName });
-        case 'shared':
+        case HistoryVisibility.Shared:
             return () => _t('%(senderName)s made future room history visible to all room members.', { senderName });
-        case 'world_readable':
+        case HistoryVisibility.WorldReadable:
             return () => _t('%(senderName)s made future room history visible to anyone.', { senderName });
         default:
             return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
@@ -695,25 +721,25 @@ interface IHandlers {
 }
 
 const handlers: IHandlers = {
-    'm.room.message': textForMessageEvent,
-    'm.sticker': textForMessageEvent,
-    'm.call.invite': textForCallInviteEvent,
+    [EventType.RoomMessage]: textForMessageEvent,
+    [EventType.Sticker]: textForMessageEvent,
+    [EventType.CallInvite]: textForCallInviteEvent,
 };
 
 const stateHandlers: IHandlers = {
-    'm.room.canonical_alias': textForCanonicalAliasEvent,
-    'm.room.name': textForRoomNameEvent,
-    'm.room.topic': textForTopicEvent,
-    'm.room.member': textForMemberEvent,
-    "m.room.avatar": textForRoomAvatarEvent,
-    'm.room.third_party_invite': textForThreePidInviteEvent,
-    'm.room.history_visibility': textForHistoryVisibilityEvent,
-    'm.room.power_levels': textForPowerEvent,
-    'm.room.pinned_events': textForPinnedEvent,
-    'm.room.server_acl': textForServerACLEvent,
-    'm.room.tombstone': textForTombstoneEvent,
-    'm.room.join_rules': textForJoinRulesEvent,
-    'm.room.guest_access': textForGuestAccessEvent,
+    [EventType.RoomCanonicalAlias]: textForCanonicalAliasEvent,
+    [EventType.RoomName]: textForRoomNameEvent,
+    [EventType.RoomTopic]: textForTopicEvent,
+    [EventType.RoomMember]: textForMemberEvent,
+    [EventType.RoomAvatar]: textForRoomAvatarEvent,
+    [EventType.RoomThirdPartyInvite]: textForThreePidInviteEvent,
+    [EventType.RoomHistoryVisibility]: textForHistoryVisibilityEvent,
+    [EventType.RoomPowerLevels]: textForPowerEvent,
+    [EventType.RoomPinnedEvents]: textForPinnedEvent,
+    [EventType.RoomServerAcl]: textForServerACLEvent,
+    [EventType.RoomTombstone]: textForTombstoneEvent,
+    [EventType.RoomJoinRules]: textForJoinRulesEvent,
+    [EventType.RoomGuestAccess]: textForGuestAccessEvent,
     'm.room.related_groups': textForRelatedGroupsEvent,
 
     // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx
index 68e10049fd..8c49a4d6ae 100644
--- a/src/accessibility/RovingTabIndex.tsx
+++ b/src/accessibility/RovingTabIndex.tsx
@@ -24,6 +24,7 @@ import React, {
     useReducer,
     Reducer,
     Dispatch,
+    RefObject,
 } from "react";
 
 import { Key } from "../Keyboard";
@@ -63,7 +64,7 @@ const RovingTabIndexContext = createContext({
 });
 RovingTabIndexContext.displayName = "RovingTabIndexContext";
 
-enum Type {
+export enum Type {
     Register = "REGISTER",
     Unregister = "UNREGISTER",
     SetFocus = "SET_FOCUS",
@@ -76,73 +77,67 @@ interface IAction {
     };
 }
 
-const reducer = (state: IState, action: IAction) => {
+export const reducer = (state: IState, action: IAction) => {
     switch (action.type) {
         case Type.Register: {
-            if (state.refs.length === 0) {
+            let left = 0;
+            let right = state.refs.length - 1;
+            let index = state.refs.length; // by default append to the end
+
+            // do a binary search to find the right slot
+            while (left <= right) {
+                index = Math.floor((left + right) / 2);
+                const ref = state.refs[index];
+
+                if (ref === action.payload.ref) {
+                    return state; // already in refs, this should not happen
+                }
+
+                if (action.payload.ref.current.compareDocumentPosition(ref.current) & DOCUMENT_POSITION_PRECEDING) {
+                    left = ++index;
+                } else {
+                    right = index - 1;
+                }
+            }
+
+            if (!state.activeRef) {
                 // Our list of refs was empty, set activeRef to this first item
-                return {
-                    ...state,
-                    activeRef: action.payload.ref,
-                    refs: [action.payload.ref],
-                };
-            }
-
-            if (state.refs.includes(action.payload.ref)) {
-                return state; // already in refs, this should not happen
-            }
-
-            // find the index of the first ref which is not preceding this one in DOM order
-            let newIndex = state.refs.findIndex(ref => {
-                return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING;
-            });
-
-            if (newIndex < 0) {
-                newIndex = state.refs.length; // append to the end
+                state.activeRef = action.payload.ref;
             }
 
             // update the refs list
-            return {
-                ...state,
-                refs: [
-                    ...state.refs.slice(0, newIndex),
-                    action.payload.ref,
-                    ...state.refs.slice(newIndex),
-                ],
-            };
+            if (index < state.refs.length) {
+                state.refs.splice(index, 0, action.payload.ref);
+            } else {
+                state.refs.push(action.payload.ref);
+            }
+            return { ...state };
         }
-        case Type.Unregister: {
-            // filter out the ref which we are removing
-            const refs = state.refs.filter(r => r !== action.payload.ref);
 
-            if (refs.length === state.refs.length) {
+        case Type.Unregister: {
+            const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
+
+            if (oldIndex === -1) {
                 return state; // already removed, this should not happen
             }
 
-            if (state.activeRef === action.payload.ref) {
+            if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
                 // we just removed the active ref, need to replace it
                 // pick the ref which is now in the index the old ref was in
-                const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
-                return {
-                    ...state,
-                    activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
-                    refs,
-                };
+                const len = state.refs.length;
+                state.activeRef = oldIndex >= len ? state.refs[len - 1] : state.refs[oldIndex];
             }
 
             // update the refs list
-            return {
-                ...state,
-                refs,
-            };
+            return { ...state };
         }
+
         case Type.SetFocus: {
             // update active ref
-            return {
-                ...state,
-                activeRef: action.payload.ref,
-            };
+            state.activeRef = action.payload.ref;
+            return { ...state };
         }
+
         default:
             return state;
     }
@@ -151,13 +146,40 @@ const reducer = (state: IState, action: IAction) => {
 interface IProps {
     handleHomeEnd?: boolean;
     handleUpDown?: boolean;
+    handleLeftRight?: boolean;
     children(renderProps: {
         onKeyDownHandler(ev: React.KeyboardEvent);
     });
     onKeyDown?(ev: React.KeyboardEvent, state: IState);
 }
 
-export const RovingTabIndexProvider: React.FC = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
+export const findSiblingElement = (
+    refs: RefObject[],
+    startIndex: number,
+    backwards = false,
+): RefObject => {
+    if (backwards) {
+        for (let i = startIndex; i < refs.length && i >= 0; i--) {
+            if (refs[i].current.offsetParent !== null) {
+                return refs[i];
+            }
+        }
+    } else {
+        for (let i = startIndex; i < refs.length && i >= 0; i++) {
+            if (refs[i].current.offsetParent !== null) {
+                return refs[i];
+            }
+        }
+    }
+};
+
+export const RovingTabIndexProvider: React.FC = ({
+    children,
+    handleHomeEnd,
+    handleUpDown,
+    handleLeftRight,
+    onKeyDown,
+}) => {
     const [state, dispatch] = useReducer>(reducer, {
         activeRef: null,
         refs: [],
@@ -166,6 +188,13 @@ export const RovingTabIndexProvider: React.FC = ({ children, handleHomeE
     const context = useMemo(() => ({ state, dispatch }), [state]);
 
     const onKeyDownHandler = useCallback((ev) => {
+        if (onKeyDown) {
+            onKeyDown(ev, context.state);
+            if (ev.defaultPrevented) {
+                return;
+            }
+        }
+
         let handled = false;
         // Don't interfere with input default keydown behaviour
         if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
@@ -174,43 +203,37 @@ export const RovingTabIndexProvider: React.FC = ({ children, handleHomeE
                 case Key.HOME:
                     if (handleHomeEnd) {
                         handled = true;
-                        // move focus to first item
-                        if (context.state.refs.length > 0) {
-                            context.state.refs[0].current.focus();
-                        }
+                        // move focus to first (visible) item
+                        findSiblingElement(context.state.refs, 0)?.current?.focus();
                     }
                     break;
 
                 case Key.END:
                     if (handleHomeEnd) {
                         handled = true;
-                        // move focus to last item
-                        if (context.state.refs.length > 0) {
-                            context.state.refs[context.state.refs.length - 1].current.focus();
-                        }
+                        // move focus to last (visible) item
+                        findSiblingElement(context.state.refs, context.state.refs.length - 1, true)?.current?.focus();
                     }
                     break;
 
                 case Key.ARROW_UP:
-                    if (handleUpDown) {
+                case Key.ARROW_RIGHT:
+                    if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_RIGHT && handleLeftRight)) {
                         handled = true;
                         if (context.state.refs.length > 0) {
                             const idx = context.state.refs.indexOf(context.state.activeRef);
-                            if (idx > 0) {
-                                context.state.refs[idx - 1].current.focus();
-                            }
+                            findSiblingElement(context.state.refs, idx - 1)?.current?.focus();
                         }
                     }
                     break;
 
                 case Key.ARROW_DOWN:
-                    if (handleUpDown) {
+                case Key.ARROW_LEFT:
+                    if ((ev.key === Key.ARROW_DOWN && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) {
                         handled = true;
                         if (context.state.refs.length > 0) {
                             const idx = context.state.refs.indexOf(context.state.activeRef);
-                            if (idx < context.state.refs.length - 1) {
-                                context.state.refs[idx + 1].current.focus();
-                            }
+                            findSiblingElement(context.state.refs, idx + 1, true)?.current?.focus();
                         }
                     }
                     break;
@@ -220,10 +243,8 @@ export const RovingTabIndexProvider: React.FC = ({ children, handleHomeE
         if (handled) {
             ev.preventDefault();
             ev.stopPropagation();
-        } else if (onKeyDown) {
-            return onKeyDown(ev, context.state);
         }
-    }, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
+    }, [context.state, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
 
     return 
         { children({ onKeyDownHandler }) }
diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx
index 90538760bb..6e99c7f1fa 100644
--- a/src/accessibility/Toolbar.tsx
+++ b/src/accessibility/Toolbar.tsx
@@ -16,7 +16,7 @@ limitations under the License.
 
 import React from "react";
 
-import { IState, RovingTabIndexProvider } from "./RovingTabIndex";
+import { RovingTabIndexProvider } from "./RovingTabIndex";
 import { Key } from "../Keyboard";
 
 interface IProps extends Omit, "onKeyDown"> {
@@ -26,7 +26,7 @@ interface IProps extends Omit, "onKeyDown"> {
 // https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
 // All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
 const Toolbar: React.FC = ({ children, ...props }) => {
-    const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
+    const onKeyDown = (ev: React.KeyboardEvent) => {
         const target = ev.target as HTMLElement;
         // Don't interfere with input default keydown behaviour
         if (target.tagName === "INPUT") return;
@@ -42,15 +42,6 @@ const Toolbar: React.FC = ({ children, ...props }) => {
                 }
                 break;
 
-            case Key.ARROW_LEFT:
-            case Key.ARROW_RIGHT:
-                if (state.refs.length > 0) {
-                    const i = state.refs.findIndex(r => r === state.activeRef);
-                    const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
-                    state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
-                }
-                break;
-
             default:
                 handled = false;
         }
diff --git a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx
similarity index 67%
rename from src/async-components/views/dialogs/security/CreateKeyBackupDialog.js
rename to src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx
index 9d449e8b80..c0aff7c8b9 100644
--- a/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js
+++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.tsx
@@ -17,56 +17,70 @@ limitations under the License.
 
 import React, { createRef } from 'react';
 import FileSaver from 'file-saver';
-import PropTypes from 'prop-types';
-import { logger } from "matrix-js-sdk/src/logger";
-
-import * as sdk from '../../../../index';
 import { MatrixClientPeg } from '../../../../MatrixClientPeg';
 import { _t, _td } from '../../../../languageHandler';
 import { accessSecretStorage } from '../../../../SecurityManager';
 import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
 import { copyNode } from "../../../../utils/strings";
 import PassphraseField from "../../../../components/views/auth/PassphraseField";
+import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
+import Field from "../../../../components/views/elements/Field";
+import Spinner from "../../../../components/views/elements/Spinner";
+import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
+import DialogButtons from "../../../../components/views/elements/DialogButtons";
+import { IValidationResult } from "../../../../components/views/elements/Validation";
+import { IPreparedKeyBackupVersion } from "matrix-js-sdk/src/crypto/backup";
+import { logger } from "matrix-js-sdk/src/logger";
 
-const PHASE_PASSPHRASE = 0;
-const PHASE_PASSPHRASE_CONFIRM = 1;
-const PHASE_SHOWKEY = 2;
-const PHASE_KEEPITSAFE = 3;
-const PHASE_BACKINGUP = 4;
-const PHASE_DONE = 5;
-const PHASE_OPTOUT_CONFIRM = 6;
+enum Phase {
+    Passphrase = "passphrase",
+    PassphraseConfirm = "passphrase_confirm",
+    ShowKey = "show_key",
+    KeepItSafe = "keep_it_safe",
+    BackingUp = "backing_up",
+    Done = "done",
+    OptOutConfirm = "opt_out_confirm",
+}
 
 const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc.
 
+interface IProps extends IDialogProps {}
+
+interface IState {
+    secureSecretStorage: boolean;
+    phase: Phase;
+    passPhrase: string;
+    passPhraseValid: boolean;
+    passPhraseConfirm: string;
+    copied: boolean;
+    downloaded: boolean;
+    error?: string;
+}
+
 /*
  * Walks the user through the process of creating an e2e key backup
  * on the server.
  */
-export default class CreateKeyBackupDialog extends React.PureComponent {
-    static propTypes = {
-        onFinished: PropTypes.func.isRequired,
-    }
+export default class CreateKeyBackupDialog extends React.PureComponent {
+    private keyBackupInfo: Pick;
+    private recoveryKeyNode = createRef();
+    private passphraseField = createRef();
 
-    constructor(props) {
+    constructor(props: IProps) {
         super(props);
 
-        this._recoveryKeyNode = null;
-        this._keyBackupInfo = null;
-
         this.state = {
             secureSecretStorage: null,
-            phase: PHASE_PASSPHRASE,
+            phase: Phase.Passphrase,
             passPhrase: '',
             passPhraseValid: false,
             passPhraseConfirm: '',
             copied: false,
             downloaded: false,
         };
-
-        this._passphraseField = createRef();
     }
 
-    async componentDidMount() {
+    public async componentDidMount(): Promise {
         const cli = MatrixClientPeg.get();
         const secureSecretStorage = await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
         this.setState({ secureSecretStorage });
@@ -74,41 +88,37 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
         // If we're using secret storage, skip ahead to the backing up step, as
         // `accessSecretStorage` will handle passphrases as needed.
         if (secureSecretStorage) {
-            this.setState({ phase: PHASE_BACKINGUP });
-            this._createBackup();
+            this.setState({ phase: Phase.BackingUp });
+            this.createBackup();
         }
     }
 
-    _collectRecoveryKeyNode = (n) => {
-        this._recoveryKeyNode = n;
-    }
-
-    _onCopyClick = () => {
-        const successful = copyNode(this._recoveryKeyNode);
+    private onCopyClick = (): void => {
+        const successful = copyNode(this.recoveryKeyNode.current);
         if (successful) {
             this.setState({
                 copied: true,
-                phase: PHASE_KEEPITSAFE,
+                phase: Phase.KeepItSafe,
             });
         }
-    }
+    };
 
-    _onDownloadClick = () => {
-        const blob = new Blob([this._keyBackupInfo.recovery_key], {
+    private onDownloadClick = (): void => {
+        const blob = new Blob([this.keyBackupInfo.recovery_key], {
             type: 'text/plain;charset=us-ascii',
         });
         FileSaver.saveAs(blob, 'security-key.txt');
 
         this.setState({
             downloaded: true,
-            phase: PHASE_KEEPITSAFE,
+            phase: Phase.KeepItSafe,
         });
-    }
+    };
 
-    _createBackup = async () => {
+    private createBackup = async (): Promise => {
         const { secureSecretStorage } = this.state;
         this.setState({
-            phase: PHASE_BACKINGUP,
+            phase: Phase.BackingUp,
             error: null,
         });
         let info;
@@ -123,12 +133,12 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
                 });
             } else {
                 info = await MatrixClientPeg.get().createKeyBackupVersion(
-                    this._keyBackupInfo,
+                    this.keyBackupInfo,
                 );
             }
             await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup();
             this.setState({
-                phase: PHASE_DONE,
+                phase: Phase.Done,
             });
         } catch (e) {
             logger.error("Error creating key backup", e);
@@ -143,97 +153,91 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
                 error: e,
             });
         }
-    }
+    };
 
-    _onCancel = () => {
+    private onCancel = (): void => {
         this.props.onFinished(false);
-    }
+    };
 
-    _onDone = () => {
+    private onDone = (): void => {
         this.props.onFinished(true);
-    }
+    };
 
-    _onOptOutClick = () => {
-        this.setState({ phase: PHASE_OPTOUT_CONFIRM });
-    }
+    private onSetUpClick = (): void => {
+        this.setState({ phase: Phase.Passphrase });
+    };
 
-    _onSetUpClick = () => {
-        this.setState({ phase: PHASE_PASSPHRASE });
-    }
-
-    _onSkipPassPhraseClick = async () => {
-        this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion();
+    private onSkipPassPhraseClick = async (): Promise => {
+        this.keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion();
         this.setState({
             copied: false,
             downloaded: false,
-            phase: PHASE_SHOWKEY,
+            phase: Phase.ShowKey,
         });
-    }
+    };
 
-    _onPassPhraseNextClick = async (e) => {
+    private onPassPhraseNextClick = async (e: React.FormEvent): Promise => {
         e.preventDefault();
-        if (!this._passphraseField.current) return; // unmounting
+        if (!this.passphraseField.current) return; // unmounting
 
-        await this._passphraseField.current.validate({ allowEmpty: false });
-        if (!this._passphraseField.current.state.valid) {
-            this._passphraseField.current.focus();
-            this._passphraseField.current.validate({ allowEmpty: false, focused: true });
+        await this.passphraseField.current.validate({ allowEmpty: false });
+        if (!this.passphraseField.current.state.valid) {
+            this.passphraseField.current.focus();
+            this.passphraseField.current.validate({ allowEmpty: false, focused: true });
             return;
         }
 
-        this.setState({ phase: PHASE_PASSPHRASE_CONFIRM });
+        this.setState({ phase: Phase.PassphraseConfirm });
     };
 
-    _onPassPhraseConfirmNextClick = async (e) => {
+    private onPassPhraseConfirmNextClick = async (e: React.FormEvent): Promise => {
         e.preventDefault();
 
         if (this.state.passPhrase !== this.state.passPhraseConfirm) return;
 
-        this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase);
+        this.keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase);
         this.setState({
             copied: false,
             downloaded: false,
-            phase: PHASE_SHOWKEY,
+            phase: Phase.ShowKey,
         });
     };
 
-    _onSetAgainClick = () => {
+    private onSetAgainClick = (): void => {
         this.setState({
             passPhrase: '',
             passPhraseValid: false,
             passPhraseConfirm: '',
-            phase: PHASE_PASSPHRASE,
+            phase: Phase.Passphrase,
         });
-    }
+    };
 
-    _onKeepItSafeBackClick = () => {
+    private onKeepItSafeBackClick = (): void => {
         this.setState({
-            phase: PHASE_SHOWKEY,
+            phase: Phase.ShowKey,
         });
-    }
+    };
 
-    _onPassPhraseValidate = (result) => {
+    private onPassPhraseValidate = (result: IValidationResult): void => {
         this.setState({
             passPhraseValid: result.valid,
         });
     };
 
-    _onPassPhraseChange = (e) => {
+    private onPassPhraseChange = (e: React.ChangeEvent): void => {
         this.setState({
             passPhrase: e.target.value,
         });
-    }
+    };
 
-    _onPassPhraseConfirmChange = (e) => {
+    private onPassPhraseConfirmChange = (e: React.ChangeEvent): void => {
         this.setState({
             passPhraseConfirm: e.target.value,
         });
-    }
+    };
 
-    _renderPhasePassPhrase() {
-        const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
-
-        return 
+ private renderPhasePassPhrase(): JSX.Element { + return

{ _t( "Warning: You should only set up key backup from a trusted computer.", {}, { b: sub => { sub } }, @@ -248,11 +252,11 @@ export default class CreateKeyBackupDialog extends React.PureComponent {

{ _t("Advanced") } - + { _t("Set up with a Security Key") }
; } - _renderPhasePassPhraseConfirm() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - + private renderPhasePassPhraseConfirm(): JSX.Element { let matchText; let changeText; if (this.state.passPhraseConfirm === this.state.passPhrase) { @@ -303,14 +305,13 @@ export default class CreateKeyBackupDialog extends React.PureComponent { passPhraseMatch =
{ matchText }
- + { changeText }
; } - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return
+ return

{ _t( "Enter your Security Phrase a second time to confirm it.", ) }

@@ -318,7 +319,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
; } - _renderPhaseShowKey() { + private renderPhaseShowKey(): JSX.Element { return

{ _t( "Your Security Key is a safety net - you can use it to restore " + @@ -352,13 +353,13 @@ export default class CreateKeyBackupDialog extends React.PureComponent {

- { this._keyBackupInfo.recovery_key } + { this.keyBackupInfo.recovery_key }
- -
@@ -367,7 +368,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
; } - _renderPhaseKeepItSafe() { + private renderPhaseKeepItSafe(): JSX.Element { let introText; if (this.state.copied) { introText = _t( @@ -380,7 +381,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent { {}, { b: s => { s } }, ); } - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
{ introText }
    @@ -389,107 +389,101 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
  • { _t("Copy it to your personal cloud storage", {}, { b: s => { s } }) }
- +
; } - _renderBusyPhase(text) { - const Spinner = sdk.getComponent('views.elements.Spinner'); + private renderBusyPhase(): JSX.Element { return
; } - _renderPhaseDone() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + private renderPhaseDone(): JSX.Element { return

{ _t( "Your keys are being backed up (the first backup could take a few minutes).", ) }

; } - _renderPhaseOptOutConfirm() { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + private renderPhaseOptOutConfirm(): JSX.Element { return
{ _t( "Without setting up Secure Message Recovery, you won't be able to restore your " + "encrypted message history if you log out or use another session.", ) } - +
; } - _titleForPhase(phase) { + private titleForPhase(phase: Phase): string { switch (phase) { - case PHASE_PASSPHRASE: + case Phase.Passphrase: return _t('Secure your backup with a Security Phrase'); - case PHASE_PASSPHRASE_CONFIRM: + case Phase.PassphraseConfirm: return _t('Confirm your Security Phrase'); - case PHASE_OPTOUT_CONFIRM: + case Phase.OptOutConfirm: return _t('Warning!'); - case PHASE_SHOWKEY: - case PHASE_KEEPITSAFE: + case Phase.ShowKey: + case Phase.KeepItSafe: return _t('Make a copy of your Security Key'); - case PHASE_BACKINGUP: + case Phase.BackingUp: return _t('Starting backup...'); - case PHASE_DONE: + case Phase.Done: return _t('Success!'); default: return _t("Create key backup"); } } - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render(): JSX.Element { let content; if (this.state.error) { - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); content =

{ _t("Unable to create key backup") }

; } else { switch (this.state.phase) { - case PHASE_PASSPHRASE: - content = this._renderPhasePassPhrase(); + case Phase.Passphrase: + content = this.renderPhasePassPhrase(); break; - case PHASE_PASSPHRASE_CONFIRM: - content = this._renderPhasePassPhraseConfirm(); + case Phase.PassphraseConfirm: + content = this.renderPhasePassPhraseConfirm(); break; - case PHASE_SHOWKEY: - content = this._renderPhaseShowKey(); + case Phase.ShowKey: + content = this.renderPhaseShowKey(); break; - case PHASE_KEEPITSAFE: - content = this._renderPhaseKeepItSafe(); + case Phase.KeepItSafe: + content = this.renderPhaseKeepItSafe(); break; - case PHASE_BACKINGUP: - content = this._renderBusyPhase(); + case Phase.BackingUp: + content = this.renderBusyPhase(); break; - case PHASE_DONE: - content = this._renderPhaseDone(); + case Phase.Done: + content = this.renderPhaseDone(); break; - case PHASE_OPTOUT_CONFIRM: - content = this._renderPhaseOptOutConfirm(); + case Phase.OptOutConfirm: + content = this.renderPhaseOptOutConfirm(); break; } } @@ -497,8 +491,8 @@ export default class CreateKeyBackupDialog extends React.PureComponent { return (
{ content } diff --git a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx similarity index 67% rename from src/async-components/views/dialogs/security/CreateSecretStorageDialog.js rename to src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx index a760e62454..145d3bcede 100644 --- a/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.tsx @@ -16,12 +16,8 @@ limitations under the License. */ import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; -import FileSaver from 'file-saver'; -import { logger } from "matrix-js-sdk/src/logger"; - -import * as sdk from '../../../../index'; import { MatrixClientPeg } from '../../../../MatrixClientPeg'; +import FileSaver from 'file-saver'; import { _t, _td } from '../../../../languageHandler'; import Modal from '../../../../Modal'; import { promptForBackupPassphrase } from '../../../../SecurityManager'; @@ -33,50 +29,105 @@ import AccessibleButton from "../../../../components/views/elements/AccessibleBu 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 { + getSecureBackupSetupMethods, + isSecureBackupRequired, + SecureBackupSetupMethod, +} from '../../../../utils/WellKnownUtils'; import SecurityCustomisations from "../../../../customisations/Security"; -const PHASE_LOADING = 0; -const PHASE_LOADERROR = 1; -const PHASE_CHOOSE_KEY_PASSPHRASE = 2; -const PHASE_MIGRATE = 3; -const PHASE_PASSPHRASE = 4; -const PHASE_PASSPHRASE_CONFIRM = 5; -const PHASE_SHOWKEY = 6; -const PHASE_STORING = 8; -const PHASE_CONFIRM_SKIP = 10; +import { logger } from "matrix-js-sdk/src/logger"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import Field from "../../../../components/views/elements/Field"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Spinner from "../../../../components/views/elements/Spinner"; +import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; +import { CrossSigningKeys } from "matrix-js-sdk"; +import InteractiveAuthDialog from "../../../../components/views/dialogs/InteractiveAuthDialog"; +import { IRecoveryKey } from "matrix-js-sdk/src/crypto/api"; +import { IValidationResult } from "../../../../components/views/elements/Validation"; + +// I made a mistake while converting this and it has to be fixed! +enum Phase { + Loading = "loading", + LoadError = "load_error", + ChooseKeyPassphrase = "choose_key_passphrase", + Migrate = "migrate", + Passphrase = "passphrase", + PassphraseConfirm = "passphrase_confirm", + ShowKey = "show_key", + Storing = "storing", + ConfirmSkip = "confirm_skip", +} const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. -// these end up as strings from being values in the radio buttons, so just use strings -const CREATE_STORAGE_OPTION_KEY = 'key'; -const CREATE_STORAGE_OPTION_PASSPHRASE = 'passphrase'; +interface IProps extends IDialogProps { + hasCancel: boolean; + accountPassword: string; + forceReset: boolean; +} + +interface IState { + phase: Phase; + passPhrase: string; + passPhraseValid: boolean; + passPhraseConfirm: string; + copied: boolean; + downloaded: boolean; + setPassphrase: boolean; + backupInfo: IKeyBackupInfo; + backupSigStatus: TrustInfo; + // does the server offer a UI auth flow with just m.login.password + // for /keys/device_signing/upload? + canUploadKeysWithPasswordOnly: boolean; + accountPassword: string; + accountPasswordCorrect: boolean; + canSkip: boolean; + passPhraseKeySelected: string; + error?: string; +} /* * Walks the user through the process of creating a passphrase to guard Secure * Secret Storage in account data. */ -export default class CreateSecretStorageDialog extends React.PureComponent { - static propTypes = { - hasCancel: PropTypes.bool, - accountPassword: PropTypes.string, - forceReset: PropTypes.bool, - }; - - static defaultProps = { +export default class CreateSecretStorageDialog extends React.PureComponent { + public static defaultProps: Partial = { hasCancel: true, forceReset: false, }; + private recoveryKey: IRecoveryKey; + private backupKey: Uint8Array; + private recoveryKeyNode = createRef(); + private passphraseField = createRef(); - constructor(props) { + constructor(props: IProps) { super(props); - this._recoveryKey = null; - this._recoveryKeyNode = null; - this._backupKey = null; + let passPhraseKeySelected; + const setupMethods = getSecureBackupSetupMethods(); + if (setupMethods.includes(SecureBackupSetupMethod.Key)) { + passPhraseKeySelected = SecureBackupSetupMethod.Key; + } else { + passPhraseKeySelected = SecureBackupSetupMethod.Passphrase; + } + + const accountPassword = props.accountPassword || ""; + let canUploadKeysWithPasswordOnly = null; + if (accountPassword) { + // If we have an account password in memory, let's simplify and + // assume it means password auth is also supported for device + // signing key upload as well. This avoids hitting the server to + // test auth flows, which may be slow under high load. + canUploadKeysWithPasswordOnly = true; + } else { + this.queryKeyUploadAuth(); + } this.state = { - phase: PHASE_LOADING, + phase: Phase.Loading, passPhrase: '', passPhraseValid: false, passPhraseConfirm: '', @@ -87,55 +138,37 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus: null, // does the server offer a UI auth flow with just m.login.password // for /keys/device_signing/upload? - canUploadKeysWithPasswordOnly: null, - accountPassword: props.accountPassword || "", accountPasswordCorrect: null, canSkip: !isSecureBackupRequired(), + canUploadKeysWithPasswordOnly, + passPhraseKeySelected, + accountPassword, }; - const setupMethods = getSecureBackupSetupMethods(); - if (setupMethods.includes("key")) { - this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_KEY; - } else { - this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_PASSPHRASE; - } + MatrixClientPeg.get().on('crypto.keyBackupStatus', this.onKeyBackupStatusChange); - this._passphraseField = createRef(); - - 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 - // signing key upload as well. This avoids hitting the server to - // test auth flows, which may be slow under high load. - this.state.canUploadKeysWithPasswordOnly = true; - } else { - this._queryKeyUploadAuth(); - } - - this._getInitialPhase(); + this.getInitialPhase(); } - componentWillUnmount() { - MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange); + public componentWillUnmount(): void { + MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this.onKeyBackupStatusChange); } - _getInitialPhase() { + private getInitialPhase(): void { const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.(); if (keyFromCustomisations) { logger.log("Created key via customisations, jumping to bootstrap step"); - this._recoveryKey = { + this.recoveryKey = { privateKey: keyFromCustomisations, }; - this._bootstrapSecretStorage(); + this.bootstrapSecretStorage(); return; } - this._fetchBackupInfo(); + this.fetchBackupInfo(); } - async _fetchBackupInfo() { + private async fetchBackupInfo(): Promise<{ backupInfo: IKeyBackupInfo, backupSigStatus: TrustInfo }> { try { const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupSigStatus = ( @@ -144,7 +177,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); const { forceReset } = this.props; - const phase = (backupInfo && !forceReset) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; + const phase = (backupInfo && !forceReset) ? Phase.Migrate : Phase.ChooseKeyPassphrase; this.setState({ phase, @@ -157,13 +190,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent { backupSigStatus, }; } catch (e) { - this.setState({ phase: PHASE_LOADERROR }); + this.setState({ phase: Phase.LoadError }); } } - async _queryKeyUploadAuth() { + private async queryKeyUploadAuth(): Promise { try { - await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); + await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {} as CrossSigningKeys); // We should never get here: the server should always require // UI auth to upload device signing keys. If we do, we upload // no keys which would be a no-op. @@ -182,59 +215,55 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } - _onKeyBackupStatusChange = () => { - if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); - } + private onKeyBackupStatusChange = (): void => { + if (this.state.phase === Phase.Migrate) this.fetchBackupInfo(); + }; - _onKeyPassphraseChange = e => { + private onKeyPassphraseChange = (e: React.ChangeEvent): void => { this.setState({ passPhraseKeySelected: e.target.value, }); - } + }; - _collectRecoveryKeyNode = (n) => { - this._recoveryKeyNode = n; - } - - _onChooseKeyPassphraseFormSubmit = async () => { - if (this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY) { - this._recoveryKey = + private onChooseKeyPassphraseFormSubmit = async (): Promise => { + if (this.state.passPhraseKeySelected === SecureBackupSetupMethod.Key) { + this.recoveryKey = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); this.setState({ copied: false, downloaded: false, setPassphrase: false, - phase: PHASE_SHOWKEY, + phase: Phase.ShowKey, }); } else { this.setState({ copied: false, downloaded: false, - phase: PHASE_PASSPHRASE, + phase: Phase.Passphrase, }); } - } + }; - _onMigrateFormSubmit = (e) => { + private onMigrateFormSubmit = (e: React.FormEvent): void => { e.preventDefault(); if (this.state.backupSigStatus.usable) { - this._bootstrapSecretStorage(); + this.bootstrapSecretStorage(); } else { - this._restoreBackup(); + this.restoreBackup(); } - } + }; - _onCopyClick = () => { - const successful = copyNode(this._recoveryKeyNode); + private onCopyClick = (): void => { + const successful = copyNode(this.recoveryKeyNode.current); if (successful) { this.setState({ copied: true, }); } - } + }; - _onDownloadClick = () => { - const blob = new Blob([this._recoveryKey.encodedPrivateKey], { + private onDownloadClick = (): void => { + const blob = new Blob([this.recoveryKey.encodedPrivateKey], { type: 'text/plain;charset=us-ascii', }); FileSaver.saveAs(blob, 'security-key.txt'); @@ -242,9 +271,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.setState({ downloaded: true, }); - } + }; - _doBootstrapUIAuth = async (makeRequest) => { + private doBootstrapUIAuth = async (makeRequest: (authData: any) => void): Promise => { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { await makeRequest({ type: 'm.login.password', @@ -258,8 +287,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { password: this.state.accountPassword, }); } else { - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { title: _t("Use Single Sign On to continue"), @@ -292,11 +319,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent { throw new Error("Cross-signing key upload auth canceled"); } } - } + }; - _bootstrapSecretStorage = async () => { + private bootstrapSecretStorage = async (): Promise => { this.setState({ - phase: PHASE_STORING, + phase: Phase.Storing, error: null, }); @@ -308,7 +335,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { if (forceReset) { logger.log("Forcing secret storage reset"); await cli.bootstrapSecretStorage({ - createSecretStorageKey: async () => this._recoveryKey, + createSecretStorageKey: async () => this.recoveryKey, setupNewKeyBackup: true, setupNewSecretStorage: true, }); @@ -321,18 +348,18 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // keys (and also happen to skip all post-authentication flows at the // moment via token login) await cli.bootstrapCrossSigning({ - authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + authUploadDeviceSigningKeys: this.doBootstrapUIAuth, }); await cli.bootstrapSecretStorage({ - createSecretStorageKey: async () => this._recoveryKey, + createSecretStorageKey: async () => this.recoveryKey, keyBackupInfo: this.state.backupInfo, setupNewKeyBackup: !this.state.backupInfo, - getKeyBackupPassphrase: () => { + getKeyBackupPassphrase: async () => { // We may already have the backup key if we earlier went // through the restore backup path, so pass it along // rather than prompting again. - if (this._backupKey) { - return this._backupKey; + if (this.backupKey) { + return this.backupKey; } return promptForBackupPassphrase(); }, @@ -344,27 +371,23 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this.setState({ accountPassword: '', accountPasswordCorrect: false, - phase: PHASE_MIGRATE, + phase: Phase.Migrate, }); } else { this.setState({ error: e }); } logger.error("Error bootstrapping secret storage", e); } - } + }; - _onCancel = () => { + private onCancel = (): void => { this.props.onFinished(false); - } + }; - _onDone = () => { - this.props.onFinished(true); - } - - _restoreBackup = async () => { + private restoreBackup = async (): Promise => { // It's possible we'll need the backup key later on for bootstrapping, // so let's stash it here, rather than prompting for it twice. - const keyCallback = k => this._backupKey = k; + const keyCallback = k => this.backupKey = k; const { finished } = Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, @@ -376,103 +399,103 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); await finished; - const { backupSigStatus } = await this._fetchBackupInfo(); + const { backupSigStatus } = await this.fetchBackupInfo(); if ( backupSigStatus.usable && this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword ) { - this._bootstrapSecretStorage(); + this.bootstrapSecretStorage(); } - } + }; - _onLoadRetryClick = () => { - this.setState({ phase: PHASE_LOADING }); - this._fetchBackupInfo(); - } + private onLoadRetryClick = (): void => { + this.setState({ phase: Phase.Loading }); + this.fetchBackupInfo(); + }; - _onShowKeyContinueClick = () => { - this._bootstrapSecretStorage(); - } + private onShowKeyContinueClick = (): void => { + this.bootstrapSecretStorage(); + }; - _onCancelClick = () => { - this.setState({ phase: PHASE_CONFIRM_SKIP }); - } + private onCancelClick = (): void => { + this.setState({ phase: Phase.ConfirmSkip }); + }; - _onGoBackClick = () => { - this.setState({ phase: PHASE_CHOOSE_KEY_PASSPHRASE }); - } + private onGoBackClick = (): void => { + this.setState({ phase: Phase.ChooseKeyPassphrase }); + }; - _onPassPhraseNextClick = async (e) => { + private onPassPhraseNextClick = async (e: React.FormEvent) => { e.preventDefault(); - if (!this._passphraseField.current) return; // unmounting + if (!this.passphraseField.current) return; // unmounting - await this._passphraseField.current.validate({ allowEmpty: false }); - if (!this._passphraseField.current.state.valid) { - this._passphraseField.current.focus(); - this._passphraseField.current.validate({ allowEmpty: false, focused: true }); + await this.passphraseField.current.validate({ allowEmpty: false }); + if (!this.passphraseField.current.state.valid) { + this.passphraseField.current.focus(); + this.passphraseField.current.validate({ allowEmpty: false, focused: true }); return; } - this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); + this.setState({ phase: Phase.PassphraseConfirm }); }; - _onPassPhraseConfirmNextClick = async (e) => { + private onPassPhraseConfirmNextClick = async (e: React.FormEvent) => { e.preventDefault(); if (this.state.passPhrase !== this.state.passPhraseConfirm) return; - this._recoveryKey = + this.recoveryKey = await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); this.setState({ copied: false, downloaded: false, setPassphrase: true, - phase: PHASE_SHOWKEY, + phase: Phase.ShowKey, }); - } + }; - _onSetAgainClick = () => { + private onSetAgainClick = (): void => { this.setState({ passPhrase: '', passPhraseValid: false, passPhraseConfirm: '', - phase: PHASE_PASSPHRASE, + phase: Phase.Passphrase, }); - } + }; - _onPassPhraseValidate = (result) => { + private onPassPhraseValidate = (result: IValidationResult): void => { this.setState({ passPhraseValid: result.valid, }); }; - _onPassPhraseChange = (e) => { + private onPassPhraseChange = (e: React.ChangeEvent): void => { this.setState({ passPhrase: e.target.value, }); - } + }; - _onPassPhraseConfirmChange = (e) => { + private onPassPhraseConfirmChange = (e: React.ChangeEvent): void => { this.setState({ passPhraseConfirm: e.target.value, }); - } + }; - _onAccountPasswordChange = (e) => { + private onAccountPasswordChange = (e: React.ChangeEvent): void => { this.setState({ accountPassword: e.target.value, }); - } + }; - _renderOptionKey() { + private renderOptionKey(): JSX.Element { return (
@@ -484,14 +507,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); } - _renderOptionPassphrase() { + private renderOptionPassphrase(): JSX.Element { return (
@@ -503,12 +526,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ); } - _renderPhaseChooseKeyPassphrase() { + private renderPhaseChooseKeyPassphrase(): JSX.Element { const setupMethods = getSecureBackupSetupMethods(); - const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null; - const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null; + const optionKey = setupMethods.includes(SecureBackupSetupMethod.Key) ? this.renderOptionKey() : null; + const optionPassphrase = setupMethods.includes(SecureBackupSetupMethod.Passphrase) + ? this.renderOptionPassphrase() + : null; - return
+ return

{ _t( "Safeguard against losing access to encrypted messages & data by " + "backing up encryption keys on your server.", @@ -519,20 +544,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

; } - _renderPhaseMigrate() { + private renderPhaseMigrate(): JSX.Element { // TODO: This is a temporary screen so people who have the labs flag turned on and // click the button are aware they're making a change to their account. // Once we're confident enough in this (and it's supported enough) we can do // it automatically. // https://github.com/vector-im/element-web/issues/11696 - const Field = sdk.getComponent('views.elements.Field'); let authPrompt; let nextCaption = _t("Next"); @@ -543,7 +567,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { type="password" label={_t("Password")} value={this.state.accountPassword} - onChange={this._onAccountPasswordChange} + onChange={this.onAccountPasswordChange} forceValidity={this.state.accountPasswordCorrect === false ? false : null} autoFocus={true} />
@@ -559,7 +583,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

; } - return
+ return

{ _t( "Upgrade this session to allow it to verify other sessions, " + "granting them access to encrypted messages and marking them " + @@ -568,19 +592,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

{ authPrompt }
-
; } - _renderPhasePassPhrase() { - return
+ private renderPhasePassPhrase(): JSX.Element { + return

{ _t( "Enter a security phrase only you know, as it’s used to safeguard your data. " + "To be secure, you shouldn’t re-use your account password.", @@ -589,11 +613,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {

; } - _renderPhasePassPhraseConfirm() { - const Field = sdk.getComponent('views.elements.Field'); - + private renderPhasePassPhraseConfirm(): JSX.Element { let matchText; let changeText; if (this.state.passPhraseConfirm === this.state.passPhrase) { @@ -641,20 +663,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent { passPhraseMatch =
{ matchText }
- + { changeText }
; } - return
+ return

{ _t( "Enter your Security Phrase a second time to confirm it.", ) }

; } - _renderPhaseShowKey() { + private renderPhaseShowKey(): JSX.Element { let continueButton; - if (this.state.phase === PHASE_SHOWKEY) { + if (this.state.phase === Phase.ShowKey) { continueButton = ; } else { @@ -700,13 +722,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
- { this._recoveryKey.encodedPrivateKey } + { this.recoveryKey.encodedPrivateKey }
{ _t("Download") } @@ -714,8 +736,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { { this.state.copied ? _t("Copied!") : _t("Copy") } @@ -726,27 +748,26 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } - _renderBusyPhase() { - const Spinner = sdk.getComponent('views.elements.Spinner'); + private renderBusyPhase(): JSX.Element { return
; } - _renderPhaseLoadError() { + private renderPhaseLoadError(): JSX.Element { return

{ _t("Unable to query secret storage status") }

; } - _renderPhaseSkipConfirm() { + private renderPhaseSkipConfirm(): JSX.Element { return

{ _t( "If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", @@ -755,98 +776,96 @@ export default class CreateSecretStorageDialog extends React.PureComponent { "You can also set up Secure Backup & manage your keys in Settings.", ) }

- +
; } - _titleForPhase(phase) { + private titleForPhase(phase: Phase): string { switch (phase) { - case PHASE_CHOOSE_KEY_PASSPHRASE: + case Phase.ChooseKeyPassphrase: return _t('Set up Secure Backup'); - case PHASE_MIGRATE: + case Phase.Migrate: return _t('Upgrade your encryption'); - case PHASE_PASSPHRASE: + case Phase.Passphrase: return _t('Set a Security Phrase'); - case PHASE_PASSPHRASE_CONFIRM: + case Phase.PassphraseConfirm: return _t('Confirm Security Phrase'); - case PHASE_CONFIRM_SKIP: + case Phase.ConfirmSkip: return _t('Are you sure?'); - case PHASE_SHOWKEY: + case Phase.ShowKey: return _t('Save your Security Key'); - case PHASE_STORING: + case Phase.Storing: return _t('Setting up keys'); default: return ''; } } - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - + public render(): JSX.Element { let content; if (this.state.error) { content =

{ _t("Unable to set up secret storage") }

; } else { switch (this.state.phase) { - case PHASE_LOADING: - content = this._renderBusyPhase(); + case Phase.Loading: + content = this.renderBusyPhase(); break; - case PHASE_LOADERROR: - content = this._renderPhaseLoadError(); + case Phase.LoadError: + content = this.renderPhaseLoadError(); break; - case PHASE_CHOOSE_KEY_PASSPHRASE: - content = this._renderPhaseChooseKeyPassphrase(); + case Phase.ChooseKeyPassphrase: + content = this.renderPhaseChooseKeyPassphrase(); break; - case PHASE_MIGRATE: - content = this._renderPhaseMigrate(); + case Phase.Migrate: + content = this.renderPhaseMigrate(); break; - case PHASE_PASSPHRASE: - content = this._renderPhasePassPhrase(); + case Phase.Passphrase: + content = this.renderPhasePassPhrase(); break; - case PHASE_PASSPHRASE_CONFIRM: - content = this._renderPhasePassPhraseConfirm(); + case Phase.PassphraseConfirm: + content = this.renderPhasePassPhraseConfirm(); break; - case PHASE_SHOWKEY: - content = this._renderPhaseShowKey(); + case Phase.ShowKey: + content = this.renderPhaseShowKey(); break; - case PHASE_STORING: - content = this._renderBusyPhase(); + case Phase.Storing: + content = this.renderBusyPhase(); break; - case PHASE_CONFIRM_SKIP: - content = this._renderPhaseSkipConfirm(); + case Phase.ConfirmSkip: + content = this.renderPhaseSkipConfirm(); break; } } let titleClass = null; switch (this.state.phase) { - case PHASE_PASSPHRASE: - case PHASE_PASSPHRASE_CONFIRM: + case Phase.Passphrase: + case Phase.PassphraseConfirm: titleClass = [ 'mx_CreateSecretStorageDialog_titleWithIcon', 'mx_CreateSecretStorageDialog_securePhraseTitle', ]; break; - case PHASE_SHOWKEY: + case Phase.ShowKey: titleClass = [ 'mx_CreateSecretStorageDialog_titleWithIcon', 'mx_CreateSecretStorageDialog_secureBackupTitle', ]; break; - case PHASE_CHOOSE_KEY_PASSPHRASE: + case Phase.ChooseKeyPassphrase: titleClass = 'mx_CreateSecretStorageDialog_centeredTitle'; break; } @@ -854,9 +873,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent { return (
diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx similarity index 79% rename from src/async-components/views/dialogs/security/ExportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx index c80f935a80..2ba78da90e 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx @@ -16,46 +16,51 @@ limitations under the License. import FileSaver from 'file-saver'; import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; +import { _t } from '../../../../languageHandler'; + import { MatrixClient } from 'matrix-js-sdk/src/client'; +import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import { logger } from "matrix-js-sdk/src/logger"; -import { _t } from '../../../../languageHandler'; -import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../../index'; +enum Phase { + Edit = "edit", + Exporting = "exporting", +} -const PHASE_EDIT = 1; -const PHASE_EXPORTING = 2; +interface IProps extends IDialogProps { + matrixClient: MatrixClient; +} -export default class ExportE2eKeysDialog extends React.Component { - static propTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, - onFinished: PropTypes.func.isRequired, - }; +interface IState { + phase: Phase; + errStr: string; +} - constructor(props) { +export default class ExportE2eKeysDialog extends React.Component { + private unmounted = false; + private passphrase1 = createRef(); + private passphrase2 = createRef(); + + constructor(props: IProps) { super(props); - this._unmounted = false; - - this._passphrase1 = createRef(); - this._passphrase2 = createRef(); - this.state = { - phase: PHASE_EDIT, + phase: Phase.Edit, errStr: null, }; } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount(): void { + this.unmounted = true; } - _onPassphraseFormSubmit = (ev) => { + private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - const passphrase = this._passphrase1.current.value; - if (passphrase !== this._passphrase2.current.value) { + const passphrase = this.passphrase1.current.value; + if (passphrase !== this.passphrase2.current.value) { this.setState({ errStr: _t('Passphrases must match') }); return false; } @@ -64,11 +69,11 @@ export default class ExportE2eKeysDialog extends React.Component { return false; } - this._startExport(passphrase); + this.startExport(passphrase); return false; }; - _startExport(passphrase) { + private startExport(passphrase: string): void { // extra Promise.resolve() to turn synchronous exceptions into // asynchronous ones. Promise.resolve().then(() => { @@ -85,39 +90,37 @@ export default class ExportE2eKeysDialog extends React.Component { this.props.onFinished(true); }).catch((e) => { logger.error("Error exporting e2e keys:", e); - if (this._unmounted) { + if (this.unmounted) { return; } const msg = e.friendlyText || _t('Unknown error'); this.setState({ errStr: msg, - phase: PHASE_EDIT, + phase: Phase.Edit, }); }); this.setState({ errStr: null, - phase: PHASE_EXPORTING, + phase: Phase.Exporting, }); } - _onCancelClick = (ev) => { + private onCancelClick = (ev: React.MouseEvent): boolean => { ev.preventDefault(); this.props.onFinished(false); return false; }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - - const disableForm = (this.state.phase === PHASE_EXPORTING); + public render(): JSX.Element { + const disableForm = (this.state.phase === Phase.Exporting); return ( -
+

{ _t( @@ -150,10 +153,10 @@ export default class ExportE2eKeysDialog extends React.Component {

@@ -166,9 +169,9 @@ export default class ExportE2eKeysDialog extends React.Component {
- @@ -183,7 +186,7 @@ export default class ExportE2eKeysDialog extends React.Component { value={_t('Export')} disabled={disableForm} /> -
diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx similarity index 74% rename from src/async-components/views/dialogs/security/ImportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx index 41b7e4e9e0..fccc730812 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx @@ -15,19 +15,19 @@ limitations under the License. */ import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; + import { MatrixClient } from 'matrix-js-sdk/src/client'; +import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; +import { _t } from '../../../../languageHandler'; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; import { logger } from "matrix-js-sdk/src/logger"; -import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../../index'; -import { _t } from '../../../../languageHandler'; - -function readFileAsArrayBuffer(file) { +function readFileAsArrayBuffer(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (e) => { - resolve(e.target.result); + resolve(e.target.result as ArrayBuffer); }; reader.onerror = reject; @@ -35,51 +35,57 @@ function readFileAsArrayBuffer(file) { }); } -const PHASE_EDIT = 1; -const PHASE_IMPORTING = 2; +enum Phase { + Edit = "edit", + Importing = "importing", +} -export default class ImportE2eKeysDialog extends React.Component { - static propTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, - onFinished: PropTypes.func.isRequired, - }; +interface IProps extends IDialogProps { + matrixClient: MatrixClient; +} - constructor(props) { +interface IState { + enableSubmit: boolean; + phase: Phase; + errStr: string; +} + +export default class ImportE2eKeysDialog extends React.Component { + private unmounted = false; + private file = createRef(); + private passphrase = createRef(); + + constructor(props: IProps) { super(props); - this._unmounted = false; - - this._file = createRef(); - this._passphrase = createRef(); - this.state = { enableSubmit: false, - phase: PHASE_EDIT, + phase: Phase.Edit, errStr: null, }; } - componentWillUnmount() { - this._unmounted = true; + public componentWillUnmount(): void { + this.unmounted = true; } - _onFormChange = (ev) => { - const files = this._file.current.files || []; + private onFormChange = (ev: React.FormEvent): void => { + const files = this.file.current.files || []; this.setState({ - enableSubmit: (this._passphrase.current.value !== "" && files.length > 0), + enableSubmit: (this.passphrase.current.value !== "" && files.length > 0), }); }; - _onFormSubmit = (ev) => { + private onFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - this._startImport(this._file.current.files[0], this._passphrase.current.value); + this.startImport(this.file.current.files[0], this.passphrase.current.value); return false; }; - _startImport(file, passphrase) { + private startImport(file: File, passphrase: string) { this.setState({ errStr: null, - phase: PHASE_IMPORTING, + phase: Phase.Importing, }); return readFileAsArrayBuffer(file).then((arrayBuffer) => { @@ -93,34 +99,32 @@ export default class ImportE2eKeysDialog extends React.Component { this.props.onFinished(true); }).catch((e) => { logger.error("Error importing e2e keys:", e); - if (this._unmounted) { + if (this.unmounted) { return; } const msg = e.friendlyText || _t('Unknown error'); this.setState({ errStr: msg, - phase: PHASE_EDIT, + phase: Phase.Edit, }); }); } - _onCancelClick = (ev) => { + private onCancelClick = (ev: React.MouseEvent): boolean => { ev.preventDefault(); this.props.onFinished(false); return false; }; - render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - - const disableForm = (this.state.phase !== PHASE_EDIT); + public render(): JSX.Element { + const disableForm = (this.state.phase !== Phase.Edit); return ( - +

{ _t( @@ -148,11 +152,11 @@ export default class ImportE2eKeysDialog extends React.Component {

@@ -164,11 +168,11 @@ export default class ImportE2eKeysDialog extends React.Component {
@@ -181,7 +185,7 @@ export default class ImportE2eKeysDialog extends React.Component { value={_t('Import')} disabled={!this.state.enableSubmit || disableForm} /> -
diff --git a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx similarity index 84% rename from src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js rename to src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx index 632e4f93bd..105d12f3d7 100644 --- a/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.tsx @@ -16,44 +16,40 @@ limitations under the License. */ import React from "react"; -import PropTypes from "prop-types"; - -import * as sdk from "../../../../index"; import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import { Action } from "../../../../dispatcher/actions"; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; -export default class NewRecoveryMethodDialog extends React.PureComponent { - static propTypes = { - // As returned by js-sdk getKeyBackupVersion() - newVersionInfo: PropTypes.object, - onFinished: PropTypes.func.isRequired, - } +interface IProps extends IDialogProps { + newVersionInfo: IKeyBackupInfo; +} - onOkClick = () => { +export default class NewRecoveryMethodDialog extends React.PureComponent { + private onOkClick = (): void => { this.props.onFinished(); - } + }; - onGoToSettingsClick = () => { + private onGoToSettingsClick = (): void => { this.props.onFinished(); dis.fire(Action.ViewUserSettings); - } + }; - onSetupClick = async () => { + private onSetupClick = async (): Promise => { Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, { onFinished: this.props.onFinished, }, null, /* priority = */ false, /* static = */ true, ); - } - - render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); + }; + public render(): JSX.Element { const title = { _t("New Recovery Method") } ; diff --git a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx similarity index 82% rename from src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js rename to src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx index 3dddec5f45..8ed6eb233e 100644 --- a/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js +++ b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.tsx @@ -15,37 +15,32 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import PropTypes from "prop-types"; - -import * as sdk from "../../../../index"; +import React, { ComponentType } from "react"; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; import { Action } from "../../../../dispatcher/actions"; +import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; -export default class RecoveryMethodRemovedDialog extends React.PureComponent { - static propTypes = { - onFinished: PropTypes.func.isRequired, - } +interface IProps extends IDialogProps {} - onGoToSettingsClick = () => { +export default class RecoveryMethodRemovedDialog extends React.PureComponent { + private onGoToSettingsClick = (): void => { this.props.onFinished(); dis.fire(Action.ViewUserSettings); - } + }; - onSetupClick = () => { + private onSetupClick = (): void => { this.props.onFinished(); Modal.createTrackedDialogAsync("Key Backup", "Key Backup", - import("./CreateKeyBackupDialog"), + import("./CreateKeyBackupDialog") as unknown as Promise>, null, null, /* priority = */ false, /* static = */ true, ); - } - - render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); + }; + public render(): JSX.Element { const title = { _t("Recovery Method Removed") } ; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 4250b5925b..aec1cf9dbf 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -249,6 +249,8 @@ export class ContextMenu extends React.PureComponent { let handled = true; switch (ev.key) { + // XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils + // to inherit proper handling of unmount edge cases case Key.TAB: case Key.ESCAPE: case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 9a2ebd45e2..f12b4cbcf5 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -40,6 +40,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; import UIStore from "../../stores/UIStore"; +import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex"; interface IProps { isMinimized: boolean; @@ -51,19 +52,12 @@ interface IState { activeSpace?: Room; } -// List of CSS classes which should be included in keyboard navigation within the room list -const cssClasses = [ - "mx_RoomSearch_input", - "mx_RoomSearch_minimizedHandle", // minimized - "mx_RoomSublist_headerText", - "mx_RoomTile", - "mx_RoomSublist_showNButton", -]; - @replaceableComponent("structures.LeftPanel") export default class LeftPanel extends React.Component { - private ref: React.RefObject = createRef(); - private listContainerRef: React.RefObject = createRef(); + private ref = createRef(); + private listContainerRef = createRef(); + private roomSearchRef = createRef(); + private roomListRef = createRef(); private focusedElement = null; private isDoingStickyHeaders = false; @@ -283,16 +277,25 @@ export default class LeftPanel extends React.Component { this.focusedElement = null; }; - private onKeyDown = (ev: React.KeyboardEvent) => { + private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState) => { if (!this.focusedElement) return; const action = getKeyBindingsManager().getRoomListAction(ev); switch (action) { case RoomListAction.NextRoom: + if (!state) { + ev.stopPropagation(); + ev.preventDefault(); + this.roomListRef.current?.focus(); + } + break; + case RoomListAction.PrevRoom: - ev.stopPropagation(); - ev.preventDefault(); - this.onMoveFocus(action === RoomListAction.PrevRoom); + if (state && state.activeRef === findSiblingElement(state.refs, 0)) { + ev.stopPropagation(); + ev.preventDefault(); + this.roomSearchRef.current?.focus(); + } break; } }; @@ -305,45 +308,6 @@ export default class LeftPanel extends React.Component { } }; - private onMoveFocus = (up: boolean) => { - let element = this.focusedElement; - - let descending = false; // are we currently descending or ascending through the DOM tree? - let classes: DOMTokenList; - - do { - const child = up ? element.lastElementChild : element.firstElementChild; - const sibling = up ? element.previousElementSibling : element.nextElementSibling; - - if (descending) { - if (child) { - element = child; - } else if (sibling) { - element = sibling; - } else { - descending = false; - element = element.parentElement; - } - } else { - if (sibling) { - element = sibling; - descending = true; - } else { - element = element.parentElement; - } - } - - if (element) { - classes = element.classList; - } - } while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null)); - - if (element) { - element.focus(); - this.focusedElement = element; - } - }; - private renderHeader(): React.ReactNode { return (
@@ -388,7 +352,7 @@ export default class LeftPanel extends React.Component { > @@ -417,6 +381,7 @@ export default class LeftPanel extends React.Component { activeSpace={this.state.activeSpace} onResize={this.refreshStickyHeaders} onListCollapse={this.refreshStickyHeaders} + ref={this.roomListRef} />; const containerClasses = classNames({ diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 2a0d068dcc..54aaf51b80 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { ComponentType, createRef } from 'react'; import { createClient } from "matrix-js-sdk/src/matrix"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -1597,12 +1597,16 @@ export default class MatrixChat extends React.PureComponent { if (haveNewVersion) { Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method', - import('../../async-components/views/dialogs/security/NewRecoveryMethodDialog'), + import( + '../../async-components/views/dialogs/security/NewRecoveryMethodDialog' + ) as unknown as Promise>, { newVersionInfo }, ); } else { Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed', - import('../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog'), + import( + '../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog' + ) as unknown as Promise>, ); } }); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index c787ce2a7e..75e8860f1c 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -196,6 +196,7 @@ interface IReadReceiptForUser { @replaceableComponent("structures.MessagePanel") export default class MessagePanel extends React.Component { static contextType = RoomContext; + public context!: React.ContextType; // opaque readreceipt info for each userId; used by ReadReceiptMarker // to manage its animations @@ -787,6 +788,7 @@ export default class MessagePanel extends React.Component { showReadReceipts={this.props.showReadReceipts} callEventGrouper={callEventGrouper} hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble} + timelineRenderingType={this.context.timelineRenderingType} /> , ); diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 9acfb7bb8e..1a1cf46023 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -32,7 +32,6 @@ import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../. interface IProps { isMinimized: boolean; - onKeyDown(ev: React.KeyboardEvent): void; /** * @returns true if a room has been selected and the search field should be cleared */ @@ -133,11 +132,6 @@ export default class RoomSearch extends React.PureComponent { this.clearInput(); defaultDispatcher.fire(Action.FocusSendMessageComposer); break; - case RoomListAction.NextRoom: - case RoomListAction.PrevRoom: - // we don't handle these actions here put pass the event on to the interested party (LeftPanel) - this.props.onKeyDown(ev); - break; case RoomListAction.SelectRoom: { const shouldClear = this.props.onSelectRoom(); if (shouldClear) { @@ -151,6 +145,10 @@ export default class RoomSearch extends React.PureComponent { } }; + public focus(): void { + this.inputRef.current?.focus(); + } + public render(): React.ReactNode { const classes = classNames({ 'mx_RoomSearch': true, diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 47d730adc0..029e7a419a 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -93,6 +93,7 @@ import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; import SpaceStore from "../../stores/SpaceStore"; import { dispatchShowThreadEvent } from '../../dispatcher/dispatch-actions/threads'; import { fetchInitialEvent } from "../../utils/EventUtils"; +import { ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -863,10 +864,11 @@ export class RoomView extends React.Component { } case Action.ComposerInsert: { + if (payload.composerType) break; // re-dispatch to the correct composer dis.dispatch({ ...payload, - action: this.state.editState ? "edit_composer_insert" : "send_composer_insert", + composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send, }); break; } diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index c97c984d59..698f24d659 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -60,18 +60,15 @@ import { getDisplayAliasForRoom } from "./RoomDirectory"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useEventEmitterState } from "../../hooks/useEventEmitter"; import { IOOBData } from "../../stores/ThreepidInviteStore"; +import { awaitRoomDownSync } from "../../utils/RoomUpgrade"; +import RoomViewStore from "../../stores/RoomViewStore"; interface IProps { space: Room; initialText?: string; additionalButtons?: ReactNode; - showRoom( - cli: MatrixClient, - hierarchy: RoomHierarchy, - roomId: string, - autoJoin?: boolean, - roomType?: RoomType, - ): void; + showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void; + joinRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void; } interface ITileProps { @@ -80,7 +77,8 @@ interface ITileProps { selected?: boolean; numChildRooms?: number; hasPermissions?: boolean; - onViewRoomClick(autoJoin: boolean, roomType: RoomType): void; + onViewRoomClick(): void; + onJoinRoomClick(): void; onToggleClick?(): void; } @@ -91,31 +89,50 @@ const Tile: React.FC = ({ hasPermissions, onToggleClick, onViewRoomClick, + onJoinRoomClick, numChildRooms, children, }) => { const cli = useContext(MatrixClientContext); - const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; + const [joinedRoom, setJoinedRoom] = useState(() => { + const cliRoom = cli.getRoom(room.room_id); + return cliRoom?.getMyMembership() === "join" ? cliRoom : null; + }); const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name); const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0] || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); const [showChildren, toggleShowChildren] = useStateToggle(true); const [onFocus, isActive, ref] = useRovingTabIndex(); + const [busy, setBusy] = useState(false); const onPreviewClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); - onViewRoomClick(false, room.room_type as RoomType); + onViewRoomClick(); }; - const onJoinClick = (ev: ButtonEvent) => { + const onJoinClick = async (ev: ButtonEvent) => { + setBusy(true); ev.preventDefault(); ev.stopPropagation(); - onViewRoomClick(true, room.room_type as RoomType); + onJoinRoomClick(); + setJoinedRoom(await awaitRoomDownSync(cli, room.room_id)); + setBusy(false); }; let button; - if (joinedRoom) { + if (busy) { + button = + + ; + } else if (joinedRoom) { button = = ({ description += " · " + topic; } + let joinedSection; + if (joinedRoom) { + joinedSection =
+ { _t("Joined") } +
; + } + let suggestedSection; - if (suggested) { + if (suggested && (!joinedRoom || hasPermissions)) { suggestedSection = { _t("Suggested") } ; @@ -183,6 +207,7 @@ const Tile: React.FC = ({ { avatar }
{ name } + { joinedSection } { suggestedSection }
@@ -274,6 +299,7 @@ const Tile: React.FC = ({ = ({ ; }; -export const showRoom = ( - cli: MatrixClient, - hierarchy: RoomHierarchy, - roomId: string, - autoJoin = false, - roomType?: RoomType, -) => { +export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void => { const room = hierarchy.roomMap.get(roomId); // Don't let the user view a room they won't be able to either peek or join: @@ -309,7 +329,6 @@ export const showRoom = ( const roomAlias = getDisplayAliasForRoom(room) || undefined; dis.dispatch({ action: "view_room", - auto_join: autoJoin, should_peek: true, _type: "room_directory", // instrumentation room_alias: roomAlias, @@ -324,13 +343,29 @@ export const showRoom = ( }); }; +export const joinRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void => { + // Don't let the user view a room they won't be able to either peek or join: + // fail earlier so they don't have to click back to the directory. + if (cli.isGuest()) { + dis.dispatch({ action: "require_registration" }); + return; + } + + cli.joinRoom(roomId, { + viaServers: Array.from(hierarchy.viaMap.get(roomId) || []), + }).catch(err => { + RoomViewStore.showJoinRoomError(err, roomId); + }); +}; + interface IHierarchyLevelProps { root: IHierarchyRoom; roomSet: Set; hierarchy: RoomHierarchy; parents: Set; selectedMap?: Map>; - onViewRoomClick(roomId: string, autoJoin: boolean, roomType?: RoomType): void; + onViewRoomClick(roomId: string, roomType?: RoomType): void; + onJoinRoomClick(roomId: string): void; onToggleClick?(parentId: string, childId: string): void; } @@ -365,6 +400,7 @@ export const HierarchyLevel = ({ parents, selectedMap, onViewRoomClick, + onJoinRoomClick, onToggleClick, }: IHierarchyLevelProps) => { const cli = useContext(MatrixClientContext); @@ -392,9 +428,8 @@ export const HierarchyLevel = ({ room={room} suggested={hierarchy.isSuggested(root.room_id, room.room_id)} selected={selectedMap?.get(root.room_id)?.has(room.room_id)} - onViewRoomClick={(autoJoin, roomType) => { - onViewRoomClick(room.room_id, autoJoin, roomType); - }} + onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)} + onJoinRoomClick={() => onJoinRoomClick(room.room_id)} hasPermissions={hasPermissions} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} /> @@ -412,9 +447,8 @@ export const HierarchyLevel = ({ }).length} suggested={hierarchy.isSuggested(root.room_id, space.room_id)} selected={selectedMap?.get(root.room_id)?.has(space.room_id)} - onViewRoomClick={(autoJoin, roomType) => { - onViewRoomClick(space.room_id, autoJoin, roomType); - }} + onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)} + onJoinRoomClick={() => onJoinRoomClick(space.room_id)} hasPermissions={hasPermissions} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} > @@ -425,6 +459,7 @@ export const HierarchyLevel = ({ parents={newParents} selectedMap={selectedMap} onViewRoomClick={onViewRoomClick} + onJoinRoomClick={onJoinRoomClick} onToggleClick={onToggleClick} /> @@ -537,9 +572,19 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu onClick={async () => { setRemoving(true); try { + const userId = cli.getUserId(); for (const [parentId, childId] of selectedRelations) { await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); + // remove the child->parent relation too, if we have permission to. + const childRoom = cli.getRoom(childId); + const parentRelation = childRoom?.currentState.getStateEvents(EventType.SpaceParent, parentId); + if (childRoom?.currentState.maySendStateEvent(EventType.SpaceParent, userId) && + Array.isArray(parentRelation?.getContent().via) + ) { + await cli.sendStateEvent(childId, EventType.SpaceParent, {}, parentId); + } + hierarchy.removeRelation(parentId, childId); } } catch (e) { @@ -678,9 +723,8 @@ const SpaceHierarchy = ({ parents={new Set()} selectedMap={selected} onToggleClick={hasPermissions ? onToggleClick : undefined} - onViewRoomClick={(roomId, autoJoin, roomType) => { - showRoom(cli, hierarchy, roomId, autoJoin, roomType); - }} + onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)} + onJoinRoomClick={(roomId) => joinRoom(cli, hierarchy, roomId)} /> ; } else if (!hierarchy.canLoadMore) { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 0a820d7fc9..04fb9691eb 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -56,7 +56,7 @@ import { showSpaceInvite, showSpaceSettings, } from "../../utils/space"; -import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; +import SpaceHierarchy, { joinRoom, showRoom } from "./SpaceHierarchy"; import MemberAvatar from "../views/avatars/MemberAvatar"; import SpaceStore from "../../stores/SpaceStore"; import FacePile from "../views/elements/FacePile"; @@ -507,7 +507,7 @@ const SpaceLanding = ({ space }: { space: Room }) => { ) } - +
; }; @@ -667,10 +667,6 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {

{ _t("Me and my teammates") }

{ _t("A private space for you and your teammates") }
-
-

{ _t("Teammates might not be able to view or join any private rooms you make.") }

-

{ _t("We're working on this, but just want to let you know.") }

-
; }; diff --git a/src/components/structures/auth/ForgotPassword.tsx b/src/components/structures/auth/ForgotPassword.tsx index b5c5912406..66ade9e6ed 100644 --- a/src/components/structures/auth/ForgotPassword.tsx +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -17,23 +17,22 @@ limitations under the License. */ import React from 'react'; -import classNames from 'classnames'; -import { logger } from "matrix-js-sdk/src/logger"; - import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; import PasswordReset from "../../../PasswordReset"; import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; +import classNames from 'classnames'; import AuthPage from "../../views/auth/AuthPage"; import CountlyAnalytics from "../../../CountlyAnalytics"; import ServerPicker from "../../views/elements/ServerPicker"; +import EmailField from "../../views/auth/EmailField"; import PassphraseField from '../../views/auth/PassphraseField'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; -import withValidation, { IValidationResult } from "../../views/elements/Validation"; -import * as Email from "../../../email"; +import { IValidationResult } from "../../views/elements/Validation"; import InlineSpinner from '../../views/elements/InlineSpinner'; +import { logger } from "matrix-js-sdk/src/logger"; enum Phase { // Show the forgot password inputs @@ -227,30 +226,10 @@ export default class ForgotPassword extends React.Component { }); } - private validateEmailRules = withValidation({ - rules: [ - { - key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !!value; - }, - invalid: () => _t("Enter email address"), - }, { - key: "email", - test: ({ value }) => !value || Email.looksValid(value), - invalid: () => _t("Doesn't look like a valid email address"), - }, - ], - }); - - private onEmailValidate = async (fieldState) => { - const result = await this.validateEmailRules(fieldState); - + private onEmailValidate = (result: IValidationResult) => { this.setState({ emailFieldValid: result.valid, }); - - return result; }; private onPasswordValidate(result: IValidationResult) { @@ -302,14 +281,12 @@ export default class ForgotPassword extends React.Component { />
- this['email_field'] = field} + autoFocus={true} onChange={this.onInputChanged.bind(this, "email")} - ref={field => this['email_field'] = field} - autoFocus onValidate={this.onEmailValidate} onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")} diff --git a/src/components/views/auth/EmailField.tsx b/src/components/views/auth/EmailField.tsx new file mode 100644 index 0000000000..3ff1700030 --- /dev/null +++ b/src/components/views/auth/EmailField.tsx @@ -0,0 +1,92 @@ +/* +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, { PureComponent, RefCallback, RefObject } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Field, { IInputProps } from "../elements/Field"; +import { _t, _td } from "../../../languageHandler"; +import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; +import * as Email from "../../../email"; + +interface IProps extends Omit { + id?: string; + fieldRef?: RefCallback | RefObject; + value: string; + autoFocus?: boolean; + + label?: string; + labelRequired?: string; + labelInvalid?: string; + + // When present, completely overrides the default validation rules. + validationRules?: (fieldState: IFieldState) => Promise; + + onChange(ev: React.FormEvent): void; + onValidate?(result: IValidationResult): void; +} + +@replaceableComponent("views.auth.EmailField") +class EmailField extends PureComponent { + static defaultProps = { + label: _td("Email"), + labelRequired: _td("Enter email address"), + labelInvalid: _td("Doesn't look like a valid email address"), + }; + + public readonly validate = withValidation({ + rules: [ + { + key: "required", + test: ({ value, allowEmpty }) => allowEmpty || !!value, + invalid: () => _t(this.props.labelRequired), + }, + { + key: "email", + test: ({ value }) => !value || Email.looksValid(value), + invalid: () => _t(this.props.labelInvalid), + }, + ], + }); + + onValidate = async (fieldState: IFieldState) => { + let validate = this.validate; + if (this.props.validationRules) { + validate = this.props.validationRules; + } + + const result = await validate(fieldState); + if (this.props.onValidate) { + this.props.onValidate(result); + } + + return result; + }; + + render() { + return ; + } +} + +export default EmailField; diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 587d7f2453..920cec4e5f 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -22,11 +22,11 @@ import SdkConfig from '../../../SdkConfig'; import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import AccessibleButton from "../elements/AccessibleButton"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import withValidation from "../elements/Validation"; -import * as Email from "../../../email"; +import withValidation, { IValidationResult } from "../elements/Validation"; import Field from "../elements/Field"; import CountryDropdown from "./CountryDropdown"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import EmailField from "./EmailField"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -262,26 +262,8 @@ export default class PasswordLogin extends React.PureComponent { return result; }; - private validateEmailRules = withValidation({ - rules: [ - { - key: "required", - test({ value, allowEmpty }) { - return allowEmpty || !!value; - }, - invalid: () => _t("Enter email address"), - }, { - key: "email", - test: ({ value }) => !value || Email.looksValid(value), - invalid: () => _t("Doesn't look like a valid email address"), - }, - ], - }); - - private onEmailValidate = async (fieldState) => { - const result = await this.validateEmailRules(fieldState); + private onEmailValidate = (result: IValidationResult) => { this.markFieldValid(LoginField.Email, result.valid); - return result; }; private validatePhoneNumberRules = withValidation({ @@ -332,12 +314,10 @@ export default class PasswordLogin extends React.PureComponent { switch (loginType) { case LoginField.Email: classes.error = this.props.loginIncorrect && !this.props.username; - return { disabled={this.props.disableSubmit} autoFocus={autoFocus} onValidate={this.onEmailValidate} - ref={field => this[LoginField.Email] = field} + fieldRef={field => this[LoginField.Email] = field} />; case LoginField.MatrixId: classes.error = this.props.loginIncorrect && !this.props.username; diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index 19963d15d4..4f6ffef20c 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -24,8 +24,9 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import { SAFE_LOCALPART_REGEX } from '../../../Registration'; -import withValidation from '../elements/Validation'; +import withValidation, { IValidationResult } from '../elements/Validation'; import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; +import EmailField from "./EmailField"; import PassphraseField from "./PassphraseField"; import CountlyAnalytics from "../../../CountlyAnalytics"; import Field from '../elements/Field'; @@ -252,10 +253,8 @@ export default class RegistrationForm extends React.PureComponent { - const result = await this.validateEmailRules(fieldState); + private onEmailValidate = (result: IValidationResult) => { this.markFieldValid(RegistrationField.Email, result.valid); - return result; }; private validateEmailRules = withValidation({ @@ -425,14 +424,14 @@ export default class RegistrationForm extends React.PureComponent this[RegistrationField.Email] = field} - type="text" - label={emailPlaceholder} + return this[RegistrationField.Email] = field} + label={emailLabel} value={this.state.email} + validationRules={this.validateEmailRules.bind(this)} onChange={this.onEmailChange} onValidate={this.onEmailValidate} onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")} diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index f285222f7b..5955c44bc3 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -17,6 +17,7 @@ limitations under the License. import React, { ComponentProps } from 'react'; import { Room } from 'matrix-js-sdk/src/models/room'; import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import classNames from "classnames"; import BaseAvatar from './BaseAvatar'; @@ -83,8 +84,7 @@ export default class RoomAvatar extends React.Component { }; } - // TODO: type when js-sdk has types - private onRoomStateEvents = (ev: any) => { + private onRoomStateEvents = (ev: MatrixEvent) => { if (!this.props.room || ev.getRoomId() !== this.props.room.roomId || ev.getType() !== 'm.room.avatar' diff --git a/src/components/views/dialogs/LogoutDialog.tsx b/src/components/views/dialogs/LogoutDialog.tsx index af70d51488..fe10a2d41f 100644 --- a/src/components/views/dialogs/LogoutDialog.tsx +++ b/src/components/views/dialogs/LogoutDialog.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { logger } from "matrix-js-sdk/src/logger"; @@ -85,7 +85,9 @@ export default class LogoutDialog extends React.Component { private onExportE2eKeysClicked = (): void => { Modal.createTrackedDialogAsync('Export E2E Keys', '', - import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), + import( + '../../../async-components/views/dialogs/security/ExportE2eKeysDialog' + ) as unknown as Promise>, { matrixClient: MatrixClientPeg.get(), }, @@ -111,7 +113,9 @@ export default class LogoutDialog extends React.Component { ); } else { Modal.createTrackedDialogAsync("Key Backup", "Key Backup", - import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog"), + import( + "../../../async-components/views/dialogs/security/CreateKeyBackupDialog" + ) as unknown as Promise>, null, null, /* priority = */ false, /* static = */ true, ); } diff --git a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx index ac83134f22..baee422f9d 100644 --- a/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx +++ b/src/components/views/dialogs/RegistrationEmailPromptDialog.tsx @@ -21,25 +21,14 @@ import { _t } from '../../../languageHandler'; import { IDialogProps } from "./IDialogProps"; import Field from "../elements/Field"; import CountlyAnalytics from "../../../CountlyAnalytics"; -import withValidation from "../elements/Validation"; -import * as Email from "../../../email"; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; +import EmailField from "../auth/EmailField"; interface IProps extends IDialogProps { onFinished(continued: boolean, email?: string): void; } -const validation = withValidation({ - rules: [ - { - key: "email", - test: ({ value }) => !value || Email.looksValid(value), - invalid: () => _t("Doesn't look like a valid email address"), - }, - ], -}); - const RegistrationEmailPromptDialog: React.FC = ({ onFinished }) => { const [email, setEmail] = useState(""); const fieldRef = useRef(); @@ -47,11 +36,11 @@ const RegistrationEmailPromptDialog: React.FC = ({ onFinished }) => { const onSubmit = async (e) => { e.preventDefault(); if (email) { - const valid = await fieldRef.current.validate({ allowEmpty: false }); + const valid = await fieldRef.current.validate({}); if (!valid) { fieldRef.current.focus(); - fieldRef.current.validate({ allowEmpty: false, focused: true }); + fieldRef.current.validate({ focused: true }); return; } } @@ -72,16 +61,15 @@ const RegistrationEmailPromptDialog: React.FC = ({ onFinished }) => { b: sub => { sub }, }) }

- { - setEmail(ev.target.value); + const target = ev.target as HTMLInputElement; + setEmail(target.value); }} - onValidate={async fieldState => await validation(fieldState)} onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email2_focus")} onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email2_blur")} /> diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index b8e068ed75..66a5cc8b54 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -44,6 +44,7 @@ import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; import EditMessageComposer from '../rooms/EditMessageComposer'; import LinkPreviewGroup from '../rooms/LinkPreviewGroup'; import { IBodyProps } from "./IBodyProps"; +import RoomContext from "../../../contexts/RoomContext"; const MAX_HIGHLIGHT_LENGTH = 4096; @@ -62,6 +63,9 @@ export default class TextualBody extends React.Component { private unmounted = false; private pills: Element[] = []; + static contextType = RoomContext; + public context!: React.ContextType; + constructor(props) { super(props); @@ -406,6 +410,7 @@ export default class TextualBody extends React.Component { dis.dispatch({ action: Action.ComposerInsert, userId: mxEvent.getSender(), + timelineRenderingType: this.context.timelineRenderingType, }); }; diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 003db101b8..dac6a44a58 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -75,6 +75,7 @@ import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialo import { bulkSpaceBehaviour } from "../../../utils/space"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; +import { TimelineRenderingType } from "../../../contexts/RoomContext"; export interface IDevice { deviceId: string; @@ -377,6 +378,7 @@ const UserOptionsSection: React.FC<{ dis.dispatch({ action: Action.ComposerInsert, userId: member.userId, + timelineRenderingType: TimelineRenderingType.Room, }); }; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 57880ed173..c8785042f3 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -29,6 +29,7 @@ import { formatRangeAsCode, toggleInlineFormat, replaceRangeAndMoveCaret, + formatRangeAsLink, } from '../../../editor/operations'; import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom'; import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; @@ -476,6 +477,8 @@ export default class BasicMessageEditor extends React.Component switch (autocompleteAction) { case AutocompleteAction.ForceComplete: case AutocompleteAction.Complete: + this.historyManager.ensureLastChangesPushed(this.props.model); + this.modifiedFlag = true; autoComplete.confirmCompletion(); handled = true; break; @@ -705,6 +708,9 @@ export default class BasicMessageEditor extends React.Component case Formatting.Quote: formatRangeAsQuote(range); break; + case Formatting.InsertLink: + formatRangeAsLink(range); + break; } }; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 6a4a259ae5..37ddc7b0f7 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -46,6 +46,7 @@ import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog'; import SettingsStore from "../../../settings/SettingsStore"; import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext'; import RoomContext from '../../../contexts/RoomContext'; +import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload"; function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; @@ -498,7 +499,12 @@ class EditMessageComposer extends React.Component { - if (payload.action === "edit_composer_insert" && this.editorRef.current) { + if (!this.editorRef.current) return; + + if (payload.action === Action.ComposerInsert) { + if (payload.timelineRenderingType !== this.context.timelineRenderingType) return; + if (payload.composerType !== ComposerType.Edit) return; + if (payload.userId) { this.editorRef.current?.insertMention(payload.userId); } else if (payload.event) { @@ -506,7 +512,7 @@ class EditMessageComposer extends React.Component { } onSenderProfileClick = () => { - const mxEvent = this.props.mxEvent; + if (!this.props.timelineRenderingType) return; dis.dispatch({ action: Action.ComposerInsert, - userId: mxEvent.getSender(), + userId: this.props.mxEvent.getSender(), + timelineRenderingType: this.props.timelineRenderingType, }); }; @@ -1090,7 +1095,7 @@ export default class EventTile extends React.Component { } if (needsSenderProfile && this.props.hideSender !== true) { - if (!this.props.tileShape) { + if (!this.props.tileShape || this.props.tileShape === TileShape.Thread) { sender = { private ref: React.RefObject = createRef(); private instanceId: number; - public static contextType = RoomContext; + static contextType = RoomContext; + public context!: React.ContextType; static defaultProps = { compact: false, @@ -399,13 +400,14 @@ export default class MessageComposer extends React.Component { } }; - private addEmoji(emoji: string): boolean { + private addEmoji = (emoji: string): boolean => { dis.dispatch({ action: Action.ComposerInsert, text: emoji, + timelineRenderingType: this.context.timelineRenderingType, }); return true; - } + }; private sendMessage = async () => { if (this.state.haveRecording && this.voiceRecordingButton.current) { diff --git a/src/components/views/rooms/MessageComposerFormatBar.tsx b/src/components/views/rooms/MessageComposerFormatBar.tsx index 7839b89c79..3716450239 100644 --- a/src/components/views/rooms/MessageComposerFormatBar.tsx +++ b/src/components/views/rooms/MessageComposerFormatBar.tsx @@ -27,6 +27,7 @@ export enum Formatting { Strikethrough = "strikethrough", Code = "code", Quote = "quote", + InsertLink = "insert_link", } interface IProps { @@ -57,6 +58,7 @@ export default class MessageComposerFormatBar extends React.PureComponent this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} /> this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} /> this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} /> + this.props.onAction(Formatting.InsertLink)} icon="InsertLink" visible={this.state.visible} />
); } diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 8dfa75e269..02f039f6c5 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactComponentElement } from "react"; +import React, { createRef, ReactComponentElement } from "react"; import { Dispatcher } from "flux"; import { Room } from "matrix-js-sdk/src/models/room"; import * as fbEmitter from "fbemitter"; @@ -22,7 +22,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { JoinRule } from "matrix-js-sdk/src/@types/partials"; import { _t, _td } from "../../../languageHandler"; -import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; +import { RovingTabIndexProvider, IState as IRovingTabIndexState } from "../../../accessibility/RovingTabIndex"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import RoomViewStore from "../../../stores/RoomViewStore"; @@ -54,7 +54,7 @@ import { shouldShowComponent } from "../../../customisations/helpers/UIComponent import { UIComponent } from "../../../settings/UIFeature"; interface IProps { - onKeyDown: (ev: React.KeyboardEvent) => void; + onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; onFocus: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void; onResize: () => void; @@ -249,6 +249,7 @@ export default class RoomList extends React.PureComponent { private dispatcherRef; private customTagStoreRef; private roomStoreToken: fbEmitter.EventSubscription; + private treeRef = createRef(); constructor(props: IProps) { super(props); @@ -505,6 +506,12 @@ export default class RoomList extends React.PureComponent { }); } + public focus(): void { + // focus the first focusable element in this aria treeview widget + [...this.treeRef.current?.querySelectorAll('[role="treeitem"]')] + .find(e => e.offsetParent !== null)?.focus(); + } + public render() { const cli = MatrixClientPeg.get(); const userId = cli.getUserId(); @@ -584,7 +591,7 @@ export default class RoomList extends React.PureComponent { const sublists = this.renderSublists(); return ( - + { ({ onKeyDownHandler }) => (
{ className="mx_RoomList" role="tree" aria-label={_t("Rooms")} + ref={this.treeRef} > { sublists } { explorePrompt } diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index d855e902cb..4707f2afec 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -58,6 +58,7 @@ import { ActionPayload } from "../../../dispatcher/payloads"; import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics"; import RoomContext from '../../../contexts/RoomContext'; import DocumentPosition from "../../../editor/position"; +import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload"; function addReplyToMessageContent( content: IContent, @@ -591,7 +592,10 @@ export class SendMessageComposer extends React.Component { private onExportE2eKeysClicked = (): void => { Modal.createTrackedDialogAsync('Export E2E Keys', 'Change Password', - import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), + import( + '../../../async-components/views/dialogs/security/ExportE2eKeysDialog' + ) as unknown as Promise>, { matrixClient: MatrixClientPeg.get(), }, diff --git a/src/components/views/settings/CryptographyPanel.tsx b/src/components/views/settings/CryptographyPanel.tsx index 67a3e8aa75..353645fd05 100644 --- a/src/components/views/settings/CryptographyPanel.tsx +++ b/src/components/views/settings/CryptographyPanel.tsx @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ComponentType } from 'react'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; @@ -92,14 +92,18 @@ export default class CryptographyPanel extends React.Component { private onExportE2eKeysClicked = (): void => { Modal.createTrackedDialogAsync('Export E2E Keys', '', - import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), + import( + '../../../async-components/views/dialogs/security/ExportE2eKeysDialog' + ) as unknown as Promise>, { matrixClient: MatrixClientPeg.get() }, ); }; private onImportE2eKeysClicked = (): void => { Modal.createTrackedDialogAsync('Import E2E Keys', '', - import('../../../async-components/views/dialogs/security/ImportE2eKeysDialog'), + import( + '../../../async-components/views/dialogs/security/ImportE2eKeysDialog' + ) as unknown as Promise>, { matrixClient: MatrixClientPeg.get() }, ); }; diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index f0c39cf10f..d44a7a78b1 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -15,10 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; -import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; -import { logger } from "matrix-js-sdk/src/logger"; +import React, { ComponentType } from 'react'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; @@ -30,6 +27,8 @@ import QuestionDialog from '../dialogs/QuestionDialog'; import RestoreKeyBackupDialog from '../dialogs/security/RestoreKeyBackupDialog'; import { accessSecretStorage } from '../../../SecurityManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { TrustInfo } from "matrix-js-sdk/src/crypto/backup"; interface IState { loading: boolean; @@ -44,6 +43,8 @@ interface IState { sessionsRemaining: number; } +import { logger } from "matrix-js-sdk/src/logger"; + @replaceableComponent("views.settings.SecureBackupPanel") export default class SecureBackupPanel extends React.PureComponent<{}, IState> { private unmounted = false; @@ -169,7 +170,9 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { private startNewBackup = (): void => { Modal.createTrackedDialogAsync('Key Backup', 'Key Backup', - import('../../../async-components/views/dialogs/security/CreateKeyBackupDialog'), + import( + '../../../async-components/views/dialogs/security/CreateKeyBackupDialog' + ) as unknown as Promise>, { onFinished: () => { this.loadBackupStatus(); diff --git a/src/components/views/settings/ThemeChoicePanel.tsx b/src/components/views/settings/ThemeChoicePanel.tsx index d71b6b66ad..0e80aacb43 100644 --- a/src/components/views/settings/ThemeChoicePanel.tsx +++ b/src/components/views/settings/ThemeChoicePanel.tsx @@ -19,7 +19,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; -import { enumerateThemes } from "../../../theme"; +import { enumerateThemes, findHighContrastTheme, findNonHighContrastTheme, isHighContrastTheme } from "../../../theme"; import ThemeWatcher from "../../../settings/watchers/ThemeWatcher"; import AccessibleButton from "../elements/AccessibleButton"; import dis from "../../../dispatcher/dispatcher"; @@ -159,7 +159,37 @@ export default class ThemeChoicePanel extends React.Component { this.setState({ customThemeUrl: e.target.value }); }; - public render() { + private renderHighContrastCheckbox(): React.ReactElement { + if ( + !this.state.useSystemTheme && ( + findHighContrastTheme(this.state.theme) || + isHighContrastTheme(this.state.theme) + ) + ) { + return
+ this.highContrastThemeChanged(e.target.checked)} + > + { _t( "Use high contrast" ) } + +
; + } + } + + private highContrastThemeChanged(checked: boolean): void { + let newTheme: string; + if (checked) { + newTheme = findHighContrastTheme(this.state.theme); + } else { + newTheme = findNonHighContrastTheme(this.state.theme); + } + if (newTheme) { + this.onThemeChange(newTheme); + } + } + + public render(): React.ReactElement { const themeWatcher = new ThemeWatcher(); let systemThemeSection: JSX.Element; if (themeWatcher.isSystemThemeSupported()) { @@ -210,7 +240,8 @@ export default class ThemeChoicePanel extends React.Component { // XXX: replace any type here const themes = Object.entries(enumerateThemes()) - .map(p => ({ id: p[0], name: p[1] })); // convert pairs to objects for code readability + .map(p => ({ id: p[0], name: p[1] })) // convert pairs to objects for code readability + .filter(p => !isHighContrastTheme(p.id)); const builtInThemes = themes.filter(p => !p.id.startsWith("custom-")); const customThemes = themes.filter(p => !builtInThemes.includes(p)) .sort((a, b) => compare(a.name, b.name)); @@ -229,12 +260,21 @@ export default class ThemeChoicePanel extends React.Component { className: "mx_ThemeSelector_" + t.id, }))} onChange={this.onThemeChange} - value={this.state.useSystemTheme ? undefined : this.state.theme} + value={this.apparentSelectedThemeId()} outlined />
+ { this.renderHighContrastCheckbox() } { customThemeForm }
); } + + apparentSelectedThemeId() { + if (this.state.useSystemTheme) { + return undefined; + } + const nonHighContrast = findNonHighContrastTheme(this.state.theme); + return nonHighContrast ? nonHighContrast : this.state.theme; + } } diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 67055d7418..f61ebdf0f7 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -43,7 +43,6 @@ import SpaceStore, { } from "../../../stores/SpaceStore"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; -import { Key } from "../../../Keyboard"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import SpaceContextMenu from "../context_menus/SpaceContextMenu"; import IconizedContextMenu, { @@ -228,75 +227,12 @@ const SpacePanel = () => { return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel"); }, []); - const onKeyDown = (ev: React.KeyboardEvent) => { - if (ev.defaultPrevented) return; - - let handled = true; - - switch (ev.key) { - case Key.ARROW_UP: - onMoveFocus(ev.target as Element, true); - break; - case Key.ARROW_DOWN: - onMoveFocus(ev.target as Element, false); - break; - default: - handled = false; - } - - if (handled) { - // consume all other keys in context menu - ev.stopPropagation(); - ev.preventDefault(); - } - }; - - const onMoveFocus = (element: Element, up: boolean) => { - let descending = false; // are we currently descending or ascending through the DOM tree? - let classes: DOMTokenList; - - do { - const child = up ? element.lastElementChild : element.firstElementChild; - const sibling = up ? element.previousElementSibling : element.nextElementSibling; - - if (descending) { - if (child) { - element = child; - } else if (sibling) { - element = sibling; - } else { - descending = false; - element = element.parentElement; - } - } else { - if (sibling) { - element = sibling; - descending = true; - } else { - element = element.parentElement; - } - } - - if (element) { - if (element.classList.contains("mx_ContextualMenu")) { // we hit the top - element = up ? element.lastElementChild : element.firstElementChild; - descending = true; - } - classes = element.classList; - } - } while (element && !classes.contains("mx_SpaceButton")); - - if (element) { - (element as HTMLElement).focus(); - } - }; - return ( { if (!result.destination) return; // dropped outside the list SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index); }}> - + { ({ onKeyDownHandler }) => (
    { if (window.electron?.getDesktopCapturerSources) { const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; + if (!source) return; + isScreensharing = await this.props.call.setScreensharingEnabled(true, source); } else { isScreensharing = await this.props.call.setScreensharingEnabled(true); @@ -545,6 +547,7 @@ export default class CallView extends React.Component {
    { sidebar }
    diff --git a/src/dispatcher/payloads/ComposerInsertPayload.ts b/src/dispatcher/payloads/ComposerInsertPayload.ts index ea5d8a0c53..e5aab51a21 100644 --- a/src/dispatcher/payloads/ComposerInsertPayload.ts +++ b/src/dispatcher/payloads/ComposerInsertPayload.ts @@ -18,9 +18,17 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { ActionPayload } from "../payloads"; import { Action } from "../actions"; +import { TimelineRenderingType } from "../../contexts/RoomContext"; + +export enum ComposerType { + Send = "send", + Edit = "edit", +} interface IBaseComposerInsertPayload extends ActionPayload { action: Action.ComposerInsert; + timelineRenderingType: TimelineRenderingType; + composerType?: ComposerType; // falsey if should be re-dispatched to the correct composer } interface IComposerInsertMentionPayload extends IBaseComposerInsertPayload { diff --git a/src/editor/operations.ts b/src/editor/operations.ts index 2ff09ccce6..85c0b783aa 100644 --- a/src/editor/operations.ts +++ b/src/editor/operations.ts @@ -32,13 +32,13 @@ export function replaceRangeAndExpandSelection(range: Range, newParts: Part[]): }); } -export function replaceRangeAndMoveCaret(range: Range, newParts: Part[]): void { +export function replaceRangeAndMoveCaret(range: Range, newParts: Part[], offset = 0): void { const { model } = range; model.transform(() => { const oldLen = range.length; const addedLen = range.replace(newParts); const firstOffset = range.start.asOffset(model); - const lastOffset = firstOffset.add(oldLen + addedLen); + const lastOffset = firstOffset.add(oldLen + addedLen + offset); return lastOffset.asPosition(model); }); } @@ -103,6 +103,15 @@ export function formatRangeAsCode(range: Range): void { replaceRangeAndExpandSelection(range, parts); } +export function formatRangeAsLink(range: Range) { + const { model, parts } = range; + const { partCreator } = model; + parts.unshift(partCreator.plain("[")); + parts.push(partCreator.plain("]()")); + // We set offset to -1 here so that the caret lands between the brackets + replaceRangeAndMoveCaret(range, parts, -1); +} + // parts helper methods const isBlank = part => !part.text || !/\S/.test(part.text); const isNL = part => part.type === Type.Newline; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 68f4fca183..1fffc04696 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -517,6 +517,8 @@ "%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.", "%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s made the room public to whoever knows the link.", "%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s made the room invite only.", + "%(senderDisplayName)s changed who can join this room. View settings.": "%(senderDisplayName)s changed who can join this room. View settings.", + "%(senderDisplayName)s changed who can join this room.": "%(senderDisplayName)s changed who can join this room.", "%(senderDisplayName)s changed the join rule to %(rule)s": "%(senderDisplayName)s changed the join rule to %(rule)s", "%(senderDisplayName)s has allowed guests to join the room.": "%(senderDisplayName)s has allowed guests to join the room.", "%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s has prevented guests from joining the room.", @@ -577,6 +579,7 @@ "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s", "Light": "Light", + "Light high contrast": "Light high contrast", "Dark": "Dark", "%(displayName)s is typing …": "%(displayName)s is typing …", "%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …", @@ -1291,6 +1294,7 @@ "Invalid theme schema.": "Invalid theme schema.", "Error downloading theme information.": "Error downloading theme information.", "Theme added!": "Theme added!", + "Use high contrast": "Use high contrast", "Custom theme URL": "Custom theme URL", "Add theme": "Add theme", "Theme": "Theme", @@ -1606,6 +1610,7 @@ "Strikethrough": "Strikethrough", "Code block": "Code block", "Quote": "Quote", + "Insert link": "Insert link", "Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.", "This is the beginning of your direct message history with .": "This is the beginning of your direct message history with .", "Topic: %(topic)s (edit)": "Topic: %(topic)s (edit)", @@ -2520,7 +2525,6 @@ "Message edits": "Message edits", "Modal Widget": "Modal Widget", "Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s", - "Doesn't look like a valid email address": "Doesn't look like a valid email address", "Continuing without email": "Continuing without email", "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.", "Email (optional)": "Email (optional)", @@ -2735,6 +2739,9 @@ "powered by Matrix": "powered by Matrix", "This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.", "Country Dropdown": "Country Dropdown", + "Email": "Email", + "Enter email address": "Enter email address", + "Doesn't look like a valid email address": "Doesn't look like a valid email address", "Confirm your identity by entering your account password below.": "Confirm your identity by entering your account password below.", "Password": "Password", "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.", @@ -2754,10 +2761,8 @@ "Password is allowed, but unsafe": "Password is allowed, but unsafe", "Keep going...": "Keep going...", "Enter username": "Enter username", - "Enter email address": "Enter email address", "Enter phone number": "Enter phone number", "That phone number doesn't look quite right, please check and try again": "That phone number doesn't look quite right, please check and try again", - "Email": "Email", "Username": "Username", "Phone": "Phone", "Forgot password?": "Forgot password?", @@ -2925,7 +2930,9 @@ "Drop file here to upload": "Drop file here to upload", "You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.", "You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.", + "Joining": "Joining", "You don't have permission": "You don't have permission", + "Joined": "Joined", "This room is suggested as a good one to join": "This room is suggested as a good one to join", "Suggested": "Suggested", "Select a room below first": "Select a room below first", @@ -2966,8 +2973,6 @@ "A private space to organise your rooms": "A private space to organise your rooms", "Me and my teammates": "Me and my teammates", "A private space for you and your teammates": "A private space for you and your teammates", - "Teammates might not be able to view or join any private rooms you make.": "Teammates might not be able to view or join any private rooms you make.", - "We're working on this, but just want to let you know.": "We're working on this, but just want to let you know.", "Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s", "Inviting...": "Inviting...", "Invite your teammates": "Invite your teammates", diff --git a/src/sentry.ts b/src/sentry.ts index 005f853f24..b2f37bbc5a 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -220,3 +220,5 @@ export async function initSentry(sentryConfig: ISentryConfig): Promise { tracesSampleRate: 1.0, }); } + +window.mxSendSentryReport = sendSentryReport; diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index d13e1af92f..5c197a2796 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -307,7 +307,7 @@ class RoomViewStore extends Store { } } - private getInvitingUserId(roomId: string): string { + private static getInvitingUserId(roomId: string): string { const cli = MatrixClientPeg.get(); const room = cli.getRoom(roomId); if (room && room.getMyMembership() === "invite") { @@ -317,12 +317,7 @@ class RoomViewStore extends Store { } } - private joinRoomError(payload: ActionPayload) { - this.setState({ - joining: false, - joinError: payload.err, - }); - const err = payload.err; + public showJoinRoomError(err: Error | MatrixError, roomId: string) { let msg = err.message ? err.message : JSON.stringify(err); logger.log("Failed to join room:", msg); @@ -334,7 +329,7 @@ class RoomViewStore extends Store { { _t("Please contact your homeserver administrator.") }
    ; } else if (err.httpStatus === 404) { - const invitingUserId = this.getInvitingUserId(this.state.roomId); + const invitingUserId = RoomViewStore.getInvitingUserId(roomId); // only provide a better error message for invites if (invitingUserId) { // if the inviting user is on the same HS, there can only be one cause: they left. @@ -354,6 +349,14 @@ class RoomViewStore extends Store { }); } + private joinRoomError(payload: ActionPayload) { + this.setState({ + joining: false, + joinError: payload.err, + }); + this.showJoinRoomError(payload.err, this.state.roomId); + } + public reset() { this.state = Object.assign({}, INITIAL_STATE); } diff --git a/src/stores/SpaceStore.ts b/src/stores/SpaceStore.ts index 55e9b0f446..547f442799 100644 --- a/src/stores/SpaceStore.ts +++ b/src/stores/SpaceStore.ts @@ -306,16 +306,23 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return room?.currentState.getStateEvents(EventType.SpaceParent) .map(ev => { const content = ev.getContent(); - if (Array.isArray(content?.via) && (!canonicalOnly || content?.canonical)) { - const parent = this.matrixClient.getRoom(ev.getStateKey()); - // only respect the relationship if the sender has sufficient permissions in the parent to set - // child relations, as per MSC1772. - // https://github.com/matrix-org/matrix-doc/blob/main/proposals/1772-groups-as-rooms.md#relationship-between-rooms-and-spaces - if (parent?.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { - return parent; - } + if (!Array.isArray(content.via) || (canonicalOnly && !content.canonical)) { + return; // skip } - // else implicit undefined which causes this element to be filtered out + + // only respect the relationship if the sender has sufficient permissions in the parent to set + // child relations, as per MSC1772. + // https://github.com/matrix-org/matrix-doc/blob/main/proposals/1772-groups-as-rooms.md#relationship-between-rooms-and-spaces + const parent = this.matrixClient.getRoom(ev.getStateKey()); + const relation = parent?.currentState.getStateEvents(EventType.SpaceChild, roomId); + if (!parent?.currentState.maySendStateEvent(EventType.SpaceChild, userId) || + // also skip this relation if the parent had this child added but then since removed it + (relation && !Array.isArray(relation.getContent().via)) + ) { + return; // skip + } + + return parent; }) .filter(Boolean) || []; } diff --git a/src/stores/widgets/WidgetLayoutStore.ts b/src/stores/widgets/WidgetLayoutStore.ts index fcb0eec28d..f66d55dd86 100644 --- a/src/stores/widgets/WidgetLayoutStore.ts +++ b/src/stores/widgets/WidgetLayoutStore.ts @@ -208,15 +208,14 @@ export class WidgetLayoutStore extends ReadyWatchingStore { const isLegacyPinned = !!legacyPinned?.[widget.id]; const defaultContainer = WidgetType.JITSI.matches(widget.type) ? Container.Top : Container.Right; - if (manualContainer === Container.Right) { - rightWidgets.push(widget); - } else if (manualContainer === Container.Top || stateContainer === Container.Top) { - topWidgets.push(widget); + let targetContainer = defaultContainer; + if (!!manualContainer || !!stateContainer) { + targetContainer = (manualContainer) ? manualContainer : stateContainer; } else if (isLegacyPinned && !stateContainer) { - topWidgets.push(widget); - } else { - (defaultContainer === Container.Top ? topWidgets : rightWidgets).push(widget); + // Special legacy case + targetContainer = Container.Top; } + (targetContainer === Container.Top ? topWidgets : rightWidgets).push(widget); } // Trim to MAX_PINNED @@ -424,7 +423,7 @@ export class WidgetLayoutStore extends ReadyWatchingStore { public moveToContainer(room: Room, widget: IApp, toContainer: Container) { const allWidgets = this.getAllWidgets(room); - if (!allWidgets.some(([w])=> w.id === widget.id)) return; // invalid + if (!allWidgets.some(([w]) => w.id === widget.id)) return; // invalid this.updateUserLayout(room, { [widget.id]: { container: toContainer }, }); diff --git a/src/theme.ts b/src/theme.ts index effd0a7b05..281eaf8652 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -20,6 +20,9 @@ import SettingsStore from "./settings/SettingsStore"; import ThemeWatcher from "./settings/watchers/ThemeWatcher"; export const DEFAULT_THEME = "light"; +const HIGH_CONTRAST_THEMES = { + "light": "light-high-contrast", +}; interface IFontFaces { src: { @@ -41,9 +44,37 @@ interface ICustomTheme { is_dark?: boolean; // eslint-disable-line camelcase } +/** + * Given a non-high-contrast theme, find the corresponding high-contrast one + * if it exists, or return undefined if not. + */ +export function findHighContrastTheme(theme: string) { + return HIGH_CONTRAST_THEMES[theme]; +} + +/** + * Given a high-contrast theme, find the corresponding non-high-contrast one + * if it exists, or return undefined if not. + */ +export function findNonHighContrastTheme(hcTheme: string) { + for (const theme in HIGH_CONTRAST_THEMES) { + if (HIGH_CONTRAST_THEMES[theme] === hcTheme) { + return theme; + } + } +} + +/** + * Decide whether the supplied theme is high contrast. + */ +export function isHighContrastTheme(theme: string) { + return Object.values(HIGH_CONTRAST_THEMES).includes(theme); +} + export function enumerateThemes(): {[key: string]: string} { const BUILTIN_THEMES = { "light": _t("Light"), + "light-high-contrast": _t("Light high contrast"), "dark": _t("Dark"), }; const customThemes = SettingsStore.getValue("custom_themes"); diff --git a/src/utils/RoomUpgrade.ts b/src/utils/RoomUpgrade.ts index 1e863d9c84..b9ea93d7fc 100644 --- a/src/utils/RoomUpgrade.ts +++ b/src/utils/RoomUpgrade.ts @@ -16,7 +16,6 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { logger } from "matrix-js-sdk/src/logger"; import { inviteUsersToRoom } from "../RoomInvite"; import Modal, { IHandle } from "../Modal"; @@ -25,6 +24,9 @@ import ErrorDialog from "../components/views/dialogs/ErrorDialog"; import SpaceStore from "../stores/SpaceStore"; import Spinner from "../components/views/elements/Spinner"; +import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + interface IProgress { roomUpgraded: boolean; roomSynced?: boolean; @@ -34,6 +36,23 @@ interface IProgress { updateSpacesTotal: number; } +export async function awaitRoomDownSync(cli: MatrixClient, roomId: string): Promise { + const room = cli.getRoom(roomId); + if (room) return room; // already have the room + + return new Promise(resolve => { + // We have to wait for the js-sdk to give us the room back so + // we can more effectively abuse the MultiInviter behaviour + // which heavily relies on the Room object being available. + const checkForRoomFn = (room: Room) => { + if (room.roomId !== roomId) return; + resolve(room); + cli.off("Room", checkForRoomFn); + }; + cli.on("Room", checkForRoomFn); + }); +} + export async function upgradeRoom( room: Room, targetVersion: string, @@ -49,7 +68,7 @@ export async function upgradeRoom( spinnerModal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner"); } - let toInvite: string[]; + let toInvite: string[] = []; if (inviteUsers) { toInvite = [ ...room.getMembersWithMembership("join"), @@ -57,7 +76,7 @@ export async function upgradeRoom( ].map(m => m.userId).filter(m => m !== cli.getUserId()); } - let parentsToRelink: Room[]; + let parentsToRelink: Room[] = []; if (updateSpaces) { parentsToRelink = Array.from(SpaceStore.instance.getKnownParents(room.roomId)) .map(roomId => cli.getRoom(roomId)) @@ -92,24 +111,7 @@ export async function upgradeRoom( progressCallback?.(progress); if (awaitRoom || inviteUsers) { - await new Promise(resolve => { - // already have the room - if (room.client.getRoom(newRoomId)) { - resolve(); - return; - } - - // We have to wait for the js-sdk to give us the room back so - // we can more effectively abuse the MultiInviter behaviour - // which heavily relies on the Room object being available. - const checkForRoomFn = (newRoom: Room) => { - if (newRoom.roomId !== newRoomId) return; - resolve(); - cli.off("Room", checkForRoomFn); - }; - cli.on("Room", checkForRoomFn); - }); - + await awaitRoomDownSync(room.client, newRoomId); progress.roomSynced = true; progressCallback?.(progress); } diff --git a/test/accessibility/RovingTabIndex-test.js b/test/accessibility/RovingTabIndex-test.js deleted file mode 100644 index 861aeaf9d7..0000000000 --- a/test/accessibility/RovingTabIndex-test.js +++ /dev/null @@ -1,118 +0,0 @@ -/* -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 '../skinned-sdk'; // Must be first for skinning to work -import React from "react"; -import { mount } from "enzyme"; - -import { - RovingTabIndexProvider, - RovingTabIndexWrapper, - useRovingTabIndex, -} from "../../src/accessibility/RovingTabIndex"; - -const Button = (props) => { - const [onFocus, isActive, ref] = useRovingTabIndex(); - return ; -const button2 = ; -const button3 = ; -const button4 = ; - -describe("RovingTabIndex", () => { - it("RovingTabIndexProvider renders children as expected", () => { - const wrapper = mount( - { () =>
    Test
    } -
    ); - expect(wrapper.text()).toBe("Test"); - expect(wrapper.html()).toBe('
    Test
    '); - }); - - it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { - const wrapper = mount( - { () => - { button1 } - { button2 } - { button3 } - } - ); - - // should begin with 0th being active - checkTabIndexes(wrapper.find("button"), [0, -1, -1]); - - // focus on 2nd button and test it is the only active one - wrapper.find("button").at(2).simulate("focus"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); - - // focus on 1st button and test it is the only active one - wrapper.find("button").at(1).simulate("focus"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); - - // check that the active button does not change even on an explicit blur event - wrapper.find("button").at(1).simulate("blur"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); - - // update the children, it should remain on the same button - wrapper.setProps({ - children: () => [button1, button4, button2, button3], - }); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0, -1]); - - // update the children, remove the active button, it should move to the next one - wrapper.setProps({ - children: () => [button1, button4, button3], - }); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); - }); - - it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => { - const wrapper = mount( - { () => - { button1 } - { button2 } - - { ({ onFocus, isActive, ref }) => - - } - - } - ); - - // should begin with 0th being active - checkTabIndexes(wrapper.find("button"), [0, -1, -1]); - - // focus on 2nd button and test it is the only active one - wrapper.find("button").at(2).simulate("focus"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); - }); -}); - diff --git a/test/accessibility/RovingTabIndex-test.tsx b/test/accessibility/RovingTabIndex-test.tsx new file mode 100644 index 0000000000..7c08a676a9 --- /dev/null +++ b/test/accessibility/RovingTabIndex-test.tsx @@ -0,0 +1,341 @@ +/* +Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import '../skinned-sdk'; // Must be first for skinning to work +import * as React from "react"; +import { mount, ReactWrapper } from "enzyme"; + +import { + IState, + reducer, + RovingTabIndexProvider, + RovingTabIndexWrapper, + Type, + useRovingTabIndex, +} from "../../src/accessibility/RovingTabIndex"; + +const Button = (props) => { + const [onFocus, isActive, ref] = useRovingTabIndex(); + return ; +const button2 = ; +const button3 = ; +const button4 = ; + +describe("RovingTabIndex", () => { + it("RovingTabIndexProvider renders children as expected", () => { + const wrapper = mount( + { () =>
    Test
    } +
    ); + expect(wrapper.text()).toBe("Test"); + expect(wrapper.html()).toBe('
    Test
    '); + }); + + it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { + const wrapper = mount( + { () => + { button1 } + { button2 } + { button3 } + } + ); + + // should begin with 0th being active + checkTabIndexes(wrapper.find("button"), [0, -1, -1]); + + // focus on 2nd button and test it is the only active one + wrapper.find("button").at(2).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + + // focus on 1st button and test it is the only active one + wrapper.find("button").at(1).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); + + // check that the active button does not change even on an explicit blur event + wrapper.find("button").at(1).simulate("blur"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); + + // update the children, it should remain on the same button + wrapper.setProps({ + children: () => [button1, button4, button2, button3], + }); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0, -1]); + + // update the children, remove the active button, it should move to the next one + wrapper.setProps({ + children: () => [button1, button4, button3], + }); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + }); + + it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => { + const wrapper = mount( + { () => + { button1 } + { button2 } + + { ({ onFocus, isActive, ref }) => + + } + + } + ); + + // should begin with 0th being active + checkTabIndexes(wrapper.find("button"), [0, -1, -1]); + + // focus on 2nd button and test it is the only active one + wrapper.find("button").at(2).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + }); + + describe("reducer functions as expected", () => { + it("SetFocus works as expected", () => { + const ref1 = React.createRef(); + const ref2 = React.createRef(); + expect(reducer({ + activeRef: ref1, + refs: [ref1, ref2], + }, { + type: Type.SetFocus, + payload: { + ref: ref2, + }, + })).toStrictEqual({ + activeRef: ref2, + refs: [ref1, ref2], + }); + }); + + it("Unregister works as expected", () => { + const ref1 = React.createRef(); + const ref2 = React.createRef(); + const ref3 = React.createRef(); + const ref4 = React.createRef(); + + let state: IState = { + activeRef: null, + refs: [ref1, ref2, ref3, ref4], + }; + + state = reducer(state, { + type: Type.Unregister, + payload: { + ref: ref2, + }, + }); + expect(state).toStrictEqual({ + activeRef: null, + refs: [ref1, ref3, ref4], + }); + + state = reducer(state, { + type: Type.Unregister, + payload: { + ref: ref3, + }, + }); + expect(state).toStrictEqual({ + activeRef: null, + refs: [ref1, ref4], + }); + + state = reducer(state, { + type: Type.Unregister, + payload: { + ref: ref4, + }, + }); + expect(state).toStrictEqual({ + activeRef: null, + refs: [ref1], + }); + + state = reducer(state, { + type: Type.Unregister, + payload: { + ref: ref1, + }, + }); + expect(state).toStrictEqual({ + activeRef: null, + refs: [], + }); + }); + + it("Register works as expected", () => { + const ref1 = React.createRef(); + const ref2 = React.createRef(); + const ref3 = React.createRef(); + const ref4 = React.createRef(); + + mount( + + + + + ); + + let state: IState = { + activeRef: null, + refs: [], + }; + + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref1, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref1, + refs: [ref1], + }); + + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref1, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref1, + refs: [ref1], + }); + + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref2, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref1, + refs: [ref1, ref2], + }); + + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref3, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref1, + refs: [ref1, ref2, ref3], + }); + + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref4, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref1, + refs: [ref1, ref2, ref3, ref4], + }); + + // test that the automatic focus switch works for unmounting + state = reducer(state, { + type: Type.SetFocus, + payload: { + ref: ref2, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref2, + refs: [ref1, ref2, ref3, ref4], + }); + + state = reducer(state, { + type: Type.Unregister, + payload: { + ref: ref2, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref3, + refs: [ref1, ref3, ref4], + }); + + // test that the insert into the middle works as expected + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref2, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref3, + refs: [ref1, ref2, ref3, ref4], + }); + + // test that insertion at the edges works + state = reducer(state, { + type: Type.Unregister, + payload: { + ref: ref1, + }, + }); + state = reducer(state, { + type: Type.Unregister, + payload: { + ref: ref4, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref3, + refs: [ref2, ref3], + }); + + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref1, + }, + }); + + state = reducer(state, { + type: Type.Register, + payload: { + ref: ref4, + }, + }); + expect(state).toStrictEqual({ + activeRef: ref3, + refs: [ref1, ref2, ref3, ref4], + }); + }); + }); +}); + diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index cdc3e58a4f..ccbf0af402 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -281,7 +281,7 @@ describe("SpaceStore", () => { mkSpace(space1, [fav1, room1]); mkSpace(space2, [fav1, fav2, fav3, room1]); mkSpace(space3, [invite2]); - // client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); + client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); [fav1, fav2, fav3].forEach(roomId => { client.getRoom(roomId).tags = { diff --git a/yarn.lock b/yarn.lock index 89100f7c79..76da4be19d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3961,9 +3961,9 @@ fbjs@0.1.0-alpha.7: whatwg-fetch "^0.9.0" fbjs@^0.8.4: - version "0.8.17" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.17.tgz#c4d598ead6949112653d6588b01a5cdcd9f90fdd" - integrity sha1-xNWY6taUkRJlPWWIsBpc3Nn5D90= + version "0.8.18" + resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-0.8.18.tgz#9835e0addb9aca2eff53295cd79ca1cfc7c9662a" + integrity sha512-EQaWFK+fEPSoibjNy8IxUtaFOMXcWsY0JaVrQoZR9zC8N2Ygf9iDITPWjUTVIax95b6I742JFLqASHfsag/vKA== dependencies: core-js "^1.0.0" isomorphic-fetch "^2.1.1" @@ -3971,7 +3971,7 @@ fbjs@^0.8.4: object-assign "^4.1.0" promise "^7.1.1" setimmediate "^1.0.5" - ua-parser-js "^0.7.18" + ua-parser-js "^0.7.30" fflate@^0.4.1: version "0.4.8" @@ -5940,8 +5940,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "14.0.1" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/94ff0ea4cd9dfd37400b079c8da08b8ecd0f7c6f" + version "15.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f0091fa81149eed4fd2d0d82be7b820238cd5a1a" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -8313,10 +8313,10 @@ typescript@4.3.5: resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.3.5.tgz#4d1c37cc16e893973c45a06886b7113234f119f4" integrity sha512-DqQgihaQ9cUrskJo9kIyW/+g0Vxsk8cDtZ52a3NGh0YNTfpUSArXSohyUGnvbPazEPLu398C0UxmKSOrPumUzA== -ua-parser-js@^0.7.18: - version "0.7.28" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31" - integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== +ua-parser-js@^0.7.30: + version "0.7.30" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.30.tgz#4cf5170e8b55ac553fe8b38df3a82f0669671f0b" + integrity sha512-uXEtSresNUlXQ1QL4/3dQORcGv7+J2ookOG2ybA/ga9+HYEXueT2o+8dUJQkpedsyTyCJ6jCCirRcKtdtx1kbg== unbox-primitive@^1.0.1: version "1.0.1"