Merge branch 'develop' into sort-imports

This commit is contained in:
Aaron Raimist 2021-10-27 21:50:56 -05:00
commit f3867ad0a9
107 changed files with 1722 additions and 1208 deletions

View file

@ -11,7 +11,8 @@ module.exports = {
"length-zero-no-unit": null, "length-zero-no-unit": null,
"rule-empty-line-before": null, "rule-empty-line-before": null,
"color-hex-length": null, "color-hex-length": null,
"max-empty-lines": null, "max-empty-lines": 1,
"no-eol-whitespace": true,
"number-no-trailing-zeros": null, "number-no-trailing-zeros": null,
"number-leading-zero": null, "number-leading-zero": null,
"selector-list-comma-newline-after": null, "selector-list-comma-newline-after": null,

View file

@ -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) Changes in [3.32.1](https://github.com/vector-im/element-desktop/releases/tag/v3.32.1) (2021-10-12)
=================================================================================================== ===================================================================================================

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.32.1", "version": "3.33.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {

View file

@ -141,12 +141,6 @@ input[type=search]::-webkit-search-results-decoration {
input::placeholder, input::placeholder,
textarea::placeholder { textarea::placeholder {
opacity: initial; opacity: initial;
font-weight: 400;
}
input::-moz-placeholder,
textarea::-moz-placeholder {
opacity: .6;
font-weight: 400;
} }
input[type=text], input[type=password], textarea { input[type=text], input[type=password], textarea {

View file

@ -200,10 +200,10 @@
@import "./views/right_panel/_EncryptionInfo.scss"; @import "./views/right_panel/_EncryptionInfo.scss";
@import "./views/right_panel/_PinnedMessagesCard.scss"; @import "./views/right_panel/_PinnedMessagesCard.scss";
@import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss";
@import "./views/right_panel/_ThreadPanel.scss";
@import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_UserInfo.scss";
@import "./views/right_panel/_VerificationPanel.scss"; @import "./views/right_panel/_VerificationPanel.scss";
@import "./views/right_panel/_WidgetCard.scss"; @import "./views/right_panel/_WidgetCard.scss";
@import "./views/right_panel/_ThreadPanel.scss";
@import "./views/room_settings/_AliasSettings.scss"; @import "./views/room_settings/_AliasSettings.scss";
@import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_AppsDrawer.scss";
@import "./views/rooms/_Autocomplete.scss"; @import "./views/rooms/_Autocomplete.scss";

View file

@ -34,4 +34,3 @@ limitations under the License.
.mx_CreateRoom_description { .mx_CreateRoom_description {
width: 330px; width: 330px;
} }

View file

@ -43,8 +43,6 @@ $roomListCollapsedWidth: 68px;
} }
} }
.mx_LeftPanel { .mx_LeftPanel {
background-color: $roomlist-bg-color; background-color: $roomlist-bg-color;
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel // TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel

View file

@ -32,7 +32,6 @@ limitations under the License.
position: relative; position: relative;
} }
@keyframes mx_RoomView_fileDropTarget_animation { @keyframes mx_RoomView_fileDropTarget_animation {
from { from {
opacity: 0; opacity: 0;
@ -112,7 +111,6 @@ limitations under the License.
max-width: 1920px !important; max-width: 1920px !important;
} }
.mx_RoomView .mx_MainSplit { .mx_RoomView .mx_MainSplit {
flex: 1 1 0; flex: 1 1 0;
} }

View file

@ -203,7 +203,8 @@ limitations under the License.
grid-row: 1; grid-row: 1;
grid-column: 2; grid-column: 2;
.mx_InfoTooltip { .mx_InfoTooltip,
.mx_SpaceHierarchy_roomTile_joined {
display: inline; display: inline;
margin-left: 12px; margin-left: 12px;
color: $tertiary-content; 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 { .mx_SpaceHierarchy_roomTile_info {
@ -268,6 +288,11 @@ limitations under the License.
visibility: visible; visibility: visible;
} }
} }
&.mx_SpaceHierarchy_joining .mx_AccessibleButton {
visibility: visible;
padding: 4px 18px;
}
} }
li.mx_SpaceHierarchy_roomTileWrapper { li.mx_SpaceHierarchy_roomTileWrapper {

View file

@ -348,7 +348,6 @@ $activeBorderColor: $secondary-content;
} }
} }
.mx_SpacePanel_sharePublicSpace { .mx_SpacePanel_sharePublicSpace {
margin: 0; margin: 0;
} }

View file

@ -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 { .mx_SpaceRoomView_inviteTeammates {
// XXX remove this when spaces leaves Beta // XXX remove this when spaces leaves Beta
.mx_SpaceRoomView_inviteTeammates_betaDisclaimer { .mx_SpaceRoomView_inviteTeammates_betaDisclaimer {

View file

@ -64,4 +64,3 @@ limitations under the License.
mask-size: contain; mask-size: contain;
} }
} }

View file

@ -64,4 +64,3 @@ limitations under the License.
padding: 0 8px; padding: 0 8px;
} }
} }

View file

@ -58,4 +58,3 @@ limitations under the License.
mask-size: 36px; mask-size: 36px;
mask-position: center; mask-position: center;
} }

View file

@ -50,4 +50,3 @@ limitations under the License.
vertical-align: middle; vertical-align: middle;
} }
} }

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_WidgetCapabilitiesPromptDialog { .mx_WidgetCapabilitiesPromptDialog {
.text-muted { .text-muted {
font-size: $font-12px; font-size: $font-12px;
@ -55,7 +54,6 @@ limitations under the License.
width: $font-32px; width: $font-32px;
height: $font-15px; height: $font-15px;
&.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball { &.mx_ToggleSwitch_on > .mx_ToggleSwitch_ball {
left: calc(100% - $font-15px); left: calc(100% - $font-15px);
} }

View file

@ -130,4 +130,3 @@ input.mx_Dropdown_option:focus {
margin-left: 5px; margin-left: 5px;
margin-bottom: 5px; margin-bottom: 5px;
} }

View file

@ -61,4 +61,3 @@ limitations under the License.
.mx_EditableItemList_label { .mx_EditableItemList_label {
margin-bottom: 5px; margin-bottom: 5px;
} }

View file

@ -58,7 +58,6 @@ limitations under the License.
height: $slider-selection-dot-size; height: $slider-selection-dot-size;
background-color: $slider-selection-color; background-color: $slider-selection-color;
border-radius: 50%; border-radius: 50%;
box-shadow: 0 0 6px lightgrey;
z-index: 10; z-index: 10;
} }

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_Checkbox { .mx_Checkbox {
$size: $font-16px; $size: $font-16px;
$border-size: $font-1-5px; $border-size: $font-1-5px;

View file

@ -49,4 +49,3 @@ limitations under the License.
text-align: start; text-align: start;
line-height: 17px !important; line-height: 17px !important;
} }

View file

@ -34,4 +34,3 @@ limitations under the License.
} }
} }
} }

View file

@ -39,7 +39,6 @@ limitations under the License.
background-color: $notice-primary-color; background-color: $notice-primary-color;
} }
.mx_cryptoEvent_state, .mx_cryptoEvent_buttons { .mx_cryptoEvent_state, .mx_cryptoEvent_buttons {
grid-column: 3; grid-column: 3;
grid-row: 1 / 3; grid-row: 1 / 3;

View file

@ -83,7 +83,7 @@ limitations under the License.
} }
.mx_RoomSummaryCard_e2ee_warning { .mx_RoomSummaryCard_e2ee_warning {
background-color: #ff4b55; background-color: #ff5b55;
&::before { &::before {
mask-image: url('$(res)/img/e2e/warning.svg'); mask-image: url('$(res)/img/e2e/warning.svg');
} }

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_ThreadPanel { .mx_ThreadPanel {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -223,7 +223,6 @@ limitations under the License.
display: flex; display: flex;
margin: 8px 0; margin: 8px 0;
&.mx_UserInfo_device_verified { &.mx_UserInfo_device_verified {
.mx_UserInfo_device_trusted { .mx_UserInfo_device_trusted {
color: $accent-color; color: $accent-color;
@ -267,7 +266,6 @@ limitations under the License.
margin: 16px 0 8px; margin: 16px 0 8px;
} }
.mx_VerificationShowSas { .mx_VerificationShowSas {
.mx_AccessibleButton + .mx_AccessibleButton { .mx_AccessibleButton + .mx_AccessibleButton {
margin: 8px 0; // space between buttons margin: 8px 0; // space between buttons

View file

@ -23,7 +23,6 @@ limitations under the License.
} }
} }
.mx_UserInfo { .mx_UserInfo {
.mx_EncryptionPanel_cancel { .mx_EncryptionPanel_cancel {
mask: url('$(res)/img/feather-customised/cancel.svg'); mask: url('$(res)/img/feather-customised/cancel.svg');

View file

@ -365,7 +365,6 @@ $MinWidth: 240px;
to { opacity: 1; } to { opacity: 1; }
} }
.mx_AppLoading iframe { .mx_AppLoading iframe {
display: none; display: none;
} }

View file

@ -24,7 +24,6 @@ limitations under the License.
margin: -7px -10px -5px -10px; margin: -7px -10px -5px -10px;
overflow: visible !important; // override mx_EventTile_content overflow: visible !important; // override mx_EventTile_content
.mx_BasicMessageComposer_input { .mx_BasicMessageComposer_input {
border-radius: 4px; border-radius: 4px;
border: solid 1px $primary-hairline-color; border: solid 1px $primary-hairline-color;

View file

@ -232,7 +232,7 @@ limitations under the License.
.mx_EditMessageComposer_buttons { .mx_EditMessageComposer_buttons {
position: static; position: static;
padding: 0; padding: 0;
margin: 0; margin: 8px 0 0;
background: transparent; background: transparent;
} }
@ -263,7 +263,6 @@ limitations under the License.
} }
} }
.mx_EventTile_readAvatars { .mx_EventTile_readAvatars {
position: absolute; position: absolute;
right: -110px; right: -110px;

View file

@ -401,7 +401,6 @@ $left-gutter: 64px;
cursor: pointer; cursor: pointer;
} }
.mx_EventTile_e2eIcon { .mx_EventTile_e2eIcon {
position: relative; position: relative;
width: 14px; width: 14px;
@ -486,7 +485,7 @@ $left-gutter: 64px;
pre, code { pre, code {
font-family: $monospace-font-family !important; font-family: $monospace-font-family !important;
background-color: $header-panel-bg-color; background-color: $codeblock-background-color;
} }
pre code > * { pre code > * {
@ -581,7 +580,6 @@ $left-gutter: 64px;
color: inherit; color: inherit;
} }
/* Make h1 and h2 the same size as h3. */ /* Make h1 and h2 the same size as h3. */
.mx_EventTile_content .markdown-body h1, .mx_EventTile_content .markdown-body h1,
.mx_EventTile_content .markdown-body h2 { .mx_EventTile_content .markdown-body h2 {
@ -613,7 +611,6 @@ $left-gutter: 64px;
/* end of overrides */ /* end of overrides */
.mx_EventTile_keyRequestInfo { .mx_EventTile_keyRequestInfo {
font-size: $font-12px; font-size: $font-12px;
} }
@ -731,8 +728,6 @@ $left-gutter: 64px;
} }
} }
.mx_ThreadView { .mx_ThreadView {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -247,7 +247,6 @@ limitations under the License.
} }
} }
.mx_MessageComposer_upload::before { .mx_MessageComposer_upload::before {
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
} }

View file

@ -16,7 +16,7 @@ limitations under the License.
.mx_MessageComposerFormatBar { .mx_MessageComposerFormatBar {
display: none; display: none;
width: calc(32px * 5); width: calc(32px * 6);
height: 32px; height: 32px;
position: absolute; position: absolute;
cursor: pointer; cursor: pointer;
@ -87,6 +87,11 @@ limitations under the License.
.mx_MessageComposerFormatBar_buttonIconCode::after { .mx_MessageComposerFormatBar_buttonIconCode::after {
mask-image: url('$(res)/img/element-icons/room/format-bar/code.svg'); 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 { .mx_MessageComposerFormatBar_buttonTooltip {

View file

@ -52,4 +52,3 @@ limitations under the License.
} }
} }
} }

View file

@ -78,7 +78,8 @@ limitations under the License.
// Hack to cut content in <pre> tags too // Hack to cut content in <pre> tags too
.mx_EventTile_pre_container > pre { .mx_EventTile_pre_container > pre {
overflow: hidden; overflow-x: scroll;
overflow-y: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
display: -webkit-box; display: -webkit-box;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;

View file

@ -68,4 +68,3 @@ limitations under the License.
cursor: pointer; cursor: pointer;
} }
} }

View file

@ -47,4 +47,3 @@ limitations under the License.
} }
} }
} }

View file

@ -17,4 +17,3 @@ limitations under the License.
.mx_E2eAdvancedPanel_settingLongDescription { .mx_E2eAdvancedPanel_settingLongDescription {
margin-right: 150px; margin-right: 150px;
} }

View file

@ -85,4 +85,3 @@ limitations under the License.
} }
} }
} }

View file

@ -35,7 +35,6 @@ limitations under the License.
margin-left: 2px; margin-left: 2px;
margin-right: 2px; margin-right: 2px;
&::before { &::before {
content: ''; content: '';
display: inline-block; display: inline-block;
@ -48,7 +47,6 @@ limitations under the License.
background-position: center; background-position: center;
} }
&.mx_CallViewButtons_dialpad::before { &.mx_CallViewButtons_dialpad::before {
background-image: url('$(res)/img/voip/dialpad.svg'); background-image: url('$(res)/img/voip/dialpad.svg');
} }

View file

