diff --git a/README.md b/README.md index 1265c2bd77..c2e3737b81 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ matrix-react-sdk This is a react-based SDK for inserting a Matrix chat/voip client into a web page. This package provides the React components needed to build a Matrix web client -using React. It is not useable in isolation, and instead must must be used from +using React. It is not useable in isolation, and instead must be used from a 'skin'. A skin provides: * Customised implementations of presentation components. * Custom CSS @@ -82,7 +82,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold: 'Stealing' styling information from other components (including parents) is not cool, as it breaks the independence of the components. - * CSS classes are named with an app-specific namespacing prefix to try to avoid + * CSS classes are named with an app-specific name-spacing prefix to try to avoid CSS collisions. The base skin shipped by Matrix.org with the matrix-react-sdk uses the naming prefix "mx_". A company called Yoyodyne Inc might use a prefix like "yy_" for its app-specific classes. @@ -107,7 +107,7 @@ practices that anyone working with the SDK needs to be be aware of and uphold: .mx_RoomTile {} in RoomList.css - only RoomTile.css is allowed to define its own CSS. Instead, say .mx_RoomList .mx_RoomTile {} to scope the override only to the context of RoomList views. N.B. overrides should be relatively - rare as in general CSS inheritence should be enough. + rare as in general CSS inheritance should be enough. * Components should render only within the bounding box of their outermost DOM element. Page-absolute positioning and negative CSS margins and similar are diff --git a/code_style.md b/code_style.md index 4b2338064c..3ad0d38873 100644 --- a/code_style.md +++ b/code_style.md @@ -174,12 +174,6 @@ React // Best, if onFooClick would do anything other than directly calling doStuff ``` - Not doing so is acceptable in a single case: in function-refs: - - ```jsx - this.component = self}> - ``` - - Prefer classes that extend `React.Component` (or `React.PureComponent`) instead of `React.createClass` - You can avoid the need to bind handler functions by using [property initializers](https://reactjs.org/docs/react-component.html#constructor): @@ -208,3 +202,5 @@ React ``` - Think about whether your component really needs state: are you duplicating information in component state that could be derived from the model? + +- Avoid things marked as Legacy or Deprecated in React 16 (e.g string refs and legacy contexts) diff --git a/package.json b/package.json index 1c37c057f5..7ef14e6635 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "build": "yarn reskindex && yarn start:init", "build:watch": "babel src -w --skip-initial-build -d lib --source-maps --copy-files", - "emoji-data-strip": "node scripts/emoji-data-strip.js", "start": "yarn start:init && yarn start:all", "start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn build:watch\" \"yarn reskindex:watch\"", "start:init": "babel src -d lib --source-maps --copy-files", @@ -75,7 +74,7 @@ "file-saver": "^1.3.3", "filesize": "3.5.6", "flux": "2.1.1", - "focus-trap-react": "^3.0.5", + "react-focus-lock": "^2.2.1", "focus-visible": "^5.0.2", "fuse.js": "^2.2.0", "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566", @@ -88,7 +87,7 @@ "linkifyjs": "^2.1.6", "lodash": "^4.17.14", "lolex": "4.2", - "matrix-js-sdk": "2.4.6", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "optimist": "^0.6.1", "pako": "^1.0.5", "png-chunks-extract": "^1.0.0", @@ -110,6 +109,7 @@ "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", "velocity-animate": "^1.5.2", + "what-input": "^5.2.6", "whatwg-fetch": "^1.1.1", "zxcvbn": "^4.4.2" }, diff --git a/res/css/_components.scss b/res/css/_components.scss index 4081e49630..7b8ca77739 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -24,7 +24,6 @@ @import "./structures/_SearchBox.scss"; @import "./structures/_TabbedView.scss"; @import "./structures/_TagPanel.scss"; -@import "./structures/_TagPanelButtons.scss"; @import "./structures/_ToastContainer.scss"; @import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_UploadBar.scss"; @@ -64,7 +63,6 @@ @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_MessageEditHistoryDialog.scss"; -@import "./views/dialogs/_RestoreKeyBackupDialog.scss"; @import "./views/dialogs/_RoomSettingsDialog.scss"; @import "./views/dialogs/_RoomUpgradeDialog.scss"; @import "./views/dialogs/_RoomUpgradeWarningDialog.scss"; @@ -83,6 +81,8 @@ @import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; @import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; @import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; +@import "./views/dialogs/secretstorage/_AccessSecretStorageDialog.scss"; +@import "./views/dialogs/secretstorage/_CreateSecretStorageDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; @@ -173,7 +173,10 @@ @import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_Stickers.scss"; @import "./views/rooms/_TopUnreadMessagesBar.scss"; +@import "./views/rooms/_UserOnlineDot.scss"; @import "./views/rooms/_WhoIsTypingTile.scss"; +@import "./views/settings/_AvatarSetting.scss"; +@import "./views/settings/_CrossSigningPanel.scss"; @import "./views/settings/_DevicesPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; diff --git a/res/css/structures/_CustomRoomTagPanel.scss b/res/css/structures/_CustomRoomTagPanel.scss index 45961d7be1..1fb18ec41e 100644 --- a/res/css/structures/_CustomRoomTagPanel.scss +++ b/res/css/structures/_CustomRoomTagPanel.scss @@ -26,11 +26,16 @@ limitations under the License. .mx_CustomRoomTagPanel_scroller { max-height: inherit; + display: flex; + flex-direction: column; + align-items: center; } .mx_CustomRoomTagPanel .mx_AccessibleButton { - margin: 9px auto; + margin: 0 auto; width: 40px; + padding: 10px 0 9px 0; + position: relative; } .mx_CustomRoomTagPanel .mx_BaseAvatar_image { @@ -39,7 +44,13 @@ limitations under the License. height: 40px; } -.mx_CustomRoomTagPanel .mx_AccessibleButton.CustomRoomTagPanel_tileSelected .mx_BaseAvatar_image { - border: 3px solid $warning-color; - border-radius: 40px; +.mx_CustomRoomTagPanel .mx_AccessibleButton.CustomRoomTagPanel_tileSelected::before { + content: ''; + height: 56px; + background-color: $accent-color-alt; + width: 5px; + position: absolute; + left: -15px; + border-radius: 0 3px 3px 0; + top: 2px; // 10 [padding-top] - (56 - 40)/2 } diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_TagPanel.scss index b03d36a592..dddd2e324c 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_TagPanel.scss @@ -68,7 +68,7 @@ limitations under the License. } .mx_TagPanel .mx_TagPanel_tagTileContainer > div { height: 40px; - padding: 5px 0 4px 0; + padding: 10px 0 9px 0; } .mx_TagPanel .mx_TagTile { @@ -82,21 +82,39 @@ limitations under the License. // opacity: 1; } -.mx_TagPanel .mx_TagTile.mx_TagTile_selected .mx_TagTile_avatar .mx_BaseAvatar { - background-color: $accent-color; - border-radius: 40px; - - /* In case this is a "initial" avatar */ - display: block; +.mx_TagPanel .mx_TagTile_plus { + margin-bottom: 12px; height: 40px; width: 40px; + border-radius: 20px; + background-color: $roomheader-addroom-bg-color; + position: relative; + /* overwrite mx_RoleButton inline-block */ + display: block !important; + + &::before { + background-color: $roomheader-addroom-fg-color; + mask-image: url('$(res)/img/feather-customised/plus.svg'); + mask-position: center; + mask-repeat: no-repeat; + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } } -.mx_TagPanel .mx_TagTile_selected .mx_BaseAvatar_image { - border: 3px solid $accent-color; - height: 40px; - width: 40px; - box-sizing: border-box; +.mx_TagPanel .mx_TagTile.mx_TagTile_selected::before { + content: ''; + height: 56px; + background-color: $accent-color; + width: 5px; + position: absolute; + left: -15px; + border-radius: 0 3px 3px 0; + top: -8px; // (56 - 40)/2 } .mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus { diff --git a/res/css/structures/_TagPanelButtons.scss b/res/css/structures/_TagPanelButtons.scss deleted file mode 100644 index 70fea92959..0000000000 --- a/res/css/structures/_TagPanelButtons.scss +++ /dev/null @@ -1,56 +0,0 @@ -/* -Copyright 2019 New Vector Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -.mx_TagPanelButtons { - background-color: $tagpanel-bg-color; - display: flex; - flex-direction: column; - align-items: center; - justify-content: space-between; - padding: 17px 0 3px 0; -} - -.mx_TagPanelButtons > .mx_GroupsButton::before { - mask: url('$(res)/img/feather-customised/users.svg'); - mask-position: center 11px; -} - -.mx_TagPanelButtons > .mx_TagPanelButtons_report::before { - mask: url('$(res)/img/feather-customised/life-buoy.svg'); - mask-position: center 9px; -} - -.mx_TagPanelButtons > .mx_AccessibleButton { - margin-bottom: 12px; - height: 40px; - width: 40px; - border-radius: 20px; - background-color: $tagpanel-button-color; - position: relative; - /* overwrite mx_RoleButton inline-block */ - display: block !important; - - &::before { - background-color: $tagpanel-bg-color; - mask-repeat: no-repeat; - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - } -} diff --git a/res/css/views/avatars/_BaseAvatar.scss b/res/css/views/avatars/_BaseAvatar.scss index a085034758..e59598278f 100644 --- a/res/css/views/avatars/_BaseAvatar.scss +++ b/res/css/views/avatars/_BaseAvatar.scss @@ -40,6 +40,7 @@ limitations under the License. } .mx_BaseAvatar_image { + object-fit: cover; border-radius: 40px; vertical-align: top; background-color: $avatar-bg-color; diff --git a/res/css/views/context_menus/_TopLeftMenu.scss b/res/css/views/context_menus/_TopLeftMenu.scss index d17d683e7e..ed0d0106bc 100644 --- a/res/css/views/context_menus/_TopLeftMenu.scss +++ b/res/css/views/context_menus/_TopLeftMenu.scss @@ -53,6 +53,10 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/home.svg'); } + .mx_TopLeftMenu_icon_help::after { + mask-image: url('$(res)/img/feather-customised/life-buoy.svg'); + } + .mx_TopLeftMenu_icon_settings::after { mask-image: url('$(res)/img/feather-customised/settings.svg'); } diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index 8e648e8881..b7641a4ad0 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -48,6 +48,12 @@ limitations under the License. padding-right: 80px; } +// show a different AvatarSetting placeholder for RoomProfileSettings which is basically a clone of ProfileSettings +.mx_RoomSettingsDialog .mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder::before { + mask: url("$(res)/img/feather-customised/image.svg"); + mask-repeat: no-repeat; + mask-size: 36px; + mask-position: center; .mx_RoomSettingsDialog_BridgeList { padding: 0; @@ -58,4 +64,5 @@ limitations under the License. padding: 0; margin: 0; border-bottom: 1px solid $panel-divider-color; -} \ No newline at end of file +} + diff --git a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss index 415a2021cc..9cba8e0da9 100644 --- a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss +++ b/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_RestoreKeyBackupDialog_keyStatus { + height: 30px; +} + .mx_RestoreKeyBackupDialog_primaryContainer { /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ padding: 20px; diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss new file mode 100644 index 0000000000..db11e91bdb --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss @@ -0,0 +1,34 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_AccessSecretStorageDialog_keyStatus { + height: 30px; +} + +.mx_AccessSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; +} + +.mx_AccessSecretStorageDialog_passPhraseInput, +.mx_AccessSecretStorageDialog_recoveryKeyInput { + width: 300px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; +} + diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss new file mode 100644 index 0000000000..757d8028f0 --- /dev/null +++ b/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss @@ -0,0 +1,88 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CreateSecretStorageDialog .mx_Dialog_title { + /* TODO: Consider setting this for all dialog titles. */ + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_primaryContainer { + /* FIXME: plinth colour in new theme(s). background-color: $accent-color; */ + padding: 20px; +} + +.mx_CreateSecretStorageDialog_primaryContainer::after { + content: ""; + clear: both; + display: block; +} + +.mx_CreateSecretStorageDialog_passPhraseContainer { + display: flex; + align-items: start; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp { + flex: 1; + height: 85px; + margin-left: 20px; + font-size: 80%; +} + +.mx_CreateSecretStorageDialog_passPhraseHelp progress { + width: 100%; +} + +.mx_CreateSecretStorageDialog_passPhraseInput { + flex: none; + width: 250px; + border: 1px solid $accent-color; + border-radius: 5px; + padding: 10px; + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_passPhraseMatch { + margin-left: 20px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyHeader { + margin-bottom: 1em; +} + +.mx_CreateSecretStorageDialog_recoveryKeyContainer { + display: flex; +} + +.mx_CreateSecretStorageDialog_recoveryKey { + width: 262px; + padding: 20px; + color: $info-plinth-fg-color; + background-color: $info-plinth-bg-color; + margin-right: 12px; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons { + flex: 1; + display: flex; + align-items: center; +} + +.mx_CreateSecretStorageDialog_recoveryKeyButtons button { + flex: 1; + white-space: nowrap; +} diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index df7d0a5f87..e87fe06a94 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -63,7 +63,6 @@ limitations under the License. .mx_UserInfo_avatar { margin: 24px 32px 0 32px; - cursor: pointer; } .mx_UserInfo_avatar > div { @@ -77,12 +76,27 @@ limitations under the License. that's why we had to put the margin to center on a parent div), and not a % of the parent height. */ padding-top: 100%; - height: 0; + position: relative; + } + + .mx_UserInfo_avatar > div > div * { border-radius: 100%; - box-sizing: content-box; - background-repeat: no-repeat; - background-size: cover; - background-position: center; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .mx_UserInfo_avatar .mx_BaseAvatar_initial { + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + + // override the calculated sizes so that the letter isn't HUGE + font-size: 26px !important; + width: 100% !important; } .mx_UserInfo_avatar .mx_BaseAvatar.mx_BaseAvatar_image { diff --git a/res/css/views/rooms/_E2EIcon.scss b/res/css/views/rooms/_E2EIcon.scss index cb99aa63f1..584ea17433 100644 --- a/res/css/views/rooms/_E2EIcon.scss +++ b/res/css/views/rooms/_E2EIcon.scss @@ -22,7 +22,9 @@ limitations under the License. display: block; } -.mx_E2EIcon_verified::after, .mx_E2EIcon_warning::after { +.mx_E2EIcon_warning::after, +.mx_E2EIcon_normal::after, +.mx_E2EIcon_verified::after { content: ""; display: block; position: absolute; @@ -34,10 +36,14 @@ limitations under the License. background-size: contain; } -.mx_E2EIcon_verified::after { - background-image: url('$(res)/img/e2e/verified.svg'); -} - .mx_E2EIcon_warning::after { background-image: url('$(res)/img/e2e/warning.svg'); } + +.mx_E2EIcon_normal::after { + background-image: url('$(res)/img/e2e/normal.svg'); +} + +.mx_E2EIcon_verified::after { + background-image: url('$(res)/img/e2e/verified.svg'); +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 43aed34a23..5359992f84 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -169,6 +169,7 @@ limitations under the License. .mx_EventTile:hover .mx_MessageActionBar, .mx_EventTile.mx_EventTile_actionBarFocused .mx_MessageActionBar, +[data-whatinput='keyboard'] .mx_EventTile:focus-within .mx_MessageActionBar, .mx_EventTile.focus-visible:focus-within .mx_MessageActionBar { visibility: visible; } diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss index 4495b142e6..022cf3ed28 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.scss +++ b/res/css/views/rooms/_LinkPreviewWidget.scss @@ -52,12 +52,18 @@ limitations under the License. } .mx_LinkPreviewWidget_cancel { - visibility: hidden; cursor: pointer; - flex: 0 0 40px; + width: 18px; + height: 18px; + + img { + flex: 0 0 40px; + visibility: hidden; + } } -.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel { +.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img, +.mx_LinkPreviewWidget_cancel.focus-visible:focus img { visibility: visible; } diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index 1814919b61..e5c7948216 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/res/css/views/rooms/_SearchBar.scss b/res/css/views/rooms/_SearchBar.scss index 894473a5fe..b6748e5ad2 100644 --- a/res/css/views/rooms/_SearchBar.scss +++ b/res/css/views/rooms/_SearchBar.scss @@ -37,6 +37,10 @@ limitations under the License. mask-position: center; } + .mx_SearchBar_buttons { + display: inherit; + } + .mx_SearchBar_button { border: 0; margin: 0 0 0 22px; diff --git a/res/css/views/dialogs/_RestoreKeyBackupDialog.scss b/res/css/views/rooms/_UserOnlineDot.scss similarity index 73% rename from res/css/views/dialogs/_RestoreKeyBackupDialog.scss rename to res/css/views/rooms/_UserOnlineDot.scss index 69e00c416a..339e5cc48a 100644 --- a/res/css/views/dialogs/_RestoreKeyBackupDialog.scss +++ b/res/css/views/rooms/_UserOnlineDot.scss @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RestoreKeyBackupDialog_keyStatus { - height: 30px; +.mx_UserOnlineDot { + border-radius: 50%; + background-color: $accent-color; + height: 5px; + width: 5px; + display: inline-block; } diff --git a/res/css/views/settings/_AvatarSetting.scss b/res/css/views/settings/_AvatarSetting.scss new file mode 100644 index 0000000000..35dba90f85 --- /dev/null +++ b/res/css/views/settings/_AvatarSetting.scss @@ -0,0 +1,87 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_AvatarSetting_avatar { + width: 88px; + height: 88px; + margin-left: 13px; + position: relative; + + & > * { + width: 88px; + box-sizing: border-box; + } + + .mx_AccessibleButton.mx_AccessibleButton_kind_primary { + margin-top: 8px; + + div { + position: relative; + height: 12px; + width: 12px; + display: inline; + padding-right: 6px; // 0.5 * 12px + left: -6px; // 0.5 * 12px + top: 3px; + } + + div::before { + content: ''; + position: absolute; + height: 12px; + width: 12px; + + background-color: $button-primary-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/feather-customised/upload.svg'); + } + } + + .mx_AccessibleButton.mx_AccessibleButton_kind_link_sm { + color: $button-danger-bg-color; + } + + & > img { + cursor: pointer; + object-fit: cover; + } + + & > img, + .mx_AvatarSetting_avatarPlaceholder { + display: block; + height: 88px; + border-radius: 4px; + } + + .mx_AvatarSetting_avatarPlaceholder::before { + background-color: $settings-profile-overlay-placeholder-fg-color; + mask: url("$(res)/img/feather-customised/user.svg"); + mask-repeat: no-repeat; + mask-size: 36px; + mask-position: center; + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + } +} + +.mx_AvatarSetting_avatar .mx_AvatarSetting_avatarPlaceholder { + background-color: $settings-profile-placeholder-bg-color; +} diff --git a/src/utils/withLegacyMatrixClient.js b/res/css/views/settings/_CrossSigningPanel.scss similarity index 52% rename from src/utils/withLegacyMatrixClient.js rename to res/css/views/settings/_CrossSigningPanel.scss index af6a930a88..fa9f76a963 100644 --- a/src/utils/withLegacyMatrixClient.js +++ b/res/css/views/settings/_CrossSigningPanel.scss @@ -14,18 +14,18 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import PropTypes from "prop-types"; -import {MatrixClient} from "matrix-js-sdk"; +.mx_CrossSigningPanel_statusList { + border-spacing: 0; -// Higher Order Component to allow use of legacy MatrixClient React Context -// in Functional Components which do not otherwise support legacy React Contexts -export default (Component) => class extends React.PureComponent { - static contextTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, - }; + td { + padding: 0; - render() { - return ; + &:first-of-type { + padding-inline-end: 1em; + } } -}; +} + +.mx_CrossSigningPanel_buttonRow { + margin: 1em 0; +} diff --git a/res/css/views/settings/_KeyBackupPanel.scss b/res/css/views/settings/_KeyBackupPanel.scss index 1bcc0ab10d..872162caad 100644 --- a/res/css/views/settings/_KeyBackupPanel.scss +++ b/res/css/views/settings/_KeyBackupPanel.scss @@ -1,5 +1,6 @@ /* Copyright 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -30,3 +31,7 @@ limitations under the License. .mx_KeyBackupPanel_deviceName { font-style: italic; } + +.mx_KeyBackupPanel_buttonRow { + margin: 1em 0; +} diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 432b713c1b..58624d1597 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -38,91 +38,6 @@ limitations under the License. } } -.mx_ProfileSettings_avatar { - width: 88px; - height: 88px; - margin-left: 13px; - position: relative; -} - -.mx_ProfileSettings_avatar > * { - display: block; - width: 88px; - height: 88px; - border-radius: 4px; -} - -.mx_ProfileSettings_avatar .mx_ProfileSettings_avatarOverlay_disabled { - cursor: default; -} - -.mx_ProfileSettings_avatar .mx_ProfileSettings_avatarPlaceholder { - background-color: $settings-profile-placeholder-bg-color; -} - -.mx_ProfileSettings_avatarOverlay { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - display: none; - text-align: center; - vertical-align: middle; - font-size: 10px; - cursor: pointer; -} - -.mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlay:not(.mx_ProfileSettings_avatarOverlay_disabled) { - display: inline-block; - opacity: 0.5 !important; - color: $settings-profile-overlay-fg-color !important; - background-color: $settings-profile-overlay-bg-color !important; -} - -.mx_ProfileSettings_avatarOverlay_show { - display: inline-block; - opacity: 1; - color: $settings-profile-overlay-placeholder-fg-color; - background-color: $settings-profile-overlay-placeholder-bg-color; -} - -.mx_ProfileSettings_avatarOverlayText { - display: block; - margin-top: 17px; - margin-bottom: 8px; -} - -.mx_ProfileSettings_noAvatarText { - display: block; - margin: 34px auto auto; -} - -.mx_ProfileSettings_avatarOverlayImgContainer { - position: relative; - width: 14px; - height: 14px; - margin: auto; -} - -.mx_ProfileSettings_avatarOverlayImg::before { - background-color: $settings-profile-overlay-placeholder-fg-color; - mask: url("$(res)/img/feather-customised/upload.svg"); - mask-repeat: no-repeat; - mask-size: 14px; - mask-position: center; - content: ''; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - -.mx_ProfileSettings_avatar:hover .mx_ProfileSettings_avatarOverlayImg::before { - background-color: $settings-profile-overlay-fg-color !important; -} - .mx_ProfileSettings_avatarUpload { display: none; } diff --git a/res/img/feather-customised/image.svg b/res/img/feather-customised/image.svg new file mode 100644 index 0000000000..9690aecf36 --- /dev/null +++ b/res/img/feather-customised/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/feather-customised/plus.svg b/res/img/feather-customised/plus.svg new file mode 100644 index 0000000000..c747253139 --- /dev/null +++ b/res/img/feather-customised/plus.svg @@ -0,0 +1,4 @@ + + + + diff --git a/scripts/emoji-data-strip.js b/scripts/emoji-data-strip.js deleted file mode 100644 index 1c3738cab1..0000000000 --- a/scripts/emoji-data-strip.js +++ /dev/null @@ -1,30 +0,0 @@ -#!/usr/bin/env node - -// This generates src/stripped-emoji.json as used by the EmojiProvider autocomplete -// provider. - -const EMOJIBASE = require('emojibase-data/en/compact.json'); - -const fs = require('fs'); - -const output = EMOJIBASE.map( - (datum) => { - const newDatum = { - name: datum.annotation, - shortname: `:${datum.shortcodes[0]}:`, - category: datum.group, - emoji_order: datum.order, - }; - if (datum.shortcodes.length > 1) { - newDatum.aliases = datum.shortcodes.slice(1).map(s => `:${s}:`); - } - if (datum.emoticon) { - newDatum.aliases_ascii = [ datum.emoticon ]; - } - return newDatum; - } -); - -// Write to a file in src. Changes should be checked into git. This file is copied by -// babel using --copy-files -fs.writeFileSync('./src/stripped-emoji.json', JSON.stringify(output)); diff --git a/src/ContentMessages.js b/src/ContentMessages.js index 6908a6a18e..0ce349f348 100644 --- a/src/ContentMessages.js +++ b/src/ContentMessages.js @@ -422,6 +422,9 @@ export default class ContentMessages { const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); let uploadAll = false; + // Promise to complete before sending next file into room, used for synchronisation of file-sending + // to match the order the files were specified in + let promBefore = Promise.resolve(); for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { @@ -440,11 +443,11 @@ export default class ContentMessages { }); if (!shouldContinue) break; } - this._sendContentToRoom(file, roomId, matrixClient); + promBefore = this._sendContentToRoom(file, roomId, matrixClient, promBefore); } } - _sendContentToRoom(file, roomId, matrixClient) { + _sendContentToRoom(file, roomId, matrixClient, promBefore) { const content = { body: file.name || 'Attachment', info: { @@ -517,7 +520,10 @@ export default class ContentMessages { content.file = result.file; content.url = result.url; }); - }).then(function(url) { + }).then((url) => { + // Await previous message being sent into the room + return promBefore; + }).then(function() { return matrixClient.sendMessage(roomId, content); }, function(err) { error = err; diff --git a/src/CrossSigningManager.js b/src/CrossSigningManager.js new file mode 100644 index 0000000000..ab0a22e4d5 --- /dev/null +++ b/src/CrossSigningManager.js @@ -0,0 +1,149 @@ +/* +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import Modal from './Modal'; +import sdk from './index'; +import MatrixClientPeg from './MatrixClientPeg'; +import { deriveKey } from 'matrix-js-sdk/lib/crypto/key_passphrase'; +import { decodeRecoveryKey } from 'matrix-js-sdk/lib/crypto/recoverykey'; +import { _t } from './languageHandler'; + +// This stores the secret storage private keys in memory for the JS SDK. This is +// only meant to act as a cache to avoid prompting the user multiple times +// during the same single operation. Use `accessSecretStorage` below to scope a +// single secret storage operation, as it will clear the cached keys once the +// operation ends. +let secretStorageKeys = {}; +let cachingAllowed = false; + +async function getSecretStorageKey({ keys: keyInfos }) { + const keyInfoEntries = Object.entries(keyInfos); + if (keyInfoEntries.length > 1) { + throw new Error("Multiple storage key requests not implemented"); + } + const [name, info] = keyInfoEntries[0]; + + // Check the in-memory cache + if (cachingAllowed && secretStorageKeys[name]) { + return [name, secretStorageKeys[name]]; + } + + const inputToKey = async ({ passphrase, recoveryKey }) => { + if (passphrase) { + return deriveKey( + passphrase, + info.passphrase.salt, + info.passphrase.iterations, + ); + } else { + return decodeRecoveryKey(recoveryKey); + } + }; + const AccessSecretStorageDialog = + sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); + const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", + AccessSecretStorageDialog, + { + keyInfo: info, + checkPrivateKey: async (input) => { + const key = await inputToKey(input); + return MatrixClientPeg.get().checkSecretStoragePrivateKey(key, info.pubkey); + }, + }, + ); + const [input] = await finished; + if (!input) { + throw new Error("Secret storage access canceled"); + } + const key = await inputToKey(input); + + // Save to cache to avoid future prompts in the current session + if (cachingAllowed) { + secretStorageKeys[name] = key; + } + + return [name, key]; +} + +export const crossSigningCallbacks = { + getSecretStorageKey, +}; + +/** + * This helper should be used whenever you need to access secret storage. It + * ensures that secret storage (and also cross-signing since they each depend on + * each other in a cycle of sorts) have been bootstrapped before running the + * provided function. + * + * Bootstrapping secret storage may take one of these paths: + * 1. Create secret storage from a passphrase and store cross-signing keys + * in secret storage. + * 2. Access existing secret storage by requesting passphrase and accessing + * cross-signing keys as needed. + * 3. All keys are loaded and there's nothing to do. + * + * Additionally, the secret storage keys are cached during the scope of this function + * to ensure the user is prompted only once for their secret storage + * passphrase. The cache is then + * + * @param {Function} [func] An operation to perform once secret storage has been + * bootstrapped. Optional. + */ +export async function accessSecretStorage(func = async () => { }) { + const cli = MatrixClientPeg.get(); + cachingAllowed = true; + + try { + if (!cli.hasSecretStorageKey()) { + // 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/secretstorage/CreateSecretStorageDialog"), + null, null, /* priority = */ false, /* static = */ true, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Secret storage creation canceled"); + } + } else { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + }); + } + + // `return await` needed here to ensure `finally` block runs after the + // inner operation completes. + return await func(); + } finally { + // Clear secret storage key cache now that work is complete + cachingAllowed = false; + secretStorageKeys = {}; + } +} diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 2b7384a5aa..7cdff26a21 100644 --- a/src/HtmlUtils.js +++ b/src/HtmlUtils.js @@ -32,9 +32,9 @@ import classNames from 'classnames'; import MatrixClientPeg from './MatrixClientPeg'; import url from 'url'; -import EMOJIBASE from 'emojibase-data/en/compact.json'; import EMOJIBASE_REGEX from 'emojibase-regex'; import {tryTransformPermalinkToLocalHref} from "./utils/permalinks/Permalinks"; +import {SHORTCODE_TO_EMOJI, getEmojiFromUnicode} from "./emoji"; linkifyMatrix(linkify); @@ -58,8 +58,6 @@ const COLOR_REGEX = /^#[0-9a-fA-F]{6}$/; const PERMITTED_URL_SCHEMES = ['http', 'https', 'ftp', 'mailto', 'magnet']; -const VARIATION_SELECTOR = String.fromCharCode(0xFE0F); - /* * Return true if the given string contains emoji * Uses a much, much simpler regex than emojibase's so will give false @@ -71,21 +69,6 @@ function mightContainEmoji(str) { return SURROGATE_PAIR_PATTERN.test(str) || SYMBOL_PATTERN.test(str); } -/** - * Find emoji data in emojibase by character. - * - * @param {String} char The emoji character - * @return {Object} The emoji data - */ -export function findEmojiData(char) { - // Check against both the char and the char with an empty variation selector - // appended because that's how emojibase stores its base emojis which have - // variations. - // See also https://github.com/vector-im/riot-web/issues/9785. - const emptyVariation = char + VARIATION_SELECTOR; - return EMOJIBASE.find(e => e.unicode === char || e.unicode === emptyVariation); -} - /** * Returns the shortcode for an emoji character. * @@ -93,7 +76,7 @@ export function findEmojiData(char) { * @return {String} The shortcode (such as :thumbup:) */ export function unicodeToShortcode(char) { - const data = findEmojiData(char); + const data = getEmojiFromUnicode(char); return (data && data.shortcodes ? `:${data.shortcodes[0]}:` : ''); } @@ -105,7 +88,7 @@ export function unicodeToShortcode(char) { */ export function shortcodeToUnicode(shortcode) { shortcode = shortcode.slice(1, shortcode.length - 1); - const data = EMOJIBASE.find(e => e.shortcodes && e.shortcodes.includes(shortcode)); + const data = SHORTCODE_TO_EMOJI.get(shortcode); return data ? data.unicode : null; } diff --git a/src/Keyboard.js b/src/Keyboard.js index 453ddab1e2..478d75acc1 100644 --- a/src/Keyboard.js +++ b/src/Keyboard.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,52 +16,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* a selection of key codes, as used in KeyboardEvent.keyCode */ -export const KeyCode = { - BACKSPACE: 8, - TAB: 9, - ENTER: 13, - SHIFT: 16, - ESCAPE: 27, - SPACE: 32, - PAGE_UP: 33, - PAGE_DOWN: 34, - END: 35, - HOME: 36, - LEFT: 37, - UP: 38, - RIGHT: 39, - DOWN: 40, - DELETE: 46, - KEY_A: 65, - KEY_B: 66, - KEY_C: 67, - KEY_D: 68, - KEY_E: 69, - KEY_F: 70, - KEY_G: 71, - KEY_H: 72, - KEY_I: 73, - KEY_J: 74, - KEY_K: 75, - KEY_L: 76, - KEY_M: 77, - KEY_N: 78, - KEY_O: 79, - KEY_P: 80, - KEY_Q: 81, - KEY_R: 82, - KEY_S: 83, - KEY_T: 84, - KEY_U: 85, - KEY_V: 86, - KEY_W: 87, - KEY_X: 88, - KEY_Y: 89, - KEY_Z: 90, - KEY_BACKTICK: 223, // DO NOT USE THIS: browsers disagree on backtick 192 vs 223 -}; - export const Key = { HOME: "Home", END: "End", @@ -80,13 +35,35 @@ export const Key = { SHIFT: "Shift", CONTEXT_MENU: "ContextMenu", + COMMA: ",", LESS_THAN: "<", GREATER_THAN: ">", BACKTICK: "`", SPACE: " ", + A: "a", B: "b", + C: "c", + D: "d", + E: "e", + F: "f", + G: "g", + H: "h", I: "i", + J: "j", K: "k", + L: "l", + M: "m", + N: "n", + O: "o", + P: "p", + Q: "q", + R: "r", + S: "s", + T: "t", + U: "u", + V: "v", + W: "w", + X: "x", Y: "y", Z: "z", }; diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index ef0130ec15..51ac7acb37 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -1,7 +1,8 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd. -Copyright 2017 New Vector Ltd +Copyright 2017, 2018, 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -30,6 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/lib/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; +import { crossSigningCallbacks } from './CrossSigningManager'; interface MatrixClientCreds { homeserverUrl: string, @@ -220,14 +222,9 @@ class MatrixClientPeg { identityServer: new IdentityAuthClient(), }; + opts.cryptoCallbacks = {}; if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { - // TODO: Cross-signing keys are temporarily in memory only. A - // separate task in the cross-signing project will build from here. - const keys = []; - opts.cryptoCallbacks = { - getCrossSigningKey: k => keys[k], - saveCrossSigningKeys: newKeys => Object.assign(keys, newKeys), - }; + Object.assign(opts.cryptoCallbacks, crossSigningCallbacks); } this.matrixClient = createMatrixClient(opts); diff --git a/src/TextForEvent.js b/src/TextForEvent.js index cd0c5cfc5f..c3c8396e26 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -19,6 +19,7 @@ import { _t } from './languageHandler'; import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; import SettingsStore from "./settings/SettingsStore"; +import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; function textForMemberEvent(ev) { // XXX: SYJS-16 "sender is sometimes null for join messages" @@ -506,6 +507,87 @@ function textForWidgetEvent(event) { } } +function textForMjolnirEvent(event) { + const senderName = event.getSender(); + const {entity: prevEntity} = event.getPrevContent(); + const {entity, recommendation, reason} = event.getContent(); + + // Rule removed + if (!entity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s removed the rule banning users matching %(glob)s", + {senderName, glob: prevEntity}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s removed the rule banning rooms matching %(glob)s", + {senderName, glob: prevEntity}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s removed the rule banning servers matching %(glob)s", + {senderName, glob: prevEntity}); + } + + // Unknown type. We'll say something, but we shouldn't end up here. + return _t("%(senderName)s removed a ban rule matching %(glob)s", {senderName, glob: prevEntity}); + } + + // Invalid rule + if (!recommendation || !reason) return _t(`%(senderName)s updated an invalid ban rule`, {senderName}); + + // Rule updated + if (entity === prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s updated the rule banning users matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s updated the rule banning rooms matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s updated the rule banning servers matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return _t("%(senderName)s updated a ban rule matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // New rule + if (!prevEntity) { + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s created a rule banning users matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s created a rule banning rooms matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s created a rule banning servers matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return _t("%(senderName)s created a ban rule matching %(glob)s for %(reason)s", + {senderName, glob: entity, reason}); + } + + // else the entity !== prevEntity - count as a removal & add + if (USER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s changed a rule that was banning users matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + } else if (ROOM_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s changed a rule that was banning rooms matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + } else if (SERVER_RULE_TYPES.includes(event.getType())) { + return _t("%(senderName)s changed a rule that was banning servers matching %(oldGlob)s to matching " + + "%(newGlob)s for %(reason)s", + {senderName, oldGlob: prevEntity, newGlob: entity, reason}); + } + + // Unknown type. We'll say something but we shouldn't end up here. + return _t("%(senderName)s updated a ban rule that was matching %(oldGlob)s to matching %(newGlob)s " + + "for %(reason)s", {senderName, oldGlob: prevEntity, newGlob: entity, reason}); +} + const handlers = { 'm.room.message': textForMessageEvent, 'm.call.invite': textForCallInviteEvent, @@ -533,6 +615,11 @@ const stateHandlers = { 'im.vector.modular.widgets': textForWidgetEvent, }; +// Add all the Mjolnir stuff to the renderer +for (const evType of ALL_RULE_TYPES) { + stateHandlers[evType] = textForMjolnirEvent; +} + module.exports = { textForEvent: function(ev) { const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index 145203136a..15bb1e046b 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import {Key} from "../../../Keyboard"; + const React = require("react"); import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; @@ -83,7 +85,7 @@ module.exports = createReactClass({ }, onKeyDown: function(e) { - if (e.keyCode === 27) { // escape + if (e.key === Key.ESCAPE) { e.stopPropagation(); e.preventDefault(); this.props.onFinished(false); diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/ExportE2eKeysDialog.js index 0fd412935a..ba2e985889 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ExportE2eKeysDialog.js @@ -15,7 +15,7 @@ limitations under the License. */ import FileSaver from 'file-saver'; -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; @@ -44,6 +44,9 @@ export default createReactClass({ componentWillMount: function() { this._unmounted = false; + + this._passphrase1 = createRef(); + this._passphrase2 = createRef(); }, componentWillUnmount: function() { @@ -53,8 +56,8 @@ export default createReactClass({ _onPassphraseFormSubmit: function(ev) { ev.preventDefault(); - const passphrase = this.refs.passphrase1.value; - if (passphrase !== this.refs.passphrase2.value) { + const passphrase = this._passphrase1.current.value; + if (passphrase !== this._passphrase2.current.value) { this.setState({errStr: _t('Passphrases must match')}); return false; } @@ -148,7 +151,7 @@ export default createReactClass({
- @@ -161,7 +164,7 @@ export default createReactClass({
- diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/ImportE2eKeysDialog.js index 17f3bba117..de9e819f5a 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/ImportE2eKeysDialog.js @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; @@ -56,6 +56,9 @@ export default createReactClass({ componentWillMount: function() { this._unmounted = false; + + this._file = createRef(); + this._passphrase = createRef(); }, componentWillUnmount: function() { @@ -63,15 +66,15 @@ export default createReactClass({ }, _onFormChange: function(ev) { - const files = this.refs.file.files || []; + const files = this._file.current.files || []; this.setState({ - enableSubmit: (this.refs.passphrase.value !== "" && files.length > 0), + enableSubmit: (this._passphrase.current.value !== "" && files.length > 0), }); }, _onFormSubmit: function(ev) { ev.preventDefault(); - this._startImport(this.refs.file.files[0], this.refs.passphrase.value); + this._startImport(this._file.current.files[0], this._passphrase.current.value); return false; }, @@ -146,7 +149,10 @@ export default createReactClass({
- @@ -159,8 +165,11 @@ export default createReactClass({
-
diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index 4953cdff68..3fac00c1b3 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -1,5 +1,6 @@ /* Copyright 2018, 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,13 +16,11 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; +import FileSaver from 'file-saver'; + import sdk from '../../../../index'; import MatrixClientPeg from '../../../../MatrixClientPeg'; import { scorePassword } from '../../../../utils/PasswordScorer'; - -import FileSaver from 'file-saver'; - import { _t } from '../../../../languageHandler'; const PHASE_PASSPHRASE = 0; @@ -45,13 +44,15 @@ function selectText(target) { selection.addRange(range); } -/** +/* * Walks the user through the process of creating an e2e key backup * on the server. */ -export default createReactClass({ - getInitialState: function() { - return { +export default class CreateKeyBackupDialog extends React.PureComponent { + constructor(props) { + super(props); + + this.state = { phase: PHASE_PASSPHRASE, passPhrase: '', passPhraseConfirm: '', @@ -60,25 +61,25 @@ export default createReactClass({ zxcvbnResult: null, setPassPhrase: false, }; - }, + } - componentWillMount: function() { + componentWillMount() { this._recoveryKeyNode = null; this._keyBackupInfo = null; this._setZxcvbnResultTimeout = null; - }, + } - componentWillUnmount: function() { + componentWillUnmount() { if (this._setZxcvbnResultTimeout !== null) { clearTimeout(this._setZxcvbnResultTimeout); } - }, + } - _collectRecoveryKeyNode: function(n) { + _collectRecoveryKeyNode = (n) => { this._recoveryKeyNode = n; - }, + } - _onCopyClick: function() { + _onCopyClick = () => { selectText(this._recoveryKeyNode); const successful = document.execCommand('copy'); if (successful) { @@ -87,9 +88,9 @@ export default createReactClass({ phase: PHASE_KEEPITSAFE, }); } - }, + } - _onDownloadClick: function() { + _onDownloadClick = () => { const blob = new Blob([this._keyBackupInfo.recovery_key], { type: 'text/plain;charset=us-ascii', }); @@ -99,9 +100,9 @@ export default createReactClass({ downloaded: true, phase: PHASE_KEEPITSAFE, }); - }, + } - _createBackup: async function() { + _createBackup = async () => { this.setState({ phase: PHASE_BACKINGUP, error: null, @@ -116,7 +117,7 @@ export default createReactClass({ phase: PHASE_DONE, }); } catch (e) { - console.log("Error creating key backup", e); + console.error("Error creating key backup", e); // TODO: If creating a version succeeds, but backup fails, should we // delete the version, disable backup, or do nothing? If we just // disable without deleting, we'll enable on next app reload since @@ -128,38 +129,38 @@ export default createReactClass({ error: e, }); } - }, + } - _onCancel: function() { + _onCancel = () => { this.props.onFinished(false); - }, + } - _onDone: function() { + _onDone = () => { this.props.onFinished(true); - }, + } - _onOptOutClick: function() { + _onOptOutClick = () => { this.setState({phase: PHASE_OPTOUT_CONFIRM}); - }, + } - _onSetUpClick: function() { + _onSetUpClick = () => { this.setState({phase: PHASE_PASSPHRASE}); - }, + } - _onSkipPassPhraseClick: async function() { + _onSkipPassPhraseClick = async () => { this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(); this.setState({ copied: false, downloaded: false, phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseNextClick: function() { + _onPassPhraseNextClick = () => { this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); - }, + } - _onPassPhraseKeyPress: async function(e) { + _onPassPhraseKeyPress = async (e) => { if (e.key === 'Enter') { // If we're waiting for the timeout before updating the result at this point, // skip ahead and do it now, otherwise we'll deny the attempt to proceed @@ -177,9 +178,9 @@ export default createReactClass({ this._onPassPhraseNextClick(); } } - }, + } - _onPassPhraseConfirmNextClick: async function() { + _onPassPhraseConfirmNextClick = async () => { this._keyBackupInfo = await MatrixClientPeg.get().prepareKeyBackupVersion(this.state.passPhrase); this.setState({ setPassPhrase: true, @@ -187,30 +188,30 @@ export default createReactClass({ downloaded: false, phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseConfirmKeyPress: function(e) { + _onPassPhraseConfirmKeyPress = (e) => { if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { this._onPassPhraseConfirmNextClick(); } - }, + } - _onSetAgainClick: function() { + _onSetAgainClick = () => { this.setState({ passPhrase: '', passPhraseConfirm: '', phase: PHASE_PASSPHRASE, zxcvbnResult: null, }); - }, + } - _onKeepItSafeBackClick: function() { + _onKeepItSafeBackClick = () => { this.setState({ phase: PHASE_SHOWKEY, }); - }, + } - _onPassPhraseChange: function(e) { + _onPassPhraseChange = (e) => { this.setState({ passPhrase: e.target.value, }); @@ -227,19 +228,19 @@ export default createReactClass({ zxcvbnResult: scorePassword(this.state.passPhrase), }); }, PASSPHRASE_FEEDBACK_DELAY); - }, + } - _onPassPhraseConfirmChange: function(e) { + _onPassPhraseConfirmChange = (e) => { this.setState({ passPhraseConfirm: e.target.value, }); - }, + } - _passPhraseIsValid: function() { + _passPhraseIsValid() { return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; - }, + } - _renderPhasePassPhrase: function() { + _renderPhasePassPhrase() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); let strengthMeter; @@ -266,7 +267,7 @@ export default createReactClass({ return

{_t( - "Warning: you should only set up key backup from a trusted computer.", {}, + "Warning: You should only set up key backup from a trusted computer.", {}, { b: sub => {sub} }, )}

{_t( @@ -305,9 +306,9 @@ export default createReactClass({

; - }, + } - _renderPhasePassPhraseConfirm: function() { + _renderPhasePassPhraseConfirm() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); let matchText; @@ -361,9 +362,9 @@ export default createReactClass({ disabled={this.state.passPhrase !== this.state.passPhraseConfirm} /> ; - }, + } - _renderPhaseShowKey: function() { + _renderPhaseShowKey() { let bodyText; if (this.state.setPassPhrase) { bodyText = _t( @@ -380,7 +381,7 @@ export default createReactClass({ "access to your encrypted messages if you forget your passphrase.", )}

{_t( - "Keep your recovery key somewhere very secure, like a password manager (or a safe)", + "Keep your recovery key somewhere very secure, like a password manager (or a safe).", )}

{bodyText}

@@ -402,18 +403,18 @@ export default createReactClass({
; - }, + } - _renderPhaseKeepItSafe: function() { + _renderPhaseKeepItSafe() { let introText; if (this.state.copied) { introText = _t( - "Your Recovery Key has been copied to your clipboard, paste it to:", + "Your recovery key has been copied to your clipboard, paste it to:", {}, {b: s => {s}}, ); } else if (this.state.downloaded) { introText = _t( - "Your Recovery Key is in your Downloads folder.", + "Your recovery key is in your Downloads folder.", {}, {b: s => {s}}, ); } @@ -431,16 +432,16 @@ export default createReactClass({ ; - }, + } - _renderBusyPhase: function(text) { + _renderBusyPhase(text) { const Spinner = sdk.getComponent('views.elements.Spinner'); return
; - }, + } - _renderPhaseDone: function() { + _renderPhaseDone() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

{_t( @@ -451,9 +452,9 @@ export default createReactClass({ hasCancel={false} />

; - }, + } - _renderPhaseOptOutConfirm: function() { + _renderPhaseOptOutConfirm() { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return
{_t( @@ -467,9 +468,9 @@ export default createReactClass({
; - }, + } - _titleForPhase: function(phase) { + _titleForPhase(phase) { switch (phase) { case PHASE_PASSPHRASE: return _t('Secure your backup with a passphrase'); @@ -488,9 +489,9 @@ export default createReactClass({ default: return _t("Create Key Backup"); } - }, + } - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); let content; @@ -543,5 +544,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js new file mode 100644 index 0000000000..25bc8cdfda --- /dev/null +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -0,0 +1,611 @@ +/* +Copyright 2018, 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import sdk from '../../../../index'; +import MatrixClientPeg from '../../../../MatrixClientPeg'; +import { scorePassword } from '../../../../utils/PasswordScorer'; +import FileSaver from 'file-saver'; +import { _t } from '../../../../languageHandler'; +import Modal from '../../../../Modal'; + +const PHASE_LOADING = 0; +const PHASE_MIGRATE = 1; +const PHASE_PASSPHRASE = 2; +const PHASE_PASSPHRASE_CONFIRM = 3; +const PHASE_SHOWKEY = 4; +const PHASE_KEEPITSAFE = 5; +const PHASE_STORING = 6; +const PHASE_DONE = 7; +const PHASE_OPTOUT_CONFIRM = 8; + +const PASSWORD_MIN_SCORE = 4; // So secure, many characters, much complex, wow, etc, etc. +const PASSPHRASE_FEEDBACK_DELAY = 500; // How long after keystroke to offer passphrase feedback, ms. + +// XXX: copied from ShareDialog: factor out into utils +function selectText(target) { + const range = document.createRange(); + range.selectNodeContents(target); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); +} + +/* + * 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 { + constructor(props) { + super(props); + + this._keyInfo = null; + this._encodedRecoveryKey = null; + this._recoveryKeyNode = null; + this._setZxcvbnResultTimeout = null; + + this.state = { + phase: PHASE_LOADING, + passPhrase: '', + passPhraseConfirm: '', + copied: false, + downloaded: false, + zxcvbnResult: null, + setPassPhrase: false, + }; + + this._fetchBackupInfo(); + } + + componentWillUnmount() { + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + } + + async _fetchBackupInfo() { + const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); + + this.setState({ + phase: backupInfo ? PHASE_MIGRATE: PHASE_PASSPHRASE, + backupInfo, + }); + } + + _collectRecoveryKeyNode = (n) => { + this._recoveryKeyNode = n; + } + + _onMigrateNextClick = () => { + this._bootstrapSecretStorage(); + } + + _onCopyClick = () => { + selectText(this._recoveryKeyNode); + const successful = document.execCommand('copy'); + if (successful) { + this.setState({ + copied: true, + phase: PHASE_KEEPITSAFE, + }); + } + } + + _onDownloadClick = () => { + const blob = new Blob([this._encodedRecoveryKey], { + type: 'text/plain;charset=us-ascii', + }); + FileSaver.saveAs(blob, 'recovery-key.txt'); + + this.setState({ + downloaded: true, + phase: PHASE_KEEPITSAFE, + }); + } + + _bootstrapSecretStorage = async () => { + this.setState({ + phase: PHASE_STORING, + error: null, + }); + const cli = MatrixClientPeg.get(); + try { + const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); + await cli.bootstrapSecretStorage({ + authUploadDeviceSigningKeys: async (makeRequest) => { + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Send cross-signing keys to homeserver"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + }, + createSecretStorageKey: async () => this._keyInfo, + keyBackupInfo: this.state.backupInfo, + }); + this.setState({ + phase: PHASE_DONE, + }); + } catch (e) { + this.setState({ error: e }); + console.error("Error bootstrapping secret storage", e); + } + } + + _onCancel = () => { + this.props.onFinished(false); + } + + _onDone = () => { + this.props.onFinished(true); + } + + _onOptOutClick = () => { + this.setState({phase: PHASE_OPTOUT_CONFIRM}); + } + + _onSetUpClick = () => { + this.setState({phase: PHASE_PASSPHRASE}); + } + + _onSkipPassPhraseClick = async () => { + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseNextClick = () => { + this.setState({phase: PHASE_PASSPHRASE_CONFIRM}); + } + + _onPassPhraseKeyPress = async (e) => { + if (e.key === 'Enter') { + // If we're waiting for the timeout before updating the result at this point, + // skip ahead and do it now, otherwise we'll deny the attempt to proceed + // even if the user entered a valid passphrase + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + this._setZxcvbnResultTimeout = null; + await new Promise((resolve) => { + this.setState({ + zxcvbnResult: scorePassword(this.state.passPhrase), + }, resolve); + }); + } + if (this._passPhraseIsValid()) { + this._onPassPhraseNextClick(); + } + } + } + + _onPassPhraseConfirmNextClick = async () => { + const [keyInfo, encodedRecoveryKey] = + await MatrixClientPeg.get().createRecoveryKeyFromPassphrase(this.state.passPhrase); + this._keyInfo = keyInfo; + this._encodedRecoveryKey = encodedRecoveryKey; + this.setState({ + setPassPhrase: true, + copied: false, + downloaded: false, + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseConfirmKeyPress = (e) => { + if (e.key === 'Enter' && this.state.passPhrase === this.state.passPhraseConfirm) { + this._onPassPhraseConfirmNextClick(); + } + } + + _onSetAgainClick = () => { + this.setState({ + passPhrase: '', + passPhraseConfirm: '', + phase: PHASE_PASSPHRASE, + zxcvbnResult: null, + }); + } + + _onKeepItSafeBackClick = () => { + this.setState({ + phase: PHASE_SHOWKEY, + }); + } + + _onPassPhraseChange = (e) => { + this.setState({ + passPhrase: e.target.value, + }); + + if (this._setZxcvbnResultTimeout !== null) { + clearTimeout(this._setZxcvbnResultTimeout); + } + this._setZxcvbnResultTimeout = setTimeout(() => { + this._setZxcvbnResultTimeout = null; + this.setState({ + // precompute this and keep it in state: zxcvbn is fast but + // we use it in a couple of different places so no point recomputing + // it unnecessarily. + zxcvbnResult: scorePassword(this.state.passPhrase), + }); + }, PASSPHRASE_FEEDBACK_DELAY); + } + + _onPassPhraseConfirmChange = (e) => { + this.setState({ + passPhraseConfirm: e.target.value, + }); + } + + _passPhraseIsValid() { + return this.state.zxcvbnResult && this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE; + } + + _renderPhaseMigrate() { + // 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/riot-web/issues/11696 + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+

{_t( + "Secret Storage will be set up using your existing key backup details." + + "Your secret storage passphrase and recovery key will be the same as " + + " they were for your key backup", + )}

+ +
; + } + + _renderPhasePassPhrase() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + + let strengthMeter; + let helpText; + if (this.state.zxcvbnResult) { + if (this.state.zxcvbnResult.score >= PASSWORD_MIN_SCORE) { + helpText = _t("Great! This passphrase looks strong enough."); + } else { + const suggestions = []; + for (let i = 0; i < this.state.zxcvbnResult.feedback.suggestions.length; ++i) { + suggestions.push(
{this.state.zxcvbnResult.feedback.suggestions[i]}
); + } + const suggestionBlock =
{suggestions.length > 0 ? suggestions : _t("Keep going...")}
; + + helpText =
+ {this.state.zxcvbnResult.feedback.warning} + {suggestionBlock} +
; + } + strengthMeter =
+ +
; + } + + return
+

{_t( + "Warning: You should only set up secret storage from a trusted computer.", {}, + { b: sub => {sub} }, + )}

+

{_t( + "We'll use secret storage to optionally store an encrypted copy of " + + "your cross-signing identity for verifying other devices and message " + + "keys on our server. Protect your access to encrypted messages with a " + + "passphrase to keep it secure.", + )}

+

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

+ +
+
+ +
+ {strengthMeter} + {helpText} +
+
+
+ + + +
+ {_t("Advanced")} +

+
+
; + } + + _renderPhasePassPhraseConfirm() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + + let matchText; + if (this.state.passPhraseConfirm === this.state.passPhrase) { + matchText = _t("That matches!"); + } else if (!this.state.passPhrase.startsWith(this.state.passPhraseConfirm)) { + // only tell them they're wrong if they've actually gone wrong. + // Security concious readers will note that if you left riot-web unattended + // on this screen, this would make it easy for a malicious person to guess + // your passphrase one letter at a time, but they could get this faster by + // just opening the browser's developer tools and reading it. + // Note that not having typed anything at all will not hit this clause and + // fall through so empty box === no hint. + matchText = _t("That doesn't match."); + } + + let passPhraseMatch = null; + if (matchText) { + passPhraseMatch =
+
{matchText}
+
+ + {_t("Go back to set it again.")} + +
+
; + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+

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

+
+
+
+ +
+ {passPhraseMatch} +
+
+ +
; + } + + _renderPhaseShowKey() { + let bodyText; + if (this.state.setPassPhrase) { + bodyText = _t( + "As a safety net, you can use it to restore your access to encrypted " + + "messages if you forget your passphrase.", + ); + } else { + bodyText = _t( + "As a safety net, you can use it to restore your access to encrypted " + + "messages.", + ); + } + + return
+

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

+

{_t( + "Keep your recovery key somewhere very secure, like a password manager (or a safe).", + )}

+

{bodyText}

+
+
+ {_t("Your Recovery Key")} +
+
+
+ {this._encodedRecoveryKey} +
+
+ + +
+
+
+
; + } + + _renderPhaseKeepItSafe() { + let introText; + if (this.state.copied) { + introText = _t( + "Your recovery key has been copied to your clipboard, paste it to:", + {}, {b: s => {s}}, + ); + } else if (this.state.downloaded) { + introText = _t( + "Your recovery key is in your Downloads folder.", + {}, {b: s => {s}}, + ); + } + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {introText} +
    +
  • {_t("Print it and store it somewhere safe", {}, {b: s => {s}})}
  • +
  • {_t("Save it on a USB key or backup drive", {}, {b: s => {s}})}
  • +
  • {_t("Copy it to your personal cloud storage", {}, {b: s => {s}})}
  • +
+ + + +
; + } + + _renderBusyPhase() { + const Spinner = sdk.getComponent('views.elements.Spinner'); + return
+ +
; + } + + _renderPhaseDone() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+

{_t( + "Your access to encrypted messages is now protected.", + )}

+ +
; + } + + _renderPhaseOptOutConfirm() { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + return
+ {_t( + "Without setting up secret storage, you won't be able to restore your " + + "access to encrypted messages or your cross-signing identity for " + + "verifying other devices if you log out or use another device.", + )} + + + +
; + } + + _titleForPhase(phase) { + switch (phase) { + case PHASE_MIGRATE: + return _t('Migrate from Key Backup'); + case PHASE_PASSPHRASE: + return _t('Secure your encrypted messages with a passphrase'); + case PHASE_PASSPHRASE_CONFIRM: + return _t('Confirm your passphrase'); + case PHASE_OPTOUT_CONFIRM: + return _t('Warning!'); + case PHASE_SHOWKEY: + return _t('Recovery key'); + case PHASE_KEEPITSAFE: + return _t('Keep it safe'); + case PHASE_STORING: + return _t('Storing secrets...'); + case PHASE_DONE: + return _t('Success!'); + default: + return null; + } + } + + render() { + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + + let content; + if (this.state.error) { + const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); + content =
+

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

+
+ +
+
; + } else { + switch (this.state.phase) { + case PHASE_LOADING: + content = this._renderBusyPhase(); + break; + case PHASE_MIGRATE: + content = this._renderPhaseMigrate(); + break; + case PHASE_PASSPHRASE: + content = this._renderPhasePassPhrase(); + break; + case PHASE_PASSPHRASE_CONFIRM: + content = this._renderPhasePassPhraseConfirm(); + break; + case PHASE_SHOWKEY: + content = this._renderPhaseShowKey(); + break; + case PHASE_KEEPITSAFE: + content = this._renderPhaseKeepItSafe(); + break; + case PHASE_STORING: + content = this._renderBusyPhase(); + break; + case PHASE_DONE: + content = this._renderPhaseDone(); + break; + case PHASE_OPTOUT_CONFIRM: + content = this._renderPhaseOptOutConfirm(); + break; + } + } + + return ( + +
+ {content} +
+
+ ); + } +} diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 1e39593022..9373ed662e 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -2,6 +2,7 @@ Copyright 2016 Aviral Dasgupta Copyright 2017 Vector Creations Ltd Copyright 2017, 2018 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -28,7 +29,7 @@ import SettingsStore from "../settings/SettingsStore"; import { shortcodeToUnicode } from '../HtmlUtils'; import EMOTICON_REGEX from 'emojibase-regex/emoticon'; -import EmojiData from '../stripped-emoji.json'; +import EMOJIBASE from 'emojibase-data/en/compact.json'; const LIMIT = 20; @@ -38,19 +39,15 @@ const EMOJI_REGEX = new RegExp('(' + EMOTICON_REGEX.source + '|:[+-\\w]*:?)$', ' // XXX: it's very unclear why we bother with this generated emojidata file. // all it means is that we end up bloating the bundle with precomputed stuff // which would be trivial to calculate and cache on demand. -const EMOJI_SHORTNAMES = Object.keys(EmojiData).map((key) => EmojiData[key]).sort( - (a, b) => { - if (a.category === b.category) { - return a.emoji_order - b.emoji_order; - } - return a.category - b.category; - }, -).map((a, index) => { +const EMOJI_SHORTNAMES = EMOJIBASE.sort((a, b) => { + if (a.group === b.group) { + return a.order - b.order; + } + return a.group - b.group; +}).map((emoji, index) => { return { - name: a.name, - shortname: a.shortname, - aliases: a.aliases ? a.aliases.join(' ') : '', - aliases_ascii: a.aliases_ascii ? a.aliases_ascii.join(' ') : '', + emoji, + shortname: `:${emoji.shortcodes[0]}:`, // Include the index so that we can preserve the original order _orderBy: index, }; @@ -69,12 +66,15 @@ export default class EmojiProvider extends AutocompleteProvider { constructor() { super(EMOJI_REGEX); this.matcher = new QueryMatcher(EMOJI_SHORTNAMES, { - keys: ['aliases_ascii', 'shortname', 'aliases'], + keys: ['emoji.emoticon', 'shortname'], + funcs: [ + (o) => o.emoji.shortcodes.length > 1 ? o.emoji.shortcodes.slice(1).map(s => `:${s}:`).join(" ") : "", // aliases + ], // For matching against ascii equivalents shouldMatchWordsOnly: false, }); this.nameMatcher = new QueryMatcher(EMOJI_SHORTNAMES, { - keys: ['name'], + keys: ['emoji.annotation'], // For removing punctuation shouldMatchWordsOnly: true, }); @@ -96,7 +96,7 @@ export default class EmojiProvider extends AutocompleteProvider { const sorters = []; // make sure that emoticons come first - sorters.push((c) => score(matchedString, c.aliases_ascii)); + sorters.push((c) => score(matchedString, c.emoji.emoticon || "")); // then sort by score (Infinity if matchedString not in shortname) sorters.push((c) => score(matchedString, c.shortname)); @@ -110,8 +110,7 @@ export default class EmojiProvider extends AutocompleteProvider { sorters.push((c) => c._orderBy); completions = _sortBy(_uniq(completions), sorters); - completions = completions.map((result) => { - const { shortname } = result; + completions = completions.map(({shortname}) => { const unicode = shortcodeToUnicode(shortname); return { completion: unicode, diff --git a/src/autocomplete/QueryMatcher.js b/src/autocomplete/QueryMatcher.js index a28d3003cf..ef1605e7a6 100644 --- a/src/autocomplete/QueryMatcher.js +++ b/src/autocomplete/QueryMatcher.js @@ -71,6 +71,7 @@ export default class QueryMatcher { } for (const keyValue of keyValues) { + if (!keyValue) continue; // skip falsy keyValues const key = stripDiacritics(keyValue).toLowerCase(); if (!this._items.has(key)) { this._items.set(key, []); diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index 455deb4708..662972ee37 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -70,10 +70,13 @@ export class ContextMenu extends React.Component { // on resize callback windowResize: PropTypes.func, + + managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself }; static defaultProps = { hasBackground: true, + managed: true, }; constructor() { @@ -183,6 +186,15 @@ export class ContextMenu extends React.Component { }; _onKeyDown = (ev) => { + if (!this.props.managed) { + if (ev.key === Key.ESCAPE) { + this.props.onFinished(); + ev.stopPropagation(); + ev.preventDefault(); + } + return; + } + let handled = true; switch (ev.key) { @@ -313,7 +325,7 @@ export class ContextMenu extends React.Component { return (
-
+
{ chevron } { props.children }
@@ -411,7 +423,7 @@ export const toRightOf = (elementRect, chevronOffset=12) => { const left = elementRect.right + window.pageXOffset + 3; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; top -= chevronOffset + 8; // where 8 is half the height of the chevron - return {left, top}; + return {left, top, chevronOffset}; }; // Placement method for to position context menu right-aligned and flowing to the left of elementRect diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js index ee69d800ed..f1b548d72f 100644 --- a/src/components/structures/CustomRoomTagPanel.js +++ b/src/components/structures/CustomRoomTagPanel.js @@ -61,30 +61,13 @@ class CustomRoomTagPanel extends React.Component { } class CustomRoomTagTile extends React.Component { - constructor(props) { - super(props); - this.state = {hover: false}; - this.onClick = this.onClick.bind(this); - this.onMouseOut = this.onMouseOut.bind(this); - this.onMouseOver = this.onMouseOver.bind(this); - } - - onMouseOver() { - this.setState({hover: true}); - } - - onMouseOut() { - this.setState({hover: false}); - } - - onClick() { + onClick = () => { dis.dispatch({action: 'select_custom_room_tag', tag: this.props.tag.name}); - } + }; render() { const BaseAvatar = sdk.getComponent('avatars.BaseAvatar'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const Tooltip = sdk.getComponent('elements.Tooltip'); + const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton'); const tag = this.props.tag; const avatarHeight = 40; @@ -102,12 +85,9 @@ class CustomRoomTagTile extends React.Component { badgeElement = (
{FormattingUtils.formatCount(badge.count)}
); } - const tip = (this.state.hover ? - : -
); return ( - -
+ +
{ badgeElement } - { tip }
- +
); } } diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index ecc01a443d..63767255e2 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -26,8 +26,8 @@ import sanitizeHtml from 'sanitize-html'; import sdk from '../../index'; import dis from '../../dispatcher'; import MatrixClientPeg from '../../MatrixClientPeg'; -import { MatrixClient } from 'matrix-js-sdk'; import classnames from 'classnames'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; export default class EmbeddedPage extends React.PureComponent { static propTypes = { @@ -39,9 +39,7 @@ export default class EmbeddedPage extends React.PureComponent { scrollbar: PropTypes.bool, }; - static contextTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient), - }; + static contextType = MatrixClientContext; constructor(props) { super(props); @@ -104,7 +102,7 @@ export default class EmbeddedPage extends React.PureComponent { render() { // HACK: Workaround for the context's MatrixClient not updating. - const client = this.context.matrixClient || MatrixClientPeg.get(); + const client = this.context || MatrixClientPeg.get(); const isGuest = client ? client.isGuest() : true; const className = this.props.className; const classes = classnames({ diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index a0aa36803f..9df4630136 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -38,6 +38,7 @@ import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; import {Group} from "matrix-js-sdk"; import {allSettled, sleep} from "../../utils/promise"; +import RightPanelStore from "../../stores/RightPanelStore"; const LONG_DESC_PLACEHOLDER = _td( `

HTML for your community's page

@@ -542,10 +543,6 @@ export default createReactClass({ }); }, - _onShowRhsClick: function(ev) { - dis.dispatch({ action: 'show_right_panel' }); - }, - _onEditClick: function() { this.setState({ editing: true, @@ -583,6 +580,10 @@ export default createReactClass({ profileForm: null, }); break; + case 'after_right_panel_phase_change': + // We don't keep state on the right panel, so just re-render to update + this.forceUpdate(); + break; default: break; } @@ -1214,25 +1215,25 @@ export default createReactClass({ const EditableText = sdk.getComponent("elements.EditableText"); - nameNode = ; + nameNode = ; - shortDescNode = ; + shortDescNode = ; } else { const onGroupHeaderItemClick = this.state.isUserMember ? this._onEditClick : null; const groupAvatarUrl = summary.profile ? summary.profile.avatar_url : null; @@ -1298,7 +1299,9 @@ export default createReactClass({ ); } - const rightPanel = !this.props.collapsedRhs ? : undefined; + const rightPanel = !RightPanelStore.getSharedInstance().isOpenForGroup + ? + : undefined; const headerClasses = { "mx_GroupView_header": true, @@ -1326,9 +1329,9 @@ export default createReactClass({
{ rightButtons }
- +
- + { this._getMembershipSection() } { this._getGroupSection() } diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index e1b02f653b..1981310a2f 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -18,7 +18,7 @@ limitations under the License. import Matrix from 'matrix-js-sdk'; const InteractiveAuth = Matrix.InteractiveAuth; -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; @@ -129,6 +129,8 @@ export default createReactClass({ this._authLogic.poll(); }, 2000); } + + this._stageComponent = createRef(); }, componentWillUnmount: function() { @@ -153,8 +155,8 @@ export default createReactClass({ }, tryContinue: function() { - if (this.refs.stageComponent && this.refs.stageComponent.tryContinue) { - this.refs.stageComponent.tryContinue(); + if (this._stageComponent.current && this._stageComponent.current.tryContinue) { + this._stageComponent.current.tryContinue(); } }, @@ -192,8 +194,8 @@ export default createReactClass({ }, _setFocus: function() { - if (this.refs.stageComponent && this.refs.stageComponent.focus) { - this.refs.stageComponent.focus(); + if (this._stageComponent.current && this._stageComponent.current.focus) { + this._stageComponent.current.focus(); } }, @@ -214,7 +216,8 @@ export default createReactClass({ const StageComponent = getEntryComponentForLoginType(stage); return ( - { isCustomTagsEnabled ? : undefined } -
); } diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index ae71af1a85..7261af3bf0 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -17,7 +17,7 @@ limitations under the License. */ import { MatrixClient } from 'matrix-js-sdk'; -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { DragDropContext } from 'react-beautiful-dnd'; @@ -38,6 +38,7 @@ import TagOrderActions from '../../actions/TagOrderActions'; import RoomListActions from '../../actions/RoomListActions'; import ResizeHandle from '../views/elements/ResizeHandle'; import {Resizer, CollapseDistributor} from '../../resizer'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. // NB. this is just for server notices rather than pinned messages in general. @@ -70,7 +71,6 @@ const LoggedInView = createReactClass({ // Called with the credentials of a registered user (if they were a ROU that // transitioned to PWLU) onRegistered: PropTypes.func, - collapsedRhs: PropTypes.bool, // Used by the RoomView to handle joining rooms viaServers: PropTypes.arrayOf(PropTypes.string), @@ -78,21 +78,6 @@ const LoggedInView = createReactClass({ // and lots and lots of other stuff. }, - childContextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient), - authCache: PropTypes.object, - }, - - getChildContext: function() { - return { - matrixClient: this._matrixClient, - authCache: { - auth: {}, - lastUpdate: 0, - }, - }; - }, - getInitialState: function() { return { // use compact timeline view @@ -129,6 +114,8 @@ const LoggedInView = createReactClass({ this._matrixClient.on("RoomState.events", this.onRoomStateEvents); fixupColorFonts(); + + this._roomView = createRef(); }, componentDidUpdate(prevProps) { @@ -165,10 +152,10 @@ const LoggedInView = createReactClass({ }, canResetTimelineInRoom: function(roomId) { - if (!this.refs.roomView) { + if (!this._roomView.current) { return true; } - return this.refs.roomView.canResetTimeline(); + return this._roomView.current.canResetTimeline(); }, _setStateFromSessionStore() { @@ -428,8 +415,8 @@ const LoggedInView = createReactClass({ * @param {Object} ev The key event */ _onScrollKeyPressed: function(ev) { - if (this.refs.roomView) { - this.refs.roomView.handleScrollKey(ev); + if (this._roomView.current) { + this._roomView.current.handleScrollKey(ev); } }, @@ -543,7 +530,7 @@ const LoggedInView = createReactClass({ switch (this.props.page_type) { case PageTypes.RoomView: pageElement = ; @@ -583,7 +569,6 @@ const LoggedInView = createReactClass({ pageElement = ; break; } @@ -632,21 +617,30 @@ const LoggedInView = createReactClass({ } return ( -
- { topBar } - - -
- - - { pageElement } -
-
-
+ +
+ { topBar } + + +
+ + + { pageElement } +
+
+
+
); }, }); diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 163755ff1a..772be358cf 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -62,7 +62,7 @@ export default class MainSplit extends React.Component { } componentDidMount() { - if (this.props.panel && !this.props.collapsedRhs) { + if (this.props.panel) { this._createResizer(); } } @@ -75,17 +75,15 @@ export default class MainSplit extends React.Component { } componentDidUpdate(prevProps) { - const wasExpanded = !this.props.collapsedRhs && prevProps.collapsedRhs; - const wasCollapsed = this.props.collapsedRhs && !prevProps.collapsedRhs; const wasPanelSet = this.props.panel && !prevProps.panel; const wasPanelCleared = !this.props.panel && prevProps.panel; - if (this.resizeContainer && (wasExpanded || wasPanelSet)) { + if (this.resizeContainer && wasPanelSet) { // The resizer can only be created when **both** expanded and the panel is // set. Once both are true, the container ref will mount, which is required // for the resizer to work. this._createResizer(); - } else if (this.resizer && (wasCollapsed || wasPanelCleared)) { + } else if (this.resizer && wasPanelCleared) { this.resizer.detach(); this.resizer = null; } diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 585499ddeb..fad57f5d52 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -24,6 +24,8 @@ import Matrix from "matrix-js-sdk"; // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss import 'focus-visible'; +// what-input helps improve keyboard accessibility +import 'what-input'; import Analytics from "../../Analytics"; import { DecryptionFailureTracker } from "../../DecryptionFailureTracker"; @@ -148,16 +150,6 @@ export default createReactClass({ makeRegistrationUrl: PropTypes.func.isRequired, }, - childContextTypes: { - appConfig: PropTypes.object, - }, - - getChildContext: function() { - return { - appConfig: this.props.config, - }; - }, - getInitialState: function() { const s = { // the master view we are showing. @@ -175,10 +167,9 @@ export default createReactClass({ viewUserId: null, // this is persisted as mx_lhs_size, loaded in LoggedInView collapseLhs: false, - collapsedRhs: window.localStorage.getItem("mx_rhs_collapsed") === "true", leftDisabled: false, middleDisabled: false, - rightDisabled: false, + // the right panel's disabled state is tracked in its store. version: null, newVersion: null, @@ -657,23 +648,11 @@ export default createReactClass({ collapseLhs: false, }); break; - case 'hide_right_panel': - window.localStorage.setItem("mx_rhs_collapsed", true); - this.setState({ - collapsedRhs: true, - }); - break; - case 'show_right_panel': - window.localStorage.setItem("mx_rhs_collapsed", false); - this.setState({ - collapsedRhs: false, - }); - break; case 'panel_disable': { this.setState({ leftDisabled: payload.leftDisabled || payload.sideDisabled || false, middleDisabled: payload.middleDisabled || false, - rightDisabled: payload.rightDisabled || payload.sideDisabled || false, + // We don't track the right panel being disabled here - it's tracked in the store. }); break; } @@ -1245,7 +1224,6 @@ export default createReactClass({ view: VIEWS.LOGIN, ready: false, collapseLhs: false, - collapsedRhs: false, currentRoomId: null, }); this.subTitleStatus = ''; @@ -1261,7 +1239,6 @@ export default createReactClass({ view: VIEWS.SOFT_LOGOUT, ready: false, collapseLhs: false, - collapsedRhs: false, currentRoomId: null, }); this.subTitleStatus = ''; @@ -1479,7 +1456,7 @@ export default createReactClass({ } }); - if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { cli.on("crypto.verification.request", request => { let requestObserver; if (request.event.getRoomId()) { @@ -1505,7 +1482,7 @@ export default createReactClass({ const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog"); Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { verifier, - }); + }, null, /* priority = */ false, /* static = */ true); }); } // Fire the tinter right on startup to ensure the default theme is applied @@ -1705,8 +1682,6 @@ export default createReactClass({ handleResize: function(e) { const hideLhsThreshold = 1000; const showLhsThreshold = 1000; - const hideRhsThreshold = 820; - const showRhsThreshold = 820; if (this._windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { dis.dispatch({ action: 'hide_left_panel' }); @@ -1714,12 +1689,6 @@ export default createReactClass({ if (this._windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) { dis.dispatch({ action: 'show_left_panel' }); } - if (this._windowWidth > hideRhsThreshold && window.innerWidth <= hideRhsThreshold) { - dis.dispatch({ action: 'hide_right_panel' }); - } - if (this._windowWidth <= showRhsThreshold && window.innerWidth > showRhsThreshold) { - dis.dispatch({ action: 'show_right_panel' }); - } this.state.resizeNotifier.notifyWindowResized(); this._windowWidth = window.innerWidth; diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index c6f218377a..f7d22bc17a 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -159,6 +159,10 @@ export default class MessagePanel extends React.Component { SettingsStore.getValue("showHiddenEventsInTimeline"); this._isMounted = false; + + this._readMarkerNode = createRef(); + this._whoIsTyping = createRef(); + this._scrollPanel = createRef(); } componentDidMount() { @@ -191,8 +195,7 @@ export default class MessagePanel extends React.Component { /* return true if the content is fully scrolled down right now; else false. */ isAtBottom() { - return this.refs.scrollPanel - && this.refs.scrollPanel.isAtBottom(); + return this._scrollPanel.current && this._scrollPanel.current.isAtBottom(); } /* get the current scroll state. See ScrollPanel.getScrollState for @@ -201,8 +204,7 @@ export default class MessagePanel extends React.Component { * returns null if we are not mounted. */ getScrollState() { - if (!this.refs.scrollPanel) { return null; } - return this.refs.scrollPanel.getScrollState(); + return this._scrollPanel.current ? this._scrollPanel.current.getScrollState() : null; } // returns one of: @@ -212,8 +214,8 @@ export default class MessagePanel extends React.Component { // 0: read marker is within the window // +1: read marker is below the window getReadMarkerPosition() { - const readMarker = this.refs.readMarkerNode; - const messageWrapper = this.refs.scrollPanel; + const readMarker = this._readMarkerNode.current; + const messageWrapper = this._scrollPanel.current; if (!readMarker || !messageWrapper) { return null; @@ -236,16 +238,16 @@ export default class MessagePanel extends React.Component { /* jump to the top of the content. */ scrollToTop() { - if (this.refs.scrollPanel) { - this.refs.scrollPanel.scrollToTop(); + if (this._scrollPanel.current) { + this._scrollPanel.current.scrollToTop(); } } /* jump to the bottom of the content. */ scrollToBottom() { - if (this.refs.scrollPanel) { - this.refs.scrollPanel.scrollToBottom(); + if (this._scrollPanel.current) { + this._scrollPanel.current.scrollToBottom(); } } @@ -255,8 +257,8 @@ export default class MessagePanel extends React.Component { * @param {number} mult: -1 to page up, +1 to page down */ scrollRelative(mult) { - if (this.refs.scrollPanel) { - this.refs.scrollPanel.scrollRelative(mult); + if (this._scrollPanel.current) { + this._scrollPanel.current.scrollRelative(mult); } } @@ -266,8 +268,8 @@ export default class MessagePanel extends React.Component { * @param {KeyboardEvent} ev: the keyboard event to handle */ handleScrollKey(ev) { - if (this.refs.scrollPanel) { - this.refs.scrollPanel.handleScrollKey(ev); + if (this._scrollPanel.current) { + this._scrollPanel.current.handleScrollKey(ev); } } @@ -282,8 +284,8 @@ export default class MessagePanel extends React.Component { * defaults to 0. */ scrollToEvent(eventId, pixelOffset, offsetBase) { - if (this.refs.scrollPanel) { - this.refs.scrollPanel.scrollToToken(eventId, pixelOffset, offsetBase); + if (this._scrollPanel.current) { + this._scrollPanel.current.scrollToToken(eventId, pixelOffset, offsetBase); } } @@ -297,8 +299,8 @@ export default class MessagePanel extends React.Component { /* check the scroll state and send out pagination requests if necessary. */ checkFillState() { - if (this.refs.scrollPanel) { - this.refs.scrollPanel.checkFillState(); + if (this._scrollPanel.current) { + this._scrollPanel.current.checkFillState(); } } @@ -345,7 +347,7 @@ export default class MessagePanel extends React.Component { } return ( -
  • { hr }
  • @@ -829,14 +831,14 @@ export default class MessagePanel extends React.Component { // once dynamic content in the events load, make the scrollPanel check the // scroll offsets. _onHeightChanged = () => { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } }; _onTypingShown = () => { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; // this will make the timeline grow, so checkScroll scrollPanel.checkScroll(); if (scrollPanel && scrollPanel.getScrollState().stuckAtBottom) { @@ -845,7 +847,7 @@ export default class MessagePanel extends React.Component { }; _onTypingHidden = () => { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { // as hiding the typing notifications doesn't // update the scrollPanel, we tell it to apply @@ -858,11 +860,11 @@ export default class MessagePanel extends React.Component { }; updateTimelineMinHeight() { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { const isAtBottom = scrollPanel.isAtBottom(); - const whoIsTyping = this.refs.whoIsTyping; + const whoIsTyping = this._whoIsTyping.current; const isTypingVisible = whoIsTyping && whoIsTyping.isVisible(); // when messages get added to the timeline, // but somebody else is still typing, @@ -875,7 +877,7 @@ export default class MessagePanel extends React.Component { } onTimelineReset() { - const scrollPanel = this.refs.scrollPanel; + const scrollPanel = this._scrollPanel.current; if (scrollPanel) { scrollPanel.clearPreventShrinking(); } @@ -909,19 +911,22 @@ export default class MessagePanel extends React.Component { room={this.props.room} onShown={this._onTypingShown} onHidden={this._onTypingHidden} - ref="whoIsTyping" /> + ref={this._whoIsTyping} /> ); } return ( - + { topSpinner } { this._getEventTiles() } { whoIsTyping } diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 63ae14ba09..d957e76dfb 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -17,12 +17,11 @@ limitations under the License. import React from 'react'; import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import { MatrixClient } from 'matrix-js-sdk'; import sdk from '../../index'; import { _t } from '../../languageHandler'; import dis from '../../dispatcher'; import AccessibleButton from '../views/elements/AccessibleButton'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; export default createReactClass({ displayName: 'MyGroups', @@ -34,8 +33,8 @@ export default createReactClass({ }; }, - contextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + statics: { + contextType: MatrixClientContext, }, componentWillMount: function() { @@ -47,7 +46,7 @@ export default createReactClass({ }, _fetch: function() { - this.context.matrixClient.getJoinedGroups().then((result) => { + this.context.getJoinedGroups().then((result) => { this.setState({groups: result.groups, error: null}); }, (err) => { if (err.errcode === 'M_GUEST_ACCESS_FORBIDDEN') { diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index 895f6ae57e..ff987a8f30 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -1,9 +1,9 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd -Copyright 2017 New Vector Ltd -Copyright 2018 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> +Copyright 2019 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -23,44 +23,31 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import sdk from '../../index'; import dis from '../../dispatcher'; -import { MatrixClient } from 'matrix-js-sdk'; import RateLimitedFunc from '../../ratelimitedfunc'; import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker'; import GroupStore from '../../stores/GroupStore'; import SettingsStore from "../../settings/SettingsStore"; +import {RIGHT_PANEL_PHASES, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPanelStorePhases"; +import RightPanelStore from "../../stores/RightPanelStore"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; export default class RightPanel extends React.Component { static get propTypes() { return { roomId: PropTypes.string, // if showing panels for a given room, this is set groupId: PropTypes.string, // if showing panels for a given group, this is set - user: PropTypes.object, + user: PropTypes.object, // used if we know the user ahead of opening the panel }; } - static get contextTypes() { - return { - matrixClient: PropTypes.instanceOf(MatrixClient), - }; - } + static contextType = MatrixClientContext; - static Phase = Object.freeze({ - RoomMemberList: 'RoomMemberList', - GroupMemberList: 'GroupMemberList', - GroupRoomList: 'GroupRoomList', - GroupRoomInfo: 'GroupRoomInfo', - FilePanel: 'FilePanel', - NotificationPanel: 'NotificationPanel', - RoomMemberInfo: 'RoomMemberInfo', - Room3pidMemberInfo: 'Room3pidMemberInfo', - GroupMemberInfo: 'GroupMemberInfo', - }); - - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this.state = { phase: this._getPhaseFromProps(), isUserPrivilegedInGroup: null, + member: this._getUserForPanel(), }; this.onAction = this.onAction.bind(this); this.onRoomStateMember = this.onRoomStateMember.bind(this); @@ -73,30 +60,44 @@ export default class RightPanel extends React.Component { }, 500); } + // Helper function to split out the logic for _getPhaseFromProps() and the constructor + // as both are called at the same time in the constructor. + _getUserForPanel() { + if (this.state && this.state.member) return this.state.member; + const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams; + return this.props.user || lastParams['member']; + } + _getPhaseFromProps() { + const rps = RightPanelStore.getSharedInstance(); if (this.props.groupId) { - return RightPanel.Phase.GroupMemberList; - } else if (this.props.user) { - return RightPanel.Phase.RoomMemberInfo; + if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { + dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.GroupMemberList}); + return RIGHT_PANEL_PHASES.GroupMemberList; + } + return rps.groupPanelPhase; + } else if (this._getUserForPanel()) { + return RIGHT_PANEL_PHASES.RoomMemberInfo; } else { - return RightPanel.Phase.RoomMemberList; + if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) { + dis.dispatch({action: "set_right_panel_phase", phase: RIGHT_PANEL_PHASES.RoomMemberList}); + return RIGHT_PANEL_PHASES.RoomMemberList; + } + return rps.roomPanelPhase; } } componentWillMount() { this.dispatcherRef = dis.register(this.onAction); - const cli = this.context.matrixClient; + const cli = this.context; cli.on("RoomState.members", this.onRoomStateMember); this._initGroupStore(this.props.groupId); - if (this.props.user) { - this.setState({member: this.props.user}); - } } componentWillUnmount() { dis.unregister(this.dispatcherRef); - if (this.context.matrixClient) { - this.context.matrixClient.removeListener("RoomState.members", this.onRoomStateMember); + if (this.context) { + this.context.removeListener("RoomState.members", this.onRoomStateMember); } this._unregisterGroupStore(this.props.groupId); } @@ -126,7 +127,7 @@ export default class RightPanel extends React.Component { onInviteToGroupButtonClick() { showGroupInviteDialog(this.props.groupId).then(() => { this.setState({ - phase: RightPanel.Phase.GroupMemberList, + phase: RIGHT_PANEL_PHASES.GroupMemberList, }); }); } @@ -142,9 +143,9 @@ export default class RightPanel extends React.Component { return; } // redraw the badge on the membership list - if (this.state.phase === RightPanel.Phase.RoomMemberList && member.roomId === this.props.roomId) { + if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList && member.roomId === this.props.roomId) { this._delayedUpdate(); - } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo && member.roomId === this.props.roomId && + } else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo && member.roomId === this.props.roomId && member.userId === this.state.member.userId) { // refresh the member info (e.g. new power level) this._delayedUpdate(); @@ -152,7 +153,7 @@ export default class RightPanel extends React.Component { } onAction(payload) { - if (payload.action === "view_right_panel_phase") { + if (payload.action === "after_right_panel_phase_change") { this.setState({ phase: payload.phase, groupRoomId: payload.groupRoomId, @@ -178,14 +179,14 @@ export default class RightPanel extends React.Component { let panel =
    ; - if (this.props.roomId && this.state.phase === RightPanel.Phase.RoomMemberList) { + if (this.props.roomId && this.state.phase === RIGHT_PANEL_PHASES.RoomMemberList) { panel = ; - } else if (this.props.groupId && this.state.phase === RightPanel.Phase.GroupMemberList) { + } else if (this.props.groupId && this.state.phase === RIGHT_PANEL_PHASES.GroupMemberList) { panel = ; - } else if (this.state.phase === RightPanel.Phase.GroupRoomList) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomList) { panel = ; - } else if (this.state.phase === RightPanel.Phase.RoomMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo) { + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { const onClose = () => { dis.dispatch({ action: "view_user", @@ -201,10 +202,10 @@ export default class RightPanel extends React.Component { } else { panel = ; } - } else if (this.state.phase === RightPanel.Phase.Room3pidMemberInfo) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.Room3pidMemberInfo) { panel = ; - } else if (this.state.phase === RightPanel.Phase.GroupMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupMemberInfo) { + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { const onClose = () => { dis.dispatch({ action: "view_user", @@ -225,14 +226,14 @@ export default class RightPanel extends React.Component { /> ); } - } else if (this.state.phase === RightPanel.Phase.GroupRoomInfo) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomInfo) { panel = ; - } else if (this.state.phase === RightPanel.Phase.NotificationPanel) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.NotificationPanel) { panel = ; - } else if (this.state.phase === RightPanel.Phase.FilePanel) { + } else if (this.state.phase === RIGHT_PANEL_PHASES.FilePanel) { panel = ; } diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index efca8d12a8..cec016c3cf 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -30,6 +30,7 @@ import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 160; @@ -65,16 +66,6 @@ module.exports = createReactClass({ }; }, - childContextTypes: { - matrixClient: PropTypes.object, - }, - - getChildContext: function() { - return { - matrixClient: MatrixClientPeg.get(), - }; - }, - componentWillMount: function() { this._unmounted = false; this.nextBatch = null; @@ -108,20 +99,9 @@ module.exports = createReactClass({ ), }); }); - - // dis.dispatch({ - // action: 'panel_disable', - // sideDisabled: true, - // middleDisabled: true, - // }); }, componentWillUnmount: function() { - // dis.dispatch({ - // action: 'panel_disable', - // sideDisabled: false, - // middleDisabled: false, - // }); if (this.filterTimeout) { clearTimeout(this.filterTimeout); } @@ -281,6 +261,7 @@ module.exports = createReactClass({ roomServer: server, instanceId: instanceId, includeAll: includeAll, + error: null, }, this.refreshRoomList); // We also refresh the room list each time even though this // filtering is client-side. It hopefully won't be client side @@ -572,7 +553,7 @@ module.exports = createReactClass({ if (rows.length === 0 && !this.state.loading) { scrollpanel_content = { _t('No rooms to show') }; } else { - scrollpanel_content = + scrollpanel_content =
    { rows } diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index b0aa4cb59b..574d3b7d1e 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -25,7 +25,7 @@ import MatrixClientPeg from '../../MatrixClientPeg'; import Resend from '../../Resend'; import * as cryptodevices from '../../cryptodevices'; import dis from '../../dispatcher'; -import { messageForResourceLimitError } from '../../utils/ErrorUtils'; +import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; @@ -272,7 +272,7 @@ module.exports = createReactClass({ unsentMessages[0].error.data && unsentMessages[0].error.data.error ) { - title = unsentMessages[0].error.data.error; + title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error; } else { title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); } diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 921680b678..123ed7c4e1 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -18,7 +18,6 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import classNames from 'classnames'; import sdk from '../../index'; import dis from '../../dispatcher'; @@ -26,7 +25,7 @@ import Unread from '../../Unread'; import * as RoomNotifs from '../../RoomNotifs'; import * as FormattingUtils from '../../utils/FormattingUtils'; import IndicatorScrollbar from './IndicatorScrollbar'; -import {Key, KeyCode} from '../../Keyboard'; +import {Key} from '../../Keyboard'; import { Group } from 'matrix-js-sdk'; import PropTypes from 'prop-types'; import RoomTile from "../views/rooms/RoomTile"; @@ -36,12 +35,11 @@ import {_t} from "../../languageHandler"; // turn this on for drop & drag console debugging galore const debug = false; -const RoomSubList = createReactClass({ - displayName: 'RoomSubList', +export default class RoomSubList extends React.PureComponent { + static displayName = 'RoomSubList'; + static debug = debug; - debug: debug, - - propTypes: { + static propTypes = { list: PropTypes.arrayOf(PropTypes.object).isRequired, label: PropTypes.string.isRequired, tagName: PropTypes.string, @@ -59,10 +57,26 @@ const RoomSubList = createReactClass({ incomingCall: PropTypes.object, extraTiles: PropTypes.arrayOf(PropTypes.node), // extra elements added beneath tiles forceExpand: PropTypes.bool, - }, + }; - getInitialState: function() { + static defaultProps = { + onHeaderClick: function() { + }, // NOP + extraTiles: [], + isInvite: false, + }; + + static getDerivedStateFromProps(props, state) { return { + listLength: props.list.length, + scrollTop: props.list.length === state.listLength ? state.scrollTop : 0, + }; + } + + constructor(props) { + super(props); + + this.state = { hidden: this.props.startAsHidden || false, // some values to get LazyRenderList starting scrollerHeight: 800, @@ -71,47 +85,33 @@ const RoomSubList = createReactClass({ // we have to store the length of the list here so we can see if it's changed or not... listLength: null, }; - }, - getDefaultProps: function() { - return { - onHeaderClick: function() { - }, // NOP - extraTiles: [], - isInvite: false, - }; - }, - - componentDidMount: function() { + this._header = createRef(); + this._subList = createRef(); + this._scroller = createRef(); this._headerButton = createRef(); + } + + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - }, + } - statics: { - getDerivedStateFromProps: function(props, state) { - return { - listLength: props.list.length, - scrollTop: props.list.length === state.listLength ? state.scrollTop : 0, - }; - }, - }, - - componentWillUnmount: function() { + componentWillUnmount() { dis.unregister(this.dispatcherRef); - }, + } // The header is collapsible if it is hidden or not stuck // The dataset elements are added in the RoomList _initAndPositionStickyHeaders method - isCollapsibleOnClick: function() { - const stuck = this.refs.header.dataset.stuck; + isCollapsibleOnClick() { + const stuck = this._header.current.dataset.stuck; if (!this.props.forceExpand && (this.state.hidden || stuck === undefined || stuck === "none")) { return true; } else { return false; } - }, + } - onAction: function(payload) { + onAction = (payload) => { // XXX: Previously RoomList would forceUpdate whenever on_room_read is dispatched, // but this is no longer true, so we must do it here (and can apply the small // optimisation of checking that we care about the room being read). @@ -124,9 +124,9 @@ const RoomSubList = createReactClass({ ) { this.forceUpdate(); } - }, + }; - onClick: function(ev) { + onClick = (ev) => { if (this.isCollapsibleOnClick()) { // The header isCollapsible, so the click is to be interpreted as collapse and truncation logic const isHidden = !this.state.hidden; @@ -135,11 +135,11 @@ const RoomSubList = createReactClass({ }); } else { // The header is stuck, so the click is to be interpreted as a scroll to the header - this.props.onHeaderClick(this.state.hidden, this.refs.header.dataset.originalPosition); + this.props.onHeaderClick(this.state.hidden, this._header.current.dataset.originalPosition); } - }, + }; - onHeaderKeyDown: function(ev) { + onHeaderKeyDown = (ev) => { switch (ev.key) { case Key.TAB: // Prevent LeftPanel handling Tab if focus is on the sublist header itself @@ -159,7 +159,7 @@ const RoomSubList = createReactClass({ this.onClick(); } else if (!this.props.forceExpand) { // sublist is expanded, go to first room - const element = this.refs.subList && this.refs.subList.querySelector(".mx_RoomTile"); + const element = this._subList.current && this._subList.current.querySelector(".mx_RoomTile"); if (element) { element.focus(); } @@ -167,9 +167,9 @@ const RoomSubList = createReactClass({ break; } } - }, + }; - onKeyDown: function(ev) { + onKeyDown = (ev) => { switch (ev.key) { // On ARROW_LEFT go to the sublist header case Key.ARROW_LEFT: @@ -180,24 +180,24 @@ const RoomSubList = createReactClass({ case Key.ARROW_RIGHT: ev.stopPropagation(); } - }, + }; - onRoomTileClick(roomId, ev) { + onRoomTileClick = (roomId, ev) => { dis.dispatch({ action: 'view_room', room_id: roomId, - clear_search: (ev && (ev.keyCode === KeyCode.ENTER || ev.keyCode === KeyCode.SPACE)), + clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), }); - }, + }; - _updateSubListCount: function() { + _updateSubListCount = () => { // Force an update by setting the state to the current state // Doing it this way rather than using forceUpdate(), so that the shouldComponentUpdate() // method is honoured this.setState(this.state); - }, + }; - makeRoomTile: function(room) { + makeRoomTile = (room) => { return ; - }, + }; - _onNotifBadgeClick: function(e) { + _onNotifBadgeClick = (e) => { // prevent the roomsublist collapsing e.preventDefault(); e.stopPropagation(); @@ -225,9 +225,9 @@ const RoomSubList = createReactClass({ room_id: room.roomId, }); } - }, + }; - _onInviteBadgeClick: function(e) { + _onInviteBadgeClick = (e) => { // prevent the roomsublist collapsing e.preventDefault(); e.stopPropagation(); @@ -247,14 +247,14 @@ const RoomSubList = createReactClass({ }); } } - }, + }; - onAddRoom: function(e) { + onAddRoom = (e) => { e.stopPropagation(); if (this.props.onAddRoom) this.props.onAddRoom(); - }, + }; - _getHeaderJsx: function(isCollapsed) { + _getHeaderJsx(isCollapsed) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleTooltipButton = sdk.getComponent('elements.AccessibleTooltipButton'); const subListNotifications = !this.props.isInvite ? @@ -328,7 +328,7 @@ const RoomSubList = createReactClass({ } return ( -
    +
    ); - }, + } - checkOverflow: function() { - if (this.refs.scroller) { - this.refs.scroller.checkOverflow(); + checkOverflow = () => { + if (this._scroller.current) { + this._scroller.current.checkOverflow(); } - }, + }; - setHeight: function(height) { - if (this.refs.subList) { - this.refs.subList.style.height = `${height}px`; + setHeight = (height) => { + if (this._subList.current) { + this._subList.current.style.height = `${height}px`; } this._updateLazyRenderHeight(height); - }, + }; - _updateLazyRenderHeight: function(height) { + _updateLazyRenderHeight(height) { this.setState({scrollerHeight: height}); - }, + } - _onScroll: function() { - this.setState({scrollTop: this.refs.scroller.getScrollTop()}); - }, + _onScroll = () => { + this.setState({scrollTop: this._scroller.current.getScrollTop()}); + }; _canUseLazyListRendering() { // for now disable lazy rendering as they are already rendered tiles // not rooms like props.list we pass to LazyRenderList return !this.props.extraTiles || !this.props.extraTiles.length; - }, + } - render: function() { + render() { const len = this.props.list.length + this.props.extraTiles.length; const isCollapsed = this.state.hidden && !this.props.forceExpand; @@ -391,7 +391,7 @@ const RoomSubList = createReactClass({ // no body } else if (this._canUseLazyListRendering()) { content = ( - + this.makeRoomTile(r)); const tiles = roomTiles.concat(this.props.extraTiles); content = ( - + { tiles } ); @@ -418,7 +418,7 @@ const RoomSubList = createReactClass({ return (
    ); - }, -}); - -module.exports = RoomSubList; + } +} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 5cc1e2b765..939f422a36 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -23,12 +23,10 @@ limitations under the License. import shouldHideEvent from '../../shouldHideEvent'; -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; -import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import {Room} from "matrix-js-sdk"; import { _t } from '../../languageHandler'; import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; @@ -44,7 +42,7 @@ import ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; import eventSearch from '../../Searching'; -import { KeyCode, isOnlyCtrlOrCmdKeyEvent } from '../../Keyboard'; +import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; @@ -54,6 +52,8 @@ import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import WidgetUtils from '../../utils/WidgetUtils'; import AccessibleButton from "../views/elements/AccessibleButton"; +import RightPanelStore from "../../stores/RightPanelStore"; +import RoomContext from "../../contexts/RoomContext"; const DEBUG = false; let debuglog = function() {}; @@ -65,12 +65,6 @@ if (DEBUG) { debuglog = console.log.bind(console); } -const RoomContext = PropTypes.shape({ - canReact: PropTypes.bool.isRequired, - canReply: PropTypes.bool.isRequired, - room: PropTypes.instanceOf(Room), -}); - module.exports = createReactClass({ displayName: 'RoomView', propTypes: { @@ -98,9 +92,6 @@ module.exports = createReactClass({ // * invited us to the room oobData: PropTypes.object, - // is the RightPanel collapsed? - collapsedRhs: PropTypes.bool, - // Servers the RoomView can use to try and assist joins viaServers: PropTypes.arrayOf(PropTypes.string), }, @@ -171,21 +162,6 @@ module.exports = createReactClass({ }; }, - childContextTypes: { - room: RoomContext, - }, - - getChildContext: function() { - const {canReact, canReply, room} = this.state; - return { - room: { - canReact, - canReply, - room, - }, - }; - }, - componentWillMount: function() { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room", this.onRoom); @@ -207,6 +183,9 @@ module.exports = createReactClass({ this._onCiderUpdated(); this._ciderWatcherRef = SettingsStore.watchSetting( "useCiderComposer", null, this._onCiderUpdated); + + this._roomView = createRef(); + this._searchResultsPanel = createRef(); }, _onCiderUpdated: function() { @@ -459,8 +438,8 @@ module.exports = createReactClass({ }, componentDidUpdate: function() { - if (this.refs.roomView) { - const roomView = ReactDOM.findDOMNode(this.refs.roomView); + if (this._roomView.current) { + const roomView = this._roomView.current; if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); @@ -474,10 +453,10 @@ module.exports = createReactClass({ // in render() prevents the ref from being set on first mount, so we try and // catch the messagePanel when it does mount. Because we only want the ref once, // we use a boolean flag to avoid duplicate work. - if (this.refs.messagePanel && !this.state.atEndOfLiveTimelineInit) { + if (this._messagePanel && !this.state.atEndOfLiveTimelineInit) { this.setState({ atEndOfLiveTimelineInit: true, - atEndOfLiveTimeline: this.refs.messagePanel.isAtEndOfLiveTimeline(), + atEndOfLiveTimeline: this._messagePanel.isAtEndOfLiveTimeline(), }); } }, @@ -499,12 +478,12 @@ module.exports = createReactClass({ // stop tracking room changes to format permalinks this._stopAllPermalinkCreators(); - if (this.refs.roomView) { + if (this._roomView.current) { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. - const roomView = ReactDOM.findDOMNode(this.refs.roomView); + const roomView = this._roomView.current; roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); @@ -560,15 +539,15 @@ module.exports = createReactClass({ let handled = false; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); - switch (ev.keyCode) { - case KeyCode.KEY_D: + switch (ev.key) { + case Key.D: if (ctrlCmdOnly) { this.onMuteAudioClick(); handled = true; } break; - case KeyCode.KEY_E: + case Key.E: if (ctrlCmdOnly) { this.onMuteVideoClick(); handled = true; @@ -584,6 +563,10 @@ module.exports = createReactClass({ onAction: function(payload) { switch (payload.action) { + case 'after_right_panel_phase_change': + // We don't keep state on the right panel, so just re-render to update + this.forceUpdate(); + break; case 'message_send_failed': case 'message_sent': this._checkIfAlone(this.state.room); @@ -701,10 +684,10 @@ module.exports = createReactClass({ }, canResetTimeline: function() { - if (!this.refs.messagePanel) { + if (!this._messagePanel) { return true; } - return this.refs.messagePanel.canResetTimeline(); + return this._messagePanel.canResetTimeline(); }, // called when state.room is first initialised (either at initial load, @@ -787,11 +770,12 @@ module.exports = createReactClass({ this._updateE2EStatus(room); }, - _updateE2EStatus: function(room) { - if (!MatrixClientPeg.get().isRoomEncrypted(room.roomId)) { + _updateE2EStatus: async function(room) { + const cli = MatrixClientPeg.get(); + if (!cli.isRoomEncrypted(room.roomId)) { return; } - if (!MatrixClientPeg.get().isCryptoEnabled()) { + if (!cli.isCryptoEnabled()) { // If crypto is not currently enabled, we aren't tracking devices at all, // so we don't know what the answer is. Let's error on the safe side and show // a warning for this case. @@ -800,10 +784,38 @@ module.exports = createReactClass({ }); return; } - room.hasUnverifiedDevices().then((hasUnverifiedDevices) => { - this.setState({ - e2eStatus: hasUnverifiedDevices ? "warning" : "verified", + if (!SettingsStore.isFeatureEnabled("feature_cross_signing")) { + room.hasUnverifiedDevices().then((hasUnverifiedDevices) => { + this.setState({ + e2eStatus: hasUnverifiedDevices ? "warning" : "verified", + }); }); + return; + } + const e2eMembers = await room.getEncryptionTargetMembers(); + for (const member of e2eMembers) { + const { userId } = member; + const userVerified = cli.checkUserTrust(userId).isCrossSigningVerified(); + if (!userVerified) { + this.setState({ + e2eStatus: "warning", + }); + return; + } + const devices = await cli.getStoredDevicesForUser(userId); + const allDevicesVerified = devices.every(device => { + const { deviceId } = device; + return cli.checkDeviceTrust(userId, deviceId).isCrossSigningVerified(); + }); + if (!allDevicesVerified) { + this.setState({ + e2eStatus: "warning", + }); + return; + } + } + this.setState({ + e2eStatus: "verified", }); }, @@ -1046,7 +1058,7 @@ module.exports = createReactClass({ }, onMessageListScroll: function(ev) { - if (this.refs.messagePanel.isAtEndOfLiveTimeline()) { + if (this._messagePanel.isAtEndOfLiveTimeline()) { this.setState({ numUnreadMessages: 0, atEndOfLiveTimeline: true, @@ -1119,8 +1131,8 @@ module.exports = createReactClass({ // if we already have a search panel, we need to tell it to forget // about its scroll state. - if (this.refs.searchResultsPanel) { - this.refs.searchResultsPanel.resetScrollState(); + if (this._searchResultsPanel.current) { + this._searchResultsPanel.current.resetScrollState(); } // make sure that we don't end up showing results from @@ -1225,7 +1237,7 @@ module.exports = createReactClass({ // once dynamic content in the search results load, make the scrollPanel check // the scroll offsets. const onHeightChanged = () => { - const scrollPanel = this.refs.searchResultsPanel; + const scrollPanel = this._searchResultsPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } @@ -1370,28 +1382,28 @@ module.exports = createReactClass({ // jump down to the bottom of this room, where new events are arriving jumpToLiveTimeline: function() { - this.refs.messagePanel.jumpToLiveTimeline(); + this._messagePanel.jumpToLiveTimeline(); dis.dispatch({action: 'focus_composer'}); }, // jump up to wherever our read marker is jumpToReadMarker: function() { - this.refs.messagePanel.jumpToReadMarker(); + this._messagePanel.jumpToReadMarker(); }, // update the read marker to match the read-receipt forgetReadMarker: function(ev) { ev.stopPropagation(); - this.refs.messagePanel.forgetReadMarker(); + this._messagePanel.forgetReadMarker(); }, // decide whether or not the top 'unread messages' bar should be shown _updateTopUnreadMessagesBar: function() { - if (!this.refs.messagePanel) { + if (!this._messagePanel) { return; } - const showBar = this.refs.messagePanel.canJumpToReadMarker(); + const showBar = this._messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { this.setState({showTopUnreadMessagesBar: showBar}); } @@ -1401,7 +1413,7 @@ module.exports = createReactClass({ // restored when we switch back to it. // _getScrollState: function() { - const messagePanel = this.refs.messagePanel; + const messagePanel = this._messagePanel; if (!messagePanel) return null; // if we're following the live timeline, we want to return null; that @@ -1506,10 +1518,10 @@ module.exports = createReactClass({ */ handleScrollKey: function(ev) { let panel; - if (this.refs.searchResultsPanel) { - panel = this.refs.searchResultsPanel; - } else if (this.refs.messagePanel) { - panel = this.refs.messagePanel; + if (this._searchResultsPanel.current) { + panel = this._searchResultsPanel.current; + } else if (this._messagePanel) { + panel = this._messagePanel; } if (panel) { @@ -1530,7 +1542,7 @@ module.exports = createReactClass({ // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. _gatherTimelinePanelRef: function(r) { - this.refs.messagePanel = r; + this._messagePanel = r; if (r) { console.log("updateTint from RoomView._gatherTimelinePanelRef"); this.updateTint(); @@ -1714,12 +1726,12 @@ module.exports = createReactClass({ let aux = null; let previewBar; let hideCancel = false; - let hideRightPanel = false; + let forceHideRightPanel = false; if (this.state.forwardingEvent !== null) { aux = ; } else if (this.state.searching) { hideCancel = true; // has own cancel - aux = ; + aux = ; } else if (showRoomUpgradeBar) { aux = ; hideCancel = true; @@ -1760,7 +1772,7 @@ module.exports = createReactClass({
    ); } else { - hideRightPanel = true; + forceHideRightPanel = true; } } else if (hiddenHighlightCount > 0) { aux = ( @@ -1775,7 +1787,7 @@ module.exports = createReactClass({ } const auxPanel = ( - ); } else { searchResultsPanel = ( - ); let topUnreadMessagesBar = null; - if (this.state.showTopUnreadMessagesBar) { + // Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense + if (this.state.showTopUnreadMessagesBar && !this.state.searchResults) { const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar'); topUnreadMessagesBar = (); } let jumpToBottom; - if (!this.state.atEndOfLiveTimeline) { + // Do not show JumpToBottomButton if we have search results showing, it makes no sense + if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); jumpToBottom = (; - const collapsedRhs = hideRightPanel || this.props.collapsedRhs; + const showRightPanel = !forceHideRightPanel && this.state.room + && RightPanelStore.getSharedInstance().isOpenForRoom; + const rightPanel = showRightPanel + ? + : null; return ( -
    - - - -
    - {auxPanel} -
    - {topUnreadMessagesBar} - {jumpToBottom} - {messagePanel} - {searchResultsPanel} -
    -
    -
    -
    - {statusBar} + +
    + + + +
    + {auxPanel} +
    + {topUnreadMessagesBar} + {jumpToBottom} + {messagePanel} + {searchResultsPanel}
    +
    +
    +
    + {statusBar} +
    +
    + {previewBar} + {messageComposer}
    - {previewBar} - {messageComposer} -
    -
    -
    -
    + + +
    + ); }, }); diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 8a67e70467..f289720542 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -14,10 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, {createRef} from "react"; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import { KeyCode } from '../../Keyboard'; +import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import AutoHideScrollbar from "./AutoHideScrollbar"; @@ -166,6 +166,8 @@ module.exports = createReactClass({ } this.resetScrollState(); + + this._itemlist = createRef(); }, componentDidMount: function() { @@ -328,7 +330,7 @@ module.exports = createReactClass({ this._isFilling = true; } - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const firstTile = itemlist && itemlist.firstElementChild; const contentTop = firstTile && firstTile.offsetTop; const fillPromises = []; @@ -373,7 +375,7 @@ module.exports = createReactClass({ const origExcessHeight = excessHeight; - const tiles = this.refs.itemlist.children; + const tiles = this._itemlist.current.children; // The scroll token of the first/last tile to be unpaginated let markerScrollToken = null; @@ -530,26 +532,26 @@ module.exports = createReactClass({ * @param {object} ev the keyboard event */ handleScrollKey: function(ev) { - switch (ev.keyCode) { - case KeyCode.PAGE_UP: + switch (ev.key) { + case Key.PAGE_UP: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollRelative(-1); } break; - case KeyCode.PAGE_DOWN: + case Key.PAGE_DOWN: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollRelative(1); } break; - case KeyCode.HOME: + case Key.HOME: if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollToTop(); } break; - case KeyCode.END: + case Key.END: if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { this.scrollToBottom(); } @@ -602,7 +604,7 @@ module.exports = createReactClass({ const scrollNode = this._getScrollNode(); const viewportBottom = scrollNode.scrollHeight - (scrollNode.scrollTop + scrollNode.clientHeight); - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const messages = itemlist.children; let node = null; @@ -644,7 +646,7 @@ module.exports = createReactClass({ const sn = this._getScrollNode(); sn.scrollTop = sn.scrollHeight; } else if (scrollState.trackedScrollToken) { - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const trackedNode = this._getTrackedNode(); if (trackedNode) { const newBottomOffset = this._topFromBottom(trackedNode); @@ -682,7 +684,7 @@ module.exports = createReactClass({ } const sn = this._getScrollNode(); - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const contentHeight = this._getMessagesHeight(); const minHeight = sn.clientHeight; const height = Math.max(minHeight, contentHeight); @@ -724,7 +726,7 @@ module.exports = createReactClass({ if (!trackedNode || !trackedNode.parentElement) { let node; - const messages = this.refs.itemlist.children; + const messages = this._itemlist.current.children; const scrollToken = scrollState.trackedScrollToken; for (let i = messages.length-1; i >= 0; --i) { @@ -756,7 +758,7 @@ module.exports = createReactClass({ }, _getMessagesHeight() { - const itemlist = this.refs.itemlist; + const itemlist = this._itemlist.current; const lastNode = itemlist.lastElementChild; const lastNodeBottom = lastNode ? lastNode.offsetTop + lastNode.clientHeight : 0; const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0; @@ -765,7 +767,7 @@ module.exports = createReactClass({ }, _topFromBottom(node) { - return this.refs.itemlist.clientHeight - node.offsetTop; + return this._itemlist.current.clientHeight - node.offsetTop; }, /* get the DOM node which has the scrollTop property we care about for our @@ -797,7 +799,7 @@ module.exports = createReactClass({ the same minimum bottom offset, effectively preventing the timeline to shrink. */ preventShrinking: function() { - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const tiles = messageList && messageList.children; if (!messageList) { return; @@ -824,7 +826,7 @@ module.exports = createReactClass({ /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ clearPreventShrinking: function() { - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const balanceElement = messageList && messageList.parentElement; if (balanceElement) balanceElement.style.paddingBottom = null; this.preventShrinkingState = null; @@ -843,7 +845,7 @@ module.exports = createReactClass({ if (this.preventShrinkingState) { const sn = this._getScrollNode(); const scrollState = this.scrollState; - const messageList = this.refs.itemlist; + const messageList = this._itemlist.current; const {offsetNode, offsetFromBottom} = this.preventShrinkingState; // element used to set paddingBottom to balance the typing notifs disappearing const balanceElement = messageList.parentElement; @@ -879,7 +881,7 @@ module.exports = createReactClass({ onScroll={this.onScroll} className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
    -
      +
        { this.props.children }
    diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 21613733db..9090152de8 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -15,10 +15,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; -import { KeyCode } from '../../Keyboard'; +import { Key } from '../../Keyboard'; import dis from '../../dispatcher'; import { throttle } from 'lodash'; import AccessibleButton from '../../components/views/elements/AccessibleButton'; @@ -53,6 +53,10 @@ module.exports = createReactClass({ }; }, + UNSAFE_componentWillMount: function() { + this._search = createRef(); + }, + componentDidMount: function() { this.dispatcherRef = dis.register(this.onAction); }, @@ -66,31 +70,31 @@ module.exports = createReactClass({ switch (payload.action) { case 'view_room': - if (this.refs.search && payload.clear_search) { + if (this._search.current && payload.clear_search) { this._clearSearch(); } break; case 'focus_room_filter': - if (this.refs.search) { - this.refs.search.focus(); + if (this._search.current) { + this._search.current.focus(); } break; } }, onChange: function() { - if (!this.refs.search) return; - this.setState({ searchTerm: this.refs.search.value }); + if (!this._search.current) return; + this.setState({ searchTerm: this._search.current.value }); this.onSearch(); }, onSearch: throttle(function() { - this.props.onSearch(this.refs.search.value); + this.props.onSearch(this._search.current.value); }, 200, {trailing: true, leading: true}), _onKeyDown: function(ev) { - switch (ev.keyCode) { - case KeyCode.ESCAPE: + switch (ev.key) { + case Key.ESCAPE: this._clearSearch("keyboard"); break; } @@ -113,7 +117,7 @@ module.exports = createReactClass({ }, _clearSearch: function(source) { - this.refs.search.value = ""; + this._search.current.value = ""; this.onChange(); if (this.props.onCleared) { this.props.onCleared(source); @@ -146,7 +150,7 @@ module.exports = createReactClass({ { if (this.unmounted) { @@ -58,13 +57,13 @@ const TagPanel = createReactClass({ }); }); // This could be done by anything with a matrix client - dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); }, componentWillUnmount() { this.unmounted = true; - this.context.matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.removeListener("sync", this._onClientSync); + this.context.removeListener("Group.myMembership", this._onGroupMyMembership); + this.context.removeListener("sync", this._onClientSync); if (this._filterStoreToken) { this._filterStoreToken.remove(); } @@ -72,7 +71,7 @@ const TagPanel = createReactClass({ _onGroupMyMembership() { if (this.unmounted) return; - dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); }, _onClientSync(syncState, prevState) { @@ -81,7 +80,7 @@ const TagPanel = createReactClass({ const reconnected = syncState !== "ERROR" && prevState !== syncState; if (reconnected) { // Load joined groups - dis.dispatch(GroupActions.fetchJoinedGroups(this.context.matrixClient)); + dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); } }, @@ -104,6 +103,7 @@ const TagPanel = createReactClass({ render() { const DNDTagTile = sdk.getComponent('elements.DNDTagTile'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + const ActionButton = sdk.getComponent('elements.ActionButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); const GeminiScrollbarWrapper = sdk.getComponent("elements.GeminiScrollbarWrapper"); @@ -154,6 +154,13 @@ const TagPanel = createReactClass({ ref={provided.innerRef} > { tags } +
    + +
    { provided.placeholder }
    ) } diff --git a/src/components/structures/TagPanelButtons.js b/src/components/structures/TagPanelButtons.js deleted file mode 100644 index 7255e12307..0000000000 --- a/src/components/structures/TagPanelButtons.js +++ /dev/null @@ -1,59 +0,0 @@ -/* -Copyright 2019 New Vector Ltd. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import createReactClass from 'create-react-class'; -import sdk from '../../index'; -import dis from '../../dispatcher'; -import Modal from '../../Modal'; -import { _t } from '../../languageHandler'; - -const TagPanelButtons = createReactClass({ - displayName: 'TagPanelButtons', - - - componentDidMount: function() { - this._dispatcherRef = dis.register(this._onAction); - }, - - componentWillUnmount() { - if (this._dispatcherRef) { - dis.unregister(this._dispatcherRef); - this._dispatcherRef = null; - } - }, - - _onAction(payload) { - if (payload.action === "show_redesign_feedback_dialog") { - const RedesignFeedbackDialog = - sdk.getComponent("views.dialogs.RedesignFeedbackDialog"); - Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); - } - }, - - render() { - const GroupsButton = sdk.getComponent('elements.GroupsButton'); - const ActionButton = sdk.getComponent("elements.ActionButton"); - - return (
    - - -
    ); - }, -}); -export default TagPanelButtons; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 80cbd43079..9d929d313b 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -19,7 +19,7 @@ limitations under the License. import SettingsStore from "../../settings/SettingsStore"; -import React from 'react'; +import React, {createRef} from 'react'; import createReactClass from 'create-react-class'; import ReactDOM from "react-dom"; import PropTypes from 'prop-types'; @@ -34,7 +34,7 @@ const dis = require("../../dispatcher"); const ObjectUtils = require('../../ObjectUtils'); const Modal = require("../../Modal"); const UserActivity = require("../../UserActivity"); -import { KeyCode } from '../../Keyboard'; +import {Key} from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; @@ -203,6 +203,8 @@ const TimelinePanel = createReactClass({ this.lastRRSentEventId = undefined; this.lastRMSentEventId = undefined; + this._messagePanel = createRef(); + if (this.props.manageReadReceipts) { this.updateReadReceiptOnUserActivity(); } @@ -425,8 +427,8 @@ const TimelinePanel = createReactClass({ if (payload.action === "edit_event") { const editState = payload.event ? new EditorStateTransfer(payload.event) : null; this.setState({editState}, () => { - if (payload.event && this.refs.messagePanel) { - this.refs.messagePanel.scrollToEventIfNeeded( + if (payload.event && this._messagePanel.current) { + this._messagePanel.current.scrollToEventIfNeeded( payload.event.getId(), ); } @@ -442,9 +444,9 @@ const TimelinePanel = createReactClass({ // updates from pagination will happen when the paginate completes. if (toStartOfTimeline || !data || !data.liveEvent) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; - if (!this.refs.messagePanel.getScrollState().stuckAtBottom) { + if (!this._messagePanel.current.getScrollState().stuckAtBottom) { // we won't load this event now, because we don't want to push any // events off the other end of the timeline. But we need to note // that we can now paginate. @@ -499,7 +501,7 @@ const TimelinePanel = createReactClass({ } this.setState(updatedState, () => { - this.refs.messagePanel.updateTimelineMinHeight(); + this._messagePanel.current.updateTimelineMinHeight(); if (callRMUpdated) { this.props.onReadMarkerUpdated(); } @@ -510,13 +512,13 @@ const TimelinePanel = createReactClass({ onRoomTimelineReset: function(room, timelineSet) { if (timelineSet !== this.props.timelineSet) return; - if (this.refs.messagePanel && this.refs.messagePanel.isAtBottom()) { + if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { this._loadTimeline(); } }, canResetTimeline: function() { - return this.refs.messagePanel && this.refs.messagePanel.isAtBottom(); + return this._messagePanel.current && this._messagePanel.current.isAtBottom(); }, onRoomRedaction: function(ev, room) { @@ -629,7 +631,7 @@ const TimelinePanel = createReactClass({ sendReadReceipt: function() { if (SettingsStore.getValue("lowBandwidth")) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; if (!this.props.manageReadReceipts) return; // This happens on user_activity_end which is delayed, and it's // very possible have logged out within that timeframe, so check @@ -815,8 +817,8 @@ const TimelinePanel = createReactClass({ if (this._timelineWindow.canPaginate(EventTimeline.FORWARDS)) { this._loadTimeline(); } else { - if (this.refs.messagePanel) { - this.refs.messagePanel.scrollToBottom(); + if (this._messagePanel.current) { + this._messagePanel.current.scrollToBottom(); } } }, @@ -826,7 +828,7 @@ const TimelinePanel = createReactClass({ */ jumpToReadMarker: function() { if (!this.props.manageReadMarkers) return; - if (!this.refs.messagePanel) return; + if (!this._messagePanel.current) return; if (!this.state.readMarkerEventId) return; // we may not have loaded the event corresponding to the read-marker @@ -835,11 +837,11 @@ const TimelinePanel = createReactClass({ // // a quick way to figure out if we've loaded the relevant event is // simply to check if the messagepanel knows where the read-marker is. - const ret = this.refs.messagePanel.getReadMarkerPosition(); + const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { // The messagepanel knows where the RM is, so we must have loaded // the relevant event. - this.refs.messagePanel.scrollToEvent(this.state.readMarkerEventId, + this._messagePanel.current.scrollToEvent(this.state.readMarkerEventId, 0, 1/3); return; } @@ -874,8 +876,8 @@ const TimelinePanel = createReactClass({ * at the end of the live timeline. */ isAtEndOfLiveTimeline: function() { - return this.refs.messagePanel - && this.refs.messagePanel.isAtBottom() + return this._messagePanel.current + && this._messagePanel.current.isAtBottom() && this._timelineWindow && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); }, @@ -887,8 +889,8 @@ const TimelinePanel = createReactClass({ * returns null if we are not mounted. */ getScrollState: function() { - if (!this.refs.messagePanel) { return null; } - return this.refs.messagePanel.getScrollState(); + if (!this._messagePanel.current) { return null; } + return this._messagePanel.current.getScrollState(); }, // returns one of: @@ -899,9 +901,9 @@ const TimelinePanel = createReactClass({ // +1: read marker is below the window getReadMarkerPosition: function() { if (!this.props.manageReadMarkers) return null; - if (!this.refs.messagePanel) return null; + if (!this._messagePanel.current) return null; - const ret = this.refs.messagePanel.getReadMarkerPosition(); + const ret = this._messagePanel.current.getReadMarkerPosition(); if (ret !== null) { return ret; } @@ -936,15 +938,14 @@ const TimelinePanel = createReactClass({ * We pass it down to the scroll panel. */ handleScrollKey: function(ev) { - if (!this.refs.messagePanel) { return; } + if (!this._messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the // timeline window. - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && - ev.keyCode == KeyCode.END) { + if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.END) { this.jumpToLiveTimeline(); } else { - this.refs.messagePanel.handleScrollKey(ev); + this._messagePanel.current.handleScrollKey(ev); } }, @@ -986,8 +987,8 @@ const TimelinePanel = createReactClass({ const onLoaded = () => { // clear the timeline min-height when // (re)loading the timeline - if (this.refs.messagePanel) { - this.refs.messagePanel.onTimelineReset(); + if (this._messagePanel.current) { + this._messagePanel.current.onTimelineReset(); } this._reloadEvents(); @@ -1002,7 +1003,7 @@ const TimelinePanel = createReactClass({ timelineLoading: false, }, () => { // initialise the scroll state of the message panel - if (!this.refs.messagePanel) { + if (!this._messagePanel.current) { // this shouldn't happen - we know we're mounted because // we're in a setState callback, and we know // timelineLoading is now false, so render() should have @@ -1012,10 +1013,10 @@ const TimelinePanel = createReactClass({ return; } if (eventId) { - this.refs.messagePanel.scrollToEvent(eventId, pixelOffset, + this._messagePanel.current.scrollToEvent(eventId, pixelOffset, offsetBase); } else { - this.refs.messagePanel.scrollToBottom(); + this._messagePanel.current.scrollToBottom(); } this.sendReadReceipt(); @@ -1134,7 +1135,7 @@ const TimelinePanel = createReactClass({ const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; - const messagePanel = this.refs.messagePanel; + const messagePanel = this._messagePanel.current; if (messagePanel === undefined) return null; const EventTile = sdk.getComponent('rooms.EventTile'); @@ -1313,7 +1314,8 @@ const TimelinePanel = createReactClass({ ['PREPARED', 'CATCHUP'].includes(this.state.clientSyncState) ); return ( -