diff --git a/.gitignore b/.gitignore index e1dd7726e1..50aa10fbfd 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /*.log package-lock.json +/coverage /node_modules /lib diff --git a/CHANGELOG.md b/CHANGELOG.md index ec73756ff9..d459b4e94a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,121 @@ +Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) + + * Upgrade to JS SDK 10.0.0 + * [Release] Dynamic max and min zoom in the new ImageView + [\#5927](https://github.com/matrix-org/matrix-react-sdk/pull/5927) + * [Release] Add a WheelEvent normalization function + [\#5911](https://github.com/matrix-org/matrix-react-sdk/pull/5911) + * Add a WheelEvent normalization function + [\#5904](https://github.com/matrix-org/matrix-react-sdk/pull/5904) + * [Release] Use floats for image background opacity + [\#5907](https://github.com/matrix-org/matrix-react-sdk/pull/5907) + +Changes in [3.19.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0-rc.1) (2021-04-21) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0...v3.19.0-rc.1) + + * Upgrade to JS SDK 10.0.0-rc.1 + * Translations update from Weblate + [\#5896](https://github.com/matrix-org/matrix-react-sdk/pull/5896) + * Fix sticky tags header in room list + [\#5895](https://github.com/matrix-org/matrix-react-sdk/pull/5895) + * Fix spaces filtering sometimes lagging behind or behaving oddly + [\#5893](https://github.com/matrix-org/matrix-react-sdk/pull/5893) + * Fix issue with spaces context switching looping and breaking + [\#5894](https://github.com/matrix-org/matrix-react-sdk/pull/5894) + * Improve RoomList render time when filtering + [\#5874](https://github.com/matrix-org/matrix-react-sdk/pull/5874) + * Avoid being stuck in a space + [\#5891](https://github.com/matrix-org/matrix-react-sdk/pull/5891) + * [Spaces] Context switching + [\#5795](https://github.com/matrix-org/matrix-react-sdk/pull/5795) + * Warn when you attempt to leave room that you are the only member of + [\#5415](https://github.com/matrix-org/matrix-react-sdk/pull/5415) + * Ensure PersistedElement are unmounted on application logout + [\#5884](https://github.com/matrix-org/matrix-react-sdk/pull/5884) + * Add missing space in seshat dialog and the corresponding string + [\#5866](https://github.com/matrix-org/matrix-react-sdk/pull/5866) + * A tiny change to make the Add existing rooms dialog a little nicer + [\#5885](https://github.com/matrix-org/matrix-react-sdk/pull/5885) + * Remove weird margin from the file panel + [\#5889](https://github.com/matrix-org/matrix-react-sdk/pull/5889) + * Trigger lazy loading when filtering using spaces + [\#5882](https://github.com/matrix-org/matrix-react-sdk/pull/5882) + * Fix typo in method call in add existing to space dialog + [\#5883](https://github.com/matrix-org/matrix-react-sdk/pull/5883) + * New Image View fixes/improvements + [\#5872](https://github.com/matrix-org/matrix-react-sdk/pull/5872) + * Limit voice recording length + [\#5871](https://github.com/matrix-org/matrix-react-sdk/pull/5871) + * Clean up add existing to space dialog and include DMs in it too + [\#5881](https://github.com/matrix-org/matrix-react-sdk/pull/5881) + * Fix unknown slash command error exploding + [\#5853](https://github.com/matrix-org/matrix-react-sdk/pull/5853) + * Switch to a spec conforming email validation Regexp + [\#5852](https://github.com/matrix-org/matrix-react-sdk/pull/5852) + * Cleanup unused state in MessageComposer + [\#5877](https://github.com/matrix-org/matrix-react-sdk/pull/5877) + * Pulse animation for voice messages recording state + [\#5869](https://github.com/matrix-org/matrix-react-sdk/pull/5869) + * Don't include invisible rooms in notify summary + [\#5875](https://github.com/matrix-org/matrix-react-sdk/pull/5875) + * Properly disable composer access when recording a voice message + [\#5870](https://github.com/matrix-org/matrix-react-sdk/pull/5870) + * Stabilise starting a DM with multiple people flow + [\#5862](https://github.com/matrix-org/matrix-react-sdk/pull/5862) + * Render msgOption only if showReadReceipts is enabled + [\#5864](https://github.com/matrix-org/matrix-react-sdk/pull/5864) + * Labs: Add quick/cheap "do not disturb" flag + [\#5873](https://github.com/matrix-org/matrix-react-sdk/pull/5873) + * Fix ReadReceipts animations + [\#5836](https://github.com/matrix-org/matrix-react-sdk/pull/5836) + * Add tooltips to message previews + [\#5859](https://github.com/matrix-org/matrix-react-sdk/pull/5859) + * IRC Layout fix layout spacing in replies + [\#5855](https://github.com/matrix-org/matrix-react-sdk/pull/5855) + * Move user to welcome_page if continuing with previous session + [\#5849](https://github.com/matrix-org/matrix-react-sdk/pull/5849) + * Improve image view + [\#5521](https://github.com/matrix-org/matrix-react-sdk/pull/5521) + * Add a button to reset personal encryption state during login + [\#5819](https://github.com/matrix-org/matrix-react-sdk/pull/5819) + * Fix js-sdk import in SlashCommands + [\#5850](https://github.com/matrix-org/matrix-react-sdk/pull/5850) + * Fix useRoomPowerLevels hook + [\#5854](https://github.com/matrix-org/matrix-react-sdk/pull/5854) + * Prevent state events being rendered with invalid state keys + [\#5851](https://github.com/matrix-org/matrix-react-sdk/pull/5851) + * Give server ACLs a name in 'roles & permissions' tab + [\#5838](https://github.com/matrix-org/matrix-react-sdk/pull/5838) + * Don't hide notification badge on the home space button as it has no menu + [\#5845](https://github.com/matrix-org/matrix-react-sdk/pull/5845) + * User Info hide disambiguation as we always show MXID anyway + [\#5843](https://github.com/matrix-org/matrix-react-sdk/pull/5843) + * Improve kick state to not show if the target was not joined to begin with + [\#5846](https://github.com/matrix-org/matrix-react-sdk/pull/5846) + * Fix space store wrongly switching to a non-space filter + [\#5844](https://github.com/matrix-org/matrix-react-sdk/pull/5844) + * Tweak appearance of invite reason + [\#5847](https://github.com/matrix-org/matrix-react-sdk/pull/5847) + * Update Inter font to v3.18 + [\#5840](https://github.com/matrix-org/matrix-react-sdk/pull/5840) + * Enable sharing historical keys on invite + [\#5839](https://github.com/matrix-org/matrix-react-sdk/pull/5839) + * Add ability to hide post-login encryption setup with customisation point + [\#5834](https://github.com/matrix-org/matrix-react-sdk/pull/5834) + * Use LaTeX and TeX delimiters by default + [\#5515](https://github.com/matrix-org/matrix-react-sdk/pull/5515) + * Clone author's deps fork for Netlify previews + [\#5837](https://github.com/matrix-org/matrix-react-sdk/pull/5837) + * Show drop file UI only if dragging a file + [\#5827](https://github.com/matrix-org/matrix-react-sdk/pull/5827) + * Ignore punctuation when filtering rooms + [\#5824](https://github.com/matrix-org/matrix-react-sdk/pull/5824) + * Resizable CallView + [\#5710](https://github.com/matrix-org/matrix-react-sdk/pull/5710) + Changes in [3.18.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.18.0) (2021-04-12) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.18.0-rc.1...v3.18.0) diff --git a/package.json b/package.json index 7c190c68bf..e54de8a96c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.18.0", + "version": "3.19.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -23,9 +23,7 @@ "package.json" ], "bin": { - "reskindex": "scripts/reskindex.js", - "matrix-gen-i18n": "scripts/gen-i18n.js", - "matrix-prune-i18n": "scripts/prune-i18n.js" + "reskindex": "scripts/reskindex.js" }, "main": "./src/index.js", "matrix_src_main": "./src/index.js", @@ -35,7 +33,7 @@ "prepublishOnly": "yarn build", "i18n": "matrix-gen-i18n", "prunei18n": "matrix-prune-i18n", - "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && ./scripts/gen-i18n.js && node scripts/compare-file.js src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", + "diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json", "reskindex": "node scripts/reskindex.js -h header", "reskindex:watch": "node scripts/reskindex.js -h header -w", "rethemendex": "res/css/rethemendex.sh", @@ -51,7 +49,8 @@ "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", - "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080" + "test:e2e": "./test/end-to-end-tests/run.sh --app-url http://localhost:8080", + "coverage": "yarn test --coverage" }, "dependencies": { "@babel/runtime": "^7.12.5", @@ -160,6 +159,7 @@ "jest-fetch-mock": "^3.0.3", "matrix-mock-request": "^1.2.3", "matrix-react-test-utils": "^0.2.2", + "matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz", "react-test-renderer": "^16.14.0", "rimraf": "^3.0.2", @@ -189,6 +189,12 @@ }, "transformIgnorePatterns": [ "/node_modules/(?!matrix-js-sdk).+$" + ], + "collectCoverageFrom": [ + "/src/**/*.{js,ts,tsx}" + ], + "coverageReporters": [ + "text" ] } } diff --git a/res/css/_components.scss b/res/css/_components.scss index 253f97bf42..0057f8a8fc 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -248,6 +248,8 @@ @import "./views/toasts/_AnalyticsToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/verification/_VerificationShowSas.scss"; +@import "./views/voice_messages/_PlayPauseButton.scss"; +@import "./views/voice_messages/_PlaybackContainer.scss"; @import "./views/voice_messages/_Waveform.scss"; @import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallView.scss"; diff --git a/res/css/structures/_RoomStatusBar.scss b/res/css/structures/_RoomStatusBar.scss index 5bf2aee3ae..8cc00aba0f 100644 --- a/res/css/structures/_RoomStatusBar.scss +++ b/res/css/structures/_RoomStatusBar.scss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_RoomStatusBar { +.mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { margin-left: 65px; min-height: 50px; } @@ -68,6 +68,99 @@ limitations under the License. min-height: 58px; } +.mx_RoomStatusBar_unsentMessages { + > div[role="alert"] { + // cheat some basic alignment + display: flex; + align-items: center; + min-height: 70px; + margin: 12px; + padding-left: 16px; + background-color: $header-panel-bg-color; + border-radius: 4px; + } + + .mx_RoomStatusBar_unsentBadge { + margin-right: 12px; + + .mx_NotificationBadge { + // Override sizing from the default badge + width: 24px !important; + height: 24px !important; + border-radius: 24px !important; + + .mx_NotificationBadge_count { + font-size: $font-16px !important; // override default + } + } + } + + .mx_RoomStatusBar_unsentTitle { + color: $warning-color; + font-size: $font-15px; + } + + .mx_RoomStatusBar_unsentDescription { + font-size: $font-12px; + } + + .mx_RoomStatusBar_unsentButtonBar { + flex-grow: 1; + text-align: right; + margin-right: 22px; + color: $muted-fg-color; + + .mx_AccessibleButton { + padding: 5px 10px; + padding-left: 28px; // 16px for the icon, 2px margin to text, 10px regular padding + display: inline-block; + position: relative; + + &:nth-child(2) { + border-left: 1px solid $resend-button-divider-color; + } + + &::before { + content: ''; + position: absolute; + left: 10px; // inset for regular button padding + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + } + + &.mx_RoomStatusBar_unsentCancelAllBtn::before { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); + width: 12px; + height: 16px; + top: calc(50% - 8px); // text sizes are dynamic + } + + &.mx_RoomStatusBar_unsentResendAllBtn { + padding-left: 34px; // 28px from above, but +6px to account for the wider icon + + &::before { + mask-image: url('$(res)/img/element-icons/retry.svg'); + width: 18px; + height: 18px; + top: calc(50% - 9px); // text sizes are dynamic + } + } + } + + .mx_InlineSpinner { + vertical-align: middle; + margin-right: 5px; + top: 1px; // just to help the vertical alignment be slightly better + + & + span { + margin-right: 10px; // same margin/padding as the rightmost button + } + } + } +} + .mx_RoomStatusBar_connectionLostBar img { padding-left: 10px; padding-right: 10px; @@ -103,7 +196,7 @@ limitations under the License. } .mx_MatrixChat_useCompactLayout { - .mx_RoomStatusBar { + .mx_RoomStatusBar:not(.mx_RoomStatusBar_unsentMessages) { min-height: 40px; } diff --git a/res/css/structures/_SpaceRoomDirectory.scss b/res/css/structures/_SpaceRoomDirectory.scss index dcceee6371..c7d087d8e0 100644 --- a/res/css/structures/_SpaceRoomDirectory.scss +++ b/res/css/structures/_SpaceRoomDirectory.scss @@ -26,7 +26,10 @@ limitations under the License. word-break: break-word; display: flex; flex-direction: column; +} +.mx_SpaceRoomDirectory, +.mx_SpaceRoomView_landing { .mx_Dialog_title { display: flex; @@ -56,65 +59,63 @@ limitations under the License. } } - .mx_Dialog_content { - .mx_AccessibleButton_kind_link { - padding: 0; - } + .mx_AccessibleButton_kind_link { + padding: 0; + } - .mx_SearchBox { - margin: 24px 0 16px; - } + .mx_SearchBox { + margin: 24px 0 16px; + } - .mx_SpaceRoomDirectory_noResults { - text-align: center; + .mx_SpaceRoomDirectory_noResults { + text-align: center; - > div { - font-size: $font-15px; - line-height: $font-24px; - color: $secondary-fg-color; - } - } - - .mx_SpaceRoomDirectory_listHeader { - display: flex; - min-height: 32px; - align-items: center; + > div { font-size: $font-15px; line-height: $font-24px; - color: $primary-fg-color; + color: $secondary-fg-color; + } + } - .mx_AccessibleButton { - padding: 2px 8px; - font-weight: normal; + .mx_SpaceRoomDirectory_listHeader { + display: flex; + min-height: 32px; + align-items: center; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-fg-color; - & + .mx_AccessibleButton { - margin-left: 16px; - } - } + .mx_AccessibleButton { + padding: 2px 8px; + font-weight: normal; - > span { - margin-left: auto; + & + .mx_AccessibleButton { + margin-left: 16px; } } - .mx_SpaceRoomDirectory_error { - position: relative; - font-weight: $font-semi-bold; - color: $notice-primary-color; - font-size: $font-15px; - line-height: $font-18px; - margin: 20px auto 12px; - padding-left: 24px; - width: max-content; + > span { + margin-left: auto; + } + } - &::before { - content: ""; - position: absolute; - height: 16px; - width: 16px; - left: 0; - background-image: url("$(res)/img/element-icons/warning-badge.svg"); - } + .mx_SpaceRoomDirectory_error { + position: relative; + font-weight: $font-semi-bold; + color: $notice-primary-color; + font-size: $font-15px; + line-height: $font-18px; + margin: 20px auto 12px; + padding-left: 24px; + width: max-content; + + &::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 0; + background-image: url("$(res)/img/element-icons/warning-badge.svg"); } } } diff --git a/res/css/views/elements/_ImageView.scss b/res/css/views/elements/_ImageView.scss index 93ebcc2d56..71035dadc3 100644 --- a/res/css/views/elements/_ImageView.scss +++ b/res/css/views/elements/_ImageView.scss @@ -31,8 +31,7 @@ limitations under the License. .mx_ImageView_image { pointer-events: all; - max-width: 95%; - max-height: 95%; + flex-shrink: 0; } .mx_ImageView_panel { diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 1254b496b5..3ecbef0d1f 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -105,3 +105,11 @@ limitations under the License. .mx_MessageActionBar_optionsButton::after { mask-image: url('$(res)/img/element-icons/context-menu.svg'); } + +.mx_MessageActionBar_resendButton::after { + mask-image: url('$(res)/img/element-icons/retry.svg'); +} + +.mx_MessageActionBar_cancelButton::after { + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 2b3e179c54..5d1dd04383 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -214,10 +214,6 @@ $left-gutter: 64px; color: $accent-fg-color; } -.mx_EventTile_notSent { - color: $event-notsent-color; -} - .mx_EventTile_receiptSent, .mx_EventTile_receiptSending { // We don't use `position: relative` on the element because then it won't line diff --git a/res/css/views/rooms/_VoiceRecordComposerTile.scss b/res/css/views/rooms/_VoiceRecordComposerTile.scss index 8100a03890..e99e1a00e1 100644 --- a/res/css/views/rooms/_VoiceRecordComposerTile.scss +++ b/res/css/views/rooms/_VoiceRecordComposerTile.scss @@ -35,44 +35,40 @@ limitations under the License. } } -.mx_VoiceRecordComposerTile_waveformContainer { - padding: 5px; - padding-right: 4px; // there's 1px from the waveform itself, so account for that - padding-left: 15px; // +10px for the live circle, +5px for regular padding - background-color: $voice-record-waveform-bg-color; - border-radius: 12px; - margin-right: 12px; // isolate from stop button +.mx_VoiceRecordComposerTile_delete { + width: 14px; // w&h are size of icon + height: 18px; + vertical-align: middle; + margin-right: 7px; // distance from left edge of waveform container (container has some margin too) + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/trashcan.svg'); +} - // Cheat at alignment a bit - display: flex; - align-items: center; +.mx_VoiceMessagePrimaryContainer { + // Note: remaining class properties are in the PlayerContainer CSS. + + margin: 6px; // force the composer area to put a gutter around us + margin-right: 12px; // isolate from stop button position: relative; // important for the live circle - color: $voice-record-waveform-fg-color; - font-size: $font-14px; + &.mx_VoiceRecordComposerTile_recording { + padding-left: 16px; // +10px for the live circle, +6px for regular padding - &::before { - animation: recording-pulse 2s infinite; + &::before { + animation: recording-pulse 2s infinite; - content: ''; - background-color: $voice-record-live-circle-color; - width: 10px; - height: 10px; - position: absolute; - left: 8px; - top: 16px; // vertically center - border-radius: 10px; - } - - .mx_Waveform_bar { - background-color: $voice-record-waveform-fg-color; - } - - .mx_Clock { - padding-right: 8px; // isolate from waveform - padding-left: 10px; // isolate from live circle - width: 42px; // we're not using a monospace font, so fake it + content: ''; + background-color: $voice-record-live-circle-color; + width: 10px; + height: 10px; + position: absolute; + left: 8px; + top: 16px; // vertically center + border-radius: 10px; + } } } diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/voice_messages/_PlayPauseButton.scss new file mode 100644 index 0000000000..c8ab162694 --- /dev/null +++ b/res/css/views/voice_messages/_PlayPauseButton.scss @@ -0,0 +1,51 @@ +/* +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_PlayPauseButton { + position: relative; + width: 32px; + height: 32px; + border-radius: 32px; + background-color: $primary-bg-color; + + &::before { + content: ''; + position: absolute; // sizing varies by icon + background-color: $muted-fg-color; + mask-repeat: no-repeat; + mask-size: contain; + } + + &.mx_PlayPauseButton_disabled::before { + opacity: 0.5; + } + + &.mx_PlayPauseButton_play::before { + width: 13px; + height: 16px; + top: 8px; // center + left: 12px; // center + mask-image: url('$(res)/img/element-icons/play.svg'); + } + + &.mx_PlayPauseButton_pause::before { + width: 10px; + height: 12px; + top: 10px; // center + left: 11px; // center + mask-image: url('$(res)/img/element-icons/pause.svg'); + } +} diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/voice_messages/_PlaybackContainer.scss new file mode 100644 index 0000000000..49bd81ef81 --- /dev/null +++ b/res/css/views/voice_messages/_PlaybackContainer.scss @@ -0,0 +1,54 @@ +/* +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. +*/ + +// Dev note: there's no actual component called . These classes +// are shared amongst multiple voice message components. + +// Container for live recording and playback controls +.mx_VoiceMessagePrimaryContainer { + padding: 6px; // makes us 4px taller than the send/stop button + padding-right: 5px; // there's 1px from the waveform itself, so account for that + background-color: $voice-record-waveform-bg-color; + border-radius: 12px; + + // Cheat at alignment a bit + display: flex; + align-items: center; + + color: $voice-record-waveform-fg-color; + font-size: $font-14px; + + .mx_Waveform { + // We want the bars to be 2px shorter than the play/pause button in the waveform control + height: 28px; // default is 30px, so we're subtracting the 2px border off the bars + + .mx_Waveform_bar { + background-color: $voice-record-waveform-incomplete-fg-color; + + &.mx_Waveform_bar_100pct { + // Small animation to remove the mechanical feel of progress + transition: background-color 250ms ease; + background-color: $voice-record-waveform-fg-color; + } + } + } + + .mx_Clock { + padding-right: 4px; // isolate from waveform + padding-left: 8px; // isolate from live circle + width: 40px; // we're not using a monospace font, so fake it + } +} diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index d13272c8c0..7292e325df 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -17,7 +17,7 @@ limitations under the License. .mx_CallView { border-radius: 8px; - background-color: $voipcall-plinth-color; + background-color: $dark-panel-bg-color; padding-left: 8px; padding-right: 8px; // XXX: CallContainer sets pointer-events: none - should probably be set back in a better place @@ -40,7 +40,8 @@ limitations under the License. width: 320px; padding-bottom: 8px; margin-top: 10px; - box-shadow: 0px 14px 24px rgba(0, 0, 0, 0.08); + background-color: $voipcall-plinth-color; + box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; .mx_CallView_voice { diff --git a/res/img/element-icons/pause.svg b/res/img/element-icons/pause.svg new file mode 100644 index 0000000000..293c0a10d8 --- /dev/null +++ b/res/img/element-icons/pause.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/play.svg b/res/img/element-icons/play.svg new file mode 100644 index 0000000000..339e20b729 --- /dev/null +++ b/res/img/element-icons/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/retry.svg b/res/img/element-icons/retry.svg new file mode 100644 index 0000000000..09448d6458 --- /dev/null +++ b/res/img/element-icons/retry.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/trashcan.svg b/res/img/element-icons/trashcan.svg new file mode 100644 index 0000000000..f8fb8b5c46 --- /dev/null +++ b/res/img/element-icons/trashcan.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 925d268eb0..f9983dce9e 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -63,6 +63,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: #b9bec64a; // muted-text with a 4A opacity. + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; @@ -110,7 +112,7 @@ $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #21262c; +$voipcall-plinth-color: #394049; // ******************** diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index 28e6e22326..194e89e548 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -61,6 +61,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: $bg-color; +$resend-button-divider-color: $muted-fg-color; + // scrollbars $scrollbar-thumb-color: rgba(255, 255, 255, 0.2); $scrollbar-track-color: transparent; @@ -107,7 +109,7 @@ $header-divider-color: $header-panel-text-primary-color; $composer-e2e-icon-color: $header-panel-text-primary-color; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #394049; // ******************** diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index 7b6bdad4a4..d7352e5684 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -97,6 +97,8 @@ $input-invalid-border-color: $warning-color; $field-focused-label-bg-color: #ffffff; +$resend-button-divider-color: $input-darker-bg-color; + $button-bg-color: $accent-color; $button-fg-color: white; @@ -174,7 +176,7 @@ $composer-e2e-icon-color: #91a1c0; $header-divider-color: #91a1c0; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #F4F6FA; // ******************** @@ -194,6 +196,7 @@ $voice-record-stop-border-color: #E3E8F0; $voice-record-stop-symbol-color: #ff4b55; $voice-record-waveform-bg-color: #E3E8F0; $voice-record-waveform-fg-color: $muted-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-live-circle-color: #ff4b55; $roomtile-preview-color: #9e9e9e; diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index 5b46138dae..20ccc2ee41 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -91,6 +91,8 @@ $field-focused-label-bg-color: #ffffff; $button-bg-color: $accent-color; $button-fg-color: white; +$resend-button-divider-color: $input-darker-bg-color; + // apart from login forms, which have stronger border $strong-input-border-color: #c7c7c7; @@ -165,7 +167,7 @@ $composer-e2e-icon-color: #91A1C0; $header-divider-color: #91A1C0; // this probably shouldn't have it's own colour -$voipcall-plinth-color: #f2f5f8; +$voipcall-plinth-color: #F4F6FA; // ******************** @@ -184,6 +186,7 @@ $voice-record-stop-border-color: #E3E8F0; $voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes $voice-record-waveform-bg-color: #E3E8F0; $voice-record-waveform-fg-color: $muted-fg-color; +$voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes $roomtile-preview-color: $secondary-fg-color; diff --git a/scripts/compare-file.js b/scripts/compare-file.js deleted file mode 100644 index f53275ebfa..0000000000 --- a/scripts/compare-file.js +++ /dev/null @@ -1,10 +0,0 @@ -const fs = require("fs"); - -if (process.argv.length < 4) throw new Error("Missing source and target file arguments"); - -const sourceFile = fs.readFileSync(process.argv[2], 'utf8'); -const targetFile = fs.readFileSync(process.argv[3], 'utf8'); - -if (sourceFile !== targetFile) { - throw new Error("Files do not match"); -} diff --git a/scripts/gen-i18n.js b/scripts/gen-i18n.js deleted file mode 100755 index 91733469f7..0000000000 --- a/scripts/gen-i18n.js +++ /dev/null @@ -1,304 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Regenerates the translations en_EN file by walking the source tree and - * parsing each file with the appropriate parser. Emits a JSON file with the - * translatable strings mapped to themselves in the order they appeared - * in the files and grouped by the file they appeared in. - * - * Usage: node scripts/gen-i18n.js - */ -const fs = require('fs'); -const path = require('path'); - -const walk = require('walk'); - -const parser = require("@babel/parser"); -const traverse = require("@babel/traverse"); - -const TRANSLATIONS_FUNCS = ['_t', '_td']; - -const INPUT_TRANSLATIONS_FILE = 'src/i18n/strings/en_EN.json'; -const OUTPUT_FILE = 'src/i18n/strings/en_EN.json'; - -// NB. The sync version of walk is broken for single files so we walk -// all of res rather than just res/home.html. -// https://git.daplie.com/Daplie/node-walk/merge_requests/1 fixes it, -// or if we get bored waiting for it to be merged, we could switch -// to a project that's actively maintained. -const SEARCH_PATHS = ['src', 'res']; - -function getObjectValue(obj, key) { - for (const prop of obj.properties) { - if (prop.key.type === 'Identifier' && prop.key.name === key) { - return prop.value; - } - } - return null; -} - -function getTKey(arg) { - if (arg.type === 'Literal' || arg.type === "StringLiteral") { - return arg.value; - } else if (arg.type === 'BinaryExpression' && arg.operator === '+') { - return getTKey(arg.left) + getTKey(arg.right); - } else if (arg.type === 'TemplateLiteral') { - return arg.quasis.map((q) => { - return q.value.raw; - }).join(''); - } - return null; -} - -function getFormatStrings(str) { - // Match anything that starts with % - // We could make a regex that matched the full placeholder, but this - // would just not match invalid placeholders and so wouldn't help us - // detect the invalid ones. - // Also note that for simplicity, this just matches a % character and then - // anything up to the next % character (or a single %, or end of string). - const formatStringRe = /%([^%]+|%|$)/g; - const formatStrings = new Set(); - - let match; - while ( (match = formatStringRe.exec(str)) !== null ) { - const placeholder = match[1]; // Minus the leading '%' - if (placeholder === '%') continue; // Literal % is %% - - const placeholderMatch = placeholder.match(/^\((.*?)\)(.)/); - if (placeholderMatch === null) { - throw new Error("Invalid format specifier: '"+match[0]+"'"); - } - if (placeholderMatch.length < 3) { - throw new Error("Malformed format specifier"); - } - const placeholderName = placeholderMatch[1]; - const placeholderFormat = placeholderMatch[2]; - - if (placeholderFormat !== 's') { - throw new Error(`'${placeholderFormat}' used as format character: you probably meant 's'`); - } - - formatStrings.add(placeholderName); - } - - return formatStrings; -} - -function getTranslationsJs(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - try { - const plugins = [ - // https://babeljs.io/docs/en/babel-parser#plugins - "classProperties", - "objectRestSpread", - "throwExpressions", - "exportDefaultFrom", - "decorators-legacy", - ]; - - if (file.endsWith(".js") || file.endsWith(".jsx")) { - // all JS is assumed to be flow or react - plugins.push("flow", "jsx"); - } else if (file.endsWith(".ts")) { - // TS can't use JSX unless it's a TSX file (otherwise angle casts fail) - plugins.push("typescript"); - } else if (file.endsWith(".tsx")) { - // When the file is a TSX file though, enable JSX parsing - plugins.push("typescript", "jsx"); - } - - const babelParsed = parser.parse(contents, { - allowImportExportEverywhere: true, - errorRecovery: true, - sourceFilename: file, - tokens: true, - plugins, - }); - traverse.default(babelParsed, { - enter: (p) => { - const node = p.node; - if (p.isCallExpression() && node.callee && TRANSLATIONS_FUNCS.includes(node.callee.name)) { - const tKey = getTKey(node.arguments[0]); - - // This happens whenever we call _t with non-literals (ie. whenever we've - // had to use a _td to compensate) so is expected. - if (tKey === null) return; - - // check the format string against the args - // We only check _t: _td has no args - if (node.callee.name === '_t') { - try { - const placeholders = getFormatStrings(tKey); - for (const placeholder of placeholders) { - if (node.arguments.length < 2) { - throw new Error(`Placeholder found ('${placeholder}') but no substitutions given`); - } - const value = getObjectValue(node.arguments[1], placeholder); - if (value === null) { - throw new Error(`No value found for placeholder '${placeholder}'`); - } - } - - // Validate tag replacements - if (node.arguments.length > 2) { - const tagMap = node.arguments[2]; - for (const prop of tagMap.properties || []) { - if (prop.key.type === 'Literal') { - const tag = prop.key.value; - // RegExp same as in src/languageHandler.js - const regexp = new RegExp(`(<${tag}>(.*?)<\\/${tag}>|<${tag}>|<${tag}\\s*\\/>)`); - if (!tKey.match(regexp)) { - throw new Error(`No match for ${regexp} in ${tKey}`); - } - } - } - } - - } catch (e) { - console.log(); - console.error(`ERROR: ${file}:${node.loc.start.line} ${tKey}`); - console.error(e); - process.exit(1); - } - } - - let isPlural = false; - if (node.arguments.length > 1 && node.arguments[1].type === 'ObjectExpression') { - const countVal = getObjectValue(node.arguments[1], 'count'); - if (countVal) { - isPlural = true; - } - } - - if (isPlural) { - trs.add(tKey + "|other"); - const plurals = enPlurals[tKey]; - if (plurals) { - for (const pluralType of Object.keys(plurals)) { - trs.add(tKey + "|" + pluralType); - } - } - } else { - trs.add(tKey); - } - } - }, - }); - } catch (e) { - console.error(e); - process.exit(1); - } - - return trs; -} - -function getTranslationsOther(file) { - const contents = fs.readFileSync(file, { encoding: 'utf8' }); - - const trs = new Set(); - - // Taken from element-web src/components/structures/HomePage.js - const translationsRegex = /_t\(['"]([\s\S]*?)['"]\)/mg; - let matches; - while (matches = translationsRegex.exec(contents)) { - trs.add(matches[1]); - } - return trs; -} - -// gather en_EN plural strings from the input translations file: -// the en_EN strings are all in the source with the exception of -// pluralised strings, which we need to pull in from elsewhere. -const inputTranslationsRaw = JSON.parse(fs.readFileSync(INPUT_TRANSLATIONS_FILE, { encoding: 'utf8' })); -const enPlurals = {}; - -for (const key of Object.keys(inputTranslationsRaw)) { - const parts = key.split("|"); - if (parts.length > 1) { - const plurals = enPlurals[parts[0]] || {}; - plurals[parts[1]] = inputTranslationsRaw[key]; - enPlurals[parts[0]] = plurals; - } -} - -const translatables = new Set(); - -const walkOpts = { - listeners: { - names: function(root, nodeNamesArray) { - // Sort the names case insensitively and alphabetically to - // maintain some sense of order between the different strings. - nodeNamesArray.sort((a, b) => { - a = a.toLowerCase(); - b = b.toLowerCase(); - if (a > b) return 1; - if (a < b) return -1; - return 0; - }); - }, - file: function(root, fileStats, next) { - const fullPath = path.join(root, fileStats.name); - - let trs; - if (fileStats.name.endsWith('.js') || fileStats.name.endsWith('.ts') || fileStats.name.endsWith('.tsx')) { - trs = getTranslationsJs(fullPath); - } else if (fileStats.name.endsWith('.html')) { - trs = getTranslationsOther(fullPath); - } else { - return; - } - console.log(`${fullPath} (${trs.size} strings)`); - for (const tr of trs.values()) { - // Convert DOS line endings to unix - translatables.add(tr.replace(/\r\n/g, "\n")); - } - }, - } -}; - -for (const path of SEARCH_PATHS) { - if (fs.existsSync(path)) { - walk.walkSync(path, walkOpts); - } -} - -const trObj = {}; -for (const tr of translatables) { - if (tr.includes("|")) { - if (inputTranslationsRaw[tr]) { - trObj[tr] = inputTranslationsRaw[tr]; - } else { - trObj[tr] = tr.split("|")[0]; - } - } else { - trObj[tr] = tr; - } -} - -fs.writeFileSync( - OUTPUT_FILE, - JSON.stringify(trObj, translatables.values(), 4) + "\n" -); - -console.log(); -console.log(`Wrote ${translatables.size} strings to ${OUTPUT_FILE}`); diff --git a/scripts/prune-i18n.js b/scripts/prune-i18n.js deleted file mode 100755 index b4fe8d69f5..0000000000 --- a/scripts/prune-i18n.js +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env node - -/* -Copyright 2017 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/* - * Looks through all the translation files and removes any strings - * which don't appear in en_EN.json. - * Use this if you remove a translation, but merge any outstanding changes - * from weblate first or you'll need to resolve the conflict in weblate. - */ - -const fs = require('fs'); -const path = require('path'); - -const I18NDIR = 'src/i18n/strings'; - -const enStringsRaw = JSON.parse(fs.readFileSync(path.join(I18NDIR, 'en_EN.json'))); - -const enStrings = new Set(); -for (const str of Object.keys(enStringsRaw)) { - const parts = str.split('|'); - if (parts.length > 1) { - enStrings.add(parts[0]); - } else { - enStrings.add(str); - } -} - -for (const filename of fs.readdirSync(I18NDIR)) { - if (filename === 'en_EN.json') continue; - if (filename === 'basefile.json') continue; - if (!filename.endsWith('.json')) continue; - - const trs = JSON.parse(fs.readFileSync(path.join(I18NDIR, filename))); - const oldLen = Object.keys(trs).length; - for (const tr of Object.keys(trs)) { - const parts = tr.split('|'); - const trKey = parts.length > 1 ? parts[0] : tr; - if (!enStrings.has(trKey)) { - delete trs[tr]; - } - } - - const removed = oldLen - Object.keys(trs).length; - if (removed > 0) { - console.log(`${filename}: removed ${removed} translations`); - // XXX: This is totally relying on the impl serialising the JSON object in the - // same order as they were parsed from the file. JSON.stringify() has a specific argument - // that can be used to control the order, but JSON.parse() lacks any kind of equivalent. - // Empirically this does maintain the order on my system, so I'm going to leave it like - // this for now. - fs.writeFileSync(path.join(I18NDIR, filename), JSON.stringify(trs, undefined, 4) + "\n"); - } -} diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 41257c21f0..0ab26ef943 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-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. @@ -39,7 +39,9 @@ import {ModalWidgetStore} from "../stores/ModalWidgetStore"; import { WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore"; import VoipUserMapper from "../VoipUserMapper"; import {SpaceStoreClass} from "../stores/SpaceStore"; -import {VoiceRecording} from "../voice/VoiceRecording"; +import TypingStore from "../stores/TypingStore"; +import { EventIndexPeg } from "../indexing/EventIndexPeg"; +import {VoiceRecordingStore} from "../stores/VoiceRecordingStore"; declare global { interface Window { @@ -71,12 +73,16 @@ declare global { mxModalWidgetStore: ModalWidgetStore; mxVoipUserMapper: VoipUserMapper; mxSpaceStore: SpaceStoreClass; - mxVoiceRecorder: typeof VoiceRecording; + mxVoiceRecordingStore: VoiceRecordingStore; + mxTypingStore: TypingStore; + mxEventIndexPeg: EventIndexPeg; } interface Document { // https://developer.mozilla.org/en-US/docs/Web/API/Document/hasStorageAccess hasStorageAccess?: () => Promise; + // https://developer.mozilla.org/en-US/docs/Web/API/Document/requestStorageAccess + requestStorageAccess?: () => Promise; // Safari & IE11 only have this prefixed: we used prefixed versions // previously so let's continue to support them for now diff --git a/src/Avatar.ts b/src/Avatar.ts index 76c88faa1c..d218ae8b46 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -27,11 +27,7 @@ export type ResizeMethod = "crop" | "scale"; export function avatarUrlForMember(member: RoomMember, width: number, height: number, resizeMethod: ResizeMethod) { let url: string; if (member?.getMxcAvatarUrl()) { - url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( - Math.floor(width * window.devicePixelRatio), - Math.floor(height * window.devicePixelRatio), - resizeMethod, - ); + url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } if (!url) { // member can be null here currently since on invites, the JS SDK @@ -44,11 +40,7 @@ export function avatarUrlForMember(member: RoomMember, width: number, height: nu export function avatarUrlForUser(user: User, width: number, height: number, resizeMethod?: ResizeMethod) { if (!user.avatarUrl) return null; - return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp( - Math.floor(width * window.devicePixelRatio), - Math.floor(height * window.devicePixelRatio), - resizeMethod, - ); + return mediaFromMxc(user.avatarUrl).getThumbnailOfSourceHttp(width, height, resizeMethod); } function isValidHexColor(color: string): boolean { diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index b6012d7597..5483ea6874 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -258,7 +258,7 @@ export default abstract class BasePlatform { return null; } - setLanguage(preferredLangs: string[]) {} + async setLanguage(preferredLangs: string[]) {} setSpellCheckLanguages(preferredLangs: string[]) {} diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index be687a4474..91fd7e4c7d 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -59,7 +59,6 @@ import {MatrixClientPeg} from './MatrixClientPeg'; import PlatformPeg from './PlatformPeg'; import Modal from './Modal'; import { _t } from './languageHandler'; -import { createNewMatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import dis from './dispatcher/dispatcher'; import WidgetUtils from './utils/WidgetUtils'; import WidgetEchoStore from './stores/WidgetEchoStore'; @@ -86,6 +85,8 @@ import { Action } from './dispatcher/actions'; import VoipUserMapper from './VoipUserMapper'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; +import SdkConfig from './SdkConfig'; +import { ensureDMExists, findDMForUser } from './createRoom'; export const PROTOCOL_PSTN = 'm.protocol.pstn'; export const PROTOCOL_PSTN_PREFIXED = 'im.vector.protocol.pstn'; @@ -167,6 +168,11 @@ export default class CallHandler { private invitedRoomsAreVirtual = new Map(); private invitedRoomCheckInProgress = false; + // Map of the asserted identity users after we've looked them up using the API. + // We need to be be able to determine the mapped room synchronously, so we + // do the async lookup when we get new information and then store these mappings here + private assertedIdentityNativeUsers = new Map(); + static sharedInstance() { if (!window.mxCallHandler) { window.mxCallHandler = new CallHandler() @@ -179,8 +185,19 @@ export default class CallHandler { * Gets the user-facing room associated with a call (call.roomId may be the call "virtual room" * if a voip_mxid_translate_pattern is set in the config) */ - public static roomIdForCall(call: MatrixCall): string { + public roomIdForCall(call: MatrixCall): string { if (!call) return null; + + const voipConfig = SdkConfig.get()['voip']; + + if (voipConfig && voipConfig.obeyAssertedIdentity) { + const nativeUser = this.assertedIdentityNativeUsers[call.callId]; + if (nativeUser) { + const room = findDMForUser(MatrixClientPeg.get(), nativeUser); + if (room) return room.roomId + } + } + return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) || call.roomId; } @@ -379,14 +396,14 @@ export default class CallHandler { // We don't allow placing more than one call per room, but that doesn't mean there // can't be more than one, eg. in a glare situation. This checks that the given call // is the call we consider 'the' call for its room. - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = this.roomIdForCall(call); const callForThisRoom = this.getCallForRoom(mappedRoomId); return callForThisRoom && call.callId === callForThisRoom.callId; } private setCallListeners(call: MatrixCall) { - const mappedRoomId = CallHandler.roomIdForCall(call); + let mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); call.on(CallEvent.Error, (err: CallError) => { if (!this.matchesCallForThisRoom(call)) return; @@ -500,6 +517,42 @@ export default class CallHandler { this.setCallListeners(newCall); this.setCallState(newCall, newCall.state); }); + call.on(CallEvent.AssertedIdentityChanged, async () => { + if (!this.matchesCallForThisRoom(call)) return; + + console.log(`Call ID ${call.callId} got new asserted identity:`, call.getRemoteAssertedIdentity()); + + const newAssertedIdentity = call.getRemoteAssertedIdentity().id; + let newNativeAssertedIdentity = newAssertedIdentity; + if (newAssertedIdentity) { + const response = await this.sipNativeLookup(newAssertedIdentity); + if (response.length) newNativeAssertedIdentity = response[0].userid; + } + console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); + + if (newNativeAssertedIdentity) { + this.assertedIdentityNativeUsers[call.callId] = newNativeAssertedIdentity; + + // If we don't already have a room with this user, make one. This will be slightly odd + // if they called us because we'll be inviting them, but there's not much we can do about + // this if we want the actual, native room to exist (which we do). This is why it's + // important to only obey asserted identity in trusted environments, since anyone you're + // on a call with can cause you to send a room invite to someone. + await ensureDMExists(MatrixClientPeg.get(), newNativeAssertedIdentity); + + const newMappedRoomId = this.roomIdForCall(call); + console.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`); + if (newMappedRoomId !== mappedRoomId) { + this.removeCallForRoom(mappedRoomId); + mappedRoomId = newMappedRoomId; + this.calls.set(mappedRoomId, call); + dis.dispatch({ + action: Action.CallChangeRoom, + call, + }); + } + } + }); } private async logCallStats(call: MatrixCall, mappedRoomId: string) { @@ -551,7 +604,7 @@ export default class CallHandler { } private setCallState(call: MatrixCall, status: CallState) { - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); console.log( `Call state in ${mappedRoomId} changed to ${status}`, @@ -639,7 +692,7 @@ export default class CallHandler { const timeUntilTurnCresExpire = MatrixClientPeg.get().getTurnServersExpiry() - Date.now(); console.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms"); - const call = createNewMatrixCall(MatrixClientPeg.get(), mappedRoomId); + const call = MatrixClientPeg.get().createCall(mappedRoomId); this.calls.set(roomId, call); if (transferee) { @@ -673,7 +726,7 @@ export default class CallHandler { call.placeScreenSharingCall( remoteElement, localElement, - async () : Promise => { + async (): Promise => { const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const [source] = await finished; return source; @@ -772,7 +825,7 @@ export default class CallHandler { const call = payload.call as MatrixCall; - const mappedRoomId = CallHandler.roomIdForCall(call); + const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); if (this.getCallForRoom(mappedRoomId)) { // ignore multiple incoming calls to the same room return; diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index d862f10c02..aac14bde20 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -231,8 +231,10 @@ export class KeyBindingsManager { /** * Finds a matching KeyAction for a given KeyboardEvent */ - private getAction(getters: KeyBindingGetter[], ev: KeyboardEvent | React.KeyboardEvent) - : T | undefined { + private getAction( + getters: KeyBindingGetter[], + ev: KeyboardEvent | React.KeyboardEvent, + ): T | undefined { for (const getter of getters) { const bindings = getter(); const binding = bindings.find(it => isKeyComboMatch(ev, it.keyCombo, isMac)); diff --git a/src/Login.ts b/src/Login.ts index db3c4c11e4..d584df7dfe 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -1,9 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd +Copyright 2015-2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -59,7 +56,7 @@ export type LoginFlow = ISSOFlow | IPasswordFlow; // TODO: Move this to JS SDK /* eslint-disable camelcase */ interface ILoginParams { - identifier?: string; + identifier?: object; password?: string; token?: string; device_id?: string; diff --git a/src/Resend.js b/src/Resend.js index bf69e59c1a..f1e5fb38f5 100644 --- a/src/Resend.js +++ b/src/Resend.js @@ -21,11 +21,11 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event'; export default class Resend { static resendUnsentEvents(room) { - room.getPendingEvents().filter(function(ev) { + return Promise.all(room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; - }).forEach(function(event) { - Resend.resend(event); - }); + }).map(function(event) { + return Resend.resend(event); + })); } static cancelUnsentEvents(room) { @@ -38,7 +38,7 @@ export default class Resend { static resend(event) { const room = MatrixClientPeg.get().getRoom(event.getRoomId()); - MatrixClientPeg.get().resendEvent(event, room).then(function(res) { + return MatrixClientPeg.get().resendEvent(event, room).then(function(res) { dis.dispatch({ action: 'message_sent', event: event, diff --git a/src/ScalarAuthClient.js b/src/ScalarAuthClient.ts similarity index 84% rename from src/ScalarAuthClient.js rename to src/ScalarAuthClient.ts index 200b4fd7b9..a09c3494a8 100644 --- a/src/ScalarAuthClient.js +++ b/src/ScalarAuthClient.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 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. @@ -17,13 +16,14 @@ limitations under the License. import url from 'url'; import SettingsStore from "./settings/SettingsStore"; -import { Service, startTermsFlow, TermsNotSignedError } from './Terms'; +import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; import {MatrixClientPeg} from "./MatrixClientPeg"; import request from "browser-request"; import SdkConfig from "./SdkConfig"; import {WidgetType} from "./widgets/WidgetType"; import {SERVICE_TYPES} from "matrix-js-sdk/src/service-types"; +import { Room } from "matrix-js-sdk/src/models/room"; // The version of the integration manager API we're intending to work with const imApiVersion = "1.1"; @@ -31,9 +31,11 @@ const imApiVersion = "1.1"; // TODO: Generify the name of this class and all components within - it's not just for Scalar. export default class ScalarAuthClient { - constructor(apiUrl, uiUrl) { - this.apiUrl = apiUrl; - this.uiUrl = uiUrl; + private scalarToken: string; + private termsInteractionCallback: TermsInteractionCallback; + private isDefaultManager: boolean; + + constructor(private apiUrl: string, private uiUrl: string) { this.scalarToken = null; // `undefined` to allow `startTermsFlow` to fallback to a default // callback if this is unset. @@ -46,7 +48,7 @@ export default class ScalarAuthClient { this.isDefaultManager = apiUrl === configApiUrl && configUiUrl === uiUrl; } - _writeTokenToStore() { + private writeTokenToStore() { window.localStorage.setItem("mx_scalar_token_at_" + this.apiUrl, this.scalarToken); if (this.isDefaultManager) { // We remove the old token from storage to migrate upwards. This is safe @@ -56,7 +58,7 @@ export default class ScalarAuthClient { } } - _readTokenFromStore() { + private readTokenFromStore(): string { let token = window.localStorage.getItem("mx_scalar_token_at_" + this.apiUrl); if (!token && this.isDefaultManager) { token = window.localStorage.getItem("mx_scalar_token"); @@ -64,33 +66,33 @@ export default class ScalarAuthClient { return token; } - _readToken() { + private readToken(): string { if (this.scalarToken) return this.scalarToken; - return this._readTokenFromStore(); + return this.readTokenFromStore(); } setTermsInteractionCallback(callback) { this.termsInteractionCallback = callback; } - connect() { + connect(): Promise { return this.getScalarToken().then((tok) => { this.scalarToken = tok; }); } - hasCredentials() { + hasCredentials(): boolean { return this.scalarToken != null; // undef or null } // Returns a promise that resolves to a scalar_token string - getScalarToken() { - const token = this._readToken(); + getScalarToken(): Promise { + const token = this.readToken(); if (!token) { return this.registerForToken(); } else { - return this._checkToken(token).catch((e) => { + return this.checkToken(token).catch((e) => { if (e instanceof TermsNotSignedError) { // retrying won't help this throw e; @@ -100,7 +102,7 @@ export default class ScalarAuthClient { } } - _getAccountName(token) { + private getAccountName(token: string): Promise { const url = this.apiUrl + "/account"; return new Promise(function(resolve, reject) { @@ -125,8 +127,8 @@ export default class ScalarAuthClient { }); } - _checkToken(token) { - return this._getAccountName(token).then(userId => { + private checkToken(token: string): Promise { + return this.getAccountName(token).then(userId => { const me = MatrixClientPeg.get().getUserId(); if (userId !== me) { throw new Error("Scalar token is owned by someone else: " + me); @@ -154,7 +156,7 @@ export default class ScalarAuthClient { parsedImRestUrl.pathname = ''; return startTermsFlow([new Service( SERVICE_TYPES.IM, - parsedImRestUrl.format(), + url.format(parsedImRestUrl), token, )], this.termsInteractionCallback).then(() => { return token; @@ -165,22 +167,22 @@ export default class ScalarAuthClient { }); } - registerForToken() { + registerForToken(): Promise { // Get openid bearer token from the HS as the first part of our dance return MatrixClientPeg.get().getOpenIdToken().then((tokenObject) => { // Now we can send that to scalar and exchange it for a scalar token return this.exchangeForScalarToken(tokenObject); }).then((token) => { // Validate it (this mostly checks to see if the IM needs us to agree to some terms) - return this._checkToken(token); + return this.checkToken(token); }).then((token) => { this.scalarToken = token; - this._writeTokenToStore(); + this.writeTokenToStore(); return token; }); } - exchangeForScalarToken(openidTokenObject) { + exchangeForScalarToken(openidTokenObject: any): Promise { const scalarRestUrl = this.apiUrl; return new Promise(function(resolve, reject) { @@ -194,7 +196,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body || !body.scalar_token) { reject(new Error("Missing scalar_token in response")); } else { @@ -204,7 +206,7 @@ export default class ScalarAuthClient { }); } - getScalarPageTitle(url) { + getScalarPageTitle(url: string): Promise { let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl += '&curl=' + encodeURIComponent(url); @@ -218,7 +220,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Missing page title in response")); } else { @@ -240,10 +242,10 @@ export default class ScalarAuthClient { * @param {string} widgetId The widget ID to disable assets for * @return {Promise} Resolves on completion */ - disableWidgetAssets(widgetType: WidgetType, widgetId) { + disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise { let url = this.apiUrl + '/widgets/set_assets_state'; url = this.getStarterLink(url); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { request({ method: 'GET', // XXX: Actions shouldn't be GET requests uri: url, @@ -257,7 +259,7 @@ export default class ScalarAuthClient { if (err) { reject(err); } else if (response.statusCode / 100 !== 2) { - reject({statusCode: response.statusCode}); + reject(new Error(`Scalar request failed: ${response.statusCode}`)); } else if (!body) { reject(new Error("Failed to set widget assets state")); } else { @@ -267,7 +269,7 @@ export default class ScalarAuthClient { }); } - getScalarInterfaceUrlForRoom(room, screen, id) { + getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string { const roomId = room.roomId; const roomName = room.name; let url = this.uiUrl; @@ -284,7 +286,7 @@ export default class ScalarAuthClient { return url; } - getStarterLink(starterLinkUrl) { + getStarterLink(starterLinkUrl: string): string { return starterLinkUrl + "?scalar_token=" + encodeURIComponent(this.scalarToken); } } diff --git a/src/Terms.js b/src/Terms.ts similarity index 87% rename from src/Terms.js rename to src/Terms.ts index 6ae89f9a2c..1bdff36cbc 100644 --- a/src/Terms.js +++ b/src/Terms.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -17,7 +17,7 @@ limitations under the License. import classNames from 'classnames'; import {MatrixClientPeg} from './MatrixClientPeg'; -import * as sdk from './'; +import * as sdk from '.'; import Modal from './Modal'; export class TermsNotSignedError extends Error {} @@ -32,13 +32,30 @@ export class Service { * @param {string} baseUrl The Base URL of the service (ie. before '/_matrix') * @param {string} accessToken The user's access token for the service */ - constructor(serviceType, baseUrl, accessToken) { - this.serviceType = serviceType; - this.baseUrl = baseUrl; - this.accessToken = accessToken; + constructor(public serviceType: string, public baseUrl: string, public accessToken: string) { } } +interface Policy { + // @ts-ignore: No great way to express indexed types together with other keys + version: string; + [lang: string]: { + url: string; + }; +} +type Policies = { + [policy: string]: Policy, +}; + +export type TermsInteractionCallback = ( + policiesAndServicePairs: { + service: Service, + policies: Policies, + }[], + agreedUrls: string[], + extraClassNames?: string, +) => Promise; + /** * Start a flow where the user is presented with terms & conditions for some services * @@ -51,8 +68,8 @@ export class Service { * if they cancel. */ export async function startTermsFlow( - services, - interactionCallback = dialogTermsInteractionCallback, + services: Service[], + interactionCallback: TermsInteractionCallback = dialogTermsInteractionCallback, ) { const termsPromises = services.map( (s) => MatrixClientPeg.get().getTerms(s.serviceType, s.baseUrl), @@ -77,7 +94,7 @@ export async function startTermsFlow( * } */ - const terms = await Promise.all(termsPromises); + const terms: { policies: Policies }[] = await Promise.all(termsPromises); const policiesAndServicePairs = terms.map((t, i) => { return { 'service': services[i], 'policies': t.policies }; }); // fetch the set of agreed policy URLs from account data @@ -158,10 +175,13 @@ export async function startTermsFlow( } export function dialogTermsInteractionCallback( - policiesAndServicePairs, - agreedUrls, - extraClassNames, -) { + policiesAndServicePairs: { + service: Service, + policies: { [policy: string]: Policy }, + }[], + agreedUrls: string[], + extraClassNames?: string, +): Promise { return new Promise((resolve, reject) => { console.log("Terms that need agreement", policiesAndServicePairs); const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); diff --git a/src/Unread.js b/src/Unread.js index ddf225ac64..12c15eb6af 100644 --- a/src/Unread.js +++ b/src/Unread.js @@ -45,7 +45,7 @@ export function eventTriggersUnreadCount(ev) { } export function doesRoomHaveUnreadMessages(room) { - const myUserId = MatrixClientPeg.get().credentials.userId; + const myUserId = MatrixClientPeg.get().getUserId(); // get the most recent read receipt sent by our account. // N.B. this is NOT a read marker (RM, aka "read up to marker"), diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index 4f5613b4a8..e5bed2e812 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -57,7 +57,11 @@ export default class VoipUserMapper { if (!virtualRoom) return null; const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE); if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null; - return virtualRoomEvent.getContent()['native_room'] || null; + const nativeRoomID = virtualRoomEvent.getContent()['native_room']; + const nativeRoom = MatrixClientPeg.get().getRoom(nativeRoomID); + if (!nativeRoom || nativeRoom.getMyMembership() !== 'join') return null; + + return nativeRoomID; } public isVirtualRoom(room: Room): boolean { diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx similarity index 90% rename from src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js rename to src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index be3368b87b..0710c513da 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.js +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-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. @@ -16,7 +16,6 @@ limitations under the License. import React from 'react'; import * as sdk from '../../../../index'; -import PropTypes from 'prop-types'; import { _t } from '../../../../languageHandler'; import SdkConfig from '../../../../SdkConfig'; import SettingsStore from "../../../../settings/SettingsStore"; @@ -26,14 +25,23 @@ import {formatBytes, formatCountLong} from "../../../../utils/FormattingUtils"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; import {SettingLevel} from "../../../../settings/SettingLevel"; +interface IProps { + onFinished: (confirmed: boolean) => void; +} + +interface IState { + eventIndexSize: number; + eventCount: number; + crawlingRoomsCount: number; + roomCount: number; + currentRoom: string; + crawlerSleepTime: number; +} + /* * Allows the user to introspect the event index state and disable it. */ -export default class ManageEventIndexDialog extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; - +export default class ManageEventIndexDialog extends React.Component { constructor(props) { super(props); @@ -84,7 +92,7 @@ export default class ManageEventIndexDialog extends React.Component { } } - async componentDidMount(): void { + async componentDidMount(): Promise { let eventIndexSize = 0; let crawlingRoomsCount = 0; let roomCount = 0; @@ -123,14 +131,14 @@ export default class ManageEventIndexDialog extends React.Component { }); } - _onDisable = async () => { + private onDisable = async () => { Modal.createTrackedDialogAsync("Disable message search", "Disable message search", import("./DisableEventIndexDialog"), null, null, /* priority = */ false, /* static = */ true, ); }; - _onCrawlerSleepTimeChange = (e) => { + private onCrawlerSleepTimeChange = (e) => { this.setState({crawlerSleepTime: e.target.value}); SettingsStore.setValue("crawlerSleepTime", null, SettingLevel.DEVICE, e.target.value); }; @@ -144,7 +152,7 @@ export default class ManageEventIndexDialog extends React.Component { crawlerState = _t("Not currently indexing messages for any room."); } else { crawlerState = ( - _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) + _t("Currently indexing: %(currentRoom)s", { currentRoom: this.state.currentRoom }) ); } @@ -169,7 +177,7 @@ export default class ManageEventIndexDialog extends React.Component { label={_t('Message downloading sleep time(ms)')} type='number' value={this.state.crawlerSleepTime} - onChange={this._onCrawlerSleepTimeChange} /> + onChange={this.onCrawlerSleepTimeChange} /> ); @@ -188,7 +196,7 @@ export default class ManageEventIndexDialog extends React.Component { onPrimaryButtonClick={this.props.onFinished} primaryButtonClass="primary" cancelButton={_t("Disable")} - onCancel={this._onDisable} + onCancel={this.onDisable} cancelButtonClass="danger" /> diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index 54b6fee233..38e3cd97e8 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -1,5 +1,5 @@ /* -Copyright 2015-2020 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -20,16 +20,20 @@ import { _t, _td } from '../../languageHandler'; import {MatrixClientPeg} from '../../MatrixClientPeg'; import Resend from '../../Resend'; import dis from '../../dispatcher/dispatcher'; -import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; +import {messageForResourceLimitError} from '../../utils/ErrorUtils'; import {Action} from "../../dispatcher/actions"; import {replaceableComponent} from "../../utils/replaceableComponent"; import {EventStatus} from "matrix-js-sdk/src/models/event"; +import NotificationBadge from "../views/rooms/NotificationBadge"; +import {StaticNotificationState} from "../../stores/notifications/StaticNotificationState"; +import AccessibleButton from "../views/elements/AccessibleButton"; +import InlineSpinner from "../views/elements/InlineSpinner"; const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -function getUnsentMessages(room) { +export function getUnsentMessages(room) { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { return ev.status === EventStatus.NOT_SENT; @@ -76,6 +80,7 @@ export default class RoomStatusBar extends React.Component { syncState: MatrixClientPeg.get().getSyncState(), syncStateData: MatrixClientPeg.get().getSyncStateData(), unsentMessages: getUnsentMessages(this.props.room), + isResending: false, }; componentDidMount() { @@ -109,7 +114,10 @@ export default class RoomStatusBar extends React.Component { }; _onResendAllClick = () => { - Resend.resendUnsentEvents(this.props.room); + Resend.resendUnsentEvents(this.props.room).then(() => { + this.setState({isResending: false}); + }); + this.setState({isResending: true}); dis.fire(Action.FocusComposer); }; @@ -120,9 +128,10 @@ export default class RoomStatusBar extends React.Component { _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { if (room.roomId !== this.props.room.roomId) return; - + const messages = getUnsentMessages(this.props.room); this.setState({ - unsentMessages: getUnsentMessages(this.props.room), + unsentMessages: messages, + isResending: messages.length > 0 && this.state.isResending, }); }; @@ -141,7 +150,7 @@ export default class RoomStatusBar extends React.Component { _getSize() { if (this._shouldShowConnectionError()) { return STATUS_BAR_EXPANDED; - } else if (this.state.unsentMessages.length > 0) { + } else if (this.state.unsentMessages.length > 0 || this.state.isResending) { return STATUS_BAR_EXPANDED_LARGE; } return STATUS_BAR_HIDDEN; @@ -162,7 +171,6 @@ export default class RoomStatusBar extends React.Component { _getUnsentMessageContent() { const unsentMessages = this.state.unsentMessages; - if (!unsentMessages.length) return null; let title; @@ -206,75 +214,76 @@ export default class RoomStatusBar extends React.Component { "Please contact your service administrator to continue using the service.", ), }); - } else if ( - unsentMessages.length === 1 && - unsentMessages[0].error && - unsentMessages[0].error.data && - unsentMessages[0].error.data.error - ) { - title = messageForSendError(unsentMessages[0].error.data) || unsentMessages[0].error.data.error; } else { - title = _t('%(count)s of your messages have not been sent.', { count: unsentMessages.length }); + title = _t('Some of your messages have not been sent'); } - const content = _t("%(count)s Resend all or cancel all " + - "now. You can also select individual messages to resend or cancel.", - { count: unsentMessages.length }, - { - 'resendText': (sub) => - { sub }, - 'cancelText': (sub) => - { sub }, - }, - ); + let buttonRow = <> + + {_t("Delete all")} + + + {_t("Retry all")} + + ; + if (this.state.isResending) { + buttonRow = <> + + {/* span for css */} + {_t("Sending")} + ; + } - return
- -
-
- { title } -
-
- { content } + return <> +
+
+
+ +
+
+
+ { title } +
+
+ { _t("You can select all or individual messages to retry or delete") } +
+
+
+ {buttonRow} +
-
; + ; } - // return suitable content for the main (text) part of the status bar. - _getContent() { + render() { if (this._shouldShowConnectionError()) { return ( -
- /!\ -
-
- { _t('Connectivity to the server has been lost.') } -
-
- { _t('Sent messages will be stored until your connection has returned.') } +
+
+
+ /!\ +
+
+ {_t('Connectivity to the server has been lost.')} +
+
+ {_t('Sent messages will be stored until your connection has returned.')} +
+
); } - if (this.state.unsentMessages.length > 0) { + if (this.state.unsentMessages.length > 0 || this.state.isResending) { return this._getUnsentMessageContent(); } return null; } - - render() { - const content = this._getContent(); - - return ( -
-
- { content } -
-
- ); - } } diff --git a/src/components/structures/ScrollPanel.js b/src/components/structures/ScrollPanel.js index 976734680c..a014a6e4fe 100644 --- a/src/components/structures/ScrollPanel.js +++ b/src/components/structures/ScrollPanel.js @@ -525,7 +525,7 @@ export default class ScrollPanel extends React.Component { */ scrollRelative = mult => { const scrollNode = this._getScrollNode(); - const delta = mult * scrollNode.clientHeight * 0.5; + const delta = mult * scrollNode.clientHeight * 0.9; scrollNode.scrollBy(0, delta); this._saveScrollState(); }; diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index 930cfa15a9..8d6c9f0a70 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -136,7 +136,7 @@ const Tile: React.FC = ({ let url: string; if (room.avatar_url) { - url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(Math.floor(20 * window.devicePixelRatio)); + url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20); } let description = _t("%(count)s members", { count: room.num_joined_members }); @@ -312,11 +312,12 @@ export const HierarchyLevel = ({ // mutate argument refreshToken to force a reload export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: any): [ + null, ISpaceSummaryRoom[], - Map>, - Map>, - Map>, -] | [] => { + Map>?, + Map>?, + Map>?, +] | [Error] => { // TODO pagination return useAsyncMemo(async () => { try { @@ -336,13 +337,12 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a } }); - return [data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; + return [null, data.rooms as ISpaceSummaryRoom[], parentChildRelations, viaMap, childParentRelations]; } catch (e) { console.error(e); // TODO + return [e]; } - - return []; - }, [space, refreshToken], []); + }, [space, refreshToken], [undefined]); }; export const SpaceHierarchy: React.FC = ({ @@ -358,7 +358,7 @@ export const SpaceHierarchy: React.FC = ({ const [selected, setSelected] = useState(new Map>()); // Map> - const [rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); + const [summaryError, rooms, parentChildMap, viaMap, childParentMap] = useSpaceSummary(cli, space, refreshToken); const roomsMap = useMemo(() => { if (!rooms) return null; @@ -397,6 +397,10 @@ export const SpaceHierarchy: React.FC = ({ const [removing, setRemoving] = useState(false); const [saving, setSaving] = useState(false); + if (summaryError) { + return

{_t("Your server does not support showing space hierarchies.")}

; + } + let content; if (roomsMap) { const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; @@ -538,10 +542,8 @@ export const SpaceHierarchy: React.FC = ({ { children } ; - } else if (!rooms) { - content = ; } else { - content =

{_t("Your server does not support showing space hierarchies.")}

; + content = ; } // TODO loading state/error state diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 3ab73fb9ac..34a5410928 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2017, 2018, 2019 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -94,7 +94,7 @@ interface IState { // be seeing. serverIsAlive: boolean; serverErrorIsFatal: boolean; - serverDeadError: string; + serverDeadError?: ReactNode; } /* diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 73955e7832..96fb9bdc82 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015, 2016, 2017, 2018, 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -95,7 +95,7 @@ interface IState { // be seeing. serverIsAlive: boolean; serverErrorIsFatal: boolean; - serverDeadError: string; + serverDeadError?: ReactNode; // Our matrix client - part of state because we can't render the UI auth // component without it. diff --git a/src/components/structures/auth/SoftLogout.js b/src/components/structures/auth/SoftLogout.tsx similarity index 92% rename from src/components/structures/auth/SoftLogout.js rename to src/components/structures/auth/SoftLogout.tsx index 08db3b2efe..fa9207efdd 100644 --- a/src/components/structures/auth/SoftLogout.js +++ b/src/components/structures/auth/SoftLogout.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -15,14 +15,13 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {_t} from '../../../languageHandler'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; -import {sendLoginRequest} from "../../../Login"; +import {ISSOFlow, LoginFlow, sendLoginRequest} from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import {SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY} from "../../../BasePlatform"; import SSOButtons from "../../views/elements/SSOButtons"; @@ -42,26 +41,38 @@ const FLOWS_TO_VIEWS = { "m.login.sso": LOGIN_VIEW.SSO, }; -@replaceableComponent("structures.auth.SoftLogout") -export default class SoftLogout extends React.Component { - static propTypes = { - // Query parameters from MatrixChat - realQueryParams: PropTypes.object, // {loginToken} - - // Called when the SSO login completes - onTokenLoginCompleted: PropTypes.func, +interface IProps { + // Query parameters from MatrixChat + realQueryParams: { + loginToken?: string; }; + fragmentAfterLogin?: string; - constructor() { - super(); + // Called when the SSO login completes + onTokenLoginCompleted: () => void, +} + +interface IState { + loginView: number; + keyBackupNeeded: boolean; + busy: boolean; + password: string; + errorText: string; + flows: LoginFlow[]; +} + +@replaceableComponent("structures.auth.SoftLogout") +export default class SoftLogout extends React.Component { + constructor(props) { + super(props); this.state = { loginView: LOGIN_VIEW.LOADING, keyBackupNeeded: true, // assume we do while we figure it out (see componentDidMount) - busy: false, password: "", errorText: "", + flows: [], }; } @@ -72,7 +83,7 @@ export default class SoftLogout extends React.Component { return; } - this._initLogin(); + this.initLogin(); const cli = MatrixClientPeg.get(); if (cli.isCryptoEnabled()) { @@ -94,7 +105,7 @@ export default class SoftLogout extends React.Component { }); }; - async _initLogin() { + private async initLogin() { const queryParams = this.props.realQueryParams; const hasAllParams = queryParams && queryParams['loginToken']; if (hasAllParams) { @@ -189,7 +200,7 @@ export default class SoftLogout extends React.Component { }); } - _renderSignInSection() { + private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { const Spinner = sdk.getComponent("elements.Spinner"); return ; @@ -247,7 +258,7 @@ export default class SoftLogout extends React.Component { } // else we already have a message and should use it (key backup warning) const loginType = this.state.loginView === LOGIN_VIEW.CAS ? "cas" : "sso"; - const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType); + const flow = this.state.flows.find(flow => flow.type === "m.login." + loginType) as ISSOFlow; return (
@@ -289,7 +300,7 @@ export default class SoftLogout extends React.Component {

{_t("Sign in")}

- {this._renderSignInSection()} + {this.renderSignInSection()}

{_t("Clear personal data")}

diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index c79cbc0d32..3205ca372c 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -68,8 +68,8 @@ export default class MemberAvatar extends React.Component { let imageUrl = null; if (props.member.getMxcAvatarUrl()) { imageUrl = mediaFromMxc(props.member.getMxcAvatarUrl()).getThumbnailOfSourceHttp( - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), + props.width, + props.height, props.resizeMethod, ); } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index ad0eb45a52..4693d907ba 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -93,8 +93,8 @@ export default class RoomAvatar extends React.Component { let oobAvatar = null; if (props.oobData.avatarUrl) { oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), + props.width, + props.height, props.resizeMethod, ); } @@ -109,12 +109,7 @@ export default class RoomAvatar extends React.Component { private static getRoomAvatarUrl(props: IProps): string { if (!props.room) return null; - return Avatar.avatarUrlForRoom( - props.room, - Math.floor(props.width * window.devicePixelRatio), - Math.floor(props.height * window.devicePixelRatio), - props.resizeMethod, - ); + return Avatar.avatarUrlForRoom(props.room, props.width, props.height, props.resizeMethod); } private onRoomAvatarClick = () => { diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.js index f86cd26f32..142b8c80a8 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.js @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015, 2016, 2018, 2019, 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. @@ -34,7 +32,7 @@ import {MenuItem} from "../../structures/ContextMenu"; import {EventType} from "matrix-js-sdk/src/@types/event"; import {replaceableComponent} from "../../../utils/replaceableComponent"; -function canCancel(eventStatus) { +export function canCancel(eventStatus) { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } @@ -98,21 +96,6 @@ export default class MessageContextMenu extends React.Component { return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); } - onResendClick = () => { - Resend.resend(this.props.mxEvent); - this.closeMenu(); - }; - - onResendEditClick = () => { - Resend.resend(this.props.mxEvent.replacingEvent()); - this.closeMenu(); - }; - - onResendRedactionClick = () => { - Resend.resend(this.props.mxEvent.localRedactionEvent()); - this.closeMenu(); - }; - onResendReactionsClick = () => { for (const reaction of this._getUnsentReactions()) { Resend.resend(reaction); @@ -170,29 +153,6 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onCancelSendClick = () => { - const mxEvent = this.props.mxEvent; - const editEvent = mxEvent.replacingEvent(); - const redactEvent = mxEvent.localRedactionEvent(); - const pendingReactions = this._getPendingReactions(); - - if (editEvent && canCancel(editEvent.status)) { - Resend.removeFromQueue(editEvent); - } - if (redactEvent && canCancel(redactEvent.status)) { - Resend.removeFromQueue(redactEvent); - } - if (pendingReactions.length) { - for (const reaction of pendingReactions) { - Resend.removeFromQueue(reaction); - } - } - if (canCancel(mxEvent.status)) { - Resend.removeFromQueue(this.props.mxEvent); - } - this.closeMenu(); - }; - onForwardClick = () => { if (this.props.onCloseDialog) this.props.onCloseDialog(); dis.dispatch({ @@ -285,20 +245,9 @@ export default class MessageContextMenu extends React.Component { const me = cli.getUserId(); const mxEvent = this.props.mxEvent; const eventStatus = mxEvent.status; - const editStatus = mxEvent.replacingEvent() && mxEvent.replacingEvent().status; - const redactStatus = mxEvent.localRedactionEvent() && mxEvent.localRedactionEvent().status; const unsentReactionsCount = this._getUnsentReactions().length; - const pendingReactionsCount = this._getPendingReactions().length; - const allowCancel = canCancel(mxEvent.status) || - canCancel(editStatus) || - canCancel(redactStatus) || - pendingReactionsCount !== 0; - let resendButton; - let resendEditButton; let resendReactionsButton; - let resendRedactionButton; let redactButton; - let cancelButton; let forwardButton; let pinButton; let unhidePreviewButton; @@ -309,22 +258,6 @@ export default class MessageContextMenu extends React.Component { // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; if (!mxEvent.isRedacted()) { - if (eventStatus === EventStatus.NOT_SENT) { - resendButton = ( - - { _t('Resend') } - - ); - } - - if (editStatus === EventStatus.NOT_SENT) { - resendEditButton = ( - - { _t('Resend edit') } - - ); - } - if (unsentReactionsCount !== 0) { resendReactionsButton = ( @@ -334,14 +267,6 @@ export default class MessageContextMenu extends React.Component { } } - if (redactStatus === EventStatus.NOT_SENT) { - resendRedactionButton = ( - - { _t('Resend removal') } - - ); - } - if (isSent && this.state.canRedact) { redactButton = ( @@ -350,14 +275,6 @@ export default class MessageContextMenu extends React.Component { ); } - if (allowCancel) { - cancelButton = ( - - { _t('Cancel Sending') } - - ); - } - if (isContentActionable(mxEvent)) { forwardButton = ( @@ -455,12 +372,8 @@ export default class MessageContextMenu extends React.Component { return (
- { resendButton } - { resendEditButton } { resendReactionsButton } - { resendRedactionButton } { redactButton } - { cancelButton } { forwardButton } { pinButton } { viewSourceButton } diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index e65a709eb0..1f45a76008 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -42,11 +42,11 @@ interface IProps extends IDialogProps { } const Entry = ({ room, checked, onChange }) => { - return
+ return
; + ; }; interface IAddExistingToSpaceProps { @@ -73,9 +73,13 @@ export const AddExistingToSpace: React.FC = ({ space, if (room !== space && !existingSubspacesSet.has(room)) { arr[0].push(room); } - } else if (!existingRoomsSet.has(room) && joinRule !== "public") { - // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. - arr[DMRoomMap.shared().getUserIdForRoomId(room.roomId) ? 2 : 1].push(room); + } else if (!existingRoomsSet.has(room)) { + if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { + arr[1].push(room); + } else if (joinRule !== "public") { + // Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones. + arr[2].push(room); + } } return arr; }, [[], [], []]); @@ -86,6 +90,7 @@ export const AddExistingToSpace: React.FC = ({ space, placeholder={ _t("Filter your rooms and spaces") } onSearch={setQuery} autoComplete={true} + autoFocus={true} /> { rooms.length > 0 ? ( diff --git a/src/components/views/dialogs/IncomingSasDialog.js b/src/components/views/dialogs/IncomingSasDialog.js index f18b7a9d0c..5df02d7a6f 100644 --- a/src/components/views/dialogs/IncomingSasDialog.js +++ b/src/components/views/dialogs/IncomingSasDialog.js @@ -130,7 +130,7 @@ export default class IncomingSasDialog extends React.Component { const oppProfile = this.state.opponentProfile; if (oppProfile) { const url = oppProfile.avatar_url - ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(Math.floor(48 * window.devicePixelRatio)) + ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48) : null; profile =
diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index cbced07bfe..fcacae2d39 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -34,16 +34,15 @@ import {RoomPermalinkCreator} from "../../../utils/permalinks/Permalinks" import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {normalizeWheelEvent} from "../../../utils/Mouse"; -const MIN_ZOOM = 100; -const MAX_ZOOM = 300; +// Max scale to keep gaps around the image +const MAX_SCALE = 0.95; // This is used for the buttons -const ZOOM_STEP = 10; +const ZOOM_STEP = 0.10; // This is used for mouse wheel events -const ZOOM_COEFFICIENT = 0.5; +const ZOOM_COEFFICIENT = 0.0025; // If we have moved only this much we can zoom const ZOOM_DISTANCE = 10; - interface IProps { src: string, // the source of the image being displayed name?: string, // the main title ('name') for the image @@ -62,8 +61,10 @@ interface IProps { } interface IState { - rotation: number, zoom: number, + minZoom: number, + maxZoom: number, + rotation: number, translationX: number, translationY: number, moving: boolean, @@ -75,8 +76,10 @@ export default class ImageView extends React.Component { constructor(props) { super(props); this.state = { + zoom: 0, + minZoom: MAX_SCALE, + maxZoom: MAX_SCALE, rotation: 0, - zoom: MIN_ZOOM, translationX: 0, translationY: 0, moving: false, @@ -87,6 +90,8 @@ export default class ImageView extends React.Component { // XXX: Refs to functional components private contextMenuButton = createRef(); private focusLock = createRef(); + private imageWrapper = createRef(); + private image = createRef(); private initX = 0; private initY = 0; @@ -99,12 +104,87 @@ export default class ImageView extends React.Component { // We have to use addEventListener() because the listener // needs to be passive in order to work with Chromium this.focusLock.current.addEventListener('wheel', this.onWheel, { passive: false }); + // We want to recalculate zoom whenever the window's size changes + window.addEventListener("resize", this.calculateZoom); + // After the image loads for the first time we want to calculate the zoom + this.image.current.addEventListener("load", this.calculateZoom); + // Try to precalculate the zoom from width and height props + this.calculateZoom(); } componentWillUnmount() { this.focusLock.current.removeEventListener('wheel', this.onWheel); } + private calculateZoom = () => { + const image = this.image.current; + const imageWrapper = this.imageWrapper.current; + + const width = this.props.width || image.naturalWidth; + const height = this.props.height || image.naturalHeight; + + const zoomX = imageWrapper.clientWidth / width; + const zoomY = imageWrapper.clientHeight / height; + + // If the image is smaller in both dimensions set its the zoom to 1 to + // display it in its original size + if (zoomX >= 1 && zoomY >= 1) { + this.setState({ + zoom: 1, + minZoom: 1, + maxZoom: 1, + }); + return; + } + // We set minZoom to the min of the zoomX and zoomY to avoid overflow in + // any direction. We also multiply by MAX_SCALE to get a gap around the + // image by default + const minZoom = Math.min(zoomX, zoomY) * MAX_SCALE; + + if (this.state.zoom <= this.state.minZoom) this.setState({zoom: minZoom}); + this.setState({ + minZoom: minZoom, + maxZoom: 1, + }); + } + + private zoom(delta: number) { + const newZoom = this.state.zoom + delta; + + if (newZoom <= this.state.minZoom) { + this.setState({ + zoom: this.state.minZoom, + translationX: 0, + translationY: 0, + }); + return; + } + if (newZoom >= this.state.maxZoom) { + this.setState({zoom: this.state.maxZoom}); + return; + } + + this.setState({ + zoom: newZoom, + }); + } + + private onWheel = (ev: WheelEvent) => { + ev.stopPropagation(); + ev.preventDefault(); + + const {deltaY} = normalizeWheelEvent(ev); + this.zoom(-(deltaY * ZOOM_COEFFICIENT)); + }; + + private onZoomInClick = () => { + this.zoom(ZOOM_STEP); + }; + + private onZoomOutClick = () => { + this.zoom(-ZOOM_STEP); + }; + private onKeyDown = (ev: KeyboardEvent) => { if (ev.key === Key.ESCAPE) { ev.stopPropagation(); @@ -113,31 +193,6 @@ export default class ImageView extends React.Component { } }; - private onWheel = (ev: WheelEvent) => { - ev.stopPropagation(); - ev.preventDefault(); - - const {deltaY} = normalizeWheelEvent(ev); - const newZoom = this.state.zoom - (deltaY * ZOOM_COEFFICIENT); - - if (newZoom <= MIN_ZOOM) { - this.setState({ - zoom: MIN_ZOOM, - translationX: 0, - translationY: 0, - }); - return; - } - if (newZoom >= MAX_ZOOM) { - this.setState({zoom: MAX_ZOOM}); - return; - } - - this.setState({ - zoom: newZoom, - }); - }; - private onRotateCounterClockwiseClick = () => { const cur = this.state.rotation; const rotationDegrees = cur - 90; @@ -150,31 +205,6 @@ export default class ImageView extends React.Component { this.setState({ rotation: rotationDegrees }); }; - private onZoomInClick = () => { - if (this.state.zoom >= MAX_ZOOM) { - this.setState({zoom: MAX_ZOOM}); - return; - } - - this.setState({ - zoom: this.state.zoom + ZOOM_STEP, - }); - }; - - private onZoomOutClick = () => { - if (this.state.zoom <= MIN_ZOOM) { - this.setState({ - zoom: MIN_ZOOM, - translationX: 0, - translationY: 0, - }); - return; - } - this.setState({ - zoom: this.state.zoom - ZOOM_STEP, - }); - }; - private onDownloadClick = () => { const a = document.createElement("a"); a.href = this.props.src; @@ -217,8 +247,8 @@ export default class ImageView extends React.Component { if (ev.button !== 0) return; // Zoom in if we are completely zoomed out - if (this.state.zoom === MIN_ZOOM) { - this.setState({zoom: MAX_ZOOM}); + if (this.state.zoom === this.state.minZoom) { + this.setState({zoom: this.state.maxZoom}); return; } @@ -251,7 +281,7 @@ export default class ImageView extends React.Component { Math.abs(this.state.translationY - this.previousY) < ZOOM_DISTANCE ) { this.setState({ - zoom: MIN_ZOOM, + zoom: this.state.minZoom, translationX: 0, translationY: 0, }); @@ -286,17 +316,20 @@ export default class ImageView extends React.Component { render() { const showEventMeta = !!this.props.mxEvent; + const zoomingDisabled = this.state.maxZoom === this.state.minZoom; let cursor; if (this.state.moving) { cursor= "grabbing"; - } else if (this.state.zoom === MIN_ZOOM) { + } else if (zoomingDisabled) { + cursor = "default"; + } else if (this.state.zoom === this.state.minZoom) { cursor = "zoom-in"; } else { cursor = "zoom-out"; } const rotationDegrees = this.state.rotation + "deg"; - const zoomPercentage = this.state.zoom/100; + const zoom = this.state.zoom; const translatePixelsX = this.state.translationX + "px"; const translatePixelsY = this.state.translationY + "px"; // The order of the values is important! @@ -308,7 +341,7 @@ export default class ImageView extends React.Component { transition: this.state.moving ? null : "transform 200ms ease 0s", transform: `translateX(${translatePixelsX}) translateY(${translatePixelsY}) - scale(${zoomPercentage}) + scale(${zoom}) rotate(${rotationDegrees})`, }; @@ -380,6 +413,25 @@ export default class ImageView extends React.Component { ); } + let zoomOutButton; + let zoomInButton; + if (!zoomingDisabled) { + zoomOutButton = ( + + + ); + zoomInButton = ( + + + ); + } + return ( { title={_t("Rotate Left")} onClick={ this.onRotateCounterClockwiseClick }> - - - - + {zoomOutButton} + {zoomInButton} { {this.renderContextMenu()}
-
+
; } - let serverName = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl; + let serverName: React.ReactNode = serverConfig.isNameResolvable ? serverConfig.hsName : serverConfig.hsUrl; if (serverConfig.hsNameIsDifferent) { serverName =
- +
); @@ -1032,13 +1064,14 @@ export default class EventTile extends React.Component { return (
- +
+
); @@ -1091,7 +1125,7 @@ export default class EventTile extends React.Component { this.props.mxEvent, this.props.onHeightChanged, this.props.permalinkCreator, - this._replyThread, + this.replyThread, this.props.layout, ); @@ -1105,15 +1139,16 @@ export default class EventTile extends React.Component { { groupTimestamp } { groupPadlock } { thread } - + { keyRequestInfo } { reactionsRow } { actionBar } @@ -1182,18 +1217,26 @@ function E2ePadlockUnknown(props) { function E2ePadlockUnauthenticated(props) { return ( - + ); } -class E2ePadlock extends React.Component { - static propTypes = { - icon: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - }; +interface IE2ePadlockProps { + icon: string; + title: string; +} - constructor() { - super(); +interface IE2ePadlockState { + hover: boolean; +} + +class E2ePadlock extends React.Component { + constructor(props) { + super(props); this.state = { hover: false, @@ -1211,14 +1254,13 @@ class E2ePadlock extends React.Component { render() { let tooltip = null; if (this.state.hover) { - tooltip = ; + tooltip = ; } const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`; return (
{tooltip}
@@ -1235,8 +1277,8 @@ interface ISentReceiptState { } class SentReceipt extends React.PureComponent { - constructor() { - super(); + constructor(props) { + super(props); this.state = { hover: false, @@ -1253,11 +1295,19 @@ class SentReceipt extends React.PureComponent; + } + let tooltip = null; if (this.state.hover) { let label = _t("Sending your message..."); @@ -1265,6 +1315,8 @@ class SentReceipt extends React.PureComponent + {nonCssBadge} {tooltip} ; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.tsx similarity index 80% rename from src/components/views/rooms/MessageComposer.js rename to src/components/views/rooms/MessageComposer.tsx index 5178d52305..3671069903 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.tsx @@ -1,5 +1,5 @@ /* -Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C. +Copyright 2015-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. @@ -13,15 +13,18 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import React, {createRef} from 'react'; +import React from 'react'; import classNames from 'classnames'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import {MatrixClientPeg} from '../../../MatrixClientPeg'; import * as sdk from '../../../index'; +import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {Room} from "matrix-js-sdk/src/models/room"; +import {RoomMember} from "matrix-js-sdk/src/models/room-member"; import dis from '../../../dispatcher/dispatcher'; +import { ActionPayload } from "../../../dispatcher/payloads"; import Stickerpicker from './Stickerpicker'; -import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks'; +import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import ContentMessages from '../../../ContentMessages'; import E2EIcon from './E2EIcon'; import SettingsStore from "../../../settings/SettingsStore"; @@ -35,19 +38,26 @@ import VoiceRecordComposerTile from "./VoiceRecordComposerTile"; import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore"; import {RecordingState} from "../../../voice/VoiceRecording"; import Tooltip, {Alignment} from "../elements/Tooltip"; +import ResizeNotifier from "../../../utils/ResizeNotifier"; +import { E2EStatus } from '../../../utils/ShieldUtils'; +import SendMessageComposer from "./SendMessageComposer"; -function ComposerAvatar(props) { +interface IComposerAvatarProps { + me: object; +} + +function ComposerAvatar(props: IComposerAvatarProps) { const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); return
; } -ComposerAvatar.propTypes = { - me: PropTypes.object.isRequired, -}; +interface ISendButtonProps { + onClick: () => void; +} -function SendButton(props) { +function SendButton(props: ISendButtonProps) { return ( { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -68,7 +74,7 @@ const EmojiButton = ({addEmoji}) => { if (menuDisplayed) { const buttonRect = button.current.getBoundingClientRect(); const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker'); - contextMenu = + contextMenu = ; } @@ -98,39 +104,39 @@ const EmojiButton = ({addEmoji}) => { ; }; -class UploadButton extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - } +interface IUploadButtonProps { + roomId: string; +} + +class UploadButton extends React.Component { + private uploadInput = React.createRef(); + private dispatcherRef: string; constructor(props) { super(props); - this.onUploadClick = this.onUploadClick.bind(this); - this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this); - this._uploadInput = createRef(); - this._dispatcherRef = dis.register(this.onAction); + this.dispatcherRef = dis.register(this.onAction); } componentWillUnmount() { - dis.unregister(this._dispatcherRef); + dis.unregister(this.dispatcherRef); } - onAction = payload => { + private onAction = (payload: ActionPayload) => { if (payload.action === "upload_file") { this.onUploadClick(); } }; - onUploadClick(ev) { + private onUploadClick = () => { if (MatrixClientPeg.get().isGuest()) { dis.dispatch({action: 'require_registration'}); return; } - this._uploadInput.current.click(); + this.uploadInput.current.click(); } - onUploadFileInputChange(ev) { + private onUploadFileInputChange = (ev: React.ChangeEvent) => { if (ev.target.files.length === 0) return; // take a copy so we can safely reset the value of the form control @@ -160,7 +166,7 @@ class UploadButton extends React.Component { title={_t('Upload file')} > { + private dispatcherRef: string; + private messageComposerInput: SendMessageComposer; + private voiceRecordingButton: VoiceRecordComposerTile; + constructor(props) { super(props); - this.onInputStateChanged = this.onInputStateChanged.bind(this); - this._onRoomStateEvents = this._onRoomStateEvents.bind(this); - this._onTombstoneClick = this._onTombstoneClick.bind(this); - this.renderPlaceholderText = this.renderPlaceholderText.bind(this); - VoiceRecordingStore.instance.on(UPDATE_EVENT, this._onVoiceStoreUpdate); - this._dispatcherRef = null; + VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate); this.state = { - tombstone: this._getRoomTombstone(), + tombstone: this.getRoomTombstone(), canSendMessages: this.props.room.maySendMessage(), isComposerEmpty: true, haveRecording: false, @@ -191,7 +213,13 @@ export default class MessageComposer extends React.Component { }; } - onAction = (payload) => { + componentDidMount() { + this.dispatcherRef = dis.register(this.onAction); + MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); + this.waitForOwnMember(); + } + + private onAction = (payload: ActionPayload) => { if (payload.action === 'reply_to_event') { // add a timeout for the reply preview to be rendered, so // that the ScrollPanel listening to the resizeNotifier can @@ -203,13 +231,7 @@ export default class MessageComposer extends React.Component { } }; - componentDidMount() { - this.dispatcherRef = dis.register(this.onAction); - MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents); - this._waitForOwnMember(); - } - - _waitForOwnMember() { + private waitForOwnMember() { // if we have the member already, do that const me = this.props.room.getMember(MatrixClientPeg.get().getUserId()); if (me) { @@ -227,34 +249,28 @@ export default class MessageComposer extends React.Component { componentWillUnmount() { if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents); + MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents); } - VoiceRecordingStore.instance.off(UPDATE_EVENT, this._onVoiceStoreUpdate); + VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate); dis.unregister(this.dispatcherRef); } - _onRoomStateEvents(ev, state) { + private onRoomStateEvents = (ev, state) => { if (ev.getRoomId() !== this.props.room.roomId) return; if (ev.getType() === 'm.room.tombstone') { - this.setState({tombstone: this._getRoomTombstone()}); + this.setState({tombstone: this.getRoomTombstone()}); } if (ev.getType() === 'm.room.power_levels') { this.setState({canSendMessages: this.props.room.maySendMessage()}); } } - _getRoomTombstone() { + private getRoomTombstone() { return this.props.room.currentState.getStateEvents('m.room.tombstone', ''); } - onInputStateChanged(inputState) { - // Merge the new input state with old to support partial updates - inputState = Object.assign({}, this.state.inputState, inputState); - this.setState({inputState}); - } - - _onTombstoneClick(ev) { + private onTombstoneClick = (ev) => { ev.preventDefault(); const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; @@ -284,7 +300,7 @@ export default class MessageComposer extends React.Component { }); } - renderPlaceholderText() { + private renderPlaceholderText = () => { if (this.props.replyToEvent) { if (this.props.e2eStatus) { return _t('Send an encrypted reply…'); @@ -307,7 +323,15 @@ export default class MessageComposer extends React.Component { }); } - sendMessage = () => { + sendMessage = async () => { + if (this.state.haveRecording && this.voiceRecordingButton) { + // There shouldn't be any text message to send when a voice recording is active, so + // just send out the voice recording. + await this.voiceRecordingButton.send(); + return; + } + + // XXX: Private function access this.messageComposerInput._sendMessage(); } @@ -317,7 +341,7 @@ export default class MessageComposer extends React.Component { }); } - _onVoiceStoreUpdate = () => { + private onVoiceStoreUpdate = () => { const recording = VoiceRecordingStore.instance.activeRecording; this.setState({haveRecording: !!recording}); if (recording) { @@ -372,6 +396,7 @@ export default class MessageComposer extends React.Component { if (SettingsStore.getValue("feature_voice_messages")) { controls.push( this.voiceRecordingButton = c} room={this.props.room} />); } @@ -386,7 +411,7 @@ export default class MessageComposer extends React.Component { const continuesLink = replacementRoomId ? (
{_t("The conversation continues here.")} @@ -394,7 +419,9 @@ export default class MessageComposer extends React.Component { controls.push(
- + {_t("This room has been replaced and is no longer active.")}
@@ -431,14 +458,3 @@ export default class MessageComposer extends React.Component { ); } } - -MessageComposer.propTypes = { - // js-sdk Room object - room: PropTypes.object.isRequired, - - // string representing the current voip call state - callState: PropTypes.string, - - // string representing the current room app drawer state - showApps: PropTypes.bool, -}; diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 36a52e260d..4b843bfc29 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -30,7 +30,7 @@ interface IProps { * If true, the badge will show a count if at all possible. This is typically * used to override the user's preference for things like room sublists. */ - forceCount: boolean; + forceCount?: boolean; /** * The room ID, if any, the badge represents. diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index e4e638fc67..a155e1b5cd 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017, 2018 Vector Creations Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2018, 2020, 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. @@ -37,7 +35,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg"; import GroupAvatar from "../avatars/GroupAvatar"; import ExtraTile from "./ExtraTile"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; -import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { Action } from "../../../dispatcher/actions"; import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; @@ -492,7 +489,7 @@ export default class RoomList extends React.PureComponent { isSelected={false} displayName={g.name} avatar={avatar} - notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)} + notificationState={StaticNotificationState.RED_EXCLAMATION} onClick={openGroup} key={`temporaryGroupTile_${g.groupId}`} /> diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index a726ab99fc..fb367349a2 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -763,7 +763,9 @@ export default class RoomSublist extends React.Component { 'mx_RoomSublist': true, 'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition, 'mx_RoomSublist_minimized': this.props.isMinimized, - 'mx_RoomSublist_hidden': !this.state.rooms.length && this.props.alwaysVisible !== true, + 'mx_RoomSublist_hidden': ( + !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true + ), }); let content = null; diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index b2a07d7e06..8521992fa1 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -1,8 +1,6 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd Copyright 2018 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2015-2017, 2019-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. @@ -19,6 +17,7 @@ limitations under the License. import React, { createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import classNames from "classnames"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; @@ -51,7 +50,9 @@ import IconizedContextMenu, { IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore"; -import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getUnsentMessages } from "../../structures/RoomStatusBar"; +import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; interface IProps { room: Room; @@ -67,6 +68,7 @@ interface IState { notificationsMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect; messagePreview?: string; + hasUnsentEvents: boolean; } const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; @@ -93,6 +95,7 @@ export default class RoomTile extends React.PureComponent { selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, + hasUnsentEvents: this.countUnsentEvents() > 0, // generatePreview() will return nothing if the user has previews disabled messagePreview: this.generatePreview(), @@ -101,6 +104,10 @@ export default class RoomTile extends React.PureComponent { this.roomProps = EchoChamber.forRoom(this.props.room); } + private countUnsentEvents(): number { + return getUnsentMessages(this.props.room).length; + } + private onRoomNameUpdate = (room) => { this.forceUpdate(); } @@ -109,6 +116,11 @@ export default class RoomTile extends React.PureComponent { this.forceUpdate(); // notification state changed - update }; + private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => { + if (!room?.roomId === this.props.room.roomId) return; + this.setState({hasUnsentEvents: this.countUnsentEvents() > 0}); + }; + private onRoomPropertyUpdate = (property: CachedRoomKey) => { if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); // else ignore - not important for this tile @@ -167,6 +179,7 @@ export default class RoomTile extends React.PureComponent { CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), this.onCommunityUpdate, ); + MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); } public componentWillUnmount() { @@ -191,6 +204,7 @@ export default class RoomTile extends React.PureComponent { CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), this.onCommunityUpdate, ); + MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); } private onAction = (payload: ActionPayload) => { @@ -554,17 +568,30 @@ export default class RoomTile extends React.PureComponent { />; let badge: React.ReactNode; - if (!this.props.isMinimized && this.notificationState) { + if (!this.props.isMinimized) { // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below - badge = ( - - ); + if (this.state.hasUnsentEvents) { + // hardcode the badge to a danger state when there's unsent messages + badge = ( + + ); + } else if (this.notificationState) { + badge = ( + + ); + } } let messagePreview = null; diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index 0d3a174766..d60eba00ec 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -506,9 +506,8 @@ export default class SendMessageComposer extends React.Component { member.rawDisplayName : userId; const caret = this._editorRef.getCaret(); const position = model.positionForOffset(caret.offset, caret.atNodeEnd); - // index is -1 if there are no parts but we only care for if this would be the part in position 0 - const insertIndex = position.index > 0 ? position.index : 0; - const parts = partCreator.createMentionParts(insertIndex, displayName, userId); + // Insert suffix only if the caret is at the start of the composer + const parts = partCreator.createMentionParts(caret.offset === 0, displayName, userId); model.transform(() => { const addedLen = model.insert(parts, position); return model.positionForOffset(caret.offset + addedLen, true); diff --git a/src/components/views/rooms/ThirdPartyMemberInfo.js b/src/components/views/rooms/ThirdPartyMemberInfo.tsx similarity index 94% rename from src/components/views/rooms/ThirdPartyMemberInfo.js rename to src/components/views/rooms/ThirdPartyMemberInfo.tsx index 67b86538c9..78fdc8b404 100644 --- a/src/components/views/rooms/ThirdPartyMemberInfo.js +++ b/src/components/views/rooms/ThirdPartyMemberInfo.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd. +Copyright 2019-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. @@ -15,9 +15,9 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixEvent} from "matrix-js-sdk/src/models/event"; +import {Room} from "matrix-js-sdk/src/models/room"; import {_t} from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import * as sdk from "../../../index"; @@ -27,11 +27,22 @@ import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +interface IProps { + event: MatrixEvent; +} + +interface IState { + stateKey: string; + roomId: string; + displayName: string; + invited: boolean; + canKick: boolean; + senderName: string; +} + @replaceableComponent("views.rooms.ThirdPartyMemberInfo") -export default class ThirdPartyMemberInfo extends React.Component { - static propTypes = { - event: PropTypes.instanceOf(MatrixEvent).isRequired, - }; +export default class ThirdPartyMemberInfo extends React.Component { + private room: Room; constructor(props) { super(props); diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx index 9b7f0da472..bab95291ba 100644 --- a/src/components/views/rooms/VoiceRecordComposerTile.tsx +++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx @@ -16,8 +16,8 @@ limitations under the License. import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import {_t} from "../../../languageHandler"; -import React from "react"; -import {VoiceRecording} from "../../../voice/VoiceRecording"; +import React, {ReactNode} from "react"; +import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording"; import {Room} from "matrix-js-sdk/src/models/room"; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import classNames from "classnames"; @@ -25,6 +25,8 @@ import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import LiveRecordingClock from "../voice_messages/LiveRecordingClock"; import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import RecordingPlayback from "../voice_messages/RecordingPlayback"; interface IProps { room: Room; @@ -32,6 +34,7 @@ interface IProps { interface IState { recorder?: VoiceRecording; + recordingPhase?: RecordingState; } /** @@ -43,87 +46,141 @@ export default class VoiceRecordComposerTile extends React.PureComponent { - // TODO: @@ TravisR: We do not want to auto-send on stop. + public async componentWillUnmount() { + await VoiceRecordingStore.instance.disposeRecording(); + } + + // called by composer + public async send() { + if (!this.state.recorder) { + throw new Error("No recording started - cannot send anything"); + } + + await this.state.recorder.stop(); + const mxc = await this.state.recorder.upload(); + MatrixClientPeg.get().sendMessage(this.props.room.roomId, { + "body": "Voice message", + "msgtype": "org.matrix.msc2516.voice", + //"msgtype": MsgType.Audio, + "url": mxc, + "info": { + duration: Math.round(this.state.recorder.durationSeconds * 1000), + mimetype: this.state.recorder.contentType, + size: this.state.recorder.contentLength, + }, + + // MSC1767 experiment + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc1767.file": { + url: mxc, + name: "Voice message.ogg", + mimetype: this.state.recorder.contentType, + size: this.state.recorder.contentLength, + }, + "org.matrix.msc1767.audio": { + duration: Math.round(this.state.recorder.durationSeconds * 1000), + // TODO: @@ TravisR: Waveform? (MSC1767 decision) + }, + "org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment + duration: Math.round(this.state.recorder.durationSeconds * 1000), + + // Events can't have floats, so we try to maintain resolution by using 1024 + // as a maximum value. The waveform contains values between zero and 1, so this + // should come out largely sane. + // + // We're expecting about one data point per second of audio. + waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)), + }, + }); + await this.disposeRecording(); + } + + private async disposeRecording() { + await VoiceRecordingStore.instance.disposeRecording(); + + // Reset back to no recording, which means no phase (ie: restart component entirely) + this.setState({recorder: null, recordingPhase: null}); + } + + private onCancel = async () => { + await this.disposeRecording(); + }; + + private onRecordStartEndClick = async () => { if (this.state.recorder) { await this.state.recorder.stop(); - const mxc = await this.state.recorder.upload(); - MatrixClientPeg.get().sendMessage(this.props.room.roomId, { - "body": "Voice message", - "msgtype": "org.matrix.msc2516.voice", - //"msgtype": MsgType.Audio, - "url": mxc, - "info": { - duration: Math.round(this.state.recorder.durationSeconds * 1000), - mimetype: this.state.recorder.contentType, - size: this.state.recorder.contentLength, - }, - - // MSC1767 experiment - "org.matrix.msc1767.text": "Voice message", - "org.matrix.msc1767.file": { - url: mxc, - name: "Voice message.ogg", - mimetype: this.state.recorder.contentType, - size: this.state.recorder.contentLength, - }, - "org.matrix.msc1767.audio": { - duration: Math.round(this.state.recorder.durationSeconds * 1000), - // TODO: @@ TravisR: Waveform? (MSC1767 decision) - }, - "org.matrix.experimental.msc2516.voice": { // MSC2516+MSC1767 experiment - duration: Math.round(this.state.recorder.durationSeconds * 1000), - - // Events can't have floats, so we try to maintain resolution by using 1024 - // as a maximum value. The waveform contains values between zero and 1, so this - // should come out largely sane. - // - // We're expecting about one data point per second of audio. - waveform: this.state.recorder.finalWaveform.map(v => Math.round(v * 1024)), - }, - }); - await VoiceRecordingStore.instance.disposeRecording(); - this.setState({recorder: null}); return; } const recorder = VoiceRecordingStore.instance.startRecording(); await recorder.start(); - this.setState({recorder}); + + // We don't need to remove the listener: the recorder will clean that up for us. + recorder.on(UPDATE_EVENT, (ev: RecordingState) => { + if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here + this.setState({recordingPhase: ev}); + }); + + this.setState({recorder, recordingPhase: RecordingState.Started}); }; - private renderWaveformArea() { - if (!this.state.recorder) return null; + private renderWaveformArea(): ReactNode { + if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform - return
+ if (this.state.recordingPhase !== RecordingState.Started) { + // TODO: @@ TR: Should we disable this during upload? What does a failed upload look like? + return ; + } + + // only other UI is the recording-in-progress UI + return
; } - public render() { - const classes = classNames({ - 'mx_MessageComposer_button': !this.state.recorder, - 'mx_MessageComposer_voiceMessage': !this.state.recorder, - 'mx_VoiceRecordComposerTile_stop': !!this.state.recorder, - }); + public render(): ReactNode { + let recordingInfo; + let deleteButton; + if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) { + const classes = classNames({ + 'mx_MessageComposer_button': !this.state.recorder, + 'mx_MessageComposer_voiceMessage': !this.state.recorder, + 'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording, + }); - let tooltip = _t("Record a voice message"); - if (!!this.state.recorder) { - // TODO: @@ TravisR: Change to match behaviour - tooltip = _t("Stop & send recording"); + let tooltip = _t("Record a voice message"); + if (!!this.state.recorder) { + tooltip = _t("Stop the recording"); + } + + let stopOrRecordBtn = ; + if (this.state.recorder && !this.state.recorder?.isRecording) { + stopOrRecordBtn = null; + } + + recordingInfo = stopOrRecordBtn; + } + + if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) { + deleteButton = ; } return (<> + {deleteButton} {this.renderWaveformArea()} - + {recordingInfo} ); } } diff --git a/src/components/views/settings/E2eAdvancedPanel.js b/src/components/views/settings/E2eAdvancedPanel.tsx similarity index 100% rename from src/components/views/settings/E2eAdvancedPanel.js rename to src/components/views/settings/E2eAdvancedPanel.tsx diff --git a/src/components/views/settings/EventIndexPanel.js b/src/components/views/settings/EventIndexPanel.tsx similarity index 65% rename from src/components/views/settings/EventIndexPanel.js rename to src/components/views/settings/EventIndexPanel.tsx index d1a02de16d..fa84063ee8 100644 --- a/src/components/views/settings/EventIndexPanel.js +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020-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. @@ -28,10 +28,17 @@ import {SettingLevel} from "../../../settings/SettingLevel"; import {replaceableComponent} from "../../../utils/replaceableComponent"; import SeshatResetDialog from '../dialogs/SeshatResetDialog'; +interface IState { + enabling: boolean; + eventIndexSize: number; + roomCount: number; + eventIndexingEnabled: boolean; +} + @replaceableComponent("views.settings.EventIndexPanel") -export default class EventIndexPanel extends React.Component { - constructor() { - super(); +export default class EventIndexPanel extends React.Component<{}, IState> { + constructor(props) { + super(props); this.state = { enabling: false, @@ -68,7 +75,7 @@ export default class EventIndexPanel extends React.Component { } } - async componentDidMount(): void { + componentDidMount(): void { this.updateState(); } @@ -102,8 +109,10 @@ export default class EventIndexPanel extends React.Component { }); } - _onManage = async () => { + private onManage = async () => { Modal.createTrackedDialogAsync('Message search', 'Message search', + // @ts-ignore: TS doesn't seem to like the type of this now that it + // has also been converted to TS as well, but I can't figure out why... import('../../../async-components/views/dialogs/eventindex/ManageEventIndexDialog'), { onFinished: () => {}, @@ -111,7 +120,7 @@ export default class EventIndexPanel extends React.Component { ); } - _onEnable = async () => { + private onEnable = async () => { this.setState({ enabling: true, }); @@ -123,14 +132,13 @@ export default class EventIndexPanel extends React.Component { await this.updateState(); } - _confirmEventStoreReset = () => { - const self = this; + private confirmEventStoreReset = () => { const { close } = Modal.createDialog(SeshatResetDialog, { onFinished: async (success) => { if (success) { await SettingsStore.setValue('enableEventIndexing', null, SettingLevel.DEVICE, false); await EventIndexPeg.deleteEventIndex(); - await self._onEnable(); + await this.onEnable(); close(); } }, @@ -145,20 +153,19 @@ export default class EventIndexPanel extends React.Component { if (EventIndexPeg.get() !== null) { eventIndexingSettings = (
-
- {_t("Securely cache encrypted messages locally for them " + - "to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", - { - size: formatBytes(this.state.eventIndexSize, 0), - // This drives the singular / plural string - // selection for "room" / "rooms" only. - count: this.state.roomCount, - rooms: formatCountLong(this.state.roomCount), - }, - )} -
+
{_t( + "Securely cache encrypted messages locally for them " + + "to appear in search results, using %(size)s to store messages from %(rooms)s rooms.", + { + size: formatBytes(this.state.eventIndexSize, 0), + // This drives the singular / plural string + // selection for "room" / "rooms" only. + count: this.state.roomCount, + rooms: formatCountLong(this.state.roomCount), + }, + )}
- + {_t("Manage")}
@@ -167,13 +174,13 @@ export default class EventIndexPanel extends React.Component { } else if (!this.state.eventIndexingEnabled && EventIndexPeg.supportIsInstalled()) { eventIndexingSettings = (
-
- {_t( "Securely cache encrypted messages locally for them to " + - "appear in search results.")} -
+
{_t( + "Securely cache encrypted messages locally for them to " + + "appear in search results.", + )}
+ onClick={this.onEnable}> {_t("Enable")} {this.state.enabling ? :
} @@ -188,40 +195,36 @@ export default class EventIndexPanel extends React.Component { ); eventIndexingSettings = ( -
+
{_t( + "%(brand)s is missing some components required for securely " + + "caching encrypted messages locally. If you'd like to " + + "experiment with this feature, build a custom %(brand)s Desktop " + + "with search components added.", { - _t( "%(brand)s is missing some components required for securely " + - "caching encrypted messages locally. If you'd like to " + - "experiment with this feature, build a custom %(brand)s Desktop " + - "with search components added.", - { - brand, - }, - { - 'nativeLink': (sub) => {sub}, - }, - ) - } -
+ brand, + }, + { + nativeLink: sub => {sub}, + }, + )}
); } else if (!EventIndexPeg.platformHasSupport()) { eventIndexingSettings = ( -
+
{_t( + "%(brand)s can't securely cache encrypted messages locally " + + "while running in a web browser. Use %(brand)s Desktop " + + "for encrypted messages to appear in search results.", { - _t( "%(brand)s can't securely cache encrypted messages locally " + - "while running in a web browser. Use %(brand)s Desktop " + - "for encrypted messages to appear in search results.", - { - brand, - }, - { - 'desktopLink': (sub) => {sub}, - }, - ) - } -
+ brand, + }, + { + desktopLink: sub => {sub}, + }, + )}
); } else { eventIndexingSettings = ( @@ -233,19 +236,18 @@ export default class EventIndexPanel extends React.Component { }

{EventIndexPeg.error && ( -
- {_t("Advanced")} - - {EventIndexPeg.error.message} - -

- - {_t("Reset")} - -

-
+
+ {_t("Advanced")} + + {EventIndexPeg.error.message} + +

+ + {_t("Reset")} + +

+
)} -
); } diff --git a/src/components/views/settings/SetIdServer.js b/src/components/views/settings/SetIdServer.tsx similarity index 89% rename from src/components/views/settings/SetIdServer.js rename to src/components/views/settings/SetIdServer.tsx index fa2a36476d..05d1f83387 100644 --- a/src/components/views/settings/SetIdServer.js +++ b/src/components/views/settings/SetIdServer.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -16,7 +16,6 @@ limitations under the License. import url from 'url'; import React from 'react'; -import PropTypes from 'prop-types'; import {_t} from "../../../languageHandler"; import * as sdk from '../../../index'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; @@ -28,6 +27,7 @@ import {abbreviateUrl, unabbreviateUrl} from "../../../utils/UrlUtils"; import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from '../../../utils/IdentityServerUtils'; import {timeout} from "../../../utils/promise"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { ActionPayload } from '../../../dispatcher/payloads'; // We'll wait up to this long when checking for 3PID bindings on the IS. const REACHABILITY_TIMEOUT = 10000; // ms @@ -59,16 +59,28 @@ async function checkIdentityServerUrl(u) { } } -@replaceableComponent("views.settings.SetIdServer") -export default class SetIdServer extends React.Component { - static propTypes = { - // Whether or not the ID server is missing terms. This affects the text - // shown to the user. - missingTerms: PropTypes.bool, - }; +interface IProps { + // Whether or not the ID server is missing terms. This affects the text + // shown to the user. + missingTerms: boolean; +} - constructor() { - super(); +interface IState { + defaultIdServer?: string; + currentClientIdServer: string; + idServer?: string; + error?: string; + busy: boolean; + disconnectBusy: boolean; + checking: boolean; +} + +@replaceableComponent("views.settings.SetIdServer") +export default class SetIdServer extends React.Component { + private dispatcherRef: string; + + constructor(props) { + super(props); let defaultIdServer = ''; if (!MatrixClientPeg.get().getIdentityServerUrl() && getDefaultIdentityServerUrl()) { @@ -96,7 +108,7 @@ export default class SetIdServer extends React.Component { dis.unregister(this.dispatcherRef); } - onAction = (payload) => { + private onAction = (payload: ActionPayload) => { // We react to changes in the ID server in the event the user is staring at this form // when changing their identity server on another device. if (payload.action !== "id_server_changed") return; @@ -106,13 +118,13 @@ export default class SetIdServer extends React.Component { }); }; - _onIdentityServerChanged = (ev) => { + private onIdentityServerChanged = (ev) => { const u = ev.target.value; this.setState({idServer: u}); }; - _getTooltip = () => { + private getTooltip = () => { if (this.state.checking) { const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); return
@@ -126,11 +138,11 @@ export default class SetIdServer extends React.Component { } }; - _idServerChangeEnabled = () => { + private idServerChangeEnabled = () => { return !!this.state.idServer && !this.state.busy; }; - _saveIdServer = (fullUrl) => { + private saveIdServer = (fullUrl) => { // Account data change will update localstorage, client, etc through dispatcher MatrixClientPeg.get().setAccountData("m.identity_server", { base_url: fullUrl, @@ -143,7 +155,7 @@ export default class SetIdServer extends React.Component { }); }; - _checkIdServer = async (e) => { + private checkIdServer = async (e) => { e.preventDefault(); const { idServer, currentClientIdServer } = this.state; @@ -166,14 +178,14 @@ export default class SetIdServer extends React.Component { // Double check that the identity server even has terms of service. const hasTerms = await doesIdentityServerHaveTerms(fullUrl); if (!hasTerms) { - const [confirmed] = await this._showNoTermsWarning(fullUrl); + const [confirmed] = await this.showNoTermsWarning(fullUrl); save = confirmed; } // Show a general warning, possibly with details about any bound // 3PIDs that would be left behind. if (save && currentClientIdServer && fullUrl !== currentClientIdServer) { - const [confirmed] = await this._showServerChangeWarning({ + const [confirmed] = await this.showServerChangeWarning({ title: _t("Change identity server"), unboundMessage: _t( "Disconnect from the identity server and " + @@ -189,7 +201,7 @@ export default class SetIdServer extends React.Component { } if (save) { - this._saveIdServer(fullUrl); + this.saveIdServer(fullUrl); } } catch (e) { console.error(e); @@ -204,7 +216,7 @@ export default class SetIdServer extends React.Component { }); }; - _showNoTermsWarning(fullUrl) { + private showNoTermsWarning(fullUrl) { const QuestionDialog = sdk.getComponent("views.dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog('No Terms Warning', '', QuestionDialog, { title: _t("Identity server has no terms of service"), @@ -223,10 +235,10 @@ export default class SetIdServer extends React.Component { return finished; } - _onDisconnectClicked = async () => { + private onDisconnectClicked = async () => { this.setState({disconnectBusy: true}); try { - const [confirmed] = await this._showServerChangeWarning({ + const [confirmed] = await this.showServerChangeWarning({ title: _t("Disconnect identity server"), unboundMessage: _t( "Disconnect from the identity server ?", {}, @@ -235,14 +247,14 @@ export default class SetIdServer extends React.Component { button: _t("Disconnect"), }); if (confirmed) { - this._disconnectIdServer(); + this.disconnectIdServer(); } } finally { this.setState({disconnectBusy: false}); } }; - async _showServerChangeWarning({ title, unboundMessage, button }) { + private async showServerChangeWarning({ title, unboundMessage, button }) { const { currentClientIdServer } = this.state; let threepids = []; @@ -318,7 +330,7 @@ export default class SetIdServer extends React.Component { return finished; } - _disconnectIdServer = () => { + private disconnectIdServer = () => { // Account data change will update localstorage, client, etc through dispatcher MatrixClientPeg.get().setAccountData("m.identity_server", { base_url: null, // clear @@ -371,7 +383,7 @@ export default class SetIdServer extends React.Component { let discoSection; if (idServerUrl) { - let discoButtonContent = _t("Disconnect"); + let discoButtonContent: React.ReactNode = _t("Disconnect"); let discoBodyText = _t( "Disconnecting from your identity server will mean you " + "won't be discoverable by other users and you won't be " + @@ -391,14 +403,14 @@ export default class SetIdServer extends React.Component { } discoSection =
{discoBodyText} - + {discoButtonContent}
; } return ( -
+ {sectionTitle} @@ -411,15 +423,15 @@ export default class SetIdServer extends React.Component { autoComplete="off" placeholder={this.state.defaultIdServer} value={this.state.idServer} - onChange={this._onIdentityServerChanged} - tooltipContent={this._getTooltip()} + onChange={this.onIdentityServerChanged} + tooltipContent={this.getTooltip()} tooltipClassName="mx_SetIdServer_tooltip" disabled={this.state.busy} forceValidity={this.state.error ? false : null} /> {_t("Change")} {discoSection}
diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx similarity index 89% rename from src/components/views/settings/tabs/room/RolesRoomSettingsTab.js rename to src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 59a175906d..4fa521f598 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019, 2021 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {_t, _td} from "../../../../../languageHandler"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import * as sdk from "../../../../.."; @@ -23,6 +22,9 @@ import AccessibleButton from "../../../elements/AccessibleButton"; import Modal from "../../../../../Modal"; import {replaceableComponent} from "../../../../../utils/replaceableComponent"; import {EventType} from "matrix-js-sdk/src/@types/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomState } from "matrix-js-sdk/src/models/room-state"; const plEventsToLabels = { // These will be translated for us later. @@ -63,15 +65,15 @@ function parseIntWithDefault(val, def) { return isNaN(res) ? def : res; } -export class BannedUser extends React.Component { - static propTypes = { - canUnban: PropTypes.bool, - member: PropTypes.object.isRequired, // js-sdk RoomMember - by: PropTypes.string.isRequired, - reason: PropTypes.string, - }; +interface IBannedUserProps { + canUnban?: boolean; + member: RoomMember; + by: string; + reason?: string; +} - _onUnbanClick = (e) => { +export class BannedUser extends React.Component { + private onUnbanClick = (e) => { MatrixClientPeg.get().unban(this.props.member.roomId, this.props.member.userId).catch((err) => { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to unban: " + err); @@ -87,8 +89,10 @@ export class BannedUser extends React.Component { if (this.props.canUnban) { unbanButton = ( - + { _t('Unban') } ); @@ -107,29 +111,29 @@ export class BannedUser extends React.Component { } } -@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab") -export default class RolesRoomSettingsTab extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - }; +interface IProps { + roomId: string; +} - componentDidMount(): void { - MatrixClientPeg.get().on("RoomState.members", this._onRoomMembership); +@replaceableComponent("views.settings.tabs.room.RolesRoomSettingsTab") +export default class RolesRoomSettingsTab extends React.Component { + componentDidMount() { + MatrixClientPeg.get().on("RoomState.members", this.onRoomMembership); } - componentWillUnmount(): void { + componentWillUnmount() { const client = MatrixClientPeg.get(); if (client) { - client.removeListener("RoomState.members", this._onRoomMembership); + client.removeListener("RoomState.members", this.onRoomMembership); } } - _onRoomMembership = (event, state, member) => { + private onRoomMembership = (event: MatrixEvent, state: RoomState, member: RoomMember) => { if (state.roomId !== this.props.roomId) return; this.forceUpdate(); }; - _populateDefaultPlEvents(eventsSection, stateLevel, eventsLevel) { + private populateDefaultPlEvents(eventsSection: Record, stateLevel: number, eventsLevel: number) { for (const desiredEvent of Object.keys(plEventsToShow)) { if (!(desiredEvent in eventsSection)) { eventsSection[desiredEvent] = (plEventsToShow[desiredEvent].isState ? stateLevel : eventsLevel); @@ -137,7 +141,7 @@ export default class RolesRoomSettingsTab extends React.Component { } } - _onPowerLevelsChanged = (value, powerLevelKey) => { + private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); @@ -148,7 +152,7 @@ export default class RolesRoomSettingsTab extends React.Component { const eventsLevelPrefix = "event_levels_"; - value = parseInt(value); + const value = parseInt(inputValue); if (powerLevelKey.startsWith(eventsLevelPrefix)) { // deep copy "events" object, Object.assign itself won't deep copy @@ -182,7 +186,7 @@ export default class RolesRoomSettingsTab extends React.Component { }); }; - _onUserPowerLevelChanged = (value, powerLevelKey) => { + private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); @@ -266,7 +270,7 @@ export default class RolesRoomSettingsTab extends React.Component { currentUserLevel = defaultUserLevel; } - this._populateDefaultPlEvents( + this.populateDefaultPlEvents( eventsLevels, parseIntWithDefault(plContent.state_default, powerLevelDescriptors.state_default.defaultValue), parseIntWithDefault(plContent.events_default, powerLevelDescriptors.events_default.defaultValue), @@ -288,7 +292,7 @@ export default class RolesRoomSettingsTab extends React.Component { label={user} key={user} powerLevelKey={user} // Will be sent as the second parameter to `onChange` - onChange={this._onUserPowerLevelChanged} + onChange={this.onUserPowerLevelChanged} />, ); } else if (userLevels[user] < defaultUserLevel) { // muted @@ -299,7 +303,7 @@ export default class RolesRoomSettingsTab extends React.Component { label={user} key={user} powerLevelKey={user} // Will be sent as the second parameter to `onChange` - onChange={this._onUserPowerLevelChanged} + onChange={this.onUserPowerLevelChanged} />, ); } @@ -345,8 +349,9 @@ export default class RolesRoomSettingsTab extends React.Component { if (sender) bannedBy = sender.name; return ( + member={member} reason={banEvent.reason} + by={bannedBy} + /> ); })} @@ -373,7 +378,7 @@ export default class RolesRoomSettingsTab extends React.Component { usersDefault={defaultUserLevel} disabled={!canChangeLevels || currentUserLevel < value} powerLevelKey={key} // Will be sent as the second parameter to `onChange` - onChange={this._onPowerLevelsChanged} + onChange={this.onPowerLevelsChanged} />
; }); @@ -398,7 +403,7 @@ export default class RolesRoomSettingsTab extends React.Component { usersDefault={defaultUserLevel} disabled={!canChangeLevels || currentUserLevel < eventsLevels[eventType]} powerLevelKey={"event_levels_" + eventType} - onChange={this._onPowerLevelsChanged} + onChange={this.onPowerLevelsChanged} />
); diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx similarity index 79% rename from src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js rename to src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index ce883c6d23..02bbcfb751 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.js +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019-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. @@ -15,7 +15,7 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import {_t} from "../../../../../languageHandler"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import * as sdk from "../../../../.."; @@ -26,64 +26,92 @@ import StyledRadioGroup from '../../../elements/StyledRadioGroup'; import {SettingLevel} from "../../../../../settings/SettingLevel"; import SettingsStore from "../../../../../settings/SettingsStore"; import {UIFeature} from "../../../../../settings/UIFeature"; -import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../../utils/replaceableComponent"; + +// Knock and private are reserved keywords which are not yet implemented. +enum JoinRule { + Public = "public", + Knock = "knock", + Invite = "invite", + Private = "private", +} + +enum GuestAccess { + CanJoin = "can_join", + Forbidden = "forbidden", +} + +enum HistoryVisibility { + Invited = "invited", + Joined = "joined", + Shared = "shared", + WorldReadable = "world_readable", +} + +interface IProps { + roomId: string; +} + +interface IState { + joinRule: JoinRule; + guestAccess: GuestAccess; + history: HistoryVisibility; + hasAliases: boolean; + encrypted: boolean; +} @replaceableComponent("views.settings.tabs.room.SecurityRoomSettingsTab") -export default class SecurityRoomSettingsTab extends React.Component { - static propTypes = { - roomId: PropTypes.string.isRequired, - }; - - constructor() { - super(); +export default class SecurityRoomSettingsTab extends React.Component { + constructor(props) { + super(props); this.state = { - joinRule: "invite", - guestAccess: "can_join", - history: "shared", + joinRule: JoinRule.Invite, + guestAccess: GuestAccess.CanJoin, + history: HistoryVisibility.Shared, hasAliases: false, encrypted: false, }; } // TODO: [REACT-WARNING] Move this to constructor - async UNSAFE_componentWillMount(): void { // eslint-disable-line camelcase - MatrixClientPeg.get().on("RoomState.events", this._onStateEvent); + async UNSAFE_componentWillMount() { // eslint-disable-line camelcase + MatrixClientPeg.get().on("RoomState.events", this.onStateEvent); const room = MatrixClientPeg.get().getRoom(this.props.roomId); const state = room.currentState; - const joinRule = this._pullContentPropertyFromEvent( + const joinRule: JoinRule = this.pullContentPropertyFromEvent( state.getStateEvents("m.room.join_rules", ""), 'join_rule', - 'invite', + JoinRule.Invite, ); - const guestAccess = this._pullContentPropertyFromEvent( + const guestAccess: GuestAccess = this.pullContentPropertyFromEvent( state.getStateEvents("m.room.guest_access", ""), 'guest_access', - 'forbidden', + GuestAccess.Forbidden, ); - const history = this._pullContentPropertyFromEvent( + const history: HistoryVisibility = this.pullContentPropertyFromEvent( state.getStateEvents("m.room.history_visibility", ""), 'history_visibility', - 'shared', + HistoryVisibility.Shared, ); const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); this.setState({joinRule, guestAccess, history, encrypted}); - const hasAliases = await this._hasAliases(); + const hasAliases = await this.hasAliases(); this.setState({hasAliases}); } - _pullContentPropertyFromEvent(event, key, defaultValue) { + private pullContentPropertyFromEvent(event: MatrixEvent, key: string, defaultValue: T): T { if (!event || !event.getContent()) return defaultValue; return event.getContent()[key] || defaultValue; } - componentWillUnmount(): void { - MatrixClientPeg.get().removeListener("RoomState.events", this._onStateEvent); + componentWillUnmount() { + MatrixClientPeg.get().removeListener("RoomState.events", this.onStateEvent); } - _onStateEvent = (e) => { + private onStateEvent = (e: MatrixEvent) => { const refreshWhenTypes = [ 'm.room.join_rules', 'm.room.guest_access', @@ -93,7 +121,7 @@ export default class SecurityRoomSettingsTab extends React.Component { if (refreshWhenTypes.includes(e.getType())) this.forceUpdate(); }; - _onEncryptionChange = (e) => { + private onEncryptionChange = (e: React.ChangeEvent) => { Modal.createTrackedDialog('Enable encryption', '', QuestionDialog, { title: _t('Enable encryption?'), description: _t( @@ -102,10 +130,9 @@ export default class SecurityRoomSettingsTab extends React.Component { "may prevent many bots and bridges from working correctly. Learn more about encryption.", {}, { - 'a': (sub) => { - return {sub}; - }, + a: sub => {sub}, }, ), onFinished: (confirm) => { @@ -127,12 +154,12 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _fixGuestAccess = (e) => { + private fixGuestAccess = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - const joinRule = "invite"; - const guestAccess = "can_join"; + const joinRule = JoinRule.Invite; + const guestAccess = GuestAccess.CanJoin; const beforeJoinRule = this.state.joinRule; const beforeGuestAccess = this.state.guestAccess; @@ -149,7 +176,7 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _onRoomAccessRadioToggle = (roomAccess) => { + private onRoomAccessRadioToggle = (roomAccess: string) => { // join_rule // INVITE | PUBLIC // ----------------------+---------------- @@ -163,20 +190,20 @@ export default class SecurityRoomSettingsTab extends React.Component { // invite them, you clearly want them to join, whether they're a // guest or not. In practice, guest_access should probably have // been implemented as part of the join_rules enum. - let joinRule = "invite"; - let guestAccess = "can_join"; + let joinRule = JoinRule.Invite; + let guestAccess = GuestAccess.CanJoin; switch (roomAccess) { case "invite_only": // no change - use defaults above break; case "public_no_guests": - joinRule = "public"; - guestAccess = "forbidden"; + joinRule = JoinRule.Public; + guestAccess = GuestAccess.Forbidden; break; case "public_with_guests": - joinRule = "public"; - guestAccess = "can_join"; + joinRule = JoinRule.Public; + guestAccess = GuestAccess.CanJoin; break; } @@ -195,7 +222,7 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _onHistoryRadioToggle = (history) => { + private onHistoryRadioToggle = (history: HistoryVisibility) => { const beforeHistory = this.state.history; this.setState({history: history}); MatrixClientPeg.get().sendStateEvent(this.props.roomId, "m.room.history_visibility", { @@ -206,11 +233,11 @@ export default class SecurityRoomSettingsTab extends React.Component { }); }; - _updateBlacklistDevicesFlag = (checked) => { + private updateBlacklistDevicesFlag = (checked: boolean) => { MatrixClientPeg.get().getRoom(this.props.roomId).setBlacklistUnverifiedDevices(checked); }; - async _hasAliases() { + private async hasAliases(): Promise { const cli = MatrixClientPeg.get(); if (await cli.doesServerSupportUnstableFeature("org.matrix.msc2432")) { const response = await cli.unstableGetLocalAliases(this.props.roomId); @@ -224,7 +251,7 @@ export default class SecurityRoomSettingsTab extends React.Component { } } - _renderRoomAccess() { + private renderRoomAccess() { const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); const joinRule = this.state.joinRule; @@ -240,7 +267,7 @@ export default class SecurityRoomSettingsTab extends React.Component { {_t("Guests cannot join this room even if explicitly invited.")}  - {_t("Click here to fix")} + {_t("Click here to fix")}
); @@ -265,7 +292,7 @@ export default class SecurityRoomSettingsTab extends React.Component { ; } @@ -356,7 +383,7 @@ export default class SecurityRoomSettingsTab extends React.Component { let historySection = (<> {_t("Who can read history?")}
- {this._renderHistory()} + {this.renderHistory()}
); if (!SettingsStore.getValue(UIFeature.RoomHistorySettings)) { @@ -373,15 +400,16 @@ export default class SecurityRoomSettingsTab extends React.Component {
{_t("Once enabled, encryption cannot be disabled.")}
- +
{encryptionSettings}
{_t("Who can access this room?")}
- {this._renderRoomAccess()} + {this.renderRoomAccess()}
{historySection} diff --git a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js index b1ad9f3d23..a216aeccbe 100644 --- a/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/GeneralUserSettingsTab.js @@ -192,7 +192,11 @@ export default class GeneralUserSettingsTab extends React.Component { SettingsStore.setValue("language", null, SettingLevel.DEVICE, newLanguage); this.setState({language: newLanguage}); - PlatformPeg.get().reload(); + const platform = PlatformPeg.get(); + if (platform) { + platform.setLanguage(newLanguage); + platform.reload(); + } }; _onSpellCheckLanguagesChange = (languages) => { diff --git a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx similarity index 75% rename from src/components/views/settings/tabs/user/HelpUserSettingsTab.js rename to src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx index e16ee686f5..a009ead064 100644 --- a/src/components/views/settings/tabs/user/HelpUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/HelpUserSettingsTab.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -16,27 +15,31 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import {_t, getCurrentLanguage} from "../../../../../languageHandler"; import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import AccessibleButton from "../../../elements/AccessibleButton"; import SdkConfig from "../../../../../SdkConfig"; import createRoom from "../../../../../createRoom"; import Modal from "../../../../../Modal"; -import * as sdk from "../../../../../"; +import * as sdk from "../../../../.."; import PlatformPeg from "../../../../../PlatformPeg"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import UpdateCheckButton from "../../UpdateCheckButton"; -import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../../utils/replaceableComponent"; + +interface IProps { + closeSettingsFn: () => {}; +} + +interface IState { + appVersion: string; + canUpdate: boolean; +} @replaceableComponent("views.settings.tabs.user.HelpUserSettingsTab") -export default class HelpUserSettingsTab extends React.Component { - static propTypes = { - closeSettingsFn: PropTypes.func.isRequired, - }; - - constructor() { - super(); +export default class HelpUserSettingsTab extends React.Component { + constructor(props) { + super(props); this.state = { appVersion: null, @@ -53,7 +56,7 @@ export default class HelpUserSettingsTab extends React.Component { }); } - _onClearCacheAndReload = (e) => { + private onClearCacheAndReload = (e) => { if (!PlatformPeg.get()) return; // Dev note: please keep this log line, it's useful when troubleshooting a MatrixClient suddenly @@ -65,7 +68,7 @@ export default class HelpUserSettingsTab extends React.Component { }); }; - _onBugReport = (e) => { + private onBugReport = (e) => { const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); if (!BugReportDialog) { return; @@ -73,7 +76,7 @@ export default class HelpUserSettingsTab extends React.Component { Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); }; - _onStartBotChat = (e) => { + private onStartBotChat = (e) => { this.props.closeSettingsFn(); createRoom({ dmUserId: SdkConfig.get().welcomeUserId, @@ -81,7 +84,7 @@ export default class HelpUserSettingsTab extends React.Component { }); }; - _showSpoiler = (event) => { + private showSpoiler = (event) => { const target = event.target; target.innerHTML = target.getAttribute('data-spoiler'); @@ -93,7 +96,7 @@ export default class HelpUserSettingsTab extends React.Component { selection.addRange(range); }; - _renderLegal() { + private renderLegal() { const tocLinks = SdkConfig.get().terms_and_conditions_links; if (!tocLinks) return null; @@ -114,7 +117,7 @@ export default class HelpUserSettingsTab extends React.Component { ); } - _renderCredits() { + private renderCredits() { // Note: This is not translated because it is legal text. // Also,   is ugly but necessary. return ( @@ -122,28 +125,28 @@ export default class HelpUserSettingsTab extends React.Component { {_t("Credits")}
@@ -188,7 +191,7 @@ export default class HelpUserSettingsTab extends React.Component { }, )}
- + {_t("Chat with %(brand)s Bot", { brand })}
@@ -212,28 +215,27 @@ export default class HelpUserSettingsTab extends React.Component {
{_t('Bug reporting')}
- { - _t( "If you've submitted a bug via GitHub, debug logs can help " + - "us track down the problem. Debug logs contain application " + - "usage data including your username, the IDs or aliases of " + - "the rooms or groups you have visited and the usernames of " + - "other users. They do not contain messages.", - ) - } + {_t( + "If you've submitted a bug via GitHub, debug logs can help " + + "us track down the problem. Debug logs contain application " + + "usage data including your username, the IDs or aliases of " + + "the rooms or groups you have visited and the usernames of " + + "other users. They do not contain messages.", + )}
- + {_t("Submit debug logs")}
- { - _t( "To report a Matrix-related security issue, please read the Matrix.org " + - "Security Disclosure Policy.", {}, - { - 'a': (sub) => - {sub}, - }) - } + {_t( + "To report a Matrix-related security issue, please read the Matrix.org " + + "Security Disclosure Policy.", {}, + { + a: sub => {sub}, + }, + )}
); @@ -260,20 +262,21 @@ export default class HelpUserSettingsTab extends React.Component { {updateButton}
- {this._renderLegal()} - {this._renderCredits()} + {this.renderLegal()} + {this.renderCredits()}
{_t("Advanced")}
{_t("Homeserver is")} {MatrixClientPeg.get().getHomeserverUrl()}
{_t("Identity Server is")} {MatrixClientPeg.get().getIdentityServerUrl()}
{_t("Access Token:") + ' '} - + <{ _t("click to reveal") }>
- + {_t("Clear cache and reload")}
diff --git a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx similarity index 90% rename from src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js rename to src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx index 91f6728a7a..6997defea9 100644 --- a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -25,10 +25,16 @@ import {MatrixClientPeg} from "../../../../../MatrixClientPeg"; import * as sdk from "../../../../../index"; import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +interface IState { + busy: boolean; + newPersonalRule: string; + newList: string; +} + @replaceableComponent("views.settings.tabs.user.MjolnirUserSettingsTab") -export default class MjolnirUserSettingsTab extends React.Component { - constructor() { - super(); +export default class MjolnirUserSettingsTab extends React.Component<{}, IState> { + constructor(props) { + super(props); this.state = { busy: false, @@ -37,15 +43,15 @@ export default class MjolnirUserSettingsTab extends React.Component { }; } - _onPersonalRuleChanged = (e) => { + private onPersonalRuleChanged = (e) => { this.setState({newPersonalRule: e.target.value}); }; - _onNewListChanged = (e) => { + private onNewListChanged = (e) => { this.setState({newList: e.target.value}); }; - _onAddPersonalRule = async (e) => { + private onAddPersonalRule = async (e) => { e.preventDefault(); e.stopPropagation(); @@ -72,7 +78,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } }; - _onSubscribeList = async (e) => { + private onSubscribeList = async (e) => { e.preventDefault(); e.stopPropagation(); @@ -94,7 +100,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } }; - async _removePersonalRule(rule: ListRule) { + private async removePersonalRule(rule: ListRule) { this.setState({busy: true}); try { const list = Mjolnir.sharedInstance().getPersonalList(); @@ -112,7 +118,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } } - async _unsubscribeFromList(list: BanList) { + private async unsubscribeFromList(list: BanList) { this.setState({busy: true}); try { await Mjolnir.sharedInstance().unsubscribeFromList(list.roomId); @@ -130,7 +136,7 @@ export default class MjolnirUserSettingsTab extends React.Component { } } - _viewListRules(list: BanList) { + private viewListRules(list: BanList) { const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const room = MatrixClientPeg.get().getRoom(list.roomId); @@ -161,7 +167,7 @@ export default class MjolnirUserSettingsTab extends React.Component { }); } - _renderPersonalBanListRules() { + private renderPersonalBanListRules() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const list = Mjolnir.sharedInstance().getPersonalList(); @@ -174,7 +180,7 @@ export default class MjolnirUserSettingsTab extends React.Component {
  • this._removePersonalRule(rule)} + onClick={() => this.removePersonalRule(rule)} disabled={this.state.busy} > {_t("Remove")} @@ -192,7 +198,7 @@ export default class MjolnirUserSettingsTab extends React.Component { ); } - _renderSubscribedBanLists() { + private renderSubscribedBanLists() { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const personalList = Mjolnir.sharedInstance().getPersonalList(); @@ -209,14 +215,14 @@ export default class MjolnirUserSettingsTab extends React.Component {
  • this._unsubscribeFromList(list)} + onClick={() => this.unsubscribeFromList(list)} disabled={this.state.busy} > {_t("Unsubscribe")}   this._viewListRules(list)} + onClick={() => this.viewListRules(list)} disabled={this.state.busy} > {_t("View rules")} @@ -271,21 +277,21 @@ export default class MjolnirUserSettingsTab extends React.Component { )}
  • - {this._renderPersonalBanListRules()} + {this.renderPersonalBanListRules()}
    -
    + {_t("Ignore")} @@ -303,20 +309,20 @@ export default class MjolnirUserSettingsTab extends React.Component { )}
    - {this._renderSubscribedBanLists()} + {this.renderSubscribedBanLists()}
    - + {_t("Subscribe")} diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx similarity index 80% rename from src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js rename to src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 0cd3dd6698..f02c5c9ce0 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019-2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); @@ -23,10 +23,24 @@ import Field from "../../../elements/Field"; import * as sdk from "../../../../.."; import PlatformPeg from "../../../../../PlatformPeg"; import {SettingLevel} from "../../../../../settings/SettingLevel"; -import {replaceableComponent} from "../../../../../utils/replaceableComponent"; +import { replaceableComponent } from "../../../../../utils/replaceableComponent"; + +interface IState { + autoLaunch: boolean; + autoLaunchSupported: boolean; + warnBeforeExit: boolean; + warnBeforeExitSupported: boolean; + alwaysShowMenuBarSupported: boolean; + alwaysShowMenuBar: boolean; + minimizeToTraySupported: boolean; + minimizeToTray: boolean; + autocompleteDelay: string; + readMarkerInViewThresholdMs: string; + readMarkerOutOfViewThresholdMs: string; +} @replaceableComponent("views.settings.tabs.user.PreferencesUserSettingsTab") -export default class PreferencesUserSettingsTab extends React.Component { +export default class PreferencesUserSettingsTab extends React.Component<{}, IState> { static ROOM_LIST_SETTINGS = [ 'breadcrumbs', ]; @@ -68,8 +82,8 @@ export default class PreferencesUserSettingsTab extends React.Component { // Autocomplete delay (niche text box) ]; - constructor() { - super(); + constructor(props) { + super(props); this.state = { autoLaunch: false, @@ -89,7 +103,7 @@ export default class PreferencesUserSettingsTab extends React.Component { }; } - async componentDidMount(): void { + async componentDidMount() { const platform = PlatformPeg.get(); const autoLaunchSupported = await platform.supportsAutoLaunch(); @@ -128,38 +142,38 @@ export default class PreferencesUserSettingsTab extends React.Component { }); } - _onAutoLaunchChange = (checked) => { + private onAutoLaunchChange = (checked: boolean) => { PlatformPeg.get().setAutoLaunchEnabled(checked).then(() => this.setState({autoLaunch: checked})); }; - _onWarnBeforeExitChange = (checked) => { + private onWarnBeforeExitChange = (checked: boolean) => { PlatformPeg.get().setWarnBeforeExit(checked).then(() => this.setState({warnBeforeExit: checked})); } - _onAlwaysShowMenuBarChange = (checked) => { + private onAlwaysShowMenuBarChange = (checked: boolean) => { PlatformPeg.get().setAutoHideMenuBarEnabled(!checked).then(() => this.setState({alwaysShowMenuBar: checked})); }; - _onMinimizeToTrayChange = (checked) => { + private onMinimizeToTrayChange = (checked: boolean) => { PlatformPeg.get().setMinimizeToTrayEnabled(checked).then(() => this.setState({minimizeToTray: checked})); }; - _onAutocompleteDelayChange = (e) => { + private onAutocompleteDelayChange = (e: React.ChangeEvent) => { this.setState({autocompleteDelay: e.target.value}); SettingsStore.setValue("autocompleteDelay", null, SettingLevel.DEVICE, e.target.value); }; - _onReadMarkerInViewThresholdMs = (e) => { + private onReadMarkerInViewThresholdMs = (e: React.ChangeEvent) => { this.setState({readMarkerInViewThresholdMs: e.target.value}); SettingsStore.setValue("readMarkerInViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); }; - _onReadMarkerOutOfViewThresholdMs = (e) => { + private onReadMarkerOutOfViewThresholdMs = (e: React.ChangeEvent) => { this.setState({readMarkerOutOfViewThresholdMs: e.target.value}); SettingsStore.setValue("readMarkerOutOfViewThresholdMs", null, SettingLevel.DEVICE, e.target.value); }; - _renderGroup(settingIds) { + private renderGroup(settingIds: string[]): React.ReactNodeArray { const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); return settingIds.filter(SettingsStore.isEnabled).map(i => { return ; @@ -171,7 +185,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.autoLaunchSupported) { autoLaunchOption = ; } @@ -179,7 +193,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.warnBeforeExitSupported) { warnBeforeExitOption = ; } @@ -187,7 +201,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.alwaysShowMenuBarSupported) { autoHideMenuOption = ; } @@ -195,7 +209,7 @@ export default class PreferencesUserSettingsTab extends React.Component { if (this.state.minimizeToTraySupported) { minimizeToTrayOption = ; } @@ -205,22 +219,22 @@ export default class PreferencesUserSettingsTab extends React.Component {
    {_t("Room list")} - {this._renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
    {_t("Composer")} - {this._renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
    {_t("Timeline")} - {this._renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)}
    {_t("General")} - {this._renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)} + {this.renderGroup(PreferencesUserSettingsTab.GENERAL_SETTINGS)} {minimizeToTrayOption} {autoHideMenuOption} {autoLaunchOption} @@ -229,17 +243,17 @@ export default class PreferencesUserSettingsTab extends React.Component { label={_t('Autocomplete delay (ms)')} type='number' value={this.state.autocompleteDelay} - onChange={this._onAutocompleteDelayChange} /> + onChange={this.onAutocompleteDelayChange} /> + onChange={this.onReadMarkerInViewThresholdMs} /> + onChange={this.onReadMarkerOutOfViewThresholdMs} />
    ); diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 8a70811399..da41f2f0dc 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -255,10 +255,9 @@ export default class SecurityUserSettingsTab extends React.Component { _renderIgnoredUsers() { const {waitingUnignored, ignoredUserIds} = this.state; - if (!ignoredUserIds || ignoredUserIds.length === 0) return null; - - const userIds = ignoredUserIds - .map((u) => { +export default class Clock extends React.Component { public constructor(props) { super(props); } + shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { + const currentFloor = Math.floor(this.props.seconds); + const nextFloor = Math.floor(nextProps.seconds); + return currentFloor !== nextFloor; + } + public render() { const minutes = Math.floor(this.props.seconds / 60).toFixed(0).padStart(2, '0'); - const seconds = Math.round(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis + const seconds = Math.floor(this.props.seconds % 60).toFixed(0).padStart(2, '0'); // hide millis return {minutes}:{seconds}; } } diff --git a/src/components/views/voice_messages/LiveRecordingClock.tsx b/src/components/views/voice_messages/LiveRecordingClock.tsx index 5e9006c6ab..b82539eb16 100644 --- a/src/components/views/voice_messages/LiveRecordingClock.tsx +++ b/src/components/views/voice_messages/LiveRecordingClock.tsx @@ -31,7 +31,7 @@ interface IState { * A clock for a live recording. */ @replaceableComponent("views.voice_messages.LiveRecordingClock") -export default class LiveRecordingClock extends React.Component { +export default class LiveRecordingClock extends React.PureComponent { public constructor(props) { super(props); @@ -39,12 +39,6 @@ export default class LiveRecordingClock extends React.Component this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); } - shouldComponentUpdate(nextProps: Readonly, nextState: Readonly, nextContext: any): boolean { - const currentFloor = Math.floor(this.state.seconds); - const nextFloor = Math.floor(nextState.seconds); - return currentFloor !== nextFloor; - } - private onRecordingUpdate = (update: IRecordingUpdate) => { this.setState({seconds: update.timeSeconds}); }; diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/voice_messages/LiveRecordingWaveform.tsx index c1f5e97fff..e7c34c9177 100644 --- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/voice_messages/LiveRecordingWaveform.tsx @@ -20,6 +20,7 @@ import {replaceableComponent} from "../../../utils/replaceableComponent"; import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {percentageOf} from "../../../utils/numbers"; import Waveform from "./Waveform"; +import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; interface IProps { recorder: VoiceRecording; @@ -29,8 +30,6 @@ interface IState { heights: number[]; } -const DOWNSAMPLE_TARGET = 35; // number of bars we want - /** * A waveform which shows the waveform of a live recording */ @@ -39,14 +38,14 @@ export default class LiveRecordingWaveform extends React.PureComponent { // The waveform and the downsample target are pretty close, so we should be fine to // do this, despite the docs on arrayFastResample. - const bars = arrayFastResample(Array.from(update.waveform), DOWNSAMPLE_TARGET); + const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES); this.setState({ // The incoming data is between zero and one, but typically even screaming into a // microphone won't send you over 0.6, so we artificially adjust the gain for the diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/voice_messages/PlayPauseButton.tsx new file mode 100644 index 0000000000..1f87eb012d --- /dev/null +++ b/src/components/views/voice_messages/PlayPauseButton.tsx @@ -0,0 +1,61 @@ +/* +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, {ReactNode} from "react"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import {_t} from "../../../languageHandler"; +import {Playback, PlaybackState} from "../../../voice/Playback"; +import classNames from "classnames"; + +interface IProps { + // Playback instance to manipulate. Cannot change during the component lifecycle. + playback: Playback; + + // The playback phase to render. Able to change during the component lifecycle. + playbackPhase: PlaybackState; +} + +/** + * Displays a play/pause button (activating the play/pause function of the recorder) + * to be displayed in reference to a recording. + */ +@replaceableComponent("views.voice_messages.PlayPauseButton") +export default class PlayPauseButton extends React.PureComponent { + public constructor(props) { + super(props); + } + + private onClick = async () => { + await this.props.playback.toggle(); + }; + + public render(): ReactNode { + const isPlaying = this.props.playback.isPlaying; + const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; + const classes = classNames('mx_PlayPauseButton', { + 'mx_PlayPauseButton_play': !isPlaying, + 'mx_PlayPauseButton_pause': isPlaying, + 'mx_PlayPauseButton_disabled': isDisabled, + }); + return ; + } +} diff --git a/src/components/views/voice_messages/PlaybackClock.tsx b/src/components/views/voice_messages/PlaybackClock.tsx new file mode 100644 index 0000000000..2e8ec9a3e7 --- /dev/null +++ b/src/components/views/voice_messages/PlaybackClock.tsx @@ -0,0 +1,71 @@ +/* +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 from "react"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; +import {Playback, PlaybackState} from "../../../voice/Playback"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; + +interface IProps { + playback: Playback; +} + +interface IState { + seconds: number; + durationSeconds: number; + playbackPhase: PlaybackState; +} + +/** + * A clock for a playback of a recording. + */ +@replaceableComponent("views.voice_messages.PlaybackClock") +export default class PlaybackClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + seconds: this.props.playback.clockInfo.timeSeconds, + // we track the duration on state because we won't really know what the clip duration + // is until the first time update, and as a PureComponent we are trying to dedupe state + // updates as much as possible. This is just the easiest way to avoid a forceUpdate() or + // member property to track "did we get a duration". + durationSeconds: this.props.playback.clockInfo.durationSeconds, + playbackPhase: PlaybackState.Stopped, // assume not started, so full clock + }; + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + // Convert Decoding -> Stopped because we don't care about the distinction here + if (ev === PlaybackState.Decoding) ev = PlaybackState.Stopped; + this.setState({playbackPhase: ev}); + }; + + private onTimeUpdate = (time: number[]) => { + this.setState({seconds: time[0], durationSeconds: time[1]}); + }; + + public render() { + let seconds = this.state.seconds; + if (this.state.playbackPhase === PlaybackState.Stopped) { + seconds = this.state.durationSeconds; + } + return ; + } +} diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/voice_messages/PlaybackWaveform.tsx new file mode 100644 index 0000000000..123af5dfa5 --- /dev/null +++ b/src/components/views/voice_messages/PlaybackWaveform.tsx @@ -0,0 +1,68 @@ +/* +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 from "react"; +import {replaceableComponent} from "../../../utils/replaceableComponent"; +import {arraySeed, arrayTrimFill} from "../../../utils/arrays"; +import Waveform from "./Waveform"; +import {Playback, PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback"; +import {percentageOf} from "../../../utils/numbers"; + +interface IProps { + playback: Playback; +} + +interface IState { + heights: number[]; + progress: number; +} + +/** + * A waveform which shows the waveform of a previously recorded recording + */ +@replaceableComponent("views.voice_messages.PlaybackWaveform") +export default class PlaybackWaveform extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + heights: this.toHeights(this.props.playback.waveform), + progress: 0, // default no progress + }; + + this.props.playback.waveformData.onUpdate(this.onWaveformUpdate); + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private toHeights(waveform: number[]) { + const seed = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); + return arrayTrimFill(waveform, PLAYBACK_WAVEFORM_SAMPLES, seed); + } + + private onWaveformUpdate = (waveform: number[]) => { + this.setState({heights: this.toHeights(waveform)}); + }; + + private onTimeUpdate = (time: number[]) => { + // Track percentages to very coarse precision, otherwise 0.002 ends up highlighting a bar. + const progress = Number(percentageOf(time[0], 0, time[1]).toFixed(1)); + this.setState({progress}); + }; + + public render() { + return ; + } +} diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/voice_messages/RecordingPlayback.tsx new file mode 100644 index 0000000000..776997cec2 --- /dev/null +++ b/src/components/views/voice_messages/RecordingPlayback.tsx @@ -0,0 +1,62 @@ +/* +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 {Playback, PlaybackState} from "../../../voice/Playback"; +import React, {ReactNode} from "react"; +import {UPDATE_EVENT} from "../../../stores/AsyncStore"; +import PlaybackWaveform from "./PlaybackWaveform"; +import PlayPauseButton from "./PlayPauseButton"; +import PlaybackClock from "./PlaybackClock"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; +} + +interface IState { + playbackPhase: PlaybackState; +} + +export default class RecordingPlayback extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = { + playbackPhase: PlaybackState.Decoding, // default assumption + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.on(UPDATE_EVENT, this.onPlaybackUpdate); + + // Don't wait for the promise to complete - it will emit a progress update when it + // is done, and it's not meant to take long anyhow. + // noinspection JSIgnoredPromiseFromCall + this.props.playback.prepare(); + } + + private onPlaybackUpdate = (ev: PlaybackState) => { + this.setState({playbackPhase: ev}); + }; + + public render(): ReactNode { + return
    + + + +
    + } +} diff --git a/src/components/views/voice_messages/Waveform.tsx b/src/components/views/voice_messages/Waveform.tsx index 5fa68dcadc..840a5a12b3 100644 --- a/src/components/views/voice_messages/Waveform.tsx +++ b/src/components/views/voice_messages/Waveform.tsx @@ -16,9 +16,11 @@ limitations under the License. import React from "react"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import classNames from "classnames"; interface IProps { relHeights: number[]; // relative heights (0-1) + progress: number; // percent complete, 0-1, default 100% } interface IState { @@ -28,9 +30,16 @@ interface IState { * A simple waveform component. This renders bars (centered vertically) for each * height provided in the component properties. Updating the properties will update * the rendered waveform. + * + * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be + * "filled", as a demonstration of the progress property. */ @replaceableComponent("views.voice_messages.Waveform") export default class Waveform extends React.PureComponent { + public static defaultProps = { + progress: 1, + }; + public constructor(props) { super(props); } @@ -38,7 +47,13 @@ export default class Waveform extends React.PureComponent { public render() { return
    {this.props.relHeights.map((h, i) => { - return ; + const progress = this.props.progress; + const isCompleteBar = (i / this.props.relHeights.length) <= progress && progress > 0; + const classes = classNames({ + 'mx_Waveform_bar': true, + 'mx_Waveform_bar_100pct': isCompleteBar, + }); + return ; })}
    ; } diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 29de068b0c..d31afddec9 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -27,6 +27,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Action } from '../../../dispatcher/actions'; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -142,6 +143,7 @@ export default class CallPreview extends React.Component { switch (payload.action) { // listen for call state changes to prod the render method, which // may hide the global CallView if the call it is tracking is dead + case Action.CallChangeRoom: case 'call_state': { const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls( CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId), diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index 8a6ed75fee..6745713845 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -208,7 +208,7 @@ export default class CallView extends React.Component { }; private onExpandClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); dis.dispatch({ action: 'view_room', room_id: userFacingRoomId, @@ -337,7 +337,7 @@ export default class CallView extends React.Component { }; private onRoomAvatarClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); dis.dispatch({ action: 'view_room', room_id: userFacingRoomId, @@ -345,7 +345,7 @@ export default class CallView extends React.Component { } private onSecondaryRoomAvatarClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.secondaryCall); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall); dis.dispatch({ action: 'view_room', @@ -354,7 +354,7 @@ export default class CallView extends React.Component { } private onCallResumeClick = () => { - const userFacingRoomId = CallHandler.roomIdForCall(this.props.call); + const userFacingRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); CallHandler.sharedInstance().setActiveCallRoomId(userFacingRoomId); } @@ -365,8 +365,8 @@ export default class CallView extends React.Component { public render() { const client = MatrixClientPeg.get(); - const callRoomId = CallHandler.roomIdForCall(this.props.call); - const secondaryCallRoomId = CallHandler.roomIdForCall(this.props.secondaryCall); + const callRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.call); + const secondaryCallRoomId = CallHandler.sharedInstance().roomIdForCall(this.props.secondaryCall); const callRoom = client.getRoom(callRoomId); const secCallRoom = this.props.secondaryCall ? client.getRoom(secondaryCallRoomId) : null; @@ -482,11 +482,13 @@ export default class CallView extends React.Component { const isOnHold = this.state.isLocalOnHold || this.state.isRemoteOnHold; let holdTransferContent; if (transfereeCall) { - const transferTargetRoom = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.props.call)); + const transferTargetRoom = MatrixClientPeg.get().getRoom( + CallHandler.sharedInstance().roomIdForCall(this.props.call), + ); const transferTargetName = transferTargetRoom ? transferTargetRoom.name : _t("unknown person"); const transfereeRoom = MatrixClientPeg.get().getRoom( - CallHandler.roomIdForCall(transfereeCall), + CallHandler.sharedInstance().roomIdForCall(transfereeCall), ); const transfereeName = transfereeRoom ? transfereeRoom.name : _t("unknown person"); diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/CallViewForRoom.tsx index 878b6af20f..7540dbc8d9 100644 --- a/src/components/views/voip/CallViewForRoom.tsx +++ b/src/components/views/voip/CallViewForRoom.tsx @@ -22,6 +22,7 @@ import dis from '../../../dispatcher/dispatcher'; import {Resizable} from "re-resizable"; import ResizeNotifier from "../../../utils/ResizeNotifier"; import {replaceableComponent} from "../../../utils/replaceableComponent"; +import { Action } from '../../../dispatcher/actions'; interface IProps { // What room we should display the call for @@ -62,6 +63,7 @@ export default class CallViewForRoom extends React.Component { private onAction = (payload) => { switch (payload.action) { + case Action.CallChangeRoom: case 'call_state': { const newCall = this.getCall(); if (newCall !== this.state.call) { diff --git a/src/components/views/voip/IncomingCallBox.tsx b/src/components/views/voip/IncomingCallBox.tsx index 0ca2a196c2..2abdc0641d 100644 --- a/src/components/views/voip/IncomingCallBox.tsx +++ b/src/components/views/voip/IncomingCallBox.tsx @@ -72,7 +72,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'answer', - room_id: CallHandler.roomIdForCall(this.state.incomingCall), + room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), }); }; @@ -80,7 +80,7 @@ export default class IncomingCallBox extends React.Component { e.stopPropagation(); dis.dispatch({ action: 'reject', - room_id: CallHandler.roomIdForCall(this.state.incomingCall), + room_id: CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall), }); }; @@ -91,7 +91,7 @@ export default class IncomingCallBox extends React.Component { let room = null; if (this.state.incomingCall) { - room = MatrixClientPeg.get().getRoom(CallHandler.roomIdForCall(this.state.incomingCall)); + room = MatrixClientPeg.get().getRoom(CallHandler.sharedInstance().roomIdForCall(this.state.incomingCall)); } const caller = room ? room.name : _t("Unknown caller"); diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts index b651e40a3b..f9d957b60c 100644 --- a/src/customisations/Media.ts +++ b/src/customisations/Media.ts @@ -96,6 +96,9 @@ export class Media { */ public getThumbnailHttp(width: number, height: number, mode: ResizeMethod = "scale"): string | null | undefined { if (!this.hasThumbnail) return null; + // scale using the device pixel ratio to keep images clear + width = Math.floor(width * window.devicePixelRatio); + height = Math.floor(height * window.devicePixelRatio); return this.client.mxcUrlToHttp(this.thumbnailMxc, width, height, mode); } @@ -107,6 +110,9 @@ export class Media { * @returns {string} The HTTP URL which points to the thumbnail. */ public getThumbnailOfSourceHttp(width: number, height: number, mode: ResizeMethod = "scale"): string { + // scale using the device pixel ratio to keep images clear + width = Math.floor(width * window.devicePixelRatio); + height = Math.floor(height * window.devicePixelRatio); return this.client.mxcUrlToHttp(this.srcMxc, width, height, mode); } @@ -117,6 +123,7 @@ export class Media { * @returns {string} An HTTP URL for the thumbnail. */ public getSquareThumbnailHttp(dim: number): string { + dim = Math.floor(dim * window.devicePixelRatio); // scale using the device pixel ratio to keep images clear if (this.hasThumbnail) { return this.getThumbnailHttp(dim, dim, 'crop'); } diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index cd32c3743f..46c962f160 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -114,6 +114,9 @@ export enum Action { */ VirtualRoomSupportUpdated = "virtual_room_support_updated", + // Probably would be better to have a VoIP states in a store and have the store emit changes + CallChangeRoom = "call_change_room", + /** * Fired when an upload has started. Should be used with UploadStartedPayload. */ diff --git a/src/editor/autocomplete.ts b/src/editor/autocomplete.ts index 2f56494ea0..240ed2d96b 100644 --- a/src/editor/autocomplete.ts +++ b/src/editor/autocomplete.ts @@ -125,10 +125,8 @@ export default class AutocompleteWrapperModel { case "at-room": return [this.partCreator.atRoomPill(completionId), this.partCreator.plain(completion.suffix)]; case "user": - // not using suffix here, because we also need to calculate - // the suffix when clicking a display name to insert a mention, - // which happens in createMentionParts - return this.partCreator.createMentionParts(this.partIndex, text, completionId); + // Insert suffix only if the pill is the part with index 0 - we are at the start of the composer + return this.partCreator.createMentionParts(this.partIndex === 0, text, completionId); case "command": // command needs special handling for auto complete, but also renders as plain texts return [(this.partCreator as CommandPartCreator).command(text)]; diff --git a/src/editor/parts.ts b/src/editor/parts.ts index ccd90da3e2..02c5d64895 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -341,11 +341,7 @@ class RoomPillPart extends PillPart { setAvatar(node: HTMLElement) { let initialLetter = ""; - let avatarUrl = Avatar.avatarUrlForRoom( - this.room, - 16 * window.devicePixelRatio, - 16 * window.devicePixelRatio, - "crop"); + let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop"); if (!avatarUrl) { initialLetter = Avatar.getInitialLetter(this.room ? this.room.name : this.resourceId); avatarUrl = Avatar.defaultAvatarUrlForString(this.room ? this.room.roomId : this.resourceId); @@ -383,11 +379,7 @@ class UserPillPart extends PillPart { } const name = this.member.name || this.member.userId; const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId); - const avatarUrl = Avatar.avatarUrlForMember( - this.member, - 16 * window.devicePixelRatio, - 16 * window.devicePixelRatio, - "crop"); + const avatarUrl = Avatar.avatarUrlForMember(this.member, 16, 16, "crop"); let initialLetter = ""; if (avatarUrl === defaultAvatarUrl) { initialLetter = Avatar.getInitialLetter(name); @@ -543,9 +535,9 @@ export class PartCreator { return new UserPillPart(userId, displayName, member); } - createMentionParts(partIndex: number, displayName: string, userId: string) { + createMentionParts(insertTrailingCharacter: boolean, displayName: string, userId: string) { const pill = this.userPill(displayName, userId); - const postfix = this.plain(partIndex === 0 ? ": " : " "); + const postfix = this.plain(insertTrailingCharacter ? ": " : " "); return [pill, postfix]; } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 6c9d22cdeb..85e8e54258 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -658,7 +658,6 @@ "No homeserver URL provided": "No homeserver URL provided", "Unexpected error resolving homeserver configuration": "Unexpected error resolving homeserver configuration", "Unexpected error resolving identity server configuration": "Unexpected error resolving identity server configuration", - "The message you are trying to send is too large.": "The message you are trying to send is too large.", "This homeserver has hit its Monthly Active User limit.": "This homeserver has hit its Monthly Active User limit.", "This homeserver has been blocked by its administrator.": "This homeserver has been blocked by its administrator.", "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", @@ -900,6 +899,8 @@ "Incoming call": "Incoming call", "Decline": "Decline", "Accept": "Accept", + "Pause": "Pause", + "Play": "Play", "The other party cancelled the verification.": "The other party cancelled the verification.", "Verified!": "Verified!", "You've successfully verified this user.": "You've successfully verified this user.", @@ -1306,6 +1307,7 @@ "Cryptography": "Cryptography", "Session ID:": "Session ID:", "Session key:": "Session key:", + "You have no ignored users.": "You have no ignored users.", "Bulk options": "Bulk options", "Accept all %(invitedRooms)s invites": "Accept all %(invitedRooms)s invites", "Reject all %(invitedRooms)s invites": "Reject all %(invitedRooms)s invites", @@ -1453,6 +1455,7 @@ "Sending your message...": "Sending your message...", "Encrypting your message...": "Encrypting your message...", "Your message was sent": "Your message was sent", + "Failed to send": "Failed to send", "Please select the destination room for this message": "Please select the destination room for this message", "Scroll to most recent messages": "Scroll to most recent messages", "Close preview": "Close preview", @@ -1645,7 +1648,8 @@ "Jump to first unread message.": "Jump to first unread message.", "Mark all as read": "Mark all as read", "Record a voice message": "Record a voice message", - "Stop & send recording": "Stop & send recording", + "Stop the recording": "Stop the recording", + "Delete recording": "Delete recording", "Error updating main address": "Error updating main address", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", @@ -1811,8 +1815,9 @@ "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", "Error decrypting audio": "Error decrypting audio", "React": "React", - "Reply": "Reply", "Edit": "Edit", + "Retry": "Retry", + "Reply": "Reply", "Message Actions": "Message Actions", "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", @@ -1923,10 +1928,10 @@ "%(count)s members including %(commaSeparatedMembers)s|one": "%(commaSeparatedMembers)s", "%(count)s people you know have already joined|other": "%(count)s people you know have already joined", "%(count)s people you know have already joined|one": "%(count)s person you know has already joined", - "Rotate Right": "Rotate Right", - "Rotate Left": "Rotate Left", "Zoom out": "Zoom out", "Zoom in": "Zoom in", + "Rotate Right": "Rotate Right", + "Rotate Left": "Rotate Left", "Download": "Download", "Information": "Information", "View message": "View message", @@ -2397,7 +2402,6 @@ "Confirm encryption setup": "Confirm encryption setup", "Click the button below to confirm setting up encryption.": "Click the button below to confirm setting up encryption.", "Unable to set up keys": "Unable to set up keys", - "Retry": "Retry", "Restoring keys from backup": "Restoring keys from backup", "Fetching keys from server...": "Fetching keys from server...", "%(completed)s of %(total)s keys restored": "%(completed)s of %(total)s keys restored", @@ -2426,10 +2430,7 @@ "Reject invitation": "Reject invitation", "Are you sure you want to reject the invitation?": "Are you sure you want to reject the invitation?", "Unable to reject invite": "Unable to reject invite", - "Resend edit": "Resend edit", "Resend %(unsentCount)s reaction(s)": "Resend %(unsentCount)s reaction(s)", - "Resend removal": "Resend removal", - "Cancel Sending": "Cancel Sending", "Forward Message": "Forward Message", "Pin Message": "Pin Message", "Unhide Preview": "Unhide Preview", @@ -2617,10 +2618,11 @@ "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please contact your service administrator to continue using the service.", "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has been blocked by it's administrator. Please contact your service administrator to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please contact your service administrator to continue using the service.", - "%(count)s of your messages have not been sent.|other": "Some of your messages have not been sent.", - "%(count)s of your messages have not been sent.|one": "Your message was not sent.", - "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|other": "Resend all or cancel all now. You can also select individual messages to resend or cancel.", - "%(count)s Resend all or cancel all now. You can also select individual messages to resend or cancel.|one": "Resend message or cancel message now.", + "Some of your messages have not been sent": "Some of your messages have not been sent", + "Delete all": "Delete all", + "Retry all": "Retry all", + "Sending": "Sending", + "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", @@ -2640,6 +2642,7 @@ "%(count)s rooms|one": "%(count)s room", "This room is suggested as a good one to join": "This room is suggested as a good one to join", "Suggested": "Suggested", + "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.", "%(count)s rooms and %(numSpaces)s spaces|other": "%(count)s rooms and %(numSpaces)s spaces", "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces", "%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space", @@ -2650,7 +2653,6 @@ "Mark as suggested": "Mark as suggested", "No results found": "No results found", "You may want to try a different search or check for typos.": "You may want to try a different search or check for typos.", - "Your server does not support showing space hierarchies.": "Your server does not support showing space hierarchies.", "Search names and description": "Search names and description", "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.", "Create room": "Create room", diff --git a/src/indexing/BaseEventIndexManager.ts b/src/indexing/BaseEventIndexManager.ts index 2474406618..6349f31524 100644 --- a/src/indexing/BaseEventIndexManager.ts +++ b/src/indexing/BaseEventIndexManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -133,6 +133,10 @@ export default abstract class BaseEventIndexManager { throw new Error("Unimplemented"); } + async isEventIndexEmpty(): Promise { + throw new Error("Unimplemented"); + } + /** * Check if our event index is empty. */ diff --git a/src/indexing/EventIndexPeg.js b/src/indexing/EventIndexPeg.ts similarity index 94% rename from src/indexing/EventIndexPeg.js rename to src/indexing/EventIndexPeg.ts index 7004efc554..4356d882d5 100644 --- a/src/indexing/EventIndexPeg.js +++ b/src/indexing/EventIndexPeg.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -27,12 +27,11 @@ import {SettingLevel} from "../settings/SettingLevel"; const INDEX_VERSION = 1; -class EventIndexPeg { - constructor() { - this.index = null; - this._supportIsInstalled = false; - this.error = null; - } +export class EventIndexPeg { + public index: EventIndex = null; + public error: Error = null; + + private _supportIsInstalled = false; /** * Initialize the EventIndexPeg and if event indexing is enabled initialize @@ -181,7 +180,7 @@ class EventIndexPeg { } } -if (!global.mxEventIndexPeg) { - global.mxEventIndexPeg = new EventIndexPeg(); +if (!window.mxEventIndexPeg) { + window.mxEventIndexPeg = new EventIndexPeg(); } -export default global.mxEventIndexPeg; +export default window.mxEventIndexPeg; diff --git a/src/linkify-matrix.js b/src/linkify-matrix.js index 1a40fde26f..84a131f23a 100644 --- a/src/linkify-matrix.js +++ b/src/linkify-matrix.js @@ -255,7 +255,7 @@ matrixLinkify.options = { target: function(href, type) { if (type === 'url') { const transformed = tryTransformPermalinkToLocalHref(href); - if (transformed !== href || href.match(matrixLinkify.ELEMENT_URL_PATTERN)) { + if (transformed !== href || decodeURIComponent(href).match(matrixLinkify.ELEMENT_URL_PATTERN)) { return null; } else { return '_blank'; diff --git a/src/mjolnir/BanList.js b/src/mjolnir/BanList.ts similarity index 100% rename from src/mjolnir/BanList.js rename to src/mjolnir/BanList.ts diff --git a/src/mjolnir/ListRule.js b/src/mjolnir/ListRule.ts similarity index 100% rename from src/mjolnir/ListRule.js rename to src/mjolnir/ListRule.ts diff --git a/src/mjolnir/Mjolnir.js b/src/mjolnir/Mjolnir.ts similarity index 100% rename from src/mjolnir/Mjolnir.js rename to src/mjolnir/Mjolnir.ts diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 601c77cdf3..a5bdb7ef33 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -60,6 +60,8 @@ const INITIAL_STATE = { replyingToEvent: null, shouldPeek: false, + + viaServers: [], }; /** @@ -113,6 +115,7 @@ class RoomViewStore extends Store { this.setState({ roomId: null, roomAlias: null, + viaServers: [], }); break; case 'view_room_error': @@ -191,6 +194,7 @@ class RoomViewStore extends Store { replyingToEvent: null, // pull the user out of Room Settings isEditingSettings: false, + viaServers: payload.via_servers, }; // Allow being given an event to be replied to when switching rooms but sanity check its for this room @@ -226,6 +230,7 @@ class RoomViewStore extends Store { roomAlias: payload.room_alias, roomLoading: true, roomLoadError: null, + viaServers: payload.via_servers, }); try { const result = await MatrixClientPeg.get().getRoomIdForAlias(payload.room_alias); @@ -261,6 +266,7 @@ class RoomViewStore extends Store { roomAlias: payload.room_alias, roomLoading: false, roomLoadError: payload.err, + viaServers: [], }); } @@ -272,9 +278,10 @@ class RoomViewStore extends Store { const cli = MatrixClientPeg.get(); const address = this.state.roomAlias || this.state.roomId; + const viaServers = this.state.viaServers || []; try { await retry(() => cli.joinRoom(address, { - viaServers: payload.via_servers, + viaServers, ...payload.opts, }), NUM_JOIN_RETRY, (err) => { // if we received a Gateway timeout then retry diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index e4b180f3ce..ec6227e45e 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -113,7 +113,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public async setActiveSpace(space: Room | null, contextSwitch = true) { - if (space === this.activeSpace) return; + if (space === this.activeSpace || (space && !space?.isSpaceRoom())) return; this._activeSpace = space; this.emit(UPDATE_SELECTED_SPACE, this.activeSpace); @@ -195,7 +195,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); return sortBy(childEvents, getOrder) .map(ev => this.matrixClient.getRoom(ev.getStateKey())) - .filter(room => room?.getMyMembership() === "join") || []; + .filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || []; } public getChildRooms(spaceId: string): Room[] { @@ -203,7 +203,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } public getChildSpaces(spaceId: string): Room[] { - return this.getChildren(spaceId).filter(r => r.isSpaceRoom()); + // don't show invited subspaces as they surface at the top level for better visibility + return this.getChildren(spaceId).filter(r => r.isSpaceRoom() && r.getMyMembership() === "join"); } public getParents(roomId: string, canonicalOnly = false): Room[] { @@ -409,32 +410,39 @@ export class SpaceStoreClass extends AsyncStoreWithClient { }); }, 100, {trailing: true, leading: true}); - private onRoom = (room: Room, membership?: string, oldMembership?: string) => { - if ((membership || room.getMyMembership()) === "invite") { - this._invitedSpaces.add(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); - } else if (oldMembership === "invite") { - this._invitedSpaces.delete(room); - this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); - } else if (room?.isSpaceRoom()) { - this.onSpaceUpdate(); - this.emit(room.roomId); - } else { + private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { + const membership = newMembership || room.getMyMembership(); + + if (!room.isSpaceRoom()) { // this.onRoomUpdate(room); this.onRoomsUpdate(); - } - if (room.getMyMembership() === "join") { - if (!room.isSpaceRoom()) { + if (membership === "join") { + // the user just joined a room, remove it from the suggested list if it was there const numSuggestedRooms = this._suggestedRooms.length; this._suggestedRooms = this._suggestedRooms.filter(r => r.room_id !== room.roomId); if (numSuggestedRooms !== this._suggestedRooms.length) { this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } - } else if (room.roomId === RoomViewStore.getRoomId()) { - // if the user was looking at the space and then joined: select that space - this.setActiveSpace(room); } + return; + } + + // Space + if (membership === "invite") { + this._invitedSpaces.add(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else if (oldMembership === "invite" && membership !== "join") { + this._invitedSpaces.delete(room); + this.emit(UPDATE_INVITED_SPACES, this.invitedSpaces); + } else { + this.onSpaceUpdate(); + this.emit(room.roomId); + } + + if (membership === "join" && room.roomId === RoomViewStore.getRoomId()) { + // if the user was looking at the space and then joined: select that space + this.setActiveSpace(room); } }; @@ -498,6 +506,17 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } }; + protected async reset() { + this.rootSpaces = []; + this.orphanedRooms = new Set(); + this.parentMap = new EnhancedMap(); + this.notificationStateMap = new Map(); + this.spaceFilteredRooms = new Map(); + this._activeSpace = null; + this._suggestedRooms = []; + this._invitedSpaces = new Set(); + } + protected async onNotReady() { if (!SettingsStore.getValue("feature_spaces")) return; if (this.matrixClient) { @@ -507,7 +526,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("accountData", this.onAccountData); } - await this.reset({}); + await this.reset(); } protected async onReady() { @@ -540,15 +559,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { // as this is not helpful and can create loops of rooms/space switching if (!room || payload.context_switch) break; - // persist last viewed room from a space - if (room.isSpaceRoom()) { - this.setActiveSpace(room); + // Don't context switch when navigating to the space room + // as it will cause you to end up in the wrong room + this.setActiveSpace(room, false); } else if (!this.getSpaceFilteredRoomIds(this.activeSpace).has(room.roomId)) { - // TODO maybe reverse these first 2 clauses once space panel active is fixed - let parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); + let parent = this.getCanonicalParent(room.roomId); if (!parent) { - parent = this.getCanonicalParent(room.roomId); + parent = this.rootSpaces.find(s => this.spaceFilteredRooms.get(s.roomId)?.has(room.roomId)); } if (!parent) { const parents = Array.from(this.parentMap.get(room.roomId) || []); @@ -582,7 +600,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return state; } - // traverse space tree with DFS calling fn on each space including the given root one + // traverse space tree with DFS calling fn on each space including the given root one, + // if includeRooms is true then fn will be called on each leaf room, if it is present in multiple sub-spaces + // then fn will be called with it multiple times. public traverseSpace( spaceId: string, fn: (roomId: string) => void, diff --git a/src/stores/TypingStore.js b/src/stores/TypingStore.ts similarity index 84% rename from src/stores/TypingStore.js rename to src/stores/TypingStore.ts index e86d698eac..d5177a33a0 100644 --- a/src/stores/TypingStore.js +++ b/src/stores/TypingStore.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019, 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. @@ -25,15 +25,23 @@ const TYPING_SERVER_TIMEOUT = 30000; * Tracks typing state for users. */ export default class TypingStore { + private typingStates: { + [roomId: string]: { + isTyping: boolean, + userTimer: Timer, + serverTimer: Timer, + }, + }; + constructor() { this.reset(); } static sharedInstance(): TypingStore { - if (global.mxTypingStore === undefined) { - global.mxTypingStore = new TypingStore(); + if (window.mxTypingStore === undefined) { + window.mxTypingStore = new TypingStore(); } - return global.mxTypingStore; + return window.mxTypingStore; } /** @@ -41,7 +49,7 @@ export default class TypingStore { * MatrixClientPeg client changes. */ reset() { - this._typingStates = { + this.typingStates = { // "roomId": { // isTyping: bool, // Whether the user is typing or not // userTimer: Timer, // Local timeout for "user has stopped typing" @@ -59,14 +67,14 @@ export default class TypingStore { if (!SettingsStore.getValue('sendTypingNotifications')) return; if (SettingsStore.getValue('lowBandwidth')) return; - let currentTyping = this._typingStates[roomId]; + let currentTyping = this.typingStates[roomId]; if ((!isTyping && !currentTyping) || (currentTyping && currentTyping.isTyping === isTyping)) { // No change in state, so don't do anything. We'll let the timer run its course. return; } if (!currentTyping) { - currentTyping = this._typingStates[roomId] = { + currentTyping = this.typingStates[roomId] = { isTyping: isTyping, serverTimer: new Timer(TYPING_SERVER_TIMEOUT), userTimer: new Timer(TYPING_USER_TIMEOUT), @@ -78,7 +86,7 @@ export default class TypingStore { if (isTyping) { if (!currentTyping.serverTimer.isRunning()) { currentTyping.serverTimer.restart().finished().then(() => { - const currentTyping = this._typingStates[roomId]; + const currentTyping = this.typingStates[roomId]; if (currentTyping) currentTyping.isTyping = false; // The server will (should) time us out on typing, so we don't diff --git a/src/stores/VoiceRecordingStore.ts b/src/stores/VoiceRecordingStore.ts index cc999f23f8..8ee44359fb 100644 --- a/src/stores/VoiceRecordingStore.ts +++ b/src/stores/VoiceRecordingStore.ts @@ -78,3 +78,5 @@ export class VoiceRecordingStore extends AsyncStoreWithClient { return this.updateState({recording: null}); } } + +window.mxVoiceRecordingStore = VoiceRecordingStore.instance; diff --git a/src/stores/WidgetEchoStore.js b/src/stores/WidgetEchoStore.ts similarity index 71% rename from src/stores/WidgetEchoStore.js rename to src/stores/WidgetEchoStore.ts index 3aef1beb3e..09120d6108 100644 --- a/src/stores/WidgetEchoStore.js +++ b/src/stores/WidgetEchoStore.ts @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018-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. @@ -16,6 +15,8 @@ limitations under the License. */ import EventEmitter from 'events'; +import { IWidget } from 'matrix-widget-api'; +import MatrixEvent from "matrix-js-sdk/src/models/event"; import {WidgetType} from "../widgets/WidgetType"; /** @@ -23,14 +24,20 @@ import {WidgetType} from "../widgets/WidgetType"; * proxying through state from the js-sdk. */ class WidgetEchoStore extends EventEmitter { + private roomWidgetEcho: { + [roomId: string]: { + [widgetId: string]: IWidget, + }, + }; + constructor() { super(); - this._roomWidgetEcho = { + this.roomWidgetEcho = { // Map as below. Object is the content of the widget state event, // so for widgets that have been deleted locally, the object is empty. // roomId: { - // widgetId: [object] + // widgetId: IWidget // } }; } @@ -42,14 +49,14 @@ class WidgetEchoStore extends EventEmitter { * and we don't really need the actual widget events anyway since we just want to * show a spinner / prevent widgets being added twice. * - * @param {Room} roomId The ID of the room to get widgets for + * @param {string} roomId The ID of the room to get widgets for * @param {MatrixEvent[]} currentRoomWidgets Current widgets for the room * @returns {MatrixEvent[]} List of widgets in the room, minus any pending removal */ - getEchoedRoomWidgets(roomId, currentRoomWidgets) { + getEchoedRoomWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): MatrixEvent[] { const echoedWidgets = []; - const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); + const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]); for (const w of currentRoomWidgets) { const widgetId = w.getStateKey(); @@ -65,8 +72,8 @@ class WidgetEchoStore extends EventEmitter { return echoedWidgets; } - roomHasPendingWidgetsOfType(roomId, currentRoomWidgets, type: WidgetType) { - const roomEchoState = Object.assign({}, this._roomWidgetEcho[roomId]); + roomHasPendingWidgetsOfType(roomId: string, currentRoomWidgets: MatrixEvent[], type?: WidgetType): boolean { + const roomEchoState = Object.assign({}, this.roomWidgetEcho[roomId]); // any widget IDs that are already in the room are not pending, so // echoes for them don't count as pending. @@ -85,20 +92,20 @@ class WidgetEchoStore extends EventEmitter { } } - roomHasPendingWidgets(roomId, currentRoomWidgets) { + roomHasPendingWidgets(roomId: string, currentRoomWidgets: MatrixEvent[]): boolean { return this.roomHasPendingWidgetsOfType(roomId, currentRoomWidgets); } - setRoomWidgetEcho(roomId, widgetId, state) { - if (this._roomWidgetEcho[roomId] === undefined) this._roomWidgetEcho[roomId] = {}; + setRoomWidgetEcho(roomId: string, widgetId: string, state: IWidget) { + if (this.roomWidgetEcho[roomId] === undefined) this.roomWidgetEcho[roomId] = {}; - this._roomWidgetEcho[roomId][widgetId] = state; + this.roomWidgetEcho[roomId][widgetId] = state; this.emit('update', roomId, widgetId); } - removeRoomWidgetEcho(roomId, widgetId) { - delete this._roomWidgetEcho[roomId][widgetId]; - if (Object.keys(this._roomWidgetEcho[roomId]).length === 0) delete this._roomWidgetEcho[roomId]; + removeRoomWidgetEcho(roomId: string, widgetId: string) { + delete this.roomWidgetEcho[roomId][widgetId]; + if (Object.keys(this.roomWidgetEcho[roomId]).length === 0) delete this.roomWidgetEcho[roomId]; this.emit('update', roomId, widgetId); } } diff --git a/src/stores/notifications/StaticNotificationState.ts b/src/stores/notifications/StaticNotificationState.ts index 0392ed3716..b18aa78e0f 100644 --- a/src/stores/notifications/StaticNotificationState.ts +++ b/src/stores/notifications/StaticNotificationState.ts @@ -18,6 +18,8 @@ import { NotificationColor } from "./NotificationColor"; import { NotificationState } from "./NotificationState"; export class StaticNotificationState extends NotificationState { + public static readonly RED_EXCLAMATION = StaticNotificationState.forSymbol("!", NotificationColor.Red); + constructor(symbol: string, count: number, color: NotificationColor) { super(); this._symbol = symbol; diff --git a/src/utils/AutoDiscoveryUtils.js b/src/utils/AutoDiscoveryUtils.tsx similarity index 91% rename from src/utils/AutoDiscoveryUtils.js rename to src/utils/AutoDiscoveryUtils.tsx index 614aa4cea8..e3a7fd2d0b 100644 --- a/src/utils/AutoDiscoveryUtils.js +++ b/src/utils/AutoDiscoveryUtils.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2019, 2020 The Matrix.org Foundation C.I.C. +Copyright 2019-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. @@ -15,13 +14,13 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { ReactNode } from 'react'; import {AutoDiscovery} from "matrix-js-sdk/src/autodiscovery"; import {_t, _td, newTranslatableError} from "../languageHandler"; import {makeType} from "./TypeUtils"; import SdkConfig from '../SdkConfig'; -const LIVELINESS_DISCOVERY_ERRORS = [ +const LIVELINESS_DISCOVERY_ERRORS: string[] = [ AutoDiscovery.ERROR_INVALID_HOMESERVER, AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER, ]; @@ -40,17 +39,23 @@ export class ValidatedServerConfig { warning: string; } +export interface IAuthComponentState { + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError?: ReactNode; +} + export default class AutoDiscoveryUtils { /** * Checks if a given error or error message is considered an error * relating to the liveliness of the server. Must be an error returned * from this AutoDiscoveryUtils class. - * @param {string|Error} error The error to check + * @param {string | Error} error The error to check * @returns {boolean} True if the error is a liveliness error. */ - static isLivelinessError(error: string|Error): boolean { + static isLivelinessError(error: string | Error): boolean { if (!error) return false; - return !!LIVELINESS_DISCOVERY_ERRORS.find(e => e === error || e === error.message); + return !!LIVELINESS_DISCOVERY_ERRORS.find(e => typeof error === "string" ? e === error : e === error.message); } /** @@ -61,7 +66,7 @@ export default class AutoDiscoveryUtils { * implementation for known values. * @returns {*} The state for the component, given the error. */ - static authComponentStateForError(err: string | Error | null, pageName = "login"): Object { + static authComponentStateForError(err: string | Error | null, pageName = "login"): IAuthComponentState { if (!err) { return { serverIsAlive: true, @@ -70,7 +75,7 @@ export default class AutoDiscoveryUtils { }; } let title = _t("Cannot reach homeserver"); - let body = _t("Ensure you have a stable internet connection, or get in touch with the server admin"); + let body: ReactNode = _t("Ensure you have a stable internet connection, or get in touch with the server admin"); if (!AutoDiscoveryUtils.isLivelinessError(err)) { const brand = SdkConfig.get().brand; title = _t("Your %(brand)s is misconfigured", { brand }); @@ -92,7 +97,7 @@ export default class AutoDiscoveryUtils { } let isFatalError = true; - const errorMessage = err.message ? err.message : err; + const errorMessage = typeof err === "string" ? err : err.message; if (errorMessage === AutoDiscovery.ERROR_INVALID_IDENTITY_SERVER) { isFatalError = false; title = _t("Cannot reach identity server"); @@ -141,7 +146,10 @@ export default class AutoDiscoveryUtils { * @returns {Promise} Resolves to the validated configuration. */ static async validateServerConfigWithStaticUrls( - homeserverUrl: string, identityUrl: string, syntaxOnly = false): ValidatedServerConfig { + homeserverUrl: string, + identityUrl?: string, + syntaxOnly = false, + ): Promise { if (!homeserverUrl) { throw newTranslatableError(_td("No homeserver URL provided")); } @@ -171,7 +179,7 @@ export default class AutoDiscoveryUtils { * @param {string} serverName The homeserver domain name (eg: "matrix.org") to validate. * @returns {Promise} Resolves to the validated configuration. */ - static async validateServerName(serverName: string): ValidatedServerConfig { + static async validateServerName(serverName: string): Promise { const result = await AutoDiscovery.findClientConfig(serverName); return AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(serverName, result); } diff --git a/src/utils/DMRoomMap.ts b/src/utils/DMRoomMap.ts index e49b74c380..b166674043 100644 --- a/src/utils/DMRoomMap.ts +++ b/src/utils/DMRoomMap.ts @@ -55,6 +55,15 @@ export default class DMRoomMap { return DMRoomMap.sharedInstance; } + /** + * Set the shared instance to the instance supplied + * Used by tests + * @param inst the new shared instance + */ + public static setShared(inst: DMRoomMap) { + DMRoomMap.sharedInstance = inst; + } + /** * Returns a shared instance of the class * that uses the singleton matrix client diff --git a/src/utils/ErrorUtils.js b/src/utils/ErrorUtils.js index 2c6acd5503..b5bd5b0af0 100644 --- a/src/utils/ErrorUtils.js +++ b/src/utils/ErrorUtils.js @@ -49,12 +49,6 @@ export function messageForResourceLimitError(limitType, adminContact, strings, e } } -export function messageForSendError(errorData) { - if (errorData.errcode === "M_TOO_LARGE") { - return _t("The message you are trying to send is too large."); - } -} - export function messageForSyncError(err) { if (err.errcode === 'M_RESOURCE_LIMIT_EXCEEDED') { const limitError = messageForResourceLimitError( diff --git a/src/utils/MatrixGlob.js b/src/utils/MatrixGlob.ts similarity index 100% rename from src/utils/MatrixGlob.js rename to src/utils/MatrixGlob.ts diff --git a/src/utils/StorageManager.js b/src/utils/StorageManager.ts similarity index 96% rename from src/utils/StorageManager.js rename to src/utils/StorageManager.ts index 23c27a2d1c..883c032771 100644 --- a/src/utils/StorageManager.js +++ b/src/utils/StorageManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019-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. @@ -32,15 +32,15 @@ try { const SYNC_STORE_NAME = "riot-web-sync"; const CRYPTO_STORE_NAME = "matrix-js-sdk:crypto"; -function log(msg) { +function log(msg: string) { console.log(`StorageManager: ${msg}`); } -function error(msg) { - console.error(`StorageManager: ${msg}`); +function error(msg: string, ...args: string[]) { + console.error(`StorageManager: ${msg}`, ...args); } -function track(action) { +function track(action: string) { Analytics.trackEvent("StorageManager", action); } @@ -73,7 +73,7 @@ export async function checkConsistency() { dataInLocalStorage = localStorage.length > 0; log(`Local storage contains data? ${dataInLocalStorage}`); - cryptoInited = localStorage.getItem("mx_crypto_initialised"); + cryptoInited = !!localStorage.getItem("mx_crypto_initialised"); log(`Crypto initialised? ${cryptoInited}`); } else { healthy = false; diff --git a/src/utils/Timer.js b/src/utils/Timer.ts similarity index 60% rename from src/utils/Timer.js rename to src/utils/Timer.ts index ca06237fbf..9760631d09 100644 --- a/src/utils/Timer.js +++ b/src/utils/Timer.ts @@ -1,5 +1,5 @@ /* -Copyright 2018 New Vector Ltd +Copyright 2018, 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. @@ -26,44 +26,48 @@ Once a timer is finished or aborted, it can't be started again a new one through `clone()` or `cloneIfRun()`. */ export default class Timer { - constructor(timeout) { - this._timeout = timeout; - this._onTimeout = this._onTimeout.bind(this); - this._setNotStarted(); + private timerHandle: NodeJS.Timeout; + private startTs: number; + private promise: Promise; + private resolve: () => void; + private reject: (Error) => void; + + constructor(private timeout: number) { + this.setNotStarted(); } - _setNotStarted() { - this._timerHandle = null; - this._startTs = null; - this._promise = new Promise((resolve, reject) => { - this._resolve = resolve; - this._reject = reject; + private setNotStarted() { + this.timerHandle = null; + this.startTs = null; + this.promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; }).finally(() => { - this._timerHandle = null; + this.timerHandle = null; }); } - _onTimeout() { + private onTimeout = () => { const now = Date.now(); - const elapsed = now - this._startTs; - if (elapsed >= this._timeout) { - this._resolve(); - this._setNotStarted(); + const elapsed = now - this.startTs; + if (elapsed >= this.timeout) { + this.resolve(); + this.setNotStarted(); } else { - const delta = this._timeout - elapsed; - this._timerHandle = setTimeout(this._onTimeout, delta); + const delta = this.timeout - elapsed; + this.timerHandle = setTimeout(this.onTimeout, delta); } } - changeTimeout(timeout) { - if (timeout === this._timeout) { + changeTimeout(timeout: number) { + if (timeout === this.timeout) { return; } - const isSmallerTimeout = timeout < this._timeout; - this._timeout = timeout; + const isSmallerTimeout = timeout < this.timeout; + this.timeout = timeout; if (this.isRunning() && isSmallerTimeout) { - clearTimeout(this._timerHandle); - this._onTimeout(); + clearTimeout(this.timerHandle); + this.onTimeout(); } } @@ -73,8 +77,8 @@ export default class Timer { */ start() { if (!this.isRunning()) { - this._startTs = Date.now(); - this._timerHandle = setTimeout(this._onTimeout, this._timeout); + this.startTs = Date.now(); + this.timerHandle = setTimeout(this.onTimeout, this.timeout); } return this; } @@ -89,7 +93,7 @@ export default class Timer { // can be called in fast succession, // instead just take note and compare // when the already running timeout expires - this._startTs = Date.now(); + this.startTs = Date.now(); return this; } else { return this.start(); @@ -103,9 +107,9 @@ export default class Timer { */ abort() { if (this.isRunning()) { - clearTimeout(this._timerHandle); - this._reject(new Error("Timer was aborted.")); - this._setNotStarted(); + clearTimeout(this.timerHandle); + this.reject(new Error("Timer was aborted.")); + this.setNotStarted(); } return this; } @@ -116,10 +120,10 @@ export default class Timer { *@return {Promise} */ finished() { - return this._promise; + return this.promise; } isRunning() { - return this._timerHandle !== null; + return this.timerHandle !== null; } } diff --git a/src/utils/TypeUtils.js b/src/utils/TypeUtils.ts similarity index 100% rename from src/utils/TypeUtils.js rename to src/utils/TypeUtils.ts diff --git a/src/utils/arrays.ts b/src/utils/arrays.ts index cea377bfe9..f7e693452b 100644 --- a/src/utils/arrays.ts +++ b/src/utils/arrays.ts @@ -73,6 +73,26 @@ export function arraySeed(val: T, length: number): T[] { return a; } +/** + * Trims or fills the array to ensure it meets the desired length. The seed array + * given is pulled from to fill any missing slots - it is recommended that this be + * at least `len` long. The resulting array will be exactly `len` long, either + * trimmed from the source or filled with the some/all of the seed array. + * @param {T[]} a The array to trim/fill. + * @param {number} len The length to trim or fill to, as needed. + * @param {T[]} seed Values to pull from if the array needs filling. + * @returns {T[]} The resulting array of `len` length. + */ +export function arrayTrimFill(a: T[], len: number, seed: T[]): T[] { + // Dev note: we do length checks because the spread operator can result in some + // performance penalties in more critical code paths. As a utility, it should be + // as fast as possible to not cause a problem for the call stack, no matter how + // critical that stack is. + if (a.length === len) return a; + if (a.length > len) return a.slice(0, len); + return a.concat(seed.slice(0, len - a.length)); +} + /** * Clones an array as fast as possible, retaining references of the array's values. * @param a The array to clone. Must be defined. diff --git a/src/utils/permalinks/ElementPermalinkConstructor.js b/src/utils/permalinks/ElementPermalinkConstructor.ts similarity index 82% rename from src/utils/permalinks/ElementPermalinkConstructor.js rename to src/utils/permalinks/ElementPermalinkConstructor.ts index da7f5797ea..cd7f2b9d2c 100644 --- a/src/utils/permalinks/ElementPermalinkConstructor.js +++ b/src/utils/permalinks/ElementPermalinkConstructor.ts @@ -20,31 +20,31 @@ import PermalinkConstructor, {PermalinkParts} from "./PermalinkConstructor"; * Generates permalinks that self-reference the running webapp */ export default class ElementPermalinkConstructor extends PermalinkConstructor { - _elementUrl: string; + private elementUrl: string; constructor(elementUrl: string) { super(); - this._elementUrl = elementUrl; + this.elementUrl = elementUrl; - if (!this._elementUrl.startsWith("http:") && !this._elementUrl.startsWith("https:")) { + if (!this.elementUrl.startsWith("http:") && !this.elementUrl.startsWith("https:")) { throw new Error("Element prefix URL does not appear to be an HTTP(S) URL"); } } forEvent(roomId: string, eventId: string, serverCandidates: string[]): string { - return `${this._elementUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; + return `${this.elementUrl}/#/room/${roomId}/${eventId}${this.encodeServerCandidates(serverCandidates)}`; } - forRoom(roomIdOrAlias: string, serverCandidates: string[]): string { - return `${this._elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; + forRoom(roomIdOrAlias: string, serverCandidates?: string[]): string { + return `${this.elementUrl}/#/room/${roomIdOrAlias}${this.encodeServerCandidates(serverCandidates)}`; } forUser(userId: string): string { - return `${this._elementUrl}/#/user/${userId}`; + return `${this.elementUrl}/#/user/${userId}`; } forGroup(groupId: string): string { - return `${this._elementUrl}/#/group/${groupId}`; + return `${this.elementUrl}/#/group/${groupId}`; } forEntity(entityId: string): string { @@ -58,11 +58,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { } isPermalinkHost(testHost: string): boolean { - const parsedUrl = new URL(this._elementUrl); + const parsedUrl = new URL(this.elementUrl); return testHost === (parsedUrl.host || parsedUrl.hostname); // one of the hosts should match } - encodeServerCandidates(candidates: string[]) { + encodeServerCandidates(candidates?: string[]) { if (!candidates || candidates.length === 0) return ''; return `?via=${candidates.map(c => encodeURIComponent(c)).join("&via=")}`; } @@ -71,11 +71,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { // https://github.com/turt2live/matrix-js-bot-sdk/blob/7c4665c9a25c2c8e0fe4e509f2616505b5b66a1c/src/Permalinks.ts#L33-L61 // Adapted for Element's URL format parsePermalink(fullUrl: string): PermalinkParts { - if (!fullUrl || !fullUrl.startsWith(this._elementUrl)) { + if (!fullUrl || !fullUrl.startsWith(this.elementUrl)) { throw new Error("Does not appear to be a permalink"); } - const parts = fullUrl.substring(`${this._elementUrl}/#/`.length); + const parts = fullUrl.substring(`${this.elementUrl}/#/`.length); return ElementPermalinkConstructor.parseAppRoute(parts); } diff --git a/src/utils/permalinks/PermalinkConstructor.js b/src/utils/permalinks/PermalinkConstructor.ts similarity index 100% rename from src/utils/permalinks/PermalinkConstructor.js rename to src/utils/permalinks/PermalinkConstructor.ts diff --git a/src/utils/permalinks/Permalinks.js b/src/utils/permalinks/Permalinks.ts similarity index 76% rename from src/utils/permalinks/Permalinks.js rename to src/utils/permalinks/Permalinks.ts index bcf4d87136..015ecca22e 100644 --- a/src/utils/permalinks/Permalinks.js +++ b/src/utils/permalinks/Permalinks.ts @@ -17,6 +17,9 @@ limitations under the License. import isIp from "is-ip"; import * as utils from "matrix-js-sdk/src/utils"; import {Room} from "matrix-js-sdk/src/models/room"; +import {EventType} from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import {MatrixClientPeg} from "../../MatrixClientPeg"; import SpecPermalinkConstructor, {baseUrl as matrixtoBaseUrl} from "./SpecPermalinkConstructor"; @@ -74,29 +77,35 @@ const MAX_SERVER_CANDIDATES = 3; // the list and magically have the link work. export class RoomPermalinkCreator { + private room: Room; + private roomId: string; + private highestPlUserId: string; + private populationMap: { [serverName: string]: number }; + private bannedHostsRegexps: RegExp[]; + private allowedHostsRegexps: RegExp[]; + private _serverCandidates: string[]; + private started: boolean; + // We support being given a roomId as a fallback in the event the `room` object // doesn't exist or is not healthy for us to rely on. For example, loading a // permalink to a room which the MatrixClient doesn't know about. - constructor(room, roomId = null) { - this._room = room; - this._roomId = room ? room.roomId : roomId; - this._highestPlUserId = null; - this._populationMap = null; - this._bannedHostsRegexps = null; - this._allowedHostsRegexps = null; + constructor(room: Room, roomId: string = null) { + this.room = room; + this.roomId = room ? room.roomId : roomId; + this.highestPlUserId = null; + this.populationMap = null; + this.bannedHostsRegexps = null; + this.allowedHostsRegexps = null; this._serverCandidates = null; - this._started = false; + this.started = false; - if (!this._roomId) { + if (!this.roomId) { throw new Error("Failed to resolve a roomId for the permalink creator to use"); } - - this.onMembership = this.onMembership.bind(this); - this.onRoomState = this.onRoomState.bind(this); } load() { - if (!this._room || !this._room.currentState) { + if (!this.room || !this.room.currentState) { // Under rare and unknown circumstances it is possible to have a room with no // currentState, at least potentially at the early stages of joining a room. // To avoid breaking everything, we'll just warn rather than throw as well as @@ -104,23 +113,23 @@ export class RoomPermalinkCreator { console.warn("Tried to load a permalink creator with no room state"); return; } - this._updateAllowedServers(); - this._updateHighestPlUser(); - this._updatePopulationMap(); - this._updateServerCandidates(); + this.updateAllowedServers(); + this.updateHighestPlUser(); + this.updatePopulationMap(); + this.updateServerCandidates(); } start() { this.load(); - this._room.on("RoomMember.membership", this.onMembership); - this._room.on("RoomState.events", this.onRoomState); - this._started = true; + this.room.on("RoomMember.membership", this.onMembership); + this.room.on("RoomState.events", this.onRoomState); + this.started = true; } stop() { - this._room.removeListener("RoomMember.membership", this.onMembership); - this._room.removeListener("RoomState.events", this.onRoomState); - this._started = false; + this.room.removeListener("RoomMember.membership", this.onMembership); + this.room.removeListener("RoomState.events", this.onRoomState); + this.started = false; } get serverCandidates() { @@ -128,44 +137,44 @@ export class RoomPermalinkCreator { } isStarted() { - return this._started; + return this.started; } - forEvent(eventId) { - return getPermalinkConstructor().forEvent(this._roomId, eventId, this._serverCandidates); + forEvent(eventId: string): string { + return getPermalinkConstructor().forEvent(this.roomId, eventId, this._serverCandidates); } - forShareableRoom() { - if (this._room) { + forShareableRoom(): string { + if (this.room) { // Prefer to use canonical alias for permalink if possible - const alias = this._room.getCanonicalAlias(); + const alias = this.room.getCanonicalAlias(); if (alias) { return getPermalinkConstructor().forRoom(alias, this._serverCandidates); } } - return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates); + return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates); } - forRoom() { - return getPermalinkConstructor().forRoom(this._roomId, this._serverCandidates); + forRoom(): string { + return getPermalinkConstructor().forRoom(this.roomId, this._serverCandidates); } - onRoomState(event) { + private onRoomState = (event: MatrixEvent) => { switch (event.getType()) { - case "m.room.server_acl": - this._updateAllowedServers(); - this._updateHighestPlUser(); - this._updatePopulationMap(); - this._updateServerCandidates(); + case EventType.RoomServerAcl: + this.updateAllowedServers(); + this.updateHighestPlUser(); + this.updatePopulationMap(); + this.updateServerCandidates(); return; - case "m.room.power_levels": - this._updateHighestPlUser(); - this._updateServerCandidates(); + case EventType.RoomPowerLevels: + this.updateHighestPlUser(); + this.updateServerCandidates(); return; } } - onMembership(evt, member, oldMembership) { + private onMembership = (evt: MatrixEvent, member: RoomMember, oldMembership: string) => { const userId = member.userId; const membership = member.membership; const serverName = getServerName(userId); @@ -173,17 +182,17 @@ export class RoomPermalinkCreator { const hasLeft = oldMembership === "join" && membership !== "join"; if (hasLeft) { - this._populationMap[serverName]--; + this.populationMap[serverName]--; } else if (hasJoined) { - this._populationMap[serverName]++; + this.populationMap[serverName]++; } - this._updateHighestPlUser(); - this._updateServerCandidates(); + this.updateHighestPlUser(); + this.updateServerCandidates(); } - _updateHighestPlUser() { - const plEvent = this._room.currentState.getStateEvents("m.room.power_levels", ""); + private updateHighestPlUser() { + const plEvent = this.room.currentState.getStateEvents("m.room.power_levels", ""); if (plEvent) { const content = plEvent.getContent(); if (content) { @@ -191,14 +200,14 @@ export class RoomPermalinkCreator { if (users) { const entries = Object.entries(users); const allowedEntries = entries.filter(([userId]) => { - const member = this._room.getMember(userId); + const member = this.room.getMember(userId); if (!member || member.membership !== "join") { return false; } const serverName = getServerName(userId); return !isHostnameIpAddress(serverName) && - !isHostInRegex(serverName, this._bannedHostsRegexps) && - isHostInRegex(serverName, this._allowedHostsRegexps); + !isHostInRegex(serverName, this.bannedHostsRegexps) && + isHostInRegex(serverName, this.allowedHostsRegexps); }); const maxEntry = allowedEntries.reduce((max, entry) => { return (entry[1] > max[1]) ? entry : max; @@ -206,20 +215,20 @@ export class RoomPermalinkCreator { const [userId, powerLevel] = maxEntry; // object wasn't empty, and max entry wasn't a demotion from the default if (userId !== null && powerLevel >= 50) { - this._highestPlUserId = userId; + this.highestPlUserId = userId; return; } } } } - this._highestPlUserId = null; + this.highestPlUserId = null; } - _updateAllowedServers() { + private updateAllowedServers() { const bannedHostsRegexps = []; let allowedHostsRegexps = [new RegExp(".*")]; // default allow everyone - if (this._room.currentState) { - const aclEvent = this._room.currentState.getStateEvents("m.room.server_acl", ""); + if (this.room.currentState) { + const aclEvent = this.room.currentState.getStateEvents("m.room.server_acl", ""); if (aclEvent && aclEvent.getContent()) { const getRegex = (hostname) => new RegExp("^" + utils.globToRegexp(hostname, false) + "$"); @@ -231,35 +240,35 @@ export class RoomPermalinkCreator { allowed.forEach(h => allowedHostsRegexps.push(getRegex(h))); } } - this._bannedHostsRegexps = bannedHostsRegexps; - this._allowedHostsRegexps = allowedHostsRegexps; + this.bannedHostsRegexps = bannedHostsRegexps; + this.allowedHostsRegexps = allowedHostsRegexps; } - _updatePopulationMap() { + private updatePopulationMap() { const populationMap: { [server: string]: number } = {}; - for (const member of this._room.getJoinedMembers()) { + for (const member of this.room.getJoinedMembers()) { const serverName = getServerName(member.userId); if (!populationMap[serverName]) { populationMap[serverName] = 0; } populationMap[serverName]++; } - this._populationMap = populationMap; + this.populationMap = populationMap; } - _updateServerCandidates() { + private updateServerCandidates() { let candidates = []; - if (this._highestPlUserId) { - candidates.push(getServerName(this._highestPlUserId)); + if (this.highestPlUserId) { + candidates.push(getServerName(this.highestPlUserId)); } - const serversByPopulation = Object.keys(this._populationMap) - .sort((a, b) => this._populationMap[b] - this._populationMap[a]) + const serversByPopulation = Object.keys(this.populationMap) + .sort((a, b) => this.populationMap[b] - this.populationMap[a]) .filter(a => { return !candidates.includes(a) && !isHostnameIpAddress(a) && - !isHostInRegex(a, this._bannedHostsRegexps) && - isHostInRegex(a, this._allowedHostsRegexps); + !isHostInRegex(a, this.bannedHostsRegexps) && + isHostInRegex(a, this.allowedHostsRegexps); }); const remainingServers = serversByPopulation.slice(0, MAX_SERVER_CANDIDATES - candidates.length); @@ -273,11 +282,11 @@ export function makeGenericPermalink(entityId: string): string { return getPermalinkConstructor().forEntity(entityId); } -export function makeUserPermalink(userId) { +export function makeUserPermalink(userId: string): string { return getPermalinkConstructor().forUser(userId); } -export function makeRoomPermalink(roomId) { +export function makeRoomPermalink(roomId: string): string { if (!roomId) { throw new Error("can't permalink a falsey roomId"); } @@ -296,7 +305,7 @@ export function makeRoomPermalink(roomId) { return permalinkCreator.forRoom(); } -export function makeGroupPermalink(groupId) { +export function makeGroupPermalink(groupId: string): string { return getPermalinkConstructor().forGroup(groupId); } @@ -337,7 +346,7 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { return permalink; } - const m = permalink.match(matrixLinkify.ELEMENT_URL_PATTERN); + const m = decodeURIComponent(permalink).match(matrixLinkify.ELEMENT_URL_PATTERN); if (m) { return m[1]; } @@ -402,8 +411,8 @@ function getPermalinkConstructor(): PermalinkConstructor { export function parsePermalink(fullUrl: string): PermalinkParts { const elementPrefix = SdkConfig.get()['permalinkPrefix']; - if (fullUrl.startsWith(matrixtoBaseUrl)) { - return new SpecPermalinkConstructor().parsePermalink(fullUrl); + if (decodeURIComponent(fullUrl).startsWith(matrixtoBaseUrl)) { + return new SpecPermalinkConstructor().parsePermalink(decodeURIComponent(fullUrl)); } else if (elementPrefix && fullUrl.startsWith(elementPrefix)) { return new ElementPermalinkConstructor(elementPrefix).parsePermalink(fullUrl); } @@ -428,24 +437,24 @@ export function parseAppLocalLink(localLink: string): PermalinkParts { return null; } -function getServerName(userId) { +function getServerName(userId: string): string { return userId.split(":").splice(1).join(":"); } -function getHostnameFromMatrixDomain(domain) { +function getHostnameFromMatrixDomain(domain: string): string { if (!domain) return null; return new URL(`https://${domain}`).hostname; } -function isHostInRegex(hostname, regexps) { +function isHostInRegex(hostname: string, regexps: RegExp[]) { hostname = getHostnameFromMatrixDomain(hostname); if (!hostname) return true; // assumed - if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0]); + if (regexps.length > 0 && !regexps[0].test) throw new Error(regexps[0].toString()); return regexps.filter(h => h.test(hostname)).length > 0; } -function isHostnameIpAddress(hostname) { +function isHostnameIpAddress(hostname: string): boolean { hostname = getHostnameFromMatrixDomain(hostname); if (!hostname) return false; diff --git a/src/utils/permalinks/SpecPermalinkConstructor.js b/src/utils/permalinks/SpecPermalinkConstructor.ts similarity index 100% rename from src/utils/permalinks/SpecPermalinkConstructor.js rename to src/utils/permalinks/SpecPermalinkConstructor.ts diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts new file mode 100644 index 0000000000..99b1f62866 --- /dev/null +++ b/src/voice/Playback.ts @@ -0,0 +1,141 @@ +/* +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 EventEmitter from "events"; +import {UPDATE_EVENT} from "../stores/AsyncStore"; +import {arrayFastResample, arraySeed} from "../utils/arrays"; +import {SimpleObservable} from "matrix-widget-api"; +import {IDestroyable} from "../utils/IDestroyable"; +import {PlaybackClock} from "./PlaybackClock"; + +export enum PlaybackState { + Decoding = "decoding", + Stopped = "stopped", // no progress on timeline + Paused = "paused", // some progress on timeline + Playing = "playing", // active progress through timeline +} + +export const PLAYBACK_WAVEFORM_SAMPLES = 35; +const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); + +export class Playback extends EventEmitter implements IDestroyable { + private readonly context: AudioContext; + private source: AudioBufferSourceNode; + private state = PlaybackState.Decoding; + private audioBuf: AudioBuffer; + private resampledWaveform: number[]; + private waveformObservable = new SimpleObservable(); + private readonly clock: PlaybackClock; + + /** + * Creates a new playback instance from a buffer. + * @param {ArrayBuffer} buf The buffer containing the sound sample. + * @param {number[]} seedWaveform Optional seed waveform to present until the proper waveform + * can be calculated. Contains values between zero and one, inclusive. + */ + constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { + super(); + this.context = new AudioContext(); + this.resampledWaveform = arrayFastResample(seedWaveform, PLAYBACK_WAVEFORM_SAMPLES); + this.waveformObservable.update(this.resampledWaveform); + this.clock = new PlaybackClock(this.context); + + // TODO: @@ TR: Calculate real waveform + } + + public get waveform(): number[] { + return this.resampledWaveform; + } + + public get waveformData(): SimpleObservable { + return this.waveformObservable; + } + + public get clockInfo(): PlaybackClock { + return this.clock; + } + + public get currentState(): PlaybackState { + return this.state; + } + + public get isPlaying(): boolean { + return this.currentState === PlaybackState.Playing; + } + + public emit(event: PlaybackState, ...args: any[]): boolean { + this.state = event; + super.emit(event, ...args); + super.emit(UPDATE_EVENT, event, ...args); + return true; // we don't ever care if the event had listeners, so just return "yes" + } + + public destroy() { + // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here + this.stop(); + this.removeAllListeners(); + this.clock.destroy(); + this.waveformObservable.close(); + } + + public async prepare() { + this.audioBuf = await this.context.decodeAudioData(this.buf); + this.emit(PlaybackState.Stopped); // signal that we're not decoding anymore + this.clock.durationSeconds = this.audioBuf.duration; + } + + private onPlaybackEnd = async () => { + await this.context.suspend(); + this.emit(PlaybackState.Stopped); + }; + + public async play() { + // We can't restart a buffer source, so we need to create a new one if we hit the end + if (this.state === PlaybackState.Stopped) { + if (this.source) { + this.source.disconnect(); + this.source.removeEventListener("ended", this.onPlaybackEnd); + } + + this.source = this.context.createBufferSource(); + this.source.connect(this.context.destination); + this.source.buffer = this.audioBuf; + this.source.start(); // start immediately + this.source.addEventListener("ended", this.onPlaybackEnd); + } + + // We use the context suspend/resume functions because it allows us to pause a source + // node, but that still doesn't help us when the source node runs out (see above). + await this.context.resume(); + this.clock.flagStart(); + this.emit(PlaybackState.Playing); + } + + public async pause() { + await this.context.suspend(); + this.emit(PlaybackState.Paused); + } + + public async stop() { + await this.onPlaybackEnd(); + this.clock.flagStop(); + } + + public async toggle() { + if (this.isPlaying) await this.pause(); + else await this.play(); + } +} diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts new file mode 100644 index 0000000000..06d6381691 --- /dev/null +++ b/src/voice/PlaybackClock.ts @@ -0,0 +1,78 @@ +/* +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 {SimpleObservable} from "matrix-widget-api"; +import {IDestroyable} from "../utils/IDestroyable"; + +// Because keeping track of time is sufficiently complicated... +export class PlaybackClock implements IDestroyable { + private clipStart = 0; + private stopped = true; + private lastCheck = 0; + private observable = new SimpleObservable(); + private timerId: number; + private clipDuration = 0; + + public constructor(private context: AudioContext) { + } + + public get durationSeconds(): number { + return this.clipDuration; + } + + public set durationSeconds(val: number) { + this.clipDuration = val; + this.observable.update([this.timeSeconds, this.clipDuration]); + } + + public get timeSeconds(): number { + return (this.context.currentTime - this.clipStart) % this.clipDuration; + } + + public get liveData(): SimpleObservable { + return this.observable; + } + + private checkTime = () => { + const now = this.timeSeconds; + if (this.lastCheck !== now) { + this.observable.update([now, this.durationSeconds]); + this.lastCheck = now; + } + }; + + public flagStart() { + if (this.stopped) { + this.clipStart = this.context.currentTime; + this.stopped = false; + } + + if (!this.timerId) { + // case to number because the types are wrong + // 100ms interval to make sure the time is as accurate as possible + this.timerId = setInterval(this.checkTime, 100); + } + } + + public flagStop() { + this.stopped = true; + } + + public destroy() { + this.observable.close(); + if (this.timerId) clearInterval(this.timerId); + } +} diff --git a/src/voice/VoiceRecording.ts b/src/voice/VoiceRecording.ts index b0cc3cd407..eb705200ca 100644 --- a/src/voice/VoiceRecording.ts +++ b/src/voice/VoiceRecording.ts @@ -24,7 +24,8 @@ import EventEmitter from "events"; import {IDestroyable} from "../utils/IDestroyable"; import {Singleflight} from "../utils/Singleflight"; import {PayloadEvent, WORKLET_NAME} from "./consts"; -import {arrayFastClone} from "../utils/arrays"; +import {UPDATE_EVENT} from "../stores/AsyncStore"; +import {Playback} from "./Playback"; const CHANNELS = 1; // stereo isn't important const SAMPLE_RATE = 48000; // 48khz is what WebRTC uses. 12khz is where we lose quality. @@ -52,20 +53,17 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderStream: MediaStream; private recorderFFT: AnalyserNode; private recorderWorklet: AudioWorkletNode; - private buffer = new Uint8Array(0); + private buffer = new Uint8Array(0); // use this.audioBuffer to access private mxc: string; private recording = false; private observable: SimpleObservable; private amplitudes: number[] = []; // at each second mark, generated + private playback: Playback; public constructor(private client: MatrixClient) { super(); } - public get finalWaveform(): number[] { - return arrayFastClone(this.amplitudes); - } - public get contentType(): string { return "audio/ogg"; } @@ -79,6 +77,16 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return this.recorderContext.currentTime; } + public get isRecording(): boolean { + return this.recording; + } + + public emit(event: string, ...args: any[]): boolean { + super.emit(event, ...args); + super.emit(UPDATE_EVENT, event, ...args); + return true; // we don't ever care if the event had listeners, so just return "yes" + } + private async makeRecorder() { this.recorderStream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -154,6 +162,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { }; } + private get audioBuffer(): Uint8Array { + // We need a clone of the buffer to avoid accidentally changing the position + // on the real thing. + return this.buffer.slice(0); + } + public get liveData(): SimpleObservable { if (!this.recording) throw new Error("No observable when not recording"); return this.observable; @@ -203,8 +217,19 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // Now that we've updated the data/waveform, let's do a time check. We don't want to // go horribly over the limit. We also emit a warning state if needed. - const secondsLeft = TARGET_MAX_LENGTH - timeSeconds; - if (secondsLeft <= 0) { + // + // We use the recorder's perspective of time to make sure we don't cut off the last + // frame of audio, otherwise we end up with a 1:59 clip (119.68 seconds). This extra + // safety can allow us to overshoot the target a bit, but at least when we say 2min + // maximum we actually mean it. + // + // In testing, recorder time and worker time lag by about 400ms, which is roughly the + // time needed to encode a sample/frame. + // + // Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields + const recorderSeconds = this.recorder.encodedSamplePosition / 48000; + const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds; + if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping this.stop(); } else if (secondsLeft <= TARGET_WARN_TIME_LEFT) { @@ -239,9 +264,9 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { } // Disconnect the source early to start shutting down resources + await this.recorder.stop(); // stop first to flush the last frame this.recorderSource.disconnect(); this.recorderWorklet.disconnect(); - await this.recorder.stop(); // close the context after the recorder so the recorder doesn't try to // connect anything to the context (this would generate a warning) @@ -255,15 +280,33 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { await this.recorder.close(); this.emit(RecordingState.Ended); - return this.buffer; + return this.audioBuffer; }); } + /** + * Gets a playback instance for this voice recording. Note that the playback will not + * have been prepared fully, meaning the `prepare()` function needs to be called on it. + * + * The same playback instance is returned each time. + * + * @returns {Playback} The playback instance. + */ + public getPlayback(): Playback { + this.playback = Singleflight.for(this, "playback").do(() => { + return new Playback(this.audioBuffer.buffer, this.amplitudes); // cast to ArrayBuffer proper; + }); + return this.playback; + } + public destroy() { // noinspection JSIgnoredPromiseFromCall - not concerned about stop() being called async here this.stop(); this.removeAllListeners(); Singleflight.forgetAllFor(this); + // noinspection JSIgnoredPromiseFromCall - not concerned about being called async here + this.playback?.destroy(); + this.observable.close(); } public async upload(): Promise { @@ -274,7 +317,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { if (this.mxc) return this.mxc; this.emit(RecordingState.Uploading); - this.mxc = await this.client.uploadContent(new Blob([this.buffer], { + this.mxc = await this.client.uploadContent(new Blob([this.audioBuffer], { type: this.contentType, }), { onlyContentUri: false, // to stop the warnings in the console @@ -283,5 +326,3 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return this.mxc; } } - -window.mxVoiceRecorder = VoiceRecording; diff --git a/test/CallHandler-test.ts b/test/CallHandler-test.ts new file mode 100644 index 0000000000..754610b223 --- /dev/null +++ b/test/CallHandler-test.ts @@ -0,0 +1,216 @@ +/* +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 './skinned-sdk'; + +import CallHandler, { PlaceCallType } from '../src/CallHandler'; +import { stubClient, mkStubRoom } from './test-utils'; +import { MatrixClientPeg } from '../src/MatrixClientPeg'; +import dis from '../src/dispatcher/dispatcher'; +import { CallEvent, CallState } from 'matrix-js-sdk/src/webrtc/call'; +import DMRoomMap from '../src/utils/DMRoomMap'; +import EventEmitter from 'events'; +import { Action } from '../src/dispatcher/actions'; +import SdkConfig from '../src/SdkConfig'; + +const REAL_ROOM_ID = '$room1:example.org'; +const MAPPED_ROOM_ID = '$room2:example.org'; +const MAPPED_ROOM_ID_2 = '$room3:example.org'; + +function mkStubDM(roomId, userId) { + const room = mkStubRoom(roomId); + room.getJoinedMembers = jest.fn().mockReturnValue([ + { + userId: '@me:example.org', + name: 'Member', + rawDisplayName: 'Member', + roomId: roomId, + membership: 'join', + getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', + }, + { + userId: userId, + name: 'Member', + rawDisplayName: 'Member', + roomId: roomId, + membership: 'join', + getAvatarUrl: () => 'mxc://avatar.url/image.png', + getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', + }, + ]); + room.currentState.getMembers = room.getJoinedMembers; + + return room; +} + +class FakeCall extends EventEmitter { + roomId: string; + callId = "fake call id"; + + constructor(roomId) { + super(); + + this.roomId = roomId; + } + + setRemoteOnHold() {} + setRemoteAudioElement() {} + + placeVoiceCall() { + this.emit(CallEvent.State, CallState.Connected, null); + } +} + +describe('CallHandler', () => { + let dmRoomMap; + let callHandler; + let audioElement; + let fakeCall; + + beforeEach(() => { + stubClient(); + MatrixClientPeg.get().createCall = roomId => { + if (fakeCall && fakeCall.roomId !== roomId) { + throw new Error("Only one call is supported!"); + } + fakeCall = new FakeCall(roomId); + return fakeCall; + }; + + callHandler = new CallHandler(); + callHandler.start(); + + dmRoomMap = { + getUserIdForRoomId: roomId => { + if (roomId === REAL_ROOM_ID) { + return '@user1:example.org'; + } else if (roomId === MAPPED_ROOM_ID) { + return '@user2:example.org'; + } else if (roomId === MAPPED_ROOM_ID_2) { + return '@user3:example.org'; + } else { + return null; + } + }, + getDMRoomsForUserId: userId => { + if (userId === '@user2:example.org') { + return [MAPPED_ROOM_ID]; + } else if (userId === '@user3:example.org') { + return [MAPPED_ROOM_ID_2]; + } else { + return []; + } + }, + }; + DMRoomMap.setShared(dmRoomMap); + + audioElement = document.createElement('audio'); + audioElement.id = "remoteAudio"; + document.body.appendChild(audioElement); + }); + + afterEach(() => { + callHandler.stop(); + DMRoomMap.setShared(null); + // @ts-ignore + window.mxCallHandler = null; + MatrixClientPeg.unset(); + + document.body.removeChild(audioElement); + SdkConfig.unset(); + }); + + it('should move calls between rooms when remote asserted identity changes', async () => { + const realRoom = mkStubDM(REAL_ROOM_ID, '@user1:example.org'); + const mappedRoom = mkStubDM(MAPPED_ROOM_ID, '@user2:example.org'); + const mappedRoom2 = mkStubDM(MAPPED_ROOM_ID_2, '@user3:example.org'); + + MatrixClientPeg.get().getRoom = roomId => { + switch (roomId) { + case REAL_ROOM_ID: + return realRoom; + case MAPPED_ROOM_ID: + return mappedRoom; + case MAPPED_ROOM_ID_2: + return mappedRoom2; + } + }; + + dis.dispatch({ + action: 'place_call', + type: PlaceCallType.Voice, + room_id: REAL_ROOM_ID, + }, true); + + let dispatchHandle; + // wait for the call to be set up + await new Promise(resolve => { + dispatchHandle = dis.register(payload => { + if (payload.action === 'call_state') { + resolve(); + } + }); + }); + dis.unregister(dispatchHandle); + + // should start off in the actual room ID it's in at the protocol level + expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBe(fakeCall); + + let callRoomChangeEventCount = 0; + const roomChangePromise = new Promise(resolve => { + dispatchHandle = dis.register(payload => { + if (payload.action === Action.CallChangeRoom) { + ++callRoomChangeEventCount; + resolve(); + } + }); + }); + + // Now emit an asserted identity for user2: this should be ignored + // because we haven't set the config option to obey asserted identity + fakeCall.getRemoteAssertedIdentity = jest.fn().mockReturnValue({ + id: "@user2:example.org", + }); + fakeCall.emit(CallEvent.AssertedIdentityChanged); + + // Now set the config option + SdkConfig.put({ + voip: { + obeyAssertedIdentity: true, + }, + }); + + // ...and send another asserted identity event for a different user + fakeCall.getRemoteAssertedIdentity = jest.fn().mockReturnValue({ + id: "@user3:example.org", + }); + fakeCall.emit(CallEvent.AssertedIdentityChanged); + + await roomChangePromise; + dis.unregister(dispatchHandle); + + // If everything's gone well, we should have seen only one room change + // event and the call should now be in user 3's room. + // If it's not obeying any, the call will still be in REAL_ROOM_ID. + // If it incorrectly obeyed both asserted identity changes, either it will + // have just processed one and the call will be in the wrong room, or we'll + // have seen two room change dispatches. + expect(callRoomChangeEventCount).toEqual(1); + expect(callHandler.getCallForRoom(REAL_ROOM_ID)).toBeNull(); + expect(callHandler.getCallForRoom(MAPPED_ROOM_ID_2)).toBe(fakeCall); + }); +}); diff --git a/test/ScalarAuthClient-test.js b/test/ScalarAuthClient-test.js index 83f357811a..3435f70932 100644 --- a/test/ScalarAuthClient-test.js +++ b/test/ScalarAuthClient-test.js @@ -29,7 +29,7 @@ describe('ScalarAuthClient', function() { it('should request a new token if the old one fails', async function() { const sac = new ScalarAuthClient(); - sac._getAccountName = jest.fn((arg) => { + sac.getAccountName = jest.fn((arg) => { switch (arg) { case "brokentoken": return Promise.reject({ diff --git a/test/components/views/rooms/RoomList-test.js b/test/components/views/rooms/RoomList-test.js index fcdd71629e..d3211f564c 100644 --- a/test/components/views/rooms/RoomList-test.js +++ b/test/components/views/rooms/RoomList-test.js @@ -29,7 +29,10 @@ function waitForRoomListStoreUpdate() { describe('RoomList', () => { function createRoom(opts) { - const room = new Room(generateRoomId(), null, client.getUserId()); + const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), { + // The room list now uses getPendingEvents(), so we need a detached ordering. + pendingEventOrdering: "detached", + }); if (opts) { Object.assign(room, opts); } diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts new file mode 100644 index 0000000000..aef788647d --- /dev/null +++ b/test/stores/SpaceStore-test.ts @@ -0,0 +1,714 @@ +/* +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 { EventEmitter } from "events"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import "../skinned-sdk"; // Must be first for skinning to work +import SpaceStore, { + UPDATE_INVITED_SPACES, + UPDATE_SELECTED_SPACE, + UPDATE_TOP_LEVEL_SPACES +} from "../../src/stores/SpaceStore"; +import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; +import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; +import { EnhancedMap } from "../../src/utils/maps"; +import SettingsStore from "../../src/settings/SettingsStore"; +import DMRoomMap from "../../src/utils/DMRoomMap"; +import { MatrixClientPeg } from "../../src/MatrixClientPeg"; +import defaultDispatcher from "../../src/dispatcher/dispatcher"; + +type MatrixEvent = any; // importing from js-sdk upsets things + +jest.useFakeTimers(); + +const mockStateEventImplementation = (events: MatrixEvent[]) => { + const stateMap = new EnhancedMap>(); + events.forEach(event => { + stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event); + }); + + return (eventType: string, stateKey?: string) => { + if (stateKey || stateKey === "") { + return stateMap.get(eventType)?.get(stateKey) || null; + } + return Array.from(stateMap.get(eventType)?.values() || []); + }; +}; + +const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r)); + +const testUserId = "@test:user"; + +let rooms = []; + +const mkRoom = (roomId: string) => { + const room = mkStubRoom(roomId); + room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([])); + rooms.push(room); + return room; +}; + +const mkSpace = (spaceId: string, children: string[] = []) => { + const space = mkRoom(spaceId); + space.isSpaceRoom.mockReturnValue(true); + space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId => + mkEvent({ + event: true, + type: EventType.SpaceChild, + room: spaceId, + user: testUserId, + skey: roomId, + content: { via: [] }, + ts: Date.now(), + }), + ))); + return space; +}; + +const getValue = jest.fn(); +SettingsStore.getValue = getValue; + +const getUserIdForRoomId = jest.fn(); +// @ts-ignore +DMRoomMap.sharedInstance = { getUserIdForRoomId }; + +const fav1 = "!fav1:server"; +const fav2 = "!fav2:server"; +const fav3 = "!fav3:server"; +const dm1 = "!dm1:server"; +const dm1Partner = "@dm1Partner:server"; +const dm2 = "!dm2:server"; +const dm2Partner = "@dm2Partner:server"; +const dm3 = "!dm3:server"; +const dm3Partner = "@dm3Partner:server"; +const orphan1 = "!orphan1:server"; +const orphan2 = "!orphan2:server"; +const invite1 = "!invite1:server"; +const invite2 = "!invite2:server"; +const room1 = "!room1:server"; +const room2 = "!room2:server"; +const space1 = "!space1:server"; +const space2 = "!space2:server"; +const space3 = "!space3:server"; + +describe("SpaceStore", () => { + stubClient(); + const store = SpaceStore.instance; + const client = MatrixClientPeg.get(); + + const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true); + + const run = async () => { + client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); + await setupAsyncStoreWithClient(store, client); + jest.runAllTimers(); + }; + + beforeEach(() => { + jest.runAllTimers(); + client.getVisibleRooms.mockReturnValue(rooms = []); + getValue.mockImplementation(settingName => { + if (settingName === "feature_spaces") { + return true; + } + }); + }); + afterEach(async () => { + await resetAsyncStoreWithClient(store); + }); + + describe("static hierarchy resolution tests", () => { + it("handles no spaces", async () => { + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("handles 3 joined top level spaces", async () => { + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!space3:server"); + await run(); + + expect(store.spacePanelSpaces.sort()).toStrictEqual(client.getVisibleRooms().sort()); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("handles a basic hierarchy", async () => { + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!company:server", [ + mkSpace("!company_dept1:server", [ + mkSpace("!company_dept1_group1:server").roomId, + ]).roomId, + mkSpace("!company_dept2:server").roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([ + "!space1:server", + "!space2:server", + "!company:server", + ].sort()); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!space1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space1:server")).toStrictEqual([]); + expect(store.getChildRooms("!space2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space2:server")).toStrictEqual([]); + expect(store.getChildRooms("!company:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company:server")).toStrictEqual([ + client.getRoom("!company_dept1:server"), + client.getRoom("!company_dept2:server"), + ]); + expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([ + client.getRoom("!company_dept1_group1:server"), + ]); + expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([]); + }); + + it("handles a sub-space existing in multiple places in the space tree", async () => { + const subspace = mkSpace("!subspace:server"); + mkSpace("!space1:server"); + mkSpace("!space2:server"); + mkSpace("!company:server", [ + mkSpace("!company_dept1:server", [ + mkSpace("!company_dept1_group1:server", [subspace.roomId]).roomId, + ]).roomId, + mkSpace("!company_dept2:server", [subspace.roomId]).roomId, + subspace.roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId).sort()).toStrictEqual([ + "!space1:server", + "!space2:server", + "!company:server", + ].sort()); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!space1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space1:server")).toStrictEqual([]); + expect(store.getChildRooms("!space2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!space2:server")).toStrictEqual([]); + expect(store.getChildRooms("!company:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company:server")).toStrictEqual([ + client.getRoom("!company_dept1:server"), + client.getRoom("!company_dept2:server"), + subspace, + ]); + expect(store.getChildRooms("!company_dept1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1:server")).toStrictEqual([ + client.getRoom("!company_dept1_group1:server"), + ]); + expect(store.getChildRooms("!company_dept1_group1:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept1_group1:server")).toStrictEqual([subspace]); + expect(store.getChildRooms("!company_dept2:server")).toStrictEqual([]); + expect(store.getChildSpaces("!company_dept2:server")).toStrictEqual([subspace]); + }); + + it("handles full cycles", async () => { + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]); + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]); + }); + + it("handles partial cycles", async () => { + mkSpace("!b:server", [ + mkSpace("!a:server", [ + mkSpace("!c:server", [ + "!a:server", + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!b:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!a:server")]); + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([client.getRoom("!a:server")]); + }); + + it("handles partial cycles with additional spaces coming off them", async () => { + // TODO this test should be failing right now + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + mkSpace("!d:server").roomId, + ]).roomId, + ]).roomId, + ]); + await run(); + + expect(store.spacePanelSpaces.map(r => r.roomId)).toStrictEqual(["!a:server"]); + expect(store.invitedSpaces).toStrictEqual([]); + + expect(store.getChildRooms("!a:server")).toStrictEqual([]); + expect(store.getChildSpaces("!a:server")).toStrictEqual([client.getRoom("!b:server")]); + expect(store.getChildRooms("!b:server")).toStrictEqual([]); + expect(store.getChildSpaces("!b:server")).toStrictEqual([client.getRoom("!c:server")]); + expect(store.getChildRooms("!c:server")).toStrictEqual([]); + expect(store.getChildSpaces("!c:server")).toStrictEqual([ + client.getRoom("!a:server"), + client.getRoom("!d:server"), + ]); + expect(store.getChildRooms("!d:server")).toStrictEqual([]); + expect(store.getChildSpaces("!d:server")).toStrictEqual([]); + }); + + it("invite to a subspace is only shown at the top level", async () => { + mkSpace(invite1).getMyMembership.mockReturnValue("invite"); + mkSpace(space1, [invite1]); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([client.getRoom(space1)]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([client.getRoom(invite1)]); + }); + + describe("test fixture 1", () => { + beforeEach(async () => { + [fav1, fav2, fav3, dm1, dm2, dm3, orphan1, orphan2, invite1, invite2, room1].forEach(mkRoom); + mkSpace(space1, [fav1, room1]); + mkSpace(space2, [fav1, fav2, fav3, room1]); + mkSpace(space3, [invite2]); + + [fav1, fav2, fav3].forEach(roomId => { + client.getRoom(roomId).tags = { + "m.favourite": { + order: 0.5, + }, + }; + }); + + [invite1, invite2].forEach(roomId => { + client.getRoom(roomId).getMyMembership.mockReturnValue("invite"); + }); + + getUserIdForRoomId.mockImplementation(roomId => { + return { + [dm1]: dm1Partner, + [dm2]: dm2Partner, + [dm3]: dm3Partner, + }[roomId]; + }); + await run(); + }); + + it("home space contains orphaned rooms", () => { + expect(store.getSpaceFilteredRoomIds(null).has(orphan1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(orphan2)).toBeTruthy(); + }); + + it("home space contains favourites", () => { + expect(store.getSpaceFilteredRoomIds(null).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(fav2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(fav3)).toBeTruthy(); + }); + + it("home space contains dm rooms", () => { + expect(store.getSpaceFilteredRoomIds(null).has(dm1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(dm2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(dm3)).toBeTruthy(); + }); + + it("home space contains invites", () => { + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy(); + }); + + it("home space contains invites even if they are also shown in a space", () => { + expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); + }); + + it("home space does not contain rooms/low priority from rooms within spaces", () => { + expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); + }); + + it("space contains child rooms", () => { + const space = client.getRoom(space1); + expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy(); + }); + + it("space contains child favourites", () => { + const space = client.getRoom(space2); + expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(fav2)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(fav3)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(space).has(room1)).toBeTruthy(); + }); + + it("space contains child invites", () => { + const space = client.getRoom(space3); + expect(store.getSpaceFilteredRoomIds(space).has(invite2)).toBeTruthy(); + }); + }); + }); + + describe("hierarchy resolution update tests", () => { + let emitter: EventEmitter; + beforeEach(async () => { + emitter = new EventEmitter(); + client.on.mockImplementation(emitter.on.bind(emitter)); + client.removeListener.mockImplementation(emitter.removeListener.bind(emitter)); + }); + afterEach(() => { + client.on.mockReset(); + client.removeListener.mockReset(); + }); + + it("updates state when spaces are joined", async () => { + await run(); + expect(store.spacePanelSpaces).toStrictEqual([]); + const space = mkSpace(space1); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room", space); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("updates state when spaces are left", async () => { + const space = mkSpace(space1); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("leave"); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room.myMembership", space, "leave", "join"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + }); + + it("updates state when space invite comes in", async () => { + await run(); + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + const prom = emitPromise(store, UPDATE_INVITED_SPACES); + emitter.emit("Room", space); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + }); + + it("updates state when space invite is accepted", async () => { + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("join"); + const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); + emitter.emit("Room.myMembership", space, "join", "invite"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("updates state when space invite is rejected", async () => { + const space = mkSpace(space1); + space.getMyMembership.mockReturnValue("invite"); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([space]); + space.getMyMembership.mockReturnValue("leave"); + const prom = emitPromise(store, UPDATE_INVITED_SPACES); + emitter.emit("Room.myMembership", space, "leave", "invite"); + await prom; + expect(store.spacePanelSpaces).toStrictEqual([]); + expect(store.invitedSpaces).toStrictEqual([]); + }); + + it("room invite gets added to relevant space filters", async () => { + const space = mkSpace(space1, [invite1]); + await run(); + + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([]); + expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeFalsy(); + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeFalsy(); + + const invite = mkRoom(invite1); + invite.getMyMembership.mockReturnValue("invite"); + const prom = emitPromise(store, space1); + emitter.emit("Room", space); + await prom; + + expect(store.spacePanelSpaces).toStrictEqual([space]); + expect(store.invitedSpaces).toStrictEqual([]); + expect(store.getChildSpaces(space1)).toStrictEqual([]); + expect(store.getChildRooms(space1)).toStrictEqual([invite]); + expect(store.getSpaceFilteredRoomIds(client.getRoom(space1)).has(invite1)).toBeTruthy(); + expect(store.getSpaceFilteredRoomIds(null).has(invite1)).toBeTruthy(); + }); + }); + + describe("active space switching tests", () => { + const fn = jest.spyOn(store, "emit"); + + beforeEach(async () => { + mkRoom(room1); // not a space + mkSpace(space1, [ + mkSpace(space2).roomId, + ]); + mkSpace(space3).getMyMembership.mockReturnValue("invite"); + await run(); + await store.setActiveSpace(null); + expect(store.activeSpace).toBe(null); + }); + afterEach(() => { + fn.mockClear(); + }); + + it("switch to home space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + fn.mockClear(); + + await store.setActiveSpace(null); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null); + expect(store.activeSpace).toBe(null); + }); + + it("switch to invited space", async () => { + const space = client.getRoom(space3); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to top level space", async () => { + const space = client.getRoom(space1); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to subspace", async () => { + const space = client.getRoom(space2); + await store.setActiveSpace(space); + expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(space); + }); + + it("switch to unknown space is a nop", async () => { + expect(store.activeSpace).toBe(null); + const space = client.getRoom(room1); // not a space + await store.setActiveSpace(space); + expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); + expect(store.activeSpace).toBe(null); + }); + }); + + describe("context switching tests", () => { + const fn = jest.spyOn(defaultDispatcher, "dispatch"); + + beforeEach(async () => { + [room1, room2, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2]); + mkSpace(space2, [room2]); + await run(); + }); + afterEach(() => { + fn.mockClear(); + localStorage.clear(); + }); + + const getCurrentRoom = () => fn.mock.calls.reverse().find(([p]) => p.action === "view_room")?.[0].room_id; + + it("last viewed room in target space is the current viewed and in both spaces", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space2)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room2); + }); + + it("last viewed room in target space is in the current space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room2); + }); + + it("last viewed room in target space is not in the current space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2)); + viewRoom(room2); + await store.setActiveSpace(client.getRoom(space1)); + expect(getCurrentRoom()).toBe(room1); + }); + + it("last viewed room is target space is not known", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + localStorage.setItem(`mx_space_context_${space2}`, orphan2); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + }); + + it("no last viewed room in target space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space2)); + expect(getCurrentRoom()).toBe(space2); + }); + + it("no last viewed room in home space", async () => { + await store.setActiveSpace(client.getRoom(space1)); + viewRoom(room1); + await store.setActiveSpace(null); + expect(fn.mock.calls[fn.mock.calls.length - 1][0]).toStrictEqual({ action: "view_home_page" }); + }); + }); + + describe("space auto switching tests", () => { + beforeEach(async () => { + [room1, room2, orphan1].forEach(mkRoom); + mkSpace(space1, [room1, room2]); + mkSpace(space2, [room1, room2]); + + client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ + mkEvent({ + event: true, + type: EventType.SpaceParent, + room: room2, + user: testUserId, + skey: space2, + content: { via: [], canonical: true }, + ts: Date.now(), + }), + ])); + await run(); + }); + + it("no switch required, room is in current space", async () => { + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space1), false); + viewRoom(room2); + expect(store.activeSpace).toBe(client.getRoom(space1)); + }); + + it("switch to canonical parent space for room", async () => { + viewRoom(room1); + await store.setActiveSpace(null, false); + viewRoom(room2); + expect(store.activeSpace).toBe(client.getRoom(space2)); + }); + + it("switch to first containing space for room", async () => { + viewRoom(room2); + await store.setActiveSpace(null, false); + viewRoom(room1); + expect(store.activeSpace).toBe(client.getRoom(space1)); + }); + + it("switch to home for orphaned room", async () => { + viewRoom(room1); + await store.setActiveSpace(client.getRoom(space1), false); + viewRoom(orphan1); + expect(store.activeSpace).toBeNull(); + }); + }); + + describe("traverseSpace", () => { + beforeEach(() => { + mkSpace("!a:server", [ + mkSpace("!b:server", [ + mkSpace("!c:server", [ + "!a:server", + mkRoom("!c-child:server").roomId, + mkRoom("!shared-child:server").roomId, + ]).roomId, + mkRoom("!b-child:server").roomId, + ]).roomId, + mkRoom("!a-child:server").roomId, + "!shared-child:server", + ]); + }); + + it("avoids cycles", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn); + + expect(fn).toBeCalledTimes(3); + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!c:server"); + }); + + it("including rooms", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn, true); + + expect(fn).toBeCalledTimes(8); // twice for shared-child + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!a-child:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!b-child:server"); + expect(fn).toBeCalledWith("!c:server"); + expect(fn).toBeCalledWith("!c-child:server"); + expect(fn).toBeCalledWith("!shared-child:server"); + }); + + it("excluding rooms", () => { + const fn = jest.fn(); + store.traverseSpace("!b:server", fn, false); + + expect(fn).toBeCalledTimes(3); + expect(fn).toBeCalledWith("!a:server"); + expect(fn).toBeCalledWith("!b:server"); + expect(fn).toBeCalledWith("!c:server"); + }); + }); +}); diff --git a/test/test-utils.js b/test/test-utils.js index d259fcb95f..6dc02463a5 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -64,6 +64,11 @@ export function createTestClient() { getRoomIdForAlias: jest.fn().mockResolvedValue(undefined), getRoomDirectoryVisibility: jest.fn().mockResolvedValue(undefined), getProfileInfo: jest.fn().mockResolvedValue({}), + getThirdpartyProtocols: jest.fn().mockResolvedValue({}), + getClientWellKnown: jest.fn().mockReturnValue(null), + supportsVoip: jest.fn().mockReturnValue(true), + getTurnServersExpiry: jest.fn().mockReturnValue(2^32), + getThirdpartyUser: jest.fn().mockResolvedValue([]), getAccountData: (type) => { return mkEvent({ type, @@ -79,6 +84,17 @@ export function createTestClient() { generateClientSecret: () => "t35tcl1Ent5ECr3T", isGuest: () => false, isCryptoEnabled: () => false, + getSpaceSummary: jest.fn().mockReturnValue({ + rooms: [], + events: [], + }), + + // Used by various internal bits we aren't concerned with (yet) + _sessionStore: { + store: { + getItem: jest.fn(), + }, + }, }; } @@ -88,8 +104,8 @@ export function createTestClient() { * @param {string} opts.type The event.type * @param {string} opts.room The event.room_id * @param {string} opts.user The event.user_id - * @param {string} opts.skey Optional. The state key (auto inserts empty string) - * @param {Number} opts.ts Optional. Timestamp for the event + * @param {string=} opts.skey Optional. The state key (auto inserts empty string) + * @param {number=} opts.ts Optional. Timestamp for the event * @param {Object} opts.content The event.content * @param {boolean} opts.event True to make a MatrixEvent. * @return {Object} a JSON object representing this event. @@ -224,7 +240,7 @@ export function mkStubRoom(roomId = null) { hasMembershipState: () => null, getVersion: () => '1', shouldUpgradeToVersion: () => null, - getMyMembership: () => "join", + getMyMembership: jest.fn().mockReturnValue("join"), maySendMessage: jest.fn().mockReturnValue(true), currentState: { getStateEvents: jest.fn(), @@ -233,17 +249,17 @@ export function mkStubRoom(roomId = null) { maySendEvent: jest.fn().mockReturnValue(true), members: [], }, - tags: { - "m.favourite": { - order: 0.5, - }, - }, + tags: {}, setBlacklistUnverifiedDevices: jest.fn(), on: jest.fn(), removeListener: jest.fn(), getDMInviter: jest.fn(), getAvatarUrl: () => 'mxc://avatar.url/room.png', getMxcAvatarUrl: () => 'mxc://avatar.url/room.png', + isSpaceRoom: jest.fn(() => false), + getUnreadNotificationCount: jest.fn(() => 0), + getEventReadUpTo: jest.fn(() => null), + timeline: [], }; } diff --git a/test/utils/ShieldUtils-test.js b/test/utils/ShieldUtils-test.js index 8e3b19c1c4..bea3d26565 100644 --- a/test/utils/ShieldUtils-test.js +++ b/test/utils/ShieldUtils-test.js @@ -128,7 +128,7 @@ describe("shieldStatusForMembership self-trust behaviour", function() { describe("shieldStatusForMembership other-trust behaviour", function() { beforeAll(() => { - DMRoomMap._sharedInstance = { + DMRoomMap.sharedInstance = { getUserIdForRoomId: (roomId) => roomId === "DM" ? "@any:h" : null, }; }); diff --git a/test/utils/arrays-test.ts b/test/utils/arrays-test.ts index ececd274b2..c5be59ab43 100644 --- a/test/utils/arrays-test.ts +++ b/test/utils/arrays-test.ts @@ -22,6 +22,7 @@ import { arrayHasOrderChange, arrayMerge, arraySeed, + arrayTrimFill, arrayUnion, ArrayUtil, GroupedArray, @@ -64,6 +65,38 @@ describe('arrays', () => { }); }); + describe('arrayTrimFill', () => { + it('should shrink arrays', () => { + const input = [1, 2, 3]; + const output = [1, 2]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + + it('should expand arrays', () => { + const input = [1, 2, 3]; + const output = [1, 2, 3, 4, 5]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + + it('should keep arrays the same', () => { + const input = [1, 2, 3]; + const output = [1, 2, 3]; + const seed = [4, 5, 6]; + const result = arrayTrimFill(input, output.length, seed); + expect(result).toBeDefined(); + expect(result).toHaveLength(output.length); + expect(result).toEqual(output); + }); + }); + describe('arraySeed', () => { it('should create an array of given length', () => { const val = 1; diff --git a/test/utils/test-utils.ts b/test/utils/test-utils.ts new file mode 100644 index 0000000000..af92987a3d --- /dev/null +++ b/test/utils/test-utils.ts @@ -0,0 +1,33 @@ +/* +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 { MatrixClient } from "matrix-js-sdk/src/client"; +import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; + +// These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent +// ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. + +export const setupAsyncStoreWithClient = async (store: AsyncStoreWithClient, client: MatrixClient) => { + // @ts-ignore + store.readyStore.useUnitTestClient(client); + // @ts-ignore + await store.onReady(); +}; + +export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient) => { + // @ts-ignore + await store.onNotReady(); +}; diff --git a/yarn.lock b/yarn.lock index 66329cfa89..acdca26e55 100644 --- a/yarn.lock +++ b/yarn.lock @@ -26,6 +26,13 @@ dependencies: "@babel/highlight" "^7.10.4" +"@babel/code-frame@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.13.tgz#dcfc826beef65e75c50e21d3837d7d95798dd658" + integrity sha512-HV1Cm0Q3ZrpCR93tkWOYiuYIgLxZXZFVG2VgK+MBWjUqZTundupbfx2aXarXuw5Ko5aMcjtJgbSs4vUGBS5v6g== + dependencies: + "@babel/highlight" "^7.12.13" + "@babel/compat-data@^7.12.5", "@babel/compat-data@^7.12.7": version "7.12.7" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.12.7.tgz#9329b4782a7d6bbd7eef57e11addf91ee3ef1e41" @@ -61,6 +68,15 @@ jsesc "^2.5.1" source-map "^0.5.0" +"@babel/generator@^7.13.16": + version "7.13.16" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.13.16.tgz#0befc287031a201d84cdfc173b46b320ae472d14" + integrity sha512-grBBR75UnKOcUWMp8WoDxNsWCFl//XCK6HWTrBQKTr5SV9f5g0pNOjdyzi/DTBv12S9GnYPInIXQBTky7OXEMg== + dependencies: + "@babel/types" "^7.13.16" + jsesc "^2.5.1" + source-map "^0.5.0" + "@babel/helper-annotate-as-pure@^7.10.4", "@babel/helper-annotate-as-pure@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.12.10.tgz#54ab9b000e60a93644ce17b3f37d313aaf1d115d" @@ -130,6 +146,15 @@ "@babel/template" "^7.12.7" "@babel/types" "^7.12.11" +"@babel/helper-function-name@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.12.13.tgz#93ad656db3c3c2232559fd7b2c3dbdcbe0eb377a" + integrity sha512-TZvmPn0UOqmvi5G4vvw0qZTpVptGkB1GL61R6lKvrSdIxGm5Pky7Q3fpKiIkQCAtRCBUwB0PaThlx9vebCDSwA== + dependencies: + "@babel/helper-get-function-arity" "^7.12.13" + "@babel/template" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/helper-get-function-arity@^7.12.10": version "7.12.10" resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.10.tgz#b158817a3165b5faa2047825dfa61970ddcc16cf" @@ -137,6 +162,13 @@ dependencies: "@babel/types" "^7.12.10" +"@babel/helper-get-function-arity@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-get-function-arity/-/helper-get-function-arity-7.12.13.tgz#bc63451d403a3b3082b97e1d8b3fe5bd4091e583" + integrity sha512-DjEVzQNz5LICkzN0REdpD5prGoidvbdYk1BVgRUOINaWJP2t6avB27X1guXK1kXNrX0WMfsrm1A/ZBthYuIMQg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-hoist-variables@^7.10.4": version "7.10.4" resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.10.4.tgz#d49b001d1d5a68ca5e6604dda01a6297f7c9381e" @@ -225,6 +257,13 @@ dependencies: "@babel/types" "^7.12.11" +"@babel/helper-split-export-declaration@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.12.13.tgz#e9430be00baf3e88b0e13e6f9d4eaf2136372b05" + integrity sha512-tCJDltF83htUtXx5NLcaDqRmknv652ZWCHyoTETf1CXYJdPC7nohZohjUgieXhv0hTJdRf2FjDueFehdNucpzg== + dependencies: + "@babel/types" "^7.12.13" + "@babel/helper-validator-identifier@^7.10.4", "@babel/helper-validator-identifier@^7.12.11": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.12.11.tgz#c9a1f021917dcb5ccf0d4e453e399022981fc9ed" @@ -263,11 +302,25 @@ chalk "^2.0.0" js-tokens "^4.0.0" +"@babel/highlight@^7.12.13": + version "7.13.10" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.13.10.tgz#a8b2a66148f5b27d666b15d81774347a731d52d1" + integrity sha512-5aPpe5XQPzflQrFwL1/QoeHkP2MsA4JCntcXHRhEsdsfPVkvPi2w7Qix4iV7t5S/oC9OodGrggd8aco1g3SZFg== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + chalk "^2.0.0" + js-tokens "^4.0.0" + "@babel/parser@^7.1.0", "@babel/parser@^7.12.10", "@babel/parser@^7.12.11", "@babel/parser@^7.12.7", "@babel/parser@^7.7.0": version "7.12.11" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.12.11.tgz#9ce3595bcd74bc5c466905e86c535b8b25011e79" integrity sha512-N3UxG+uuF4CMYoNj8AhnbAcJF0PiuJ9KHuy1lQmkYsxTer/MAH9UBNHsBoAX/4s6NvlDD047No8mYVGGzLL4hg== +"@babel/parser@^7.12.13", "@babel/parser@^7.13.16": + version "7.13.16" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.16.tgz#0f18179b0448e6939b1f3f5c4c355a3a9bcdfd37" + integrity sha512-6bAg36mCwuqLO0hbR+z7PHuqWiCeP7Dzg73OpQwsAB1Eb8HnGEz5xYBzCfbu+YjoaJsJs+qheDxVAuqbt3ILEw== + "@babel/plugin-proposal-async-generator-functions@^7.12.1": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.12.12.tgz#04b8f24fd4532008ab4e79f788468fd5a8476566" @@ -980,6 +1033,15 @@ "@babel/parser" "^7.12.7" "@babel/types" "^7.12.7" +"@babel/template@^7.12.13": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.12.13.tgz#530265be8a2589dbb37523844c5bcb55947fb327" + integrity sha512-/7xxiGA57xMo/P2GVvdEumr8ONhFOhfgq2ihK3h1e6THqzTAkHbkXgB0xI9yeTfIUoH3+oAeHhqm/I43OTbbjA== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/parser" "^7.12.13" + "@babel/types" "^7.12.13" + "@babel/traverse@^7.1.0", "@babel/traverse@^7.10.4", "@babel/traverse@^7.12.1", "@babel/traverse@^7.12.10", "@babel/traverse@^7.12.12", "@babel/traverse@^7.12.5", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.4": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.12.12.tgz#d0cd87892704edd8da002d674bc811ce64743376" @@ -995,6 +1057,20 @@ globals "^11.1.0" lodash "^4.17.19" +"@babel/traverse@^7.13.17": + version "7.13.17" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.17.tgz#c85415e0c7d50ac053d758baec98b28b2ecfeea3" + integrity sha512-BMnZn0R+X6ayqm3C3To7o1j7Q020gWdqdyP50KEoVqaCO2c/Im7sYZSmVgvefp8TTMQ+9CtwuBp0Z1CZ8V3Pvg== + dependencies: + "@babel/code-frame" "^7.12.13" + "@babel/generator" "^7.13.16" + "@babel/helper-function-name" "^7.12.13" + "@babel/helper-split-export-declaration" "^7.12.13" + "@babel/parser" "^7.13.16" + "@babel/types" "^7.13.17" + debug "^4.1.0" + globals "^11.1.0" + "@babel/types@^7.0.0", "@babel/types@^7.10.4", "@babel/types@^7.10.5", "@babel/types@^7.12.1", "@babel/types@^7.12.10", "@babel/types@^7.12.11", "@babel/types@^7.12.12", "@babel/types@^7.12.5", "@babel/types@^7.12.7", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": version "7.12.12" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.12.12.tgz#4608a6ec313abbd87afa55004d373ad04a96c299" @@ -1004,6 +1080,14 @@ lodash "^4.17.19" to-fast-properties "^2.0.0" +"@babel/types@^7.12.13", "@babel/types@^7.13.16", "@babel/types@^7.13.17": + version "7.13.17" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.17.tgz#48010a115c9fba7588b4437dd68c9469012b38b4" + integrity sha512-RawydLgxbOPDlTLJNtoIypwdmAy//uQIzlKt2+iBiJaRlVuI6QLUxVAyWGNfOzp8Yu4L4lLIacoCyTNtpb4wiA== + dependencies: + "@babel/helper-validator-identifier" "^7.12.11" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" @@ -5588,8 +5672,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "9.11.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/e277de6e3d9bbb98fbfbbedd47d86ee85f6f47e5" + version "10.0.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/c8f69c0b7937b9064938c134d708c4d064b71315" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5614,6 +5698,14 @@ matrix-react-test-utils@^0.2.2: resolved "https://registry.yarnpkg.com/matrix-react-test-utils/-/matrix-react-test-utils-0.2.2.tgz#c87144d3b910c7edc544a6699d13c7c2bf02f853" integrity sha512-49+7gfV6smvBIVbeloql+37IeWMTD+fiywalwCqk8Dnz53zAFjKSltB3rmWHso1uecLtQEcPtCijfhzcLXAxTQ== +"matrix-web-i18n@github:matrix-org/matrix-web-i18n": + version "1.1.2" + resolved "https://codeload.github.com/matrix-org/matrix-web-i18n/tar.gz/63f9119bc0bc304e83d4e8e22364caa7850e7671" + dependencies: + "@babel/parser" "^7.13.16" + "@babel/traverse" "^7.13.17" + walk "^2.3.14" + matrix-widget-api@^0.1.0-beta.13: version "0.1.0-beta.13" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.13.tgz#ebddc83eaef39bbb87b621a02a35902e1a29b9ef"