diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 7d998f8c4b..36b03b121c 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -33,7 +33,6 @@ src/components/views/rooms/RoomList.js src/components/views/rooms/RoomPreviewBar.js src/components/views/rooms/SearchBar.js src/components/views/rooms/SearchResultTile.js -src/components/views/rooms/SlateMessageComposer.js src/components/views/settings/ChangeAvatar.js src/components/views/settings/ChangePassword.js src/components/views/settings/DevicesPanel.js @@ -58,7 +57,6 @@ src/utils/Receipt.js src/Velociraptor.js test/components/structures/MessagePanel-test.js test/components/views/dialogs/InteractiveAuthDialog-test.js -test/components/views/rooms/MessageComposerInput-test.js test/mock-clock.js test/notifications/ContentRules-test.js test/notifications/PushRuleVectorState-test.js diff --git a/README.md b/README.md index aefbeb9964..0fbed22030 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 @@ -83,7 +83,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. @@ -108,7 +108,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/package.json b/package.json index a6f9e5076f..7c5fbe656f 100644 --- a/package.json +++ b/package.json @@ -34,10 +34,22 @@ "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", +<<<<<<< HEAD "emoji-data-strip": "node scripts/emoji-data-strip.js", "reskindex": "node scripts/reskindex.js -h header", "reskindex:watch": "node scripts/reskindex.js -h header -w", "rethemendex": "res/css/rethemendex.sh", +======= + "build": "yarn reskindex && yarn start:init", + "build:watch": "babel src -w --skip-initial-build -d lib --source-maps --copy-files", + "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", + "lint": "eslint src/", + "lintall": "eslint src/ test/", + "lintwithexclusions": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", + "stylelint": "stylelint 'res/css/**/*.scss'", +>>>>>>> develop "clean": "rimraf lib", "build": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types", "build:compile": "yarn reskindex && babel -d lib --verbose --extensions \".ts,.js\" src", @@ -70,7 +82,6 @@ "file-saver": "^1.3.3", "filesize": "3.5.6", "flux": "2.1.1", - "focus-trap-react": "^3.0.5", "focus-visible": "^5.0.2", "fuse.js": "^2.2.0", "gemini-scrollbar": "github:matrix-org/gemini-scrollbar#91e1e566", @@ -78,6 +89,8 @@ "glob": "^5.0.14", "glob-to-regexp": "^0.4.1", "highlight.js": "^9.15.8", + "html-entities": "^1.2.1", + "humanize": "^0.0.9", "is-ip": "^2.0.0", "isomorphic-fetch": "^2.2.1", "linkifyjs": "^2.1.6", @@ -95,13 +108,10 @@ "react-addons-css-transition-group": "15.6.2", "react-beautiful-dnd": "^4.0.1", "react-dom": "^16.9.0", + "react-focus-lock": "^2.2.1", "react-gemini-scrollbar": "github:matrix-org/react-gemini-scrollbar#9cf17f63b7c0b0ec5f31df27da0f82f7238dc594", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", - "slate": "^0.41.2", - "slate-html-serializer": "^0.6.1", - "slate-md-serializer": "github:matrix-org/slate-md-serializer#f7c4ad3", - "slate-react": "^0.18.10", "text-encoding-utf-8": "^1.0.1", "url": "^0.11.0", "velocity-animate": "^1.5.2", @@ -128,6 +138,8 @@ "babel-eslint": "^10.0.3", "chokidar": "^2.1.2", "concurrently": "^4.0.1", + "enzyme": "^3.10.0", + "enzyme-adapter-react-16": "^1.15.1", "eslint": "^5.12.0", "eslint-config-google": "^0.7.1", "eslint-plugin-babel": "^5.2.1", diff --git a/res/css/_components.scss b/res/css/_components.scss index 529ce9ac85..7a9ebfdf26 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"; @@ -57,6 +56,7 @@ @import "./views/dialogs/_ConfirmUserActionDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; +@import "./views/dialogs/_DMInviteDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DeviceVerifyDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @@ -174,7 +174,9 @@ @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"; 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/_DMInviteDialog.scss b/res/css/views/dialogs/_DMInviteDialog.scss new file mode 100644 index 0000000000..f806e85120 --- /dev/null +++ b/res/css/views/dialogs/_DMInviteDialog.scss @@ -0,0 +1,197 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_DMInviteDialog_addressBar { + display: flex; + flex-direction: row; + + .mx_DMInviteDialog_editor { + flex: 1; + width: 100%; // Needed to make the Field inside grow + background-color: $user-tile-hover-bg-color; + border-radius: 4px; + min-height: 25px; + padding-left: 8px; + overflow-x: hidden; + overflow-y: auto; + + .mx_DMInviteDialog_userTile { + display: inline-block; + float: left; + position: relative; + top: 7px; + } + + // Using a textarea for this element, to circumvent autofill + // Mostly copied from AddressPickerDialog + textarea, + textarea:focus { + height: 34px; + line-height: 34px; + font-size: 14px; + padding-left: 12px; + margin: 0 !important; + border: 0 !important; + outline: 0 !important; + resize: none; + overflow: hidden; + box-sizing: border-box; + word-wrap: nowrap; + + // Roughly fill about 2/5ths of the available space. This is to try and 'fill' the + // remaining space after a bunch of pills, but is a bit hacky. Ideally we'd have + // support for "fill remaining width", but traditional tricks don't work with what + // we're pushing into this "field". Flexbox just makes things worse. The theory is + // that users won't need more than about 2/5ths of the input to find the person + // they're looking for. + width: 40%; + } + } + + .mx_DMInviteDialog_goButton { + width: 48px; + margin-left: 10px; + height: 25px; + line-height: 25px; + } +} + +.mx_DMInviteDialog_section { + padding-bottom: 10px; + + h3 { + font-size: 12px; + color: $muted-fg-color; + font-weight: bold; + text-transform: uppercase; + } +} + +.mx_DMInviteDialog_roomTile { + cursor: pointer; + padding: 5px 10px; + + &:hover { + background-color: $user-tile-hover-bg-color; + border-radius: 4px; + } + + * { + vertical-align: middle; + } + + .mx_DMInviteDialog_roomTile_avatarStack { + display: inline-block; + position: relative; + width: 36px; + height: 36px; + + & > * { + position: absolute; + top: 0; + left: 0; + } + } + + .mx_DMInviteDialog_roomTile_selected { + width: 36px; + height: 36px; + border-radius: 36px; + background-color: $username-variant1-color; + display: inline-block; + position: relative; + + &::before { + content: ""; + width: 24px; + height: 24px; + grid-column: 1; + grid-row: 1; + mask-image: url('$(res)/img/feather-customised/check.svg'); + mask-size: 100%; + mask-repeat: no-repeat; + position: absolute; + top: 6px; // 50% + left: 6px; // 50% + background-color: #ffffff; // this is fine without a var because it's for both themes + } + } + + .mx_DMInviteDialog_roomTile_name { + font-weight: 600; + font-size: 14px; + color: $primary-fg-color; + margin-left: 7px; + } + + .mx_DMInviteDialog_roomTile_userId { + font-size: 12px; + color: $muted-fg-color; + margin-left: 7px; + } + + .mx_DMInviteDialog_roomTile_time { + text-align: right; + font-size: 12px; + color: $muted-fg-color; + float: right; + line-height: 36px; // Height of the avatar to keep the time vertically aligned + } + + .mx_DMInviteDialog_roomTile_highlight { + font-weight: 900; + } +} + +// Many of these styles are stolen from mx_UserPill, but adjusted for the invite dialog. +.mx_DMInviteDialog_userTile { + margin-right: 8px; + + .mx_DMInviteDialog_userTile_pill { + background-color: $username-variant1-color; + border-radius: 12px; + display: inline-block; + height: 24px; + line-height: 24px; + padding-left: 8px; + padding-right: 8px; + color: #ffffff; // this is fine without a var because it's for both themes + + .mx_DMInviteDialog_userTile_avatar { + border-radius: 20px; + position: relative; + left: -5px; + top: 2px; + } + + img.mx_DMInviteDialog_userTile_avatar { + vertical-align: top; + } + + .mx_DMInviteDialog_userTile_name { + vertical-align: top; + } + + .mx_DMInviteDialog_userTile_threepidAvatar { + background-color: #ffffff; // this is fine without a var because it's for both themes + } + } + + .mx_DMInviteDialog_userTile_remove { + display: inline-block; + margin-left: 4px; + } +} diff --git a/res/css/views/dialogs/_RoomSettingsDialog.scss b/res/css/views/dialogs/_RoomSettingsDialog.scss index 723eb237ad..aa66e97f9e 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.scss +++ b/res/css/views/dialogs/_RoomSettingsDialog.scss @@ -29,6 +29,11 @@ limitations under the License. mask-image: url('$(res)/img/feather-customised/users-sm.svg'); } +.mx_RoomSettingsDialog_bridgesIcon::before { + // This icon is pants, please improve :) + mask-image: url('$(res)/img/feather-customised/bridge.svg'); +} + .mx_RoomSettingsDialog_warningIcon::before { mask-image: url('$(res)/img/feather-customised/warning-triangle.svg'); } @@ -42,3 +47,25 @@ limitations under the License. padding-left: 40px; 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; +} + +.mx_RoomSettingsDialog_BridgeList li { + list-style-type: none; + padding: 5px; + margin-bottom: 5px; + border-width: 1px 0px; + border-color: #dee1f3; + border-style: solid; +} + 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 5359992f84..fbac1e932a 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -1,5 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -353,7 +354,6 @@ div.mx_EventTile_notSent.mx_EventTile_redacted .mx_UnknownBody { left: 46px; width: 15px; height: 15px; - cursor: pointer; display: block; bottom: 0; right: 0; 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/_RoomRecoveryReminder.scss b/res/css/views/rooms/_RoomRecoveryReminder.scss index 68e2bf861e..85d42ca4b4 100644 --- a/res/css/views/rooms/_RoomRecoveryReminder.scss +++ b/res/css/views/rooms/_RoomRecoveryReminder.scss @@ -40,4 +40,5 @@ limitations under the License. .mx_RoomRecoveryReminder_secondary { font-size: 90%; + margin-top: 1em; } 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/_UserOnlineDot.scss b/res/css/views/rooms/_UserOnlineDot.scss new file mode 100644 index 0000000000..339e5cc48a --- /dev/null +++ b/res/css/views/rooms/_UserOnlineDot.scss @@ -0,0 +1,23 @@ +/* +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_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/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/bridge.svg b/res/img/feather-customised/bridge.svg new file mode 100644 index 0000000000..f8f3468155 --- /dev/null +++ b/res/img/feather-customised/bridge.svg @@ -0,0 +1,50 @@ + + + + + + + image/svg+xml + + + + + + + + + + + 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/res/img/icon-email-pill-avatar.svg b/res/img/icon-email-pill-avatar.svg new file mode 100644 index 0000000000..6b0ac200a5 --- /dev/null +++ b/res/img/icon-email-pill-avatar.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/img/icon-pill-remove.svg b/res/img/icon-pill-remove.svg new file mode 100644 index 0000000000..adf6fd4771 --- /dev/null +++ b/res/img/icon-pill-remove.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index eadde4c672..a3515a9d99 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -16,6 +16,7 @@ $room-highlight-color: #343a46; // typical text (dark-on-white in light skin) $primary-fg-color: $text-primary-color; $primary-bg-color: $bg-color; +$muted-fg-color: $header-panel-text-primary-color; // used for dialog box text $light-fg-color: $header-panel-text-secondary-color; @@ -172,6 +173,8 @@ $interactive-tooltip-fg-color: #ffffff; $breadcrumb-placeholder-bg-color: #272c35; +$user-tile-hover-bg-color: $header-panel-bg-color; + // ***** Mixins! ***** @define-mixin mx_DialogButton { @@ -243,3 +246,13 @@ $breadcrumb-placeholder-bg-color: #272c35; } } } + +// diff highlight colors +// intentionally swapped to avoid inversion +.hljs-addition { + background: #fdd; +} + +.hljs-deletion { + background: #dfd; +} diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 0a3ef812b8..288fb3cadc 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -21,6 +21,7 @@ $header-panel-bg-color: #f3f8fd; // typical text (dark-on-white in light skin) $primary-fg-color: #2e2f32; $primary-bg-color: #ffffff; +$muted-fg-color: #61708b; // Commonly used in headings and relevant alt text // used for dialog box text $light-fg-color: #747474; @@ -293,6 +294,8 @@ $interactive-tooltip-fg-color: #ffffff; $breadcrumb-placeholder-bg-color: #e8eef5; +$user-tile-hover-bg-color: $header-panel-bg-color; + // ***** Mixins! ***** @define-mixin mx_DialogButton { @@ -338,3 +341,12 @@ $breadcrumb-placeholder-bg-color: #e8eef5; color: $accent-color; text-decoration: none; } + +// diff highlight colors +.hljs-addition { + background: #dfd; +} + +.hljs-deletion { + background: #fdd; +} diff --git a/scripts/ci/end-to-end-tests.sh b/scripts/ci/end-to-end-tests.sh index ae88ef70c7..a592888292 100755 --- a/scripts/ci/end-to-end-tests.sh +++ b/scripts/ci/end-to-end-tests.sh @@ -36,7 +36,8 @@ echo "--- Install synapse & other dependencies" ./install.sh # install static webserver to server symlinked local copy of riot ./riot/install-webserver.sh -mkdir logs || rm -r logs/* +rm -r logs || true +mkdir logs echo "+++ Running end-to-end tests" TESTS_STARTED=1 ./run.sh --no-sandbox --log-directory logs/ 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 0752b0e59d..34379c029b 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 index a242042bdb..0773a8d32d 100644 --- a/src/CrossSigningManager.js +++ b/src/CrossSigningManager.js @@ -97,7 +97,7 @@ export const crossSigningCallbacks = { * * 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 + * passphrase. The cache is then cleared once the provided function completes. * * @param {Function} [func] An operation to perform once secret storage has been * bootstrapped. Optional. diff --git a/src/HtmlUtils.js b/src/HtmlUtils.js index 3402ee13e3..236aa0157e 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; } @@ -394,6 +377,7 @@ class TextHighlighter extends BaseHighlighter { * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing * opts.returnString: return an HTML string rather than JSX elements * opts.forComposerQuote: optional param to lessen the url rewriting done by sanitization, for quoting into composer + * opts.ref: React ref to attach to any React components returned (not compatible with opts.returnString) */ export function bodyToHtml(content, highlights, opts={}) { const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; @@ -476,8 +460,8 @@ export function bodyToHtml(content, highlights, opts={}) { }); return isDisplayedWithHtml ? - : - { strippedBody }; + : + { strippedBody }; } /** diff --git a/src/KeyRequestHandler.js b/src/KeyRequestHandler.js index 8101d24e6e..65dc7fdb0f 100644 --- a/src/KeyRequestHandler.js +++ b/src/KeyRequestHandler.js @@ -111,6 +111,12 @@ export default class KeyRequestHandler { this._currentUser = null; this._currentDevice = null; + if (!this._pendingKeyRequests[userId] || !this._pendingKeyRequests[userId][deviceId]) { + // request was removed in the time the dialog was displayed + this._processNextRequest(); + return; + } + if (r) { for (const req of this._pendingKeyRequests[userId][deviceId]) { req.share(); 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/RoomInvite.js b/src/RoomInvite.js index 380bb4a7ac..2fe64c994f 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -25,6 +25,7 @@ import * as sdk from './'; import dis from './dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import { _t } from './languageHandler'; +import SettingsStore from "./settings/SettingsStore"; /** * Invites multiple addresses to a room @@ -41,6 +42,18 @@ function inviteMultipleToRoom(roomId, addrs) { } export function showStartChatInviteDialog() { + if (SettingsStore.isFeatureEnabled("feature_ftue_dms")) { + const DMInviteDialog = sdk.getComponent("dialogs.DMInviteDialog"); + Modal.createTrackedDialog('Start DM', '', DMInviteDialog, { + onFinished: (inviteIds) => { + // TODO: Replace _onStartDmFinished with less hacks + if (inviteIds.length > 0) _onStartDmFinished(true, inviteIds.map(i => ({address: i}))); + // else ignore and just do nothing + }, + }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); + return; + } + const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Start a chat', '', AddressPickerDialog, { @@ -99,7 +112,7 @@ export function isValid3pidInvite(event) { return true; } -// TODO: Immutable DMs replaces this +// TODO: Canonical DMs replaces this function _onStartDmFinished(shouldInvite, addrs) { if (!shouldInvite) return; diff --git a/src/SlashCommands.js b/src/SlashCommands.js index c855b42c29..20b8ba76da 100644 --- a/src/SlashCommands.js +++ b/src/SlashCommands.js @@ -780,54 +780,52 @@ export const CommandMap = { const deviceId = matches[2]; const fingerprint = matches[3]; - return success( - // Promise.resolve to handle transition from static result to promise; can be removed - // in future - Promise.resolve(cli.getStoredDevice(userId, deviceId)).then((device) => { - if (!device) { - throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`); - } + return success((async () => { + const device = await cli.getStoredDevice(userId, deviceId); + if (!device) { + throw new Error(_t('Unknown (user, device) pair:') + ` (${userId}, ${deviceId})`); + } + const deviceTrust = await cli.checkDeviceTrust(userId, deviceId); - if (device.isVerified()) { - if (device.getFingerprint() === fingerprint) { - throw new Error(_t('Device already verified!')); - } else { - throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!')); - } + if (deviceTrust.isVerified()) { + if (device.getFingerprint() === fingerprint) { + throw new Error(_t('Device already verified!')); + } else { + throw new Error(_t('WARNING: Device already verified, but keys do NOT MATCH!')); } + } - if (device.getFingerprint() !== fingerprint) { - const fprint = device.getFingerprint(); - throw new Error( - _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' + - ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' + - '"%(fingerprint)s". This could mean your communications are being intercepted!', - { - fprint, - userId, - deviceId, - fingerprint, - })); - } + if (device.getFingerprint() !== fingerprint) { + const fprint = device.getFingerprint(); + throw new Error( + _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and device' + + ' %(deviceId)s is "%(fprint)s" which does not match the provided key ' + + '"%(fingerprint)s". This could mean your communications are being intercepted!', + { + fprint, + userId, + deviceId, + fingerprint, + })); + } - return cli.setDeviceVerified(userId, deviceId, true); - }).then(() => { - // Tell the user we verified everything - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); - Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, { - title: _t('Verified key'), - description:
-

