Merge branch 'develop' into sort-imports

pull/21833/head
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,
"rule-empty-line-before": null,
"color-hex-length": null,
"max-empty-lines": null,
"max-empty-lines": 1,
"no-eol-whitespace": true,
"number-no-trailing-zeros": null,
"number-leading-zero": null,
"selector-list-comma-newline-after": null,

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)
===================================================================================================

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -203,7 +203,8 @@ limitations under the License.
grid-row: 1;
grid-column: 2;
.mx_InfoTooltip {
.mx_InfoTooltip,
.mx_SpaceHierarchy_roomTile_joined {
display: inline;
margin-left: 12px;
color: $tertiary-content;
@ -222,6 +223,25 @@ limitations under the License.
}
}
}
.mx_SpaceHierarchy_roomTile_joined {
position: relative;
padding-left: 16px;
&::before {
content: '';
width: 20px;
height: 20px;
top: -2px;
left: -4px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: $accent-color;
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
}
}
}
.mx_SpaceHierarchy_roomTile_info {
@ -268,6 +288,11 @@ limitations under the License.
visibility: visible;
}
}
&.mx_SpaceHierarchy_joining .mx_AccessibleButton {
visibility: visible;
padding: 4px 18px;
}
}
li.mx_SpaceHierarchy_roomTileWrapper {

View File

@ -348,7 +348,6 @@ $activeBorderColor: $secondary-content;
}
}
.mx_SpacePanel_sharePublicSpace {
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 {
// XXX remove this when spaces leaves Beta
.mx_SpaceRoomView_inviteTeammates_betaDisclaimer {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,4 +49,3 @@ limitations under the License.
text-align: start;
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;
}
.mx_cryptoEvent_state, .mx_cryptoEvent_buttons {
grid-column: 3;
grid-row: 1 / 3;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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
.mx_EventTile_pre_container > pre {
overflow: hidden;
overflow-x: scroll;
overflow-y: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;

View File

@ -68,4 +68,3 @@ limitations under the License.
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 {
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-right: 2px;
&::before {
content: '';
display: inline-block;
@ -48,7 +47,6 @@ limitations under the License.
background-position: center;
}
&.mx_CallViewButtons_dialpad::before {
background-image: url('$(res)/img/voip/dialpad.svg');
}

View File

@ -200,7 +200,6 @@ limitations under the License.
}
}
.mx_CallView_presenting {
opacity: 1;
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">
<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>
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 562 B

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">
<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"/>
<rect x="3.96826" y="8.99951" 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="#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="#FF5B55"/>
</svg>

Before

Width:  |  Height:  |  Size: 430 B

After

Width:  |  Height:  |  Size: 430 B

View File

@ -28,5 +28,5 @@
<path
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 "
style="fill:#ff4b55;fill-opacity:1" />
style="fill:#ff5b55;fill-opacity:1" />
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

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">
<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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

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

View File

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

View File

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

View File

@ -1,11 +1,12 @@
//// Reference: https://www.figma.com/file/RnLKnv09glhxGIZtn8zfmh/UI-Themes-%26-Accessibility?node-id=321%3A65847
$accent: #268075;
$alert: #D62C25;
$notice-primary-color: #D61C25;
$links: #0A6ECA;
$secondary-content: #5E6266;
$tertiary-content: #5E6266; // Same as secondary
$quaternary-content: #5E6266; // Same as secondary
$tertiary-content: $secondary-content;
$quaternary-content: $secondary-content;
$quinary-content: $secondary-content;
$roomlist-button-bg-color: rgba(141, 151, 165, 0.2);
$username-variant1-color: #0A6ECA;
$username-variant2-color: #AC3BA8;
@ -18,9 +19,13 @@ $username-variant8-color: #3E810A;
$accent-color: $accent;
$accent-color-50pct: rgba($accent-color, 0.5);
$accent-color-alt: $links;
$input-border-color: $secondary-content;
$input-darker-bg-color: $quinary-content;
$input-darker-fg-color: $secondary-content;
$input-lighter-fg-color: $input-darker-fg-color;
$input-valid-border-color: $accent-color;
$input-focused-border-color: $accent-color;
$button-bg-color: $accent-color;
$resend-button-divider-color: $input-darker-bg-color;
$icon-button-color: $quaternary-content;
@ -41,12 +46,14 @@ $voice-record-stop-border-color: $quinary-content;
$voice-record-icon-color: $tertiary-content;
$appearance-tab-border-color: $input-darker-bg-color;
$eventbubble-reply-color: $quaternary-content;
$notice-primary-color: $alert;
$warning-color: $notice-primary-color; // red
$pinned-unread-color: $notice-primary-color;
$button-danger-bg-color: $notice-primary-color;
$mention-user-pill-bg-color: $warning-color;
$input-invalid-border-color: $warning-color;
$event-highlight-fg-color: $warning-color;
$roomtopic-color: $secondary-content;
@define-mixin mx_DialogButton_danger {
background-color: $accent-color;
@ -64,3 +71,38 @@ $event-highlight-fg-color: $warning-color;
color: $accent-color;
text-decoration: none;
}
.mx_AccessibleButton {
margin-left: 4px;
}
.mx_AccessibleButton:focus {
outline: 2px solid $accent-color;
outline-offset: 2px;
}
.mx_BasicMessageComposer .mx_BasicMessageComposer_inputEmpty > :first-child::before {
color: $secondary-content;
opacity: 1 !important;
}
.mx_TextualEvent {
color: $secondary-content;
opacity: 1 !important;
}
.mx_Dialog, .mx_MatrixChat_wrapper {
:not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=text]::placeholder,
:not(.mx_textinput):not(.mx_Field):not(.mx_no_textinput) > input[type=search]::placeholder,
.mx_textinput input::placeholder {
color: $input-darker-fg-color !important;
}
}
.mx_UserMenu_contextMenu .mx_UserMenu_contextMenu_header .mx_UserMenu_contextMenu_themeButton {
background-color: $roomlist-button-bg-color !important;
}
.mx_FontScalingPanel_fontSlider {
background-color: $roomlist-button-bg-color !important;
}

View File

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

View File

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

View File

@ -18,7 +18,7 @@ limitations under the License.
import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import { defer } from "matrix-js-sdk/src/utils";
import { defer, sleep } from "matrix-js-sdk/src/utils";
import Analytics from './Analytics';
import dis from './dispatcher/dispatcher';
@ -332,7 +332,10 @@ export class ModalManager {
return this.priorityModal ? this.priorityModal : (this.modals[0] || this.staticModal);
}
private reRender() {
private async reRender() {
// await next tick because sometimes ReactDOM can race with itself and cause the modal to wrongly stick around
await sleep(0);
if (this.modals.length === 0 && !this.priorityModal && !this.staticModal) {
// If there is no modal to render, make all of Element available
// to screen reader users again

View File

@ -32,6 +32,10 @@ import AccessSecretStorageDialog from './components/views/dialogs/security/Acces
import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog';
import SettingsStore from "./settings/SettingsStore";
import SecurityCustomisations from "./customisations/Security";
import { DeviceTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
import { logger } from "matrix-js-sdk/src/logger";
import { ComponentType } from "react";
// This stores the secret storage private keys in memory for the JS SDK. This is
// only meant to act as a cache to avoid prompting the user multiple times
@ -335,7 +339,9 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f
// This dialog calls bootstrap itself after guiding the user through
// passphrase creation.
const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '',
import("./async-components/views/dialogs/security/CreateSecretStorageDialog"),
import(
"./async-components/views/dialogs/security/CreateSecretStorageDialog"
) as unknown as Promise<ComponentType<{}>>,
{
forceReset,
},

View File

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

View File

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

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

View File

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

View File

@ -16,7 +16,7 @@ limitations under the License.
import React from "react";
import { IState, RovingTabIndexProvider } from "./RovingTabIndex";
import { RovingTabIndexProvider } from "./RovingTabIndex";
import { Key } from "../Keyboard";
interface IProps extends Omit<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
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
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;
// Don't interfere with input default keydown behaviour
if (target.tagName === "INPUT") return;
@ -42,15 +42,6 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
}
break;
case Key.ARROW_LEFT:
case Key.ARROW_RIGHT:
if (state.refs.length > 0) {
const i = state.refs.findIndex(r => r === state.activeRef);
const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
}
break;
default:
handled = false;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -249,6 +249,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
let handled = true;
switch (ev.key) {
// XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils
// to inherit proper handling of unmount edge cases
case Key.TAB:
case Key.ESCAPE:
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />

View File

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

View File

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

View File

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

View File

@ -32,7 +32,6 @@ import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../.
interface IProps {
isMinimized: boolean;
onKeyDown(ev: React.KeyboardEvent): void;
/**
* @returns true if a room has been selected and the search field should be cleared
*/
@ -133,11 +132,6 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
this.clearInput();
defaultDispatcher.fire(Action.FocusSendMessageComposer);
break;
case RoomListAction.NextRoom:
case RoomListAction.PrevRoom:
// we don't handle these actions here put pass the event on to the interested party (LeftPanel)
this.props.onKeyDown(ev);
break;
case RoomListAction.SelectRoom: {
const shouldClear = this.props.onSelectRoom();
if (shouldClear) {
@ -151,6 +145,10 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
}
};
public focus(): void {
this.inputRef.current?.focus();
}
public render(): React.ReactNode {
const classes = classNames({
'mx_RoomSearch': true,

View File

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

View File

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

View File

@ -56,7 +56,7 @@ import {
showSpaceInvite,
showSpaceSettings,
} from "../../utils/space";
import SpaceHierarchy, { showRoom } from "./SpaceHierarchy";
import SpaceHierarchy, { joinRoom, showRoom } from "./SpaceHierarchy";
import MemberAvatar from "../views/avatars/MemberAvatar";
import SpaceStore from "../../stores/SpaceStore";
import FacePile from "../views/elements/FacePile";
@ -507,7 +507,7 @@ const SpaceLanding = ({ space }: { space: Room }) => {
) }
</RoomTopic>
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
<SpaceHierarchy space={space} showRoom={showRoom} joinRoom={joinRoom} additionalButtons={addRoomButton} />
</div>;
};
@ -667,10 +667,6 @@ const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
<h3>{ _t("Me and my teammates") }</h3>
<div>{ _t("A private space for you and your teammates") }</div>
</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>;
};

View File

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

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

View File

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

View File

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

View File

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

View File

@ -21,25 +21,14 @@ import { _t } from '../../../languageHandler';
import { IDialogProps } from "./IDialogProps";
import Field from "../elements/Field";
import CountlyAnalytics from "../../../CountlyAnalytics";
import withValidation from "../elements/Validation";
import * as Email from "../../../email";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import EmailField from "../auth/EmailField";
interface IProps extends IDialogProps {
onFinished(continued: boolean, email?: string): void;
}
const validation = withValidation({
rules: [
{
key: "email",
test: ({ value }) => !value || Email.looksValid(value),
invalid: () => _t("Doesn't look like a valid email address"),
},
],
});
const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
const [email, setEmail] = useState("");
const fieldRef = useRef<Field>();
@ -47,11 +36,11 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
const onSubmit = async (e) => {
e.preventDefault();
if (email) {
const valid = await fieldRef.current.validate({ allowEmpty: false });
const valid = await fieldRef.current.validate({});
if (!valid) {
fieldRef.current.focus();
fieldRef.current.validate({ allowEmpty: false, focused: true });
fieldRef.current.validate({ focused: true });
return;
}
}
@ -72,16 +61,15 @@ const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
b: sub => <b>{ sub }</b>,
}) }</p>
<form onSubmit={onSubmit}>
<Field
ref={fieldRef}
<EmailField
fieldRef={fieldRef}
autoFocus={true}
type="text"
label={_t("Email (optional)")}
value={email}
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")}
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 LinkPreviewGroup from '../rooms/LinkPreviewGroup';
import { IBodyProps } from "./IBodyProps";
import RoomContext from "../../../contexts/RoomContext";
const MAX_HIGHLIGHT_LENGTH = 4096;
@ -62,6 +63,9 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
private unmounted = false;
private pills: Element[] = [];
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
constructor(props) {
super(props);
@ -406,6 +410,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
dis.dispatch<ComposerInsertPayload>({
action: Action.ComposerInsert,
userId: mxEvent.getSender(),
timelineRenderingType: this.context.timelineRenderingType,
});
};

View File

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

View File

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

View File

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

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

View File

@ -27,6 +27,7 @@ export enum Formatting {
Strikethrough = "strikethrough",
Code = "code",
Quote = "quote",
InsertLink = "insert_link",
}
interface IProps {
@ -57,6 +58,7 @@ export default class MessageComposerFormatBar extends React.PureComponent<IProps
<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("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>);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { enumerateThemes } from "../../../theme";
import { enumerateThemes, findHighContrastTheme, findNonHighContrastTheme, isHighContrastTheme } from "../../../theme";
import ThemeWatcher from "../../../settings/watchers/ThemeWatcher";
import AccessibleButton from "../elements/AccessibleButton";
import dis from "../../../dispatcher/dispatcher";
@ -159,7 +159,37 @@ export default class ThemeChoicePanel extends React.Component<IProps, IState> {
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();
let systemThemeSection: JSX.Element;
if (themeWatcher.isSystemThemeSupported()) {
@ -210,7 +240,8 @@ export default class ThemeChoicePanel extends React.Component<IProps, IState> {
// XXX: replace any type here
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 customThemes = themes.filter(p => !builtInThemes.includes(p))
.sort((a, b) => compare(a.name, b.name));
@ -229,12 +260,21 @@ export default class ThemeChoicePanel extends React.Component<IProps, IState> {
className: "mx_ThemeSelector_" + t.id,
}))}
onChange={this.onThemeChange}
value={this.state.useSystemTheme ? undefined : this.state.theme}
value={this.apparentSelectedThemeId()}
outlined
/>
</div>
{ this.renderHighContrastCheckbox() }
{ customThemeForm }
</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";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import IconizedContextMenu, {
@ -228,75 +227,12 @@ const SpacePanel = () => {
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
}, []);
const onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.defaultPrevented) return;
let handled = true;
switch (ev.key) {
case Key.ARROW_UP:
onMoveFocus(ev.target as Element, true);
break;
case Key.ARROW_DOWN:
onMoveFocus(ev.target as Element, false);
break;
default:
handled = false;
}
if (handled) {
// consume all other keys in context menu
ev.stopPropagation();
ev.preventDefault();
}
};
const onMoveFocus = (element: Element, up: boolean) => {
let descending = false; // are we currently descending or ascending through the DOM tree?
let classes: DOMTokenList;
do {
const child = up ? element.lastElementChild : element.firstElementChild;
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
if (descending) {
if (child) {
element = child;
} else if (sibling) {
element = sibling;
} else {
descending = false;
element = element.parentElement;
}
} else {
if (sibling) {
element = sibling;
descending = true;
} else {
element = element.parentElement;
}
}
if (element) {
if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
element = up ? element.lastElementChild : element.firstElementChild;
descending = true;
}
classes = element.classList;
}
} while (element && !classes.contains("mx_SpaceButton"));
if (element) {
(element as HTMLElement).focus();
}
};
return (
<DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
}}>
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
<RovingTabIndexProvider handleHomeEnd handleUpDown>
{ ({ onKeyDownHandler }) => (
<ul
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) {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
if (!source) return;
isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
} else {
isScreensharing = await this.props.call.setScreensharingEnabled(true);
@ -545,6 +547,7 @@ export default class CallView extends React.Component<IProps, IState> {
<div
className={classes}
onMouseMove={this.onMouseMove}
ref={this.contentRef}
>
{ sidebar }
<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 { Action } from "../actions";
import { TimelineRenderingType } from "../../contexts/RoomContext";
export enum ComposerType {
Send = "send",
Edit = "edit",
}
interface IBaseComposerInsertPayload extends ActionPayload {
action: Action.ComposerInsert;
timelineRenderingType: TimelineRenderingType;
composerType?: ComposerType; // falsey if should be re-dispatched to the correct composer
}
interface IComposerInsertMentionPayload extends IBaseComposerInsertPayload {

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;
model.transform(() => {
const oldLen = range.length;
const addedLen = range.replace(newParts);
const firstOffset = range.start.asOffset(model);
const lastOffset = firstOffset.add(oldLen + addedLen);
const lastOffset = firstOffset.add(oldLen + addedLen + offset);
return lastOffset.asPosition(model);
});
}
@ -103,6 +103,15 @@ export function formatRangeAsCode(range: Range): void {
replaceRangeAndExpandSelection(range, parts);
}
export function formatRangeAsLink(range: Range) {
const { model, parts } = range;
const { partCreator } = model;
parts.unshift(partCreator.plain("["));
parts.push(partCreator.plain("]()"));
// We set offset to -1 here so that the caret lands between the brackets
replaceRangeAndMoveCaret(range, parts, -1);
}
// parts helper methods
const isBlank = part => !part.text || !/\S/.test(part.text);
const isNL = part => part.type === Type.Newline;

View File

@ -517,6 +517,8 @@
"%(senderDisplayName)s upgraded this room.": "%(senderDisplayName)s upgraded this room.",
"%(senderDisplayName)s made the room public to whoever knows the link.": "%(senderDisplayName)s made the room public to whoever knows the link.",
"%(senderDisplayName)s made the room invite only.": "%(senderDisplayName)s made the room invite only.",
"%(senderDisplayName)s changed who can join this room. <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 has allowed guests to join the room.": "%(senderDisplayName)s has allowed guests to join the room.",
"%(senderDisplayName)s has prevented guests from joining the room.": "%(senderDisplayName)s has prevented guests from joining the room.",
@ -577,6 +579,7 @@
"%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching %(newGlob)s for %(reason)s",
"%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s": "%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s for %(reason)s",
"Light": "Light",
"Light high contrast": "Light high contrast",
"Dark": "Dark",
"%(displayName)s is typing …": "%(displayName)s is typing …",
"%(names)s and %(count)s others are typing …|other": "%(names)s and %(count)s others are typing …",
@ -1291,6 +1294,7 @@
"Invalid theme schema.": "Invalid theme schema.",
"Error downloading theme information.": "Error downloading theme information.",
"Theme added!": "Theme added!",
"Use high contrast": "Use high contrast",
"Custom theme URL": "Custom theme URL",
"Add theme": "Add theme",
"Theme": "Theme",
@ -1606,6 +1610,7 @@
"Strikethrough": "Strikethrough",
"Code block": "Code block",
"Quote": "Quote",
"Insert link": "Insert link",
"Only the two of you are in this conversation, unless either of you invites anyone to join.": "Only the two of you are in this conversation, unless either of you invites anyone to join.",
"This is the beginning of your direct message history with <displayName/>.": "This is the beginning of your direct message history with <displayName/>.",
"Topic: %(topic)s (<a>edit</a>)": "Topic: %(topic)s (<a>edit</a>)",
@ -2520,7 +2525,6 @@
"Message edits": "Message edits",
"Modal Widget": "Modal Widget",
"Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s",
"Doesn't look like a valid email address": "Doesn't look like a valid email address",
"Continuing without email": "Continuing without email",
"Just a heads up, if you don't add an email and forget your password, you could <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)",
@ -2735,6 +2739,9 @@
"powered by Matrix": "powered by Matrix",
"This homeserver would like to make sure you are not a robot.": "This homeserver would like to make sure you are not a robot.",
"Country Dropdown": "Country Dropdown",
"Email": "Email",
"Enter email address": "Enter email address",
"Doesn't look like a valid email address": "Doesn't look like a valid email address",
"Confirm your identity by entering your account password below.": "Confirm your identity by entering your account password below.",
"Password": "Password",
"Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.": "Missing captcha public key in homeserver configuration. Please report this to your homeserver administrator.",
@ -2754,10 +2761,8 @@
"Password is allowed, but unsafe": "Password is allowed, but unsafe",
"Keep going...": "Keep going...",
"Enter username": "Enter username",
"Enter email address": "Enter email address",
"Enter phone number": "Enter phone number",
"That phone number doesn't look quite right, please check and try again": "That phone number doesn't look quite right, please check and try again",
"Email": "Email",
"Username": "Username",
"Phone": "Phone",
"Forgot password?": "Forgot password?",
@ -2925,7 +2930,9 @@
"Drop file here to upload": "Drop file here to upload",
"You have %(count)s unread notifications in a prior version of this room.|other": "You have %(count)s unread notifications in a prior version of this room.",
"You have %(count)s unread notifications in a prior version of this room.|one": "You have %(count)s unread notification in a prior version of this room.",
"Joining": "Joining",
"You don't have permission": "You don't have permission",
"Joined": "Joined",
"This room is suggested as a good one to join": "This room is suggested as a good one to join",
"Suggested": "Suggested",
"Select a room below first": "Select a room below first",
@ -2966,8 +2973,6 @@
"A private space to organise your rooms": "A private space to organise your rooms",
"Me and my teammates": "Me and my teammates",
"A private space for you and your teammates": "A private space for you and your teammates",
"Teammates might not be able to view or join any private rooms you make.": "Teammates might not be able to view or join any private rooms you make.",
"We're working on this, but just want to let you know.": "We're working on this, but just want to let you know.",
"Failed to invite the following users to your space: %(csvUsers)s": "Failed to invite the following users to your space: %(csvUsers)s",
"Inviting...": "Inviting...",
"Invite your teammates": "Invite your teammates",

View File

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

View File

@ -306,16 +306,23 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return room?.currentState.getStateEvents(EventType.SpaceParent)
.map(ev => {
const content = ev.getContent();
if (Array.isArray(content?.via) && (!canonicalOnly || content?.canonical)) {
const parent = this.matrixClient.getRoom(ev.getStateKey());
// only respect the relationship if the sender has sufficient permissions in the parent to set
// child relations, as per MSC1772.
// https://github.com/matrix-org/matrix-doc/blob/main/proposals/1772-groups-as-rooms.md#relationship-between-rooms-and-spaces
if (parent?.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
return parent;
}
if (!Array.isArray(content.via) || (canonicalOnly && !content.canonical)) {
return; // skip
}
// else implicit undefined which causes this element to be filtered out
// only respect the relationship if the sender has sufficient permissions in the parent to set
// child relations, as per MSC1772.
// https://github.com/matrix-org/matrix-doc/blob/main/proposals/1772-groups-as-rooms.md#relationship-between-rooms-and-spaces
const parent = this.matrixClient.getRoom(ev.getStateKey());
const relation = parent?.currentState.getStateEvents(EventType.SpaceChild, roomId);
if (!parent?.currentState.maySendStateEvent(EventType.SpaceChild, userId) ||
// also skip this relation if the parent had this child added but then since removed it
(relation && !Array.isArray(relation.getContent().via))
) {
return; // skip
}
return parent;
})
.filter(Boolean) || [];
}

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