diff --git a/README.md b/README.md index 67e5e12f59..4588a0586e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Code should be committed as follows: * CSS: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/css * Theme specific CSS & resources: https://github.com/matrix-org/matrix-react-sdk/tree/master/res/themes -React components in matrix-react-sdk are come in two different flavours: +React components in matrix-react-sdk come in two different flavours: 'structures' and 'views'. Structures are stateful components which handle the more complicated business logic of the app, delegating their actual presentation rendering to stateless 'view' components. For instance, the RoomView component diff --git a/package.json b/package.json index 89084acd68..46ff26bf32 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,9 @@ "bin": { "reskindex": "scripts/reskindex.js" }, - "main": "./src/index.js", - "matrix_src_main": "./src/index.js", - "matrix_lib_main": "./lib/index.js", + "main": "./src/index.ts", + "matrix_src_main": "./src/index.ts", + "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", "scripts": { "prepublishOnly": "yarn build", @@ -79,6 +79,7 @@ "highlight.js": "^10.5.0", "html-entities": "^1.4.0", "is-ip": "^3.1.0", + "jszip": "^3.7.0", "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", @@ -133,6 +134,7 @@ "@types/counterpart": "^0.18.1", "@types/css-font-loading-module": "^0.0.6", "@types/diff-match-patch": "^1.0.32", + "@types/file-saver": "^2.0.3", "@types/flux": "^3.1.9", "@types/jest": "^26.0.20", "@types/linkifyjs": "^2.1.3", @@ -166,9 +168,11 @@ "jest-canvas-mock": "^2.3.0", "jest-environment-jsdom-sixteen": "^1.0.3", "jest-fetch-mock": "^3.0.3", + "jest-raw-loader": "^1.0.1", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.3", "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", + "raw-loader": "^4.0.2", "react-test-renderer": "^17.0.2", "rimraf": "^3.0.2", "rrweb-snapshot": "1.1.7", @@ -199,6 +203,7 @@ "decoderWorker\\.min\\.wasm": "/__mocks__/empty.js", "waveWorker\\.min\\.js": "/__mocks__/empty.js", "workers/(.+)\\.worker\\.ts": "/__mocks__/workerMock.js", + "^!!raw-loader!.*": "jest-raw-loader", "RecorderWorklet": "/__mocks__/empty.js" }, "transformIgnorePatterns": [ diff --git a/res/css/_components.scss b/res/css/_components.scss index ffaec43b68..60428fd193 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -1,8 +1,10 @@ // autogenerated by rethemendex.sh +@import "./_animations.scss"; @import "./_common.scss"; @import "./_font-sizes.scss"; @import "./_font-weights.scss"; @import "./structures/_AutoHideScrollbar.scss"; +@import "./structures/_BackdropPanel.scss"; @import "./structures/_CompatibilityPage.scss"; @import "./structures/_ContextualMenu.scss"; @import "./structures/_CreateRoom.scss"; @@ -17,7 +19,6 @@ @import "./structures/_LeftPanelWidget.scss"; @import "./structures/_MainSplit.scss"; @import "./structures/_MatrixChat.scss"; -@import "./structures/_BackdropPanel.scss"; @import "./structures/_MyGroups.scss"; @import "./structures/_NonUrgentToastContainer.scss"; @import "./structures/_NotificationPanel.scss"; @@ -38,6 +39,7 @@ @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; +@import "./structures/auth/_SetupEncryptionBody.scss"; @import "./views/audio_messages/_AudioPlayer.scss"; @import "./views/audio_messages/_PlayPauseButton.scss"; @import "./views/audio_messages/_PlaybackContainer.scss"; @@ -81,6 +83,7 @@ @import "./views/dialogs/_DeactivateAccountDialog.scss"; @import "./views/dialogs/_DevtoolsDialog.scss"; @import "./views/dialogs/_EditCommunityPrototypeDialog.scss"; +@import "./views/dialogs/_ExportDialog.scss"; @import "./views/dialogs/_FeedbackDialog.scss"; @import "./views/dialogs/_ForwardDialog.scss"; @import "./views/dialogs/_GenericFeatureFeedbackDialog.scss"; @@ -243,6 +246,7 @@ @import "./views/settings/_E2eAdvancedPanel.scss"; @import "./views/settings/_EmailAddresses.scss"; @import "./views/settings/_IntegrationManager.scss"; +@import "./views/settings/_JoinRuleSettings.scss"; @import "./views/settings/_LayoutSwitcher.scss"; @import "./views/settings/_Notifications.scss"; @import "./views/settings/_PhoneNumbers.scss"; diff --git a/res/css/structures/_ToastContainer.scss b/res/css/structures/_ToastContainer.scss index 6024df5dc0..55181a8b53 100644 --- a/res/css/structures/_ToastContainer.scss +++ b/res/css/structures/_ToastContainer.scss @@ -122,7 +122,7 @@ limitations under the License. float: right; font-size: $font-12px; line-height: $font-22px; - color: $muted-fg-color; + color: $secondary-content; } } diff --git a/res/css/structures/auth/_CompleteSecurity.scss b/res/css/structures/auth/_CompleteSecurity.scss index 80e7aaada0..566507211c 100644 --- a/res/css/structures/auth/_CompleteSecurity.scss +++ b/res/css/structures/auth/_CompleteSecurity.scss @@ -33,6 +33,19 @@ limitations under the License. margin: 0 auto; } +.mx_CompleteSecurity_skip { + mask: url('$(res)/img/feather-customised/cancel.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: cover; + width: 18px; + height: 18px; + background-color: $dialog-close-fg-color; + cursor: pointer; + position: absolute; + right: 24px; +} + .mx_CompleteSecurity_body { font-size: $font-15px; } diff --git a/src/contexts/MatrixClientContext.ts b/res/css/structures/auth/_SetupEncryptionBody.scss similarity index 63% rename from src/contexts/MatrixClientContext.ts rename to res/css/structures/auth/_SetupEncryptionBody.scss index 7e8a92064d..24ee114544 100644 --- a/src/contexts/MatrixClientContext.ts +++ b/res/css/structures/auth/_SetupEncryptionBody.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,9 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { createContext } from "react"; -import { MatrixClient } from "matrix-js-sdk/src/client"; +.mx_SetupEncryptionBody_reset { + color: $light-fg-color; + margin-top: $font-14px; -const MatrixClientContext = createContext(undefined); -MatrixClientContext.displayName = "MatrixClientContext"; -export default MatrixClientContext; + a.mx_SetupEncryptionBody_reset_link:is(:link, :hover, :visited) { + color: $warning-color; + } +} diff --git a/res/css/views/audio_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss index 773fc50fb9..3886e38583 100644 --- a/res/css/views/audio_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -39,7 +39,7 @@ limitations under the License. &.mx_Waveform_bar_100pct { // Small animation to remove the mechanical feel of progress transition: background-color 250ms ease; - background-color: $message-body-panel-fg-color; + background-color: $secondary-content; } } } diff --git a/res/css/views/dialogs/_ExportDialog.scss b/res/css/views/dialogs/_ExportDialog.scss new file mode 100644 index 0000000000..d578e72ead --- /dev/null +++ b/res/css/views/dialogs/_ExportDialog.scss @@ -0,0 +1,91 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_ExportDialog { + .mx_ExportDialog_subheading { + font-size: $font-16px; + display: block; + font-family: $font-family; + font-weight: $font-semi-bold; + color: $primary-content; + margin-top: 18px; + margin-bottom: 12px; + } + + &.mx_ExportDialog_Exporting { + .mx_ExportDialog_options { + pointer-events: none; + } + + .mx_Field_select::before { + display: none; + } + + .mx_RadioButton input[type="radio"]:checked + div > div { + background: $greyed-fg-color; + } + + .mx_RadioButton input[type=radio]:checked + div { + border-color: unset; + } + + .mx_Field_valid.mx_Field label, + .mx_Field_valid.mx_Field:focus-within label { + color: unset; + } + + .mx_Field_valid.mx_Field, .mx_Field_valid.mx_Field:focus-within { + border-color: $input-border-color; + } + + .mx_Checkbox input[type="checkbox"]:checked + label > .mx_Checkbox_background { + background: $greyed-fg-color; + border-color: $greyed-fg-color; + } + } + + .mx_ExportDialog_progress { + .mx_Dialog_buttons { + margin-top: unset; + margin-left: 18px; + } + + .mx_Spinner { + width: unset; + height: unset; + flex: unset; + margin-right: 10px; + } + + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + } + + .mx_RadioButton > .mx_RadioButton_content { + margin-top: 5px; + margin-bottom: 5px; + } + + .mx_Field { + width: 256px; + } + + .mx_Field_postfix { + padding: 9px 10px; + } +} diff --git a/res/css/views/dialogs/_InviteDialog.scss b/res/css/views/dialogs/_InviteDialog.scss index 3a2918f9ec..a753115614 100644 --- a/res/css/views/dialogs/_InviteDialog.scss +++ b/res/css/views/dialogs/_InviteDialog.scss @@ -28,7 +28,7 @@ limitations under the License. .mx_InviteDialog_editor { flex: 1; width: 100%; // Needed to make the Field inside grow - background-color: $user-tile-hover-bg-color; + background-color: $header-panel-bg-color; border-radius: 4px; min-height: 25px; padding-left: 8px; @@ -167,7 +167,7 @@ limitations under the License. padding: 5px 10px; &:hover { - background-color: $user-tile-hover-bg-color; + background-color: $header-panel-bg-color; border-radius: 4px; } @@ -395,7 +395,7 @@ limitations under the License. left: -24px; padding-left: 24px; padding-right: 24px; - border-top: 1px solid $message-body-panel-bg-color; + border-top: 1px solid $quinary-content; display: flex; flex-direction: row; diff --git a/res/css/views/dialogs/_SpaceSettingsDialog.scss b/res/css/views/dialogs/_SpaceSettingsDialog.scss index a1fa9d52a8..e26e4f8b49 100644 --- a/res/css/views/dialogs/_SpaceSettingsDialog.scss +++ b/res/css/views/dialogs/_SpaceSettingsDialog.scss @@ -38,7 +38,7 @@ limitations under the License. } & + .mx_SettingsTab_subheading { - border-top: 1px solid $message-body-panel-bg-color; + border-top: 1px solid $quinary-content; margin-top: 0; padding-top: 24px; } diff --git a/res/css/views/messages/_MFileBody.scss b/res/css/views/messages/_MFileBody.scss index d941a8132f..e23696e6a9 100644 --- a/res/css/views/messages/_MFileBody.scss +++ b/res/css/views/messages/_MFileBody.scss @@ -63,7 +63,7 @@ limitations under the License. cursor: pointer; .mx_MFileBody_info_icon { - background-color: $message-body-panel-icon-bg-color; + background-color: $system; border-radius: 20px; display: inline-block; width: 32px; @@ -78,7 +78,7 @@ limitations under the License. mask-position: center; mask-size: cover; mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); - background-color: $message-body-panel-icon-fg-color; + background-color: $secondary-content; width: 15px; height: 15px; diff --git a/res/css/views/messages/_MediaBody.scss b/res/css/views/messages/_MediaBody.scss index 7f4bfd3fdc..874de05c71 100644 --- a/res/css/views/messages/_MediaBody.scss +++ b/res/css/views/messages/_MediaBody.scss @@ -18,11 +18,11 @@ limitations under the License. // have unique styles). .mx_MediaBody { - background-color: $message-body-panel-bg-color; + background-color: $quinary-content; border-radius: 12px; max-width: 243px; // use max-width instead of width so it fits within right panels - color: $message-body-panel-fg-color; + color: $secondary-content; font-size: $font-14px; line-height: $font-24px; diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index 7ac4787111..c137bb7677 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -243,3 +243,7 @@ limitations under the License. .mx_RoomSummaryCard_icon_settings::before { mask-image: url('$(res)/img/element-icons/settings.svg'); } + +.mx_RoomSummaryCard_icon_export::before { + mask-image: url('$(res)/img/element-icons/export.svg'); +} diff --git a/res/css/views/right_panel/_VerificationPanel.scss b/res/css/views/right_panel/_VerificationPanel.scss index 12148b09de..95856a5d69 100644 --- a/res/css/views/right_panel/_VerificationPanel.scss +++ b/res/css/views/right_panel/_VerificationPanel.scss @@ -87,7 +87,7 @@ limitations under the License. } .mx_VerificationPanel_QRPhase_startOption { - background-color: $user-tile-hover-bg-color; + background-color: $header-panel-bg-color; border-radius: 10px; flex: 1; display: flex; diff --git a/res/css/views/settings/_JoinRuleSettings.scss b/res/css/views/settings/_JoinRuleSettings.scss new file mode 100644 index 0000000000..8b520b2ab1 --- /dev/null +++ b/res/css/views/settings/_JoinRuleSettings.scss @@ -0,0 +1,88 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_JoinRuleSettings_upgradeRequired { + margin-left: 16px; + padding: 4px 16px; + border: 1px solid $accent-color; + border-radius: 8px; + color: $accent-color; + font-size: $font-12px; + line-height: $font-15px; +} + +.mx_JoinRuleSettings_spacesWithAccess { + > h4 { + color: $secondary-content; + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + text-transform: uppercase; + } + + > span { + font-weight: 500; + font-size: $font-14px; + line-height: 32px; // matches height of avatar for v-align + color: $secondary-content; + display: inline-block; + + img.mx_RoomAvatar_isSpaceRoom, + .mx_RoomAvatar_isSpaceRoom img { + border-radius: 8px; + } + + .mx_BaseAvatar { + margin-right: 8px; + } + + & + span { + margin-left: 16px; + } + } +} + +.mx_JoinRuleSettings_radioButton { + padding-top: 16px; + margin-bottom: 8px; + + .mx_RadioButton_content { + margin-left: 14px; + font-weight: $font-semi-bold; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-content; + display: block; + } + + & + span { + display: inline-block; + margin-left: 34px; + margin-bottom: 16px; + font-size: $font-15px; + line-height: $font-24px; + color: $secondary-content; + + & + .mx_RadioButton { + border-top: 1px solid $menu-border-color; + } + } +} + +.mx_JoinRuleSettings_linkButton { + padding: 0; + font-size: inherit; +} diff --git a/res/css/views/settings/_ProfileSettings.scss b/res/css/views/settings/_ProfileSettings.scss index 63a5fa7edf..35e517b5ac 100644 --- a/res/css/views/settings/_ProfileSettings.scss +++ b/res/css/views/settings/_ProfileSettings.scss @@ -67,5 +67,7 @@ limitations under the License. > .mx_AccessibleButton_kind_link { padding-left: 0; // to align with left side + padding-right: 0; + margin-right: 10px; } } diff --git a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss index 8fd0f14418..a3b3b17899 100644 --- a/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss +++ b/res/css/views/settings/tabs/room/_SecurityRoomSettingsTab.scss @@ -19,37 +19,6 @@ limitations under the License. padding: 0; margin-bottom: 16px; } - - .mx_SecurityRoomSettingsTab_spacesWithAccess { - > h4 { - color: $secondary-content; - font-weight: $font-semi-bold; - font-size: $font-12px; - line-height: $font-15px; - text-transform: uppercase; - } - - > span { - font-weight: 500; - font-size: $font-14px; - line-height: 32px; // matches height of avatar for v-align - color: $secondary-content; - display: inline-block; - - img.mx_RoomAvatar_isSpaceRoom, - .mx_RoomAvatar_isSpaceRoom img { - border-radius: 8px; - } - - .mx_BaseAvatar { - margin-right: 8px; - } - - & + span { - margin-left: 16px; - } - } - } } .mx_SecurityRoomSettingsTab_warning { @@ -68,47 +37,3 @@ limitations under the License. border-bottom: 1px solid $menu-border-color; margin-bottom: 32px; } - -.mx_SecurityRoomSettingsTab_upgradeRequired { - margin-left: 16px; - padding: 4px 16px; - border: 1px solid $accent-color; - border-radius: 8px; - color: $accent-color; - font-size: $font-12px; - line-height: $font-15px; -} - -.mx_SecurityRoomSettingsTab_joinRule { - .mx_RadioButton { - padding-top: 16px; - margin-bottom: 8px; - - .mx_RadioButton_content { - margin-left: 14px; - font-weight: $font-semi-bold; - font-size: $font-15px; - line-height: $font-24px; - color: $primary-content; - display: block; - } - } - - > span { - display: inline-block; - margin-left: 34px; - margin-bottom: 16px; - font-size: $font-15px; - line-height: $font-24px; - color: $secondary-content; - - & + .mx_RadioButton { - border-top: 1px solid $menu-border-color; - } - } - - .mx_AccessibleButton_kind_link { - padding: 0; - font-size: inherit; - } -} diff --git a/res/css/views/spaces/_SpaceCreateMenu.scss b/res/css/views/spaces/_SpaceCreateMenu.scss index 3f526a6bba..7084c2f20e 100644 --- a/res/css/views/spaces/_SpaceCreateMenu.scss +++ b/res/css/views/spaces/_SpaceCreateMenu.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -$spacePanelWidth: 71px; +$spacePanelWidth: 68px; .mx_SpaceCreateMenu_wrapper { // background blur everything except SpacePanel diff --git a/res/img/element-icons/export.svg b/res/img/element-icons/export.svg new file mode 100644 index 0000000000..49899e9520 --- /dev/null +++ b/res/img/element-icons/export.svg @@ -0,0 +1,14 @@ + + + diff --git a/res/img/element-icons/message/view-in-timeline.svg b/res/img/element-icons/message/view-in-timeline.svg new file mode 100644 index 0000000000..9f05950ce0 --- /dev/null +++ b/res/img/element-icons/message/view-in-timeline.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 0bc61d438d..f012dff0a1 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -206,23 +206,13 @@ $kbd-border-color: #000000; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: $primary-content; -$interactive-tooltip-bg-color: $background; -$interactive-tooltip-fg-color: $primary-content; - $breadcrumb-placeholder-bg-color: #272c35; -$user-tile-hover-bg-color: $header-panel-bg-color; - -$message-body-panel-fg-color: $secondary-content; -$message-body-panel-bg-color: $quinary-content; -$message-body-panel-icon-bg-color: $system; -$message-body-panel-icon-fg-color: $secondary-content; - $voice-record-stop-border-color: $quaternary-content; $voice-record-waveform-incomplete-fg-color: $quaternary-content; $voice-record-icon-color: $quaternary-content; -$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; -$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; +$voice-playback-button-bg-color: $system; +$voice-playback-button-fg-color: $secondary-content; // Appearance tab colors $appearance-tab-border-color: $room-highlight-color; diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index d5bc5e6dd7..bed1e9c661 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -202,18 +202,8 @@ $kbd-border-color: #000000; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; -$interactive-tooltip-bg-color: $base-color; -$interactive-tooltip-fg-color: #ffffff; - $breadcrumb-placeholder-bg-color: #272c35; -$user-tile-hover-bg-color: $header-panel-bg-color; - -$message-body-panel-fg-color: $secondary-fg-color; -$message-body-panel-bg-color: #394049; -$message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: #21262C; - // See non-legacy dark for variable information $voice-record-stop-border-color: #6F7882; $voice-record-waveform-incomplete-fg-color: #6F7882; diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 47247e5e23..2ce5b6062c 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -326,26 +326,16 @@ $kbd-border-color: $reaction-row-button-border-color; $tooltip-timeline-bg-color: $groupFilterPanel-bg-color; $tooltip-timeline-fg-color: #ffffff; -$interactive-tooltip-bg-color: #27303a; -$interactive-tooltip-fg-color: #ffffff; - $breadcrumb-placeholder-bg-color: #e8eef5; -$user-tile-hover-bg-color: $header-panel-bg-color; - -$message-body-panel-fg-color: $secondary-fg-color; -$message-body-panel-bg-color: #E3E8F0; -$message-body-panel-icon-fg-color: $secondary-fg-color; -$message-body-panel-icon-bg-color: $system; - // See non-legacy _light for variable information $voice-record-stop-symbol-color: #ff4b55; $voice-record-live-circle-color: #ff4b55; $voice-record-stop-border-color: #E3E8F0; $voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-icon-color: $tertiary-fg-color; -$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; -$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; +$voice-playback-button-bg-color: $system; +$voice-playback-button-fg-color: $secondary-content; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/res/themes/light-custom/css/_custom.scss b/res/themes/light-custom/css/_custom.scss index 455798a556..af302bf252 100644 --- a/res/themes/light-custom/css/_custom.scss +++ b/res/themes/light-custom/css/_custom.scss @@ -16,6 +16,25 @@ limitations under the License. $font-family: var(--font-family, $font-family); $monospace-font-family: var(--font-family-monospace, $monospace-font-family); + +// Colors from Figma Compound https://www.figma.com/file/X4XTH9iS2KGJ2wFKDqkyed/Compound?node-id=559%3A741 +$accent: var(--accent); +$alert: var(--alert); +$links: var(--links); +$primary-content: var(--primary-content); +$secondary-content: var(--secondary-content); +$tertiary-content: var(--tertiary-content); +$quaternary-content: var(--quaternary-content); +$quinary-content: var(--quinary-content); +$system: var(--system); +$background: var(--background); +$panels: rgba($system, 0.9); +$panel-base: var(--panel-base); // This color is not intended for use in the app +$panel-selected: rgba($panel-base, 0.3); +$panel-hover: rgba($panel-base, 0.1); +$panel-actions: rgba($panel-base, 0.2); +$space-nav: rgba($panel-base, 0.1); + // // --accent-color $accent-color: var(--accent-color); @@ -48,7 +67,6 @@ $roomheader-bg-color: var(--timeline-background-color); $roomtile-selected-bg-color: var(--roomlist-highlights-color); // // --sidebar-color -$interactive-tooltip-bg-color: var(--sidebar-color); $groupFilterPanel-bg-color: var(--sidebar-color); $tooltip-timeline-bg-color: var(--sidebar-color); $dialog-backdrop-color: var(--sidebar-color-50pct); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 96e5fd7155..26cd1766c1 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -326,18 +326,8 @@ $inverted-bg-color: #27303a; $tooltip-timeline-bg-color: $inverted-bg-color; $tooltip-timeline-fg-color: $background; -$interactive-tooltip-bg-color: #27303a; -$interactive-tooltip-fg-color: $background; - $breadcrumb-placeholder-bg-color: #e8eef5; -$user-tile-hover-bg-color: $header-panel-bg-color; - -$message-body-panel-fg-color: $secondary-content; -$message-body-panel-bg-color: $quinary-content; -$message-body-panel-icon-bg-color: $system; -$message-body-panel-icon-fg-color: $secondary-content; - // These two don't change between themes. They are the $warning-color, but we don't // want custom themes to affect them by accident. $voice-record-stop-symbol-color: #ff4b55; @@ -346,8 +336,8 @@ $voice-record-live-circle-color: #ff4b55; $voice-record-stop-border-color: $quinary-content; $voice-record-waveform-incomplete-fg-color: $quaternary-content; $voice-record-icon-color: $tertiary-content; -$voice-playback-button-bg-color: $message-body-panel-icon-bg-color; -$voice-playback-button-fg-color: $message-body-panel-icon-fg-color; +$voice-playback-button-bg-color: $system; +$voice-playback-button-fg-color: $secondary-content; // FontSlider colors $appearance-tab-border-color: $input-darker-bg-color; diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index d5856a5702..38f237b9c3 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -51,6 +51,7 @@ import { SetupEncryptionStore } from "../stores/SetupEncryptionStore"; import { RoomScrollStateStore } from "../stores/RoomScrollStateStore"; import { ConsoleLogger, IndexedDBLogStore } from "../rageshake/rageshake"; import ActiveWidgetStore from "../stores/ActiveWidgetStore"; +import { Skinner } from "../Skinner"; /* eslint-disable @typescript-eslint/naming-convention */ @@ -95,6 +96,7 @@ declare global { mxSetupEncryptionStore?: SetupEncryptionStore; mxRoomScrollStateStore?: RoomScrollStateStore; mxActiveWidgetStore?: ActiveWidgetStore; + mxSkinner?: Skinner; mxOnRecaptchaLoaded?: () => void; electron?: Electron; } @@ -157,6 +159,10 @@ declare global { setSinkId(outputId: string); } + interface HTMLStyleElement { + disabled?: boolean; + } + // Add Chrome-specific `instant` ScrollBehaviour type _ScrollBehavior = ScrollBehavior | "instant"; diff --git a/src/RoomNotifsTypes.ts b/src/@types/raw-loader.d.ts similarity index 69% rename from src/RoomNotifsTypes.ts rename to src/@types/raw-loader.d.ts index 0e7093e434..efd825204e 100644 --- a/src/RoomNotifsTypes.ts +++ b/src/@types/raw-loader.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,11 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { - ALL_MESSAGES, - ALL_MESSAGES_LOUD, - MENTIONS_ONLY, - MUTE, -} from "./RoomNotifs"; - -export type Volume = ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE; +declare module '!!raw-loader!*' { + const contents: string; + export default contents; +} diff --git a/src/AddThreepid.js b/src/AddThreepid.ts similarity index 91% rename from src/AddThreepid.js rename to src/AddThreepid.ts index ab291128a7..54250c5eb3 100644 --- a/src/AddThreepid.js +++ b/src/AddThreepid.ts @@ -17,13 +17,14 @@ limitations under the License. */ import { MatrixClientPeg } from './MatrixClientPeg'; -import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; import IdentityAuthClient from './IdentityAuthClient'; import { SSOAuthEntry } from "./components/views/auth/InteractiveAuthEntryComponents"; +import { IRequestMsisdnTokenResponse, IRequestTokenResponse } from "matrix-js-sdk"; +import InteractiveAuthDialog from "./components/views/dialogs/InteractiveAuthDialog"; -function getIdServerDomain() { +function getIdServerDomain(): string { return MatrixClientPeg.get().idBaseUrl.split("://")[1]; } @@ -40,10 +41,13 @@ function getIdServerDomain() { * https://gist.github.com/jryans/839a09bf0c5a70e2f36ed990d50ed928 */ export default class AddThreepid { + private sessionId: string; + private submitUrl: string; + private clientSecret: string; + private bind: boolean; + constructor() { this.clientSecret = MatrixClientPeg.get().generateClientSecret(); - this.sessionId = null; - this.submitUrl = null; } /** @@ -52,7 +56,7 @@ export default class AddThreepid { * @param {string} emailAddress The email address to add * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). */ - addEmailAddress(emailAddress) { + public addEmailAddress(emailAddress: string): Promise { return MatrixClientPeg.get().requestAdd3pidEmailToken(emailAddress, this.clientSecret, 1).then((res) => { this.sessionId = res.sid; return res; @@ -72,7 +76,7 @@ export default class AddThreepid { * @param {string} emailAddress The email address to add * @return {Promise} Resolves when the email has been sent. Then call checkEmailLinkClicked(). */ - async bindEmailAddress(emailAddress) { + public async bindEmailAddress(emailAddress: string): Promise { this.bind = true; if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { // For separate bind, request a token directly from the IS. @@ -105,7 +109,7 @@ export default class AddThreepid { * @param {string} phoneNumber The national or international formatted phone number to add * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). */ - addMsisdn(phoneCountry, phoneNumber) { + public addMsisdn(phoneCountry: string, phoneNumber: string): Promise { return MatrixClientPeg.get().requestAdd3pidMsisdnToken( phoneCountry, phoneNumber, this.clientSecret, 1, ).then((res) => { @@ -129,7 +133,7 @@ export default class AddThreepid { * @param {string} phoneNumber The national or international formatted phone number to add * @return {Promise} Resolves when the text message has been sent. Then call haveMsisdnToken(). */ - async bindMsisdn(phoneCountry, phoneNumber) { + public async bindMsisdn(phoneCountry: string, phoneNumber: string): Promise { this.bind = true; if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { // For separate bind, request a token directly from the IS. @@ -161,7 +165,7 @@ export default class AddThreepid { * with a "message" property which contains a human-readable message detailing why * the request failed. */ - async checkEmailLinkClicked() { + public async checkEmailLinkClicked(): Promise { try { if (await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind()) { if (this.bind) { @@ -175,7 +179,7 @@ export default class AddThreepid { }); } else { try { - await this._makeAddThreepidOnlyRequest(); + await this.makeAddThreepidOnlyRequest(); // The spec has always required this to use UI auth but synapse briefly // implemented it without, so this may just succeed and that's OK. @@ -186,9 +190,6 @@ export default class AddThreepid { throw e; } - // pop up an interactive auth dialog - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { title: _t("Use Single Sign On to continue"), @@ -208,7 +209,7 @@ export default class AddThreepid { title: _t("Add Email Address"), matrixClient: MatrixClientPeg.get(), authData: e.data, - makeRequest: this._makeAddThreepidOnlyRequest, + makeRequest: this.makeAddThreepidOnlyRequest, aestheticsForStagePhases: { [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, @@ -235,16 +236,16 @@ export default class AddThreepid { } /** - * @param {Object} auth UI auth object + * @param {{type: string, session?: string}} auth UI auth object * @return {Promise} Response from /3pid/add call (in current spec, an empty object) */ - _makeAddThreepidOnlyRequest = (auth) => { + private makeAddThreepidOnlyRequest = (auth?: {type: string, session?: string}): Promise<{}> => { return MatrixClientPeg.get().addThreePidOnly({ sid: this.sessionId, client_secret: this.clientSecret, auth, }); - } + }; /** * Takes a phone number verification code as entered by the user and validates @@ -254,7 +255,7 @@ export default class AddThreepid { * with a "message" property which contains a human-readable message detailing why * the request failed. */ - async haveMsisdnToken(msisdnToken) { + public async haveMsisdnToken(msisdnToken: string): Promise { const authClient = new IdentityAuthClient(); const supportsSeparateAddAndBind = await MatrixClientPeg.get().doesServerSupportSeparateAddAndBind(); @@ -291,7 +292,7 @@ export default class AddThreepid { }); } else { try { - await this._makeAddThreepidOnlyRequest(); + await this.makeAddThreepidOnlyRequest(); // The spec has always required this to use UI auth but synapse briefly // implemented it without, so this may just succeed and that's OK. @@ -302,9 +303,6 @@ export default class AddThreepid { throw e; } - // pop up an interactive auth dialog - const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); - const dialogAesthetics = { [SSOAuthEntry.PHASE_PREAUTH]: { title: _t("Use Single Sign On to continue"), @@ -324,7 +322,7 @@ export default class AddThreepid { title: _t("Add Phone Number"), matrixClient: MatrixClientPeg.get(), authData: e.data, - makeRequest: this._makeAddThreepidOnlyRequest, + makeRequest: this.makeAddThreepidOnlyRequest, aestheticsForStagePhases: { [SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics, [SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics, diff --git a/src/Avatar.ts b/src/Avatar.ts index c0ecb19eaf..93109a470e 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -142,15 +142,11 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi // space rooms cannot be DMs so skip the rest if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null; - let otherMember = null; - const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - if (otherUserId) { - otherMember = room.getMember(otherUserId); - } else { - // if the room is not marked as a 1:1, but only has max 2 members - // then still try to show any avatar (pref. other member) - otherMember = room.getAvatarFallbackMember(); - } + // If the room is not a DM don't fallback to a member avatar + if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) return null; + + // If there are only two members in the DM use the avatar of the other member + const otherMember = room.getAvatarFallbackMember(); if (otherMember?.getMxcAvatarUrl()) { return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } diff --git a/src/DateUtils.ts b/src/DateUtils.ts index c81099b893..221ecfeb2a 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -161,3 +161,20 @@ export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): bo // Compare weekdays return prevEventDate.getDay() !== nextEventDate.getDay(); } + +export function formatFullDateNoDay(date: Date) { + return _t("%(date)s at %(time)s", { + date: date.toLocaleDateString().replace(/\//g, '-'), + time: date.toLocaleTimeString().replace(/:/g, '-'), + }); +} + +export function formatFullDateNoDayNoTime(date: Date) { + return ( + date.getFullYear() + + "/" + + pad(date.getMonth() + 1) + + "/" + + pad(date.getDate()) + ); +} diff --git a/src/IdentityAuthClient.js b/src/IdentityAuthClient.tsx similarity index 82% rename from src/IdentityAuthClient.js rename to src/IdentityAuthClient.tsx index 54cf3b43e3..ae55c20438 100644 --- a/src/IdentityAuthClient.js +++ b/src/IdentityAuthClient.tsx @@ -14,12 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from "react"; import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; -import { createClient } from 'matrix-js-sdk/src/matrix'; +import { createClient, MatrixClient } from 'matrix-js-sdk/src/matrix'; import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; -import * as sdk from './index'; import { _t } from './languageHandler'; import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; import { @@ -27,23 +27,25 @@ import { doesIdentityServerHaveTerms, useDefaultIdentityServer, } from './utils/IdentityServerUtils'; -import { abbreviateUrl } from './utils/UrlUtils'; import { logger } from "matrix-js-sdk/src/logger"; +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; +import { abbreviateUrl } from "./utils/UrlUtils"; export class AbortedIdentityActionError extends Error {} export default class IdentityAuthClient { + private accessToken: string; + private tempClient: MatrixClient; + private authEnabled = true; + /** * Creates a new identity auth client * @param {string} identityUrl The URL to contact the identity server with. * When provided, this class will operate solely within memory, refusing to * persist any information such as tokens. Default null (not provided). */ - constructor(identityUrl = null) { - this.accessToken = null; - this.authEnabled = true; - + constructor(identityUrl?: string) { if (identityUrl) { // XXX: We shouldn't have to create a whole new MatrixClient just to // do identity server auth. The functions don't take an identity URL @@ -54,32 +56,29 @@ export default class IdentityAuthClient { baseUrl: "", // invalid by design idBaseUrl: identityUrl, }); - } else { - // Indicates that we're using the real client, not some workaround. - this.tempClient = null; } } - get _matrixClient() { + private get matrixClient(): MatrixClient { return this.tempClient ? this.tempClient : MatrixClientPeg.get(); } - _writeToken() { + private writeToken(): void { if (this.tempClient) return; // temporary client: ignore window.localStorage.setItem("mx_is_access_token", this.accessToken); } - _readToken() { + private readToken(): string { if (this.tempClient) return null; // temporary client: ignore return window.localStorage.getItem("mx_is_access_token"); } - hasCredentials() { - return this.accessToken != null; // undef or null + public hasCredentials(): boolean { + return Boolean(this.accessToken); } // Returns a promise that resolves to the access_token string from the IS - async getAccessToken({ check = true } = {}) { + public async getAccessToken({ check = true } = {}): Promise { if (!this.authEnabled) { // The current IS doesn't support authentication return null; @@ -87,21 +86,21 @@ export default class IdentityAuthClient { let token = this.accessToken; if (!token) { - token = this._readToken(); + token = this.readToken(); } if (!token) { token = await this.registerForToken(check); if (token) { this.accessToken = token; - this._writeToken(); + this.writeToken(); } return token; } if (check) { try { - await this._checkToken(token); + await this.checkToken(token); } catch (e) { if ( e instanceof TermsNotSignedError || @@ -114,7 +113,7 @@ export default class IdentityAuthClient { token = await this.registerForToken(); if (token) { this.accessToken = token; - this._writeToken(); + this.writeToken(); } } } @@ -122,11 +121,11 @@ export default class IdentityAuthClient { return token; } - async _checkToken(token) { - const identityServerUrl = this._matrixClient.getIdentityServerUrl(); + private async checkToken(token: string): Promise { + const identityServerUrl = this.matrixClient.getIdentityServerUrl(); try { - await this._matrixClient.getIdentityAccount(token); + await this.matrixClient.getIdentityAccount(token); } catch (e) { if (e.errcode === "M_TERMS_NOT_SIGNED") { logger.log("Identity server requires new terms to be agreed to"); @@ -145,8 +144,8 @@ export default class IdentityAuthClient { !doesAccountDataHaveIdentityServer() && !(await doesIdentityServerHaveTerms(identityServerUrl)) ) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const { finished } = Modal.createTrackedDialog('Default identity server terms warning', '', + const { finished } = Modal.createTrackedDialog( + 'Default identity server terms warning', '', QuestionDialog, { title: _t("Identity server has no terms of service"), description: ( @@ -184,13 +183,13 @@ export default class IdentityAuthClient { // See also https://github.com/vector-im/element-web/issues/10455. } - async registerForToken(check=true) { + public async registerForToken(check = true): Promise { const hsOpenIdToken = await MatrixClientPeg.get().getOpenIdToken(); // XXX: The spec is `token`, but we used `access_token` for a Sydent release. const { access_token: accessToken, token } = - await this._matrixClient.registerWithIdentityServer(hsOpenIdToken); + await this.matrixClient.registerWithIdentityServer(hsOpenIdToken); const identityAccessToken = token ? token : accessToken; - if (check) await this._checkToken(identityAccessToken); + if (check) await this.checkToken(identityAccessToken); return identityAccessToken; } } diff --git a/src/NodeAnimator.js b/src/NodeAnimator.tsx similarity index 63% rename from src/NodeAnimator.js rename to src/NodeAnimator.tsx index 8456e6e9fd..1a8942f5f5 100644 --- a/src/NodeAnimator.js +++ b/src/NodeAnimator.tsx @@ -1,6 +1,21 @@ import React from "react"; import ReactDom from "react-dom"; -import PropTypes from 'prop-types'; + +interface IChildProps { + style: React.CSSProperties; + ref: (node: React.ReactInstance) => void; +} + +interface IProps { + // either a list of child nodes, or a single child. + children: React.ReactNode; + + // optional transition information for changing existing children + transition?: object; + + // a list of state objects to apply to each child node in turn + startStyles: React.CSSProperties[]; +} /** * The NodeAnimator contains components and animates transitions. @@ -9,55 +24,45 @@ import PropTypes from 'prop-types'; * from DOM order. This makes it a lot simpler and lighter: if you need fully * automatic positional animation, look at react-shuffle or similar libraries. */ -export default class NodeAnimator extends React.Component { - static propTypes = { - // either a list of child nodes, or a single child. - children: PropTypes.any, - - // optional transition information for changing existing children - transition: PropTypes.object, - - // a list of state objects to apply to each child node in turn - startStyles: PropTypes.array, - }; - - static defaultProps = { +export default class NodeAnimator extends React.Component { + private nodes = {}; + private children: { [key: string]: React.DetailedReactHTMLElement }; + public static defaultProps: Partial = { startStyles: [], }; - constructor(props) { + constructor(props: IProps) { super(props); - this.nodes = {}; - this._updateChildren(this.props.children); + this.updateChildren(this.props.children); } - componentDidUpdate() { - this._updateChildren(this.props.children); + public componentDidUpdate(): void { + this.updateChildren(this.props.children); } /** * * @param {HTMLElement} node element to apply styles to - * @param {object} styles a key/value pair of CSS properties + * @param {React.CSSProperties} styles a key/value pair of CSS properties * @returns {void} */ - _applyStyles(node, styles) { + private applyStyles(node: HTMLElement, styles: React.CSSProperties): void { Object.entries(styles).forEach(([property, value]) => { node.style[property] = value; }); } - _updateChildren(newChildren) { + private updateChildren(newChildren: React.ReactNode): void { const oldChildren = this.children || {}; this.children = {}; - React.Children.toArray(newChildren).forEach((c) => { + React.Children.toArray(newChildren).forEach((c: any) => { if (oldChildren[c.key]) { const old = oldChildren[c.key]; const oldNode = ReactDom.findDOMNode(this.nodes[old.key]); - if (oldNode && oldNode.style.left !== c.props.style.left) { - this._applyStyles(oldNode, { left: c.props.style.left }); + if (oldNode && (oldNode as HTMLElement).style.left !== c.props.style.left) { + this.applyStyles(oldNode as HTMLElement, { left: c.props.style.left }); // console.log("translation: "+oldNode.style.left+" -> "+c.props.style.left); } // clone the old element with the props (and children) of the new element @@ -66,7 +71,7 @@ export default class NodeAnimator extends React.Component { } else { // new element. If we have a startStyle, use that as the style and go through // the enter animations - const newProps = {}; + const newProps: Partial = {}; const restingStyle = c.props.style; const startStyles = this.props.startStyles; @@ -76,7 +81,7 @@ export default class NodeAnimator extends React.Component { // console.log("mounted@startstyle0: "+JSON.stringify(startStyle)); } - newProps.ref = ((n) => this._collectNode( + newProps.ref = ((n) => this.collectNode( c.key, n, restingStyle, )); @@ -85,7 +90,7 @@ export default class NodeAnimator extends React.Component { }); } - _collectNode(k, node, restingStyle) { + private collectNode(k: string, node: React.ReactInstance, restingStyle: React.CSSProperties): void { if ( node && this.nodes[k] === undefined && @@ -96,7 +101,7 @@ export default class NodeAnimator extends React.Component { // start from startStyle 1: 0 is the one we gave it // to start with, so now we animate 1 etc. for (let i = 1; i < startStyles.length; ++i) { - this._applyStyles(domNode, startStyles[i]); + this.applyStyles(domNode as HTMLElement, startStyles[i]); // console.log("start:" // JSON.stringify(startStyles[i]), // ); @@ -104,7 +109,7 @@ export default class NodeAnimator extends React.Component { // and then we animate to the resting state setTimeout(() => { - this._applyStyles(domNode, restingStyle); + this.applyStyles(domNode as HTMLElement, restingStyle); }, 0); // console.log("enter:", @@ -113,7 +118,7 @@ export default class NodeAnimator extends React.Component { this.nodes[k] = node; } - render() { + public render(): JSX.Element { return ( <>{ Object.values(this.children) } ); diff --git a/src/PageTypes.js b/src/PageTypes.ts similarity index 74% rename from src/PageTypes.js rename to src/PageTypes.ts index 09e0eadbd7..73967f351e 100644 --- a/src/PageTypes.js +++ b/src/PageTypes.ts @@ -16,11 +16,13 @@ limitations under the License. */ /** The types of page which can be shown by the LoggedInView */ -export default { - HomePage: "home_page", - RoomView: "room_view", - RoomDirectory: "room_directory", - UserView: "user_view", - GroupView: "group_view", - MyGroups: "my_groups", -}; +enum PageType { + HomePage = "home_page", + RoomView = "room_view", + RoomDirectory = "room_directory", + UserView = "user_view", + GroupView = "group_view", + MyGroups = "my_groups", +} + +export default PageType; diff --git a/src/Registration.js b/src/Registration.tsx similarity index 89% rename from src/Registration.js rename to src/Registration.tsx index c59d244149..90e81c0d45 100644 --- a/src/Registration.js +++ b/src/Registration.tsx @@ -20,10 +20,11 @@ limitations under the License. * registration code. */ +import React from "react"; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import Modal from './Modal'; import { _t } from './languageHandler'; +import QuestionDialog from "./components/views/dialogs/QuestionDialog"; // Regex for what a "safe" or "Matrix-looking" localpart would be. // TODO: Update as needed for https://github.com/matrix-org/matrix-doc/issues/1514 @@ -41,9 +42,11 @@ export const SAFE_LOCALPART_REGEX = /^[a-z0-9=_\-./]+$/; * @param {bool} options.screen_after * If present the screen to redirect to after a successful login or register. */ -export async function startAnyRegistrationFlow(options) { +export async function startAnyRegistrationFlow( + // eslint-disable-next-line camelcase + options: { go_home_on_cancel?: boolean, go_welcome_on_cancel?: boolean, screen_after?: boolean}, +): Promise { if (options === undefined) options = {}; - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const modal = Modal.createTrackedDialog('Registration required', '', QuestionDialog, { hasCancelButton: true, quitOnly: true, diff --git a/src/RoomNotifs.js b/src/RoomNotifs.ts similarity index 66% rename from src/RoomNotifs.js rename to src/RoomNotifs.ts index 5d109094af..5abee9a6ad 100644 --- a/src/RoomNotifs.js +++ b/src/RoomNotifs.ts @@ -17,27 +17,31 @@ limitations under the License. import { MatrixClientPeg } from './MatrixClientPeg'; import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { IAnnotatedPushRule, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules"; -export const ALL_MESSAGES_LOUD = 'all_messages_loud'; -export const ALL_MESSAGES = 'all_messages'; -export const MENTIONS_ONLY = 'mentions_only'; -export const MUTE = 'mute'; +export enum RoomNotifState { + AllMessagesLoud = 'all_messages_loud', + AllMessages = 'all_messages', + MentionsOnly = 'mentions_only', + Mute = 'mute', +} -export const BADGE_STATES = [ALL_MESSAGES, ALL_MESSAGES_LOUD]; -export const MENTION_BADGE_STATES = [...BADGE_STATES, MENTIONS_ONLY]; +export const BADGE_STATES = [RoomNotifState.AllMessages, RoomNotifState.AllMessagesLoud]; +export const MENTION_BADGE_STATES = [...BADGE_STATES, RoomNotifState.MentionsOnly]; -export function shouldShowNotifBadge(roomNotifState) { +export function shouldShowNotifBadge(roomNotifState: RoomNotifState): boolean { return BADGE_STATES.includes(roomNotifState); } -export function shouldShowMentionBadge(roomNotifState) { +export function shouldShowMentionBadge(roomNotifState: RoomNotifState): boolean { return MENTION_BADGE_STATES.includes(roomNotifState); } -export function aggregateNotificationCount(rooms) { - return rooms.reduce((result, room) => { +export function aggregateNotificationCount(rooms: Room[]): {count: number, highlight: boolean} { + return rooms.reduce<{count: number, highlight: boolean}>((result, room) => { const roomNotifState = getRoomNotifsState(room.roomId); - const highlight = room.getUnreadNotificationCount('highlight') > 0; + const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0; // use helper method to include highlights in the previous version of the room const notificationCount = getUnreadNotificationCount(room); @@ -55,9 +59,9 @@ export function aggregateNotificationCount(rooms) { }, { count: 0, highlight: false }); } -export function getRoomHasBadge(room) { +export function getRoomHasBadge(room: Room): boolean { const roomNotifState = getRoomNotifsState(room.roomId); - const highlight = room.getUnreadNotificationCount('highlight') > 0; + const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0; const notificationCount = room.getUnreadNotificationCount(); const notifBadges = notificationCount > 0 && shouldShowNotifBadge(roomNotifState); @@ -66,14 +70,14 @@ export function getRoomHasBadge(room) { return notifBadges || mentionBadges; } -export function getRoomNotifsState(roomId) { - if (MatrixClientPeg.get().isGuest()) return ALL_MESSAGES; +export function getRoomNotifsState(roomId: string): RoomNotifState { + if (MatrixClientPeg.get().isGuest()) return RoomNotifState.AllMessages; // look through the override rules for a rule affecting this room: // if one exists, it will take precedence. const muteRule = findOverrideMuteRule(roomId); if (muteRule) { - return MUTE; + return RoomNotifState.Mute; } // for everything else, look at the room rule. @@ -89,27 +93,27 @@ export function getRoomNotifsState(roomId) { // XXX: We have to assume the default is to notify for all messages // (in particular this will be 'wrong' for one to one rooms because // they will notify loudly for all messages) - if (!roomRule || !roomRule.enabled) return ALL_MESSAGES; + if (!roomRule || !roomRule.enabled) return RoomNotifState.AllMessages; // a mute at the room level will still allow mentions // to notify - if (isMuteRule(roomRule)) return MENTIONS_ONLY; + if (isMuteRule(roomRule)) return RoomNotifState.MentionsOnly; const actionsObject = PushProcessor.actionListToActionsObject(roomRule.actions); - if (actionsObject.tweaks.sound) return ALL_MESSAGES_LOUD; + if (actionsObject.tweaks.sound) return RoomNotifState.AllMessagesLoud; return null; } -export function setRoomNotifsState(roomId, newState) { - if (newState === MUTE) { +export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Promise { + if (newState === RoomNotifState.Mute) { return setRoomNotifsStateMuted(roomId); } else { return setRoomNotifsStateUnmuted(roomId, newState); } } -export function getUnreadNotificationCount(room, type=null) { +export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number { let notificationCount = room.getUnreadNotificationCount(type); // Check notification counts in the old room just in case there's some lost @@ -124,21 +128,21 @@ export function getUnreadNotificationCount(room, type=null) { // notifying the user for unread messages because they would have extreme // difficulty changing their notification preferences away from "All Messages" // and "Noisy". - notificationCount += oldRoom.getUnreadNotificationCount("highlight"); + notificationCount += oldRoom.getUnreadNotificationCount(NotificationCountType.Highlight); } } return notificationCount; } -function setRoomNotifsStateMuted(roomId) { +function setRoomNotifsStateMuted(roomId: string): Promise { const cli = MatrixClientPeg.get(); const promises = []; // delete the room rule const roomRule = cli.getRoomPushRule('global', roomId); if (roomRule) { - promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); + promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id)); } // add/replace an override rule to squelch everything in this room @@ -146,7 +150,7 @@ function setRoomNotifsStateMuted(roomId) { // is an override rule, not a room rule: it still pertains to this room // though, so using the room ID as the rule ID is logical and prevents // duplicate copies of the rule. - promises.push(cli.addPushRule('global', 'override', roomId, { + promises.push(cli.addPushRule('global', PushRuleKind.Override, roomId, { conditions: [ { kind: 'event_match', @@ -162,30 +166,30 @@ function setRoomNotifsStateMuted(roomId) { return Promise.all(promises); } -function setRoomNotifsStateUnmuted(roomId, newState) { +function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Promise { const cli = MatrixClientPeg.get(); const promises = []; const overrideMuteRule = findOverrideMuteRule(roomId); if (overrideMuteRule) { - promises.push(cli.deletePushRule('global', 'override', overrideMuteRule.rule_id)); + promises.push(cli.deletePushRule('global', PushRuleKind.Override, overrideMuteRule.rule_id)); } - if (newState === 'all_messages') { + if (newState === RoomNotifState.AllMessages) { const roomRule = cli.getRoomPushRule('global', roomId); if (roomRule) { - promises.push(cli.deletePushRule('global', 'room', roomRule.rule_id)); + promises.push(cli.deletePushRule('global', PushRuleKind.RoomSpecific, roomRule.rule_id)); } - } else if (newState === 'mentions_only') { - promises.push(cli.addPushRule('global', 'room', roomId, { + } else if (newState === RoomNotifState.MentionsOnly) { + promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, { actions: [ 'dont_notify', ], })); // https://matrix.org/jira/browse/SPEC-400 - promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); - } else if ('all_messages_loud') { - promises.push(cli.addPushRule('global', 'room', roomId, { + promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true)); + } else if (newState === RoomNotifState.AllMessagesLoud) { + promises.push(cli.addPushRule('global', PushRuleKind.RoomSpecific, roomId, { actions: [ 'notify', { @@ -195,13 +199,13 @@ function setRoomNotifsStateUnmuted(roomId, newState) { ], })); // https://matrix.org/jira/browse/SPEC-400 - promises.push(cli.setPushRuleEnabled('global', 'room', roomId, true)); + promises.push(cli.setPushRuleEnabled('global', PushRuleKind.RoomSpecific, roomId, true)); } return Promise.all(promises); } -function findOverrideMuteRule(roomId) { +function findOverrideMuteRule(roomId: string): IAnnotatedPushRule { const cli = MatrixClientPeg.get(); if (!cli.pushRules || !cli.pushRules['global'] || @@ -218,7 +222,7 @@ function findOverrideMuteRule(roomId) { return null; } -function isRuleForRoom(roomId, rule) { +function isRuleForRoom(roomId: string, rule: IAnnotatedPushRule): boolean { if (rule.conditions.length !== 1) { return false; } @@ -226,6 +230,6 @@ function isRuleForRoom(roomId, rule) { return (cond.kind === 'event_match' && cond.key === 'room_id' && cond.pattern === roomId); } -function isMuteRule(rule) { +function isMuteRule(rule: IAnnotatedPushRule): boolean { return (rule.actions.length === 1 && rule.actions[0] === 'dont_notify'); } diff --git a/src/ScalarMessaging.js b/src/ScalarMessaging.ts similarity index 87% rename from src/ScalarMessaging.js rename to src/ScalarMessaging.ts index 609ac5c67c..888b9ce9ed 100644 --- a/src/ScalarMessaging.js +++ b/src/ScalarMessaging.ts @@ -247,13 +247,31 @@ import { objectClone } from "./utils/objects"; import { logger } from "matrix-js-sdk/src/logger"; -function sendResponse(event, res) { +enum Action { + CloseScalar = "close_scalar", + GetWidgets = "get_widgets", + SetWidgets = "set_widgets", + SetWidget = "set_widget", + JoinRulesState = "join_rules_state", + SetPlumbingState = "set_plumbing_state", + GetMembershipCount = "get_membership_count", + GetRoomEncryptionState = "get_room_enc_state", + CanSendEvent = "can_send_event", + MembershipState = "membership_state", + invite = "invite", + BotOptions = "bot_options", + SetBotOptions = "set_bot_options", + SetBotPower = "set_bot_power", +} + +function sendResponse(event: MessageEvent, res: any): void { const data = objectClone(event.data); data.response = res; + // @ts-ignore event.source.postMessage(data, event.origin); } -function sendError(event, msg, nestedError) { +function sendError(event: MessageEvent, msg: string, nestedError?: Error): void { console.error("Action:" + event.data.action + " failed with message: " + msg); const data = objectClone(event.data); data.response = { @@ -264,10 +282,11 @@ function sendError(event, msg, nestedError) { if (nestedError) { data.response.error._error = nestedError; } + // @ts-ignore event.source.postMessage(data, event.origin); } -function inviteUser(event, roomId, userId) { +function inviteUser(event: MessageEvent, roomId: string, userId: string): void { logger.log(`Received request to invite ${userId} into room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { @@ -295,7 +314,7 @@ function inviteUser(event, roomId, userId) { }); } -function setWidget(event, roomId) { +function setWidget(event: MessageEvent, roomId: string): void { const widgetId = event.data.widget_id; let widgetType = event.data.type; const widgetUrl = event.data.url; @@ -356,7 +375,7 @@ function setWidget(event, roomId) { } } -function getWidgets(event, roomId) { +function getWidgets(event: MessageEvent, roomId: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -382,7 +401,7 @@ function getWidgets(event, roomId) { sendResponse(event, widgetStateEvents); } -function getRoomEncState(event, roomId) { +function getRoomEncState(event: MessageEvent, roomId: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -398,7 +417,7 @@ function getRoomEncState(event, roomId) { sendResponse(event, roomIsEncrypted); } -function setPlumbingState(event, roomId, status) { +function setPlumbingState(event: MessageEvent, roomId: string, status: string): void { if (typeof status !== 'string') { throw new Error('Plumbing state status should be a string'); } @@ -417,7 +436,7 @@ function setPlumbingState(event, roomId, status) { }); } -function setBotOptions(event, roomId, userId) { +function setBotOptions(event: MessageEvent, roomId: string, userId: string): void { logger.log(`Received request to set options for bot ${userId} in room ${roomId}`); const client = MatrixClientPeg.get(); if (!client) { @@ -433,7 +452,7 @@ function setBotOptions(event, roomId, userId) { }); } -function setBotPower(event, roomId, userId, level) { +function setBotPower(event: MessageEvent, roomId: string, userId: string, level: number): void { if (!(Number.isInteger(level) && level >= 0)) { sendError(event, _t('Power level must be positive integer.')); return; @@ -464,22 +483,22 @@ function setBotPower(event, roomId, userId, level) { }); } -function getMembershipState(event, roomId, userId) { +function getMembershipState(event: MessageEvent, roomId: string, userId: string): void { logger.log(`membership_state of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.member", userId); } -function getJoinRules(event, roomId) { +function getJoinRules(event: MessageEvent, roomId: string): void { logger.log(`join_rules of ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.join_rules", ""); } -function botOptions(event, roomId, userId) { +function botOptions(event: MessageEvent, roomId: string, userId: string): void { logger.log(`bot_options of ${userId} in room ${roomId} requested.`); returnStateEvent(event, roomId, "m.room.bot.options", "_" + userId); } -function getMembershipCount(event, roomId) { +function getMembershipCount(event: MessageEvent, roomId: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -494,7 +513,7 @@ function getMembershipCount(event, roomId) { sendResponse(event, count); } -function canSendEvent(event, roomId) { +function canSendEvent(event: MessageEvent, roomId: string): void { const evType = "" + event.data.event_type; // force stringify const isState = Boolean(event.data.is_state); const client = MatrixClientPeg.get(); @@ -528,7 +547,7 @@ function canSendEvent(event, roomId) { sendResponse(event, true); } -function returnStateEvent(event, roomId, eventType, stateKey) { +function returnStateEvent(event: MessageEvent, roomId: string, eventType: string, stateKey: string): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t('You need to be logged in.')); @@ -547,8 +566,9 @@ function returnStateEvent(event, roomId, eventType, stateKey) { sendResponse(event, stateEvent.getContent()); } -const onMessage = function(event) { +const onMessage = function(event: MessageEvent): void { if (!event.origin) { // stupid chrome + // @ts-ignore event.origin = event.originalEvent.origin; } @@ -582,8 +602,8 @@ const onMessage = function(event) { return; } - if (event.data.action === "close_scalar") { - dis.dispatch({ action: "close_scalar" }); + if (event.data.action === Action.CloseScalar) { + dis.dispatch({ action: Action.CloseScalar }); sendResponse(event, null); return; } @@ -596,10 +616,10 @@ const onMessage = function(event) { // Get and set user widgets (not associated with a specific room) // If roomId is specified, it must be validated, so room-based widgets agreed // handled further down. - if (event.data.action === "get_widgets") { + if (event.data.action === Action.GetWidgets) { getWidgets(event, null); return; - } else if (event.data.action === "set_widget") { + } else if (event.data.action === Action.SetWidgets) { setWidget(event, null); return; } else { @@ -614,28 +634,28 @@ const onMessage = function(event) { } // Get and set room-based widgets - if (event.data.action === "get_widgets") { + if (event.data.action === Action.GetWidgets) { getWidgets(event, roomId); return; - } else if (event.data.action === "set_widget") { + } else if (event.data.action === Action.SetWidget) { setWidget(event, roomId); return; } // These APIs don't require userId - if (event.data.action === "join_rules_state") { + if (event.data.action === Action.JoinRulesState) { getJoinRules(event, roomId); return; - } else if (event.data.action === "set_plumbing_state") { + } else if (event.data.action === Action.SetPlumbingState) { setPlumbingState(event, roomId, event.data.status); return; - } else if (event.data.action === "get_membership_count") { + } else if (event.data.action === Action.GetMembershipCount) { getMembershipCount(event, roomId); return; - } else if (event.data.action === "get_room_enc_state") { + } else if (event.data.action === Action.GetRoomEncryptionState) { getRoomEncState(event, roomId); return; - } else if (event.data.action === "can_send_event") { + } else if (event.data.action === Action.CanSendEvent) { canSendEvent(event, roomId); return; } @@ -645,19 +665,19 @@ const onMessage = function(event) { return; } switch (event.data.action) { - case "membership_state": + case Action.MembershipState: getMembershipState(event, roomId, userId); break; - case "invite": + case Action.invite: inviteUser(event, roomId, userId); break; - case "bot_options": + case Action.BotOptions: botOptions(event, roomId, userId); break; - case "set_bot_options": + case Action.SetBotOptions: setBotOptions(event, roomId, userId); break; - case "set_bot_power": + case Action.SetBotPower: setBotPower(event, roomId, userId, event.data.level); break; default: @@ -667,16 +687,16 @@ const onMessage = function(event) { }; let listenerCount = 0; -let openManagerUrl = null; +let openManagerUrl: string = null; -export function startListening() { +export function startListening(): void { if (listenerCount === 0) { window.addEventListener("message", onMessage, false); } listenerCount += 1; } -export function stopListening() { +export function stopListening(): void { listenerCount -= 1; if (listenerCount === 0) { window.removeEventListener("message", onMessage); @@ -691,6 +711,6 @@ export function stopListening() { } } -export function setOpenManagerUrl(url) { +export function setOpenManagerUrl(url: string): void { openManagerUrl = url; } diff --git a/src/Skinner.js b/src/Skinner.ts similarity index 84% rename from src/Skinner.js rename to src/Skinner.ts index ef340e4052..6b20781b59 100644 --- a/src/Skinner.js +++ b/src/Skinner.ts @@ -14,12 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ -class Skinner { - constructor() { - this.components = null; - } +import React from "react"; - getComponent(name) { +export interface IComponents { + [key: string]: React.Component; +} + +export interface ISkinObject { + components: IComponents; +} + +export class Skinner { + public components: IComponents = null; + + public getComponent(name: string): React.Component { if (!name) throw new Error(`Invalid component name: ${name}`); if (this.components === null) { throw new Error( @@ -30,7 +38,7 @@ class Skinner { ); } - const doLookup = (components) => { + const doLookup = (components: IComponents): React.Component => { if (!components) return null; let comp = components[name]; // XXX: Temporarily also try 'views.' as we're currently @@ -58,7 +66,7 @@ class Skinner { return comp; } - load(skinObject) { + public load(skinObject: ISkinObject): void { if (this.components !== null) { throw new Error( "Attempted to load a skin while a skin is already loaded"+ @@ -72,6 +80,7 @@ class Skinner { } // Now that we have a skin, load our components too + // eslint-disable-next-line @typescript-eslint/no-var-requires const idx = require("./component-index"); if (!idx || !idx.components) throw new Error("Invalid react-sdk component index"); for (const c in idx.components) { @@ -79,7 +88,7 @@ class Skinner { } } - addComponent(name, comp) { + public addComponent(name: string, comp: any) { let slot = name; if (comp.replaces !== undefined) { if (comp.replaces.indexOf('.') > -1) { @@ -91,7 +100,7 @@ class Skinner { this.components[slot] = comp; } - reset() { + public reset(): void { this.components = null; } } @@ -105,8 +114,8 @@ class Skinner { // See https://derickbailey.com/2016/03/09/creating-a-true-singleton-in-node-js-with-es6-symbols/ // or https://nodejs.org/api/modules.html#modules_module_caching_caveats // ("Modules are cached based on their resolved filename") -if (global.mxSkinner === undefined) { - global.mxSkinner = new Skinner(); +if (window.mxSkinner === undefined) { + window.mxSkinner = new Skinner(); } -export default global.mxSkinner; +export default window.mxSkinner; diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 0e9dc1cf15..6fb4107d20 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -166,6 +166,11 @@ function textForTopicEvent(ev: MatrixEvent): () => string | null { }); } +function textForRoomAvatarEvent(ev: MatrixEvent): () => string | null { + const senderDisplayName = ev?.sender?.name || ev.getSender(); + return () => _t('%(senderDisplayName)s changed the room avatar.', { senderDisplayName }); +} + function textForRoomNameEvent(ev: MatrixEvent): () => string | null { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); @@ -289,11 +294,27 @@ function textForServerACLEvent(ev: MatrixEvent): () => string | null { function textForMessageEvent(ev: MatrixEvent): () => string | null { return () => { const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); - let message = senderDisplayName + ': ' + ev.getContent().body; + let message = ev.getContent().body; + if (ev.isRedacted()) { + message = _t("Message deleted"); + const unsigned = ev.getUnsigned(); + const redactedBecauseUserId = unsigned?.redacted_because?.sender; + if (redactedBecauseUserId && redactedBecauseUserId !== ev.getSender()) { + const room = MatrixClientPeg.get().getRoom(ev.getRoomId()); + const sender = room?.getMember(redactedBecauseUserId); + message = _t("Message deleted by %(name)s", { name: sender?.name + || redactedBecauseUserId }); + } + } if (ev.getContent().msgtype === "m.emote") { message = "* " + senderDisplayName + " " + message; } else if (ev.getContent().msgtype === "m.image") { message = _t('%(senderDisplayName)s sent an image.', { senderDisplayName }); + } else if (ev.getType() == "m.sticker") { + message = _t('%(senderDisplayName)s sent a sticker.', { senderDisplayName }); + } else { + // in this case, parse it as a plain text message + message = senderDisplayName + ': ' + message; } return message; }; @@ -669,6 +690,7 @@ interface IHandlers { const handlers: IHandlers = { 'm.room.message': textForMessageEvent, + 'm.sticker': textForMessageEvent, 'm.call.invite': textForCallInviteEvent, }; @@ -677,6 +699,7 @@ const stateHandlers: IHandlers = { 'm.room.name': textForRoomNameEvent, 'm.room.topic': textForTopicEvent, 'm.room.member': textForMemberEvent, + "m.room.avatar": textForRoomAvatarEvent, 'm.room.third_party_invite': textForThreePidInviteEvent, 'm.room.history_visibility': textForHistoryVisibilityEvent, 'm.room.power_levels': textForPowerEvent, diff --git a/src/boundThreepids.js b/src/boundThreepids.ts similarity index 84% rename from src/boundThreepids.js rename to src/boundThreepids.ts index 3b32815913..94ff36ad4f 100644 --- a/src/boundThreepids.js +++ b/src/boundThreepids.ts @@ -14,9 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids"; +import { MatrixClient } from "matrix-js-sdk/src/client"; import IdentityAuthClient from './IdentityAuthClient'; -export async function getThreepidsWithBindStatus(client, filterMedium) { +export async function getThreepidsWithBindStatus( + client: MatrixClient, filterMedium?: ThreepidMedium, +): Promise { const userId = client.getUserId(); let { threepids } = await client.getThreePids(); @@ -31,7 +35,7 @@ export async function getThreepidsWithBindStatus(client, filterMedium) { const identityAccessToken = await authClient.getAccessToken({ check: false }); // Restructure for lookup query - const query = threepids.map(({ medium, address }) => [medium, address]); + const query = threepids.map(({ medium, address }): [string, string] => [medium, address]); const lookupResults = await client.bulkLookupThreePids(query, identityAccessToken); // Record which are already bound diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 2173230627..9f1a5adc9d 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -226,6 +226,11 @@ export class ContextMenu extends React.PureComponent { } }; + private onClick = (ev: React.MouseEvent) => { + // Don't allow clicks to escape the context menu wrapper + ev.stopPropagation(); + }; + private onKeyDown = (ev: React.KeyboardEvent) => { // don't let keyboard handling escape the context menu ev.stopPropagation(); @@ -383,6 +388,7 @@ export class ContextMenu extends React.PureComponent { className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)} style={{ ...position, ...wrapperStyle }} onKeyDown={this.onKeyDown} + onClick={this.onClick} onContextMenu={this.onContextMenuPreventBubbling} >
{ break; } case 'view_my_groups': - this.setPage(PageTypes.MyGroups); + this.setPage(PageType.MyGroups); this.notifyNewScreen('groups'); break; case 'view_group': @@ -756,7 +756,7 @@ export default class MatrixChat extends React.PureComponent { localStorage.setItem("mx_seenSpacesBeta", "1"); // We just dispatch the page change rather than have to worry about // what the logic is for each of these branches. - if (this.state.page_type === PageTypes.MyGroups) { + if (this.state.page_type === PageType.MyGroups) { dis.dispatch({ action: 'view_last_screen' }); } else { dis.dispatch({ action: 'view_my_groups' }); @@ -842,7 +842,7 @@ export default class MatrixChat extends React.PureComponent { } }; - private setPage(pageType: string) { + private setPage(pageType: PageType) { this.setState({ page_type: pageType, }); @@ -949,7 +949,7 @@ export default class MatrixChat extends React.PureComponent { this.setState({ view: Views.LOGGED_IN, currentRoomId: roomInfo.room_id || null, - page_type: PageTypes.RoomView, + page_type: PageType.RoomView, threepidInvite: roomInfo.threepid_invite, roomOobData: roomInfo.oob_data, ready: true, @@ -977,7 +977,7 @@ export default class MatrixChat extends React.PureComponent { currentGroupId: groupId, currentGroupIsNew: payload.group_is_new, }); - this.setPage(PageTypes.GroupView); + this.setPage(PageType.GroupView); this.notifyNewScreen('group/' + groupId); } @@ -1020,7 +1020,7 @@ export default class MatrixChat extends React.PureComponent { justRegistered, currentRoomId: null, }); - this.setPage(PageTypes.HomePage); + this.setPage(PageType.HomePage); this.notifyNewScreen('home'); ThemeController.isLogin = false; this.themeWatcher.recheck(); @@ -1038,7 +1038,7 @@ export default class MatrixChat extends React.PureComponent { } this.notifyNewScreen('user/' + userId); this.setState({ currentUserId: userId }); - this.setPage(PageTypes.UserView); + this.setPage(PageType.UserView); }); } diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 74f281405c..42980efc57 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -48,6 +48,8 @@ import Spinner from "../views/elements/Spinner"; import TileErrorBoundary from '../views/messages/TileErrorBoundary'; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { logger } from 'matrix-js-sdk/src/logger'; +import { Action } from '../../dispatcher/actions'; const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes const continuedTypes = [EventType.Sticker, EventType.RoomMessage]; @@ -60,7 +62,7 @@ const groupedEvents = [ // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL -function shouldFormContinuation( +export function shouldFormContinuation( prevEvent: MatrixEvent, mxEvent: MatrixEvent, showHiddenEvents: boolean, @@ -287,6 +289,15 @@ export default class MessagePanel extends React.Component { ghostReadMarkers, }); } + + const pendingEditItem = this.pendingEditItem; + if (!this.props.editState && this.props.room && pendingEditItem) { + defaultDispatcher.dispatch({ + action: Action.EditEvent, + event: this.props.room.findEventById(pendingEditItem), + timelineRenderingType: this.context.timelineRenderingType, + }); + } } private calculateRoomMembersCount = (): void => { @@ -550,10 +561,14 @@ export default class MessagePanel extends React.Component { return { nextEvent, nextTile }; } - private get roomHasPendingEdit(): string { - return this.props.room && localStorage.getItem(`mx_edit_room_${this.props.room.roomId}`); + private get pendingEditItem(): string | undefined { + try { + return localStorage.getItem(`mx_edit_room_${this.props.room.roomId}_${this.context.timelineRenderingType}`); + } catch (err) { + logger.error(err); + return undefined; + } } - private getEventTiles(): ReactNode[] { this.eventNodes = {}; @@ -663,13 +678,6 @@ export default class MessagePanel extends React.Component { } } - if (!this.props.editState && this.roomHasPendingEdit) { - defaultDispatcher.dispatch({ - action: "edit_event", - event: this.props.room.findEventById(this.roomHasPendingEdit), - }); - } - if (grouper) { ret.push(...grouper.getTiles()); } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 15bf327a74..e5067f1fcf 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -48,8 +48,8 @@ import { Layout } from "../../settings/Layout"; import AccessibleButton from "../views/elements/AccessibleButton"; import RightPanelStore from "../../stores/RightPanelStore"; import { haveTileForEvent } from "../views/rooms/EventTile"; -import RoomContext from "../../contexts/RoomContext"; -import MatrixClientContext from "../../contexts/MatrixClientContext"; +import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; +import MatrixClientContext, { withMatrixClientHOC, MatrixClientProps } from "../../contexts/MatrixClientContext"; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { Action } from "../../dispatcher/actions"; import { IMatrixClientCreds } from "../../MatrixClientPeg"; @@ -91,6 +91,7 @@ import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; import SpaceStore from "../../stores/SpaceStore"; import { logger } from "matrix-js-sdk/src/logger"; +import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -102,7 +103,7 @@ if (DEBUG) { debuglog = logger.log.bind(console); } -interface IProps { +interface IRoomProps extends MatrixClientProps { threepidInvite: IThreepidInvite; oobData?: IOOBData; @@ -113,7 +114,7 @@ interface IProps { onRegistered?(credentials: IMatrixClientCreds): void; } -export interface IState { +export interface IRoomState { room?: Room; roomId?: string; roomAlias?: string; @@ -187,10 +188,12 @@ export interface IState { // if it did we don't want the room to be marked as read as soon as it is loaded. wasContextSwitch?: boolean; editState?: EditorStateTransfer; + timelineRenderingType: TimelineRenderingType; + liveTimeline?: EventTimeline; } @replaceableComponent("structures.RoomView") -export default class RoomView extends React.Component { +export class RoomView extends React.Component { private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; private readonly rightPanelStoreToken: EventSubscription; @@ -247,6 +250,8 @@ export default class RoomView extends React.Component { showDisplaynameChanges: true, matrixClientIsReady: this.context && this.context.isInitialSyncComplete(), dragCounter: 0, + timelineRenderingType: TimelineRenderingType.Room, + liveTimeline: undefined, }; this.dispatcherRef = dis.register(this.onAction); @@ -336,7 +341,7 @@ export default class RoomView extends React.Component { const roomId = RoomViewStore.getRoomId(); - const newState: Pick = { + const newState: Pick = { roomId, roomAlias: RoomViewStore.getRoomAlias(), roomLoading: RoomViewStore.isRoomLoading(), @@ -808,7 +813,9 @@ export default class RoomView extends React.Component { this.onSearchClick(); break; - case "edit_event": { + case Action.EditEvent: { + // Quit early if we're trying to edit events in wrong rendering context + if (payload.timelineRenderingType !== this.state.timelineRenderingType) return; const editState = payload.event ? new EditorStateTransfer(payload.event) : null; this.setState({ editState }, () => { if (payload.event) { @@ -932,6 +939,10 @@ export default class RoomView extends React.Component { this.updateE2EStatus(room); this.updatePermissions(room); this.checkWidgets(room); + + this.setState({ + liveTimeline: room.getLiveTimeline(), + }); }; private async calculateRecommendedVersion(room: Room) { @@ -2086,3 +2097,6 @@ export default class RoomView extends React.Component { ); } } + +const RoomViewWithMatrixClient = withMatrixClientHOC(RoomView); +export default RoomViewWithMatrixClient; diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 2eae585f4f..0ea070627a 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -277,8 +277,15 @@ export default class ScrollPanel extends React.Component { // 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; + // + // We therefore leave a bit of wiggle-room and assume we're at the + // bottom if the unscrolled area is less than one pixel high. + // + // non-standard DPI settings also seem to have effect here and can + // actually lead to scrollTop+clientHeight being *larger* than + // scrollHeight. (observed in element-desktop on Ubuntu 20.04) + // + return sn.scrollHeight - (sn.scrollTop + sn.clientHeight) <= 1; }; // returns the vertical height in the given direction that can be removed from diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index db16011917..ed87b04c8a 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -446,13 +446,13 @@ export const useSpaceSummary = (space: Room): { })); const loadMore = useCallback(async (pageSize?: number) => { - if (!hierarchy.canLoadMore || hierarchy.noSupport) return; + if (loading || !hierarchy.canLoadMore || hierarchy.noSupport) return; setLoading(true); await hierarchy.load(pageSize); setRooms(hierarchy.rooms); setLoading(false); - }, [hierarchy]); + }, [loading, hierarchy]); return { loading, rooms, hierarchy, loadMore }; }; @@ -648,8 +648,6 @@ const SpaceHierarchy = ({ return { ({ onKeyDownHandler }) => { let content: JSX.Element; - let loader: JSX.Element; - if (loading && !rooms.length) { content = ; } else { @@ -671,19 +669,20 @@ const SpaceHierarchy = ({ }} /> ; - - if (hierarchy.canLoadMore) { - loader =
- -
; - } - } else { + } else if (!hierarchy.canLoadMore) { results =

{ _t("No results found") }

{ _t("You may want to try a different search or check for typos.") }
; } + let loader: JSX.Element; + if (hierarchy.canLoadMore) { + loader =
+ +
; + } + content = <>