@ -200,7 +200,6 @@ limitations under the License.
} }
} }
.mx_CallView_presenting { .mx_CallView_presenting {
opacity: 1; opacity: 1;
transition: opacity 0.5s; transition: opacity 0.5s;

View file

@ -1,4 +1 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="none" viewBox="0 0 18 18"><path fill="#17191C" d="M5 5.25a.75.75 0 0 0 0 1.5h8a.75.75 0 0 0 0-1.5H5ZM5 8.25a.75.75 0 0 0 0 1.5h4a.75.75 0 1 0 0-1.5H5Z"/><path fill="#17191C" fill-rule="evenodd" d="M3 .25A2.75 2.75 0 0 0 .25 3v14a.75.75 0 0 0 1.2.6L4.916 15c.217-.162.48-.25.75-.25H15A2.75 2.75 0 0 0 17.75 12V3A2.75 2.75 0 0 0 15 .25H3ZM1.75 3c0-.69.56-1.25 1.25-1.25h12c.69 0 1.25.56 1.25 1.25v9c0 .69-.56 1.25-1.25 1.25H5.666a2.75 2.75 0 0 0-1.65.55L1.75 15.5V3Z" clip-rule="evenodd"/></svg>
<path d="M3 8V8C1.89543 8 1 7.10457 1 6V3C1 1.89543 1.89543 1 3 1H15C16.1046 1 17 1.89484 17 2.9994C17 3.88147 17 4.95392 17 6.00008C17 7.10465 16.1046 8 15 8H10.5" stroke="#737D8C" stroke-width="1.5" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2011 16.7176C12.9087 17.011 12.9088 17.4866 13.2012 17.78C13.4936 18.0734 13.9677 18.0733 14.2601 17.78C14.9484 17.0894 15.6519 16.3829 16.1834 15.8491L16.8282 15.2014L17.0099 15.0188L17.0579 14.9706L17.0702 14.9582L17.0733 14.955L17.0741 14.9542L17.0743 14.954L17.0743 14.954L16.5444 14.4233L17.0744 14.954C17.3663 14.6606 17.3661 14.1855 17.0741 13.8922L14.2539 11.061C13.9616 10.7675 13.4875 10.7674 13.195 11.0606C12.9024 11.3539 12.9023 11.8295 13.1946 12.123L14.7442 13.6787L10.1137 13.6787C8.69795 13.6787 7.49996 12.4759 7.49996 10.9288L7.49996 7.00002C7.49996 6.58581 7.16417 6.25002 6.74996 6.25002C6.33574 6.25002 5.99996 6.58581 5.99996 7.00002L5.99996 10.9288C5.99996 13.2476 7.81395 15.1787 10.1137 15.1787H14.7341C14.2713 15.6436 13.7316 16.1854 13.2011 16.7176Z" fill="#737D8C"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 562 B

Before After
Before After

View file

@ -1,6 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="20" height="20" rx="4" fill="#FF4B55"/> <rect width="20" height="20" rx="4" fill="#FF5B55"/>
<path d="M2 7H18V16C18 17.1046 17.1046 18 16 18H4C2.89543 18 2 17.1046 2 16V7Z" fill="white"/> <path d="M2 7H18V16C18 17.1046 17.1046 18 16 18H4C2.89543 18 2 17.1046 2 16V7Z" fill="white"/>
<rect x="3.96826" y="8.99951" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/> <rect x="3.96826" y="8.99951" width="2.99723" height="3" rx="0.25" fill="#FF5B55"/>
<rect x="10.9614" y="13" width="2.99723" height="3" rx="0.25" fill="#FF4B55"/> <rect x="10.9614" y="13" width="2.99723" height="3" rx="0.25" fill="#FF5B55"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 430 B

After

Width:  |  Height:  |  Size: 430 B

Before After
Before After

View file

@ -28,5 +28,5 @@
<path <path
id="path2" id="path2"
d="M 12 2 C 6.47715 2 2 6.47715 2 12 C 2 17.5228 6.47715 22 12 22 C 17.5228 22 22 17.5228 22 12 C 22 6.47715 17.5228 2 12 2 z M 11.880859 5.5039062 C 12.720859 5.4439063 13.470547 6.0746875 13.560547 6.9296875 L 13.560547 7.1699219 L 13.080078 13.169922 C 13.035078 13.724922 12.570625 14.144531 12.015625 14.144531 L 11.925781 14.144531 C 11.400781 14.099531 10.996172 13.694922 10.951172 13.169922 L 10.470703 7.1699219 C 10.395703 6.3149219 11.025859 5.5639064 11.880859 5.5039062 z M 12 15.763672 C 12.729 15.763672 13.320312 16.354884 13.320312 17.083984 C 13.320313 17.812984 12.729 18.404297 12 18.404297 C 11.271 18.404297 10.679688 17.812984 10.679688 17.083984 C 10.679688 16.354884 11.271 15.763672 12 15.763672 z " d="M 12 2 C 6.47715 2 2 6.47715 2 12 C 2 17.5228 6.47715 22 12 22 C 17.5228 22 22 17.5228 22 12 C 22 6.47715 17.5228 2 12 2 z M 11.880859 5.5039062 C 12.720859 5.4439063 13.470547 6.0746875 13.560547 6.9296875 L 13.560547 7.1699219 L 13.080078 13.169922 C 13.035078 13.724922 12.570625 14.144531 12.015625 14.144531 L 11.925781 14.144531 C 11.400781 14.099531 10.996172 13.694922 10.951172 13.169922 L 10.470703 7.1699219 C 10.395703 6.3149219 11.025859 5.5639064 11.880859 5.5039062 z M 12 15.763672 C 12.729 15.763672 13.320312 16.354884 13.320312 17.083984 C 13.320313 17.812984 12.729 18.404297 12 18.404297 C 11.271 18.404297 10.679688 17.812984 10.679688 17.083984 C 10.679688 16.354884 11.271 15.763672 12 15.763672 z "
style="fill:#ff4b55;fill-opacity:1" /> style="fill:#ff5b55;fill-opacity:1" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Before After
Before After

View file

@ -1,3 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C9.23858 2 7 4.23858 7 7L17 7C17 4.23858 14.7614 2 12 2ZM2.29289 7.70711C1.90237 7.31658 1.90237 6.68342 2.29289 6.29289C2.68342 5.90237 3.31658 5.90237 3.70711 6.29289L6.41421 9H17.5858L20.2929 6.29289C20.6834 5.90237 21.3166 5.90237 21.7071 6.29289C22.0976 6.68342 22.0976 7.31658 21.7071 7.70711L19 10.4142V13H22C22.5523 13 23 13.4477 23 14C23 14.5523 22.5523 15 22 15H19C19 15.7795 18.8726 16.5292 18.6375 17.2295C18.6614 17.2493 18.6847 17.2705 18.7071 17.2929L21.7071 20.2929C22.0976 20.6834 22.0976 21.3166 21.7071 21.7071C21.3166 22.0976 20.6834 22.0976 20.2929 21.7071L17.6791 19.0933C16.5924 20.5983 14.9222 21.6542 13 21.9291L13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12V21.9291C9.07785 21.6542 7.40759 20.5983 6.32091 19.0933L3.70711 21.7071C3.31658 22.0976 2.68342 22.0976 2.29289 21.7071C1.90237 21.3166 1.90237 20.6834 2.29289 20.2929L5.29289 17.2929C5.31533 17.2705 5.33857 17.2493 5.36252 17.2295C5.1274 16.5292 5 15.7795 5 15H2C1.44772 15 1 14.5523 1 14C1 13.4477 1.44772 13 2 13H5V10.4142L2.29289 7.70711Z" fill="#FF4B55"/> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 2C9.23858 2 7 4.23858 7 7L17 7C17 4.23858 14.7614 2 12 2ZM2.29289 7.70711C1.90237 7.31658 1.90237 6.68342 2.29289 6.29289C2.68342 5.90237 3.31658 5.90237 3.70711 6.29289L6.41421 9H17.5858L20.2929 6.29289C20.6834 5.90237 21.3166 5.90237 21.7071 6.29289C22.0976 6.68342 22.0976 7.31658 21.7071 7.70711L19 10.4142V13H22C22.5523 13 23 13.4477 23 14C23 14.5523 22.5523 15 22 15H19C19 15.7795 18.8726 16.5292 18.6375 17.2295C18.6614 17.2493 18.6847 17.2705 18.7071 17.2929L21.7071 20.2929C22.0976 20.6834 22.0976 21.3166 21.7071 21.7071C21.3166 22.0976 20.6834 22.0976 20.2929 21.7071L17.6791 19.0933C16.5924 20.5983 14.9222 21.6542 13 21.9291L13 12C13 11.4477 12.5523 11 12 11C11.4477 11 11 11.4477 11 12V21.9291C9.07785 21.6542 7.40759 20.5983 6.32091 19.0933L3.70711 21.7071C3.31658 22.0976 2.68342 22.0976 2.29289 21.7071C1.90237 21.3166 1.90237 20.6834 2.29289 20.2929L5.29289 17.2929C5.31533 17.2705 5.33857 17.2493 5.36252 17.2295C5.1274 16.5292 5 15.7795 5 15H2C1.44772 15 1 14.5523 1 14C1 13.4477 1.44772 13 2 13H5V10.4142L2.29289 7.70711Z" fill="#FF5B55"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

@ -165,6 +165,9 @@ $button-link-bg-color: transparent;
// Toggle switch // Toggle switch
$togglesw-off-color: $room-highlight-color; $togglesw-off-color: $room-highlight-color;
// Slider
$slider-background-color: $quinary-content;
$progressbar-fg-color: $accent-color; $progressbar-fg-color: $accent-color;
$progressbar-bg-color: $system; $progressbar-bg-color: $system;
@ -209,6 +212,8 @@ $appearance-tab-border-color: $room-highlight-color;
$composer-shadow-color: rgba(0, 0, 0, 0.28); $composer-shadow-color: rgba(0, 0, 0, 0.28);
$codeblock-background-color: #2a3039;
// Bubble tiles // Bubble tiles
$eventbubble-self-bg: #14322E; $eventbubble-self-bg: #14322E;
$eventbubble-others-bg: $event-selected-color; $eventbubble-others-bg: $event-selected-color;

View file

@ -221,6 +221,8 @@ $appearance-tab-border-color: $room-highlight-color;
$composer-shadow-color: tranparent; $composer-shadow-color: tranparent;
$codeblock-background-color: #2a3039;
// Bubble tiles // Bubble tiles
$eventbubble-self-bg: #14322E; $eventbubble-self-bg: #14322E;
$eventbubble-others-bg: $event-selected-color; $eventbubble-others-bg: $event-selected-color;

View file

@ -334,6 +334,8 @@ $appearance-tab-border-color: $input-darker-bg-color;
$composer-shadow-color: tranparent; $composer-shadow-color: tranparent;
$codeblock-background-color: $header-panel-bg-color;
// Bubble tiles // Bubble tiles
$eventbubble-self-bg: #F0FBF8; $eventbubble-self-bg: #F0FBF8;
$eventbubble-others-bg: $system; $eventbubble-others-bg: $system;

View file

@ -1,11 +1,12 @@
//// Reference: https://www.figma.com/file/RnLKnv09glhxGIZtn8zfmh/UI-Themes-%26-Accessibility?node-id=321%3A65847 //// Reference: https://www.figma.com/file/RnLKnv09glhxGIZtn8zfmh/UI-Themes-%26-Accessibility?node-id=321%3A65847
$accent: #268075; $accent: #268075;
$alert: #D62C25; $alert: #D62C25;
$notice-primary-color: #D61C25;
$links: #0A6ECA; $links: #0A6ECA;
$secondary-content: #5E6266; $secondary-content: #5E6266;
$tertiary-content: #5E6266; // Same as secondary $tertiary-content: $secondary-content;
$quaternary-content: #5E6266; // Same as secondary $quaternary-content: $secondary-content;
$quinary-content: $secondary-content;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2);
$username-variant1-color: #0A6ECA; $username-variant1-color: #0A6ECA;
$username-variant2-color: #AC3BA8; $username-variant2-color: #AC3BA8;
@ -18,9 +19,13 @@ $username-variant8-color: #3E810A;
$accent-color: $accent; $accent-color: $accent;
$accent-color-50pct: rgba($accent-color, 0.5); $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-bg-color: $quinary-content;
$input-darker-fg-color: $secondary-content;
$input-lighter-fg-color: $input-darker-fg-color; $input-lighter-fg-color: $input-darker-fg-color;
$input-valid-border-color: $accent-color; $input-valid-border-color: $accent-color;
$input-focused-border-color: $accent-color;
$button-bg-color: $accent-color; $button-bg-color: $accent-color;
$resend-button-divider-color: $input-darker-bg-color; $resend-button-divider-color: $input-darker-bg-color;
$icon-button-color: $quaternary-content; $icon-button-color: $quaternary-content;
@ -41,12 +46,14 @@ $voice-record-stop-border-color: $quinary-content;
$voice-record-icon-color: $tertiary-content; $voice-record-icon-color: $tertiary-content;
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;
$eventbubble-reply-color: $quaternary-content; $eventbubble-reply-color: $quaternary-content;
$notice-primary-color: $alert;
$warning-color: $notice-primary-color; // red $warning-color: $notice-primary-color; // red
$pinned-unread-color: $notice-primary-color; $pinned-unread-color: $notice-primary-color;
$button-danger-bg-color: $notice-primary-color; $button-danger-bg-color: $notice-primary-color;
$mention-user-pill-bg-color: $warning-color; $mention-user-pill-bg-color: $warning-color;
$input-invalid-border-color: $warning-color; $input-invalid-border-color: $warning-color;
$event-highlight-fg-color: $warning-color; $event-highlight-fg-color: $warning-color;
$roomtopic-color: $secondary-content;
@define-mixin mx_DialogButton_danger { @define-mixin mx_DialogButton_danger {
background-color: $accent-color; background-color: $accent-color;
@ -64,3 +71,38 @@ $event-highlight-fg-color: $warning-color;
color: $accent-color; color: $accent-color;
text-decoration: none; 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;
}

View file

@ -35,7 +35,7 @@ $space-nav: rgba($tertiary-content, 0.15);
// try to use these colors when possible // try to use these colors when possible
$accent-color: $accent; $accent-color: $accent;
$accent-bg-color: rgba(3, 179, 129, 0.16); $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); $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
$header-panel-bg-color: #f3f8fd; $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 // These two don't change between themes. They are the $warning-color, but we don't
// want custom themes to affect them by accident. // want custom themes to affect them by accident.
$voice-record-stop-symbol-color: #ff4b55; $voice-record-stop-symbol-color: #ff5b55;
$voice-record-live-circle-color: #ff4b55; $voice-record-live-circle-color: #ff5b55;
$voice-record-stop-border-color: $quinary-content; $voice-record-stop-border-color: $quinary-content;
$voice-record-icon-color: $tertiary-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); $composer-shadow-color: rgba(0, 0, 0, 0.04);
$codeblock-background-color: $header-panel-bg-color;
// Bubble tiles // Bubble tiles
$eventbubble-self-bg: #F0FBF8; $eventbubble-self-bg: #F0FBF8;
$eventbubble-others-bg: $system; $eventbubble-others-bg: $system;

View file