- { - _t('The signing key you provided matches the signing key you received ' + - 'from %(userId)s\'s device %(deviceId)s. Device marked as verified.', - {userId, deviceId}) - } -

-
, - }); - }), - ); + await cli.setDeviceVerified(userId, deviceId, true); + + // Tell the user we verified everything + const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); + Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, { + title: _t('Verified key'), + description:
+

+ { + _t('The signing key you provided matches the signing key you received ' + + 'from %(userId)s\'s device %(deviceId)s. Device marked as verified.', + {userId, deviceId}) + } +

+
, + }); + })()); } } return reject(this.getUsage()); diff --git a/src/SlateComposerHistoryManager.js b/src/SlateComposerHistoryManager.js deleted file mode 100644 index 948dcf64ff..0000000000 --- a/src/SlateComposerHistoryManager.js +++ /dev/null @@ -1,86 +0,0 @@ -//@flow -/* -Copyright 2017 Aviral Dasgupta - -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 {Value} from 'slate'; - -import _clamp from 'lodash/clamp'; - -type MessageFormat = 'rich' | 'markdown'; - -class HistoryItem { - // We store history items in their native format to ensure history is accurate - // and then convert them if our RTE has subsequently changed format. - value: Value; - format: MessageFormat = 'rich'; - - constructor(value: ?Value, format: ?MessageFormat) { - this.value = value; - this.format = format; - } - - static fromJSON(obj: Object): HistoryItem { - return new HistoryItem( - Value.fromJSON(obj.value), - obj.format, - ); - } - - toJSON(): Object { - return { - value: this.value.toJSON(), - format: this.format, - }; - } -} - -export default class SlateComposerHistoryManager { - history: Array = []; - prefix: string; - lastIndex: number = 0; // used for indexing the storage - currentIndex: number = 0; // used for indexing the loaded validated history Array - - constructor(roomId: string, prefix: string = 'mx_composer_history_') { - this.prefix = prefix + roomId; - - // TODO: Performance issues? - let item; - for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { - try { - this.history.push( - HistoryItem.fromJSON(JSON.parse(item)), - ); - } catch (e) { - console.warn("Throwing away unserialisable history", e); - } - } - this.lastIndex = this.currentIndex; - // reset currentIndex to account for any unserialisable history - this.currentIndex = this.history.length; - } - - save(value: Value, format: MessageFormat) { - const item = new HistoryItem(value, format); - this.history.push(item); - this.currentIndex = this.history.length; - sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); - } - - getItem(offset: number): ?HistoryItem { - this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); - return this.history[this.currentIndex]; - } -} diff --git a/src/TextForEvent.js b/src/TextForEvent.js index 8290e17555..a0d088affb 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -473,7 +473,7 @@ function textForPowerEvent(event) { } function textForPinnedEvent(event) { - const senderName = event.getSender(); + const senderName = event.sender ? event.sender.name : event.getSender(); return _t("%(senderName)s changed the pinned messages for the room.", {senderName}); } diff --git a/src/async-components/views/dialogs/EncryptedEventDialog.js b/src/async-components/views/dialogs/EncryptedEventDialog.js index 1309efd811..28dd802082 100644 --- a/src/async-components/views/dialogs/EncryptedEventDialog.js +++ b/src/async-components/views/dialogs/EncryptedEventDialog.js @@ -14,7 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +<<<<<<< HEAD import React from "react"; +======= +import {Key} from "../../../Keyboard"; + +const React = require("react"); +>>>>>>> develop import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; @@ -22,7 +28,14 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg"; const sdk = require('../../../index'); +<<<<<<< HEAD export default createReactClass({ +======= +// XXX: This component is not cross-signing aware. +// https://github.com/vector-im/riot-web/issues/11752 tracks either updating this +// component or taking it out to pasture. +module.exports = createReactClass({ +>>>>>>> develop displayName: 'EncryptedEventDialog', propTypes: { @@ -84,7 +97,7 @@ export default 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/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js index aec9162c97..af07ce3fb7 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js @@ -1,6 +1,6 @@ /* Copyright 2018, 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -17,10 +17,19 @@ limitations under the License. import React from 'react'; import FileSaver from 'file-saver'; +<<<<<<< HEAD import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; +======= +import PropTypes from 'prop-types'; + +import sdk from '../../../../index'; +import MatrixClientPeg from '../../../../MatrixClientPeg'; +>>>>>>> develop import { scorePassword } from '../../../../utils/PasswordScorer'; import { _t } from '../../../../languageHandler'; +import { accessSecretStorage } from '../../../../CrossSigningManager'; +import SettingsStore from '../../../../settings/SettingsStore'; const PHASE_PASSPHRASE = 0; const PHASE_PASSPHRASE_CONFIRM = 1; @@ -48,10 +57,20 @@ function selectText(target) { * on the server. */ export default class CreateKeyBackupDialog extends React.PureComponent { + static propTypes = { + secureSecretStorage: PropTypes.bool, + onFinished: PropTypes.func.isRequired, + } + constructor(props) { super(props); + this._recoveryKeyNode = null; + this._keyBackupInfo = null; + this._setZxcvbnResultTimeout = null; + this.state = { + secureSecretStorage: props.secureSecretStorage, phase: PHASE_PASSPHRASE, passPhrase: '', passPhraseConfirm: '', @@ -60,12 +79,25 @@ export default class CreateKeyBackupDialog extends React.PureComponent { zxcvbnResult: null, setPassPhrase: false, }; + + if (this.state.secureSecretStorage === undefined) { + this.state.secureSecretStorage = + SettingsStore.isFeatureEnabled("feature_cross_signing"); + } + + // If we're using secret storage, skip ahead to the backing up step, as + // `accessSecretStorage` will handle passphrases as needed. + if (this.state.secureSecretStorage) { + this.state.phase = PHASE_BACKINGUP; + } } - componentWillMount() { - this._recoveryKeyNode = null; - this._keyBackupInfo = null; - this._setZxcvbnResultTimeout = null; + componentDidMount() { + // If we're using secret storage, skip ahead to the backing up step, as + // `accessSecretStorage` will handle passphrases as needed. + if (this.state.secureSecretStorage) { + this._createBackup(); + } } componentWillUnmount() { @@ -102,15 +134,26 @@ export default class CreateKeyBackupDialog extends React.PureComponent { } _createBackup = async () => { + const { secureSecretStorage } = this.state; this.setState({ phase: PHASE_BACKINGUP, error: null, }); let info; try { - info = await MatrixClientPeg.get().createKeyBackupVersion( - this._keyBackupInfo, - ); + if (secureSecretStorage) { + await accessSecretStorage(async () => { + info = await MatrixClientPeg.get().prepareKeyBackupVersion( + null /* random key */, + { secureSecretStorage: true }, + ); + info = await MatrixClientPeg.get().createKeyBackupVersion(info); + }); + } else { + info = await MatrixClientPeg.get().createKeyBackupVersion( + this._keyBackupInfo, + ); + } await MatrixClientPeg.get().scheduleAllGroupSessionsForBackup(); this.setState({ phase: PHASE_DONE, diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js index 7a7d130dbe..559d972f24 100644 --- a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js @@ -1,5 +1,6 @@ /* -Copyright 2018-2019 New Vector Ltd +Copyright 2018, 2019 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -40,9 +41,11 @@ export default class NewRecoveryMethodDialog extends React.PureComponent { onSetupClick = async () => { const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); - Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { - onFinished: this.props.onFinished, - }); + Modal.createTrackedDialog( + 'Restore Backup', '', RestoreKeyBackupDialog, { + onFinished: this.props.onFinished, + }, null, /* priority = */ false, /* static = */ true, + ); } render() { diff --git a/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js index 2ca595fdc9..9dfc3e73ed 100644 --- a/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js +++ b/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js @@ -1,5 +1,6 @@ /* Copyright 2019 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -35,6 +36,7 @@ export default class RecoveryMethodRemovedDialog extends React.PureComponent { this.props.onFinished(); Modal.createTrackedDialogAsync("Key Backup", "Key Backup", import("./CreateKeyBackupDialog"), + null, null, /* priority = */ false, /* static = */ true, ); } diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js index df51650719..daea3224b7 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js @@ -23,13 +23,15 @@ import FileSaver from 'file-saver'; import { _t } from '../../../../languageHandler'; import Modal from '../../../../Modal'; -const PHASE_PASSPHRASE = 0; -const PHASE_PASSPHRASE_CONFIRM = 1; -const PHASE_SHOWKEY = 2; -const PHASE_KEEPITSAFE = 3; -const PHASE_STORING = 4; -const PHASE_DONE = 5; -const PHASE_OPTOUT_CONFIRM = 6; +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. @@ -58,7 +60,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { this._setZxcvbnResultTimeout = null; this.state = { - phase: PHASE_PASSPHRASE, + phase: PHASE_LOADING, passPhrase: '', passPhraseConfirm: '', copied: false, @@ -66,6 +68,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { zxcvbnResult: null, setPassPhrase: false, }; + + this._fetchBackupInfo(); } componentWillUnmount() { @@ -74,10 +78,23 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } } + 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'); @@ -125,6 +142,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { } }, createSecretStorageKey: async () => this._keyInfo, + keyBackupInfo: this.state.backupInfo, }); this.setState({ phase: PHASE_DONE, @@ -250,6 +268,27 @@ export default class CreateSecretStorageDialog extends React.PureComponent { 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'); @@ -449,7 +488,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { ; } - _renderBusyPhase(text) { + _renderBusyPhase() { const Spinner = sdk.getComponent('views.elements.Spinner'); return
@@ -488,6 +527,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _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: @@ -525,6 +566,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
; } 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; 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/PlainWithPillsSerializer.js b/src/autocomplete/PlainWithPillsSerializer.js deleted file mode 100644 index 09bb3772ac..0000000000 --- a/src/autocomplete/PlainWithPillsSerializer.js +++ /dev/null @@ -1,92 +0,0 @@ -/* -Copyright 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -// Based originally on slate-plain-serializer - -import { Block } from 'slate'; - -/** - * Plain text serializer, which converts a Slate `value` to a plain text string, - * serializing pills into various different formats as required. - * - * @type {PlainWithPillsSerializer} - */ - -class PlainWithPillsSerializer { - /* - * @param {String} options.pillFormat - either 'md', 'plain', 'id' - */ - constructor(options = {}) { - const { - pillFormat = 'plain', - } = options; - this.pillFormat = pillFormat; - } - - /** - * Serialize a Slate `value` to a plain text string, - * serializing pills as either MD links, plain text representations or - * ID representations as required. - * - * @param {Value} value - * @return {String} - */ - serialize = value => { - return this._serializeNode(value.document); - } - - /** - * Serialize a `node` to plain text. - * - * @param {Node} node - * @return {String} - */ - _serializeNode = node => { - if ( - node.object == 'document' || - (node.object == 'block' && Block.isBlockList(node.nodes)) - ) { - return node.nodes.map(this._serializeNode).join('\n'); - } else if (node.type == 'emoji') { - return node.data.get('emojiUnicode'); - } else if (node.type == 'pill') { - const completion = node.data.get('completion'); - // over the wire the @room pill is just plaintext - if (completion === '@room') return completion; - - switch (this.pillFormat) { - case 'plain': - return completion; - case 'md': - return `[${ completion }](${ node.data.get('href') })`; - case 'id': - return node.data.get('completionId') || completion; - } - } else if (node.nodes) { - return node.nodes.map(this._serializeNode).join(''); - } else { - return node.text; - } - } -} - -/** - * Export. - * - * @type {PlainWithPillsSerializer} - */ - -export default PlainWithPillsSerializer; 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 bc6fd68686..b4b1b80163 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -71,12 +71,12 @@ export class ContextMenu extends React.Component { // on resize callback windowResize: PropTypes.func, - catchTab: PropTypes.bool, // whether to close the ContextMenu on TAB (default=true) + managed: PropTypes.bool, // whether this context menu should be focus managed. If false it must handle itself }; static defaultProps = { hasBackground: true, - catchTab: true, + managed: true, }; constructor() { @@ -186,15 +186,19 @@ 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) { case Key.TAB: - if (!this.props.catchTab) { - handled = false; - break; - } - // fallthrough case Key.ESCAPE: this.props.onFinished(); break; @@ -321,7 +325,7 @@ export class ContextMenu extends React.Component { return (
-
+
{ chevron } { props.children }
diff --git a/src/components/structures/CustomRoomTagPanel.js b/src/components/structures/CustomRoomTagPanel.js index f378e0f25e..e8ff6e814e 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 85ae414a0a..276baccef1 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -25,9 +25,14 @@ import { _t } from '../../languageHandler'; import sanitizeHtml from 'sanitize-html'; import * as sdk from '../../index'; import dis from '../../dispatcher'; +<<<<<<< HEAD import {MatrixClientPeg} from '../../MatrixClientPeg'; import { MatrixClient } from 'matrix-js-sdk'; +======= +import MatrixClientPeg from '../../MatrixClientPeg'; +>>>>>>> develop import classnames from 'classnames'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; export default class EmbeddedPage extends React.PureComponent { static propTypes = { @@ -39,9 +44,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 +107,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/LeftPanel.js b/src/components/structures/LeftPanel.js index 4f70dc2a9a..ec52afba19 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -19,12 +19,15 @@ import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { MatrixClient } from 'matrix-js-sdk'; import { Key } from '../../Keyboard'; import * as sdk from '../../index'; import dis from '../../dispatcher'; +<<<<<<< HEAD import * as VectorConferenceHandler from '../../VectorConferenceHandler'; import TagPanelButtons from './TagPanelButtons'; +======= +import VectorConferenceHandler from '../../VectorConferenceHandler'; +>>>>>>> develop import SettingsStore from '../../settings/SettingsStore'; import {_t} from "../../languageHandler"; import Analytics from "../../Analytics"; @@ -39,10 +42,6 @@ const LeftPanel = createReactClass({ collapsed: PropTypes.bool.isRequired, }, - contextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient), - }, - getInitialState: function() { return { searchFilter: '', @@ -243,7 +242,6 @@ const LeftPanel = createReactClass({ tagPanelContainer = (
{ isCustomTagsEnabled ? : undefined } -
); } diff --git a/src/components/structures/LoggedInView.js b/src/components/structures/LoggedInView.js index 2cc30a5b39..9597f99cd2 100644 --- a/src/components/structures/LoggedInView.js +++ b/src/components/structures/LoggedInView.js @@ -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. @@ -77,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 @@ -407,13 +393,6 @@ const LoggedInView = createReactClass({ return; } - // XXX: Remove after CIDER replaces Slate completely: https://github.com/vector-im/riot-web/issues/11036 - // If using Slate, consume the Backspace without first focusing as it causes an implosion - if (ev.key === Key.BACKSPACE && !SettingsStore.getValue("useCiderComposer")) { - ev.stopPropagation(); - return; - } - if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { // synchronous dispatch so we focus before key generates input dis.dispatch({action: 'focus_composer'}, true); @@ -631,21 +610,30 @@ const LoggedInView = createReactClass({ } return ( -
- { topBar } - - -
- - - { pageElement } -
-
-
+ +
+ { topBar } + + +
+ + + { pageElement } +
+
+
+
); }, }); diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index bd7bfd8780..772be358cf 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -74,6 +74,21 @@ export default class MainSplit extends React.Component { } } + componentDidUpdate(prevProps) { + const wasPanelSet = this.props.panel && !prevProps.panel; + const wasPanelCleared = !this.props.panel && prevProps.panel; + + 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 && wasPanelCleared) { + this.resizer.detach(); + this.resizer = null; + } + } + render() { const bodyView = React.Children.only(this.props.children); const panelView = this.props.panel; diff --git a/src/components/structures/MatrixChat.js b/src/components/structures/MatrixChat.js index 0713290a26..e88c1bfa16 100644 --- a/src/components/structures/MatrixChat.js +++ b/src/components/structures/MatrixChat.js @@ -150,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. @@ -1466,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()) { @@ -1492,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 @@ -1579,9 +1569,17 @@ export default createReactClass({ action: 'start_post_registration', }); } else if (screen.indexOf('room/') == 0) { - const segments = screen.substring(5).split('/'); - const roomString = segments[0]; - let eventId = segments.splice(1).join("/"); // empty string if no event id given + // Rooms can have the following formats: + // #room_alias:domain or !opaque_id:domain + const room = screen.substring(5); + const domainOffset = room.indexOf(':') + 1; // 0 in case room does not contain a : + let eventOffset = room.length; + // room aliases can contain slashes only look for slash after domain + if (room.substring(domainOffset).indexOf('/') > -1) { + eventOffset = domainOffset + room.substring(domainOffset).indexOf('/'); + } + const roomString = room.substring(0, eventOffset); + let eventId = room.substring(eventOffset + 1); // empty string if no event id given // Previously we pulled the eventID from the segments in such a way // where if there was no eventId then we'd get undefined. However, we diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 3c01c3d6a3..7d7b19d979 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -17,12 +17,17 @@ limitations under the License. import React from 'react'; import createReactClass from 'create-react-class'; +<<<<<<< HEAD import PropTypes from 'prop-types'; import { MatrixClient } from 'matrix-js-sdk'; import * as sdk from '../../index'; +======= +import sdk from '../../index'; +>>>>>>> develop 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 +39,8 @@ export default createReactClass({ }; }, - contextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + statics: { + contextType: MatrixClientContext, }, componentWillMount: function() { @@ -47,7 +52,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 bb62699ecf..a725b73d9a 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -23,13 +23,13 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import * as 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() { @@ -40,14 +40,10 @@ export default class RightPanel extends React.Component { }; } - static get contextTypes() { - return { - matrixClient: PropTypes.instanceOf(MatrixClient), - }; - } + static contextType = MatrixClientContext; - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this.state = { phase: this._getPhaseFromProps(), isUserPrivilegedInGroup: null, @@ -93,15 +89,15 @@ export default class RightPanel extends React.Component { 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); } 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); } @@ -190,7 +186,7 @@ export default class RightPanel extends React.Component { } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupRoomList) { panel = ; } else if (this.state.phase === RIGHT_PANEL_PHASES.RoomMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { const onClose = () => { dis.dispatch({ action: "view_user", @@ -209,7 +205,7 @@ export default class RightPanel extends React.Component { } else if (this.state.phase === RIGHT_PANEL_PHASES.Room3pidMemberInfo) { panel = ; } else if (this.state.phase === RIGHT_PANEL_PHASES.GroupMemberInfo) { - if (SettingsStore.isFeatureEnabled("feature_dm_verification")) { + if (SettingsStore.isFeatureEnabled("feature_cross_signing")) { const onClose = () => { dis.dispatch({ action: "view_user", diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index d783c8cc3f..87b9d076e2 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -27,7 +27,11 @@ import PropTypes from 'prop-types'; import { _t } from '../../languageHandler'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import Analytics from '../../Analytics'; +<<<<<<< HEAD import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; +======= +import MatrixClientContext from "../../contexts/MatrixClientContext"; +>>>>>>> develop const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 160; @@ -63,16 +67,6 @@ export default createReactClass({ }; }, - childContextTypes: { - matrixClient: PropTypes.object, - }, - - getChildContext: function() { - return { - matrixClient: MatrixClientPeg.get(), - }; - }, - componentWillMount: function() { this._unmounted = false; this.nextBatch = null; @@ -268,6 +262,7 @@ export default 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 diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 0839c364e5..d9ce032ba8 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -26,7 +26,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; @@ -273,7 +273,7 @@ export default 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 e16fee64a5..c2a644287d 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -25,7 +25,7 @@ import * as 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"; @@ -186,7 +186,7 @@ export default class RoomSubList extends React.PureComponent { 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)), }); }; diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index d3309a1f43..83ef6441f6 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -25,10 +25,8 @@ import shouldHideEvent from '../../shouldHideEvent'; 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 * as 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'; @@ -55,7 +53,11 @@ import SettingsStore, {SettingLevel} from "../../settings/SettingsStore"; import WidgetUtils from '../../utils/WidgetUtils'; import AccessibleButton from "../views/elements/AccessibleButton"; import RightPanelStore from "../../stores/RightPanelStore"; +<<<<<<< HEAD import {haveTileForEvent} from "../views/rooms/EventTile"; +======= +import RoomContext from "../../contexts/RoomContext"; +>>>>>>> develop const DEBUG = false; let debuglog = function() {}; @@ -67,6 +69,7 @@ if (DEBUG) { debuglog = console.log.bind(console); } +<<<<<<< HEAD export const RoomContext = PropTypes.shape({ canReact: PropTypes.bool.isRequired, canReply: PropTypes.bool.isRequired, @@ -74,6 +77,9 @@ export const RoomContext = PropTypes.shape({ }); export default createReactClass({ +======= +module.exports = createReactClass({ +>>>>>>> develop displayName: 'RoomView', propTypes: { ConferenceHandler: PropTypes.any, @@ -165,23 +171,6 @@ export default createReactClass({ canReact: false, canReply: false, - - useCider: false, - }; - }, - - childContextTypes: { - room: RoomContext, - }, - - getChildContext: function() { - const {canReact, canReply, room} = this.state; - return { - room: { - canReact, - canReply, - room, - }, }; }, @@ -203,18 +192,10 @@ export default createReactClass({ WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); - this._onCiderUpdated(); - this._ciderWatcherRef = SettingsStore.watchSetting( - "useCiderComposer", null, this._onCiderUpdated); - this._roomView = createRef(); this._searchResultsPanel = createRef(); }, - _onCiderUpdated: function() { - this.setState({useCider: SettingsStore.getValue("useCiderComposer")}); - }, - _onRoomViewStoreUpdate: function(initial) { if (this.unmounted) { return; @@ -462,7 +443,7 @@ export default createReactClass({ componentDidUpdate: function() { if (this._roomView.current) { - const roomView = ReactDOM.findDOMNode(this._roomView.current); + const roomView = this._roomView.current; if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); @@ -506,7 +487,7 @@ export default createReactClass({ // 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._roomView.current); + const roomView = this._roomView.current; roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); @@ -562,15 +543,15 @@ export default 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; @@ -793,11 +774,12 @@ export default 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. @@ -806,10 +788,38 @@ export default 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", }); }, @@ -1800,29 +1810,16 @@ export default createReactClass({ myMembership === 'join' && !this.state.searchResults ); if (canSpeak) { - if (this.state.useCider) { - const MessageComposer = sdk.getComponent('rooms.MessageComposer'); - messageComposer = - ; - } else { - const SlateMessageComposer = sdk.getComponent('rooms.SlateMessageComposer'); - messageComposer = - ; - } + const MessageComposer = sdk.getComponent('rooms.MessageComposer'); + messageComposer = + ; } // TODO: Why aren't we storing the term/scope/count in this format @@ -1925,7 +1922,8 @@ export default createReactClass({ />); 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 = ( - - - -
- {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 c5725e1343..bc7c400949 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -17,7 +17,7 @@ limitations under the License. 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"; @@ -532,26 +532,26 @@ export default 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(); } diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index faa20a68ba..3be2f65dc5 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -18,7 +18,7 @@ limitations under the License. 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'; @@ -93,8 +93,8 @@ export default createReactClass({ }, 200, {trailing: true, leading: true}), _onKeyDown: function(ev) { - switch (ev.keyCode) { - case KeyCode.ESCAPE: + switch (ev.key) { + case Key.ESCAPE: this._clearSearch("keyboard"); break; } diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 0724027e37..cefb60653f 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -16,8 +16,6 @@ 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 TagOrderStore from '../../stores/TagOrderStore'; import GroupActions from '../../actions/GroupActions'; @@ -28,12 +26,13 @@ import { _t } from '../../languageHandler'; import { Droppable } from 'react-beautiful-dnd'; import classNames from 'classnames'; +import MatrixClientContext from "../../contexts/MatrixClientContext"; const TagPanel = createReactClass({ displayName: 'TagPanel', - contextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient), + statics: { + contextType: MatrixClientContext, }, getInitialState() { @@ -45,8 +44,8 @@ const TagPanel = createReactClass({ componentWillMount: function() { this.unmounted = false; - this.context.matrixClient.on("Group.myMembership", this._onGroupMyMembership); - this.context.matrixClient.on("sync", this._onClientSync); + this.context.on("Group.myMembership", this._onGroupMyMembership); + this.context.on("sync", this._onClientSync); this._tagOrderStoreToken = TagOrderStore.addListener(() => { 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/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 06be5af41c..ceb4113d86 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -25,6 +25,7 @@ import PropTypes from 'prop-types'; import {EventTimeline} from "matrix-js-sdk"; import * as Matrix from "matrix-js-sdk"; import { _t } from '../../languageHandler'; +<<<<<<< HEAD import {MatrixClientPeg} from "../../MatrixClientPeg"; import * as ObjectUtils from "../../ObjectUtils"; import UserActivity from "../../UserActivity"; @@ -32,6 +33,14 @@ import Modal from "../../Modal"; import dis from "../../dispatcher"; import * as sdk from "../../index"; import { KeyCode } from '../../Keyboard'; +======= +const MatrixClientPeg = require("../../MatrixClientPeg"); +const dis = require("../../dispatcher"); +const ObjectUtils = require('../../ObjectUtils'); +const Modal = require("../../Modal"); +const UserActivity = require("../../UserActivity"); +import {Key} from '../../Keyboard'; +>>>>>>> develop import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; @@ -940,8 +949,7 @@ const TimelinePanel = createReactClass({ // 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._messagePanel.current.handleScrollKey(ev); diff --git a/src/components/views/auth/CountryDropdown.js b/src/components/views/auth/CountryDropdown.js index c55d2419ff..63dc9d1ada 100644 --- a/src/components/views/auth/CountryDropdown.js +++ b/src/components/views/auth/CountryDropdown.js @@ -21,6 +21,7 @@ import * as sdk from '../../../index'; import { COUNTRIES } from '../../../phonenumber'; import SdkConfig from "../../../SdkConfig"; +import { _t } from "../../../languageHandler"; const COUNTRIES_BY_ISO2 = {}; for (const c of COUNTRIES) { @@ -130,10 +131,17 @@ export default class CountryDropdown extends React.Component { // values between mounting and the initial value propgating const value = this.props.value || this.state.defaultCountry.iso2; - return { options } ; diff --git a/src/components/views/avatars/BaseAvatar.js b/src/components/views/avatars/BaseAvatar.js index 1dd52c99fa..2b49916bbe 100644 --- a/src/components/views/avatars/BaseAvatar.js +++ b/src/components/views/avatars/BaseAvatar.js @@ -20,10 +20,15 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import createReactClass from 'create-react-class'; +<<<<<<< HEAD import { MatrixClient } from 'matrix-js-sdk'; import * as AvatarLogic from '../../../Avatar'; +======= +import AvatarLogic from '../../../Avatar'; +>>>>>>> develop import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from '../elements/AccessibleButton'; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; export default createReactClass({ displayName: 'BaseAvatar', @@ -39,10 +44,16 @@ export default createReactClass({ // XXX resizeMethod not actually used. resizeMethod: PropTypes.string, defaultToInitialLetter: PropTypes.bool, // true to add default url + inputRef: PropTypes.oneOfType([ + // Either a function + PropTypes.func, + // Or the instance of a DOM native element + PropTypes.shape({ current: PropTypes.instanceOf(Element) }), + ]), }, - contextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient), + statics: { + contextType: MatrixClientContext, }, getDefaultProps: function() { @@ -60,12 +71,12 @@ export default createReactClass({ componentDidMount() { this.unmounted = false; - this.context.matrixClient.on('sync', this.onClientSync); + this.context.on('sync', this.onClientSync); }, componentWillUnmount() { this.unmounted = true; - this.context.matrixClient.removeListener('sync', this.onClientSync); + this.context.removeListener('sync', this.onClientSync); }, componentWillReceiveProps: function(nextProps) { @@ -149,7 +160,7 @@ export default createReactClass({ const { name, idName, title, url, urls, width, height, resizeMethod, - defaultToInitialLetter, onClick, + defaultToInitialLetter, onClick, inputRef, ...otherProps } = this.props; @@ -172,7 +183,7 @@ export default createReactClass({ if (onClick != null) { return ( { textNode } { imgNode } @@ -180,7 +191,7 @@ export default createReactClass({ ); } else { return ( - + { textNode } { imgNode } @@ -189,21 +200,26 @@ export default createReactClass({ } if (onClick != null) { return ( - ); } else { return ( - ); } diff --git a/src/components/views/avatars/MemberStatusMessageAvatar.js b/src/components/views/avatars/MemberStatusMessageAvatar.js index 18af80991a..aaac61ce7d 100644 --- a/src/components/views/avatars/MemberStatusMessageAvatar.js +++ b/src/components/views/avatars/MemberStatusMessageAvatar.js @@ -38,8 +38,8 @@ export default class MemberStatusMessageAvatar extends React.Component { resizeMethod: 'crop', }; - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this.state = { hasStatus: this.hasStatus, diff --git a/src/components/views/context_menus/GroupInviteTileContextMenu.js b/src/components/views/context_menus/GroupInviteTileContextMenu.js index 6d0f921ed0..27ef76452f 100644 --- a/src/components/views/context_menus/GroupInviteTileContextMenu.js +++ b/src/components/views/context_menus/GroupInviteTileContextMenu.js @@ -31,8 +31,8 @@ export default class GroupInviteTileContextMenu extends React.Component { onFinished: PropTypes.func, }; - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this._onClickReject = this._onClickReject.bind(this); } diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 6a049ce9b9..7215a45be2 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -422,7 +422,7 @@ export default createReactClass({ ); - if (this.props.eventTileOps && this.props.eventTileOps.getInnerText) { + if (this.props.eventTileOps) { // this event is rendered using TextualBody quoteButton = ( { _t('Quote') } diff --git a/src/components/views/context_menus/StatusMessageContextMenu.js b/src/components/views/context_menus/StatusMessageContextMenu.js index c7cf5607a1..d5cba45956 100644 --- a/src/components/views/context_menus/StatusMessageContextMenu.js +++ b/src/components/views/context_menus/StatusMessageContextMenu.js @@ -27,8 +27,8 @@ export default class StatusMessageContextMenu extends React.Component { user: PropTypes.object, }; - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this.state = { message: this.comittedStatusMessage, diff --git a/src/components/views/context_menus/TagTileContextMenu.js b/src/components/views/context_menus/TagTileContextMenu.js index 2be0487ced..7313a278cc 100644 --- a/src/components/views/context_menus/TagTileContextMenu.js +++ b/src/components/views/context_menus/TagTileContextMenu.js @@ -17,12 +17,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import { MatrixClient } from 'matrix-js-sdk'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher'; import TagOrderActions from '../../../actions/TagOrderActions'; import * as sdk from '../../../index'; import {MenuItem} from "../../structures/ContextMenu"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; export default class TagTileContextMenu extends React.Component { static propTypes = { @@ -31,9 +31,7 @@ export default class TagTileContextMenu extends React.Component { onFinished: PropTypes.func.isRequired, }; - static contextTypes = { - matrixClient: PropTypes.instanceOf(MatrixClient), - }; + static contextType = MatrixClientContext; constructor() { super(); @@ -51,7 +49,7 @@ export default class TagTileContextMenu extends React.Component { } _onRemoveClick() { - dis.dispatch(TagOrderActions.removeTag(this.context.matrixClient, this.props.tag)); + dis.dispatch(TagOrderActions.removeTag(this.context, this.props.tag)); this.props.onFinished(); } diff --git a/src/components/views/context_menus/TopLeftMenu.js b/src/components/views/context_menus/TopLeftMenu.js index b75ec7ce36..4f6da336f7 100644 --- a/src/components/views/context_menus/TopLeftMenu.js +++ b/src/components/views/context_menus/TopLeftMenu.js @@ -25,6 +25,7 @@ import SdkConfig from '../../../SdkConfig'; import { getHostingLink } from '../../../utils/HostingLink'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MenuItem} from "../../structures/ContextMenu"; +import sdk from "../../../index"; export class TopLeftMenu extends React.Component { static propTypes = { @@ -100,6 +101,12 @@ export class TopLeftMenu extends React.Component { ); } + const helpItem = ( + + {_t("Help")} + + ); + const settingsItem = ( {_t("Settings")} @@ -115,11 +122,18 @@ export class TopLeftMenu extends React.Component {
    {homePageItem} {settingsItem} + {helpItem} {signInOutItem}
; } + openHelp = () => { + this.closeMenu(); + const RedesignFeedbackDialog = sdk.getComponent("views.dialogs.RedesignFeedbackDialog"); + Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); + }; + viewHomePage() { dis.dispatch({action: 'view_home_page'}); this.closeMenu(); diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 8867800d1e..e309c3a0cf 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -32,6 +32,7 @@ import IdentityAuthClient from '../../../IdentityAuthClient'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils'; import { abbreviateUrl } from '../../../utils/UrlUtils'; import {sleep} from "../../../utils/promise"; +import {Key} from "../../../Keyboard"; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -142,39 +143,41 @@ export default createReactClass({ }, onKeyDown: function(e) { - if (e.keyCode === 27) { // escape + const textInput = this._textinput.current ? this._textinput.current.value : undefined; + + if (e.key === Key.ESCAPE) { e.stopPropagation(); e.preventDefault(); this.props.onFinished(false); - } else if (e.keyCode === 38) { // up arrow + } else if (e.key === Key.ARROW_UP) { e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.moveSelectionUp(); - } else if (e.keyCode === 40) { // down arrow + } else if (e.key === Key.ARROW_DOWN) { e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.moveSelectionDown(); - } else if (this.state.suggestedList.length > 0 && (e.keyCode === 188 || e.keyCode === 13 || e.keyCode === 9)) { // comma or enter or tab + } else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) { e.stopPropagation(); e.preventDefault(); if (this.addressSelector) this.addressSelector.chooseSelection(); - } else if (this._textinput.current.value.length === 0 && this.state.selectedList.length && e.keyCode === 8) { // backspace + } else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) { e.stopPropagation(); e.preventDefault(); this.onDismissed(this.state.selectedList.length - 1)(); - } else if (e.keyCode === 13) { // enter + } else if (e.key === Key.ENTER) { e.stopPropagation(); e.preventDefault(); - if (this._textinput.current.value === '') { + if (textInput === '') { // if there's nothing in the input box, submit the form this.onButtonClick(); } else { - this._addAddressesToList([this._textinput.current.value]); + this._addAddressesToList([textInput]); } - } else if (e.keyCode === 188 || e.keyCode === 9) { // comma or tab + } else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) { e.stopPropagation(); e.preventDefault(); - this._addAddressesToList([this._textinput.current.value]); + this._addAddressesToList([textInput]); } }, diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 1a4523ad80..19f22a15ad 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations Ltd 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. @@ -17,16 +18,15 @@ limitations under the License. import React from 'react'; import createReactClass from 'create-react-class'; -import FocusTrap from 'focus-trap-react'; +import FocusLock from 'react-focus-lock'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { MatrixClient } from 'matrix-js-sdk'; - -import { KeyCode } from '../../../Keyboard'; +import { Key } from '../../../Keyboard'; import AccessibleButton from '../elements/AccessibleButton'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; /** * Basic container for modal dialogs. @@ -83,16 +83,6 @@ export default createReactClass({ }; }, - childContextTypes: { - matrixClient: PropTypes.instanceOf(MatrixClient), - }, - - getChildContext: function() { - return { - matrixClient: this._matrixClient, - }; - }, - componentWillMount() { this._matrixClient = MatrixClientPeg.get(); }, @@ -101,7 +91,7 @@ export default createReactClass({ if (this.props.onKeyDown) { this.props.onKeyDown(e); } - if (this.props.hasCancel && e.keyCode === KeyCode.ESCAPE) { + if (this.props.hasCancel && e.key === Key.ESCAPE) { e.stopPropagation(); e.preventDefault(); this.props.onFinished(false); @@ -121,32 +111,38 @@ export default createReactClass({ } return ( - -
-
- { this.props.title } + + +
+
+ { this.props.title } +
+ { this.props.headerButton } + { cancelButton }
- { this.props.headerButton } - { cancelButton } -
- { this.props.children } - + { this.props.children } + + ); }, }); diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js index 9289947e61..ccb332fa60 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.js @@ -25,8 +25,8 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; export default class BugReportDialog extends React.Component { - constructor(props, context) { - super(props, context); + constructor(props) { + super(props); this.state = { sendLogs: true, busy: false, diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index 41b43af287..288074a891 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -173,7 +173,7 @@ export default createReactClass({ const domain = MatrixClientPeg.get().getDomain(); aliasField = (
- this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} /> + this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} />
); } else { diff --git a/src/components/views/dialogs/DMInviteDialog.js b/src/components/views/dialogs/DMInviteDialog.js new file mode 100644 index 0000000000..371768eb4e --- /dev/null +++ b/src/components/views/dialogs/DMInviteDialog.js @@ -0,0 +1,773 @@ +/* +Copyright 2019, 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, {createRef} from 'react'; +import PropTypes from 'prop-types'; +import {_t} from "../../../languageHandler"; +import sdk from "../../../index"; +import MatrixClientPeg from "../../../MatrixClientPeg"; +import {makeUserPermalink} from "../../../utils/permalinks/Permalinks"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import {RoomMember} from "matrix-js-sdk/lib/matrix"; +import * as humanize from "humanize"; +import SdkConfig from "../../../SdkConfig"; +import {getHttpUriForMxc} from "matrix-js-sdk/lib/content-repo"; +import * as Email from "../../../email"; +import {getDefaultIdentityServerUrl, useDefaultIdentityServer} from "../../../utils/IdentityServerUtils"; +import {abbreviateUrl} from "../../../utils/UrlUtils"; +import dis from "../../../dispatcher"; +import IdentityAuthClient from "../../../IdentityAuthClient"; +import Modal from "../../../Modal"; + +// TODO: [TravisR] Make this generic for all kinds of invites + +const INITIAL_ROOMS_SHOWN = 3; // Number of rooms to show at first +const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is clicked + +// This is the interface that is expected by various components in this file. It is a bit +// awkward because it also matches the RoomMember class from the js-sdk with some extra support +// for 3PIDs/email addresses. +// +// XXX: We should use TypeScript interfaces instead of this weird "abstract" class. +class Member { + /** + * The display name of this Member. For users this should be their profile's display + * name or user ID if none set. For 3PIDs this should be the 3PID address (email). + */ + get name(): string { throw new Error("Member class not implemented"); } + + /** + * The ID of this Member. For users this should be their user ID. For 3PIDs this should + * be the 3PID address (email). + */ + get userId(): string { throw new Error("Member class not implemented"); } + + /** + * Gets the MXC URL of this Member's avatar. For users this should be their profile's + * avatar MXC URL or null if none set. For 3PIDs this should always be null. + */ + getMxcAvatarUrl(): string { throw new Error("Member class not implemented"); } +} + +class DirectoryMember extends Member { + _userId: string; + _displayName: string; + _avatarUrl: string; + + constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) { + super(); + this._userId = userDirResult.user_id; + this._displayName = userDirResult.display_name; + this._avatarUrl = userDirResult.avatar_url; + } + + // These next class members are for the Member interface + get name(): string { + return this._displayName || this._userId; + } + + get userId(): string { + return this._userId; + } + + getMxcAvatarUrl(): string { + return this._avatarUrl; + } +} + +class ThreepidMember extends Member { + _id: string; + + constructor(id: string) { + super(); + this._id = id; + } + + // This is a getter that would be falsey on all other implementations. Until we have + // better type support in the react-sdk we can use this trick to determine the kind + // of 3PID we're dealing with, if any. + get isEmail(): boolean { + return this._id.includes('@'); + } + + // These next class members are for the Member interface + get name(): string { + return this._id; + } + + get userId(): string { + return this._id; + } + + getMxcAvatarUrl(): string { + return null; + } +} + +class DMUserTile extends React.PureComponent { + static propTypes = { + member: PropTypes.object.isRequired, // Should be a Member (see interface above) + onRemove: PropTypes.func.isRequired, // takes 1 argument, the member being removed + }; + + _onRemove = (e) => { + // Stop the browser from highlighting text + e.preventDefault(); + e.stopPropagation(); + + this.props.onRemove(this.props.member); + }; + + render() { + const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); + const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + + const avatarSize = 20; + const avatar = this.props.member.isEmail + ? + : ; + + return ( + + + {avatar} + {this.props.member.name} + + + {_t('Remove')} + + + ); + } +} + +class DMRoomTile extends React.PureComponent { + static propTypes = { + member: PropTypes.object.isRequired, // Should be a Member (see interface above) + lastActiveTs: PropTypes.number, + onToggle: PropTypes.func.isRequired, // takes 1 argument, the member being toggled + highlightWord: PropTypes.string, + isSelected: PropTypes.bool, + }; + + _onClick = (e) => { + // Stop the browser from highlighting text + e.preventDefault(); + e.stopPropagation(); + + this.props.onToggle(this.props.member); + }; + + _highlightName(str: string) { + if (!this.props.highlightWord) return str; + + // We convert things to lowercase for index searching, but pull substrings from + // the submitted text to preserve case. Note: we don't need to htmlEntities the + // string because React will safely encode the text for us. + const lowerStr = str.toLowerCase(); + const filterStr = this.props.highlightWord.toLowerCase(); + + const result = []; + + let i = 0; + let ii; + while ((ii = lowerStr.indexOf(filterStr, i)) >= 0) { + // Push any text we missed (first bit/middle of text) + if (ii > i) { + // Push any text we aren't highlighting (middle of text match, or beginning of text) + result.push({str.substring(i, ii)}); + } + + i = ii; // copy over ii only if we have a match (to preserve i for end-of-text matching) + + // Highlight the word the user entered + const substr = str.substring(i, filterStr.length + i); + result.push({substr}); + i += substr.length; + } + + // Push any text we missed (end of text) + if (i < (str.length - 1)) { + result.push({str.substring(i)}); + } + + return result; + } + + render() { + const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); + + let timestamp = null; + if (this.props.lastActiveTs) { + // TODO: [TravisR] Figure out how to i18n this + // `humanize` wants seconds for a timestamp, so divide by 1000 + const humanTs = humanize.relativeTime(this.props.lastActiveTs / 1000); + timestamp = {humanTs}; + } + + const avatarSize = 36; + const avatar = this.props.member.isEmail + ? + : ; + + let checkmark = null; + if (this.props.isSelected) { + // To reduce flickering we put the 'selected' room tile above the real avatar + checkmark =
; + } + + // To reduce flickering we put the checkmark on top of the actual avatar (prevents + // the browser from reloading the image source when the avatar remounts). + const stackedAvatar = ( + + {avatar} + {checkmark} + + ); + + return ( +
+ {stackedAvatar} + {this._highlightName(this.props.member.name)} + {this._highlightName(this.props.member.userId)} + {timestamp} +
+ ); + } +} + +export default class DMInviteDialog extends React.PureComponent { + static propTypes = { + // Takes an array of user IDs/emails to invite. + onFinished: PropTypes.func.isRequired, + }; + + _debounceTimer: number = null; + _editorRef: any = null; + + constructor() { + super(); + + this.state = { + targets: [], // array of Member objects (see interface above) + filterText: "", + recents: this._buildRecents(), + numRecentsShown: INITIAL_ROOMS_SHOWN, + suggestions: this._buildSuggestions(), + numSuggestionsShown: INITIAL_ROOMS_SHOWN, + serverResultsMixin: [], // { user: DirectoryMember, userId: string }[], like recents and suggestions + threepidResultsMixin: [], // { user: ThreepidMember, userId: string}[], like recents and suggestions + canUseIdentityServer: !!MatrixClientPeg.get().getIdentityServerUrl(), + tryingIdentityServer: false, + }; + + this._editorRef = createRef(); + } + + _buildRecents(): {userId: string, user: RoomMember, lastActive: number} { + const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); + const recents = []; + for (const userId in rooms) { + const room = rooms[userId]; + const member = room.getMember(userId); + if (!member) continue; // just skip people who don't have memberships for some reason + + const lastEventTs = room.timeline && room.timeline.length + ? room.timeline[room.timeline.length - 1].getTs() + : 0; + if (!lastEventTs) continue; // something weird is going on with this room + + recents.push({userId, user: member, lastActive: lastEventTs}); + } + + // Sort the recents by last active to save us time later + recents.sort((a, b) => b.lastActive - a.lastActive); + + return recents; + } + + _buildSuggestions(): {userId: string, user: RoomMember} { + const maxConsideredMembers = 200; + const client = MatrixClientPeg.get(); + const excludedUserIds = [client.getUserId(), SdkConfig.get()['welcomeUserId']]; + const joinedRooms = client.getRooms() + .filter(r => r.getMyMembership() === 'join') + .filter(r => r.getJoinedMemberCount() <= maxConsideredMembers); + + // Generates { userId: {member, rooms[]} } + const memberRooms = joinedRooms.reduce((members, room) => { + const joinedMembers = room.getJoinedMembers().filter(u => !excludedUserIds.includes(u.userId)); + for (const member of joinedMembers) { + if (!members[member.userId]) { + members[member.userId] = { + member: member, + // Track the room size of the 'picked' member so we can use the profile of + // the smallest room (likely a DM). + pickedMemberRoomSize: room.getJoinedMemberCount(), + rooms: [], + }; + } + + members[member.userId].rooms.push(room); + + if (room.getJoinedMemberCount() < members[member.userId].pickedMemberRoomSize) { + members[member.userId].member = member; + members[member.userId].pickedMemberRoomSize = room.getJoinedMemberCount(); + } + } + return members; + }, {}); + + // Generates { userId: {member, numRooms, score} } + const memberScores = Object.values(memberRooms).reduce((scores, entry) => { + const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0); + const maxRange = maxConsideredMembers * entry.rooms.length; + scores[entry.member.userId] = { + member: entry.member, + numRooms: entry.rooms.length, + score: Math.max(0, Math.pow(1 - (numMembersTotal / maxRange), 5)), + }; + return scores; + }, {}); + + const members = Object.values(memberScores); + members.sort((a, b) => { + if (a.score === b.score) { + if (a.numRooms === b.numRooms) { + return a.member.userId.localeCompare(b.member.userId); + } + + return b.numRooms - a.numRooms; + } + return b.score - a.score; + }); + return members.map(m => ({userId: m.member.userId, user: m.member})); + } + + _startDm = () => { + this.props.onFinished(this.state.targets.map(t => t.userId)); + }; + + _cancel = () => { + this.props.onFinished([]); + }; + + _updateFilter = (e) => { + const term = e.target.value; + this.setState({filterText: term}); + + // Debounce server lookups to reduce spam. We don't clear the existing server + // results because they might still be vaguely accurate, likewise for races which + // could happen here. + if (this._debounceTimer) { + clearTimeout(this._debounceTimer); + } + this._debounceTimer = setTimeout(async () => { + MatrixClientPeg.get().searchUserDirectory({term}).then(r => { + if (term !== this.state.filterText) { + // Discard the results - we were probably too slow on the server-side to make + // these results useful. This is a race we want to avoid because we could overwrite + // more accurate results. + return; + } + this.setState({ + serverResultsMixin: r.results.map(u => ({ + userId: u.user_id, + user: new DirectoryMember(u), + })), + }); + }).catch(e => { + console.error("Error searching user directory:"); + console.error(e); + this.setState({serverResultsMixin: []}); // clear results because it's moderately fatal + }); + + // Whenever we search the directory, also try to search the identity server. It's + // all debounced the same anyways. + if (!this.state.canUseIdentityServer) { + // The user doesn't have an identity server set - warn them of that. + this.setState({tryingIdentityServer: true}); + return; + } + if (term.indexOf('@') > 0 && Email.looksValid(term)) { + // Start off by suggesting the plain email while we try and resolve it + // to a real account. + this.setState({ + // per above: the userId is a lie here - it's just a regular identifier + threepidResultsMixin: [{user: new ThreepidMember(term), userId: term}], + }); + try { + const authClient = new IdentityAuthClient(); + const token = await authClient.getAccessToken(); + if (term !== this.state.filterText) return; // abandon hope + + const lookup = await MatrixClientPeg.get().lookupThreePid( + 'email', + term, + undefined, // callback + token, + ); + if (term !== this.state.filterText) return; // abandon hope + + if (!lookup || !lookup.mxid) { + // We weren't able to find anyone - we're already suggesting the plain email + // as an alternative, so do nothing. + return; + } + + // We append the user suggestion to give the user an option to click + // the email anyways, and so we don't cause things to jump around. In + // theory, the user would see the user pop up and think "ah yes, that + // person!" + const profile = await MatrixClientPeg.get().getProfileInfo(lookup.mxid); + if (term !== this.state.filterText || !profile) return; // abandon hope + this.setState({ + threepidResultsMixin: [...this.state.threepidResultsMixin, { + user: new DirectoryMember({ + user_id: lookup.mxid, + display_name: profile.displayname, + avatar_url: profile.avatar_url, + }), + userId: lookup.mxid, + }], + }); + } catch (e) { + console.error("Error searching identity server:"); + console.error(e); + this.setState({threepidResultsMixin: []}); // clear results because it's moderately fatal + } + } + }, 150); // 150ms debounce (human reaction time + some) + }; + + _showMoreRecents = () => { + this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN}); + }; + + _showMoreSuggestions = () => { + this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN}); + }; + + _toggleMember = (member: Member) => { + const targets = this.state.targets.map(t => t); // cheap clone for mutation + const idx = targets.indexOf(member); + if (idx >= 0) targets.splice(idx, 1); + else targets.push(member); + this.setState({targets}); + }; + + _removeMember = (member: Member) => { + const targets = this.state.targets.map(t => t); // cheap clone for mutation + const idx = targets.indexOf(member); + if (idx >= 0) { + targets.splice(idx, 1); + this.setState({targets}); + } + }; + + _onPaste = async (e) => { + // Prevent the text being pasted into the textarea + e.preventDefault(); + + // Process it as a list of addresses to add instead + const text = e.clipboardData.getData("text"); + const possibleMembers = [ + // If we can avoid hitting the profile endpoint, we should. + ...this.state.recents, + ...this.state.suggestions, + ...this.state.serverResultsMixin, + ...this.state.threepidResultsMixin, + ]; + const toAdd = []; + const failed = []; + const potentialAddresses = text.split(/[\s,]+/); + for (const address of potentialAddresses) { + const member = possibleMembers.find(m => m.userId === address); + if (member) { + toAdd.push(member.user); + continue; + } + + if (address.indexOf('@') > 0 && Email.looksValid(address)) { + toAdd.push(new ThreepidMember(address)); + continue; + } + + if (address[0] !== '@') { + failed.push(address); // not a user ID + continue; + } + + try { + const profile = await MatrixClientPeg.get().getProfileInfo(address); + const displayName = profile ? profile.displayname : null; + const avatarUrl = profile ? profile.avatar_url : null; + toAdd.push(new DirectoryMember({ + user_id: address, + display_name: displayName, + avatar_url: avatarUrl, + })); + } catch (e) { + console.error("Error looking up profile for " + address); + console.error(e); + failed.push(address); + } + } + + if (failed.length > 0) { + const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); + Modal.createTrackedDialog('Invite Paste Fail', '', QuestionDialog, { + title: _t('Failed to find the following users'), + description: _t( + "The following users might not exist or are invalid, and cannot be invited: %(csvNames)s", + {csvNames: failed.join(", ")}, + ), + button: _t('OK'), + }); + } + + this.setState({targets: [...this.state.targets, ...toAdd]}); + }; + + _onClickInputArea = (e) => { + // Stop the browser from highlighting text + e.preventDefault(); + e.stopPropagation(); + + if (this._editorRef && this._editorRef.current) { + this._editorRef.current.focus(); + } + }; + + _onUseDefaultIdentityServerClick = (e) => { + e.preventDefault(); + + // Update the IS in account data. Actually using it may trigger terms. + // eslint-disable-next-line react-hooks/rules-of-hooks + useDefaultIdentityServer(); + this.setState({canUseIdentityServer: true, tryingIdentityServer: false}); + }; + + _onManageSettingsClick = (e) => { + e.preventDefault(); + dis.dispatch({ action: 'view_user_settings' }); + this._cancel(); + }; + + _renderSection(kind: "recents"|"suggestions") { + let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions; + let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown; + const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); + const lastActive = (m) => kind === 'recents' ? m.lastActive : null; + const sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); + + // Mix in the server results if we have any, but only if we're searching. We track the additional + // members separately because we want to filter sourceMembers but trust the mixin arrays to have + // the right members in them. + let additionalMembers = []; + const hasMixins = this.state.serverResultsMixin || this.state.threepidResultsMixin; + if (this.state.filterText && hasMixins && kind === 'suggestions') { + // We don't want to duplicate members though, so just exclude anyone we've already seen. + const notAlreadyExists = (u: Member): boolean => { + return !sourceMembers.some(m => m.userId === u.userId) + && !additionalMembers.some(m => m.userId === u.userId); + }; + + const uniqueServerResults = this.state.serverResultsMixin.filter(notAlreadyExists); + additionalMembers = additionalMembers.concat(...uniqueServerResults); + + const uniqueThreepidResults = this.state.threepidResultsMixin.filter(notAlreadyExists); + additionalMembers = additionalMembers.concat(...uniqueThreepidResults); + } + + // Hide the section if there's nothing to filter by + if (sourceMembers.length === 0 && additionalMembers.length === 0) return null; + + // Do some simple filtering on the input before going much further. If we get no results, say so. + if (this.state.filterText) { + const filterBy = this.state.filterText.toLowerCase(); + sourceMembers = sourceMembers + .filter(m => m.user.name.toLowerCase().includes(filterBy) || m.userId.toLowerCase().includes(filterBy)); + + if (sourceMembers.length === 0 && additionalMembers.length === 0) { + return ( +
+

{sectionName}

+

{_t("No results")}

+
+ ); + } + } + + // Now we mix in the additional members. Again, we presume these have already been filtered. We + // also assume they are more relevant than our suggestions and prepend them to the list. + sourceMembers = [...additionalMembers, ...sourceMembers]; + + // If we're going to hide one member behind 'show more', just use up the space of the button + // with the member's tile instead. + if (showNum === sourceMembers.length - 1) showNum++; + + // .slice() will return an incomplete array but won't error on us if we go too far + const toRender = sourceMembers.slice(0, showNum); + const hasMore = toRender.length < sourceMembers.length; + + const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + let showMore = null; + if (hasMore) { + showMore = ( + + {_t("Show more")} + + ); + } + + const tiles = toRender.map(r => ( + t.userId === r.userId)} + /> + )); + return ( +
+

{sectionName}

+ {tiles} + {showMore} +
+ ); + } + + _renderEditor() { + const targets = this.state.targets.map(t => ( + + )); + const input = ( +