{ query.trim() ? _t("Results") : _t("Rooms and spaces") }

diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx index bb31c32877..8fac538bbc 100644 --- a/src/components/structures/ThreadView.tsx +++ b/src/components/structures/ThreadView.tsx @@ -34,6 +34,8 @@ import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPan import { Action } from '../../dispatcher/actions'; import { MatrixClientPeg } from '../../MatrixClientPeg'; import { E2EStatus } from '../../utils/ShieldUtils'; +import EditorStateTransfer from '../../utils/EditorStateTransfer'; +import RoomContext, { TimelineRenderingType } from '../../contexts/RoomContext'; interface IProps { room: Room; @@ -47,10 +49,14 @@ interface IProps { interface IState { replyToEvent?: MatrixEvent; thread?: Thread; + editState?: EditorStateTransfer; + } @replaceableComponent("structures.ThreadView") export default class ThreadView extends React.Component { + static contextType = RoomContext; + private dispatcherRef: string; private timelinePanelRef: React.RefObject = React.createRef(); @@ -90,6 +96,23 @@ export default class ThreadView extends React.Component { this.setupThread(payload.event); } } + switch (payload.action) { + case Action.EditEvent: { + // Quit early if it's not a thread context + if (payload.timelineRenderingType !== TimelineRenderingType.Thread) return; + // Quit early if that's not a thread event + if (payload.event && !payload.event.getThread()) return; + const editState = payload.event ? new EditorStateTransfer(payload.event) : null; + this.setState({ editState }, () => { + if (payload.event) { + this.timelinePanelRef.current?.scrollToEventIfNeeded(payload.event.getId()); + } + }); + break; + } + default: + break; + } }; private setupThread = (mxEv: MatrixEvent) => { @@ -124,44 +147,53 @@ export default class ThreadView extends React.Component { public render(): JSX.Element { return ( - - { this.state.thread && ( - empty
} - alwaysShowTimestamps={true} - layout={Layout.Group} - hideThreadedMessages={false} - hidden={false} - showReactions={true} - className="mx_RoomView_messagePanel mx_GroupLayout" + + + + { this.state.thread && ( + empty
} + alwaysShowTimestamps={true} + layout={Layout.Group} + hideThreadedMessages={false} + hidden={false} + showReactions={true} + className="mx_RoomView_messagePanel mx_GroupLayout" + permalinkCreator={this.props.permalinkCreator} + membersLoaded={true} + editState={this.state.editState} + /> + ) } + + { this.state?.thread?.timelineSet && ( - ) } - - + e2eStatus={this.props.e2eStatus} + compact={true} + />) } + + ); } } diff --git a/src/components/structures/auth/CompleteSecurity.tsx b/src/components/structures/auth/CompleteSecurity.tsx index 8c3d5e80a0..1e46f0e657 100644 --- a/src/components/structures/auth/CompleteSecurity.tsx +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -20,6 +20,7 @@ import * as sdk from '../../../index'; import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; import SetupEncryptionBody from "./SetupEncryptionBody"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AccessibleButton from '../../views/elements/AccessibleButton'; interface IProps { onFinished: () => void; @@ -27,6 +28,7 @@ interface IProps { interface IState { phase: Phase; + lostKeys: boolean; } @replaceableComponent("structures.auth.CompleteSecurity") @@ -36,12 +38,17 @@ export default class CompleteSecurity extends React.Component { const store = SetupEncryptionStore.sharedInstance(); store.on("update", this.onStoreUpdate); store.start(); - this.state = { phase: store.phase }; + this.state = { phase: store.phase, lostKeys: store.lostKeys() }; } private onStoreUpdate = (): void => { const store = SetupEncryptionStore.sharedInstance(); - this.setState({ phase: store.phase }); + this.setState({ phase: store.phase, lostKeys: store.lostKeys() }); + }; + + private onSkipClick = (): void => { + const store = SetupEncryptionStore.sharedInstance(); + store.skip(); }; public componentWillUnmount(): void { @@ -53,15 +60,20 @@ export default class CompleteSecurity extends React.Component { public render() { const AuthPage = sdk.getComponent("auth.AuthPage"); const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); - const { phase } = this.state; + const { phase, lostKeys } = this.state; let icon; let title; if (phase === Phase.Loading) { return null; } else if (phase === Phase.Intro) { - icon = ; - title = _t("Verify this login"); + if (lostKeys) { + icon = ; + title = _t("Unable to verify this login"); + } else { + icon = ; + title = _t("Verify this login"); + } } else if (phase === Phase.Done) { icon = ; title = _t("Session verified"); @@ -71,16 +83,29 @@ export default class CompleteSecurity extends React.Component { } else if (phase === Phase.Busy) { icon = ; title = _t("Verify this login"); + } else if (phase === Phase.ConfirmReset) { + icon = ; + title = _t("Really reset verification keys?"); + } else if (phase === Phase.Finished) { + // SetupEncryptionBody will take care of calling onFinished, we don't need to do anything } else { throw new Error(`Unknown phase ${phase}`); } + let skipButton; + if (phase === Phase.Intro || phase === Phase.ConfirmReset) { + skipButton = ( + + ); + } + return (

{ icon } { title } + { skipButton }

diff --git a/src/components/structures/auth/SetupEncryptionBody.tsx b/src/components/structures/auth/SetupEncryptionBody.tsx index 87d74a5a79..e2b1aebcfd 100644 --- a/src/components/structures/auth/SetupEncryptionBody.tsx +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -46,6 +46,7 @@ interface IState { phase: Phase; verificationRequest: VerificationRequest; backupInfo: IKeyBackupInfo; + lostKeys: boolean; } @replaceableComponent("structures.auth.SetupEncryptionBody") @@ -62,6 +63,7 @@ export default class SetupEncryptionBody extends React.Component // Because of the latter, it lives in the state. verificationRequest: store.verificationRequest, backupInfo: store.backupInfo, + lostKeys: store.lostKeys(), }; } @@ -75,6 +77,7 @@ export default class SetupEncryptionBody extends React.Component phase: store.phase, verificationRequest: store.verificationRequest, backupInfo: store.backupInfo, + lostKeys: store.lostKeys(), }); }; @@ -105,11 +108,6 @@ export default class SetupEncryptionBody extends React.Component }); }; - private onSkipClick = () => { - const store = SetupEncryptionStore.sharedInstance(); - store.skip(); - }; - private onSkipConfirmClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skipConfirm(); @@ -120,6 +118,22 @@ export default class SetupEncryptionBody extends React.Component store.returnAfterSkip(); }; + private onResetClick = (ev: React.MouseEvent) => { + ev.preventDefault(); + const store = SetupEncryptionStore.sharedInstance(); + store.reset(); + }; + + private onResetConfirmClick = () => { + const store = SetupEncryptionStore.sharedInstance(); + store.resetConfirm(); + }; + + private onResetBackClick = () => { + const store = SetupEncryptionStore.sharedInstance(); + store.returnAfterReset(); + }; + private onDoneClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.done(); @@ -132,6 +146,7 @@ export default class SetupEncryptionBody extends React.Component public render() { const { phase, + lostKeys, } = this.state; if (this.state.verificationRequest) { @@ -143,43 +158,67 @@ export default class SetupEncryptionBody extends React.Component isRoomEncrypted={false} />; } else if (phase === Phase.Intro) { - const store = SetupEncryptionStore.sharedInstance(); - let recoveryKeyPrompt; - if (store.keyInfo && keyHasPassphrase(store.keyInfo)) { - recoveryKeyPrompt = _t("Use Security Key or Phrase"); - } else if (store.keyInfo) { - recoveryKeyPrompt = _t("Use Security Key"); - } + if (lostKeys) { + return ( +
+

{ _t( + "It looks like you don't have a Security Key or any other devices you can " + + "verify against. This device will not be able to access old encrypted messages. " + + "In order to verify your identity on this device, you'll need to reset " + + "your verification keys.", + ) }

- let useRecoveryKeyButton; - if (recoveryKeyPrompt) { - useRecoveryKeyButton = - { recoveryKeyPrompt } - ; - } - - let verifyButton; - if (store.hasDevicesToVerifyAgainst) { - verifyButton = - { _t("Use another login") } - ; - } - - return ( -
-

{ _t( - "Verify your identity to access encrypted messages and prove your identity to others.", - ) }

- -
- { verifyButton } - { useRecoveryKeyButton } - - { _t("Skip") } - +
+ + { _t("Proceed with reset") } + +
-
- ); + ); + } else { + const store = SetupEncryptionStore.sharedInstance(); + let recoveryKeyPrompt; + if (store.keyInfo && keyHasPassphrase(store.keyInfo)) { + recoveryKeyPrompt = _t("Verify with Security Key or Phrase"); + } else if (store.keyInfo) { + recoveryKeyPrompt = _t("Verify with Security Key"); + } + + let useRecoveryKeyButton; + if (recoveryKeyPrompt) { + useRecoveryKeyButton = + { recoveryKeyPrompt } + ; + } + + let verifyButton; + if (store.hasDevicesToVerifyAgainst) { + verifyButton = + { _t("Verify with another login") } + ; + } + + return ( +
+

{ _t( + "Verify your identity to access encrypted messages and prove your identity to others.", + ) }

+ +
+ { verifyButton } + { useRecoveryKeyButton } +
+
+ { _t("Forgotten or lost all recovery methods? Reset all", null, { + a: (sub) => { sub }, + }) } +
+
+ ); + } } else if (phase === Phase.Done) { let message; if (this.state.backupInfo) { @@ -215,14 +254,13 @@ export default class SetupEncryptionBody extends React.Component ) }

- { _t("Skip") } + { _t("I'll verify later") } { _t("Go Back") } @@ -230,6 +268,30 @@ export default class SetupEncryptionBody extends React.Component
); + } else if (phase === Phase.ConfirmReset) { + return ( +
+

{ _t( + "Resetting your verification keys cannot be undone. After resetting, " + + "you won't have access to old encrypted messages, and any friends who " + + "have previously verified you will see security warnings until you " + + "re-verify with them.", + ) }

+

{ _t( + "Please only proceed if you're sure you've lost all of your other " + + "devices and your security key.", + ) }

+ +
+ + { _t("Proceed with reset") } + + + { _t("Go Back") } + +
+
+ ); } else if (phase === Phase.Busy || phase === Phase.Loading) { return ; } else { diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 3c734705b7..001df16d40 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -33,7 +33,7 @@ interface IProps extends Omit, "name" | resizeMethod?: ResizeMethod; // The onClick to give the avatar onClick?: React.MouseEventHandler; - // Whether the onClick of the avatar should be overriden to dispatch `Action.ViewUser` + // Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser` viewUserOnClick?: boolean; title?: string; style?: any; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index c7fcf32260..22dd3ac438 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -34,8 +34,7 @@ import ForwardDialog from "../dialogs/ForwardDialog"; import { Action } from "../../../dispatcher/actions"; import ReportEventDialog from '../dialogs/ReportEventDialog'; import ViewSource from '../../structures/ViewSource'; -import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog'; -import ErrorDialog from '../dialogs/ErrorDialog'; +import { createRedactEventDialog } from '../dialogs/ConfirmRedactDialog'; import ShareDialog from '../dialogs/ShareDialog'; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { IPosition, ChevronFace } from '../../structures/ContextMenu'; @@ -140,34 +139,11 @@ export default class MessageContextMenu extends React.Component }; private onRedactClick = (): void => { - Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed: boolean, reason?: string) => { - if (!proceed) return; - - const cli = MatrixClientPeg.get(); - try { - this.props.onCloseDialog?.(); - await cli.redactEvent( - this.props.mxEvent.getRoomId(), - this.props.mxEvent.getId(), - undefined, - reason ? { reason } : {}, - ); - } catch (e) { - const code = e.errcode || e.statusCode; - // only show the dialog if failing for something other than a network error - // (e.g. no errcode or statusCode) as in that case the redactions end up in the - // detached queue and we show the room status bar to allow retry - if (typeof code !== "undefined") { - // display error message stating you couldn't delete this. - Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { - title: _t('Error'), - description: _t('You cannot delete this message. (%(code)s)', { code }), - }); - } - } - }, - }, 'mx_Dialog_confirmredact'); + const { mxEvent, onCloseDialog } = this.props; + createRedactEventDialog({ + mxEvent, + onCloseDialog, + }); this.closeMenu(); }; diff --git a/src/components/views/dialogs/ConfirmRedactDialog.tsx b/src/components/views/dialogs/ConfirmRedactDialog.tsx index b346d2d44c..74b3320fdf 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.tsx +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -14,9 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import React from 'react'; import { _t } from '../../../languageHandler'; +import { MatrixClientPeg } from '../../../MatrixClientPeg'; +import Modal from '../../../Modal'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ErrorDialog from './ErrorDialog'; import TextInputDialog from "./TextInputDialog"; interface IProps { @@ -42,3 +46,40 @@ export default class ConfirmRedactDialog extends React.Component { ); } } + +export function createRedactEventDialog({ + mxEvent, + onCloseDialog = () => {}, +}: { + mxEvent: MatrixEvent; + onCloseDialog?: () => void; +}) { + Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { + onFinished: async (proceed: boolean, reason?: string) => { + if (!proceed) return; + + const cli = MatrixClientPeg.get(); + try { + onCloseDialog?.(); + await cli.redactEvent( + mxEvent.getRoomId(), + mxEvent.getId(), + undefined, + reason ? { reason } : {}, + ); + } catch (e) { + const code = e.errcode || e.statusCode; + // only show the dialog if failing for something other than a network error + // (e.g. no errcode or statusCode) as in that case the redactions end up in the + // detached queue and we show the room status bar to allow retry + if (typeof code !== "undefined") { + // display error message stating you couldn't delete this. + Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { + title: _t('Error'), + description: _t('You cannot delete this message. (%(code)s)', { code }), + }); + } + } + }, + }, 'mx_Dialog_confirmredact'); +} diff --git a/src/components/views/dialogs/ExportDialog.tsx b/src/components/views/dialogs/ExportDialog.tsx new file mode 100644 index 0000000000..33a14887fb --- /dev/null +++ b/src/components/views/dialogs/ExportDialog.tsx @@ -0,0 +1,397 @@ +/* +Copyright 2021 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useRef, useState } from "react"; +import { Room } from "matrix-js-sdk/src"; +import { _t } from "../../../languageHandler"; +import { IDialogProps } from "./IDialogProps"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import Field from "../elements/Field"; +import StyledRadioGroup from "../elements/StyledRadioGroup"; +import StyledCheckbox from "../elements/StyledCheckbox"; +import { + ExportFormat, + ExportType, + textForFormat, + textForType, +} from "../../../utils/exportUtils/exportUtils"; +import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; +import HTMLExporter from "../../../utils/exportUtils/HtmlExport"; +import JSONExporter from "../../../utils/exportUtils/JSONExport"; +import PlainTextExporter from "../../../utils/exportUtils/PlainTextExport"; +import { useStateCallback } from "../../../hooks/useStateCallback"; +import Exporter from "../../../utils/exportUtils/Exporter"; +import Spinner from "../elements/Spinner"; +import InfoDialog from "./InfoDialog"; + +interface IProps extends IDialogProps { + room: Room; +} + +const ExportDialog: React.FC = ({ room, onFinished }) => { + const [exportFormat, setExportFormat] = useState(ExportFormat.Html); + const [exportType, setExportType] = useState(ExportType.Timeline); + const [includeAttachments, setAttachments] = useState(false); + const [isExporting, setExporting] = useState(false); + const [numberOfMessages, setNumberOfMessages] = useState(100); + const [sizeLimit, setSizeLimit] = useState(8); + const sizeLimitRef = useRef(); + const messageCountRef = useRef(); + const [exportProgressText, setExportProgressText] = useState("Processing..."); + const [displayCancel, setCancelWarning] = useState(false); + const [exportCancelled, setExportCancelled] = useState(false); + const [exportSuccessful, setExportSuccessful] = useState(false); + const [exporter, setExporter] = useStateCallback( + null, + async (exporter: Exporter) => { + await exporter?.export().then(() => { + if (!exportCancelled) setExportSuccessful(true); + }); + }, + ); + + const startExport = async () => { + const exportOptions = { + numberOfMessages, + attachmentsIncluded: includeAttachments, + maxSize: sizeLimit * 1024 * 1024, + }; + switch (exportFormat) { + case ExportFormat.Html: + setExporter( + new HTMLExporter( + room, + ExportType[exportType], + exportOptions, + setExportProgressText, + ), + ); + break; + case ExportFormat.Json: + setExporter( + new JSONExporter( + room, + ExportType[exportType], + exportOptions, + setExportProgressText, + ), + ); + break; + case ExportFormat.PlainText: + setExporter( + new PlainTextExporter( + room, + ExportType[exportType], + exportOptions, + setExportProgressText, + ), + ); + break; + default: + console.error("Unknown export format"); + return; + } + }; + + const onExportClick = async () => { + const isValidSize = await sizeLimitRef.current.validate({ + focused: false, + }); + if (!isValidSize) { + sizeLimitRef.current.validate({ focused: true }); + return; + } + if (exportType === ExportType.LastNMessages) { + const isValidNumberOfMessages = + await messageCountRef.current.validate({ focused: false }); + if (!isValidNumberOfMessages) { + messageCountRef.current.validate({ focused: true }); + return; + } + } + setExporting(true); + await startExport(); + }; + + const validateSize = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t("Enter a number between %(min)s and %(max)s", { + min, + max, + }); + }, + }, { + key: "number", + test: ({ value }) => { + const parsedSize = parseFloat(value); + const min = 1; + const max = 2000; + return !(isNaN(parsedSize) || min > parsedSize || parsedSize > max); + }, + invalid: () => { + const min = 1; + const max = 2000; + return _t( + "Size can only be a number between %(min)s MB and %(max)s MB", + { min, max }, + ); + }, + }, + ], + }); + + const onValidateSize = async (fieldState: IFieldState): Promise => { + const result = await validateSize(fieldState); + return result; + }; + + const validateNumberOfMessages = withValidation({ + rules: [ + { + key: "required", + test({ value, allowEmpty }) { + return allowEmpty || !!value; + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t("Enter a number between %(min)s and %(max)s", { + min, + max, + }); + }, + }, { + key: "number", + test: ({ value }) => { + const parsedSize = parseFloat(value); + const min = 1; + const max = 10 ** 8; + if (isNaN(parsedSize)) return false; + return !(min > parsedSize || parsedSize > max); + }, + invalid: () => { + const min = 1; + const max = 10 ** 8; + return _t( + "Number of messages can only be a number between %(min)s and %(max)s", + { min, max }, + ); + }, + }, + ], + }); + + const onValidateNumberOfMessages = async (fieldState: IFieldState): Promise => { + const result = await validateNumberOfMessages(fieldState); + return result; + }; + + const onCancel = async () => { + if (isExporting) setCancelWarning(true); + else onFinished(false); + }; + + const confirmCanel = async () => { + await exporter?.cancelExport(); + setExportCancelled(true); + setExporting(false); + setExporter(null); + }; + + const exportFormatOptions = Object.keys(ExportFormat).map((format) => ({ + value: ExportFormat[format], + label: textForFormat(ExportFormat[format]), + })); + + const exportTypeOptions = Object.keys(ExportType).map((type) => { + return ( + + ); + }); + + let messageCount = null; + if (exportType === ExportType.LastNMessages) { + messageCount = ( + { + setNumberOfMessages(parseInt(e.target.value)); + }} + /> + ); + } + + const sizePostFix = { _t("MB") }; + + if (exportCancelled) { + // Display successful cancellation message + return ( + + ); + } else if (exportSuccessful) { + // Display successful export message + return ( + + ); + } else if (displayCancel) { + // Display cancel warning + return ( + +

+ { _t( + "Are you sure you want to stop exporting your data? If you do, you'll need to start over.", + ) } +

+ setCancelWarning(false)} + onPrimaryButtonClick={confirmCanel} + /> +
+ ); + } else { + // Display export settings + return ( + + { !isExporting ?

+ { _t( + "Select from the options below to export chats from your timeline", + ) } +

: null } + + + { _t("Format") } + + +
+ setExportFormat(ExportFormat[key])} + definitions={exportFormatOptions} + /> + + + { _t("Messages") } + + + { + setExportType(ExportType[e.target.value]); + }} + > + { exportTypeOptions } + + { messageCount } + + + { _t("Size Limit") } + + + setSizeLimit(parseInt(e.target.value))} + /> + + + setAttachments( + (e.target as HTMLInputElement).checked, + ) + } + > + { _t("Include Attachments") } + +
+ { isExporting ? ( +
+ +

+ { exportProgressText } +

+ +
+ ) : ( + onFinished(false)} + /> + ) } +
+ ); + } +}; + +export default ExportDialog; diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index b4f382c9c3..86e0822b11 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -178,6 +178,14 @@ export default class Dropdown extends React.Component { this.ignoreEvent = ev; }; + private onChevronClick = (ev: React.MouseEvent) => { + if (this.state.expanded) { + this.setState({ expanded: false }); + ev.stopPropagation(); + ev.preventDefault(); + } + }; + private onAccessibleButtonClick = (ev: ButtonEvent) => { if (this.props.disabled) return; @@ -375,7 +383,7 @@ export default class Dropdown extends React.Component { onKeyDown={this.onKeyDown} > { currentValue } - + { menu }
; diff --git a/src/components/views/elements/ReplyThread.tsx b/src/components/views/elements/ReplyThread.tsx index bd81218623..b5e2d1191c 100644 --- a/src/components/views/elements/ReplyThread.tsx +++ b/src/components/views/elements/ReplyThread.tsx @@ -53,6 +53,7 @@ interface IProps { layout?: Layout; // Whether to always show a timestamp alwaysShowTimestamps?: boolean; + forExport?: boolean; isQuoteExpanded?: boolean; setQuoteExpanded: (isExpanded: boolean) => void; } @@ -381,6 +382,17 @@ export default class ReplyThread extends React.Component { }) } ; + } else if (this.props.forExport) { + const eventId = ReplyThread.getParentEventId(this.props.parentEv); + header =

+ { _t("In reply to this message", + {}, + { a: (sub) => ( + { sub } + ), + }) + } +

; } else if (this.state.loading) { header = ; } diff --git a/src/components/views/messages/DateSeparator.tsx b/src/components/views/messages/DateSeparator.tsx index 5d43e2182d..d66fcbf118 100644 --- a/src/components/views/messages/DateSeparator.tsx +++ b/src/components/views/messages/DateSeparator.tsx @@ -35,12 +35,17 @@ function getDaysArray(): string[] { interface IProps { ts: number; + forExport?: boolean; } @replaceableComponent("views.messages.DateSeparator") export default class DateSeparator extends React.Component { private getLabel() { const date = new Date(this.props.ts); + + // During the time the archive is being viewed, a specific day might not make sense, so we return the full date + if (this.props.forExport) return formatFullDateNoTime(date); + const today = new Date(); const yesterday = new Date(); const days = getDaysArray(); diff --git a/src/components/views/messages/IBodyProps.ts b/src/components/views/messages/IBodyProps.ts index 8aabd3080c..daa05c3b1a 100644 --- a/src/components/views/messages/IBodyProps.ts +++ b/src/components/views/messages/IBodyProps.ts @@ -33,6 +33,7 @@ export interface IBodyProps { onHeightChanged: () => void; showUrlPreview?: boolean; + forExport?: boolean; tileShape: TileShape; maxImageHeight?: number; replacingEventId?: string; diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx index 1975fe8d42..3611435e55 100644 --- a/src/components/views/messages/MAudioBody.tsx +++ b/src/components/views/messages/MAudioBody.tsx @@ -90,6 +90,17 @@ export default class MAudioBody extends React.PureComponent ); } + if (this.props.forExport) { + const content = this.props.mxEvent.getContent(); + // During export, the content url will point to the MSC, which will later point to a local url + const contentUrl = content.file?.url || content.url; + return ( + + + ); + } + if (!this.state.playback) { return ( diff --git a/src/components/views/messages/MFileBody.tsx b/src/components/views/messages/MFileBody.tsx index c997aa6666..80c6b16f0d 100644 --- a/src/components/views/messages/MFileBody.tsx +++ b/src/components/views/messages/MFileBody.tsx @@ -123,6 +123,11 @@ export default class MFileBody extends React.Component { this.state = {}; } + private getContentUrl(): string | null { + if (this.props.forExport) return null; + const media = mediaFromContent(this.props.mxEvent.getContent()); + return media.srcHttp; + } private get content(): IMediaEventContent { return this.props.mxEvent.getContent(); } @@ -149,11 +154,6 @@ export default class MFileBody extends React.Component { }); } - private getContentUrl(): string { - const media = mediaFromContent(this.props.mxEvent.getContent()); - return media.srcHttp; - } - public componentDidUpdate(prevProps, prevState) { if (this.props.onHeightChanged && !prevState.decryptedBlob && this.state.decryptedBlob) { this.props.onHeightChanged(); @@ -213,6 +213,16 @@ export default class MFileBody extends React.Component { ); } + if (this.props.forExport) { + const content = this.props.mxEvent.getContent(); + // During export, the content url will point to the MSC, which will later point to a local url + return + + { placeholder } + + ; + } + const showDownloadLink = this.props.tileShape || !this.props.showGenericPlaceholder; if (isEncrypted) { diff --git a/src/components/views/messages/MImageBody.tsx b/src/components/views/messages/MImageBody.tsx index 01bbf3403f..072e111c4b 100644 --- a/src/components/views/messages/MImageBody.tsx +++ b/src/components/views/messages/MImageBody.tsx @@ -179,6 +179,9 @@ export default class MImageBody extends React.Component { }; protected getContentUrl(): string { + const content: IMediaEventContent = this.props.mxEvent.getContent(); + // During export, the content url will point to the MSC, which will later point to a local url + if (this.props.forExport) return content.url || content.file?.url; if (this.media.isEncrypted) { return this.state.decryptedUrl; } else { @@ -372,7 +375,7 @@ export default class MImageBody extends React.Component { let placeholder = null; let gifLabel = null; - if (!this.state.imgLoaded) { + if (!this.props.forExport && !this.state.imgLoaded) { placeholder = this.getPlaceholder(maxWidth, maxHeight); } @@ -462,7 +465,7 @@ export default class MImageBody extends React.Component { // Overidden by MStickerBody protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element { - return + return { children } ; } @@ -490,6 +493,7 @@ export default class MImageBody extends React.Component { // Overidden by MStickerBody protected getFileBody(): string | JSX.Element { + if (this.props.forExport) return null; // We only ever need the download bar if we're appearing outside of the timeline if (this.props.tileShape) { return ; @@ -510,7 +514,7 @@ export default class MImageBody extends React.Component { const contentUrl = this.getContentUrl(); let thumbUrl; - if (this.isGif() && SettingsStore.getValue("autoplayGifs")) { + if (this.props.forExport || (this.isGif() && SettingsStore.getValue("autoplayGifs"))) { thumbUrl = contentUrl; } else { thumbUrl = this.getThumbUrl(); diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index 5af1d7d9f5..d119662f8a 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -79,7 +79,10 @@ export default class MVideoBody extends React.PureComponent } private getContentUrl(): string|null { - const media = mediaFromContent(this.props.mxEvent.getContent()); + const content = this.props.mxEvent.getContent(); + // During export, the content url will point to the MSC, which will later point to a local url + if (this.props.forExport) return content.file?.url || content.url; + const media = mediaFromContent(content); if (media.isEncrypted) { return this.state.decryptedUrl; } else { @@ -93,6 +96,9 @@ export default class MVideoBody extends React.PureComponent } private getThumbUrl(): string|null { + // there's no need of thumbnail when the content is local + if (this.props.forExport) return null; + const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); @@ -209,6 +215,11 @@ export default class MVideoBody extends React.PureComponent this.props.onHeightChanged(); }; + private getFileBody = () => { + if (this.props.forExport) return null; + return this.props.tileShape && ; + }; + render() { const content = this.props.mxEvent.getContent(); const autoplay = SettingsStore.getValue("autoplayVideo"); @@ -222,8 +233,8 @@ export default class MVideoBody extends React.PureComponent ); } - // Important: If we aren't autoplaying and we haven't decrypred it yet, show a video with a poster. - if (content.file !== undefined && this.state.decryptedUrl === null && autoplay) { + // Important: If we aren't autoplaying and we haven't decrypted it yet, show a video with a poster. + if (!this.props.forExport && content.file !== undefined && this.state.decryptedUrl === null && autoplay) { // Need to decrypt the attachment // The attachment is decrypted in componentDidMount. // For now add an img tag with a spinner. @@ -254,6 +265,8 @@ export default class MVideoBody extends React.PureComponent preload = "none"; } } + + const fileBody = this.getFileBody(); return ( ); } diff --git a/src/components/views/messages/MVoiceOrAudioBody.tsx b/src/components/views/messages/MVoiceOrAudioBody.tsx index 5a7e34b8a1..b4dce5d1aa 100644 --- a/src/components/views/messages/MVoiceOrAudioBody.tsx +++ b/src/components/views/messages/MVoiceOrAudioBody.tsx @@ -24,7 +24,7 @@ import { isVoiceMessage } from "../../../utils/EventUtils"; @replaceableComponent("views.messages.MVoiceOrAudioBody") export default class MVoiceOrAudioBody extends React.PureComponent { public render() { - if (isVoiceMessage(this.props.mxEvent)) { + if (!this.props.forExport && isVoiceMessage(this.props.mxEvent)) { return ; } else { return ; diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index e835584387..7ee951a812 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -27,7 +27,7 @@ import { Action } from '../../../dispatcher/actions'; import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; -import RoomContext from "../../../contexts/RoomContext"; +import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import Toolbar from "../../../accessibility/Toolbar"; import { RovingAccessibleTooltipButton, useRovingTabIndex } from "../../../accessibility/RovingTabIndex"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -135,9 +135,9 @@ interface IMessageActionBarProps { getTile: () => any | null; getReplyThread: () => ReplyThread | undefined; permalinkCreator?: RoomPermalinkCreator; - onFocusChange: (menuDisplayed: boolean) => void; - isQuoteExpanded?: boolean; + onFocusChange?: (menuDisplayed: boolean) => void; toggleThreadExpanded: () => void; + isQuoteExpanded?: boolean; } @replaceableComponent("views.messages.MessageActionBar") @@ -207,8 +207,9 @@ export default class MessageActionBar extends React.PureComponent { dis.dispatch({ - action: 'edit_event', + action: Action.EditEvent, event: this.props.mxEvent, + timelineRenderingType: this.context.timelineRenderingType, }); }; @@ -288,7 +289,7 @@ export default class MessageActionBar extends React.PureComponent implements IMe highlightLink={this.props.highlightLink} showUrlPreview={this.props.showUrlPreview} tileShape={this.props.tileShape} + forExport={this.props.forExport} maxImageHeight={this.props.maxImageHeight} replacingEventId={this.props.replacingEventId} editState={this.props.editState} diff --git a/src/components/views/messages/RedactedBody.tsx b/src/components/views/messages/RedactedBody.tsx index 66200036cd..9af4ebf1cb 100644 --- a/src/components/views/messages/RedactedBody.tsx +++ b/src/components/views/messages/RedactedBody.tsx @@ -29,7 +29,6 @@ interface IProps { const RedactedBody = React.forwardRef(({ mxEvent }, ref) => { const cli: MatrixClient = useContext(MatrixClientContext); - let text = _t("Message deleted"); const unsigned = mxEvent.getUnsigned(); const redactedBecauseUserId = unsigned && unsigned.redacted_because && unsigned.redacted_because.sender; diff --git a/src/components/views/right_panel/EncryptionInfo.tsx b/src/components/views/right_panel/EncryptionInfo.tsx index 34aeb8b88a..c9a4b3b84c 100644 --- a/src/components/views/right_panel/EncryptionInfo.tsx +++ b/src/components/views/right_panel/EncryptionInfo.tsx @@ -49,16 +49,18 @@ const EncryptionInfo: React.FC = ({ isSelfVerification, }: IProps) => { let content: JSX.Element; - if (waitingForOtherParty || waitingForNetwork) { + if (waitingForOtherParty && isSelfVerification) { + content = ( +
+ { _t("To proceed, please accept the verification request on your other login.") } +
+ ); + } else if (waitingForOtherParty || waitingForNetwork) { let text: string; if (waitingForOtherParty) { - if (isSelfVerification) { - text = _t("Accept on your other login…"); - } else { - text = _t("Waiting for %(displayName)s to accept…", { - displayName: (member as User).displayName || (member as RoomMember).name || member.userId, - }); - } + text = _t("Waiting for %(displayName)s to accept…", { + displayName: (member as User).displayName || (member as RoomMember).name || member.userId, + }); } else { text = _t("Accepting…"); } diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index 00d52831c7..1fe556dde2 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -47,6 +47,7 @@ import { useRoomMemberCount } from "../../../hooks/useRoomMembers"; import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import RoomName from "../elements/RoomName"; import UIStore from "../../../stores/UIStore"; +import ExportDialog from "../dialogs/ExportDialog"; interface IProps { room: Room; @@ -240,6 +241,12 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { }); }; + const onRoomExportClick = async () => { + Modal.createTrackedDialog('export room dialog', '', ExportDialog, { + room, + }); + }; + const isRoomEncrypted = useIsEncrypted(cli, room); const roomContext = useContext(RoomContext); const e2eStatus = roomContext.e2eStatus; @@ -280,6 +287,9 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { + { SettingsStore.getValue("feature_thread") && (