@ -99,6 +99,7 @@ declare global {
mxSkinner?: Skinner; mxSkinner?: Skinner;
mxOnRecaptchaLoaded?: () => void; mxOnRecaptchaLoaded?: () => void;
electron?: Electron; electron?: Electron;
mxSendSentryReport: (userText: string, issueUrl: string, error: Error) => Promise<void>;
} }
interface DesktopCapturerSource { interface DesktopCapturerSource {

View file

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; 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 Analytics from './Analytics';
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
@ -332,7 +332,10 @@ export class ModalManager {
return this.priorityModal ? this.priorityModal : (this.modals[0] || this.staticModal); 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 (this.modals.length === 0 && !this.priorityModal && !this.staticModal) {
// If there is no modal to render, make all of Element available // If there is no modal to render, make all of Element available
// to screen reader users again // to screen reader users again

View file

@ -32,6 +32,10 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security"; 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 // 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 // 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 // This dialog calls bootstrap itself after guiding the user through
// passphrase creation. // passphrase creation.
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', 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<ComponentType<{}>>,
{ {
forceReset, forceReset,
}, },

View file

@ -1013,14 +1013,14 @@ export const Commands = [
new Command({ new Command({
command: "msg", command: "msg",
description: _td("Sends a message to the given user"), description: _td("Sends a message to the given user"),
args: "<user-id> <message>", args: "<user-id> [<message>]",
runFn: function(roomId, args) { runFn: function(roomId, args) {
if (args) { if (args) {
// matches the first whitespace delimited group and then the rest of the string // matches the first whitespace delimited group and then the rest of the string
const matches = args.match(/^(\S+?)(?: +(.*))?$/s); const matches = args.match(/^(\S+?)(?: +(.*))?$/s);
if (matches) { if (matches) {
const [userId, msg] = matches.slice(1); const [userId, msg] = matches.slice(1);
if (msg && userId && userId.startsWith("@") && userId.includes(":")) { if (userId && userId.startsWith("@") && userId.includes(":")) {
return success((async () => { return success((async () => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const roomId = await ensureDMExists(cli, userId); const roomId = await ensureDMExists(cli, userId);
@ -1028,7 +1028,9 @@ export const Commands = [
action: 'view_room', action: 'view_room',
room_id: roomId, room_id: roomId,
}); });
if (msg) {
cli.sendTextMessage(roomId, msg); cli.sendTextMessage(roomId, msg);
}
})()); })());
} }
} }

View file

@ -180,7 +180,7 @@ export async function startTermsFlow(
return Promise.all(agreePromises); return Promise.all(agreePromises);
} }
export function dialogTermsInteractionCallback( export async function dialogTermsInteractionCallback(
policiesAndServicePairs: { policiesAndServicePairs: {
service: Service; service: Service;
policies: { [policy: string]: Policy }; policies: { [policy: string]: Policy };
@ -188,21 +188,18 @@ export function dialogTermsInteractionCallback(
agreedUrls: string[], agreedUrls: string[],
extraClassNames?: string, extraClassNames?: string,
): Promise<string[]> { ): Promise<string[]> {
return new Promise((resolve, reject) => {
logger.log("Terms that need agreement", policiesAndServicePairs); logger.log("Terms that need agreement", policiesAndServicePairs);
// FIXME: Using an import will result in test failures // FIXME: Using an import will result in test failures
const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog");
Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { const { finished } = Modal.createTrackedDialog<[boolean, string[]]>('Terms of Service', '', TermsDialog, {
policiesAndServicePairs, policiesAndServicePairs,
agreedUrls, agreedUrls,
onFinished: (done, agreedUrls) => {
if (!done) {
reject(new TermsNotSignedError());
return;
}
resolve(agreedUrls);
},
}, classNames("mx_TermsDialog", extraClassNames)); }, classNames("mx_TermsDialog", extraClassNames));
});
const [done, _agreedUrls] = await finished;
if (!done) {
throw new TermsNotSignedError();
}
return _agreedUrls;
} }

View file

@ -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 See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@ -28,7 +29,11 @@ import { RightPanelPhases } from './stores/RightPanelStorePhases';
import { Action } from './dispatcher/actions'; import { Action } from './dispatcher/actions';
import defaultDispatcher from './dispatcher/dispatcher'; import defaultDispatcher from './dispatcher/dispatcher';
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload'; 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 { MatrixClientPeg } from "./MatrixClientPeg";
import { ROOM_SECURITY_TAB } from "./components/views/dialogs/RoomSettingsDialog";
// These functions are frequently used just to check whether an event has // 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 // 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 }); 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(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) { switch (ev.getContent().join_rule) {
case "public": case JoinRule.Public:
return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', { return () => _t('%(senderDisplayName)s made the room public to whoever knows the link.', {
senderDisplayName, senderDisplayName,
}); });
case "invite": case JoinRule.Invite:
return () => _t('%(senderDisplayName)s made the room invite only.', { return () => _t('%(senderDisplayName)s made the room invite only.', {
senderDisplayName, senderDisplayName,
}); });
case JoinRule.Restricted:
if (allowJSX) {
return () => <span>
{ _t('%(senderDisplayName)s changed who can join this room. <a>View settings</a>.', {
senderDisplayName,
}, {
"a": (sub) => <a onClick={onViewJoinRuleSettingsClick}>
{ sub }
</a>,
}) }
</span>;
}
return () => _t('%(senderDisplayName)s changed who can join this room.', { senderDisplayName });
default: default:
// The spec supports "knock" and "private", however nothing implements these. // The spec supports "knock" and "private", however nothing implements these.
return () => _t('%(senderDisplayName)s changed the join rule to %(rule)s', { 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 { function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) { switch (ev.getContent().guest_access) {
case "can_join": case GuestAccess.CanJoin:
return () => _t('%(senderDisplayName)s has allowed guests to join the room.', { senderDisplayName }); 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 }); return () => _t('%(senderDisplayName)s has prevented guests from joining the room.', { senderDisplayName });
default: default:
// There's no other options we can expect, however just for safety's sake we'll do this. // 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 }); || redactedBecauseUserId });
} }
} }
if (ev.getContent().msgtype === "m.emote") { if (ev.getContent().msgtype === MsgType.Emote) {
message = "* " + senderDisplayName + " " + message; 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 }); 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 }); message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName });
} else { } else {
// in this case, parse it as a plain text message // 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 { function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) { switch (event.getContent().history_visibility) {
case 'invited': case HistoryVisibility.Invited:
return () => _t('%(senderName)s made future room history visible to all room members, ' return () => _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they are invited.', { senderName }); + '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, ' return () => _t('%(senderName)s made future room history visible to all room members, '
+ 'from the point they joined.', { senderName }); + '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 }); 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 }); return () => _t('%(senderName)s made future room history visible to anyone.', { senderName });
default: default:
return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', { return () => _t('%(senderName)s made future room history visible to unknown (%(visibility)s).', {
@ -695,25 +721,25 @@ interface IHandlers {
} }
const handlers: IHandlers = { const handlers: IHandlers = {
'm.room.message': textForMessageEvent, [EventType.RoomMessage]: textForMessageEvent,
'm.sticker': textForMessageEvent, [EventType.Sticker]: textForMessageEvent,
'm.call.invite': textForCallInviteEvent, [EventType.CallInvite]: textForCallInviteEvent,
}; };
const stateHandlers: IHandlers = { const stateHandlers: IHandlers = {
'm.room.canonical_alias': textForCanonicalAliasEvent, [EventType.RoomCanonicalAlias]: textForCanonicalAliasEvent,
'm.room.name': textForRoomNameEvent, [EventType.RoomName]: textForRoomNameEvent,
'm.room.topic': textForTopicEvent, [EventType.RoomTopic]: textForTopicEvent,
'm.room.member': textForMemberEvent, [EventType.RoomMember]: textForMemberEvent,
"m.room.avatar": textForRoomAvatarEvent, [EventType.RoomAvatar]: textForRoomAvatarEvent,
'm.room.third_party_invite': textForThreePidInviteEvent, [EventType.RoomThirdPartyInvite]: textForThreePidInviteEvent,
'm.room.history_visibility': textForHistoryVisibilityEvent, [EventType.RoomHistoryVisibility]: textForHistoryVisibilityEvent,
'm.room.power_levels': textForPowerEvent, [EventType.RoomPowerLevels]: textForPowerEvent,
'm.room.pinned_events': textForPinnedEvent, [EventType.RoomPinnedEvents]: textForPinnedEvent,
'm.room.server_acl': textForServerACLEvent, [EventType.RoomServerAcl]: textForServerACLEvent,
'm.room.tombstone': textForTombstoneEvent, [EventType.RoomTombstone]: textForTombstoneEvent,
'm.room.join_rules': textForJoinRulesEvent, [EventType.RoomJoinRules]: textForJoinRulesEvent,
'm.room.guest_access': textForGuestAccessEvent, [EventType.RoomGuestAccess]: textForGuestAccessEvent,
'm.room.related_groups': textForRelatedGroupsEvent, 'm.room.related_groups': textForRelatedGroupsEvent,
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)

View file

@ -24,6 +24,7 @@ import React, {
useReducer, useReducer,
Reducer, Reducer,
Dispatch, Dispatch,
RefObject,
} from "react"; } from "react";
import { Key } from "../Keyboard"; import { Key } from "../Keyboard";
@ -63,7 +64,7 @@ const RovingTabIndexContext = createContext<IContext>({
}); });
RovingTabIndexContext.displayName = "RovingTabIndexContext"; RovingTabIndexContext.displayName = "RovingTabIndexContext";
enum Type { export enum Type {
Register = "REGISTER", Register = "REGISTER",
Unregister = "UNREGISTER", Unregister = "UNREGISTER",
SetFocus = "SET_FOCUS", 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) { switch (action.type) {
case Type.Register: { case Type.Register: {
if (state.refs.length === 0) { let left = 0;
// Our list of refs was empty, set activeRef to this first item let right = state.refs.length - 1;
return { let index = state.refs.length; // by default append to the end
...state,
activeRef: action.payload.ref,
refs: [action.payload.ref],
};
}
if (state.refs.includes(action.payload.ref)) { // 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 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 if (action.payload.ref.current.compareDocumentPosition(ref.current) & DOCUMENT_POSITION_PRECEDING) {
let newIndex = state.refs.findIndex(ref => { left = ++index;
return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING; } else {
}); right = index - 1;
}
}
if (newIndex < 0) { if (!state.activeRef) {
newIndex = state.refs.length; // append to the end // Our list of refs was empty, set activeRef to this first item
state.activeRef = action.payload.ref;
} }
// update the refs list // update the refs list
return { if (index < state.refs.length) {
...state, state.refs.splice(index, 0, action.payload.ref);
refs: [ } else {
...state.refs.slice(0, newIndex), state.refs.push(action.payload.ref);
action.payload.ref, }
...state.refs.slice(newIndex), 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 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 // we just removed the active ref, need to replace it
// pick the ref which is now in the index the old ref was in // pick the ref which is now in the index the old ref was in
const oldIndex = state.refs.findIndex(r => r === action.payload.ref); const len = state.refs.length;
return { state.activeRef = oldIndex >= len ? state.refs[len - 1] : state.refs[oldIndex];
...state,
activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
refs,
};
} }
// update the refs list // update the refs list
return { return { ...state };
...state,
refs,
};
} }
case Type.SetFocus: { case Type.SetFocus: {
// update active ref // update active ref
return { state.activeRef = action.payload.ref;
...state, return { ...state };
activeRef: action.payload.ref,
};
} }
default: default:
return state; return state;
} }
@ -151,13 +146,40 @@ const reducer = (state: IState, action: IAction) => {
interface IProps { interface IProps {
handleHomeEnd?: boolean; handleHomeEnd?: boolean;
handleUpDown?: boolean; handleUpDown?: boolean;
handleLeftRight?: boolean;
children(renderProps: { children(renderProps: {
onKeyDownHandler(ev: React.KeyboardEvent); onKeyDownHandler(ev: React.KeyboardEvent);
}); });
onKeyDown?(ev: React.KeyboardEvent, state: IState); onKeyDown?(ev: React.KeyboardEvent, state: IState);
} }
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => { export const findSiblingElement = (
refs: RefObject<HTMLElement>[],
startIndex: number,
backwards = false,
): RefObject<HTMLElement> => {
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<IProps> = ({
children,
handleHomeEnd,
handleUpDown,
handleLeftRight,
onKeyDown,
}) => {
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, { const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
activeRef: null, activeRef: null,
refs: [], refs: [],
@ -166,6 +188,13 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]); const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
const onKeyDownHandler = useCallback((ev) => { const onKeyDownHandler = useCallback((ev) => {
if (onKeyDown) {
onKeyDown(ev, context.state);
if (ev.defaultPrevented) {
return;
}
}
let handled = false; let handled = false;
// Don't interfere with input default keydown behaviour // Don't interfere with input default keydown behaviour
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
@ -174,43 +203,37 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
case Key.HOME: case Key.HOME:
if (handleHomeEnd) { if (handleHomeEnd) {
handled = true; handled = true;
// move focus to first item // move focus to first (visible) item
if (context.state.refs.length > 0) { findSiblingElement(context.state.refs, 0)?.current?.focus();
context.state.refs[0].current.focus();
}
} }
break; break;
case Key.END: case Key.END:
if (handleHomeEnd) { if (handleHomeEnd) {
handled = true; handled = true;
// move focus to last item // move focus to last (visible) item
if (context.state.refs.length > 0) { findSiblingElement(context.state.refs, context.state.refs.length - 1, true)?.current?.focus();
context.state.refs[context.state.refs.length - 1].current.focus();
}
} }
break; break;
case Key.ARROW_UP: 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; handled = true;
if (context.state.refs.length > 0) { if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef); const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx > 0) { findSiblingElement(context.state.refs, idx - 1)?.current?.focus();
context.state.refs[idx - 1].current.focus();
}
} }
} }
break; break;
case Key.ARROW_DOWN: 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; handled = true;
if (context.state.refs.length > 0) { if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef); const idx = context.state.refs.indexOf(context.state.activeRef);
if (idx < context.state.refs.length - 1) { findSiblingElement(context.state.refs, idx + 1, true)?.current?.focus();
context.state.refs[idx + 1].current.focus();
}
} }
} }
break; break;
@ -220,10 +243,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
if (handled) { if (handled) {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} else if (onKeyDown) {
return onKeyDown(ev, context.state);
} }
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]); }, [context.state, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
return <RovingTabIndexContext.Provider value={context}> return <RovingTabIndexContext.Provider value={context}>
{ children({ onKeyDownHandler }) } { children({ onKeyDownHandler }) }

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from "react"; import React from "react";
import { IState, RovingTabIndexProvider } from "./RovingTabIndex"; import { RovingTabIndexProvider } from "./RovingTabIndex";
import { Key } from "../Keyboard"; import { Key } from "../Keyboard";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> { interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
@ -26,7 +26,7 @@ interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar // https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref` // All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
const Toolbar: React.FC<IProps> = ({ children, ...props }) => { const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { const onKeyDown = (ev: React.KeyboardEvent) => {
const target = ev.target as HTMLElement; const target = ev.target as HTMLElement;
// Don't interfere with input default keydown behaviour // Don't interfere with input default keydown behaviour
if (target.tagName === "INPUT") return; if (target.tagName === "INPUT") return;
@ -42,15 +42,6 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
} }
break; 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: default:
handled = false; handled = false;
} }

View file

@ -17,56 +17,70 @@ limitations under the License.
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import FileSaver from 'file-saver'; 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 { MatrixClientPeg } from '../../../../MatrixClientPeg';
import { _t, _td } from '../../../../languageHandler'; import { _t, _td } from '../../../../languageHandler';
import { accessSecretStorage } from '../../../../SecurityManager'; import { accessSecretStorage } from '../../../../SecurityManager';
import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import AccessibleButton from "../../../../components/views/elements/AccessibleButton";
import { copyNode } from "../../../../utils/strings"; import { copyNode } from "../../../../utils/strings";
import PassphraseField from "../../../../components/views/auth/PassphraseField"; 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; enum Phase {
const PHASE_PASSPHRASE_CONFIRM = 1; Passphrase = "passphrase",
const PHASE_SHOWKEY = 2; PassphraseConfirm = "passphrase_confirm",
const PHASE_KEEPITSAFE = 3; ShowKey = "show_key",
const PHASE_BACKINGUP = 4; KeepItSafe = "keep_it_safe",
const PHASE_DONE = 5; BackingUp = "backing_up",
const PHASE_OPTOUT_CONFIRM = 6; Done = "done",
OptOutConfirm = "opt_out_confirm",
}
const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. 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 * Walks the user through the process of creating an e2e key backup
* on the server. * on the server.
*/ */
export default class CreateKeyBackupDialog extends React.PureComponent { export default class CreateKeyBackupDialog extends React.PureComponent<IProps, IState> {
static propTypes = { private keyBackupInfo: Pick<IPreparedKeyBackupVersion, "recovery_key" | "algorithm" | "auth_data">;
onFinished: PropTypes.func.isRequired, private recoveryKeyNode = createRef<HTMLElement>();
} private passphraseField = createRef<Field>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._recoveryKeyNode = null;
this._keyBackupInfo = null;
this.state = { this.state = {
secureSecretStorage: null, secureSecretStorage: null,
phase: PHASE_PASSPHRASE, phase: Phase.Passphrase,
passPhrase: '', passPhrase: '',
passPhraseValid: false, passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
copied: false, copied: false,
downloaded: false, downloaded: false,
}; };
this._passphraseField = createRef();
} }
async componentDidMount() { public async componentDidMount(): Promise<void> {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const secureSecretStorage = await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"); const secureSecretStorage = await cli.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing");
this.setState({ secureSecretStorage }); 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 // If we're using secret storage, skip ahead to the backing up step, as
// `accessSecretStorage` will handle passphrases as needed. // `accessSecretStorage` will handle passphrases as needed.
if (secureSecretStorage) { if (secureSecretStorage) {
this.setState({ phase: PHASE_BACKINGUP }); this.setState({ phase: Phase.BackingUp });
this._createBackup(); this.createBackup();
} }
} }
_collectRecoveryKeyNode = (n) => { private onCopyClick = (): void => {
this._recoveryKeyNode = n; const successful = copyNode(this.recoveryKeyNode.current);
}
_onCopyClick = () => {
const successful = copyNode(this._recoveryKeyNode);
if (successful) { if (successful) {
this.setState({ this.setState({
copied: true, copied: true,
phase: PHASE_KEEPITSAFE, phase: Phase.KeepItSafe,
}); });
} }
} };
_onDownloadClick = () => { private onDownloadClick = (): void => {
const blob = new Blob([this._keyBackupInfo.recovery_key], { const blob = new Blob([this.keyBackupInfo.recovery_key], {
type: 'text/plain;charset=us-ascii', type: 'text/plain;charset=us-ascii',
}); });
FileSaver.saveAs(blob, 'security-key.txt'); FileSaver.saveAs(blob, 'security-key.txt');
this.setState({ this.setState({
downloaded: true, downloaded: true,
phase: PHASE_KEEPITSAFE, phase: Phase.KeepItSafe,
}); });
} };
_createBackup = async () => { private createBackup = async (): Promise<void> => {
const { secureSecretStorage } = this.state; const { secureSecretStorage } = this.state;
this.setState({ this.setState({
phase: PHASE_BACKINGUP, phase: Phase.BackingUp,
error: null, error: null,
}); });
let info; let info;
@ -123,12 +133,12 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
}); });
} else { } else {
info = await MatrixClientPeg.get().createKeyBackupVersion( info = await MatrixClientPeg.get().createKeyBackupVersion(
this._keyBackupInfo, this.keyBackupInfo,
); );
} }
await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup(); await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup();
this.setState({ this.setState({
phase: PHASE_DONE, phase: Phase.Done,
}); });
} catch (e) { } catch (e) {
logger.error("Error creating key backup", e); logger.error("Error creating key backup", e);
@ -143,97 +153,91 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
error: e, error: e,
}); });
} }
} };
_onCancel = () => { private onCancel = (): void => {
this.props.onFinished(false); this.props.onFinished(false);
} };
_onDone = () => { private onDone = (): void => {
this.props.onFinished(true); this.props.onFinished(true);
} };
_onOptOutClick = () => { private onSetUpClick = (): void => {
this.setState({ phase: PHASE_OPTOUT_CONFIRM }); this.setState({ phase: Phase.Passphrase });
} };
_onSetUpClick = () => { private onSkipPassPhraseClick = async (): Promise<void> => {
this.setState({ phase: PHASE_PASSPHRASE }); this.keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion();
}
_onSkipPassPhraseClick = async () => {
this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion();
this.setState({ this.setState({
copied: false, copied: false,
downloaded: false, downloaded: false,
phase: PHASE_SHOWKEY, phase: Phase.ShowKey,
}); });
} };
_onPassPhraseNextClick = async (e) => { private onPassPhraseNextClick = async (e: React.FormEvent): Promise<void> => {
e.preventDefault(); e.preventDefault();
if (!this._passphraseField.current) return; // unmounting if (!this.passphraseField.current) return; // unmounting
await this._passphraseField.current.validate({ allowEmpty: false }); await this.passphraseField.current.validate({ allowEmpty: false });
if (!this._passphraseField.current.state.valid) { if (!this.passphraseField.current.state.valid) {
this._passphraseField.current.focus(); this.passphraseField.current.focus();
this._passphraseField.current.validate({ allowEmpty: false, focused: true }); this.passphraseField.current.validate({ allowEmpty: false, focused: true });
return; return;
} }
this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); this.setState({ phase: Phase.PassphraseConfirm });
}; };
_onPassPhraseConfirmNextClick = async (e) => { private onPassPhraseConfirmNextClick = async (e: React.FormEvent): Promise<void> => {
e.preventDefault(); e.preventDefault();
if (this.state.passPhrase !== this.state.passPhraseConfirm) return; 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({ this.setState({
copied: false, copied: false,
downloaded: false, downloaded: false,
phase: PHASE_SHOWKEY, phase: Phase.ShowKey,
}); });
}; };
_onSetAgainClick = () => { private onSetAgainClick = (): void => {
this.setState({ this.setState({
passPhrase: '', passPhrase: '',
passPhraseValid: false, passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
phase: PHASE_PASSPHRASE, phase: Phase.Passphrase,
}); });
} };
_onKeepItSafeBackClick = () => { private onKeepItSafeBackClick = (): void => {
this.setState({ this.setState({
phase: PHASE_SHOWKEY, phase: Phase.ShowKey,
}); });
} };
_onPassPhraseValidate = (result) => { private onPassPhraseValidate = (result: IValidationResult): void => {
this.setState({ this.setState({
passPhraseValid: result.valid, passPhraseValid: result.valid,
}); });
}; };
_onPassPhraseChange = (e) => { private onPassPhraseChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
passPhrase: e.target.value, passPhrase: e.target.value,
}); });
} };
_onPassPhraseConfirmChange = (e) => { private onPassPhraseConfirmChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
passPhraseConfirm: e.target.value, passPhraseConfirm: e.target.value,
}); });
} };
_renderPhasePassPhrase() { private renderPhasePassPhrase(): JSX.Element {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return <form onSubmit={this.onPassPhraseNextClick}>
return <form onSubmit={this._onPassPhraseNextClick}>
<p>{ _t( <p>{ _t(
"<b>Warning</b>: You should only set up key backup from a trusted computer.", {}, "<b>Warning</b>: You should only set up key backup from a trusted computer.", {},
{ b: sub => <b>{ sub }</b> }, { b: sub => <b>{ sub }</b> },
@ -248,11 +252,11 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<div className="mx_CreateKeyBackupDialog_passPhraseContainer"> <div className="mx_CreateKeyBackupDialog_passPhraseContainer">
<PassphraseField <PassphraseField
className="mx_CreateKeyBackupDialog_passPhraseInput" className="mx_CreateKeyBackupDialog_passPhraseInput"
onChange={this._onPassPhraseChange} onChange={this.onPassPhraseChange}
minScore={PASSWORD_MIN_SCORE} minScore={PASSWORD_MIN_SCORE}
value={this.state.passPhrase} value={this.state.passPhrase}
onValidate={this._onPassPhraseValidate} onValidate={this.onPassPhraseValidate}
fieldRef={this._passphraseField} fieldRef={this.passphraseField}
autoFocus={true} autoFocus={true}
label={_td("Enter a Security Phrase")} label={_td("Enter a Security Phrase")}
labelEnterPassword={_td("Enter a Security Phrase")} labelEnterPassword={_td("Enter a Security Phrase")}
@ -264,23 +268,21 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<DialogButtons <DialogButtons
primaryButton={_t('Next')} primaryButton={_t('Next')}
onPrimaryButtonClick={this._onPassPhraseNextClick} onPrimaryButtonClick={this.onPassPhraseNextClick}
hasCancel={false} hasCancel={false}
disabled={!this.state.passPhraseValid} disabled={!this.state.passPhraseValid}
/> />
<details> <details>
<summary>{ _t("Advanced") }</summary> <summary>{ _t("Advanced") }</summary>
<AccessibleButton kind='primary' onClick={this._onSkipPassPhraseClick}> <AccessibleButton kind='primary' onClick={this.onSkipPassPhraseClick}>
{ _t("Set up with a Security Key") } { _t("Set up with a Security Key") }
</AccessibleButton> </AccessibleButton>
</details> </details>
</form>; </form>;
} }
_renderPhasePassPhraseConfirm() { private renderPhasePassPhraseConfirm(): JSX.Element {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let matchText; let matchText;
let changeText; let changeText;
if (this.state.passPhraseConfirm === this.state.passPhrase) { if (this.state.passPhraseConfirm === this.state.passPhrase) {
@ -303,14 +305,13 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
passPhraseMatch = <div className="mx_CreateKeyBackupDialog_passPhraseMatch"> passPhraseMatch = <div className="mx_CreateKeyBackupDialog_passPhraseMatch">
<div>{ matchText }</div> <div>{ matchText }</div>
<div> <div>
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}> <AccessibleButton element="span" className="mx_linkButton" onClick={this.onSetAgainClick}>
{ changeText } { changeText }
</AccessibleButton> </AccessibleButton>
</div> </div>
</div>; </div>;
} }
const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return <form onSubmit={this.onPassPhraseConfirmNextClick}>
return <form onSubmit={this._onPassPhraseConfirmNextClick}>
<p>{ _t( <p>{ _t(
"Enter your Security Phrase a second time to confirm it.", "Enter your Security Phrase a second time to confirm it.",
) }</p> ) }</p>
@ -318,7 +319,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<div className="mx_CreateKeyBackupDialog_passPhraseContainer"> <div className="mx_CreateKeyBackupDialog_passPhraseContainer">
<div> <div>
<input type="password" <input type="password"
onChange={this._onPassPhraseConfirmChange} onChange={this.onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm} value={this.state.passPhraseConfirm}
className="mx_CreateKeyBackupDialog_passPhraseInput" className="mx_CreateKeyBackupDialog_passPhraseInput"
placeholder={_t("Repeat your Security Phrase...")} placeholder={_t("Repeat your Security Phrase...")}
@ -330,14 +331,14 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
</div> </div>
<DialogButtons <DialogButtons
primaryButton={_t('Next')} primaryButton={_t('Next')}
onPrimaryButtonClick={this._onPassPhraseConfirmNextClick} onPrimaryButtonClick={this.onPassPhraseConfirmNextClick}
hasCancel={false} hasCancel={false}
disabled={this.state.passPhrase !== this.state.passPhraseConfirm} disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
/> />
</form>; </form>;
} }
_renderPhaseShowKey() { private renderPhaseShowKey(): JSX.Element {
return <div> return <div>
<p>{ _t( <p>{ _t(
"Your Security Key is a safety net - you can use it to restore " + "Your Security Key is a safety net - you can use it to restore " +
@ -352,13 +353,13 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
</div> </div>
<div className="mx_CreateKeyBackupDialog_recoveryKeyContainer"> <div className="mx_CreateKeyBackupDialog_recoveryKeyContainer">
<div className="mx_CreateKeyBackupDialog_recoveryKey"> <div className="mx_CreateKeyBackupDialog_recoveryKey">
<code ref={this._collectRecoveryKeyNode}>{ this._keyBackupInfo.recovery_key }</code> <code ref={this.recoveryKeyNode}>{ this.keyBackupInfo.recovery_key }</code>
</div> </div>
<div className="mx_CreateKeyBackupDialog_recoveryKeyButtons"> <div className="mx_CreateKeyBackupDialog_recoveryKeyButtons">
<button className="mx_Dialog_primary" onClick={this._onCopyClick}> <button className="mx_Dialog_primary" onClick={this.onCopyClick}>
{ _t("Copy") } { _t("Copy") }
</button> </button>
<button className="mx_Dialog_primary" onClick={this._onDownloadClick}> <button className="mx_Dialog_primary" onClick={this.onDownloadClick}>
{ _t("Download") } { _t("Download") }
</button> </button>
</div> </div>
@ -367,7 +368,7 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
</div>; </div>;
} }
_renderPhaseKeepItSafe() { private renderPhaseKeepItSafe(): JSX.Element {
let introText; let introText;
if (this.state.copied) { if (this.state.copied) {
introText = _t( introText = _t(
@ -380,7 +381,6 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
{}, { b: s => <b>{ s }</b> }, {}, { b: s => <b>{ s }</b> },
); );
} }
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div> return <div>
{ introText } { introText }
<ul> <ul>
@ -389,107 +389,101 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
<li>{ _t("<b>Copy it</b> to your personal cloud storage", {}, { b: s => <b>{ s }</b> }) }</li> <li>{ _t("<b>Copy it</b> to your personal cloud storage", {}, { b: s => <b>{ s }</b> }) }</li>
</ul> </ul>
<DialogButtons primaryButton={_t("Continue")} <DialogButtons primaryButton={_t("Continue")}
onPrimaryButtonClick={this._createBackup} onPrimaryButtonClick={this.createBackup}
hasCancel={false}> hasCancel={false}>
<button onClick={this._onKeepItSafeBackClick}>{ _t("Back") }</button> <button onClick={this.onKeepItSafeBackClick}>{ _t("Back") }</button>
</DialogButtons> </DialogButtons>
</div>; </div>;
} }
_renderBusyPhase(text) { private renderBusyPhase(): JSX.Element {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <div> return <div>
<Spinner /> <Spinner />
</div>; </div>;
} }
_renderPhaseDone() { private renderPhaseDone(): JSX.Element {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div> return <div>
<p>{ _t( <p>{ _t(
"Your keys are being backed up (the first backup could take a few minutes).", "Your keys are being backed up (the first backup could take a few minutes).",
) }</p> ) }</p>
<DialogButtons primaryButton={_t('OK')} <DialogButtons primaryButton={_t('OK')}
onPrimaryButtonClick={this._onDone} onPrimaryButtonClick={this.onDone}
hasCancel={false} hasCancel={false}
/> />
</div>; </div>;
} }
_renderPhaseOptOutConfirm() { private renderPhaseOptOutConfirm(): JSX.Element {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return <div> return <div>
{ _t( { _t(
"Without setting up Secure Message Recovery, you won't be able to restore your " + "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.", "encrypted message history if you log out or use another session.",
) } ) }
<DialogButtons primaryButton={_t('Set up Secure Message Recovery')} <DialogButtons primaryButton={_t('Set up Secure Message Recovery')}
onPrimaryButtonClick={this._onSetUpClick} onPrimaryButtonClick={this.onSetUpClick}
hasCancel={false} hasCancel={false}
> >
<button onClick={this._onCancel}>I understand, continue without</button> <button onClick={this.onCancel}>I understand, continue without</button>
</DialogButtons> </DialogButtons>
</div>; </div>;
} }
_titleForPhase(phase) { private titleForPhase(phase: Phase): string {
switch (phase) { switch (phase) {
case PHASE_PASSPHRASE: case Phase.Passphrase:
return _t('Secure your backup with a Security Phrase'); return _t('Secure your backup with a Security Phrase');
case PHASE_PASSPHRASE_CONFIRM: case Phase.PassphraseConfirm:
return _t('Confirm your Security Phrase'); return _t('Confirm your Security Phrase');
case PHASE_OPTOUT_CONFIRM: case Phase.OptOutConfirm:
return _t('Warning!'); return _t('Warning!');
case PHASE_SHOWKEY: case Phase.ShowKey:
case PHASE_KEEPITSAFE: case Phase.KeepItSafe:
return _t('Make a copy of your Security Key'); return _t('Make a copy of your Security Key');
case PHASE_BACKINGUP: case Phase.BackingUp:
return _t('Starting backup...'); return _t('Starting backup...');
case PHASE_DONE: case Phase.Done:
return _t('Success!'); return _t('Success!');
default: default:
return _t("Create key backup"); return _t("Create key backup");
} }
} }
render() { public render(): JSX.Element {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let content; let content;
if (this.state.error) { if (this.state.error) {
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
content = <div> content = <div>
<p>{ _t("Unable to create key backup") }</p> <p>{ _t("Unable to create key backup") }</p>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')} <DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._createBackup} onPrimaryButtonClick={this.createBackup}
hasCancel={true} hasCancel={true}
onCancel={this._onCancel} onCancel={this.onCancel}
/> />
</div> </div>
</div>; </div>;
} else { } else {
switch (this.state.phase) { switch (this.state.phase) {
case PHASE_PASSPHRASE: case Phase.Passphrase:
content = this._renderPhasePassPhrase(); content = this.renderPhasePassPhrase();
break; break;
case PHASE_PASSPHRASE_CONFIRM: case Phase.PassphraseConfirm:
content = this._renderPhasePassPhraseConfirm(); content = this.renderPhasePassPhraseConfirm();
break; break;
case PHASE_SHOWKEY: case Phase.ShowKey:
content = this._renderPhaseShowKey(); content = this.renderPhaseShowKey();
break; break;
case PHASE_KEEPITSAFE: case Phase.KeepItSafe:
content = this._renderPhaseKeepItSafe(); content = this.renderPhaseKeepItSafe();
break; break;
case PHASE_BACKINGUP: case Phase.BackingUp:
content = this._renderBusyPhase(); content = this.renderBusyPhase();
break; break;
case PHASE_DONE: case Phase.Done:
content = this._renderPhaseDone(); content = this.renderPhaseDone();
break; break;
case PHASE_OPTOUT_CONFIRM: case Phase.OptOutConfirm:
content = this._renderPhaseOptOutConfirm(); content = this.renderPhaseOptOutConfirm();
break; break;
} }
} }
@ -497,8 +491,8 @@ export default class CreateKeyBackupDialog extends React.PureComponent {
return ( return (
<BaseDialog className='mx_CreateKeyBackupDialog' <BaseDialog className='mx_CreateKeyBackupDialog'
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this._titleForPhase(this.state.phase)} title={this.titleForPhase(this.state.phase)}
hasCancel={[PHASE_PASSPHRASE, PHASE_DONE].includes(this.state.phase)} hasCancel={[Phase.Passphrase, Phase.Done].includes(this.state.phase)}
> >
<div> <div>
{ content } { content }

View file

@ -16,12 +16,8 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; 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 { MatrixClientPeg } from '../../../../MatrixClientPeg';
import FileSaver from 'file-saver';
import { _t, _td } from '../../../../languageHandler'; import { _t, _td } from '../../../../languageHandler';
import Modal from '../../../../Modal'; import Modal from '../../../../Modal';
import { promptForBackupPassphrase } from '../../../../SecurityManager'; import { promptForBackupPassphrase } from '../../../../SecurityManager';
@ -33,50 +29,105 @@ import AccessibleButton from "../../../../components/views/elements/AccessibleBu
import DialogButtons from "../../../../components/views/elements/DialogButtons"; import DialogButtons from "../../../../components/views/elements/DialogButtons";
import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; 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"; import SecurityCustomisations from "../../../../customisations/Security";
const PHASE_LOADING = 0; import { logger } from "matrix-js-sdk/src/logger";
const PHASE_LOADERROR = 1; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
const PHASE_CHOOSE_KEY_PASSPHRASE = 2; import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps";
const PHASE_MIGRATE = 3; import Field from "../../../../components/views/elements/Field";
const PHASE_PASSPHRASE = 4; import BaseDialog from "../../../../components/views/dialogs/BaseDialog";
const PHASE_PASSPHRASE_CONFIRM = 5; import Spinner from "../../../../components/views/elements/Spinner";
const PHASE_SHOWKEY = 6; import { TrustInfo } from "matrix-js-sdk/src/crypto/backup";
const PHASE_STORING = 8; import { CrossSigningKeys } from "matrix-js-sdk";
const PHASE_CONFIRM_SKIP = 10; 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. 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 interface IProps extends IDialogProps {
const CREATE_STORAGE_OPTION_KEY = 'key'; hasCancel: boolean;
const CREATE_STORAGE_OPTION_PASSPHRASE = 'passphrase'; 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 * Walks the user through the process of creating a passphrase to guard Secure
* Secret Storage in account data. * Secret Storage in account data.
*/ */
export default class CreateSecretStorageDialog extends React.PureComponent { export default class CreateSecretStorageDialog extends React.PureComponent<IProps, IState> {
static propTypes = { public static defaultProps: Partial<IProps> = {
hasCancel: PropTypes.bool,
accountPassword: PropTypes.string,
forceReset: PropTypes.bool,
};
static defaultProps = {
hasCancel: true, hasCancel: true,
forceReset: false, forceReset: false,
}; };
private recoveryKey: IRecoveryKey;
private backupKey: Uint8Array;
private recoveryKeyNode = createRef<HTMLElement>();
private passphraseField = createRef<Field>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this._recoveryKey = null; let passPhraseKeySelected;
this._recoveryKeyNode = null; const setupMethods = getSecureBackupSetupMethods();
this._backupKey = null; 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 = { this.state = {
phase: PHASE_LOADING, phase: Phase.Loading,
passPhrase: '', passPhrase: '',
passPhraseValid: false, passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
@ -87,55 +138,37 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
backupSigStatus: null, backupSigStatus: null,
// does the server offer a UI auth flow with just m.login.password // does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload? // for /keys/device_signing/upload?
canUploadKeysWithPasswordOnly: null,
accountPassword: props.accountPassword || "",
accountPasswordCorrect: null, accountPasswordCorrect: null,
canSkip: !isSecureBackupRequired(), canSkip: !isSecureBackupRequired(),
canUploadKeysWithPasswordOnly,
passPhraseKeySelected,
accountPassword,
}; };
const setupMethods = getSecureBackupSetupMethods(); MatrixClientPeg.get().on('crypto.keyBackupStatus', this.onKeyBackupStatusChange);
if (setupMethods.includes("key")) {
this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_KEY; this.getInitialPhase();
} else {
this.state.passPhraseKeySelected = CREATE_STORAGE_OPTION_PASSPHRASE;
} }
this._passphraseField = createRef(); public componentWillUnmount(): void {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this.onKeyBackupStatusChange);
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(); private getInitialPhase(): void {
}
componentWillUnmount() {
MatrixClientPeg.get().removeListener('crypto.keyBackupStatus', this._onKeyBackupStatusChange);
}
_getInitialPhase() {
const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.(); const keyFromCustomisations = SecurityCustomisations.createSecretStorageKey?.();
if (keyFromCustomisations) { if (keyFromCustomisations) {
logger.log("Created key via customisations, jumping to bootstrap step"); logger.log("Created key via customisations, jumping to bootstrap step");
this._recoveryKey = { this.recoveryKey = {
privateKey: keyFromCustomisations, privateKey: keyFromCustomisations,
}; };
this._bootstrapSecretStorage(); this.bootstrapSecretStorage();
return; return;
} }
this._fetchBackupInfo(); this.fetchBackupInfo();
} }
async _fetchBackupInfo() { private async fetchBackupInfo(): Promise<{ backupInfo: IKeyBackupInfo, backupSigStatus: TrustInfo }> {
try { try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
const backupSigStatus = ( const backupSigStatus = (
@ -144,7 +177,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
); );
const { forceReset } = this.props; const { forceReset } = this.props;
const phase = (backupInfo && !forceReset) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; const phase = (backupInfo && !forceReset) ? Phase.Migrate : Phase.ChooseKeyPassphrase;
this.setState({ this.setState({
phase, phase,
@ -157,13 +190,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
backupSigStatus, backupSigStatus,
}; };
} catch (e) { } catch (e) {
this.setState({ phase: PHASE_LOADERROR }); this.setState({ phase: Phase.LoadError });
} }
} }
async _queryKeyUploadAuth() { private async queryKeyUploadAuth(): Promise<void> {
try { try {
await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {} as CrossSigningKeys);
// We should never get here: the server should always require // We should never get here: the server should always require
// UI auth to upload device signing keys. If we do, we upload // UI auth to upload device signing keys. If we do, we upload
// no keys which would be a no-op. // no keys which would be a no-op.
@ -182,59 +215,55 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
} }
} }
_onKeyBackupStatusChange = () => { private onKeyBackupStatusChange = (): void => {
if (this.state.phase === PHASE_MIGRATE) this._fetchBackupInfo(); if (this.state.phase === Phase.Migrate) this.fetchBackupInfo();
} };
_onKeyPassphraseChange = e => { private onKeyPassphraseChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
passPhraseKeySelected: e.target.value, passPhraseKeySelected: e.target.value,
}); });
} };
_collectRecoveryKeyNode = (n) => { private onChooseKeyPassphraseFormSubmit = async (): Promise<void> => {
this._recoveryKeyNode = n; if (this.state.passPhraseKeySelected === SecureBackupSetupMethod.Key) {
} this.recoveryKey =
_onChooseKeyPassphraseFormSubmit = async () => {
if (this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY) {
this._recoveryKey =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); await MatrixClientPeg.get().createRecoveryKeyFromPassphrase();
this.setState({ this.setState({
copied: false, copied: false,
downloaded: false, downloaded: false,
setPassphrase: false, setPassphrase: false,
phase: PHASE_SHOWKEY, phase: Phase.ShowKey,
}); });
} else { } else {
this.setState({ this.setState({
copied: false, copied: false,
downloaded: false, downloaded: false,
phase: PHASE_PASSPHRASE, phase: Phase.Passphrase,
}); });
} }
} };
_onMigrateFormSubmit = (e) => { private onMigrateFormSubmit = (e: React.FormEvent): void => {
e.preventDefault(); e.preventDefault();
if (this.state.backupSigStatus.usable) { if (this.state.backupSigStatus.usable) {
this._bootstrapSecretStorage(); this.bootstrapSecretStorage();
} else { } else {
this._restoreBackup(); this.restoreBackup();
}
} }
};
_onCopyClick = () => { private onCopyClick = (): void => {
const successful = copyNode(this._recoveryKeyNode); const successful = copyNode(this.recoveryKeyNode.current);
if (successful) { if (successful) {
this.setState({ this.setState({
copied: true, copied: true,
}); });
} }
} };
_onDownloadClick = () => { private onDownloadClick = (): void => {
const blob = new Blob([this._recoveryKey.encodedPrivateKey], { const blob = new Blob([this.recoveryKey.encodedPrivateKey], {
type: 'text/plain;charset=us-ascii', type: 'text/plain;charset=us-ascii',
}); });
FileSaver.saveAs(blob, 'security-key.txt'); FileSaver.saveAs(blob, 'security-key.txt');
@ -242,9 +271,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this.setState({ this.setState({
downloaded: true, downloaded: true,
}); });
} };
_doBootstrapUIAuth = async (makeRequest) => { private doBootstrapUIAuth = async (makeRequest: (authData: any) => void): Promise<void> => {
if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) {
await makeRequest({ await makeRequest({
type: 'm.login.password', type: 'm.login.password',
@ -258,8 +287,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
password: this.state.accountPassword, password: this.state.accountPassword,
}); });
} else { } else {
const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog");
const dialogAesthetics = { const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: { [SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("Use Single Sign On to continue"), 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"); throw new Error("Cross-signing key upload auth canceled");
} }
} }
} };
_bootstrapSecretStorage = async () => { private bootstrapSecretStorage = async (): Promise<void> => {
this.setState({ this.setState({
phase: PHASE_STORING, phase: Phase.Storing,
error: null, error: null,
}); });
@ -308,7 +335,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
if (forceReset) { if (forceReset) {
logger.log("Forcing secret storage reset"); logger.log("Forcing secret storage reset");
await cli.bootstrapSecretStorage({ await cli.bootstrapSecretStorage({
createSecretStorageKey: async () => this._recoveryKey, createSecretStorageKey: async () => this.recoveryKey,
setupNewKeyBackup: true, setupNewKeyBackup: true,
setupNewSecretStorage: 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 // keys (and also happen to skip all post-authentication flows at the
// moment via token login) // moment via token login)
await cli.bootstrapCrossSigning({ await cli.bootstrapCrossSigning({
authUploadDeviceSigningKeys: this._doBootstrapUIAuth, authUploadDeviceSigningKeys: this.doBootstrapUIAuth,
}); });
await cli.bootstrapSecretStorage({ await cli.bootstrapSecretStorage({
createSecretStorageKey: async () => this._recoveryKey, createSecretStorageKey: async () => this.recoveryKey,
keyBackupInfo: this.state.backupInfo, keyBackupInfo: this.state.backupInfo,
setupNewKeyBackup: !this.state.backupInfo, setupNewKeyBackup: !this.state.backupInfo,
getKeyBackupPassphrase: () => { getKeyBackupPassphrase: async () => {
// We may already have the backup key if we earlier went // We may already have the backup key if we earlier went
// through the restore backup path, so pass it along // through the restore backup path, so pass it along
// rather than prompting again. // rather than prompting again.
if (this._backupKey) { if (this.backupKey) {
return this._backupKey; return this.backupKey;
} }
return promptForBackupPassphrase(); return promptForBackupPassphrase();
}, },
@ -344,27 +371,23 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
this.setState({ this.setState({
accountPassword: '', accountPassword: '',
accountPasswordCorrect: false, accountPasswordCorrect: false,
phase: PHASE_MIGRATE, phase: Phase.Migrate,
}); });
} else { } else {
this.setState({ error: e }); this.setState({ error: e });
} }
logger.error("Error bootstrapping secret storage", e); logger.error("Error bootstrapping secret storage", e);
} }
} };
_onCancel = () => { private onCancel = (): void => {
this.props.onFinished(false); this.props.onFinished(false);
} };
_onDone = () => { private restoreBackup = async (): Promise<void> => {
this.props.onFinished(true);
}
_restoreBackup = async () => {
// It's possible we'll need the backup key later on for bootstrapping, // 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. // 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( const { finished } = Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, 'Restore Backup', '', RestoreKeyBackupDialog,
@ -376,103 +399,103 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
); );
await finished; await finished;
const { backupSigStatus } = await this._fetchBackupInfo(); const { backupSigStatus } = await this.fetchBackupInfo();
if ( if (
backupSigStatus.usable && backupSigStatus.usable &&
this.state.canUploadKeysWithPasswordOnly && this.state.canUploadKeysWithPasswordOnly &&
this.state.accountPassword this.state.accountPassword
) { ) {
this._bootstrapSecretStorage(); this.bootstrapSecretStorage();
}
} }
};
_onLoadRetryClick = () => { private onLoadRetryClick = (): void => {
this.setState({ phase: PHASE_LOADING }); this.setState({ phase: Phase.Loading });
this._fetchBackupInfo(); this.fetchBackupInfo();
} };
_onShowKeyContinueClick = () => { private onShowKeyContinueClick = (): void => {
this._bootstrapSecretStorage(); this.bootstrapSecretStorage();
} };
_onCancelClick = () => { private onCancelClick = (): void => {
this.setState({ phase: PHASE_CONFIRM_SKIP }); this.setState({ phase: Phase.ConfirmSkip });
} };
_onGoBackClick = () => { private onGoBackClick = (): void => {
this.setState({ phase: PHASE_CHOOSE_KEY_PASSPHRASE }); this.setState({ phase: Phase.ChooseKeyPassphrase });
} };
_onPassPhraseNextClick = async (e) => { private onPassPhraseNextClick = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!this._passphraseField.current) return; // unmounting if (!this.passphraseField.current) return; // unmounting
await this._passphraseField.current.validate({ allowEmpty: false }); await this.passphraseField.current.validate({ allowEmpty: false });
if (!this._passphraseField.current.state.valid) { if (!this.passphraseField.current.state.valid) {
this._passphraseField.current.focus(); this.passphraseField.current.focus();
this._passphraseField.current.validate({ allowEmpty: false, focused: true }); this.passphraseField.current.validate({ allowEmpty: false, focused: true });
return; return;
} }
this.setState({ phase: PHASE_PASSPHRASE_CONFIRM }); this.setState({ phase: Phase.PassphraseConfirm });
}; };
_onPassPhraseConfirmNextClick = async (e) => { private onPassPhraseConfirmNextClick = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (this.state.passPhrase !== this.state.passPhraseConfirm) return; if (this.state.passPhrase !== this.state.passPhraseConfirm) return;
this._recoveryKey = this.recoveryKey =
await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase);
this.setState({ this.setState({
copied: false, copied: false,
downloaded: false, downloaded: false,
setPassphrase: true, setPassphrase: true,
phase: PHASE_SHOWKEY, phase: Phase.ShowKey,
}); });
} };
_onSetAgainClick = () => { private onSetAgainClick = (): void => {
this.setState({ this.setState({
passPhrase: '', passPhrase: '',
passPhraseValid: false, passPhraseValid: false,
passPhraseConfirm: '', passPhraseConfirm: '',
phase: PHASE_PASSPHRASE, phase: Phase.Passphrase,
}); });
} };
_onPassPhraseValidate = (result) => { private onPassPhraseValidate = (result: IValidationResult): void => {
this.setState({ this.setState({
passPhraseValid: result.valid, passPhraseValid: result.valid,
}); });
}; };
_onPassPhraseChange = (e) => { private onPassPhraseChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
passPhrase: e.target.value, passPhrase: e.target.value,
}); });
} };
_onPassPhraseConfirmChange = (e) => { private onPassPhraseConfirmChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
passPhraseConfirm: e.target.value, passPhraseConfirm: e.target.value,
}); });
} };
_onAccountPasswordChange = (e) => { private onAccountPasswordChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({ this.setState({
accountPassword: e.target.value, accountPassword: e.target.value,
}); });
} };
_renderOptionKey() { private renderOptionKey(): JSX.Element {
return ( return (
<StyledRadioButton <StyledRadioButton
key={CREATE_STORAGE_OPTION_KEY} key={SecureBackupSetupMethod.Key}
value={CREATE_STORAGE_OPTION_KEY} value={SecureBackupSetupMethod.Key}
name="keyPassphrase" name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_KEY} checked={this.state.passPhraseKeySelected === SecureBackupSetupMethod.Key}
onChange={this._onKeyPassphraseChange} onChange={this.onKeyPassphraseChange}
outlined outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
@ -484,14 +507,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
); );
} }
_renderOptionPassphrase() { private renderOptionPassphrase(): JSX.Element {
return ( return (
<StyledRadioButton <StyledRadioButton
key={CREATE_STORAGE_OPTION_PASSPHRASE} key={SecureBackupSetupMethod.Passphrase}
value={CREATE_STORAGE_OPTION_PASSPHRASE} value={SecureBackupSetupMethod.Passphrase}
name="keyPassphrase" name="keyPassphrase"
checked={this.state.passPhraseKeySelected === CREATE_STORAGE_OPTION_PASSPHRASE} checked={this.state.passPhraseKeySelected === SecureBackupSetupMethod.Passphrase}
onChange={this._onKeyPassphraseChange} onChange={this.onKeyPassphraseChange}
outlined outlined
> >
<div className="mx_CreateSecretStorageDialog_optionTitle"> <div className="mx_CreateSecretStorageDialog_optionTitle">
@ -503,12 +526,14 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
); );
} }
_renderPhaseChooseKeyPassphrase() { private renderPhaseChooseKeyPassphrase(): JSX.Element {
const setupMethods = getSecureBackupSetupMethods(); const setupMethods = getSecureBackupSetupMethods();
const optionKey = setupMethods.includes("key") ? this._renderOptionKey() : null; const optionKey = setupMethods.includes(SecureBackupSetupMethod.Key) ? this.renderOptionKey() : null;
const optionPassphrase = setupMethods.includes("passphrase") ? this._renderOptionPassphrase() : null; const optionPassphrase = setupMethods.includes(SecureBackupSetupMethod.Passphrase)
? this.renderOptionPassphrase()
: null;
return <form onSubmit={this._onChooseKeyPassphraseFormSubmit}> return <form onSubmit={this.onChooseKeyPassphraseFormSubmit}>
<p className="mx_CreateSecretStorageDialog_centeredBody">{ _t( <p className="mx_CreateSecretStorageDialog_centeredBody">{ _t(
"Safeguard against losing access to encrypted messages & data by " + "Safeguard against losing access to encrypted messages & data by " +
"backing up encryption keys on your server.", "backing up encryption keys on your server.",
@ -519,20 +544,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div> </div>
<DialogButtons <DialogButtons
primaryButton={_t("Continue")} primaryButton={_t("Continue")}
onPrimaryButtonClick={this._onChooseKeyPassphraseFormSubmit} onPrimaryButtonClick={this.onChooseKeyPassphraseFormSubmit}
onCancel={this._onCancelClick} onCancel={this.onCancelClick}
hasCancel={this.state.canSkip} hasCancel={this.state.canSkip}
/> />
</form>; </form>;
} }
_renderPhaseMigrate() { private renderPhaseMigrate(): JSX.Element {
// TODO: This is a temporary screen so people who have the labs flag turned on and // 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. // 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 // Once we're confident enough in this (and it's supported enough) we can do
// it automatically. // it automatically.
// https://github.com/vector-im/element-web/issues/11696 // https://github.com/vector-im/element-web/issues/11696
const Field = sdk.getComponent('views.elements.Field');
let authPrompt; let authPrompt;
let nextCaption = _t("Next"); let nextCaption = _t("Next");
@ -543,7 +567,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
type="password" type="password"
label={_t("Password")} label={_t("Password")}
value={this.state.accountPassword} value={this.state.accountPassword}
onChange={this._onAccountPasswordChange} onChange={this.onAccountPasswordChange}
forceValidity={this.state.accountPasswordCorrect === false ? false : null} forceValidity={this.state.accountPasswordCorrect === false ? false : null}
autoFocus={true} autoFocus={true}
/></div> /></div>
@ -559,7 +583,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</p>; </p>;
} }
return <form onSubmit={this._onMigrateFormSubmit}> return <form onSubmit={this.onMigrateFormSubmit}>
<p>{ _t( <p>{ _t(
"Upgrade this session to allow it to verify other sessions, " + "Upgrade this session to allow it to verify other sessions, " +
"granting them access to encrypted messages and marking them " + "granting them access to encrypted messages and marking them " +
@ -568,19 +592,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div>{ authPrompt }</div> <div>{ authPrompt }</div>
<DialogButtons <DialogButtons
primaryButton={nextCaption} primaryButton={nextCaption}
onPrimaryButtonClick={this._onMigrateFormSubmit} onPrimaryButtonClick={this.onMigrateFormSubmit}
hasCancel={false} hasCancel={false}
primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword} primaryDisabled={this.state.canUploadKeysWithPasswordOnly && !this.state.accountPassword}
> >
<button type="button" className="danger" onClick={this._onCancelClick}> <button type="button" className="danger" onClick={this.onCancelClick}>
{ _t('Skip') } { _t('Skip') }
</button> </button>
</DialogButtons> </DialogButtons>
</form>; </form>;
} }
_renderPhasePassPhrase() { private renderPhasePassPhrase(): JSX.Element {
return <form onSubmit={this._onPassPhraseNextClick}> return <form onSubmit={this.onPassPhraseNextClick}>
<p>{ _t( <p>{ _t(
"Enter a security phrase only you know, as its used to safeguard your data. " + "Enter a security phrase only you know, as its used to safeguard your data. " +
"To be secure, you shouldnt re-use your account password.", "To be secure, you shouldnt re-use your account password.",
@ -589,11 +613,11 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div className="mx_CreateSecretStorageDialog_passPhraseContainer"> <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<PassphraseField <PassphraseField
className="mx_CreateSecretStorageDialog_passPhraseField" className="mx_CreateSecretStorageDialog_passPhraseField"
onChange={this._onPassPhraseChange} onChange={this.onPassPhraseChange}
minScore={PASSWORD_MIN_SCORE} minScore={PASSWORD_MIN_SCORE}
value={this.state.passPhrase} value={this.state.passPhrase}
onValidate={this._onPassPhraseValidate} onValidate={this.onPassPhraseValidate}
fieldRef={this._passphraseField} fieldRef={this.passphraseField}
autoFocus={true} autoFocus={true}
label={_td("Enter a Security Phrase")} label={_td("Enter a Security Phrase")}
labelEnterPassword={_td("Enter a Security Phrase")} labelEnterPassword={_td("Enter a Security Phrase")}
@ -604,21 +628,19 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<DialogButtons <DialogButtons
primaryButton={_t('Continue')} primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseNextClick} onPrimaryButtonClick={this.onPassPhraseNextClick}
hasCancel={false} hasCancel={false}
disabled={!this.state.passPhraseValid} disabled={!this.state.passPhraseValid}
> >
<button type="button" <button type="button"
onClick={this._onCancelClick} onClick={this.onCancelClick}
className="danger" className="danger"
>{ _t("Cancel") }</button> >{ _t("Cancel") }</button>
</DialogButtons> </DialogButtons>
</form>; </form>;
} }
_renderPhasePassPhraseConfirm() { private renderPhasePassPhraseConfirm(): JSX.Element {
const Field = sdk.getComponent('views.elements.Field');
let matchText; let matchText;
let changeText; let changeText;
if (this.state.passPhraseConfirm === this.state.passPhrase) { if (this.state.passPhraseConfirm === this.state.passPhrase) {
@ -641,20 +663,20 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
passPhraseMatch = <div> passPhraseMatch = <div>
<div>{ matchText }</div> <div>{ matchText }</div>
<div> <div>
<AccessibleButton element="span" className="mx_linkButton" onClick={this._onSetAgainClick}> <AccessibleButton element="span" className="mx_linkButton" onClick={this.onSetAgainClick}>
{ changeText } { changeText }
</AccessibleButton> </AccessibleButton>
</div> </div>
</div>; </div>;
} }
return <form onSubmit={this._onPassPhraseConfirmNextClick}> return <form onSubmit={this.onPassPhraseConfirmNextClick}>
<p>{ _t( <p>{ _t(
"Enter your Security Phrase a second time to confirm it.", "Enter your Security Phrase a second time to confirm it.",
) }</p> ) }</p>
<div className="mx_CreateSecretStorageDialog_passPhraseContainer"> <div className="mx_CreateSecretStorageDialog_passPhraseContainer">
<Field <Field
type="password" type="password"
onChange={this._onPassPhraseConfirmChange} onChange={this.onPassPhraseConfirmChange}
value={this.state.passPhraseConfirm} value={this.state.passPhraseConfirm}
className="mx_CreateSecretStorageDialog_passPhraseField" className="mx_CreateSecretStorageDialog_passPhraseField"
label={_t("Confirm your Security Phrase")} label={_t("Confirm your Security Phrase")}
@ -667,24 +689,24 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div> </div>
<DialogButtons <DialogButtons
primaryButton={_t('Continue')} primaryButton={_t('Continue')}
onPrimaryButtonClick={this._onPassPhraseConfirmNextClick} onPrimaryButtonClick={this.onPassPhraseConfirmNextClick}
hasCancel={false} hasCancel={false}
disabled={this.state.passPhrase !== this.state.passPhraseConfirm} disabled={this.state.passPhrase !== this.state.passPhraseConfirm}
> >
<button type="button" <button type="button"
onClick={this._onCancelClick} onClick={this.onCancelClick}
className="danger" className="danger"
>{ _t("Skip") }</button> >{ _t("Skip") }</button>
</DialogButtons> </DialogButtons>
</form>; </form>;
} }
_renderPhaseShowKey() { private renderPhaseShowKey(): JSX.Element {
let continueButton; let continueButton;
if (this.state.phase === PHASE_SHOWKEY) { if (this.state.phase === Phase.ShowKey) {
continueButton = <DialogButtons primaryButton={_t("Continue")} continueButton = <DialogButtons primaryButton={_t("Continue")}
disabled={!this.state.downloaded && !this.state.copied && !this.state.setPassphrase} disabled={!this.state.downloaded && !this.state.copied && !this.state.setPassphrase}
onPrimaryButtonClick={this._onShowKeyContinueClick} onPrimaryButtonClick={this.onShowKeyContinueClick}
hasCancel={false} hasCancel={false}
/>; />;
} else { } else {
@ -700,13 +722,13 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<div className="mx_CreateSecretStorageDialog_primaryContainer"> <div className="mx_CreateSecretStorageDialog_primaryContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKeyContainer"> <div className="mx_CreateSecretStorageDialog_recoveryKeyContainer">
<div className="mx_CreateSecretStorageDialog_recoveryKey"> <div className="mx_CreateSecretStorageDialog_recoveryKey">
<code ref={this._collectRecoveryKeyNode}>{ this._recoveryKey.encodedPrivateKey }</code> <code ref={this.recoveryKeyNode}>{ this.recoveryKey.encodedPrivateKey }</code>
</div> </div>
<div className="mx_CreateSecretStorageDialog_recoveryKeyButtons"> <div className="mx_CreateSecretStorageDialog_recoveryKeyButtons">
<AccessibleButton kind='primary' <AccessibleButton kind='primary'
className="mx_Dialog_primary" className="mx_Dialog_primary"
onClick={this._onDownloadClick} onClick={this.onDownloadClick}
disabled={this.state.phase === PHASE_STORING} disabled={this.state.phase === Phase.Storing}
> >
{ _t("Download") } { _t("Download") }
</AccessibleButton> </AccessibleButton>
@ -714,8 +736,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
<AccessibleButton <AccessibleButton
kind='primary' kind='primary'
className="mx_Dialog_primary mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn" className="mx_Dialog_primary mx_CreateSecretStorageDialog_recoveryKeyButtons_copyBtn"
onClick={this._onCopyClick} onClick={this.onCopyClick}
disabled={this.state.phase === PHASE_STORING} disabled={this.state.phase === Phase.Storing}
> >
{ this.state.copied ? _t("Copied!") : _t("Copy") } { this.state.copied ? _t("Copied!") : _t("Copy") }
</AccessibleButton> </AccessibleButton>
@ -726,27 +748,26 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
</div>; </div>;
} }
_renderBusyPhase() { private renderBusyPhase(): JSX.Element {
const Spinner = sdk.getComponent('views.elements.Spinner');
return <div> return <div>
<Spinner /> <Spinner />
</div>; </div>;
} }
_renderPhaseLoadError() { private renderPhaseLoadError(): JSX.Element {
return <div> return <div>
<p>{ _t("Unable to query secret storage status") }</p> <p>{ _t("Unable to query secret storage status") }</p>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')} <DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._onLoadRetryClick} onPrimaryButtonClick={this.onLoadRetryClick}
hasCancel={this.state.canSkip} hasCancel={this.state.canSkip}
onCancel={this._onCancel} onCancel={this.onCancel}
/> />
</div> </div>
</div>; </div>;
} }
_renderPhaseSkipConfirm() { private renderPhaseSkipConfirm(): JSX.Element {
return <div> return <div>
<p>{ _t( <p>{ _t(
"If you cancel now, you may lose encrypted messages & data if you lose access to your logins.", "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.", "You can also set up Secure Backup & manage your keys in Settings.",
) }</p> ) }</p>
<DialogButtons primaryButton={_t('Go back')} <DialogButtons primaryButton={_t('Go back')}
onPrimaryButtonClick={this._onGoBackClick} onPrimaryButtonClick={this.onGoBackClick}
hasCancel={false} hasCancel={false}
> >
<button type="button" className="danger" onClick={this._onCancel}>{ _t('Cancel') }</button> <button type="button" className="danger" onClick={this.onCancel}>{ _t('Cancel') }</button>
</DialogButtons> </DialogButtons>
</div>; </div>;
} }
_titleForPhase(phase) { private titleForPhase(phase: Phase): string {
switch (phase) { switch (phase) {
case PHASE_CHOOSE_KEY_PASSPHRASE: case Phase.ChooseKeyPassphrase:
return _t('Set up Secure Backup'); return _t('Set up Secure Backup');
case PHASE_MIGRATE: case Phase.Migrate:
return _t('Upgrade your encryption'); return _t('Upgrade your encryption');
case PHASE_PASSPHRASE: case Phase.Passphrase:
return _t('Set a Security Phrase'); return _t('Set a Security Phrase');
case PHASE_PASSPHRASE_CONFIRM: case Phase.PassphraseConfirm:
return _t('Confirm Security Phrase'); return _t('Confirm Security Phrase');
case PHASE_CONFIRM_SKIP: case Phase.ConfirmSkip:
return _t('Are you sure?'); return _t('Are you sure?');
case PHASE_SHOWKEY: case Phase.ShowKey:
return _t('Save your Security Key'); return _t('Save your Security Key');
case PHASE_STORING: case Phase.Storing:
return _t('Setting up keys'); return _t('Setting up keys');
default: default:
return ''; return '';
} }
} }
render() { public render(): JSX.Element {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
let content; let content;
if (this.state.error) { if (this.state.error) {
content = <div> content = <div>
<p>{ _t("Unable to set up secret storage") }</p> <p>{ _t("Unable to set up secret storage") }</p>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<DialogButtons primaryButton={_t('Retry')} <DialogButtons primaryButton={_t('Retry')}
onPrimaryButtonClick={this._bootstrapSecretStorage} onPrimaryButtonClick={this.bootstrapSecretStorage}
hasCancel={this.state.canSkip} hasCancel={this.state.canSkip}
onCancel={this._onCancel} onCancel={this.onCancel}
/> />
</div> </div>
</div>; </div>;
} else { } else {
switch (this.state.phase) { switch (this.state.phase) {
case PHASE_LOADING: case Phase.Loading:
content = this._renderBusyPhase(); content = this.renderBusyPhase();
break; break;
case PHASE_LOADERROR: case Phase.LoadError:
content = this._renderPhaseLoadError(); content = this.renderPhaseLoadError();
break; break;
case PHASE_CHOOSE_KEY_PASSPHRASE: case Phase.ChooseKeyPassphrase:
content = this._renderPhaseChooseKeyPassphrase(); content = this.renderPhaseChooseKeyPassphrase();
break; break;
case PHASE_MIGRATE: case Phase.Migrate:
content = this._renderPhaseMigrate(); content = this.renderPhaseMigrate();
break; break;
case PHASE_PASSPHRASE: case Phase.Passphrase:
content = this._renderPhasePassPhrase(); content = this.renderPhasePassPhrase();
break; break;
case PHASE_PASSPHRASE_CONFIRM: case Phase.PassphraseConfirm:
content = this._renderPhasePassPhraseConfirm(); content = this.renderPhasePassPhraseConfirm();
break; break;
case PHASE_SHOWKEY: case Phase.ShowKey:
content = this._renderPhaseShowKey(); content = this.renderPhaseShowKey();
break; break;
case PHASE_STORING: case Phase.Storing:
content = this._renderBusyPhase(); content = this.renderBusyPhase();
break; break;
case PHASE_CONFIRM_SKIP: case Phase.ConfirmSkip:
content = this._renderPhaseSkipConfirm(); content = this.renderPhaseSkipConfirm();
break; break;
} }
} }
let titleClass = null; let titleClass = null;
switch (this.state.phase) { switch (this.state.phase) {
case PHASE_PASSPHRASE: case Phase.Passphrase:
case PHASE_PASSPHRASE_CONFIRM: case Phase.PassphraseConfirm:
titleClass = [ titleClass = [
'mx_CreateSecretStorageDialog_titleWithIcon', 'mx_CreateSecretStorageDialog_titleWithIcon',
'mx_CreateSecretStorageDialog_securePhraseTitle', 'mx_CreateSecretStorageDialog_securePhraseTitle',
]; ];
break; break;
case PHASE_SHOWKEY: case Phase.ShowKey:
titleClass = [ titleClass = [
'mx_CreateSecretStorageDialog_titleWithIcon', 'mx_CreateSecretStorageDialog_titleWithIcon',
'mx_CreateSecretStorageDialog_secureBackupTitle', 'mx_CreateSecretStorageDialog_secureBackupTitle',
]; ];
break; break;
case PHASE_CHOOSE_KEY_PASSPHRASE: case Phase.ChooseKeyPassphrase:
titleClass = 'mx_CreateSecretStorageDialog_centeredTitle'; titleClass = 'mx_CreateSecretStorageDialog_centeredTitle';
break; break;
} }
@ -854,9 +873,9 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
return ( return (
<BaseDialog className='mx_CreateSecretStorageDialog' <BaseDialog className='mx_CreateSecretStorageDialog'
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={this._titleForPhase(this.state.phase)} title={this.titleForPhase(this.state.phase)}
titleClass={titleClass} titleClass={titleClass}
hasCancel={this.props.hasCancel && [PHASE_PASSPHRASE].includes(this.state.phase)} hasCancel={this.props.hasCancel && [Phase.Passphrase].includes(this.state.phase)}
fixedWidth={false} fixedWidth={false}
> >
<div> <div>

View file

@ -16,46 +16,51 @@ limitations under the License.
import FileSaver from 'file-saver'; import FileSaver from 'file-saver';
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler';
import { MatrixClient } from 'matrix-js-sdk/src/client'; 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 { logger } from "matrix-js-sdk/src/logger";
import { _t } from '../../../../languageHandler'; enum Phase {
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; Edit = "edit",
import * as sdk from '../../../../index'; Exporting = "exporting",
}
const PHASE_EDIT = 1; interface IProps extends IDialogProps {
const PHASE_EXPORTING = 2; matrixClient: MatrixClient;
}
export default class ExportE2eKeysDialog extends React.Component { interface IState {
static propTypes = { phase: Phase;
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, errStr: string;
onFinished: PropTypes.func.isRequired, }
};
constructor(props) { export default class ExportE2eKeysDialog extends React.Component<IProps, IState> {
private unmounted = false;
private passphrase1 = createRef<HTMLInputElement>();
private passphrase2 = createRef<HTMLInputElement>();
constructor(props: IProps) {
super(props); super(props);
this._unmounted = false;
this._passphrase1 = createRef();
this._passphrase2 = createRef();
this.state = { this.state = {
phase: PHASE_EDIT, phase: Phase.Edit,
errStr: null, errStr: null,
}; };
} }
componentWillUnmount() { public componentWillUnmount(): void {
this._unmounted = true; this.unmounted = true;
} }
_onPassphraseFormSubmit = (ev) => { private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => {
ev.preventDefault(); ev.preventDefault();
const passphrase = this._passphrase1.current.value; const passphrase = this.passphrase1.current.value;
if (passphrase !== this._passphrase2.current.value) { if (passphrase !== this.passphrase2.current.value) {
this.setState({ errStr: _t('Passphrases must match') }); this.setState({ errStr: _t('Passphrases must match') });
return false; return false;
} }
@ -64,11 +69,11 @@ export default class ExportE2eKeysDialog extends React.Component {
return false; return false;
} }
this._startExport(passphrase); this.startExport(passphrase);
return false; return false;
}; };
_startExport(passphrase) { private startExport(passphrase: string): void {
// extra Promise.resolve() to turn synchronous exceptions into // extra Promise.resolve() to turn synchronous exceptions into
// asynchronous ones. // asynchronous ones.
Promise.resolve().then(() => { Promise.resolve().then(() => {
@ -85,39 +90,37 @@ export default class ExportE2eKeysDialog extends React.Component {
this.props.onFinished(true); this.props.onFinished(true);
}).catch((e) => { }).catch((e) => {
logger.error("Error exporting e2e keys:", e); logger.error("Error exporting e2e keys:", e);
if (this._unmounted) { if (this.unmounted) {
return; return;
} }
const msg = e.friendlyText || _t('Unknown error'); const msg = e.friendlyText || _t('Unknown error');
this.setState({ this.setState({
errStr: msg, errStr: msg,
phase: PHASE_EDIT, phase: Phase.Edit,
}); });
}); });
this.setState({ this.setState({
errStr: null, errStr: null,
phase: PHASE_EXPORTING, phase: Phase.Exporting,
}); });
} }
_onCancelClick = (ev) => { private onCancelClick = (ev: React.MouseEvent): boolean => {
ev.preventDefault(); ev.preventDefault();
this.props.onFinished(false); this.props.onFinished(false);
return false; return false;
}; };
render() { public render(): JSX.Element {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const disableForm = (this.state.phase === Phase.Exporting);
const disableForm = (this.state.phase === PHASE_EXPORTING);
return ( return (
<BaseDialog className='mx_exportE2eKeysDialog' <BaseDialog className='mx_exportE2eKeysDialog'
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t("Export room keys")} title={_t("Export room keys")}
> >
<form onSubmit={this._onPassphraseFormSubmit}> <form onSubmit={this.onPassphraseFormSubmit}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<p> <p>
{ _t( { _t(
@ -150,10 +153,10 @@ export default class ExportE2eKeysDialog extends React.Component {
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input <input
ref={this._passphrase1} ref={this.passphrase1}
id='passphrase1' id='passphrase1'
autoFocus={true} autoFocus={true}
size='64' size={64}
type='password' type='password'
disabled={disableForm} disabled={disableForm}
/> />
@ -166,9 +169,9 @@ export default class ExportE2eKeysDialog extends React.Component {
</label> </label>
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input ref={this._passphrase2} <input ref={this.passphrase2}
id='passphrase2' id='passphrase2'
size='64' size={64}
type='password' type='password'
disabled={disableForm} disabled={disableForm}
/> />
@ -183,7 +186,7 @@ export default class ExportE2eKeysDialog extends React.Component {
value={_t('Export')} value={_t('Export')}
disabled={disableForm} disabled={disableForm}
/> />
<button onClick={this._onCancelClick} disabled={disableForm}> <button onClick={this.onCancelClick} disabled={disableForm}>
{ _t("Cancel") } { _t("Cancel") }
</button> </button>
</div> </div>

View file

@ -15,19 +15,19 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk/src/client'; 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 { logger } from "matrix-js-sdk/src/logger";
import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
import * as sdk from '../../../../index';
import { _t } from '../../../../languageHandler';
function readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
resolve(e.target.result); resolve(e.target.result as ArrayBuffer);
}; };
reader.onerror = reject; reader.onerror = reject;
@ -35,51 +35,57 @@ function readFileAsArrayBuffer(file) {
}); });
} }
const PHASE_EDIT = 1; enum Phase {
const PHASE_IMPORTING = 2; Edit = "edit",
Importing = "importing",
}
export default class ImportE2eKeysDialog extends React.Component { interface IProps extends IDialogProps {
static propTypes = { matrixClient: MatrixClient;
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, }
onFinished: PropTypes.func.isRequired,
};
constructor(props) { interface IState {
enableSubmit: boolean;
phase: Phase;
errStr: string;
}
export default class ImportE2eKeysDialog extends React.Component<IProps, IState> {
private unmounted = false;
private file = createRef<HTMLInputElement>();
private passphrase = createRef<HTMLInputElement>();
constructor(props: IProps) {
super(props); super(props);
this._unmounted = false;
this._file = createRef();
this._passphrase = createRef();
this.state = { this.state = {
enableSubmit: false, enableSubmit: false,
phase: PHASE_EDIT, phase: Phase.Edit,
errStr: null, errStr: null,
}; };
} }
componentWillUnmount() { public componentWillUnmount(): void {
this._unmounted = true; this.unmounted = true;
} }
_onFormChange = (ev) => { private onFormChange = (ev: React.FormEvent): void => {
const files = this._file.current.files || []; const files = this.file.current.files || [];
this.setState({ 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(); 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; return false;
}; };
_startImport(file, passphrase) { private startImport(file: File, passphrase: string) {
this.setState({ this.setState({
errStr: null, errStr: null,
phase: PHASE_IMPORTING, phase: Phase.Importing,
}); });
return readFileAsArrayBuffer(file).then((arrayBuffer) => { return readFileAsArrayBuffer(file).then((arrayBuffer) => {
@ -93,34 +99,32 @@ export default class ImportE2eKeysDialog extends React.Component {
this.props.onFinished(true); this.props.onFinished(true);
}).catch((e) => { }).catch((e) => {
logger.error("Error importing e2e keys:", e); logger.error("Error importing e2e keys:", e);
if (this._unmounted) { if (this.unmounted) {
return; return;
} }
const msg = e.friendlyText || _t('Unknown error'); const msg = e.friendlyText || _t('Unknown error');
this.setState({ this.setState({
errStr: msg, errStr: msg,
phase: PHASE_EDIT, phase: Phase.Edit,
}); });
}); });
} }
_onCancelClick = (ev) => { private onCancelClick = (ev: React.MouseEvent): boolean => {
ev.preventDefault(); ev.preventDefault();
this.props.onFinished(false); this.props.onFinished(false);
return false; return false;
}; };
render() { public render(): JSX.Element {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const disableForm = (this.state.phase !== Phase.Edit);
const disableForm = (this.state.phase !== PHASE_EDIT);
return ( return (
<BaseDialog className='mx_importE2eKeysDialog' <BaseDialog className='mx_importE2eKeysDialog'
onFinished={this.props.onFinished} onFinished={this.props.onFinished}
title={_t("Import room keys")} title={_t("Import room keys")}
> >
<form onSubmit={this._onFormSubmit}> <form onSubmit={this.onFormSubmit}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<p> <p>
{ _t( { _t(
@ -148,11 +152,11 @@ export default class ImportE2eKeysDialog extends React.Component {
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input <input
ref={this._file} ref={this.file}
id='importFile' id='importFile'
type='file' type='file'
autoFocus={true} autoFocus={true}
onChange={this._onFormChange} onChange={this.onFormChange}
disabled={disableForm} /> disabled={disableForm} />
</div> </div>
</div> </div>
@ -164,11 +168,11 @@ export default class ImportE2eKeysDialog extends React.Component {
</div> </div>
<div className='mx_E2eKeysDialog_inputCell'> <div className='mx_E2eKeysDialog_inputCell'>
<input <input
ref={this._passphrase} ref={this.passphrase}
id='passphrase' id='passphrase'
size='64' size={64}
type='password' type='password'
onChange={this._onFormChange} onChange={this.onFormChange}
disabled={disableForm} /> disabled={disableForm} />
</div> </div>
</div> </div>
@ -181,7 +185,7 @@ export default class ImportE2eKeysDialog extends React.Component {
value={_t('Import')} value={_t('Import')}
disabled={!this.state.enableSubmit || disableForm} disabled={!this.state.enableSubmit || disableForm}
/> />
<button onClick={this._onCancelClick} disabled={disableForm}> <button onClick={this.onCancelClick} disabled={disableForm}>
{ _t("Cancel") } { _t("Cancel") }
</button> </button>
</div> </div>

View file

@ -16,44 +16,40 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import PropTypes from "prop-types";
import * as sdk from "../../../../index";
import { MatrixClientPeg } from '../../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../../MatrixClientPeg';
import dis from "../../../../dispatcher/dispatcher"; import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal"; import Modal from "../../../../Modal";
import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog";
import { Action } from "../../../../dispatcher/actions"; 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 { interface IProps extends IDialogProps {
static propTypes = { newVersionInfo: IKeyBackupInfo;
// As returned by js-sdk getKeyBackupVersion()
newVersionInfo: PropTypes.object,
onFinished: PropTypes.func.isRequired,
} }
onOkClick = () => { export default class NewRecoveryMethodDialog extends React.PureComponent<IProps> {
private onOkClick = (): void => {
this.props.onFinished(); this.props.onFinished();
} };
onGoToSettingsClick = () => { private onGoToSettingsClick = (): void => {
this.props.onFinished(); this.props.onFinished();
dis.fire(Action.ViewUserSettings); dis.fire(Action.ViewUserSettings);
} };
onSetupClick = async () => { private onSetupClick = async (): Promise<void> => {
Modal.createTrackedDialog( Modal.createTrackedDialog(
'Restore Backup', '', RestoreKeyBackupDialog, { 'Restore Backup', '', RestoreKeyBackupDialog, {
onFinished: this.props.onFinished, onFinished: this.props.onFinished,
}, null, /* priority = */ false, /* static = */ true, }, 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 = <span className="mx_KeyBackupFailedDialog_title"> const title = <span className="mx_KeyBackupFailedDialog_title">
{ _t("New Recovery Method") } { _t("New Recovery Method") }
</span>; </span>;

View file

@ -15,37 +15,32 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { ComponentType } from "react";
import PropTypes from "prop-types";
import * as sdk from "../../../../index";
import dis from "../../../../dispatcher/dispatcher"; import dis from "../../../../dispatcher/dispatcher";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal"; import Modal from "../../../../Modal";
import { Action } from "../../../../dispatcher/actions"; 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 { interface IProps extends IDialogProps {}
static propTypes = {
onFinished: PropTypes.func.isRequired,
}
onGoToSettingsClick = () => { export default class RecoveryMethodRemovedDialog extends React.PureComponent<IProps> {
private onGoToSettingsClick = (): void => {
this.props.onFinished(); this.props.onFinished();
dis.fire(Action.ViewUserSettings); dis.fire(Action.ViewUserSettings);
} };
onSetupClick = () => { private onSetupClick = (): void => {
this.props.onFinished(); this.props.onFinished();
Modal.createTrackedDialogAsync("Key Backup", "Key Backup", Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("./CreateKeyBackupDialog"), import("./CreateKeyBackupDialog") as unknown as Promise<ComponentType<{}>>,
null, null, /* priority = */ false, /* static = */ true, 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 = <span className="mx_KeyBackupFailedDialog_title"> const title = <span className="mx_KeyBackupFailedDialog_title">
{ _t("Recovery Method Removed") } { _t("Recovery Method Removed") }
</span>; </span>;

View file

@ -249,6 +249,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
let handled = true; let handled = true;
switch (ev.key) { 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.TAB:
case Key.ESCAPE: case Key.ESCAPE:
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar /> case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />

View file

@ -40,6 +40,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore"; import UIStore from "../../stores/UIStore";
import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -51,19 +52,12 @@ interface IState {
activeSpace?: Room; 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 <RoomSearch />
"mx_RoomSublist_headerText",
"mx_RoomTile",
"mx_RoomSublist_showNButton",
];
@replaceableComponent("structures.LeftPanel") @replaceableComponent("structures.LeftPanel")
export default class LeftPanel extends React.Component<IProps, IState> { export default class LeftPanel extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref = createRef<HTMLDivElement>();
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef = createRef<HTMLDivElement>();
private roomSearchRef = createRef<RoomSearch>();
private roomListRef = createRef<RoomList>();
private focusedElement = null; private focusedElement = null;
private isDoingStickyHeaders = false; private isDoingStickyHeaders = false;
@ -283,16 +277,25 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.focusedElement = null; this.focusedElement = null;
}; };
private onKeyDown = (ev: React.KeyboardEvent) => { private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState) => {
if (!this.focusedElement) return; if (!this.focusedElement) return;
const action = getKeyBindingsManager().getRoomListAction(ev); const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) { switch (action) {
case RoomListAction.NextRoom: case RoomListAction.NextRoom:
case RoomListAction.PrevRoom: if (!state) {
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
this.onMoveFocus(action === RoomListAction.PrevRoom); this.roomListRef.current?.focus();
}
break;
case RoomListAction.PrevRoom:
if (state && state.activeRef === findSiblingElement(state.refs, 0)) {
ev.stopPropagation();
ev.preventDefault();
this.roomSearchRef.current?.focus();
}
break; break;
} }
}; };
@ -305,45 +308,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
} }
}; };
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 { private renderHeader(): React.ReactNode {
return ( return (
<div className="mx_LeftPanel_userHeader"> <div className="mx_LeftPanel_userHeader">
@ -388,7 +352,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
> >
<RoomSearch <RoomSearch
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onKeyDown={this.onKeyDown} ref={this.roomSearchRef}
onSelectRoom={this.selectRoom} onSelectRoom={this.selectRoom}
/> />
@ -417,6 +381,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
activeSpace={this.state.activeSpace} activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders} onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders} onListCollapse={this.refreshStickyHeaders}
ref={this.roomListRef}
/>; />;
const containerClasses = classNames({ const containerClasses = classNames({

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { ComponentType, createRef } from 'react';
import { createClient } from "matrix-js-sdk/src/matrix"; import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
@ -1597,12 +1597,16 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (haveNewVersion) { if (haveNewVersion) {
Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method', 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<ComponentType<{}>>,
{ newVersionInfo }, { newVersionInfo },
); );
} else { } else {
Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed', 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<ComponentType<{}>>,
); );
} }
}); });

View file

@ -196,6 +196,7 @@ interface IReadReceiptForUser {
@replaceableComponent("structures.MessagePanel") @replaceableComponent("structures.MessagePanel")
export default class MessagePanel extends React.Component<IProps, IState> { export default class MessagePanel extends React.Component<IProps, IState> {
static contextType = RoomContext; static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
// opaque readreceipt info for each userId; used by ReadReceiptMarker // opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations // to manage its animations
@ -787,6 +788,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
showReadReceipts={this.props.showReadReceipts} showReadReceipts={this.props.showReadReceipts}
callEventGrouper={callEventGrouper} callEventGrouper={callEventGrouper}
hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble} hideSender={this.membersCount <= 2 && this.props.layout === Layout.Bubble}
timelineRenderingType={this.context.timelineRenderingType}
/> />
</TileErrorBoundary>, </TileErrorBoundary>,
); );

View file

@ -32,7 +32,6 @@ import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../.
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
onKeyDown(ev: React.KeyboardEvent): void;
/** /**
* @returns true if a room has been selected and the search field should be cleared * @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<IProps, IState> {
this.clearInput(); this.clearInput();
defaultDispatcher.fire(Action.FocusSendMessageComposer); defaultDispatcher.fire(Action.FocusSendMessageComposer);
break; 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: { case RoomListAction.SelectRoom: {
const shouldClear = this.props.onSelectRoom(); const shouldClear = this.props.onSelectRoom();
if (shouldClear) { if (shouldClear) {
@ -151,6 +145,10 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
} }
}; };
public focus(): void {
this.inputRef.current?.focus();
}
public render(): React.ReactNode { public render(): React.ReactNode {
const classes = classNames({ const classes = classNames({
'mx_RoomSearch': true, 'mx_RoomSearch': true,

View file

@ -93,6 +93,7 @@ import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import { dispatchShowThreadEvent } from '../../dispatcher/dispatch-actions/threads'; import { dispatchShowThreadEvent } from '../../dispatcher/dispatch-actions/threads';
import { fetchInitialEvent } from "../../utils/EventUtils"; import { fetchInitialEvent } from "../../utils/EventUtils";
import { ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload";
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -863,10 +864,11 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
} }
case Action.ComposerInsert: { case Action.ComposerInsert: {
if (payload.composerType) break;
// re-dispatch to the correct composer // re-dispatch to the correct composer
dis.dispatch({ dis.dispatch({
...payload, ...payload,
action: this.state.editState ? "edit_composer_insert" : "send_composer_insert", composerType: this.state.editState ? ComposerType.Edit : ComposerType.Send,
}); });
break; break;
} }

View file

@ -60,18 +60,15 @@ import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../hooks/useEventEmitter";
import { IOOBData } from "../../stores/ThreepidInviteStore"; import { IOOBData } from "../../stores/ThreepidInviteStore";
import { awaitRoomDownSync } from "../../utils/RoomUpgrade";
import RoomViewStore from "../../stores/RoomViewStore";
interface IProps { interface IProps {
space: Room; space: Room;
initialText?: string; initialText?: string;
additionalButtons?: ReactNode; additionalButtons?: ReactNode;
showRoom( showRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void;
cli: MatrixClient, joinRoom(cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string): void;
hierarchy: RoomHierarchy,
roomId: string,
autoJoin?: boolean,
roomType?: RoomType,
): void;
} }
interface ITileProps { interface ITileProps {
@ -80,7 +77,8 @@ interface ITileProps {
selected?: boolean; selected?: boolean;
numChildRooms?: number; numChildRooms?: number;
hasPermissions?: boolean; hasPermissions?: boolean;
onViewRoomClick(autoJoin: boolean, roomType: RoomType): void; onViewRoomClick(): void;
onJoinRoomClick(): void;
onToggleClick?(): void; onToggleClick?(): void;
} }
@ -91,31 +89,50 @@ const Tile: React.FC<ITileProps> = ({
hasPermissions, hasPermissions,
onToggleClick, onToggleClick,
onViewRoomClick, onViewRoomClick,
onJoinRoomClick,
numChildRooms, numChildRooms,
children, children,
}) => { }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; const [joinedRoom, setJoinedRoom] = useState<Room>(() => {
const cliRoom = cli.getRoom(room.room_id);
return cliRoom?.getMyMembership() === "join" ? cliRoom : null;
});
const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name); const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name);
const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0] const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true); const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex(); const [onFocus, isActive, ref] = useRovingTabIndex();
const [busy, setBusy] = useState(false);
const onPreviewClick = (ev: ButtonEvent) => { const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
onViewRoomClick(false, room.room_type as RoomType); onViewRoomClick();
}; };
const onJoinClick = (ev: ButtonEvent) => { const onJoinClick = async (ev: ButtonEvent) => {
setBusy(true);
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
onViewRoomClick(true, room.room_type as RoomType); onJoinRoomClick();
setJoinedRoom(await awaitRoomDownSync(cli, room.room_id));
setBusy(false);
}; };
let button; let button;
if (joinedRoom) { if (busy) {
button = <AccessibleTooltipButton
disabled={true}
onClick={onJoinClick}
kind="primary_outline"
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
title={_t("Joining")}
>
<Spinner w={24} h={24} />
</AccessibleTooltipButton>;
} else if (joinedRoom) {
button = <AccessibleButton button = <AccessibleButton
onClick={onPreviewClick} onClick={onPreviewClick}
kind="primary_outline" kind="primary_outline"
@ -172,8 +189,15 @@ const Tile: React.FC<ITileProps> = ({
description += " · " + topic; description += " · " + topic;
} }
let joinedSection;
if (joinedRoom) {
joinedSection = <div className="mx_SpaceHierarchy_roomTile_joined">
{ _t("Joined") }
</div>;
}
let suggestedSection; let suggestedSection;
if (suggested) { if (suggested && (!joinedRoom || hasPermissions)) {
suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}> suggestedSection = <InfoTooltip tooltip={_t("This room is suggested as a good one to join")}>
{ _t("Suggested") } { _t("Suggested") }
</InfoTooltip>; </InfoTooltip>;
@ -183,6 +207,7 @@ const Tile: React.FC<ITileProps> = ({
{ avatar } { avatar }
<div className="mx_SpaceHierarchy_roomTile_name"> <div className="mx_SpaceHierarchy_roomTile_name">
{ name } { name }
{ joinedSection }
{ suggestedSection } { suggestedSection }
</div> </div>
@ -274,6 +299,7 @@ const Tile: React.FC<ITileProps> = ({
<AccessibleButton <AccessibleButton
className={classNames("mx_SpaceHierarchy_roomTile", { className={classNames("mx_SpaceHierarchy_roomTile", {
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space, mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
mx_SpaceHierarchy_joining: busy,
})} })}
onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick} onClick={(hasPermissions && onToggleClick) ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -288,13 +314,7 @@ const Tile: React.FC<ITileProps> = ({
</li>; </li>;
}; };
export const showRoom = ( export const showRoom = (cli: MatrixClient, hierarchy: RoomHierarchy, roomId: string, roomType?: RoomType): void => {
cli: MatrixClient,
hierarchy: RoomHierarchy,
roomId: string,
autoJoin = false,
roomType?: RoomType,
) => {
const room = hierarchy.roomMap.get(roomId); const room = hierarchy.roomMap.get(roomId);
// Don't let the user view a room they won't be able to either peek or join: // 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; const roomAlias = getDisplayAliasForRoom(room) || undefined;
dis.dispatch({ dis.dispatch({
action: "view_room", action: "view_room",
auto_join: autoJoin,
should_peek: true, should_peek: true,
_type: "room_directory", // instrumentation _type: "room_directory", // instrumentation
room_alias: roomAlias, 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 { interface IHierarchyLevelProps {
root: IHierarchyRoom; root: IHierarchyRoom;
roomSet: Set<IHierarchyRoom>; roomSet: Set<IHierarchyRoom>;
hierarchy: RoomHierarchy; hierarchy: RoomHierarchy;
parents: Set<string>; parents: Set<string>;
selectedMap?: Map<string, Set<string>>; selectedMap?: Map<string, Set<string>>;
onViewRoomClick(roomId: string, autoJoin: boolean, roomType?: RoomType): void; onViewRoomClick(roomId: string, roomType?: RoomType): void;
onJoinRoomClick(roomId: string): void;
onToggleClick?(parentId: string, childId: string): void; onToggleClick?(parentId: string, childId: string): void;
} }
@ -365,6 +400,7 @@ export const HierarchyLevel = ({
parents, parents,
selectedMap, selectedMap,
onViewRoomClick, onViewRoomClick,
onJoinRoomClick,
onToggleClick, onToggleClick,
}: IHierarchyLevelProps) => { }: IHierarchyLevelProps) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
@ -392,9 +428,8 @@ export const HierarchyLevel = ({
room={room} room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)} suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)} selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={(autoJoin, roomType) => { onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)}
onViewRoomClick(room.room_id, autoJoin, roomType); onJoinRoomClick={() => onJoinRoomClick(room.room_id)}
}}
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/> />
@ -412,9 +447,8 @@ export const HierarchyLevel = ({
}).length} }).length}
suggested={hierarchy.isSuggested(root.room_id, space.room_id)} suggested={hierarchy.isSuggested(root.room_id, space.room_id)}
selected={selectedMap?.get(root.room_id)?.has(space.room_id)} selected={selectedMap?.get(root.room_id)?.has(space.room_id)}
onViewRoomClick={(autoJoin, roomType) => { onViewRoomClick={() => onViewRoomClick(space.room_id, RoomType.Space)}
onViewRoomClick(space.room_id, autoJoin, roomType); onJoinRoomClick={() => onJoinRoomClick(space.room_id)}
}}
hasPermissions={hasPermissions} hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined} onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, space.room_id) : undefined}
> >
@ -425,6 +459,7 @@ export const HierarchyLevel = ({
parents={newParents} parents={newParents}
selectedMap={selectedMap} selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick} onViewRoomClick={onViewRoomClick}
onJoinRoomClick={onJoinRoomClick}
onToggleClick={onToggleClick} onToggleClick={onToggleClick}
/> />
</Tile> </Tile>
@ -537,9 +572,19 @@ const ManageButtons = ({ hierarchy, selected, setSelected, setError }: IManageBu
onClick={async () => { onClick={async () => {
setRemoving(true); setRemoving(true);
try { try {
const userId = cli.getUserId();
for (const [parentId, childId] of selectedRelations) { for (const [parentId, childId] of selectedRelations) {
await cli.sendStateEvent(parentId, EventType.SpaceChild, {}, childId); 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); hierarchy.removeRelation(parentId, childId);
} }
} catch (e) { } catch (e) {
@ -678,9 +723,8 @@ const SpaceHierarchy = ({
parents={new Set()} parents={new Set()}
selectedMap={selected} selectedMap={selected}
onToggleClick={hasPermissions ? onToggleClick : undefined} onToggleClick={hasPermissions ? onToggleClick : undefined}
onViewRoomClick={(roomId, autoJoin, roomType) => { onViewRoomClick={(roomId, roomType) => showRoom(cli, hierarchy, roomId, roomType)}
showRoom(cli, hierarchy, roomId, autoJoin, roomType); onJoinRoomClick={(roomId) => joinRoom(cli, hierarchy, roomId)}
}}
/> />
</>; </>;
} else if (!hierarchy.canLoadMore) { } else if (!hierarchy.canLoadMore) {

View file

@ -56,7 +56,7 @@ import {
showSpaceInvite, showSpaceInvite,
showSpaceSettings, showSpaceSettings,
} from "../../utils/space"; } from "../../utils/space";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import SpaceHierarchy, { joinRoom, showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar"; import MemberAvatar from "../views/avatars/MemberAvatar";
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile"; import FacePile from "../views/elements/FacePile";
@ -507,7 +507,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
) } ) }
</RoomTopic> </RoomTopic>
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} /> <SpaceHierarchy space={space} showRoom={showRoom} joinRoom={joinRoom} additionalButtons={addRoomButton} />
</div>; </div>;
}; };
@ -667,10 +667,6 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
<h3>{ _t("Me and my teammates") }</h3> <h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div> <div>{ _t("A private space for you and your teammates") }</div>
</AccessibleButton> </AccessibleButton>
<div className="mx_SpaceRoomView_betaWarning">
<h3>{ _t("Teammates might not be able to view or join any private rooms you make.") }</h3>
<p>{ _t("We're working on this, but just want to let you know.") }</p>
</div>
</div>; </div>;
}; };

View file

@ -17,23 +17,22 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import { logger } from "matrix-js-sdk/src/logger";
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import PasswordReset from "../../../PasswordReset"; import PasswordReset from "../../../PasswordReset";
import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import classNames from 'classnames';
import AuthPage from "../../views/auth/AuthPage"; import AuthPage from "../../views/auth/AuthPage";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import ServerPicker from "../../views/elements/ServerPicker"; import ServerPicker from "../../views/elements/ServerPicker";
import EmailField from "../../views/auth/EmailField";
import PassphraseField from '../../views/auth/PassphraseField'; import PassphraseField from '../../views/auth/PassphraseField';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm';
import withValidation, { IValidationResult } from "../../views/elements/Validation"; import { IValidationResult } from "../../views/elements/Validation";
import * as Email from "../../../email";
import InlineSpinner from '../../views/elements/InlineSpinner'; import InlineSpinner from '../../views/elements/InlineSpinner';
import { logger } from "matrix-js-sdk/src/logger";
enum Phase { enum Phase {
// Show the forgot password inputs // Show the forgot password inputs
@ -227,30 +226,10 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
}); });
} }
private validateEmailRules = withValidation({ private onEmailValidate = (result: IValidationResult) => {
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter email address"),
}, {
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
private onEmailValidate = async (fieldState) => {
const result = await this.validateEmailRules(fieldState);
this.setState({ this.setState({
emailFieldValid: result.valid, emailFieldValid: result.valid,
}); });
return result;
}; };
private onPasswordValidate(result: IValidationResult) { private onPasswordValidate(result: IValidationResult) {
@ -302,14 +281,12 @@ export default class ForgotPassword extends React.Component<IProps, IState> {
/> />
<form onSubmit={this.onSubmitForm}> <form onSubmit={this.onSubmitForm}>
<div className="mx_AuthBody_fieldRow"> <div className="mx_AuthBody_fieldRow">
<Field <EmailField
name="reset_email" // define a name so browser's password autofill gets less confused name="reset_email" // define a name so browser's password autofill gets less confused
type="text"
label={_t('Email')}
value={this.state.email} value={this.state.email}
fieldRef={field => this['email_field'] = field}
autoFocus={true}
onChange={this.onInputChanged.bind(this, "email")} onChange={this.onInputChanged.bind(this, "email")}
ref={field => this['email_field'] = field}
autoFocus
onValidate={this.onEmailValidate} onValidate={this.onEmailValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")} onFocus={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")} onBlur={() => CountlyAnalytics.instance.track("onboarding_forgot_password_email_blur")}

View file

@ -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<IInputProps, "onValidate"> {
id?: string;
fieldRef?: RefCallback<Field> | RefObject<Field>;
value: string;
autoFocus?: boolean;
label?: string;
labelRequired?: string;
labelInvalid?: string;
// When present, completely overrides the default validation rules.
validationRules?: (fieldState: IFieldState) => Promise<IValidationResult>;
onChange(ev: React.FormEvent<HTMLElement>): void;
onValidate?(result: IValidationResult): void;
}
@replaceableComponent("views.auth.EmailField")
class EmailField extends PureComponent<IProps> {
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 <Field
id={this.props.id}
ref={this.props.fieldRef}
type="text"
label={_t(this.props.label)}
value={this.props.value}
autoFocus={this.props.autoFocus}
onChange={this.props.onChange}
onValidate={this.onValidate}
/>;
}
}
export default EmailField;

View file

@ -22,11 +22,11 @@ import SdkConfig from '../../../SdkConfig';
import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation"; import withValidation, { IValidationResult } from "../elements/Validation";
import * as Email from "../../../email";
import Field from "../elements/Field"; import Field from "../elements/Field";
import CountryDropdown from "./CountryDropdown"; import CountryDropdown from "./CountryDropdown";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import EmailField from "./EmailField";
// For validating phone numbers without country codes // For validating phone numbers without country codes
const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/;
@ -262,26 +262,8 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
return result; return result;
}; };
private validateEmailRules = withValidation({ private onEmailValidate = (result: IValidationResult) => {
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => _t("Enter email address"),
}, {
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
private onEmailValidate = async (fieldState) => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(LoginField.Email, result.valid); this.markFieldValid(LoginField.Email, result.valid);
return result;
}; };
private validatePhoneNumberRules = withValidation({ private validatePhoneNumberRules = withValidation({
@ -332,12 +314,10 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
switch (loginType) { switch (loginType) {
case LoginField.Email: case LoginField.Email:
classes.error = this.props.loginIncorrect && !this.props.username; classes.error = this.props.loginIncorrect && !this.props.username;
return <Field return <EmailField
className={classNames(classes)} className={classNames(classes)}
name="username" // make it a little easier for browser's remember-password name="username" // make it a little easier for browser's remember-password
key="email_input" key="email_input"
type="text"
label={_t("Email")}
placeholder="joe@example.com" placeholder="joe@example.com"
value={this.props.username} value={this.props.username}
onChange={this.onUsernameChanged} onChange={this.onUsernameChanged}
@ -346,7 +326,7 @@ export default class PasswordLogin extends React.PureComponent<IProps, IState> {
disabled={this.props.disableSubmit} disabled={this.props.disableSubmit}
autoFocus={autoFocus} autoFocus={autoFocus}
onValidate={this.onEmailValidate} onValidate={this.onEmailValidate}
ref={field => this[LoginField.Email] = field} fieldRef={field => this[LoginField.Email] = field}
/>; />;
case LoginField.MatrixId: case LoginField.MatrixId:
classes.error = this.props.loginIncorrect && !this.props.username; classes.error = this.props.loginIncorrect && !this.props.username;

View file

@ -24,8 +24,9 @@ import Modal from '../../../Modal';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import { SAFE_LOCALPART_REGEX } from '../../../Registration'; import { SAFE_LOCALPART_REGEX } from '../../../Registration';
import withValidation from '../elements/Validation'; import withValidation, { IValidationResult } from '../elements/Validation';
import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils";
import EmailField from "./EmailField";
import PassphraseField from "./PassphraseField"; import PassphraseField from "./PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import Field from '../elements/Field'; import Field from '../elements/Field';
@ -252,10 +253,8 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
}); });
}; };
private onEmailValidate = async fieldState => { private onEmailValidate = (result: IValidationResult) => {
const result = await this.validateEmailRules(fieldState);
this.markFieldValid(RegistrationField.Email, result.valid); this.markFieldValid(RegistrationField.Email, result.valid);
return result;
}; };
private validateEmailRules = withValidation({ private validateEmailRules = withValidation({
@ -425,14 +424,14 @@ export default class RegistrationForm extends React.PureComponent<IProps, IState
if (!this.showEmail()) { if (!this.showEmail()) {
return null; return null;
} }
const emailPlaceholder = this.authStepIsRequired('m.login.email.identity') ? const emailLabel = this.authStepIsRequired('m.login.email.identity') ?
_t("Email") : _t("Email") :
_t("Email (optional)"); _t("Email (optional)");
return <Field return <EmailField
ref={field => this[RegistrationField.Email] = field} fieldRef={field => this[RegistrationField.Email] = field}
type="text" label={emailLabel}
label={emailPlaceholder}
value={this.state.email} value={this.state.email}
validationRules={this.validateEmailRules.bind(this)}
onChange={this.onEmailChange} onChange={this.onEmailChange}
onValidate={this.onEmailValidate} onValidate={this.onEmailValidate}
onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")} onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email_focus")}

View file

@ -17,6 +17,7 @@ limitations under the License.
import React, { ComponentProps } from 'react'; import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials'; import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import classNames from "classnames"; import classNames from "classnames";
import BaseAvatar from './BaseAvatar'; import BaseAvatar from './BaseAvatar';
@ -83,8 +84,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}; };
} }
// TODO: type when js-sdk has types private onRoomStateEvents = (ev: MatrixEvent) => {
private onRoomStateEvents = (ev: any) => {
if (!this.props.room || if (!this.props.room ||
ev.getRoomId() !== this.props.room.roomId || ev.getRoomId() !== this.props.room.roomId ||
ev.getType() !== 'm.room.avatar' ev.getType() !== 'm.room.avatar'

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ComponentType } from 'react';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@ -85,7 +85,9 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
private onExportE2eKeysClicked = (): void => { private onExportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Export E2E Keys', '', Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), import(
'../../../async-components/views/dialogs/security/ExportE2eKeysDialog'
) as unknown as Promise<ComponentType<{}>>,
{ {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
}, },
@ -111,7 +113,9 @@ export default class LogoutDialog extends React.Component<IProps, IState> {
); );
} else { } else {
Modal.createTrackedDialogAsync("Key Backup", "Key Backup", Modal.createTrackedDialogAsync("Key Backup", "Key Backup",
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog"), import(
"../../../async-components/views/dialogs/security/CreateKeyBackupDialog"
) as unknown as Promise<ComponentType<{}>>,
null, null, /* priority = */ false, /* static = */ true, null, null, /* priority = */ false, /* static = */ true,
); );
} }

View file

@ -21,25 +21,14 @@ import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps"; import { IDialogProps } from "./IDialogProps";
import Field from "../elements/Field"; import Field from "../elements/Field";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons"; import DialogButtons from "../elements/DialogButtons";
import EmailField from "../auth/EmailField";
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
onFinished(continued: boolean, email?: string): void; 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<IProps> = ({ onFinished }) => { const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const fieldRef = useRef<Field>(); const fieldRef = useRef<Field>();
@ -47,11 +36,11 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
const onSubmit = async (e) => { const onSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
if (email) { if (email) {
const valid = await fieldRef.current.validate({ allowEmpty: false }); const valid = await fieldRef.current.validate({});
if (!valid) { if (!valid) {
fieldRef.current.focus(); fieldRef.current.focus();
fieldRef.current.validate({ allowEmpty: false, focused: true }); fieldRef.current.validate({ focused: true });
return; return;
} }
} }
@ -72,16 +61,15 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
b: sub => <b>{ sub }</b>, b: sub => <b>{ sub }</b>,
}) }</p> }) }</p>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<Field <EmailField
ref={fieldRef} fieldRef={fieldRef}
autoFocus={true} autoFocus={true}
type="text"
label={_t("Email (optional)")} label={_t("Email (optional)")}
value={email} value={email}
onChange={ev => { onChange={ev => {
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")} onFocus={() => CountlyAnalytics.instance.track("onboarding_registration_email2_focus")}
onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email2_blur")} onBlur={() => CountlyAnalytics.instance.track("onboarding_registration_email2_blur")}
/> />

View file

@ -44,6 +44,7 @@ import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog";
import EditMessageComposer from '../rooms/EditMessageComposer'; import EditMessageComposer from '../rooms/EditMessageComposer';
import LinkPreviewGroup from '../rooms/LinkPreviewGroup'; import LinkPreviewGroup from '../rooms/LinkPreviewGroup';
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext";
const MAX_HIGHLIGHT_LENGTH = 4096; const MAX_HIGHLIGHT_LENGTH = 4096;
@ -62,6 +63,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
private unmounted = false; private unmounted = false;
private pills: Element[] = []; private pills: Element[] = [];
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
constructor(props) { constructor(props) {
super(props); super(props);
@ -406,6 +410,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert, action: Action.ComposerInsert,
userId: mxEvent.getSender(), userId: mxEvent.getSender(),
timelineRenderingType: this.context.timelineRenderingType,
}); });
}; };

View file

@ -75,6 +75,7 @@ import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialo
import { bulkSpaceBehaviour } from "../../../utils/space"; import { bulkSpaceBehaviour } from "../../../utils/space";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
import { TimelineRenderingType } from "../../../contexts/RoomContext";
export interface IDevice { export interface IDevice {
deviceId: string; deviceId: string;
@ -377,6 +378,7 @@ const UserOptionsSection: React.FC<{
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert, action: Action.ComposerInsert,
userId: member.userId, userId: member.userId,
timelineRenderingType: TimelineRenderingType.Room,
}); });
}; };

View file

@ -29,6 +29,7 @@ import {
formatRangeAsCode, formatRangeAsCode,
toggleInlineFormat, toggleInlineFormat,
replaceRangeAndMoveCaret, replaceRangeAndMoveCaret,
formatRangeAsLink,
} from '../../../editor/operations'; } from '../../../editor/operations';
import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom'; import { getCaretOffsetAndText, getRangeForSelection } from '../../../editor/dom';
import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete'; import Autocomplete, { generateCompletionDomId } from '../rooms/Autocomplete';
@ -476,6 +477,8 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
switch (autocompleteAction) { switch (autocompleteAction) {
case AutocompleteAction.ForceComplete: case AutocompleteAction.ForceComplete:
case AutocompleteAction.Complete: case AutocompleteAction.Complete:
this.historyManager.ensureLastChangesPushed(this.props.model);
this.modifiedFlag = true;
autoComplete.confirmCompletion(); autoComplete.confirmCompletion();
handled = true; handled = true;
break; break;
@ -705,6 +708,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
case Formatting.Quote: case Formatting.Quote:
formatRangeAsQuote(range); formatRangeAsQuote(range);
break; break;
case Formatting.InsertLink:
formatRangeAsLink(range);
break;
} }
}; };

View file

@ -46,6 +46,7 @@ import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext'; import { withMatrixClientHOC, MatrixClientProps } from '../../../contexts/MatrixClientContext';
import RoomContext from '../../../contexts/RoomContext'; import RoomContext from '../../../contexts/RoomContext';
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
function getHtmlReplyFallback(mxEvent: MatrixEvent): string { function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
const html = mxEvent.getContent().formatted_body; const html = mxEvent.getContent().formatted_body;
@ -498,7 +499,12 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
}; };
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
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) { if (payload.userId) {
this.editorRef.current?.insertMention(payload.userId); this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) { } else if (payload.event) {
@ -506,7 +512,7 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
} else if (payload.text) { } else if (payload.text) {
this.editorRef.current?.insertPlaintext(payload.text); this.editorRef.current?.insertPlaintext(payload.text);
} }
} else if (payload.action === Action.FocusEditMessageComposer && this.editorRef.current) { } else if (payload.action === Action.FocusEditMessageComposer) {
this.editorRef.current.focus(); this.editorRef.current.focus();
} }
}; };

View file

@ -22,7 +22,6 @@ import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations"; import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread'; import { Thread, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { logger } from "matrix-js-sdk/src/logger";
import ReplyChain from "../elements/ReplyChain"; import ReplyChain from "../elements/ReplyChain";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -62,6 +61,9 @@ import MKeyVerificationConclusion from "../messages/MKeyVerificationConclusion";
import { dispatchShowThreadEvent } from '../../../dispatcher/dispatch-actions/threads'; import { dispatchShowThreadEvent } from '../../../dispatcher/dispatch-actions/threads';
import { MessagePreviewStore } from '../../../stores/room-list/MessagePreviewStore'; import { MessagePreviewStore } from '../../../stores/room-list/MessagePreviewStore';
import { logger } from "matrix-js-sdk/src/logger";
import { TimelineRenderingType } from "../../../contexts/RoomContext";
const eventTileTypes = { const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent', [EventType.RoomMessage]: 'messages.MessageEvent',
[EventType.Sticker]: 'messages.MessageEvent', [EventType.Sticker]: 'messages.MessageEvent',
@ -312,6 +314,8 @@ interface IProps {
// whether or not to display thread info // whether or not to display thread info
showThreadInfo?: boolean; showThreadInfo?: boolean;
timelineRenderingType?: TimelineRenderingType;
} }
interface IState { interface IState {
@ -854,10 +858,11 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
onSenderProfileClick = () => { onSenderProfileClick = () => {
const mxEvent = this.props.mxEvent; if (!this.props.timelineRenderingType) return;
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert, 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<IProps, IState> {
} }
if (needsSenderProfile && this.props.hideSender !== true) { if (needsSenderProfile && this.props.hideSender !== true) {
if (!this.props.tileShape) { if (!this.props.tileShape || this.props.tileShape === TileShape.Thread) {
sender = <SenderProfile onClick={this.onSenderProfileClick} sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}

View file

@ -253,7 +253,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
private instanceId: number; private instanceId: number;
public static contextType = RoomContext; static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
static defaultProps = { static defaultProps = {
compact: false, compact: false,
@ -399,13 +400,14 @@ export default class MessageComposer extends React.Component<IProps, IState> {
} }
}; };
private addEmoji(emoji: string): boolean { private addEmoji = (emoji: string): boolean => {
dis.dispatch<ComposerInsertPayload>({ dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert, action: Action.ComposerInsert,
text: emoji, text: emoji,
timelineRenderingType: this.context.timelineRenderingType,
}); });
return true; return true;
} };
private sendMessage = async () => { private sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton.current) { if (this.state.haveRecording && this.voiceRecordingButton.current) {

View file

@ -27,6 +27,7 @@ export enum Formatting {
Strikethrough = "strikethrough", Strikethrough = "strikethrough",
Code = "code", Code = "code",
Quote = "quote", Quote = "quote",
InsertLink = "insert_link",
} }
interface IProps { interface IProps {
@ -57,6 +58,7 @@ export default class MessageComposerFormatBar extends React.PureComponent<IProps
<FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} /> <FormatButton label={_t("Strikethrough")} onClick={() => this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} />
<FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} /> <FormatButton label={_t("Code block")} onClick={() => this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} />
<FormatButton label={_t("Quote")} onClick={() => this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} /> <FormatButton label={_t("Quote")} onClick={() => this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
<FormatButton label={_t("Insert link")} onClick={() => this.props.onAction(Formatting.InsertLink)} icon="InsertLink" visible={this.state.visible} />
</div>); </div>);
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { ReactComponentElement } from "react"; import React, { createRef, ReactComponentElement } from "react";
import { Dispatcher } from "flux"; import { Dispatcher } from "flux";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import * as fbEmitter from "fbemitter"; 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 { JoinRule } from "matrix-js-sdk/src/@types/partials";
import { _t, _td } from "../../../languageHandler"; import { _t, _td } from "../../../languageHandler";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexProvider, IState as IRovingTabIndexState } from "../../../accessibility/RovingTabIndex";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import RoomViewStore from "../../../stores/RoomViewStore"; import RoomViewStore from "../../../stores/RoomViewStore";
@ -54,7 +54,7 @@ import { shouldShowComponent } from "../../../customisations/helpers/UIComponent
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
interface IProps { interface IProps {
onKeyDown: (ev: React.KeyboardEvent) => void; onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
onFocus: (ev: React.FocusEvent) => void; onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void; onBlur: (ev: React.FocusEvent) => void;
onResize: () => void; onResize: () => void;
@ -249,6 +249,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef; private dispatcherRef;
private customTagStoreRef; private customTagStoreRef;
private roomStoreToken: fbEmitter.EventSubscription; private roomStoreToken: fbEmitter.EventSubscription;
private treeRef = createRef<HTMLDivElement>();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -505,6 +506,12 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
}); });
} }
public focus(): void {
// focus the first focusable element in this aria treeview widget
[...this.treeRef.current?.querySelectorAll<HTMLElement>('[role="treeitem"]')]
.find(e => e.offsetParent !== null)?.focus();
}
public render() { public render() {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const userId = cli.getUserId(); const userId = cli.getUserId();
@ -584,7 +591,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const sublists = this.renderSublists(); const sublists = this.renderSublists();
return ( return (
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}> <RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.props.onKeyDown}>
{ ({ onKeyDownHandler }) => ( { ({ onKeyDownHandler }) => (
<div <div
onFocus={this.props.onFocus} onFocus={this.props.onFocus}
@ -593,6 +600,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
className="mx_RoomList" className="mx_RoomList"
role="tree" role="tree"
aria-label={_t("Rooms")} aria-label={_t("Rooms")}
ref={this.treeRef}
> >
{ sublists } { sublists }
{ explorePrompt } { explorePrompt }

View file

@ -58,6 +58,7 @@ import { ActionPayload } from "../../../dispatcher/payloads";
import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics"; import { decorateStartSendingTime, sendRoundTripMetric } from "../../../sendTimePerformanceMetrics";
import RoomContext from '../../../contexts/RoomContext'; import RoomContext from '../../../contexts/RoomContext';
import DocumentPosition from "../../../editor/position"; import DocumentPosition from "../../../editor/position";
import { ComposerType } from "../../../dispatcher/payloads/ComposerInsertPayload";
function addReplyToMessageContent( function addReplyToMessageContent(
content: IContent, content: IContent,
@ -591,7 +592,10 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
this.editorRef.current?.focus(); this.editorRef.current?.focus();
} }
break; break;
case "send_composer_insert": case Action.ComposerInsert:
if (payload.timelineRenderingType !== this.context.timelineRenderingType) break;
if (payload.composerType !== ComposerType.Send) break;
if (payload.userId) { if (payload.userId) {
this.editorRef.current?.insertMention(payload.userId); this.editorRef.current?.insertMention(payload.userId);
} else if (payload.event) { } else if (payload.event) {

View file

@ -15,10 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react';
import { MatrixClient } from "matrix-js-sdk/src/client";
import Field from "../elements/Field"; import Field from "../elements/Field";
import React, { ComponentType } from 'react';
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import Spinner from '../elements/Spinner'; import Spinner from '../elements/Spinner';
@ -29,6 +27,7 @@ import PassphraseField from "../auth/PassphraseField";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { PASSWORD_MIN_SCORE } from '../auth/RegistrationForm'; import { PASSWORD_MIN_SCORE } from '../auth/RegistrationForm';
import { MatrixClient } from "matrix-js-sdk/src/client";
import SetEmailDialog from "../dialogs/SetEmailDialog"; import SetEmailDialog from "../dialogs/SetEmailDialog";
import QuestionDialog from "../dialogs/QuestionDialog"; import QuestionDialog from "../dialogs/QuestionDialog";
@ -187,7 +186,9 @@ export default class ChangePassword extends React.Component<IProps, IState> {
private onExportE2eKeysClicked = (): void => { private onExportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Export E2E Keys', 'Change Password', 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<ComponentType<{}>>,
{ {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
}, },

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ComponentType } from 'react';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -92,14 +92,18 @@ export default class CryptographyPanel extends React.Component<IProps, IState> {
private onExportE2eKeysClicked = (): void => { private onExportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Export E2E Keys', '', Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), import(
'../../../async-components/views/dialogs/security/ExportE2eKeysDialog'
) as unknown as Promise<ComponentType<{}>>,
{ matrixClient: MatrixClientPeg.get() }, { matrixClient: MatrixClientPeg.get() },
); );
}; };
private onImportE2eKeysClicked = (): void => { private onImportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Import E2E Keys', '', Modal.createTrackedDialogAsync('Import E2E Keys', '',
import('../../../async-components/views/dialogs/security/ImportE2eKeysDialog'), import(
'../../../async-components/views/dialogs/security/ImportE2eKeysDialog'
) as unknown as Promise<ComponentType<{}>>,
{ matrixClient: MatrixClientPeg.get() }, { matrixClient: MatrixClientPeg.get() },
); );
}; };

View file

@ -15,10 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ComponentType } 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 { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -30,6 +27,8 @@ import QuestionDialog from '../dialogs/QuestionDialog';
import RestoreKeyBackupDialog from '../dialogs/security/RestoreKeyBackupDialog'; import RestoreKeyBackupDialog from '../dialogs/security/RestoreKeyBackupDialog';
import { accessSecretStorage } from '../../../SecurityManager'; import { accessSecretStorage } from '../../../SecurityManager';
import { replaceableComponent } from "../../../utils/replaceableComponent"; 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 { interface IState {
loading: boolean; loading: boolean;
@ -44,6 +43,8 @@ interface IState {
sessionsRemaining: number; sessionsRemaining: number;
} }
import { logger } from "matrix-js-sdk/src/logger";
@replaceableComponent("views.settings.SecureBackupPanel") @replaceableComponent("views.settings.SecureBackupPanel")
export default class SecureBackupPanel extends React.PureComponent<{}, IState> { export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
private unmounted = false; private unmounted = false;
@ -169,7 +170,9 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> {
private startNewBackup = (): void => { private startNewBackup = (): void => {
Modal.createTrackedDialogAsync('Key Backup', 'Key Backup', Modal.createTrackedDialogAsync('Key Backup', 'Key Backup',
import('../../../async-components/views/dialogs/security/CreateKeyBackupDialog'), import(
'../../../async-components/views/dialogs/security/CreateKeyBackupDialog'
) as unknown as Promise<ComponentType<{}>>,
{ {
onFinished: () => { onFinished: () => {
this.loadBackupStatus(); this.loadBackupStatus();

View file

@ -19,7 +19,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { enumerateThemes } from "../../../theme"; import { enumerateThemes, findHighContrastTheme, findNonHighContrastTheme, isHighContrastTheme } from "../../../theme";
import ThemeWatcher from "../../../settings/watchers/ThemeWatcher"; import ThemeWatcher from "../../../settings/watchers/ThemeWatcher";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
@ -159,7 +159,37 @@ export default class ThemeChoicePanel extends React.Component<IProps, IState> {
this.setState({ customThemeUrl: e.target.value }); this.setState({ customThemeUrl: e.target.value });
}; };
public render() { private renderHighContrastCheckbox(): React.ReactElement<HTMLDivElement> {
if (
!this.state.useSystemTheme && (
findHighContrastTheme(this.state.theme) ||
isHighContrastTheme(this.state.theme)
)
) {
return <div>
<StyledCheckbox
checked={isHighContrastTheme(this.state.theme)}
onChange={(e) => this.highContrastThemeChanged(e.target.checked)}
>
{ _t( "Use high contrast" ) }
</StyledCheckbox>
</div>;
}
}
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<HTMLDivElement> {
const themeWatcher = new ThemeWatcher(); const themeWatcher = new ThemeWatcher();
let systemThemeSection: JSX.Element; let systemThemeSection: JSX.Element;
if (themeWatcher.isSystemThemeSupported()) { if (themeWatcher.isSystemThemeSupported()) {
@ -210,7 +240,8 @@ export default class ThemeChoicePanel extends React.Component<IProps, IState> {
// XXX: replace any type here // XXX: replace any type here
const themes = Object.entries<any>(enumerateThemes()) const themes = Object.entries<any>(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 builtInThemes = themes.filter(p => !p.id.startsWith("custom-"));
const customThemes = themes.filter(p => !builtInThemes.includes(p)) const customThemes = themes.filter(p => !builtInThemes.includes(p))
.sort((a, b) => compare(a.name, b.name)); .sort((a, b) => compare(a.name, b.name));
@ -229,12 +260,21 @@ export default class ThemeChoicePanel extends React.Component<IProps, IState> {
className: "mx_ThemeSelector_" + t.id, className: "mx_ThemeSelector_" + t.id,
}))} }))}
onChange={this.onThemeChange} onChange={this.onThemeChange}
value={this.state.useSystemTheme ? undefined : this.state.theme} value={this.apparentSelectedThemeId()}
outlined outlined
/> />
</div> </div>
{ this.renderHighContrastCheckbox() }
{ customThemeForm } { customThemeForm }
</div> </div>
); );
} }
apparentSelectedThemeId() {
if (this.state.useSystemTheme) {
return undefined;
}
const nonHighContrast = findNonHighContrastTheme(this.state.theme);
return nonHighContrast ? nonHighContrast : this.state.theme;
}
} }

View file

@ -43,7 +43,6 @@ import SpaceStore, {
} from "../../../stores/SpaceStore"; } from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import SpaceContextMenu from "../context_menus/SpaceContextMenu"; import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import IconizedContextMenu, { import IconizedContextMenu, {
@ -228,75 +227,12 @@ const SpacePanel = () => {
return () => UIStore.instance.stopTrackingElementDimensions("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 ( return (
<DragDropContext onDragEnd={result => { <DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list if (!result.destination) return; // dropped outside the list
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index); SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
}}> }}>
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}> <RovingTabIndexProvider handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => ( { ({ onKeyDownHandler }) => (
<ul <ul
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })} className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}

View file

@ -279,6 +279,8 @@ export default class CallView extends React.Component<IProps, IState> {
if (window.electron?.getDesktopCapturerSources) { if (window.electron?.getDesktopCapturerSources) {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
if (!source) return;
isScreensharing = await this.props.call.setScreensharingEnabled(true, source); isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
} else { } else {
isScreensharing = await this.props.call.setScreensharingEnabled(true); isScreensharing = await this.props.call.setScreensharingEnabled(true);
@ -545,6 +547,7 @@ export default class CallView extends React.Component<IProps, IState> {
<div <div
className={classes} className={classes}
onMouseMove={this.onMouseMove} onMouseMove={this.onMouseMove}
ref={this.contentRef}
> >
{ sidebar } { sidebar }
<div className="mx_CallView_voice_avatarsContainer"> <div className="mx_CallView_voice_avatarsContainer">

View file

@ -18,9 +18,17 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { ActionPayload } from "../payloads"; import { ActionPayload } from "../payloads";
import { Action } from "../actions"; import { Action } from "../actions";
import { TimelineRenderingType } from "../../contexts/RoomContext";
export enum ComposerType {
Send = "send",
Edit = "edit",
}
interface IBaseComposerInsertPayload extends ActionPayload { interface IBaseComposerInsertPayload extends ActionPayload {
action: Action.ComposerInsert; action: Action.ComposerInsert;
timelineRenderingType: TimelineRenderingType;
composerType?: ComposerType; // falsey if should be re-dispatched to the correct composer
} }
interface IComposerInsertMentionPayload extends IBaseComposerInsertPayload { interface IComposerInsertMentionPayload extends IBaseComposerInsertPayload {

View file

@ -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; const { model } = range;
model.transform(() => { model.transform(() => {
const oldLen = range.length; const oldLen = range.length;
const addedLen = range.replace(newParts); const addedLen = range.replace(newParts);
const firstOffset = range.start.asOffset(model); const firstOffset = range.start.asOffset(model);
const lastOffset = firstOffset.add(oldLen + addedLen); const lastOffset = firstOffset.add(oldLen + addedLen + offset);
return lastOffset.asPosition(model); return lastOffset.asPosition(model);
}); });
} }
@ -103,6 +103,15 @@ export function formatRangeAsCode(range: Range): void {
replaceRangeAndExpandSelection(range, parts); 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 // parts helper methods
const isBlank = part => !part.text || !/\S/.test(part.text); const isBlank = part => !part.text || !/\S/.test(part.text);
const isNL = part => part.type === Type.Newline; const isNL = part => part.type === Type.Newline;

View file

@ -517,6 +517,8 @@
"%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.", "%(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 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 made the room invite only.": "%(senderDisplayName)s made the room invite only.",
"%(senderDisplayName)s changed who can join this room. <a>View settings</a>.": "%(senderDisplayName)s changed who can join this room. <a>View settings</a>.",
"%(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 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 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.", "%(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 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", "%(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": "Light",
"Light high contrast": "Light high contrast",
"Dark": "Dark", "Dark": "Dark",
"%(displayName)s is typing …": "%(displayName)s is typing …", "%(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 …", "%(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.", "Invalid theme schema.": "Invalid theme schema.",
"Error downloading theme information.": "Error downloading theme information.", "Error downloading theme information.": "Error downloading theme information.",
"Theme added!": "Theme added!", "Theme added!": "Theme added!",
"Use high contrast": "Use high contrast",
"Custom theme URL": "Custom theme URL", "Custom theme URL": "Custom theme URL",
"Add theme": "Add theme", "Add theme": "Add theme",
"Theme": "Theme", "Theme": "Theme",
@ -1606,6 +1610,7 @@
"Strikethrough": "Strikethrough", "Strikethrough": "Strikethrough",
"Code block": "Code block", "Code block": "Code block",
"Quote": "Quote", "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.", "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 <displayName/>.": "This is the beginning of your direct message history with <displayName/>.", "This is the beginning of your direct message history with <displayName/>.": "This is the beginning of your direct message history with <displayName/>.",
"Topic: %(topic)s (<a>edit</a>)": "Topic: %(topic)s (<a>edit</a>)", "Topic: %(topic)s (<a>edit</a>)": "Topic: %(topic)s (<a>edit</a>)",
@ -2520,7 +2525,6 @@
"Message edits": "Message edits", "Message edits": "Message edits",
"Modal Widget": "Modal Widget", "Modal Widget": "Modal Widget",
"Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s", "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", "Continuing without email": "Continuing without email",
"Just a heads up, if you don't add an email and forget your password, you could <b>permanently lose access to your account</b>.": "Just a heads up, if you don't add an email and forget your password, you could <b>permanently lose access to your account</b>.", "Just a heads up, if you don't add an email and forget your password, you could <b>permanently lose access to your account</b>.": "Just a heads up, if you don't add an email and forget your password, you could <b>permanently lose access to your account</b>.",
"Email (optional)": "Email (optional)", "Email (optional)": "Email (optional)",
@ -2735,6 +2739,9 @@
"powered by Matrix": "powered by Matrix", "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.", "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", "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.", "Confirm your identity by entering your account password below.": "Confirm your identity by entering your account password below.",
"Password": "Password", "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.", "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", "Password is allowed, but unsafe": "Password is allowed, but unsafe",
"Keep going...": "Keep going...", "Keep going...": "Keep going...",
"Enter username": "Enter username", "Enter username": "Enter username",
"Enter email address": "Enter email address",
"Enter phone number": "Enter phone number", "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", "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", "Username": "Username",
"Phone": "Phone", "Phone": "Phone",
"Forgot password?": "Forgot password?", "Forgot password?": "Forgot password?",
@ -2925,7 +2930,9 @@
"Drop file here to upload": "Drop file here to upload", "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.|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.", "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", "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", "This room is suggested as a good one to join": "This room is suggested as a good one to join",
"Suggested": "Suggested", "Suggested": "Suggested",
"Select a room below first": "Select a room below first", "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", "A private space to organise your rooms": "A private space to organise your rooms",
"Me and my teammates": "Me and my teammates", "Me and my teammates": "Me and my teammates",
"A private space for you and your teammates": "A private space for you and your 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", "Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s",
"Inviting...": "Inviting...", "Inviting...": "Inviting...",
"Invite your teammates": "Invite your teammates", "Invite your teammates": "Invite your teammates",

View file

@ -220,3 +220,5 @@ export async function initSentry(sentryConfig: ISentryConfig): Promise<void> {
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
}); });
} }
window.mxSendSentryReport = sendSentryReport;

View file

@ -307,7 +307,7 @@ class RoomViewStore extends Store<ActionPayload> {
} }
} }
private getInvitingUserId(roomId: string): string { private static getInvitingUserId(roomId: string): string {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const room = cli.getRoom(roomId); const room = cli.getRoom(roomId);
if (room && room.getMyMembership() === "invite") { if (room && room.getMyMembership() === "invite") {
@ -317,12 +317,7 @@ class RoomViewStore extends Store<ActionPayload> {
} }
} }
private joinRoomError(payload: ActionPayload) { public showJoinRoomError(err: Error | MatrixError, roomId: string) {
this.setState({
joining: false,
joinError: payload.err,
});
const err = payload.err;
let msg = err.message ? err.message : JSON.stringify(err); let msg = err.message ? err.message : JSON.stringify(err);
logger.log("Failed to join room:", msg); logger.log("Failed to join room:", msg);
@ -334,7 +329,7 @@ class RoomViewStore extends Store<ActionPayload> {
{ _t("Please contact your homeserver administrator.") } { _t("Please contact your homeserver administrator.") }
</div>; </div>;
} else if (err.httpStatus === 404) { } 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 // only provide a better error message for invites
if (invitingUserId) { if (invitingUserId) {
// if the inviting user is on the same HS, there can only be one cause: they left. // 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<ActionPayload> {
}); });
} }
private joinRoomError(payload: ActionPayload) {
this.setState({
joining: false,
joinError: payload.err,
});
this.showJoinRoomError(payload.err, this.state.roomId);
}
public reset() { public reset() {
this.state = Object.assign({}, INITIAL_STATE); this.state = Object.assign({}, INITIAL_STATE);
} }

View file

@ -306,16 +306,23 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return room?.currentState.getStateEvents(EventType.SpaceParent) return room?.currentState.getStateEvents(EventType.SpaceParent)
.map(ev => { .map(ev => {
const content = ev.getContent(); const content = ev.getContent();
if (Array.isArray(content?.via) && (!canonicalOnly || content?.canonical)) { if (!Array.isArray(content.via) || (canonicalOnly && !content.canonical)) {
const parent = this.matrixClient.getRoom(ev.getStateKey()); return; // skip
}
// only respect the relationship if the sender has sufficient permissions in the parent to set // only respect the relationship if the sender has sufficient permissions in the parent to set
// child relations, as per MSC1772. // 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 // 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)) { 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; return parent;
}
}
// else implicit undefined which causes this element to be filtered out
}) })
.filter(Boolean) || []; .filter(Boolean) || [];
} }

Some files were not shown because too many files have changed in this diff Show more