diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles index 1faffbbdf7..2e2a404338 100644 --- a/.eslintignore.errorfiles +++ b/.eslintignore.errorfiles @@ -2,7 +2,6 @@ src/components/structures/RoomDirectory.js src/components/structures/RoomStatusBar.js -src/components/structures/RoomView.js src/components/structures/ScrollPanel.js src/components/structures/SearchBox.js src/components/structures/UploadBar.js diff --git a/.eslintrc.js b/.eslintrc.js index fc82e75ce2..bc2a142c2d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -19,7 +19,7 @@ module.exports = { }, overrides: [{ - "files": ["src/**/*.{ts, tsx}"], + "files": ["src/**/*.{ts,tsx}"], "extends": ["matrix-org/ts"], "rules": { // We disable this while we're transitioning diff --git a/CHANGELOG.md b/CHANGELOG.md index 29cbb040f4..6fa9cc29f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,165 @@ +Changes in [3.4.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.1) (2020-09-14) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.0...v3.4.1) + + * Don't count widgets which no longer exist towards pinned count + [\#5202](https://github.com/matrix-org/matrix-react-sdk/pull/5202) + * Fix crashes with cannot read isResizing of undefined + [\#5205](https://github.com/matrix-org/matrix-react-sdk/pull/5205) + +Changes in [3.4.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.0) (2020-09-14) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.4.0-rc.1...v3.4.0) + + * Upgrade to JS SDK 8.3.0 + * [Release] Show verification status in the room summary card + [\#5196](https://github.com/matrix-org/matrix-react-sdk/pull/5196) + * Fix user info scrolling in new card view + [\#5200](https://github.com/matrix-org/matrix-react-sdk/pull/5200) + * Fix sticker picker height + [\#5199](https://github.com/matrix-org/matrix-react-sdk/pull/5199) + * [Release] Account for via in pill matching regex + [\#5190](https://github.com/matrix-org/matrix-react-sdk/pull/5190) + +Changes in [3.4.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.4.0-rc.1) (2020-09-09) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.3.0...v3.4.0-rc.1) + + * Upgrade to JS SDK 8.3.0-rc.1 + * Update from Weblate + [\#5183](https://github.com/matrix-org/matrix-react-sdk/pull/5183) + * Right Panel Room Summary and Widgets + [\#5167](https://github.com/matrix-org/matrix-react-sdk/pull/5167) + * null-guard roomId in RightPanel and pass Room to UserView + [\#5180](https://github.com/matrix-org/matrix-react-sdk/pull/5180) + * Fix create-react-class regression. + [\#5178](https://github.com/matrix-org/matrix-react-sdk/pull/5178) + * Fix WatchManager for global room watchers and tidy widget code a little + [\#5176](https://github.com/matrix-org/matrix-react-sdk/pull/5176) + * Fix permalink local linkification to not strip via servers + [\#5174](https://github.com/matrix-org/matrix-react-sdk/pull/5174) + * Support creation of Jitsi widgets with "openidtoken-jwt" auth + [\#5173](https://github.com/matrix-org/matrix-react-sdk/pull/5173) + * Fix create-react-class regression. + [\#5177](https://github.com/matrix-org/matrix-react-sdk/pull/5177) + * Update openid_credentials Widget API action for MSC1960 updates + [\#5172](https://github.com/matrix-org/matrix-react-sdk/pull/5172) + * Allow persistent resizing of the widget app drawer + [\#5138](https://github.com/matrix-org/matrix-react-sdk/pull/5138) + * add lenny face command + [\#5158](https://github.com/matrix-org/matrix-react-sdk/pull/5158) + * Prep work for Settings changes with cross-signing deferral + [\#5169](https://github.com/matrix-org/matrix-react-sdk/pull/5169) + * Small code clean ups and tweaks + [\#5168](https://github.com/matrix-org/matrix-react-sdk/pull/5168) + * Fix soft crash from TruncatedList in the createReactClass conversion + [\#5170](https://github.com/matrix-org/matrix-react-sdk/pull/5170) + * Remove create-react-class + [\#5157](https://github.com/matrix-org/matrix-react-sdk/pull/5157) + * Consolidate Lodash files in bundle + [\#5162](https://github.com/matrix-org/matrix-react-sdk/pull/5162) + * Communities v2 prototype: "In community" view + [\#5161](https://github.com/matrix-org/matrix-react-sdk/pull/5161) + * Respect user preference for whether pills should have an avatar or not + [\#5165](https://github.com/matrix-org/matrix-react-sdk/pull/5165) + * Communities v2 prototype: DM copy updates + [\#5153](https://github.com/matrix-org/matrix-react-sdk/pull/5153) + * Only wait for public keys during verification + [\#5164](https://github.com/matrix-org/matrix-react-sdk/pull/5164) + * Fix eslint ts override tsx matching and delint + [\#5155](https://github.com/matrix-org/matrix-react-sdk/pull/5155) + * Fix react error about functional components can't take refs + [\#5159](https://github.com/matrix-org/matrix-react-sdk/pull/5159) + * Remove redundant components and devDependencies + [\#5156](https://github.com/matrix-org/matrix-react-sdk/pull/5156) + * Add display-capture to iframe allow for widgets + [\#5154](https://github.com/matrix-org/matrix-react-sdk/pull/5154) + * Update create room dialog copy & community prototype home icon + [\#5151](https://github.com/matrix-org/matrix-react-sdk/pull/5151) + * Migrate to new, separate APIs for cross-signing and secret storage + [\#5149](https://github.com/matrix-org/matrix-react-sdk/pull/5149) + * Fix clicking the background of the tag panel not clearing the filter + [\#5152](https://github.com/matrix-org/matrix-react-sdk/pull/5152) + * Communities v2 prototype: Associate created rooms with the selected + community + [\#5147](https://github.com/matrix-org/matrix-react-sdk/pull/5147) + * Communities v2 prototype: Tag panel selection changes + [\#5145](https://github.com/matrix-org/matrix-react-sdk/pull/5145) + * Communities v2 prototype: Create community flow + [\#5144](https://github.com/matrix-org/matrix-react-sdk/pull/5144) + * Communities v2 prototype: Override invite aesthetics for community-as-room + invites + [\#5143](https://github.com/matrix-org/matrix-react-sdk/pull/5143) + +Changes in [3.3.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.3.0) (2020-09-01) +=================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.3.0-rc.1...v3.3.0) + + * Upgrade to JS SDK 8.2.0 + +Changes in [3.3.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.3.0-rc.1) (2020-08-26) +============================================================================================================= +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.2.0...v3.3.0-rc.1) + + * Upgrade to JS SDK 8.2.0-rc.1 + * Update from Weblate + [\#5146](https://github.com/matrix-org/matrix-react-sdk/pull/5146) + * BaseAvatar avoid initial render with default avatar + [\#5142](https://github.com/matrix-org/matrix-react-sdk/pull/5142) + * Enforce Secure Backup completion when requested by HS + [\#5130](https://github.com/matrix-org/matrix-react-sdk/pull/5130) + * Communities v2 prototype: Explore rooms, global state, and default room + [\#5139](https://github.com/matrix-org/matrix-react-sdk/pull/5139) + * Add communities v2 prototyping feature flag + initial tag panel prototypes + [\#5133](https://github.com/matrix-org/matrix-react-sdk/pull/5133) + * Remove some unused components + [\#5134](https://github.com/matrix-org/matrix-react-sdk/pull/5134) + * Allow avatar image view for 1:1 rooms + [\#5137](https://github.com/matrix-org/matrix-react-sdk/pull/5137) + * Send mx_local_settings in rageshake + [\#5136](https://github.com/matrix-org/matrix-react-sdk/pull/5136) + * Run all room leaving behaviour through a single function + [\#5132](https://github.com/matrix-org/matrix-react-sdk/pull/5132) + * Add clarifying comment in media device selection + [\#5131](https://github.com/matrix-org/matrix-react-sdk/pull/5131) + * Settings v3: Feature flag changes + [\#5124](https://github.com/matrix-org/matrix-react-sdk/pull/5124) + * Clear url previews if they all get edited out of the event + [\#5129](https://github.com/matrix-org/matrix-react-sdk/pull/5129) + * Consider tab completions as modifications for editing purposes to unlock + sending + [\#5128](https://github.com/matrix-org/matrix-react-sdk/pull/5128) + * Use matrix-doc for SAS emoji translations + [\#5125](https://github.com/matrix-org/matrix-react-sdk/pull/5125) + * Add a rageshake function to download the logs locally + [\#3849](https://github.com/matrix-org/matrix-react-sdk/pull/3849) + * Room List filtering visual tweaks + [\#5123](https://github.com/matrix-org/matrix-react-sdk/pull/5123) + * Make reply preview not an overlay so you can see new messages + [\#5072](https://github.com/matrix-org/matrix-react-sdk/pull/5072) + * Allow room tile context menu when minimized using right click + [\#5113](https://github.com/matrix-org/matrix-react-sdk/pull/5113) + * Add null guard to group inviter for corrupted groups + [\#5121](https://github.com/matrix-org/matrix-react-sdk/pull/5121) + * Room List styling tweaks + [\#5118](https://github.com/matrix-org/matrix-react-sdk/pull/5118) + * Fix corner rounding on images not always affecting right side + [\#5120](https://github.com/matrix-org/matrix-react-sdk/pull/5120) + * Change add room action for rooms to context menu + [\#5108](https://github.com/matrix-org/matrix-react-sdk/pull/5108) + * Switch out the globe icon and colour it depending on theme + [\#5106](https://github.com/matrix-org/matrix-react-sdk/pull/5106) + * Message Action Bar watch for event send changes + [\#5115](https://github.com/matrix-org/matrix-react-sdk/pull/5115) + * Put message previews for Emoji behind Labs + [\#5110](https://github.com/matrix-org/matrix-react-sdk/pull/5110) + * Fix styling for selected community marker + [\#5107](https://github.com/matrix-org/matrix-react-sdk/pull/5107) + * Fix action bar safe area regression + [\#5111](https://github.com/matrix-org/matrix-react-sdk/pull/5111) + * Fix /op slash command + [\#5109](https://github.com/matrix-org/matrix-react-sdk/pull/5109) + Changes in [3.2.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.2.0) (2020-08-17) =================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.2.0-rc.1...v3.2.0) diff --git a/docs/settings.md b/docs/settings.md index 4172c72c15..891877a57a 100644 --- a/docs/settings.md +++ b/docs/settings.md @@ -120,6 +120,18 @@ Call `SettingsStore.getValue()` as you would for any other setting. Call `SettingsStore.setValue("feature_name", null, SettingLevel.DEVICE, true)`. +### A note on UI features + +UI features are a different concept to plain features. Instead of being representative of unstable or +unpredicatable behaviour, they are logical chunks of UI which can be disabled by deployments for ease +of understanding with users. They are simply represented as boring settings with a convention of being +named as `UIFeature.$location` where `$location` is a rough descriptor of what is toggled, such as +`URLPreviews` or `Communities`. + +UI features also tend to have their own setting controller (see below) to manipulate settings which might +be affected by the UI feature being disabled. For example, if URL previews are disabled as a UI feature +then the URL preview options will use the `UIFeatureController` to ensure they remain disabled while the +UI feature is disabled. ## Setting controllers @@ -226,4 +238,3 @@ In practice, handlers which rely on remote changes (account data, room events, e generalized `WatchManager` - a class specifically designed to deduplicate the logic of managing watchers. The handlers which are localized to the local client (device) generally just trigger the `WatchManager` when they manipulate the setting themselves as there's nothing to really 'watch'. - diff --git a/package.json b/package.json index ab71f68b08..156cbb1bc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.2.0", + "version": "3.4.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -61,7 +61,6 @@ "classnames": "^2.2.6", "commonmark": "^0.29.1", "counterpart": "^0.18.6", - "create-react-class": "^15.6.3", "diff-dom": "^4.1.6", "diff-match-patch": "^1.0.5", "emojibase-data": "^5.0.1", @@ -95,6 +94,7 @@ "react-focus-lock": "^2.4.1", "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.1", + "rfc4648": "^1.4.0", "sanitize-html": "^1.27.1", "tar-js": "^0.3.0", "text-encoding-utf-8": "^1.0.2", @@ -149,7 +149,6 @@ "eslint-plugin-flowtype": "^2.50.3", "eslint-plugin-react": "^7.20.3", "eslint-plugin-react-hooks": "^2.5.1", - "file-loader": "^3.0.1", "glob": "^5.0.15", "jest": "^24.9.0", "jest-canvas-mock": "^2.2.0", @@ -158,14 +157,11 @@ "matrix-react-test-utils": "^0.2.2", "react-test-renderer": "^16.13.1", "rimraf": "^2.7.1", - "source-map-loader": "^0.2.4", "stylelint": "^9.10.1", "stylelint-config-standard": "^18.3.0", "stylelint-scss": "^3.18.0", "typescript": "^3.9.7", - "walk": "^2.3.14", - "webpack": "^4.43.0", - "webpack-cli": "^3.3.12" + "walk": "^2.3.14" }, "jest": { "testMatch": [ diff --git a/res/css/_components.scss b/res/css/_components.scss index aedb5c1334..35b4c1b965 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -53,21 +53,22 @@ @import "./views/avatars/_PulsedAvatar.scss"; @import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss"; -@import "./views/context_menus/_RoomTileContextMenu.scss"; @import "./views/context_menus/_StatusMessageContextMenu.scss"; @import "./views/context_menus/_TagTileContextMenu.scss"; -@import "./views/context_menus/_TopLeftMenu.scss"; @import "./views/context_menus/_WidgetContextMenu.scss"; @import "./views/dialogs/_AddressPickerDialog.scss"; @import "./views/dialogs/_Analytics.scss"; @import "./views/dialogs/_BugReportDialog.scss"; @import "./views/dialogs/_ChangelogDialog.scss"; @import "./views/dialogs/_ChatCreateOrReuseChatDialog.scss"; +@import "./views/dialogs/_CommunityPrototypeInviteDialog.scss"; @import "./views/dialogs/_ConfirmUserActionDialog.scss"; +@import "./views/dialogs/_CreateCommunityPrototypeDialog.scss"; @import "./views/dialogs/_CreateGroupDialog.scss"; @import "./views/dialogs/_CreateRoomDialog.scss"; @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; +@import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; @import "./views/dialogs/_GroupAddressPicker.scss"; @import "./views/dialogs/_IncomingSasDialog.scss"; @import "./views/dialogs/_InviteDialog.scss"; @@ -90,11 +91,12 @@ @import "./views/dialogs/_UploadConfirmDialog.scss"; @import "./views/dialogs/_UserSettingsDialog.scss"; @import "./views/dialogs/_WidgetOpenIDPermissionsDialog.scss"; -@import "./views/dialogs/keybackup/_CreateKeyBackupDialog.scss"; -@import "./views/dialogs/keybackup/_KeyBackupFailedDialog.scss"; -@import "./views/dialogs/keybackup/_RestoreKeyBackupDialog.scss"; -@import "./views/dialogs/secretstorage/_AccessSecretStorageDialog.scss"; -@import "./views/dialogs/secretstorage/_CreateSecretStorageDialog.scss"; +@import "./views/dialogs/security/_AccessSecretStorageDialog.scss"; +@import "./views/dialogs/security/_CreateCrossSigningDialog.scss"; +@import "./views/dialogs/security/_CreateKeyBackupDialog.scss"; +@import "./views/dialogs/security/_CreateSecretStorageDialog.scss"; +@import "./views/dialogs/security/_KeyBackupFailedDialog.scss"; +@import "./views/dialogs/security/_RestoreKeyBackupDialog.scss"; @import "./views/directory/_NetworkDropdown.scss"; @import "./views/elements/_AccessibleButton.scss"; @import "./views/elements/_AddressSelector.scss"; @@ -108,6 +110,7 @@ @import "./views/elements/_FormButton.scss"; @import "./views/elements/_IconButton.scss"; @import "./views/elements/_ImageView.scss"; +@import "./views/elements/_InfoTooltip.scss"; @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_PowerSelector.scss"; @@ -153,11 +156,13 @@ @import "./views/messages/_UnknownBody.scss"; @import "./views/messages/_ViewSourceEvent.scss"; @import "./views/messages/_common_CryptoEvent.scss"; +@import "./views/right_panel/_BaseCard.scss"; @import "./views/right_panel/_EncryptionInfo.scss"; +@import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_VerificationPanel.scss"; +@import "./views/right_panel/_WidgetCard.scss"; @import "./views/room_settings/_AliasSettings.scss"; -@import "./views/room_settings/_ColorSettings.scss"; @import "./views/rooms/_AppsDrawer.scss"; @import "./views/rooms/_Autocomplete.scss"; @import "./views/rooms/_AuxPanel.scss"; @@ -183,7 +188,6 @@ @import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; -@import "./views/rooms/_RoomRecoveryReminder.scss"; @import "./views/rooms/_RoomSublist.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @@ -198,10 +202,10 @@ @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; -@import "./views/settings/_KeyBackupPanel.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; @import "./views/settings/_ProfileSettings.scss"; +@import "./views/settings/_SecureBackupPanel.scss"; @import "./views/settings/_SetIdServer.scss"; @import "./views/settings/_SetIntegrationManager.scss"; @import "./views/settings/_UpdateCheckButton.scss"; diff --git a/res/css/structures/_FilePanel.scss b/res/css/structures/_FilePanel.scss index 21b30d804a..2aa068b674 100644 --- a/res/css/structures/_FilePanel.scss +++ b/res/css/structures/_FilePanel.scss @@ -23,6 +23,13 @@ limitations under the License. .mx_FilePanel .mx_RoomView_messageListWrapper { margin-right: 20px; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.mx_FilePanel .mx_RoomView_MessageList { + width: 100%; } .mx_FilePanel .mx_RoomView_MessageList h2 { diff --git a/res/css/structures/_HeaderButtons.scss b/res/css/structures/_HeaderButtons.scss index 9ef40e9d6a..72b663ef0e 100644 --- a/res/css/structures/_HeaderButtons.scss +++ b/res/css/structures/_HeaderButtons.scss @@ -18,6 +18,14 @@ limitations under the License. display: flex; } +.mx_RoomHeader_buttons + .mx_HeaderButtons { + // remove the | separator line for when next to RoomHeaderButtons + // TODO: remove this once when we redo communities and make the right panel similar to the new rooms one + &::before { + content: unset; + } +} + .mx_HeaderButtons::before { content: ""; background-color: $header-divider-color; diff --git a/res/css/structures/_MainSplit.scss b/res/css/structures/_MainSplit.scss index dc62cb8218..ad1656efbb 100644 --- a/res/css/structures/_MainSplit.scss +++ b/res/css/structures/_MainSplit.scss @@ -25,6 +25,7 @@ limitations under the License. padding: 5px; // margin left to not allow the handle to not encroach on the space for the scrollbar margin-left: 8px; + height: calc(100vh - 51px); // height of .mx_RoomHeader.light-panel &:hover .mx_RightPanel_ResizeHandle { // Need to use important to override element style attributes diff --git a/res/css/structures/_NotificationPanel.scss b/res/css/structures/_NotificationPanel.scss index 715a94fe2c..1258ace069 100644 --- a/res/css/structures/_NotificationPanel.scss +++ b/res/css/structures/_NotificationPanel.scss @@ -22,7 +22,13 @@ limitations under the License. } .mx_NotificationPanel .mx_RoomView_messageListWrapper { - margin-right: 20px; + flex-direction: row; + align-items: center; + justify-content: center; +} + +.mx_NotificationPanel .mx_RoomView_MessageList { + width: 100%; } .mx_NotificationPanel .mx_RoomView_MessageList h2 { @@ -35,11 +41,32 @@ limitations under the License. .mx_NotificationPanel .mx_EventTile { word-break: break-word; + position: relative; + padding-bottom: 18px; + + &:not(.mx_EventTile_last):not(.mx_EventTile_lastInSection)::after { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background-color: $tertiary-fg-color; + height: 1px; + opacity: 0.4; + content: ''; + } } .mx_NotificationPanel .mx_EventTile_roomName { font-weight: bold; font-size: $font-14px; + + > * { + vertical-align: middle; + } + + > .mx_BaseAvatar { + margin-right: 8px; + } } .mx_NotificationPanel .mx_EventTile_roomName a { @@ -47,8 +74,7 @@ limitations under the License. } .mx_NotificationPanel .mx_EventTile_avatar { - top: 8px; - left: 0px; + display: none; // we don't need this in this view } .mx_NotificationPanel .mx_EventTile .mx_SenderProfile, @@ -60,8 +86,7 @@ limitations under the License. } .mx_NotificationPanel .mx_EventTile_senderDetails { - padding-left: 32px; - padding-top: 8px; + padding-left: 36px; // align with the room name position: relative; a { @@ -82,7 +107,7 @@ limitations under the License. .mx_NotificationPanel .mx_EventTile_line { margin-right: 0px; - padding-left: 32px; + padding-left: 36px; // align with the room name padding-top: 0px; padding-bottom: 0px; padding-right: 0px; diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index c7c0d6fac4..5bf0d953f3 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -68,16 +68,14 @@ limitations under the License. mask-repeat: no-repeat; mask-size: contain; } -} -.mx_RightPanel_membersButton::before { - mask-image: url('$(res)/img/element-icons/room/members.svg'); - mask-position: center; -} + &:hover { + background: rgba($accent-color, 0.1); -.mx_RightPanel_filesButton::before { - mask-image: url('$(res)/img/element-icons/room/files.svg'); - mask-position: center; + &::before { + background-color: $accent-color; + } + } } .mx_RightPanel_notifsButton::before { @@ -85,6 +83,11 @@ limitations under the License. mask-position: center; } +.mx_RightPanel_roomSummaryButton::before { + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + mask-position: center; +} + .mx_RightPanel_groupMembersButton::before { mask-image: url('$(res)/img/element-icons/community-members.svg'); mask-position: center; @@ -96,23 +99,11 @@ limitations under the License. } .mx_RightPanel_headerButton_highlight { - background: rgba($accent-color, 0.25); - // make the icon the accent color too &::before { background-color: $accent-color !important; } } -.mx_RightPanel_headerButton:not(.mx_RightPanel_headerButton_highlight) { - &:hover { - background: rgba($accent-color, 0.1); - - &::before { - background-color: $accent-color; - } - } -} - .mx_RightPanel_headerButton_badge { font-size: $font-8px; border-radius: 8px; @@ -146,7 +137,7 @@ limitations under the License. } .mx_RightPanel_empty { - margin-right: -42px; + margin-right: -28px; h2 { font-weight: 700; diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 3b60c4e62b..572c7166d2 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -185,13 +185,11 @@ limitations under the License. } .mx_RoomView_empty { - flex: 1 1 auto; font-size: $font-13px; - padding-left: 3em; - padding-right: 3em; - margin-right: 20px; - margin-top: 33%; + padding: 0 24px; + margin-right: 30px; text-align: center; + margin-bottom: 80px; // visually center the content (intentional offset) } .mx_RoomView_MessageList { diff --git a/res/css/structures/_TagPanel.scss b/res/css/structures/_TagPanel.scss index b2d05ad7e6..cdca1f0764 100644 --- a/res/css/structures/_TagPanel.scss +++ b/res/css/structures/_TagPanel.scss @@ -30,30 +30,11 @@ limitations under the License. cursor: pointer; } -.mx_TagPanel .mx_TagPanel_clearButton_container { - /* Constant height within flex mx_TagPanel */ - height: 70px; - width: 56px; - - flex: none; - - justify-content: center; - align-items: flex-start; - - display: none; -} - -.mx_TagPanel .mx_TagPanel_clearButton object { - /* Same as .mx_SearchBox padding-top */ - margin-top: 24px; - pointer-events: none; -} - .mx_TagPanel .mx_TagPanel_divider { height: 0px; - width: 34px; - border-bottom: 1px solid $panel-divider-color; - display: none; + width: 90%; + border: none; + border-bottom: 1px solid $tagpanel-divider-color; } .mx_TagPanel .mx_TagPanel_scroller { @@ -76,12 +57,57 @@ limitations under the License. // opacity: 0.5; position: relative; } + +.mx_TagPanel .mx_TagTile.mx_TagTile_prototype { + padding: 3px; +} + .mx_TagPanel .mx_TagTile:focus, .mx_TagPanel .mx_TagTile:hover, .mx_TagPanel .mx_TagTile.mx_TagTile_selected { // opacity: 1; } +.mx_TagPanel .mx_TagTile.mx_TagTile_selected_prototype { + background-color: $primary-bg-color; + border-radius: 6px; +} + +.mx_TagTile_selected_prototype { + .mx_TagTile_homeIcon::before { + background-color: $primary-fg-color; // dark-on-light + } +} + +.mx_TagTile:not(.mx_TagTile_selected_prototype) .mx_TagTile_homeIcon { + background-color: $roomheader-addroom-bg-color; + border-radius: 48px; + + &::before { + background-color: $roomheader-addroom-fg-color; + } +} + +.mx_TagTile_homeIcon { + width: 32px; + height: 32px; + position: relative; + + &::before { + mask-image: url('$(res)/img/element-icons/home.svg'); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 21px; + content: ''; + display: inline-block; + width: 32px; + height: 32px; + position: absolute; + top: calc(50% - 16px); + left: calc(50% - 16px); + } +} + .mx_TagPanel .mx_TagTile_plus { margin-bottom: 12px; height: 32px; diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 544dcbc180..c381668a6a 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -80,6 +80,11 @@ limitations under the License. } } + &.mx_Toast_icon_secure_backup::after { + mask-image: url('$(res)/img/feather-customised/secure-backup.svg'); + background-color: $primary-fg-color; + } + .mx_Toast_title, .mx_Toast_body { grid-column: 2; } diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index 78795c85a2..fecac40e4e 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -15,10 +15,33 @@ limitations under the License. */ .mx_UserMenu { - - // to make the ... button sort of aligned with the explore button below + // to make the menu button sort of aligned with the explore button below padding-right: 6px; + &.mx_UserMenu_prototype { + // The margin & padding combination between here and the ::after is to + // align the border line with the tag panel. + margin-bottom: 6px; + + padding-right: 0; // make the right edge line up with the explore button + + .mx_UserMenu_headerButtons { + // considering we've eliminated right padding on the menu itself, we need to + // push the chevron in slightly (roughly lining up with the center of the + // plus buttons) + margin-right: 2px; + } + + // we cheat opacity on the theme colour with an after selector here + &::after { + content: ''; + border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + opacity: 0.2; + display: block; + padding-top: 8px; + } + } + .mx_UserMenu_headerButtons { width: 16px; height: 16px; @@ -35,8 +58,8 @@ limitations under the License. mask-position: center; mask-size: contain; mask-repeat: no-repeat; - background: $primary-fg-color; - mask-image: url('$(res)/img/element-icons/context-menu.svg'); + background: $tertiary-fg-color; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); } } @@ -56,6 +79,28 @@ limitations under the License. } } + .mx_UserMenu_doubleName { + flex: 1; + min-width: 0; // make flexbox aware that it can crush this to a tiny width + + .mx_UserMenu_userName, + .mx_UserMenu_subUserName { + display: block; + } + + .mx_UserMenu_subUserName { + color: $muted-fg-color; + font-size: $font-13px; + line-height: $font-18px; + flex: 1; + + // Ellipsize any text overflow + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + } + } + .mx_UserMenu_userName { font-weight: 600; font-size: $font-15px; @@ -89,6 +134,44 @@ limitations under the License. .mx_UserMenu_contextMenu { width: 247px; + // These override the styles already present on the user menu rather than try to + // define a new menu. They are specifically for the stacked menu when a community + // is being represented as a prototype. + &.mx_UserMenu_contextMenu_prototype { + padding-bottom: 16px; + + .mx_UserMenu_contextMenu_header { + padding-bottom: 0; + padding-top: 16px; + + &:nth-child(n + 2) { + padding-top: 8px; + } + } + + hr { + width: 85%; + opacity: 0.2; + border: none; + border-bottom: 1px solid $primary-fg-color; // XXX: Variable abuse + } + + &.mx_IconizedContextMenu { + > .mx_IconizedContextMenu_optionList { + margin-top: 4px; + + &::before { + border: none; + } + + > .mx_AccessibleButton { + padding-top: 2px; + padding-bottom: 2px; + } + } + } + } + &.mx_IconizedContextMenu .mx_IconizedContextMenu_optionList_red { .mx_AccessibleButton { padding-top: 16px; @@ -193,4 +276,12 @@ limitations under the License. .mx_UserMenu_iconSignOut::before { mask-image: url('$(res)/img/element-icons/leave.svg'); } + + .mx_UserMenu_iconMembers::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_UserMenu_iconInvite::before { + mask-image: url('$(res)/img/element-icons/room/invite.svg'); + } } diff --git a/res/css/views/auth/_Welcome.scss b/res/css/views/auth/_Welcome.scss index 9043289184..f0e2b3de33 100644 --- a/res/css/views/auth/_Welcome.scss +++ b/res/css/views/auth/_Welcome.scss @@ -18,6 +18,12 @@ limitations under the License. display: flex; flex-direction: column; align-items: center; + + &.mx_WelcomePage_registrationDisabled { + .mx_ButtonCreateAccount { + display: none; + } + } } .mx_Welcome .mx_AuthBody_language { diff --git a/res/css/views/context_menus/_IconizedContextMenu.scss b/res/css/views/context_menus/_IconizedContextMenu.scss index 7913058995..d911ac6dfe 100644 --- a/res/css/views/context_menus/_IconizedContextMenu.scss +++ b/res/css/views/context_menus/_IconizedContextMenu.scss @@ -82,7 +82,6 @@ limitations under the License. } span.mx_IconizedContextMenu_label { // labels - padding-left: 14px; width: 100%; flex: 1; @@ -91,6 +90,10 @@ limitations under the License. overflow: hidden; white-space: nowrap; } + + .mx_IconizedContextMenu_icon + .mx_IconizedContextMenu_label { + padding-left: 14px; + } } } diff --git a/res/css/views/context_menus/_RoomTileContextMenu.scss b/res/css/views/context_menus/_RoomTileContextMenu.scss deleted file mode 100644 index 9697ac9bef..0000000000 --- a/res/css/views/context_menus/_RoomTileContextMenu.scss +++ /dev/null @@ -1,114 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket 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_RoomTileContextMenu { - padding: 6px; -} - -.mx_RoomTileContextMenu_tag_icon { - padding-right: 8px; - padding-left: 4px; - display: inline-block; -} - -.mx_RoomTileContextMenu_tag_icon_set { - padding-right: 8px; - padding-left: 4px; - display: none; -} - -.mx_RoomTileContextMenu_tag_field, .mx_RoomTileContextMenu_leave { - padding-top: 8px; - padding-right: 20px; - padding-bottom: 8px; - cursor: pointer; - white-space: nowrap; - display: flex; - align-items: center; - line-height: $font-16px; -} - -.mx_RoomTileContextMenu_tag_field.mx_RoomTileContextMenu_tag_fieldSet { - font-weight: bold; -} - -.mx_RoomTileContextMenu_tag_field.mx_RoomTileContextMenu_tag_fieldSet .mx_RoomTileContextMenu_tag_icon { - display: none; -} - -.mx_RoomTileContextMenu_tag_field.mx_RoomTileContextMenu_tag_fieldSet .mx_RoomTileContextMenu_tag_icon_set { - display: inline-block; -} - -.mx_RoomTileContextMenu_tag_field.mx_RoomTileContextMenu_tag_fieldDisabled { - color: rgba(0, 0, 0, 0.2); -} - -.mx_RoomTileContextMenu_separator { - margin-top: 0; - margin-bottom: 0; - border-bottom-style: none; - border-left-style: none; - border-right-style: none; - border-top-style: solid; - border-top-width: 1px; - border-color: $menu-border-color; -} - -.mx_RoomTileContextMenu_leave { - color: $warning-color; -} - -.mx_RoomTileContextMenu_notif_picker { - position: absolute; - top: 16px; - left: 5px; -} - -.mx_RoomTileContextMenu_notif_field { - padding-top: 4px; - padding-right: 6px; - padding-bottom: 10px; - padding-left: 8px; /* 20px */ - cursor: pointer; - white-space: nowrap; - display: flex; - align-items: center; -} - -.mx_RoomTileContextMenu_notif_field.mx_RoomTileContextMenu_notif_fieldSet { - font-weight: bold; -} - -.mx_RoomTileContextMenu_notif_field.mx_RoomTileContextMenu_notif_fieldDisabled { - color: rgba(0, 0, 0, 0.2); -} - -.mx_RoomTileContextMenu_notif_icon { - padding-right: 4px; - padding-left: 4px; -} - -.mx_RoomTileContextMenu_notif_activeIcon { - display: inline-block; - opacity: 0; - position: relative; - left: -5px; -} - -.mx_RoomTileContextMenu_notif_fieldSet .mx_RoomTileContextMenu_notif_activeIcon { - opacity: 1; -} diff --git a/res/css/views/context_menus/_TopLeftMenu.scss b/res/css/views/context_menus/_TopLeftMenu.scss deleted file mode 100644 index e0f5dd47bd..0000000000 --- a/res/css/views/context_menus/_TopLeftMenu.scss +++ /dev/null @@ -1,96 +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. -*/ - -.mx_TopLeftMenu { - min-width: 210px; - border-radius: 4px; - - .mx_TopLeftMenu_greyedText { - font-size: $font-12px; - opacity: 0.5; - } - - .mx_TopLeftMenu_upgradeLink { - font-size: $font-12px; - - img { - margin-left: 5px; - } - } - - .mx_TopLeftMenu_section:not(:last-child) { - border-bottom: 1px solid $menu-border-color; - } - - .mx_TopLeftMenu_section_noIcon { - margin: 5px 0; - padding: 5px 20px 5px 15px; - - div:not(:first-child) { - margin-top: 5px; - } - } - - .mx_TopLeftMenu_section_withIcon { - margin: 5px 0; - padding: 0; - list-style: none; - - .mx_TopLeftMenu_icon_home::after { - 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'); - } - - .mx_TopLeftMenu_icon_signin::after { - mask-image: url('$(res)/img/feather-customised/sign-in.svg'); - } - - .mx_TopLeftMenu_icon_signout::after { - mask-image: url('$(res)/img/feather-customised/sign-out.svg'); - } - - .mx_AccessibleButton::after { - mask-repeat: no-repeat; - mask-position: 0 center; - mask-size: $font-16px; - position: absolute; - width: $font-16px; - height: $font-16px; - content: ""; - top: 5px; - left: 14px; - background-color: $primary-fg-color; - } - - .mx_AccessibleButton { - position: relative; - cursor: pointer; - white-space: nowrap; - padding: 5px 20px 5px 43px; - } - - .mx_AccessibleButton:hover { - background-color: $menu-selected-color; - } - } -} diff --git a/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss b/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss new file mode 100644 index 0000000000..beae03f00f --- /dev/null +++ b/res/css/views/dialogs/_CommunityPrototypeInviteDialog.scss @@ -0,0 +1,88 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CommunityPrototypeInviteDialog { + &.mx_Dialog_fixedWidth { + width: 360px; + } + + .mx_Dialog_content { + margin-bottom: 0; + + .mx_CommunityPrototypeInviteDialog_people { + position: relative; + margin-bottom: 4px; + + .mx_AccessibleButton { + display: inline-block; + background-color: $focus-bg-color; // XXX: Abuse of variables + border-radius: 4px; + padding: 3px 5px; + font-size: $font-12px; + float: right; + } + } + + .mx_CommunityPrototypeInviteDialog_morePeople { + margin-top: 8px; + } + + .mx_CommunityPrototypeInviteDialog_person { + position: relative; + margin-top: 4px; + + & > * { + vertical-align: middle; + } + + .mx_Checkbox { + position: absolute; + right: 0; + top: calc(50% - 8px); // checkbox is 16px high + width: 16px; // to force a square + } + + .mx_CommunityPrototypeInviteDialog_personIdentifiers { + display: inline-block; + + & > * { + display: block; + } + + .mx_CommunityPrototypeInviteDialog_personName { + font-weight: 600; + font-size: $font-14px; + color: $primary-fg-color; + margin-left: 7px; + } + + .mx_CommunityPrototypeInviteDialog_personId { + font-size: $font-12px; + color: $muted-fg-color; + margin-left: 7px; + } + } + } + + .mx_CommunityPrototypeInviteDialog_primaryButton { + display: block; + font-size: $font-13px; + line-height: 20px; + height: 20px; + margin-top: 24px; + } + } +} diff --git a/res/css/views/dialogs/_CreateCommunityPrototypeDialog.scss b/res/css/views/dialogs/_CreateCommunityPrototypeDialog.scss new file mode 100644 index 0000000000..81babc4c38 --- /dev/null +++ b/res/css/views/dialogs/_CreateCommunityPrototypeDialog.scss @@ -0,0 +1,102 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CreateCommunityPrototypeDialog { + .mx_Dialog_content { + display: flex; + flex-direction: row; + margin-bottom: 12px; + + .mx_CreateCommunityPrototypeDialog_colName { + flex-basis: 66.66%; + padding-right: 100px; + + .mx_Field input { + font-size: $font-16px; + line-height: $font-20px; + } + + .mx_CreateCommunityPrototypeDialog_subtext { + display: block; + color: $muted-fg-color; + margin-bottom: 16px; + + &:last-child { + margin-top: 16px; + } + + &.mx_CreateCommunityPrototypeDialog_subtext_error { + color: $warning-color; + } + } + + .mx_CreateCommunityPrototypeDialog_communityId { + position: relative; + + .mx_InfoTooltip { + float: right; + } + } + + .mx_AccessibleButton { + display: block; + height: 32px; + font-size: $font-16px; + line-height: 32px; + } + } + + .mx_CreateCommunityPrototypeDialog_colAvatar { + flex-basis: 33.33%; + + .mx_CreateCommunityPrototypeDialog_avatarContainer { + margin-top: 12px; + margin-bottom: 20px; + + .mx_CreateCommunityPrototypeDialog_avatar, + .mx_CreateCommunityPrototypeDialog_placeholderAvatar { + width: 96px; + height: 96px; + border-radius: 96px; + } + + .mx_CreateCommunityPrototypeDialog_placeholderAvatar { + background-color: #368bd6; // hardcoded for both themes + + &::before { + display: inline-block; + background-color: #fff; // hardcoded because the background is + mask-repeat: no-repeat; + mask-size: 96px; + width: 96px; + height: 96px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/element-icons/add-photo.svg'); + } + } + } + + .mx_CreateCommunityPrototypeDialog_tip { + & > b, & > span { + display: block; + color: $muted-fg-color; + } + } + } + } +} diff --git a/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss b/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss new file mode 100644 index 0000000000..75a56bf6b3 --- /dev/null +++ b/res/css/views/dialogs/_EditCommunityPrototypeDialog.scss @@ -0,0 +1,77 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// XXX: many of these styles are shared with the create dialog +.mx_EditCommunityPrototypeDialog { + &.mx_Dialog_fixedWidth { + width: 360px; + } + + .mx_Dialog_content { + margin-bottom: 12px; + + .mx_AccessibleButton.mx_AccessibleButton_kind_primary { + display: block; + height: 32px; + font-size: $font-16px; + line-height: 32px; + } + + .mx_EditCommunityPrototypeDialog_rowAvatar { + display: flex; + flex-direction: row; + align-items: center; + } + + .mx_EditCommunityPrototypeDialog_avatarContainer { + margin-top: 20px; + margin-bottom: 20px; + + .mx_EditCommunityPrototypeDialog_avatar, + .mx_EditCommunityPrototypeDialog_placeholderAvatar { + width: 96px; + height: 96px; + border-radius: 96px; + } + + .mx_EditCommunityPrototypeDialog_placeholderAvatar { + background-color: #368bd6; // hardcoded for both themes + + &::before { + display: inline-block; + background-color: #fff; // hardcoded because the background is + mask-repeat: no-repeat; + mask-size: 96px; + width: 96px; + height: 96px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/element-icons/add-photo.svg'); + } + } + } + + .mx_EditCommunityPrototypeDialog_tip { + margin-left: 20px; + + & > b, & > span { + display: block; + color: $muted-fg-color; + } + } + } +} diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index a77d0bfbba..b9063f46b9 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -89,6 +89,13 @@ limitations under the License. font-weight: bold; text-transform: uppercase; } + + .mx_InviteDialog_subname { + margin-bottom: 10px; + margin-top: -10px; // HACK: Positioning with margins is bad + font-size: $font-12px; + color: $muted-fg-color; + } } .mx_InviteDialog_roomTile { @@ -226,3 +233,7 @@ limitations under the License. .mx_InviteDialog_addressBar { margin-right: 45px; } + +.mx_InviteDialog_helpText .mx_AccessibleButton_kind_link { + padding: 0; +} diff --git a/res/css/views/dialogs/_ShareDialog.scss b/res/css/views/dialogs/_ShareDialog.scss index c343b872fd..ce3fdd021f 100644 --- a/res/css/views/dialogs/_ShareDialog.scss +++ b/res/css/views/dialogs/_ShareDialog.scss @@ -71,9 +71,12 @@ limitations under the License. margin-right: 64px; } +.mx_ShareDialog_qrcode_container + .mx_ShareDialog_social_container { + width: 299px; +} + .mx_ShareDialog_social_container { display: inline-block; - width: 299px; } .mx_ShareDialog_social_icon { diff --git a/res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss similarity index 100% rename from res/css/views/dialogs/secretstorage/_AccessSecretStorageDialog.scss rename to res/css/views/dialogs/security/_AccessSecretStorageDialog.scss diff --git a/res/css/views/dialogs/security/_CreateCrossSigningDialog.scss b/res/css/views/dialogs/security/_CreateCrossSigningDialog.scss new file mode 100644 index 0000000000..8303e02b9e --- /dev/null +++ b/res/css/views/dialogs/security/_CreateCrossSigningDialog.scss @@ -0,0 +1,33 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_CreateCrossSigningDialog { + // Why you ask? Because CompleteSecurityBody is 600px so this is the width + // we end up when in there, but when in our own dialog we set our own width + // so need to fix it to something sensible as otherwise we'd end up either + // really wide or really narrow depending on the phase. I bet you wish you + // never asked. + width: 560px; + + details .mx_AccessibleButton { + margin: 1em 0; // emulate paragraph spacing because we can't put this button in a paragraph due to HTML rules + } +} + +.mx_CreateCrossSigningDialog .mx_Dialog_title { + /* TODO: Consider setting this for all dialog titles. */ + margin-bottom: 1em; +} diff --git a/res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss b/res/css/views/dialogs/security/_CreateKeyBackupDialog.scss similarity index 100% rename from res/css/views/dialogs/keybackup/_CreateKeyBackupDialog.scss rename to res/css/views/dialogs/security/_CreateKeyBackupDialog.scss diff --git a/res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss b/res/css/views/dialogs/security/_CreateSecretStorageDialog.scss similarity index 100% rename from res/css/views/dialogs/secretstorage/_CreateSecretStorageDialog.scss rename to res/css/views/dialogs/security/_CreateSecretStorageDialog.scss diff --git a/res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss b/res/css/views/dialogs/security/_KeyBackupFailedDialog.scss similarity index 100% rename from res/css/views/dialogs/keybackup/_KeyBackupFailedDialog.scss rename to res/css/views/dialogs/security/_KeyBackupFailedDialog.scss diff --git a/res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss b/res/css/views/dialogs/security/_RestoreKeyBackupDialog.scss similarity index 100% rename from res/css/views/dialogs/keybackup/_RestoreKeyBackupDialog.scss rename to res/css/views/dialogs/security/_RestoreKeyBackupDialog.scss diff --git a/res/css/views/room_settings/_ColorSettings.scss b/res/css/views/elements/_InfoTooltip.scss similarity index 56% rename from res/css/views/room_settings/_ColorSettings.scss rename to res/css/views/elements/_InfoTooltip.scss index fc6a4443ad..5858a60629 100644 --- a/res/css/views/room_settings/_ColorSettings.scss +++ b/res/css/views/elements/_InfoTooltip.scss @@ -1,5 +1,5 @@ /* -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. @@ -14,26 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_ColorSettings_roomColor { +.mx_InfoTooltip_icon { + width: 16px; + height: 16px; display: inline-block; - position: relative; - width: 37px; - height: 37px; - border: 1px solid #979797; - margin-right: 13px; - cursor: pointer; } -.mx_ColorSettings_roomColor_selected { - position: absolute; - left: 10px; - top: 4px; - cursor: default !important; -} - -.mx_ColorSettings_roomColorPrimary { - height: 10px; - position: absolute; - bottom: 0px; - width: 100%; +.mx_InfoTooltip_icon::before { + display: inline-block; + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: 16px; + width: 16px; + height: 16px; + mask-position: center; + content: ''; + vertical-align: middle; + mask-image: url('$(res)/img/element-icons/info.svg'); } diff --git a/res/css/views/right_panel/_BaseCard.scss b/res/css/views/right_panel/_BaseCard.scss new file mode 100644 index 0000000000..26f846fe0a --- /dev/null +++ b/res/css/views/right_panel/_BaseCard.scss @@ -0,0 +1,166 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_BaseCard { + padding: 0 8px; + overflow: hidden; + display: flex; + flex-direction: column; + flex: 1; + + .mx_BaseCard_header { + margin: 8px 0; + + > h2 { + margin: 0 44px; + font-size: $font-18px; + font-weight: $font-semi-bold; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .mx_BaseCard_back, .mx_BaseCard_close { + position: absolute; + background-color: rgba(141, 151, 165, 0.2); + height: 20px; + width: 20px; + margin: 12px; + top: 0; + + &::before { + content: ""; + position: absolute; + height: 20px; + width: 20px; + top: 0; + left: 0; + mask-repeat: no-repeat; + mask-position: center; + background-color: $rightpanel-button-color; + } + } + + .mx_BaseCard_back { + border-radius: 4px; + left: 0; + + &::before { + transform: rotate(90deg); + mask-size: 22px; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + } + + .mx_BaseCard_close { + border-radius: 10px; + right: 0; + + &::before { + mask-image: url('$(res)/img/icons-close.svg'); + mask-size: 8px; + } + } + } + + .mx_AutoHideScrollbar { + // collapse the margin into a padding to move the scrollbar into the right gutter + margin-right: -8px; + padding-right: 8px; + min-height: 0; + width: 100%; + height: 100%; + } + + .mx_BaseCard_Group { + margin: 20px 0 16px; + + & > * { + margin-left: 12px; + margin-right: 12px; + } + + > h1 { + color: $tertiary-fg-color; + font-size: $font-12px; + font-weight: 500; + } + + .mx_BaseCard_Button { + padding: 10px 38px 10px 12px; + margin: 0; + position: relative; + font-size: $font-13px; + height: 20px; + line-height: 20px; + border-radius: 8px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &:hover { + background-color: rgba(141, 151, 165, 0.1); + } + + &::after { + content: ''; + position: absolute; + top: 10px; + right: 6px; + height: 20px; + width: 20px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $icon-button-color; + transform: rotate(270deg); + mask-size: 20px; + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); + } + } + } + + .mx_BaseCard_footer { + padding-top: 4px; + text-align: center; + display: flex; + justify-content: space-around; + + .mx_AccessibleButton_kind_secondary { + color: $secondary-fg-color; + background-color: rgba(141, 151, 165, 0.2); + font-weight: $font-semi-bold; + font-size: $font-14px; + } + + .mx_AccessibleButton_disabled { + cursor: not-allowed; + } + } +} + +.mx_FilePanel, +.mx_UserInfo, +.mx_NotificationPanel, +.mx_MemberList { + &.mx_BaseCard { + padding: 32px 0 0; + + .mx_AutoHideScrollbar { + margin-right: unset; + padding-right: unset; + } + } +} diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss new file mode 100644 index 0000000000..0031d3a64c --- /dev/null +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -0,0 +1,161 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_RoomSummaryCard { + .mx_BaseCard_header { + text-align: center; + margin-top: 20px; + + h2 { + font-weight: $font-semi-bold; + font-size: $font-18px; + margin: 12px 0 4px; + } + + .mx_RoomSummaryCard_alias { + font-size: $font-13px; + color: $secondary-fg-color; + } + + h2, .mx_RoomSummaryCard_alias { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + text-overflow: ellipsis; + } + + .mx_RoomSummaryCard_avatar { + display: inline-flex; + + .mx_RoomSummaryCard_e2ee { + display: inline-block; + position: relative; + width: 54px; + height: 54px; + border-radius: 50%; + background-color: #737d8c; + margin-top: -3px; // alignment + margin-left: -10px; // overlap + border: 3px solid $dark-panel-bg-color; + + &::before { + content: ''; + position: absolute; + top: 13px; + left: 13px; + height: 28px; + width: 28px; + mask-size: cover; + mask-repeat: no-repeat; + mask-position: center; + mask-image: url('$(res)/img/e2e/disabled.svg'); + background-color: #ffffff; + } + } + + .mx_RoomSummaryCard_e2ee_normal { + background-color: #424446; + &::before { + mask-image: url('$(res)/img/e2e/normal.svg'); + } + } + + .mx_RoomSummaryCard_e2ee_verified { + background-color: #0dbd8b; + &::before { + mask-image: url('$(res)/img/e2e/verified.svg'); + } + } + + .mx_RoomSummaryCard_e2ee_warning { + background-color: #ff4b55; + &::before { + mask-image: url('$(res)/img/e2e/warning.svg'); + } + } + } + } + + .mx_RoomSummaryCard_aboutGroup { + .mx_RoomSummaryCard_Button { + padding-left: 44px; + + &::before { + content: ''; + position: absolute; + top: 8px; + left: 10px; + height: 24px; + width: 24px; + mask-repeat: no-repeat; + mask-position: center; + background-color: $icon-button-color; + } + } + } + + .mx_RoomSummaryCard_appsGroup { + .mx_RoomSummaryCard_Button { + padding-left: 12px; + color: $tertiary-fg-color; + + span { + color: $primary-fg-color; + } + + img { + vertical-align: top; + margin-right: 12px; + border-radius: 4px; + } + + &::before { + content: unset; + } + } + + .mx_RoomSummaryCard_icon_app_pinned::after { + mask-image: url('$(res)/img/element-icons/room/pin-upright.svg'); + background-color: $accent-color; + transform: unset; + } + } + + .mx_AccessibleButton_kind_link { + padding: 0; + margin-top: 12px; + margin-bottom: 12px; + font-size: $font-13px; + font-weight: $font-semi-bold; + } +} + +.mx_RoomSummaryCard_icon_people::before { + mask-image: url("$(res)/img/element-icons/room/members.svg"); +} + +.mx_RoomSummaryCard_icon_files::before { + mask-image: url('$(res)/img/element-icons/room/files.svg'); +} + +.mx_RoomSummaryCard_icon_share::before { + mask-image: url('$(res)/img/element-icons/room/share.svg'); +} + +.mx_RoomSummaryCard_icon_settings::before { + mask-image: url('$(res)/img/element-icons/settings.svg'); +} diff --git a/res/css/views/right_panel/_UserInfo.scss b/res/css/views/right_panel/_UserInfo.scss index 6f86d1ad18..f20c9b7868 100644 --- a/res/css/views/right_panel/_UserInfo.scss +++ b/res/css/views/right_panel/_UserInfo.scss @@ -15,7 +15,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_UserInfo { +.mx_UserInfo.mx_BaseCard { + // UserInfo has a circular image at the top so it fits between the back & close buttons + padding-top: 0; display: flex; flex-direction: column; flex: 1; @@ -217,9 +219,8 @@ limitations under the License. text-overflow: clip; } - .mx_UserInfo_scrollContainer { + .mx_AutoHideScrollbar { flex: 1 1 0; - padding-bottom: 16px; } .mx_UserInfo_container:not(.mx_UserInfo_separator) { diff --git a/res/css/views/right_panel/_WidgetCard.scss b/res/css/views/right_panel/_WidgetCard.scss new file mode 100644 index 0000000000..315fd5213c --- /dev/null +++ b/res/css/views/right_panel/_WidgetCard.scss @@ -0,0 +1,62 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_WidgetCard { + height: 100%; + display: contents; + + .mx_AppTileFullWidth { + max-width: unset; + height: 100%; + border: 0; + } + + &.mx_WidgetCard_noEdit { + .mx_AccessibleButton_kind_secondary { + margin: 0 12px; + + &:first-child { + // expand the Pin to room primary action + flex-grow: 1; + } + } + } + + .mx_WidgetCard_optionsButton { + position: relative; + height: 18px; + width: 26px; + + &::before { + content: ""; + position: absolute; + width: 20px; + height: 20px; + top: 6px; + left: 20px; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/ellipsis.svg'); + background-color: $secondary-fg-color; + } + } +} + +.mx_WidgetCard_maxPinnedTooltip { + background-color: $notice-primary-color; + color: #ffffff; +} diff --git a/res/css/views/rooms/_AppsDrawer.scss b/res/css/views/rooms/_AppsDrawer.scss index 6be417f631..fee3d61153 100644 --- a/res/css/views/rooms/_AppsDrawer.scss +++ b/res/css/views/rooms/_AppsDrawer.scss @@ -15,18 +15,39 @@ See the License for the specific language governing permissions and limitations under the License. */ -/* -the tile title bar is 5 (top border) + 12 (title, buttons) + 5 (bottom padding) px = 22px -the body is assumed to be 300px (assumed by at least the sticker pickerm, perhaps elsewhere), -so the body height would be 300px - 22px (room for title bar) = 278px -BUT! the sticker picker also assumes it's a little less high than that because the iframe -for the sticker picker doesn't have any padding or margin on it's bottom. -so subtracking another 5px, which brings us at 273px. -*/ -$AppsDrawerBodyHeight: 273px; +$MiniAppTileHeight: 114px; .mx_AppsDrawer { - margin: 5px; + margin: 5px 5px 5px 18px; + position: relative; + display: flex; + flex-direction: column; + overflow: hidden; + + .mx_AppsContainer_resizerHandle { + cursor: ns-resize; + border-radius: 3px; + + // Override styles from library + width: unset !important; + height: 4px !important; + + // This is positioned directly below frame + position: absolute; + bottom: -8px !important; // override from library + + // Together, these make the bar 64px wide + // These are also overridden from the library + left: calc(50% - 32px) !important; + right: calc(50% - 32px) !important; + } + + &:hover { + .mx_AppsContainer_resizerHandle { + opacity: 0.8; + background: $primary-fg-color; + } + } } .mx_AppsDrawer_hidden { @@ -36,15 +57,23 @@ $AppsDrawerBodyHeight: 273px; .mx_AppsContainer { display: flex; flex-direction: row; - align-items: center; + align-items: stretch; justify-content: center; + height: 100%; + margin-bottom: 8px; +} + +.mx_AppsDrawer_minimised .mx_AppsContainer { + // override the re-resizable inline styles + height: inherit !important; + min-height: inherit !important; } .mx_AddWidget_button { order: 2; cursor: pointer; padding: 0; - margin: 5px auto 5px auto; + margin: -3px auto 5px 0; color: $accent-color; font-size: $font-12px; } @@ -65,40 +94,52 @@ $AppsDrawerBodyHeight: 273px; .mx_AppTile { max-width: 960px; width: 50%; - margin-right: 5px; border: 5px solid $widget-menu-bar-bg-color; border-radius: 4px; -} + display: flex; + flex-direction: column; -.mx_AppTile:last-child { - margin-right: 1px; + & + .mx_AppTile { + margin-left: 5px; + } } .mx_AppTileFullWidth { max-width: 960px; width: 100%; - height: 100%; margin: 0; padding: 0; border: 5px solid $widget-menu-bar-bg-color; border-radius: 8px; + display: flex; + flex-direction: column; } .mx_AppTile_mini { max-width: 960px; width: 100%; - height: 100%; margin: 0; padding: 0; + display: flex; + flex-direction: column; + height: $MiniAppTileHeight; } -.mx_AppTile_persistedWrapper { - height: $AppsDrawerBodyHeight; +.mx_AppTile.mx_AppTile_minimised, +.mx_AppTileFullWidth.mx_AppTile_minimised, +.mx_AppTile_mini.mx_AppTile_minimised { + height: 14px; } +.mx_AppTile .mx_AppTile_persistedWrapper, +.mx_AppTileFullWidth .mx_AppTile_persistedWrapper, .mx_AppTile_mini .mx_AppTile_persistedWrapper { - height: 114px; - min-width: 300px; + flex: 1; +} + +.mx_AppTile_persistedWrapper div { + width: 100%; + height: 100%; } .mx_AppTileMenuBar { @@ -110,6 +151,7 @@ $AppsDrawerBodyHeight: 273px; align-items: center; justify-content: space-between; cursor: pointer; + width: 100%; } .mx_AppTileMenuBar_expanded { @@ -172,7 +214,7 @@ $AppsDrawerBodyHeight: 273px; } .mx_AppTileBody { - height: $AppsDrawerBodyHeight; + height: 100%; width: 100%; overflow: hidden; } @@ -183,6 +225,13 @@ $AppsDrawerBodyHeight: 273px; overflow: hidden; } +.mx_AppTile .mx_AppTileBody, +.mx_AppTileFullWidth .mx_AppTileBody, +.mx_AppTile_mini .mx_AppTileBody_mini { + height: inherit; + flex: 1; +} + .mx_AppTileBody_mini iframe { border: none; width: 100%; @@ -191,7 +240,7 @@ $AppsDrawerBodyHeight: 273px; .mx_AppTileBody iframe { width: 100%; - height: $AppsDrawerBodyHeight; + height: 100%; overflow: hidden; border: none; padding: 0; @@ -331,7 +380,7 @@ form.mx_Custom_Widget_Form div { align-items: center; font-weight: bold; position: relative; - height: $AppsDrawerBodyHeight; + height: 100%; } .mx_AppLoading .mx_Spinner { @@ -358,3 +407,16 @@ form.mx_Custom_Widget_Form div { .mx_AppLoading iframe { display: none; } + +.mx_AppsDrawer_minimised .mx_AppsContainer_resizerHandle { + display: none; +} + +/* Avoid apptile iframes capturing mouse event focus when resizing */ +.mx_AppsDrawer_resizing iframe { + pointer-events: none; +} + +.mx_AppsDrawer_resizing .mx_AppTile_persistedWrapper { + z-index: 1; +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index eb0e1dd7b0..3b9a491db5 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -394,16 +394,6 @@ $left-gutter: 64px; opacity: 1; } -.mx_EventTile_e2eIcon_hidden { - display: none; -} - -/* always override hidden attribute for blocked and warning */ -.mx_EventTile_e2eIcon_hidden[src*="img/e2e-blocked.svg"], -.mx_EventTile_e2eIcon_hidden[src*="img/e2e-warning.svg"] { - display: block; -} - .mx_EventTile_keyRequestInfo { font-size: $font-12px; } diff --git a/res/css/views/rooms/_MemberList.scss b/res/css/views/rooms/_MemberList.scss index 90667d41b4..2366667c95 100644 --- a/res/css/views/rooms/_MemberList.scss +++ b/res/css/views/rooms/_MemberList.scss @@ -70,6 +70,10 @@ limitations under the License. } } +.mx_MemberList_query { + height: 16px; +} + .mx_MemberList_wrapper { padding: 10px; } diff --git a/res/css/views/rooms/_RoomHeader.scss b/res/css/views/rooms/_RoomHeader.scss index a880a7bee2..d240877507 100644 --- a/res/css/views/rooms/_RoomHeader.scss +++ b/res/css/views/rooms/_RoomHeader.scss @@ -236,10 +236,6 @@ limitations under the License. } } -.mx_RoomHeader_settingsButton::before { - mask-image: url('$(res)/img/element-icons/settings.svg'); -} - .mx_RoomHeader_forgetButton::before { mask-image: url('$(res)/img/element-icons/leave.svg'); width: 26px; @@ -249,14 +245,6 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); } -.mx_RoomHeader_shareButton::before { - mask-image: url('$(res)/img/element-icons/room/share.svg'); -} - -.mx_RoomHeader_manageIntegsButton::before { - mask-image: url('$(res)/img/element-icons/room/integrations.svg'); -} - .mx_RoomHeader_showPanel { height: 16px; } diff --git a/res/css/views/rooms/_Stickers.scss b/res/css/views/rooms/_Stickers.scss index 4bd45631cc..d99276b70a 100644 --- a/res/css/views/rooms/_Stickers.scss +++ b/res/css/views/rooms/_Stickers.scss @@ -7,12 +7,19 @@ height: 300px; } -#mx_persistedElement_stickerPicker .mx_AppTileFullWidth { - height: unset; - box-sizing: border-box; - border-left: none; - border-right: none; - border-bottom: none; +#mx_persistedElement_stickerPicker { + .mx_AppTileFullWidth { + height: unset; + box-sizing: border-box; + border-left: none; + border-right: none; + border-bottom: none; + } + + iframe { + // Sticker picker depends on the fixed height previously used for all tiles + height: 273px; + } } .mx_Stickers_contentPlaceholder { diff --git a/res/css/views/settings/_CrossSigningPanel.scss b/res/css/views/settings/_CrossSigningPanel.scss index fa9f76a963..12a0e36835 100644 --- a/res/css/views/settings/_CrossSigningPanel.scss +++ b/res/css/views/settings/_CrossSigningPanel.scss @@ -28,4 +28,8 @@ limitations under the License. .mx_CrossSigningPanel_buttonRow { margin: 1em 0; + + :nth-child(n + 1) { + margin-inline-end: 10px; + } } diff --git a/res/css/views/settings/_KeyBackupPanel.scss b/res/css/views/settings/_SecureBackupPanel.scss similarity index 52% rename from res/css/views/settings/_KeyBackupPanel.scss rename to res/css/views/settings/_SecureBackupPanel.scss index 872162caad..a9dab06b57 100644 --- a/res/css/views/settings/_KeyBackupPanel.scss +++ b/res/css/views/settings/_SecureBackupPanel.scss @@ -1,6 +1,6 @@ /* Copyright 2018 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. @@ -15,23 +15,39 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_KeyBackupPanel_sigValid, .mx_KeyBackupPanel_sigInvalid, -.mx_KeyBackupPanel_deviceVerified, .mx_KeyBackupPanel_deviceNotVerified { +.mx_SecureBackupPanel_sigValid, .mx_SecureBackupPanel_sigInvalid, +.mx_SecureBackupPanel_deviceVerified, .mx_SecureBackupPanel_deviceNotVerified { font-weight: bold; } -.mx_KeyBackupPanel_sigValid, .mx_KeyBackupPanel_deviceVerified { +.mx_SecureBackupPanel_sigValid, .mx_SecureBackupPanel_deviceVerified { color: $e2e-verified-color; } -.mx_KeyBackupPanel_sigInvalid, .mx_KeyBackupPanel_deviceNotVerified { +.mx_SecureBackupPanel_sigInvalid, .mx_SecureBackupPanel_deviceNotVerified { color: $e2e-warning-color; } -.mx_KeyBackupPanel_deviceName { +.mx_SecureBackupPanel_deviceName { font-style: italic; } -.mx_KeyBackupPanel_buttonRow { +.mx_SecureBackupPanel_buttonRow { margin: 1em 0; + + :nth-child(n + 1) { + margin-inline-end: 10px; + } +} + +.mx_SecureBackupPanel_statusList { + border-spacing: 0; + + td { + padding: 0; + + &:first-of-type { + padding-inline-end: 1em; + } + } } diff --git a/res/css/views/settings/tabs/_SettingsTab.scss b/res/css/views/settings/tabs/_SettingsTab.scss index e3a61e6825..892f5fe744 100644 --- a/res/css/views/settings/tabs/_SettingsTab.scss +++ b/res/css/views/settings/tabs/_SettingsTab.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2020 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. @@ -14,6 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_SettingsTab { + color: $muted-fg-color; +} + .mx_SettingsTab_warningText { color: $warning-color; } @@ -22,6 +26,7 @@ limitations under the License. font-size: $font-20px; font-weight: 600; color: $primary-fg-color; + margin-bottom: 10px; } .mx_SettingsTab_heading:nth-child(n + 2) { diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 8d1b68dd99..4d26d8a312 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -36,6 +36,10 @@ limitations under the License. } } + .mx_AppTile_persistedWrapper div { + min-width: 300px; + } + .mx_IncomingCallBox { min-width: 250px; background-color: $primary-bg-color; diff --git a/res/img/e2e/disabled.svg b/res/img/e2e/disabled.svg new file mode 100644 index 0000000000..2f6110a36a --- /dev/null +++ b/res/img/e2e/disabled.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/e2e/normal.svg b/res/img/e2e/normal.svg index 23ca78e44d..83b544a326 100644 --- a/res/img/e2e/normal.svg +++ b/res/img/e2e/normal.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/e2e/verified.svg b/res/img/e2e/verified.svg index ac4827baed..f90d9db554 100644 --- a/res/img/e2e/verified.svg +++ b/res/img/e2e/verified.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/e2e/warning.svg b/res/img/e2e/warning.svg index d42922892a..58f5c3b7d1 100644 --- a/res/img/e2e/warning.svg +++ b/res/img/e2e/warning.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/element-icons/add-photo.svg b/res/img/element-icons/add-photo.svg new file mode 100644 index 0000000000..bde5253bea --- /dev/null +++ b/res/img/element-icons/add-photo.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/home.svg b/res/img/element-icons/home.svg new file mode 100644 index 0000000000..a6c15456ff --- /dev/null +++ b/res/img/element-icons/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/info.svg b/res/img/element-icons/info.svg new file mode 100644 index 0000000000..b5769074ab --- /dev/null +++ b/res/img/element-icons/info.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/room/default_app.svg b/res/img/element-icons/room/default_app.svg new file mode 100644 index 0000000000..08734170df --- /dev/null +++ b/res/img/element-icons/room/default_app.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/res/img/element-icons/room/default_cal.svg b/res/img/element-icons/room/default_cal.svg new file mode 100644 index 0000000000..5bced115cf --- /dev/null +++ b/res/img/element-icons/room/default_cal.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/res/img/element-icons/room/default_clock.svg b/res/img/element-icons/room/default_clock.svg new file mode 100644 index 0000000000..cc21716d15 --- /dev/null +++ b/res/img/element-icons/room/default_clock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/room/default_doc.svg b/res/img/element-icons/room/default_doc.svg new file mode 100644 index 0000000000..93e7507be3 --- /dev/null +++ b/res/img/element-icons/room/default_doc.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/room/ellipsis.svg b/res/img/element-icons/room/ellipsis.svg new file mode 100644 index 0000000000..db1db6ec8b --- /dev/null +++ b/res/img/element-icons/room/ellipsis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/element-icons/room/pin-upright.svg b/res/img/element-icons/room/pin-upright.svg new file mode 100644 index 0000000000..9297f62a02 --- /dev/null +++ b/res/img/element-icons/room/pin-upright.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/res/img/element-icons/room/room-summary.svg b/res/img/element-icons/room/room-summary.svg new file mode 100644 index 0000000000..b6ac258b18 --- /dev/null +++ b/res/img/element-icons/room/room-summary.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index d48abf6a4c..a3b03c777e 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -119,6 +119,8 @@ $roomlist-bg-color: rgba(33, 38, 44, 0.90); $roomlist-header-color: $tertiary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$tagpanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: rgba(141, 151, 165, 0.2); diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 4ab5f99942..2741dcebf8 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -116,6 +116,8 @@ $roomlist-bg-color: $header-panel-bg-color; $roomsublist-divider-color: $primary-fg-color; +$tagpanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #1A1D23; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 6e66964fdf..4fd2a3615b 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -183,6 +183,8 @@ $roomlist-bg-color: $header-panel-bg-color; $roomlist-header-color: $primary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$tagpanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: #9e9e9e; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #fff; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index ceb8d5677c..05302a2a80 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -177,6 +177,8 @@ $roomlist-bg-color: rgba(245, 245, 245, 0.90); $roomlist-header-color: $tertiary-fg-color; $roomsublist-divider-color: $primary-fg-color; +$tagpanel-divider-color: $roomlist-header-color; + $roomtile-preview-color: $secondary-fg-color; $roomtile-default-badge-bg-color: #61708b; $roomtile-selected-bg-color: #FFF; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 86ee995a13..e1111a8a94 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -27,10 +27,14 @@ import {ModalManager} from "../Modal"; import SettingsStore from "../settings/SettingsStore"; import {ActiveRoomObserver} from "../ActiveRoomObserver"; import {Notifier} from "../Notifier"; +import type {Renderer} from "react-dom"; +import RightPanelStore from "../stores/RightPanelStore"; +import WidgetStore from "../stores/WidgetStore"; declare global { interface Window { Modernizr: ModernizrStatic; + matrixChat: ReturnType; mxMatrixClientPeg: IMatrixClientPeg; Olm: { init: () => Promise; @@ -47,6 +51,8 @@ declare global { singletonModalManager: ModalManager; mxSettingsStore: SettingsStore; mxNotifier: typeof Notifier; + mxRightPanelStore: RightPanelStore; + mxWidgetStore: WidgetStore; } interface Document { diff --git a/src/Analytics.js b/src/Analytics.js index 9966d0845e..135cc2eb7a 100644 --- a/src/Analytics.js +++ b/src/Analytics.js @@ -170,15 +170,19 @@ class Analytics { return !this.baseUrl; } + canEnable() { + const config = SdkConfig.get(); + return navigator.doNotTrack !== "1" && config && config.piwik && config.piwik.url && config.piwik.siteId; + } + /** * Enable Analytics if initialized but disabled * otherwise try and initalize, no-op if piwik config missing */ async enable() { if (!this.disabled) return; - + if (!this.canEnable()) return; const config = SdkConfig.get(); - if (!config || !config.piwik || !config.piwik.url || !config.piwik.siteId) return; this.baseUrl = new URL("piwik.php", config.piwik.url); // set constants diff --git a/src/AsyncWrapper.js b/src/AsyncWrapper.js index 94de5df214..359828b312 100644 --- a/src/AsyncWrapper.js +++ b/src/AsyncWrapper.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import createReactClass from 'create-react-class'; +import React from "react"; import * as sdk from './index'; import PropTypes from 'prop-types'; import { _t } from './languageHandler'; @@ -24,21 +24,19 @@ import { _t } from './languageHandler'; * Wrap an asynchronous loader function with a react component which shows a * spinner until the real component loads. */ -export default createReactClass({ - propTypes: { +export default class AsyncWrapper extends React.Component { + static propTypes = { /** A promise which resolves with the real component */ prom: PropTypes.object.isRequired, - }, + }; - getInitialState: function() { - return { - component: null, - error: null, - }; - }, + state = { + component: null, + error: null, + }; - componentDidMount: function() { + componentDidMount() { this._unmounted = false; // XXX: temporary logging to try to diagnose // https://github.com/vector-im/element-web/issues/3148 @@ -56,17 +54,17 @@ export default createReactClass({ console.warn('AsyncWrapper promise failed', e); this.setState({error: e}); }); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this._unmounted = true; - }, + } - _onWrapperCancelClick: function() { + _onWrapperCancelClick = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { if (this.state.component) { const Component = this.state.component; return ; @@ -87,6 +85,6 @@ export default createReactClass({ const Spinner = sdk.getComponent("elements.Spinner"); return ; } - }, -}); + } +} diff --git a/src/CallHandler.js b/src/CallHandler.js index 18f6aeb98a..ad40332af5 100644 --- a/src/CallHandler.js +++ b/src/CallHandler.js @@ -1,7 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017, 2018 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. @@ -56,7 +56,6 @@ limitations under the License. import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; -import * as sdk from './index'; import { _t } from './languageHandler'; import Matrix from 'matrix-js-sdk'; import dis from './dispatcher/dispatcher'; @@ -67,6 +66,10 @@ import {generateHumanReadableId} from "./utils/NamingUtils"; import {Jitsi} from "./widgets/Jitsi"; import {WidgetType} from "./widgets/WidgetType"; import {SettingLevel} from "./settings/SettingLevel"; +import {base32} from "rfc4648"; + +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; global.mxCalls = { //room_id: MatrixCall @@ -130,7 +133,6 @@ function _setCallListeners(call) { return; } - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { title: _t('Call Failed'), description: err.message, @@ -159,7 +161,6 @@ function _setCallListeners(call) { _setCallState(call, call.roomId, "busy"); pause("ringbackAudio"); play("busyAudio"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Handler', 'Call Timeout', ErrorDialog, { title: _t('Call Timeout'), description: _t('The remote side failed to pick up') + '.', @@ -201,7 +202,6 @@ function _setCallState(call, roomId, status) { function _showICEFallbackPrompt() { const cli = MatrixClientPeg.get(); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const code = sub => {sub}; Modal.createTrackedDialog('No TURN servers', '', QuestionDialog, { title: _t("Call failed due to misconfigured server"), @@ -244,7 +244,6 @@ function _onAction(payload) { if (screenCapErrorString) { _setCallState(undefined, newCall.roomId, "ended"); console.log("Can't capture screen: " + screenCapErrorString); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Handler', 'Unable to capture screen', ErrorDialog, { title: _t('Unable to capture screen'), description: screenCapErrorString, @@ -264,7 +263,6 @@ function _onAction(payload) { case 'place_call': { if (callHandler.getAnyActiveCall()) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Handler', 'Existing Call', ErrorDialog, { title: _t('Existing Call'), description: _t('You are already in a call.'), @@ -274,7 +272,6 @@ function _onAction(payload) { // if the runtime env doesn't do VoIP, whine. if (!MatrixClientPeg.get().supportsVoip()) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Handler', 'VoIP is unsupported', ErrorDialog, { title: _t('VoIP is unsupported'), description: _t('You cannot place VoIP calls in this browser.'), @@ -290,7 +287,6 @@ function _onAction(payload) { const members = room.getJoinedMembers(); if (members.length <= 1) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Call Handler', 'Cannot place call with self', ErrorDialog, { description: _t('You cannot place a call with yourself.'), }); @@ -365,8 +361,6 @@ async function _startCallApp(roomId, type) { const currentJitsiWidgets = WidgetUtils.getRoomWidgetsOfType(room, WidgetType.JITSI); if (WidgetEchoStore.roomHasPendingWidgetsOfType(roomId, currentJitsiWidgets, WidgetType.JITSI)) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call already in progress', '', ErrorDialog, { title: _t('Call in Progress'), description: _t('A call is currently being placed!'), @@ -379,19 +373,43 @@ async function _startCallApp(roomId, type) { "Refusing to start conference call widget in " + roomId + " a conference call widget is already present", ); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, { - title: _t('Call in Progress'), - description: _t('A call is already in progress!'), - }); + if (WidgetUtils.canUserModifyWidgets(roomId)) { + Modal.createTrackedDialog('Already have Jitsi Widget', '', QuestionDialog, { + title: _t('End Call'), + description: _t('Remove the group call from the room?'), + button: _t('End Call'), + cancelButton: _t('Cancel'), + onFinished: (endCall) => { + if (endCall) { + WidgetUtils.setRoomWidget(roomId, currentJitsiWidgets[0].getContent()['id']); + } + }, + }); + } else { + Modal.createTrackedDialog('Already have Jitsi Widget', '', ErrorDialog, { + title: _t('Call in Progress'), + description: _t("You don't have permission to remove the call from the room"), + }); + } return; } - const confId = `JitsiConference${generateHumanReadableId()}`; const jitsiDomain = Jitsi.getInstance().preferredDomain; + const jitsiAuth = await Jitsi.getInstance().getJitsiAuth(); + let confId; + if (jitsiAuth === 'openidtoken-jwt') { + // Create conference ID from room ID + // For compatibility with Jitsi, use base32 without padding. + // More details here: + // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + confId = base32.stringify(Buffer.from(roomId), { pad: false }); + } else { + // Create a random human readable conference ID + confId = `JitsiConference${generateHumanReadableId()}`; + } - let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl(); + let widgetUrl = WidgetUtils.getLocalJitsiWrapperUrl({auth: jitsiAuth}); // TODO: Remove URL hacks when the mobile clients eventually support v2 widgets const parsedUrl = new URL(widgetUrl); @@ -403,6 +421,7 @@ async function _startCallApp(roomId, type) { conferenceId: confId, isAudioOnly: type === 'voice', domain: jitsiDomain, + auth: jitsiAuth, }; const widgetId = ( @@ -416,8 +435,6 @@ async function _startCallApp(roomId, type) { console.log('Jitsi widget added'); }).catch((e) => { if (e.errcode === 'M_FORBIDDEN') { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Call Failed', '', ErrorDialog, { title: _t('Permission Required'), description: _t("You do not have permission to start a conference call in this room"), diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index 6f55a75d0c..eb8fff0eb1 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -70,6 +70,7 @@ interface IContent { interface IThumbnail { info: { + // eslint-disable-next-line camelcase thumbnail_info: { w: number; h: number; @@ -104,7 +105,12 @@ interface IAbortablePromise extends Promise { * @return {Promise} A promise that resolves with an object with an info key * and a thumbnail key. */ -function createThumbnail(element: ThumbnailableElement, inputWidth: number, inputHeight: number, mimeType: string): Promise { +function createThumbnail( + element: ThumbnailableElement, + inputWidth: number, + inputHeight: number, + mimeType: string, +): Promise { return new Promise((resolve) => { let targetWidth = inputWidth; let targetHeight = inputHeight; @@ -437,11 +443,13 @@ export default class ContentMessages { for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { - const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, { - file, - currentIndex: i, - totalFiles: okFiles.length, - }); + const {finished} = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', + '', UploadConfirmDialog, { + file, + currentIndex: i, + totalFiles: okFiles.length, + }, + ); const [shouldContinue, shouldUploadAll] = await finished; if (!shouldContinue) break; if (shouldUploadAll) { diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index a37521118f..df494e6bdd 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -15,6 +15,7 @@ limitations under the License. */ import {MatrixClientPeg} from './MatrixClientPeg'; +import dis from "./dispatcher/dispatcher"; import { hideToast as hideBulkUnverifiedSessionsToast, showToast as showBulkUnverifiedSessionsToast, @@ -28,11 +29,15 @@ import { hideToast as hideUnverifiedSessionsToast, showToast as showUnverifiedSessionsToast, } from "./toasts/UnverifiedSessionToast"; -import {privateShouldBeEncrypted} from "./createRoom"; +import { isSecretStorageBeingAccessed, accessSecretStorage } from "./SecurityManager"; +import { isSecureBackupRequired } from './utils/WellKnownUtils'; +import { isLoggedIn } from './components/structures/MatrixChat'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; const KEY_BACKUP_POLL_INTERVAL = 5 * 60 * 1000; export default class DeviceListener { + private dispatcherRef: string; // device IDs for which the user has dismissed the verify toast ('Later') private dismissed = new Set(); // has the user dismissed any of the various nag toasts to setup encryption on this device? @@ -60,6 +65,8 @@ export default class DeviceListener { MatrixClientPeg.get().on('crossSigning.keysChanged', this._onCrossSingingKeysChanged); MatrixClientPeg.get().on('accountData', this._onAccountData); MatrixClientPeg.get().on('sync', this._onSync); + MatrixClientPeg.get().on('RoomState.events', this._onRoomStateEvents); + this.dispatcherRef = dis.register(this._onAction); this._recheck(); } @@ -72,6 +79,11 @@ export default class DeviceListener { MatrixClientPeg.get().removeListener('crossSigning.keysChanged', this._onCrossSingingKeysChanged); MatrixClientPeg.get().removeListener('accountData', this._onAccountData); MatrixClientPeg.get().removeListener('sync', this._onSync); + MatrixClientPeg.get().removeListener('RoomState.events', this._onRoomStateEvents); + } + if (this.dispatcherRef) { + dis.unregister(this.dispatcherRef); + this.dispatcherRef = null; } this.dismissed.clear(); this.dismissedThisDeviceToast = false; @@ -158,6 +170,21 @@ export default class DeviceListener { if (state === 'PREPARED' && prevState === null) this._recheck(); }; + _onRoomStateEvents = (ev: MatrixEvent) => { + if (ev.getType() !== "m.room.encryption") { + return; + } + + // If a room changes to encrypted, re-check as it may be our first + // encrypted room. This also catches encrypted room creation as well. + this._recheck(); + }; + + _onAction = ({ action }) => { + if (action !== "on_logged_in") return; + this._recheck(); + }; + // The server doesn't tell us when key backup is set up, so we poll // & cache the result async _getKeyBackupInfo() { @@ -170,9 +197,10 @@ export default class DeviceListener { } private shouldShowSetupEncryptionToast() { - // In a default configuration, show the toasts. If the well-known config causes e2ee default to be false - // then do not show the toasts until user is in at least one encrypted room. - if (privateShouldBeEncrypted()) return true; + // If we're in the middle of a secret storage operation, we're likely + // modifying the state involved here, so don't add new toasts to setup. + if (isSecretStorageBeingAccessed()) return false; + // Show setup toasts once the user is in at least one encrypted room. const cli = MatrixClientPeg.get(); return cli && cli.getRooms().some(r => cli.isRoomEncrypted(r.roomId)); } @@ -189,15 +217,20 @@ export default class DeviceListener { if (!cli.isInitialSyncComplete()) return; const crossSigningReady = await cli.isCrossSigningReady(); + const secretStorageReady = await cli.isSecretStorageReady(); + const allSystemsReady = crossSigningReady && secretStorageReady; - if (this.dismissedThisDeviceToast || crossSigningReady) { + if (this.dismissedThisDeviceToast || allSystemsReady) { hideSetupEncryptionToast(); } else if (this.shouldShowSetupEncryptionToast()) { // make sure our keys are finished downloading await cli.downloadKeys([cli.getUserId()]); // cross signing isn't enabled - nag to enable it // There are 3 different toasts for: - if (cli.getStoredCrossSigningForUser(cli.getUserId())) { + if ( + !cli.getCrossSigningId() && + cli.getStoredCrossSigningForUser(cli.getUserId()) + ) { // Cross-signing on account but this device doesn't trust the master key (verify this session) showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); } else { @@ -207,7 +240,15 @@ export default class DeviceListener { showSetupEncryptionToast(SetupKind.UPGRADE_ENCRYPTION); } else { // No cross-signing or key backup on account (set up encryption) - showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + await cli.waitForClientWellKnown(); + if (isSecureBackupRequired() && isLoggedIn()) { + // If we're meant to set up, and Secure Backup is required, + // trigger the flow directly without a toast once logged in. + hideSetupEncryptionToast(); + accessSecretStorage(); + } else { + showSetupEncryptionToast(SetupKind.SET_UP_ENCRYPTION); + } } } } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 5d33645bb7..bd314c2e5f 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -339,33 +339,9 @@ class HtmlHighlighter extends BaseHighlighter { } } -class TextHighlighter extends BaseHighlighter { - private key = 0; - - /* create a node to hold the given content - * - * snippet: content of the span - * highlight: true to highlight as a search match - * - * returns a React node - */ - protected processSnippet(snippet: string, highlight: boolean): React.ReactNode { - const key = this.key++; - - let node = - { snippet } - ; - - if (highlight && this.highlightLink) { - node = { node }; - } - - return node; - } -} - interface IContent { format?: string; + // eslint-disable-next-line camelcase formatted_body?: string; body: string; } @@ -474,8 +450,13 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts }); return isDisplayedWithHtml ? - : - { strippedBody }; + : { strippedBody }; } /** diff --git a/src/Lifecycle.js b/src/Lifecycle.js index 9a84d4e1f4..88a5e8c5b5 100644 --- a/src/Lifecycle.js +++ b/src/Lifecycle.js @@ -43,6 +43,7 @@ import DeviceListener from "./DeviceListener"; import {Jitsi} from "./widgets/Jitsi"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "./BasePlatform"; import {decodeBase64, encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; +import ThreepidInviteStore from "./stores/ThreepidInviteStore"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -708,17 +709,30 @@ export async function onLoggedOut() { // that can occur when components try to use a null client. dis.dispatch({action: 'on_logged_out'}, true); stopMatrixClient(); - await _clearStorage(); + await _clearStorage({deleteEverything: true}); } /** + * @param {object} opts Options for how to clear storage. * @returns {Promise} promise which resolves once the stores have been cleared */ -async function _clearStorage() { +async function _clearStorage(opts: {deleteEverything: boolean}) { Analytics.disable(); if (window.localStorage) { + // try to save any 3pid invites from being obliterated + const pendingInvites = ThreepidInviteStore.instance.getWireInvites(); + window.localStorage.clear(); + + // now restore those invites + if (!opts?.deleteEverything) { + pendingInvites.forEach(i => { + const roomId = i.roomId; + delete i.roomId; // delete to avoid confusing the store + ThreepidInviteStore.instance.storeInvite(roomId, i); + }); + } } if (window.sessionStorage) { diff --git a/src/Markdown.js b/src/Markdown.js index e57507b4de..492450e87d 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -15,7 +15,7 @@ limitations under the License. */ import commonmark from 'commonmark'; -import escape from 'lodash/escape'; +import {escape} from "lodash"; const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 18af378fac..a5fa0fb3cf 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -31,7 +31,7 @@ import {verificationMethods} from 'matrix-js-sdk/src/crypto'; import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientBackedSettingsHandler"; import * as StorageManager from './utils/StorageManager'; import IdentityAuthClient from './IdentityAuthClient'; -import { cacheDehydrationKey, crossSigningCallbacks } from './CrossSigningManager'; +import { cacheDehydrationKey, crossSigningCallbacks } from './SecurityManager'; import {SHOW_QR_CODE_METHOD} from "matrix-js-sdk/src/crypto/verification/QRCode"; export interface IMatrixClientCreds { diff --git a/src/Modal.tsx b/src/Modal.tsx index 82ed33b794..0a36813961 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -151,7 +151,7 @@ export class ModalManager { prom: Promise, props?: IProps, className?: string, - options?: IOptions + options?: IOptions, ) { const modal: IModal = { onFinished: props ? props.onFinished : null, @@ -182,7 +182,7 @@ export class ModalManager { private getCloseFn( modal: IModal, - props: IProps + props: IProps, ): [IHandle["close"], IHandle["finished"]] { const deferred = defer(); return [async (...args: T) => { @@ -264,7 +264,7 @@ export class ModalManager { className?: string, isPriorityModal = false, isStaticModal = false, - options: IOptions = {} + options: IOptions = {}, ): IHandle { const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, options); if (isPriorityModal) { @@ -287,7 +287,7 @@ export class ModalManager { private appendDialogAsync( prom: Promise, props?: IProps, - className?: string + className?: string, ): IHandle { const {modal, closeDialog, onFinishedProm} = this.buildModal(prom, props, className, {}); diff --git a/src/Notifier.ts b/src/Notifier.ts index 473de6c161..2643de1abc 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -33,6 +33,7 @@ import Modal from './Modal'; import SettingsStore from "./settings/SettingsStore"; import { hideToast as hideNotificationsToast } from "./toasts/DesktopNotificationsToast"; import {SettingLevel} from "./settings/SettingLevel"; +import {isPushNotifyDisabled} from "./settings/controllers/NotificationControllers"; /* * Dispatches: @@ -258,7 +259,7 @@ export const Notifier = { } // set the notifications_hidden flag, as the user has knowingly interacted // with the setting we shouldn't nag them any further - this.setToolbarHidden(true); + this.setPromptHidden(true); }, isEnabled: function() { @@ -283,7 +284,7 @@ export const Notifier = { return SettingsStore.getValue("audioNotificationsEnabled"); }, - setToolbarHidden: function(hidden: boolean, persistent = true) { + setPromptHidden: function(hidden: boolean, persistent = true) { this.toolbarHidden = hidden; Analytics.trackEvent('Notifier', 'Set Toolbar Hidden', hidden); @@ -296,17 +297,17 @@ export const Notifier = { } }, - shouldShowToolbar: function() { + shouldShowPrompt: function() { const client = MatrixClientPeg.get(); if (!client) { return false; } const isGuest = client.isGuest(); - return !isGuest && this.supportsDesktopNotifications() && - !this.isEnabled() && !this._isToolbarHidden(); + return !isGuest && this.supportsDesktopNotifications() && !isPushNotifyDisabled() && + !this.isEnabled() && !this._isPromptHidden(); }, - _isToolbarHidden: function() { + _isPromptHidden: function() { // Check localStorage for any such meta data if (global.localStorage) { return global.localStorage.getItem("notifications_hidden") === "true"; diff --git a/src/RoomInvite.js b/src/RoomInvite.js index 839d677069..7eb7f5dbb2 100644 --- a/src/RoomInvite.js +++ b/src/RoomInvite.js @@ -23,6 +23,8 @@ import Modal from './Modal'; import * as sdk from './'; import { _t } from './languageHandler'; import {KIND_DM, KIND_INVITE} from "./components/views/dialogs/InviteDialog"; +import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; +import {CommunityPrototypeStore} from "./stores/CommunityPrototypeStore"; /** * Invites multiple addresses to a room @@ -56,6 +58,23 @@ export function showRoomInviteDialog(roomId) { ); } +export function showCommunityRoomInviteDialog(roomId, communityName) { + Modal.createTrackedDialog( + 'Invite Users to Community', '', CommunityPrototypeInviteDialog, {communityName, roomId}, + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, + ); +} + +export function showCommunityInviteDialog(communityId) { + const chat = CommunityPrototypeStore.instance.getGeneralChat(communityId); + if (chat) { + const name = CommunityPrototypeStore.instance.getCommunityName(communityId); + showCommunityRoomInviteDialog(chat.roomId, name); + } else { + throw new Error("Failed to locate appropriate room to start an invite in"); + } +} + /** * Checks if the given MatrixEvent is a valid 3rd party user invite. * @param {MatrixEvent} event The event to check @@ -77,7 +96,7 @@ export function isValid3pidInvite(event) { export function inviteUsersToRoom(roomId, userIds) { return inviteMultipleToRoom(roomId, userIds).then((result) => { const room = MatrixClientPeg.get().getRoom(roomId); - return _showAnyInviteErrors(result.states, room, result.inviter); + showAnyInviteErrors(result.states, room, result.inviter); }).catch((err) => { console.error(err.stack); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); @@ -88,7 +107,7 @@ export function inviteUsersToRoom(roomId, userIds) { }); } -function _showAnyInviteErrors(addrs, room, inviter) { +export function showAnyInviteErrors(addrs, room, inviter) { // Show user any errors const failedUsers = Object.keys(addrs).filter(a => addrs[a] === 'error'); if (failedUsers.length === 1 && inviter.fatal) { @@ -100,6 +119,7 @@ function _showAnyInviteErrors(addrs, room, inviter) { title: _t("Failed to invite users to the room:", {roomName: room.name}), description: inviter.getErrorText(failedUsers[0]), }); + return false; } else { const errorList = []; for (const addr of failedUsers) { @@ -118,8 +138,9 @@ function _showAnyInviteErrors(addrs, room, inviter) { title: _t("Failed to invite the following users to the %(roomName)s room:", {roomName: room.name}), description, }); + return false; } } - return addrs; + return true; } diff --git a/src/CrossSigningManager.js b/src/SecurityManager.js similarity index 82% rename from src/CrossSigningManager.js rename to src/SecurityManager.js index 111fc26889..e8bd63d2ff 100644 --- a/src/CrossSigningManager.js +++ b/src/SecurityManager.js @@ -1,5 +1,5 @@ /* -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. @@ -21,6 +21,9 @@ import { deriveKey } from 'matrix-js-sdk/src/crypto/key_passphrase'; import { decodeRecoveryKey } from 'matrix-js-sdk/src/crypto/recoverykey'; import { _t } from './languageHandler'; import {encodeBase64} from "matrix-js-sdk/src/crypto/olmlib"; +import { isSecureBackupRequired } from './utils/WellKnownUtils'; +import AccessSecretStorageDialog from './components/views/dialogs/security/AccessSecretStorageDialog'; +import RestoreKeyBackupDialog from './components/views/dialogs/security/RestoreKeyBackupDialog'; // This stores the secret storage private keys in memory for the JS SDK. This is // only meant to act as a cache to avoid prompting the user multiple times @@ -44,6 +47,17 @@ function isCachingAllowed() { return secretStorageBeingAccessed; } +/** + * This can be used by other components to check if secret storage access is in + * progress, so that we can e.g. avoid intermittently showing toasts during + * secret storage setup. + * + * @returns {bool} + */ +export function isSecretStorageBeingAccessed() { + return secretStorageBeingAccessed; +} + export class AccessCancelledError extends Error { constructor() { super("Secret storage access canceled"); @@ -67,11 +81,11 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { if (keyInfoEntries.length > 1) { throw new Error("Multiple storage key requests not implemented"); } - const [name, info] = keyInfoEntries[0]; + const [keyId, keyInfo] = keyInfoEntries[0]; // Check the in-memory cache - if (isCachingAllowed() && secretStorageKeys[name]) { - return [name, secretStorageKeys[name]]; + if (isCachingAllowed() && secretStorageKeys[keyId]) { + return [keyId, secretStorageKeys[keyId]]; } // if we dehydrated a device, see if that key works for SSSS @@ -94,23 +108,21 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { if (passphrase) { return deriveKey( passphrase, - info.passphrase.salt, - info.passphrase.iterations, + keyInfo.passphrase.salt, + keyInfo.passphrase.iterations, ); } else { return decodeRecoveryKey(recoveryKey); } }; - const AccessSecretStorageDialog = - sdk.getComponent("dialogs.secretstorage.AccessSecretStorageDialog"); const { finished } = Modal.createTrackedDialog("Access Secret Storage dialog", "", AccessSecretStorageDialog, /* props= */ { - keyInfo: info, + keyInfo, checkPrivateKey: async (input) => { const key = await inputToKey(input); - return await MatrixClientPeg.get().checkSecretStorageKey(key, info); + return await MatrixClientPeg.get().checkSecretStorageKey(key, keyInfo); }, }, /* className= */ null, @@ -132,11 +144,15 @@ async function getSecretStorageKey({ keys: keyInfos }, ssssItemName) { const key = await inputToKey(input); // Save to cache to avoid future prompts in the current session - if (isCachingAllowed()) { - secretStorageKeys[name] = key; - } + cacheSecretStorageKey(keyId, key); - return [name, key]; + return [keyId, key]; +} + +function cacheSecretStorageKey(keyId, key) { + if (isCachingAllowed()) { + secretStorageKeys[keyId] = key; + } } const onSecretRequested = async function({ @@ -152,7 +168,7 @@ const onSecretRequested = async function({ return; } if (!deviceTrust || !deviceTrust.isVerified()) { - console.log(`CrossSigningManager: Ignoring request from untrusted device ${deviceId}`); + console.log(`Ignoring secret request from untrusted device ${deviceId}`); return; } if ( @@ -184,13 +200,13 @@ const onSecretRequested = async function({ export const crossSigningCallbacks = { getSecretStorageKey, + cacheSecretStorageKey, onSecretRequested, }; export async function promptForBackupPassphrase() { let key; - const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); const { finished } = Modal.createTrackedDialog('Restore Backup', '', RestoreKeyBackupDialog, { showSummary: false, keyCallback: k => key = k, }, null, /* priority = */ false, /* static = */ true); @@ -230,11 +246,22 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f // This dialog calls bootstrap itself after guiding the user through // passphrase creation. const { finished } = Modal.createTrackedDialogAsync('Create Secret Storage dialog', '', - import("./async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"), + import("./async-components/views/dialogs/security/CreateSecretStorageDialog"), { - force: forceReset, + forceReset, + }, + null, + /* priority = */ false, + /* static = */ true, + /* options = */ { + onBeforeClose(reason) { + // If Secure Backup is required, you cannot leave the modal. + if (reason === "backgroundClick") { + return !isSecureBackupRequired(); + } + return true; + }, }, - null, /* priority = */ false, /* static = */ true, ); const [confirmed] = await finished; if (!confirmed) { @@ -242,13 +269,13 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f } } else { const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - await cli.bootstrapSecretStorage({ + await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest) => { const { finished } = Modal.createTrackedDialog( 'Cross-signing keys dialog', '', InteractiveAuthDialog, { title: _t("Setting up keys"), - matrixClient: MatrixClientPeg.get(), + matrixClient: cli, makeRequest, }, ); @@ -257,7 +284,9 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f throw new Error("Cross-signing key upload auth canceled"); } }, - getBackupPassphrase: promptForBackupPassphrase, + }); + await cli.bootstrapSecretStorage({ + getKeyBackupPassphrase: promptForBackupPassphrase, }); } diff --git a/src/SendHistoryManager.js b/src/SendHistoryManager.js index 794a58ad6f..d9955727a4 100644 --- a/src/SendHistoryManager.js +++ b/src/SendHistoryManager.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import _clamp from 'lodash/clamp'; +import {clamp} from "lodash"; export default class SendHistoryManager { history: Array = []; @@ -54,7 +54,7 @@ export default class SendHistoryManager { } getItem(offset: number): ?HistoryItem { - this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1); + this.currentIndex = clamp(this.currentIndex + offset, 0, this.history.length - 1); return this.history[this.currentIndex]; } } diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 50a49ccf1c..a6481d5b95 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -38,12 +38,14 @@ import {inviteUsersToRoom} from "./RoomInvite"; import { WidgetType } from "./widgets/WidgetType"; import { Jitsi } from "./widgets/Jitsi"; import { parseFragment as parseHtml } from "parse5"; -import sendBugReport from "./rageshake/submit-rageshake"; -import SdkConfig from "./SdkConfig"; +import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import { ensureDMExists } from "./createRoom"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; import { Action } from "./dispatcher/actions"; -import { EffectiveMembership, getEffectiveMembership } from "./utils/membership"; +import { EffectiveMembership, getEffectiveMembership, leaveRoomBehaviour } from "./utils/membership"; +import SdkConfig from "./SdkConfig"; +import SettingsStore from "./settings/SettingsStore"; +import {UIFeature} from "./settings/UIFeature"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -88,6 +90,7 @@ interface ICommandOpts { runFn?: RunFn; category: string; hideCompletionAfterSpace?: boolean; + isEnabled?(): boolean; } export class Command { @@ -98,6 +101,7 @@ export class Command { runFn: undefined | RunFn; category: string; hideCompletionAfterSpace: boolean; + _isEnabled?: () => boolean; constructor(opts: ICommandOpts) { this.command = opts.command; @@ -107,6 +111,7 @@ export class Command { this.runFn = opts.runFn; this.category = opts.category || CommandCategories.other; this.hideCompletionAfterSpace = opts.hideCompletionAfterSpace || false; + this._isEnabled = opts.isEnabled; } getCommand() { @@ -126,6 +131,10 @@ export class Command { getUsage() { return _t('Usage') + ': ' + this.getCommandWithArgs(); } + + isEnabled() { + return this._isEnabled ? this._isEnabled() : true; + } } function reject(error) { @@ -154,6 +163,19 @@ export const Commands = [ }, category: CommandCategories.messages, }), + new Command({ + command: 'lenny', + args: '', + description: _td('Prepends ( ͡° ͜ʖ ͡°) to a plain-text message'), + runFn: function(roomId, args) { + let message = '( ͡° ͜ʖ ͡°)'; + if (args) { + message = message + ' ' + args; + } + return success(MatrixClientPeg.get().sendTextMessage(roomId, message)); + }, + category: CommandCategories.messages, + }), new Command({ command: 'plain', args: '', @@ -601,11 +623,7 @@ export const Commands = [ } if (!targetRoomId) targetRoomId = roomId; - return success( - cli.leaveRoomChain(targetRoomId).then(function() { - dis.dispatch({action: 'view_next_room'}); - }), - ); + return success(leaveRoomBehaviour(targetRoomId)); }, category: CommandCategories.actions, }), @@ -781,6 +799,7 @@ export const Commands = [ command: 'addwidget', args: '', description: _td('Adds a custom widget by URL to the room'), + isEnabled: () => SettingsStore.getValue(UIFeature.Widgets), runFn: function(roomId, widgetUrl) { if (!widgetUrl) { return reject(_t("Please supply a widget URL or embed code")); @@ -864,12 +883,12 @@ export const Commands = [ _t('WARNING: KEY VERIFICATION FAILED! The signing key for %(userId)s and session' + ' %(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, - })); + { + fprint, + userId, + deviceId, + fingerprint, + })); } await cli.setDeviceVerified(userId, deviceId, true); @@ -883,7 +902,7 @@ export const Commands = [ { _t('The signing key you provided matches the signing key you received ' + 'from %(userId)s\'s session %(deviceId)s. Session marked as verified.', - {userId, deviceId}) + {userId, deviceId}) }

, @@ -963,19 +982,13 @@ export const Commands = [ command: "rageshake", aliases: ["bugreport"], description: _td("Send a bug report with logs"), + isEnabled: () => !!SdkConfig.get().bug_report_endpoint_url, args: "", runFn: function(roomId, args) { return success( - sendBugReport(SdkConfig.get().bug_report_endpoint_url, { - userText: args, - sendLogs: true, - }).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); - Modal.createTrackedDialog('Slash Commands', 'Rageshake sent', InfoDialog, { - title: _t('Logs sent'), - description: _t('Thank you!'), - }); - }), + Modal.createTrackedDialog('Slash Commands', 'Bug Report Dialog', BugReportDialog, { + initialText: args, + }).finished, ); }, category: CommandCategories.advanced, @@ -1047,7 +1060,7 @@ Commands.forEach(cmd => { }); }); -export function parseCommandString(input) { +export function parseCommandString(input: string) { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); @@ -1074,10 +1087,10 @@ export function parseCommandString(input) { * processing the command, or 'promise' if a request was sent out. * Returns null if the input didn't match a command. */ -export function getCommand(roomId, input) { +export function getCommand(roomId: string, input: string) { const {cmd, args} = parseCommandString(input); - if (CommandMap.has(cmd)) { + if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { return () => CommandMap.get(cmd).run(roomId, args, cmd); } } diff --git a/src/TextForEvent.js b/src/TextForEvent.js index c55380bd9b..a76c1f59e6 100644 --- a/src/TextForEvent.js +++ b/src/TextForEvent.js @@ -19,6 +19,7 @@ import { _t } from './languageHandler'; import * as Roles from './Roles'; import {isValid3pidInvite} from "./RoomInvite"; import SettingsStore from "./settings/SettingsStore"; +import {WidgetType} from "./widgets/WidgetType"; import {ALL_RULE_TYPES, ROOM_RULE_TYPES, SERVER_RULE_TYPES, USER_RULE_TYPES} from "./mjolnir/BanList"; function textForMemberEvent(ev) { @@ -475,6 +476,10 @@ function textForWidgetEvent(event) { const {name: prevName, type: prevType, url: prevUrl} = event.getPrevContent(); const {name, type, url} = event.getContent() || {}; + if (WidgetType.JITSI.matches(type) || WidgetType.JITSI.matches(prevType)) { + return textForJitsiWidgetEvent(event, senderName, url, prevUrl); + } + let widgetName = name || prevName || type || prevType || ''; // Apply sentence case to widget name if (widgetName && widgetName.length > 0) { @@ -500,6 +505,24 @@ function textForWidgetEvent(event) { } } +function textForJitsiWidgetEvent(event, senderName, url, prevUrl) { + if (url) { + if (prevUrl) { + return _t('Group call modified by %(senderName)s', { + senderName, + }); + } else { + return _t('Group call started by %(senderName)s', { + senderName, + }); + } + } else { + return _t('Group call ended by %(senderName)s', { + senderName, + }); + } +} + function textForMjolnirEvent(event) { const senderName = event.getSender(); const {entity: prevEntity} = event.getPrevContent(); diff --git a/src/WidgetMessaging.js b/src/WidgetMessaging.js index 6aed08c39d..c68e926ac1 100644 --- a/src/WidgetMessaging.js +++ b/src/WidgetMessaging.js @@ -186,7 +186,14 @@ export default class WidgetMessaging { isUserWidget: this.isUserWidget, onFinished: async (confirm) => { - const responseBody = {success: confirm}; + const responseBody = { + // Legacy (early draft) fields + success: confirm, + + // New style MSC1960 fields + state: confirm ? "allowed" : "blocked", + original_request_id: ev.requestId, // eslint-disable-line camelcase + }; if (confirm) { const credentials = await MatrixClientPeg.get().getOpenIdToken(); Object.assign(responseBody, credentials); diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index f527ab4a14..58d8124122 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -168,7 +168,7 @@ const shortcuts: Record = { key: Key.U, }], description: _td("Upload a file"), - } + }, ], [Categories.ROOM_LIST]: [ diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 5a650d4b6e..b1dbb56a01 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -190,7 +190,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn ev.preventDefault(); ev.stopPropagation(); } else if (onKeyDown) { - return onKeyDown(ev, state); + return onKeyDown(ev, context.state); } }, [context.state, onKeyDown, handleHomeEnd]); diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index 0e968461a8..cc2a1769c7 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -30,6 +30,7 @@ const Toolbar: React.FC = ({children, ...props}) => { const target = ev.target as HTMLElement; let handled = true; + // HOME and END are handled by RovingTabIndexProvider switch (ev.key) { case Key.ARROW_UP: case Key.ARROW_DOWN: @@ -47,8 +48,6 @@ const Toolbar: React.FC = ({children, ...props}) => { } break; - // HOME and END are handled by RovingTabIndexProvider - default: handled = false; } diff --git a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx index abc5412100..49f57ca7b6 100644 --- a/src/accessibility/context_menu/ContextMenuTooltipButton.tsx +++ b/src/accessibility/context_menu/ContextMenuTooltipButton.tsx @@ -20,7 +20,7 @@ import React from "react"; import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton"; -interface IProps extends React.ComponentProps { +interface IProps extends React.ComponentProps { // whether or not the context menu is currently open isExpanded: boolean; } diff --git a/src/accessibility/context_menu/MenuItem.tsx b/src/accessibility/context_menu/MenuItem.tsx index 64233e51ad..0bb169abf8 100644 --- a/src/accessibility/context_menu/MenuItem.tsx +++ b/src/accessibility/context_menu/MenuItem.tsx @@ -26,8 +26,9 @@ interface IProps extends React.ComponentProps { // Semantic component for representing a role=menuitem export const MenuItem: React.FC = ({children, label, ...props}) => { + const ariaLabel = props["aria-label"] || label; return ( - + { children } ); diff --git a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx index cc824fef22..2cb974d60e 100644 --- a/src/accessibility/roving/RovingAccessibleTooltipButton.tsx +++ b/src/accessibility/roving/RovingAccessibleTooltipButton.tsx @@ -20,7 +20,8 @@ import AccessibleTooltipButton from "../../components/views/elements/AccessibleT import {useRovingTabIndex} from "../RovingTabIndex"; import {Ref} from "./types"; -interface IProps extends Omit, "onFocus" | "inputRef" | "tabIndex"> { +type ATBProps = React.ComponentProps; +interface IProps extends Omit { inputRef?: Ref; } diff --git a/src/accessibility/roving/RovingTabIndexWrapper.tsx b/src/accessibility/roving/RovingTabIndexWrapper.tsx index c826b74497..5211f30215 100644 --- a/src/accessibility/roving/RovingTabIndexWrapper.tsx +++ b/src/accessibility/roving/RovingTabIndexWrapper.tsx @@ -16,7 +16,6 @@ limitations under the License. import React from "react"; -import AccessibleButton from "../../components/views/elements/AccessibleButton"; import {useRovingTabIndex} from "../RovingTabIndex"; import {FocusHandler, Ref} from "./types"; diff --git a/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js b/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js deleted file mode 100644 index b79911c66e..0000000000 --- a/src/async-components/views/dialogs/keybackup/IgnoreRecoveryReminderDialog.js +++ /dev/null @@ -1,70 +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. -*/ - -import React from "react"; -import PropTypes from "prop-types"; -import * as sdk from "../../../../index"; -import { _t } from "../../../../languageHandler"; - -export default class IgnoreRecoveryReminderDialog extends React.PureComponent { - static propTypes = { - onDontAskAgain: PropTypes.func.isRequired, - onFinished: PropTypes.func.isRequired, - onSetup: PropTypes.func.isRequired, - } - - onDontAskAgainClick = () => { - this.props.onFinished(); - this.props.onDontAskAgain(); - } - - onSetupClick = () => { - this.props.onFinished(); - this.props.onSetup(); - } - - render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const DialogButtons = sdk.getComponent("views.elements.DialogButtons"); - - return ( - -
-

{_t( - "Without setting up Secure Message Recovery, " + - "you'll lose your secure message history when you " + - "log out.", - )}

-

{_t( - "If you don't want to set this up now, you can later " + - "in Settings.", - )}

-
- -
-
-
- ); - } -} diff --git a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js similarity index 99% rename from src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js rename to src/async-components/views/dialogs/security/CreateKeyBackupDialog.js index c3aef9109a..ab39a094db 100644 --- a/src/async-components/views/dialogs/keybackup/CreateKeyBackupDialog.js +++ b/src/async-components/views/dialogs/security/CreateKeyBackupDialog.js @@ -21,7 +21,7 @@ import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import PropTypes from 'prop-types'; import {_t, _td} from '../../../../languageHandler'; -import { accessSecretStorage } from '../../../../CrossSigningManager'; +import { accessSecretStorage } from '../../../../SecurityManager'; import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import {copyNode} from "../../../../utils/strings"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; diff --git a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js similarity index 95% rename from src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js rename to src/async-components/views/dialogs/security/CreateSecretStorageDialog.js index b1c9dc5a60..ba2521f0cd 100644 --- a/src/async-components/views/dialogs/secretstorage/CreateSecretStorageDialog.js +++ b/src/async-components/views/dialogs/security/CreateSecretStorageDialog.js @@ -22,7 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import FileSaver from 'file-saver'; import {_t, _td} from '../../../../languageHandler'; import Modal from '../../../../Modal'; -import { promptForBackupPassphrase } from '../../../../CrossSigningManager'; +import { promptForBackupPassphrase } from '../../../../SecurityManager'; import {copyNode} from "../../../../utils/strings"; import {SSOAuthEntry} from "../../../../components/views/auth/InteractiveAuthEntryComponents"; import PassphraseField from "../../../../components/views/auth/PassphraseField"; @@ -30,6 +30,8 @@ import StyledRadioButton from '../../../../components/views/elements/StyledRadio import AccessibleButton from "../../../../components/views/elements/AccessibleButton"; import DialogButtons from "../../../../components/views/elements/DialogButtons"; import InlineSpinner from "../../../../components/views/elements/InlineSpinner"; +import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; +import { isSecureBackupRequired } from '../../../../utils/WellKnownUtils'; const PHASE_LOADING = 0; const PHASE_LOADERROR = 1; @@ -55,12 +57,12 @@ export default class CreateSecretStorageDialog extends React.PureComponent { static propTypes = { hasCancel: PropTypes.bool, accountPassword: PropTypes.string, - force: PropTypes.bool, + forceReset: PropTypes.bool, }; static defaultProps = { hasCancel: true, - force: false, + forceReset: false, }; constructor(props) { @@ -85,8 +87,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { canUploadKeysWithPasswordOnly: null, accountPassword: props.accountPassword || "", accountPasswordCorrect: null, - passPhraseKeySelected: CREATE_STORAGE_OPTION_KEY, + canSkip: !isSecureBackupRequired(), }; this._passphraseField = createRef(); @@ -117,8 +119,8 @@ export default class CreateSecretStorageDialog extends React.PureComponent { MatrixClientPeg.get().isCryptoEnabled() && await MatrixClientPeg.get().isKeyBackupTrusted(backupInfo) ); - const { force } = this.props; - const phase = (backupInfo && !force) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; + const { forceReset } = this.props; + const phase = (backupInfo && !forceReset) ? PHASE_MIGRATE : PHASE_CHOOSE_KEY_PASSPHRASE; this.setState({ phase, @@ -276,20 +278,28 @@ export default class CreateSecretStorageDialog extends React.PureComponent { const cli = MatrixClientPeg.get(); - const { force } = this.props; + const { forceReset } = this.props; try { - if (force) { - console.log("Forcing secret storage reset"); // log something so we can debug this later + if (forceReset) { + console.log("Forcing secret storage reset"); await cli.bootstrapSecretStorage({ - authUploadDeviceSigningKeys: this._doBootstrapUIAuth, createSecretStorageKey: async () => this._recoveryKey, setupNewKeyBackup: true, setupNewSecretStorage: true, }); } else { - await cli.bootstrapSecretStorage({ + // For password authentication users after 2020-09, this cross-signing + // step will be a no-op since it is now setup during registration or login + // when needed. We should keep this here to cover other cases such as: + // * Users with existing sessions prior to 2020-09 changes + // * SSO authentication users which require interactive auth to upload + // keys (and also happen to skip all post-authentication flows at the + // moment via token login) + await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + }); + await cli.bootstrapSecretStorage({ createSecretStorageKey: async () => this._recoveryKey, keyBackupInfo: this.state.backupInfo, setupNewKeyBackup: !this.state.backupInfo, @@ -337,7 +347,6 @@ export default class CreateSecretStorageDialog extends React.PureComponent { // so let's stash it here, rather than prompting for it twice. const keyCallback = k => this._backupKey = k; - const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); const { finished } = Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, { @@ -475,7 +484,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { primaryButton={_t("Continue")} onPrimaryButtonClick={this._onChooseKeyPassphraseFormSubmit} onCancel={this._onCancelClick} - hasCancel={true} + hasCancel={this.state.canSkip} /> ; } @@ -692,7 +701,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
@@ -719,7 +728,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent { _titleForPhase(phase) { switch (phase) { case PHASE_CHOOSE_KEY_PASSPHRASE: - return _t('Set up Secure backup'); + return _t('Set up Secure Backup'); case PHASE_MIGRATE: return _t('Upgrade your encryption'); case PHASE_PASSPHRASE: @@ -747,7 +756,7 @@ export default class CreateSecretStorageDialog extends React.PureComponent {
diff --git a/src/async-components/views/dialogs/ExportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js similarity index 90% rename from src/async-components/views/dialogs/ExportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ExportE2eKeysDialog.js index a92578a547..4dd296a8f1 100644 --- a/src/async-components/views/dialogs/ExportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.js @@ -17,44 +17,40 @@ limitations under the License. import FileSaver from 'file-saver'; import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import { _t } from '../../../languageHandler'; +import { _t } from '../../../../languageHandler'; import { MatrixClient } from 'matrix-js-sdk'; -import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../index'; +import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; +import * as sdk from '../../../../index'; const PHASE_EDIT = 1; const PHASE_EXPORTING = 2; -export default createReactClass({ - displayName: 'ExportE2eKeysDialog', - - propTypes: { +export default class ExportE2eKeysDialog extends React.Component { + static propTypes = { matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - phase: PHASE_EDIT, - errStr: null, - }; - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._unmounted = false; this._passphrase1 = createRef(); this._passphrase2 = createRef(); - }, - componentWillUnmount: function() { + this.state = { + phase: PHASE_EDIT, + errStr: null, + }; + } + + componentWillUnmount() { this._unmounted = true; - }, + } - _onPassphraseFormSubmit: function(ev) { + _onPassphraseFormSubmit = (ev) => { ev.preventDefault(); const passphrase = this._passphrase1.current.value; @@ -69,9 +65,9 @@ export default createReactClass({ this._startExport(passphrase); return false; - }, + }; - _startExport: function(passphrase) { + _startExport(passphrase) { // extra Promise.resolve() to turn synchronous exceptions into // asynchronous ones. Promise.resolve().then(() => { @@ -102,15 +98,15 @@ export default createReactClass({ errStr: null, phase: PHASE_EXPORTING, }); - }, + } - _onCancelClick: function(ev) { + _onCancelClick = (ev) => { ev.preventDefault(); this.props.onFinished(false); return false; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const disableForm = (this.state.phase === PHASE_EXPORTING); @@ -184,5 +180,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/async-components/views/dialogs/ImportE2eKeysDialog.js b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js similarity index 89% rename from src/async-components/views/dialogs/ImportE2eKeysDialog.js rename to src/async-components/views/dialogs/security/ImportE2eKeysDialog.js index 6b9d2c7e45..e7bae3578b 100644 --- a/src/async-components/views/dialogs/ImportE2eKeysDialog.js +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.js @@ -16,12 +16,11 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import { MatrixClient } from 'matrix-js-sdk'; -import * as MegolmExportEncryption from '../../../utils/MegolmExportEncryption'; -import * as sdk from '../../../index'; -import { _t } from '../../../languageHandler'; +import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; +import * as sdk from '../../../../index'; +import { _t } from '../../../../languageHandler'; function readFileAsArrayBuffer(file) { return new Promise((resolve, reject) => { @@ -38,48 +37,45 @@ function readFileAsArrayBuffer(file) { const PHASE_EDIT = 1; const PHASE_IMPORTING = 2; -export default createReactClass({ - displayName: 'ImportE2eKeysDialog', - - propTypes: { +export default class ImportE2eKeysDialog extends React.Component { + static propTypes = { matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - enableSubmit: false, - phase: PHASE_EDIT, - errStr: null, - }; - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._unmounted = false; this._file = createRef(); this._passphrase = createRef(); - }, - componentWillUnmount: function() { + this.state = { + enableSubmit: false, + phase: PHASE_EDIT, + errStr: null, + }; + } + + componentWillUnmount() { this._unmounted = true; - }, + } - _onFormChange: function(ev) { + _onFormChange = (ev) => { const files = this._file.current.files || []; this.setState({ enableSubmit: (this._passphrase.current.value !== "" && files.length > 0), }); - }, + }; - _onFormSubmit: function(ev) { + _onFormSubmit = (ev) => { ev.preventDefault(); this._startImport(this._file.current.files[0], this._passphrase.current.value); return false; - }, + }; - _startImport: function(file, passphrase) { + _startImport(file, passphrase) { this.setState({ errStr: null, phase: PHASE_IMPORTING, @@ -105,15 +101,15 @@ export default createReactClass({ phase: PHASE_EDIT, }); }); - }, + } - _onCancelClick: function(ev) { + _onCancelClick = (ev) => { ev.preventDefault(); this.props.onFinished(false); return false; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const disableForm = (this.state.phase !== PHASE_EDIT); @@ -188,5 +184,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js similarity index 97% rename from src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js rename to src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js index 74552a5c08..9f5045635d 100644 --- a/src/async-components/views/dialogs/keybackup/NewRecoveryMethodDialog.js +++ b/src/async-components/views/dialogs/security/NewRecoveryMethodDialog.js @@ -22,6 +22,7 @@ import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import dis from "../../../../dispatcher/dispatcher"; import { _t } from "../../../../languageHandler"; import Modal from "../../../../Modal"; +import RestoreKeyBackupDialog from "../../../../components/views/dialogs/security/RestoreKeyBackupDialog"; import {Action} from "../../../../dispatcher/actions"; export default class NewRecoveryMethodDialog extends React.PureComponent { @@ -41,7 +42,6 @@ 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, diff --git a/src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js b/src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js similarity index 100% rename from src/async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog.js rename to src/async-components/views/dialogs/security/RecoveryMethodRemovedDialog.js diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index e7a6f44536..c2d1290e08 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -47,7 +47,7 @@ export default class CommandProvider extends AutocompleteProvider { if (command[0] !== command[1]) { // The input looks like a command with arguments, perform exact match const name = command[1].substr(1); // strip leading `/` - if (CommandMap.has(name)) { + if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) { // some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments if (CommandMap.get(name).hideCompletionAfterSpace) return []; matches = [CommandMap.get(name)]; @@ -63,7 +63,7 @@ export default class CommandProvider extends AutocompleteProvider { } - return matches.map((result) => { + return matches.filter(cmd => cmd.isEnabled()).map((result) => { let completion = result.getCommand() + ' '; const usedAlias = result.aliases.find(alias => `/${alias}` === command[1]); // If the command (or an alias) is the same as the one they entered, we don't want to discard their arguments @@ -89,7 +89,11 @@ export default class CommandProvider extends AutocompleteProvider { renderCompletions(completions: React.ReactNode[]): React.ReactNode { return ( -
+
{ completions }
); diff --git a/src/autocomplete/CommunityProvider.tsx b/src/autocomplete/CommunityProvider.tsx index f34fee890e..ebf5d536ec 100644 --- a/src/autocomplete/CommunityProvider.tsx +++ b/src/autocomplete/CommunityProvider.tsx @@ -23,7 +23,7 @@ import {MatrixClientPeg} from '../MatrixClientPeg'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; import * as sdk from '../index'; -import _sortBy from 'lodash/sortBy'; +import {sortBy} from "lodash"; import {makeGroupPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; import FlairStore from "../stores/FlairStore"; @@ -81,7 +81,7 @@ export default class CommunityProvider extends AutocompleteProvider { const matchedString = command[0]; completions = this.matcher.match(matchedString); - completions = _sortBy(completions, [ + completions = sortBy(completions, [ (c) => score(matchedString, c.groupId), (c) => c.groupId.length, ]).map(({avatarUrl, groupId, name}) => ({ @@ -91,15 +91,15 @@ export default class CommunityProvider extends AutocompleteProvider { href: makeGroupPermalink(groupId), component: ( - + ), range, - })) - .slice(0, 4); + })).slice(0, 4); } return completions; } diff --git a/src/autocomplete/Components.tsx b/src/autocomplete/Components.tsx index 6ac2f4db14..4b0d35698d 100644 --- a/src/autocomplete/Components.tsx +++ b/src/autocomplete/Components.tsx @@ -34,9 +34,9 @@ export const TextualCompletion = forwardRef((props const {title, subtitle, description, className, ...restProps} = props; return (
{ title } { subtitle } @@ -53,9 +53,9 @@ export const PillCompletion = forwardRef((props, ref) const {title, subtitle, description, className, children, ...restProps} = props; return (
{ children } { title } diff --git a/src/autocomplete/EmojiProvider.tsx b/src/autocomplete/EmojiProvider.tsx index 147d68f5ff..705474f8d0 100644 --- a/src/autocomplete/EmojiProvider.tsx +++ b/src/autocomplete/EmojiProvider.tsx @@ -23,8 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider'; import QueryMatcher from './QueryMatcher'; import {PillCompletion} from './Components'; import {ICompletion, ISelectionRange} from './Autocompleter'; -import _uniq from 'lodash/uniq'; -import _sortBy from 'lodash/sortBy'; +import {uniq, sortBy} from 'lodash'; import SettingsStore from "../settings/SettingsStore"; import { shortcodeToUnicode } from '../HtmlUtils'; import { EMOJI, IEmoji } from '../emoji'; @@ -115,7 +114,7 @@ export default class EmojiProvider extends AutocompleteProvider { } // Finally, sort by original ordering sorters.push((c) => c._orderBy); - completions = _sortBy(_uniq(completions), sorters); + completions = sortBy(uniq(completions), sorters); completions = completions.map(({shortname}) => { const unicode = shortcodeToUnicode(shortname); @@ -139,7 +138,11 @@ export default class EmojiProvider extends AutocompleteProvider { renderCompletions(completions: React.ReactNode[]): React.ReactNode { return ( -
+
{ completions }
); diff --git a/src/autocomplete/QueryMatcher.ts b/src/autocomplete/QueryMatcher.ts index 9c91414556..a07ed29c7e 100644 --- a/src/autocomplete/QueryMatcher.ts +++ b/src/autocomplete/QueryMatcher.ts @@ -16,8 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import _at from 'lodash/at'; -import _uniq from 'lodash/uniq'; +import {at, uniq} from 'lodash'; import {removeHiddenChars} from "matrix-js-sdk/src/utils"; interface IOptions { @@ -73,7 +72,7 @@ export default class QueryMatcher { // type for their values. We assume that those values who's keys have // been specified will be string. Also, we cannot infer all the // types of the keys of the objects at compile. - const keyValues = _at(object, this._options.keys); + const keyValues = at(object, this._options.keys); if (this._options.funcs) { for (const f of this._options.funcs) { @@ -137,7 +136,7 @@ export default class QueryMatcher { }); // Now map the keys to the result objects. Also remove any duplicates. - return _uniq(matches.map((match) => match.object)); + return uniq(matches.map((match) => match.object)); } private processQuery(query: string): string { diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index b18b2d132c..74deacf61f 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -27,7 +27,7 @@ import {PillCompletion} from './Components'; import * as sdk from '../index'; import {makeRoomPermalink} from "../utils/permalinks/Permalinks"; import {ICompletion, ISelectionRange} from "./Autocompleter"; -import { uniqBy, sortBy } from 'lodash'; +import {uniqBy, sortBy} from "lodash"; const ROOM_REGEX = /\B#\S*/g; @@ -110,9 +110,7 @@ export default class RoomProvider extends AutocompleteProvider { ), range, }; - }) - .filter((completion) => !!completion.completion && completion.completion.length > 0) - .slice(0, 4); + }).filter((completion) => !!completion.completion && completion.completion.length > 0).slice(0, 4); } return completions; } diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index c957b5e597..32eea55b0b 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -23,7 +23,7 @@ import AutocompleteProvider from './AutocompleteProvider'; import {PillCompletion} from './Components'; import * as sdk from '../index'; import QueryMatcher from './QueryMatcher'; -import _sortBy from 'lodash/sortBy'; +import {sortBy} from 'lodash'; import {MatrixClientPeg} from '../MatrixClientPeg'; import MatrixEvent from "matrix-js-sdk/src/models/event"; @@ -71,8 +71,13 @@ export default class UserProvider extends AutocompleteProvider { } } - private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed: boolean, - data: IRoomTimelineData) => { + private onRoomTimeline = ( + ev: MatrixEvent, + room: Room, + toStartOfTimeline: boolean, + removed: boolean, + data: IRoomTimelineData, + ) => { if (!room) return; if (removed) return; if (room.roomId !== this.room.roomId) return; @@ -151,7 +156,7 @@ export default class UserProvider extends AutocompleteProvider { const currentUserId = MatrixClientPeg.get().credentials.userId; this.users = this.room.getJoinedMembers().filter(({userId}) => userId !== currentUserId); - this.users = _sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20); + this.users = sortBy(this.users, (member) => 1E20 - lastSpoken[member.userId] || 1E20); this.matcher.setObjects(this.users); } @@ -171,7 +176,11 @@ export default class UserProvider extends AutocompleteProvider { renderCompletions(completions: React.ReactNode[]): React.ReactNode { return ( -
+
{ completions }
); diff --git a/src/components/structures/CompatibilityPage.js b/src/components/structures/CompatibilityPage.js deleted file mode 100644 index 1fa6068675..0000000000 --- a/src/components/structures/CompatibilityPage.js +++ /dev/null @@ -1,90 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -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 from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; -import { _t } from '../../languageHandler'; -import SdkConfig from '../../SdkConfig'; - -export default createReactClass({ - displayName: 'CompatibilityPage', - propTypes: { - onAccept: PropTypes.func, - }, - - getDefaultProps: function() { - return { - onAccept: function() {}, // NOP - }; - }, - - onAccept: function() { - this.props.onAccept(); - }, - - render: function() { - const brand = SdkConfig.get().brand; - - return ( -
-
-

{_t( - "Sorry, your browser is not able to run %(brand)s.", - { - brand, - }, - { - 'b': (sub) => {sub}, - }) - }

-

- { _t( - "%(brand)s uses many advanced browser features, some of which are not available " + - "or experimental in your current browser.", - { brand }, - ) } -

-

- { _t( - 'Please install Chrome, Firefox, ' + - 'or Safari for the best experience.', - {}, - { - 'chromeLink': (sub) => {sub}, - 'firefoxLink': (sub) => {sub}, - 'safariLink': (sub) => {sub}, - }, - )} -

-

- { _t( - "With your current browser, the look and feel of the application may be " + - "completely incorrect, and some or all features may not function. " + - "If you want to try it anyway you can continue, but you are on your own in terms " + - "of any issues you may encounter!", - ) } -

- -
-
- ); - }, -}); diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 587ae2cb6b..884f77aba5 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, {CSSProperties, useRef, useState} from "react"; +import React, {CSSProperties, RefObject, useRef, useState} from "react"; import ReactDOM from "react-dom"; import classNames from "classnames"; @@ -233,8 +233,7 @@ export class ContextMenu extends React.PureComponent { switch (ev.key) { case Key.TAB: case Key.ESCAPE: - // close on left and right arrows too for when it is a context menu on a - case Key.ARROW_LEFT: + case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a case Key.ARROW_RIGHT: this.props.onFinished(); break; @@ -417,8 +416,8 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None return menuOptions; }; -export const useContextMenu = () => { - const button = useRef(null); +export const useContextMenu = (): [boolean, RefObject, () => void, () => void, (val: boolean) => void] => { + const button = useRef(null); const [isOpen, setIsOpen] = useState(false); const open = () => { setIsOpen(true); diff --git a/src/components/structures/EmbeddedPage.js b/src/components/structures/EmbeddedPage.js index 49ba3d1227..cbfeff7582 100644 --- a/src/components/structures/EmbeddedPage.js +++ b/src/components/structures/EmbeddedPage.js @@ -43,8 +43,8 @@ export default class EmbeddedPage extends React.PureComponent { static contextType = MatrixClientContext; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this._dispatcherRef = null; diff --git a/src/components/structures/FilePanel.js b/src/components/structures/FilePanel.js index d873dd4094..6d618d0b9d 100644 --- a/src/components/structures/FilePanel.js +++ b/src/components/structures/FilePanel.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import {Filter} from 'matrix-js-sdk'; @@ -24,27 +23,27 @@ import * as sdk from '../../index'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; +import BaseCard from "../views/right_panel/BaseCard"; +import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; /* * Component which shows the filtered file using a TimelinePanel */ -const FilePanel = createReactClass({ - displayName: 'FilePanel', +class FilePanel extends React.Component { + static propTypes = { + roomId: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + }; + // This is used to track if a decrypted event was a live event and should be // added to the timeline. - decryptingEvents: new Set(), + decryptingEvents = new Set(); - propTypes: { - roomId: PropTypes.string.isRequired, - }, + state = { + timelineSet: null, + }; - getInitialState: function() { - return { - timelineSet: null, - }; - }, - - onRoomTimeline(ev, room, toStartOfTimeline, removed, data) { + onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { if (room.roomId !== this.props.roomId) return; if (toStartOfTimeline || !data || !data.liveEvent || ev.isRedacted()) return; @@ -53,9 +52,9 @@ const FilePanel = createReactClass({ } else { this.addEncryptedLiveEvent(ev); } - }, + }; - onEventDecrypted(ev, err) { + onEventDecrypted = (ev, err) => { if (ev.getRoomId() !== this.props.roomId) return; const eventId = ev.getId(); @@ -63,7 +62,7 @@ const FilePanel = createReactClass({ if (err) return; this.addEncryptedLiveEvent(ev); - }, + }; addEncryptedLiveEvent(ev, toStartOfTimeline) { if (!this.state.timelineSet) return; @@ -77,7 +76,7 @@ const FilePanel = createReactClass({ if (!this.state.timelineSet.eventIdToTimeline(ev.getId())) { this.state.timelineSet.addEventToTimeline(ev, timeline, false); } - }, + } async componentDidMount() { const client = MatrixClientPeg.get(); @@ -98,7 +97,7 @@ const FilePanel = createReactClass({ client.on('Room.timeline', this.onRoomTimeline); client.on('Event.decrypted', this.onEventDecrypted); } - }, + } componentWillUnmount() { const client = MatrixClientPeg.get(); @@ -110,7 +109,7 @@ const FilePanel = createReactClass({ client.removeListener('Room.timeline', this.onRoomTimeline); client.removeListener('Event.decrypted', this.onEventDecrypted); } - }, + } async fetchFileEventsServer(room) { const client = MatrixClientPeg.get(); @@ -134,9 +133,9 @@ const FilePanel = createReactClass({ const timelineSet = room.getOrCreateFilteredTimelineSet(filter); return timelineSet; - }, + } - onPaginationRequest(timelineWindow, direction, limit) { + onPaginationRequest = (timelineWindow, direction, limit) => { const client = MatrixClientPeg.get(); const eventIndex = EventIndexPeg.get(); const roomId = this.props.roomId; @@ -152,7 +151,7 @@ const FilePanel = createReactClass({ } else { return timelineWindow.paginate(direction, limit); } - }, + }; async updateTimelineSet(roomId: string) { const client = MatrixClientPeg.get(); @@ -188,22 +187,30 @@ const FilePanel = createReactClass({ } else { console.error("Failed to add filtered timelineSet for FilePanel as no room!"); } - }, + } - render: function() { + render() { if (MatrixClientPeg.get().isGuest()) { - return
+ return
{ _t("You must register to use this functionality", {}, { 'a': (sub) => { sub } }) }
-
; + ; } else if (this.noRoom) { - return
+ return
{ _t("You must join the room to see its files") }
-
; + ; } // wrap a TimelinePanel with the jump-to-event bits turned off. @@ -219,8 +226,13 @@ const FilePanel = createReactClass({ // console.log("rendering TimelinePanel for timelineSet " + this.state.timelineSet.room.roomId + " " + // "(" + this.state.timelineSet._timelines.join(", ") + ")" + " with key " + this.props.roomId); return ( -
- + -
+ ); } else { return ( -
+ -
+ ); } - }, -}); + } +} export default FilePanel; diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 2e2fa25169..5dadba983a 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import * as sdk from '../../index'; @@ -70,10 +69,8 @@ const UserSummaryType = PropTypes.shape({ }).isRequired, }); -const CategoryRoomList = createReactClass({ - displayName: 'CategoryRoomList', - - props: { +class CategoryRoomList extends React.Component { + static propTypes = { rooms: PropTypes.arrayOf(RoomSummaryType).isRequired, category: PropTypes.shape({ profile: PropTypes.shape({ @@ -84,9 +81,9 @@ const CategoryRoomList = createReactClass({ // Whether the list should be editable editing: PropTypes.bool.isRequired, - }, + }; - onAddRoomsToSummaryClicked: function(ev) { + onAddRoomsToSummaryClicked = (ev) => { ev.preventDefault(); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Add Rooms to Group Summary', '', AddressPickerDialog, { @@ -122,9 +119,9 @@ const CategoryRoomList = createReactClass({ }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); - }, + }; - render: function() { + render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? (; - }, -}); + } +} -const FeaturedRoom = createReactClass({ - displayName: 'FeaturedRoom', - - props: { +class FeaturedRoom extends React.Component { + static propTypes = { summaryInfo: RoomSummaryType.isRequired, editing: PropTypes.bool.isRequired, groupId: PropTypes.string.isRequired, - }, + }; - onClick: function(e) { + onClick = (e) => { e.preventDefault(); e.stopPropagation(); @@ -176,9 +171,9 @@ const FeaturedRoom = createReactClass({ room_alias: this.props.summaryInfo.profile.canonical_alias, room_id: this.props.summaryInfo.room_id, }); - }, + }; - onDeleteClicked: function(e) { + onDeleteClicked = (e) => { e.preventDefault(); e.stopPropagation(); GroupStore.removeRoomFromGroupSummary( @@ -201,9 +196,9 @@ const FeaturedRoom = createReactClass({ description: _t("The room '%(roomName)s' could not be removed from the summary.", {roomName}), }); }); - }, + }; - render: function() { + render() { const RoomAvatar = sdk.getComponent("avatars.RoomAvatar"); const roomName = this.props.summaryInfo.profile.name || @@ -243,13 +238,11 @@ const FeaturedRoom = createReactClass({
{ roomNameNode }
{ deleteButton }
; - }, -}); + } +} -const RoleUserList = createReactClass({ - displayName: 'RoleUserList', - - props: { +class RoleUserList extends React.Component { + static propTypes = { users: PropTypes.arrayOf(UserSummaryType).isRequired, role: PropTypes.shape({ profile: PropTypes.shape({ @@ -260,9 +253,9 @@ const RoleUserList = createReactClass({ // Whether the list should be editable editing: PropTypes.bool.isRequired, - }, + }; - onAddUsersClicked: function(ev) { + onAddUsersClicked = (ev) => { ev.preventDefault(); const AddressPickerDialog = sdk.getComponent("dialogs.AddressPickerDialog"); Modal.createTrackedDialog('Add Users to Group Summary', '', AddressPickerDialog, { @@ -298,9 +291,9 @@ const RoleUserList = createReactClass({ }); }, }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); - }, + }; - render: function() { + render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); const addButton = this.props.editing ? ( @@ -325,19 +318,17 @@ const RoleUserList = createReactClass({ { userNodes } { addButton }
; - }, -}); + } +} -const FeaturedUser = createReactClass({ - displayName: 'FeaturedUser', - - props: { +class FeaturedUser extends React.Component { + static propTypes = { summaryInfo: UserSummaryType.isRequired, editing: PropTypes.bool.isRequired, groupId: PropTypes.string.isRequired, - }, + }; - onClick: function(e) { + onClick = (e) => { e.preventDefault(); e.stopPropagation(); @@ -345,9 +336,9 @@ const FeaturedUser = createReactClass({ action: 'view_start_chat_or_reuse', user_id: this.props.summaryInfo.user_id, }); - }, + }; - onDeleteClicked: function(e) { + onDeleteClicked = (e) => { e.preventDefault(); e.stopPropagation(); GroupStore.removeUserFromGroupSummary( @@ -368,9 +359,9 @@ const FeaturedUser = createReactClass({ description: _t("The user '%(displayName)s' could not be removed from the summary.", {displayName}), }); }); - }, + }; - render: function() { + render() { const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); const name = this.props.summaryInfo.displayname || this.props.summaryInfo.user_id; @@ -394,41 +385,37 @@ const FeaturedUser = createReactClass({
{ userNameNode }
{ deleteButton } ; - }, -}); + } +} const GROUP_JOINPOLICY_OPEN = "open"; const GROUP_JOINPOLICY_INVITE = "invite"; -export default createReactClass({ - displayName: 'GroupView', - - propTypes: { +export default class GroupView extends React.Component { + static propTypes = { groupId: PropTypes.string.isRequired, // Whether this is the first time the group admin is viewing the group groupIsNew: PropTypes.bool, - }, + }; - getInitialState: function() { - return { - summary: null, - isGroupPublicised: null, - isUserPrivileged: null, - groupRooms: null, - groupRoomsLoading: null, - error: null, - editing: false, - saving: false, - uploadingAvatar: false, - avatarChanged: false, - membershipBusy: false, - publicityBusy: false, - inviterProfile: null, - showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup, - }; - }, + state = { + summary: null, + isGroupPublicised: null, + isUserPrivileged: null, + groupRooms: null, + groupRoomsLoading: null, + error: null, + editing: false, + saving: false, + uploadingAvatar: false, + avatarChanged: false, + membershipBusy: false, + publicityBusy: false, + inviterProfile: null, + showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup, + }; - componentDidMount: function() { + componentDidMount() { this._unmounted = false; this._matrixClient = MatrixClientPeg.get(); this._matrixClient.on("Group.myMembership", this._onGroupMyMembership); @@ -437,9 +424,9 @@ export default createReactClass({ this._dispatcherRef = dis.register(this._onAction); this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this._unmounted = true; this._matrixClient.removeListener("Group.myMembership", this._onGroupMyMembership); dis.unregister(this._dispatcherRef); @@ -448,10 +435,11 @@ export default createReactClass({ if (this._rightPanelStoreToken) { this._rightPanelStoreToken.remove(); } - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { if (this.props.groupId !== newProps.groupId) { this.setState({ summary: null, @@ -460,24 +448,24 @@ export default createReactClass({ this._initGroupStore(newProps.groupId); }); } - }, + } - _onRightPanelStoreUpdate: function() { + _onRightPanelStoreUpdate = () => { this.setState({ showRightPanel: RightPanelStore.getSharedInstance().isOpenForGroup, }); - }, + }; - _onGroupMyMembership: function(group) { + _onGroupMyMembership = (group) => { if (this._unmounted || group.groupId !== this.props.groupId) return; if (group.myMembership === 'leave') { // Leave settings - the user might have clicked the "Leave" button this._closeSettings(); } this.setState({membershipBusy: false}); - }, + }; - _initGroupStore: function(groupId, firstInit) { + _initGroupStore(groupId, firstInit) { const group = this._matrixClient.getGroup(groupId); if (group && group.inviter && group.inviter.userId) { this._fetchInviterProfile(group.inviter.userId); @@ -506,9 +494,9 @@ export default createReactClass({ }); } }); - }, + } - onGroupStoreUpdated(firstInit) { + onGroupStoreUpdated = (firstInit) => { if (this._unmounted) return; const summary = GroupStore.getSummary(this.props.groupId); if (summary.profile) { @@ -533,7 +521,7 @@ export default createReactClass({ if (this.props.groupIsNew && firstInit) { this._onEditClick(); } - }, + }; _fetchInviterProfile(userId) { this.setState({ @@ -555,9 +543,9 @@ export default createReactClass({ inviterProfileBusy: false, }); }); - }, + } - _onEditClick: function() { + _onEditClick = () => { this.setState({ editing: true, profileForm: Object.assign({}, this.state.summary.profile), @@ -568,20 +556,20 @@ export default createReactClass({ GROUP_JOINPOLICY_INVITE, }, }); - }, + }; - _onShareClick: function() { + _onShareClick = () => { const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share community dialog', '', ShareDialog, { target: this._matrixClient.getGroup(this.props.groupId) || new Group(this.props.groupId), }); - }, + }; - _onCancelClick: function() { + _onCancelClick = () => { this._closeSettings(); - }, + }; - _onAction(payload) { + _onAction = (payload) => { switch (payload.action) { // NOTE: close_settings is an app-wide dispatch; as it is dispatched from MatrixChat case 'close_settings': @@ -593,34 +581,34 @@ export default createReactClass({ default: break; } - }, + }; - _closeSettings() { + _closeSettings = () => { dis.dispatch({action: 'close_settings'}); - }, + }; - _onNameChange: function(value) { + _onNameChange = (value) => { const newProfileForm = Object.assign(this.state.profileForm, { name: value }); this.setState({ profileForm: newProfileForm, }); - }, + }; - _onShortDescChange: function(value) { + _onShortDescChange = (value) => { const newProfileForm = Object.assign(this.state.profileForm, { short_description: value }); this.setState({ profileForm: newProfileForm, }); - }, + }; - _onLongDescChange: function(e) { + _onLongDescChange = (e) => { const newProfileForm = Object.assign(this.state.profileForm, { long_description: e.target.value }); this.setState({ profileForm: newProfileForm, }); - }, + }; - _onAvatarSelected: function(ev) { + _onAvatarSelected = ev => { const file = ev.target.files[0]; if (!file) return; @@ -644,15 +632,15 @@ export default createReactClass({ description: _t('Failed to upload image'), }); }); - }, + }; - _onJoinableChange: function(ev) { + _onJoinableChange = ev => { this.setState({ joinableForm: { policyType: ev.target.value }, }); - }, + }; - _onSaveClick: function() { + _onSaveClick = () => { this.setState({saving: true}); const savePromise = this.state.isUserPrivileged ? this._saveGroup() : Promise.resolve(); savePromise.then((result) => { @@ -683,16 +671,16 @@ export default createReactClass({ avatarChanged: false, }); }); - }, + }; - _saveGroup: async function() { + async _saveGroup() { await this._matrixClient.setGroupProfile(this.props.groupId, this.state.profileForm); await this._matrixClient.setGroupJoinPolicy(this.props.groupId, { type: this.state.joinableForm.policyType, }); - }, + } - _onAcceptInviteClick: async function() { + _onAcceptInviteClick = async () => { this.setState({membershipBusy: true}); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the @@ -709,9 +697,9 @@ export default createReactClass({ description: _t("Unable to accept invite"), }); }); - }, + }; - _onRejectInviteClick: async function() { + _onRejectInviteClick = async () => { this.setState({membershipBusy: true}); // Wait 500ms to prevent flashing. Do this before sending a request otherwise we risk the @@ -728,9 +716,9 @@ export default createReactClass({ description: _t("Unable to reject invite"), }); }); - }, + }; - _onJoinClick: async function() { + _onJoinClick = async () => { if (this._matrixClient.isGuest()) { dis.dispatch({action: 'require_registration', screen_after: {screen: `group/${this.props.groupId}`}}); return; @@ -752,9 +740,9 @@ export default createReactClass({ description: _t("Unable to join community"), }); }); - }, + }; - _leaveGroupWarnings: function() { + _leaveGroupWarnings() { const warnings = []; if (this.state.isUserPrivileged) { @@ -768,10 +756,9 @@ export default createReactClass({ } return warnings; - }, + } - - _onLeaveClick: function() { + _onLeaveClick = () => { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const warnings = this._leaveGroupWarnings(); @@ -806,13 +793,13 @@ export default createReactClass({ }); }, }); - }, + }; - _onAddRoomsClick: function() { + _onAddRoomsClick = () => { showGroupAddRoomDialog(this.props.groupId); - }, + }; - _getGroupSection: function() { + _getGroupSection() { const groupSettingsSectionClasses = classnames({ "mx_GroupView_group": this.state.editing, "mx_GroupView_group_disabled": this.state.editing && !this.state.isUserPrivileged, @@ -856,9 +843,9 @@ export default createReactClass({ { this._getLongDescriptionNode() } { this._getRoomsNode() }
; - }, + } - _getRoomsNode: function() { + _getRoomsNode() { const RoomDetailList = sdk.getComponent('rooms.RoomDetailList'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const TintableSvg = sdk.getComponent('elements.TintableSvg'); @@ -902,9 +889,9 @@ export default createReactClass({ className={roomDetailListClassName} /> }
; - }, + } - _getFeaturedRoomsNode: function() { + _getFeaturedRoomsNode() { const summary = this.state.summary; const defaultCategoryRooms = []; @@ -943,9 +930,9 @@ export default createReactClass({ { defaultCategoryNode } { categoryRoomNodes }
; - }, + } - _getFeaturedUsersNode: function() { + _getFeaturedUsersNode() { const summary = this.state.summary; const noRoleUsers = []; @@ -984,9 +971,9 @@ export default createReactClass({ { noRoleNode } { roleUserNodes }
; - }, + } - _getMembershipSection: function() { + _getMembershipSection() { const Spinner = sdk.getComponent("elements.Spinner"); const BaseAvatar = sdk.getComponent("avatars.BaseAvatar"); @@ -1100,9 +1087,9 @@ export default createReactClass({ ; - }, + } - _getJoinableNode: function() { + _getJoinableNode() { const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); return this.state.editing ?

@@ -1136,9 +1123,9 @@ export default createReactClass({

: null; - }, + } - _getLongDescriptionNode: function() { + _getLongDescriptionNode() { const summary = this.state.summary; let description = null; if (summary.profile && summary.profile.long_description) { @@ -1175,9 +1162,9 @@ export default createReactClass({
{ description }
; - }, + } - render: function() { + render() { const GroupAvatar = sdk.getComponent("avatars.GroupAvatar"); const Spinner = sdk.getComponent("elements.Spinner"); @@ -1335,7 +1322,7 @@ export default createReactClass({ - + { this._getMembershipSection() } { this._getGroupSection() } @@ -1366,5 +1353,5 @@ export default createReactClass({ console.error("Invalid state for GroupView"); return
; } - }, -}); + } +} diff --git a/src/components/structures/InteractiveAuth.js b/src/components/structures/InteractiveAuth.js index fa7860ccef..c8fcd7e9ca 100644 --- a/src/components/structures/InteractiveAuth.js +++ b/src/components/structures/InteractiveAuth.js @@ -17,7 +17,6 @@ limitations under the License. import {InteractiveAuth} from "matrix-js-sdk"; import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import getEntryComponentForLoginType from '../views/auth/InteractiveAuthEntryComponents'; @@ -26,10 +25,8 @@ import * as sdk from '../../index'; export const ERROR_USER_CANCELLED = new Error("User cancelled auth session"); -export default createReactClass({ - displayName: 'InteractiveAuth', - - propTypes: { +export default class InteractiveAuthComponent extends React.Component { + static propTypes = { // matrix client to use for UI auth requests matrixClient: PropTypes.object.isRequired, @@ -86,20 +83,19 @@ export default createReactClass({ // continueText and continueKind are passed straight through to the AuthEntryComponent. continueText: PropTypes.string, continueKind: PropTypes.string, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { authStage: null, busy: false, errorText: null, stageErrorText: null, submitButtonEnabled: false, }; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._unmounted = false; this._authLogic = new InteractiveAuth({ authData: this.props.authData, @@ -114,6 +110,18 @@ export default createReactClass({ requestEmailToken: this._requestEmailToken, }); + this._intervalId = null; + if (this.props.poll) { + this._intervalId = setInterval(() => { + this._authLogic.poll(); + }, 2000); + } + + this._stageComponent = createRef(); + } + + // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs + UNSAFE_componentWillMount() { // eslint-disable-line camelcase this._authLogic.attemptAuth().then((result) => { const extra = { emailSid: this._authLogic.getEmailSid(), @@ -132,26 +140,17 @@ export default createReactClass({ errorText: msg, }); }); + } - this._intervalId = null; - if (this.props.poll) { - this._intervalId = setInterval(() => { - this._authLogic.poll(); - }, 2000); - } - - this._stageComponent = createRef(); - }, - - componentWillUnmount: function() { + componentWillUnmount() { this._unmounted = true; if (this._intervalId !== null) { clearInterval(this._intervalId); } - }, + } - _requestEmailToken: async function(...args) { + _requestEmailToken = async (...args) => { this.setState({ busy: true, }); @@ -162,15 +161,15 @@ export default createReactClass({ busy: false, }); } - }, + }; - tryContinue: function() { + tryContinue = () => { if (this._stageComponent.current && this._stageComponent.current.tryContinue) { this._stageComponent.current.tryContinue(); } - }, + }; - _authStateUpdated: function(stageType, stageState) { + _authStateUpdated = (stageType, stageState) => { const oldStage = this.state.authStage; this.setState({ busy: false, @@ -180,16 +179,16 @@ export default createReactClass({ }, () => { if (oldStage != stageType) this._setFocus(); }); - }, + }; - _requestCallback: function(auth) { + _requestCallback = (auth) => { // This wrapper just exists because the js-sdk passes a second // 'busy' param for backwards compat. This throws the tests off // so discard it here. return this.props.makeRequest(auth); - }, + }; - _onBusyChanged: function(busy) { + _onBusyChanged = (busy) => { // if we've started doing stuff, reset the error messages if (busy) { this.setState({ @@ -204,29 +203,29 @@ export default createReactClass({ // there's a new screen to show the user. This is implemented by setting // `busy: false` in `_authStateUpdated`. // See also https://github.com/vector-im/element-web/issues/12546 - }, + }; - _setFocus: function() { + _setFocus() { if (this._stageComponent.current && this._stageComponent.current.focus) { this._stageComponent.current.focus(); } - }, + } - _submitAuthDict: function(authData) { + _submitAuthDict = authData => { this._authLogic.submitAuthDict(authData); - }, + }; - _onPhaseChange: function(newPhase) { + _onPhaseChange = newPhase => { if (this.props.onStagePhaseChange) { this.props.onStagePhaseChange(this.state.authStage, newPhase || 0); } - }, + }; - _onStageCancel: function() { + _onStageCancel = () => { this.props.onAuthFinished(false, ERROR_USER_CANCELLED); - }, + }; - _renderCurrentStage: function() { + _renderCurrentStage() { const stage = this.state.authStage; if (!stage) { if (this.state.busy) { @@ -260,16 +259,17 @@ export default createReactClass({ onCancel={this._onStageCancel} /> ); - }, + } - _onAuthStageFailed: function(e) { + _onAuthStageFailed = e => { this.props.onAuthFinished(false, e); - }, - _setEmailSid: function(sid) { - this._authLogic.setEmailSid(sid); - }, + }; - render: function() { + _setEmailSid = sid => { + this._authLogic.setEmailSid(sid); + }; + + render() { let error = null; if (this.state.errorText) { error = ( @@ -287,5 +287,5 @@ export default createReactClass({
); - }, -}); + } +} diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 899dfe222d..090a64904c 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -52,7 +52,7 @@ interface IState { // List of CSS classes which should be included in keyboard navigation within the room list const cssClasses = [ "mx_RoomSearch_input", - "mx_RoomSearch_icon", // minimized + "mx_RoomSearch_minimizedHandle", // minimized "mx_RoomSublist_headerText", "mx_RoomTile", "mx_RoomSublist_showNButton", @@ -377,7 +377,7 @@ export default class LeftPanel extends React.Component { public render(): React.ReactNode { const tagPanel = !this.state.showTagPanel ? null : (
- + {SettingsStore.getValue("feature_custom_tags") ? : null}
); diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index d7f2c73a0b..81b8da2cad 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -43,11 +43,11 @@ import PlatformPeg from "../../PlatformPeg"; import { DefaultTagID } from "../../stores/room-list/models"; import { showToast as showSetPasswordToast, - hideToast as hideSetPasswordToast + hideToast as hideSetPasswordToast, } from "../../toasts/SetPasswordToast"; import { showToast as showServerLimitToast, - hideToast as hideServerLimitToast + hideToast as hideServerLimitToast, } from "../../toasts/ServerLimitToast"; import { Action } from "../../dispatcher/actions"; import LeftPanel from "./LeftPanel"; @@ -56,6 +56,7 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; +import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; // 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. @@ -76,12 +77,12 @@ interface IProps { hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; middleDisabled: boolean; - initialEventPixelOffset: number; leftDisabled: boolean; rightDisabled: boolean; + // eslint-disable-next-line camelcase page_type: string; autoJoin: boolean; - thirdPartyInvite?: object; + threepidInvite?: IThreepidInvite; roomOobData?: object; currentRoomId: string; ConferenceHandler?: object; @@ -98,7 +99,9 @@ interface IProps { } interface IUsageLimit { + // eslint-disable-next-line camelcase limit_type: "monthly_active_user" | string; + // eslint-disable-next-line camelcase admin_contact?: string; } @@ -254,6 +257,12 @@ class LoggedInView extends React.Component { window.localStorage.setItem("mx_lhs_size", '' + size); this.props.resizeNotifier.notifyLeftHandleResized(); }, + onResizeStart: () => { + this.props.resizeNotifier.startResizing(); + }, + onResizeStop: () => { + this.props.resizeNotifier.stopResizing(); + }, }; const resizer = new Resizer( this._resizeContainer.current, @@ -316,10 +325,10 @@ class LoggedInView extends React.Component { } }; - _calculateServerLimitToast(syncErrorData: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { - const error = syncErrorData && syncErrorData.error && syncErrorData.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; + _calculateServerLimitToast(syncError: IState["syncErrorData"], usageLimitEventContent?: IUsageLimit) { + const error = syncError && syncError.error && syncError.error.errcode === "M_RESOURCE_LIMIT_EXCEEDED"; if (error) { - usageLimitEventContent = syncErrorData.error.data; + usageLimitEventContent = syncError.error.data; } if (usageLimitEventContent) { @@ -620,18 +629,17 @@ class LoggedInView extends React.Component { switch (this.props.page_type) { case PageTypes.RoomView: pageElement = ; + ref={this._roomView} + autoJoin={this.props.autoJoin} + onRegistered={this.props.onRegistered} + threepidInvite={this.props.threepidInvite} + oobData={this.props.roomOobData} + viaServers={this.props.viaServers} + key={this.props.currentRoomId || 'roomview'} + disabled={this.props.middleDisabled} + ConferenceHandler={this.props.ConferenceHandler} + resizeNotifier={this.props.resizeNotifier} + />; break; case PageTypes.MyGroups: @@ -647,12 +655,13 @@ class LoggedInView extends React.Component { break; case PageTypes.UserView: - pageElement = ; + pageElement = ; break; case PageTypes.GroupView: pageElement = ; break; } diff --git a/src/components/structures/MainSplit.js b/src/components/structures/MainSplit.js index 800ed76bb9..47dfe83ad6 100644 --- a/src/components/structures/MainSplit.js +++ b/src/components/structures/MainSplit.js @@ -19,9 +19,18 @@ import React from 'react'; import { Resizable } from 're-resizable'; export default class MainSplit extends React.Component { - _onResized = (event, direction, refToElement, delta) => { + _onResizeStart = () => { + this.props.resizeNotifier.startResizing(); + }; + + _onResize = () => { + this.props.resizeNotifier.notifyRightHandleResized(); + }; + + _onResizeStop = (event, direction, refToElement, delta) => { + this.props.resizeNotifier.stopResizing(); window.localStorage.setItem("mx_rhs_size", this._loadSidePanelSize().width + delta.width); - } + }; _loadSidePanelSize() { let rhsSize = parseInt(window.localStorage.getItem("mx_rhs_size"), 10); @@ -58,7 +67,9 @@ export default class MainSplit extends React.Component { bottomLeft: false, topLeft: false, }} - onResizeStop={this._onResized} + onResizeStart={this._onResizeStart} + onResize={this._onResize} + onResizeStop={this._onResizeStop} className="mx_RightPanel_ResizeWrapper" handleClasses={{left: "mx_RightPanel_ResizeHandle"}} > diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index ce96847d28..ea1f424af6 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -69,13 +69,17 @@ import { ViewUserPayload } from "../../dispatcher/payloads/ViewUserPayload"; import { Action } from "../../dispatcher/actions"; import { showToast as showAnalyticsToast, - hideToast as hideAnalyticsToast + hideToast as hideAnalyticsToast, } from "../../toasts/AnalyticsToast"; import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast"; import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import ErrorDialog from "../views/dialogs/ErrorDialog"; import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotificationStateStore"; import { SettingLevel } from "../../settings/SettingLevel"; +import { leaveRoomBehaviour } from "../../utils/membership"; +import CreateCommunityPrototypeDialog from "../views/dialogs/CreateCommunityPrototypeDialog"; +import ThreepidInviteStore, { IThreepidInvite, IThreepidInviteWireFormat } from "../../stores/ThreepidInviteStore"; +import {UIFeature} from "../../settings/UIFeature"; /** constants for MatrixChat.state.view */ export enum Views { @@ -127,6 +131,7 @@ interface IScreen { params?: object; } +/* eslint-disable camelcase */ interface IRoomInfo { room_id?: string; room_alias?: string; @@ -134,16 +139,17 @@ interface IRoomInfo { auto_join?: boolean; highlighted?: boolean; - third_party_invite?: object; oob_data?: object; via_servers?: string[]; + threepid_invite?: IThreepidInvite; } +/* eslint-enable camelcase */ interface IProps { // TODO type things better config: Record; serverConfig?: ValidatedServerConfig; ConferenceHandler?: any; - onNewScreen: (string) => void; + onNewScreen: (screen: string, replaceLast: boolean) => void; enableGuest?: boolean; // the queryParams extracted from the [real] query-string of the URI realQueryParams?: Record; @@ -163,6 +169,7 @@ interface IState { // the master view we are showing. view: Views; // What the LoggedInView would be showing if visible + // eslint-disable-next-line camelcase page_type?: PageTypes; // The ID of the room we're viewing. This is either populated directly // in the case where we view a room by ID or by RoomView when it resolves @@ -178,8 +185,11 @@ interface IState { middleDisabled: boolean; // the right panel's disabled state is tracked in its store. // Parameters used in the registration dance with the IS + // eslint-disable-next-line camelcase register_client_secret?: string; + // eslint-disable-next-line camelcase register_session_id?: string; + // eslint-disable-next-line camelcase register_id_sid?: string; // When showing Modal dialogs we need to set aria-hidden on the root app element // and disable it when there are no dialogs @@ -188,7 +198,7 @@ interface IState { resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; - thirdPartyInvite?: object; + threepidInvite?: IThreepidInvite, roomOobData?: object; viaServers?: string[]; pendingInitialSync?: boolean; @@ -252,6 +262,14 @@ export default class MatrixChat extends React.PureComponent { // outside this.state because updating it should never trigger a // rerender. this.screenAfterLogin = this.props.initialScreenAfterLogin; + if (this.screenAfterLogin) { + const params = this.screenAfterLogin.params || {}; + if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) { + // probably a threepid invite - try to store it + const roomId = this.screenAfterLogin.screen.substring("room/".length); + ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat); + } + } this.windowWidth = 10000; this.handleResize(); @@ -339,6 +357,7 @@ export default class MatrixChat extends React.PureComponent { } // TODO: [REACT-WARNING] Replace with appropriate lifecycle stage + // eslint-disable-next-line camelcase UNSAFE_componentWillUpdate(props, state) { if (this.shouldTrackPageChange(this.state, state)) { this.startPageChangeTimer(); @@ -395,8 +414,12 @@ export default class MatrixChat extends React.PureComponent { }); }).then((loadedSession) => { if (!loadedSession) { - // fall back to showing the welcome screen - dis.dispatch({action: "view_welcome_page"}); + // fall back to showing the welcome screen... unless we have a 3pid invite pending + if (ThreepidInviteStore.instance.pickBestInvite()) { + dis.dispatch({action: 'start_registration'}); + } else { + dis.dispatch({action: "view_welcome_page"}); + } } }); // Note we don't catch errors from this: we catch everything within @@ -608,8 +631,7 @@ export default class MatrixChat extends React.PureComponent { const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {initialTabId: tabPayload.initialTabId}, - /*className=*/null, /*isPriority=*/false, /*isStatic=*/true - ); + /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); // View the welcome or home page if we need something to look at this.viewSomethingBehindModal(); @@ -619,7 +641,10 @@ export default class MatrixChat extends React.PureComponent { this.createRoom(payload.public); break; case 'view_create_group': { - const CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog"); + let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog") + if (SettingsStore.getValue("feature_communities_v2_prototypes")) { + CreateGroupDialog = CreateCommunityPrototypeDialog; + } Modal.createTrackedDialog('Create Community', '', CreateGroupDialog); break; } @@ -824,10 +849,8 @@ export default class MatrixChat extends React.PureComponent { // context of that particular event. // @param {boolean=} roomInfo.highlighted If true, add event_id to the hash of the URL // and alter the EventTile to appear highlighted. - // @param {Object=} roomInfo.third_party_invite Object containing data about the third party - // we received to join the room, if any. - // @param {string=} roomInfo.third_party_invite.inviteSignUrl 3pid invite sign URL - // @param {string=} roomInfo.third_party_invite.invitedEmail The email address the invite was sent to + // @param {Object=} roomInfo.threepid_invite Object containing data about the third party + // we received to join the room, if any. // @param {Object=} roomInfo.oob_data Object of additional data about the room // that has been passed out-of-band (eg. // room name and avatar from an invite email) @@ -875,6 +898,9 @@ export default class MatrixChat extends React.PureComponent { } } + // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item + const replaceLast = presentedId[0] === "#" && roomInfo.room_id === this.state.currentRoomId; + if (roomInfo.event_id && roomInfo.highlighted) { presentedId += "/" + roomInfo.event_id; } @@ -882,12 +908,12 @@ export default class MatrixChat extends React.PureComponent { view: Views.LOGGED_IN, currentRoomId: roomInfo.room_id || null, page_type: PageTypes.RoomView, - thirdPartyInvite: roomInfo.third_party_invite, + threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, viaServers: roomInfo.via_servers, ready: true, }, () => { - this.notifyNewScreen('room/' + presentedId); + this.notifyNewScreen('room/' + presentedId, replaceLast); }); }); } @@ -1075,57 +1101,20 @@ export default class MatrixChat extends React.PureComponent { title: _t("Leave room"), description: ( - { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } + { _t("Are you sure you want to leave the room '%(roomName)s'?", {roomName: roomToLeave.name}) } { warnings } ), button: _t("Leave"), onFinished: (shouldLeave) => { if (shouldLeave) { - const d = MatrixClientPeg.get().leaveRoomChain(roomId); + const d = leaveRoomBehaviour(roomId); // FIXME: controller shouldn't be loading a view :( const Loader = sdk.getComponent("elements.Spinner"); const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); - d.then((errors) => { - modal.close(); - - for (const leftRoomId of Object.keys(errors)) { - const err = errors[leftRoomId]; - if (!err) continue; - - console.error("Failed to leave room " + leftRoomId + " " + err); - let title = _t("Failed to leave room"); - let message = _t("Server may be unavailable, overloaded, or you hit a bug."); - if (err.errcode === 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM') { - title = _t("Can't leave Server Notices room"); - message = _t( - "This room is used for important messages from the Homeserver, " + - "so you cannot leave it.", - ); - } else if (err && err.message) { - message = err.message; - } - Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { - title: title, - description: message, - }); - return; - } - - if (this.state.currentRoomId === roomId) { - dis.dispatch({action: 'view_next_room'}); - } - }, (err) => { - // This should only happen if something went seriously wrong with leaving the chain. - modal.close(); - console.error("Failed to leave room " + roomId + " " + err); - Modal.createTrackedDialog('Failed to leave room', '', ErrorDialog, { - title: _t("Failed to leave room"), - description: _t("Unknown error"), - }); - }); + d.finally(() => modal.close()); } }, }); @@ -1226,6 +1215,14 @@ export default class MatrixChat extends React.PureComponent { // the homepage. dis.dispatch({action: 'view_home_page'}); } + } else if (ThreepidInviteStore.instance.pickBestInvite()) { + // The user has a 3pid invite pending - show them that + const threepidInvite = ThreepidInviteStore.instance.pickBestInvite(); + + // HACK: This is a pretty brutal way of threading the invite back through + // our systems, but it's the safest we have for now. + const params = ThreepidInviteStore.instance.translateToWireFormat(threepidInvite); + this.showScreen(`room/${threepidInvite.roomId}`, params) } else { // The user has just logged in after registering, // so show the homepage. @@ -1237,8 +1234,8 @@ export default class MatrixChat extends React.PureComponent { StorageManager.tryPersistStorage(); - if (SettingsStore.getValue("showCookieBar") && this.props.config.piwik && navigator.doNotTrack !== "1") { - showAnalyticsToast(this.props.config.piwik && this.props.config.piwik.policyUrl); + if (SettingsStore.getValue("showCookieBar") && Analytics.canEnable()) { + showAnalyticsToast(this.props.config.piwik?.policyUrl); } } @@ -1367,7 +1364,7 @@ export default class MatrixChat extends React.PureComponent { this.firstSyncComplete = true; this.firstSyncPromise.resolve(); - if (Notifier.shouldShowToolbar()) { + if (Notifier.shouldShowPrompt()) { showNotificationsToast(); } @@ -1376,15 +1373,19 @@ export default class MatrixChat extends React.PureComponent { ready: true, }); }); - cli.on('Call.incoming', function(call) { - // we dispatch this synchronously to make sure that the event - // handlers on the call are set up immediately (so that if - // we get an immediate hangup, we don't get a stuck call) - dis.dispatch({ - action: 'incoming_call', - call: call, - }, true); - }); + + if (SettingsStore.getValue(UIFeature.Voip)) { + cli.on('Call.incoming', function(call) { + // we dispatch this synchronously to make sure that the event + // handlers on the call are set up immediately (so that if + // we get an immediate hangup, we don't get a stuck call) + dis.dispatch({ + action: 'incoming_call', + call: call, + }, true); + }); + } + cli.on('Session.logged_out', function(errObj) { if (Lifecycle.isLoggingOut()) return; @@ -1465,7 +1466,6 @@ export default class MatrixChat extends React.PureComponent { cli.on("crypto.warning", (type) => { switch (type) { case 'CRYPTO_WARNING_OLD_VERSION_DETECTED': - const brand = SdkConfig.get().brand; Modal.createTrackedDialog('Crypto migrated', '', ErrorDialog, { title: _t('Old cryptography data detected'), description: _t( @@ -1476,7 +1476,7 @@ export default class MatrixChat extends React.PureComponent { "in this version. This may also cause messages exchanged with this " + "version to fail. If you experience problems, log out and back in " + "again. To retain message history, export and re-import your keys.", - { brand }, + { brand: SdkConfig.get().brand }, ), }); break; @@ -1501,12 +1501,12 @@ export default class MatrixChat extends React.PureComponent { if (haveNewVersion) { Modal.createTrackedDialogAsync('New Recovery Method', 'New Recovery Method', - import('../../async-components/views/dialogs/keybackup/NewRecoveryMethodDialog'), + import('../../async-components/views/dialogs/security/NewRecoveryMethodDialog'), { newVersionInfo }, ); } else { Modal.createTrackedDialogAsync('Recovery Method Removed', 'Recovery Method Removed', - import('../../async-components/views/dialogs/keybackup/RecoveryMethodRemovedDialog'), + import('../../async-components/views/dialogs/security/RecoveryMethodRemovedDialog'), ); } }); @@ -1663,16 +1663,11 @@ export default class MatrixChat extends React.PureComponent { // TODO: Handle encoded room/event IDs: https://github.com/vector-im/element-web/issues/9149 - // FIXME: sort_out caseConsistency - const thirdPartyInvite = { - inviteSignUrl: params.signurl, - invitedEmail: params.email, - }; - const oobData = { - name: params.room_name, - avatarUrl: params.room_avatar_url, - inviterName: params.inviter_name, - }; + let threepidInvite: IThreepidInvite; + if (params.signurl && params.email) { + threepidInvite = ThreepidInviteStore.instance + .storeInvite(roomString, params as IThreepidInviteWireFormat); + } // on our URLs there might be a ?via=matrix.org or similar to help // joins to the room succeed. We'll pass these through as an array @@ -1693,8 +1688,15 @@ export default class MatrixChat extends React.PureComponent { // it as highlighted, which will propagate to RoomView and highlight the // associated EventTile. highlighted: Boolean(eventId), - third_party_invite: thirdPartyInvite, - oob_data: oobData, + threepid_invite: threepidInvite, + // TODO: Replace oob_data with the threepidInvite (which has the same info). + // This isn't done yet because it's threaded through so many more places. + // See https://github.com/vector-im/element-web/issues/15157 + oob_data: { + name: threepidInvite?.roomName, + avatarUrl: threepidInvite?.roomAvatarUrl, + inviterName: threepidInvite?.inviterName, + }, room_alias: undefined, room_id: undefined, }; @@ -1726,9 +1728,9 @@ export default class MatrixChat extends React.PureComponent { } } - notifyNewScreen(screen: string) { + notifyNewScreen(screen: string, replaceLast = false) { if (this.props.onNewScreen) { - this.props.onNewScreen(screen); + this.props.onNewScreen(screen, replaceLast); } this.setPageSubtitle(); } @@ -1879,6 +1881,13 @@ export default class MatrixChat extends React.PureComponent { return this.props.makeRegistrationUrl(params); }; + /** + * After registration or login, we run various post-auth steps before entering the app + * proper, such setting up cross-signing or verifying the new session. + * + * Note: SSO users (and any others using token login) currently do not pass through + * this, as they instead jump straight into the app after `attemptTokenLogin`. + */ onUserCompletedLoginFlow = async (credentials: object, password: string) => { this.accountPassword = password; // self-destruct the password after 5mins @@ -1945,7 +1954,7 @@ export default class MatrixChat extends React.PureComponent { render() { const fragmentAfterLogin = this.getFragmentAfterLogin(); - let view; + let view = null; if (this.state.view === Views.LOADING) { const Spinner = sdk.getComponent('elements.Spinner'); @@ -2024,14 +2033,15 @@ export default class MatrixChat extends React.PureComponent { } else if (this.state.view === Views.WELCOME) { const Welcome = sdk.getComponent('auth.Welcome'); view = ; - } else if (this.state.view === Views.REGISTER) { + } else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) { const Registration = sdk.getComponent('structures.auth.Registration'); + const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; view = ( { {...this.getServerProperties()} /> ); - } else if (this.state.view === Views.FORGOT_PASSWORD) { + } else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) { const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword'); view = ( { /> ); } else if (this.state.view === Views.LOGIN) { + const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset); const Login = sdk.getComponent('structures.auth.Login'); view = ( { onRegisterClick={this.onRegisterClick} fallbackHsUrl={this.getFallbackHsUrl()} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} - onForgotPasswordClick={this.onForgotPasswordClick} + onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onServerConfigChange={this.onServerConfigChange} fragmentAfterLogin={fragmentAfterLogin} {...this.getServerProperties()} @@ -2085,3 +2096,12 @@ export default class MatrixChat extends React.PureComponent { ; } } + +export function isLoggedIn(): boolean { + // JRS: Maybe we should move the step that writes this to the window out of + // `element-web` and into this file? Better yet, we should probably create a + // store to hold this state. + // See also https://github.com/vector-im/element-web/issues/15034. + const app = window.matrixChat; + return app && (app as MatrixChat).state.view === Views.LOGGED_IN; +} diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 230d136e04..e2e3592536 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -135,6 +135,9 @@ export default class MessagePanel extends React.Component { // whether to use the irc layout useIRCLayout: PropTypes.bool, + + // whether or not to show flair at all + enableFlair: PropTypes.bool, }; // Force props to be loaded for useIRCLayout @@ -515,10 +518,13 @@ export default class MessagePanel extends React.Component { if (!grouper) { const wantTile = this._shouldShowEvent(mxEv); if (wantTile) { + const nextEvent = i < this.props.events.length - 1 + ? this.props.events[i + 1] + : null; // make sure we unpack the array returned by _getTilesForEvent, // otherwise react will auto-generate keys and we will end up // replacing all of the DOM elements every time we paginate. - ret.push(...this._getTilesForEvent(prevEvent, mxEv, last)); + ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent)); prevEvent = mxEv; } @@ -534,7 +540,7 @@ export default class MessagePanel extends React.Component { return ret; } - _getTilesForEvent(prevEvent, mxEv, last) { + _getTilesForEvent(prevEvent, mxEv, last, nextEvent) { const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const EventTile = sdk.getComponent('rooms.EventTile'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); @@ -559,6 +565,11 @@ export default class MessagePanel extends React.Component { ret.push(dateSeparator); } + let willWantDateSeparator = false; + if (nextEvent) { + willWantDateSeparator = this._wantsDateSeparator(mxEv, nextEvent.getDate() || new Date()); + } + // is this a continuation of the previous message? const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv); @@ -579,7 +590,8 @@ export default class MessagePanel extends React.Component { data-scroll-tokens={scrollToken} > - , diff --git a/src/components/structures/MyGroups.js b/src/components/structures/MyGroups.js index 7043c7f38a..e0551eecdb 100644 --- a/src/components/structures/MyGroups.js +++ b/src/components/structures/MyGroups.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import * as sdk from '../../index'; import { _t } from '../../languageHandler'; import SdkConfig from '../../SdkConfig'; @@ -26,29 +25,23 @@ import AccessibleButton from '../views/elements/AccessibleButton'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; -export default createReactClass({ - displayName: 'MyGroups', +export default class MyGroups extends React.Component { + static contextType = MatrixClientContext; - getInitialState: function() { - return { - groups: null, - error: null, - }; - }, + state = { + groups: null, + error: null, + }; - statics: { - contextType: MatrixClientContext, - }, - - componentDidMount: function() { + componentDidMount() { this._fetch(); - }, + } - _onCreateGroupClick: function() { + _onCreateGroupClick = () => { dis.dispatch({action: 'view_create_group'}); - }, + }; - _fetch: function() { + _fetch() { this.context.getJoinedGroups().then((result) => { this.setState({groups: result.groups, error: null}); }, (err) => { @@ -59,9 +52,9 @@ export default createReactClass({ } this.setState({groups: null, error: err}); }); - }, + } - render: function() { + render() { const brand = SdkConfig.get().brand; const Loader = sdk.getComponent("elements.Spinner"); const SimpleRoomHeader = sdk.getComponent('rooms.SimpleRoomHeader'); @@ -149,5 +142,5 @@ export default createReactClass({ { content } ; - }, -}); + } +} diff --git a/src/components/structures/NotificationPanel.js b/src/components/structures/NotificationPanel.js index c1f78cffda..2889afc1fc 100644 --- a/src/components/structures/NotificationPanel.js +++ b/src/components/structures/NotificationPanel.js @@ -17,21 +17,22 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; +import PropTypes from "prop-types"; + import { _t } from '../../languageHandler'; import {MatrixClientPeg} from "../../MatrixClientPeg"; import * as sdk from "../../index"; +import BaseCard from "../views/right_panel/BaseCard"; /* * Component which shows the global notification list using a TimelinePanel */ -const NotificationPanel = createReactClass({ - displayName: 'NotificationPanel', +class NotificationPanel extends React.Component { + static propTypes = { + onClose: PropTypes.func.isRequired, + }; - propTypes: { - }, - - render: function() { + render() { // wrap a TimelinePanel with the jump-to-event bits turned off. const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); const Loader = sdk.getComponent("elements.Spinner"); @@ -41,29 +42,28 @@ const NotificationPanel = createReactClass({

{_t('You have no visible notifications in this room.')}

); + let content; const timelineSet = MatrixClientPeg.get().getNotifTimelineSet(); if (timelineSet) { - return ( -
- -
+ content = ( + ); } else { console.error("No notifTimelineSet available!"); - return ( -
- -
- ); + content = ; } - }, -}); + + return + { content } + ; + } +} export default NotificationPanel; diff --git a/src/components/structures/RightPanel.js b/src/components/structures/RightPanel.js index a4e3254e4c..6c6d8700a5 100644 --- a/src/components/structures/RightPanel.js +++ b/src/components/structures/RightPanel.js @@ -21,6 +21,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; +import {Room} from "matrix-js-sdk/src/models/room"; + import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import RateLimitedFunc from '../../ratelimitedfunc'; @@ -30,11 +32,14 @@ import {RightPanelPhases, RIGHT_PANEL_PHASES_NO_ARGS} from "../../stores/RightPa import RightPanelStore from "../../stores/RightPanelStore"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import {Action} from "../../dispatcher/actions"; +import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; +import WidgetCard from "../views/right_panel/WidgetCard"; +import defaultDispatcher from "../../dispatcher/dispatcher"; export default class RightPanel extends React.Component { static get propTypes() { return { - roomId: PropTypes.string, // if showing panels for a given room, this is set + room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set groupId: PropTypes.string, // if showing panels for a given group, this is set user: PropTypes.object, // used if we know the user ahead of opening the panel }; @@ -42,13 +47,13 @@ export default class RightPanel extends React.Component { static contextType = MatrixClientContext; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.state = { + ...RightPanelStore.getSharedInstance().roomPanelPhaseParams, phase: this._getPhaseFromProps(), isUserPrivilegedInGroup: null, member: this._getUserForPanel(), - verificationRequest: RightPanelStore.getSharedInstance().roomPanelPhaseParams.verificationRequest, }; this.onAction = this.onAction.bind(this); this.onRoomStateMember = this.onRoomStateMember.bind(this); @@ -100,10 +105,6 @@ export default class RightPanel extends React.Component { } return RightPanelPhases.RoomMemberInfo; } else { - if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.roomPanelPhase)) { - dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList}); - return RightPanelPhases.RoomMemberList; - } return rps.roomPanelPhase; } } @@ -161,13 +162,13 @@ export default class RightPanel extends React.Component { } onRoomStateMember(ev, state, member) { - if (member.roomId !== this.props.roomId) { + if (member.roomId !== this.props.room.roomId) { return; } // redraw the badge on the membership list - if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.roomId) { + if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) { this._delayedUpdate(); - } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.roomId && + } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId && member.userId === this.state.member.userId) { // refresh the member info (e.g. new power level) this._delayedUpdate(); @@ -184,6 +185,7 @@ export default class RightPanel extends React.Component { event: payload.event, verificationRequest: payload.verificationRequest, verificationRequestPromise: payload.verificationRequestPromise, + widgetId: payload.widgetId, }); } } @@ -211,6 +213,14 @@ export default class RightPanel extends React.Component { } }; + onClose = () => { + // the RightPanelStore has no way of knowing which mode room/group it is in, so we handle closing here + defaultDispatcher.dispatch({ + action: Action.ToggleRightPanel, + type: this.props.groupId ? "group" : "room", + }); + }; + render() { const MemberList = sdk.getComponent('rooms.MemberList'); const UserInfo = sdk.getComponent('right_panel.UserInfo'); @@ -223,36 +233,42 @@ export default class RightPanel extends React.Component { const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo'); let panel =
; + const roomId = this.props.room ? this.props.room.roomId : undefined; switch (this.state.phase) { case RightPanelPhases.RoomMemberList: - if (this.props.roomId) { - panel = ; + if (roomId) { + panel = ; } break; + case RightPanelPhases.GroupMemberList: if (this.props.groupId) { panel = ; } break; + case RightPanelPhases.GroupRoomList: panel = ; break; + case RightPanelPhases.RoomMemberInfo: case RightPanelPhases.EncryptionPanel: panel = ; break; + case RightPanelPhases.Room3pidMemberInfo: - panel = ; + panel = ; break; + case RightPanelPhases.GroupMemberInfo: panel = ; break; + case RightPanelPhases.GroupRoomInfo: panel = ; break; + case RightPanelPhases.NotificationPanel: - panel = ; + panel = ; break; + case RightPanelPhases.FilePanel: - panel = ; + panel = ; + break; + + case RightPanelPhases.RoomSummary: + panel = ; + break; + + case RightPanelPhases.Widget: + panel = ; break; } diff --git a/src/components/structures/RoomDirectory.js b/src/components/structures/RoomDirectory.js index 5b12dae7df..55c6527f06 100644 --- a/src/components/structures/RoomDirectory.js +++ b/src/components/structures/RoomDirectory.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import {MatrixClientPeg} from "../../MatrixClientPeg"; import * as sdk from "../../index"; import dis from "../../dispatcher/dispatcher"; @@ -30,6 +29,10 @@ import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/Di import Analytics from '../../Analytics'; import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; +import SettingsStore from "../../settings/SettingsStore"; +import TagOrderStore from "../../stores/TagOrderStore"; +import GroupStore from "../../stores/GroupStore"; +import FlairStore from "../../stores/FlairStore"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 160; @@ -38,15 +41,16 @@ function track(action) { Analytics.trackEvent('RoomDirectory', action); } -export default createReactClass({ - displayName: 'RoomDirectory', - - propTypes: { +export default class RoomDirectory extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + const selectedCommunityId = TagOrderStore.getSelectedTags()[0]; + this.state = { publicRooms: [], loading: true, protocolsLoading: true, @@ -54,66 +58,108 @@ export default createReactClass({ instanceId: undefined, roomServer: MatrixClientPeg.getHomeserverName(), filterString: null, + selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes") + ? selectedCommunityId + : null, + communityName: null, }; - }, - // TODO: [REACT-WARNING] Move this to constructor - UNSAFE_componentWillMount: function() { this._unmounted = false; this.nextBatch = null; this.filterTimeout = null; this.scrollPanel = null; this.protocols = null; - this.setState({protocolsLoading: true}); + this.state.protocolsLoading = true; if (!MatrixClientPeg.get()) { // We may not have a client yet when invoked from welcome page - this.setState({protocolsLoading: false}); + this.state.protocolsLoading = false; return; } - MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { - this.protocols = response; - this.setState({protocolsLoading: false}); - }, (err) => { - console.warn(`error loading third party protocols: ${err}`); - this.setState({protocolsLoading: false}); - if (MatrixClientPeg.get().isGuest()) { - // Guests currently aren't allowed to use this API, so - // ignore this as otherwise this error is literally the - // thing you see when loading the client! - return; - } - track('Failed to get protocol list from homeserver'); - const brand = SdkConfig.get().brand; - this.setState({ - error: _t( - '%(brand)s failed to get the protocol list from the homeserver. ' + - 'The homeserver may be too old to support third party networks.', - { brand }, - ), + + if (!this.state.selectedCommunityId) { + MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { + this.protocols = response; + this.setState({protocolsLoading: false}); + }, (err) => { + console.warn(`error loading third party protocols: ${err}`); + this.setState({protocolsLoading: false}); + if (MatrixClientPeg.get().isGuest()) { + // Guests currently aren't allowed to use this API, so + // ignore this as otherwise this error is literally the + // thing you see when loading the client! + return; + } + track('Failed to get protocol list from homeserver'); + const brand = SdkConfig.get().brand; + this.setState({ + error: _t( + '%(brand)s failed to get the protocol list from the homeserver. ' + + 'The homeserver may be too old to support third party networks.', + {brand}, + ), + }); }); - }); + } else { + // We don't use the protocols in the communities v2 prototype experience + this.state.protocolsLoading = false; + // Grab the profile info async + FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => { + this.setState({communityName: profile.name}); + }); + } + } + + componentDidMount() { this.refreshRoomList(); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { if (this.filterTimeout) { clearTimeout(this.filterTimeout); } this._unmounted = true; - }, + } + + refreshRoomList = () => { + if (this.state.selectedCommunityId) { + this.setState({ + publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => { + return { + // Translate all the group properties to the directory format + room_id: r.roomId, + name: r.name, + topic: r.topic, + canonical_alias: r.canonicalAlias, + num_joined_members: r.numJoinedMembers, + avatarUrl: r.avatarUrl, + world_readable: r.worldReadable, + guest_can_join: r.guestsCanJoin, + }; + }).filter(r => { + const filterString = this.state.filterString; + if (filterString) { + const containedIn = (s: string) => (s || "").toLowerCase().includes(filterString.toLowerCase()); + return containedIn(r.name) || containedIn(r.topic) || containedIn(r.canonical_alias); + } + return true; + }), + loading: false, + }); + return; + } - refreshRoomList: function() { this.nextBatch = null; this.setState({ publicRooms: [], loading: true, }); this.getMoreRooms(); - }, + }; - getMoreRooms: function() { + getMoreRooms() { + if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms if (!MatrixClientPeg.get()) return Promise.resolve(); this.setState({ @@ -185,7 +231,7 @@ export default createReactClass({ ), }); }); - }, + } /** * A limited interface for removing rooms from the directory. @@ -194,7 +240,7 @@ export default createReactClass({ * HS admins to do this through the RoomSettings interface, but * this needs SPEC-417. */ - removeFromDirectory: function(room) { + removeFromDirectory(room) { const alias = get_display_alias_for_room(room); const name = room.name || alias || _t('Unnamed room'); @@ -236,18 +282,18 @@ export default createReactClass({ }); }, }); - }, + } - onRoomClicked: function(room, ev) { - if (ev.shiftKey) { + onRoomClicked = (room, ev) => { + if (ev.shiftKey && !this.state.selectedCommunityId) { ev.preventDefault(); this.removeFromDirectory(room); } else { this.showRoom(room); } - }, + }; - onOptionChange: function(server, instanceId) { + onOptionChange = (server, instanceId) => { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -265,15 +311,15 @@ export default createReactClass({ // find the five gitter ones, at which point we do not want // to render all those rooms when switching back to 'all networks'. // Easiest to just blow away the state & re-fetch. - }, + }; - onFillRequest: function(backwards) { + onFillRequest = (backwards) => { if (backwards || !this.nextBatch) return Promise.resolve(false); return this.getMoreRooms(); - }, + }; - onFilterChange: function(alias) { + onFilterChange = (alias) => { this.setState({ filterString: alias || null, }); @@ -289,9 +335,9 @@ export default createReactClass({ this.filterTimeout = null; this.refreshRoomList(); }, 700); - }, + }; - onFilterClear: function() { + onFilterClear = () => { // update immediately this.setState({ filterString: null, @@ -300,9 +346,9 @@ export default createReactClass({ if (this.filterTimeout) { clearTimeout(this.filterTimeout); } - }, + }; - onJoinFromSearchClick: function(alias) { + onJoinFromSearchClick = (alias) => { // If we don't have a particular instance id selected, just show that rooms alias if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { // If the user specified an alias without a domain, add on whichever server is selected @@ -343,50 +389,41 @@ export default createReactClass({ }); }); } - }, + }; - onPreviewClick: function(ev, room) { - this.props.onFinished(); - dis.dispatch({ - action: 'view_room', - room_id: room.room_id, - should_peek: true, - }); + onPreviewClick = (ev, room) => { + this.showRoom(room, null, false, true); ev.stopPropagation(); - }, + }; - onViewClick: function(ev, room) { - this.props.onFinished(); - dis.dispatch({ - action: 'view_room', - room_id: room.room_id, - should_peek: false, - }); + onViewClick = (ev, room) => { + this.showRoom(room); ev.stopPropagation(); - }, + }; - onJoinClick: function(ev, room) { + onJoinClick = (ev, room) => { this.showRoom(room, null, true); ev.stopPropagation(); - }, + }; - onCreateRoomClick: function(room) { + onCreateRoomClick = room => { this.props.onFinished(); dis.dispatch({ action: 'view_create_room', public: true, }); - }, + }; - showRoomAlias: function(alias, autoJoin=false) { + showRoomAlias(alias, autoJoin=false) { this.showRoom(null, alias, autoJoin); - }, + } - showRoom: function(room, room_alias, autoJoin=false) { + showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { this.props.onFinished(); const payload = { action: 'view_room', auto_join: autoJoin, + should_peek: shouldPeek, }; if (room) { // Don't let the user view a room they won't be able to either @@ -411,6 +448,7 @@ export default createReactClass({ }; if (this.state.roomServer) { + payload.via_servers = [this.state.roomServer]; payload.opts = { viaServers: [this.state.roomServer], }; @@ -426,7 +464,7 @@ export default createReactClass({ payload.room_id = room.room_id; } dis.dispatch(payload); - }, + } getRow(room) { const client = MatrixClientPeg.get(); @@ -492,22 +530,22 @@ export default createReactClass({ {joinOrViewButton} ); - }, + } - collectScrollPanel: function(element) { + collectScrollPanel = (element) => { this.scrollPanel = element; - }, + }; - _stringLooksLikeId: function(s, field_type) { + _stringLooksLikeId(s, field_type) { let pat = /^#[^\s]+:[^\s]/; if (field_type && field_type.regexp) { pat = new RegExp(field_type.regexp); } return pat.test(s); - }, + } - _getFieldsForThirdPartyLocation: function(userInput, protocol, instance) { + _getFieldsForThirdPartyLocation(userInput, protocol, instance) { // make an object with the fields specified by that protocol. We // require that the values of all but the last field come from the // instance. The last is the user input. @@ -521,20 +559,20 @@ export default createReactClass({ } fields[requiredFields[requiredFields.length - 1]] = userInput; return fields; - }, + } /** * called by the parent component when PageUp/Down/etc is pressed. * * We pass it down to the scroll panel. */ - handleScrollKey: function(ev) { + handleScrollKey = ev => { if (this.scrollPanel) { this.scrollPanel.handleScrollKey(ev); } - }, + }; - render: function() { + render() { const Loader = sdk.getComponent("elements.Spinner"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -610,6 +648,18 @@ export default createReactClass({ } } + let dropdown = ( + + ); + if (this.state.selectedCommunityId) { + dropdown = null; + } + listHeader =
- + {dropdown}
; } const explanation = @@ -637,12 +682,16 @@ export default createReactClass({ }}, ); + const title = this.state.selectedCommunityId + ? _t("Explore rooms in %(communityName)s", { + communityName: this.state.communityName || this.state.selectedCommunityId, + }) : _t("Explore rooms"); return (
{explanation} @@ -653,8 +702,8 @@ export default createReactClass({
); - }, -}); + } +} // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // but works with the objects we get from the public room list diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index f6b8d42c30..526aecddd7 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -20,7 +20,6 @@ import classNames from "classnames"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import { ActionPayload } from "../../dispatcher/payloads"; -import { throttle } from 'lodash'; import { Key } from "../../Keyboard"; import AccessibleButton from "../views/elements/AccessibleButton"; import { Action } from "../../dispatcher/actions"; @@ -137,7 +136,7 @@ export default class RoomSearch extends React.PureComponent { }); let icon = ( -
+
); let input = ( { icon = ( ); diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 3171dbccbe..cdaa0bb7f9 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import Matrix from 'matrix-js-sdk'; import { _t, _td } from '../../languageHandler'; @@ -39,10 +38,8 @@ function getUnsentMessages(room) { }); } -export default createReactClass({ - displayName: 'RoomStatusBar', - - propTypes: { +export default class RoomStatusBar extends React.Component { + static propTypes = { // the room this statusbar is representing. room: PropTypes.object.isRequired, // This is true when the user is alone in the room, but has also sent a message. @@ -86,37 +83,35 @@ export default createReactClass({ // callback for when the status bar is displaying something and should // be visible onVisible: PropTypes.func, - }, + }; - getInitialState: function() { - return { - syncState: MatrixClientPeg.get().getSyncState(), - syncStateData: MatrixClientPeg.get().getSyncStateData(), - unsentMessages: getUnsentMessages(this.props.room), - }; - }, + state = { + syncState: MatrixClientPeg.get().getSyncState(), + syncStateData: MatrixClientPeg.get().getSyncStateData(), + unsentMessages: getUnsentMessages(this.props.room), + }; - componentDidMount: function() { + componentDidMount() { MatrixClientPeg.get().on("sync", this.onSyncStateChange); MatrixClientPeg.get().on("Room.localEchoUpdated", this._onRoomLocalEchoUpdated); this._checkSize(); - }, + } - componentDidUpdate: function() { + componentDidUpdate() { this._checkSize(); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { // we may have entirely lost our client as we're logging out before clicking login on the guest bar... const client = MatrixClientPeg.get(); if (client) { client.removeListener("sync", this.onSyncStateChange); client.removeListener("Room.localEchoUpdated", this._onRoomLocalEchoUpdated); } - }, + } - onSyncStateChange: function(state, prevState, data) { + onSyncStateChange = (state, prevState, data) => { if (state === "SYNCING" && prevState === "SYNCING") { return; } @@ -124,39 +119,39 @@ export default createReactClass({ syncState: state, syncStateData: data, }); - }, + }; - _onResendAllClick: function() { + _onResendAllClick = () => { Resend.resendUnsentEvents(this.props.room); dis.fire(Action.FocusComposer); - }, + }; - _onCancelAllClick: function() { + _onCancelAllClick = () => { Resend.cancelUnsentEvents(this.props.room); dis.fire(Action.FocusComposer); - }, + }; - _onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) { + _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { if (room.roomId !== this.props.room.roomId) return; this.setState({ unsentMessages: getUnsentMessages(this.props.room), }); - }, + }; // Check whether current size is greater than 0, if yes call props.onVisible - _checkSize: function() { + _checkSize() { if (this._getSize()) { if (this.props.onVisible) this.props.onVisible(); } else { if (this.props.onHidden) this.props.onHidden(); } - }, + } // We don't need the actual height - just whether it is likely to have // changed - so we use '0' to indicate normal size, and other values to // indicate other sizes. - _getSize: function() { + _getSize() { if (this._shouldShowConnectionError() || this.props.hasActiveCall || this.props.sentMessageAndIsAlone @@ -166,10 +161,10 @@ export default createReactClass({ return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; - }, + } // return suitable content for the image on the left of the status bar. - _getIndicator: function() { + _getIndicator() { if (this.props.hasActiveCall) { const TintableSvg = sdk.getComponent("elements.TintableSvg"); return ( @@ -182,9 +177,9 @@ export default createReactClass({ } return null; - }, + } - _shouldShowConnectionError: function() { + _shouldShowConnectionError() { // no conn bar trumps the "some not sent" msg since you can't resend without // a connection! // There's one situation in which we don't show this 'no connection' bar, and that's @@ -195,9 +190,9 @@ export default createReactClass({ this.state.syncStateData.error.errcode === 'M_RESOURCE_LIMIT_EXCEEDED', ); return this.state.syncState === "ERROR" && !errorIsMauError; - }, + } - _getUnsentMessageContent: function() { + _getUnsentMessageContent() { const unsentMessages = this.state.unsentMessages; if (!unsentMessages.length) return null; @@ -272,10 +267,10 @@ export default createReactClass({
; - }, + } // return suitable content for the main (text) part of the status bar. - _getContent: function() { + _getContent() { if (this._shouldShowConnectionError()) { return (
@@ -323,9 +318,9 @@ export default createReactClass({ } return null; - }, + } - render: function() { + render() { const content = this._getContent(); const indicator = this._getIndicator(); @@ -339,5 +334,5 @@ export default createReactClass({
); - }, -}); + } +} diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.tsx similarity index 74% rename from src/components/structures/RoomView.js rename to src/components/structures/RoomView.tsx index a79e5b0aa8..4c418e9994 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.tsx @@ -21,28 +21,27 @@ limitations under the License. // - Search results component // - Drag and drop -import shouldHideEvent from '../../shouldHideEvent'; - import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { _t } from '../../languageHandler'; -import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; +import {Room} from "matrix-js-sdk/src/models/room"; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {EventSubscription} from "fbemitter"; +import shouldHideEvent from '../../shouldHideEvent'; +import {_t} from '../../languageHandler'; +import {RoomPermalinkCreator} from '../../utils/permalinks/Permalinks'; +import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; import * as sdk from '../../index'; import CallHandler from '../../CallHandler'; import dis from '../../dispatcher/dispatcher'; import Tinter from '../../Tinter'; -import rate_limited_func from '../../ratelimitedfunc'; +import rateLimitedFunc from '../../ratelimitedfunc'; import * as ObjectUtils from '../../ObjectUtils'; import * as Rooms from '../../Rooms'; import eventSearch, {searchPagination} from '../../Searching'; - import {isOnlyCtrlOrCmdIgnoreShiftKeyEvent, isOnlyCtrlOrCmdKeyEvent, Key} from '../../Keyboard'; - import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; @@ -54,12 +53,28 @@ import RightPanelStore from "../../stores/RightPanelStore"; import {haveTileForEvent} from "../views/rooms/EventTile"; import RoomContext from "../../contexts/RoomContext"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import { shieldStatusForRoom } from '../../utils/ShieldUtils'; +import {E2EStatus, shieldStatusForRoom} from '../../utils/ShieldUtils'; import {Action} from "../../dispatcher/actions"; import {SettingLevel} from "../../settings/SettingLevel"; +import {RightPanelPhases} from "../../stores/RightPanelStorePhases"; +import {IMatrixClientCreds} from "../../MatrixClientPeg"; +import ScrollPanel from "./ScrollPanel"; +import TimelinePanel from "./TimelinePanel"; +import ErrorBoundary from "../views/elements/ErrorBoundary"; +import RoomPreviewBar from "../views/rooms/RoomPreviewBar"; +import ForwardMessage from "../views/rooms/ForwardMessage"; +import SearchBar from "../views/rooms/SearchBar"; +import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; +import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel"; +import AuxPanel from "../views/rooms/AuxPanel"; +import RoomHeader from "../views/rooms/RoomHeader"; +import TintableSvg from "../views/elements/TintableSvg"; +import type * as ConferenceHandler from '../../VectorConferenceHandler'; +import {XOR} from "../../@types/common"; +import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; const DEBUG = false; -let debuglog = function() {}; +let debuglog = function(msg: string) {}; const BROWSER_SUPPORTS_SANDBOX = 'sandbox' in document.createElement('iframe'); @@ -68,64 +83,136 @@ if (DEBUG) { debuglog = console.log.bind(console); } -export default createReactClass({ - displayName: 'RoomView', - propTypes: { - ConferenceHandler: PropTypes.any, +interface IProps { + ConferenceHandler?: ConferenceHandler; - // Called with the credentials of a registered user (if they were a ROU that - // transitioned to PWLU) - onRegistered: PropTypes.func, + threepidInvite: IThreepidInvite, - // An object representing a third party invite to join this room - // Fields: - // * inviteSignUrl (string) The URL used to join this room from an email invite - // (given as part of the link in the invite email) - // * invitedEmail (string) The email address that was invited to this room - thirdPartyInvite: PropTypes.object, + // Any data about the room that would normally come from the homeserver + // but has been passed out-of-band, eg. the room name and avatar URL + // from an email invite (a workaround for the fact that we can't + // get this information from the HS using an email invite). + // Fields: + // * name (string) The room's name + // * avatarUrl (string) The mxc:// avatar URL for the room + // * inviterName (string) The display name of the person who + // * invited us to the room + oobData?: { + name?: string; + avatarUrl?: string; + inviterName?: string; + }; - // Any data about the room that would normally come from the homeserver - // but has been passed out-of-band, eg. the room name and avatar URL - // from an email invite (a workaround for the fact that we can't - // get this information from the HS using an email invite). - // Fields: - // * name (string) The room's name - // * avatarUrl (string) The mxc:// avatar URL for the room - // * inviterName (string) The display name of the person who - // * invited us to the room - oobData: PropTypes.object, + // Servers the RoomView can use to try and assist joins + viaServers?: string[]; - // Servers the RoomView can use to try and assist joins - viaServers: PropTypes.arrayOf(PropTypes.string), - }, + autoJoin?: boolean; + disabled?: boolean; + resizeNotifier: ResizeNotifier; - statics: { - contextType: MatrixClientContext, - }, + // Called with the credentials of a registered user (if they were a ROU that transitioned to PWLU) + onRegistered?(credentials: IMatrixClientCreds): void; +} + +export interface IState { + room?: Room; + roomId?: string; + roomAlias?: string; + roomLoading: boolean; + peekLoading: boolean; + shouldPeek: boolean; + // used to trigger a rerender in TimelinePanel once the members are loaded, + // so RR are rendered again (now with the members available), ... + membersLoaded: boolean; + // The event to be scrolled to initially + initialEventId?: string; + // The offset in pixels from the event with which to scroll vertically + initialEventPixelOffset?: number; + // Whether to highlight the event scrolled to + isInitialEventHighlighted?: boolean; + forwardingEvent?: MatrixEvent; + numUnreadMessages: number; + draggingFile: boolean; + searching: boolean; + searchTerm?: string; + searchScope?: "All" | "Room"; + searchResults?: XOR<{}, { + count: number; + highlights: string[]; + results: MatrixEvent[]; + next_batch: string; // eslint-disable-line camelcase + }>; + searchHighlights?: string[]; + searchInProgress?: boolean; + callState?: string; + guestsCanJoin: boolean; + canPeek: boolean; + showApps: boolean; + isAlone: boolean; + isPeeking: boolean; + showingPinned: boolean; + showReadReceipts: boolean; + showRightPanel: boolean; + // error object, as from the matrix client/server API + // If we failed to load information about the room, + // store the error here. + roomLoadError?: Error; + // Have we sent a request to join the room that we're waiting to complete? + joining: boolean; + // this is true if we are fully scrolled-down, and are looking at + // the end of the live timeline. It has the effect of hiding the + // 'scroll to bottom' knob, among a couple of other things. + atEndOfLiveTimeline: boolean; + // used by componentDidUpdate to avoid unnecessary checks + atEndOfLiveTimelineInit: boolean; + showTopUnreadMessagesBar: boolean; + auxPanelMaxHeight?: number; + statusBarVisible: boolean; + // We load this later by asking the js-sdk to suggest a version for us. + // This object is the result of Room#getRecommendedVersion() + upgradeRecommendation?: { + version: string; + needsUpgrade: boolean; + urgent: boolean; + }; + canReact: boolean; + canReply: boolean; + useIRCLayout: boolean; + matrixClientIsReady: boolean; + showUrlPreview?: boolean; + e2eStatus?: E2EStatus; + displayConfCallNotification?: boolean; + rejecting?: boolean; + rejectError?: Error; +} + +export default class RoomView extends React.Component { + private readonly dispatcherRef: string; + private readonly roomStoreToken: EventSubscription; + private readonly rightPanelStoreToken: EventSubscription; + private readonly showReadReceiptsWatchRef: string; + private readonly layoutWatcherRef: string; + + private unmounted = false; + private permalinkCreators: Record = {}; + private searchId: number; + + private roomView = createRef(); + private searchResultsPanel = createRef(); + private messagePanel: TimelinePanel; + + static contextType = MatrixClientContext; + + constructor(props, context) { + super(props, context); - getInitialState: function() { const llMembers = this.context.hasLazyLoadMembersEnabled(); - return { - room: null, + this.state = { roomId: null, roomLoading: true, peekLoading: false, shouldPeek: true, - - // Media limits for uploading. - mediaConfig: undefined, - - // used to trigger a rerender in TimelinePanel once the members are loaded, - // so RR are rendered again (now with the members available), ... membersLoaded: !llMembers, - // The event to be scrolled to initially - initialEventId: null, - // The offset in pixels from the event with which to scroll vertically - initialEventPixelOffset: null, - // Whether to highlight the event scrolled to - isInitialEventHighlighted: null, - - forwardingEvent: null, numUnreadMessages: 0, draggingFile: false, searching: false, @@ -139,42 +226,17 @@ export default createReactClass({ showingPinned: false, showReadReceipts: true, showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, - - // error object, as from the matrix client/server API - // If we failed to load information about the room, - // store the error here. - roomLoadError: null, - - // Have we sent a request to join the room that we're waiting to complete? joining: false, - - // this is true if we are fully scrolled-down, and are looking at - // the end of the live timeline. It has the effect of hiding the - // 'scroll to bottom' knob, among a couple of other things. atEndOfLiveTimeline: true, - atEndOfLiveTimelineInit: false, // used by componentDidUpdate to avoid unnecessary checks - + atEndOfLiveTimelineInit: false, showTopUnreadMessagesBar: false, - - auxPanelMaxHeight: undefined, - statusBarVisible: false, - - // We load this later by asking the js-sdk to suggest a version for us. - // This object is the result of Room#getRecommendedVersion() - upgradeRecommendation: null, - canReact: false, canReply: false, - useIRCLayout: SettingsStore.getValue("useIRCLayout"), - matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), }; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this.dispatcherRef = dis.register(this.onAction); this.context.on("Room", this.onRoom); this.context.on("Room.timeline", this.onRoomTimeline); @@ -189,27 +251,28 @@ export default createReactClass({ this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); // Start listening for RoomViewStore updates - this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); - this._rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this._onRightPanelStoreUpdate); - this._onRoomViewStoreUpdate(true); + this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); + this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); - WidgetEchoStore.on('update', this._onWidgetEchoStoreUpdate); - this._showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, - this._onReadReceiptsChange); + WidgetEchoStore.on('update', this.onWidgetEchoStoreUpdate); + this.showReadReceiptsWatchRef = SettingsStore.watchSetting("showReadReceipts", null, + this.onReadReceiptsChange); + this.layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); + } - this._roomView = createRef(); - this._searchResultsPanel = createRef(); + // TODO: [REACT-WARNING] Move into constructor + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + this.onRoomViewStoreUpdate(true); + } - this._layoutWatcherRef = SettingsStore.watchSetting("useIRCLayout", null, this.onLayoutChange); - }, - - _onReadReceiptsChange: function() { + private onReadReceiptsChange = () => { this.setState({ showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), }); - }, + }; - _onRoomViewStoreUpdate: function(initial) { + private onRoomViewStoreUpdate = (initial?: boolean) => { if (this.unmounted) { return; } @@ -231,7 +294,7 @@ export default createReactClass({ const roomId = RoomViewStore.getRoomId(); - const newState = { + const newState: Pick = { roomId, roomAlias: RoomViewStore.getRoomAlias(), roomLoading: RoomViewStore.isRoomLoading(), @@ -267,8 +330,8 @@ export default createReactClass({ if (initial) { newState.room = this.context.getRoom(newState.roomId); if (newState.room) { - newState.showApps = this._shouldShowApps(newState.room); - this._onRoomLoaded(newState.room); + newState.showApps = this.shouldShowApps(newState.room); + this.onRoomLoaded(newState.room); } } @@ -301,48 +364,47 @@ export default createReactClass({ // callback because this would prevent the setStates from being batched, // ie. cause it to render RoomView twice rather than the once that is necessary. if (initial) { - this._setupRoom(newState.room, newState.roomId, newState.joining, newState.shouldPeek); + this.setupRoom(newState.room, newState.roomId, newState.joining, newState.shouldPeek); } - }, + }; - _getRoomId() { - // According to `_onRoomViewStoreUpdate`, `state.roomId` can be null + private getRoomId = () => { + // According to `onRoomViewStoreUpdate`, `state.roomId` can be null // if we have a room alias we haven't resolved yet. To work around this, // first we'll try the room object if it's there, and then fallback to // the bare room ID. (We may want to update `state.roomId` after // resolving aliases, so we could always trust it.) return this.state.room ? this.state.room.roomId : this.state.roomId; - }, + }; - _getPermalinkCreatorForRoom: function(room) { - if (!this._permalinkCreators) this._permalinkCreators = {}; - if (this._permalinkCreators[room.roomId]) return this._permalinkCreators[room.roomId]; + private getPermalinkCreatorForRoom(room: Room) { + if (this.permalinkCreators[room.roomId]) return this.permalinkCreators[room.roomId]; - this._permalinkCreators[room.roomId] = new RoomPermalinkCreator(room); + this.permalinkCreators[room.roomId] = new RoomPermalinkCreator(room); if (this.state.room && room.roomId === this.state.room.roomId) { // We want to watch for changes in the creator for the primary room in the view, but // don't need to do so for search results. - this._permalinkCreators[room.roomId].start(); + this.permalinkCreators[room.roomId].start(); } else { - this._permalinkCreators[room.roomId].load(); + this.permalinkCreators[room.roomId].load(); } - return this._permalinkCreators[room.roomId]; - }, + return this.permalinkCreators[room.roomId]; + } - _stopAllPermalinkCreators: function() { - if (!this._permalinkCreators) return; - for (const roomId of Object.keys(this._permalinkCreators)) { - this._permalinkCreators[roomId].stop(); + private stopAllPermalinkCreators() { + if (!this.permalinkCreators) return; + for (const roomId of Object.keys(this.permalinkCreators)) { + this.permalinkCreators[roomId].stop(); } - }, + } - _onWidgetEchoStoreUpdate: function() { + private onWidgetEchoStoreUpdate = () => { this.setState({ - showApps: this._shouldShowApps(this.state.room), + showApps: this.shouldShowApps(this.state.room), }); - }, + }; - _setupRoom: function(room, roomId, joining, shouldPeek) { + private setupRoom(room: Room, roomId: string, joining: boolean, shouldPeek: boolean) { // if this is an unknown room then we're in one of three states: // - This is a room we can peek into (search engine) (we can /peek) // - This is a room we can publicly join or were invited to. (we can /join) @@ -375,7 +437,7 @@ export default createReactClass({ room: room, peekLoading: false, }); - this._onRoomLoaded(room); + this.onRoomLoaded(room); }).catch((err) => { if (this.unmounted) { return; @@ -404,9 +466,9 @@ export default createReactClass({ this.setState({isPeeking: false}); } } - }, + } - _shouldShowApps: function(room) { + private shouldShowApps(room: Room) { if (!BROWSER_SUPPORTS_SANDBOX) return false; // Check if user has previously chosen to hide the app drawer for this @@ -417,16 +479,16 @@ export default createReactClass({ // This is confusing, but it means to say that we default to the tray being // hidden unless the user clicked to open it. return hideWidgetDrawer === "false"; - }, + } - componentDidMount: function() { - const call = this._getCallForRoom(); + componentDidMount() { + const call = this.getCallForRoom(); const callState = call ? call.call_state : "ended"; this.setState({ callState: callState, }); - this._updateConfCallNotification(); + this.updateConfCallNotification(); window.addEventListener('beforeunload', this.onPageUnload); if (this.props.resizeNotifier) { @@ -435,16 +497,16 @@ export default createReactClass({ this.onResize(); document.addEventListener("keydown", this.onNativeKeyDown); - }, + } - shouldComponentUpdate: function(nextProps, nextState) { + shouldComponentUpdate(nextProps, nextState) { return (!ObjectUtils.shallowEqual(this.props, nextProps) || !ObjectUtils.shallowEqual(this.state, nextState)); - }, + } - componentDidUpdate: function() { - if (this._roomView.current) { - const roomView = this._roomView.current; + componentDidUpdate() { + if (this.roomView.current) { + const roomView = this.roomView.current; if (!roomView.ondrop) { roomView.addEventListener('drop', this.onDrop); roomView.addEventListener('dragover', this.onDragOver); @@ -458,15 +520,15 @@ export default createReactClass({ // in render() prevents the ref from being set on first mount, so we try and // catch the messagePanel when it does mount. Because we only want the ref once, // we use a boolean flag to avoid duplicate work. - if (this._messagePanel && !this.state.atEndOfLiveTimelineInit) { + if (this.messagePanel && !this.state.atEndOfLiveTimelineInit) { this.setState({ atEndOfLiveTimelineInit: true, - atEndOfLiveTimeline: this._messagePanel.isAtEndOfLiveTimeline(), + atEndOfLiveTimeline: this.messagePanel.isAtEndOfLiveTimeline(), }); } - }, + } - componentWillUnmount: function() { + componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // @@ -475,7 +537,7 @@ export default createReactClass({ // update the scroll map before we get unmounted if (this.state.roomId) { - RoomScrollStateStore.setScrollState(this.state.roomId, this._getScrollState()); + RoomScrollStateStore.setScrollState(this.state.roomId, this.getScrollState()); } if (this.state.shouldPeek) { @@ -483,14 +545,14 @@ export default createReactClass({ } // stop tracking room changes to format permalinks - this._stopAllPermalinkCreators(); + this.stopAllPermalinkCreators(); - if (this._roomView.current) { + if (this.roomView.current) { // disconnect the D&D event listeners from the room view. This // is really just for hygiene - we're going to be // deleted anyway, so it doesn't matter if the event listeners // don't get cleaned up. - const roomView = this._roomView.current; + const roomView = this.roomView.current; roomView.removeEventListener('drop', this.onDrop); roomView.removeEventListener('dragover', this.onDragOver); roomView.removeEventListener('dragleave', this.onDragLeaveOrEnd); @@ -520,55 +582,54 @@ export default createReactClass({ document.removeEventListener("keydown", this.onNativeKeyDown); // Remove RoomStore listener - if (this._roomStoreToken) { - this._roomStoreToken.remove(); + if (this.roomStoreToken) { + this.roomStoreToken.remove(); } // Remove RightPanelStore listener - if (this._rightPanelStoreToken) { - this._rightPanelStoreToken.remove(); + if (this.rightPanelStoreToken) { + this.rightPanelStoreToken.remove(); } - WidgetEchoStore.removeListener('update', this._onWidgetEchoStoreUpdate); + WidgetEchoStore.removeListener('update', this.onWidgetEchoStoreUpdate); - if (this._showReadReceiptsWatchRef) { - SettingsStore.unwatchSetting(this._showReadReceiptsWatchRef); - this._showReadReceiptsWatchRef = null; + if (this.showReadReceiptsWatchRef) { + SettingsStore.unwatchSetting(this.showReadReceiptsWatchRef); } // cancel any pending calls to the rate_limited_funcs - this._updateRoomMembers.cancelPendingCall(); + this.updateRoomMembers.cancelPendingCall(); // no need to do this as Dir & Settings are now overlays. It just burnt CPU. // console.log("Tinter.tint from RoomView.unmount"); // Tinter.tint(); // reset colourscheme - SettingsStore.unwatchSetting(this._layoutWatcherRef); - }, + SettingsStore.unwatchSetting(this.layoutWatcherRef); + } - onLayoutChange: function() { + private onLayoutChange = () => { this.setState({ useIRCLayout: SettingsStore.getValue("useIRCLayout"), }); - }, + }; - _onRightPanelStoreUpdate: function() { + private onRightPanelStoreUpdate = () => { this.setState({ showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, }); - }, + }; - onPageUnload(event) { + private onPageUnload = event => { if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { return event.returnValue = _t("You seem to be uploading files, are you sure you want to quit?"); - } else if (this._getCallForRoom() && this.state.callState !== 'ended') { + } else if (this.getCallForRoom() && this.state.callState !== 'ended') { return event.returnValue = _t("You seem to be in a call, are you sure you want to quit?"); } - }, + }; // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire - onNativeKeyDown: function(ev) { + private onNativeKeyDown = ev => { let handled = false; const ctrlCmdOnly = isOnlyCtrlOrCmdKeyEvent(ev); @@ -592,15 +653,15 @@ export default createReactClass({ ev.stopPropagation(); ev.preventDefault(); } - }, + }; - onReactKeyDown: function(ev) { + private onReactKeyDown = ev => { let handled = false; switch (ev.key) { case Key.ESCAPE: if (!ev.altKey && !ev.ctrlKey && !ev.shiftKey && !ev.metaKey) { - this._messagePanel.forgetReadMarker(); + this.messagePanel.forgetReadMarker(); this.jumpToLiveTimeline(); handled = true; } @@ -611,9 +672,10 @@ export default createReactClass({ handled = true; } break; + case Key.U: // Mac returns lowercase case Key.U.toUpperCase(): if (isOnlyCtrlOrCmdIgnoreShiftKeyEvent(ev) && ev.shiftKey) { - dis.dispatch({ action: "upload_file" }) + dis.dispatch({ action: "upload_file" }, true); handled = true; } break; @@ -623,22 +685,23 @@ export default createReactClass({ ev.stopPropagation(); ev.preventDefault(); } - }, + }; - onAction: function(payload) { + private onAction = payload => { switch (payload.action) { case 'message_send_failed': case 'message_sent': - this._checkIfAlone(this.state.room); + this.checkIfAlone(this.state.room); break; case 'post_sticker_message': - this.injectSticker( - payload.data.content.url, - payload.data.content.info, - payload.data.description || payload.data.name); - break; + this.injectSticker( + payload.data.content.url, + payload.data.content.info, + payload.data.description || payload.data.name); + break; case 'picture_snapshot': - ContentMessages.sharedInstance().sendContentListToRoom([payload.file], this.state.room.roomId, this.context); + ContentMessages.sharedInstance().sendContentListToRoom( + [payload.file], this.state.room.roomId, this.context); break; case 'notifier_enabled': case 'upload_started': @@ -646,7 +709,7 @@ export default createReactClass({ case 'upload_canceled': this.forceUpdate(); break; - case 'call_state': + case 'call_state': { // don't filter out payloads for room IDs other than props.room because // we may be interested in the conf 1:1 room @@ -654,24 +717,22 @@ export default createReactClass({ return; } - var call = this._getCallForRoom(); - var callState; + const call = this.getCallForRoom(); + let callState = "ended"; if (call) { callState = call.call_state; - } else { - callState = "ended"; } // possibly remove the conf call notification if we're now in // the conf - this._updateConfCallNotification(); + this.updateConfCallNotification(); this.setState({ callState: callState, }); - break; + } case 'appsDrawer': this.setState({ showApps: payload.show, @@ -704,14 +765,14 @@ export default createReactClass({ matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), }, () => { // send another "initial" RVS update to trigger peeking if needed - this._onRoomViewStoreUpdate(true); + this.onRoomViewStoreUpdate(true); }); } break; } - }, + }; - onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { + private onRoomTimeline = (ev: MatrixEvent, room: Room, toStartOfTimeline: boolean, removed, data) => { if (this.unmounted) return; // ignore events for other rooms @@ -722,11 +783,11 @@ export default createReactClass({ if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (ev.getType() === "org.matrix.room.preview_urls") { - this._updatePreviewUrlVisibility(room); + this.updatePreviewUrlVisibility(room); } if (ev.getType() === "m.room.encryption") { - this._updateE2EStatus(room); + this.updateE2EStatus(room); } // ignore anything but real-time updates at the end of the room: @@ -747,51 +808,45 @@ export default createReactClass({ }); } } - }, + }; - onRoomName: function(room) { + private onRoomName = (room: Room) => { if (this.state.room && room.roomId == this.state.room.roomId) { this.forceUpdate(); } - }, + }; - onRoomRecoveryReminderDontAskAgain: function() { - // Called when the option to not ask again is set: - // force an update to hide the recovery reminder - this.forceUpdate(); - }, - - onKeyBackupStatus() { + private onKeyBackupStatus = () => { // Key backup status changes affect whether the in-room recovery // reminder is displayed. this.forceUpdate(); - }, + }; - canResetTimeline: function() { - if (!this._messagePanel) { + public canResetTimeline = () => { + if (!this.messagePanel) { return true; } - return this._messagePanel.canResetTimeline(); - }, + return this.messagePanel.canResetTimeline(); + }; // called when state.room is first initialised (either at initial load, // after a successful peek, or after we join the room). - _onRoomLoaded: function(room) { - this._calculatePeekRules(room); - this._updatePreviewUrlVisibility(room); - this._loadMembersIfJoined(room); - this._calculateRecommendedVersion(room); - this._updateE2EStatus(room); - this._updatePermissions(room); - }, + private onRoomLoaded = (room: Room) => { + this.calculatePeekRules(room); + this.updatePreviewUrlVisibility(room); + this.loadMembersIfJoined(room); + this.calculateRecommendedVersion(room); + this.updateE2EStatus(room); + this.updatePermissions(room); + }; - _calculateRecommendedVersion: async function(room) { + private async calculateRecommendedVersion(room: Room) { this.setState({ upgradeRecommendation: await room.getRecommendedVersion(), }); - }, + } - _loadMembersIfJoined: async function(room) { + private async loadMembersIfJoined(room: Room) { // lazy load members if enabled if (this.context.hasLazyLoadMembersEnabled()) { if (room && room.getMyMembership() === 'join') { @@ -808,9 +863,9 @@ export default createReactClass({ } } } - }, + } - _calculatePeekRules: function(room) { + private calculatePeekRules(room: Room) { const guestAccessEvent = room.currentState.getStateEvents("m.room.guest_access", ""); if (guestAccessEvent && guestAccessEvent.getContent().guest_access === "can_join") { this.setState({ @@ -824,51 +879,51 @@ export default createReactClass({ canPeek: true, }); } - }, + } - _updatePreviewUrlVisibility: function({roomId}) { + private updatePreviewUrlVisibility({roomId}: Room) { // URL Previews in E2EE rooms can be a privacy leak so use a different setting which is per-room explicit const key = this.context.isRoomEncrypted(roomId) ? 'urlPreviewsEnabled_e2ee' : 'urlPreviewsEnabled'; this.setState({ showUrlPreview: SettingsStore.getValue(key, roomId), }); - }, + } - onRoom: function(room) { + private onRoom = (room: Room) => { if (!room || room.roomId !== this.state.roomId) { return; } this.setState({ room: room, }, () => { - this._onRoomLoaded(room); + this.onRoomLoaded(room); }); - }, + }; - onDeviceVerificationChanged: function(userId, device) { + private onDeviceVerificationChanged = (userId: string, device: object) => { const room = this.state.room; if (!room.currentState.getMember(userId)) { return; } - this._updateE2EStatus(room); - }, + this.updateE2EStatus(room); + }; - onUserVerificationChanged: function(userId, _trustStatus) { + private onUserVerificationChanged = (userId: string, trustStatus: object) => { const room = this.state.room; if (!room || !room.currentState.getMember(userId)) { return; } - this._updateE2EStatus(room); - }, + this.updateE2EStatus(room); + }; - onCrossSigningKeysChanged: function() { + private onCrossSigningKeysChanged = () => { const room = this.state.room; if (room) { - this._updateE2EStatus(room); + this.updateE2EStatus(room); } - }, + }; - _updateE2EStatus: async function(room) { + private async updateE2EStatus(room: Room) { if (!this.context.isRoomEncrypted(room.roomId)) { return; } @@ -877,7 +932,7 @@ export default createReactClass({ // so we don't know what the answer is. Let's error on the safe side and show // a warning for this case. this.setState({ - e2eStatus: "warning", + e2eStatus: E2EStatus.Warning, }); return; } @@ -886,26 +941,26 @@ export default createReactClass({ this.setState({ e2eStatus: await shieldStatusForRoom(this.context, room), }); - }, + } - updateTint: function() { + private updateTint() { const room = this.state.room; if (!room) return; console.log("Tinter.tint from updateTint"); const colorScheme = SettingsStore.getValue("roomColor", room.roomId); Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); - }, + } - onAccountData: function(event) { + private onAccountData = (event: MatrixEvent) => { const type = event.getType(); if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` - this._updatePreviewUrlVisibility(this.state.room); + this.updatePreviewUrlVisibility(this.state.room); } - }, + }; - onRoomAccountData: function(event, room) { + private onRoomAccountData = (event: MatrixEvent, room: Room) => { if (room.roomId == this.state.roomId) { const type = event.getType(); if (type === "org.matrix.room.color_scheme") { @@ -915,21 +970,21 @@ export default createReactClass({ Tinter.tint(colorScheme.primary_color, colorScheme.secondary_color); } else if (type === "org.matrix.room.preview_urls" || type === "im.vector.web.settings") { // non-e2ee url previews are stored in legacy event type `org.matrix.room.preview_urls` - this._updatePreviewUrlVisibility(room); + this.updatePreviewUrlVisibility(room); } } - }, + }; - onRoomStateEvents: function(ev, state) { + private onRoomStateEvents = (ev: MatrixEvent, state) => { // ignore if we don't have a room yet if (!this.state.room || this.state.room.roomId !== state.roomId) { return; } - this._updatePermissions(this.state.room); - }, + this.updatePermissions(this.state.room); + }; - onRoomStateMember: function(ev, state, member) { + private onRoomStateMember = (ev: MatrixEvent, state, member) => { // ignore if we don't have a room yet if (!this.state.room) { return; @@ -940,18 +995,18 @@ export default createReactClass({ return; } - this._updateRoomMembers(member); - }, + this.updateRoomMembers(member); + }; - onMyMembership: function(room, membership, oldMembership) { + private onMyMembership = (room: Room, membership: string, oldMembership: string) => { if (room.roomId === this.state.roomId) { this.forceUpdate(); - this._loadMembersIfJoined(room); - this._updatePermissions(room); + this.loadMembersIfJoined(room); + this.updatePermissions(room); } - }, + }; - _updatePermissions: function(room) { + private updatePermissions(room: Room) { if (room) { const me = this.context.getUserId(); const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me); @@ -959,15 +1014,14 @@ export default createReactClass({ this.setState({canReact, canReply}); } - }, + } - // rate limited because a power level change will emit an event for every - // member in the room. - _updateRoomMembers: rate_limited_func(function(dueToMember) { + // rate limited because a power level change will emit an event for every member in the room. + private updateRoomMembers = rateLimitedFunc((dueToMember) => { // a member state changed in this room // refresh the conf call notification state - this._updateConfCallNotification(); - this._updateDMState(); + this.updateConfCallNotification(); + this.updateDMState(); let memberCountInfluence = 0; if (dueToMember && dueToMember.membership === "invite" && this.state.room.getInvitedMemberCount() === 0) { @@ -975,15 +1029,15 @@ export default createReactClass({ // count by 1 to counteract this. memberCountInfluence = 1; } - this._checkIfAlone(this.state.room, memberCountInfluence); + this.checkIfAlone(this.state.room, memberCountInfluence); - this._updateE2EStatus(this.state.room); - }, 500), + this.updateE2EStatus(this.state.room); + }, 500); - _checkIfAlone: function(room, countInfluence) { + private checkIfAlone(room: Room, countInfluence?: number) { let warnedAboutLonelyRoom = false; if (localStorage) { - warnedAboutLonelyRoom = localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId); + warnedAboutLonelyRoom = Boolean(localStorage.getItem('mx_user_alone_warned_' + this.state.room.roomId)); } if (warnedAboutLonelyRoom) { if (this.state.isAlone) this.setState({isAlone: false}); @@ -993,9 +1047,9 @@ export default createReactClass({ let joinedOrInvitedMemberCount = room.getJoinedMemberCount() + room.getInvitedMemberCount(); if (countInfluence) joinedOrInvitedMemberCount += countInfluence; this.setState({isAlone: joinedOrInvitedMemberCount === 1}); - }, + } - _updateConfCallNotification: function() { + private updateConfCallNotification() { const room = this.state.room; if (!room || !this.props.ConferenceHandler) { return; @@ -1017,9 +1071,9 @@ export default createReactClass({ confMember.membership === "join" ), }); - }, + } - _updateDMState() { + private updateDMState() { const room = this.state.room; if (room.getMyMembership() != "join") { return; @@ -1028,9 +1082,9 @@ export default createReactClass({ if (dmInviter) { Rooms.setDMRoom(room.roomId, dmInviter); } - }, + } - onSearchResultsFillRequest: function(backwards) { + private onSearchResultsFillRequest = (backwards: boolean) => { if (!backwards) { return Promise.resolve(false); } @@ -1038,30 +1092,30 @@ export default createReactClass({ if (this.state.searchResults.next_batch) { debuglog("requesting more search results"); const searchPromise = searchPagination(this.state.searchResults); - return this._handleSearchResult(searchPromise); + return this.handleSearchResult(searchPromise); } else { debuglog("no more search results"); return Promise.resolve(false); } - }, + }; - onInviteButtonClick: function() { + private onInviteButtonClick = () => { // call AddressPickerDialog dis.dispatch({ action: 'view_invite', roomId: this.state.room.roomId, }); this.setState({isAlone: false}); // there's a good chance they'll invite someone - }, + }; - onStopAloneWarningClick: function() { + private onStopAloneWarningClick = () => { if (localStorage) { - localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, true); + localStorage.setItem('mx_user_alone_warned_' + this.state.room.roomId, String(true)); } this.setState({isAlone: false}); - }, + }; - onJoinButtonClicked: function(ev) { + private onJoinButtonClicked = () => { // If the user is a ROU, allow them to transition to a PWLU if (this.context && this.context.isGuest()) { // Join this room once the user has registered and logged in @@ -1070,7 +1124,7 @@ export default createReactClass({ action: 'do_after_sync_prepared', deferred_action: { action: 'view_room', - room_id: this._getRoomId(), + room_id: this.getRoomId(), }, }); @@ -1111,8 +1165,7 @@ export default createReactClass({ // return; } else { Promise.resolve().then(() => { - const signUrl = this.props.thirdPartyInvite ? - this.props.thirdPartyInvite.inviteSignUrl : undefined; + const signUrl = this.props.threepidInvite?.signUrl; dis.dispatch({ action: 'join_room', opts: { inviteSignUrl: signUrl, viaServers: this.props.viaServers }, @@ -1120,11 +1173,10 @@ export default createReactClass({ return Promise.resolve(); }); } + }; - }, - - onMessageListScroll: function(ev) { - if (this._messagePanel.isAtEndOfLiveTimeline()) { + private onMessageListScroll = ev => { + if (this.messagePanel.isAtEndOfLiveTimeline()) { this.setState({ numUnreadMessages: 0, atEndOfLiveTimeline: true, @@ -1134,10 +1186,10 @@ export default createReactClass({ atEndOfLiveTimeline: false, }); } - this._updateTopUnreadMessagesBar(); - }, + this.updateTopUnreadMessagesBar(); + }; - onDragOver: function(ev) { + private onDragOver = ev => { ev.stopPropagation(); ev.preventDefault(); @@ -1154,9 +1206,9 @@ export default createReactClass({ ev.dataTransfer.dropEffect = 'copy'; } } - }, + }; - onDrop: function(ev) { + private onDrop = ev => { ev.stopPropagation(); ev.preventDefault(); ContentMessages.sharedInstance().sendContentListToRoom( @@ -1164,15 +1216,15 @@ export default createReactClass({ ); this.setState({ draggingFile: false }); dis.fire(Action.FocusComposer); - }, + }; - onDragLeaveOrEnd: function(ev) { + private onDragLeaveOrEnd = ev => { ev.stopPropagation(); ev.preventDefault(); this.setState({ draggingFile: false }); - }, + }; - injectSticker: function(url, info, text) { + private injectSticker(url, info, text) { if (this.context.isGuest()) { dis.dispatch({action: 'require_registration'}); return; @@ -1185,9 +1237,9 @@ export default createReactClass({ return; } }); - }, + } - onSearch: function(term, scope) { + private onSearch = (term: string, scope) => { this.setState({ searchTerm: term, searchScope: scope, @@ -1197,8 +1249,8 @@ export default createReactClass({ // if we already have a search panel, we need to tell it to forget // about its scroll state. - if (this._searchResultsPanel.current) { - this._searchResultsPanel.current.resetScrollState(); + if (this.searchResultsPanel.current) { + this.searchResultsPanel.current.resetScrollState(); } // make sure that we don't end up showing results from @@ -1212,12 +1264,10 @@ export default createReactClass({ debuglog("sending search request"); const searchPromise = eventSearch(term, roomId); - this._handleSearchResult(searchPromise); - }, - - _handleSearchResult: function(searchPromise) { - const self = this; + this.handleSearchResult(searchPromise); + }; + private handleSearchResult(searchPromise: Promise) { // keep a record of the current search id, so that if the search terms // change before we get a response, we can ignore the results. const localSearchId = this.searchId; @@ -1226,9 +1276,9 @@ export default createReactClass({ searchInProgress: true, }); - return searchPromise.then(function(results) { + return searchPromise.then((results) => { debuglog("search complete"); - if (self.unmounted || !self.state.searching || self.searchId != localSearchId) { + if (this.unmounted || !this.state.searching || this.searchId != localSearchId) { console.error("Discarding stale search results"); return; } @@ -1240,8 +1290,8 @@ export default createReactClass({ // whether it was used by the search engine or not. let highlights = results.highlights; - if (highlights.indexOf(self.state.searchTerm) < 0) { - highlights = highlights.concat(self.state.searchTerm); + if (highlights.indexOf(this.state.searchTerm) < 0) { + highlights = highlights.concat(this.state.searchTerm); } // For overlapping highlights, @@ -1250,25 +1300,26 @@ export default createReactClass({ return b.length - a.length; }); - self.setState({ + this.setState({ searchHighlights: highlights, searchResults: results, }); - }, function(error) { + }, (error) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Search failed", error); Modal.createTrackedDialog('Search failed', '', ErrorDialog, { title: _t("Search failed"), - description: ((error && error.message) ? error.message : _t("Server may be unavailable, overloaded, or search timed out :(")), + description: ((error && error.message) ? error.message : + _t("Server may be unavailable, overloaded, or search timed out :(")), }); - }).finally(function() { - self.setState({ + }).finally(() => { + this.setState({ searchInProgress: false, }); }); - }, + } - getSearchResultTiles: function() { + private getSearchResultTiles() { const SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); const Spinner = sdk.getComponent("elements.Spinner"); @@ -1279,20 +1330,20 @@ export default createReactClass({ if (this.state.searchInProgress) { ret.push(
  • - -
  • ); + + ); } if (!this.state.searchResults.next_batch) { if (this.state.searchResults.results.length == 0) { ret.push(
  • -

    { _t("No results") }

    -
  • , +

    { _t("No results") }

    + , ); } else { ret.push(
  • -

    { _t("No more results") }

    -
  • , +

    { _t("No more results") }

    + , ); } } @@ -1300,7 +1351,7 @@ export default createReactClass({ // once dynamic content in the search results load, make the scrollPanel check // the scroll offsets. const onHeightChanged = () => { - const scrollPanel = this._searchResultsPanel.current; + const scrollPanel = this.searchResultsPanel.current; if (scrollPanel) { scrollPanel.checkScroll(); } @@ -1332,36 +1383,41 @@ export default createReactClass({ if (this.state.searchScope === 'All') { if (roomId !== lastRoomId) { ret.push(
  • -

    { _t("Room") }: { room.name }

    -
  • ); +

    { _t("Room") }: { room.name }

    + ); lastRoomId = roomId; } } const resultLink = "#/room/"+roomId+"/"+mxEv.getId(); - ret.push(); + ret.push(); } return ret; - }, + } - onPinnedClick: function() { + private onPinnedClick = () => { const nowShowingPinned = !this.state.showingPinned; const roomId = this.state.room.roomId; this.setState({showingPinned: nowShowingPinned, searching: false}); SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned); - }, + }; - onSettingsClick: function() { - dis.dispatch({ action: 'open_room_settings' }); - }, + private onSettingsClick = () => { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomSummary, + }); + }; - onCancelClick: function() { + private onCancelClick = () => { console.log("updateTint from onCancelClick"); this.updateTint(); if (this.state.forwardingEvent) { @@ -1371,33 +1427,32 @@ export default createReactClass({ }); } dis.fire(Action.FocusComposer); - }, + }; - onLeaveClick: function() { + private onLeaveClick = () => { dis.dispatch({ action: 'leave_room', room_id: this.state.room.roomId, }); - }, + }; - onForgetClick: function() { + private onForgetClick = () => { dis.dispatch({ action: 'forget_room', room_id: this.state.room.roomId, }); - }, + }; - onRejectButtonClicked: function(ev) { - const self = this; + private onRejectButtonClicked = ev => { this.setState({ rejecting: true, }); - this.context.leave(this.state.roomId).then(function() { + this.context.leave(this.state.roomId).then(() => { dis.dispatch({ action: 'view_next_room' }); - self.setState({ + this.setState({ rejecting: false, }); - }, function(error) { + }, (error) => { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); @@ -1407,14 +1462,14 @@ export default createReactClass({ description: msg, }); - self.setState({ + this.setState({ rejecting: false, rejectError: error, }); }); - }, + }; - onRejectAndIgnoreClick: async function() { + private onRejectAndIgnoreClick = async () => { this.setState({ rejecting: true, }); @@ -1441,69 +1496,69 @@ export default createReactClass({ description: msg, }); - self.setState({ + this.setState({ rejecting: false, rejectError: error, }); } - }, + }; - onRejectThreepidInviteButtonClicked: function(ev) { + private onRejectThreepidInviteButtonClicked = ev => { // We can reject 3pid invites in the same way that we accept them, // using /leave rather than /join. In the short term though, we // just ignore them. // https://github.com/vector-im/vector-web/issues/1134 dis.fire(Action.ViewRoomDirectory); - }, + }; - onSearchClick: function() { + private onSearchClick = () => { this.setState({ searching: !this.state.searching, showingPinned: false, }); - }, + }; - onCancelSearchClick: function() { + private onCancelSearchClick = () => { this.setState({ searching: false, searchResults: null, }); - }, + }; // jump down to the bottom of this room, where new events are arriving - jumpToLiveTimeline: function() { - this._messagePanel.jumpToLiveTimeline(); + private jumpToLiveTimeline = () => { + this.messagePanel.jumpToLiveTimeline(); dis.fire(Action.FocusComposer); - }, + }; // jump up to wherever our read marker is - jumpToReadMarker: function() { - this._messagePanel.jumpToReadMarker(); - }, + private jumpToReadMarker = () => { + this.messagePanel.jumpToReadMarker(); + }; // update the read marker to match the read-receipt - forgetReadMarker: function(ev) { + private forgetReadMarker = ev => { ev.stopPropagation(); - this._messagePanel.forgetReadMarker(); - }, + this.messagePanel.forgetReadMarker(); + }; // decide whether or not the top 'unread messages' bar should be shown - _updateTopUnreadMessagesBar: function() { - if (!this._messagePanel) { + private updateTopUnreadMessagesBar = () => { + if (!this.messagePanel) { return; } - const showBar = this._messagePanel.canJumpToReadMarker(); + const showBar = this.messagePanel.canJumpToReadMarker(); if (this.state.showTopUnreadMessagesBar != showBar) { this.setState({showTopUnreadMessagesBar: showBar}); } - }, + }; // get the current scroll position of the room, so that it can be // restored when we switch back to it. // - _getScrollState: function() { - const messagePanel = this._messagePanel; + private getScrollState() { + const messagePanel = this.messagePanel; if (!messagePanel) return null; // if we're following the live timeline, we want to return null; that @@ -1537,9 +1592,9 @@ export default createReactClass({ focussedEvent: scrollState.trackedScrollToken, pixelOffset: scrollState.pixelOffset, }; - }, + } - onResize: function() { + private onResize = () => { // It seems flexbox doesn't give us a way to constrain the auxPanel height to have // a minimum of the height of the video element, whilst also capping it from pushing out the page // so we have to do it via JS instead. In this implementation we cap the height by putting @@ -1547,9 +1602,9 @@ export default createReactClass({ // header + footer + status + give us at least 120px of scrollback at all times. let auxPanelMaxHeight = window.innerHeight - - (83 + // height of RoomHeader + (54 + // height of RoomHeader 36 + // height of the status area - 72 + // minimum height of the message compmoser + 51 + // minimum height of the message compmoser 120); // amount of desired scrollback // XXX: this is a bit of a hack and might possibly cause the video to push out the page anyway @@ -1557,121 +1612,108 @@ export default createReactClass({ if (auxPanelMaxHeight < 50) auxPanelMaxHeight = 50; this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); - }, + }; - onFullscreenClick: function() { + private onFullscreenClick = () => { dis.dispatch({ action: 'video_fullscreen', fullscreen: true, }, true); - }, + }; - onMuteAudioClick: function() { - const call = this._getCallForRoom(); + private onMuteAudioClick = () => { + const call = this.getCallForRoom(); if (!call) { return; } const newState = !call.isMicrophoneMuted(); call.setMicrophoneMuted(newState); this.forceUpdate(); // TODO: just update the voip buttons - }, + }; - onMuteVideoClick: function() { - const call = this._getCallForRoom(); + private onMuteVideoClick = () => { + const call = this.getCallForRoom(); if (!call) { return; } const newState = !call.isLocalVideoMuted(); call.setLocalVideoMuted(newState); this.forceUpdate(); // TODO: just update the voip buttons - }, + }; - onStatusBarVisible: function() { + private onStatusBarVisible = () => { if (this.unmounted) return; this.setState({ statusBarVisible: true, }); - }, + }; - onStatusBarHidden: function() { + private onStatusBarHidden = () => { // This is currently not desired as it is annoying if it keeps expanding and collapsing if (this.unmounted) return; this.setState({ statusBarVisible: false, }); - }, + }; /** * called by the parent component when PageUp/Down/etc is pressed. * * We pass it down to the scroll panel. */ - handleScrollKey: function(ev) { + private handleScrollKey = ev => { let panel; - if (this._searchResultsPanel.current) { - panel = this._searchResultsPanel.current; - } else if (this._messagePanel) { - panel = this._messagePanel; + if (this.searchResultsPanel.current) { + panel = this.searchResultsPanel.current; + } else if (this.messagePanel) { + panel = this.messagePanel; } if (panel) { panel.handleScrollKey(ev); } - }, + }; /** * get any current call for this room */ - _getCallForRoom: function() { + private getCallForRoom() { if (!this.state.room) { return null; } return CallHandler.getCallForRoom(this.state.room.roomId); - }, + } // this has to be a proper method rather than an unnamed function, // otherwise react calls it with null on each update. - _gatherTimelinePanelRef: function(r) { - this._messagePanel = r; + private gatherTimelinePanelRef = r => { + this.messagePanel = r; if (r) { - console.log("updateTint from RoomView._gatherTimelinePanelRef"); + console.log("updateTint from RoomView.gatherTimelinePanelRef"); this.updateTint(); } - }, + }; - _getOldRoom: function() { + private getOldRoom() { const createEvent = this.state.room.currentState.getStateEvents("m.room.create", ""); if (!createEvent || !createEvent.getContent()['predecessor']) return null; return this.context.getRoom(createEvent.getContent()['predecessor']['room_id']); - }, + } - _getHiddenHighlightCount: function() { - const oldRoom = this._getOldRoom(); + getHiddenHighlightCount() { + const oldRoom = this.getOldRoom(); if (!oldRoom) return 0; return oldRoom.getUnreadNotificationCount('highlight'); - }, + } - _onHiddenHighlightsClick: function() { - const oldRoom = this._getOldRoom(); + onHiddenHighlightsClick = () => { + const oldRoom = this.getOldRoom(); if (!oldRoom) return; dis.dispatch({action: "view_room", room_id: oldRoom.roomId}); - }, - - render: function() { - const RoomHeader = sdk.getComponent('rooms.RoomHeader'); - const ForwardMessage = sdk.getComponent("rooms.ForwardMessage"); - const AuxPanel = sdk.getComponent("rooms.AuxPanel"); - const SearchBar = sdk.getComponent("rooms.SearchBar"); - const PinnedEventsPanel = sdk.getComponent("rooms.PinnedEventsPanel"); - const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); - const TintableSvg = sdk.getComponent("elements.TintableSvg"); - const RoomPreviewBar = sdk.getComponent("rooms.RoomPreviewBar"); - const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - const RoomUpgradeWarningBar = sdk.getComponent("rooms.RoomUpgradeWarningBar"); - const RoomRecoveryReminder = sdk.getComponent("rooms.RoomRecoveryReminder"); - const ErrorBoundary = sdk.getComponent("elements.ErrorBoundary"); + }; + render() { if (!this.state.room) { const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading; if (loading) { @@ -1692,14 +1734,11 @@ export default createReactClass({ ); } else { - var inviterName = undefined; + let inviterName = undefined; if (this.props.oobData) { inviterName = this.props.oobData.inviterName; } - var invitedEmail = undefined; - if (this.props.thirdPartyInvite) { - invitedEmail = this.props.thirdPartyInvite.invitedEmail; - } + const invitedEmail = this.props.threepidInvite?.toEmail; // We have no room object for this room, only the ID. // We've got to this room by following a link, possibly a third party invite. @@ -1717,7 +1756,7 @@ export default createReactClass({ inviterName={inviterName} invitedEmail={invitedEmail} oobData={this.props.oobData} - signUrl={this.props.thirdPartyInvite ? this.props.thirdPartyInvite.inviteSignUrl : null} + signUrl={this.props.threepidInvite?.signUrl} room={this.state.room} /> @@ -1775,13 +1814,13 @@ export default createReactClass({ // We have successfully loaded this room, and are not previewing. // Display the "normal" room view. - const call = this._getCallForRoom(); + const call = this.getCallForRoom(); let inCall = false; if (call && (this.state.callState !== 'ended' && this.state.callState !== 'ringing')) { inCall = true; } - const scrollheader_classes = classNames({ + const scrollheaderClasses = classNames({ mx_RoomView_scrollheader: true, }); @@ -1813,55 +1852,47 @@ export default createReactClass({ this.state.room.userMayUpgradeRoom(this.context.credentials.userId) ); - const showRoomRecoveryReminder = ( - this.context.isCryptoEnabled() && - SettingsStore.getValue("showRoomRecoveryReminder") && - this.context.isRoomEncrypted(this.state.room.roomId) && - this.context.getKeyBackupEnabled() === false - ); - - const hiddenHighlightCount = this._getHiddenHighlightCount(); + const hiddenHighlightCount = this.getHiddenHighlightCount(); let aux = null; let previewBar; let hideCancel = false; let forceHideRightPanel = false; - if (this.state.forwardingEvent !== null) { + if (this.state.forwardingEvent) { aux = ; } else if (this.state.searching) { hideCancel = true; // has own cancel - aux = ; + aux = ; } else if (showRoomUpgradeBar) { aux = ; hideCancel = true; - } else if (showRoomRecoveryReminder) { - aux = ; - hideCancel = true; } else if (this.state.showingPinned) { hideCancel = true; // has own cancel aux = ; } else if (myMembership !== "join") { // We do have a room object for this room, but we're not currently in it. // We may have a 3rd party invite to it. - var inviterName = undefined; + let inviterName = undefined; if (this.props.oobData) { inviterName = this.props.oobData.inviterName; } - var invitedEmail = undefined; - if (this.props.thirdPartyInvite) { - invitedEmail = this.props.thirdPartyInvite.invitedEmail; - } + const invitedEmail = this.props.threepidInvite?.toEmail; hideCancel = true; previewBar = ( - ); if (!this.state.canPeek) { @@ -1875,8 +1906,11 @@ export default createReactClass({ } } else if (hiddenHighlightCount > 0) { aux = ( - + {_t( "You have %(count)s unread notifications in a prior version of this room.", {count: hiddenHighlightCount}, @@ -1886,15 +1920,19 @@ export default createReactClass({ } const auxPanel = ( - + { aux } ); @@ -1914,7 +1952,7 @@ export default createReactClass({ showApps={this.state.showApps} e2eStatus={this.state.e2eStatus} resizeNotifier={this.props.resizeNotifier} - permalinkCreator={this._getPermalinkCreatorForRoom(this.state.room)} + permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} />; } @@ -1934,26 +1972,37 @@ export default createReactClass({ if (call.type === "video") { zoomButton = (
    - +
    ); videoMuteButton =
    - +
    ; } const voiceMuteButton =
    - +
    ; // wrap the existing status bar into a 'callStatusBar' which adds more knobs. @@ -1974,16 +2023,18 @@ export default createReactClass({ if (this.state.searchResults) { // show searching spinner if (this.state.searchResults.results === undefined) { - searchResultsPanel = (
    ); + searchResultsPanel = ( +
    + ); } else { searchResultsPanel = ( -
  • +
  • { this.getSearchResultTiles() } ); @@ -2009,7 +2060,7 @@ export default createReactClass({ // console.info("ShowUrlPreview for %s is %s", this.state.room.roomId, this.state.showUrlPreview); const messagePanel = ( ); + topUnreadMessagesBar = ( + + ); } let jumpToBottom; // Do not show JumpToBottomButton if we have search results showing, it makes no sense @@ -2048,23 +2098,18 @@ export default createReactClass({ onScrollToBottomClick={this.jumpToLiveTimeline} />); } - const statusBarAreaClass = classNames( - "mx_RoomView_statusArea", - { - "mx_RoomView_statusArea_expanded": isStatusAreaExpanded, - }, - ); - const fadableSectionClasses = classNames( - "mx_RoomView_body", "mx_fadable", - { - "mx_fadable_faded": this.props.disabled, - }, - ); + const statusBarAreaClass = classNames("mx_RoomView_statusArea", { + "mx_RoomView_statusArea_expanded": isStatusAreaExpanded, + }); + + const fadableSectionClasses = classNames("mx_RoomView_body", "mx_fadable", { + "mx_fadable_faded": this.props.disabled, + }); const showRightPanel = !forceHideRightPanel && this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel - ? + ? : null; const timelineClasses = classNames("mx_RoomView_timeline", { @@ -2077,7 +2122,7 @@ export default createReactClass({ return ( -
    +
    - +
    {auxPanel}
    @@ -2118,5 +2160,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 51113f4f56..99a3da2565 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -15,7 +15,6 @@ limitations under the License. */ import React, {createRef} from "react"; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; @@ -84,10 +83,8 @@ if (DEBUG_SCROLL) { * offset as normal. */ -export default createReactClass({ - displayName: 'ScrollPanel', - - propTypes: { +export default class ScrollPanel extends React.Component { + static propTypes = { /* stickyBottom: if set to true, then once the user hits the bottom of * the list, any new children added to the list will cause the list to * scroll down to show the new element, rather than preserving the @@ -97,7 +94,7 @@ export default createReactClass({ /* startAtBottom: if set to true, the view is assumed to start * scrolled to the bottom. - * XXX: It's likley this is unecessary and can be derived from + * XXX: It's likely this is unnecessary and can be derived from * stickyBottom, but I'm adding an extra parameter to ensure * behaviour stays the same for other uses of ScrollPanel. * If so, let's remove this parameter down the line. @@ -141,6 +138,7 @@ export default createReactClass({ /* style: styles to add to the top-level div */ style: PropTypes.object, + /* resizeNotifier: ResizeNotifier to know when middle column has changed size */ resizeNotifier: PropTypes.object, @@ -149,36 +147,35 @@ export default createReactClass({ * of the wrapper */ fixedChildren: PropTypes.node, - }, + }; - getDefaultProps: function() { - return { - stickyBottom: true, - startAtBottom: true, - onFillRequest: function(backwards) { return Promise.resolve(false); }, - onUnfillRequest: function(backwards, scrollToken) {}, - onScroll: function() {}, - }; - }, + static defaultProps = { + stickyBottom: true, + startAtBottom: true, + onFillRequest: function(backwards) { return Promise.resolve(false); }, + onUnfillRequest: function(backwards, scrollToken) {}, + onScroll: function() {}, + }; + + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._pendingFillRequests = {b: null, f: null}; if (this.props.resizeNotifier) { - this.props.resizeNotifier.on("middlePanelResized", this.onResize); + this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); } this.resetScrollState(); this._itemlist = createRef(); - }, + } - componentDidMount: function() { + componentDidMount() { this.checkScroll(); - }, + } - componentDidUpdate: function() { + componentDidUpdate() { // after adding event tiles, we may need to tweak the scroll (either to // keep at the bottom of the timeline, or to maintain the view after // adding events to the top). @@ -186,9 +183,9 @@ export default createReactClass({ // This will also re-check the fill state, in case the paginate was inadequate this.checkScroll(); this.updatePreventShrinking(); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // @@ -196,51 +193,53 @@ export default createReactClass({ this.unmounted = true; if (this.props.resizeNotifier) { - this.props.resizeNotifier.removeListener("middlePanelResized", this.onResize); + this.props.resizeNotifier.removeListener("middlePanelResizedNoisy", this.onResize); } - }, + } - onScroll: function(ev) { + onScroll = ev => { + // skip scroll events caused by resizing + if (this.props.resizeNotifier && this.props.resizeNotifier.isResizing) return; debuglog("onScroll", this._getScrollNode().scrollTop); this._scrollTimeout.restart(); this._saveScrollState(); this.updatePreventShrinking(); this.props.onScroll(ev); this.checkFillState(); - }, + }; - onResize: function() { + onResize = () => { + debuglog("onResize"); this.checkScroll(); // update preventShrinkingState if present if (this.preventShrinkingState) { this.preventShrinking(); } - }, + }; // after an update to the contents of the panel, check that the scroll is // where it ought to be, and set off pagination requests if necessary. - checkScroll: function() { + checkScroll = () => { if (this.unmounted) { return; } this._restoreSavedScrollState(); this.checkFillState(); - }, + }; // return true if the content is fully scrolled down right now; else false. // // note that this is independent of the 'stuckAtBottom' state - it is simply // about whether the content is scrolled down right now, irrespective of // whether it will stay that way when the children update. - isAtBottom: function() { + isAtBottom = () => { const sn = this._getScrollNode(); // fractional values (both too big and too small) // for scrollTop happen on certain browsers/platforms // when scrolled all the way down. E.g. Chrome 72 on debian. // so check difference <= 1; return Math.abs(sn.scrollHeight - (sn.scrollTop + sn.clientHeight)) <= 1; - - }, + }; // returns the vertical height in the given direction that can be removed from // the content box (which has a height of scrollHeight, see checkFillState) without @@ -273,7 +272,7 @@ export default createReactClass({ // |#########| - | // |#########| | // `---------' - - _getExcessHeight: function(backwards) { + _getExcessHeight(backwards) { const sn = this._getScrollNode(); const contentHeight = this._getMessagesHeight(); const listHeight = this._getListHeight(); @@ -285,10 +284,10 @@ export default createReactClass({ } else { return contentHeight - (unclippedScrollTop + 2*sn.clientHeight) - UNPAGINATION_PADDING; } - }, + } // check the scroll state and send out backfill requests if necessary. - checkFillState: async function(depth=0) { + checkFillState = async (depth=0) => { if (this.unmounted) { return; } @@ -368,10 +367,10 @@ export default createReactClass({ this._fillRequestWhileRunning = false; this.checkFillState(); } - }, + }; // check if unfilling is possible and send an unfill request if necessary - _checkUnfillState: function(backwards) { + _checkUnfillState(backwards) { let excessHeight = this._getExcessHeight(backwards); if (excessHeight <= 0) { return; @@ -417,10 +416,10 @@ export default createReactClass({ this.props.onUnfillRequest(backwards, markerScrollToken); }, UNFILL_REQUEST_DEBOUNCE_MS); } - }, + } // check if there is already a pending fill request. If not, set one off. - _maybeFill: function(depth, backwards) { + _maybeFill(depth, backwards) { const dir = backwards ? 'b' : 'f'; if (this._pendingFillRequests[dir]) { debuglog("Already a "+dir+" fill in progress - not starting another"); @@ -456,7 +455,7 @@ export default createReactClass({ return this.checkFillState(depth + 1); } }); - }, + } /* get the current scroll state. This returns an object with the following * properties: @@ -472,9 +471,7 @@ export default createReactClass({ * the number of pixels the bottom of the tracked child is above the * bottom of the scroll panel. */ - getScrollState: function() { - return this.scrollState; - }, + getScrollState = () => this.scrollState; /* reset the saved scroll state. * @@ -488,7 +485,7 @@ export default createReactClass({ * no use if no children exist yet, or if you are about to replace the * child list.) */ - resetScrollState: function() { + resetScrollState = () => { this.scrollState = { stuckAtBottom: this.props.startAtBottom, }; @@ -496,20 +493,20 @@ export default createReactClass({ this._pages = 0; this._scrollTimeout = new Timer(100); this._heightUpdateInProgress = false; - }, + }; /** * jump to the top of the content. */ - scrollToTop: function() { + scrollToTop = () => { this._getScrollNode().scrollTop = 0; this._saveScrollState(); - }, + }; /** * jump to the bottom of the content. */ - scrollToBottom: function() { + scrollToBottom = () => { // the easiest way to make sure that the scroll state is correctly // saved is to do the scroll, then save the updated state. (Calculating // it ourselves is hard, and we can't rely on an onScroll callback @@ -517,25 +514,25 @@ export default createReactClass({ const sn = this._getScrollNode(); sn.scrollTop = sn.scrollHeight; this._saveScrollState(); - }, + }; /** * Page up/down. * * @param {number} mult: -1 to page up, +1 to page down */ - scrollRelative: function(mult) { + scrollRelative = mult => { const scrollNode = this._getScrollNode(); const delta = mult * scrollNode.clientHeight * 0.5; scrollNode.scrollBy(0, delta); this._saveScrollState(); - }, + }; /** * Scroll up/down in response to a scroll key * @param {object} ev the keyboard event */ - handleScrollKey: function(ev) { + handleScrollKey = ev => { switch (ev.key) { case Key.PAGE_UP: if (!ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) { @@ -561,7 +558,7 @@ export default createReactClass({ } break; } - }, + }; /* Scroll the panel to bring the DOM node with the scroll token * `scrollToken` into view. @@ -574,7 +571,7 @@ export default createReactClass({ * node (specifically, the bottom of it) will be positioned. If omitted, it * defaults to 0. */ - scrollToToken: function(scrollToken, pixelOffset, offsetBase) { + scrollToToken = (scrollToken, pixelOffset, offsetBase) => { pixelOffset = pixelOffset || 0; offsetBase = offsetBase || 0; @@ -596,9 +593,9 @@ export default createReactClass({ scrollNode.scrollTop = (trackedNode.offsetTop - (scrollNode.clientHeight * offsetBase)) + pixelOffset; this._saveScrollState(); } - }, + }; - _saveScrollState: function() { + _saveScrollState() { if (this.props.stickyBottom && this.isAtBottom()) { this.scrollState = { stuckAtBottom: true }; debuglog("saved stuckAtBottom state"); @@ -641,9 +638,9 @@ export default createReactClass({ bottomOffset: bottomOffset, pixelOffset: bottomOffset - viewportBottom, //needed for restoring the scroll position when coming back to the room }; - }, + } - _restoreSavedScrollState: async function() { + async _restoreSavedScrollState() { const scrollState = this.scrollState; if (scrollState.stuckAtBottom) { @@ -676,7 +673,8 @@ export default createReactClass({ } else { debuglog("not updating height because request already in progress"); } - }, + } + // need a better name that also indicates this will change scrollTop? Rebalance height? Reveal content? async _updateHeight() { // wait until user has stopped scrolling @@ -731,7 +729,7 @@ export default createReactClass({ debuglog("updateHeight to", {newHeight, topDiff}); } } - }, + } _getTrackedNode() { const scrollState = this.scrollState; @@ -764,11 +762,11 @@ export default createReactClass({ } return scrollState.trackedNode; - }, + } _getListHeight() { return this._bottomGrowth + (this._pages * PAGE_SIZE); - }, + } _getMessagesHeight() { const itemlist = this._itemlist.current; @@ -777,17 +775,17 @@ export default createReactClass({ const firstNodeTop = itemlist.firstElementChild ? itemlist.firstElementChild.offsetTop : 0; // 18 is itemlist padding return lastNodeBottom - firstNodeTop + (18 * 2); - }, + } _topFromBottom(node) { // current capped height - distance from top = distance from bottom of container to top of tracked element return this._itemlist.current.clientHeight - node.offsetTop; - }, + } /* get the DOM node which has the scrollTop property we care about for our * message panel. */ - _getScrollNode: function() { + _getScrollNode() { if (this.unmounted) { // this shouldn't happen, but when it does, turn the NPE into // something more meaningful. @@ -801,18 +799,18 @@ export default createReactClass({ } return this._divScroll; - }, + } - _collectScroll: function(divScroll) { + _collectScroll = divScroll => { this._divScroll = divScroll; - }, + }; /** Mark the bottom offset of the last tile so we can balance it out when anything below it changes, by calling updatePreventShrinking, to keep the same minimum bottom offset, effectively preventing the timeline to shrink. */ - preventShrinking: function() { + preventShrinking = () => { const messageList = this._itemlist.current; const tiles = messageList && messageList.children; if (!messageList) { @@ -836,16 +834,16 @@ export default createReactClass({ offsetNode: lastTileNode, }; debuglog("prevent shrinking, last tile ", offsetFromBottom, "px from bottom"); - }, + }; /** Clear shrinking prevention. Used internally, and when the timeline is reloaded. */ - clearPreventShrinking: function() { + clearPreventShrinking = () => { const messageList = this._itemlist.current; const balanceElement = messageList && messageList.parentElement; if (balanceElement) balanceElement.style.paddingBottom = null; this.preventShrinkingState = null; debuglog("prevent shrinking cleared"); - }, + }; /** update the container padding to balance @@ -855,7 +853,7 @@ export default createReactClass({ from the bottom of the marked tile grows larger than what it was when marking. */ - updatePreventShrinking: function() { + updatePreventShrinking = () => { if (this.preventShrinkingState) { const sn = this._getScrollNode(); const scrollState = this.scrollState; @@ -885,9 +883,9 @@ export default createReactClass({ this.clearPreventShrinking(); } } - }, + }; - render: function() { + render() { // TODO: the classnames on the div and ol could do with being updated to // reflect the fact that we don't necessarily contain a list of messages. // it's not obvious why we have a separate div and ol anyway. @@ -905,5 +903,5 @@ export default createReactClass({
  • ); - }, -}); + } +} diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 7e9d290bce..c1e3ad0cf2 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -16,18 +16,15 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { Key } from '../../Keyboard'; import dis from '../../dispatcher/dispatcher'; -import { throttle } from 'lodash'; +import {throttle} from 'lodash'; import AccessibleButton from '../../components/views/elements/AccessibleButton'; import classNames from 'classnames'; -export default createReactClass({ - displayName: 'SearchBox', - - propTypes: { +export default class SearchBox extends React.Component { + static propTypes = { onSearch: PropTypes.func, onCleared: PropTypes.func, onKeyDown: PropTypes.func, @@ -38,35 +35,32 @@ export default createReactClass({ // on room search focus action (it would be nicer to take // this functionality out, but not obvious how that would work) enableRoomSearchFocus: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - enableRoomSearchFocus: false, - }; - }, + static defaultProps = { + enableRoomSearchFocus: false, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this._search = createRef(); + + this.state = { searchTerm: "", blurred: true, }; - }, + } - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._search = createRef(); - }, - - componentDidMount: function() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { dis.unregister(this.dispatcherRef); - }, + } - onAction: function(payload) { + onAction = payload => { if (!this.props.enableRoomSearchFocus) return; switch (payload.action) { @@ -81,51 +75,51 @@ export default createReactClass({ } break; } - }, + }; - onChange: function() { + onChange = () => { if (!this._search.current) return; this.setState({ searchTerm: this._search.current.value }); this.onSearch(); - }, + }; - onSearch: throttle(function() { + onSearch = throttle(() => { this.props.onSearch(this._search.current.value); - }, 200, {trailing: true, leading: true}), + }, 200, {trailing: true, leading: true}); - _onKeyDown: function(ev) { + _onKeyDown = ev => { switch (ev.key) { case Key.ESCAPE: this._clearSearch("keyboard"); break; } if (this.props.onKeyDown) this.props.onKeyDown(ev); - }, + }; - _onFocus: function(ev) { + _onFocus = ev => { this.setState({blurred: false}); ev.target.select(); if (this.props.onFocus) { this.props.onFocus(ev); } - }, + }; - _onBlur: function(ev) { + _onBlur = ev => { this.setState({blurred: true}); if (this.props.onBlur) { this.props.onBlur(ev); } - }, + }; - _clearSearch: function(source) { + _clearSearch(source) { this._search.current.value = ""; this.onChange(); if (this.props.onCleared) { this.props.onCleared(source); } - }, + } - render: function() { + render() { // check for collapsed here and // not at parent so we keep // searchTerm in our state @@ -166,5 +160,5 @@ export default createReactClass({ { clearButton }
    ); - }, -}); + } +} diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 704dbf8832..6bc35eb2a4 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -18,7 +18,6 @@ limitations under the License. import * as React from "react"; import {_t} from '../../languageHandler'; -import * as PropTypes from "prop-types"; import * as sdk from "../../index"; import AutoHideScrollbar from './AutoHideScrollbar'; import { ReactNode } from "react"; diff --git a/src/components/structures/TagPanel.js b/src/components/structures/TagPanel.js index 4f8a051e62..135b2a1c5c 100644 --- a/src/components/structures/TagPanel.js +++ b/src/components/structures/TagPanel.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import TagOrderStore from '../../stores/TagOrderStore'; import GroupActions from '../../actions/GroupActions'; @@ -29,22 +28,18 @@ import { Droppable } from 'react-beautiful-dnd'; import classNames from 'classnames'; import MatrixClientContext from "../../contexts/MatrixClientContext"; import AutoHideScrollbar from "./AutoHideScrollbar"; +import SettingsStore from "../../settings/SettingsStore"; +import UserTagTile from "../views/elements/UserTagTile"; -const TagPanel = createReactClass({ - displayName: 'TagPanel', +class TagPanel extends React.Component { + static contextType = MatrixClientContext; - statics: { - contextType: MatrixClientContext, - }, + state = { + orderedTags: [], + selectedTags: [], + }; - getInitialState() { - return { - orderedTags: [], - selectedTags: [], - }; - }, - - componentDidMount: function() { + componentDidMount() { this.unmounted = false; this.context.on("Group.myMembership", this._onGroupMyMembership); this.context.on("sync", this._onClientSync); @@ -60,7 +55,7 @@ const TagPanel = createReactClass({ }); // This could be done by anything with a matrix client dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); - }, + } componentWillUnmount() { this.unmounted = true; @@ -69,14 +64,14 @@ const TagPanel = createReactClass({ if (this._tagOrderStoreToken) { this._tagOrderStoreToken.remove(); } - }, + } - _onGroupMyMembership() { + _onGroupMyMembership = () => { if (this.unmounted) return; dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); - }, + }; - _onClientSync(syncState, prevState) { + _onClientSync = (syncState, prevState) => { // Consider the client reconnected if there is no error with syncing. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. const reconnected = syncState !== "ERROR" && prevState !== syncState; @@ -84,29 +79,33 @@ const TagPanel = createReactClass({ // Load joined groups dis.dispatch(GroupActions.fetchJoinedGroups(this.context)); } - }, + }; - onMouseDown(e) { + onMouseDown = e => { // only dispatch if its not a no-op if (this.state.selectedTags.length > 0) { dis.dispatch({action: 'deselect_tags'}); } - }, + }; - onCreateGroupClick(ev) { - ev.stopPropagation(); - dis.dispatch({action: 'view_create_group'}); - }, - - onClearFilterClick(ev) { + onClearFilterClick = ev => { dis.dispatch({action: 'deselect_tags'}); - }, + }; + + renderGlobalIcon() { + if (!SettingsStore.getValue("feature_communities_v2_prototypes")) return null; + + return ( +
    + +
    +
    + ); + } 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 tags = this.state.orderedTags.map((tag, index) => { return 0; - - let clearButton; - if (itemsSelected) { - clearButton = - - ; - } - const classes = classNames('mx_TagPanel', { mx_TagPanel_items_selected: itemsSelected, }); - return
    -
    - { clearButton } -
    -
    + let createButton = ( + + ); + + if (SettingsStore.getValue("feature_communities_v2_prototypes")) { + createButton = ( + + ); + } + + return
    + { this.renderGlobalIcon() } { tags }
    - + {createButton}
    { provided.placeholder }
    @@ -167,6 +166,6 @@ const TagPanel = createReactClass({
    ; - }, -}); + } +} export default TagPanel; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 2313b60ab1..8bbc66bf40 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -19,7 +19,6 @@ limitations under the License. import SettingsStore from "../../settings/SettingsStore"; import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import ReactDOM from "react-dom"; import PropTypes from 'prop-types'; import {EventTimeline} from "matrix-js-sdk"; @@ -36,6 +35,7 @@ import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; import EditorStateTransfer from '../../utils/EditorStateTransfer'; import {haveTileForEvent} from "../views/rooms/EventTile"; +import {UIFeature} from "../../settings/UIFeature"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -54,10 +54,8 @@ if (DEBUG) { * * Also responsible for handling and sending read receipts. */ -const TimelinePanel = createReactClass({ - displayName: 'TimelinePanel', - - propTypes: { +class TimelinePanel extends React.Component { + static propTypes = { // The js-sdk EventTimelineSet object for the timeline sequence we are // representing. This may or may not have a room, depending on what it's // a timeline representing. If it has a room, we maintain RRs etc for @@ -107,31 +105,36 @@ const TimelinePanel = createReactClass({ // shape property to be passed to EventTiles tileShape: PropTypes.string, - // placeholder text to use if the timeline is empty - empty: PropTypes.string, + // placeholder to use if the timeline is empty + empty: PropTypes.node, // whether to show reactions for an event showReactions: PropTypes.bool, // whether to use the irc layout useIRCLayout: PropTypes.bool, - }, + } - statics: { - // a map from room id to read marker event timestamp - roomReadMarkerTsMap: {}, - }, + // a map from room id to read marker event timestamp + static roomReadMarkerTsMap = {}; - getDefaultProps: function() { - return { - // By default, disable the timelineCap in favour of unpaginating based on - // event tile heights. (See _unpaginateEvents) - timelineCap: Number.MAX_VALUE, - className: 'mx_RoomView_messagePanel', - }; - }, + static defaultProps = { + // By default, disable the timelineCap in favour of unpaginating based on + // event tile heights. (See _unpaginateEvents) + timelineCap: Number.MAX_VALUE, + className: 'mx_RoomView_messagePanel', + }; + + constructor(props) { + super(props); + + debuglog("TimelinePanel: mounting"); + + this.lastRRSentEventId = undefined; + this.lastRMSentEventId = undefined; + + this._messagePanel = createRef(); - getInitialState: function() { // XXX: we could track RM per TimelineSet rather than per Room. // but for now we just do it per room for simplicity. let initialReadMarker = null; @@ -144,7 +147,7 @@ const TimelinePanel = createReactClass({ } } - return { + this.state = { events: [], liveEvents: [], timelineLoading: true, // track whether our room timeline is loading @@ -203,24 +206,6 @@ const TimelinePanel = createReactClass({ // how long to show the RM for when it's scrolled off-screen readMarkerOutOfViewThresholdMs: SettingsStore.getValue("readMarkerOutOfViewThresholdMs"), }; - }, - - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - debuglog("TimelinePanel: mounting"); - - this.lastRRSentEventId = undefined; - this.lastRMSentEventId = undefined; - - this._messagePanel = createRef(); - - if (this.props.manageReadReceipts) { - this.updateReadReceiptOnUserActivity(); - } - if (this.props.manageReadMarkers) { - this.updateReadMarkerOnUserActivity(); - } - this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); @@ -234,12 +219,24 @@ const TimelinePanel = createReactClass({ MatrixClientPeg.get().on("Event.decrypted", this.onEventDecrypted); MatrixClientPeg.get().on("Event.replaced", this.onEventReplaced); MatrixClientPeg.get().on("sync", this.onSync); + } + + // TODO: [REACT-WARNING] Move into constructor + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + if (this.props.manageReadReceipts) { + this.updateReadReceiptOnUserActivity(); + } + if (this.props.manageReadMarkers) { + this.updateReadMarkerOnUserActivity(); + } this._initTimeline(this.props); - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { if (newProps.timelineSet !== this.props.timelineSet) { // throw new Error("changing timelineSet on a TimelinePanel is not supported"); @@ -260,9 +257,9 @@ const TimelinePanel = createReactClass({ " (was " + this.props.eventId + ")"); return this._initTimeline(newProps); } - }, + } - shouldComponentUpdate: function(nextProps, nextState) { + shouldComponentUpdate(nextProps, nextState) { if (!ObjectUtils.shallowEqual(this.props, nextProps)) { if (DEBUG) { console.group("Timeline.shouldComponentUpdate: props change"); @@ -284,9 +281,9 @@ const TimelinePanel = createReactClass({ } return false; - }, + } - componentWillUnmount: function() { + componentWillUnmount() { // set a boolean to say we've been unmounted, which any pending // promises can use to throw away their results. // @@ -316,9 +313,9 @@ const TimelinePanel = createReactClass({ client.removeListener("Event.replaced", this.onEventReplaced); client.removeListener("sync", this.onSync); } - }, + } - onMessageListUnfillRequest: function(backwards, scrollToken) { + onMessageListUnfillRequest = (backwards, scrollToken) => { // If backwards, unpaginate from the back (i.e. the start of the timeline) const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; debuglog("TimelinePanel: unpaginating events in direction", dir); @@ -349,18 +346,18 @@ const TimelinePanel = createReactClass({ firstVisibleEventIndex, }); } - }, + }; - onPaginationRequest(timelineWindow, direction, size) { + onPaginationRequest = (timelineWindow, direction, size) => { if (this.props.onPaginationRequest) { return this.props.onPaginationRequest(timelineWindow, direction, size); } else { return timelineWindow.paginate(direction, size); } - }, + }; // set off a pagination request. - onMessageListFillRequest: function(backwards) { + onMessageListFillRequest = backwards => { if (!this._shouldPaginate()) return Promise.resolve(false); const dir = backwards ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS; @@ -425,9 +422,9 @@ const TimelinePanel = createReactClass({ }); }); }); - }, + }; - onMessageListScroll: function(e) { + onMessageListScroll = e => { if (this.props.onScroll) { this.props.onScroll(e); } @@ -447,9 +444,9 @@ const TimelinePanel = createReactClass({ // NO-OP when timeout already has set to the given value this._readMarkerActivityTimer.changeTimeout(timeout); } - }, + }; - onAction: function(payload) { + onAction = payload => { if (payload.action === 'ignore_state_changed') { this.forceUpdate(); } @@ -463,9 +460,9 @@ const TimelinePanel = createReactClass({ } }); } - }, + }; - onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { + onRoomTimeline = (ev, room, toStartOfTimeline, removed, data) => { // ignore events for other timeline sets if (data.timeline.getTimelineSet() !== this.props.timelineSet) return; @@ -537,21 +534,19 @@ const TimelinePanel = createReactClass({ } }); }); - }, + }; - onRoomTimelineReset: function(room, timelineSet) { + onRoomTimelineReset = (room, timelineSet) => { if (timelineSet !== this.props.timelineSet) return; if (this._messagePanel.current && this._messagePanel.current.isAtBottom()) { this._loadTimeline(); } - }, + }; - canResetTimeline: function() { - return this._messagePanel.current && this._messagePanel.current.isAtBottom(); - }, + canResetTimeline = () => this._messagePanel.current && this._messagePanel.current.isAtBottom(); - onRoomRedaction: function(ev, room) { + onRoomRedaction = (ev, room) => { if (this.unmounted) return; // ignore events for other rooms @@ -560,9 +555,9 @@ const TimelinePanel = createReactClass({ // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. this.forceUpdate(); - }, + }; - onEventReplaced: function(replacedEvent, room) { + onEventReplaced = (replacedEvent, room) => { if (this.unmounted) return; // ignore events for other rooms @@ -571,27 +566,27 @@ const TimelinePanel = createReactClass({ // we could skip an update if the event isn't in our timeline, // but that's probably an early optimisation. this.forceUpdate(); - }, + }; - onRoomReceipt: function(ev, room) { + onRoomReceipt = (ev, room) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; this.forceUpdate(); - }, + }; - onLocalEchoUpdated: function(ev, room, oldEventId) { + onLocalEchoUpdated = (ev, room, oldEventId) => { if (this.unmounted) return; // ignore events for other rooms if (room !== this.props.timelineSet.room) return; this._reloadEvents(); - }, + }; - onAccountData: function(ev, room) { + onAccountData = (ev, room) => { if (this.unmounted) return; // ignore events for other rooms @@ -605,9 +600,9 @@ const TimelinePanel = createReactClass({ this.setState({ readMarkerEventId: ev.getContent().event_id, }, this.props.onReadMarkerUpdated); - }, + }; - onEventDecrypted: function(ev) { + onEventDecrypted = ev => { // Can be null for the notification timeline, etc. if (!this.props.timelineSet.room) return; @@ -620,19 +615,19 @@ const TimelinePanel = createReactClass({ if (ev.getRoomId() === this.props.timelineSet.room.roomId) { this.forceUpdate(); } - }, + }; - onSync: function(state, prevState, data) { + onSync = (state, prevState, data) => { this.setState({clientSyncState: state}); - }, + }; _readMarkerTimeout(readMarkerPosition) { return readMarkerPosition === 0 ? this.state.readMarkerInViewThresholdMs : this.state.readMarkerOutOfViewThresholdMs; - }, + } - updateReadMarkerOnUserActivity: async function() { + async updateReadMarkerOnUserActivity() { const initialTimeout = this._readMarkerTimeout(this.getReadMarkerPosition()); this._readMarkerActivityTimer = new Timer(initialTimeout); @@ -644,9 +639,9 @@ const TimelinePanel = createReactClass({ // outside of try/catch to not swallow errors this.updateReadMarker(); } - }, + } - updateReadReceiptOnUserActivity: async function() { + async updateReadReceiptOnUserActivity() { this._readReceiptActivityTimer = new Timer(READ_RECEIPT_INTERVAL_MS); while (this._readReceiptActivityTimer) { //unset on unmount UserActivity.sharedInstance().timeWhileActiveNow(this._readReceiptActivityTimer); @@ -656,9 +651,9 @@ const TimelinePanel = createReactClass({ // outside of try/catch to not swallow errors this.sendReadReceipt(); } - }, + } - sendReadReceipt: function() { + sendReadReceipt = () => { if (SettingsStore.getValue("lowBandwidth")) return; if (!this._messagePanel.current) return; @@ -766,11 +761,11 @@ const TimelinePanel = createReactClass({ }); } } - }, + }; // if the read marker is on the screen, we can now assume we've caught up to the end // of the screen, so move the marker down to the bottom of the screen. - updateReadMarker: function() { + updateReadMarker = () => { if (!this.props.manageReadMarkers) return; if (this.getReadMarkerPosition() === 1) { // the read marker is at an event below the viewport, @@ -801,11 +796,11 @@ const TimelinePanel = createReactClass({ // Send the updated read marker (along with read receipt) to the server this.sendReadReceipt(); - }, + }; // advance the read marker past any events we sent ourselves. - _advanceReadMarkerPastMyEvents: function() { + _advanceReadMarkerPastMyEvents() { if (!this.props.manageReadMarkers) return; // we call `_timelineWindow.getEvents()` rather than using @@ -837,11 +832,11 @@ const TimelinePanel = createReactClass({ const ev = events[i]; this._setReadMarker(ev.getId(), ev.getTs()); - }, + } /* jump down to the bottom of this room, where new events are arriving */ - jumpToLiveTimeline: function() { + jumpToLiveTimeline = () => { // if we can't forward-paginate the existing timeline, then there // is no point reloading it - just jump straight to the bottom. // @@ -854,12 +849,12 @@ const TimelinePanel = createReactClass({ this._messagePanel.current.scrollToBottom(); } } - }, + }; /* scroll to show the read-up-to marker. We put it 1/3 of the way down * the container. */ - jumpToReadMarker: function() { + jumpToReadMarker = () => { if (!this.props.manageReadMarkers) return; if (!this._messagePanel.current) return; if (!this.state.readMarkerEventId) return; @@ -883,11 +878,11 @@ const TimelinePanel = createReactClass({ // As with jumpToLiveTimeline, we want to reload the timeline around the // read-marker. this._loadTimeline(this.state.readMarkerEventId, 0, 1/3); - }, + }; /* update the read-up-to marker to match the read receipt */ - forgetReadMarker: function() { + forgetReadMarker = () => { if (!this.props.manageReadMarkers) return; const rmId = this._getCurrentReadReceipt(); @@ -903,17 +898,17 @@ const TimelinePanel = createReactClass({ } this._setReadMarker(rmId, rmTs); - }, + }; /* return true if the content is fully scrolled down and we are * at the end of the live timeline. */ - isAtEndOfLiveTimeline: function() { + isAtEndOfLiveTimeline = () => { return this._messagePanel.current && this._messagePanel.current.isAtBottom() && this._timelineWindow && !this._timelineWindow.canPaginate(EventTimeline.FORWARDS); - }, + } /* get the current scroll state. See ScrollPanel.getScrollState for @@ -921,10 +916,10 @@ const TimelinePanel = createReactClass({ * * returns null if we are not mounted. */ - getScrollState: function() { + getScrollState = () => { if (!this._messagePanel.current) { return null; } return this._messagePanel.current.getScrollState(); - }, + }; // returns one of: // @@ -932,7 +927,7 @@ const TimelinePanel = createReactClass({ // -1: read marker is above the window // 0: read marker is visible // +1: read marker is below the window - getReadMarkerPosition: function() { + getReadMarkerPosition = () => { if (!this.props.manageReadMarkers) return null; if (!this._messagePanel.current) return null; @@ -953,9 +948,9 @@ const TimelinePanel = createReactClass({ } return null; - }, + }; - canJumpToReadMarker: function() { + canJumpToReadMarker = () => { // 1. Do not show jump bar if neither the RM nor the RR are set. // 3. We want to show the bar if the read-marker is off the top of the screen. // 4. Also, if pos === null, the event might not be paginated - show the unread bar @@ -963,14 +958,14 @@ const TimelinePanel = createReactClass({ const ret = this.state.readMarkerEventId !== null && // 1. (pos < 0 || pos === null); // 3., 4. return ret; - }, + }; /* * called by the parent component when PageUp/Down/etc is pressed. * * We pass it down to the scroll panel. */ - handleScrollKey: function(ev) { + handleScrollKey = ev => { if (!this._messagePanel.current) { return; } // jump to the live timeline on ctrl-end, rather than the end of the @@ -980,9 +975,9 @@ const TimelinePanel = createReactClass({ } else { this._messagePanel.current.handleScrollKey(ev); } - }, + }; - _initTimeline: function(props) { + _initTimeline(props) { const initialEvent = props.eventId; const pixelOffset = props.eventPixelOffset; @@ -994,7 +989,7 @@ const TimelinePanel = createReactClass({ } return this._loadTimeline(initialEvent, pixelOffset, offsetBase); - }, + } /** * (re)-load the event timeline, and initialise the scroll state, centered @@ -1012,7 +1007,7 @@ const TimelinePanel = createReactClass({ * * returns a promise which will resolve when the load completes. */ - _loadTimeline: function(eventId, pixelOffset, offsetBase) { + _loadTimeline(eventId, pixelOffset, offsetBase) { this._timelineWindow = new Matrix.TimelineWindow( MatrixClientPeg.get(), this.props.timelineSet, {windowLimit: this.props.timelineCap}); @@ -1122,21 +1117,21 @@ const TimelinePanel = createReactClass({ }); prom.then(onLoaded, onError); } - }, + } // handle the completion of a timeline load or localEchoUpdate, by // reloading the events from the timelinewindow and pending event list into // the state. - _reloadEvents: function() { + _reloadEvents() { // we might have switched rooms since the load started - just bin // the results if so. if (this.unmounted) return; this.setState(this._getEvents()); - }, + } // get the list of events from the timeline window and the pending event list - _getEvents: function() { + _getEvents() { const events = this._timelineWindow.getEvents(); const firstVisibleEventIndex = this._checkForPreJoinUISI(events); @@ -1154,7 +1149,7 @@ const TimelinePanel = createReactClass({ liveEvents, firstVisibleEventIndex, }; - }, + } /** * Check for undecryptable messages that were sent while the user was not in @@ -1166,7 +1161,7 @@ const TimelinePanel = createReactClass({ * undecryptable event that was sent while the user was not in the room. If no * such events were found, then it returns 0. */ - _checkForPreJoinUISI: function(events) { + _checkForPreJoinUISI(events) { const room = this.props.timelineSet.room; if (events.length === 0 || !room || @@ -1228,18 +1223,18 @@ const TimelinePanel = createReactClass({ } } return 0; - }, + } - _indexForEventId: function(evId) { + _indexForEventId(evId) { for (let i = 0; i < this.state.events.length; ++i) { if (evId == this.state.events[i].getId()) { return i; } } return null; - }, + } - _getLastDisplayedEventIndex: function(opts) { + _getLastDisplayedEventIndex(opts) { opts = opts || {}; const ignoreOwn = opts.ignoreOwn || false; const allowPartial = opts.allowPartial || false; @@ -1313,7 +1308,7 @@ const TimelinePanel = createReactClass({ } return null; - }, + } /** * Get the id of the event corresponding to our user's latest read-receipt. @@ -1324,7 +1319,7 @@ const TimelinePanel = createReactClass({ * SDK. * @return {String} the event ID */ - _getCurrentReadReceipt: function(ignoreSynthesized) { + _getCurrentReadReceipt(ignoreSynthesized) { const client = MatrixClientPeg.get(); // the client can be null on logout if (client == null) { @@ -1333,9 +1328,9 @@ const TimelinePanel = createReactClass({ const myUserId = client.credentials.userId; return this.props.timelineSet.room.getEventReadUpTo(myUserId, ignoreSynthesized); - }, + } - _setReadMarker: function(eventId, eventTs, inhibitSetState) { + _setReadMarker(eventId, eventTs, inhibitSetState) { const roomId = this.props.timelineSet.room.roomId; // don't update the state (and cause a re-render) if there is @@ -1358,9 +1353,9 @@ const TimelinePanel = createReactClass({ this.setState({ readMarkerEventId: eventId, }, this.props.onReadMarkerUpdated); - }, + } - _shouldPaginate: function() { + _shouldPaginate() { // don't try to paginate while events in the timeline are // still being decrypted. We don't render events while they're // being decrypted, so they don't take up space in the timeline. @@ -1369,13 +1364,11 @@ const TimelinePanel = createReactClass({ return !this.state.events.some((e) => { return e.isBeingDecrypted(); }); - }, + } - getRelationsForEvent(...args) { - return this.props.timelineSet.getRelationsForEvent(...args); - }, + getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args); - render: function() { + render() { const MessagePanel = sdk.getComponent("structures.MessagePanel"); const Loader = sdk.getComponent("elements.Spinner"); @@ -1454,9 +1447,10 @@ const TimelinePanel = createReactClass({ editState={this.state.editState} showReactions={this.props.showReactions} useIRCLayout={this.props.useIRCLayout} + enableFlair={SettingsStore.getValue(UIFeature.Flair)} /> ); - }, -}); + } +} export default TimelinePanel; diff --git a/src/components/structures/UploadBar.js b/src/components/structures/UploadBar.js index 421d1d79a7..0865764c5a 100644 --- a/src/components/structures/UploadBar.js +++ b/src/components/structures/UploadBar.js @@ -16,30 +16,28 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import ContentMessages from '../../ContentMessages'; import dis from "../../dispatcher/dispatcher"; import filesize from "filesize"; import { _t } from '../../languageHandler'; -export default createReactClass({ - displayName: 'UploadBar', - propTypes: { +export default class UploadBar extends React.Component { + static propTypes = { room: PropTypes.object, - }, + }; - componentDidMount: function() { + componentDidMount() { this.dispatcherRef = dis.register(this.onAction); this.mounted = true; - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this.mounted = false; dis.unregister(this.dispatcherRef); - }, + } - onAction: function(payload) { + onAction = payload => { switch (payload.action) { case 'upload_progress': case 'upload_finished': @@ -48,9 +46,9 @@ export default createReactClass({ if (this.mounted) this.forceUpdate(); break; } - }, + }; - render: function() { + render() { const uploads = ContentMessages.sharedInstance().getCurrentUploads(); // for testing UI... - also fix up the ContentMessages.getCurrentUploads().length @@ -105,5 +103,5 @@ export default createReactClass({
    { uploadText }
    ); - }, -}); + } +} diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index e782618872..369d3b7720 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -40,8 +40,17 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { SettingLevel } from "../../settings/SettingLevel"; import IconizedContextMenu, { IconizedContextMenuOption, - IconizedContextMenuOptionList + IconizedContextMenuOptionList, } from "../views/context_menus/IconizedContextMenu"; +import { CommunityPrototypeStore } from "../../stores/CommunityPrototypeStore"; +import * as fbEmitter from "fbemitter"; +import TagOrderStore from "../../stores/TagOrderStore"; +import { showCommunityInviteDialog } from "../../RoomInvite"; +import dis from "../../dispatcher/dispatcher"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import ErrorDialog from "../views/dialogs/ErrorDialog"; +import EditCommunityPrototypeDialog from "../views/dialogs/EditCommunityPrototypeDialog"; +import {UIFeature} from "../../settings/UIFeature"; interface IProps { isMinimized: boolean; @@ -58,6 +67,7 @@ export default class UserMenu extends React.Component { private dispatcherRef: string; private themeWatcherRef: string; private buttonRef: React.RefObject = createRef(); + private tagStoreRef: fbEmitter.EventSubscription; constructor(props: IProps) { super(props); @@ -77,14 +87,20 @@ export default class UserMenu extends React.Component { public componentDidMount() { this.dispatcherRef = defaultDispatcher.register(this.onAction); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); + this.tagStoreRef = TagOrderStore.addListener(this.onTagStoreUpdate); } public componentWillUnmount() { if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); + this.tagStoreRef.remove(); } + private onTagStoreUpdate = () => { + this.forceUpdate(); // we don't have anything useful in state to update + }; + private isUserOnDarkTheme(): boolean { const theme = SettingsStore.getValue("theme"); if (theme.startsWith("custom-")) { @@ -189,9 +205,54 @@ export default class UserMenu extends React.Component { defaultDispatcher.dispatch({action: 'view_home_page'}); }; + private onCommunitySettingsClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + Modal.createTrackedDialog('Edit Community', '', EditCommunityPrototypeDialog, { + communityId: CommunityPrototypeStore.instance.getSelectedCommunityId(), + }); + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onCommunityMembersClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + // We'd ideally just pop open a right panel with the member list, but the current + // way the right panel is structured makes this exceedingly difficult. Instead, we'll + // switch to the general room and open the member list there as it should be in sync + // anyways. + const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); + if (chat) { + dis.dispatch({ + action: 'view_room', + room_id: chat.roomId, + }, true); + dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.RoomMemberList}); + } else { + // "This should never happen" clauses go here for the prototype. + Modal.createTrackedDialog('Failed to find general chat', '', ErrorDialog, { + title: _t('Failed to find the general chat for this community'), + description: _t("Failed to find the general chat for this community"), + }); + } + this.setState({contextMenuPosition: null}); // also close the menu + }; + + private onCommunityInviteClick = (ev: ButtonEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); + this.setState({contextMenuPosition: null}); // also close the menu + }; + private renderContextMenu = (): React.ReactNode => { if (!this.state.contextMenuPosition) return null; + const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + let hostingLink; const signupLink = getHostingLink("user-context-menu"); if (signupLink) { @@ -225,22 +286,138 @@ export default class UserMenu extends React.Component { ); } + let feedbackButton; + if (SettingsStore.getValue(UIFeature.Feedback)) { + feedbackButton = ; + } + + let primaryHeader = ( +
    + + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
    + ); + let primaryOptionList = ( + + + {homeButton} + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} + /> + this.onSettingsOpen(e, USER_SECURITY_TAB)} + /> + this.onSettingsOpen(e, null)} + /> + {/* */} + { feedbackButton } + + + + + + ); + let secondarySection = null; + + if (prototypeCommunityName) { + primaryHeader = ( +
    + + {prototypeCommunityName} + +
    + ); + primaryOptionList = ( + + + + + + ); + secondarySection = ( + +
    +
    +
    + + {OwnProfileStore.instance.displayName} + + + {MatrixClientPeg.get().getUserId()} + +
    +
    + + this.onSettingsOpen(e, null)} + /> + { feedbackButton } + + + + +
    + ) + } + + const classes = classNames({ + "mx_UserMenu_contextMenu": true, + "mx_UserMenu_contextMenu_prototype": !!prototypeCommunityName, + }); + return
    -
    - - {OwnProfileStore.instance.displayName} - - - {MatrixClientPeg.get().getUserId()} - -
    + {primaryHeader} {
    {hostingLink} - - {homeButton} - this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)} - /> - this.onSettingsOpen(e, USER_SECURITY_TAB)} - /> - this.onSettingsOpen(e, null)} - /> - {/* */} - - - - - + {primaryOptionList} + {secondarySection}
    ; }; public render() { const avatarSize = 32; // should match border-radius of the avatar - let name = {OwnProfileStore.instance.displayName}; + const displayName = OwnProfileStore.instance.displayName || MatrixClientPeg.get().getUserId(); + const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(avatarSize); + + const prototypeCommunityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + + let isPrototype = false; + let menuName = _t("User menu"); + let name = {displayName}; let buttons = ( {/* masked image in CSS */} ); + if (prototypeCommunityName) { + name = ( +
    + {prototypeCommunityName} + {displayName} +
    + ); + menuName = _t("Community and user menu"); + isPrototype = true; + } else if (SettingsStore.getValue("feature_communities_v2_prototypes")) { + name = ( +
    + {_t("Home")} + {displayName} +
    + ); + isPrototype = true; + } if (this.props.isMinimized) { name = null; buttons = null; @@ -309,6 +478,7 @@ export default class UserMenu extends React.Component { const classes = classNames({ 'mx_UserMenu': true, 'mx_UserMenu_minimized': this.props.isMinimized, + 'mx_UserMenu_prototype': isPrototype, }); return ( @@ -317,16 +487,16 @@ export default class UserMenu extends React.Component { className={classes} onClick={this.onOpenMenuClick} inputRef={this.buttonRef} - label={_t("User menu")} + label={menuName} isExpanded={!!this.state.contextMenuPosition} onContextMenu={this.onContextMenu} >
    ; - return (); + return ( + + ); } else { return (
    ); } diff --git a/src/components/structures/ViewSource.js b/src/components/structures/ViewSource.js index 326ba2c22f..0b969784e5 100644 --- a/src/components/structures/ViewSource.js +++ b/src/components/structures/ViewSource.js @@ -17,24 +17,21 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import SyntaxHighlight from '../views/elements/SyntaxHighlight'; import {_t} from "../../languageHandler"; import * as sdk from "../../index"; -export default createReactClass({ - displayName: 'ViewSource', - - propTypes: { +export default class ViewSource extends React.Component { + static propTypes = { content: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, roomId: PropTypes.string.isRequired, eventId: PropTypes.string.isRequired, - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( @@ -49,5 +46,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.js index 9b390d24cc..6df8158002 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.js @@ -16,8 +16,9 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import AsyncWrapper from '../../../AsyncWrapper'; -import * as sdk from '../../../index'; +import AuthPage from '../../views/auth/AuthPage'; +import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody'; +import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog'; export default class E2eSetup extends React.Component { static propTypes = { @@ -25,21 +26,11 @@ export default class E2eSetup extends React.Component { accountPassword: PropTypes.string, }; - constructor() { - super(); - // awkwardly indented because https://github.com/eslint/eslint/issues/11310 - this._createStorageDialogPromise = - import("../../../async-components/views/dialogs/secretstorage/CreateSecretStorageDialog"); - } - render() { - const AuthPage = sdk.getComponent("auth.AuthPage"); - const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); return ( - diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.js index 9877c53106..3fa2713a35 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; @@ -40,50 +39,47 @@ const PHASE_EMAIL_SENT = 3; // User has clicked the link in email and completed reset const PHASE_DONE = 4; -export default createReactClass({ - displayName: 'ForgotPassword', - - propTypes: { +export default class ForgotPassword extends React.Component { + static propTypes = { serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, onServerConfigChange: PropTypes.func.isRequired, onLoginClick: PropTypes.func, onComplete: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - phase: PHASE_FORGOT, - email: "", - password: "", - password2: "", - errorText: null, + state = { + phase: PHASE_FORGOT, + email: "", + password: "", + password2: "", + errorText: null, - // We perform liveliness checks later, but for now suppress the errors. - // We also track the server dead errors independently of the regular errors so - // that we can render it differently, and override any other error the user may - // be seeing. - serverIsAlive: true, - serverErrorIsFatal: false, - serverDeadError: "", - serverRequiresIdServer: null, - }; - }, + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: true, + serverErrorIsFatal: false, + serverDeadError: "", + serverRequiresIdServer: null, + }; - componentDidMount: function() { + componentDidMount() { this.reset = null; this._checkServerLiveliness(this.props.serverConfig); - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Do a liveliness check on the new URLs this._checkServerLiveliness(newProps.serverConfig); - }, + } - _checkServerLiveliness: async function(serverConfig) { + async _checkServerLiveliness(serverConfig) { try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( serverConfig.hsUrl, @@ -100,9 +96,9 @@ export default createReactClass({ } catch (e) { this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); } - }, + } - submitPasswordReset: function(email, password) { + submitPasswordReset(email, password) { this.setState({ phase: PHASE_SENDING_EMAIL, }); @@ -117,9 +113,9 @@ export default createReactClass({ phase: PHASE_FORGOT, }); }); - }, + } - onVerify: async function(ev) { + onVerify = async ev => { ev.preventDefault(); if (!this.reset) { console.error("onVerify called before submitPasswordReset!"); @@ -131,9 +127,9 @@ export default createReactClass({ } catch (err) { this.showErrorDialog(err.message); } - }, + }; - onSubmitForm: async function(ev) { + onSubmitForm = async ev => { ev.preventDefault(); // refresh the server errors, just in case the server came back online @@ -166,41 +162,41 @@ export default createReactClass({ }, }); } - }, + }; - onInputChanged: function(stateKey, ev) { + onInputChanged = (stateKey, ev) => { this.setState({ [stateKey]: ev.target.value, }); - }, + }; - async onServerDetailsNextPhaseClick() { + onServerDetailsNextPhaseClick = async () => { this.setState({ phase: PHASE_FORGOT, }); - }, + }; - onEditServerDetailsClick(ev) { + onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ phase: PHASE_SERVER_DETAILS, }); - }, + }; - onLoginClick: function(ev) { + onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); - }, + }; - showErrorDialog: function(body, title) { + showErrorDialog(body, title) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, { title: title, description: body, }); - }, + } renderServerDetails() { const ServerConfig = sdk.getComponent("auth.ServerConfig"); @@ -218,7 +214,7 @@ export default createReactClass({ submitText={_t("Next")} submitClass="mx_Login_submit" />; - }, + } renderForgot() { const Field = sdk.getComponent('elements.Field'); @@ -335,12 +331,12 @@ export default createReactClass({ {_t('Sign in instead')}
    ; - }, + } renderSendingEmail() { const Spinner = sdk.getComponent("elements.Spinner"); return ; - }, + } renderEmailSent() { return
    @@ -350,7 +346,7 @@ export default createReactClass({
    ; - }, + } renderDone() { return
    @@ -363,9 +359,9 @@ export default createReactClass({
    ; - }, + } - render: function() { + render() { const AuthHeader = sdk.getComponent("auth.AuthHeader"); const AuthBody = sdk.getComponent("auth.AuthBody"); @@ -397,5 +393,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/structures/auth/Login.js b/src/components/structures/auth/Login.js index 3bc363f863..118eed59e3 100644 --- a/src/components/structures/auth/Login.js +++ b/src/components/structures/auth/Login.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import {_t, _td} from '../../../languageHandler'; import * as sdk from '../../../index'; @@ -29,6 +28,8 @@ import classNames from "classnames"; import AuthPage from "../../views/auth/AuthPage"; import SSOButton from "../../views/elements/SSOButton"; import PlatformPeg from '../../../PlatformPeg'; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -53,13 +54,11 @@ _td("Invalid base_url for m.identity_server"); _td("Identity server URL does not appear to be a valid identity server"); _td("General failure"); -/** +/* * A wire component which glues together login UI components and Login logic */ -export default createReactClass({ - displayName: 'Login', - - propTypes: { +export default class LoginComponent extends React.Component { + static propTypes = { // Called when the user has logged in. Params: // - The object returned by the login API // - The user's password, if applicable, (may be cached in memory for a @@ -85,10 +84,14 @@ export default createReactClass({ serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, isSyncing: PropTypes.bool, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this._unmounted = false; + + this.state = { busy: false, busyLoggingIn: null, errorText: null, @@ -113,11 +116,6 @@ export default createReactClass({ serverErrorIsFatal: false, serverDeadError: "", }; - }, - - // TODO: [REACT-WARNING] Move this to constructor - UNSAFE_componentWillMount: function() { - this._unmounted = false; // map from login step type to a function which will render a control // letting you do that login type @@ -128,35 +126,38 @@ export default createReactClass({ 'm.login.cas': () => this._renderSsoStep("cas"), 'm.login.sso': () => this._renderSsoStep("sso"), }; - - this._initLoginLogic(); - }, - - componentWillUnmount: function() { - this._unmounted = true; - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + this._initLoginLogic(); + } + + componentWillUnmount() { + this._unmounted = true; + } + + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Ensure that we end up actually logging in to the right place this._initLoginLogic(newProps.serverConfig.hsUrl, newProps.serverConfig.isUrl); - }, + } - onPasswordLoginError: function(errorText) { + onPasswordLoginError = errorText => { this.setState({ errorText, loginIncorrect: Boolean(errorText), }); - }, + }; - isBusy: function() { - return this.state.busy || this.props.busy; - }, + isBusy = () => this.state.busy || this.props.busy; - onPasswordLogin: async function(username, phoneCountry, phoneNumber, password) { + onPasswordLogin = async (username, phoneCountry, phoneNumber, password) => { if (!this.state.serverIsAlive) { this.setState({busy: true}); // Do a quick liveliness check on the URLs @@ -263,13 +264,13 @@ export default createReactClass({ loginIncorrect: error.httpStatus === 401 || error.httpStatus === 403, }); }); - }, + }; - onUsernameChanged: function(username) { + onUsernameChanged = username => { this.setState({ username: username }); - }, + }; - onUsernameBlur: async function(username) { + onUsernameBlur = async username => { const doWellknownLookup = username[0] === "@"; this.setState({ username: username, @@ -314,19 +315,19 @@ export default createReactClass({ }); } } - }, + }; - onPhoneCountryChanged: function(phoneCountry) { + onPhoneCountryChanged = phoneCountry => { this.setState({ phoneCountry: phoneCountry }); - }, + }; - onPhoneNumberChanged: function(phoneNumber) { + onPhoneNumberChanged = phoneNumber => { this.setState({ phoneNumber: phoneNumber, }); - }, + }; - onPhoneNumberBlur: function(phoneNumber) { + onPhoneNumberBlur = phoneNumber => { // Validate the phone number entered if (!PHONE_NUMBER_REGEX.test(phoneNumber)) { this.setState({ @@ -339,15 +340,15 @@ export default createReactClass({ canTryLogin: true, }); } - }, + }; - onRegisterClick: function(ev) { + onRegisterClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onRegisterClick(); - }, + }; - onTryRegisterClick: function(ev) { + onTryRegisterClick = ev => { const step = this._getCurrentFlowStep(); if (step === 'm.login.sso' || step === 'm.login.cas') { // If we're showing SSO it means that registration is also probably disabled, @@ -361,23 +362,23 @@ export default createReactClass({ // Don't intercept - just go through to the register page this.onRegisterClick(ev); } - }, + }; - async onServerDetailsNextPhaseClick() { + onServerDetailsNextPhaseClick = () => { this.setState({ phase: PHASE_LOGIN, }); - }, + }; - onEditServerDetailsClick(ev) { + onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ phase: PHASE_SERVER_DETAILS, }); - }, + }; - _initLoginLogic: async function(hsUrl, isUrl) { + async _initLoginLogic(hsUrl, isUrl) { hsUrl = hsUrl || this.props.serverConfig.hsUrl; isUrl = isUrl || this.props.serverConfig.isUrl; @@ -465,9 +466,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - _isSupportedFlow: function(flow) { + _isSupportedFlow(flow) { // technically the flow can have multiple steps, but no one does this // for login and loginLogic doesn't support it so we can ignore it. if (!this._stepRendererMap[flow.type]) { @@ -475,11 +476,11 @@ export default createReactClass({ return false; } return true; - }, + } - _getCurrentFlowStep: function() { + _getCurrentFlowStep() { return this._loginLogic ? this._loginLogic.getCurrentFlowStep() : null; - }, + } _errorTextFromError(err) { let errCode = err.errcode; @@ -526,7 +527,7 @@ export default createReactClass({ } return errorText; - }, + } renderServerComponent() { const ServerConfig = sdk.getComponent("auth.ServerConfig"); @@ -552,7 +553,7 @@ export default createReactClass({ delayTimeMs={250} {...serverDetailsProps} />; - }, + } renderLoginComponentForStep() { if (PHASES_ENABLED && this.state.phase !== PHASE_LOGIN) { @@ -572,9 +573,9 @@ export default createReactClass({ } return null; - }, + } - _renderPasswordStep: function() { + _renderPasswordStep = () => { const PasswordLogin = sdk.getComponent('auth.PasswordLogin'); let onEditServerDetailsClick = null; @@ -603,9 +604,9 @@ export default createReactClass({ busy={this.props.isSyncing || this.state.busyLoggingIn} /> ); - }, + }; - _renderSsoStep: function(loginType) { + _renderSsoStep = loginType => { const SignInToText = sdk.getComponent('views.auth.SignInToText'); let onEditServerDetailsClick = null; @@ -634,9 +635,9 @@ export default createReactClass({ /> ); - }, + }; - render: function() { + render() { const Loader = sdk.getComponent("elements.Spinner"); const InlineSpinner = sdk.getComponent("elements.InlineSpinner"); const AuthHeader = sdk.getComponent("auth.AuthHeader"); @@ -680,7 +681,7 @@ export default createReactClass({ {_t("If you've joined lots of rooms, this might take a while")} } ; - } else { + } else if (SettingsStore.getValue(UIFeature.Registration)) { footer = ( { _t('Create account') } @@ -704,5 +705,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/structures/auth/PostRegistration.js b/src/components/structures/auth/PostRegistration.js index 687ab9a195..aa36de6596 100644 --- a/src/components/structures/auth/PostRegistration.js +++ b/src/components/structures/auth/PostRegistration.js @@ -15,29 +15,24 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from '../../../languageHandler'; import AuthPage from "../../views/auth/AuthPage"; -export default createReactClass({ - displayName: 'PostRegistration', - - propTypes: { +export default class PostRegistration extends React.Component { + static propTypes = { onComplete: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - avatarUrl: null, - errorString: null, - busy: false, - }; - }, + state = { + avatarUrl: null, + errorString: null, + busy: false, + }; - componentDidMount: function() { + componentDidMount() { // There is some assymetry between ChangeDisplayName and ChangeAvatar, // as ChangeDisplayName will auto-get the name but ChangeAvatar expects // the URL to be passed to you (because it's also used for room avatars). @@ -55,9 +50,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - render: function() { + render() { const ChangeDisplayName = sdk.getComponent('settings.ChangeDisplayName'); const ChangeAvatar = sdk.getComponent('settings.ChangeAvatar'); const AuthHeader = sdk.getComponent('auth.AuthHeader'); @@ -78,5 +73,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/structures/auth/Registration.js b/src/components/structures/auth/Registration.js index 13e48f6287..630e04da9c 100644 --- a/src/components/structures/auth/Registration.js +++ b/src/components/structures/auth/Registration.js @@ -19,7 +19,6 @@ limitations under the License. import Matrix from 'matrix-js-sdk'; import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; @@ -43,10 +42,8 @@ const PHASE_REGISTRATION = 1; // Enable phases for registration const PHASES_ENABLED = true; -export default createReactClass({ - displayName: 'Registration', - - propTypes: { +export default class Registration extends React.Component { + static propTypes = { // Called when the user has logged in. Params: // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken // - The user's password, if available and applicable (may be cached in memory @@ -65,12 +62,13 @@ export default createReactClass({ onLoginClick: PropTypes.func.isRequired, onServerConfigChange: PropTypes.func.isRequired, defaultDeviceDisplayName: PropTypes.string, - }, + }; + + constructor(props) { + super(props); - getInitialState: function() { const serverType = ServerType.getTypeFromServerConfig(this.props.serverConfig); - - return { + this.state = { busy: false, errorText: null, // We remember the values entered by the user because @@ -118,14 +116,15 @@ export default createReactClass({ // this is the user ID that's logged in. differentLoggedInUserId: null, }; - }, + } - componentDidMount: function() { + componentDidMount() { this._unmounted = false; this._replaceClient(); - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(newProps) { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; @@ -142,7 +141,7 @@ export default createReactClass({ phase: this.getDefaultPhaseForServerType(serverType), }); } - }, + } getDefaultPhaseForServerType(type) { switch (type) { @@ -155,9 +154,9 @@ export default createReactClass({ case ServerType.ADVANCED: return PHASE_SERVER_DETAILS; } - }, + } - onServerTypeChange(type) { + onServerTypeChange = type => { this.setState({ serverType: type, }); @@ -184,9 +183,9 @@ export default createReactClass({ this.setState({ phase: this.getDefaultPhaseForServerType(type), }); - }, + }; - _replaceClient: async function(serverConfig) { + async _replaceClient(serverConfig) { this.setState({ errorText: null, serverDeadError: null, @@ -286,18 +285,18 @@ export default createReactClass({ showGenericError(e); } } - }, + } - onFormSubmit: function(formVals) { + onFormSubmit = formVals => { this.setState({ errorText: "", busy: true, formVals: formVals, doingUIAuth: true, }); - }, + }; - _requestEmailToken: function(emailAddress, clientSecret, sendAttempt, sessionId) { + _requestEmailToken = (emailAddress, clientSecret, sendAttempt, sessionId) => { return this.state.matrixClient.requestRegisterEmailToken( emailAddress, clientSecret, @@ -309,9 +308,9 @@ export default createReactClass({ session_id: sessionId, }), ); - }, + } - _onUIAuthFinished: async function(success, response, extra) { + _onUIAuthFinished = async (success, response, extra) => { if (!success) { let msg = response.message || response.toString(); // can we give a better error message? @@ -395,9 +394,9 @@ export default createReactClass({ } this.setState(newState); - }, + }; - _setupPushers: function() { + _setupPushers() { if (!this.props.brand) { return Promise.resolve(); } @@ -418,15 +417,15 @@ export default createReactClass({ }, (error) => { console.error("Couldn't get pushers: " + error); }); - }, + } - onLoginClick: function(ev) { + onLoginClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); - }, + }; - onGoToFormClicked(ev) { + onGoToFormClicked = ev => { ev.preventDefault(); ev.stopPropagation(); this._replaceClient(); @@ -435,23 +434,23 @@ export default createReactClass({ doingUIAuth: false, phase: PHASE_REGISTRATION, }); - }, + }; - async onServerDetailsNextPhaseClick() { + onServerDetailsNextPhaseClick = async () => { this.setState({ phase: PHASE_REGISTRATION, }); - }, + }; - onEditServerDetailsClick(ev) { + onEditServerDetailsClick = ev => { ev.preventDefault(); ev.stopPropagation(); this.setState({ phase: PHASE_SERVER_DETAILS, }); - }, + }; - _makeRegisterRequest: function(auth) { + _makeRegisterRequest = auth => { // We inhibit login if we're trying to register with an email address: this // avoids a lot of complex race conditions that can occur if we try to log // the user in one one or both of the tabs they might end up with after @@ -471,20 +470,20 @@ export default createReactClass({ if (auth) registerParams.auth = auth; if (inhibitLogin !== undefined && inhibitLogin !== null) registerParams.inhibit_login = inhibitLogin; return this.state.matrixClient.registerRequest(registerParams); - }, + }; - _getUIAuthInputs: function() { + _getUIAuthInputs() { return { emailAddress: this.state.formVals.email, phoneCountry: this.state.formVals.phoneCountry, phoneNumber: this.state.formVals.phoneNumber, }; - }, + } // Links to the login page shown after registration is completed are routed through this // which checks the user hasn't already logged in somewhere else (perhaps we should do // this more generally?) - _onLoginClickWithCheck: async function(ev) { + _onLoginClickWithCheck = async ev => { ev.preventDefault(); const sessionLoaded = await Lifecycle.loadSession({ignoreGuest: true}); @@ -492,7 +491,7 @@ export default createReactClass({ // ok fine, there's still no session: really go to the login page this.props.onLoginClick(); } - }, + }; renderServerComponent() { const ServerTypeSelector = sdk.getComponent("auth.ServerTypeSelector"); @@ -553,7 +552,7 @@ export default createReactClass({ /> {serverDetails} ; - }, + } renderRegisterComponent() { if (PHASES_ENABLED && this.state.phase !== PHASE_REGISTRATION) { @@ -608,9 +607,9 @@ export default createReactClass({ serverRequiresIdServer={this.state.serverRequiresIdServer} />; } - }, + } - render: function() { + render() { const AuthHeader = sdk.getComponent('auth.AuthHeader'); const AuthBody = sdk.getComponent("auth.AuthBody"); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); @@ -706,5 +705,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/auth/AuthFooter.js b/src/components/views/auth/AuthFooter.js index 1309800772..3de5a19350 100644 --- a/src/components/views/auth/AuthFooter.js +++ b/src/components/views/auth/AuthFooter.js @@ -18,16 +18,13 @@ limitations under the License. import { _t } from '../../../languageHandler'; import React from 'react'; -import createReactClass from 'create-react-class'; -export default createReactClass({ - displayName: 'AuthFooter', - - render: function() { +export default class AuthFooter extends React.Component { + render() { return ( ); - }, -}); + } +} diff --git a/src/components/views/auth/AuthHeader.js b/src/components/views/auth/AuthHeader.js index 6e787ba77c..57499e397c 100644 --- a/src/components/views/auth/AuthHeader.js +++ b/src/components/views/auth/AuthHeader.js @@ -17,17 +17,14 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; -export default createReactClass({ - displayName: 'AuthHeader', - - propTypes: { +export default class AuthHeader extends React.Component { + static propTypes = { disableLanguageSelector: PropTypes.bool, - }, + }; - render: function() { + render() { const AuthHeaderLogo = sdk.getComponent('auth.AuthHeaderLogo'); const LanguageSelector = sdk.getComponent('views.auth.LanguageSelector'); @@ -37,5 +34,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/auth/CaptchaForm.js b/src/components/views/auth/CaptchaForm.js index e162603b01..783d519621 100644 --- a/src/components/views/auth/CaptchaForm.js +++ b/src/components/views/auth/CaptchaForm.js @@ -15,7 +15,6 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; @@ -24,36 +23,31 @@ const DIV_ID = 'mx_recaptcha'; /** * A pure UI component which displays a captcha form. */ -export default createReactClass({ - displayName: 'CaptchaForm', - - propTypes: { +export default class CaptchaForm extends React.Component { + static propTypes = { sitePublicKey: PropTypes.string, // called with the captcha response onCaptchaResponse: PropTypes.func, - }, + }; - getDefaultProps: function() { - return { - onCaptchaResponse: () => {}, - }; - }, + static defaultProps = { + onCaptchaResponse: () => {}, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { errorText: null, }; - }, - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { this._captchaWidgetId = null; this._recaptchaContainer = createRef(); - }, + } - componentDidMount: function() { + componentDidMount() { // Just putting a script tag into the returned jsx doesn't work, annoyingly, // so we do this instead. if (global.grecaptcha) { @@ -68,13 +62,13 @@ export default createReactClass({ ); this._recaptchaContainer.current.appendChild(scriptTag); } - }, + } - componentWillUnmount: function() { + componentWillUnmount() { this._resetRecaptcha(); - }, + } - _renderRecaptcha: function(divId) { + _renderRecaptcha(divId) { if (!global.grecaptcha) { console.error("grecaptcha not loaded!"); throw new Error("Recaptcha did not load successfully"); @@ -93,15 +87,15 @@ export default createReactClass({ sitekey: publicKey, callback: this.props.onCaptchaResponse, }); - }, + } - _resetRecaptcha: function() { + _resetRecaptcha() { if (this._captchaWidgetId !== null) { global.grecaptcha.reset(this._captchaWidgetId); } - }, + } - _onCaptchaLoaded: function() { + _onCaptchaLoaded() { console.log("Loaded recaptcha script."); try { this._renderRecaptcha(DIV_ID); @@ -110,9 +104,9 @@ export default createReactClass({ errorText: e.toString(), }); } - }, + } - render: function() { + render() { let error = null; if (this.state.errorText) { error = ( @@ -131,5 +125,5 @@ export default createReactClass({ { error } ); - }, -}); + } +} diff --git a/src/components/views/auth/CustomServerDialog.js b/src/components/views/auth/CustomServerDialog.js index 7b2c8f88aa..138f8c4689 100644 --- a/src/components/views/auth/CustomServerDialog.js +++ b/src/components/views/auth/CustomServerDialog.js @@ -16,14 +16,11 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; -export default createReactClass({ - displayName: 'CustomServerDialog', - - render: function() { +export default class CustomServerDialog extends React.Component { + render() { const brand = SdkConfig.get().brand; return (
    @@ -46,5 +43,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.js b/src/components/views/auth/InteractiveAuthEntryComponents.js index 7080eb3602..47263c1e21 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.js +++ b/src/components/views/auth/InteractiveAuthEntryComponents.js @@ -17,7 +17,6 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import url from 'url'; import classnames from 'classnames'; @@ -26,6 +25,7 @@ import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; +import Spinner from "../elements/Spinner"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -75,14 +75,10 @@ import AccessibleButton from "../elements/AccessibleButton"; export const DEFAULT_PHASE = 0; -export const PasswordAuthEntry = createReactClass({ - displayName: 'PasswordAuthEntry', +export class PasswordAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.password"; - statics: { - LOGIN_TYPE: "m.login.password", - }, - - propTypes: { + static propTypes = { matrixClient: PropTypes.object.isRequired, submitAuthDict: PropTypes.func.isRequired, errorText: PropTypes.string, @@ -90,19 +86,17 @@ export const PasswordAuthEntry = createReactClass({ // happen? busy: PropTypes.bool, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - }, + } - getInitialState: function() { - return { - password: "", - }; - }, + state = { + password: "", + }; - _onSubmit: function(e) { + _onSubmit = e => { e.preventDefault(); if (this.props.busy) return; @@ -117,16 +111,16 @@ export const PasswordAuthEntry = createReactClass({ }, password: this.state.password, }); - }, + }; - _onPasswordFieldChange: function(ev) { + _onPasswordFieldChange = ev => { // enable the submit button iff the password is non-empty this.setState({ password: ev.target.value, }); - }, + }; - render: function() { + render() { const passwordBoxClass = classnames({ "error": this.props.errorText, }); @@ -176,36 +170,32 @@ export const PasswordAuthEntry = createReactClass({ { errorSection } ); - }, -}); + } +} -export const RecaptchaAuthEntry = createReactClass({ - displayName: 'RecaptchaAuthEntry', +export class RecaptchaAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.recaptcha"; - statics: { - LOGIN_TYPE: "m.login.recaptcha", - }, - - propTypes: { + static propTypes = { submitAuthDict: PropTypes.func.isRequired, stageParams: PropTypes.object.isRequired, errorText: PropTypes.string, busy: PropTypes.bool, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - }, + } - _onCaptchaResponse: function(response) { + _onCaptchaResponse = response => { this.props.submitAuthDict({ type: RecaptchaAuthEntry.LOGIN_TYPE, response: response, }); - }, + }; - render: function() { + render() { if (this.props.busy) { const Loader = sdk.getComponent("elements.Spinner"); return ; @@ -241,31 +231,24 @@ export const RecaptchaAuthEntry = createReactClass({ { errorSection } ); - }, -}); + } +} -export const TermsAuthEntry = createReactClass({ - displayName: 'TermsAuthEntry', +export class TermsAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.terms"; - statics: { - LOGIN_TYPE: "m.login.terms", - }, - - propTypes: { + static propTypes = { submitAuthDict: PropTypes.func.isRequired, stageParams: PropTypes.object.isRequired, errorText: PropTypes.string, busy: PropTypes.bool, showContinue: PropTypes.bool, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { - this.props.onPhaseChange(DEFAULT_PHASE); - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Move this to constructor - componentWillMount: function() { // example stageParams: // // { @@ -310,17 +293,22 @@ export const TermsAuthEntry = createReactClass({ pickedPolicies.push(langPolicy); } - this.setState({ - "toggledPolicies": initToggles, - "policies": pickedPolicies, - }); - }, + this.state = { + toggledPolicies: initToggles, + policies: pickedPolicies, + }; + } - tryContinue: function() { + + componentDidMount() { + this.props.onPhaseChange(DEFAULT_PHASE); + } + + tryContinue = () => { this._trySubmit(); - }, + }; - _togglePolicy: function(policyId) { + _togglePolicy(policyId) { const newToggles = {}; for (const policy of this.state.policies) { let checked = this.state.toggledPolicies[policy.id]; @@ -329,9 +317,9 @@ export const TermsAuthEntry = createReactClass({ newToggles[policy.id] = checked; } this.setState({"toggledPolicies": newToggles}); - }, + } - _trySubmit: function() { + _trySubmit = () => { let allChecked = true; for (const policy of this.state.policies) { const checked = this.state.toggledPolicies[policy.id]; @@ -340,9 +328,9 @@ export const TermsAuthEntry = createReactClass({ if (allChecked) this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); else this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); - }, + }; - render: function() { + render() { if (this.props.busy) { const Loader = sdk.getComponent("elements.Spinner"); return ; @@ -387,17 +375,13 @@ export const TermsAuthEntry = createReactClass({ { submitButton } ); - }, -}); + } +} -export const EmailIdentityAuthEntry = createReactClass({ - displayName: 'EmailIdentityAuthEntry', +export class EmailIdentityAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.email.identity"; - statics: { - LOGIN_TYPE: "m.login.email.identity", - }, - - propTypes: { + static propTypes = { matrixClient: PropTypes.object.isRequired, submitAuthDict: PropTypes.func.isRequired, authSessionId: PropTypes.string.isRequired, @@ -407,13 +391,13 @@ export const EmailIdentityAuthEntry = createReactClass({ fail: PropTypes.func.isRequired, setEmailSid: PropTypes.func.isRequired, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); - }, + } - render: function() { + render() { // This component is now only displayed once the token has been requested, // so we know the email has been sent. It can also get loaded after the user // has clicked the validation link if the server takes a while to propagate @@ -421,8 +405,12 @@ export const EmailIdentityAuthEntry = createReactClass({ // the validation link, we won't know the email address, so if we don't have it, // assume that the link has been clicked and the server will realise when we poll. if (this.props.inputs.emailAddress === undefined) { - const Loader = sdk.getComponent("elements.Spinner"); - return ; + return ; + } else if (this.props.stageState?.emailSid) { + // we only have a session ID if the user has clicked the link in their email, + // so show a loading state instead of "an email has been sent to..." because + // that's confusing when you've already read that email. + return ; } else { return (
    @@ -434,17 +422,13 @@ export const EmailIdentityAuthEntry = createReactClass({
    ); } - }, -}); + } +} -export const MsisdnAuthEntry = createReactClass({ - displayName: 'MsisdnAuthEntry', +export class MsisdnAuthEntry extends React.Component { + static LOGIN_TYPE = "m.login.msisdn"; - statics: { - LOGIN_TYPE: "m.login.msisdn", - }, - - propTypes: { + static propTypes = { inputs: PropTypes.shape({ phoneCountry: PropTypes.string, phoneNumber: PropTypes.string, @@ -454,16 +438,14 @@ export const MsisdnAuthEntry = createReactClass({ submitAuthDict: PropTypes.func.isRequired, matrixClient: PropTypes.object, onPhaseChange: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - token: '', - requestingToken: false, - }; - }, + state = { + token: '', + requestingToken: false, + }; - componentDidMount: function() { + componentDidMount() { this.props.onPhaseChange(DEFAULT_PHASE); this._submitUrl = null; @@ -477,12 +459,12 @@ export const MsisdnAuthEntry = createReactClass({ }).finally(() => { this.setState({requestingToken: false}); }); - }, + } /* * Requests a verification token by SMS. */ - _requestMsisdnToken: function() { + _requestMsisdnToken() { return this.props.matrixClient.requestRegisterMsisdnToken( this.props.inputs.phoneCountry, this.props.inputs.phoneNumber, @@ -493,15 +475,15 @@ export const MsisdnAuthEntry = createReactClass({ this._sid = result.sid; this._msisdn = result.msisdn; }); - }, + } - _onTokenChange: function(e) { + _onTokenChange = e => { this.setState({ token: e.target.value, }); - }, + }; - _onFormSubmit: async function(e) { + _onFormSubmit = async e => { e.preventDefault(); if (this.state.token == '') return; @@ -552,9 +534,9 @@ export const MsisdnAuthEntry = createReactClass({ this.props.fail(e); console.log("Failed to submit msisdn token"); } - }, + }; - render: function() { + render() { if (this.state.requestingToken) { const Loader = sdk.getComponent("elements.Spinner"); return ; @@ -598,8 +580,8 @@ export const MsisdnAuthEntry = createReactClass({ ); } - }, -}); + } +} export class SSOAuthEntry extends React.Component { static propTypes = { @@ -686,46 +668,46 @@ export class SSOAuthEntry extends React.Component { } } -export const FallbackAuthEntry = createReactClass({ - displayName: 'FallbackAuthEntry', - - propTypes: { +export class FallbackAuthEntry extends React.Component { + static propTypes = { matrixClient: PropTypes.object.isRequired, authSessionId: PropTypes.string.isRequired, loginType: PropTypes.string.isRequired, submitAuthDict: PropTypes.func.isRequired, errorText: PropTypes.string, onPhaseChange: PropTypes.func.isRequired, - }, + }; - componentDidMount: function() { - this.props.onPhaseChange(DEFAULT_PHASE); - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { // we have to make the user click a button, as browsers will block // the popup if we open it immediately. this._popupWindow = null; window.addEventListener("message", this._onReceiveMessage); this._fallbackButton = createRef(); - }, + } - componentWillUnmount: function() { + + componentDidMount() { + this.props.onPhaseChange(DEFAULT_PHASE); + } + + componentWillUnmount() { window.removeEventListener("message", this._onReceiveMessage); if (this._popupWindow) { this._popupWindow.close(); } - }, + } - focus: function() { + focus = () => { if (this._fallbackButton.current) { this._fallbackButton.current.focus(); } - }, + }; - _onShowFallbackClick: function(e) { + _onShowFallbackClick = e => { e.preventDefault(); e.stopPropagation(); @@ -735,18 +717,18 @@ export const FallbackAuthEntry = createReactClass({ ); this._popupWindow = window.open(url); this._popupWindow.opener = null; - }, + }; - _onReceiveMessage: function(event) { + _onReceiveMessage = event => { if ( event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl() ) { this.props.submitAuthDict({}); } - }, + }; - render: function() { + render() { let errorSection; if (this.props.errorText) { errorSection = ( @@ -761,8 +743,8 @@ export const FallbackAuthEntry = createReactClass({ {errorSection} ); - }, -}); + } +} const AuthEntryComponents = [ PasswordAuthEntry, diff --git a/src/components/views/auth/RegistrationForm.js b/src/components/views/auth/RegistrationForm.js index 17c65fa94e..c07486d3bd 100644 --- a/src/components/views/auth/RegistrationForm.js +++ b/src/components/views/auth/RegistrationForm.js @@ -18,7 +18,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import * as Email from '../../../email'; @@ -39,13 +38,11 @@ const FIELD_PASSWORD_CONFIRM = 'field_password_confirm'; const PASSWORD_MIN_SCORE = 3; // safely unguessable: moderate protection from offline slow-hash scenario. -/** +/* * A pure UI component which displays a registration form. */ -export default createReactClass({ - displayName: 'RegistrationForm', - - propTypes: { +export default class RegistrationForm extends React.Component { + static propTypes = { // Values pre-filled in the input boxes when the component loads defaultEmail: PropTypes.string, defaultPhoneCountry: PropTypes.string, @@ -58,17 +55,17 @@ export default createReactClass({ serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, canSubmit: PropTypes.bool, serverRequiresIdServer: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - onValidationChange: console.error, - canSubmit: true, - }; - }, + static defaultProps = { + onValidationChange: console.error, + canSubmit: true, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { // Field error codes by field ID fieldValid: {}, // The ISO2 country code selected in the phone number entry @@ -80,9 +77,9 @@ export default createReactClass({ passwordConfirm: this.props.defaultPassword || "", passwordComplexity: null, }; - }, + } - onSubmit: async function(ev) { + onSubmit = async ev => { ev.preventDefault(); if (!this.props.canSubmit) return; @@ -118,7 +115,7 @@ export default createReactClass({ title: _t("Warning!"), description: desc, button: _t("Continue"), - onFinished: function(confirmed) { + onFinished(confirmed) { if (confirmed) { self._doSubmit(ev); } @@ -127,9 +124,9 @@ export default createReactClass({ } else { self._doSubmit(ev); } - }, + }; - _doSubmit: function(ev) { + _doSubmit(ev) { const email = this.state.email.trim(); const promise = this.props.onRegisterClick({ username: this.state.username.trim(), @@ -145,7 +142,7 @@ export default createReactClass({ ev.target.disabled = false; }); } - }, + } async verifyFieldsBeforeSubmit() { // Blur the active element if any, so we first run its blur validation, @@ -196,12 +193,12 @@ export default createReactClass({ invalidField.focus(); invalidField.validate({ allowEmpty: false, focused: true }); return false; - }, + } /** * @returns {boolean} true if all fields were valid last time they were validated. */ - allFieldsValid: function() { + allFieldsValid() { const keys = Object.keys(this.state.fieldValid); for (let i = 0; i < keys.length; ++i) { if (!this.state.fieldValid[keys[i]]) { @@ -209,7 +206,7 @@ export default createReactClass({ } } return true; - }, + } findFirstInvalidField(fieldIDs) { for (const fieldID of fieldIDs) { @@ -218,34 +215,34 @@ export default createReactClass({ } } return null; - }, + } - markFieldValid: function(fieldID, valid) { + markFieldValid(fieldID, valid) { const { fieldValid } = this.state; fieldValid[fieldID] = valid; this.setState({ fieldValid, }); - }, + } - onEmailChange(ev) { + onEmailChange = ev => { this.setState({ email: ev.target.value, }); - }, + }; - async onEmailValidate(fieldState) { + onEmailValidate = async fieldState => { const result = await this.validateEmailRules(fieldState); this.markFieldValid(FIELD_EMAIL, result.valid); return result; - }, + }; - validateEmailRules: withValidation({ + validateEmailRules = withValidation({ description: () => _t("Use an email address to recover your account"), rules: [ { key: "required", - test: function({ value, allowEmpty }) { + test({ value, allowEmpty }) { return allowEmpty || !this._authStepIsRequired('m.login.email.identity') || !!value; }, invalid: () => _t("Enter email address (required on this homeserver)"), @@ -256,31 +253,31 @@ export default createReactClass({ invalid: () => _t("Doesn't look like a valid email address"), }, ], - }), + }); - onPasswordChange(ev) { + onPasswordChange = ev => { this.setState({ password: ev.target.value, }); - }, + }; - onPasswordValidate(result) { + onPasswordValidate = result => { this.markFieldValid(FIELD_PASSWORD, result.valid); - }, + }; - onPasswordConfirmChange(ev) { + onPasswordConfirmChange = ev => { this.setState({ passwordConfirm: ev.target.value, }); - }, + }; - async onPasswordConfirmValidate(fieldState) { + onPasswordConfirmValidate = async fieldState => { const result = await this.validatePasswordConfirmRules(fieldState); this.markFieldValid(FIELD_PASSWORD_CONFIRM, result.valid); return result; - }, + }; - validatePasswordConfirmRules: withValidation({ + validatePasswordConfirmRules = withValidation({ rules: [ { key: "required", @@ -289,39 +286,39 @@ export default createReactClass({ }, { key: "match", - test: function({ value }) { + test({ value }) { return !value || value === this.state.password; }, invalid: () => _t("Passwords don't match"), }, ], - }), + }); - onPhoneCountryChange(newVal) { + onPhoneCountryChange = newVal => { this.setState({ phoneCountry: newVal.iso2, phonePrefix: newVal.prefix, }); - }, + }; - onPhoneNumberChange(ev) { + onPhoneNumberChange = ev => { this.setState({ phoneNumber: ev.target.value, }); - }, + }; - async onPhoneNumberValidate(fieldState) { + onPhoneNumberValidate = async fieldState => { const result = await this.validatePhoneNumberRules(fieldState); this.markFieldValid(FIELD_PHONE_NUMBER, result.valid); return result; - }, + }; - validatePhoneNumberRules: withValidation({ + validatePhoneNumberRules = withValidation({ description: () => _t("Other users can invite you to rooms using your contact details"), rules: [ { key: "required", - test: function({ value, allowEmpty }) { + test({ value, allowEmpty }) { return allowEmpty || !this._authStepIsRequired('m.login.msisdn') || !!value; }, invalid: () => _t("Enter phone number (required on this homeserver)"), @@ -332,21 +329,21 @@ export default createReactClass({ invalid: () => _t("Doesn't look like a valid phone number"), }, ], - }), + }); - onUsernameChange(ev) { + onUsernameChange = ev => { this.setState({ username: ev.target.value, }); - }, + }; - async onUsernameValidate(fieldState) { + onUsernameValidate = async fieldState => { const result = await this.validateUsernameRules(fieldState); this.markFieldValid(FIELD_USERNAME, result.valid); return result; - }, + }; - validateUsernameRules: withValidation({ + validateUsernameRules = withValidation({ description: () => _t("Use lowercase letters, numbers, dashes and underscores only"), rules: [ { @@ -360,7 +357,7 @@ export default createReactClass({ invalid: () => _t("Some characters not allowed"), }, ], - }), + }); /** * A step is required if all flows include that step. @@ -372,7 +369,7 @@ export default createReactClass({ return this.props.flows.every((flow) => { return flow.stages.includes(step); }); - }, + } /** * A step is used if any flows include that step. @@ -384,7 +381,7 @@ export default createReactClass({ return this.props.flows.some((flow) => { return flow.stages.includes(step); }); - }, + } _showEmail() { const haveIs = Boolean(this.props.serverConfig.isUrl); @@ -395,7 +392,7 @@ export default createReactClass({ return false; } return true; - }, + } _showPhoneNumber() { const threePidLogin = !SdkConfig.get().disable_3pid_login; @@ -408,7 +405,7 @@ export default createReactClass({ return false; } return true; - }, + } renderEmail() { if (!this._showEmail()) { @@ -426,7 +423,7 @@ export default createReactClass({ onChange={this.onEmailChange} onValidate={this.onEmailValidate} />; - }, + } renderPassword() { return ; - }, + } renderPasswordConfirm() { const Field = sdk.getComponent('elements.Field'); @@ -451,7 +448,7 @@ export default createReactClass({ onChange={this.onPasswordConfirmChange} onValidate={this.onPasswordConfirmValidate} />; - }, + } renderPhoneNumber() { if (!this._showPhoneNumber()) { @@ -477,7 +474,7 @@ export default createReactClass({ onChange={this.onPhoneNumberChange} onValidate={this.onPhoneNumberValidate} />; - }, + } renderUsername() { const Field = sdk.getComponent('elements.Field'); @@ -491,9 +488,9 @@ export default createReactClass({ onChange={this.onUsernameChange} onValidate={this.onUsernameValidate} />; - }, + } - render: function() { + render() { let yourMatrixAccountText = _t('Create your Matrix account on %(serverName)s', { serverName: this.props.serverConfig.hsName, }); @@ -578,5 +575,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/auth/Welcome.js b/src/components/views/auth/Welcome.js index 5a30a02490..21032f4f1a 100644 --- a/src/components/views/auth/Welcome.js +++ b/src/components/views/auth/Welcome.js @@ -15,10 +15,14 @@ limitations under the License. */ import React from 'react'; +import classNames from "classnames"; + import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import AuthPage from "./AuthPage"; import {_td} from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; // translatable strings for Welcome pages _td("Sign in with SSO"); @@ -39,7 +43,9 @@ export default class Welcome extends React.PureComponent { return ( -
    +
    { + // work out the full set of urls to try to load. This is formed like so: + // imageUrls: [ props.url, ...props.urls ] + + let _urls = []; + if (!SettingsStore.getValue("lowBandwidth")) { + _urls = urls || []; + + if (url) { + _urls.unshift(url); // put in urls[0] + } + } + + // deduplicate URLs + return Array.from(new Set(_urls)); +}; + const useImageUrl = ({url, urls}): [string, () => void] => { - const [imageUrls, setUrls] = useState([]); - const [urlsIndex, setIndex] = useState(); + const [imageUrls, setUrls] = useState(calculateUrls(url, urls)); + const [urlsIndex, setIndex] = useState(0); const onError = useCallback(() => { setIndex(i => i + 1); // try the next one }, []); - const memoizedUrls = useMemo(() => urls, [JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { - // work out the full set of urls to try to load. This is formed like so: - // imageUrls: [ props.url, ...props.urls ] - - let _urls = []; - if (!SettingsStore.getValue("lowBandwidth")) { - _urls = memoizedUrls || []; - - if (url) { - _urls.unshift(url); // put in urls[0] - } - } - - // deduplicate URLs - _urls = Array.from(new Set(_urls)); - + setUrls(calculateUrls(url, urls)); setIndex(0); - setUrls(_urls); - }, [url, memoizedUrls]); // eslint-disable-line react-hooks/exhaustive-deps + }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps const cli = useContext(MatrixClientContext); const onClientSync = useCallback((syncState, prevState) => { @@ -95,7 +96,7 @@ const BaseAvatar = (props: IProps) => { urls, width = 40, height = 40, - resizeMethod = "crop", // eslint-disable-line no-unused-vars + resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars defaultToInitialLetter = true, onClick, inputRef, diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index e6dadf676c..d7e012467b 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -126,7 +126,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent { if (this.isUnmounted) return; - let newIcon = this.getPresenceIcon(); + const newIcon = this.getPresenceIcon(); if (newIcon !== this.state.icon) this.setState({icon: newIcon}); }; diff --git a/src/components/views/avatars/GroupAvatar.tsx b/src/components/views/avatars/GroupAvatar.tsx index e55e2e6fac..51327605c0 100644 --- a/src/components/views/avatars/GroupAvatar.tsx +++ b/src/components/views/avatars/GroupAvatar.tsx @@ -47,7 +47,7 @@ export default class GroupAvatar extends React.Component { render() { // extract the props we use from props so we can pass any others through // should consider adding this as a global rule in js-sdk? - /*eslint no-unused-vars: ["error", { "ignoreRestSiblings": true }]*/ + /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const {groupId, groupAvatarUrl, groupName, ...otherProps} = this.props; return ( diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 1d23d85b0f..8fd51d3715 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -16,23 +16,24 @@ limitations under the License. */ import React from 'react'; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; + import dis from "../../../dispatcher/dispatcher"; import {Action} from "../../../dispatcher/actions"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import BaseAvatar from "./BaseAvatar"; interface IProps { - // TODO: replace with correct type - member: any; - fallbackUserId: string; + member: RoomMember; + fallbackUserId?: string; width: number; height: number; - resizeMethod: string; + resizeMethod?: string; // The onClick to give the avatar - onClick: React.MouseEventHandler; + onClick?: React.MouseEventHandler; // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` - viewUserOnClick: boolean; - title: string; + viewUserOnClick?: boolean; + title?: string; } interface IState { diff --git a/src/components/views/avatars/PulsedAvatar.tsx b/src/components/views/avatars/PulsedAvatar.tsx index 94a6c87687..b4e876b9f6 100644 --- a/src/components/views/avatars/PulsedAvatar.tsx +++ b/src/components/views/avatars/PulsedAvatar.tsx @@ -25,4 +25,4 @@ const PulsedAvatar: React.FC = (props) => {
    ; }; -export default PulsedAvatar; \ No newline at end of file +export default PulsedAvatar; diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 3317ed3a60..e37dff4bfe 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -114,9 +114,12 @@ export default class RoomAvatar extends React.Component { } private onRoomAvatarClick = () => { - const avatarUrl = this.props.room.getAvatarUrl( - MatrixClientPeg.get().getHomeserverUrl(), - null, null, null, false); + const avatarUrl = Avatar.avatarUrlForRoom( + this.props.room, + null, + null, + null, + ); const params = { src: avatarUrl, name: this.props.room.name, diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index b3ca9fde6f..a3fb00a9f4 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -37,7 +37,7 @@ interface IOptionListProps { } interface IOptionProps extends React.ComponentProps { - iconClassName: string; + iconClassName?: string; } interface ICheckboxProps extends React.ComponentProps { @@ -92,7 +92,7 @@ export const IconizedContextMenuCheckbox: React.FC = ({ export const IconizedContextMenuOption: React.FC = ({label, iconClassName, ...props}) => { return - + { iconClassName && } {label} ; }; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index 6fa54058a0..d760c8defa 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -19,7 +19,6 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import {EventStatus} from 'matrix-js-sdk'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; @@ -37,10 +36,8 @@ function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } -export default createReactClass({ - displayName: 'MessageContextMenu', - - propTypes: { +export default class MessageContextMenu extends React.Component { + static propTypes = { /* the MatrixEvent associated with the context menu */ mxEvent: PropTypes.object.isRequired, @@ -52,28 +49,26 @@ export default createReactClass({ /* callback called when the menu is dismissed */ onFinished: PropTypes.func, - }, + }; - getInitialState: function() { - return { - canRedact: false, - canPin: false, - }; - }, + state = { + canRedact: false, + canPin: false, + }; - componentDidMount: function() { + componentDidMount() { MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); this._checkPermissions(); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { const cli = MatrixClientPeg.get(); if (cli) { cli.removeListener('RoomMember.powerLevel', this._checkPermissions); } - }, + } - _checkPermissions: function() { + _checkPermissions = () => { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); @@ -84,47 +79,47 @@ export default createReactClass({ if (!SettingsStore.getValue("feature_pinning")) canPin = false; this.setState({canRedact, canPin}); - }, + }; - _isPinned: function() { + _isPinned() { const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); if (!pinnedEvent) return false; const content = pinnedEvent.getContent(); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); - }, + } - onResendClick: function() { + onResendClick = () => { Resend.resend(this.props.mxEvent); this.closeMenu(); - }, + }; - onResendEditClick: function() { + onResendEditClick = () => { Resend.resend(this.props.mxEvent.replacingEvent()); this.closeMenu(); - }, + }; - onResendRedactionClick: function() { + onResendRedactionClick = () => { Resend.resend(this.props.mxEvent.localRedactionEvent()); this.closeMenu(); - }, + }; - onResendReactionsClick: function() { + onResendReactionsClick = () => { for (const reaction of this._getUnsentReactions()) { Resend.resend(reaction); } this.closeMenu(); - }, + }; - onReportEventClick: function() { + onReportEventClick = () => { const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog"); Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { mxEvent: this.props.mxEvent, }, 'mx_Dialog_reportEvent'); this.closeMenu(); - }, + }; - onViewSourceClick: function() { + onViewSourceClick = () => { const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Event Source', '', ViewSource, { @@ -133,9 +128,9 @@ export default createReactClass({ content: ev.event, }, 'mx_Dialog_viewsource'); this.closeMenu(); - }, + }; - onViewClearSourceClick: function() { + onViewClearSourceClick = () => { const ev = this.props.mxEvent.replacingEvent() || this.props.mxEvent; const ViewSource = sdk.getComponent('structures.ViewSource'); Modal.createTrackedDialog('View Clear Event Source', '', ViewSource, { @@ -145,9 +140,9 @@ export default createReactClass({ content: ev._clearEvent, }, 'mx_Dialog_viewsource'); this.closeMenu(); - }, + }; - onRedactClick: function() { + onRedactClick = () => { const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { onFinished: async (proceed) => { @@ -176,9 +171,9 @@ export default createReactClass({ }, }, 'mx_Dialog_confirmredact'); this.closeMenu(); - }, + }; - onCancelSendClick: function() { + onCancelSendClick = () => { const mxEvent = this.props.mxEvent; const editEvent = mxEvent.replacingEvent(); const redactEvent = mxEvent.localRedactionEvent(); @@ -199,17 +194,17 @@ export default createReactClass({ Resend.removeFromQueue(this.props.mxEvent); } this.closeMenu(); - }, + }; - onForwardClick: function() { + onForwardClick = () => { dis.dispatch({ action: 'forward_event', event: this.props.mxEvent, }); this.closeMenu(); - }, + }; - onPinClick: function() { + onPinClick = () => { MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '') .catch((e) => { // Intercept the Event Not Found error and fall through the promise chain with no event. @@ -230,28 +225,28 @@ export default createReactClass({ cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, ''); }); this.closeMenu(); - }, + }; - closeMenu: function() { + closeMenu = () => { if (this.props.onFinished) this.props.onFinished(); - }, + }; - onUnhidePreviewClick: function() { + onUnhidePreviewClick = () => { if (this.props.eventTileOps) { this.props.eventTileOps.unhideWidget(); } this.closeMenu(); - }, + }; - onQuoteClick: function() { + onQuoteClick = () => { dis.dispatch({ action: 'quote', event: this.props.mxEvent, }); this.closeMenu(); - }, + }; - onPermalinkClick: function(e: Event) { + onPermalinkClick = (e: Event) => { e.preventDefault(); const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { @@ -259,12 +254,12 @@ export default createReactClass({ permalinkCreator: this.props.permalinkCreator, }); this.closeMenu(); - }, + }; - onCollapseReplyThreadClick: function() { + onCollapseReplyThreadClick = () => { this.props.collapseReplyThread(); this.closeMenu(); - }, + }; _getReactions(filter) { const cli = MatrixClientPeg.get(); @@ -277,17 +272,17 @@ export default createReactClass({ relation.event_id === eventId && filter(e); }); - }, + } _getPendingReactions() { return this._getReactions(e => canCancel(e.status)); - }, + } _getUnsentReactions() { return this._getReactions(e => e.status === EventStatus.NOT_SENT); - }, + } - render: function() { + render() { const cli = MatrixClientPeg.get(); const me = cli.getUserId(); const mxEvent = this.props.mxEvent; @@ -489,5 +484,5 @@ export default createReactClass({ { reportEventButton }
    ); - }, -}); + } +} diff --git a/src/components/views/context_menus/RoomTileContextMenu.js b/src/components/views/context_menus/RoomTileContextMenu.js deleted file mode 100644 index b08cf3be60..0000000000 --- a/src/components/views/context_menus/RoomTileContextMenu.js +++ /dev/null @@ -1,404 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import classNames from 'classnames'; -import * as sdk from '../../../index'; -import { _t, _td } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import dis from '../../../dispatcher/dispatcher'; -import DMRoomMap from '../../../utils/DMRoomMap'; -import * as Rooms from '../../../Rooms'; -import * as RoomNotifs from '../../../RoomNotifs'; -import Modal from '../../../Modal'; -import RoomListActions from '../../../actions/RoomListActions'; -import RoomViewStore from '../../../stores/RoomViewStore'; -import {sleep} from "../../../utils/promise"; -import {MenuItem, MenuItemCheckbox, MenuItemRadio} from "../../structures/ContextMenu"; - -const RoomTagOption = ({active, onClick, src, srcSet, label}) => { - const classes = classNames('mx_RoomTileContextMenu_tag_field', { - 'mx_RoomTileContextMenu_tag_fieldSet': active, - 'mx_RoomTileContextMenu_tag_fieldDisabled': false, - }); - - return ( - - - - { label } - - ); -}; - -const NotifOption = ({active, onClick, src, label}) => { - const classes = classNames('mx_RoomTileContextMenu_notif_field', { - 'mx_RoomTileContextMenu_notif_fieldSet': active, - }); - - return ( - - - - { label } - - ); -}; - -export default createReactClass({ - displayName: 'RoomTileContextMenu', - - propTypes: { - room: PropTypes.object.isRequired, - /* callback called when the menu is dismissed */ - onFinished: PropTypes.func, - }, - - getInitialState() { - const dmRoomMap = new DMRoomMap(MatrixClientPeg.get()); - return { - roomNotifState: RoomNotifs.getRoomNotifsState(this.props.room.roomId), - isFavourite: this.props.room.tags.hasOwnProperty("m.favourite"), - isLowPriority: this.props.room.tags.hasOwnProperty("m.lowpriority"), - isDirectMessage: Boolean(dmRoomMap.getUserIdForRoomId(this.props.room.roomId)), - }; - }, - - componentDidMount: function() { - this._unmounted = false; - }, - - componentWillUnmount: function() { - this._unmounted = true; - }, - - _toggleTag: function(tagNameOn, tagNameOff) { - if (!MatrixClientPeg.get().isGuest()) { - sleep(500).then(() => { - dis.dispatch(RoomListActions.tagRoom( - MatrixClientPeg.get(), - this.props.room, - tagNameOff, tagNameOn, - undefined, 0, - ), true); - - this.props.onFinished(); - }); - } - }, - - _onClickFavourite: function() { - // Tag room as 'Favourite' - if (!this.state.isFavourite && this.state.isLowPriority) { - this.setState({ - isFavourite: true, - isLowPriority: false, - }); - this._toggleTag("m.favourite", "m.lowpriority"); - } else if (this.state.isFavourite) { - this.setState({isFavourite: false}); - this._toggleTag(null, "m.favourite"); - } else if (!this.state.isFavourite) { - this.setState({isFavourite: true}); - this._toggleTag("m.favourite"); - } - }, - - _onClickLowPriority: function() { - // Tag room as 'Low Priority' - if (!this.state.isLowPriority && this.state.isFavourite) { - this.setState({ - isFavourite: false, - isLowPriority: true, - }); - this._toggleTag("m.lowpriority", "m.favourite"); - } else if (this.state.isLowPriority) { - this.setState({isLowPriority: false}); - this._toggleTag(null, "m.lowpriority"); - } else if (!this.state.isLowPriority) { - this.setState({isLowPriority: true}); - this._toggleTag("m.lowpriority"); - } - }, - - _onClickDM: function() { - if (MatrixClientPeg.get().isGuest()) return; - - const newIsDirectMessage = !this.state.isDirectMessage; - this.setState({ - isDirectMessage: newIsDirectMessage, - }); - - Rooms.guessAndSetDMRoom( - this.props.room, newIsDirectMessage, - ).then(sleep(500)).finally(() => { - // Close the context menu - if (this.props.onFinished) { - this.props.onFinished(); - } - }, (err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to set Direct Message status of room', '', ErrorDialog, { - title: _t('Failed to set Direct Message status of room'), - description: ((err && err.message) ? err.message : _t('Operation failed')), - }); - }); - }, - - _onClickLeave: function() { - // Leave room - dis.dispatch({ - action: 'leave_room', - room_id: this.props.room.roomId, - }); - - // Close the context menu - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - _onClickReject: function() { - dis.dispatch({ - action: 'reject_invite', - room_id: this.props.room.roomId, - }); - - // Close the context menu - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - _onClickForget: function() { - // FIXME: duplicated with RoomSettings (and dead code in RoomView) - MatrixClientPeg.get().forget(this.props.room.roomId).then(() => { - // Switch to another room view if we're currently viewing the - // historical room - if (RoomViewStore.getRoomId() === this.props.room.roomId) { - dis.dispatch({ action: 'view_next_room' }); - } - }, function(err) { - const errCode = err.errcode || _td("unknown error code"); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog('Failed to forget room', '', ErrorDialog, { - title: _t('Failed to forget room %(errCode)s', {errCode: errCode}), - description: ((err && err.message) ? err.message : _t('Operation failed')), - }); - }); - - // Close the context menu - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - _saveNotifState: function(newState) { - if (MatrixClientPeg.get().isGuest()) return; - - const oldState = this.state.roomNotifState; - const roomId = this.props.room.roomId; - - this.setState({ - roomNotifState: newState, - }); - RoomNotifs.setRoomNotifsState(roomId, newState).then(() => { - // delay slightly so that the user can see their state change - // before closing the menu - return sleep(500).then(() => { - if (this._unmounted) return; - // Close the context menu - if (this.props.onFinished) { - this.props.onFinished(); - } - }); - }, (error) => { - // TODO: some form of error notification to the user - // to inform them that their state change failed. - // For now we at least set the state back - if (this._unmounted) return; - this.setState({ - roomNotifState: oldState, - }); - }); - }, - - _onClickAlertMe: function() { - this._saveNotifState(RoomNotifs.ALL_MESSAGES_LOUD); - }, - - _onClickAllNotifs: function() { - this._saveNotifState(RoomNotifs.ALL_MESSAGES); - }, - - _onClickMentions: function() { - this._saveNotifState(RoomNotifs.MENTIONS_ONLY); - }, - - _onClickMute: function() { - this._saveNotifState(RoomNotifs.MUTE); - }, - - _renderNotifMenu: function() { - return ( -
    -
    - -
    - - - - - -
    - ); - }, - - _onClickSettings: function() { - dis.dispatch({ - action: 'open_room_settings', - room_id: this.props.room.roomId, - }); - if (this.props.onFinished) { - this.props.onFinished(); - } - }, - - _renderSettingsMenu: function() { - return ( -
    - - - { _t('Settings') } - -
    - ); - }, - - _renderLeaveMenu: function(membership) { - if (!membership) { - return null; - } - - let leaveClickHandler = null; - let leaveText = null; - - switch (membership) { - case "join": - leaveClickHandler = this._onClickLeave; - leaveText = _t('Leave'); - break; - case "leave": - case "ban": - leaveClickHandler = this._onClickForget; - leaveText = _t('Forget'); - break; - case "invite": - leaveClickHandler = this._onClickReject; - leaveText = _t('Reject'); - break; - } - - return ( -
    - - - { leaveText } - -
    - ); - }, - - _renderRoomTagMenu: function() { - return ( -
    - - - -
    - ); - }, - - render: function() { - const myMembership = this.props.room.getMyMembership(); - - switch (myMembership) { - case 'join': - return
    - { this._renderNotifMenu() } -
    - { this._renderLeaveMenu(myMembership) } -
    - { this._renderRoomTagMenu() } -
    - { this._renderSettingsMenu() } -
    ; - case 'invite': - return
    - { this._renderLeaveMenu(myMembership) } -
    ; - default: - return
    - { this._renderLeaveMenu(myMembership) } -
    - { this._renderSettingsMenu() } -
    ; - } - }, -}); diff --git a/src/components/views/context_menus/TopLeftMenu.js b/src/components/views/context_menus/TopLeftMenu.js deleted file mode 100644 index ec99c63724..0000000000 --- a/src/components/views/context_menus/TopLeftMenu.js +++ /dev/null @@ -1,155 +0,0 @@ -/* -Copyright 2018, 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import dis from '../../../dispatcher/dispatcher'; -import { _t } from '../../../languageHandler'; -import LogoutDialog from "../dialogs/LogoutDialog"; -import Modal from "../../../Modal"; -import SdkConfig from '../../../SdkConfig'; -import { getHostingLink } from '../../../utils/HostingLink'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; -import {MenuItem} from "../../structures/ContextMenu"; -import * as sdk from "../../../index"; -import {getHomePageUrl} from "../../../utils/pages"; -import {Action} from "../../../dispatcher/actions"; - -export default class TopLeftMenu extends React.Component { - static propTypes = { - displayName: PropTypes.string.isRequired, - userId: PropTypes.string.isRequired, - onFinished: PropTypes.func, - - // Optional function to collect a reference to the container - // of this component directly. - containerRef: PropTypes.func, - }; - - constructor() { - super(); - this.viewHomePage = this.viewHomePage.bind(this); - this.openSettings = this.openSettings.bind(this); - this.signIn = this.signIn.bind(this); - this.signOut = this.signOut.bind(this); - } - - hasHomePage() { - return !!getHomePageUrl(SdkConfig.get()); - } - - render() { - const isGuest = MatrixClientPeg.get().isGuest(); - - const hostingSignupLink = getHostingLink('user-context-menu'); - let hostingSignup = null; - if (hostingSignupLink) { - hostingSignup =
    - {_t( - "Upgrade to your own domain", {}, - { - a: sub => - {sub}, - }, - )} - - - -
    ; - } - - let homePageItem = null; - if (this.hasHomePage()) { - homePageItem = ( - - {_t("Home")} - - ); - } - - let signInOutItem; - if (isGuest) { - signInOutItem = ( - - {_t("Sign in")} - - ); - } else { - signInOutItem = ( - - {_t("Sign out")} - - ); - } - - const helpItem = ( - - {_t("Help")} - - ); - - const settingsItem = ( - - {_t("Settings")} - - ); - - return
    -
    -
    {this.props.displayName}
    -
    {this.props.userId}
    - {hostingSignup} -
    -
      - {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(); - } - - openSettings() { - dis.fire(Action.ViewUserSettings); - this.closeMenu(); - } - - signIn() { - dis.dispatch({action: 'start_login'}); - this.closeMenu(); - } - - signOut() { - Modal.createTrackedDialog('Logout E2E Export', '', LogoutDialog); - this.closeMenu(); - } - - closeMenu() { - if (this.props.onFinished) this.props.onFinished(); - } -} diff --git a/src/components/views/context_menus/WidgetContextMenu.js b/src/components/views/context_menus/WidgetContextMenu.js index 1ec74b2e6c..6ed32daa5c 100644 --- a/src/components/views/context_menus/WidgetContextMenu.js +++ b/src/components/views/context_menus/WidgetContextMenu.js @@ -26,6 +26,9 @@ export default class WidgetContextMenu extends React.Component { // Callback for when the revoke button is clicked. Required. onRevokeClicked: PropTypes.func.isRequired, + // Callback for when the unpin button is clicked. If absent, unpin will be hidden. + onUnpinClicked: PropTypes.func, + // Callback for when the snapshot button is clicked. Button not shown // without a callback. onSnapshotClicked: PropTypes.func, @@ -70,6 +73,8 @@ export default class WidgetContextMenu extends React.Component { this.proxyClick(this.props.onRevokeClicked); }; + onUnpinClicked = () => this.proxyClick(this.props.onUnpinClicked); + render() { const options = []; @@ -81,6 +86,14 @@ export default class WidgetContextMenu extends React.Component { ); } + if (this.props.onUnpinClicked) { + options.push( + + {_t("Unpin")} + , + ); + } + if (this.props.onReloadClicked) { options.push( diff --git a/src/components/views/create_room/Presets.js b/src/components/views/create_room/Presets.js deleted file mode 100644 index 0f18d11511..0000000000 --- a/src/components/views/create_room/Presets.js +++ /dev/null @@ -1,57 +0,0 @@ -/* -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. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from "react"; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import { _t } from '../../../languageHandler'; - -const Presets = { - PrivateChat: "private_chat", - PublicChat: "public_chat", - Custom: "custom", -}; - -export default createReactClass({ - displayName: 'CreateRoomPresets', - propTypes: { - onChange: PropTypes.func, - preset: PropTypes.string, - }, - - Presets: Presets, - - getDefaultProps: function() { - return { - onChange: function() {}, - }; - }, - - onValueChanged: function(ev) { - this.props.onChange(ev.target.value); - }, - - render: function() { - return ( - - ); - }, -}); diff --git a/src/components/views/create_room/RoomAlias.js b/src/components/views/create_room/RoomAlias.js deleted file mode 100644 index 5bdfdde08d..0000000000 --- a/src/components/views/create_room/RoomAlias.js +++ /dev/null @@ -1,106 +0,0 @@ -/* -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. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; -import { _t } from '../../../languageHandler'; - -export default createReactClass({ - displayName: 'RoomAlias', - propTypes: { - // Specifying a homeserver will make magical things happen when you, - // e.g. start typing in the room alias box. - homeserver: PropTypes.string, - alias: PropTypes.string, - onChange: PropTypes.func, - }, - - getDefaultProps: function() { - return { - onChange: function() {}, - alias: '', - }; - }, - - getAliasLocalpart: function() { - let room_alias = this.props.alias; - - if (room_alias && this.props.homeserver) { - const suffix = ":" + this.props.homeserver; - if (room_alias.startsWith("#") && room_alias.endsWith(suffix)) { - room_alias = room_alias.slice(1, -suffix.length); - } - } - - return room_alias; - }, - - onValueChanged: function(ev) { - this.props.onChange(ev.target.value); - }, - - onFocus: function(ev) { - const target = ev.target; - const curr_val = ev.target.value; - - if (this.props.homeserver) { - if (curr_val == "") { - const self = this; - setTimeout(function() { - target.value = "#:" + self.props.homeserver; - target.setSelectionRange(1, 1); - }, 0); - } else { - const suffix = ":" + this.props.homeserver; - setTimeout(function() { - target.setSelectionRange( - curr_val.startsWith("#") ? 1 : 0, - curr_val.endsWith(suffix) ? (target.value.length - suffix.length) : target.value.length, - ); - }, 0); - } - } - }, - - onBlur: function(ev) { - const curr_val = ev.target.value; - - if (this.props.homeserver) { - if (curr_val == "#:" + this.props.homeserver) { - ev.target.value = ""; - return; - } - - if (curr_val != "") { - let new_val = ev.target.value; - const suffix = ":" + this.props.homeserver; - if (!curr_val.startsWith("#")) new_val = "#" + new_val; - if (!curr_val.endsWith(suffix)) new_val = new_val + suffix; - ev.target.value = new_val; - } - } - }, - - render: function() { - return ( - - ); - }, -}); diff --git a/src/components/views/dialogs/AddressPickerDialog.js b/src/components/views/dialogs/AddressPickerDialog.js index 8ddd89dc65..2cd09874b2 100644 --- a/src/components/views/dialogs/AddressPickerDialog.js +++ b/src/components/views/dialogs/AddressPickerDialog.js @@ -19,7 +19,6 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; @@ -45,10 +44,8 @@ const addressTypeName = { }; -export default createReactClass({ - displayName: "AddressPickerDialog", - - propTypes: { +export default class AddressPickerDialog extends React.Component { + static propTypes = { title: PropTypes.string.isRequired, description: PropTypes.node, // Extra node inserted after picker input, dropdown and errors @@ -66,26 +63,28 @@ export default createReactClass({ // Whether the current user should be included in the addresses returned. Only // applicable when pickerType is `user`. Default: false. includeSelf: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - value: "", - focus: true, - validAddressTypes: addressTypes, - pickerType: 'user', - includeSelf: false, - }; - }, + static defaultProps = { + value: "", + focus: true, + validAddressTypes: addressTypes, + pickerType: 'user', + includeSelf: false, + }; + + constructor(props) { + super(props); + + this._textinput = createRef(); - getInitialState: function() { let validAddressTypes = this.props.validAddressTypes; // Remove email from validAddressTypes if no IS is configured. It may be added at a later stage by the user if (!MatrixClientPeg.get().getIdentityServerUrl() && validAddressTypes.includes("email")) { validAddressTypes = validAddressTypes.filter(type => type !== "email"); } - return { + this.state = { // Whether to show an error message because of an invalid address invalidAddressError: false, // List of UserAddressType objects representing @@ -106,19 +105,14 @@ export default createReactClass({ // dialog is open and represents the supported list of address types at this time. validAddressTypes, }; - }, + } - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._textinput = createRef(); - }, - - componentDidMount: function() { + componentDidMount() { if (this.props.focus) { // Set the cursor at the end of the text input this._textinput.current.value = this.props.value; } - }, + } getPlaceholder() { const { placeholder } = this.props; @@ -127,9 +121,9 @@ export default createReactClass({ } // Otherwise it's a function, as checked by prop types. return placeholder(this.state.validAddressTypes); - }, + } - onButtonClick: function() { + onButtonClick = () => { let selectedList = this.state.selectedList.slice(); // Check the text input field to see if user has an unconverted address // If there is and it's valid add it to the local selectedList @@ -138,13 +132,13 @@ export default createReactClass({ if (selectedList === null) return; } this.props.onFinished(true, selectedList); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - onKeyDown: function(e) { + onKeyDown = e => { const textInput = this._textinput.current ? this._textinput.current.value : undefined; if (e.key === Key.ESCAPE) { @@ -181,9 +175,9 @@ export default createReactClass({ e.preventDefault(); this._addAddressesToList([textInput]); } - }, + }; - onQueryChanged: function(ev) { + onQueryChanged = ev => { const query = ev.target.value; if (this.queryChangedDebouncer) { clearTimeout(this.queryChangedDebouncer); @@ -216,28 +210,24 @@ export default createReactClass({ searchError: null, }); } - }, + }; - onDismissed: function(index) { - return () => { - const selectedList = this.state.selectedList.slice(); - selectedList.splice(index, 1); - this.setState({ - selectedList, - suggestedList: [], - query: "", - }); - if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - }; - }, + onDismissed = index => () => { + const selectedList = this.state.selectedList.slice(); + selectedList.splice(index, 1); + this.setState({ + selectedList, + suggestedList: [], + query: "", + }); + if (this._cancelThreepidLookup) this._cancelThreepidLookup(); + }; - onClick: function(index) { - return () => { - this.onSelected(index); - }; - }, + onClick = index => () => { + this.onSelected(index); + }; - onSelected: function(index) { + onSelected = index => { const selectedList = this.state.selectedList.slice(); selectedList.push(this._getFilteredSuggestions()[index]); this.setState({ @@ -246,9 +236,9 @@ export default createReactClass({ query: "", }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); - }, + }; - _doNaiveGroupSearch: function(query) { + _doNaiveGroupSearch(query) { const lowerCaseQuery = query.toLowerCase(); this.setState({ busy: true, @@ -280,9 +270,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - _doNaiveGroupRoomSearch: function(query) { + _doNaiveGroupRoomSearch(query) { const lowerCaseQuery = query.toLowerCase(); const results = []; GroupStore.getGroupRooms(this.props.groupId).forEach((r) => { @@ -302,9 +292,9 @@ export default createReactClass({ this.setState({ busy: false, }); - }, + } - _doRoomSearch: function(query) { + _doRoomSearch(query) { const lowerCaseQuery = query.toLowerCase(); const rooms = MatrixClientPeg.get().getRooms(); const results = []; @@ -359,9 +349,9 @@ export default createReactClass({ this.setState({ busy: false, }); - }, + } - _doUserDirectorySearch: function(query) { + _doUserDirectorySearch(query) { this.setState({ busy: true, query, @@ -393,9 +383,9 @@ export default createReactClass({ busy: false, }); }); - }, + } - _doLocalSearch: function(query) { + _doLocalSearch(query) { this.setState({ query, searchError: null, @@ -417,9 +407,9 @@ export default createReactClass({ }); }); this._processResults(results, query); - }, + } - _processResults: function(results, query) { + _processResults(results, query) { const suggestedList = []; results.forEach((result) => { if (result.room_id) { @@ -485,9 +475,9 @@ export default createReactClass({ }, () => { if (this.addressSelector) this.addressSelector.moveSelectionTop(); }); - }, + } - _addAddressesToList: function(addressTexts) { + _addAddressesToList(addressTexts) { const selectedList = this.state.selectedList.slice(); let hasError = false; @@ -529,9 +519,9 @@ export default createReactClass({ }); if (this._cancelThreepidLookup) this._cancelThreepidLookup(); return hasError ? null : selectedList; - }, + } - _lookupThreepid: async function(medium, address) { + async _lookupThreepid(medium, address) { let cancelled = false; // Note that we can't safely remove this after we're done // because we don't know that it's the same one, so we just @@ -577,9 +567,9 @@ export default createReactClass({ searchError: _t('Something went wrong!'), }); } - }, + } - _getFilteredSuggestions: function() { + _getFilteredSuggestions() { // map addressType => set of addresses to avoid O(n*m) operation const selectedAddresses = {}; this.state.selectedList.forEach(({address, addressType}) => { @@ -591,17 +581,17 @@ export default createReactClass({ return this.state.suggestedList.filter(({address, addressType}) => { return !(selectedAddresses[addressType] && selectedAddresses[addressType].has(address)); }); - }, + } - _onPaste: function(e) { + _onPaste = e => { // Prevent the text being pasted into the textarea e.preventDefault(); const text = e.clipboardData.getData("text"); // Process it as a list of addresses to add instead this._addAddressesToList(text.split(/[\s,]+/)); - }, + }; - onUseDefaultIdentityServerClick(e) { + onUseDefaultIdentityServerClick = e => { e.preventDefault(); // Update the IS in account data. Actually using it may trigger terms. @@ -612,15 +602,15 @@ export default createReactClass({ const { validAddressTypes } = this.state; validAddressTypes.push('email'); this.setState({ validAddressTypes }); - }, + }; - onManageSettingsClick(e) { + onManageSettingsClick = e => { e.preventDefault(); dis.fire(Action.ViewUserSettings); this.onCancel(); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const AddressSelector = sdk.getComponent("elements.AddressSelector"); @@ -738,5 +728,5 @@ export default createReactClass({ onCancel={this.onCancel} /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.js b/src/components/views/dialogs/AskInviteAnywayDialog.js index 7a12d2bd20..c69400977a 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.js +++ b/src/components/views/dialogs/AskInviteAnywayDialog.js @@ -16,37 +16,36 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import {SettingLevel} from "../../../settings/SettingLevel"; -export default createReactClass({ - propTypes: { +export default class AskInviteAnywayDialog extends React.Component { + static propTypes = { unknownProfileUsers: PropTypes.array.isRequired, // [ {userId, errorText}... ] onInviteAnyways: PropTypes.func.isRequired, onGiveUp: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - _onInviteClicked: function() { + _onInviteClicked = () => { this.props.onInviteAnyways(); this.props.onFinished(true); - }, + }; - _onInviteNeverWarnClicked: function() { + _onInviteNeverWarnClicked = () => { SettingsStore.setValue("promptBeforeInviteUnknownUsers", null, SettingLevel.ACCOUNT, false); this.props.onInviteAnyways(); this.props.onFinished(true); - }, + }; - _onGiveUpClicked: function() { + _onGiveUpClicked = () => { this.props.onGiveUp(); this.props.onFinished(false); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const errorList = this.props.unknownProfileUsers @@ -78,5 +77,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/BaseDialog.js b/src/components/views/dialogs/BaseDialog.js index 353298032c..9ba5368ee5 100644 --- a/src/components/views/dialogs/BaseDialog.js +++ b/src/components/views/dialogs/BaseDialog.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import FocusLock from 'react-focus-lock'; import PropTypes from 'prop-types'; import classNames from 'classnames'; @@ -28,16 +27,14 @@ import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -/** +/* * Basic container for modal dialogs. * * Includes a div for the title, and a keypress handler which cancels the * dialog on escape. */ -export default createReactClass({ - displayName: 'BaseDialog', - - propTypes: { +export default class BaseDialog extends React.Component { + static propTypes = { // onFinished callback to call when Escape is pressed // Take a boolean which is true if the dialog was dismissed // with a positive / confirm action or false if it was @@ -81,21 +78,20 @@ export default createReactClass({ PropTypes.object, PropTypes.arrayOf(PropTypes.string), ]), - }, + }; - getDefaultProps: function() { - return { - hasCancel: true, - fixedWidth: true, - }; - }, + static defaultProps = { + hasCancel: true, + fixedWidth: true, + }; + + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Move this to constructor - UNSAFE_componentWillMount() { this._matrixClient = MatrixClientPeg.get(); - }, + } - _onKeyDown: function(e) { + _onKeyDown = (e) => { if (this.props.onKeyDown) { this.props.onKeyDown(e); } @@ -104,13 +100,13 @@ export default createReactClass({ e.preventDefault(); this.props.onFinished(false); } - }, + }; - _onCancelClick: function(e) { + _onCancelClick = (e) => { this.props.onFinished(false); - }, + }; - render: function() { + render() { let cancelButton; if (this.props.hasCancel) { cancelButton = ( @@ -161,5 +157,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/BugReportDialog.js b/src/components/views/dialogs/BugReportDialog.js index d001d3993d..c4dd0a1430 100644 --- a/src/components/views/dialogs/BugReportDialog.js +++ b/src/components/views/dialogs/BugReportDialog.js @@ -34,7 +34,7 @@ export default class BugReportDialog extends React.Component { busy: false, err: null, issueUrl: "", - text: "", + text: props.initialText || "", progress: null, downloadBusy: false, downloadProgress: null, @@ -255,4 +255,5 @@ export default class BugReportDialog extends React.Component { BugReportDialog.propTypes = { onFinished: PropTypes.func.isRequired, + initialText: PropTypes.string, }; diff --git a/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx new file mode 100644 index 0000000000..1c8a4ad6f6 --- /dev/null +++ b/src/components/views/dialogs/CommunityPrototypeInviteDialog.tsx @@ -0,0 +1,248 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ChangeEvent, FormEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { arrayFastClone } from "../../../utils/arrays"; +import SdkConfig from "../../../SdkConfig"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import InviteDialog from "./InviteDialog"; +import BaseAvatar from "../avatars/BaseAvatar"; +import {getHttpUriForMxc} from "matrix-js-sdk/src/content-repo"; +import {inviteMultipleToRoom, showAnyInviteErrors} from "../../../RoomInvite"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import Modal from "../../../Modal"; +import ErrorDialog from "./ErrorDialog"; + +interface IProps extends IDialogProps { + roomId: string; + communityName: string; +} + +interface IPerson { + userId: string; + user: RoomMember; + lastActive: number; +} + +interface IState { + emailTargets: string[]; + userTargets: string[]; + showPeople: boolean; + people: IPerson[]; + numPeople: number; + busy: boolean; +} + +export default class CommunityPrototypeInviteDialog extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + emailTargets: [], + userTargets: [], + showPeople: false, + people: this.buildSuggestions(), + numPeople: 5, // arbitrary default + busy: false, + }; + } + + private buildSuggestions(): IPerson[] { + const alreadyInvited = new Set([MatrixClientPeg.get().getUserId(), SdkConfig.get()['welcomeUserId']]); + if (this.props.roomId) { + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + if (!room) throw new Error("Room ID given to InviteDialog does not look like a room"); + room.getMembersWithMembership('invite').forEach(m => alreadyInvited.add(m.userId)); + room.getMembersWithMembership('join').forEach(m => alreadyInvited.add(m.userId)); + // add banned users, so we don't try to invite them + room.getMembersWithMembership('ban').forEach(m => alreadyInvited.add(m.userId)); + } + + return InviteDialog.buildRecents(alreadyInvited); + } + + private onSubmit = async (ev: FormEvent) => { + ev.preventDefault(); + ev.stopPropagation(); + + this.setState({busy: true}); + try { + const targets = [...this.state.emailTargets, ...this.state.userTargets]; + const result = await inviteMultipleToRoom(this.props.roomId, targets); + const room = MatrixClientPeg.get().getRoom(this.props.roomId); + const success = showAnyInviteErrors(result.states, room, result.inviter); + if (success) { + this.props.onFinished(true); + } else { + this.setState({busy: false}); + } + } catch (e) { + this.setState({busy: false}); + console.error(e); + Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { + title: _t("Failed to invite"), + description: ((e && e.message) ? e.message : _t("Operation failed")), + }); + } + }; + + private onAddressChange = (ev: ChangeEvent, index: number) => { + const targets = arrayFastClone(this.state.emailTargets); + if (index >= targets.length) { + targets.push(ev.target.value); + } else { + targets[index] = ev.target.value; + } + this.setState({emailTargets: targets}); + }; + + private onAddressBlur = (index: number) => { + const targets = arrayFastClone(this.state.emailTargets); + if (index >= targets.length) return; // not important + if (targets[index].trim() === "") { + targets.splice(index, 1); + this.setState({emailTargets: targets}); + } + }; + + private onShowPeopleClick = () => { + this.setState({showPeople: !this.state.showPeople}); + }; + + private setPersonToggle = (person: IPerson, selected: boolean) => { + const targets = arrayFastClone(this.state.userTargets); + if (selected && !targets.includes(person.userId)) { + targets.push(person.userId); + } else if (!selected && targets.includes(person.userId)) { + targets.splice(targets.indexOf(person.userId), 1); + } + this.setState({userTargets: targets}); + }; + + private renderPerson(person: IPerson, key: any) { + const avatarSize = 36; + return ( +
    + +
    + {person.user.name} + {person.userId} +
    + this.setPersonToggle(person, e.target.checked)} /> +
    + ); + } + + private onShowMorePeople = () => { + this.setState({numPeople: this.state.numPeople + 5}); // arbitrary increase + }; + + public render() { + const emailAddresses = []; + this.state.emailTargets.forEach((address, i) => { + emailAddresses.push(( + this.onAddressChange(e, i)} + label={_t("Email address")} + placeholder={_t("Email address")} + onBlur={() => this.onAddressBlur(i)} + /> + )); + }); + + // Push a clean input + emailAddresses.push(( + this.onAddressChange(e, emailAddresses.length)} + label={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} + placeholder={emailAddresses.length > 0 ? _t("Add another email") : _t("Email address")} + /> + )); + + let peopleIntro = null; + const people = []; + if (this.state.showPeople) { + const humansToPresent = this.state.people.slice(0, this.state.numPeople); + humansToPresent.forEach((person, i) => { + people.push(this.renderPerson(person, i)); + }); + if (humansToPresent.length < this.state.people.length) { + people.push(( + {_t("Show more")} + )); + } + } + if (this.state.people.length > 0) { + peopleIntro = ( +
    + {_t("People you know on %(brand)s", {brand: SdkConfig.get().brand})} + + {this.state.showPeople ? _t("Hide") : _t("Show")} + +
    + ); + } + + let buttonText = _t("Skip"); + const targetCount = this.state.userTargets.length + this.state.emailTargets.length; + if (targetCount > 0) { + buttonText = _t("Send %(count)s invites", {count: targetCount}); + } + + return ( + +
    +
    + {emailAddresses} + {peopleIntro} + {people} + {buttonText} +
    +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/ConfirmRedactDialog.js b/src/components/views/dialogs/ConfirmRedactDialog.js index 71139155ec..3106df1d5b 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.js +++ b/src/components/views/dialogs/ConfirmRedactDialog.js @@ -15,17 +15,14 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; /* * A dialog for confirming a redaction. */ -export default createReactClass({ - displayName: 'ConfirmRedactDialog', - - render: function() { +export default class ConfirmRedactDialog extends React.Component { + render() { const QuestionDialog = sdk.getComponent('views.dialogs.QuestionDialog'); return ( ); - }, -}); + } +} diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.js b/src/components/views/dialogs/ConfirmUserActionDialog.js index 2495c46327..44f57f047e 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.js +++ b/src/components/views/dialogs/ConfirmUserActionDialog.js @@ -15,7 +15,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 * as sdk from '../../../index'; @@ -30,9 +29,8 @@ import { GroupMemberType } from '../../../groups'; * to make it obvious what is going to happen. * Also tweaks the style for 'dangerous' actions (albeit only with colour) */ -export default createReactClass({ - displayName: 'ConfirmUserActionDialog', - propTypes: { +export default class ConfirmUserActionDialog extends React.Component { + static propTypes = { // matrix-js-sdk (room) member object. Supply either this or 'groupMember' member: PropTypes.object, // group member object. Supply either this or 'member' @@ -48,35 +46,36 @@ export default createReactClass({ askReason: PropTypes.bool, danger: PropTypes.bool, onFinished: PropTypes.func.isRequired, - }, + }; - getDefaultProps: () => ({ + static defaultProps = { danger: false, askReason: false, - }), + }; + + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Move this to constructor - UNSAFE_componentWillMount: function() { this._reasonField = null; - }, + } - onOk: function() { + onOk = () => { let reason; if (this._reasonField) { reason = this._reasonField.value; } this.props.onFinished(true, reason); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - _collectReasonField: function(e) { + _collectReasonField = e => { this._reasonField = e; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); @@ -134,5 +133,5 @@ export default createReactClass({ onCancel={this.onCancel} /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx new file mode 100644 index 0000000000..1d9d92b9c9 --- /dev/null +++ b/src/components/views/dialogs/CreateCommunityPrototypeDialog.tsx @@ -0,0 +1,227 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ChangeEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import InfoTooltip from "../elements/InfoTooltip"; +import dis from "../../../dispatcher/dispatcher"; +import {showCommunityRoomInviteDialog} from "../../../RoomInvite"; +import GroupStore from "../../../stores/GroupStore"; + +interface IProps extends IDialogProps { +} + +interface IState { + name: string; + localpart: string; + error: string; + busy: boolean; + avatarFile: File; + avatarPreview: string; +} + +export default class CreateCommunityPrototypeDialog extends React.PureComponent { + private avatarUploadRef: React.RefObject = React.createRef(); + + constructor(props: IProps) { + super(props); + + this.state = { + name: "", + localpart: "", + error: null, + busy: false, + avatarFile: null, + avatarPreview: null, + }; + } + + private onNameChange = (ev: ChangeEvent) => { + const localpart = (ev.target.value || "").toLowerCase().replace(/[^a-z0-9.\-_]/g, '-'); + this.setState({name: ev.target.value, localpart}); + }; + + private onSubmit = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (this.state.busy) return; + + // We'll create the community now to see if it's taken, leaving it active in + // the background for the user to look at while they invite people. + this.setState({busy: true}); + try { + let avatarUrl = ''; // must be a string for synapse to accept it + if (this.state.avatarFile) { + avatarUrl = await MatrixClientPeg.get().uploadContent(this.state.avatarFile); + } + + const result = await MatrixClientPeg.get().createGroup({ + localpart: this.state.localpart, + profile: { + name: this.state.name, + avatar_url: avatarUrl, + }, + }); + + // Ensure the tag gets selected now that we've created it + dis.dispatch({action: 'deselect_tags'}, true); + dis.dispatch({ + action: 'select_tag', + tag: result.group_id, + }); + + // Close our own dialog before moving much further + this.props.onFinished(true); + + if (result.room_id) { + // Force the group store to update as it might have missed the general chat + await GroupStore.refreshGroupRooms(result.group_id); + dis.dispatch({ + action: 'view_room', + room_id: result.room_id, + }); + showCommunityRoomInviteDialog(result.room_id, this.state.name); + } else { + dis.dispatch({ + action: 'view_group', + group_id: result.group_id, + group_is_new: true, + }); + } + } catch (e) { + console.error(e); + this.setState({ + busy: false, + error: _t( + "There was an error creating your community. The name may be taken or the " + + "server is unable to process your request.", + ), + }); + } + }; + + private onAvatarChanged = (e: ChangeEvent) => { + if (!e.target.files || !e.target.files.length) { + this.setState({avatarFile: null}); + } else { + this.setState({busy: true}); + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (ev: ProgressEvent) => { + this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + }; + reader.readAsDataURL(file); + } + }; + + private onChangeAvatar = () => { + if (this.avatarUploadRef.current) this.avatarUploadRef.current.click(); + }; + + public render() { + let communityId = null; + if (this.state.localpart) { + communityId = ( + + {_t("Community ID: +:%(domain)s", { + domain: MatrixClientPeg.getHomeserverName(), + }, { + localpart: () => {this.state.localpart}, + })} + + + ); + } + + let helpText = ( + + {_t("You can change this later if needed.")} + + ); + if (this.state.error) { + const classes = "mx_CreateCommunityPrototypeDialog_subtext mx_CreateCommunityPrototypeDialog_subtext_error"; + helpText = ( + + {this.state.error} + + ); + } + + let preview = ; + if (!this.state.avatarPreview) { + preview =
    + } + + return ( + +
    +
    +
    + + {helpText} + + {/*nbsp is to reserve the height of this element when there's nothing*/} +  {communityId} + + + {_t("Create")} + +
    +
    + + + {preview} + +
    + {_t("Add image (optional)")} + + {_t("An image will help people identify your community.")} + +
    +
    +
    +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/CreateGroupDialog.js b/src/components/views/dialogs/CreateGroupDialog.js index 10285ccee0..6636153c98 100644 --- a/src/components/views/dialogs/CreateGroupDialog.js +++ b/src/components/views/dialogs/CreateGroupDialog.js @@ -15,46 +15,42 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -export default createReactClass({ - displayName: 'CreateGroupDialog', - propTypes: { +export default class CreateGroupDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - groupName: '', - groupId: '', - groupError: null, - creating: false, - createError: null, - }; - }, + state = { + groupName: '', + groupId: '', + groupError: null, + creating: false, + createError: null, + }; - _onGroupNameChange: function(e) { + _onGroupNameChange = e => { this.setState({ groupName: e.target.value, }); - }, + }; - _onGroupIdChange: function(e) { + _onGroupIdChange = e => { this.setState({ groupId: e.target.value, }); - }, + }; - _onGroupIdBlur: function(e) { + _onGroupIdBlur = e => { this._checkGroupId(); - }, + }; - _checkGroupId: function(e) { + _checkGroupId(e) { let error = null; if (!this.state.groupId) { error = _t("Community IDs cannot be empty."); @@ -67,9 +63,9 @@ export default createReactClass({ createError: null, }); return error; - }, + } - _onFormSubmit: function(e) { + _onFormSubmit = e => { e.preventDefault(); if (this._checkGroupId()) return; @@ -94,13 +90,13 @@ export default createReactClass({ }).finally(() => { this.setState({creating: false}); }); - }, + }; - _onCancel: function() { + _onCancel = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Spinner = sdk.getComponent('elements.Spinner'); @@ -171,5 +167,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/CreateRoomDialog.js b/src/components/views/dialogs/CreateRoomDialog.js index ce7ac6e59c..2b6bb5e187 100644 --- a/src/components/views/dialogs/CreateRoomDialog.js +++ b/src/components/views/dialogs/CreateRoomDialog.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; @@ -25,17 +24,19 @@ import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {Key} from "../../../Keyboard"; import {privateShouldBeEncrypted} from "../../../createRoom"; +import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; -export default createReactClass({ - displayName: 'CreateRoomDialog', - propTypes: { +export default class CreateRoomDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, defaultPublic: PropTypes.bool, - }, + }; + + constructor(props) { + super(props); - getInitialState() { const config = SdkConfig.get(); - return { + this.state = { isPublic: this.props.defaultPublic || false, isEncrypted: privateShouldBeEncrypted(), name: "", @@ -44,8 +45,12 @@ export default createReactClass({ detailsOpen: false, noFederate: config.default_federate === false, nameIsValid: false, + canChangeEncryption: true, }; - }, + + MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") + .then(isForced => this.setState({canChangeEncryption: !isForced})); + } _roomCreateOptions() { const opts = {}; @@ -67,31 +72,41 @@ export default createReactClass({ } if (!this.state.isPublic) { - opts.encryption = this.state.isEncrypted; + if (this.state.canChangeEncryption) { + opts.encryption = this.state.isEncrypted; + } else { + // the server should automatically do this for us, but for safety + // we'll demand it too. + opts.encryption = true; + } + } + + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + opts.associatedWithCommunity = CommunityPrototypeStore.instance.getSelectedCommunityId(); } return opts; - }, + } componentDidMount() { this._detailsRef.addEventListener("toggle", this.onDetailsToggled); // move focus to first field when showing dialog this._nameFieldRef.focus(); - }, + } componentWillUnmount() { this._detailsRef.removeEventListener("toggle", this.onDetailsToggled); - }, + } - _onKeyDown: function(event) { + _onKeyDown = event => { if (event.key === Key.ENTER) { this.onOk(); event.preventDefault(); event.stopPropagation(); } - }, + }; - onOk: async function() { + onOk = async () => { const activeElement = document.activeElement; if (activeElement) { activeElement.blur(); @@ -117,51 +132,51 @@ export default createReactClass({ field.validate({ allowEmpty: false, focused: true }); } } - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - onNameChange(ev) { + onNameChange = ev => { this.setState({name: ev.target.value}); - }, + }; - onTopicChange(ev) { + onTopicChange = ev => { this.setState({topic: ev.target.value}); - }, + }; - onPublicChange(isPublic) { + onPublicChange = isPublic => { this.setState({isPublic}); - }, + }; - onEncryptedChange(isEncrypted) { + onEncryptedChange = isEncrypted => { this.setState({isEncrypted}); - }, + }; - onAliasChange(alias) { + onAliasChange = alias => { this.setState({alias}); - }, + }; - onDetailsToggled(ev) { + onDetailsToggled = ev => { this.setState({detailsOpen: ev.target.open}); - }, + }; - onNoFederateChange(noFederate) { + onNoFederateChange = noFederate => { this.setState({noFederate}); - }, + }; - collectDetailsRef(ref) { + collectDetailsRef = ref => { this._detailsRef = ref; - }, + }; - async onNameValidate(fieldState) { - const result = await this._validateRoomName(fieldState); + onNameValidate = async fieldState => { + const result = await CreateRoomDialog._validateRoomName(fieldState); this.setState({nameIsValid: result.valid}); return result; - }, + }; - _validateRoomName: withValidation({ + static _validateRoomName = withValidation({ rules: [ { key: "required", @@ -169,34 +184,45 @@ export default createReactClass({ invalid: () => _t("Please enter a name for the room"), }, ], - }), + }); - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Field = sdk.getComponent('views.elements.Field'); const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch'); const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField'); - let publicPrivateLabel; let aliasField; if (this.state.isPublic) { - publicPrivateLabel = (

    {_t("Set a room address to easily share your room with other people.")}

    ); const domain = MatrixClientPeg.get().getDomain(); aliasField = (
    this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} />
    ); - } else { - publicPrivateLabel = (

    {_t("This room is private, and can only be joined by invitation.")}

    ); + } + + let publicPrivateLabel =

    {_t( + "Private rooms can be found and joined by invitation only. Public rooms can be " + + "found and joined by anyone.", + )}

    ; + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + publicPrivateLabel =

    {_t( + "Private rooms can be found and joined by invitation only. Public rooms can be " + + "found and joined by anyone in this community.", + )}

    ; } let e2eeSection; if (!this.state.isPublic) { let microcopy; if (privateShouldBeEncrypted()) { - microcopy = _t("You can’t disable this later. Bridges & most bots won’t work yet."); + if (this.state.canChangeEncryption) { + microcopy = _t("You can’t disable this later. Bridges & most bots won’t work yet."); + } else { + microcopy = _t("Your server requires encryption to be enabled in private rooms."); + } } else { microcopy = _t("Your server admin has disabled end-to-end encryption by default " + "in private rooms & Direct Messages."); @@ -207,12 +233,30 @@ export default createReactClass({ onChange={this.onEncryptedChange} value={this.state.isEncrypted} className='mx_CreateRoomDialog_e2eSwitch' // for end-to-end tests + disabled={!this.state.canChangeEncryption} />

    { microcopy }

    ; } - const title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); + let federateLabel = _t( + "You might enable this if the room will only be used for collaborating with internal " + + "teams on your homeserver. This cannot be changed later.", + ); + if (SdkConfig.get().default_federate === false) { + // We only change the label if the default setting is different to avoid jarring text changes to the + // user. They will have read the implications of turning this off/on, so no need to rephrase for them. + federateLabel = _t( + "You might disable this if the room will be used for collaborating with external " + + "teams who have their own homeserver. This cannot be changed later.", + ); + } + + let title = this.state.isPublic ? _t('Create a public room') : _t('Create a private room'); + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const name = CommunityPrototypeStore.instance.getSelectedCommunityName(); + title = _t("Create a room in %(communityName)s", {communityName: name}); + } return ( { this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') } - + +

    {federateLabel}

    @@ -236,5 +288,5 @@ export default createReactClass({ onCancel={this.onCancel} /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx new file mode 100644 index 0000000000..3071854b3e --- /dev/null +++ b/src/components/views/dialogs/EditCommunityPrototypeDialog.tsx @@ -0,0 +1,167 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { ChangeEvent } from 'react'; +import BaseDialog from "./BaseDialog"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import Field from "../elements/Field"; +import AccessibleButton from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; +import FlairStore from "../../../stores/FlairStore"; + +interface IProps extends IDialogProps { + communityId: string; +} + +interface IState { + name: string; + error: string; + busy: boolean; + currentAvatarUrl: string; + avatarFile: File; + avatarPreview: string; +} + +// XXX: This is a lot of duplication from the create dialog, just in a different shape +export default class EditCommunityPrototypeDialog extends React.PureComponent { + private avatarUploadRef: React.RefObject = React.createRef(); + + constructor(props: IProps) { + super(props); + + const profile = CommunityPrototypeStore.instance.getCommunityProfile(props.communityId); + + this.state = { + name: profile?.name || "", + error: null, + busy: false, + avatarFile: null, + avatarPreview: null, + currentAvatarUrl: profile?.avatarUrl, + }; + } + + private onNameChange = (ev: ChangeEvent) => { + this.setState({name: ev.target.value}); + }; + + private onSubmit = async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (this.state.busy) return; + + // We'll create the community now to see if it's taken, leaving it active in + // the background for the user to look at while they invite people. + this.setState({busy: true}); + try { + let avatarUrl = this.state.currentAvatarUrl || ""; // must be a string for synapse to accept it + if (this.state.avatarFile) { + avatarUrl = await MatrixClientPeg.get().uploadContent(this.state.avatarFile); + } + + await MatrixClientPeg.get().setGroupProfile(this.props.communityId, { + name: this.state.name, + avatar_url: avatarUrl, + }); + + // ask the flair store to update the profile too + await FlairStore.refreshGroupProfile(MatrixClientPeg.get(), this.props.communityId); + + // we did it, so close the dialog + this.props.onFinished(true); + } catch (e) { + console.error(e); + this.setState({ + busy: false, + error: _t("There was an error updating your community. The server is unable to process your request."), + }); + } + }; + + private onAvatarChanged = (e: ChangeEvent) => { + if (!e.target.files || !e.target.files.length) { + this.setState({avatarFile: null}); + } else { + this.setState({busy: true}); + const file = e.target.files[0]; + const reader = new FileReader(); + reader.onload = (ev: ProgressEvent) => { + this.setState({avatarFile: file, busy: false, avatarPreview: ev.target.result as string}); + }; + reader.readAsDataURL(file); + } + }; + + private onChangeAvatar = () => { + if (this.avatarUploadRef.current) this.avatarUploadRef.current.click(); + }; + + public render() { + let preview = ; + if (!this.state.avatarPreview) { + if (this.state.currentAvatarUrl) { + const url = MatrixClientPeg.get().mxcUrlToHttp(this.state.currentAvatarUrl); + preview = ; + } else { + preview =
    + } + } + + return ( + +
    +
    +
    + +
    +
    + + {preview} +
    + {_t("Add image (optional)")} + + {_t("An image will help people identify your community.")} + +
    +
    + + {_t("Save")} + +
    +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/ErrorDialog.js b/src/components/views/dialogs/ErrorDialog.js index fbc5509457..acebdcd854 100644 --- a/src/components/views/dialogs/ErrorDialog.js +++ b/src/components/views/dialogs/ErrorDialog.js @@ -26,14 +26,12 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'ErrorDialog', - propTypes: { +export default class ErrorDialog extends React.Component { + static propTypes = { title: PropTypes.string, description: PropTypes.oneOfType([ PropTypes.element, @@ -43,18 +41,16 @@ export default createReactClass({ focus: PropTypes.bool, onFinished: PropTypes.func.isRequired, headerImage: PropTypes.string, - }, + }; - getDefaultProps: function() { - return { - focus: true, - title: null, - description: null, - button: null, - }; - }, + static defaultProps = { + focus: true, + title: null, + description: null, + button: null, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( ); - }, -}); + } +} diff --git a/src/contexts/RoomContext.js b/src/components/views/dialogs/IDialogProps.ts similarity index 66% rename from src/contexts/RoomContext.js rename to src/components/views/dialogs/IDialogProps.ts index 8613be195c..1027ca7607 100644 --- a/src/contexts/RoomContext.js +++ b/src/components/views/dialogs/IDialogProps.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +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. @@ -14,12 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { createContext } from "react"; - -const RoomContext = createContext({ - canReact: undefined, - canReply: undefined, - room: undefined, -}); -RoomContext.displayName = "RoomContext"; -export default RoomContext; +export interface IDialogProps { + onFinished: (bool) => void; +} diff --git a/src/components/views/dialogs/InfoDialog.js b/src/components/views/dialogs/InfoDialog.js index b63f6ba9c6..8125bc3edd 100644 --- a/src/components/views/dialogs/InfoDialog.js +++ b/src/components/views/dialogs/InfoDialog.js @@ -17,15 +17,13 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import classNames from "classnames"; -export default createReactClass({ - displayName: 'InfoDialog', - propTypes: { +export default class InfoDialog extends React.Component { + static propTypes = { className: PropTypes.string, title: PropTypes.string, description: PropTypes.node, @@ -33,21 +31,19 @@ export default createReactClass({ onFinished: PropTypes.func, hasCloseButton: PropTypes.bool, onKeyDown: PropTypes.func, - }, + }; - getDefaultProps: function() { - return { - title: '', - description: '', - hasCloseButton: false, - }; - }, + static defaultProps = { + title: '', + description: '', + hasCloseButton: false, + }; - onFinished: function() { + onFinished = () => { this.props.onFinished(); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( @@ -69,5 +65,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/InteractiveAuthDialog.js b/src/components/views/dialogs/InteractiveAuthDialog.js index b06ce63ecd..22291225ad 100644 --- a/src/components/views/dialogs/InteractiveAuthDialog.js +++ b/src/components/views/dialogs/InteractiveAuthDialog.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; @@ -27,10 +26,8 @@ import AccessibleButton from '../elements/AccessibleButton'; import {ERROR_USER_CANCELLED} from "../../structures/InteractiveAuth"; import {SSOAuthEntry} from "../auth/InteractiveAuthEntryComponents"; -export default createReactClass({ - displayName: 'InteractiveAuthDialog', - - propTypes: { +export default class InteractiveAuthDialog extends React.Component { + static propTypes = { // matrix client to use for UI auth requests matrixClient: PropTypes.object.isRequired, @@ -70,19 +67,17 @@ export default createReactClass({ // // Default is defined in _getDefaultDialogAesthetics() aestheticsForStagePhases: PropTypes.object, - }, + }; - getInitialState: function() { - return { - authError: null, + state = { + authError: null, - // See _onUpdateStagePhase() - uiaStage: null, - uiaStagePhase: null, - }; - }, + // See _onUpdateStagePhase() + uiaStage: null, + uiaStagePhase: null, + }; - _getDefaultDialogAesthetics: function() { + _getDefaultDialogAesthetics() { const ssoAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { title: _t("Use Single Sign On to continue"), @@ -102,9 +97,9 @@ export default createReactClass({ [SSOAuthEntry.LOGIN_TYPE]: ssoAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: ssoAesthetics, }; - }, + } - _onAuthFinished: function(success, result) { + _onAuthFinished = (success, result) => { if (success) { this.props.onFinished(true, result); } else { @@ -116,18 +111,18 @@ export default createReactClass({ }); } } - }, + }; - _onUpdateStagePhase: function(newStage, newPhase) { + _onUpdateStagePhase = (newStage, newPhase) => { // We copy the stage and stage phase params into state for title selection in render() this.setState({uiaStage: newStage, uiaStagePhase: newPhase}); - }, + }; - _onDismissClick: function() { + _onDismissClick = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { const InteractiveAuth = sdk.getComponent("structures.InteractiveAuth"); const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); @@ -190,5 +185,5 @@ export default createReactClass({ { content } ); - }, -}); + } +} diff --git a/src/components/views/dialogs/InviteDialog.js b/src/components/views/dialogs/InviteDialog.js index c90811ed5a..73101056f3 100644 --- a/src/components/views/dialogs/InviteDialog.js +++ b/src/components/views/dialogs/InviteDialog.js @@ -32,11 +32,14 @@ import IdentityAuthClient from "../../../IdentityAuthClient"; import Modal from "../../../Modal"; import {humanizeTime} from "../../../utils/humanize"; import createRoom, {canEncryptToAllUsers, privateShouldBeEncrypted} from "../../../createRoom"; -import {inviteMultipleToRoom} from "../../../RoomInvite"; +import {inviteMultipleToRoom, showCommunityInviteDialog} from "../../../RoomInvite"; import {Key} from "../../../Keyboard"; import {Action} from "../../../dispatcher/actions"; import {DefaultTagID} from "../../../stores/room-list/models"; import RoomListStore from "../../../stores/room-list/RoomListStore"; +import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -327,7 +330,7 @@ export default class InviteDialog extends React.PureComponent { this.state = { targets: [], // array of Member objects (see interface above) filterText: "", - recents: this._buildRecents(alreadyInvited), + recents: InviteDialog.buildRecents(alreadyInvited), numRecentsShown: INITIAL_ROOMS_SHOWN, suggestions: this._buildSuggestions(alreadyInvited), numSuggestionsShown: INITIAL_ROOMS_SHOWN, @@ -344,7 +347,7 @@ export default class InviteDialog extends React.PureComponent { this._editorRef = createRef(); } - _buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { + static buildRecents(excludedTargetIds: Set): {userId: string, user: RoomMember, lastActive: number} { const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room // Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the @@ -548,7 +551,7 @@ export default class InviteDialog extends React.PureComponent { if (this.state.filterText.startsWith('@')) { // Assume mxid newMember = new DirectoryMember({user_id: this.state.filterText, display_name: null, avatar_url: null}); - } else { + } else if (SettingsStore.getValue(UIFeature.IdentityServer)) { // Assume email newMember = new ThreepidMember(this.state.filterText); } @@ -733,7 +736,7 @@ export default class InviteDialog extends React.PureComponent { this.setState({tryingIdentityServer: true}); return; } - if (term.indexOf('@') > 0 && Email.looksValid(term)) { + if (term.indexOf('@') > 0 && Email.looksValid(term) && SettingsStore.getValue(UIFeature.IdentityServer)) { // Start off by suggesting the plain email while we try and resolve it // to a real account. this.setState({ @@ -909,12 +912,23 @@ export default class InviteDialog extends React.PureComponent { this.props.onFinished(); }; + _onCommunityInviteClick = (e) => { + this.props.onFinished(); + showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); + }; + _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; let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); + let sectionSubname = null; + + if (kind === 'suggestions' && CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + sectionSubname = _t("May include members not in %(communityName)s", {communityName}); + } if (this.props.kind === KIND_INVITE) { sectionName = kind === 'recents' ? _t("Recently Direct Messaged") : _t("Suggestions"); @@ -993,6 +1007,7 @@ export default class InviteDialog extends React.PureComponent { return (

    {sectionName}

    + {sectionSubname ?

    {sectionSubname}

    : null} {tiles} {showMore}
    @@ -1024,7 +1039,9 @@ export default class InviteDialog extends React.PureComponent { } _renderIdentityServerWarning() { - if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer) { + if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer || + !SettingsStore.getValue(UIFeature.IdentityServer) + ) { return null; } @@ -1073,30 +1090,92 @@ export default class InviteDialog extends React.PureComponent { let buttonText; let goButtonFn; + const identityServersEnabled = SettingsStore.getValue(UIFeature.IdentityServer); + const userId = MatrixClientPeg.get().getUserId(); if (this.props.kind === KIND_DM) { title = _t("Direct Messages"); - helpText = _t( - "Start a conversation with someone using their name, username (like ) or email address.", - {}, - {userId: () => { - return {userId}; - }}, - ); + + if (identityServersEnabled) { + helpText = _t( + "Start a conversation with someone using their name, username (like ) or email address.", + {}, + {userId: () => { + return ( + {userId} + ); + }}, + ); + } else { + helpText = _t( + "Start a conversation with someone using their name or username (like ).", + {}, + {userId: () => { + return ( + {userId} + ); + }}, + ); + } + + if (CommunityPrototypeStore.instance.getSelectedCommunityId()) { + const communityName = CommunityPrototypeStore.instance.getSelectedCommunityName(); + const inviteText = _t("This won't invite them to %(communityName)s. " + + "To invite someone to %(communityName)s, click here", + {communityName}, { + userId: () => { + return ( + {userId} + ); + }, + a: (sub) => { + return ( + {sub} + ); + }, + }, + ); + helpText = + { helpText } {inviteText} + ; + } buttonText = _t("Go"); goButtonFn = this._startDm; } else { // KIND_INVITE title = _t("Invite to this room"); - helpText = _t( - "Invite someone using their name, username (like ), email address or share this room.", - {}, - { - userId: () => - {userId}, - a: (sub) => - {sub}, - }, - ); + + if (identityServersEnabled) { + helpText = _t( + "Invite someone using their name, username (like ), email address or " + + "share this room.", + {}, + { + userId: () => + {userId}, + a: (sub) => + {sub}, + }, + ); + } else { + helpText = _t( + "Invite someone using their name, username (like ) or share this room.", + {}, + { + userId: () => + {userId}, + a: (sub) => + {sub}, + }, + ); + } + buttonText = _t("Invite"); goButtonFn = this._inviteUsers; } diff --git a/src/components/views/dialogs/LogoutDialog.js b/src/components/views/dialogs/LogoutDialog.js index 930acaa0b8..af36dba2b6 100644 --- a/src/components/views/dialogs/LogoutDialog.js +++ b/src/components/views/dialogs/LogoutDialog.js @@ -20,7 +20,8 @@ import Modal from '../../../Modal'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; -import {MatrixClientPeg} from '../../../MatrixClientPeg'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; export default class LogoutDialog extends React.Component { defaultProps = { @@ -73,7 +74,7 @@ export default class LogoutDialog extends React.Component { _onExportE2eKeysClicked() { Modal.createTrackedDialogAsync('Export E2E Keys', '', - import('../../../async-components/views/dialogs/ExportE2eKeysDialog'), + import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), { matrixClient: MatrixClientPeg.get(), }, @@ -93,14 +94,13 @@ export default class LogoutDialog extends React.Component { // A key backup exists for this account, but the creating device is not // verified, so restore the backup which will give us the keys from it and // allow us to trust it (ie. upload keys to it) - const RestoreKeyBackupDialog = sdk.getComponent('dialogs.keybackup.RestoreKeyBackupDialog'); Modal.createTrackedDialog( 'Restore Backup', '', RestoreKeyBackupDialog, null, null, /* priority = */ false, /* static = */ true, ); } else { Modal.createTrackedDialogAsync("Key Backup", "Key Backup", - import("../../../async-components/views/dialogs/keybackup/CreateKeyBackupDialog"), + import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog"), null, null, /* priority = */ false, /* static = */ true, ); } diff --git a/src/components/views/dialogs/QuestionDialog.js b/src/components/views/dialogs/QuestionDialog.js index 07a1eae5d5..d6de60195f 100644 --- a/src/components/views/dialogs/QuestionDialog.js +++ b/src/components/views/dialogs/QuestionDialog.js @@ -16,14 +16,12 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'QuestionDialog', - propTypes: { +export default class QuestionDialog extends React.Component { + static propTypes = { title: PropTypes.string, description: PropTypes.node, extraButtons: PropTypes.node, @@ -34,29 +32,27 @@ export default createReactClass({ headerImage: PropTypes.string, quitOnly: PropTypes.bool, // quitOnly doesn't show the cancel button just the quit [x]. fixedWidth: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - title: "", - description: "", - extraButtons: null, - focus: true, - hasCancelButton: true, - danger: false, - quitOnly: false, - }; - }, + static defaultProps = { + title: "", + description: "", + extraButtons: null, + focus: true, + hasCancelButton: true, + danger: false, + quitOnly: false, + }; - onOk: function() { + onOk = () => { this.props.onFinished(true); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); let primaryButtonClass = ""; @@ -88,5 +84,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/RoomSettingsDialog.js b/src/components/views/dialogs/RoomSettingsDialog.js index 613708e436..a43b284c42 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.js +++ b/src/components/views/dialogs/RoomSettingsDialog.js @@ -29,6 +29,7 @@ import * as sdk from "../../../index"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; @@ -96,12 +97,14 @@ export default class RoomSettingsDialog extends React.Component { )); } - tabs.push(new Tab( - ROOM_ADVANCED_TAB, - _td("Advanced"), - "mx_RoomSettingsDialog_warningIcon", - , - )); + if (SettingsStore.getValue(UIFeature.AdvancedSettings)) { + tabs.push(new Tab( + ROOM_ADVANCED_TAB, + _td("Advanced"), + "mx_RoomSettingsDialog_warningIcon", + , + )); + } return tabs; } diff --git a/src/components/views/dialogs/RoomUpgradeDialog.js b/src/components/views/dialogs/RoomUpgradeDialog.js index c45d82303b..85e97444ed 100644 --- a/src/components/views/dialogs/RoomUpgradeDialog.js +++ b/src/components/views/dialogs/RoomUpgradeDialog.js @@ -15,38 +15,33 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'RoomUpgradeDialog', - - propTypes: { +export default class RoomUpgradeDialog extends React.Component { + static propTypes = { room: PropTypes.object.isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - componentDidMount: async function() { + state = { + busy: true, + }; + + async componentDidMount() { const recommended = await this.props.room.getRecommendedVersion(); this._targetVersion = recommended.version; this.setState({busy: false}); - }, + } - getInitialState: function() { - return { - busy: true, - }; - }, - - _onCancelClick: function() { + _onCancelClick = () => { this.props.onFinished(false); - }, + }; - _onUpgradeClick: function() { + _onUpgradeClick = () => { this.setState({busy: true}); MatrixClientPeg.get().upgradeRoom(this.props.room.roomId, this._targetVersion).then(() => { this.props.onFinished(true); @@ -59,9 +54,9 @@ export default createReactClass({ }).finally(() => { this.setState({busy: false}); }); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); const Spinner = sdk.getComponent('views.elements.Spinner'); @@ -106,5 +101,5 @@ export default createReactClass({ {buttons} ); - }, -}); + } +} diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx index f6767dcb8d..81f628343b 100644 --- a/src/components/views/dialogs/ServerOfflineDialog.tsx +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -27,9 +27,9 @@ import Spinner from "../elements/Spinner"; import AccessibleButton from "../elements/AccessibleButton"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { IDialogProps } from "./IDialogProps"; -interface IProps { - onFinished: (bool) => void; +interface IProps extends IDialogProps { } export default class ServerOfflineDialog extends React.PureComponent { diff --git a/src/components/views/dialogs/SessionRestoreErrorDialog.js b/src/components/views/dialogs/SessionRestoreErrorDialog.js index 3706172085..bae6b19fbe 100644 --- a/src/components/views/dialogs/SessionRestoreErrorDialog.js +++ b/src/components/views/dialogs/SessionRestoreErrorDialog.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; @@ -25,20 +24,18 @@ import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -export default createReactClass({ - displayName: 'SessionRestoreErrorDialog', - - propTypes: { +export default class SessionRestoreErrorDialog extends React.Component { + static propTypes = { error: PropTypes.string.isRequired, onFinished: PropTypes.func.isRequired, - }, + }; - _sendBugReport: function() { + _sendBugReport = () => { const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {}); - }, + }; - _onClearStorageClick: function() { + _onClearStorageClick = () => { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Session Restore Confirm Logout', '', QuestionDialog, { title: _t("Sign out"), @@ -48,15 +45,15 @@ export default createReactClass({ danger: true, onFinished: this.props.onFinished, }); - }, + }; - _onRefreshClick: function() { + _onRefreshClick = () => { // Is this likely to help? Probably not, but giving only one button // that clears your storage seems awful. window.location.reload(true); - }, + }; - render: function() { + render() { const brand = SdkConfig.get().brand; const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); @@ -110,5 +107,5 @@ export default createReactClass({ { dialogButtons } ); - }, -}); + } +} diff --git a/src/components/views/dialogs/SetEmailDialog.js b/src/components/views/dialogs/SetEmailDialog.js index 2e38d6a7c4..6514d94dc9 100644 --- a/src/components/views/dialogs/SetEmailDialog.js +++ b/src/components/views/dialogs/SetEmailDialog.js @@ -16,7 +16,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import * as Email from '../../../email'; @@ -25,31 +24,28 @@ import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; -/** +/* * Prompt the user to set an email address. * * On success, `onFinished(true)` is called. */ -export default createReactClass({ - displayName: 'SetEmailDialog', - propTypes: { +export default class SetEmailDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - emailAddress: '', - emailBusy: false, - }; - }, + state = { + emailAddress: '', + emailBusy: false, + }; - onEmailAddressChanged: function(value) { + onEmailAddressChanged = value => { this.setState({ emailAddress: value, }); - }, + }; - onSubmit: function() { + onSubmit = () => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); @@ -81,21 +77,21 @@ export default createReactClass({ }); }); this.setState({emailBusy: true}); - }, + }; - onCancelled: function() { + onCancelled = () => { this.props.onFinished(false); - }, + }; - onEmailDialogFinished: function(ok) { + onEmailDialogFinished = ok => { if (ok) { this.verifyEmailAddress(); } else { this.setState({emailBusy: false}); } - }, + }; - verifyEmailAddress: function() { + verifyEmailAddress() { this._addThreepid.checkEmailLinkClicked().then(() => { this.props.onFinished(true); }, (err) => { @@ -119,9 +115,9 @@ export default createReactClass({ }); } }); - }, + } - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const Spinner = sdk.getComponent('elements.Spinner'); const EditableText = sdk.getComponent('elements.EditableText'); @@ -161,5 +157,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/views/dialogs/SetMxIdDialog.js b/src/components/views/dialogs/SetMxIdDialog.js index f99d065e7e..090def5e54 100644 --- a/src/components/views/dialogs/SetMxIdDialog.js +++ b/src/components/views/dialogs/SetMxIdDialog.js @@ -16,7 +16,6 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; @@ -29,23 +28,27 @@ import { SAFE_LOCALPART_REGEX } from '../../../Registration'; // sending a request to the server const USERNAME_CHECK_DEBOUNCE_MS = 250; -/** +/* * Prompt the user to set a display name. * * On success, `onFinished(true, newDisplayName)` is called. */ -export default createReactClass({ - displayName: 'SetMxIdDialog', - propTypes: { +export default class SetMxIdDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, // Called when the user requests to register with a different homeserver onDifferentServerClicked: PropTypes.func.isRequired, // Called if the user wants to switch to login instead onLoginClick: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this._input_value = createRef(); + this._uiAuth = createRef(); + + this.state = { // The entered username username: '', // Indicate ongoing work on the username @@ -60,21 +63,15 @@ export default createReactClass({ // Indicate error with auth authError: '', }; - }, + } - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._input_value = createRef(); - this._uiAuth = createRef(); - }, - - componentDidMount: function() { + componentDidMount() { this._input_value.current.select(); this._matrixClient = MatrixClientPeg.get(); - }, + } - onValueChange: function(ev) { + onValueChange = ev => { this.setState({ username: ev.target.value, usernameBusy: true, @@ -99,24 +96,24 @@ export default createReactClass({ }); }, USERNAME_CHECK_DEBOUNCE_MS); }); - }, + }; - onKeyUp: function(ev) { + onKeyUp = ev => { if (ev.key === Key.ENTER) { this.onSubmit(); } - }, + }; - onSubmit: function(ev) { + onSubmit = ev => { if (this._uiAuth.current) { this._uiAuth.current.tryContinue(); } this.setState({ doingUIAuth: true, }); - }, + }; - _doUsernameCheck: function() { + _doUsernameCheck() { // We do a quick check ahead of the username availability API to ensure the // user ID roughly looks okay from a Matrix perspective. if (!SAFE_LOCALPART_REGEX.test(this.state.username)) { @@ -167,13 +164,13 @@ export default createReactClass({ this.setState(newState); }, ); - }, + } - _generatePassword: function() { + _generatePassword() { return Math.random().toString(36).slice(2); - }, + } - _makeRegisterRequest: function(auth) { + _makeRegisterRequest = auth => { // Not upgrading - changing mxids const guestAccessToken = null; if (!this._generatedPassword) { @@ -187,9 +184,9 @@ export default createReactClass({ {}, guestAccessToken, ); - }, + }; - _onUIAuthFinished: function(success, response) { + _onUIAuthFinished = (success, response) => { this.setState({ doingUIAuth: false, }); @@ -207,9 +204,9 @@ export default createReactClass({ accessToken: response.access_token, password: this._generatedPassword, }); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); @@ -303,5 +300,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/SetPasswordDialog.js b/src/components/views/dialogs/SetPasswordDialog.js index fcc6e67656..3649190ac9 100644 --- a/src/components/views/dialogs/SetPasswordDialog.js +++ b/src/components/views/dialogs/SetPasswordDialog.js @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; @@ -63,32 +62,25 @@ const WarmFuzzy = function(props) { * * On success, `onFinished()` when finished */ -export default createReactClass({ - displayName: 'SetPasswordDialog', - propTypes: { +export default class SetPasswordDialog extends React.Component { + static propTypes = { onFinished: PropTypes.func.isRequired, - }, + }; - getInitialState: function() { - return { - error: null, - }; - }, + state = { + error: null, + }; - componentDidMount: function() { - console.info('SetPasswordDialog component did mount'); - }, - - _onPasswordChanged: function(res) { + _onPasswordChanged = res => { Modal.createDialog(WarmFuzzy, { didSetEmail: res.didSetEmail, onFinished: () => { this.props.onFinished(); }, }); - }, + }; - _onPasswordChangeError: function(err) { + _onPasswordChangeError = err => { let errMsg = err.error || ""; if (err.httpStatus === 403) { errMsg = _t('Failed to change password. Is your password correct?'); @@ -101,9 +93,9 @@ export default createReactClass({ this.setState({ error: errMsg, }); - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const ChangePassword = sdk.getComponent('views.settings.ChangePassword'); @@ -132,5 +124,5 @@ export default createReactClass({ ); - }, -}); + } +} diff --git a/src/components/views/dialogs/ShareDialog.tsx b/src/components/views/dialogs/ShareDialog.tsx index dc2a987f13..1569977d58 100644 --- a/src/components/views/dialogs/ShareDialog.tsx +++ b/src/components/views/dialogs/ShareDialog.tsx @@ -31,6 +31,9 @@ import {toRightOf} from "../../structures/ContextMenu"; import {copyPlaintext, selectText} from "../../../utils/strings"; import StyledCheckbox from '../elements/StyledCheckbox'; import AccessibleTooltipButton from '../elements/AccessibleTooltipButton'; +import { IDialogProps } from "./IDialogProps"; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; const socials = [ { @@ -60,8 +63,7 @@ const socials = [ }, ]; -interface IProps { - onFinished: () => void; +interface IProps extends IDialogProps { target: Room | User | Group | RoomMember | MatrixEvent; permalinkCreator: RoomPermalinkCreator; } @@ -186,8 +188,8 @@ export default class ShareDialog extends React.PureComponent { title = _t('Share Room Message'); checkbox =
    { _t('Link to selected message') } @@ -197,34 +199,18 @@ export default class ShareDialog extends React.PureComponent { const matrixToUrl = this.getUrl(); const encodedUrl = encodeURIComponent(matrixToUrl); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return -
    - - { checkbox } -
    + const showQrCode = SettingsStore.getValue(UIFeature.ShareQRCode); + const showSocials = SettingsStore.getValue(UIFeature.ShareSocial); + let qrSocialSection; + if (showQrCode || showSocials) { + qrSocialSection = <> +
    -
    + { showQrCode &&
    -
    -
    +
    } + { showSocials &&
    { socials.map((social) => ( { {social.name} )) } -
    +
    }
    + ; + } + + const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); + return +
    + + { checkbox } + { qrSocialSection }
    ; } diff --git a/src/components/views/dialogs/SlashCommandHelpDialog.js b/src/components/views/dialogs/SlashCommandHelpDialog.js index bae5b37993..5b4148e939 100644 --- a/src/components/views/dialogs/SlashCommandHelpDialog.js +++ b/src/components/views/dialogs/SlashCommandHelpDialog.js @@ -24,6 +24,7 @@ export default ({onFinished}) => { const categories = {}; Commands.forEach(cmd => { + if (!cmd.isEnabled()) return; if (!categories[cmd.category]) { categories[cmd.category] = []; } diff --git a/src/components/views/dialogs/TextInputDialog.js b/src/components/views/dialogs/TextInputDialog.js index d7ca3f144d..571ed7e413 100644 --- a/src/components/views/dialogs/TextInputDialog.js +++ b/src/components/views/dialogs/TextInputDialog.js @@ -15,14 +15,12 @@ limitations under the License. */ import React, {createRef} from 'react'; -import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import Field from "../elements/Field"; -export default createReactClass({ - displayName: 'TextInputDialog', - propTypes: { +export default class TextInputDialog extends React.Component { + static propTypes = { title: PropTypes.string, description: PropTypes.oneOfType([ PropTypes.element, @@ -36,39 +34,36 @@ export default createReactClass({ hasCancel: PropTypes.bool, validator: PropTypes.func, // result of withValidation fixedWidth: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - title: "", - value: "", - description: "", - focus: true, - hasCancel: true, - }; - }, + static defaultProps = { + title: "", + value: "", + description: "", + focus: true, + hasCancel: true, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this._field = createRef(); + + this.state = { value: this.props.value, valid: false, }; - }, + } - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { - this._field = createRef(); - }, - - componentDidMount: function() { + componentDidMount() { if (this.props.focus) { // Set the cursor at the end of the text input // this._field.current.value = this.props.value; this._field.current.focus(); } - }, + } - onOk: async function(ev) { + onOk = async ev => { ev.preventDefault(); if (this.props.validator) { await this._field.current.validate({ allowEmpty: false }); @@ -80,27 +75,27 @@ export default createReactClass({ } } this.props.onFinished(true, this.state.value); - }, + }; - onCancel: function() { + onCancel = () => { this.props.onFinished(false); - }, + }; - onChange: function(ev) { + onChange = ev => { this.setState({ value: ev.target.value, }); - }, + }; - onValidate: async function(fieldState) { + onValidate = async fieldState => { const result = await this.props.validator(fieldState); this.setState({ valid: result.valid, }); return result; - }, + }; - render: function() { + render() { const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( @@ -137,5 +132,5 @@ export default createReactClass({ /> ); - }, -}); + } +} diff --git a/src/components/views/dialogs/UserSettingsDialog.js b/src/components/views/dialogs/UserSettingsDialog.js index ffde03fe31..7164540aea 100644 --- a/src/components/views/dialogs/UserSettingsDialog.js +++ b/src/components/views/dialogs/UserSettingsDialog.js @@ -32,6 +32,7 @@ import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab"; import * as sdk from "../../../index"; import SdkConfig from "../../../SdkConfig"; import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab"; +import {UIFeature} from "../../../settings/UIFeature"; export const USER_GENERAL_TAB = "USER_GENERAL_TAB"; export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB"; @@ -86,12 +87,14 @@ export default class UserSettingsDialog extends React.Component { "mx_UserSettingsDialog_appearanceIcon", , )); - tabs.push(new Tab( - USER_FLAIR_TAB, - _td("Flair"), - "mx_UserSettingsDialog_flairIcon", - , - )); + if (SettingsStore.getValue(UIFeature.Flair)) { + tabs.push(new Tab( + USER_FLAIR_TAB, + _td("Flair"), + "mx_UserSettingsDialog_flairIcon", + , + )); + } tabs.push(new Tab( USER_NOTIFICATIONS_TAB, _td("Notifications"), @@ -104,12 +107,16 @@ export default class UserSettingsDialog extends React.Component { "mx_UserSettingsDialog_preferencesIcon", , )); - tabs.push(new Tab( - USER_VOICE_TAB, - _td("Voice & Video"), - "mx_UserSettingsDialog_voiceIcon", - , - )); + + if (SettingsStore.getValue(UIFeature.Voip)) { + tabs.push(new Tab( + USER_VOICE_TAB, + _td("Voice & Video"), + "mx_UserSettingsDialog_voiceIcon", + , + )); + } + tabs.push(new Tab( USER_SECURITY_TAB, _td("Security & Privacy"), diff --git a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js b/src/components/views/dialogs/security/AccessSecretStorageDialog.js similarity index 99% rename from src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js rename to src/components/views/dialogs/security/AccessSecretStorageDialog.js index 5c01a6907f..85ace249a3 100644 --- a/src/components/views/dialogs/secretstorage/AccessSecretStorageDialog.js +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.js @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { debounce } from 'lodash'; +import {debounce} from "lodash"; import classNames from 'classnames'; import React from 'react'; import PropTypes from "prop-types"; diff --git a/src/components/views/dialogs/ConfirmDestroyCrossSigningDialog.js b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js similarity index 96% rename from src/components/views/dialogs/ConfirmDestroyCrossSigningDialog.js rename to src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js index 9e1980e98d..abc1586205 100644 --- a/src/components/views/dialogs/ConfirmDestroyCrossSigningDialog.js +++ b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.js @@ -16,8 +16,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import {_t} from "../../../languageHandler"; -import * as sdk from "../../../index"; +import {_t} from "../../../../languageHandler"; +import * as sdk from "../../../../index"; export default class ConfirmDestroyCrossSigningDialog extends React.Component { static propTypes = { diff --git a/src/components/views/dialogs/security/CreateCrossSigningDialog.js b/src/components/views/dialogs/security/CreateCrossSigningDialog.js new file mode 100644 index 0000000000..226419e759 --- /dev/null +++ b/src/components/views/dialogs/security/CreateCrossSigningDialog.js @@ -0,0 +1,187 @@ +/* +Copyright 2018, 2019 New Vector Ltd +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 from 'react'; +import PropTypes from 'prop-types'; +import { MatrixClientPeg } from '../../../../MatrixClientPeg'; +import { _t } from '../../../../languageHandler'; +import Modal from '../../../../Modal'; +import { SSOAuthEntry } from '../../auth/InteractiveAuthEntryComponents'; +import DialogButtons from '../../elements/DialogButtons'; +import BaseDialog from '../BaseDialog'; +import Spinner from '../../elements/Spinner'; +import InteractiveAuthDialog from '../InteractiveAuthDialog'; + +/* + * Walks the user through the process of creating a cross-signing keys. In most + * cases, only a spinner is shown, but for more complex auth like SSO, the user + * may need to complete some steps to proceed. + */ +export default class CreateCrossSigningDialog extends React.PureComponent { + static propTypes = { + accountPassword: PropTypes.string, + }; + + constructor(props) { + super(props); + + this.state = { + error: null, + // Does the server offer a UI auth flow with just m.login.password + // for /keys/device_signing/upload? + canUploadKeysWithPasswordOnly: null, + accountPassword: props.accountPassword || "", + }; + + if (this.state.accountPassword) { + // If we have an account password in memory, let's simplify and + // assume it means password auth is also supported for device + // signing key upload as well. This avoids hitting the server to + // test auth flows, which may be slow under high load. + this.state.canUploadKeysWithPasswordOnly = true; + } else { + this._queryKeyUploadAuth(); + } + } + + componentDidMount() { + this._bootstrapCrossSigning(); + } + + async _queryKeyUploadAuth() { + try { + await MatrixClientPeg.get().uploadDeviceSigningKeys(null, {}); + // We should never get here: the server should always require + // UI auth to upload device signing keys. If we do, we upload + // no keys which would be a no-op. + console.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!"); + } catch (error) { + if (!error.data || !error.data.flows) { + console.log("uploadDeviceSigningKeys advertised no flows!"); + return; + } + const canUploadKeysWithPasswordOnly = error.data.flows.some(f => { + return f.stages.length === 1 && f.stages[0] === 'm.login.password'; + }); + this.setState({ + canUploadKeysWithPasswordOnly, + }); + } + } + + _doBootstrapUIAuth = async (makeRequest) => { + if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) { + await makeRequest({ + type: 'm.login.password', + identifier: { + type: 'm.id.user', + user: MatrixClientPeg.get().getUserId(), + }, + // TODO: Remove `user` once servers support proper UIA + // See https://github.com/matrix-org/synapse/issues/5665 + user: MatrixClientPeg.get().getUserId(), + password: this.state.accountPassword, + }); + } else { + const dialogAesthetics = { + [SSOAuthEntry.PHASE_PREAUTH]: { + title: _t("Use Single Sign On to continue"), + body: _t("To continue, use Single Sign On to prove your identity."), + continueText: _t("Single Sign On"), + continueKind: "primary", + }, + [SSOAuthEntry.PHASE_POSTAUTH]: { + title: _t("Confirm encryption setup"), + body: _t("Click the button below to confirm setting up encryption."), + continueText: _t("Confirm"), + continueKind: "primary", + }, + }; + + const { finished } = Modal.createTrackedDialog( + 'Cross-signing keys dialog', '', InteractiveAuthDialog, + { + title: _t("Setting up keys"), + matrixClient: MatrixClientPeg.get(), + makeRequest, + aestheticsForStagePhases: { + [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, + [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, + }, + }, + ); + const [confirmed] = await finished; + if (!confirmed) { + throw new Error("Cross-signing key upload auth canceled"); + } + } + } + + _bootstrapCrossSigning = async () => { + this.setState({ + error: null, + }); + + const cli = MatrixClientPeg.get(); + + try { + await cli.bootstrapCrossSigning({ + authUploadDeviceSigningKeys: this._doBootstrapUIAuth, + }); + this.props.onFinished(true); + } catch (e) { + this.setState({ error: e }); + console.error("Error bootstrapping cross-signing", e); + } + } + + _onCancel = () => { + this.props.onFinished(false); + } + + render() { + let content; + if (this.state.error) { + content =
    +

    {_t("Unable to set up keys")}

    +
    + +
    +
    ; + } else { + content =
    + +
    ; + } + + return ( + +
    + {content} +
    +
    + ); + } +} diff --git a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js similarity index 99% rename from src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js rename to src/components/views/dialogs/security/RestoreKeyBackupDialog.js index dd34dfbbf0..2362133460 100644 --- a/src/components/views/dialogs/keybackup/RestoreKeyBackupDialog.js +++ b/src/components/views/dialogs/security/RestoreKeyBackupDialog.js @@ -21,7 +21,7 @@ import * as sdk from '../../../../index'; import {MatrixClientPeg} from '../../../../MatrixClientPeg'; import { MatrixClient } from 'matrix-js-sdk'; import { _t } from '../../../../languageHandler'; -import { accessSecretStorage } from '../../../../CrossSigningManager'; +import { accessSecretStorage } from '../../../../SecurityManager'; const RESTORE_TYPE_PASSPHRASE = 0; const RESTORE_TYPE_RECOVERYKEY = 1; diff --git a/src/components/views/dialogs/SetupEncryptionDialog.js b/src/components/views/dialogs/security/SetupEncryptionDialog.js similarity index 80% rename from src/components/views/dialogs/SetupEncryptionDialog.js rename to src/components/views/dialogs/security/SetupEncryptionDialog.js index d7723de588..9ce3144534 100644 --- a/src/components/views/dialogs/SetupEncryptionDialog.js +++ b/src/components/views/dialogs/security/SetupEncryptionDialog.js @@ -16,16 +16,16 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import SetupEncryptionBody from '../../structures/auth/SetupEncryptionBody'; -import BaseDialog from './BaseDialog'; -import { _t } from '../../../languageHandler'; -import { SetupEncryptionStore, PHASE_DONE } from '../../../stores/SetupEncryptionStore'; +import SetupEncryptionBody from '../../../structures/auth/SetupEncryptionBody'; +import BaseDialog from '../BaseDialog'; +import { _t } from '../../../../languageHandler'; +import { SetupEncryptionStore, PHASE_DONE } from '../../../../stores/SetupEncryptionStore'; function iconFromPhase(phase) { if (phase === PHASE_DONE) { - return require("../../../../res/img/e2e/verified.svg"); + return require("../../../../../res/img/e2e/verified.svg"); } else { - return require("../../../../res/img/e2e/warning.svg"); + return require("../../../../../res/img/e2e/warning.svg"); } } diff --git a/src/components/views/elements/ActionButton.js b/src/components/views/elements/ActionButton.js index 7536d66653..bec016bce0 100644 --- a/src/components/views/elements/ActionButton.js +++ b/src/components/views/elements/ActionButton.js @@ -16,16 +16,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import AccessibleButton from './AccessibleButton'; import dis from '../../../dispatcher/dispatcher'; import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; -export default createReactClass({ - displayName: 'RoleButton', - - propTypes: { +export default class ActionButton extends React.Component { + static propTypes = { size: PropTypes.string, tooltip: PropTypes.bool, action: PropTypes.string.isRequired, @@ -33,39 +30,35 @@ export default createReactClass({ label: PropTypes.string.isRequired, iconPath: PropTypes.string, className: PropTypes.string, - }, + }; - getDefaultProps: function() { - return { - size: "25", - tooltip: false, - }; - }, + static defaultProps = { + size: "25", + tooltip: false, + }; - getInitialState: function() { - return { - showTooltip: false, - }; - }, + state = { + showTooltip: false, + }; - _onClick: function(ev) { + _onClick = (ev) => { ev.stopPropagation(); Analytics.trackEvent('Action Button', 'click', this.props.action); dis.dispatch({action: this.props.action}); - }, + }; - _onMouseEnter: function() { + _onMouseEnter = () => { if (this.props.tooltip) this.setState({showTooltip: true}); if (this.props.mouseOverAction) { dis.dispatch({action: this.props.mouseOverAction}); } - }, + }; - _onMouseLeave: function() { + _onMouseLeave = () => { this.setState({showTooltip: false}); - }, + }; - render: function() { + render() { const TintableSvg = sdk.getComponent("elements.TintableSvg"); let tooltip; @@ -94,5 +87,5 @@ export default createReactClass({ { tooltip } ); - }, -}); + } +} diff --git a/src/components/views/elements/AddressSelector.js b/src/components/views/elements/AddressSelector.js index ab29723a45..45cdbeced8 100644 --- a/src/components/views/elements/AddressSelector.js +++ b/src/components/views/elements/AddressSelector.js @@ -17,15 +17,12 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; import classNames from 'classnames'; import { UserAddressType } from '../../../UserAddress'; -export default createReactClass({ - displayName: 'AddressSelector', - - propTypes: { +export default class AddressSelector extends React.Component { + static propTypes = { onSelected: PropTypes.func.isRequired, // List of the addresses to display @@ -37,90 +34,91 @@ export default createReactClass({ // Element to put as a header on top of the list header: PropTypes.node, - }, + }; - getInitialState: function() { - return { + constructor(props) { + super(props); + + this.state = { selected: this.props.selected === undefined ? 0 : this.props.selected, hover: false, }; - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(props) { + UNSAFE_componentWillReceiveProps(props) { // Make sure the selected item isn't outside the list bounds const selected = this.state.selected; const maxSelected = this._maxSelected(props.addressList); if (selected > maxSelected) { this.setState({ selected: maxSelected }); } - }, + } - componentDidUpdate: function() { + componentDidUpdate() { // As the user scrolls with the arrow keys keep the selected item // at the top of the window. if (this.scrollElement && this.props.addressList.length > 0 && !this.state.hover) { const elementHeight = this.addressListElement.getBoundingClientRect().height; this.scrollElement.scrollTop = (this.state.selected * elementHeight) - elementHeight; } - }, + } - moveSelectionTop: function() { + moveSelectionTop = () => { if (this.state.selected > 0) { this.setState({ selected: 0, hover: false, }); } - }, + }; - moveSelectionUp: function() { + moveSelectionUp = () => { if (this.state.selected > 0) { this.setState({ selected: this.state.selected - 1, hover: false, }); } - }, + }; - moveSelectionDown: function() { + moveSelectionDown = () => { if (this.state.selected < this._maxSelected(this.props.addressList)) { this.setState({ selected: this.state.selected + 1, hover: false, }); } - }, + }; - chooseSelection: function() { + chooseSelection = () => { this.selectAddress(this.state.selected); - }, + }; - onClick: function(index) { + onClick = index => { this.selectAddress(index); - }, + }; - onMouseEnter: function(index) { + onMouseEnter = index => { this.setState({ selected: index, hover: true, }); - }, + }; - onMouseLeave: function() { + onMouseLeave = () => { this.setState({ hover: false }); - }, + }; - selectAddress: function(index) { + selectAddress = index => { // Only try to select an address if one exists if (this.props.addressList.length !== 0) { this.props.onSelected(index); this.setState({ hover: false }); } - }, + }; - createAddressListTiles: function() { - const self = this; + createAddressListTiles() { const AddressTile = sdk.getComponent("elements.AddressTile"); const maxSelected = this._maxSelected(this.props.addressList); const addressList = []; @@ -157,15 +155,15 @@ export default createReactClass({ } } return addressList; - }, + } - _maxSelected: function(list) { + _maxSelected(list) { const listSize = list.length === 0 ? 0 : list.length - 1; const maxSelected = listSize > (this.props.truncateAt - 1) ? (this.props.truncateAt - 1) : listSize; return maxSelected; - }, + } - render: function() { + render() { const classes = classNames({ "mx_AddressSelector": true, "mx_AddressSelector_empty": this.props.addressList.length === 0, @@ -177,5 +175,5 @@ export default createReactClass({ { this.createAddressListTiles() }
    ); - }, -}); + } +} diff --git a/src/components/views/elements/AddressTile.js b/src/components/views/elements/AddressTile.js index e5ea2e5d20..dc6c6b2914 100644 --- a/src/components/views/elements/AddressTile.js +++ b/src/components/views/elements/AddressTile.js @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import classNames from 'classnames'; import * as sdk from "../../../index"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; @@ -25,25 +24,21 @@ import { _t } from '../../../languageHandler'; import { UserAddressType } from '../../../UserAddress.js'; -export default createReactClass({ - displayName: 'AddressTile', - - propTypes: { +export default class AddressTile extends React.Component { + static propTypes = { address: UserAddressType.isRequired, canDismiss: PropTypes.bool, onDismissed: PropTypes.func, justified: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - canDismiss: false, - onDismissed: function() {}, // NOP - justified: false, - }; - }, + static defaultProps = { + canDismiss: false, + onDismissed: function() {}, // NOP + justified: false, + }; - render: function() { + render() { const address = this.props.address; const name = address.displayName || address.address; @@ -144,5 +139,5 @@ export default createReactClass({ { dismiss }
    ); - }, -}); + } +} diff --git a/src/components/views/elements/AppTile.js b/src/components/views/elements/AppTile.js index a52dea3e0a..6aaeab060f 100644 --- a/src/components/views/elements/AppTile.js +++ b/src/components/views/elements/AppTile.js @@ -42,6 +42,8 @@ import {WidgetType} from "../../../widgets/WidgetType"; import {Capability} from "../../../widgets/WidgetApi"; import {sleep} from "../../../utils/promise"; import {SettingLevel} from "../../../settings/SettingLevel"; +import WidgetStore from "../../../stores/WidgetStore"; +import {Action} from "../../../dispatcher/actions"; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ENABLE_REACT_PERF = false; @@ -100,6 +102,8 @@ export default class AppTile extends React.Component { _getNewState(newProps) { // This is a function to make the impact of calling SettingsStore slightly less const hasPermissionToLoad = () => { + if (this._usingLocalWidget()) return true; + const currentlyAllowedWidgets = SettingsStore.getValue("allowedWidgets", newProps.room.roomId); return !!currentlyAllowedWidgets[newProps.app.eventId]; }; @@ -310,35 +314,12 @@ export default class AppTile extends React.Component { if (this.props.onEditClick) { this.props.onEditClick(); } else { - // TODO: Open the right manager for the widget - if (SettingsStore.getValue("feature_many_integration_managers")) { - IntegrationManagers.sharedInstance().openAll( - this.props.room, - 'type_' + this.props.app.type, - this.props.app.id, - ); - } else { - IntegrationManagers.sharedInstance().getPrimaryManager().open( - this.props.room, - 'type_' + this.props.app.type, - this.props.app.id, - ); - } + WidgetUtils.editWidget(this.props.room, this.props.app); } } _onSnapshotClick() { - console.log("Requesting widget snapshot"); - ActiveWidgetStore.getWidgetMessaging(this.props.app.id).getScreenshot() - .catch((err) => { - console.error("Failed to get screenshot", err); - }) - .then((screenshot) => { - dis.dispatch({ - action: 'picture_snapshot', - file: screenshot, - }, true); - }); + WidgetUtils.snapshotWidget(this.props.app); } /** @@ -419,6 +400,10 @@ export default class AppTile extends React.Component { } } + _onUnpinClicked = () => { + WidgetStore.instance.unpinWidget(this.props.app.id); + } + _onRevokeClicked() { console.info("Revoke widget permissions - %s", this.props.app.id); this._revokeWidgetPermission(); @@ -490,12 +475,20 @@ export default class AppTile extends React.Component { if (payload.widgetId === this.props.app.id) { switch (payload.action) { case 'm.sticker': - if (this._hasCapability('m.sticker')) { - dis.dispatch({action: 'post_sticker_message', data: payload.data}); - } else { - console.warn('Ignoring sticker message. Invalid capability'); - } - break; + if (this._hasCapability('m.sticker')) { + dis.dispatch({action: 'post_sticker_message', data: payload.data}); + } else { + console.warn('Ignoring sticker message. Invalid capability'); + } + break; + + case Action.AppTileDelete: + this._onDeleteClick(); + break; + + case Action.AppTileRevoke: + this._onRevokeClicked(); + break; } } } @@ -613,6 +606,15 @@ export default class AppTile extends React.Component { return uriFromTemplate(u, vars); } + /** + * Whether we're using a local version of the widget rather than loading the + * actual widget URL + * @returns {bool} true If using a local version of the widget + */ + _usingLocalWidget() { + return WidgetType.JITSI.matches(this.props.app.type); + } + /** * Get the URL used in the iframe * In cases where we supply our own UI for a widget, this is an internal @@ -626,7 +628,10 @@ export default class AppTile extends React.Component { if (WidgetType.JITSI.matches(this.props.app.type)) { console.log("Replacing Jitsi widget URL with local wrapper"); - url = WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: true}); + url = WidgetUtils.getLocalJitsiWrapperUrl({ + forLocalRender: true, + auth: this.props.app.data ? this.props.app.data.auth : null, + }); url = this._addWurlParams(url); } else { url = this._getSafeUrl(this.state.widgetUrl); @@ -637,7 +642,10 @@ export default class AppTile extends React.Component { _getPopoutUrl() { if (WidgetType.JITSI.matches(this.props.app.type)) { return this._templatedUrl( - WidgetUtils.getLocalJitsiWrapperUrl({forLocalRender: false}), + WidgetUtils.getLocalJitsiWrapperUrl({ + forLocalRender: false, + auth: this.props.app.data ? this.props.app.data.auth : null, + }), this.props.app.type, ); } else { @@ -735,7 +743,7 @@ export default class AppTile extends React.Component { // Additional iframe feature pemissions // (see - https://sites.google.com/a/chromium.org/dev/Home/chromium-security/deprecating-permissions-in-cross-origin-iframes and https://wicg.github.io/feature-policy/) - const iframeFeatures = "microphone; camera; encrypted-media; autoplay;"; + const iframeFeatures = "microphone; camera; encrypted-media; autoplay; display-capture;"; const appTileBodyClass = 'mx_AppTileBody' + (this.props.miniMode ? '_mini ' : ' '); @@ -804,14 +812,16 @@ export default class AppTile extends React.Component { const showMinimiseButton = this.props.showMinimise && this.props.show; const showMaximiseButton = this.props.showMinimise && !this.props.show; - let appTileClass; + let appTileClasses; if (this.props.miniMode) { - appTileClass = 'mx_AppTile_mini'; + appTileClasses = {mx_AppTile_mini: true}; } else if (this.props.fullWidth) { - appTileClass = 'mx_AppTileFullWidth'; + appTileClasses = {mx_AppTileFullWidth: true}; } else { - appTileClass = 'mx_AppTile'; + appTileClasses = {mx_AppTile: true}; } + appTileClasses.mx_AppTile_minimised = !this.props.show; + appTileClasses = classNames(appTileClasses); const menuBarClasses = classNames({ mx_AppTileMenuBar: true, @@ -831,6 +841,9 @@ export default class AppTile extends React.Component { contextMenu = ( -
    +
    { this.props.showMenubar &&
    { /* Minimise widget */ } { showMinimiseButton && } { /* Maximise widget */ } { showMaximiseButton && } { /* Title */ } diff --git a/src/components/views/elements/DialogButtons.js b/src/components/views/elements/DialogButtons.js index 9223b5ade8..001292b6b7 100644 --- a/src/components/views/elements/DialogButtons.js +++ b/src/components/views/elements/DialogButtons.js @@ -18,16 +18,13 @@ limitations under the License. import React from "react"; import PropTypes from "prop-types"; -import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; /** * Basic container for buttons in modal dialogs. */ -export default createReactClass({ - displayName: "DialogButtons", - - propTypes: { +export default class DialogButtons extends React.Component { + static propTypes = { // The primary button which is styled differently and has default focus. primaryButton: PropTypes.node.isRequired, @@ -57,20 +54,18 @@ export default createReactClass({ // disables only the primary button primaryDisabled: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - hasCancel: true, - disabled: false, - }; - }, + static defaultProps = { + hasCancel: true, + disabled: false, + }; - _onCancelClick: function() { + _onCancelClick = () => { this.props.onCancel(); - }, + }; - render: function() { + render() { let primaryButtonClassName = "mx_Dialog_primary"; if (this.props.primaryButtonClass) { primaryButtonClassName += " " + this.props.primaryButtonClass; @@ -104,5 +99,5 @@ export default createReactClass({
    ); - }, -}); + } +} diff --git a/src/components/views/elements/Draggable.tsx b/src/components/views/elements/Draggable.tsx index 3397fd901c..a6eb8323f3 100644 --- a/src/components/views/elements/Draggable.tsx +++ b/src/components/views/elements/Draggable.tsx @@ -34,7 +34,6 @@ export interface ILocationState { } export default class Draggable extends React.Component { - constructor(props: IProps) { super(props); @@ -77,5 +76,4 @@ export default class Draggable extends React.Component { render() { return
    ; } - -} \ No newline at end of file +} diff --git a/src/components/views/elements/EditableText.js b/src/components/views/elements/EditableText.js index 82f5eef125..49eb331aef 100644 --- a/src/components/views/elements/EditableText.js +++ b/src/components/views/elements/EditableText.js @@ -17,13 +17,10 @@ limitations under the License. import React, {createRef} from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import {Key} from "../../../Keyboard"; -export default createReactClass({ - displayName: 'EditableText', - - propTypes: { +export default class EditableText extends React.Component { + static propTypes = { onValueChanged: PropTypes.func, initialValue: PropTypes.string, label: PropTypes.string, @@ -36,60 +33,58 @@ export default createReactClass({ // Will cause onValueChanged(value, true) to fire on blur blurToSubmit: PropTypes.bool, editable: PropTypes.bool, - }, + }; - Phases: { + static Phases = { Display: "display", Edit: "edit", - }, + }; - getDefaultProps: function() { - return { - onValueChanged: function() {}, - initialValue: '', - label: '', - placeholder: '', - editable: true, - className: "mx_EditableText", - placeholderClassName: "mx_EditableText_placeholder", - blurToSubmit: false, - }; - }, + static defaultProps = { + onValueChanged() {}, + initialValue: '', + label: '', + placeholder: '', + editable: true, + className: "mx_EditableText", + placeholderClassName: "mx_EditableText_placeholder", + blurToSubmit: false, + }; - getInitialState: function() { - return { - phase: this.Phases.Display, - }; - }, + constructor(props) { + super(props); - // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(nextProps) { - if (nextProps.initialValue !== this.props.initialValue) { - this.value = nextProps.initialValue; - if (this._editable_div.current) { - this.showPlaceholder(!this.value); - } - } - }, - - // TODO: [REACT-WARNING] Replace component with real class, use constructor for refs - UNSAFE_componentWillMount: function() { // we track value as an JS object field rather than in React state // as React doesn't play nice with contentEditable. this.value = ''; this.placeholder = false; this._editable_div = createRef(); - }, + } - componentDidMount: function() { + state = { + phase: EditableText.Phases.Display, + }; + + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(nextProps) { + if (nextProps.initialValue !== this.props.initialValue) { + this.value = nextProps.initialValue; + if (this._editable_div.current) { + this.showPlaceholder(!this.value); + } + } + } + + componentDidMount() { this.value = this.props.initialValue; if (this._editable_div.current) { this.showPlaceholder(!this.value); } - }, + } - showPlaceholder: function(show) { + showPlaceholder = show => { if (show) { this._editable_div.current.textContent = this.props.placeholder; this._editable_div.current.setAttribute("class", this.props.className @@ -101,38 +96,36 @@ export default createReactClass({ this._editable_div.current.setAttribute("class", this.props.className); this.placeholder = false; } - }, + }; - getValue: function() { - return this.value; - }, + getValue = () => this.value; - setValue: function(value) { + setValue = value => { this.value = value; this.showPlaceholder(!this.value); - }, + }; - edit: function() { + edit = () => { this.setState({ - phase: this.Phases.Edit, + phase: EditableText.Phases.Edit, }); - }, + }; - cancelEdit: function() { + cancelEdit = () => { this.setState({ - phase: this.Phases.Display, + phase: EditableText.Phases.Display, }); this.value = this.props.initialValue; this.showPlaceholder(!this.value); this.onValueChanged(false); this._editable_div.current.blur(); - }, + }; - onValueChanged: function(shouldSubmit) { + onValueChanged = shouldSubmit => { this.props.onValueChanged(this.value, shouldSubmit); - }, + }; - onKeyDown: function(ev) { + onKeyDown = ev => { // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); if (this.placeholder) { @@ -145,9 +138,9 @@ export default createReactClass({ } // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); - }, + }; - onKeyUp: function(ev) { + onKeyUp = ev => { // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); if (!ev.target.textContent) { @@ -163,17 +156,17 @@ export default createReactClass({ } // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); - }, + }; - onClickDiv: function(ev) { + onClickDiv = ev => { if (!this.props.editable) return; this.setState({ - phase: this.Phases.Edit, + phase: EditableText.Phases.Edit, }); - }, + }; - onFocus: function(ev) { + onFocus = ev => { //ev.target.setSelectionRange(0, ev.target.textContent.length); const node = ev.target.childNodes[0]; @@ -186,21 +179,21 @@ export default createReactClass({ sel.removeAllRanges(); sel.addRange(range); } - }, + }; - onFinish: function(ev, shouldSubmit) { + onFinish = (ev, shouldSubmit) => { const self = this; const submit = (ev.key === Key.ENTER) || shouldSubmit; this.setState({ - phase: this.Phases.Display, + phase: EditableText.Phases.Display, }, () => { if (this.value !== this.props.initialValue) { self.onValueChanged(submit); } }); - }, + }; - onBlur: function(ev) { + onBlur = ev => { const sel = window.getSelection(); sel.removeAllRanges(); @@ -211,13 +204,15 @@ export default createReactClass({ } this.showPlaceholder(!this.value); - }, + }; - render: function() { + render() { const {className, editable, initialValue, label, labelClassName} = this.props; let editableEl; - if (!editable || (this.state.phase === this.Phases.Display && (label || labelClassName) && !this.value)) { + if (!editable || (this.state.phase === EditableText.Phases.Display && + (label || labelClassName) && !this.value) + ) { // show the label editableEl =
    { label || initialValue } @@ -234,5 +229,5 @@ export default createReactClass({ } return editableEl; - }, -}); + } +} diff --git a/src/components/views/elements/ErrorBoundary.js b/src/components/views/elements/ErrorBoundary.js index 68bec667d8..9fe6861250 100644 --- a/src/components/views/elements/ErrorBoundary.js +++ b/src/components/views/elements/ErrorBoundary.js @@ -20,6 +20,7 @@ import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import PlatformPeg from '../../../PlatformPeg'; import Modal from '../../../Modal'; +import SdkConfig from "../../../SdkConfig"; /** * This error boundary component can be used to wrap large content areas and @@ -73,9 +74,10 @@ export default class ErrorBoundary extends React.PureComponent { if (this.state.error) { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const newIssueUrl = "https://github.com/vector-im/element-web/issues/new"; - return
    -
    -

    {_t("Something went wrong!")}

    + + let bugReportSection; + if (SdkConfig.get().bug_report_endpoint_url) { + bugReportSection =

    {_t( "Please create a new issue " + "on GitHub so that we can investigate this bug.", {}, { @@ -94,6 +96,13 @@ export default class ErrorBoundary extends React.PureComponent { {_t("Submit debug logs")} + ; + } + + return

    +
    +

    {_t("Something went wrong!")}

    + { bugReportSection } {_t("Clear cache and reload")} diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index 7d8b774955..35019a901e 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -21,6 +21,8 @@ import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import * as Avatar from '../../../Avatar'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import EventTile from '../rooms/EventTile'; +import SettingsStore from "../../../settings/SettingsStore"; +import {UIFeature} from "../../../settings/UIFeature"; interface IProps { /** @@ -39,11 +41,13 @@ interface IProps { className: string; } +/* eslint-disable camelcase */ interface IState { userId: string; displayname: string; avatar_url: string; } +/* eslint-enable camelcase */ const AVATAR_SIZE = 32; @@ -63,19 +67,18 @@ export default class EventTilePreview extends React.Component { const client = MatrixClientPeg.get(); const userId = client.getUserId(); const profileInfo = await client.getProfileInfo(userId); - const avatar_url = Avatar.avatarUrlForUser( + const avatarUrl = Avatar.avatarUrlForUser( {avatarUrl: profileInfo.avatar_url}, AVATAR_SIZE, AVATAR_SIZE, "crop"); this.setState({ userId, displayname: profileInfo.displayname, - avatar_url, + avatar_url: avatarUrl, }); - } - private fakeEvent({userId, displayname, avatar_url}: IState) { + private fakeEvent({userId, displayname, avatar_url: avatarUrl}: IState) { // Fake it till we make it const event = new MatrixEvent(JSON.parse(`{ "type": "m.room.message", @@ -85,12 +88,12 @@ export default class EventTilePreview extends React.Component { "msgtype": "m.text", "body": "${this.props.message}", "displayname": "${displayname}", - "avatar_url": "${avatar_url}" + "avatar_url": "${avatarUrl}" }, "msgtype": "m.text", "body": "${this.props.message}", "displayname": "${displayname}", - "avatar_url": "${avatar_url}" + "avatar_url": "${avatarUrl}" }, "unsigned": { "age": 97 @@ -104,7 +107,7 @@ export default class EventTilePreview extends React.Component { name: displayname, userId: userId, getAvatarUrl: (..._) => { - return avatar_url; + return avatarUrl; }, }; @@ -114,16 +117,17 @@ export default class EventTilePreview extends React.Component { public render() { const event = this.fakeEvent(this.state); - let className = classnames( - this.props.className, - { - "mx_IRCLayout": this.props.useIRCLayout, - "mx_GroupLayout": !this.props.useIRCLayout, - } - ); + const className = classnames(this.props.className, { + "mx_IRCLayout": this.props.useIRCLayout, + "mx_GroupLayout": !this.props.useIRCLayout, + }); return
    - +
    ; } } diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index d9fd59dc11..7fd154047d 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -17,7 +17,7 @@ limitations under the License. import React, {InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes} from 'react'; import classNames from 'classnames'; import * as sdk from '../../../index'; -import { debounce } from 'lodash'; +import {debounce} from "lodash"; import {IFieldState, IValidationResult} from "./Validation"; // Invoke validation from user input (when typing, etc.) at most once every N ms. @@ -198,11 +198,9 @@ export default class Field extends React.PureComponent { } } - - public render() { - const { - element, prefixComponent, postfixComponent, className, onValidate, children, + /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ + const { element, prefixComponent, postfixComponent, className, onValidate, children, tooltipContent, forceValidity, tooltipClassName, list, ...inputProps} = this.props; // Set some defaults for the element diff --git a/src/components/views/elements/IRCTimelineProfileResizer.tsx b/src/components/views/elements/IRCTimelineProfileResizer.tsx index 1098d0293e..ecd63816de 100644 --- a/src/components/views/elements/IRCTimelineProfileResizer.tsx +++ b/src/components/views/elements/IRCTimelineProfileResizer.tsx @@ -78,7 +78,12 @@ export default class IRCTimelineProfileResizer extends React.Component +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from 'react'; +import classNames from 'classnames'; + +import Tooltip from './Tooltip'; +import { _t } from "../../../languageHandler"; + +interface ITooltipProps { + tooltip?: React.ReactNode; + tooltipClassName?: string; +} + +interface IState { + hover: boolean; +} + +export default class InfoTooltip extends React.PureComponent { + constructor(props: ITooltipProps) { + super(props); + this.state = { + hover: false, + }; + } + + onMouseOver = () => { + this.setState({ + hover: true, + }); + }; + + onMouseLeave = () => { + this.setState({ + hover: false, + }); + }; + + render() { + const {tooltip, children, tooltipClassName} = this.props; + const title = _t("Information"); + + // Tooltip are forced on the right for a more natural feel to them on info icons + const tip = this.state.hover ? :
    ; + return ( +
    + + {children} + {tip} +
    + ); + } +} diff --git a/src/components/views/elements/InlineSpinner.js b/src/components/views/elements/InlineSpinner.js index ce3c738f3b..73316157f4 100644 --- a/src/components/views/elements/InlineSpinner.js +++ b/src/components/views/elements/InlineSpinner.js @@ -15,14 +15,11 @@ limitations under the License. */ import React from "react"; -import createReactClass from 'create-react-class'; import {_t} from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; -export default createReactClass({ - displayName: 'InlineSpinner', - - render: function() { +export default class InlineSpinner extends React.Component { + render() { const w = this.props.w || 16; const h = this.props.h || 16; const imgClass = this.props.imgClassName || ""; @@ -45,5 +42,5 @@ export default createReactClass({ />
    ); - }, -}); + } +} diff --git a/src/components/views/elements/ManageIntegsButton.js b/src/components/views/elements/ManageIntegsButton.js deleted file mode 100644 index 0990218c65..0000000000 --- a/src/components/views/elements/ManageIntegsButton.js +++ /dev/null @@ -1,63 +0,0 @@ -/* -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. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; -import {IntegrationManagers} from "../../../integrations/IntegrationManagers"; -import SettingsStore from "../../../settings/SettingsStore"; -import AccessibleTooltipButton from "./AccessibleTooltipButton"; - -export default class ManageIntegsButton extends React.Component { - constructor(props) { - super(props); - } - - onManageIntegrations = (ev) => { - ev.preventDefault(); - - const managers = IntegrationManagers.sharedInstance(); - if (!managers.hasManager()) { - managers.openNoManagerDialog(); - } else { - if (SettingsStore.getValue("feature_many_integration_managers")) { - managers.openAll(this.props.room); - } else { - managers.getPrimaryManager().open(this.props.room); - } - } - }; - - render() { - let integrationsButton =
    ; - if (IntegrationManagers.sharedInstance().hasManager()) { - integrationsButton = ( - - ); - } - - return integrationsButton; - } -} - -ManageIntegsButton.propTypes = { - room: PropTypes.object.isRequired, -}; diff --git a/src/components/views/elements/MemberEventListSummary.js b/src/components/views/elements/MemberEventListSummary.js index 956b69ca7b..e16b52c8a2 100644 --- a/src/components/views/elements/MemberEventListSummary.js +++ b/src/components/views/elements/MemberEventListSummary.js @@ -18,17 +18,14 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import { _t } from '../../../languageHandler'; import { formatCommaSeparatedList } from '../../../utils/FormattingUtils'; import * as sdk from "../../../index"; import {MatrixEvent} from "matrix-js-sdk"; import {isValid3pidInvite} from "../../../RoomInvite"; -export default createReactClass({ - displayName: 'MemberEventListSummary', - - propTypes: { +export default class MemberEventListSummary extends React.Component { + static propTypes = { // An array of member events to summarise events: PropTypes.arrayOf(PropTypes.instanceOf(MatrixEvent)).isRequired, // An array of EventTiles to render when expanded @@ -43,17 +40,15 @@ export default createReactClass({ onToggle: PropTypes.func, // Whether or not to begin with state.expanded=true startExpanded: PropTypes.bool, - }, + }; - getDefaultProps: function() { - return { - summaryLength: 1, - threshold: 3, - avatarsMaxLength: 5, - }; - }, + static defaultProps = { + summaryLength: 1, + threshold: 3, + avatarsMaxLength: 5, + }; - shouldComponentUpdate: function(nextProps) { + shouldComponentUpdate(nextProps) { // Update if // - The number of summarised events has changed // - or if the summary is about to toggle to become collapsed @@ -62,7 +57,7 @@ export default createReactClass({ nextProps.events.length !== this.props.events.length || nextProps.events.length < this.props.threshold ); - }, + } /** * Generate the text for users aggregated by their transition sequences (`eventAggregates`) where @@ -73,7 +68,7 @@ export default createReactClass({ * `Object.keys(eventAggregates)`. * @returns {string} the textual summary of the aggregated events that occurred. */ - _generateSummary: function(eventAggregates, orderedTransitionSequences) { + _generateSummary(eventAggregates, orderedTransitionSequences) { const summaries = orderedTransitionSequences.map((transitions) => { const userNames = eventAggregates[transitions]; const nameList = this._renderNameList(userNames); @@ -105,7 +100,7 @@ export default createReactClass({ } return summaries.join(", "); - }, + } /** * @param {string[]} users an array of user display names or user IDs. @@ -113,9 +108,9 @@ export default createReactClass({ * more items in `users` than `this.props.summaryLength`, which is the number of names * included before "and [n] others". */ - _renderNameList: function(users) { + _renderNameList(users) { return formatCommaSeparatedList(users, this.props.summaryLength); - }, + } /** * Canonicalise an array of transitions such that some pairs of transitions become @@ -124,7 +119,7 @@ export default createReactClass({ * @param {string[]} transitions an array of transitions. * @returns {string[]} an array of transitions. */ - _getCanonicalTransitions: function(transitions) { + _getCanonicalTransitions(transitions) { const modMap = { 'joined': { 'after': 'left', @@ -155,7 +150,7 @@ export default createReactClass({ res.push(transition); } return res; - }, + } /** * Transform an array of transitions into an array of transitions and how many times @@ -171,7 +166,7 @@ export default createReactClass({ * @param {string[]} transitions the array of transitions to transform. * @returns {object[]} an array of coalesced transitions. */ - _coalesceRepeatedTransitions: function(transitions) { + _coalesceRepeatedTransitions(transitions) { const res = []; for (let i = 0; i < transitions.length; i++) { if (res.length > 0 && res[res.length - 1].transitionType === transitions[i]) { @@ -184,7 +179,7 @@ export default createReactClass({ } } return res; - }, + } /** * For a certain transition, t, describe what happened to the users that @@ -268,11 +263,11 @@ export default createReactClass({ } return res; - }, + } - _getTransitionSequence: function(events) { + _getTransitionSequence(events) { return events.map(this._getTransition); - }, + } /** * Label a given membership event, `e`, where `getContent().membership` has @@ -282,7 +277,7 @@ export default createReactClass({ * @returns {string?} the transition type given to this event. This defaults to `null` * if a transition is not recognised. */ - _getTransition: function(e) { + _getTransition(e) { if (e.mxEvent.getType() === 'm.room.third_party_invite') { // Handle 3pid invites the same as invites so they get bundled together if (!isValid3pidInvite(e.mxEvent)) { @@ -323,9 +318,9 @@ export default createReactClass({ } default: return null; } - }, + } - _getAggregate: function(userEvents) { + _getAggregate(userEvents) { // A map of aggregate type to arrays of display names. Each aggregate type // is a comma-delimited string of transitions, e.g. "joined,left,kicked". // The array of display names is the array of users who went through that @@ -364,9 +359,9 @@ export default createReactClass({ names: aggregate, indices: aggregateIndices, }; - }, + } - render: function() { + render() { const eventsToRender = this.props.events; // Map user IDs to an array of objects: @@ -420,5 +415,5 @@ export default createReactClass({ children={this.props.children} summaryMembers={avatarMembers} summaryText={this._generateSummary(aggregate.names, orderedTransitionSequences)} />; - }, -}); + } +} diff --git a/src/components/views/elements/PersistedElement.js b/src/components/views/elements/PersistedElement.js index 7f9bfdebf4..9a64b7c7c4 100644 --- a/src/components/views/elements/PersistedElement.js +++ b/src/components/views/elements/PersistedElement.js @@ -17,7 +17,7 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; import PropTypes from 'prop-types'; - +import {throttle} from "lodash"; import ResizeObserver from 'resize-observer-polyfill'; import dis from '../../../dispatcher/dispatcher'; @@ -156,7 +156,7 @@ export default class PersistedElement extends React.Component { child.style.display = visible ? 'block' : 'none'; } - updateChildPosition(child, parent) { + updateChildPosition = throttle((child, parent) => { if (!child || !parent) return; const parentRect = parent.getBoundingClientRect(); @@ -167,9 +167,9 @@ export default class PersistedElement extends React.Component { width: parentRect.width + 'px', height: parentRect.height + 'px', }); - } + }, 100, {trailing: true, leading: true}); render() { - return
    ; + return
    ; } } diff --git a/src/components/views/elements/PersistentApp.js b/src/components/views/elements/PersistentApp.js index a146debc45..686739a9f7 100644 --- a/src/components/views/elements/PersistentApp.js +++ b/src/components/views/elements/PersistentApp.js @@ -16,49 +16,44 @@ limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import RoomViewStore from '../../../stores/RoomViewStore'; import ActiveWidgetStore from '../../../stores/ActiveWidgetStore'; import WidgetUtils from '../../../utils/WidgetUtils'; import * as sdk from '../../../index'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; -export default createReactClass({ - displayName: 'PersistentApp', +export default class PersistentApp extends React.Component { + state = { + roomId: RoomViewStore.getRoomId(), + persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(), + }; - getInitialState: function() { - return { - roomId: RoomViewStore.getRoomId(), - persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(), - }; - }, - - componentDidMount: function() { + componentDidMount() { this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate); ActiveWidgetStore.on('update', this._onActiveWidgetStoreUpdate); - }, + } - componentWillUnmount: function() { + componentWillUnmount() { if (this._roomStoreToken) { this._roomStoreToken.remove(); } ActiveWidgetStore.removeListener('update', this._onActiveWidgetStoreUpdate); - }, + } - _onRoomViewStoreUpdate: function(payload) { + _onRoomViewStoreUpdate = payload => { if (RoomViewStore.getRoomId() === this.state.roomId) return; this.setState({ roomId: RoomViewStore.getRoomId(), }); - }, + }; - _onActiveWidgetStoreUpdate: function() { + _onActiveWidgetStoreUpdate = () => { this.setState({ persistentWidgetId: ActiveWidgetStore.getPersistentWidgetId(), }); - }, + }; - render: function() { + render() { if (this.state.persistentWidgetId) { const persistentWidgetInRoomId = ActiveWidgetStore.getRoomId(this.state.persistentWidgetId); if (this.state.roomId !== persistentWidgetInRoomId) { @@ -81,7 +76,7 @@ export default createReactClass({ userId={MatrixClientPeg.get().credentials.userId} show={true} creatorUserId={app.creatorUserId} - widgetPageTitle={(app.data && app.data.title) ? app.data.title : ''} + widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)} waitForIframeLoad={app.waitForIframeLoad} whitelistCapabilities={capWhitelist} showDelete={false} @@ -91,6 +86,6 @@ export default createReactClass({ } } return null; - }, -}); + } +} diff --git a/src/components/views/elements/Pill.js b/src/components/views/elements/Pill.js index 03a1aeed85..3094f17fb7 100644 --- a/src/components/views/elements/Pill.js +++ b/src/components/views/elements/Pill.js @@ -16,7 +16,6 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import createReactClass from 'create-react-class'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import classNames from 'classnames'; @@ -30,29 +29,31 @@ import {Action} from "../../../dispatcher/actions"; // For URLs of matrix.to links in the timeline which have been reformatted by // HttpUtils transformTags to relative links. This excludes event URLs (with `[^\/]*`) -const REGEX_LOCAL_PERMALINK = /^#\/(?:user|room|group)\/(([#!@+])[^/]*)$/; +const REGEX_LOCAL_PERMALINK = /^#\/(?:user|room|group)\/(([#!@+]).*?)(?=\/|\?|$)/; -const Pill = createReactClass({ - statics: { - isPillUrl: (url) => { - return !!getPrimaryPermalinkEntity(url); - }, - isMessagePillUrl: (url) => { - return !!REGEX_LOCAL_PERMALINK.exec(url); - }, - roomNotifPos: (text) => { - return text.indexOf("@room"); - }, - roomNotifLen: () => { - return "@room".length; - }, - TYPE_USER_MENTION: 'TYPE_USER_MENTION', - TYPE_ROOM_MENTION: 'TYPE_ROOM_MENTION', - TYPE_GROUP_MENTION: 'TYPE_GROUP_MENTION', - TYPE_AT_ROOM_MENTION: 'TYPE_AT_ROOM_MENTION', // '@room' mention - }, +class Pill extends React.Component { + static isPillUrl(url) { + return !!getPrimaryPermalinkEntity(url); + } - props: { + static isMessagePillUrl(url) { + return !!REGEX_LOCAL_PERMALINK.exec(url); + } + + static roomNotifPos(text) { + return text.indexOf("@room"); + } + + static roomNotifLen() { + return "@room".length; + } + + static TYPE_USER_MENTION = 'TYPE_USER_MENTION'; + static TYPE_ROOM_MENTION = 'TYPE_ROOM_MENTION'; + static TYPE_GROUP_MENTION = 'TYPE_GROUP_MENTION'; + static TYPE_AT_ROOM_MENTION = 'TYPE_AT_ROOM_MENTION'; // '@room' mention + + static propTypes = { // The Type of this Pill. If url is given, this is auto-detected. type: PropTypes.string, // The URL to pillify (no validation is done, see isPillUrl and isMessagePillUrl) @@ -65,25 +66,24 @@ const Pill = createReactClass({ shouldShowPillAvatar: PropTypes.bool, // Whether to render this pill as if it were highlit by a selection isSelected: PropTypes.bool, - }, + }; - getInitialState() { - return { - // ID/alias of the room/user - resourceId: null, - // Type of pill - pillType: null, + state = { + // ID/alias of the room/user + resourceId: null, + // Type of pill + pillType: null, - // The member related to the user pill - member: null, - // The group related to the group pill - group: null, - // The room related to the room pill - room: null, - }; - }, + // The member related to the user pill + member: null, + // The group related to the group pill + group: null, + // The room related to the room pill + room: null, + }; // TODO: [REACT-WARNING] Replace with appropriate lifecycle event + // eslint-disable-next-line camelcase async UNSAFE_componentWillReceiveProps(nextProps) { let resourceId; let prefix; @@ -155,7 +155,7 @@ const Pill = createReactClass({ } } this.setState({resourceId, pillType, member, group, room}); - }, + } componentDidMount() { this._unmounted = false; @@ -163,13 +163,13 @@ const Pill = createReactClass({ // eslint-disable-next-line new-cap this.UNSAFE_componentWillReceiveProps(this.props); // HACK: We shouldn't be calling lifecycle functions ourselves. - }, + } componentWillUnmount() { this._unmounted = true; - }, + } - doProfileLookup: function(userId, member) { + doProfileLookup(userId, member) { MatrixClientPeg.get().getProfileInfo(userId).then((resp) => { if (this._unmounted) { return; @@ -188,15 +188,16 @@ const Pill = createReactClass({ }).catch((err) => { console.error('Could not retrieve profile data for ' + userId + ':', err); }); - }, + } - onUserPillClicked: function() { + onUserPillClicked = () => { dis.dispatch({ action: Action.ViewUser, member: this.state.member, }); - }, - render: function() { + }; + + render() { const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const RoomAvatar = sdk.getComponent('avatars.RoomAvatar'); @@ -285,7 +286,7 @@ const Pill = createReactClass({ // Deliberately render nothing if the URL isn't recognised return null; } - }, -}); + } +} export default Pill; diff --git a/src/components/views/elements/PowerSelector.js b/src/components/views/elements/PowerSelector.js index 948b4835d5..66922df0f8 100644 --- a/src/components/views/elements/PowerSelector.js +++ b/src/components/views/elements/PowerSelector.js @@ -16,16 +16,13 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; -import createReactClass from 'create-react-class'; import * as Roles from '../../../Roles'; import { _t } from '../../../languageHandler'; import Field from "./Field"; import {Key} from "../../../Keyboard"; -export default createReactClass({ - displayName: 'PowerSelector', - - propTypes: { +export default class PowerSelector extends React.Component { + static propTypes = { value: PropTypes.number.isRequired, // The maximum value that can be set with the power selector maxValue: PropTypes.number.isRequired, @@ -42,10 +39,17 @@ export default createReactClass({ // The name to annotate the selector with label: PropTypes.string, - }, + } - getInitialState: function() { - return { + static defaultProps = { + maxValue: Infinity, + usersDefault: 0, + }; + + constructor(props) { + super(props); + + this.state = { levelRoleMap: {}, // List of power levels to show in the drop-down options: [], @@ -53,26 +57,20 @@ export default createReactClass({ customValue: this.props.value, selectValue: 0, }; - }, - - getDefaultProps: function() { - return { - maxValue: Infinity, - usersDefault: 0, - }; - }, - - componentDidMount: function() { - // TODO: [REACT-WARNING] Move this to class constructor - this._initStateFromProps(this.props); - }, + } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event - UNSAFE_componentWillReceiveProps: function(newProps) { - this._initStateFromProps(newProps); - }, + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount() { + this._initStateFromProps(this.props); + } - _initStateFromProps: function(newProps) { + // eslint-disable-next-line camelcase + UNSAFE_componentWillReceiveProps(newProps) { + this._initStateFromProps(newProps); + } + + _initStateFromProps(newProps) { // This needs to be done now because levelRoleMap has translated strings const levelRoleMap = Roles.levelRoleMap(newProps.usersDefault); const options = Object.keys(levelRoleMap).filter(level => { @@ -92,9 +90,9 @@ export default createReactClass({ customLevel: newProps.value, selectValue: isCustom ? "SELECT_VALUE_CUSTOM" : newProps.value, }); - }, + } - onSelectChange: function(event) { + onSelectChange = event => { const isCustom = event.target.value === "SELECT_VALUE_CUSTOM"; if (isCustom) { this.setState({custom: true}); @@ -102,20 +100,20 @@ export default createReactClass({ this.props.onChange(event.target.value, this.props.powerLevelKey); this.setState({selectValue: event.target.value}); } - }, + }; - onCustomChange: function(event) { + onCustomChange = event => { this.setState({customValue: event.target.value}); - }, + }; - onCustomBlur: function(event) { + onCustomBlur = event => { event.preventDefault(); event.stopPropagation(); this.props.onChange(parseInt(this.state.customValue), this.props.powerLevelKey); - }, + }; - onCustomKeyDown: function(event) { + onCustomKeyDown = event => { if (event.key === Key.ENTER) { event.preventDefault(); event.stopPropagation(); @@ -127,9 +125,9 @@ export default createReactClass({ // handle the onBlur safely. event.target.blur(); } - }, + }; - render: function() { + render() { let picker; const label = typeof this.props.label === "undefined" ? _t("Power level") : this.props.label; if (this.state.custom) { @@ -166,5 +164,5 @@ export default createReactClass({ { picker }
    ); - }, -}); + } +} diff --git a/src/components/views/elements/QRCode.tsx b/src/components/views/elements/QRCode.tsx index f70ab48fa3..9ce3dc7202 100644 --- a/src/components/views/elements/QRCode.tsx +++ b/src/components/views/elements/QRCode.tsx @@ -41,7 +41,7 @@ const QRCode: React.FC = ({data, className, ...options}) => { return () => { cancelled = true; }; - }, [JSON.stringify(data), options]); + }, [JSON.stringify(data), options]); // eslint-disable-line react-hooks/exhaustive-deps return
    { dataUri ? {_t("QR : } diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index 409bf9e01f..2d17c858a2 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -28,6 +28,7 @@ import escapeHtml from "escape-html"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import {Action} from "../../../dispatcher/actions"; import sanitizeHtml from "sanitize-html"; +import {UIFeature} from "../../../settings/UIFeature"; // This component does no cycle detection, simply because the only way to make such a cycle would be to // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would @@ -45,8 +46,8 @@ export default class ReplyThread extends React.Component { static contextType = MatrixClientContext; - constructor(props) { - super(props); + constructor(props, context) { + super(props, context); this.state = { // The loaded events to be rendered as linear-replies @@ -331,8 +332,14 @@ export default class ReplyThread extends React.Component { { _t('In reply to ', {}, { 'a': (sub) => { sub }, - 'pill': , + 'pill': ( + + ), }) } ; @@ -360,6 +367,7 @@ export default class ReplyThread extends React.Component { isRedacted={ev.isRedacted()} isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} useIRCLayout={this.props.useIRCLayout} + enableFlair={SettingsStore.getValue(UIFeature.Flair)} /> ; }); diff --git a/src/components/views/elements/Slider.tsx b/src/components/views/elements/Slider.tsx index a88c581d07..b7c8e1b533 100644 --- a/src/components/views/elements/Slider.tsx +++ b/src/components/views/elements/Slider.tsx @@ -45,7 +45,7 @@ export default class Slider extends React.Component { // non linear slider. private offset(values: number[], value: number): number { // the index of the first number greater than value. - let closest = values.reduce((prev, curr) => { + const closest = values.reduce((prev, curr) => { return (value > curr ? prev + 1 : prev); }, 0); @@ -68,17 +68,16 @@ export default class Slider extends React.Component { const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue); return 100 * (closest - 1 + linearInterpolation) * intervalWidth; - } render(): React.ReactNode { - const dots = this.props.values.map(v => - {} : () => this.props.onSelectionChange(v)} - key={v} - disabled={this.props.disabled} - />); + const dots = this.props.values.map(v => {} : () => this.props.onSelectionChange(v)} + key={v} + disabled={this.props.disabled} + />); let selection = null; @@ -93,7 +92,7 @@ export default class Slider extends React.Component { return
    -
    {} : this.onClick.bind(this)}/> +
    {} : this.onClick.bind(this)} /> { selection }
    diff --git a/src/components/views/elements/StyledCheckbox.tsx b/src/components/views/elements/StyledCheckbox.tsx index be983828ff..f8d2665d07 100644 --- a/src/components/views/elements/StyledCheckbox.tsx +++ b/src/components/views/elements/StyledCheckbox.tsx @@ -17,8 +17,6 @@ limitations under the License. import React from "react"; import { randomString } from "matrix-js-sdk/src/randomstring"; -const CHECK_BOX_SVG = require("../../../../res/img/feather-customised/check.svg"); - interface IProps extends React.InputHTMLAttributes { } @@ -39,13 +37,14 @@ export default class StyledCheckbox extends React.PureComponent } public render() { + /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ const { children, className, ...otherProps } = this.props; return