diff --git a/.eslintignore.errorfiles b/.eslintignore.errorfiles deleted file mode 100644 index d9177bebb5..0000000000 --- a/.eslintignore.errorfiles +++ /dev/null @@ -1,16 +0,0 @@ -# autogenerated file: run scripts/generate-eslint-error-ignore-file to update. - -src/Markdown.js -src/NodeAnimator.js -src/components/structures/RoomDirectory.js -src/components/views/rooms/MemberList.js -src/ratelimitedfunc.js -src/utils/DMRoomMap.js -src/utils/MultiInviter.js -test/components/structures/MessagePanel-test.js -test/components/views/dialogs/InteractiveAuthDialog-test.js -test/mock-clock.js -src/component-index.js -test/end-to-end-tests/node_modules/ -test/end-to-end-tests/element/ -test/end-to-end-tests/synapse/ diff --git a/.eslintrc.js b/.eslintrc.js index bf6e245b93..827b373949 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -24,6 +24,18 @@ module.exports = { // It's disabled here, but we should using it sparingly. "react/jsx-no-bind": "off", "react/jsx-key": ["error"], + + "no-restricted-properties": [ + "error", + ...buildRestrictedPropertiesOptions( + ["window.innerHeight", "window.innerWidth", "window.visualViewport"], + "Use UIStore to access window dimensions instead.", + ), + ...buildRestrictedPropertiesOptions( + ["*.mxcUrlToHttp", "*.getHttpUriForMxc"], + "Use Media helper instead to centralise access for customisation.", + ), + ], }, overrides: [{ files: [ @@ -49,21 +61,16 @@ module.exports = { "@typescript-eslint/no-explicit-any": "off", // We'd rather not do this but we do "@typescript-eslint/ban-ts-comment": "off", - - "no-restricted-properties": [ - "error", - ...buildRestrictedPropertiesOptions( - ["window.innerHeight", "window.innerWidth", "window.visualViewport"], - "Use UIStore to access window dimensions instead", - ), - ], }, }], }; function buildRestrictedPropertiesOptions(properties, message) { return properties.map(prop => { - const [object, property] = prop.split("."); + let [object, property] = prop.split("."); + if (object === "*") { + object = undefined; + } return { object, property, diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f979b4802..22b35b7c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,174 @@ +Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05) +===================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0) + + * Remove reminescent references to the tinter + [\#6316](https://github.com/matrix-org/matrix-react-sdk/pull/6316) + * Update to released version of js-sdk + +Changes in [3.25.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0-rc.1) (2021-06-29) +=============================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0...v3.25.0-rc.1) + + * Update to js-sdk v12.0.1-rc.1 + * Translations update from Weblate + [\#6286](https://github.com/matrix-org/matrix-react-sdk/pull/6286) + * Fix back button on user info card after clicking a permalink + [\#6277](https://github.com/matrix-org/matrix-react-sdk/pull/6277) + * Group ACLs with MELS + [\#6280](https://github.com/matrix-org/matrix-react-sdk/pull/6280) + * Fix editState not getting passed through + [\#6282](https://github.com/matrix-org/matrix-react-sdk/pull/6282) + * Migrate message context menu to IconizedContextMenu + [\#5671](https://github.com/matrix-org/matrix-react-sdk/pull/5671) + * Improve audio recording performance + [\#6240](https://github.com/matrix-org/matrix-react-sdk/pull/6240) + * Fix multiple timeline panels handling composer and edit events + [\#6278](https://github.com/matrix-org/matrix-react-sdk/pull/6278) + * Let m.notice messages mark a room as unread + [\#6281](https://github.com/matrix-org/matrix-react-sdk/pull/6281) + * Removes the override on the Bubble Container + [\#5953](https://github.com/matrix-org/matrix-react-sdk/pull/5953) + * Fix IRC layout regressions + [\#6193](https://github.com/matrix-org/matrix-react-sdk/pull/6193) + * Fix trashcan.svg by exporting it with its viewbox + [\#6248](https://github.com/matrix-org/matrix-react-sdk/pull/6248) + * Fix tiny scrollbar dot on chrome/electron in Forward Dialog + [\#6276](https://github.com/matrix-org/matrix-react-sdk/pull/6276) + * Upgrade puppeteer to use newer version of Chrome + [\#6268](https://github.com/matrix-org/matrix-react-sdk/pull/6268) + * Make toast dismiss button less prominent + [\#6275](https://github.com/matrix-org/matrix-react-sdk/pull/6275) + * Encrypt the voice message file if needed + [\#6269](https://github.com/matrix-org/matrix-react-sdk/pull/6269) + * Fix hyper-precise presence + [\#6270](https://github.com/matrix-org/matrix-react-sdk/pull/6270) + * Fix issues around private spaces, including previewable + [\#6265](https://github.com/matrix-org/matrix-react-sdk/pull/6265) + * Make _pinned messages_ in `m.room.pinned_events` event clickable + [\#6257](https://github.com/matrix-org/matrix-react-sdk/pull/6257) + * Fix space avatar management layout being broken + [\#6266](https://github.com/matrix-org/matrix-react-sdk/pull/6266) + * Convert EntityTile, MemberTile and PresenceLabel to TS + [\#6251](https://github.com/matrix-org/matrix-react-sdk/pull/6251) + * Fix UserInfo not working when rendered without a room + [\#6260](https://github.com/matrix-org/matrix-react-sdk/pull/6260) + * Update membership reason handling, including leave reason displaying + [\#6253](https://github.com/matrix-org/matrix-react-sdk/pull/6253) + * Consolidate types with js-sdk changes + [\#6220](https://github.com/matrix-org/matrix-react-sdk/pull/6220) + * Fix edit history modal + [\#6258](https://github.com/matrix-org/matrix-react-sdk/pull/6258) + * Convert MemberList to TS + [\#6249](https://github.com/matrix-org/matrix-react-sdk/pull/6249) + * Fix two PRs duplicating the css attribute + [\#6259](https://github.com/matrix-org/matrix-react-sdk/pull/6259) + * Improve invite error messages in InviteDialog for room invites + [\#6201](https://github.com/matrix-org/matrix-react-sdk/pull/6201) + * Fix invite dialog being cut off when it has limited results + [\#6256](https://github.com/matrix-org/matrix-react-sdk/pull/6256) + * Fix pinning event in a room which hasn't had events pinned in before + [\#6255](https://github.com/matrix-org/matrix-react-sdk/pull/6255) + * Allow modal widget buttons to be disabled when the modal opens + [\#6178](https://github.com/matrix-org/matrix-react-sdk/pull/6178) + * Decrease e2e shield fill mask size so that it doesn't overlap + [\#6250](https://github.com/matrix-org/matrix-react-sdk/pull/6250) + * Dial Pad UI bug fixes + [\#5786](https://github.com/matrix-org/matrix-react-sdk/pull/5786) + * Simple handling of mid-call output changes + [\#6247](https://github.com/matrix-org/matrix-react-sdk/pull/6247) + * Improve ForwardDialog performance by using TruncatedList + [\#6228](https://github.com/matrix-org/matrix-react-sdk/pull/6228) + * Fix dependency and lockfile mismatch + [\#6246](https://github.com/matrix-org/matrix-react-sdk/pull/6246) + * Improve room directory click behaviour + [\#6234](https://github.com/matrix-org/matrix-react-sdk/pull/6234) + * Fix keyboard accessibility of the space panel + [\#6239](https://github.com/matrix-org/matrix-react-sdk/pull/6239) + * Add ways to manage addresses for Spaces + [\#6151](https://github.com/matrix-org/matrix-react-sdk/pull/6151) + * Hide communities invites and the community autocompleter when Spaces on + [\#6244](https://github.com/matrix-org/matrix-react-sdk/pull/6244) + * Convert bunch of files to TS + [\#6241](https://github.com/matrix-org/matrix-react-sdk/pull/6241) + * Open local addresses section by default when there are no existing local + addresses + [\#6179](https://github.com/matrix-org/matrix-react-sdk/pull/6179) + * Allow reordering of the space panel via Drag and Drop + [\#6137](https://github.com/matrix-org/matrix-react-sdk/pull/6137) + * Replace drag and drop mechanism in communities with something simpler + [\#6134](https://github.com/matrix-org/matrix-react-sdk/pull/6134) + * EventTilePreview fixes + [\#6000](https://github.com/matrix-org/matrix-react-sdk/pull/6000) + * Upgrade @types/react and @types/react-dom + [\#6233](https://github.com/matrix-org/matrix-react-sdk/pull/6233) + * Fix type error in the SpaceStore + [\#6242](https://github.com/matrix-org/matrix-react-sdk/pull/6242) + * Add experimental options to the Spaces beta + [\#6199](https://github.com/matrix-org/matrix-react-sdk/pull/6199) + * Consolidate types with js-sdk changes + [\#6215](https://github.com/matrix-org/matrix-react-sdk/pull/6215) + * Fix branch matching for Buildkite + [\#6236](https://github.com/matrix-org/matrix-react-sdk/pull/6236) + * Migrate SearchBar to TypeScript + [\#6230](https://github.com/matrix-org/matrix-react-sdk/pull/6230) + * Add support to keyboard shortcuts dialog for [digits] + [\#6088](https://github.com/matrix-org/matrix-react-sdk/pull/6088) + * Fix modal opening race condition + [\#6238](https://github.com/matrix-org/matrix-react-sdk/pull/6238) + * Deprecate FormButton in favour of AccessibleButton + [\#6229](https://github.com/matrix-org/matrix-react-sdk/pull/6229) + * Add PR template + [\#6216](https://github.com/matrix-org/matrix-react-sdk/pull/6216) + * Prefer canonical aliases while autocompleting rooms + [\#6222](https://github.com/matrix-org/matrix-react-sdk/pull/6222) + * Fix quote button + [\#6232](https://github.com/matrix-org/matrix-react-sdk/pull/6232) + * Restore branch matching support for GitHub Actions e2e tests + [\#6224](https://github.com/matrix-org/matrix-react-sdk/pull/6224) + * Fix View Source accessing renamed private field on MatrixEvent + [\#6225](https://github.com/matrix-org/matrix-react-sdk/pull/6225) + * Fix ConfirmUserActionDialog returning an input field rather than text + [\#6219](https://github.com/matrix-org/matrix-react-sdk/pull/6219) + * Revert "Partially restore immutable event objects at the rendering layer" + [\#6221](https://github.com/matrix-org/matrix-react-sdk/pull/6221) + * Add jq to e2e tests Dockerfile + [\#6218](https://github.com/matrix-org/matrix-react-sdk/pull/6218) + * Partially restore immutable event objects at the rendering layer + [\#6196](https://github.com/matrix-org/matrix-react-sdk/pull/6196) + * Update MSC number references for voice messages + [\#6197](https://github.com/matrix-org/matrix-react-sdk/pull/6197) + * Fix phase enum usage in JS modules as well + [\#6214](https://github.com/matrix-org/matrix-react-sdk/pull/6214) + * Migrate some dialogs to TypeScript + [\#6185](https://github.com/matrix-org/matrix-react-sdk/pull/6185) + * Typescript fixes due to MatrixEvent being TSified + [\#6208](https://github.com/matrix-org/matrix-react-sdk/pull/6208) + * Allow click-to-ping, quote & emoji picker for edit composer too + [\#5858](https://github.com/matrix-org/matrix-react-sdk/pull/5858) + * Add call silencing + [\#6082](https://github.com/matrix-org/matrix-react-sdk/pull/6082) + * Fix types in SlashCommands + [\#6207](https://github.com/matrix-org/matrix-react-sdk/pull/6207) + * Benchmark multiple common user scenario + [\#6190](https://github.com/matrix-org/matrix-react-sdk/pull/6190) + * Fix forward dialog message preview display names + [\#6204](https://github.com/matrix-org/matrix-react-sdk/pull/6204) + * Remove stray bullet point in reply preview + [\#6206](https://github.com/matrix-org/matrix-react-sdk/pull/6206) + * Stop requesting null next replies from the server + [\#6203](https://github.com/matrix-org/matrix-react-sdk/pull/6203) + * Fix soft crash caused by a broken shouldComponentUpdate + [\#6202](https://github.com/matrix-org/matrix-react-sdk/pull/6202) + * Keep composer reply when scrolling away from a highlighted event + [\#6200](https://github.com/matrix-org/matrix-react-sdk/pull/6200) + * Cache virtual/native room mappings when they're created + [\#6194](https://github.com/matrix-org/matrix-react-sdk/pull/6194) + * Disable comment-on-alert + [\#6191](https://github.com/matrix-org/matrix-react-sdk/pull/6191) + * Bump postcss from 7.0.35 to 7.0.36 + [\#6195](https://github.com/matrix-org/matrix-react-sdk/pull/6195) + Changes in [3.24.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.24.0) (2021-06-21) ===================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.24.0-rc.1...v3.24.0) diff --git a/package.json b/package.json index 4ad585ba7d..bb92ad11d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.24.0", + "version": "3.25.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -45,7 +45,7 @@ "start:all": "concurrently --kill-others-on-fail --prefix \"{time} [{name}]\" -n build,reskindex \"yarn start:build\" \"yarn reskindex:watch\"", "start:build": "babel src -w -s -d lib --verbose --extensions \".ts,.js\"", "lint": "yarn lint:types && yarn lint:js && yarn lint:style", - "lint:js": "eslint --max-warnings 0 --ignore-path .eslintignore.errorfiles src test", + "lint:js": "eslint --max-warnings 0 src test", "lint:types": "tsc --noEmit --jsx react", "lint:style": "stylelint 'res/css/**/*.scss'", "test": "jest", @@ -55,6 +55,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "await-lock": "^2.1.0", + "blurhash": "^1.1.3", "browser-encrypt-attachment": "^0.3.0", "browser-request": "^0.3.3", "cheerio": "^1.0.0-rc.9", @@ -78,7 +79,7 @@ "katex": "^0.12.0", "linkifyjs": "^2.1.9", "lodash": "^4.17.20", - "matrix-js-sdk": "12.0.0", + "matrix-js-sdk": "12.0.1", "matrix-widget-api": "^0.1.0-beta.15", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", @@ -90,6 +91,7 @@ "re-resizable": "^6.9.0", "react": "^17.0.2", "react-beautiful-dnd": "^13.1.0", + "react-blurhash": "^0.1.3", "react-dom": "^17.0.2", "react-focus-lock": "^2.5.0", "react-transition-group": "^4.4.1", @@ -122,6 +124,7 @@ "@peculiar/webcrypto": "^1.1.4", "@sinonjs/fake-timers": "^7.0.2", "@types/classnames": "^2.2.11", + "@types/commonmark": "^0.27.4", "@types/counterpart": "^0.18.1", "@types/diff-match-patch": "^1.0.32", "@types/flux": "^3.1.9", diff --git a/res/css/_components.scss b/res/css/_components.scss index ec3af8655e..8f80f1bf97 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -37,6 +37,11 @@ @import "./structures/_ViewSource.scss"; @import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_Login.scss"; +@import "./views/audio_messages/_AudioPlayer.scss"; +@import "./views/audio_messages/_PlayPauseButton.scss"; +@import "./views/audio_messages/_PlaybackContainer.scss"; +@import "./views/audio_messages/_SeekBar.scss"; +@import "./views/audio_messages/_Waveform.scss"; @import "./views/auth/_AuthBody.scss"; @import "./views/auth/_AuthButtons.scss"; @import "./views/auth/_AuthFooter.scss"; @@ -52,7 +57,6 @@ @import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_DecoratedRoomAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss"; -@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss"; @import "./views/beta/_BetaCard.scss"; @import "./views/context_menus/_CallContextMenu.scss"; @@ -165,6 +169,7 @@ @import "./views/messages/_MTextBody.scss"; @import "./views/messages/_MVideoBody.scss"; @import "./views/messages/_MVoiceMessageBody.scss"; +@import "./views/messages/_MediaBody.scss"; @import "./views/messages/_MessageActionBar.scss"; @import "./views/messages/_MessageTimestamp.scss"; @import "./views/messages/_MjolnirBody.scss"; @@ -196,6 +201,7 @@ @import "./views/rooms/_GroupLayout.scss"; @import "./views/rooms/_IRCLayout.scss"; @import "./views/rooms/_JumpToBottomButton.scss"; +@import "./views/rooms/_LinkPreviewGroup.scss"; @import "./views/rooms/_LinkPreviewWidget.scss"; @import "./views/rooms/_MemberInfo.scss"; @import "./views/rooms/_MemberList.scss"; @@ -253,12 +259,10 @@ @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"; @import "./views/voip/_CallViewForRoom.scss"; +@import "./views/voip/_CallPreview.scss"; @import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadModal.scss"; diff --git a/res/css/structures/_GroupView.scss b/res/css/structures/_GroupView.scss index 2350d9f28a..60f9ebdd08 100644 --- a/res/css/structures/_GroupView.scss +++ b/res/css/structures/_GroupView.scss @@ -323,7 +323,7 @@ limitations under the License. } .mx_GroupView_featuredThing .mx_BaseAvatar { - /* To prevent misalignment with mx_TintableSvg (in addButton) */ + /* To prevent misalignment with img (in addButton) */ vertical-align: initial; } diff --git a/res/css/structures/_RightPanel.scss b/res/css/structures/_RightPanel.scss index 52a2a68b6a..3222fe936c 100644 --- a/res/css/structures/_RightPanel.scss +++ b/res/css/structures/_RightPanel.scss @@ -121,23 +121,51 @@ $pulse-color: $pinned-unread-color; box-shadow: 0 0 0 0 rgba($pulse-color, 1); animation: mx_RightPanel_indicator_pulse 2s infinite; animation-iteration-count: 1; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_RightPanel_indicator_pulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } } } @keyframes mx_RightPanel_indicator_pulse { 0% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@keyframes mx_RightPanel_indicator_pulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; } } diff --git a/res/css/structures/_RoomView.scss b/res/css/structures/_RoomView.scss index 0efa2d01a1..831f186ed4 100644 --- a/res/css/structures/_RoomView.scss +++ b/res/css/structures/_RoomView.scss @@ -57,14 +57,15 @@ limitations under the License. @keyframes mx_RoomView_fileDropTarget_image_animation { from { - width: 0px; + transform: scaleX(0); } to { - width: 32px; + transform: scaleX(1); } } .mx_RoomView_fileDropTarget_image { + width: 32px; animation: mx_RoomView_fileDropTarget_image_animation; animation-duration: 0.5s; margin-bottom: 16px; diff --git a/res/css/views/audio_messages/_AudioPlayer.scss b/res/css/views/audio_messages/_AudioPlayer.scss new file mode 100644 index 0000000000..9a65ad008f --- /dev/null +++ b/res/css/views/audio_messages/_AudioPlayer.scss @@ -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. +*/ + +.mx_AudioPlayer_container { + padding: 16px 12px 12px 12px; + max-width: 267px; // use max to make the control fit in the files/pinned panels + + .mx_AudioPlayer_primaryContainer { + display: flex; + + .mx_PlayPauseButton { + margin-right: 8px; + } + + .mx_AudioPlayer_mediaInfo { + flex: 1; + overflow: hidden; // makes the ellipsis on the file name work + + & > * { + display: block; + } + + .mx_AudioPlayer_mediaName { + color: $primary-fg-color; + font-size: $font-15px; + line-height: $font-15px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + padding-bottom: 4px; // mimics the line-height differences in the Figma + } + + .mx_AudioPlayer_byline { + font-size: $font-12px; + line-height: $font-12px; + } + } + } + + .mx_AudioPlayer_seek { + display: flex; + align-items: center; + + .mx_SeekBar { + flex: 1; + } + + .mx_Clock { + width: $font-42px; // we're not using a monospace font, so fake it + min-width: $font-42px; // for flexbox + padding-left: 4px; // isolate from seek bar + text-align: right; + } + } +} diff --git a/res/css/views/voice_messages/_PlayPauseButton.scss b/res/css/views/audio_messages/_PlayPauseButton.scss similarity index 91% rename from res/css/views/voice_messages/_PlayPauseButton.scss rename to res/css/views/audio_messages/_PlayPauseButton.scss index 6caedafa29..714da3e605 100644 --- a/res/css/views/voice_messages/_PlayPauseButton.scss +++ b/res/css/views/audio_messages/_PlayPauseButton.scss @@ -18,6 +18,8 @@ limitations under the License. position: relative; width: 32px; height: 32px; + min-width: 32px; // for when the button is used in a flexbox + min-height: 32px; // for when the button is used in a flexbox border-radius: 32px; background-color: $voice-playback-button-bg-color; diff --git a/res/css/views/voice_messages/_PlaybackContainer.scss b/res/css/views/audio_messages/_PlaybackContainer.scss similarity index 88% rename from res/css/views/voice_messages/_PlaybackContainer.scss rename to res/css/views/audio_messages/_PlaybackContainer.scss index f0e29900ab..fd01864bba 100644 --- a/res/css/views/voice_messages/_PlaybackContainer.scss +++ b/res/css/views/audio_messages/_PlaybackContainer.scss @@ -22,17 +22,11 @@ limitations under the License. // 7px top and bottom for visual design. 12px left & right, but the waveform (right) // has a 1px padding on it that we want to account for. padding: 7px 12px 7px 11px; - 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; - line-height: $font-24px; - contain: content; .mx_Waveform { @@ -45,7 +39,7 @@ limitations under the License. &.mx_Waveform_bar_100pct { // Small animation to remove the mechanical feel of progress transition: background-color 250ms ease; - background-color: $voice-record-waveform-fg-color; + background-color: $message-body-panel-fg-color; } } } diff --git a/res/css/views/audio_messages/_SeekBar.scss b/res/css/views/audio_messages/_SeekBar.scss new file mode 100644 index 0000000000..d13fe4ac6a --- /dev/null +++ b/res/css/views/audio_messages/_SeekBar.scss @@ -0,0 +1,103 @@ +/* +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. +*/ + +// CSS inspiration from: +// * https://www.w3schools.com/howto/howto_js_rangeslider.asp +// * https://stackoverflow.com/a/28283806 +// * https://css-tricks.com/styling-cross-browser-compatible-range-inputs-css/ + +.mx_SeekBar { + // Dev note: we deliberately do not have the -ms-track (and friends) selectors because we don't + // need to support IE. + + appearance: none; // default style override + + width: 100%; + height: 1px; + background: $quaternary-fg-color; + outline: none; // remove blue selection border + position: relative; // for before+after pseudo elements later on + + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; // default style override + + // Dev note: This needs to be duplicated with the -moz-range-thumb selector + // because otherwise Edge (webkit) will fail to see the styles and just refuse + // to apply them. + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-fg-color; + cursor: pointer; + } + + &::-moz-range-thumb { + width: 8px; + height: 8px; + border-radius: 8px; + background-color: $tertiary-fg-color; + cursor: pointer; + + // Firefox adds a border on the thumb + border: none; + } + + // This is for webkit support, but we can't limit the functionality of it to just webkit + // browsers. Firefox responds to webkit-prefixed values now, which means we can't use media + // or support queries to selectively apply the rule. An upside is that this CSS doesn't work + // in firefox, so it's just wasted CPU/GPU time. + &::before { // ::before to ensure it ends up under the thumb + content: ''; + background-color: $tertiary-fg-color; + + // Absolute positioning to ensure it overlaps with the existing bar + position: absolute; + top: 0; + left: 0; + + // Sizing to match the bar + width: 100%; + height: 1px; + + // And finally dynamic width without overly hurting the rendering engine. + transform-origin: 0 100%; + transform: scaleX(var(--fillTo)); + } + + // This is firefox's built-in support for the above, with 100% less hacks. + &::-moz-range-progress { + background-color: $tertiary-fg-color; + height: 1px; + } + + &:disabled { + opacity: 0.5; + } + + // Increase clickable area for the slider (approximately same size as browser default) + // We do it this way to keep the same padding and margins of the element, avoiding margin math. + // Source: https://front-back.com/expand-clickable-areas-for-a-better-touch-experience/ + &::after { + content: ''; + position: absolute; + top: -6px; + bottom: -6px; + left: 0; + right: 0; + } +} diff --git a/res/css/views/voice_messages/_Waveform.scss b/res/css/views/audio_messages/_Waveform.scss similarity index 100% rename from res/css/views/voice_messages/_Waveform.scss rename to res/css/views/audio_messages/_Waveform.scss diff --git a/res/css/views/beta/_BetaCard.scss b/res/css/views/beta/_BetaCard.scss index 1a8241b65f..2af4e79ecd 100644 --- a/res/css/views/beta/_BetaCard.scss +++ b/res/css/views/beta/_BetaCard.scss @@ -110,24 +110,52 @@ $dot-size: 12px; width: $dot-size; transform: scale(1); background: rgba($pulse-color, 1); - box-shadow: 0 0 0 0 rgba($pulse-color, 1); animation: mx_Beta_bluePulse 2s infinite; animation-iteration-count: 20; + position: relative; + + &::after { + content: ""; + position: absolute; + width: inherit; + height: inherit; + top: 0; + left: 0; + transform: scale(1); + transform-origin: center center; + animation-name: mx_Beta_bluePulse_shadow; + animation-duration: inherit; + animation-iteration-count: inherit; + border-radius: 50%; + background: rgba($pulse-color, 1); + } } @keyframes mx_Beta_bluePulse { 0% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0.7); } 70% { transform: scale(1); - box-shadow: 0 0 0 10px rgba($pulse-color, 0); } 100% { transform: scale(0.95); - box-shadow: 0 0 0 0 rgba($pulse-color, 0); + } +} + +@keyframes mx_Beta_bluePulse_shadow { + 0% { + opacity: 0.7; + } + + 70% { + transform: scale(2.2); + opacity: 0; + } + + 100% { + opacity: 0; } } diff --git a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss index 30b79c1a9a..ec3bea0ef7 100644 --- a/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss +++ b/res/css/views/dialogs/security/_AccessSecretStorageDialog.scss @@ -28,6 +28,7 @@ limitations under the License. left: 0; top: 2px; // alignment background-image: url("$(res)/img/element-icons/warning-badge.svg"); + background-size: contain; } .mx_AccessSecretStorageDialog_reset_link { diff --git a/res/css/views/messages/_MImageBody.scss b/res/css/views/messages/_MImageBody.scss index 1c773c2f06..878a4154cd 100644 --- a/res/css/views/messages/_MImageBody.scss +++ b/res/css/views/messages/_MImageBody.scss @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +$timelineImageBorderRadius: 4px; + .mx_MImageBody { display: block; margin-right: 34px; @@ -25,7 +27,11 @@ limitations under the License. height: 100%; left: 0; top: 0; - border-radius: 4px; + border-radius: $timelineImageBorderRadius; + + > canvas { + border-radius: $timelineImageBorderRadius; + } } .mx_MImageBody_thumbnail_container { @@ -43,7 +49,7 @@ limitations under the License. top: 50%; } -// Inner img and TintableSvg should be centered around 0, 0 +// Inner img should be centered around 0, 0 .mx_MImageBody_thumbnail_spinner > * { transform: translate(-50%, -50%); } diff --git a/res/css/views/messages/_MediaBody.scss b/res/css/views/messages/_MediaBody.scss new file mode 100644 index 0000000000..12e441750c --- /dev/null +++ b/res/css/views/messages/_MediaBody.scss @@ -0,0 +1,28 @@ +/* +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. +*/ + +// A "media body" is any file upload looking thing, apart from images and videos (they +// have unique styles). + +.mx_MediaBody { + background-color: $message-body-panel-bg-color; + border-radius: 12px; + + color: $message-body-panel-fg-color; + font-size: $font-14px; + line-height: $font-24px; +} + diff --git a/res/css/views/messages/_common_CryptoEvent.scss b/res/css/views/messages/_common_CryptoEvent.scss index bcc40f1181..afaed50fa4 100644 --- a/res/css/views/messages/_common_CryptoEvent.scss +++ b/res/css/views/messages/_common_CryptoEvent.scss @@ -48,6 +48,7 @@ limitations under the License. .mx_cryptoEvent_buttons { align-items: center; display: flex; + gap: 5px; } .mx_cryptoEvent_state { diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index 27a83e58f8..55f73c0315 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -477,8 +477,7 @@ $hover-select-border: 4px; pre, code { font-family: $monospace-font-family !important; - // deliberate constants as we're behind an invert filter - color: #333; + background-color: $header-panel-bg-color; } pre { @@ -488,11 +487,6 @@ $hover-select-border: 4px; overflow-x: overlay; overflow-y: visible; } - - code { - // deliberate constants as we're behind an invert filter - background-color: #f8f8f8; - } } .mx_EventTile_lineNumbers { diff --git a/res/css/views/rooms/_LinkPreviewGroup.scss b/res/css/views/rooms/_LinkPreviewGroup.scss new file mode 100644 index 0000000000..ed341904fd --- /dev/null +++ b/res/css/views/rooms/_LinkPreviewGroup.scss @@ -0,0 +1,38 @@ +/* +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_LinkPreviewGroup { + .mx_LinkPreviewGroup_hide { + cursor: pointer; + width: 18px; + height: 18px; + + img { + flex: 0 0 40px; + visibility: hidden; + } + } + + &:hover .mx_LinkPreviewGroup_hide img, + .mx_LinkPreviewGroup_hide.focus-visible:focus img { + visibility: visible; + } + + > .mx_AccessibleButton { + color: $accent-color; + text-align: center; + } +} diff --git a/res/css/views/rooms/_LinkPreviewWidget.scss b/res/css/views/rooms/_LinkPreviewWidget.scss index 022cf3ed28..0832337ecd 100644 --- a/res/css/views/rooms/_LinkPreviewWidget.scss +++ b/res/css/views/rooms/_LinkPreviewWidget.scss @@ -33,38 +33,29 @@ limitations under the License. .mx_LinkPreviewWidget_caption { margin-left: 15px; flex: 1 1 auto; + overflow-x: hidden; // cause it to wrap rather than clip } .mx_LinkPreviewWidget_title { - display: inline; font-weight: bold; white-space: normal; -} + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; -.mx_LinkPreviewWidget_siteName { - display: inline; + .mx_LinkPreviewWidget_siteName { + font-weight: normal; + } } .mx_LinkPreviewWidget_description { margin-top: 8px; white-space: normal; word-wrap: break-word; -} - -.mx_LinkPreviewWidget_cancel { - cursor: pointer; - width: 18px; - height: 18px; - - img { - flex: 0 0 40px; - visibility: hidden; - } -} - -.mx_LinkPreviewWidget:hover .mx_LinkPreviewWidget_cancel img, -.mx_LinkPreviewWidget_cancel.focus-visible:focus img { - visibility: visible; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; } .mx_MatrixChat_useCompactLayout { diff --git a/res/css/views/voip/_CallContainer.scss b/res/css/views/voip/_CallContainer.scss index 168a8bb74b..0c09070334 100644 --- a/res/css/views/voip/_CallContainer.scss +++ b/res/css/views/voip/_CallContainer.scss @@ -30,8 +30,8 @@ limitations under the License. pointer-events: initial; // restore pointer events so the user can leave/interact cursor: pointer; - .mx_CallView_video { - width: 350px; + .mx_VideoFeed_remote.mx_VideoFeed_voice { + min-height: 150px; } .mx_VideoFeed_local { diff --git a/res/css/views/avatars/_PulsedAvatar.scss b/res/css/views/voip/_CallPreview.scss similarity index 61% rename from res/css/views/avatars/_PulsedAvatar.scss rename to res/css/views/voip/_CallPreview.scss index ce9e3382ab..92348fb465 100644 --- a/res/css/views/avatars/_PulsedAvatar.scss +++ b/res/css/views/voip/_CallPreview.scss @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2021 Šimon Brandner Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,17 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_PulsedAvatar { - @keyframes shadow-pulse { - 0% { - box-shadow: 0 0 0 0px rgba($accent-color, 0.2); - } - 100% { - box-shadow: 0 0 0 6px rgba($accent-color, 0); - } - } - - img { - animation: shadow-pulse 1s infinite; - } +.mx_CallPreview { + position: fixed; + left: 0; + top: 0; } diff --git a/res/css/views/voip/_CallView.scss b/res/css/views/voip/_CallView.scss index 0be75be28c..205d431752 100644 --- a/res/css/views/voip/_CallView.scss +++ b/res/css/views/voip/_CallView.scss @@ -39,7 +39,6 @@ limitations under the License. .mx_CallView_pip { width: 320px; padding-bottom: 8px; - margin-top: 10px; background-color: $voipcall-plinth-color; box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.20); border-radius: 8px; diff --git a/res/css/views/voip/_VideoFeed.scss b/res/css/views/voip/_VideoFeed.scss index 7d85ac264e..4a3fbdf597 100644 --- a/res/css/views/voip/_VideoFeed.scss +++ b/res/css/views/voip/_VideoFeed.scss @@ -15,8 +15,6 @@ limitations under the License. */ .mx_VideoFeed_voice { - // We don't want to collide with the call controls that have 52px of height - padding-bottom: 52px; background-color: $inverted-bg-color; } diff --git a/res/themes/dark/css/_dark.scss b/res/themes/dark/css/_dark.scss index 8b5fde3bd1..57cbc7efa9 100644 --- a/res/themes/dark/css/_dark.scss +++ b/res/themes/dark/css/_dark.scss @@ -119,8 +119,6 @@ $voipcall-plinth-color: #394049; $theme-button-bg-color: #e3e8f0; $dialpad-button-bg-color: #6F7882; -; - $roomlist-button-bg-color: rgba(141, 151, 165, 0.2); // Buttons include the filter box, explore button, and sublist buttons $roomlist-filter-active-bg-color: $bg-color; @@ -215,8 +213,6 @@ $message-body-panel-icon-fg-color: #21262C; // "Separator" $message-body-panel-icon-bg-color: $tertiary-fg-color; $voice-record-stop-border-color: $quaternary-fg-color; -$voice-record-waveform-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-icon-color: $quaternary-fg-color; $voice-playback-button-bg-color: $message-body-panel-icon-bg-color; @@ -276,24 +272,7 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); } // markdown overrides: -.mx_EventTile_content .markdown-body pre:hover { - border-color: #808080 !important; // inverted due to rules below - scrollbar-color: rgba(0, 0, 0, 0.2) transparent; // copied from light theme due to inversion below - // the code above works only in Firefox, this is for other browsers - // see https://developer.mozilla.org/en-US/docs/Web/CSS/scrollbar-color - &::-webkit-scrollbar-thumb { - background-color: rgba(0, 0, 0, 0.2); // copied from light theme due to inversion below - } -} .mx_EventTile_content .markdown-body { - pre, code { - filter: invert(1); - } - - pre code { - filter: none; - } - table { tr { background-color: #000000; @@ -303,18 +282,9 @@ $composer-shadow-color: rgba(0, 0, 0, 0.28); background-color: #080808; } } - - blockquote { - color: #919191; - } } -// diff highlight colors -// intentionally swapped to avoid inversion -.hljs-addition { - background: #fdd; -} - -.hljs-deletion { - background: #dfd; +// highlight.js overrides +.hljs-tag { + color: inherit; // Without this they'd be weirdly blue which doesn't match the theme } diff --git a/res/themes/dark/css/dark.scss b/res/themes/dark/css/dark.scss index f9695018e4..600cfd528a 100644 --- a/res/themes/dark/css/dark.scss +++ b/res/themes/dark/css/dark.scss @@ -9,3 +9,4 @@ @import "_dark.scss"; @import "../../light/css/_mods.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-dark.css"); diff --git a/res/themes/legacy-dark/css/_legacy-dark.scss b/res/themes/legacy-dark/css/_legacy-dark.scss index eb6dc40599..555ef4f66c 100644 --- a/res/themes/legacy-dark/css/_legacy-dark.scss +++ b/res/themes/legacy-dark/css/_legacy-dark.scss @@ -20,6 +20,9 @@ $tertiary-fg-color: $primary-fg-color; $primary-bg-color: $bg-color; $muted-fg-color: $header-panel-text-primary-color; +// Legacy theme backports +$quaternary-fg-color: #6F7882; + // used for dialog box text $light-fg-color: $header-panel-text-secondary-color; @@ -115,7 +118,7 @@ $voipcall-plinth-color: #394049; $theme-button-bg-color: #e3e8f0; $dialpad-button-bg-color: #6F7882; -; + $roomlist-button-bg-color: #1A1D23; // Buttons include the filter box, explore button, and sublist buttons $roomlist-filter-active-bg-color: $roomlist-button-bg-color; @@ -209,8 +212,6 @@ $message-body-panel-icon-bg-color: $secondary-fg-color; // See non-legacy dark for variable information $voice-record-stop-border-color: #6F7882; -$voice-record-waveform-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-fg-color; $voice-record-waveform-incomplete-fg-color: #6F7882; $voice-record-icon-color: #6F7882; $voice-playback-button-bg-color: $tertiary-fg-color; @@ -248,7 +249,7 @@ $composer-shadow-color: tranparent; @define-mixin mx_DialogButton_secondary { // flip colours for the secondary ones font-weight: 600; - border: 1px solid $accent-color ! important; + border: 1px solid $accent-color !important; color: $accent-color; background-color: $button-secondary-bg-color; } @@ -266,18 +267,7 @@ $composer-shadow-color: tranparent; } // markdown overrides: -.mx_EventTile_content .markdown-body pre:hover { - border-color: #808080 !important; // inverted due to rules below -} .mx_EventTile_content .markdown-body { - pre, code { - filter: invert(1); - } - - pre code { - filter: none; - } - table { tr { background-color: #000000; @@ -289,12 +279,7 @@ $composer-shadow-color: tranparent; } } -// diff highlight colors -// intentionally swapped to avoid inversion -.hljs-addition { - background: #fdd; -} - -.hljs-deletion { - background: #dfd; +// highlight.js overrides: +.hljs-tag { + color: inherit; // Without this they'd be weirdly blue which doesn't match the theme } diff --git a/res/themes/legacy-dark/css/legacy-dark.scss b/res/themes/legacy-dark/css/legacy-dark.scss index 2a4d432d26..840794f7c0 100644 --- a/res/themes/legacy-dark/css/legacy-dark.scss +++ b/res/themes/legacy-dark/css/legacy-dark.scss @@ -4,3 +4,4 @@ @import "../../legacy-light/css/_legacy-light.scss"; @import "_legacy-dark.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-dark.css"); diff --git a/res/themes/legacy-light/css/_legacy-light.scss b/res/themes/legacy-light/css/_legacy-light.scss index a6b180bab4..c7debcdabe 100644 --- a/res/themes/legacy-light/css/_legacy-light.scss +++ b/res/themes/legacy-light/css/_legacy-light.scss @@ -28,6 +28,9 @@ $tertiary-fg-color: $primary-fg-color; $primary-bg-color: #ffffff; $muted-fg-color: #61708b; // Commonly used in headings and relevant alt text +// Legacy theme backports +$quaternary-fg-color: #C1C6CD; + // used for dialog box text $light-fg-color: #747474; @@ -334,8 +337,6 @@ $message-body-panel-icon-bg-color: $primary-bg-color; $voice-record-stop-symbol-color: #ff4b55; $voice-record-live-circle-color: #ff4b55; $voice-record-stop-border-color: #E3E8F0; -$voice-record-waveform-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-fg-color; $voice-record-waveform-incomplete-fg-color: #C1C6CD; $voice-record-icon-color: $tertiary-fg-color; $voice-playback-button-bg-color: $message-body-panel-icon-bg-color; diff --git a/res/themes/legacy-light/css/legacy-light.scss b/res/themes/legacy-light/css/legacy-light.scss index e39a1765f3..347d240fc6 100644 --- a/res/themes/legacy-light/css/legacy-light.scss +++ b/res/themes/legacy-light/css/legacy-light.scss @@ -3,3 +3,4 @@ @import "_fonts.scss"; @import "_legacy-light.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-light.css"); diff --git a/res/themes/light/css/_light.scss b/res/themes/light/css/_light.scss index d8dab9c9c4..7e958c2af6 100644 --- a/res/themes/light/css/_light.scss +++ b/res/themes/light/css/_light.scss @@ -335,8 +335,6 @@ $voice-record-stop-symbol-color: #ff4b55; $voice-record-live-circle-color: #ff4b55; $voice-record-stop-border-color: #E3E8F0; // "Separator" -$voice-record-waveform-bg-color: $message-body-panel-bg-color; -$voice-record-waveform-fg-color: $message-body-panel-fg-color; $voice-record-waveform-incomplete-fg-color: $quaternary-fg-color; $voice-record-icon-color: $tertiary-fg-color; $voice-playback-button-bg-color: $message-body-panel-icon-bg-color; diff --git a/res/themes/light/css/light.scss b/res/themes/light/css/light.scss index f31ce5c139..4e912bc756 100644 --- a/res/themes/light/css/light.scss +++ b/res/themes/light/css/light.scss @@ -4,3 +4,4 @@ @import "_light.scss"; @import "_mods.scss"; @import "../../../../res/css/_components.scss"; +@import url("highlight.js/styles/atom-one-light.css"); diff --git a/scripts/ci/install-deps.sh b/scripts/ci/install-deps.sh index bbda74ef9d..fcbf6b1198 100755 --- a/scripts/ci/install-deps.sh +++ b/scripts/ci/install-deps.sh @@ -6,8 +6,8 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk pushd matrix-js-sdk yarn link -yarn install $@ +yarn install --pure-lockfile $@ popd yarn link matrix-js-sdk -yarn install $@ +yarn install --pure-lockfile $@ diff --git a/scripts/ci/layered.sh b/scripts/ci/layered.sh index 039f90c7df..2e163456fe 100755 --- a/scripts/ci/layered.sh +++ b/scripts/ci/layered.sh @@ -13,13 +13,13 @@ scripts/fetchdep.sh matrix-org matrix-js-sdk pushd matrix-js-sdk yarn link -yarn install +yarn install --pure-lockfile popd # Now set up the react-sdk yarn link matrix-js-sdk yarn link -yarn install +yarn install --pure-lockfile yarn reskindex # Finally, set up element-web @@ -27,6 +27,6 @@ scripts/fetchdep.sh vector-im element-web pushd element-web yarn link matrix-js-sdk yarn link matrix-react-sdk -yarn install +yarn install --pure-lockfile yarn build:res popd diff --git a/scripts/generate-eslint-error-ignore-file b/scripts/generate-eslint-error-ignore-file deleted file mode 100755 index 54aacfc9fa..0000000000 --- a/scripts/generate-eslint-error-ignore-file +++ /dev/null @@ -1,23 +0,0 @@ -#!/bin/sh -# -# generates .eslintignore.errorfiles to list the files which have errors in, -# so that they can be ignored in future automated linting. - -out=.eslintignore.errorfiles - -cd `dirname $0`/.. - -echo "generating $out" - -{ - cat < 0) | .filePath' | - sed -e 's/.*matrix-react-sdk\///'; -} > "$out" -# also append rules from eslintignore file -cat .eslintignore >> $out diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index f75c17aaf4..759cc306f5 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,7 +15,7 @@ limitations under the License. */ import "matrix-js-sdk/src/@types/global"; // load matrix-js-sdk's type extensions first -import * as ModernizrStatic from "modernizr"; +import "@types/modernizr"; import ContentMessages from "../ContentMessages"; import { IMatrixClientPeg } from "../MatrixClientPeg"; @@ -46,10 +46,10 @@ import { VoiceRecordingStore } from "../stores/VoiceRecordingStore"; import PerformanceMonitor from "../performance"; import UIStore from "../stores/UIStore"; import { SetupEncryptionStore } from "../stores/SetupEncryptionStore"; +import { RoomScrollStateStore } from "../stores/RoomScrollStateStore"; declare global { interface Window { - Modernizr: ModernizrStatic; matrixChat: ReturnType; mxMatrixClientPeg: IMatrixClientPeg; Olm: { @@ -87,6 +87,7 @@ declare global { mxPerformanceEntryNames: any; mxUIStore: UIStore; mxSetupEncryptionStore?: SetupEncryptionStore; + mxRoomScrollStateStore?: RoomScrollStateStore; } interface Document { diff --git a/src/Analytics.tsx b/src/Analytics.tsx index 8c82639b5f..ce8287de56 100644 --- a/src/Analytics.tsx +++ b/src/Analytics.tsx @@ -390,6 +390,7 @@ export class Analytics { { expl: _td('Your device resolution'), value: resolution }, ]; + // FIXME: Using an import will result in test failures const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Analytics Details', '', ErrorDialog, { title: _t('Analytics'), diff --git a/src/AsyncWrapper.tsx b/src/AsyncWrapper.tsx index 3bbef71093..ef8924add8 100644 --- a/src/AsyncWrapper.tsx +++ b/src/AsyncWrapper.tsx @@ -77,6 +77,7 @@ export default class AsyncWrapper extends React.Component { const Component = this.state.component; return ; } else if (this.state.error) { + // FIXME: Using an import will result in test failures const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index cb54db3f8a..6e1e6ce83a 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -124,9 +124,9 @@ interface ThirdpartyLookupResponseFields { } interface ThirdpartyLookupResponse { - userid: string, - protocol: string, - fields: ThirdpartyLookupResponseFields, + userid: string; + protocol: string; + fields: ThirdpartyLookupResponseFields; } // Unlike 'CallType' in js-sdk, this one includes screen sharing diff --git a/src/ContentMessages.tsx b/src/ContentMessages.tsx index ef0a89a690..0ab193081b 100644 --- a/src/ContentMessages.tsx +++ b/src/ContentMessages.tsx @@ -17,9 +17,10 @@ limitations under the License. */ import React from "react"; -import dis from './dispatcher/dispatcher'; -import { MatrixClientPeg } from './MatrixClientPeg'; +import { encode } from "blurhash"; import { MatrixClient } from "matrix-js-sdk/src/client"; + +import dis from './dispatcher/dispatcher'; import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; @@ -47,6 +48,8 @@ const MAX_HEIGHT = 600; // 5669 px (x-axis) , 5669 px (y-axis) , per metre const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; +export const BLURHASH_FIELD = "xyz.amorgan.blurhash"; // MSC2448 + export class UploadCanceledError extends Error {} type ThumbnailableElement = HTMLImageElement | HTMLVideoElement; @@ -77,6 +80,7 @@ interface IThumbnail { }; w: number; h: number; + [BLURHASH_FIELD]: string; }; thumbnail: Blob; } @@ -124,7 +128,17 @@ function createThumbnail( const canvas = document.createElement("canvas"); canvas.width = targetWidth; canvas.height = targetHeight; - canvas.getContext("2d").drawImage(element, 0, 0, targetWidth, targetHeight); + const context = canvas.getContext("2d"); + context.drawImage(element, 0, 0, targetWidth, targetHeight); + const imageData = context.getImageData(0, 0, targetWidth, targetHeight); + const blurhash = encode( + imageData.data, + imageData.width, + imageData.height, + // use 4 components on the longer dimension, if square then both + imageData.width >= imageData.height ? 4 : 3, + imageData.height >= imageData.width ? 4 : 3, + ); canvas.toBlob(function(thumbnail) { resolve({ info: { @@ -136,8 +150,9 @@ function createThumbnail( }, w: inputWidth, h: inputHeight, + [BLURHASH_FIELD]: blurhash, }, - thumbnail: thumbnail, + thumbnail, }); }, mimeType); }); @@ -220,7 +235,8 @@ function infoForImageFile(matrixClient, roomId, imageFile) { } /** - * Load a file into a newly created video element. + * Load a file into a newly created video element and pull some strings + * in an attempt to guarantee the first frame will be showing. * * @param {File} videoFile The file to load in an video element. * @return {Promise} A promise that resolves with the video image element. @@ -229,20 +245,25 @@ function loadVideoElement(videoFile): Promise { return new Promise((resolve, reject) => { // Load the file into an html element const video = document.createElement("video"); + video.preload = "metadata"; + video.playsInline = true; + video.muted = true; const reader = new FileReader(); reader.onload = function(ev) { - video.src = ev.target.result as string; - - // Once ready, returns its size // Wait until we have enough data to thumbnail the first frame. - video.onloadeddata = function() { + video.onloadeddata = async function() { resolve(video); + video.pause(); }; video.onerror = function(e) { reject(e); }; + + video.src = ev.target.result as string; + video.load(); + video.play(); }; reader.onerror = function(e) { reject(e); @@ -347,7 +368,7 @@ export function uploadFile( }); (prom as IAbortablePromise).abort = () => { canceled = true; - if (uploadPromise) MatrixClientPeg.get().cancelUpload(uploadPromise); + if (uploadPromise) matrixClient.cancelUpload(uploadPromise); }; return prom; } else { @@ -357,11 +378,11 @@ export function uploadFile( const promise1 = basePromise.then(function(url) { if (canceled) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly. - return { "url": url }; + return { url }; }); (promise1 as any).abort = () => { canceled = true; - MatrixClientPeg.get().cancelUpload(basePromise); + matrixClient.cancelUpload(basePromise); }; return promise1; } @@ -373,7 +394,7 @@ export default class ContentMessages { sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) { const startTime = CountlyAnalytics.getTimestamp(); - const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { + const prom = matrixClient.sendStickerMessage(roomId, url, info, text).catch((e) => { console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); throw e; }); @@ -397,6 +418,7 @@ export default class ContentMessages { const isQuoting = Boolean(RoomViewStore.getQuotingEvent()); if (isQuoting) { + // FIXME: Using an import will result in Element crashing const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Reply Warning', '', QuestionDialog, { title: _t('Replying With Files'), @@ -415,7 +437,7 @@ export default class ContentMessages { if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); - await this.ensureMediaConfigFetched(); + await this.ensureMediaConfigFetched(matrixClient); modal.close(); } @@ -431,6 +453,7 @@ export default class ContentMessages { } if (tooBigFiles.length > 0) { + // FIXME: Using an import will result in Element crashing const UploadFailureDialog = sdk.getComponent("dialogs.UploadFailureDialog"); const { finished } = Modal.createTrackedDialog<[boolean]>('Upload Failure', '', UploadFailureDialog, { badFiles: tooBigFiles, @@ -441,7 +464,6 @@ export default class ContentMessages { if (!shouldContinue) return; } - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); let uploadAll = false; // Promise to complete before sending next file into room, used for synchronisation of file-sending // to match the order the files were specified in @@ -449,6 +471,8 @@ export default class ContentMessages { for (let i = 0; i < okFiles.length; ++i) { const file = okFiles[i]; if (!uploadAll) { + // FIXME: Using an import will result in Element crashing + const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); const { finished } = Modal.createTrackedDialog<[boolean, boolean]>('Upload Files confirmation', '', UploadConfirmDialog, { file, @@ -470,7 +494,7 @@ export default class ContentMessages { return this.inprogress.filter(u => !u.canceled); } - cancelUpload(promise: Promise) { + cancelUpload(promise: Promise, matrixClient: MatrixClient) { let upload: IUpload; for (let i = 0; i < this.inprogress.length; ++i) { if (this.inprogress[i].promise === promise) { @@ -480,7 +504,7 @@ export default class ContentMessages { } if (upload) { upload.canceled = true; - MatrixClientPeg.get().cancelUpload(upload.promise); + matrixClient.cancelUpload(upload.promise); dis.dispatch({ action: Action.UploadCanceled, upload }); } } @@ -545,7 +569,7 @@ export default class ContentMessages { dis.dispatch({ action: Action.UploadStarted, upload }); // Focus the composer view - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); function onProgress(ev) { upload.total = ev.total; @@ -584,6 +608,7 @@ export default class ContentMessages { { fileName: upload.fileName }, ); } + // FIXME: Using an import will result in Element crashing const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Upload failed', '', ErrorDialog, { title: _t('Upload Failed'), @@ -621,11 +646,11 @@ export default class ContentMessages { return true; } - private ensureMediaConfigFetched() { + private ensureMediaConfigFetched(matrixClient: MatrixClient) { if (this.mediaConfig !== null) return; console.log("[Media Config] Fetching"); - return MatrixClientPeg.get().getMediaConfig().then((config) => { + return matrixClient.getMediaConfig().then((config) => { console.log("[Media Config] Fetched config:", config); return config; }).catch(() => { diff --git a/src/CountlyAnalytics.ts b/src/CountlyAnalytics.ts index 39dcac4048..a75c578536 100644 --- a/src/CountlyAnalytics.ts +++ b/src/CountlyAnalytics.ts @@ -15,12 +15,13 @@ limitations under the License. */ import { randomString } from "matrix-js-sdk/src/randomstring"; +import { IContent } from "matrix-js-sdk/src/models/event"; +import { sleep } from "matrix-js-sdk/src/utils"; import { getCurrentLanguage } from './languageHandler'; import PlatformPeg from './PlatformPeg'; import SdkConfig from './SdkConfig'; import { MatrixClientPeg } from "./MatrixClientPeg"; -import { sleep } from "./utils/promise"; import RoomViewStore from "./stores/RoomViewStore"; import { Action } from "./dispatcher/actions"; @@ -255,7 +256,7 @@ interface ICreateRoomEvent extends IEvent { num_users: number; is_encrypted: boolean; is_public: boolean; - } + }; } interface IJoinRoomEvent extends IEvent { @@ -868,7 +869,7 @@ export default class CountlyAnalytics { roomId: string, isEdit: boolean, isReply: boolean, - content: {format?: string, msgtype: string}, + content: IContent, ) { if (this.disabled) return; const cli = MatrixClientPeg.get(); diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index d70585e5ec..d033063677 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -160,7 +160,8 @@ export default class DeviceListener { // which result in account data changes affecting checks below. if ( ev.getType().startsWith('m.secret_storage.') || - ev.getType().startsWith('m.cross_signing.') + ev.getType().startsWith('m.cross_signing.') || + ev.getType() === 'm.megolm_backup.v1' ) { this._recheck(); } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index c80b50c566..016b557477 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -358,11 +358,11 @@ interface IOpts { stripReplyFallback?: boolean; returnString?: boolean; forComposerQuote?: boolean; - ref?: React.Ref; + ref?: React.Ref; } export interface IOptsReturnNode extends IOpts { - returnString: false; + returnString: false | undefined; } export interface IOptsReturnString extends IOpts { @@ -403,9 +403,14 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts try { if (highlights && highlights.length > 0) { const highlighter = new HtmlHighlighter("mx_EventTile_searchHighlight", opts.highlightLink); - const safeHighlights = highlights.map(function(highlight) { - return sanitizeHtml(highlight, sanitizeParams); - }); + const safeHighlights = highlights + // sanitizeHtml can hang if an unclosed HTML tag is thrown at it + // A search for ` !highlight.includes("<")) + .map((highlight: string): string => sanitizeHtml(highlight, sanitizeParams)); // XXX: hacky bodge to temporarily apply a textFilter to the sanitizeParams structure. sanitizeParams.textFilter = function(safeText) { return highlighter.applyHighlights(safeText, safeHighlights).join(''); diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 76dee5ab55..61ded93833 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -33,7 +33,6 @@ import Presence from './Presence'; import dis from './dispatcher/dispatcher'; import DMRoomMap from './utils/DMRoomMap'; import Modal from './Modal'; -import * as sdk from './index'; import ActiveWidgetStore from './stores/ActiveWidgetStore'; import PlatformPeg from "./PlatformPeg"; import { sendLoginRequest } from "./Login"; @@ -52,6 +51,10 @@ import CallHandler from './CallHandler'; import LifecycleCustomisations from "./customisations/Lifecycle"; import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import { _t } from "./languageHandler"; +import LazyLoadingResyncDialog from "./components/views/dialogs/LazyLoadingResyncDialog"; +import LazyLoadingDisabledDialog from "./components/views/dialogs/LazyLoadingDisabledDialog"; +import SessionRestoreErrorDialog from "./components/views/dialogs/SessionRestoreErrorDialog"; +import StorageEvictedDialog from "./components/views/dialogs/StorageEvictedDialog"; const HOMESERVER_URL_KEY = "mx_hs_url"; const ID_SERVER_URL_KEY = "mx_is_url"; @@ -238,8 +241,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { return Promise.resolve().then(() => { const lazyLoadEnabled = e.value; if (lazyLoadEnabled) { - const LazyLoadingResyncDialog = - sdk.getComponent("views.dialogs.LazyLoadingResyncDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingResyncDialog, { onFinished: resolve, @@ -250,8 +251,6 @@ export function handleInvalidStoreError(e: InvalidStoreError): Promise { // between LL/non-LL version on same host. // as disabling LL when previously enabled // is a strong indicator of this (/develop & /app) - const LazyLoadingDisabledDialog = - sdk.getComponent("views.dialogs.LazyLoadingDisabledDialog"); return new Promise((resolve) => { Modal.createDialog(LazyLoadingDisabledDialog, { onFinished: resolve, @@ -451,9 +450,6 @@ export async function restoreFromLocalStorage(opts?: { ignoreGuest?: boolean }): async function handleLoadSessionFailure(e: Error): Promise { console.error("Unable to load session", e); - const SessionRestoreErrorDialog = - sdk.getComponent('views.dialogs.SessionRestoreErrorDialog'); - const modal = Modal.createTrackedDialog('Session Restore Error', '', SessionRestoreErrorDialog, { error: e.message, }); @@ -612,7 +608,6 @@ async function doSetLoggedIn( } function showStorageEvictedDialog(): Promise { - const StorageEvictedDialog = sdk.getComponent('views.dialogs.StorageEvictedDialog'); return new Promise(resolve => { Modal.createTrackedDialog('Storage evicted', '', StorageEvictedDialog, { onFinished: resolve, diff --git a/src/Markdown.js b/src/Markdown.ts similarity index 74% rename from src/Markdown.js rename to src/Markdown.ts index f670bded12..96169d4011 100644 --- a/src/Markdown.js +++ b/src/Markdown.ts @@ -1,5 +1,6 @@ /* Copyright 2016 OpenMarket Ltd +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. @@ -15,16 +16,24 @@ limitations under the License. */ import * as commonmark from 'commonmark'; -import {escape} from "lodash"; +import { escape } from "lodash"; const ALLOWED_HTML_TAGS = ['sub', 'sup', 'del', 'u']; // These types of node are definitely text const TEXT_NODES = ['text', 'softbreak', 'linebreak', 'paragraph', 'document']; -function is_allowed_html_tag(node) { +// As far as @types/commonmark is concerned, these are not public, so add them +interface CommonmarkHtmlRendererInternal extends commonmark.HtmlRenderer { + paragraph: (node: commonmark.Node, entering: boolean) => void; + link: (node: commonmark.Node, entering: boolean) => void; + html_inline: (node: commonmark.Node) => void; // eslint-disable-line camelcase + html_block: (node: commonmark.Node) => void; // eslint-disable-line camelcase +} + +function isAllowedHtmlTag(node: commonmark.Node): boolean { if (node.literal != null && - node.literal.match('^<((div|span) data-mx-maths="[^"]*"|\/(div|span))>$') != null) { + node.literal.match('^<((div|span) data-mx-maths="[^"]*"|/(div|span))>$') != null) { return true; } @@ -39,21 +48,12 @@ function is_allowed_html_tag(node) { return false; } -function html_if_tag_allowed(node) { - if (is_allowed_html_tag(node)) { - this.lit(node.literal); - return; - } else { - this.lit(escape(node.literal)); - } -} - /* * Returns true if the parse output containing the node * comprises multiple block level elements (ie. lines), * or false if it is only a single line. */ -function is_multi_line(node) { +function isMultiLine(node: commonmark.Node): boolean { let par = node; while (par.parent) { par = par.parent; @@ -67,6 +67,9 @@ function is_multi_line(node) { * it's plain text. */ export default class Markdown { + private input: string; + private parsed: commonmark.Node; + constructor(input) { this.input = input; @@ -74,7 +77,7 @@ export default class Markdown { this.parsed = parser.parse(this.input); } - isPlainText() { + isPlainText(): boolean { const walker = this.parsed.walker(); let ev; @@ -87,7 +90,7 @@ export default class Markdown { // if it's an allowed html tag, we need to render it and therefore // we will need to use HTML. If it's not allowed, it's not HTML since // we'll just be treating it as text. - if (is_allowed_html_tag(node)) { + if (isAllowedHtmlTag(node)) { return false; } } else { @@ -97,7 +100,7 @@ export default class Markdown { return true; } - toHTML({ externalLinks = false } = {}) { + toHTML({ externalLinks = false } = {}): string { const renderer = new commonmark.HtmlRenderer({ safe: false, @@ -107,7 +110,7 @@ export default class Markdown { // block quote ends up all on one line // (https://github.com/vector-im/element-web/issues/3154) softbreak: '
', - }); + }) as CommonmarkHtmlRendererInternal; // Trying to strip out the wrapping

causes a lot more complication // than it's worth, i think. For instance, this code will go and strip @@ -118,16 +121,16 @@ export default class Markdown { // // Let's try sending with

s anyway for now, though. - const real_paragraph = renderer.paragraph; + const realParagraph = renderer.paragraph; - renderer.paragraph = function(node, entering) { + renderer.paragraph = function(node: commonmark.Node, entering: boolean) { // If there is only one top level node, just return the // bare text: it's a single line of text and so should be // 'inline', rather than unnecessarily wrapped in its own // p tag. If, however, we have multiple nodes, each gets // its own p tag to keep them as separate paragraphs. - if (is_multi_line(node)) { - real_paragraph.call(this, node, entering); + if (isMultiLine(node)) { + realParagraph.call(this, node, entering); } }; @@ -150,19 +153,26 @@ export default class Markdown { } }; - renderer.html_inline = html_if_tag_allowed; + renderer.html_inline = function(node: commonmark.Node) { + if (isAllowedHtmlTag(node)) { + this.lit(node.literal); + return; + } else { + this.lit(escape(node.literal)); + } + }; - renderer.html_block = function(node) { -/* + renderer.html_block = function(node: commonmark.Node) { + /* // as with `paragraph`, we only insert line breaks // if there are multiple lines in the markdown. const isMultiLine = is_multi_line(node); if (isMultiLine) this.cr(); -*/ - html_if_tag_allowed.call(this, node); -/* + */ + renderer.html_inline(node); + /* if (isMultiLine) this.cr(); -*/ + */ }; return renderer.render(this.parsed); @@ -177,23 +187,22 @@ export default class Markdown { * N.B. this does **NOT** render arbitrary MD to plain text - only MD * which has no formatting. Otherwise it emits HTML(!). */ - toPlaintext() { - const renderer = new commonmark.HtmlRenderer({safe: false}); - const real_paragraph = renderer.paragraph; + toPlaintext(): string { + const renderer = new commonmark.HtmlRenderer({ safe: false }) as CommonmarkHtmlRendererInternal; - renderer.paragraph = function(node, entering) { + renderer.paragraph = function(node: commonmark.Node, entering: boolean) { // as with toHTML, only append lines to paragraphs if there are // multiple paragraphs - if (is_multi_line(node)) { + if (isMultiLine(node)) { if (!entering && node.next) { this.lit('\n\n'); } } }; - renderer.html_block = function(node) { + renderer.html_block = function(node: commonmark.Node) { this.lit(node.literal); - if (is_multi_line(node) && node.next) this.lit('\n\n'); + if (isMultiLine(node) && node.next) this.lit('\n\n'); }; return renderer.render(this.parsed); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index cb6cb5c65c..063c5f4cad 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -219,6 +219,7 @@ class _MatrixClientPeg implements IMatrixClientPeg { } catch (e) { if (e && e.name === 'InvalidCryptoStoreError') { // The js-sdk found a crypto DB too new for it to use + // FIXME: Using an import will result in test failures const CryptoStoreTooNewDialog = sdk.getComponent("views.dialogs.CryptoStoreTooNewDialog"); Modal.createDialog(CryptoStoreTooNewDialog); diff --git a/src/MediaDeviceHandler.ts b/src/MediaDeviceHandler.ts index 49ef123def..073f24523d 100644 --- a/src/MediaDeviceHandler.ts +++ b/src/MediaDeviceHandler.ts @@ -20,12 +20,15 @@ import { SettingLevel } from "./settings/SettingLevel"; import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix"; import EventEmitter from 'events'; -interface IMediaDevices { - audioOutput: Array; - audioInput: Array; - videoInput: Array; +// XXX: MediaDeviceKind is a union type, so we make our own enum +export enum MediaDeviceKindEnum { + AudioOutput = "audiooutput", + AudioInput = "audioinput", + VideoInput = "videoinput", } +export type IMediaDevices = Record>; + export enum MediaDeviceHandlerEvent { AudioOutputChanged = "audio_output_changed", } @@ -51,20 +54,14 @@ export default class MediaDeviceHandler extends EventEmitter { try { const devices = await navigator.mediaDevices.enumerateDevices(); + const output = { + [MediaDeviceKindEnum.AudioOutput]: [], + [MediaDeviceKindEnum.AudioInput]: [], + [MediaDeviceKindEnum.VideoInput]: [], + }; - const audioOutput = []; - const audioInput = []; - const videoInput = []; - - devices.forEach((device) => { - switch (device.kind) { - case 'audiooutput': audioOutput.push(device); break; - case 'audioinput': audioInput.push(device); break; - case 'videoinput': videoInput.push(device); break; - } - }); - - return { audioOutput, audioInput, videoInput }; + devices.forEach((device) => output[device.kind].push(device)); + return output; } catch (error) { console.warn('Unable to refresh WebRTC Devices: ', error); } @@ -106,6 +103,14 @@ export default class MediaDeviceHandler extends EventEmitter { setMatrixCallVideoInput(deviceId); } + public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { + switch (kind) { + case MediaDeviceKindEnum.AudioOutput: this.setAudioOutput(deviceId); break; + case MediaDeviceKindEnum.AudioInput: this.setAudioInput(deviceId); break; + case MediaDeviceKindEnum.VideoInput: this.setVideoInput(deviceId); break; + } + } + public static getAudioOutput(): string { return SettingsStore.getValueAt(SettingLevel.DEVICE, "webrtc_audiooutput"); } diff --git a/src/Modal.tsx b/src/Modal.tsx index 0b0e621e89..55fc871d67 100644 --- a/src/Modal.tsx +++ b/src/Modal.tsx @@ -18,10 +18,10 @@ limitations under the License. import React from 'react'; import ReactDOM from 'react-dom'; import classNames from 'classnames'; +import { defer } from "matrix-js-sdk/src/utils"; import Analytics from './Analytics'; import dis from './dispatcher/dispatcher'; -import { defer } from './utils/promise'; import AsyncWrapper from './AsyncWrapper'; const DIALOG_CONTAINER_ID = "mx_Dialog_Container"; diff --git a/src/Notifier.ts b/src/Notifier.ts index 2335dc59ac..415adcafc8 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -27,7 +27,6 @@ import * as TextForEvent from './TextForEvent'; import Analytics from './Analytics'; import * as Avatar from './Avatar'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import { _t } from './languageHandler'; import Modal from './Modal'; import SettingsStore from "./settings/SettingsStore"; @@ -37,6 +36,7 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl import RoomViewStore from "./stores/RoomViewStore"; import UserActivity from "./UserActivity"; import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; /* * Dispatches: @@ -240,7 +240,6 @@ export const Notifier = { ? _t('%(brand)s does not have permission to send you notifications - ' + 'please check your browser settings', { brand }) : _t('%(brand)s was not given permission to send notifications - please try again', { brand }); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Unable to enable Notifications', result, ErrorDialog, { title: _t('Unable to enable Notifications'), description, diff --git a/src/RoomInvite.tsx b/src/RoomInvite.tsx index c7f377b6e8..7d093f4092 100644 --- a/src/RoomInvite.tsx +++ b/src/RoomInvite.tsx @@ -22,13 +22,13 @@ import { User } from "matrix-js-sdk/src/models/user"; import { MatrixClientPeg } from './MatrixClientPeg'; import MultiInviter, { CompletionStates } from './utils/MultiInviter'; import Modal from './Modal'; -import * as sdk from './'; import { _t } from './languageHandler'; import InviteDialog, { KIND_DM, KIND_INVITE, Member } from "./components/views/dialogs/InviteDialog"; import CommunityPrototypeInviteDialog from "./components/views/dialogs/CommunityPrototypeInviteDialog"; import { CommunityPrototypeStore } from "./stores/CommunityPrototypeStore"; import BaseAvatar from "./components/views/avatars/BaseAvatar"; import { mediaFromMxc } from "./customisations/Media"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; export interface IInviteResult { states: CompletionStates; @@ -51,7 +51,6 @@ export function inviteMultipleToRoom(roomId: string, addresses: string[]): Promi export function showStartChatInviteDialog(initialText = ""): void { // This dialog handles the room creation internally - we don't need to worry about it. - const InviteDialog = sdk.getComponent("dialogs.InviteDialog"); Modal.createTrackedDialog( 'Start DM', '', InviteDialog, { kind: KIND_DM, initialText }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true, @@ -111,7 +110,6 @@ export function inviteUsersToRoom(roomId: string, userIds: string[]): Promise { console.error(err.stack); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite', '', ErrorDialog, { title: _t("Failed to invite"), description: ((err && err.message) ? err.message : _t("Operation failed")), @@ -131,7 +129,6 @@ export function showAnyInviteErrors( // Just get the first message because there was a fatal problem on the first // user. This usually means that no other users were attempted, making it // pointless for us to list who failed exactly. - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to invite users to the room', '', ErrorDialog, { title: _t("Failed to invite users to the room:", { roomName: room.name }), description: inviter.getErrorText(failedUsers[0]), @@ -178,7 +175,6 @@ export function showAnyInviteErrors( ; - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog("Some invites could not be sent", "", ErrorDialog, { title: _t("Some invites couldn't be sent"), description, diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index 6b372bba28..370b21b396 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -14,7 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ICryptoCallbacks, ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; +import { ICryptoCallbacks } from 'matrix-js-sdk/src/matrix'; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import Modal from './Modal'; import * as sdk from './index'; @@ -42,8 +43,8 @@ let secretStorageBeingAccessed = false; let nonInteractive = false; let dehydrationCache: { - key?: Uint8Array, - keyInfo?: ISecretStorageKeyInfo, + key?: Uint8Array; + keyInfo?: ISecretStorageKeyInfo; } = {}; function isCachingAllowed(): boolean { @@ -354,6 +355,7 @@ export async function accessSecretStorage(func = async () => { }, forceReset = f throw new Error("Secret storage creation canceled"); } } else { + // FIXME: Using an import will result in test failures const InteractiveAuthDialog = sdk.getComponent("dialogs.InteractiveAuthDialog"); await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest) => { diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 0f38c5fffc..7753ff6f75 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -23,7 +23,6 @@ import { User } from "matrix-js-sdk/src/models/user"; import * as ContentHelpers from 'matrix-js-sdk/src/content-helpers'; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from './dispatcher/dispatcher'; -import * as sdk from './index'; import { _t, _td } from './languageHandler'; import Modal from './Modal'; import MultiInviter from './utils/MultiInviter'; @@ -50,6 +49,12 @@ import { UIFeature } from "./settings/UIFeature"; import { CHAT_EFFECTS } from "./effects"; import CallHandler from "./CallHandler"; import { guessAndSetDMRoom } from "./Rooms"; +import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; +import ErrorDialog from './components/views/dialogs/ErrorDialog'; +import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog'; +import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog"; +import InfoDialog from "./components/views/dialogs/InfoDialog"; +import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpDialog"; // XXX: workaround for https://github.com/microsoft/TypeScript/issues/31816 interface HTMLInputEvent extends Event { @@ -63,7 +68,6 @@ const singleMxcUpload = async (): Promise => { fileSelector.onchange = (ev: HTMLInputEvent) => { const file = ev.target.files[0]; - const UploadConfirmDialog = sdk.getComponent("dialogs.UploadConfirmDialog"); Modal.createTrackedDialog('Upload Files confirmation', '', UploadConfirmDialog, { file, onFinished: (shouldContinue) => { @@ -246,7 +250,6 @@ export const Commands = [ args: '', description: _td('Searches DuckDuckGo for results'), runFn: function() { - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); // TODO Don't explain this away, actually show a search UI here. Modal.createTrackedDialog('Slash Commands', '/ddg is not a command', ErrorDialog, { title: _t('/ddg is not a command'), @@ -269,8 +272,6 @@ export const Commands = [ return reject(_t("You do not have the required permissions to use this command.")); } - const RoomUpgradeWarningDialog = sdk.getComponent("dialogs.RoomUpgradeWarningDialog"); - const { finished } = Modal.createTrackedDialog('Slash Commands', 'upgrade room confirmation', RoomUpgradeWarningDialog, { roomId: roomId, targetVersion: args }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); @@ -314,7 +315,6 @@ export const Commands = [ if (checkForUpgradeFn) cli.removeListener('Room', checkForUpgradeFn); - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('Slash Commands', 'room upgrade error', ErrorDialog, { title: _t('Error upgrading room'), description: _t( @@ -434,7 +434,6 @@ export const Commands = [ const topic = topicEvents && topicEvents.getContent().topic; const topicHtml = topic ? linkifyAndSanitizeHtml(topic) : _t('This room has no topic.'); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'Topic', InfoDialog, { title: room.name, description:

, @@ -737,7 +736,6 @@ export const Commands = [ ignoredUsers.push(userId); // de-duped internally in the js-sdk return success( cli.setIgnoredUsers(ignoredUsers).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'User ignored', InfoDialog, { title: _t('Ignored user'), description:
@@ -768,7 +766,6 @@ export const Commands = [ if (index !== -1) ignoredUsers.splice(index, 1); return success( cli.setIgnoredUsers(ignoredUsers).then(() => { - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'User unignored', InfoDialog, { title: _t('Unignored user'), description:
@@ -838,7 +835,6 @@ export const Commands = [ command: 'devtools', description: _td('Opens the Developer Tools dialog'), runFn: function(roomId) { - const DevtoolsDialog = sdk.getComponent('dialogs.DevtoolsDialog'); Modal.createDialog(DevtoolsDialog, { roomId }); return success(); }, @@ -943,7 +939,6 @@ export const Commands = [ await cli.setDeviceVerified(userId, deviceId, true); // Tell the user we verified everything - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); Modal.createTrackedDialog('Slash Commands', 'Verified key', InfoDialog, { title: _t('Verified key'), description:
@@ -1000,8 +995,6 @@ export const Commands = [ command: "help", description: _td("Displays list of commands with usages and descriptions"), runFn: function() { - const SlashCommandHelpDialog = sdk.getComponent('dialogs.SlashCommandHelpDialog'); - Modal.createTrackedDialog('Slash Commands', 'Help', SlashCommandHelpDialog); return success(); }, @@ -1181,7 +1174,7 @@ export const Commands = [ ]; // build a map from names and aliases to the Command objects. -export const CommandMap = new Map(); +export const CommandMap = new Map(); Commands.forEach(cmd => { CommandMap.set(cmd.command, cmd); cmd.aliases.forEach(alias => { @@ -1189,15 +1182,15 @@ Commands.forEach(cmd => { }); }); -export function parseCommandString(input: string) { +export function parseCommandString(input: string): { cmd?: string, args?: string } { // trim any trailing whitespace, as it can confuse the parser for // IRC-style commands input = input.replace(/\s+$/, ''); if (input[0] !== '/') return {}; // not a command const bits = input.match(/^(\S+?)(?:[ \n]+((.|\n)*))?$/); - let cmd; - let args; + let cmd: string; + let args: string; if (bits) { cmd = bits[1].substring(1).toLowerCase(); args = bits[2]; @@ -1208,6 +1201,11 @@ export function parseCommandString(input: string) { return { cmd, args }; } +interface ICmd { + cmd?: Command; + args?: string; +} + /** * Process the given text for /commands and return a bound method to perform them. * @param {string} roomId The room in which the command was performed. @@ -1216,7 +1214,7 @@ export function parseCommandString(input: string) { * processing the command, or 'promise' if a request was sent out. * Returns null if the input didn't match a command. */ -export function getCommand(input: string) { +export function getCommand(input: string): ICmd { const { cmd, args } = parseCommandString(input); if (CommandMap.has(cmd) && CommandMap.get(cmd).isEnabled()) { diff --git a/src/Terms.ts b/src/Terms.ts index 3859cc1c73..351d1c0951 100644 --- a/src/Terms.ts +++ b/src/Terms.ts @@ -15,6 +15,7 @@ limitations under the License. */ import classNames from 'classnames'; +import { SERVICE_TYPES } from 'matrix-js-sdk/src/service-types'; import { MatrixClientPeg } from './MatrixClientPeg'; import * as sdk from '.'; @@ -32,7 +33,7 @@ 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(public serviceType: string, public baseUrl: string, public accessToken: string) { + constructor(public serviceType: SERVICE_TYPES, public baseUrl: string, public accessToken: string) { } } @@ -48,13 +49,13 @@ export interface Policy { } export type Policies = { - [policy: string]: Policy, + [policy: string]: Policy; }; export type TermsInteractionCallback = ( policiesAndServicePairs: { - service: Service, - policies: Policies, + service: Service; + policies: Policies; }[], agreedUrls: string[], extraClassNames?: string, @@ -180,14 +181,15 @@ export async function startTermsFlow( export function dialogTermsInteractionCallback( policiesAndServicePairs: { - service: Service, - policies: { [policy: string]: Policy }, + service: Service; + policies: { [policy: string]: Policy }; }[], agreedUrls: string[], extraClassNames?: string, ): Promise { return new Promise((resolve, reject) => { console.log("Terms that need agreement", policiesAndServicePairs); + // FIXME: Using an import will result in test failures const TermsDialog = sdk.getComponent("views.dialogs.TermsDialog"); Modal.createTrackedDialog('Terms of Service', '', TermsDialog, { diff --git a/src/accessibility/KeyboardShortcuts.tsx b/src/accessibility/KeyboardShortcuts.tsx index 25c41f9db5..c5cf85facd 100644 --- a/src/accessibility/KeyboardShortcuts.tsx +++ b/src/accessibility/KeyboardShortcuts.tsx @@ -17,10 +17,10 @@ limitations under the License. import * as React from "react"; import classNames from "classnames"; -import * as sdk from "../index"; import Modal from "../Modal"; import { _t, _td } from "../languageHandler"; import { isMac, Key } from "../Keyboard"; +import InfoDialog from "../components/views/dialogs/InfoDialog"; // TS: once languageHandler is TS we can probably inline this into the enum _td("Navigation"); @@ -375,7 +375,6 @@ export const toggleDialog = () => {
; }); - const InfoDialog = sdk.getComponent('dialogs.InfoDialog'); activeModal = Modal.createTrackedDialog("Keyboard Shortcuts", "", InfoDialog, { className: "mx_KeyboardShortcutsDialog", title: _t("Keyboard Shortcuts"), diff --git a/src/actions/RoomListActions.ts b/src/actions/RoomListActions.ts index 81e05f8678..a7f629c40d 100644 --- a/src/actions/RoomListActions.ts +++ b/src/actions/RoomListActions.ts @@ -19,13 +19,13 @@ import { asyncAction } from './actionCreators'; import Modal from '../Modal'; import * as Rooms from '../Rooms'; import { _t } from '../languageHandler'; -import * as sdk from '../index'; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { AsyncActionPayload } from "../dispatcher/payloads"; import RoomListStore from "../stores/room-list/RoomListStore"; import { SortAlgorithm } from "../stores/room-list/algorithms/models"; import { DefaultTagID } from "../stores/room-list/models"; +import ErrorDialog from '../components/views/dialogs/ErrorDialog'; export default class RoomListActions { /** @@ -88,7 +88,6 @@ export default class RoomListActions { return Rooms.guessAndSetDMRoom( room, newTag === DefaultTagID.DM, ).catch((err) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to set direct chat tag " + err); Modal.createTrackedDialog('Failed to set direct chat tag', '', ErrorDialog, { title: _t('Failed to set direct chat tag'), @@ -109,7 +108,6 @@ export default class RoomListActions { const promiseToDelete = matrixClient.deleteRoomTag( roomId, oldTag, ).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to remove tag " + oldTag + " from room: " + err); Modal.createTrackedDialog('Failed to remove tag from room', '', ErrorDialog, { title: _t('Failed to remove tag %(tagName)s from room', { tagName: oldTag }), @@ -129,7 +127,6 @@ export default class RoomListActions { metaData = metaData || {}; const promiseToAdd = matrixClient.setRoomTag(roomId, newTag, metaData).catch(function(err) { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Failed to add tag " + newTag + " to room: " + err); Modal.createTrackedDialog('Failed to add tag to room', '', ErrorDialog, { title: _t('Failed to add tag %(tagName)s to room', { tagName: newTag }), diff --git a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx index 76c3373ba4..c5c8022346 100644 --- a/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx +++ b/src/async-components/views/dialogs/eventindex/ManageEventIndexDialog.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../../index'; import { _t } from '../../../../languageHandler'; import SdkConfig from '../../../../SdkConfig'; import SettingsStore from "../../../../settings/SettingsStore"; @@ -24,6 +23,9 @@ import Modal from '../../../../Modal'; import { formatBytes, formatCountLong } from "../../../../utils/FormattingUtils"; import EventIndexPeg from "../../../../indexing/EventIndexPeg"; import { SettingLevel } from "../../../../settings/SettingLevel"; +import Field from '../../../../components/views/elements/Field'; +import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import DialogButtons from "../../../../components/views/elements/DialogButtons"; interface IProps { onFinished: (confirmed: boolean) => void; @@ -145,7 +147,6 @@ export default class ManageEventIndexDialog extends React.Component
); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( { - const BaseAvatar = sdk.getComponent('views.avatars.BaseAvatar'); - // Disable autocompletions when composing commands because of various issues // (see https://github.com/vector-im/element-web/issues/4762) if (/^(\/join|\/leave)/.test(query)) { diff --git a/src/autocomplete/NotifProvider.tsx b/src/autocomplete/NotifProvider.tsx index 1d42915ec9..31b834ccfe 100644 --- a/src/autocomplete/NotifProvider.tsx +++ b/src/autocomplete/NotifProvider.tsx @@ -21,8 +21,8 @@ import AutocompleteProvider from './AutocompleteProvider'; import { _t } from '../languageHandler'; import { MatrixClientPeg } from '../MatrixClientPeg'; import { PillCompletion } from './Components'; -import * as sdk from '../index'; import { ICompletion, ISelectionRange } from "./Autocompleter"; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; const AT_ROOM_REGEX = /@\S*/g; @@ -40,8 +40,6 @@ export default class NotifProvider extends AutocompleteProvider { force = false, limit = -1, ): Promise { - const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); - const client = MatrixClientPeg.get(); if (!this.room.currentState.mayTriggerNotifOfType('room', client.credentials.userId)) return []; diff --git a/src/autocomplete/UserProvider.tsx b/src/autocomplete/UserProvider.tsx index 470e018e22..d8f17c54d0 100644 --- a/src/autocomplete/UserProvider.tsx +++ b/src/autocomplete/UserProvider.tsx @@ -21,7 +21,6 @@ import React from 'react'; import { _t } from '../languageHandler'; import AutocompleteProvider from './AutocompleteProvider'; import { PillCompletion } from './Components'; -import * as sdk from '../index'; import QueryMatcher from './QueryMatcher'; import { sortBy } from 'lodash'; import { MatrixClientPeg } from '../MatrixClientPeg'; @@ -33,6 +32,7 @@ import { RoomState } from "matrix-js-sdk/src/models/room-state"; import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; import { makeUserPermalink } from "../utils/permalinks/Permalinks"; import { ICompletion, ISelectionRange } from "./Autocompleter"; +import MemberAvatar from '../components/views/avatars/MemberAvatar'; const USER_REGEX = /\B@\S*/g; @@ -108,8 +108,6 @@ export default class UserProvider extends AutocompleteProvider { force = false, limit = -1, ): Promise { - const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); - // lazy-load user list into matcher if (!this.users) this._makeUsers(); diff --git a/src/components/structures/AutoHideScrollbar.tsx b/src/components/structures/AutoHideScrollbar.tsx index e8a9872b48..184d883dda 100644 --- a/src/components/structures/AutoHideScrollbar.tsx +++ b/src/components/structures/AutoHideScrollbar.tsx @@ -21,8 +21,8 @@ interface IProps extends Omit, "onScroll"> { className?: string; onScroll?: (event: Event) => void; onWheel?: (event: WheelEvent) => void; - style?: React.CSSProperties - tabIndex?: number, + style?: React.CSSProperties; + tabIndex?: number; wrappedRef?: (ref: HTMLDivElement) => void; } diff --git a/src/components/structures/FilePanel.tsx b/src/components/structures/FilePanel.tsx index 21ef0c4f31..36f774a130 100644 --- a/src/components/structures/FilePanel.tsx +++ b/src/components/structures/FilePanel.tsx @@ -19,11 +19,11 @@ import React from 'react'; import { Filter } from 'matrix-js-sdk/src/filter'; import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; +import { Direction } from "matrix-js-sdk/src/models/event-timeline"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from 'matrix-js-sdk/src/models/room'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; -import * as sdk from '../../index'; import { MatrixClientPeg } from '../../MatrixClientPeg'; import EventIndexPeg from "../../indexing/EventIndexPeg"; import { _t } from '../../languageHandler'; @@ -33,11 +33,14 @@ import DesktopBuildsNotice, { WarningKind } from "../views/elements/DesktopBuild import { replaceableComponent } from "../../utils/replaceableComponent"; import ResizeNotifier from '../../utils/ResizeNotifier'; +import TimelinePanel from "./TimelinePanel"; +import Spinner from "../views/elements/Spinner"; +import { TileShape } from '../views/rooms/EventTile'; interface IProps { roomId: string; onClose: () => void; - resizeNotifier: ResizeNotifier + resizeNotifier: ResizeNotifier; } interface IState { @@ -129,7 +132,7 @@ class FilePanel extends React.Component { } } - public async fetchFileEventsServer(room: Room): Promise { + public async fetchFileEventsServer(room: Room): Promise { const client = MatrixClientPeg.get(); const filter = new Filter(client.credentials.userId); @@ -153,7 +156,11 @@ class FilePanel extends React.Component { return timelineSet; } - private onPaginationRequest = (timelineWindow: TimelineWindow, direction: string, limit: number): void => { + private onPaginationRequest = ( + timelineWindow: TimelineWindow, + direction: Direction, + limit: number, + ): Promise => { const client = MatrixClientPeg.get(); const eventIndex = EventIndexPeg.get(); const roomId = this.props.roomId; @@ -232,8 +239,6 @@ class FilePanel extends React.Component { } // wrap a TimelinePanel with the jump-to-event bits turned off. - const TimelinePanel = sdk.getComponent("structures.TimelinePanel"); - const Loader = sdk.getComponent("elements.Spinner"); const emptyState = (

{_t('No files visible in this room')}

@@ -259,7 +264,7 @@ class FilePanel extends React.Component { timelineSet={this.state.timelineSet} showUrlPreview = {false} onPaginationRequest={this.onPaginationRequest} - tileShape="file_grid" + tileShape={TileShape.FileGrid} resizeNotifier={this.props.resizeNotifier} empty={emptyState} /> @@ -272,7 +277,7 @@ class FilePanel extends React.Component { onClose={this.props.onClose} previousPhase={RightPanelPhases.RoomSummary} > - + ); } diff --git a/src/components/structures/GroupView.js b/src/components/structures/GroupView.js index 93c44c4e50..f31f302b29 100644 --- a/src/components/structures/GroupView.js +++ b/src/components/structures/GroupView.js @@ -36,7 +36,7 @@ import FlairStore from '../../stores/FlairStore'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import { makeGroupPermalink, makeUserPermalink } from "../../utils/permalinks/Permalinks"; import { Group } from "matrix-js-sdk/src/models/group"; -import { sleep } from "../../utils/promise"; +import { sleep } from "matrix-js-sdk/src/utils"; import RightPanelStore from "../../stores/RightPanelStore"; import AutoHideScrollbar from "./AutoHideScrollbar"; import { mediaFromMxc } from "../../customisations/Media"; diff --git a/src/components/structures/HomePage.tsx b/src/components/structures/HomePage.tsx index 046e07f455..4ed160d493 100644 --- a/src/components/structures/HomePage.tsx +++ b/src/components/structures/HomePage.tsx @@ -96,6 +96,7 @@ const HomePage: React.FC = ({ justRegistered = false }) => { const pageUrl = getHomePageUrl(config); if (pageUrl) { + // FIXME: Using an import will result in wrench-element-tests failures const EmbeddedPage = sdk.getComponent('structures.EmbeddedPage'); return ; } diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 26bb0fe24a..89fa8db376 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -24,7 +24,6 @@ import { Key } from '../../Keyboard'; import PageTypes from '../../PageTypes'; import MediaDeviceHandler from '../../MediaDeviceHandler'; import { fixupColorFonts } from '../../utils/FontManager'; -import * as sdk from '../../index'; import dis from '../../dispatcher/dispatcher'; import { IMatrixClientCreds } from '../../MatrixClientPeg'; import SettingsStore from "../../settings/SettingsStore"; @@ -48,7 +47,7 @@ import { ViewRoomDeltaPayload } from "../../dispatcher/payloads/ViewRoomDeltaPay import RoomListStore from "../../stores/room-list/RoomListStore"; import NonUrgentToastContainer from "./NonUrgentToastContainer"; import { ToggleRightPanelPayload } from "../../dispatcher/payloads/ToggleRightPanelPayload"; -import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import Modal from "../../Modal"; import { ICollapseConfig } from "../../resizer/distributors/collapse"; import HostSignupContainer from '../views/host_signup/HostSignupContainer'; @@ -59,6 +58,11 @@ import { replaceableComponent } from "../../utils/replaceableComponent"; import CallHandler, { CallHandlerEvent } from '../../CallHandler'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall'; +import RoomView from './RoomView'; +import ToastContainer from './ToastContainer'; +import MyGroups from "./MyGroups"; +import UserView from "./UserView"; +import GroupView from "./GroupView"; // We need to fetch each pinned message individually (if we don't already have it) // so each pinned message may trigger a request. Limit the number per room for sanity. @@ -78,17 +82,17 @@ interface IProps { hideToSRUsers: boolean; resizeNotifier: ResizeNotifier; // eslint-disable-next-line camelcase - page_type: string; - autoJoin: boolean; + page_type?: string; + autoJoin?: boolean; threepidInvite?: IThreepidInvite; - roomOobData?: object; + roomOobData?: IOOBData; currentRoomId: string; collapseLhs: boolean; config: { piwik: { policyUrl: string; - }, - [key: string]: any, + }; + [key: string]: any; }; currentUserId?: string; currentGroupId?: string; @@ -394,7 +398,7 @@ class LoggedInView extends React.Component { // refocusing during a paste event will make the // paste end up in the newly focused element, // so dispatch synchronously before paste happens - dis.fire(Action.FocusComposer, true); + dis.fire(Action.FocusSendMessageComposer, true); } }; @@ -548,7 +552,7 @@ class LoggedInView extends React.Component { if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { // synchronous dispatch so we focus before key generates input - dis.fire(Action.FocusComposer, true); + dis.fire(Action.FocusSendMessageComposer, true); ev.stopPropagation(); // we should *not* preventDefault() here as // that would prevent typing in the now-focussed composer @@ -567,12 +571,6 @@ class LoggedInView extends React.Component { }; render() { - const RoomView = sdk.getComponent('structures.RoomView'); - const UserView = sdk.getComponent('structures.UserView'); - const GroupView = sdk.getComponent('structures.GroupView'); - const MyGroups = sdk.getComponent('structures.MyGroups'); - const ToastContainer = sdk.getComponent('structures.ToastContainer'); - let pageElement; switch (this.props.page_type) { diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index c1e0b8d7cb..d692b0fa7f 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -19,6 +19,8 @@ import { createClient } from "matrix-js-sdk/src/matrix"; import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils"; + // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss import 'focus-visible'; // what-input helps improve keyboard accessibility @@ -34,7 +36,6 @@ import dis from "../../dispatcher/dispatcher"; import Notifier from '../../Notifier'; import Modal from "../../Modal"; -import * as sdk from '../../index'; import { showRoomInviteDialog, showStartChatInviteDialog } from '../../RoomInvite'; import * as Rooms from '../../Rooms'; import linkifyMatrix from "../../linkify-matrix"; @@ -55,7 +56,6 @@ import DMRoomMap from '../../utils/DMRoomMap'; import ThemeWatcher from "../../settings/watchers/ThemeWatcher"; import { FontWatcher } from '../../settings/watchers/FontWatcher'; import { storeRoomAliasInCache } from '../../RoomAliasCache'; -import { defer, IDeferred, sleep } from "../../utils/promise"; import ToastStore from "../../stores/ToastStore"; import * as StorageManager from "../../utils/StorageManager"; import type LoggedInViewType from "./LoggedInView"; @@ -84,9 +84,27 @@ import RoomListStore from "../../stores/room-list/RoomListStore"; import { RoomUpdateCause } from "../../stores/room-list/models"; import defaultDispatcher from "../../dispatcher/dispatcher"; import SecurityCustomisations from "../../customisations/Security"; +import Spinner from "../views/elements/Spinner"; +import QuestionDialog from "../views/dialogs/QuestionDialog"; +import UserSettingsDialog from '../views/dialogs/UserSettingsDialog'; +import CreateGroupDialog from '../views/dialogs/CreateGroupDialog'; +import CreateRoomDialog from '../views/dialogs/CreateRoomDialog'; +import RoomDirectory from './RoomDirectory'; +import KeySignatureUploadFailedDialog from "../views/dialogs/KeySignatureUploadFailedDialog"; +import IncomingSasDialog from "../views/dialogs/IncomingSasDialog"; +import CompleteSecurity from "./auth/CompleteSecurity"; +import LoggedInView from './LoggedInView'; +import Welcome from "../views/auth/Welcome"; +import ForgotPassword from "./auth/ForgotPassword"; +import E2eSetup from "./auth/E2eSetup"; +import Registration from './auth/Registration'; +import Login from "./auth/Login"; +import ErrorBoundary from '../views/elements/ErrorBoundary'; +import VerificationRequestToast from '../views/toasts/VerificationRequestToast'; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import UIStore, { UI_EVENTS } from "../../stores/UIStore"; +import SoftLogout from './auth/SoftLogout'; /** constants for MatrixChat.state.view */ export enum Views { @@ -155,7 +173,12 @@ interface IRoomInfo { /* eslint-enable camelcase */ interface IProps { // TODO type things better - config: Record; + config: { + piwik: { + policyUrl: string; + }; + [key: string]: any; + }; serverConfig?: ValidatedServerConfig; onNewScreen: (screen: string, replaceLast: boolean) => void; enableGuest?: boolean; @@ -203,7 +226,7 @@ interface IState { resizeNotifier: ResizeNotifier; serverConfig?: ValidatedServerConfig; ready: boolean; - threepidInvite?: IThreepidInvite, + threepidInvite?: IThreepidInvite; roomOobData?: object; pendingInitialSync?: boolean; justRegistered?: boolean; @@ -420,7 +443,7 @@ export default class MatrixChat extends React.PureComponent { CountlyAnalytics.instance.trackPageChange(durationMs); } if (this.focusComposer) { - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.focusComposer = false; } } @@ -518,7 +541,6 @@ export default class MatrixChat extends React.PureComponent { onAction = (payload) => { // console.log(`MatrixClientPeg.onAction: ${payload.action}`); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // Start the onboarding process for certain actions if (MatrixClientPeg.get() && MatrixClientPeg.get().isGuest() && @@ -612,8 +634,7 @@ export default class MatrixChat extends React.PureComponent { onFinished: (confirm) => { if (confirm) { // FIXME: controller shouldn't be loading a view :( - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); MatrixClientPeg.get().leave(payload.room_id).then(() => { modal.close(); @@ -649,7 +670,6 @@ export default class MatrixChat extends React.PureComponent { } case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; - const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); Modal.createTrackedDialog('User settings', '', UserSettingsDialog, { initialTabId: tabPayload.initialTabId }, /*className=*/null, /*isPriority=*/false, /*isStatic=*/true); @@ -662,11 +682,12 @@ export default class MatrixChat extends React.PureComponent { this.createRoom(payload.public, payload.defaultName); break; case 'view_create_group': { - let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog"); - if (SettingsStore.getValue("feature_communities_v2_prototypes")) { - CreateGroupDialog = CreateCommunityPrototypeDialog; - } - Modal.createTrackedDialog('Create Community', '', CreateGroupDialog); + const prototype = SettingsStore.getValue("feature_communities_v2_prototypes"); + Modal.createTrackedDialog( + 'Create Community', + '', + prototype ? CreateCommunityPrototypeDialog : CreateGroupDialog, + ); break; } case Action.ViewRoomDirectory: { @@ -676,7 +697,6 @@ export default class MatrixChat extends React.PureComponent { room_id: SpaceStore.instance.activeSpace.roomId, }); } else { - const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); Modal.createTrackedDialog('Room directory', '', RoomDirectory, { initialText: payload.initialText, }, 'mx_RoomDirectory_dialogWrapper', false, true); @@ -1018,7 +1038,6 @@ export default class MatrixChat extends React.PureComponent { } } - const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic, defaultName, @@ -1115,7 +1134,6 @@ export default class MatrixChat extends React.PureComponent { } private leaveRoom(roomId: string) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const warnings = this.leaveRoomWarnings(roomId); @@ -1142,8 +1160,7 @@ export default class MatrixChat extends React.PureComponent { const d = leaveRoomBehaviour(roomId); // FIXME: controller shouldn't be loading a view :( - const Loader = sdk.getComponent("elements.Spinner"); - const modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + const modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); d.finally(() => modal.close()); dis.dispatch({ @@ -1410,7 +1427,7 @@ export default class MatrixChat extends React.PureComponent { showNotificationsToast(false); } - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.setState({ ready: true, }); @@ -1438,7 +1455,6 @@ export default class MatrixChat extends React.PureComponent { }); }); cli.on('no_consent', function(message, consentUri) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('No Consent Dialog', '', QuestionDialog, { title: _t('Terms and Conditions'), description:
@@ -1547,8 +1563,6 @@ export default class MatrixChat extends React.PureComponent { }); cli.on("crypto.keySignatureUploadFailure", (failures, source, continuation) => { - const KeySignatureUploadFailedDialog = - sdk.getComponent('views.dialogs.KeySignatureUploadFailedDialog'); Modal.createTrackedDialog( 'Failed to upload key signatures', 'Failed to upload key signatures', @@ -1558,7 +1572,6 @@ export default class MatrixChat extends React.PureComponent { cli.on("crypto.verification.request", request => { if (request.verifier) { - const IncomingSasDialog = sdk.getComponent("views.dialogs.IncomingSasDialog"); Modal.createTrackedDialog('Incoming Verification', '', IncomingSasDialog, { verifier: request.verifier, }, null, /* priority = */ false, /* static = */ true); @@ -1568,7 +1581,7 @@ export default class MatrixChat extends React.PureComponent { title: _t("Verification requested"), icon: "verification", props: { request }, - component: sdk.getComponent("toasts.VerificationRequestToast"), + component: VerificationRequestToast, priority: 90, }); } @@ -1976,21 +1989,18 @@ export default class MatrixChat extends React.PureComponent { let view = null; if (this.state.view === Views.LOADING) { - const Spinner = sdk.getComponent('elements.Spinner'); view = (
); } else if (this.state.view === Views.COMPLETE_SECURITY) { - const CompleteSecurity = sdk.getComponent('structures.auth.CompleteSecurity'); view = ( ); } else if (this.state.view === Views.E2E_SETUP) { - const E2eSetup = sdk.getComponent('structures.auth.E2eSetup'); view = ( { * we should go through and figure out what we actually need to pass down, as well * as using something like redux to avoid having a billion bits of state kicking around. */ - const LoggedInView = sdk.getComponent('structures.LoggedInView'); view = ( { ref={this.loggedInView} matrixClient={MatrixClientPeg.get()} onRoomCreated={this.onRoomCreated} - onCloseAllSettings={this.onCloseAllSettings} onRegistered={this.onRegistered} currentRoomId={this.state.currentRoomId} /> ); } else { // we think we are logged in, but are still waiting for the /sync to complete - const Spinner = sdk.getComponent('elements.Spinner'); let errorBox; if (this.state.syncError && !isStoreError) { errorBox =
@@ -2044,10 +2051,8 @@ export default class MatrixChat extends React.PureComponent { ); } } else if (this.state.view === Views.WELCOME) { - const Welcome = sdk.getComponent('auth.Welcome'); view = ; } else if (this.state.view === Views.REGISTER && SettingsStore.getValue(UIFeature.Registration)) { - const Registration = sdk.getComponent('structures.auth.Registration'); const email = ThreepidInviteStore.instance.pickBestInvite()?.toEmail; view = ( { /> ); } else if (this.state.view === Views.FORGOT_PASSWORD && SettingsStore.getValue(UIFeature.PasswordReset)) { - const ForgotPassword = sdk.getComponent('structures.auth.ForgotPassword'); view = ( { ); } else if (this.state.view === Views.LOGIN) { const showPasswordReset = SettingsStore.getValue(UIFeature.PasswordReset); - const Login = sdk.getComponent('structures.auth.Login'); view = ( { /> ); } else if (this.state.view === Views.SOFT_LOGOUT) { - const SoftLogout = sdk.getComponent('structures.auth.SoftLogout'); view = ( { console.error(`Unknown view ${this.state.view}`); } - const ErrorBoundary = sdk.getComponent('elements.ErrorBoundary'); return {view} ; diff --git a/src/components/structures/NonUrgentToastContainer.tsx b/src/components/structures/NonUrgentToastContainer.tsx index b1424a2974..a2d419b4ba 100644 --- a/src/components/structures/NonUrgentToastContainer.tsx +++ b/src/components/structures/NonUrgentToastContainer.tsx @@ -24,7 +24,7 @@ interface IProps { } interface IState { - toasts: ComponentClass[], + toasts: ComponentClass[]; } @replaceableComponent("structures.NonUrgentToastContainer") diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index c608f0eee9..63027ab627 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -23,7 +23,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import dis from '../../dispatcher/dispatcher'; -import RateLimitedFunc from '../../ratelimitedfunc'; import GroupStore from '../../stores/GroupStore'; import { RIGHT_PANEL_PHASES_NO_ARGS, @@ -48,6 +47,7 @@ import FilePanel from "./FilePanel"; import NotificationPanel from "./NotificationPanel"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; +import { throttle } from 'lodash'; interface IProps { room?: Room; // if showing panels for a given room, this is set @@ -73,7 +73,6 @@ interface IState { export default class RightPanel extends React.Component { static contextType = MatrixClientContext; - private readonly delayedUpdate: RateLimitedFunc; private dispatcherRef: string; constructor(props, context) { @@ -84,12 +83,12 @@ export default class RightPanel extends React.Component { isUserPrivilegedInGroup: null, member: this.getUserForPanel(), }; - - this.delayedUpdate = new RateLimitedFunc(() => { - this.forceUpdate(); - }, 500); } + private readonly delayedUpdate = throttle((): void => { + this.forceUpdate(); + }, 500, { leading: true, trailing: true }); + // Helper function to split out the logic for getPhaseFromProps() and the constructor // as both are called at the same time in the constructor. private getUserForPanel() { diff --git a/src/components/structures/RoomDirectory.tsx b/src/components/structures/RoomDirectory.tsx index 2ac990436f..bd25a764a0 100644 --- a/src/components/structures/RoomDirectory.tsx +++ b/src/components/structures/RoomDirectory.tsx @@ -48,6 +48,9 @@ import { ActionPayload } from "../../dispatcher/payloads"; const MAX_NAME_LENGTH = 80; const MAX_TOPIC_LENGTH = 800; +const LAST_SERVER_KEY = "mx_last_room_directory_server"; +const LAST_INSTANCE_KEY = "mx_last_room_directory_instance"; + function track(action: string) { Analytics.trackEvent('RoomDirectory', action); } @@ -61,7 +64,7 @@ interface IState { loading: boolean; protocolsLoading: boolean; error?: string; - instanceId: string | symbol; + instanceId: string; roomServer: string; filterString: string; selectedCommunityId?: string; @@ -116,6 +119,36 @@ export default class RoomDirectory extends React.Component { } else if (!selectedCommunityId) { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { this.protocols = response; + const myHomeserver = MatrixClientPeg.getHomeserverName(); + const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY); + const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY); + + let roomServer = myHomeserver; + if ( + SdkConfig.get().roomDirectory?.servers?.includes(lsRoomServer) || + SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer) + ) { + roomServer = lsRoomServer; + } + + let instanceId: string = null; + if (roomServer === myHomeserver && ( + lsInstanceId === ALL_ROOMS || + Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId)) + )) { + instanceId = lsInstanceId; + } + + // Refresh the room list only if validation failed and we had to change these + if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) { + this.setState({ + protocolsLoading: false, + instanceId, + roomServer, + }); + this.refreshRoomList(); + return; + } this.setState({ protocolsLoading: false }); }, (err) => { console.warn(`error loading third party protocols: ${err}`); @@ -150,8 +183,8 @@ export default class RoomDirectory extends React.Component { publicRooms: [], loading: true, error: null, - instanceId: undefined, - roomServer: MatrixClientPeg.getHomeserverName(), + instanceId: localStorage.getItem(LAST_INSTANCE_KEY), + roomServer: localStorage.getItem(LAST_SERVER_KEY), filterString: this.props.initialText || "", selectedCommunityId, communityName: null, @@ -342,7 +375,7 @@ export default class RoomDirectory extends React.Component { } }; - private onOptionChange = (server: string, instanceId?: string | symbol) => { + private onOptionChange = (server: string, instanceId?: string) => { // clear next batch so we don't try to load more rooms this.nextBatch = null; this.setState({ @@ -360,6 +393,14 @@ export default class RoomDirectory extends React.Component { // find the five gitter ones, at which point we do not want // to render all those rooms when switching back to 'all networks'. // Easiest to just blow away the state & re-fetch. + + // We have to be careful here so that we don't set instanceId = "undefined" + localStorage.setItem(LAST_SERVER_KEY, server); + if (instanceId) { + localStorage.setItem(LAST_INSTANCE_KEY, instanceId); + } else { + localStorage.removeItem(LAST_INSTANCE_KEY); + } }; private onFillRequest = (backwards: boolean) => { @@ -370,7 +411,7 @@ export default class RoomDirectory extends React.Component { private onFilterChange = (alias: string) => { this.setState({ - filterString: alias || null, + filterString: alias || "", }); // don't send the request for a little bit, @@ -389,7 +430,7 @@ export default class RoomDirectory extends React.Component { private onFilterClear = () => { // update immediately this.setState({ - filterString: null, + filterString: "", }, this.refreshRoomList); if (this.filterTimeout) { diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 9cdd1efe7e..e8080b4f7b 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -131,7 +131,7 @@ export default class RoomSearch extends React.PureComponent { switch (action) { case RoomListAction.ClearSearch: this.clearInput(); - defaultDispatcher.fire(Action.FocusComposer); + defaultDispatcher.fire(Action.FocusSendMessageComposer); break; case RoomListAction.NextRoom: case RoomListAction.PrevRoom: diff --git a/src/components/structures/RoomStatusBar.js b/src/components/structures/RoomStatusBar.js index f6e42a4f9c..80ea26c3f2 100644 --- a/src/components/structures/RoomStatusBar.js +++ b/src/components/structures/RoomStatusBar.js @@ -118,12 +118,12 @@ export default class RoomStatusBar extends React.PureComponent { this.setState({ isResending: false }); }); this.setState({ isResending: true }); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); }; _onCancelAllClick = () => { Resend.cancelUnsentEvents(this.props.room); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); }; _onRoomLocalEchoUpdated = (event, room, oldEventId, oldStatus) => { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 12425bacf6..010f20c234 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -34,16 +34,14 @@ import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; -import * as sdk from '../../index'; import CallHandler, { PlaceCallType } from '../../CallHandler'; import dis from '../../dispatcher/dispatcher'; -import rateLimitedFunc from '../../ratelimitedfunc'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; import MainSplit from './MainSplit'; import RightPanel from './RightPanel'; import RoomViewStore from '../../stores/RoomViewStore'; -import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; +import RoomScrollStateStore, { ScrollState } from '../../stores/RoomScrollStateStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore'; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/Layout"; @@ -64,7 +62,7 @@ import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import AuxPanel from "../views/rooms/AuxPanel"; import RoomHeader from "../views/rooms/RoomHeader"; import { XOR } from "../../@types/common"; -import { IThreepidInvite } from "../../stores/ThreepidInviteStore"; +import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore"; import EffectsOverlay from "../views/elements/EffectsOverlay"; import { containsEmoji } from '../../effects/utils'; import { CHAT_EFFECTS } from '../../effects'; @@ -82,6 +80,15 @@ import { IOpts } from "../../createRoom"; import { replaceableComponent } from "../../utils/replaceableComponent"; import UIStore from "../../stores/UIStore"; import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import { throttle } from "lodash"; +import ErrorDialog from '../views/dialogs/ErrorDialog'; +import SearchResultTile from '../views/rooms/SearchResultTile'; +import Spinner from "../views/elements/Spinner"; +import UploadBar from './UploadBar'; +import RoomStatusBar from "./RoomStatusBar"; +import MessageComposer from '../views/rooms/MessageComposer'; +import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; +import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -94,22 +101,8 @@ if (DEBUG) { } interface IProps { - threepidInvite: IThreepidInvite, - - // Any data about the room that would normally come from the homeserver - // but has been passed out-of-band, eg. the room name and avatar URL - // from an email invite (a workaround for the fact that we can't - // get this information from the HS using an email invite). - // Fields: - // * name (string) The room's name - // * avatarUrl (string) The mxc:// avatar URL for the room - // * inviterName (string) The display name of the person who - // * invited us to the room - oobData?: { - name?: string; - avatarUrl?: string; - inviterName?: string; - }; + threepidInvite: IThreepidInvite; + oobData?: IOOBData; resizeNotifier: ResizeNotifier; justCreatedOpts?: IOpts; @@ -680,8 +673,8 @@ export default class RoomView extends React.Component { ); } - // cancel any pending calls to the rate_limited_funcs - this.updateRoomMembers.cancelPendingCall(); + // cancel any pending calls to the throttled updated + this.updateRoomMembers.cancel(); for (const watcher of this.settingWatchers) { SettingsStore.unwatchSetting(watcher); @@ -830,17 +823,16 @@ export default class RoomView extends React.Component { case Action.ComposerInsert: { // re-dispatch to the correct composer - if (this.state.editState) { - dis.dispatch({ - ...payload, - action: "edit_composer_insert", - }); - } else { - dis.dispatch({ - ...payload, - action: "send_composer_insert", - }); - } + dis.dispatch({ + ...payload, + action: this.state.editState ? "edit_composer_insert" : "send_composer_insert", + }); + break; + } + + case Action.FocusAComposer: { + // re-dispatch to the correct composer + dis.fire(this.state.editState ? Action.FocusEditMessageComposer : Action.FocusSendMessageComposer); break; } @@ -1059,11 +1051,6 @@ export default class RoomView extends React.Component { }); } - private updateTint() { - const room = this.state.room; - if (!room) return; - } - private onAccountData = (event: MatrixEvent) => { const type = event.getType(); if ((type === "org.matrix.preview_urls" || type === "im.vector.web.settings") && this.state.room) { @@ -1102,7 +1089,7 @@ export default class RoomView extends React.Component { return; } - this.updateRoomMembers(member); + this.updateRoomMembers(); }; private onMyMembership = (room: Room, membership: string, oldMembership: string) => { @@ -1124,10 +1111,10 @@ export default class RoomView extends React.Component { } // rate limited because a power level change will emit an event for every member in the room. - private updateRoomMembers = rateLimitedFunc(() => { + private updateRoomMembers = throttle(() => { this.updateDMState(); this.updateE2EStatus(this.state.room); - }, 500); + }, 500, { leading: true, trailing: true }); private checkDesktopNotifications() { const memberCount = this.state.room.getJoinedMemberCount() + this.state.room.getInvitedMemberCount(); @@ -1263,7 +1250,7 @@ export default class RoomView extends React.Component { ContentMessages.sharedInstance().sendContentListToRoom( ev.dataTransfer.files, this.state.room.roomId, this.context, ); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); this.setState({ draggingFile: false, @@ -1271,7 +1258,7 @@ export default class RoomView extends React.Component { }); }; - private injectSticker(url, info, text) { + private injectSticker(url: string, info: object, text: string) { if (this.context.isGuest()) { dis.dispatch({ action: 'require_registration' }); return; @@ -1352,7 +1339,6 @@ export default class RoomView extends React.Component { searchResults: results, }); }, (error) => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); console.error("Search failed", error); Modal.createTrackedDialog('Search failed', '', ErrorDialog, { title: _t("Search failed"), @@ -1368,9 +1354,6 @@ export default class RoomView extends React.Component { } private getSearchResultTiles() { - const SearchResultTile = sdk.getComponent('rooms.SearchResultTile'); - const Spinner = sdk.getComponent("elements.Spinner"); - // XXX: todo: merge overlapping results somehow? // XXX: why doesn't searching on name work? @@ -1470,13 +1453,6 @@ export default class RoomView extends React.Component { }); }; - private onLeaveClick = () => { - dis.dispatch({ - action: 'leave_room', - room_id: this.state.room.roomId, - }); - }; - private onForgetClick = () => { dis.dispatch({ action: 'forget_room', @@ -1497,7 +1473,6 @@ export default class RoomView extends React.Component { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, { title: _t("Failed to reject invite"), description: msg, @@ -1531,7 +1506,6 @@ export default class RoomView extends React.Component { console.error("Failed to reject invite: %s", error); const msg = error.message ? error.message : JSON.stringify(error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to reject invite', '', ErrorDialog, { title: _t("Failed to reject invite"), description: msg, @@ -1578,7 +1552,7 @@ export default class RoomView extends React.Component { } else { // Otherwise we have to jump manually this.messagePanel.jumpToLiveTimeline(); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); } }; @@ -1608,7 +1582,7 @@ export default class RoomView extends React.Component { // get the current scroll position of the room, so that it can be // restored when we switch back to it. // - private getScrollState() { + private getScrollState(): ScrollState { const messagePanel = this.messagePanel; if (!messagePanel) return null; @@ -1710,10 +1684,6 @@ export default class RoomView extends React.Component { // otherwise react calls it with null on each update. private gatherTimelinePanelRef = r => { this.messagePanel = r; - if (r) { - console.log("updateTint from RoomView.gatherTimelinePanelRef"); - this.updateTint(); - } }; private getOldRoom() { @@ -1869,10 +1839,8 @@ export default class RoomView extends React.Component { let isStatusAreaExpanded = true; if (ContentMessages.sharedInstance().getCurrentUploads().length > 0) { - const UploadBar = sdk.getComponent('structures.UploadBar'); statusBar = ; } else if (!this.state.searchResults) { - const RoomStatusBar = sdk.getComponent('structures.RoomStatusBar'); isStatusAreaExpanded = this.state.statusBarVisible; statusBar = { myMembership === 'join' && !this.state.searchResults ); if (canSpeak) { - const MessageComposer = sdk.getComponent('rooms.MessageComposer'); messageComposer = { let topUnreadMessagesBar = null; // Do not show TopUnreadMessagesBar if we have search results showing, it makes no sense if (this.state.showTopUnreadMessagesBar && !this.state.searchResults) { - const TopUnreadMessagesBar = sdk.getComponent('rooms.TopUnreadMessagesBar'); topUnreadMessagesBar = ( ); @@ -2077,7 +2041,6 @@ export default class RoomView extends React.Component { let jumpToBottom; // Do not show JumpToBottomButton if we have search results showing, it makes no sense if (!this.state.atEndOfLiveTimeline && !this.state.searchResults) { - const JumpToBottomButton = sdk.getComponent('rooms.JumpToBottomButton'); jumpToBottom = ( 0} numUnreadMessages={this.state.numUnreadMessages} @@ -2120,7 +2083,6 @@ export default class RoomView extends React.Component { onSearchClick={this.onSearchClick} onSettingsClick={this.onSettingsClick} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} - onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} e2eStatus={this.state.e2eStatus} onAppsClick={this.state.hasPinnedWidgets ? this.onAppsClick : null} appsShown={this.state.showApps} diff --git a/src/components/structures/SpaceRoomDirectory.tsx b/src/components/structures/SpaceRoomDirectory.tsx index e5c4372ab6..2ee0327420 100644 --- a/src/components/structures/SpaceRoomDirectory.tsx +++ b/src/components/structures/SpaceRoomDirectory.tsx @@ -58,7 +58,7 @@ export interface ISpaceSummaryRoom { avatar_url?: string; guest_can_join: boolean; name?: string; - num_joined_members: number + num_joined_members: number; room_id: string; topic?: string; world_readable: boolean; diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 3d77eaeac1..dcfde94811 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -18,9 +18,9 @@ limitations under the License. import * as React from "react"; import { _t } from '../../languageHandler'; -import * as sdk from "../../index"; import AutoHideScrollbar from './AutoHideScrollbar'; import { replaceableComponent } from "../../utils/replaceableComponent"; +import AccessibleButton from "../views/elements/AccessibleButton"; /** * Represents a tab for the TabbedView. @@ -82,8 +82,6 @@ export default class TabbedView extends React.Component { } private _renderTabLabel(tab: Tab) { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let classes = "mx_TabbedView_tabLabel "; const idx = this.props.tabs.indexOf(tab); diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 7968a6a558..259c29d804 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -16,11 +16,13 @@ limitations under the License. import React, { createRef, ReactNode, SyntheticEvent } from 'react'; import ReactDOM from "react-dom"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -import { TimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; -import { EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; +import { EventTimelineSet } from "matrix-js-sdk/src/models/event-timeline-set"; +import { Direction, EventTimeline } from "matrix-js-sdk/src/models/event-timeline"; import { TimelineWindow } from "matrix-js-sdk/src/timeline-window"; +import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event'; +import { SyncState } from 'matrix-js-sdk/src/sync.api'; import SettingsStore from "../../settings/SettingsStore"; import { Layout } from "../../settings/Layout"; @@ -30,7 +32,6 @@ import RoomContext from "../../contexts/RoomContext"; import UserActivity from "../../UserActivity"; import Modal from "../../Modal"; import dis from "../../dispatcher/dispatcher"; -import * as sdk from "../../index"; import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; @@ -39,14 +40,13 @@ import { UIFeature } from "../../settings/UIFeature"; import { replaceableComponent } from "../../utils/replaceableComponent"; import { arrayFastClone } from "../../utils/arrays"; import MessagePanel from "./MessagePanel"; -import { SyncState } from 'matrix-js-sdk/src/sync.api'; import { IScrollState } from "./ScrollPanel"; import { ActionPayload } from "../../dispatcher/payloads"; -import { EventType } from 'matrix-js-sdk/src/@types/event'; import ResizeNotifier from "../../utils/ResizeNotifier"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import Spinner from "../views/elements/Spinner"; import EditorStateTransfer from '../../utils/EditorStateTransfer'; +import ErrorDialog from '../views/dialogs/ErrorDialog'; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -65,7 +65,7 @@ interface IProps { // representing. This may or may not have a room, depending on what it's // a timeline representing. If it has a room, we maintain RRs etc for // that room. - timelineSet: TimelineSet; + timelineSet: EventTimelineSet; showReadReceipts?: boolean; // Enable managing RRs and RMs. These require the timelineSet to have a room. manageReadReceipts?: boolean; @@ -125,7 +125,7 @@ interface IProps { onReadMarkerUpdated?(): void; // callback which is called when we wish to paginate the timeline window. - onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise, + onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise; } interface IState { @@ -388,7 +388,7 @@ class TimelinePanel extends React.Component { private onPaginationRequest = ( timelineWindow: TimelineWindow, - direction: string, + direction: Direction, size: number, ): Promise => { if (this.props.onPaginationRequest) { @@ -579,7 +579,7 @@ class TimelinePanel extends React.Component { }); }; - private onRoomTimelineReset = (room: Room, timelineSet: TimelineSet): void => { + private onRoomTimelineReset = (room: Room, timelineSet: EventTimelineSet): void => { if (timelineSet !== this.props.timelineSet) return; if (this.messagePanel.current && this.messagePanel.current.isAtBottom()) { @@ -792,8 +792,8 @@ class TimelinePanel extends React.Component { // that sending an RR for the latest message will set our notif counter // to zero: it may not do this if we send an RR for somewhere before the end. if (this.isAtEndOfLiveTimeline()) { - this.props.timelineSet.room.setUnreadNotificationCount('total', 0); - this.props.timelineSet.room.setUnreadNotificationCount('highlight', 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Total, 0); + this.props.timelineSet.room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); dis.dispatch({ action: 'on_room_read', roomId: this.props.timelineSet.room.roomId, @@ -1096,7 +1096,6 @@ class TimelinePanel extends React.Component { console.error( `Error loading timeline panel at ${eventId}: ${error}`, ); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); let onFinished; @@ -1417,7 +1416,11 @@ class TimelinePanel extends React.Component { }); } - private getRelationsForEvent = (...args) => this.props.timelineSet.getRelationsForEvent(...args); + private getRelationsForEvent = ( + eventId: string, + relationType: RelationType, + eventType: EventType | string, + ) => this.props.timelineSet.getRelationsForEvent(eventId, relationType, eventType); render() { // just show a spinner while the timeline loads. diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index b8dce48235..c8e90a1c0a 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -26,6 +26,7 @@ import ProgressBar from "../views/elements/ProgressBar"; import AccessibleButton from "../views/elements/AccessibleButton"; import { IUpload } from "../../models/IUpload"; import { replaceableComponent } from "../../utils/replaceableComponent"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; interface IProps { room: Room; @@ -38,6 +39,8 @@ interface IState { @replaceableComponent("structures.UploadBar") export default class UploadBar extends React.Component { + static contextType = MatrixClientContext; + private dispatcherRef: string; private mounted: boolean; @@ -82,7 +85,7 @@ export default class UploadBar extends React.Component { private onCancelClick = (ev) => { ev.preventDefault(); - ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise); + ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload.promise, this.context); }; render() { diff --git a/src/components/structures/auth/CompleteSecurity.js b/src/components/structures/auth/CompleteSecurity.tsx similarity index 87% rename from src/components/structures/auth/CompleteSecurity.js rename to src/components/structures/auth/CompleteSecurity.tsx index d691f6034b..2f37e60450 100644 --- a/src/components/structures/auth/CompleteSecurity.js +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -15,39 +15,42 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; import SetupEncryptionBody from "./SetupEncryptionBody"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -@replaceableComponent("structures.auth.CompleteSecurity") -export default class CompleteSecurity extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: () => void; +} - constructor() { - super(); +interface IState { + phase: Phase; +} + +@replaceableComponent("structures.auth.CompleteSecurity") +export default class CompleteSecurity extends React.Component { + constructor(props: IProps) { + super(props); const store = SetupEncryptionStore.sharedInstance(); - store.on("update", this._onStoreUpdate); + store.on("update", this.onStoreUpdate); store.start(); this.state = { phase: store.phase }; } - _onStoreUpdate = () => { + private onStoreUpdate = (): void => { const store = SetupEncryptionStore.sharedInstance(); this.setState({ phase: store.phase }); }; - componentWillUnmount() { + public componentWillUnmount(): void { const store = SetupEncryptionStore.sharedInstance(); - store.off("update", this._onStoreUpdate); + store.off("update", this.onStoreUpdate); store.stop(); } - render() { + public render() { const AuthPage = sdk.getComponent("auth.AuthPage"); const CompleteSecurityBody = sdk.getComponent("auth.CompleteSecurityBody"); const { phase } = this.state; diff --git a/src/components/structures/auth/E2eSetup.js b/src/components/structures/auth/E2eSetup.tsx similarity index 84% rename from src/components/structures/auth/E2eSetup.js rename to src/components/structures/auth/E2eSetup.tsx index 9b627449bc..93cb92664f 100644 --- a/src/components/structures/auth/E2eSetup.js +++ b/src/components/structures/auth/E2eSetup.tsx @@ -15,20 +15,19 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import AuthPage from '../../views/auth/AuthPage'; import CompleteSecurityBody from '../../views/auth/CompleteSecurityBody'; import CreateCrossSigningDialog from '../../views/dialogs/security/CreateCrossSigningDialog'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -@replaceableComponent("structures.auth.E2eSetup") -export default class E2eSetup extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - accountPassword: PropTypes.string, - tokenLogin: PropTypes.bool, - }; +interface IProps { + onFinished: () => void; + accountPassword?: string; + tokenLogin?: boolean; +} +@replaceableComponent("structures.auth.E2eSetup") +export default class E2eSetup extends React.Component { render() { return ( diff --git a/src/components/structures/auth/ForgotPassword.js b/src/components/structures/auth/ForgotPassword.tsx similarity index 81% rename from src/components/structures/auth/ForgotPassword.js rename to src/components/structures/auth/ForgotPassword.tsx index 9f2ac9deed..6382e143f9 100644 --- a/src/components/structures/auth/ForgotPassword.js +++ b/src/components/structures/auth/ForgotPassword.tsx @@ -17,7 +17,6 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t, _td } from '../../../languageHandler'; import * as sdk from '../../../index'; import Modal from "../../../Modal"; @@ -31,27 +30,50 @@ import PassphraseField from '../../views/auth/PassphraseField'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { PASSWORD_MIN_SCORE } from '../../views/auth/RegistrationForm'; -// Phases -// Show the forgot password inputs -const PHASE_FORGOT = 1; -// Email is in the process of being sent -const PHASE_SENDING_EMAIL = 2; -// Email has been sent -const PHASE_EMAIL_SENT = 3; -// User has clicked the link in email and completed reset -const PHASE_DONE = 4; +import { IValidationResult } from "../../views/elements/Validation"; + +enum Phase { + // Show the forgot password inputs + Forgot = 1, + // Email is in the process of being sent + SendingEmail = 2, + // Email has been sent + EmailSent = 3, + // User has clicked the link in email and completed reset + Done = 4, +} + +interface IProps { + serverConfig: ValidatedServerConfig; + onServerConfigChange: (serverConfig: ValidatedServerConfig) => void; + onLoginClick?: () => void; + onComplete: () => void; +} + +interface IState { + phase: Phase; + email: string; + password: string; + password2: string; + errorText: string; + + // We perform liveliness checks later, but for now suppress the errors. + // We also track the server dead errors independently of the regular errors so + // that we can render it differently, and override any other error the user may + // be seeing. + serverIsAlive: boolean; + serverErrorIsFatal: boolean; + serverDeadError: string; + + passwordFieldValid: boolean; +} @replaceableComponent("structures.auth.ForgotPassword") -export default class ForgotPassword extends React.Component { - static propTypes = { - serverConfig: PropTypes.instanceOf(ValidatedServerConfig).isRequired, - onServerConfigChange: PropTypes.func.isRequired, - onLoginClick: PropTypes.func, - onComplete: PropTypes.func.isRequired, - }; +export default class ForgotPassword extends React.Component { + private reset: PasswordReset; state = { - phase: PHASE_FORGOT, + phase: Phase.Forgot, email: "", password: "", password2: "", @@ -64,30 +86,31 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, serverErrorIsFatal: false, serverDeadError: "", + passwordFieldValid: false, }; - constructor(props) { + constructor(props: IProps) { super(props); CountlyAnalytics.instance.track("onboarding_forgot_password_begin"); } - componentDidMount() { + public componentDidMount() { this.reset = null; - this._checkServerLiveliness(this.props.serverConfig); + this.checkServerLiveliness(this.props.serverConfig); } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase - UNSAFE_componentWillReceiveProps(newProps) { + public UNSAFE_componentWillReceiveProps(newProps: IProps): void { if (newProps.serverConfig.hsUrl === this.props.serverConfig.hsUrl && newProps.serverConfig.isUrl === this.props.serverConfig.isUrl) return; // Do a liveliness check on the new URLs - this._checkServerLiveliness(newProps.serverConfig); + this.checkServerLiveliness(newProps.serverConfig); } - async _checkServerLiveliness(serverConfig) { + private async checkServerLiveliness(serverConfig): Promise { try { await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( serverConfig.hsUrl, @@ -98,28 +121,28 @@ export default class ForgotPassword extends React.Component { serverIsAlive: true, }); } catch (e) { - this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password")); + this.setState(AutoDiscoveryUtils.authComponentStateForError(e, "forgot_password") as IState); } } - submitPasswordReset(email, password) { + public submitPasswordReset(email: string, password: string): void { this.setState({ - phase: PHASE_SENDING_EMAIL, + phase: Phase.SendingEmail, }); this.reset = new PasswordReset(this.props.serverConfig.hsUrl, this.props.serverConfig.isUrl); this.reset.resetPassword(email, password).then(() => { this.setState({ - phase: PHASE_EMAIL_SENT, + phase: Phase.EmailSent, }); }, (err) => { this.showErrorDialog(_t('Failed to send email') + ": " + err.message); this.setState({ - phase: PHASE_FORGOT, + phase: Phase.Forgot, }); }); } - onVerify = async ev => { + private onVerify = async (ev: React.MouseEvent): Promise => { ev.preventDefault(); if (!this.reset) { console.error("onVerify called before submitPasswordReset!"); @@ -127,17 +150,17 @@ export default class ForgotPassword extends React.Component { } try { await this.reset.checkEmailLinkClicked(); - this.setState({ phase: PHASE_DONE }); + this.setState({ phase: Phase.Done }); } catch (err) { this.showErrorDialog(err.message); } }; - onSubmitForm = async ev => { + private onSubmitForm = async (ev: React.FormEvent): Promise => { ev.preventDefault(); // refresh the server errors, just in case the server came back online - await this._checkServerLiveliness(this.props.serverConfig); + await this.checkServerLiveliness(this.props.serverConfig); await this['password_field'].validate({ allowEmpty: false }); @@ -172,27 +195,27 @@ export default class ForgotPassword extends React.Component { } }; - onInputChanged = (stateKey, ev) => { + private onInputChanged = (stateKey: string, ev: React.FormEvent) => { this.setState({ - [stateKey]: ev.target.value, - }); + [stateKey]: ev.currentTarget.value, + } as any); }; - onLoginClick = ev => { + private onLoginClick = (ev: React.MouseEvent): void => { ev.preventDefault(); ev.stopPropagation(); this.props.onLoginClick(); }; - showErrorDialog(body, title) { + public showErrorDialog(description: string, title?: string) { const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Forgot Password Error', '', ErrorDialog, { - title: title, - description: body, + title, + description, }); } - onPasswordValidate(result) { + private onPasswordValidate(result: IValidationResult) { this.setState({ passwordFieldValid: result.valid, }); @@ -316,16 +339,16 @@ export default class ForgotPassword extends React.Component { let resetPasswordJsx; switch (this.state.phase) { - case PHASE_FORGOT: + case Phase.Forgot: resetPasswordJsx = this.renderForgot(); break; - case PHASE_SENDING_EMAIL: + case Phase.SendingEmail: resetPasswordJsx = this.renderSendingEmail(); break; - case PHASE_EMAIL_SENT: + case Phase.EmailSent: resetPasswordJsx = this.renderEmailSent(); break; - case PHASE_DONE: + case Phase.Done: resetPasswordJsx = this.renderDone(); break; } diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 61d3759dee..9f12521a34 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -18,7 +18,6 @@ import React, { ReactNode } from 'react'; import { MatrixError } from "matrix-js-sdk/src/http-api"; import { _t, _td } from '../../../languageHandler'; -import * as sdk from '../../../index'; import Login, { ISSOFlow, LoginFlow } from '../../../Login'; import SdkConfig from '../../../SdkConfig'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; @@ -36,6 +35,8 @@ import Spinner from "../../views/elements/Spinner"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from "../../views/elements/ServerPicker"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; // These are used in several places, and come from the js-sdk's autodiscovery // stuff. We define them here so that they'll be picked up by i18n. @@ -541,8 +542,6 @@ export default class LoginComponent extends React.PureComponent }; render() { - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); const loader = this.isBusy() && !this.state.busyLoggingIn ?
: null; diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index f27bed2cc3..8d32981e57 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -18,19 +18,24 @@ import { createClient } from 'matrix-js-sdk/src/matrix'; import React, { ReactNode } from 'react'; import { MatrixClient } from "matrix-js-sdk/src/client"; -import * as sdk from '../../../index'; import { _t, _td } from '../../../languageHandler'; import { messageForResourceLimitError } from '../../../utils/ErrorUtils'; import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../../utils/AutoDiscoveryUtils"; import classNames from "classnames"; import * as Lifecycle from '../../../Lifecycle'; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; import Login, { ISSOFlow } from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from '../../views/elements/ServerPicker'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import RegistrationForm from '../../views/auth/RegistrationForm'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import AuthBody from "../../views/auth/AuthBody"; +import AuthHeader from "../../views/auth/AuthHeader"; +import InteractiveAuth from "../InteractiveAuth"; +import Spinner from "../../views/elements/Spinner"; interface IProps { serverConfig: ValidatedServerConfig; @@ -47,13 +52,7 @@ interface IProps { // - The user's password, if available and applicable (may be cached in memory // for a short time so the user is not required to re-enter their password // for operations like uploading cross-signing keys). - onLoggedIn(params: { - userId: string; - deviceId: string - homeserverUrl: string; - identityServerUrl?: string; - accessToken: string; - }, password: string): void; + onLoggedIn(params: IMatrixClientCreds, password: string): void; makeRegistrationUrl(params: { /* eslint-disable camelcase */ client_secret: string; @@ -246,7 +245,7 @@ export default class Registration extends React.Component { } } - private onFormSubmit = formVals => { + private onFormSubmit = async (formVals): Promise => { this.setState({ errorText: "", busy: true, @@ -442,10 +441,6 @@ export default class Registration extends React.Component { }; private renderRegisterComponent() { - const InteractiveAuth = sdk.getComponent('structures.InteractiveAuth'); - const Spinner = sdk.getComponent('elements.Spinner'); - const RegistrationForm = sdk.getComponent('auth.RegistrationForm'); - if (this.state.matrixClient && this.state.doingUIAuth) { return { } render() { - const AuthHeader = sdk.getComponent('auth.AuthHeader'); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let errorText; const err = this.state.errorText; if (err) { diff --git a/src/components/structures/auth/SetupEncryptionBody.js b/src/components/structures/auth/SetupEncryptionBody.tsx similarity index 81% rename from src/components/structures/auth/SetupEncryptionBody.js rename to src/components/structures/auth/SetupEncryptionBody.tsx index f0798b6d1a..c7ce74077b 100644 --- a/src/components/structures/auth/SetupEncryptionBody.js +++ b/src/components/structures/auth/SetupEncryptionBody.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. @@ -15,33 +15,43 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDialog'; -import * as sdk from '../../../index'; import { SetupEncryptionStore, Phase } from '../../../stores/SetupEncryptionStore'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; +import EncryptionPanel from "../../views/right_panel/EncryptionPanel"; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from '../../views/elements/Spinner'; +import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; +import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; -function keyHasPassphrase(keyInfo) { - return ( +function keyHasPassphrase(keyInfo: ISecretStorageKeyInfo): boolean { + return Boolean( keyInfo.passphrase && keyInfo.passphrase.salt && - keyInfo.passphrase.iterations + keyInfo.passphrase.iterations, ); } -@replaceableComponent("structures.auth.SetupEncryptionBody") -export default class SetupEncryptionBody extends React.Component { - static propTypes = { - onFinished: PropTypes.func.isRequired, - }; +interface IProps { + onFinished: (boolean) => void; +} - constructor() { - super(); +interface IState { + phase: Phase; + verificationRequest: VerificationRequest; + backupInfo: IKeyBackupInfo; +} + +@replaceableComponent("structures.auth.SetupEncryptionBody") +export default class SetupEncryptionBody extends React.Component { + constructor(props) { + super(props); const store = SetupEncryptionStore.sharedInstance(); - store.on("update", this._onStoreUpdate); + store.on("update", this.onStoreUpdate); store.start(); this.state = { phase: store.phase, @@ -53,10 +63,10 @@ export default class SetupEncryptionBody extends React.Component { }; } - _onStoreUpdate = () => { + private onStoreUpdate = () => { const store = SetupEncryptionStore.sharedInstance(); if (store.phase === Phase.Finished) { - this.props.onFinished(); + this.props.onFinished(true); return; } this.setState({ @@ -66,18 +76,18 @@ export default class SetupEncryptionBody extends React.Component { }); }; - componentWillUnmount() { + public componentWillUnmount() { const store = SetupEncryptionStore.sharedInstance(); - store.off("update", this._onStoreUpdate); + store.off("update", this.onStoreUpdate); store.stop(); } - _onUsePassphraseClick = async () => { + private onUsePassphraseClick = async () => { const store = SetupEncryptionStore.sharedInstance(); store.usePassPhrase(); - } + }; - _onVerifyClick = () => { + private onVerifyClick = () => { const cli = MatrixClientPeg.get(); const userId = cli.getUserId(); const requestPromise = cli.requestVerification(userId); @@ -91,42 +101,44 @@ export default class SetupEncryptionBody extends React.Component { request.cancel(); }, }); - } + }; - onSkipClick = () => { + private onSkipClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skip(); - } + }; - onSkipConfirmClick = () => { + private onSkipConfirmClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.skipConfirm(); - } + }; - onSkipBackClick = () => { + private onSkipBackClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.returnAfterSkip(); - } + }; - onDoneClick = () => { + private onDoneClick = () => { const store = SetupEncryptionStore.sharedInstance(); store.done(); - } + }; - render() { - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); + private onEncryptionPanelClose = () => { + this.props.onFinished(false); + }; + public render() { const { phase, } = this.state; if (this.state.verificationRequest) { - const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel"); return ; } else if (phase === Phase.Intro) { const store = SetupEncryptionStore.sharedInstance(); @@ -139,14 +151,14 @@ export default class SetupEncryptionBody extends React.Component { let useRecoveryKeyButton; if (recoveryKeyPrompt) { - useRecoveryKeyButton = + useRecoveryKeyButton = {recoveryKeyPrompt} ; } let verifyButton; if (store.hasDevicesToVerifyAgainst) { - verifyButton = + verifyButton = { _t("Use another login") } ; } @@ -217,7 +229,6 @@ export default class SetupEncryptionBody extends React.Component {
); } else if (phase === Phase.Busy || phase === Phase.Loading) { - const Spinner = sdk.getComponent('views.elements.Spinner'); return ; } else { console.log(`SetupEncryptionBody: Unknown phase ${phase}`); diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index 7fb60a7b5d..d232f55dd1 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -16,7 +16,6 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import * as Lifecycle from '../../../Lifecycle'; import Modal from '../../../Modal'; @@ -26,6 +25,12 @@ import AuthPage from "../../views/auth/AuthPage"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform"; import SSOButtons from "../../views/elements/SSOButtons"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmWipeDeviceDialog from '../../views/dialogs/ConfirmWipeDeviceDialog'; +import Field from '../../views/elements/Field'; +import AccessibleButton from '../../views/elements/AccessibleButton'; +import Spinner from "../../views/elements/Spinner"; +import AuthHeader from "../../views/auth/AuthHeader"; +import AuthBody from "../../views/auth/AuthBody"; const LOGIN_VIEW = { LOADING: 1, @@ -49,7 +54,7 @@ interface IProps { fragmentAfterLogin?: string; // Called when the SSO login completes - onTokenLoginCompleted: () => void, + onTokenLoginCompleted: () => void; } interface IState { @@ -94,7 +99,6 @@ export default class SoftLogout extends React.Component { } onClearAll = () => { - const ConfirmWipeDeviceDialog = sdk.getComponent('dialogs.ConfirmWipeDeviceDialog'); Modal.createTrackedDialog('Clear Data', 'Soft Logout', ConfirmWipeDeviceDialog, { onFinished: (wipeData) => { if (!wipeData) return; @@ -202,7 +206,6 @@ export default class SoftLogout extends React.Component { private renderSignInSection() { if (this.state.loginView === LOGIN_VIEW.LOADING) { - const Spinner = sdk.getComponent("elements.Spinner"); return ; } @@ -214,9 +217,6 @@ export default class SoftLogout extends React.Component { } if (this.state.loginView === LOGIN_VIEW.PASSWORD) { - const Field = sdk.getComponent("elements.Field"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let error = null; if (this.state.errorText) { error = {this.state.errorText}; @@ -286,10 +286,6 @@ export default class SoftLogout extends React.Component { } render() { - const AuthHeader = sdk.getComponent("auth.AuthHeader"); - const AuthBody = sdk.getComponent("auth.AuthBody"); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return ( diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx new file mode 100644 index 0000000000..66efa64658 --- /dev/null +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -0,0 +1,124 @@ +/* +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, { createRef, ReactNode, RefObject } from "react"; +import { UPDATE_EVENT } from "../../../stores/AsyncStore"; +import PlayPauseButton from "./PlayPauseButton"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { formatBytes } from "../../../utils/FormattingUtils"; +import DurationClock from "./DurationClock"; +import { Key } from "../../../Keyboard"; +import { _t } from "../../../languageHandler"; +import SeekBar from "./SeekBar"; +import PlaybackClock from "./PlaybackClock"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; + + mediaName: string; +} + +interface IState { + playbackPhase: PlaybackState; +} + +@replaceableComponent("views.audio_messages.AudioPlayer") +export default class AudioPlayer extends React.PureComponent { + private playPauseRef: RefObject = createRef(); + private seekRef: RefObject = createRef(); + + 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 }); + }; + + private onKeyDown = (ev: React.KeyboardEvent) => { + // stopPropagation() prevents the FocusComposer catch-all from triggering, + // but we need to do it on key down instead of press (even though the user + // interaction is typically on press). + if (ev.key === Key.SPACE) { + ev.stopPropagation(); + this.playPauseRef.current?.toggleState(); + } else if (ev.key === Key.ARROW_LEFT) { + ev.stopPropagation(); + this.seekRef.current?.left(); + } else if (ev.key === Key.ARROW_RIGHT) { + ev.stopPropagation(); + this.seekRef.current?.right(); + } + }; + + protected renderFileSize(): string { + const bytes = this.props.playback.sizeBytes; + if (!bytes) return null; + + // Not translated here - we're just presenting the data which should already + // be translated if needed. + return `(${formatBytes(bytes)})`; + } + + public render(): ReactNode { + // tabIndex=0 to ensure that the whole component becomes a tab stop, where we handle keyboard + // events for accessibility + return
+
+ +
+ + {this.props.mediaName || _t("Unnamed audio")} + +
+ +   {/* easiest way to introduce a gap between the components */} + { this.renderFileSize() } +
+
+
+
+ + +
+
; + } +} diff --git a/src/components/views/voice_messages/Clock.tsx b/src/components/views/audio_messages/Clock.tsx similarity index 96% rename from src/components/views/voice_messages/Clock.tsx rename to src/components/views/audio_messages/Clock.tsx index 1e78cc7bbd..7f387715f8 100644 --- a/src/components/views/voice_messages/Clock.tsx +++ b/src/components/views/audio_messages/Clock.tsx @@ -28,7 +28,7 @@ interface IState { * Simply converts seconds into minutes and seconds. Note that hours will not be * displayed, making it possible to see "82:29". */ -@replaceableComponent("views.voice_messages.Clock") +@replaceableComponent("views.audio_messages.Clock") export default class Clock extends React.Component { public constructor(props) { super(props); diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx new file mode 100644 index 0000000000..81852b5944 --- /dev/null +++ b/src/components/views/audio_messages/DurationClock.tsx @@ -0,0 +1,55 @@ +/* +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 } from "../../../voice/Playback"; + +interface IProps { + playback: Playback; +} + +interface IState { + durationSeconds: number; +} + +/** + * A clock which shows a clip's maximum duration. + */ +@replaceableComponent("views.audio_messages.DurationClock") +export default class DurationClock extends React.PureComponent { + public constructor(props) { + super(props); + + this.state = { + // 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, + }; + this.props.playback.clockInfo.liveData.onUpdate(this.onTimeUpdate); + } + + private onTimeUpdate = (time: number[]) => { + this.setState({ durationSeconds: time[1] }); + }; + + public render() { + return ; + } +} diff --git a/src/components/views/voice_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx similarity index 90% rename from src/components/views/voice_messages/LiveRecordingClock.tsx rename to src/components/views/audio_messages/LiveRecordingClock.tsx index 2a20e9bfec..a9dbd3c52f 100644 --- a/src/components/views/voice_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -1,9 +1,12 @@ /* 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. @@ -12,16 +15,13 @@ limitations under the License. */ import React from "react"; -import Clock from "./Clock"; +import { IRecordingUpdate, VoiceRecording } from "../../../voice/VoiceRecording"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Clock from "./Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; -import { - IRecordingUpdate, - VoiceRecording, -} from "../../../voice/VoiceRecording"; interface IProps { - recorder?: VoiceRecording; + recorder: VoiceRecording; } interface IState { @@ -31,7 +31,7 @@ interface IState { /** * A clock for a live recording. */ -@replaceableComponent("views.voice_messages.LiveRecordingClock") +@replaceableComponent("views.audio_messages.LiveRecordingClock") export default class LiveRecordingClock extends React.PureComponent { private seconds = 0; private scheduledUpdate = new MarkedExecution( diff --git a/src/components/views/voice_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx similarity index 66% rename from src/components/views/voice_messages/LiveRecordingWaveform.tsx rename to src/components/views/audio_messages/LiveRecordingWaveform.tsx index 1e3440fbad..b9c5f80f05 100644 --- a/src/components/views/voice_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -1,9 +1,12 @@ /* 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. @@ -12,16 +15,15 @@ limitations under the License. */ import React from "react"; -import Waveform from "./Waveform"; +import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording } from "../../../voice/VoiceRecording"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { arrayFastResample } from "../../../utils/arrays"; +import { percentageOf } from "../../../utils/numbers"; +import Waveform from "./Waveform"; import { MarkedExecution } from "../../../utils/MarkedExecution"; -import { - IRecordingUpdate, - VoiceRecording, -} from "../../../voice/VoiceRecording"; interface IProps { - recorder?: VoiceRecording; + recorder: VoiceRecording; } interface IState { @@ -31,7 +33,7 @@ interface IState { /** * A waveform which shows the waveform of a live recording */ -@replaceableComponent("views.voice_messages.LiveRecordingWaveform") +@replaceableComponent("views.audio_messages.LiveRecordingWaveform") export default class LiveRecordingWaveform extends React.PureComponent { public static defaultProps = { progress: 1, @@ -52,15 +54,18 @@ export default class LiveRecordingWaveform extends React.PureComponent { - this.waveform = update.waveform; + const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES); + // 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 + // waveform. This results in a slightly more cinematic/animated waveform for the + // user. + this.waveform = bars.map(b => percentageOf(b, 0, 0.50)); this.scheduledUpdate.mark(); }); } private updateWaveform() { - this.setState({ - waveform: this.waveform, - }); + this.setState({ waveform: this.waveform }); } public render() { diff --git a/src/components/views/voice_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx similarity index 76% rename from src/components/views/voice_messages/PlayPauseButton.tsx rename to src/components/views/audio_messages/PlayPauseButton.tsx index 1ca05a2cd0..a4f1e770f2 100644 --- a/src/components/views/voice_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -21,7 +21,8 @@ import { _t } from "../../../languageHandler"; import { Playback, PlaybackState } from "../../../voice/Playback"; import classNames from "classnames"; -interface IProps { +// omitted props are handled by render function +interface IProps extends Omit, "title" | "onClick" | "disabled"> { // Playback instance to manipulate. Cannot change during the component lifecycle. playback: Playback; @@ -33,19 +34,25 @@ interface IProps { * 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") +@replaceableComponent("views.audio_messages.PlayPauseButton") export default class PlayPauseButton extends React.PureComponent { public constructor(props) { super(props); } - private onClick = async () => { - await this.props.playback.toggle(); + private onClick = () => { + // noinspection JSIgnoredPromiseFromCall + this.toggleState(); }; + public async toggleState() { + await this.props.playback.toggle(); + } + public render(): ReactNode { - const isPlaying = this.props.playback.isPlaying; - const isDisabled = this.props.playbackPhase === PlaybackState.Decoding; + const { playback, playbackPhase, ...restProps } = this.props; + const isPlaying = playback.isPlaying; + const isDisabled = playbackPhase === PlaybackState.Decoding; const classes = classNames('mx_PlayPauseButton', { 'mx_PlayPauseButton_play': !isPlaying, 'mx_PlayPauseButton_pause': isPlaying, @@ -56,6 +63,7 @@ export default class PlayPauseButton extends React.PureComponent { title={isPlaying ? _t("Pause") : _t("Play")} onClick={this.onClick} disabled={isDisabled} + {...restProps} />; } } diff --git a/src/components/views/voice_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx similarity index 83% rename from src/components/views/voice_messages/PlaybackClock.tsx rename to src/components/views/audio_messages/PlaybackClock.tsx index 9c9298f764..374d47c31d 100644 --- a/src/components/views/voice_messages/PlaybackClock.tsx +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -22,6 +22,11 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; interface IProps { playback: Playback; + + // The default number of seconds to show when the playback has completed or + // has not started. Not used during playback, even when paused. Defaults to + // clip duration length. + defaultDisplaySeconds?: number; } interface IState { @@ -33,7 +38,7 @@ interface IState { /** * A clock for a playback of a recording. */ -@replaceableComponent("views.voice_messages.PlaybackClock") +@replaceableComponent("views.audio_messages.PlaybackClock") export default class PlaybackClock extends React.PureComponent { public constructor(props) { super(props); @@ -64,7 +69,11 @@ export default class PlaybackClock extends React.PureComponent { public render() { let seconds = this.state.seconds; if (this.state.playbackPhase === PlaybackState.Stopped) { - seconds = this.state.durationSeconds; + if (Number.isFinite(this.props.defaultDisplaySeconds)) { + seconds = this.props.defaultDisplaySeconds; + } else { + seconds = this.state.durationSeconds; + } } return ; } diff --git a/src/components/views/voice_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx similarity index 97% rename from src/components/views/voice_messages/PlaybackWaveform.tsx rename to src/components/views/audio_messages/PlaybackWaveform.tsx index 096ba8199c..ea1b846c01 100644 --- a/src/components/views/voice_messages/PlaybackWaveform.tsx +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -33,7 +33,7 @@ interface IState { /** * A waveform which shows the waveform of a previously recorded recording */ -@replaceableComponent("views.voice_messages.PlaybackWaveform") +@replaceableComponent("views.audio_messages.PlaybackWaveform") export default class PlaybackWaveform extends React.PureComponent { public constructor(props) { super(props); diff --git a/src/components/views/voice_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx similarity index 90% rename from src/components/views/voice_messages/RecordingPlayback.tsx rename to src/components/views/audio_messages/RecordingPlayback.tsx index 63e823200e..a0dea1c6db 100644 --- a/src/components/views/voice_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -20,6 +20,7 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import PlaybackWaveform from "./PlaybackWaveform"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { // Playback instance to render. Cannot change during component lifecycle: create @@ -31,6 +32,7 @@ interface IState { playbackPhase: PlaybackState; } +@replaceableComponent("views.audio_messages.RecordingPlayback") export default class RecordingPlayback extends React.PureComponent { constructor(props: IProps) { super(props); @@ -53,7 +55,7 @@ export default class RecordingPlayback extends React.PureComponent + return
diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx new file mode 100644 index 0000000000..5231a2fb79 --- /dev/null +++ b/src/components/views/audio_messages/SeekBar.tsx @@ -0,0 +1,112 @@ +/* +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, { ChangeEvent, CSSProperties, ReactNode } from "react"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { MarkedExecution } from "../../../utils/MarkedExecution"; +import { percentageOf } from "../../../utils/numbers"; + +interface IProps { + // Playback instance to render. Cannot change during component lifecycle: create + // an all-new component instead. + playback: Playback; + + // Tab index for the underlying component. Useful if the seek bar is in a managed state. + // Defaults to zero. + tabIndex?: number; + + playbackPhase: PlaybackState; +} + +interface IState { + percentage: number; +} + +interface ISeekCSS extends CSSProperties { + '--fillTo': number; +} + +const ARROW_SKIP_SECONDS = 5; // arbitrary + +@replaceableComponent("views.audio_messages.SeekBar") +export default class SeekBar extends React.PureComponent { + // We use an animation frame request to avoid overly spamming prop updates, even if we aren't + // really using anything demanding on the CSS front. + + private animationFrameFn = new MarkedExecution( + () => this.doUpdate(), + () => requestAnimationFrame(() => this.animationFrameFn.trigger())); + + public static defaultProps = { + tabIndex: 0, + }; + + constructor(props: IProps) { + super(props); + + this.state = { + percentage: 0, + }; + + // We don't need to de-register: the class handles this for us internally + this.props.playback.clockInfo.liveData.onUpdate(() => this.animationFrameFn.mark()); + } + + private doUpdate() { + this.setState({ + percentage: percentageOf( + this.props.playback.clockInfo.timeSeconds, + 0, + this.props.playback.clockInfo.durationSeconds), + }); + } + + public left() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds - ARROW_SKIP_SECONDS); + } + + public right() { + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(this.props.playback.clockInfo.timeSeconds + ARROW_SKIP_SECONDS); + } + + private onChange = (ev: ChangeEvent) => { + // Thankfully, onChange is only called when the user changes the value, not when we + // change the value on the component. We can use this as a reliable "skip to X" function. + // + // noinspection JSIgnoredPromiseFromCall + this.props.playback.skipTo(Number(ev.target.value) * this.props.playback.clockInfo.durationSeconds); + }; + + public render(): ReactNode { + // We use a range input to avoid having to re-invent accessibility handling on + // a custom set of divs. + return ; + } +} diff --git a/src/components/views/voice_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx similarity index 93% rename from src/components/views/voice_messages/Waveform.tsx rename to src/components/views/audio_messages/Waveform.tsx index 5a4447065a..3b7a881754 100644 --- a/src/components/views/voice_messages/Waveform.tsx +++ b/src/components/views/audio_messages/Waveform.tsx @@ -17,8 +17,13 @@ limitations under the License. import React from "react"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import classNames from "classnames"; +import { CSSProperties } from "react"; -export interface IProps { +interface WaveformCSSProperties extends CSSProperties { + '--barHeight': number; +} + +interface IProps { relHeights: number[]; // relative heights (0-1) progress: number; // percent complete, 0-1, default 100% } @@ -34,14 +39,7 @@ interface IState { * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be * "filled", as a demonstration of the progress property. */ - -import { CSSProperties } from "react"; - -export interface WaveformCSSProperties extends CSSProperties { - '--barHeight': number; -} - -@replaceableComponent("views.voice_messages.Waveform") +@replaceableComponent("views.audio_messages.Waveform") export default class Waveform extends React.PureComponent { public static defaultProps = { progress: 1, diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index e002eb5717..4b1ecec740 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -18,7 +18,6 @@ import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react'; import classNames from 'classnames'; import { MatrixClient } from "matrix-js-sdk/src/client"; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; @@ -26,6 +25,8 @@ import Spinner from "../elements/Spinner"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { LocalisedPolicy, Policies } from '../../../Terms'; +import Field from '../elements/Field'; +import CaptchaForm from "./CaptchaForm"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -164,8 +165,7 @@ export class PasswordAuthEntry extends React.Component; + submitButtonOrSpinner = ; } else { submitButtonOrSpinner = (

{ _t("Confirm your identity by entering your account password below.") }

@@ -236,13 +234,11 @@ export class RecaptchaAuthEntry extends React.Component; + return ; } let errorText = this.props.errorText; - const CaptchaForm = sdk.getComponent("views.auth.CaptchaForm"); let sitePublicKey; if (!this.props.stageParams || !this.props.stageParams.public_key) { errorText = _t( @@ -390,8 +386,7 @@ export class TermsAuthEntry extends React.Component; + return ; } const checkboxes = []; @@ -590,8 +585,7 @@ export class MsisdnAuthEntry extends React.Component; + return ; } else { const enableSubmit = Boolean(this.state.token); const submitClasses = classNames({ diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index 12f55a112c..a77dd0b683 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -52,8 +52,8 @@ interface IProps { interface IState { fieldValid: Partial>; - loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone, - password: "", + loginType: LoginField.Email | LoginField.MatrixId | LoginField.Phone; + password: ""; } enum LoginField { diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index be6e29a493..25ea347d24 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; -import * as sdk from '../../../index'; import * as Email from '../../../email'; import { looksValid as phoneNumberLooksValid } from '../../../phonenumber'; import Modal from '../../../Modal'; @@ -31,6 +30,7 @@ import CountlyAnalytics from "../../../CountlyAnalytics"; import Field from '../elements/Field'; import RegistrationEmailPromptDialog from '../dialogs/RegistrationEmailPromptDialog'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import CountryDropdown from "./CountryDropdown"; enum RegistrationField { Email = "field_email", @@ -471,7 +471,6 @@ export default class RegistrationForm extends React.PureComponent, "name" | "idName" | "url" | "onClick"> { // Room may be left unset here, but if it is, // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) room?: Room; - // TODO: type when js-sdk has types - oobData?: any; + oobData?: IOOBData; width?: number; height?: number; resizeMethod?: ResizeMethod; diff --git a/src/components/views/context_menus/MessageContextMenu.js b/src/components/views/context_menus/MessageContextMenu.tsx similarity index 73% rename from src/components/views/context_menus/MessageContextMenu.js rename to src/components/views/context_menus/MessageContextMenu.tsx index a2086451cd..999e98f4ad 100644 --- a/src/components/views/context_menus/MessageContextMenu.js +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -1,6 +1,6 @@ /* Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2015, 2016, 2018, 2019, 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. @@ -16,12 +16,11 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; -import { EventStatus } from 'matrix-js-sdk/src/models/event'; +import { EventStatus, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import dis from '../../../dispatcher/dispatcher'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import Modal from '../../../Modal'; import Resend from '../../../Resend'; @@ -29,53 +28,65 @@ import SettingsStore from '../../../settings/SettingsStore'; import { isUrlPermitted } from '../../../HtmlUtils'; import { isContentActionable } from '../../../utils/EventUtils'; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from './IconizedContextMenu'; -import { EventType } from "matrix-js-sdk/src/@types/event"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard"; import ForwardDialog from "../dialogs/ForwardDialog"; import { Action } from "../../../dispatcher/actions"; +import ReportEventDialog from '../dialogs/ReportEventDialog'; +import ViewSource from '../../structures/ViewSource'; +import ConfirmRedactDialog from '../dialogs/ConfirmRedactDialog'; +import ErrorDialog from '../dialogs/ErrorDialog'; +import ShareDialog from '../dialogs/ShareDialog'; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -export function canCancel(eventStatus) { +export function canCancel(eventStatus: EventStatus): boolean { return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; } +interface IEventTileOps { + isWidgetHidden(): boolean; + unhideWidget(): void; +} + +interface IProps { + /* the MatrixEvent associated with the context menu */ + mxEvent: MatrixEvent; + /* an optional EventTileOps implementation that can be used to unhide preview widgets */ + eventTileOps?: IEventTileOps; + permalinkCreator?: RoomPermalinkCreator; + /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ + collapseReplyThread?(): void; + /* callback called when the menu is dismissed */ + onFinished(): void; + /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ + onCloseDialog?(): void; +} + +interface IState { + canRedact: boolean; + canPin: boolean; +} + @replaceableComponent("views.context_menus.MessageContextMenu") -export default class MessageContextMenu extends React.Component { - static propTypes = { - /* the MatrixEvent associated with the context menu */ - mxEvent: PropTypes.object.isRequired, - - /* an optional EventTileOps implementation that can be used to unhide preview widgets */ - eventTileOps: PropTypes.object, - - /* an optional function to be called when the user clicks collapse thread, if not provided hide button */ - collapseReplyThread: PropTypes.func, - - /* callback called when the menu is dismissed */ - onFinished: PropTypes.func, - - /* if the menu is inside a dialog, we sometimes need to close that dialog after click (forwarding) */ - onCloseDialog: PropTypes.func, - }; - +export default class MessageContextMenu extends React.Component { state = { canRedact: false, canPin: false, }; componentDidMount() { - MatrixClientPeg.get().on('RoomMember.powerLevel', this._checkPermissions); - this._checkPermissions(); + MatrixClientPeg.get().on('RoomMember.powerLevel', this.checkPermissions); + this.checkPermissions(); } componentWillUnmount() { const cli = MatrixClientPeg.get(); if (cli) { - cli.removeListener('RoomMember.powerLevel', this._checkPermissions); + cli.removeListener('RoomMember.powerLevel', this.checkPermissions); } } - _checkPermissions = () => { + private checkPermissions = (): void => { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); @@ -93,7 +104,7 @@ export default class MessageContextMenu extends React.Component { this.setState({ canRedact, canPin }); }; - _isPinned() { + private isPinned(): boolean { const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ''); if (!pinnedEvent) return false; @@ -101,38 +112,35 @@ export default class MessageContextMenu extends React.Component { return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); } - onResendReactionsClick = () => { - for (const reaction of this._getUnsentReactions()) { + private onResendReactionsClick = (): void => { + for (const reaction of this.getUnsentReactions()) { Resend.resend(reaction); } this.closeMenu(); }; - onReportEventClick = () => { - const ReportEventDialog = sdk.getComponent("dialogs.ReportEventDialog"); + private onReportEventClick = (): void => { Modal.createTrackedDialog('Report Event', '', ReportEventDialog, { mxEvent: this.props.mxEvent, }, 'mx_Dialog_reportEvent'); this.closeMenu(); }; - onViewSourceClick = () => { - const ViewSource = sdk.getComponent('structures.ViewSource'); + private onViewSourceClick = (): void => { Modal.createTrackedDialog('View Event Source', '', ViewSource, { mxEvent: this.props.mxEvent, }, 'mx_Dialog_viewsource'); this.closeMenu(); }; - onRedactClick = () => { - const ConfirmRedactDialog = sdk.getComponent("dialogs.ConfirmRedactDialog"); + private onRedactClick = (): void => { Modal.createTrackedDialog('Confirm Redact Dialog', '', ConfirmRedactDialog, { - onFinished: async (proceed, reason) => { + onFinished: async (proceed: boolean, reason?: string) => { if (!proceed) return; const cli = MatrixClientPeg.get(); try { - if (this.props.onCloseDialog) this.props.onCloseDialog(); + this.props.onCloseDialog?.(); await cli.redactEvent( this.props.mxEvent.getRoomId(), this.props.mxEvent.getId(), @@ -145,7 +153,6 @@ export default class MessageContextMenu extends React.Component { // (e.g. no errcode or statusCode) as in that case the redactions end up in the // detached queue and we show the room status bar to allow retry if (typeof code !== "undefined") { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); // display error message stating you couldn't delete this. Modal.createTrackedDialog('You cannot delete this message', '', ErrorDialog, { title: _t('Error'), @@ -158,7 +165,7 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onForwardClick = () => { + private onForwardClick = (): void => { Modal.createTrackedDialog('Forward Message', '', ForwardDialog, { matrixClient: MatrixClientPeg.get(), event: this.props.mxEvent, @@ -167,12 +174,12 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onPinClick = () => { + private onPinClick = (): void => { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); const eventId = this.props.mxEvent.getId(); - const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || []; + const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || []; if (pinnedIds.includes(eventId)) { pinnedIds.splice(pinnedIds.indexOf(eventId), 1); } else { @@ -188,18 +195,16 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - closeMenu = () => { - if (this.props.onFinished) this.props.onFinished(); + private closeMenu = (): void => { + this.props.onFinished(); }; - onUnhidePreviewClick = () => { - if (this.props.eventTileOps) { - this.props.eventTileOps.unhideWidget(); - } + private onUnhidePreviewClick = (): void => { + this.props.eventTileOps?.unhideWidget(); this.closeMenu(); }; - onQuoteClick = () => { + private onQuoteClick = (): void => { dis.dispatch({ action: Action.ComposerInsert, event: this.props.mxEvent, @@ -207,9 +212,8 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onPermalinkClick = (e) => { + private onPermalinkClick = (e: React.MouseEvent): void => { e.preventDefault(); - const ShareDialog = sdk.getComponent("dialogs.ShareDialog"); Modal.createTrackedDialog('share room message dialog', '', ShareDialog, { target: this.props.mxEvent, permalinkCreator: this.props.permalinkCreator, @@ -217,30 +221,27 @@ export default class MessageContextMenu extends React.Component { this.closeMenu(); }; - onCollapseReplyThreadClick = () => { + private onCollapseReplyThreadClick = (): void => { this.props.collapseReplyThread(); this.closeMenu(); }; - _getReactions(filter) { + private getReactions(filter: (e: MatrixEvent) => boolean): MatrixEvent[] { const cli = MatrixClientPeg.get(); const room = cli.getRoom(this.props.mxEvent.getRoomId()); const eventId = this.props.mxEvent.getId(); return room.getPendingEvents().filter(e => { const relation = e.getRelation(); - return relation && - relation.rel_type === "m.annotation" && - relation.event_id === eventId && - filter(e); + return relation?.rel_type === RelationType.Annotation && relation.event_id === eventId && filter(e); }); } - _getPendingReactions() { - return this._getReactions(e => canCancel(e.status)); + private getPendingReactions(): MatrixEvent[] { + return this.getReactions(e => canCancel(e.status)); } - _getUnsentReactions() { - return this._getReactions(e => e.status === EventStatus.NOT_SENT); + private getUnsentReactions(): MatrixEvent[] { + return this.getReactions(e => e.status === EventStatus.NOT_SENT); } render() { @@ -248,16 +249,17 @@ export default class MessageContextMenu extends React.Component { const me = cli.getUserId(); const mxEvent = this.props.mxEvent; const eventStatus = mxEvent.status; - const unsentReactionsCount = this._getUnsentReactions().length; - let resendReactionsButton; - let redactButton; - let forwardButton; - let pinButton; - let unhidePreviewButton; - let externalURLButton; - let quoteButton; - let collapseReplyThread; - let redactItemList; + const unsentReactionsCount = this.getUnsentReactions().length; + + let resendReactionsButton: JSX.Element; + let redactButton: JSX.Element; + let forwardButton: JSX.Element; + let pinButton: JSX.Element; + let unhidePreviewButton: JSX.Element; + let externalURLButton: JSX.Element; + let quoteButton: JSX.Element; + let collapseReplyThread: JSX.Element; + let redactItemList: JSX.Element; // status is SENT before remote-echo, null after const isSent = !eventStatus || eventStatus === EventStatus.SENT; @@ -296,7 +298,7 @@ export default class MessageContextMenu extends React.Component { pinButton = ( ); @@ -327,16 +329,20 @@ export default class MessageContextMenu extends React.Component { if (this.props.permalinkCreator) { permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); } - // XXX: if we use room ID, we should also include a server where the event can be found (other than in the domain of the event ID) const permalinkButton = ( ); @@ -351,8 +357,8 @@ export default class MessageContextMenu extends React.Component { } // Bridges can provide a 'external_url' to link back to the source. - if (typeof (mxEvent.event.content.external_url) === "string" && - isUrlPermitted(mxEvent.event.content.external_url) + if (typeof (mxEvent.getContent().external_url) === "string" && + isUrlPermitted(mxEvent.getContent().external_url) ) { externalURLButton = ( ); } @@ -377,7 +388,7 @@ export default class MessageContextMenu extends React.Component { ); } - let reportEventButton; + let reportEventButton: JSX.Element; if (mxEvent.getSender() !== me) { reportEventButton = ( { }; public render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const errorList = this.props.unknownProfileUsers .map(address =>
  • {address.userId}: {address.errorText}
  • ); diff --git a/src/components/views/dialogs/BugReportDialog.tsx b/src/components/views/dialogs/BugReportDialog.tsx index eeb3769bf9..6baf24f797 100644 --- a/src/components/views/dialogs/BugReportDialog.tsx +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -18,13 +18,17 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; import sendBugReport, { downloadBugReport } from '../../../rageshake/submit-rageshake'; import AccessibleButton from "../elements/AccessibleButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import QuestionDialog from "./QuestionDialog"; +import BaseDialog from "./BaseDialog"; +import Field from '../elements/Field'; +import Spinner from "../elements/Spinner"; +import DialogButtons from "../elements/DialogButtons"; interface IProps { onFinished: (success: boolean) => void; @@ -93,7 +97,6 @@ export default class BugReportDialog extends React.Component { }).then(() => { if (!this.unmounted) { this.props.onFinished(false); - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); // N.B. first param is passed to piwik and so doesn't want i18n Modal.createTrackedDialog('Bug report sent', '', QuestionDialog, { title: _t('Logs sent'), @@ -160,11 +163,6 @@ export default class BugReportDialog extends React.Component { }; public render() { - const Loader = sdk.getComponent("elements.Spinner"); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Field = sdk.getComponent('elements.Field'); - let error = null; if (this.state.err) { error =
    @@ -176,7 +174,7 @@ export default class BugReportDialog extends React.Component { if (this.state.busy) { progress = (
    - + {this.state.progress} ...
    ); diff --git a/src/components/views/dialogs/ChangelogDialog.tsx b/src/components/views/dialogs/ChangelogDialog.tsx index 8acacd8e73..d484d94249 100644 --- a/src/components/views/dialogs/ChangelogDialog.tsx +++ b/src/components/views/dialogs/ChangelogDialog.tsx @@ -16,9 +16,10 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> */ import React from 'react'; -import * as sdk from '../../../index'; import request from 'browser-request'; import { _t } from '../../../languageHandler'; +import QuestionDialog from "./QuestionDialog"; +import Spinner from "../elements/Spinner"; interface IProps { newVersion: string; @@ -65,9 +66,6 @@ export default class ChangelogDialog extends React.Component { } public render() { - const Spinner = sdk.getComponent('views.elements.Spinner'); - const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); - const logs = REPOS.map(repo => { let content; if (this.state[repo] == null) { diff --git a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx index 90b749b959..d21fde329c 100644 --- a/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx +++ b/src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx @@ -15,9 +15,12 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import ConfirmRedactDialog from './ConfirmRedactDialog'; +import ErrorDialog from './ErrorDialog'; +import BaseDialog from "./BaseDialog"; +import Spinner from "../elements/Spinner"; interface IProps { redact: () => Promise; @@ -73,7 +76,6 @@ export default class ConfirmAndWaitRedactDialog extends React.PureComponent ); } else { - const BaseDialog = sdk.getComponent("dialogs.BaseDialog"); - const Spinner = sdk.getComponent('elements.Spinner'); return ( ; } } diff --git a/src/components/views/dialogs/ConfirmRedactDialog.tsx b/src/components/views/dialogs/ConfirmRedactDialog.tsx index 94f29a71fc..a2f2b10144 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.tsx +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -15,9 +15,9 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import TextInputDialog from "./TextInputDialog"; interface IProps { onFinished: (success: boolean) => void; @@ -29,7 +29,6 @@ interface IProps { @replaceableComponent("views.dialogs.ConfirmRedactDialog") export default class ConfirmRedactDialog extends React.Component { render() { - const TextInputDialog = sdk.getComponent('views.dialogs.TextInputDialog'); return ( { }; public render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar"); - const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar"); - const confirmButtonClass = this.props.danger ? 'danger' : ''; let reasonBox; diff --git a/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx index 2978179817..544d0df1c9 100644 --- a/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx +++ b/src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx @@ -16,8 +16,9 @@ limitations under the License. import React from 'react'; import { _t } from "../../../languageHandler"; -import * as sdk from "../../../index"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; interface IProps { onFinished: (success: boolean) => void; @@ -34,9 +35,6 @@ export default class ConfirmWipeDeviceDialog extends React.Component { }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - return ( void; @@ -106,9 +107,6 @@ export default class CreateGroupDialog extends React.Component { }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const Spinner = sdk.getComponent('elements.Spinner'); - if (this.state.creating) { return ; } diff --git a/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx index 2cdaf9cf4f..134c4ab79e 100644 --- a/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx +++ b/src/components/views/dialogs/CryptoStoreTooNewDialog.tsx @@ -16,21 +16,22 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import Modal from '../../../Modal'; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import QuestionDialog from "./QuestionDialog"; interface IProps { onFinished: (success: boolean) => void; } -export default (props: IProps) => { +const CryptoStoreTooNewDialog: React.FC = (props: IProps) => { const brand = SdkConfig.get().brand; const _onLogoutClicked = () => { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); Modal.createTrackedDialog('Logout e2e db too new', '', QuestionDialog, { title: _t("Sign out"), description: _t( @@ -58,8 +59,6 @@ export default (props: IProps) => { { brand }, ); - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return ( { ); }; + +export default CryptoStoreTooNewDialog; diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index 6df6056670..b2ac849314 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; -import * as sdk from '../../../index'; import Analytics from '../../../Analytics'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import * as Lifecycle from '../../../Lifecycle'; @@ -26,6 +25,7 @@ import InteractiveAuth, { ERROR_USER_CANCELLED } from "../../structures/Interact import { DEFAULT_PHASE, PasswordAuthEntry, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; interface IProps { onFinished: (success: boolean) => void; @@ -165,8 +165,6 @@ export default class DeactivateAccountDialog extends React.Component diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index de958f8e9a..86b8f93d7b 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -16,7 +16,6 @@ limitations under the License. */ import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react'; -import * as sdk from '../../../index'; import SyntaxHighlight from '../elements/SyntaxHighlight'; import { _t } from '../../../languageHandler'; import Field from "../elements/Field"; @@ -42,6 +41,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { SettingLevel } from '../../../settings/SettingLevel'; +import BaseDialog from "./BaseDialog"; +import TruncatedList from "../elements/TruncatedList"; interface IGenericEditorProps { onBack: () => void; @@ -369,7 +370,6 @@ class FilteredList extends React.PureComponent ; } - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( { body } diff --git a/src/components/views/dialogs/ErrorDialog.tsx b/src/components/views/dialogs/ErrorDialog.tsx index 0f675f0df7..56cd76237f 100644 --- a/src/components/views/dialogs/ErrorDialog.tsx +++ b/src/components/views/dialogs/ErrorDialog.tsx @@ -26,9 +26,9 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; interface IProps { onFinished: (success: boolean) => void; @@ -57,7 +57,6 @@ export default class ErrorDialog extends React.Component { }; public render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return ( 0) { - const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); Modal.createTrackedDialog('Invite Paste Fail', '', QuestionDialog, { title: _t('Failed to find the following users'), description: _t( @@ -1158,7 +1159,6 @@ export default class InviteDialog extends React.PureComponent; diff --git a/src/components/views/dialogs/NewSessionReviewDialog.js b/src/components/views/dialogs/NewSessionReviewDialog.js deleted file mode 100644 index 749f48ef48..0000000000 --- a/src/components/views/dialogs/NewSessionReviewDialog.js +++ /dev/null @@ -1,121 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; -import Modal from '../../../Modal'; -import { replaceableComponent } from '../../../utils/replaceableComponent'; -import VerificationRequestDialog from './VerificationRequestDialog'; -import BaseDialog from './BaseDialog'; -import DialogButtons from '../elements/DialogButtons'; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import * as sdk from '../../../index'; - -@replaceableComponent("views.dialogs.NewSessionReviewDialog") -export default class NewSessionReviewDialog extends React.PureComponent { - static propTypes = { - userId: PropTypes.string.isRequired, - device: PropTypes.object.isRequired, - onFinished: PropTypes.func.isRequired, - } - - onCancelClick = () => { - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, { - headerImage: require("../../../../res/img/e2e/warning.svg"), - title: _t("Your account is not secure"), - description:
    - {_t("One of the following may be compromised:")} -
      -
    • {_t("Your password")}
    • -
    • {_t("Your homeserver")}
    • -
    • {_t("This session, or the other session")}
    • -
    • {_t("The internet connection either session is using")}
    • -
    -
    - {_t("We recommend you change your password and Security Key in Settings immediately")} -
    -
    , - onFinished: () => this.props.onFinished(false), - }); - } - - onContinueClick = () => { - const { userId, device } = this.props; - const cli = MatrixClientPeg.get(); - const requestPromise = cli.requestVerification( - userId, - [device.deviceId], - ); - - this.props.onFinished(true); - Modal.createTrackedDialog('New Session Verification', 'Starting dialog', VerificationRequestDialog, { - verificationRequestPromise: requestPromise, - member: cli.getUser(userId), - onFinished: async () => { - const request = await requestPromise; - request.cancel(); - }, - }); - } - - render() { - const { device } = this.props; - - const icon = ; - const titleText = _t("New session"); - - const title =

    - {icon} - {titleText} -

    ; - - return ( - -
    -

    {_t( - "Use this session to verify your new one, " + - "granting it access to encrypted messages:", - )}

    -
    -
    - - {device.getDisplayName()} - - ({device.deviceId}) - -
    -
    -

    {_t( - "If you didn’t sign in to this session, " + - "your account may be compromised.", - )}

    - -
    -
    - ); - } -} diff --git a/src/components/views/dialogs/ReportEventDialog.tsx b/src/components/views/dialogs/ReportEventDialog.tsx index 81a7d569fe..494fd59082 100644 --- a/src/components/views/dialogs/ReportEventDialog.tsx +++ b/src/components/views/dialogs/ReportEventDialog.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React from 'react'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { ensureDMExists } from "../../../createRoom"; import { IDialogProps } from "./IDialogProps"; @@ -26,6 +25,10 @@ import Markdown from '../../../Markdown'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import SettingsStore from "../../../settings/SettingsStore"; import StyledRadioButton from "../elements/StyledRadioButton"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import Field from "../elements/Field"; +import Spinner from "../elements/Spinner"; interface IProps extends IDialogProps { mxEvent: MatrixEvent; @@ -239,11 +242,6 @@ export default class ReportEventDialog extends React.Component { }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - const Loader = sdk.getComponent('elements.Spinner'); - const Field = sdk.getComponent('elements.Field'); - let error = null; if (this.state.err) { error =
    @@ -255,7 +253,7 @@ export default class ReportEventDialog extends React.Component { if (this.state.busy) { progress = (
    - +
    ); } diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index 005222a94e..a426dce5c7 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -24,12 +24,12 @@ import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab"; import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsTab"; import BridgeSettingsTab from "../settings/tabs/room/BridgeSettingsTab"; -import * as sdk from "../../../index"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; @@ -119,8 +119,6 @@ export default class RoomSettingsDialog extends React.Component { } render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const roomName = MatrixClientPeg.get().getRoom(this.props.roomId).name; return ( { const successful = await copyPlaintext(this.getUrl()); const buttonRect = target.getBoundingClientRect(); - const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const { close } = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), @@ -230,7 +230,6 @@ export default class ShareDialog extends React.PureComponent { ; } - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); return void; @@ -46,19 +47,19 @@ interface ITermsDialogProps { * Array of [Service, policies] pairs, where policies is the response from the * /terms endpoint for that service */ - policiesAndServicePairs: any[], + policiesAndServicePairs: any[]; /** * urls that the user has already agreed to */ - agreedUrls?: string[], + agreedUrls?: string[]; /** * Called with: * * success {bool} True if the user accepted any douments, false if cancelled * * agreedUrls {string[]} List of agreed URLs */ - onFinished: (success: boolean, agreedUrls?: string[]) => void, + onFinished: (success: boolean, agreedUrls?: string[]) => void; } interface IState { @@ -117,9 +118,6 @@ export default class TermsDialog extends React.PureComponent { }; render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); - let title; if (this.props.totalFiles > 1 && this.props.currentIndex !== undefined) { title = _t( diff --git a/src/components/views/dialogs/UserSettingsDialog.tsx b/src/components/views/dialogs/UserSettingsDialog.tsx index 2878a07d35..e85938afe0 100644 --- a/src/components/views/dialogs/UserSettingsDialog.tsx +++ b/src/components/views/dialogs/UserSettingsDialog.tsx @@ -28,11 +28,11 @@ import PreferencesUserSettingsTab from "../settings/tabs/user/PreferencesUserSet import VoiceUserSettingsTab from "../settings/tabs/user/VoiceUserSettingsTab"; import HelpUserSettingsTab from "../settings/tabs/user/HelpUserSettingsTab"; import FlairUserSettingsTab from "../settings/tabs/user/FlairUserSettingsTab"; -import * as sdk from "../../../index"; import SdkConfig from "../../../SdkConfig"; import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab"; import { UIFeature } from "../../../settings/UIFeature"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import BaseDialog from "./BaseDialog"; export enum UserTab { General = "USER_GENERAL_TAB", @@ -162,8 +162,6 @@ export default class UserSettingsDialog extends React.Component } render() { - const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog'); - return ( ; + onFinished: () => void; + member: User; +} + +interface IState { + verificationRequest: VerificationRequest; +} @replaceableComponent("views.dialogs.VerificationRequestDialog") -export default class VerificationRequestDialog extends React.Component { - static propTypes = { - verificationRequest: PropTypes.object, - verificationRequestPromise: PropTypes.object, - onFinished: PropTypes.func.isRequired, - member: PropTypes.string, - }; - - constructor(...args) { - super(...args); - this.state = {}; - if (this.props.verificationRequest) { - this.state.verificationRequest = this.props.verificationRequest; - } else if (this.props.verificationRequestPromise) { +export default class VerificationRequestDialog extends React.Component { + constructor(props) { + super(props); + this.state = { + verificationRequest: this.props.verificationRequest, + }; + if (this.props.verificationRequestPromise) { this.props.verificationRequestPromise.then(r => { this.setState({ verificationRequest: r }); }); @@ -43,8 +49,6 @@ export default class VerificationRequestDialog extends React.Component { } render() { - const BaseDialog = sdk.getComponent("views.dialogs.BaseDialog"); - const EncryptionPanel = sdk.getComponent("views.right_panel.EncryptionPanel"); const request = this.state.verificationRequest; const otherUserId = request && request.otherUserId; const member = this.props.member || @@ -65,6 +69,7 @@ export default class VerificationRequestDialog extends React.Component { verificationRequestPromise={this.props.verificationRequestPromise} onClose={this.props.onFinished} member={member} + isRoomEncrypted={false} /> ; } diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index d614cc0956..90c640977c 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -17,7 +17,7 @@ limitations under the License. import { debounce } from "lodash"; import classNames from 'classnames'; import React, { ChangeEvent, FormEvent } from 'react'; -import { ISecretStorageKeyInfo } from "matrix-js-sdk/src"; +import { ISecretStorageKeyInfo } from "matrix-js-sdk/src/crypto/api"; import * as sdk from '../../../../index'; import { MatrixClientPeg } from '../../../../MatrixClientPeg'; diff --git a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.tsx b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.tsx index 6272302a76..c0530a35ea 100644 --- a/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.tsx +++ b/src/components/views/dialogs/security/ConfirmDestroyCrossSigningDialog.tsx @@ -16,8 +16,9 @@ limitations under the License. import React from 'react'; import { _t } from "../../../../languageHandler"; -import * as sdk from "../../../../index"; import { replaceableComponent } from "../../../../utils/replaceableComponent"; +import BaseDialog from "../BaseDialog"; +import DialogButtons from "../../elements/DialogButtons"; interface IProps { onFinished: (success: boolean) => void; @@ -34,9 +35,6 @@ export default class ConfirmDestroyCrossSigningDialog extends React.Component; interface IProps { protocols: Protocols; selectedServerName: string; - selectedInstanceId: string | symbol; - onOptionChange(server: string, instanceId?: string | symbol): void; + selectedInstanceId: string; + onOptionChange(server: string, instanceId?: string): void; } // This dropdown sources homeservers from three places: @@ -171,7 +171,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s const protocolsList = server === hsName ? Object.values(protocols) : []; if (protocolsList.length > 0) { - // add a fake protocol with the ALL_ROOMS symbol + // add a fake protocol with ALL_ROOMS protocolsList.push({ instances: [{ fields: [], diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index 997bbcb9c2..8bb6341c3d 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -14,7 +14,7 @@ limitations under the License. */ -import React from 'react'; +import React, { ReactHTML } from 'react'; import { Key } from '../../../Keyboard'; import classnames from 'classnames'; @@ -29,7 +29,7 @@ export type ButtonEvent = React.MouseEvent | React.KeyboardEvent { inputRef?: React.Ref; - element?: string; + element?: keyof ReactHTML; // The kind of button, similar to how Bootstrap works. // See available classes for AccessibleButton for options. kind?: string; @@ -122,7 +122,7 @@ export default function AccessibleButton({ } AccessibleButton.defaultProps = { - element: 'div', + element: 'div' as keyof ReactHTML, role: 'button', tabIndex: 0, }; diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index ab647db9ed..681817ca86 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -29,7 +29,7 @@ interface IProps { // The minimum number of events needed to trigger summarisation threshold?: number; // Whether or not to begin with state.expanded=true - startExpanded?: boolean, + startExpanded?: boolean; // The list of room members for which to show avatars next to the summary summaryMembers?: RoomMember[]; // The text to show as the summary of this event list diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 297044e422..60f029c32e 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -260,6 +260,7 @@ export default class Field extends React.PureComponent { }); // Handle displaying feedback on validity + // FIXME: Using an import will result in test failures const Tooltip = sdk.getComponent("elements.Tooltip"); let fieldTooltip; if (tooltipContent || this.state.feedback) { diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 74538d2fa9..90f5d18be7 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -24,7 +24,7 @@ import FocusLock from "react-focus-lock"; import MemberAvatar from "../avatars/MemberAvatar"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import MessageContextMenu from "../context_menus/MessageContextMenu"; -import { aboveLeftOf, ContextMenu } from '../../structures/ContextMenu'; +import { aboveLeftOf } from '../../structures/ContextMenu'; import MessageTimestamp from "../messages/MessageTimestamp"; import SettingsStore from "../../../settings/SettingsStore"; import { formatFullDate } from "../../../DateUtils"; @@ -44,31 +44,31 @@ const ZOOM_COEFFICIENT = 0.0025; const ZOOM_DISTANCE = 10; interface IProps { - src: string, // the source of the image being displayed - name?: string, // the main title ('name') for the image - link?: string, // the link (if any) applied to the name of the image - width?: number, // width of the image src in pixels - height?: number, // height of the image src in pixels - fileSize?: number, // size of the image src in bytes - onFinished(): void, // callback when the lightbox is dismissed + src: string; // the source of the image being displayed + name?: string; // the main title ('name') for the image + link?: string; // the link (if any) applied to the name of the image + width?: number; // width of the image src in pixels + height?: number; // height of the image src in pixels + fileSize?: number; // size of the image src in bytes + onFinished(): void; // callback when the lightbox is dismissed // the event (if any) that the Image is displaying. Used for event-specific stuff like // redactions, senders, timestamps etc. Other descriptors are taken from the explicit // properties above, which let us use lightboxes to display images which aren't associated // with events. - mxEvent: MatrixEvent, - permalinkCreator: RoomPermalinkCreator, + mxEvent: MatrixEvent; + permalinkCreator: RoomPermalinkCreator; } interface IState { - zoom: number, - minZoom: number, - maxZoom: number, - rotation: number, - translationX: number, - translationY: number, - moving: boolean, - contextMenuDisplayed: boolean, + zoom: number; + minZoom: number; + maxZoom: number; + rotation: number; + translationX: number; + translationY: number; + moving: boolean; + contextMenuDisplayed: boolean; } @replaceableComponent("views.elements.ImageView") @@ -122,7 +122,7 @@ export default class ImageView extends React.Component { const image = this.image.current; const imageWrapper = this.imageWrapper.current; - const rotation = inputRotation || this.state.rotation; + const rotation = inputRotation ?? this.state.rotation; const imageIsNotFlipped = rotation % 180 === 0; @@ -304,17 +304,13 @@ export default class ImageView extends React.Component { let contextMenu = null; if (this.state.contextMenuDisplayed) { contextMenu = ( - - - + onCloseDialog={this.props.onFinished} + /> ); } diff --git a/src/components/views/elements/ReplyThread.js b/src/components/views/elements/ReplyThread.js index aea447c9b1..2047de6c58 100644 --- a/src/components/views/elements/ReplyThread.js +++ b/src/components/views/elements/ReplyThread.js @@ -334,7 +334,7 @@ export default class ReplyThread extends React.Component { events, }); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); } render() { diff --git a/src/components/views/elements/RoomAliasField.tsx b/src/components/views/elements/RoomAliasField.tsx index d9e081341b..62de4dd2bb 100644 --- a/src/components/views/elements/RoomAliasField.tsx +++ b/src/components/views/elements/RoomAliasField.tsx @@ -27,6 +27,7 @@ interface IProps { value: string; label?: string; placeholder?: string; + disabled?: boolean; onChange?(value: string): void; } @@ -68,6 +69,7 @@ export default class RoomAliasField extends React.PureComponent onChange={this.onChange} value={this.props.value.substring(1, this.props.value.length - this.props.domain.length - 1)} maxLength={maxlength} + disabled={this.props.disabled} /> ); } diff --git a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx index d10a599d95..5230042c38 100644 --- a/src/components/views/elements/SpellCheckLanguagesDropdown.tsx +++ b/src/components/views/elements/SpellCheckLanguagesDropdown.tsx @@ -17,11 +17,11 @@ limitations under the License. import React from 'react'; import Dropdown from "../../views/elements/Dropdown"; -import * as sdk from '../../../index'; import PlatformPeg from "../../../PlatformPeg"; import SettingsStore from "../../../settings/SettingsStore"; import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Spinner from "./Spinner"; function languageMatchesSearchQuery(query, language) { if (language.label.toUpperCase().includes(query.toUpperCase())) return true; @@ -30,14 +30,14 @@ function languageMatchesSearchQuery(query, language) { } interface SpellCheckLanguagesDropdownIProps { - className: string, - value: string, - onOptionChange(language: string), + className: string; + value: string; + onOptionChange(language: string); } interface SpellCheckLanguagesDropdownIState { - searchQuery: string, - languages: any, + searchQuery: string; + languages: any; } @replaceableComponent("views.elements.SpellCheckLanguagesDropdown") @@ -84,7 +84,6 @@ export default class SpellCheckLanguagesDropdown extends React.Component; } diff --git a/src/components/views/elements/ToggleSwitch.tsx b/src/components/views/elements/ToggleSwitch.tsx index 7315cc6383..c439ef4f85 100644 --- a/src/components/views/elements/ToggleSwitch.tsx +++ b/src/components/views/elements/ToggleSwitch.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import * as sdk from "../../../index"; +import AccessibleButton from "./AccessibleButton"; interface IProps { // Whether or not this toggle is in the 'on' position. @@ -43,7 +43,6 @@ export default ({ checked, disabled = false, onChange, ...props }: IProps) => { "mx_ToggleSwitch_enabled": !disabled, }); - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); return ( { }; render() { - const Tooltip = sdk.getComponent("elements.Tooltip"); const tip = this.state.hover ? ; + selectedEmojis?: Set; showQuickReactions?: boolean; onChoose(unicode: string): boolean; } diff --git a/src/components/views/emojipicker/Header.tsx b/src/components/views/emojipicker/Header.tsx index 010801141a..ac39affdd9 100644 --- a/src/components/views/emojipicker/Header.tsx +++ b/src/components/views/emojipicker/Header.tsx @@ -25,7 +25,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { categories: ICategory[]; - onAnchorClick(id: CategoryKey): void + onAnchorClick(id: CategoryKey): void; } @replaceableComponent("views.emojipicker.Header") diff --git a/src/components/views/emojipicker/ReactionPicker.tsx b/src/components/views/emojipicker/ReactionPicker.tsx index d8f8b7f2ff..e129b45c9a 100644 --- a/src/components/views/emojipicker/ReactionPicker.tsx +++ b/src/components/views/emojipicker/ReactionPicker.tsx @@ -22,6 +22,7 @@ import EmojiPicker from "./EmojiPicker"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import dis from "../../../dispatcher/dispatcher"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Action } from '../../../dispatcher/actions'; interface IProps { mxEvent: MatrixEvent; @@ -93,6 +94,7 @@ class ReactionPicker extends React.Component { this.props.mxEvent.getRoomId(), myReactions[reaction], ); + dis.dispatch({ action: Action.FocusAComposer }); // Tell the emoji picker not to bump this in the more frequently used list. return false; } else { @@ -104,6 +106,7 @@ class ReactionPicker extends React.Component { }, }); dis.dispatch({ action: "message_sent" }); + dis.dispatch({ action: Action.FocusAComposer }); return true; } }; diff --git a/src/components/views/messages/MAudioBody.js b/src/components/views/messages/MAudioBody.js deleted file mode 100644 index 977c88448b..0000000000 --- a/src/components/views/messages/MAudioBody.js +++ /dev/null @@ -1,112 +0,0 @@ -/* - Copyright 2016 OpenMarket Ltd - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - */ - -import React from 'react'; -import MFileBody from './MFileBody'; - -import { decryptFile } from '../../../utils/DecryptFile'; -import { _t } from '../../../languageHandler'; -import InlineSpinner from '../elements/InlineSpinner'; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { mediaFromContent } from "../../../customisations/Media"; - -@replaceableComponent("views.messages.MAudioBody") -export default class MAudioBody extends React.Component { - constructor(props) { - super(props); - this.state = { - playing: false, - decryptedUrl: null, - decryptedBlob: null, - error: null, - }; - } - onPlayToggle() { - this.setState({ - playing: !this.state.playing, - }); - } - - _getContentUrl() { - const media = mediaFromContent(this.props.mxEvent.getContent()); - if (media.isEncrypted) { - return this.state.decryptedUrl; - } else { - return media.srcHttp; - } - } - - componentDidMount() { - const content = this.props.mxEvent.getContent(); - if (content.file !== undefined && this.state.decryptedUrl === null) { - let decryptedBlob; - decryptFile(content.file).then(function(blob) { - decryptedBlob = blob; - return URL.createObjectURL(decryptedBlob); - }).then((url) => { - this.setState({ - decryptedUrl: url, - decryptedBlob: decryptedBlob, - }); - }, (err) => { - console.warn("Unable to decrypt attachment: ", err); - this.setState({ - error: err, - }); - }); - } - } - - componentWillUnmount() { - if (this.state.decryptedUrl) { - URL.revokeObjectURL(this.state.decryptedUrl); - } - } - - render() { - const content = this.props.mxEvent.getContent(); - - if (this.state.error !== null) { - return ( - - - { _t("Error decrypting audio") } - - ); - } - - if (content.file !== undefined && this.state.decryptedUrl === null) { - // Need to decrypt the attachment - // The attachment is decrypted in componentDidMount. - // For now add an img tag with a 16x16 spinner. - // Not sure how tall the audio player is so not sure how tall it should actually be. - return ( - - - - ); - } - - const contentUrl = this._getContentUrl(); - - return ( - - - ); - } -} diff --git a/src/components/views/messages/MAudioBody.tsx b/src/components/views/messages/MAudioBody.tsx new file mode 100644 index 0000000000..bc7216f42c --- /dev/null +++ b/src/components/views/messages/MAudioBody.tsx @@ -0,0 +1,110 @@ +/* +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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { Playback } from "../../../voice/Playback"; +import MFileBody from "./MFileBody"; +import InlineSpinner from '../elements/InlineSpinner'; +import { _t } from "../../../languageHandler"; +import { mediaFromContent } from "../../../customisations/Media"; +import { decryptFile } from "../../../utils/DecryptFile"; +import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; +import AudioPlayer from "../audio_messages/AudioPlayer"; + +interface IProps { + mxEvent: MatrixEvent; +} + +interface IState { + error?: Error; + playback?: Playback; + decryptedBlob?: Blob; +} + +@replaceableComponent("views.messages.MAudioBody") +export default class MAudioBody extends React.PureComponent { + constructor(props: IProps) { + super(props); + + this.state = {}; + } + + public async componentDidMount() { + let buffer: ArrayBuffer; + const content: IMediaEventContent = this.props.mxEvent.getContent(); + const media = mediaFromContent(content); + if (media.isEncrypted) { + try { + const blob = await decryptFile(content.file); + buffer = await blob.arrayBuffer(); + this.setState({ decryptedBlob: blob }); + } catch (e) { + this.setState({ error: e }); + console.warn("Unable to decrypt audio message", e); + return; // stop processing the audio file + } + } else { + try { + buffer = await media.downloadSource().then(r => r.blob()).then(r => r.arrayBuffer()); + } catch (e) { + this.setState({ error: e }); + console.warn("Unable to download audio message", e); + return; // stop processing the audio file + } + } + + // We should have a buffer to work with now: let's set it up + const playback = new Playback(buffer); + playback.clockInfo.populatePlaceholdersFrom(this.props.mxEvent); + this.setState({ playback }); + // Note: the RecordingPlayback component will handle preparing the Playback class for us. + } + + public componentWillUnmount() { + this.state.playback?.destroy(); + } + + public render() { + if (this.state.error) { + // TODO: @@TR: Verify error state + return ( + + + { _t("Error processing audio message") } + + ); + } + + if (!this.state.playback) { + // TODO: @@TR: Verify loading/decrypting state + return ( + + + + ); + } + + // At this point we should have a playable state + return ( + + + + + ); + } +} diff --git a/src/components/views/messages/MImageBody.js b/src/components/views/messages/MImageBody.js index 7e85f15898..6da4aa1494 100644 --- a/src/components/views/messages/MImageBody.js +++ b/src/components/views/messages/MImageBody.js @@ -18,6 +18,7 @@ limitations under the License. import React, { createRef } from 'react'; import PropTypes from 'prop-types'; +import { Blurhash } from "react-blurhash"; import MFileBody from './MFileBody'; import Modal from '../../../Modal'; @@ -29,6 +30,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext"; import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; +import { BLURHASH_FIELD } from "../../../ContentMessages"; @replaceableComponent("views.messages.MImageBody") export default class MImageBody extends React.Component { @@ -333,7 +335,8 @@ export default class MImageBody extends React.Component { infoWidth = content.info.w; infoHeight = content.info.h; } else { - // Whilst the image loads, display nothing. + // Whilst the image loads, display nothing. We also don't display a blurhash image + // because we don't really know what size of image we'll end up with. // // Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`. // @@ -368,12 +371,8 @@ export default class MImageBody extends React.Component { let placeholder = null; let gifLabel = null; - // e2e image hasn't been decrypted yet - if (content.file !== undefined && this.state.decryptedUrl === null) { - placeholder = ; - } else if (!this.state.imgLoaded) { - // Deliberately, getSpinner is left unimplemented here, MStickerBody overides - placeholder = this.getPlaceholder(); + if (!this.state.imgLoaded) { + placeholder = this.getPlaceholder(maxWidth, maxHeight); } let showPlaceholder = Boolean(placeholder); @@ -395,7 +394,7 @@ export default class MImageBody extends React.Component { if (!this.state.showImage) { img = ; - showPlaceholder = false; // because we're hiding the image, so don't show the sticker icon. + showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. } if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { @@ -411,9 +410,7 @@ export default class MImageBody extends React.Component { // Constrain width here so that spinner appears central to the loaded thumbnail maxWidth: infoWidth + "px", }}> -
    - { placeholder } -
    + { placeholder }
    } @@ -437,9 +434,12 @@ export default class MImageBody extends React.Component { } // Overidden by MStickerBody - getPlaceholder() { - // MImageBody doesn't show a placeholder whilst the image loads, (but it could do) - return null; + getPlaceholder(width, height) { + const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD]; + if (blurhash) return ; + return
    + +
    ; } // Overidden by MStickerBody diff --git a/src/components/views/messages/MKeyVerificationRequest.tsx b/src/components/views/messages/MKeyVerificationRequest.tsx index d690513d55..c57cb5932d 100644 --- a/src/components/views/messages/MKeyVerificationRequest.tsx +++ b/src/components/views/messages/MKeyVerificationRequest.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; import { MatrixEvent } from 'matrix-js-sdk/src'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { getNameForEventRoom, userLabelForEventRoom } from '../../../utils/KeyVerificationStateObserver'; @@ -26,9 +25,10 @@ import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; import { Action } from "../../../dispatcher/actions"; import EventTileBubble from "./EventTileBubble"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AccessibleButton from '../elements/AccessibleButton'; interface IProps { - mxEvent: MatrixEvent + mxEvent: MatrixEvent; } @replaceableComponent("views.messages.MKeyVerificationRequest") @@ -115,8 +115,6 @@ export default class MKeyVerificationRequest extends React.Component { } public render() { - const AccessibleButton = sdk.getComponent("elements.AccessibleButton"); - const { mxEvent } = this.props; const request = mxEvent.verificationRequest; @@ -154,7 +152,7 @@ export default class MKeyVerificationRequest extends React.Component { {_t("Decline")} - + {_t("Accept")}
    ); diff --git a/src/components/views/messages/MStickerBody.js b/src/components/views/messages/MStickerBody.js index aca3dba37c..31af66baf5 100644 --- a/src/components/views/messages/MStickerBody.js +++ b/src/components/views/messages/MStickerBody.js @@ -18,6 +18,7 @@ import React from 'react'; import MImageBody from './MImageBody'; import * as sdk from '../../../index'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { BLURHASH_FIELD } from "../../../ContentMessages"; @replaceableComponent("views.messages.MStickerBody") export default class MStickerBody extends MImageBody { @@ -41,9 +42,9 @@ export default class MStickerBody extends MImageBody { // Placeholder to show in place of the sticker image if // img onLoad hasn't fired yet. - getPlaceholder() { - const TintableSVG = sdk.getComponent('elements.TintableSvg'); - return ; + getPlaceholder(width, height) { + if (this.props.mxEvent.getContent().info[BLURHASH_FIELD]) return super.getPlaceholder(width, height); + return ; } // Tooltip to show on mouse over diff --git a/src/components/views/messages/MVideoBody.tsx b/src/components/views/messages/MVideoBody.tsx index ef79e96370..d882bb1eb0 100644 --- a/src/components/views/messages/MVideoBody.tsx +++ b/src/components/views/messages/MVideoBody.tsx @@ -16,6 +16,8 @@ limitations under the License. */ import React from 'react'; +import { decode } from "blurhash"; + import MFileBody from './MFileBody'; import { decryptFile } from '../../../utils/DecryptFile'; import { _t } from '../../../languageHandler'; @@ -23,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import InlineSpinner from '../elements/InlineSpinner'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromContent } from "../../../customisations/Media"; +import { BLURHASH_FIELD } from "../../../ContentMessages"; interface IProps { /* the MatrixEvent to show */ @@ -32,11 +35,13 @@ interface IProps { } interface IState { - decryptedUrl: string|null, - decryptedThumbnailUrl: string|null, - decryptedBlob: Blob|null, - error: any|null, - fetchingData: boolean, + decryptedUrl?: string; + decryptedThumbnailUrl?: string; + decryptedBlob?: Blob; + error?: any; + fetchingData: boolean; + posterLoading: boolean; + blurhashUrl: string; } @replaceableComponent("views.messages.MVideoBody") @@ -51,10 +56,12 @@ export default class MVideoBody extends React.PureComponent { decryptedThumbnailUrl: null, decryptedBlob: null, error: null, + posterLoading: false, + blurhashUrl: null, }; } - thumbScale(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number) { + thumbScale(fullWidth: number, fullHeight: number, thumbWidth = 480, thumbHeight = 360) { if (!fullWidth || !fullHeight) { // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // log this because it's spammy @@ -92,8 +99,11 @@ export default class MVideoBody extends React.PureComponent { private getThumbUrl(): string|null { const content = this.props.mxEvent.getContent(); const media = mediaFromContent(content); - if (media.isEncrypted) { + + if (media.isEncrypted && this.state.decryptedThumbnailUrl) { return this.state.decryptedThumbnailUrl; + } else if (this.state.posterLoading) { + return this.state.blurhashUrl; } else if (media.hasThumbnail) { return media.thumbnailHttp; } else { @@ -101,18 +111,57 @@ export default class MVideoBody extends React.PureComponent { } } + private loadBlurhash() { + const info = this.props.mxEvent.getContent()?.info; + if (!info[BLURHASH_FIELD]) return; + + const canvas = document.createElement("canvas"); + + let width = info.w; + let height = info.h; + const scale = this.thumbScale(info.w, info.h); + if (scale) { + width = Math.floor(info.w * scale); + height = Math.floor(info.h * scale); + } + + canvas.width = width; + canvas.height = height; + + const pixels = decode(info[BLURHASH_FIELD], width, height); + const ctx = canvas.getContext("2d"); + const imgData = ctx.createImageData(width, height); + imgData.data.set(pixels); + ctx.putImageData(imgData, 0, 0); + + this.setState({ + blurhashUrl: canvas.toDataURL(), + posterLoading: true, + }); + + const content = this.props.mxEvent.getContent(); + const media = mediaFromContent(content); + if (media.hasThumbnail) { + const image = new Image(); + image.onload = () => { + this.setState({ posterLoading: false }); + }; + image.src = media.thumbnailHttp; + } + } + async componentDidMount() { const autoplay = SettingsStore.getValue("autoplayGifsAndVideos") as boolean; const content = this.props.mxEvent.getContent(); + this.loadBlurhash(); + if (content.file !== undefined && this.state.decryptedUrl === null) { let thumbnailPromise = Promise.resolve(null); - if (content.info && content.info.thumbnail_file) { - thumbnailPromise = decryptFile( - content.info.thumbnail_file, - ).then(function(blob) { - return URL.createObjectURL(blob); - }); + if (content?.info?.thumbnail_file) { + thumbnailPromise = decryptFile(content.info.thumbnail_file) + .then(blob => URL.createObjectURL(blob)); } + try { const thumbnailUrl = await thumbnailPromise; if (autoplay) { @@ -218,7 +267,7 @@ export default class MVideoBody extends React.PureComponent { let poster = null; let preload = "metadata"; if (content.info) { - const scale = this.thumbScale(content.info.w, content.info.h, 480, 360); + const scale = this.thumbScale(content.info.w, content.info.h); if (scale) { width = Math.floor(content.info.w * scale); height = Math.floor(content.info.h * scale); diff --git a/src/components/views/messages/MVoiceMessageBody.tsx b/src/components/views/messages/MVoiceMessageBody.tsx index edb791f7ee..2edd42f2e4 100644 --- a/src/components/views/messages/MVoiceMessageBody.tsx +++ b/src/components/views/messages/MVoiceMessageBody.tsx @@ -23,7 +23,7 @@ import InlineSpinner from '../elements/InlineSpinner'; import { _t } from "../../../languageHandler"; import { mediaFromContent } from "../../../customisations/Media"; import { decryptFile } from "../../../utils/DecryptFile"; -import RecordingPlayback from "../voice_messages/RecordingPlayback"; +import RecordingPlayback from "../audio_messages/RecordingPlayback"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; interface IProps { diff --git a/src/components/views/messages/SenderProfile.tsx b/src/components/views/messages/SenderProfile.tsx index 11c3ca4e3c..bdae9cec4a 100644 --- a/src/components/views/messages/SenderProfile.tsx +++ b/src/components/views/messages/SenderProfile.tsx @@ -24,7 +24,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; interface IProps { mxEvent: MatrixEvent; - onClick(): void; + onClick?(): void; enableFlair: boolean; } diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.tsx similarity index 78% rename from src/components/views/messages/TextualBody.js rename to src/components/views/messages/TextualBody.tsx index ffaaaada4d..9c2786c642 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.tsx @@ -1,7 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 New Vector Ltd -Copyright 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. @@ -16,134 +14,151 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { createRef, SyntheticEvent } from 'react'; import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; import highlight from 'highlight.js'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { MsgType } from "matrix-js-sdk/src/@types/event"; + import * as HtmlUtils from '../../../HtmlUtils'; import { formatDate } from '../../../DateUtils'; -import * as sdk from '../../../index'; import Modal from '../../../Modal'; import dis from '../../../dispatcher/dispatcher'; import { _t } from '../../../languageHandler'; import * as ContextMenu from '../../structures/ContextMenu'; +import { toRightOf } from '../../structures/ContextMenu'; import SettingsStore from "../../../settings/SettingsStore"; import ReplyThread from "../elements/ReplyThread"; import { pillifyLinks, unmountPills } from '../../../utils/pillify'; import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import { isPermalinkHost } from "../../../utils/permalinks/Permalinks"; -import { toRightOf } from "../../structures/ContextMenu"; import { copyPlaintext } from "../../../utils/strings"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import UIStore from "../../../stores/UIStore"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; +import { TileShape } from '../rooms/EventTile'; +import EditorStateTransfer from "../../../utils/EditorStateTransfer"; +import GenericTextContextMenu from "../context_menus/GenericTextContextMenu"; +import Spoiler from "../elements/Spoiler"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import MessageEditHistoryDialog from "../dialogs/MessageEditHistoryDialog"; +import EditMessageComposer from '../rooms/EditMessageComposer'; +import LinkPreviewGroup from '../rooms/LinkPreviewGroup'; + +interface IProps { + /* the MatrixEvent to show */ + mxEvent: MatrixEvent; + + /* a list of words to highlight */ + highlights?: string[]; + + /* link URL for the highlights */ + highlightLink?: string; + + /* should show URL previews for this event */ + showUrlPreview?: boolean; + + /* the shape of the tile, used */ + tileShape?: TileShape; + + editState?: EditorStateTransfer; + replacingEventId?: string; + + /* callback for when our widget has loaded */ + onHeightChanged(): void; +} + +interface IState { + // the URLs (if any) to be previewed with a LinkPreviewWidget inside this TextualBody. + links: string[]; + + // track whether the preview widget is hidden + widgetHidden: boolean; +} @replaceableComponent("views.messages.TextualBody") -export default class TextualBody extends React.Component { - static propTypes = { - /* the MatrixEvent to show */ - mxEvent: PropTypes.object.isRequired, +export default class TextualBody extends React.Component { + private readonly contentRef = createRef(); - /* a list of words to highlight */ - highlights: PropTypes.array, - - /* link URL for the highlights */ - highlightLink: PropTypes.string, - - /* should show URL previews for this event */ - showUrlPreview: PropTypes.bool, - - /* callback for when our widget has loaded */ - onHeightChanged: PropTypes.func, - - /* the shape of the tile, used */ - tileShape: PropTypes.string, - }; + private unmounted = false; + private pills: Element[] = []; constructor(props) { super(props); - this._content = createRef(); - this.state = { - // the URLs (if any) to be previewed with a LinkPreviewWidget - // inside this TextualBody. links: [], - - // track whether the preview widget is hidden widgetHidden: false, }; } componentDidMount() { - this._unmounted = false; - this._pills = []; if (!this.props.editState) { - this._applyFormatting(); + this.applyFormatting(); } } - _applyFormatting() { + private applyFormatting(): void { const showLineNumbers = SettingsStore.getValue("showCodeLineNumbers"); - this.activateSpoilers([this._content.current]); + this.activateSpoilers([this.contentRef.current]); // pillifyLinks BEFORE linkifyElement because plain room/user URLs in the composer // are still sent as plaintext URLs. If these are ever pillified in the composer, // we should be pillify them here by doing the linkifying BEFORE the pillifying. - pillifyLinks([this._content.current], this.props.mxEvent, this._pills); - HtmlUtils.linkifyElement(this._content.current); + pillifyLinks([this.contentRef.current], this.props.mxEvent, this.pills); + HtmlUtils.linkifyElement(this.contentRef.current); this.calculateUrlPreview(); if (this.props.mxEvent.getContent().format === "org.matrix.custom.html") { // Handle expansion and add buttons - const pres = ReactDOM.findDOMNode(this).getElementsByTagName("pre"); + const pres = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("pre"); if (pres.length > 0) { for (let i = 0; i < pres.length; i++) { // If there already is a div wrapping the codeblock we want to skip this. // This happens after the codeblock was edited. - if (pres[i].parentNode.className == "mx_EventTile_pre_container") continue; + if (pres[i].parentElement.className == "mx_EventTile_pre_container") continue; // Add code element if it's missing since we depend on it if (pres[i].getElementsByTagName("code").length == 0) { - this._addCodeElement(pres[i]); + this.addCodeElement(pres[i]); } // Wrap a div around
     so that the copy button can be correctly positioned
                         // when the 
     overflows and is scrolled horizontally.
    -                    const div = this._wrapInDiv(pres[i]);
    -                    this._handleCodeBlockExpansion(pres[i]);
    -                    this._addCodeExpansionButton(div, pres[i]);
    -                    this._addCodeCopyButton(div);
    +                    const div = this.wrapInDiv(pres[i]);
    +                    this.handleCodeBlockExpansion(pres[i]);
    +                    this.addCodeExpansionButton(div, pres[i]);
    +                    this.addCodeCopyButton(div);
                         if (showLineNumbers) {
    -                        this._addLineNumbers(pres[i]);
    +                        this.addLineNumbers(pres[i]);
                         }
                     }
                 }
                 // Highlight code
    -            const codes = ReactDOM.findDOMNode(this).getElementsByTagName("code");
    +            const codes = (ReactDOM.findDOMNode(this) as Element).getElementsByTagName("code");
                 if (codes.length > 0) {
                     // Do this asynchronously: parsing code takes time and we don't
                     // need to block the DOM update on it.
                     setTimeout(() => {
    -                    if (this._unmounted) return;
    +                    if (this.unmounted) return;
                         for (let i = 0; i < codes.length; i++) {
                             // If the code already has the hljs class we want to skip this.
                             // This happens after the codeblock was edited.
                             if (codes[i].className.includes("hljs")) continue;
    -                        this._highlightCode(codes[i]);
    +                        this.highlightCode(codes[i]);
                         }
                     }, 10);
                 }
             }
         }
     
    -    _addCodeElement(pre) {
    +    private addCodeElement(pre: HTMLPreElement): void {
             const code = document.createElement("code");
             code.append(...pre.childNodes);
             pre.appendChild(code);
         }
     
    -    _addCodeExpansionButton(div, pre) {
    +    private addCodeExpansionButton(div: HTMLDivElement, pre: HTMLPreElement): void {
             // Calculate how many percent does the pre element take up.
             // If it's less than 30% we don't add the expansion button.
             const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
    @@ -175,7 +190,7 @@ export default class TextualBody extends React.Component {
             div.appendChild(button);
         }
     
    -    _addCodeCopyButton(div) {
    +    private addCodeCopyButton(div: HTMLDivElement): void {
             const button = document.createElement("span");
             button.className = "mx_EventTile_button mx_EventTile_copyButton ";
     
    @@ -185,11 +200,10 @@ export default class TextualBody extends React.Component {
             if (expansionButtonExists.length > 0) button.className += "mx_EventTile_buttonBottom";
     
             button.onclick = async () => {
    -            const copyCode = button.parentNode.getElementsByTagName("code")[0];
    +            const copyCode = button.parentElement.getElementsByTagName("code")[0];
                 const successful = await copyPlaintext(copyCode.textContent);
     
                 const buttonRect = button.getBoundingClientRect();
    -            const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu');
                 const { close } = ContextMenu.createMenu(GenericTextContextMenu, {
                     ...toRightOf(buttonRect, 2),
                     message: successful ? _t('Copied!') : _t('Failed to copy'),
    @@ -200,7 +214,7 @@ export default class TextualBody extends React.Component {
             div.appendChild(button);
         }
     
    -    _wrapInDiv(pre) {
    +    private wrapInDiv(pre: HTMLPreElement): HTMLDivElement {
             const div = document.createElement("div");
             div.className = "mx_EventTile_pre_container";
     
    @@ -212,13 +226,13 @@ export default class TextualBody extends React.Component {
             return div;
         }
     
    -    _handleCodeBlockExpansion(pre) {
    +    private handleCodeBlockExpansion(pre: HTMLPreElement): void {
             if (!SettingsStore.getValue("expandCodeByDefault")) {
                 pre.className = "mx_EventTile_collapsedCodeBlock";
             }
         }
     
    -    _addLineNumbers(pre) {
    +    private addLineNumbers(pre: HTMLPreElement): void {
             // Calculate number of lines in pre
             const number = pre.innerHTML.replace(/\n(<\/code>)?$/, "").split(/\n/).length;
             pre.innerHTML = '' + pre.innerHTML + '';
    @@ -229,7 +243,7 @@ export default class TextualBody extends React.Component {
             }
         }
     
    -    _highlightCode(code) {
    +    private highlightCode(code: HTMLElement): void {
             if (SettingsStore.getValue("enableSyntaxHighlightLanguageDetection")) {
                 highlight.highlightBlock(code);
             } else {
    @@ -249,14 +263,14 @@ export default class TextualBody extends React.Component {
                 const stoppedEditing = prevProps.editState && !this.props.editState;
                 const messageWasEdited = prevProps.replacingEventId !== this.props.replacingEventId;
                 if (messageWasEdited || stoppedEditing) {
    -                this._applyFormatting();
    +                this.applyFormatting();
                 }
             }
         }
     
         componentWillUnmount() {
    -        this._unmounted = true;
    -        unmountPills(this._pills);
    +        this.unmounted = true;
    +        unmountPills(this.pills);
         }
     
         shouldComponentUpdate(nextProps, nextState) {
    @@ -273,26 +287,20 @@ export default class TextualBody extends React.Component {
                     nextState.widgetHidden !== this.state.widgetHidden);
         }
     
    -    calculateUrlPreview() {
    +    private calculateUrlPreview(): void {
             //console.info("calculateUrlPreview: ShowUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview);
     
             if (this.props.showUrlPreview) {
                 // pass only the first child which is the event tile otherwise this recurses on edited events
    -            let links = this.findLinks([this._content.current]);
    +            let links = this.findLinks([this.contentRef.current]);
                 if (links.length) {
    -                // de-duplicate the links after stripping hashes as they don't affect the preview
    -                // using a set here maintains the order
    -                links = Array.from(new Set(links.map(link => {
    -                    const url = new URL(link);
    -                    url.hash = "";
    -                    return url.toString();
    -                })));
    -
    +                // de-duplicate the links using a set here maintains the order
    +                links = Array.from(new Set(links));
                     this.setState({ links });
     
                     // lazy-load the hidden state of the preview widget from localstorage
    -                if (global.localStorage) {
    -                    const hidden = global.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
    +                if (window.localStorage) {
    +                    const hidden = !!window.localStorage.getItem("hide_preview_" + this.props.mxEvent.getId());
                         this.setState({ widgetHidden: hidden });
                     }
                 } else if (this.state.links.length) {
    @@ -301,19 +309,15 @@ export default class TextualBody extends React.Component {
             }
         }
     
    -    activateSpoilers(nodes) {
    +    private activateSpoilers(nodes: ArrayLike): void {
             let node = nodes[0];
             while (node) {
                 if (node.tagName === "SPAN" && typeof node.getAttribute("data-mx-spoiler") === "string") {
                     const spoilerContainer = document.createElement('span');
     
                     const reason = node.getAttribute("data-mx-spoiler");
    -                const Spoiler = sdk.getComponent('elements.Spoiler');
                     node.removeAttribute("data-mx-spoiler"); // we don't want to recurse
    -                const spoiler = ;
    +                const spoiler = ;
     
                     ReactDOM.render(spoiler, spoilerContainer);
                     node.parentNode.replaceChild(spoilerContainer, node);
    @@ -322,15 +326,15 @@ export default class TextualBody extends React.Component {
                 }
     
                 if (node.childNodes && node.childNodes.length) {
    -                this.activateSpoilers(node.childNodes);
    +                this.activateSpoilers(node.childNodes as NodeListOf);
                 }
     
    -            node = node.nextSibling;
    +            node = node.nextSibling as Element;
             }
         }
     
    -    findLinks(nodes) {
    -        let links = [];
    +    private findLinks(nodes: ArrayLike): string[] {
    +        let links: string[] = [];
     
             for (let i = 0; i < nodes.length; i++) {
                 const node = nodes[i];
    @@ -348,7 +352,7 @@ export default class TextualBody extends React.Component {
             return links;
         }
     
    -    isLinkPreviewable(node) {
    +    private isLinkPreviewable(node: Element): boolean {
             // don't try to preview relative links
             if (!node.getAttribute("href").startsWith("http://") &&
                 !node.getAttribute("href").startsWith("https://")) {
    @@ -381,7 +385,7 @@ export default class TextualBody extends React.Component {
             }
         }
     
    -    onCancelClick = event => {
    +    private onCancelClick = (): void => {
             this.setState({ widgetHidden: true });
             // FIXME: persist this somewhere smarter than local storage
             if (global.localStorage) {
    @@ -390,7 +394,7 @@ export default class TextualBody extends React.Component {
             this.forceUpdate();
         };
     
    -    onEmoteSenderClick = event => {
    +    private onEmoteSenderClick = (): void => {
             const mxEvent = this.props.mxEvent;
             dis.dispatch({
                 action: Action.ComposerInsert,
    @@ -398,7 +402,7 @@ export default class TextualBody extends React.Component {
             });
         };
     
    -    getEventTileOps = () => ({
    +    public getEventTileOps = () => ({
             isWidgetHidden: () => {
                 return this.state.widgetHidden;
             },
    @@ -411,7 +415,7 @@ export default class TextualBody extends React.Component {
             },
         });
     
    -    onStarterLinkClick = (starterLink, ev) => {
    +    private onStarterLinkClick = (starterLink: string, ev: SyntheticEvent): void => {
             ev.preventDefault();
             // We need to add on our scalar token to the starter link, but we may not have one!
             // In addition, we can't fetch one on click and then go to it immediately as that
    @@ -431,7 +435,6 @@ export default class TextualBody extends React.Component {
             const scalarClient = integrationManager.getScalarClient();
             scalarClient.connect().then(() => {
                 const completeUrl = scalarClient.getStarterLink(starterLink);
    -            const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
                 const integrationsUrl = integrationManager.uiUrl;
                 Modal.createTrackedDialog('Add an integration', '', QuestionDialog, {
                     title: _t("Add an Integration"),
    @@ -458,12 +461,11 @@ export default class TextualBody extends React.Component {
             });
         };
     
    -    _openHistoryDialog = async () => {
    -        const MessageEditHistoryDialog = sdk.getComponent("views.dialogs.MessageEditHistoryDialog");
    +    private openHistoryDialog = async (): Promise => {
             Modal.createDialog(MessageEditHistoryDialog, { mxEvent: this.props.mxEvent });
         };
     
    -    _renderEditedMarker() {
    +    private renderEditedMarker() {
             const date = this.props.mxEvent.replacingEventDate();
             const dateString = date && formatDate(date);
     
    @@ -479,7 +481,7 @@ export default class TextualBody extends React.Component {
             return (
                 
    @@ -490,24 +492,25 @@ export default class TextualBody extends React.Component {
     
         render() {
             if (this.props.editState) {
    -            const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer');
                 return ;
             }
             const mxEvent = this.props.mxEvent;
             const content = mxEvent.getContent();
     
             // only strip reply if this is the original replying event, edits thereafter do not have the fallback
    -        const stripReply = !mxEvent.replacingEvent() && ReplyThread.getParentEventId(mxEvent);
    +        const stripReply = !mxEvent.replacingEvent() && !!ReplyThread.getParentEventId(mxEvent);
             let body = HtmlUtils.bodyToHtml(content, this.props.highlights, {
    -            disableBigEmoji: content.msgtype === "m.emote" || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
    +            disableBigEmoji: content.msgtype === MsgType.Emote
    +                || !SettingsStore.getValue('TextualBody.enableBigEmoji'),
                 // Part of Replies fallback support
                 stripReplyFallback: stripReply,
    -            ref: this._content,
    +            ref: this.contentRef,
    +            returnString: false,
             });
             if (this.props.replacingEventId) {
                 body = <>
                     {body}
    -                {this._renderEditedMarker()}
    +                {this.renderEditedMarker()}
                 ;
             }
     
    @@ -521,20 +524,16 @@ export default class TextualBody extends React.Component {
     
             let widgets;
             if (this.state.links.length && !this.state.widgetHidden && this.props.showUrlPreview) {
    -            const LinkPreviewWidget = sdk.getComponent('rooms.LinkPreviewWidget');
    -            widgets = this.state.links.map((link)=>{
    -                return ;
    -            });
    +            widgets = ;
             }
     
             switch (content.msgtype) {
    -            case "m.emote":
    +            case MsgType.Emote:
                     return (
                         
                             * 
    @@ -549,7 +548,7 @@ export default class TextualBody extends React.Component {
                             { widgets }
                         
                     );
    -            case "m.notice":
    +            case MsgType.Notice:
                     return (
                         
                             { body }
    diff --git a/src/components/views/messages/TextualEvent.tsx b/src/components/views/messages/TextualEvent.tsx
    index beaf605e1f..8fc116b5d0 100644
    --- a/src/components/views/messages/TextualEvent.tsx
    +++ b/src/components/views/messages/TextualEvent.tsx
    @@ -1,6 +1,5 @@
     /*
    -Copyright 2015, 2016 OpenMarket Ltd
    -Copyright 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.
    @@ -17,12 +16,12 @@ limitations under the License.
     
     import React from "react";
     import { MatrixEvent } from "matrix-js-sdk/src/models/event";
    +
     import RoomContext from "../../../contexts/RoomContext";
     import * as TextForEvent from "../../../TextForEvent";
     import { replaceableComponent } from "../../../utils/replaceableComponent";
     
     interface IProps {
    -    // The event to show
         mxEvent: MatrixEvent;
     }
     
    diff --git a/src/components/views/right_panel/EncryptionInfo.tsx b/src/components/views/right_panel/EncryptionInfo.tsx
    index c34cf18710..e74caf8457 100644
    --- a/src/components/views/right_panel/EncryptionInfo.tsx
    +++ b/src/components/views/right_panel/EncryptionInfo.tsx
    @@ -16,13 +16,13 @@ limitations under the License.
     
     import React from "react";
     
    -import * as sdk from "../../../index";
     import { _t } from "../../../languageHandler";
     import { RoomMember } from "matrix-js-sdk/src/models/room-member";
     import { User } from "matrix-js-sdk/src/models/user";
    +import AccessibleButton from "../elements/AccessibleButton";
    +import Spinner from "../elements/Spinner";
     
     export const PendingActionSpinner = ({ text }) => {
    -    const Spinner = sdk.getComponent('elements.Spinner');
         return 
    { text } @@ -64,7 +64,6 @@ const EncryptionInfo: React.FC = ({ } content = ; } else { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); content = ( {_t("Start Verification")} diff --git a/src/components/views/right_panel/EncryptionPanel.tsx b/src/components/views/right_panel/EncryptionPanel.tsx index 3a26427246..9ed791c229 100644 --- a/src/components/views/right_panel/EncryptionPanel.tsx +++ b/src/components/views/right_panel/EncryptionPanel.tsx @@ -39,9 +39,8 @@ interface IProps { member: RoomMember | User; onClose: () => void; verificationRequest: VerificationRequest; - verificationRequestPromise: Promise; + verificationRequestPromise?: Promise; layout: string; - inDialog: boolean; isRoomEncrypted: boolean; } @@ -82,6 +81,7 @@ const EncryptionPanel: React.FC = (props: IProps) => { const changeHandler = useCallback(() => { // handle transitions -> cancelled for mismatches which fire a modal instead of showing a card if (request && request.cancelled && MISMATCHES.includes(request.cancellationCode)) { + // FIXME: Using an import will result in test failures const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog("Verification failed", "insecure", ErrorDialog, { headerImage: require("../../../../res/img/e2e/warning.svg"), diff --git a/src/components/views/right_panel/VerificationPanel.tsx b/src/components/views/right_panel/VerificationPanel.tsx index 6087923057..a4d4d2fa30 100644 --- a/src/components/views/right_panel/VerificationPanel.tsx +++ b/src/components/views/right_panel/VerificationPanel.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from "react"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import * as sdk from '../../../index'; import { verificationMethods } from 'matrix-js-sdk/src/crypto'; import { SCAN_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; @@ -38,6 +37,8 @@ import { } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import Spinner from "../elements/Spinner"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import AccessibleButton from "../elements/AccessibleButton"; +import VerificationShowSas from "../verification/VerificationShowSas"; // XXX: Should be defined in matrix-js-sdk enum VerificationPhase { @@ -81,7 +82,6 @@ export default class VerificationPanel extends React.PureComponent

    {_t("Verified")}

    @@ -282,8 +280,6 @@ export default class VerificationPanel extends React.PureComponent void, - fullHeight: boolean, + onResize: () => void; + fullHeight: boolean; - resizeNotifier: ResizeNotifier, + resizeNotifier: ResizeNotifier; } interface Counter { - title: string, - value: number, - link: string, - severity: string, - stateKey: string, + title: string; + value: number; + link: string; + severity: string; + stateKey: string; } interface IState { - counters: Counter[], + counters: Counter[]; } @replaceableComponent("views.rooms.AuxPanel") @@ -99,9 +99,9 @@ export default class AuxPanel extends React.Component { } } - private rateLimitedUpdate = new RateLimitedFunc(() => { + private rateLimitedUpdate = throttle(() => { this.setState({ counters: this.computeCounters() }); - }, 500); + }, 500, { leading: true, trailing: true }); private computeCounters() { const counters = []; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 94a292afe7..3258674cf6 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -41,7 +41,7 @@ import { Key } from "../../../Keyboard"; import { EMOTICON_TO_EMOJI } from "../../../emoji"; import { CommandCategories, CommandMap, parseCommandString } from "../../../SlashCommands"; import Range from "../../../editor/range"; -import MessageComposerFormatBar from "./MessageComposerFormatBar"; +import MessageComposerFormatBar, { Formatting } from "./MessageComposerFormatBar"; import DocumentOffset from "../../../editor/offset"; import { IDiff } from "../../../editor/diff"; import AutocompleteWrapperModel from "../../../editor/autocomplete"; @@ -55,7 +55,7 @@ const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.sourc const IS_MAC = navigator.platform.indexOf("Mac") !== -1; -function ctrlShortcutLabel(key) { +function ctrlShortcutLabel(key: string): string { return (IS_MAC ? "⌘" : "Ctrl") + "+" + key; } @@ -81,14 +81,6 @@ function selectionEquals(a: Partial, b: Selection): boolean { a.type === b.type; } -enum Formatting { - Bold = "bold", - Italics = "italics", - Strikethrough = "strikethrough", - Code = "code", - Quote = "quote", -} - interface IProps { model: EditorModel; room: Room; @@ -111,9 +103,9 @@ interface IState { @replaceableComponent("views.rooms.BasicMessageEditor") export default class BasicMessageEditor extends React.Component { - private editorRef = createRef(); + public readonly editorRef = createRef(); private autocompleteRef = createRef(); - private formatBarRef = createRef(); + private formatBarRef = createRef(); private modifiedFlag = false; private isIMEComposing = false; @@ -156,7 +148,7 @@ export default class BasicMessageEditor extends React.Component } } - private replaceEmoticon = (caretPosition: DocumentPosition) => { + private replaceEmoticon = (caretPosition: DocumentPosition): number => { const { model } = this.props; const range = model.startRange(caretPosition); // expand range max 8 characters backwards from caretPosition, @@ -188,7 +180,7 @@ export default class BasicMessageEditor extends React.Component } }; - private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff) => { + private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { renderModel(this.editorRef.current, this.props.model); if (selection) { // set the caret/selection try { @@ -230,25 +222,25 @@ export default class BasicMessageEditor extends React.Component } }; - private showPlaceholder() { + private showPlaceholder(): void { // escape single quotes const placeholder = this.props.placeholder.replace(/'/g, '\\\''); this.editorRef.current.style.setProperty("--placeholder", `'${placeholder}'`); this.editorRef.current.classList.add("mx_BasicMessageComposer_inputEmpty"); } - private hidePlaceholder() { + private hidePlaceholder(): void { this.editorRef.current.classList.remove("mx_BasicMessageComposer_inputEmpty"); this.editorRef.current.style.removeProperty("--placeholder"); } - private onCompositionStart = () => { + private onCompositionStart = (): void => { this.isIMEComposing = true; // even if the model is empty, the composition text shouldn't be mixed with the placeholder this.hidePlaceholder(); }; - private onCompositionEnd = () => { + private onCompositionEnd = (): void => { this.isIMEComposing = false; // some browsers (Chrome) don't fire an input event after ending a composition, // so trigger a model update after the composition is done by calling the input handler. @@ -271,14 +263,14 @@ export default class BasicMessageEditor extends React.Component } }; - isComposing(event: React.KeyboardEvent) { + public isComposing(event: React.KeyboardEvent): boolean { // checking the event.isComposing flag just in case any browser out there // emits events related to the composition after compositionend // has been fired return !!(this.isIMEComposing || (event.nativeEvent && event.nativeEvent.isComposing)); } - private onCutCopy = (event: ClipboardEvent, type: string) => { + private onCutCopy = (event: ClipboardEvent, type: string): void => { const selection = document.getSelection(); const text = selection.toString(); if (text) { @@ -296,15 +288,15 @@ export default class BasicMessageEditor extends React.Component } }; - private onCopy = (event: ClipboardEvent) => { + private onCopy = (event: ClipboardEvent): void => { this.onCutCopy(event, "copy"); }; - private onCut = (event: ClipboardEvent) => { + private onCut = (event: ClipboardEvent): void => { this.onCutCopy(event, "cut"); }; - private onPaste = (event: ClipboardEvent) => { + private onPaste = (event: ClipboardEvent): boolean => { event.preventDefault(); // we always handle the paste ourselves if (this.props.onPaste && this.props.onPaste(event, this.props.model)) { // to prevent double handling, allow props.onPaste to skip internal onPaste @@ -328,7 +320,7 @@ export default class BasicMessageEditor extends React.Component replaceRangeAndMoveCaret(range, parts); }; - private onInput = (event: Partial) => { + private onInput = (event: Partial): void => { // ignore any input while doing IME compositions if (this.isIMEComposing) { return; @@ -339,7 +331,7 @@ export default class BasicMessageEditor extends React.Component this.props.model.update(text, event.inputType, caret); }; - private insertText(textToInsert: string, inputType = "insertText") { + private insertText(textToInsert: string, inputType = "insertText"): void { const sel = document.getSelection(); const { caret, text } = getCaretOffsetAndText(this.editorRef.current, sel); const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset); @@ -353,14 +345,14 @@ export default class BasicMessageEditor extends React.Component // we don't need to. But if the user is navigating the caret without input // we need to recalculate it, to be able to know where to insert content after // losing focus - private setLastCaretFromPosition(position: DocumentPosition) { + private setLastCaretFromPosition(position: DocumentPosition): void { const { model } = this.props; this._isCaretAtEnd = position.isAtEnd(model); this.lastCaret = position.asOffset(model); this.lastSelection = cloneSelection(document.getSelection()); } - private refreshLastCaretIfNeeded() { + private refreshLastCaretIfNeeded(): DocumentOffset { // XXX: needed when going up and down in editing messages ... not sure why yet // because the editors should stop doing this when when blurred ... // maybe it's on focus and the _editorRef isn't available yet or something. @@ -377,38 +369,38 @@ export default class BasicMessageEditor extends React.Component return this.lastCaret; } - clearUndoHistory() { + public clearUndoHistory(): void { this.historyManager.clear(); } - getCaret() { + public getCaret(): DocumentOffset { return this.lastCaret; } - isSelectionCollapsed() { + public isSelectionCollapsed(): boolean { return !this.lastSelection || this.lastSelection.isCollapsed; } - isCaretAtStart() { + public isCaretAtStart(): boolean { return this.getCaret().offset === 0; } - isCaretAtEnd() { + public isCaretAtEnd(): boolean { return this._isCaretAtEnd; } - private onBlur = () => { + private onBlur = (): void => { document.removeEventListener("selectionchange", this.onSelectionChange); }; - private onFocus = () => { + private onFocus = (): void => { document.addEventListener("selectionchange", this.onSelectionChange); // force to recalculate this.lastSelection = null; this.refreshLastCaretIfNeeded(); }; - private onSelectionChange = () => { + private onSelectionChange = (): void => { const { isEmpty } = this.props.model; this.refreshLastCaretIfNeeded(); @@ -427,7 +419,7 @@ export default class BasicMessageEditor extends React.Component } }; - private onKeyDown = (event: React.KeyboardEvent) => { + private onKeyDown = (event: React.KeyboardEvent): void => { const model = this.props.model; let handled = false; const action = getKeyBindingsManager().getMessageComposerAction(event); @@ -523,7 +515,7 @@ export default class BasicMessageEditor extends React.Component } }; - private async tabCompleteName() { + private async tabCompleteName(): Promise { try { await new Promise(resolve => this.setState({ showVisualBell: false }, resolve)); const { model } = this.props; @@ -557,27 +549,27 @@ export default class BasicMessageEditor extends React.Component } } - isModified() { + public isModified(): boolean { return this.modifiedFlag; } - private onAutoCompleteConfirm = (completion: ICompletion) => { + private onAutoCompleteConfirm = (completion: ICompletion): void => { this.modifiedFlag = true; this.props.model.autoComplete.onComponentConfirm(completion); }; - private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number) => { + private onAutoCompleteSelectionChange = (completion: ICompletion, completionIndex: number): void => { this.modifiedFlag = true; this.props.model.autoComplete.onComponentSelectionChange(completion); this.setState({ completionIndex }); }; - private configureEmoticonAutoReplace = () => { + private configureEmoticonAutoReplace = (): void => { const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null); }; - private configureShouldShowPillAvatar = () => { + private configureShouldShowPillAvatar = (): void => { const showPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar"); this.setState({ showPillAvatar }); }; @@ -611,8 +603,8 @@ export default class BasicMessageEditor extends React.Component this.editorRef.current.focus(); } - private getInitialCaretPosition() { - let caretPosition; + private getInitialCaretPosition(): DocumentPosition { + let caretPosition: DocumentPosition; if (this.props.initialCaret) { // if restoring state from a previous editor, // restore caret position from the state @@ -625,7 +617,7 @@ export default class BasicMessageEditor extends React.Component return caretPosition; } - private onFormatAction = (action: Formatting) => { + private onFormatAction = (action: Formatting): void => { const range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); // trim the range as we want it to exclude leading/trailing spaces range.trim(); @@ -680,9 +672,9 @@ export default class BasicMessageEditor extends React.Component }); const shortcuts = { - bold: ctrlShortcutLabel("B"), - italics: ctrlShortcutLabel("I"), - quote: ctrlShortcutLabel(">"), + [Formatting.Bold]: ctrlShortcutLabel("B"), + [Formatting.Italics]: ctrlShortcutLabel("I"), + [Formatting.Quote]: ctrlShortcutLabel(">"), }; const { completionIndex } = this.state; @@ -714,11 +706,12 @@ export default class BasicMessageEditor extends React.Component
    ); } - focus() { + public focus(): void { this.editorRef.current.focus(); } - public insertMention(userId: string) { + public insertMention(userId: string): void { + this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; const member = this.props.room.getMember(userId); @@ -736,7 +729,8 @@ export default class BasicMessageEditor extends React.Component this.focus(); } - public insertQuotedMessage(event: MatrixEvent) { + public insertQuotedMessage(event: MatrixEvent): void { + this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); @@ -751,7 +745,8 @@ export default class BasicMessageEditor extends React.Component this.focus(); } - public insertPlaintext(text: string) { + public insertPlaintext(text: string): void { + this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; const caret = this.getCaret(); diff --git a/src/components/views/rooms/EditMessageComposer.js b/src/components/views/rooms/EditMessageComposer.tsx similarity index 65% rename from src/components/views/rooms/EditMessageComposer.js rename to src/components/views/rooms/EditMessageComposer.tsx index 0ab972b5f1..e4b13e2155 100644 --- a/src/components/views/rooms/EditMessageComposer.js +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -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. @@ -14,37 +13,42 @@ 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 * as sdk from '../../../index'; + +import React, { createRef, KeyboardEvent } from 'react'; +import classNames from 'classnames'; +import { EventStatus, IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event'; + import { _t, _td } from '../../../languageHandler'; -import PropTypes from 'prop-types'; import dis from '../../../dispatcher/dispatcher'; import EditorModel from '../../../editor/model'; import { getCaretOffsetAndText } from '../../../editor/dom'; import { htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand } from '../../../editor/serialize'; import { findEditableEvent } from '../../../utils/EventUtils'; import { parseEvent } from '../../../editor/deserialize'; -import { CommandPartCreator } from '../../../editor/parts'; +import { CommandPartCreator, Part, PartCreator } from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; -import classNames from 'classnames'; -import { EventStatus } from 'matrix-js-sdk/src/models/event'; import BasicMessageComposer from "./BasicMessageComposer"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { CommandCategories, getCommand } from '../../../SlashCommands'; +import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; import { Action } from "../../../dispatcher/actions"; import CountlyAnalytics from "../../../CountlyAnalytics"; import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import SendHistoryManager from '../../../SendHistoryManager'; import Modal from '../../../Modal'; +import { MsgType } from 'matrix-js-sdk/src/@types/event'; +import { Room } from 'matrix-js-sdk/src/models/room'; +import ErrorDialog from "../dialogs/ErrorDialog"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import { ActionPayload } from "../../../dispatcher/payloads"; +import AccessibleButton from '../elements/AccessibleButton'; -function _isReply(mxEvent) { +function eventIsReply(mxEvent: MatrixEvent): boolean { const relatesTo = mxEvent.getContent()["m.relates_to"]; - const isReply = !!(relatesTo && relatesTo["m.in_reply_to"]); - return isReply; + return !!(relatesTo && relatesTo["m.in_reply_to"]); } -function getHtmlReplyFallback(mxEvent) { +function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; if (!html) { return ""; @@ -54,7 +58,7 @@ function getHtmlReplyFallback(mxEvent) { return (mxReply && mxReply.outerHTML) || ""; } -function getTextReplyFallback(mxEvent) { +function getTextReplyFallback(mxEvent: MatrixEvent): string { const body = mxEvent.getContent().body; const lines = body.split("\n").map(l => l.trim()); if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) { @@ -63,12 +67,12 @@ function getTextReplyFallback(mxEvent) { return ""; } -function createEditContent(model, editedEvent) { +function createEditContent(model: EditorModel, editedEvent: MatrixEvent): IContent { const isEmote = containsEmote(model); if (isEmote) { model = stripEmoteCommand(model); } - const isReply = _isReply(editedEvent); + const isReply = eventIsReply(editedEvent); let plainPrefix = ""; let htmlPrefix = ""; @@ -79,11 +83,11 @@ function createEditContent(model, editedEvent) { const body = textSerialize(model); - const newContent = { - "msgtype": isEmote ? "m.emote" : "m.text", + const newContent: IContent = { + "msgtype": isEmote ? MsgType.Emote : MsgType.Text, "body": body, }; - const contentBody = { + const contentBody: IContent = { msgtype: newContent.msgtype, body: `${plainPrefix} * ${body}`, }; @@ -105,55 +109,61 @@ function createEditContent(model, editedEvent) { }, contentBody); } +interface IProps { + editState: EditorStateTransfer; + className?: string; +} + +interface IState { + saveDisabled: boolean; +} + @replaceableComponent("views.rooms.EditMessageComposer") -export default class EditMessageComposer extends React.Component { - static propTypes = { - // the message event being edited - editState: PropTypes.instanceOf(EditorStateTransfer).isRequired, - }; - +export default class EditMessageComposer extends React.Component { static contextType = MatrixClientContext; + context!: React.ContextType; - constructor(props, context) { - super(props, context); - this.model = null; - this._editorRef = null; + private readonly editorRef = createRef(); + private readonly dispatcherRef: string; + private model: EditorModel = null; + constructor(props: IProps, context: React.ContextType) { + super(props); + this.context = context; // otherwise React will only set it prior to render due to type def above + + const isRestored = this.createEditorModel(); + const ev = this.props.editState.getEvent(); this.state = { - saveDisabled: true, + saveDisabled: !isRestored || !this.isContentModified(createEditContent(this.model, ev)["m.new_content"]), }; - this._createEditorModel(); - window.addEventListener("beforeunload", this._saveStoredEditorState); + + window.addEventListener("beforeunload", this.saveStoredEditorState); this.dispatcherRef = dis.register(this.onAction); } - _setEditorRef = ref => { - this._editorRef = ref; - }; - - _getRoom() { + private getRoom(): Room { return this.context.getRoom(this.props.editState.getEvent().getRoomId()); } - _onKeyDown = (event) => { + private onKeyDown = (event: KeyboardEvent): void => { // ignore any keypress while doing IME compositions - if (this._editorRef.isComposing(event)) { + if (this.editorRef.current?.isComposing(event)) { return; } const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { case MessageComposerAction.Send: - this._sendEdit(); + this.sendEdit(); event.preventDefault(); break; case MessageComposerAction.CancelEditing: - this._cancelEdit(); + this.cancelEdit(); break; case MessageComposerAction.EditPrevMessage: { - if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) { + if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtStart()) { return; } - const previousEvent = findEditableEvent(this._getRoom(), false, + const previousEvent = findEditableEvent(this.getRoom(), false, this.props.editState.getEvent().getId()); if (previousEvent) { dis.dispatch({ action: 'edit_event', event: previousEvent }); @@ -162,47 +172,47 @@ export default class EditMessageComposer extends React.Component { break; } case MessageComposerAction.EditNextMessage: { - if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) { + if (this.editorRef.current?.isModified() || !this.editorRef.current?.isCaretAtEnd()) { return; } - const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId()); + const nextEvent = findEditableEvent(this.getRoom(), true, this.props.editState.getEvent().getId()); if (nextEvent) { dis.dispatch({ action: 'edit_event', event: nextEvent }); } else { - this._clearStoredEditorState(); + this.clearStoredEditorState(); dis.dispatch({ action: 'edit_event', event: null }); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); } event.preventDefault(); break; } } + }; + + private get editorRoomKey(): string { + return `mx_edit_room_${this.getRoom().roomId}`; } - get _editorRoomKey() { - return `mx_edit_room_${this._getRoom().roomId}`; - } - - get _editorStateKey() { + private get editorStateKey(): string { return `mx_edit_state_${this.props.editState.getEvent().getId()}`; } - _cancelEdit = () => { - this._clearStoredEditorState(); + private cancelEdit = (): void => { + this.clearStoredEditorState(); dis.dispatch({ action: "edit_event", event: null }); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); + }; + + private get shouldSaveStoredEditorState(): boolean { + return localStorage.getItem(this.editorRoomKey) !== null; } - get _shouldSaveStoredEditorState() { - return localStorage.getItem(this._editorRoomKey) !== null; - } - - _restoreStoredEditorState(partCreator) { - const json = localStorage.getItem(this._editorStateKey); + private restoreStoredEditorState(partCreator: PartCreator): Part[] { + const json = localStorage.getItem(this.editorStateKey); if (json) { try { const { parts: serializedParts } = JSON.parse(json); - const parts = serializedParts.map(p => partCreator.deserializePart(p)); + const parts: Part[] = serializedParts.map(p => partCreator.deserializePart(p)); return parts; } catch (e) { console.error("Error parsing editing state: ", e); @@ -210,25 +220,25 @@ export default class EditMessageComposer extends React.Component { } } - _clearStoredEditorState() { - localStorage.removeItem(this._editorRoomKey); - localStorage.removeItem(this._editorStateKey); + private clearStoredEditorState(): void { + localStorage.removeItem(this.editorRoomKey); + localStorage.removeItem(this.editorStateKey); } - _clearPreviousEdit() { - if (localStorage.getItem(this._editorRoomKey)) { - localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this._editorRoomKey)}`); + private clearPreviousEdit(): void { + if (localStorage.getItem(this.editorRoomKey)) { + localStorage.removeItem(`mx_edit_state_${localStorage.getItem(this.editorRoomKey)}`); } } - _saveStoredEditorState() { + private saveStoredEditorState = (): void => { const item = SendHistoryManager.createItem(this.model); - this._clearPreviousEdit(); - localStorage.setItem(this._editorRoomKey, this.props.editState.getEvent().getId()); - localStorage.setItem(this._editorStateKey, JSON.stringify(item)); - } + this.clearPreviousEdit(); + localStorage.setItem(this.editorRoomKey, this.props.editState.getEvent().getId()); + localStorage.setItem(this.editorStateKey, JSON.stringify(item)); + }; - _isSlashCommand() { + private isSlashCommand(): boolean { const parts = this.model.parts; const firstPart = parts[0]; if (firstPart) { @@ -244,19 +254,18 @@ export default class EditMessageComposer extends React.Component { return false; } - _isContentModified(newContent) { + private isContentModified(newContent: IContent): boolean { // if nothing has changed then bail const oldContent = this.props.editState.getEvent().getContent(); - if (!this._editorRef.isModified() || - (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && + if (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && oldContent["format"] === newContent["format"] && - oldContent["formatted_body"] === newContent["formatted_body"])) { + oldContent["formatted_body"] === newContent["formatted_body"]) { return false; } return true; } - _getSlashCommand() { + private getSlashCommand(): [Command, string, string] { const commandText = this.model.parts.reduce((text, part) => { // use mxid to textify user pills in a command if (part.type === "user-pill") { @@ -268,7 +277,7 @@ export default class EditMessageComposer extends React.Component { return [cmd, args, commandText]; } - async _runSlashCommand(cmd, args, roomId) { + private async runSlashCommand(cmd: Command, args: string, roomId: string): Promise { const result = cmd.run(roomId, args); let messageContent; let error = result.error; @@ -285,7 +294,6 @@ export default class EditMessageComposer extends React.Component { } if (error) { console.error("Command failure: %s", error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); // assume the error is a server error when the command is async const isServerError = !!result.promise; const title = isServerError ? _td("Server error") : _td("Command error"); @@ -309,7 +317,7 @@ export default class EditMessageComposer extends React.Component { } } - _sendEdit = async () => { + private sendEdit = async (): Promise => { const startTime = CountlyAnalytics.getTimestamp(); const editedEvent = this.props.editState.getEvent(); const editContent = createEditContent(this.model, editedEvent); @@ -318,20 +326,19 @@ export default class EditMessageComposer extends React.Component { let shouldSend = true; // If content is modified then send an updated event into the room - if (this._isContentModified(newContent)) { + if (this.isContentModified(newContent)) { const roomId = editedEvent.getRoomId(); - if (!containsEmote(this.model) && this._isSlashCommand()) { - const [cmd, args, commandText] = this._getSlashCommand(); + if (!containsEmote(this.model) && this.isSlashCommand()) { + const [cmd, args, commandText] = this.getSlashCommand(); if (cmd) { if (cmd.category === CommandCategories.messages) { - editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId); + editContent["m.new_content"] = await this.runSlashCommand(cmd, args, roomId); } else { - this._runSlashCommand(cmd, args, roomId); + this.runSlashCommand(cmd, args, roomId); shouldSend = false; } } else { // ask the user if their unknown command should be sent as a message - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { title: _t("Unknown Command"), description:
    @@ -358,9 +365,9 @@ export default class EditMessageComposer extends React.Component { } } if (shouldSend) { - this._cancelPreviousPendingEdit(); + this.cancelPreviousPendingEdit(); const prom = this.context.sendMessage(roomId, editContent); - this._clearStoredEditorState(); + this.clearStoredEditorState(); dis.dispatch({ action: "message_sent" }); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); } @@ -368,10 +375,10 @@ export default class EditMessageComposer extends React.Component { // close the event editing and focus composer dis.dispatch({ action: "edit_event", event: null }); - dis.fire(Action.FocusComposer); + dis.fire(Action.FocusSendMessageComposer); }; - _cancelPreviousPendingEdit() { + private cancelPreviousPendingEdit(): void { const originalEvent = this.props.editState.getEvent(); const previousEdit = originalEvent.replacingEvent(); if (previousEdit && ( @@ -389,54 +396,45 @@ export default class EditMessageComposer extends React.Component { const sel = document.getSelection(); let caret; if (sel.focusNode) { - caret = getCaretOffsetAndText(this._editorRef, sel).caret; + caret = getCaretOffsetAndText(this.editorRef.current?.editorRef.current, sel).caret; } const parts = this.model.serializeParts(); // if caret is undefined because for some reason there isn't a valid selection, // then when mounting the editor again with the same editor state, // it will set the cursor at the end. this.props.editState.setEditorState(caret, parts); - window.removeEventListener("beforeunload", this._saveStoredEditorState); - if (this._shouldSaveStoredEditorState) { - this._saveStoredEditorState(); + window.removeEventListener("beforeunload", this.saveStoredEditorState); + if (this.shouldSaveStoredEditorState) { + this.saveStoredEditorState(); } dis.unregister(this.dispatcherRef); } - _createEditorModel() { + private createEditorModel(): boolean { const { editState } = this.props; - const room = this._getRoom(); + const room = this.getRoom(); const partCreator = new CommandPartCreator(room, this.context); + let parts; + let isRestored = false; if (editState.hasEditorState()) { // if restoring state from a previous editor, // restore serialized parts from the state parts = editState.getSerializedParts().map(p => partCreator.deserializePart(p)); } else { - //otherwise, either restore serialized parts from localStorage or parse the body of the event - parts = this._restoreStoredEditorState(partCreator) || parseEvent(editState.getEvent(), partCreator); + // otherwise, either restore serialized parts from localStorage or parse the body of the event + const restoredParts = this.restoreStoredEditorState(partCreator); + parts = restoredParts || parseEvent(editState.getEvent(), partCreator); + isRestored = !!restoredParts; } this.model = new EditorModel(parts, partCreator); - this._saveStoredEditorState(); + this.saveStoredEditorState(); + + return isRestored; } - _getInitialCaretPosition() { - const { editState } = this.props; - let caretPosition; - if (editState.hasEditorState() && editState.getCaret()) { - // if restoring state from a previous editor, - // restore caret position from the state - const caret = editState.getCaret(); - caretPosition = this.model.positionForOffset(caret.offset, caret.atNodeEnd); - } else { - // otherwise, set it at the end - caretPosition = this.model.getPositionAtEnd(); - } - return caretPosition; - } - - _onChange = () => { - if (!this.state.saveDisabled || !this._editorRef || !this._editorRef.isModified()) { + private onChange = (): void => { + if (!this.state.saveDisabled || !this.editorRef.current?.isModified()) { return; } @@ -445,33 +443,36 @@ export default class EditMessageComposer extends React.Component { }); }; - onAction = payload => { - if (payload.action === "edit_composer_insert" && this._editorRef) { + private onAction = (payload: ActionPayload) => { + if (payload.action === "edit_composer_insert" && this.editorRef.current) { if (payload.userId) { - this._editorRef.insertMention(payload.userId); + this.editorRef.current?.insertMention(payload.userId); } else if (payload.event) { - this._editorRef.insertQuotedMessage(payload.event); + this.editorRef.current?.insertQuotedMessage(payload.event); } else if (payload.text) { - this._editorRef.insertPlaintext(payload.text); + this.editorRef.current?.insertPlaintext(payload.text); } + } else if (payload.action === Action.FocusEditMessageComposer && this.editorRef.current) { + this.editorRef.current.focus(); } }; render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return (
    + return (
    - {_t("Cancel")} - - {_t("Save")} + + { _t("Cancel") } + + + { _t("Save") }
    ); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 3153a9e4ce..9679fc7797 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -47,6 +47,13 @@ import { StaticNotificationState } from "../../../stores/notifications/StaticNot import NotificationBadge from "./NotificationBadge"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from '../../../dispatcher/actions'; +import MemberAvatar from '../avatars/MemberAvatar'; +import SenderProfile from '../messages/SenderProfile'; +import MessageTimestamp from '../messages/MessageTimestamp'; +import TooltipButton from '../elements/TooltipButton'; +import ReadReceiptMarker from "./ReadReceiptMarker"; +import MessageActionBar from "../messages/MessageActionBar"; +import ReactionsRow from '../messages/ReactionsRow'; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', @@ -267,7 +274,7 @@ interface IProps { showReactions?: boolean; // which layout to use - layout: Layout; + layout?: Layout; // whether or not to show flair at all enableFlair?: boolean; @@ -287,10 +294,10 @@ interface IProps { permalinkCreator?: RoomPermalinkCreator; // Symbol of the root node - as?: string + as?: string; // whether or not to always show timestamps - alwaysShowTimestamps?: boolean + alwaysShowTimestamps?: boolean; } interface IState { @@ -321,6 +328,7 @@ export default class EventTile extends React.Component { static defaultProps = { // no-op function because onHeightChanged is optional yet some sub-components assume its existence onHeightChanged: function() {}, + layout: Layout.Group, }; static contextType = MatrixClientContext; @@ -665,7 +673,6 @@ export default class EventTile extends React.Component { ); } - const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker'); const avatars = []; const receiptOffset = 15; let left = 0; @@ -732,7 +739,7 @@ export default class EventTile extends React.Component { ); } - onSenderProfileClick = event => { + onSenderProfileClick = () => { const mxEvent = this.props.mxEvent; dis.dispatch({ action: Action.ComposerInsert, @@ -840,10 +847,6 @@ export default class EventTile extends React.Component { }; render() { - const MessageTimestamp = sdk.getComponent('messages.MessageTimestamp'); - const SenderProfile = sdk.getComponent('messages.SenderProfile'); - const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); - //console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); const content = this.props.mxEvent.getContent(); @@ -986,7 +989,6 @@ export default class EventTile extends React.Component { } } - const MessageActionBar = sdk.getComponent('messages.MessageActionBar'); const actionBar = !isEditing ? { { 'requestLink': (sub) => { sub } }, ); - const TooltipButton = sdk.getComponent('elements.TooltipButton'); const keyRequestInfo = isEncryptionFailure && !isRedacted ?
    @@ -1037,7 +1038,6 @@ export default class EventTile extends React.Component { let reactionsRow; if (!isRedacted) { - const ReactionsRow = sdk.getComponent('messages.ReactionsRow'); reactionsRow = = ({ links, mxEvent, onCancelClick, onHeightChanged }) => { + const [expanded, toggleExpanded] = useStateToggle(); + useEffect(() => { + onHeightChanged(); + }, [onHeightChanged, expanded]); + + const shownLinks = expanded ? links : links.slice(0, INITIAL_NUM_PREVIEWS); + + let toggleButton; + if (links.length > INITIAL_NUM_PREVIEWS) { + toggleButton = + { expanded + ? _t("Collapse") + : _t("Show %(count)s other previews", { count: links.length - shownLinks.length }) } + ; + } + + return
    + { shownLinks.map((link, i) => ( + + { i === 0 ? ( + + + + ): undefined } + + )) } + { toggleButton } +
    ; +}; + +export default LinkPreviewGroup; diff --git a/src/components/views/rooms/LinkPreviewWidget.js b/src/components/views/rooms/LinkPreviewWidget.tsx similarity index 69% rename from src/components/views/rooms/LinkPreviewWidget.js rename to src/components/views/rooms/LinkPreviewWidget.tsx index 360ca41d55..7e6dd86d19 100644 --- a/src/components/views/rooms/LinkPreviewWidget.js +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 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,26 +15,33 @@ limitations under the License. */ import React, { createRef } from 'react'; -import PropTypes from 'prop-types'; import { AllHtmlEntities } from 'html-entities'; +import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { IPreviewUrlResponse } from 'matrix-js-sdk/src/client'; + import { linkifyElement } from '../../../HtmlUtils'; import SettingsStore from "../../../settings/SettingsStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import * as sdk from "../../../index"; import Modal from "../../../Modal"; import * as ImageUtils from "../../../ImageUtils"; -import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { mediaFromMxc } from "../../../customisations/Media"; +import ImageView from '../elements/ImageView'; + +interface IProps { + link: string; // the URL being previewed + mxEvent: MatrixEvent; // the Event associated with the preview + onHeightChanged(): void; // called when the preview's contents has loaded +} + +interface IState { + preview?: IPreviewUrlResponse; +} @replaceableComponent("views.rooms.LinkPreviewWidget") -export default class LinkPreviewWidget extends React.Component { - static propTypes = { - link: PropTypes.string.isRequired, // the URL being previewed - mxEvent: PropTypes.object.isRequired, // the Event associated with the preview - onCancelClick: PropTypes.func, // called when the preview's cancel ('hide') button is clicked - onHeightChanged: PropTypes.func, // called when the preview's contents has loaded - }; +export default class LinkPreviewWidget extends React.Component { + private unmounted = false; + private readonly description = createRef(); constructor(props) { super(props); @@ -44,31 +50,25 @@ export default class LinkPreviewWidget extends React.Component { preview: null, }; - this.unmounted = false; - MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((res)=>{ + MatrixClientPeg.get().getUrlPreview(this.props.link, this.props.mxEvent.getTs()).then((preview) => { if (this.unmounted) { return; } - this.setState( - { preview: res }, - this.props.onHeightChanged, - ); - }, (error)=>{ + this.setState({ preview }, this.props.onHeightChanged); + }, (error) => { console.error("Failed to get URL preview: " + error); }); - - this._description = createRef(); } componentDidMount() { - if (this._description.current) { - linkifyElement(this._description.current); + if (this.description.current) { + linkifyElement(this.description.current); } } componentDidUpdate() { - if (this._description.current) { - linkifyElement(this._description.current); + if (this.description.current) { + linkifyElement(this.description.current); } } @@ -76,11 +76,10 @@ export default class LinkPreviewWidget extends React.Component { this.unmounted = true; } - onImageClick = ev => { + private onImageClick = ev => { const p = this.state.preview; if (ev.button != 0 || ev.metaKey) return; ev.preventDefault(); - const ImageView = sdk.getComponent("elements.ImageView"); let src = p["og:image"]; if (src && src.startsWith("mxc://")) { @@ -136,21 +135,21 @@ export default class LinkPreviewWidget extends React.Component { // opaque string. This does not allow any HTML to be injected into the DOM. const description = AllHtmlEntities.decode(p["og:description"] || ""); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return (
    { img }
    - -
    { p["og:site_name"] ? (" - " + p["og:site_name"]) : null }
    -
    +
    + { p["og:title"] } + { p["og:site_name"] && + { (" - " + p["og:site_name"]) } + } +
    +
    { description }
    - - - + { this.props.children }
    ); } diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index 68f87580df..f4df70c7ee 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -22,7 +22,6 @@ import { _t } from '../../../languageHandler'; import SdkConfig from '../../../SdkConfig'; import dis from '../../../dispatcher/dispatcher'; import { isValid3pidInvite } from "../../../RoomInvite"; -import rateLimitedFunction from "../../../ratelimitedfunc"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import BaseCard from "../right_panel/BaseCard"; @@ -43,6 +42,7 @@ import AccessibleButton from '../elements/AccessibleButton'; import EntityTile from "./EntityTile"; import MemberTile from "./MemberTile"; import BaseAvatar from '../avatars/BaseAvatar'; +import { throttle } from 'lodash'; const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_INVITED = 5; @@ -133,7 +133,7 @@ export default class MemberList extends React.Component { } // cancel any pending calls to the rate_limited_funcs - this.updateList.cancelPendingCall(); + this.updateList.cancel(); } /** @@ -237,9 +237,9 @@ export default class MemberList extends React.Component { if (this.canInvite !== this.state.canInvite) this.setState({ canInvite: this.canInvite }); }; - private updateList = rateLimitedFunction(() => { + private updateList = throttle(() => { this.updateListNow(); - }, 500); + }, 500, { leading: true, trailing: true }); private updateListNow(): void { const members = this.roomMembers(); diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index db57f98025..b7015d2275 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -17,7 +17,6 @@ import React from 'react'; import classNames from 'classnames'; 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"; @@ -43,13 +42,15 @@ import { E2EStatus } from '../../../utils/ShieldUtils'; import SendMessageComposer from "./SendMessageComposer"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; +import EditorModel from "../../../editor/model"; +import EmojiPicker from '../emojipicker/EmojiPicker'; +import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar"; interface IComposerAvatarProps { me: object; } function ComposerAvatar(props: IComposerAvatarProps) { - const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar'); return
    ; @@ -75,7 +76,6 @@ const EmojiButton = ({ addEmoji }) => { let contextMenu; if (menuDisplayed) { const buttonRect = button.current.getBoundingClientRect(); - const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker'); contextMenu = ; @@ -318,14 +318,14 @@ export default class MessageComposer extends React.Component { } }; - addEmoji(emoji: string) { + private addEmoji(emoji: string) { dis.dispatch({ action: Action.ComposerInsert, text: emoji, }); } - sendMessage = async () => { + private 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. @@ -333,11 +333,10 @@ export default class MessageComposer extends React.Component { return; } - // XXX: Private function access - this.messageComposerInput._sendMessage(); + this.messageComposerInput.sendMessage(); }; - onChange = (model) => { + private onChange = (model: EditorModel) => { this.setState({ isComposerEmpty: model.isEmpty, }); @@ -366,15 +365,12 @@ export default class MessageComposer extends React.Component { ]; if (!this.state.tombstone && this.state.canSendMessages) { - const SendMessageComposer = sdk.getComponent("rooms.SendMessageComposer"); - controls.push( this.messageComposerInput = c} key="controls_input" room={this.props.room} placeholder={this.renderPlaceholderText()} - resizeNotifier={this.props.resizeNotifier} permalinkCreator={this.props.permalinkCreator} replyToEvent={this.props.replyToEvent} onChange={this.onChange} diff --git a/src/components/views/rooms/MessageComposerFormatBar.js b/src/components/views/rooms/MessageComposerFormatBar.tsx similarity index 55% rename from src/components/views/rooms/MessageComposerFormatBar.js rename to src/components/views/rooms/MessageComposerFormatBar.tsx index c31538c6cd..75bca8aac7 100644 --- a/src/components/views/rooms/MessageComposerFormatBar.js +++ b/src/components/views/rooms/MessageComposerFormatBar.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. @@ -14,21 +14,35 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; +import React, { createRef } from 'react'; import classNames from 'classnames'; + +import { _t } from '../../../languageHandler'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; -@replaceableComponent("views.rooms.MessageComposerFormatBar") -export default class MessageComposerFormatBar extends React.PureComponent { - static propTypes = { - onAction: PropTypes.func.isRequired, - shortcuts: PropTypes.object.isRequired, - }; +export enum Formatting { + Bold = "bold", + Italics = "italics", + Strikethrough = "strikethrough", + Code = "code", + Quote = "quote", +} - constructor(props) { +interface IProps { + shortcuts: Partial>; + onAction(action: Formatting): void; +} + +interface IState { + visible: boolean; +} + +@replaceableComponent("views.rooms.MessageComposerFormatBar") +export default class MessageComposerFormatBar extends React.PureComponent { + private readonly formatBarRef = createRef(); + + constructor(props: IProps) { super(props); this.state = { visible: false }; } @@ -37,49 +51,53 @@ export default class MessageComposerFormatBar extends React.PureComponent { const classes = classNames("mx_MessageComposerFormatBar", { "mx_MessageComposerFormatBar_shown": this.state.visible, }); - return (
    this._formatBarRef = ref}> - this.props.onAction("bold")} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} /> - this.props.onAction("italics")} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} /> - this.props.onAction("strikethrough")} icon="Strikethrough" visible={this.state.visible} /> - this.props.onAction("code")} icon="Code" visible={this.state.visible} /> - this.props.onAction("quote")} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} /> + return (
    + this.props.onAction(Formatting.Bold)} icon="Bold" shortcut={this.props.shortcuts.bold} visible={this.state.visible} /> + this.props.onAction(Formatting.Italics)} icon="Italic" shortcut={this.props.shortcuts.italics} visible={this.state.visible} /> + this.props.onAction(Formatting.Strikethrough)} icon="Strikethrough" visible={this.state.visible} /> + this.props.onAction(Formatting.Code)} icon="Code" visible={this.state.visible} /> + this.props.onAction(Formatting.Quote)} icon="Quote" shortcut={this.props.shortcuts.quote} visible={this.state.visible} />
    ); } - showAt(selectionRect) { + public showAt(selectionRect: DOMRect): void { + if (!this.formatBarRef.current) return; + this.setState({ visible: true }); - const parentRect = this._formatBarRef.parentElement.getBoundingClientRect(); - this._formatBarRef.style.left = `${selectionRect.left - parentRect.left}px`; + const parentRect = this.formatBarRef.current.parentElement.getBoundingClientRect(); + this.formatBarRef.current.style.left = `${selectionRect.left - parentRect.left}px`; // 12 is half the height of the bar (e.g. to center it) and 16 is an offset that felt ok. - this._formatBarRef.style.top = `${selectionRect.top - parentRect.top - 16 - 12}px`; + this.formatBarRef.current.style.top = `${selectionRect.top - parentRect.top - 16 - 12}px`; } - hide() { + public hide(): void { this.setState({ visible: false }); } } -class FormatButton extends React.PureComponent { - static propTypes = { - label: PropTypes.string.isRequired, - onClick: PropTypes.func.isRequired, - icon: PropTypes.string.isRequired, - shortcut: PropTypes.string, - visible: PropTypes.bool, - }; +interface IFormatButtonProps { + label: string; + icon: string; + shortcut?: string; + visible?: boolean; + onClick(): void; +} +class FormatButton extends React.PureComponent { render() { const className = `mx_MessageComposerFormatBar_button mx_MessageComposerFormatBar_buttonIcon${this.props.icon}`; let shortcut; if (this.props.shortcut) { - shortcut =
    {this.props.shortcut}
    ; + shortcut =
    + { this.props.shortcut } +
    ; } const tooltip =
    - {this.props.label} + { this.props.label }
    - {shortcut} + { shortcut }
    ; diff --git a/src/components/views/rooms/RoomHeader.js b/src/components/views/rooms/RoomHeader.tsx similarity index 84% rename from src/components/views/rooms/RoomHeader.js rename to src/components/views/rooms/RoomHeader.tsx index 886317f2bf..af5daed5bc 100644 --- a/src/components/views/rooms/RoomHeader.js +++ b/src/components/views/rooms/RoomHeader.tsx @@ -1,6 +1,6 @@ /* Copyright 2015, 2016 OpenMarket Ltd -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,11 +16,9 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import RateLimitedFunc from '../../../ratelimitedfunc'; import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; @@ -31,54 +29,65 @@ import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; import { PlaceCallType } from "../../../CallHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { throttle } from 'lodash'; +import { MatrixEvent, Room, RoomState } from 'matrix-js-sdk/src'; +import { E2EStatus } from '../../../utils/ShieldUtils'; +import { IOOBData } from '../../../stores/ThreepidInviteStore'; +import { SearchScope } from './SearchBar'; + +export interface ISearchInfo { + searchTerm: string; + searchScope: SearchScope; + searchCount: number; +} + +interface IProps { + room: Room; + oobData?: IOOBData; + inRoom: boolean; + onSettingsClick: () => void; + onSearchClick: () => void; + onForgetClick: () => void; + onCallPlaced: (type: PlaceCallType) => void; + onAppsClick: () => void; + e2eStatus: E2EStatus; + appsShown: boolean; + searchInfo: ISearchInfo; +} @replaceableComponent("views.rooms.RoomHeader") -export default class RoomHeader extends React.Component { - static propTypes = { - room: PropTypes.object, - oobData: PropTypes.object, - inRoom: PropTypes.bool, - onSettingsClick: PropTypes.func, - onSearchClick: PropTypes.func, - onLeaveClick: PropTypes.func, - e2eStatus: PropTypes.string, - onAppsClick: PropTypes.func, - appsShown: PropTypes.bool, - onCallPlaced: PropTypes.func, // (PlaceCallType) => void; - }; - +export default class RoomHeader extends React.Component { static defaultProps = { editing: false, inRoom: false, }; - componentDidMount() { + public componentDidMount() { const cli = MatrixClientPeg.get(); - cli.on("RoomState.events", this._onRoomStateEvents); + cli.on("RoomState.events", this.onRoomStateEvents); } - componentWillUnmount() { + public componentWillUnmount() { const cli = MatrixClientPeg.get(); if (cli) { - cli.removeListener("RoomState.events", this._onRoomStateEvents); + cli.removeListener("RoomState.events", this.onRoomStateEvents); } } - _onRoomStateEvents = (event, state) => { + private onRoomStateEvents = (event: MatrixEvent, state: RoomState) => { if (!this.props.room || event.getRoomId() !== this.props.room.roomId) { return; } // redisplay the room name, topic, etc. - this._rateLimitedUpdate(); + this.rateLimitedUpdate(); }; - _rateLimitedUpdate = new RateLimitedFunc(function() { - /* eslint-disable @babel/no-invalid-this */ + private rateLimitedUpdate = throttle(() => { this.forceUpdate(); - }, 500); + }, 500, { leading: true, trailing: true }); - render() { + public render() { let searchStatus = null; // don't display the search count until the search completes and diff --git a/src/components/views/rooms/RoomListNumResults.tsx b/src/components/views/rooms/RoomListNumResults.tsx index a05db89f61..95c8c6590f 100644 --- a/src/components/views/rooms/RoomListNumResults.tsx +++ b/src/components/views/rooms/RoomListNumResults.tsx @@ -22,7 +22,7 @@ import { useEventEmitter } from "../../../hooks/useEventEmitter"; import SpaceStore from "../../../stores/SpaceStore"; interface IProps { - onVisibilityChange?: () => void + onVisibilityChange?: () => void; } const RoomListNumResults: React.FC = ({ onVisibilityChange }) => { diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index 8af0fa5abd..47e9849214 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -18,23 +18,22 @@ limitations under the License. import React from "react"; import { SearchResult } from "matrix-js-sdk/src/models/search-result"; import RoomContext from "../../../contexts/RoomContext"; -import { haveTileForEvent } from "./EventTile"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; +import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import DateSeparator from "../messages/DateSeparator"; -import EventTile from "./EventTile"; +import EventTile, { haveTileForEvent } from "./EventTile"; interface IProps { - // The details of this result + // a matrix-js-sdk SearchResult containing the details of this result searchResult: SearchResult; - // Strings to be highlighted in the results + // a list of strings to be highlighted in the results searchHighlights?: string[]; // href for the highlights in this result resultLink?: string; - onHeightChanged: () => void; - permalinkCreator: RoomPermalinkCreator; + onHeightChanged?: () => void; + permalinkCreator?: RoomPermalinkCreator; } @replaceableComponent("views.rooms.SearchResultTile") diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.tsx similarity index 76% rename from src/components/views/rooms/SendMessageComposer.js rename to src/components/views/rooms/SendMessageComposer.tsx index cc819e05e1..0639c20fef 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -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. @@ -14,42 +13,53 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; -import PropTypes from 'prop-types'; + +import React, { ClipboardEvent, createRef, KeyboardEvent } from 'react'; +import EMOJI_REGEX from 'emojibase-regex'; +import { IContent, MatrixEvent } from 'matrix-js-sdk/src/models/event'; +import { DebouncedFunc, throttle } from 'lodash'; +import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; + import dis from '../../../dispatcher/dispatcher'; import EditorModel from '../../../editor/model'; import { - htmlSerializeIfNeeded, - textSerialize, containsEmote, - stripEmoteCommand, - unescapeMessage, + htmlSerializeIfNeeded, startsWith, + stripEmoteCommand, stripPrefix, + textSerialize, + unescapeMessage, } from '../../../editor/serialize'; -import { CommandPartCreator } from '../../../editor/parts'; +import { CommandPartCreator, Part, PartCreator, SerializedPart } from '../../../editor/parts'; import BasicMessageComposer from "./BasicMessageComposer"; import ReplyThread from "../elements/ReplyThread"; import { findEditableEvent } from '../../../utils/EventUtils'; import SendHistoryManager from "../../../SendHistoryManager"; -import { CommandCategories, getCommand } from '../../../SlashCommands'; -import * as sdk from '../../../index'; +import { Command, CommandCategories, getCommand } from '../../../SlashCommands'; import Modal from '../../../Modal'; import { _t, _td } from '../../../languageHandler'; import ContentMessages from '../../../ContentMessages'; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import RateLimitedFunc from '../../../ratelimitedfunc'; import { Action } from "../../../dispatcher/actions"; import { containsEmoji } from "../../../effects/utils"; import { CHAT_EFFECTS } from '../../../effects'; import CountlyAnalytics from "../../../CountlyAnalytics"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import EMOJI_REGEX from 'emojibase-regex'; import { getKeyBindingsManager, MessageComposerAction } from '../../../KeyBindingsManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; import SettingsStore from '../../../settings/SettingsStore'; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { Room } from 'matrix-js-sdk/src/models/room'; +import ErrorDialog from "../dialogs/ErrorDialog"; +import QuestionDialog from "../dialogs/QuestionDialog"; +import { ActionPayload } from "../../../dispatcher/payloads"; -function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { +function addReplyToMessageContent( + content: IContent, + repliedToEvent: MatrixEvent, + permalinkCreator: RoomPermalinkCreator, +): void { const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); Object.assign(content, replyContent); @@ -65,7 +75,11 @@ function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { } // exported for tests -export function createMessageContent(model, permalinkCreator, replyToEvent) { +export function createMessageContent( + model: EditorModel, + permalinkCreator: RoomPermalinkCreator, + replyToEvent: MatrixEvent, +): IContent { const isEmote = containsEmote(model); if (isEmote) { model = stripEmoteCommand(model); @@ -76,7 +90,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) { model = unescapeMessage(model); const body = textSerialize(model); - const content = { + const content: IContent = { msgtype: isEmote ? "m.emote" : "m.text", body: body, }; @@ -94,7 +108,7 @@ export function createMessageContent(model, permalinkCreator, replyToEvent) { } // exported for tests -export function isQuickReaction(model) { +export function isQuickReaction(model: EditorModel): boolean { const parts = model.parts; if (parts.length == 0) return false; const text = textSerialize(model); @@ -111,46 +125,48 @@ export function isQuickReaction(model) { return false; } +interface IProps { + room: Room; + placeholder?: string; + permalinkCreator: RoomPermalinkCreator; + replyToEvent?: MatrixEvent; + disabled?: boolean; + onChange?(model: EditorModel): void; +} + @replaceableComponent("views.rooms.SendMessageComposer") -export default class SendMessageComposer extends React.Component { - static propTypes = { - room: PropTypes.object.isRequired, - placeholder: PropTypes.string, - permalinkCreator: PropTypes.object.isRequired, - replyToEvent: PropTypes.object, - onChange: PropTypes.func, - disabled: PropTypes.bool, - }; - +export default class SendMessageComposer extends React.Component { static contextType = MatrixClientContext; + context!: React.ContextType; - constructor(props, context) { - super(props, context); - this.model = null; - this._editorRef = null; - this.currentlyComposedEditorState = null; + private readonly prepareToEncrypt?: DebouncedFunc<() => void>; + private readonly editorRef = createRef(); + private model: EditorModel = null; + private currentlyComposedEditorState: SerializedPart[] = null; + private dispatcherRef: string; + private sendHistoryManager: SendHistoryManager; + + constructor(props: IProps, context: React.ContextType) { + super(props); + this.context = context; // otherwise React will only set it prior to render due to type def above if (this.context.isCryptoEnabled() && this.context.isRoomEncrypted(this.props.room.roomId)) { - this._prepareToEncrypt = new RateLimitedFunc(() => { + this.prepareToEncrypt = throttle(() => { this.context.prepareToEncrypt(this.props.room); - }, 60000); + }, 60000, { leading: true, trailing: false }); } - window.addEventListener("beforeunload", this._saveStoredEditorState); + window.addEventListener("beforeunload", this.saveStoredEditorState); } - _setEditorRef = ref => { - this._editorRef = ref; - }; - - _onKeyDown = (event) => { + private onKeyDown = (event: KeyboardEvent): void => { // ignore any keypress while doing IME compositions - if (this._editorRef.isComposing(event)) { + if (this.editorRef.current?.isComposing(event)) { return; } const action = getKeyBindingsManager().getMessageComposerAction(event); switch (action) { case MessageComposerAction.Send: - this._sendMessage(); + this.sendMessage(); event.preventDefault(); break; case MessageComposerAction.SelectPrevSendHistory: @@ -165,7 +181,7 @@ export default class SendMessageComposer extends React.Component { } case MessageComposerAction.EditPrevMessage: // selection must be collapsed and caret at start - if (this._editorRef.isSelectionCollapsed() && this._editorRef.isCaretAtStart()) { + if (this.editorRef.current?.isSelectionCollapsed() && this.editorRef.current?.isCaretAtStart()) { const editEvent = findEditableEvent(this.props.room, false); if (editEvent) { // We're selecting history, so prevent the key event from doing anything else @@ -184,29 +200,29 @@ export default class SendMessageComposer extends React.Component { }); break; default: - if (this._prepareToEncrypt) { + if (this.prepareToEncrypt) { // This needs to be last! - this._prepareToEncrypt(); + this.prepareToEncrypt(); } } }; // we keep sent messages/commands in a separate history (separate from undo history) // so you can alt+up/down in them - selectSendHistory(up) { + private selectSendHistory(up: boolean): boolean { const delta = up ? -1 : 1; // True if we are not currently selecting history, but composing a message if (this.sendHistoryManager.currentIndex === this.sendHistoryManager.history.length) { // We can't go any further - there isn't any more history, so nop. if (!up) { - return; + return false; } this.currentlyComposedEditorState = this.model.serializeParts(); } else if (this.sendHistoryManager.currentIndex + delta === this.sendHistoryManager.history.length) { // True when we return to the message being composed currently this.model.reset(this.currentlyComposedEditorState); this.sendHistoryManager.currentIndex = this.sendHistoryManager.history.length; - return; + return true; } const { parts, replyEventId } = this.sendHistoryManager.getItem(delta); dis.dispatch({ @@ -215,11 +231,12 @@ export default class SendMessageComposer extends React.Component { }); if (parts) { this.model.reset(parts); - this._editorRef.focus(); + this.editorRef.current?.focus(); } + return true; } - _isSlashCommand() { + private isSlashCommand(): boolean { const parts = this.model.parts; const firstPart = parts[0]; if (firstPart) { @@ -237,17 +254,17 @@ export default class SendMessageComposer extends React.Component { return false; } - _sendQuickReaction() { + private sendQuickReaction(): void { const timeline = this.props.room.getLiveTimeline(); const events = timeline.getEvents(); const reaction = this.model.parts[1].text; for (let i = events.length - 1; i >= 0; i--) { - if (events[i].getType() === "m.room.message") { + if (events[i].getType() === EventType.RoomMessage) { let shouldReact = true; const lastMessage = events[i]; const userId = MatrixClientPeg.get().getUserId(); const messageReactions = this.props.room.getUnfilteredTimelineSet() - .getRelationsForEvent(lastMessage.getId(), "m.annotation", "m.reaction"); + .getRelationsForEvent(lastMessage.getId(), RelationType.Annotation, EventType.Reaction); // if we have already sent this reaction, don't redact but don't re-send if (messageReactions) { @@ -258,9 +275,9 @@ export default class SendMessageComposer extends React.Component { shouldReact = !myReactionKeys.includes(reaction); } if (shouldReact) { - MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), "m.reaction", { + MatrixClientPeg.get().sendEvent(lastMessage.getRoomId(), EventType.Reaction, { "m.relates_to": { - "rel_type": "m.annotation", + "rel_type": RelationType.Annotation, "event_id": lastMessage.getId(), "key": reaction, }, @@ -272,7 +289,7 @@ export default class SendMessageComposer extends React.Component { } } - _getSlashCommand() { + private getSlashCommand(): [Command, string, string] { const commandText = this.model.parts.reduce((text, part) => { // use mxid to textify user pills in a command if (part.type === "user-pill") { @@ -284,7 +301,7 @@ export default class SendMessageComposer extends React.Component { return [cmd, args, commandText]; } - async _runSlashCommand(cmd, args) { + private async runSlashCommand(cmd: Command, args: string): Promise { const result = cmd.run(this.props.room.roomId, args); let messageContent; let error = result.error; @@ -302,7 +319,6 @@ export default class SendMessageComposer extends React.Component { } if (error) { console.error("Command failure: %s", error); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); // assume the error is a server error when the command is async const isServerError = !!result.promise; const title = isServerError ? _td("Server error") : _td("Command error"); @@ -326,7 +342,7 @@ export default class SendMessageComposer extends React.Component { } } - async _sendMessage() { + public async sendMessage(): Promise { if (this.model.isEmpty) { return; } @@ -335,21 +351,20 @@ export default class SendMessageComposer extends React.Component { let shouldSend = true; let content; - if (!containsEmote(this.model) && this._isSlashCommand()) { - const [cmd, args, commandText] = this._getSlashCommand(); + if (!containsEmote(this.model) && this.isSlashCommand()) { + const [cmd, args, commandText] = this.getSlashCommand(); if (cmd) { if (cmd.category === CommandCategories.messages) { - content = await this._runSlashCommand(cmd, args); + content = await this.runSlashCommand(cmd, args); if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, this.props.permalinkCreator); } } else { - this._runSlashCommand(cmd, args); + this.runSlashCommand(cmd, args); shouldSend = false; } } else { // ask the user if their unknown command should be sent as a message - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, { title: _t("Unknown Command"), description:
    @@ -378,7 +393,7 @@ export default class SendMessageComposer extends React.Component { if (isQuickReaction(this.model)) { shouldSend = false; - this._sendQuickReaction(); + this.sendQuickReaction(); } if (shouldSend) { @@ -411,9 +426,9 @@ export default class SendMessageComposer extends React.Component { this.sendHistoryManager.save(this.model, replyToEvent); // clear composer this.model.reset([]); - this._editorRef.clearUndoHistory(); - this._editorRef.focus(); - this._clearStoredEditorState(); + this.editorRef.current?.clearUndoHistory(); + this.editorRef.current?.focus(); + this.clearStoredEditorState(); if (SettingsStore.getValue("scrollToBottomOnMessageSent")) { dis.dispatch({ action: "scroll_to_bottom" }); } @@ -421,33 +436,33 @@ export default class SendMessageComposer extends React.Component { componentWillUnmount() { dis.unregister(this.dispatcherRef); - window.removeEventListener("beforeunload", this._saveStoredEditorState); - this._saveStoredEditorState(); + window.removeEventListener("beforeunload", this.saveStoredEditorState); + this.saveStoredEditorState(); } // TODO: [REACT-WARNING] Move this to constructor UNSAFE_componentWillMount() { // eslint-disable-line camelcase const partCreator = new CommandPartCreator(this.props.room, this.context); - const parts = this._restoreStoredEditorState(partCreator) || []; + const parts = this.restoreStoredEditorState(partCreator) || []; this.model = new EditorModel(parts, partCreator); this.dispatcherRef = dis.register(this.onAction); this.sendHistoryManager = new SendHistoryManager(this.props.room.roomId, 'mx_cider_history_'); } - get _editorStateKey() { + private get editorStateKey() { return `mx_cider_state_${this.props.room.roomId}`; } - _clearStoredEditorState() { - localStorage.removeItem(this._editorStateKey); + private clearStoredEditorState(): void { + localStorage.removeItem(this.editorStateKey); } - _restoreStoredEditorState(partCreator) { - const json = localStorage.getItem(this._editorStateKey); + private restoreStoredEditorState(partCreator: PartCreator): Part[] { + const json = localStorage.getItem(this.editorStateKey); if (json) { try { const { parts: serializedParts, replyEventId } = JSON.parse(json); - const parts = serializedParts.map(p => partCreator.deserializePart(p)); + const parts: Part[] = serializedParts.map(p => partCreator.deserializePart(p)); if (replyEventId) { dis.dispatch({ action: 'reply_to_event', @@ -462,42 +477,42 @@ export default class SendMessageComposer extends React.Component { } // should save state when editor has contents or reply is open - _shouldSaveStoredEditorState = () => { - return !this.model.isEmpty || this.props.replyToEvent; - } + private shouldSaveStoredEditorState = (): boolean => { + return !this.model.isEmpty || !!this.props.replyToEvent; + }; - _saveStoredEditorState = () => { - if (this._shouldSaveStoredEditorState()) { + private saveStoredEditorState = (): void => { + if (this.shouldSaveStoredEditorState()) { const item = SendHistoryManager.createItem(this.model, this.props.replyToEvent); - localStorage.setItem(this._editorStateKey, JSON.stringify(item)); + localStorage.setItem(this.editorStateKey, JSON.stringify(item)); } else { - this._clearStoredEditorState(); + this.clearStoredEditorState(); } - } + }; - onAction = (payload) => { + private onAction = (payload: ActionPayload): void => { // don't let the user into the composer if it is disabled - all of these branches lead // to the cursor being in the composer if (this.props.disabled) return; switch (payload.action) { case 'reply_to_event': - case Action.FocusComposer: - this._editorRef && this._editorRef.focus(); + case Action.FocusSendMessageComposer: + this.editorRef.current?.focus(); break; case "send_composer_insert": if (payload.userId) { - this._editorRef && this._editorRef.insertMention(payload.userId); + this.editorRef.current?.insertMention(payload.userId); } else if (payload.event) { - this._editorRef && this._editorRef.insertQuotedMessage(payload.event); + this.editorRef.current?.insertQuotedMessage(payload.event); } else if (payload.text) { - this._editorRef && this._editorRef.insertPlaintext(payload.text); + this.editorRef.current?.insertPlaintext(payload.text); } break; } }; - _onPaste = (event) => { + private onPaste = (event: ClipboardEvent): boolean => { const { clipboardData } = event; // Prioritize text on the clipboard over files as Office on macOS puts a bitmap // in the clipboard as well as the content being copied. @@ -511,23 +526,27 @@ export default class SendMessageComposer extends React.Component { ); return true; // to skip internal onPaste handler } - } + }; - onChange = () => { + private onChange = (): void => { if (this.props.onChange) this.props.onChange(this.model); - } + }; + + private focusComposer = (): void => { + this.editorRef.current?.focus(); + }; render() { return ( -
    +
    diff --git a/src/components/views/rooms/SimpleRoomHeader.js b/src/components/views/rooms/SimpleRoomHeader.js index 2133ccabcd..768a456b35 100644 --- a/src/components/views/rooms/SimpleRoomHeader.js +++ b/src/components/views/rooms/SimpleRoomHeader.js @@ -27,7 +27,7 @@ export default class SimpleRoomHeader extends React.Component { static propTypes = { title: PropTypes.string, - // `src` to a TintableSvg. Optional. + // `src` to an image. Optional. icon: PropTypes.string, }; diff --git a/src/components/views/rooms/ThirdPartyMemberInfo.tsx b/src/components/views/rooms/ThirdPartyMemberInfo.tsx index 4cdabede2f..2bcc3ead57 100644 --- a/src/components/views/rooms/ThirdPartyMemberInfo.tsx +++ b/src/components/views/rooms/ThirdPartyMemberInfo.tsx @@ -20,13 +20,14 @@ 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"; import Modal from "../../../Modal"; import { isValid3pidInvite } from "../../../RoomInvite"; import RoomAvatar from "../avatars/RoomAvatar"; import RoomName from "../elements/RoomName"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import SettingsStore from "../../../settings/SettingsStore"; +import ErrorDialog from '../dialogs/ErrorDialog'; +import AccessibleButton from '../elements/AccessibleButton'; interface IProps { event: MatrixEvent; @@ -104,7 +105,6 @@ export default class ThirdPartyMemberInfo extends React.Component { - private waveform: number[] = []; - private seconds = 0; - private scheduledAnimationFrame = false; - public constructor(props) { super(props); this.state = { recorder: null, // no recording started by default - seconds: 0, - relHeights: arraySeed(0, RECORDING_PLAYBACK_SAMPLES), }; } - public componentDidUpdate(prevProps, prevState) { - if (!prevState.recorder && this.state.recorder) { - this.state.recorder.liveData.onUpdate(this.onRecordingUpdate); - } - } - public async componentWillUnmount() { await VoiceRecordingStore.instance.disposeRecording(); } - private onRecordingUpdate = (update: IRecordingUpdate): void => { - this.waveform = update.waveform; - this.seconds = update.timeSeconds; - - if (this.scheduledAnimationFrame) { - return; - } - - this.scheduledAnimationFrame = true; - // The audio recorder flushes data faster than the screen refresh rate - // Using requestAnimationFrame makes sure that we only flush the data - // to react once per tick to avoid unneeded work. - requestAnimationFrame(() => { - // 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(this.waveform), RECORDING_PLAYBACK_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 - // waveform. This results in a slightly more cinematic/animated waveform for the - // user. - relHeights: bars.map(b => percentageOf(b, 0, 0.50)), - seconds: this.seconds, - }); - this.scheduledAnimationFrame = false; - }); - }; - // called by composer public async send() { if (!this.state.recorder) { @@ -181,7 +135,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent @@ -228,7 +182,7 @@ export default class VoiceRecordComposerTile extends React.PureComponent + return
    ; diff --git a/src/components/views/settings/E2eAdvancedPanel.tsx b/src/components/views/settings/E2eAdvancedPanel.tsx index ca7b71697e..f8746682d7 100644 --- a/src/components/views/settings/E2eAdvancedPanel.tsx +++ b/src/components/views/settings/E2eAdvancedPanel.tsx @@ -16,15 +16,14 @@ limitations under the License. import React from 'react'; -import * as sdk from '../../../index'; import { _t } from "../../../languageHandler"; import { SettingLevel } from "../../../settings/SettingLevel"; import SettingsStore from "../../../settings/SettingsStore"; +import SettingsFlag from '../elements/SettingsFlag'; const SETTING_MANUALLY_VERIFY_ALL_SESSIONS = "e2ee.manuallyVerifyAllSessions"; const E2eAdvancedPanel = props => { - const SettingsFlag = sdk.getComponent('views.elements.SettingsFlag'); return
    {_t("Encryption")} diff --git a/src/components/views/settings/EventIndexPanel.tsx b/src/components/views/settings/EventIndexPanel.tsx index 59b4a166c0..73b324b739 100644 --- a/src/components/views/settings/EventIndexPanel.tsx +++ b/src/components/views/settings/EventIndexPanel.tsx @@ -18,7 +18,6 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import SdkConfig from "../../../SdkConfig"; -import * as sdk from '../../../index'; import Modal from '../../../Modal'; import SettingsStore from "../../../settings/SettingsStore"; import AccessibleButton from "../elements/AccessibleButton"; @@ -27,6 +26,7 @@ import EventIndexPeg from "../../../indexing/EventIndexPeg"; import { SettingLevel } from "../../../settings/SettingLevel"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import SeshatResetDialog from '../dialogs/SeshatResetDialog'; +import InlineSpinner from '../elements/InlineSpinner'; interface IState { enabling: boolean; @@ -147,7 +147,6 @@ export default class EventIndexPanel extends React.Component<{}, IState> { render() { let eventIndexingSettings = null; - const InlineSpinner = sdk.getComponent('elements.InlineSpinner'); const brand = SdkConfig.get().brand; if (EventIndexPeg.get() !== null) { diff --git a/src/components/views/settings/SetIdServer.tsx b/src/components/views/settings/SetIdServer.tsx index 4a73c82d9b..9180c98101 100644 --- a/src/components/views/settings/SetIdServer.tsx +++ b/src/components/views/settings/SetIdServer.tsx @@ -17,7 +17,6 @@ limitations under the License. import url from 'url'; import React from 'react'; import { _t } from "../../../languageHandler"; -import * as sdk from '../../../index'; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Modal from '../../../Modal'; import dis from "../../../dispatcher/dispatcher"; @@ -28,6 +27,10 @@ import { getDefaultIdentityServerUrl, doesIdentityServerHaveTerms } from '../../ import { timeout } from "../../../utils/promise"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { ActionPayload } from '../../../dispatcher/payloads'; +import InlineSpinner from '../elements/InlineSpinner'; +import AccessibleButton from '../elements/AccessibleButton'; +import Field from '../elements/Field'; +import QuestionDialog from "../dialogs/QuestionDialog"; // We'll wait up to this long when checking for 3PID bindings on the IS. const REACHABILITY_TIMEOUT = 10000; // ms @@ -126,7 +129,6 @@ export default class SetIdServer extends React.Component { private getTooltip = () => { if (this.state.checking) { - const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); return
    { _t("Checking server") } @@ -217,7 +219,6 @@ export default class SetIdServer extends React.Component { }; 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"), description: ( @@ -319,7 +320,6 @@ export default class SetIdServer extends React.Component { message = unboundMessage; } - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); const { finished } = Modal.createTrackedDialog('Identity Server Bound Warning', '', QuestionDialog, { title, description: message, @@ -352,8 +352,6 @@ export default class SetIdServer extends React.Component { }; render() { - const AccessibleButton = sdk.getComponent('views.elements.AccessibleButton'); - const Field = sdk.getComponent('elements.Field'); const idServerUrl = this.state.currentClientIdServer; let sectionTitle; let bodyText; @@ -398,7 +396,6 @@ export default class SetIdServer extends React.Component { discoButtonContent = _t("Do not use an identity server"); } if (this.state.disconnectBusy) { - const InlineSpinner = sdk.getComponent('views.elements.InlineSpinner'); discoButtonContent = ; } discoSection =
    diff --git a/src/components/views/settings/SetIntegrationManager.js b/src/components/views/settings/SetIntegrationManager.tsx similarity index 89% rename from src/components/views/settings/SetIntegrationManager.js rename to src/components/views/settings/SetIntegrationManager.tsx index 6636c87df8..ada78e2848 100644 --- a/src/components/views/settings/SetIntegrationManager.js +++ b/src/components/views/settings/SetIntegrationManager.tsx @@ -17,15 +17,25 @@ limitations under the License. import React from 'react'; import { _t } from "../../../languageHandler"; import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; +import { IntegrationManagerInstance } from "../../../integrations/IntegrationManagerInstance"; import * as sdk from '../../../index'; import SettingsStore from "../../../settings/SettingsStore"; import { SettingLevel } from "../../../settings/SettingLevel"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +interface IProps { + +} + +interface IState { + currentManager: IntegrationManagerInstance; + provisioningEnabled: boolean; +} + @replaceableComponent("views.settings.SetIntegrationManager") -export default class SetIntegrationManager extends React.Component { - constructor() { - super(); +export default class SetIntegrationManager extends React.Component { + constructor(props: IProps) { + super(props); const currentManager = IntegrationManagers.sharedInstance().getPrimaryManager(); @@ -35,7 +45,7 @@ export default class SetIntegrationManager extends React.Component { }; } - onProvisioningToggled = () => { + private onProvisioningToggled = (): void => { const current = this.state.provisioningEnabled; SettingsStore.setValue("integrationProvisioning", null, SettingLevel.ACCOUNT, !current).catch(err => { console.error("Error changing integration manager provisioning"); @@ -46,7 +56,7 @@ export default class SetIntegrationManager extends React.Component { this.setState({ provisioningEnabled: !current }); }; - render() { + public render(): React.ReactNode { const ToggleSwitch = sdk.getComponent("views.elements.ToggleSwitch"); const currentManager = this.state.currentManager; diff --git a/src/components/views/settings/SpellCheckSettings.tsx b/src/components/views/settings/SpellCheckSettings.tsx index 0876f07142..1858412dac 100644 --- a/src/components/views/settings/SpellCheckSettings.tsx +++ b/src/components/views/settings/SpellCheckSettings.tsx @@ -21,17 +21,17 @@ import { _t } from "../../../languageHandler"; import { replaceableComponent } from "../../../utils/replaceableComponent"; interface ExistingSpellCheckLanguageIProps { - language: string, - onRemoved(language: string), + language: string; + onRemoved(language: string); } interface SpellCheckLanguagesIProps { - languages: Array, - onLanguagesChange(languages: Array), + languages: Array; + onLanguagesChange(languages: Array); } interface SpellCheckLanguagesIState { - newLanguage: string, + newLanguage: string; } export class ExistingSpellCheckLanguage extends React.Component { diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index 67c7998751..f12499e7f9 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -17,7 +17,6 @@ limitations under the License. import React from 'react'; import { _t, _td } from "../../../../../languageHandler"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import * as sdk from "../../../../.."; import AccessibleButton from "../../../elements/AccessibleButton"; import Modal from "../../../../../Modal"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; @@ -26,6 +25,8 @@ 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"; import { compare } from "../../../../../utils/strings"; +import ErrorDialog from '../../../dialogs/ErrorDialog'; +import PowerSelector from "../../../elements/PowerSelector"; const plEventsToLabels = { // These will be translated for us later. @@ -76,7 +77,6 @@ interface IBannedUserProps { 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); Modal.createTrackedDialog('Failed to unban', '', ErrorDialog, { title: _t('Error'), @@ -176,7 +176,6 @@ export default class RolesRoomSettingsTab extends React.Component { client.sendStateEvent(this.props.roomId, "m.room.power_levels", plContent).catch(e => { console.error(e); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Power level requirement change failed', '', ErrorDialog, { title: _t('Error changing power level requirement'), description: _t( @@ -203,7 +202,6 @@ export default class RolesRoomSettingsTab extends React.Component { client.sendStateEvent(this.props.roomId, "m.room.power_levels", plContent).catch(e => { console.error(e); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Power level change failed', '', ErrorDialog, { title: _t('Error changing power level'), description: _t( @@ -215,8 +213,6 @@ export default class RolesRoomSettingsTab extends React.Component { }; render() { - const PowerSelector = sdk.getComponent('elements.PowerSelector'); - const client = MatrixClientPeg.get(); const room = client.getRoom(this.props.roomId); const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index 4868ffd4fc..312d7f21a0 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -18,7 +18,6 @@ import React from 'react'; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from "../../../../../languageHandler"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import * as sdk from "../../../../.."; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import Modal from "../../../../../Modal"; import QuestionDialog from "../../../dialogs/QuestionDialog"; @@ -27,6 +26,7 @@ import { SettingLevel } from "../../../../../settings/SettingLevel"; import SettingsStore from "../../../../../settings/SettingsStore"; import { UIFeature } from "../../../../../settings/UIFeature"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import SettingsFlag from '../../../elements/SettingsFlag'; // Knock and private are reserved keywords which are not yet implemented. export enum JoinRule { @@ -385,8 +385,6 @@ export default class SecurityRoomSettingsTab extends React.Component void; @@ -81,10 +82,6 @@ export default class HelpUserSettingsTab extends React.Component }; private onBugReport = (e) => { - const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog"); - if (!BugReportDialog) { - return; - } Modal.createTrackedDialog('Bug Report Dialog', '', BugReportDialog, {}); }; @@ -171,7 +168,6 @@ export default class HelpUserSettingsTab extends React.Component const successful = await copyPlaintext(MatrixClientPeg.get().getAccessToken()); const buttonRect = target.getBoundingClientRect(); - const GenericTextContextMenu = sdk.getComponent('context_menus.GenericTextContextMenu'); const { close } = ContextMenu.createMenu(GenericTextContextMenu, { ...toRightOf(buttonRect, 2), message: successful ? _t('Copied!') : _t('Failed to copy'), diff --git a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx index 0a2b9f3212..41c44e65a0 100644 --- a/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/MjolnirUserSettingsTab.tsx @@ -22,8 +22,11 @@ import { ListRule } from "../../../../../mjolnir/ListRule"; import { BanList, RULE_SERVER, RULE_USER } from "../../../../../mjolnir/BanList"; import Modal from "../../../../../Modal"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import * as sdk from "../../../../../index"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import ErrorDialog from "../../../dialogs/ErrorDialog"; +import QuestionDialog from "../../../dialogs/QuestionDialog"; +import AccessibleButton from "../../../elements/AccessibleButton"; +import Field from "../../../elements/Field"; interface IState { busy: boolean; @@ -68,7 +71,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } catch (e) { console.error(e); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to add Mjolnir rule', '', ErrorDialog, { title: _t('Error adding ignored user/server'), description: _t('Something went wrong. Please try again or view your console for hints.'), @@ -90,7 +92,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } catch (e) { console.error(e); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to subscribe to Mjolnir list', '', ErrorDialog, { title: _t('Error subscribing to list'), description: _t('Please verify the room ID or address and try again.'), @@ -108,7 +109,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } catch (e) { console.error(e); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to remove Mjolnir rule', '', ErrorDialog, { title: _t('Error removing ignored user/server'), description: _t('Something went wrong. Please try again or view your console for hints.'), @@ -126,7 +126,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } catch (e) { console.error(e); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to unsubscribe from Mjolnir list', '', ErrorDialog, { title: _t('Error unsubscribing from list'), description: _t('Please try again or view your console for hints.'), @@ -137,8 +136,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } private viewListRules(list: BanList) { - const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog"); - const room = MatrixClientPeg.get().getRoom(list.roomId); const name = room ? room.name : list.roomId; @@ -168,8 +165,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } private renderPersonalBanListRules() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const list = Mjolnir.sharedInstance().getPersonalList(); const rules = list ? [...list.userRules, ...list.serverRules] : []; if (!list || rules.length <= 0) return {_t("You have not ignored anyone.")}; @@ -199,8 +194,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } private renderSubscribedBanLists() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - const personalList = Mjolnir.sharedInstance().getPersonalList(); const lists = Mjolnir.sharedInstance().lists.filter(b => { return personalList? personalList.roomId !== b.roomId : true; @@ -241,8 +234,6 @@ export default class MjolnirUserSettingsTab extends React.Component<{}, IState> } render() { - const Field = sdk.getComponent('elements.Field'); - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const brand = SdkConfig.get().brand; return ( diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 1988ec50ac..c4140153a5 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -20,10 +20,12 @@ import { _t } from "../../../../../languageHandler"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import SettingsStore from "../../../../../settings/SettingsStore"; import Field from "../../../elements/Field"; -import * as sdk from "../../../../.."; import PlatformPeg from "../../../../../PlatformPeg"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import SettingsFlag from '../../../elements/SettingsFlag'; +import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; +import AccessibleButton from "../../../elements/AccessibleButton"; interface IState { autoLaunch: boolean; @@ -45,6 +47,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta 'breadcrumbs', ]; + static KEYBINDINGS_SETTINGS = [ + 'ctrlFForSearch', + ]; + static COMPOSER_SETTINGS = [ 'MessageComposerInput.autoReplaceEmoji', 'MessageComposerInput.suggestEmoji', @@ -53,28 +59,32 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta 'MessageComposerInput.showStickersButton', ]; - static TIMELINE_SETTINGS = [ - 'showTypingNotifications', - 'autoplayGifsAndVideos', - 'urlPreviewsEnabled', - 'TextualBody.enableBigEmoji', - 'showReadReceipts', + static TIME_SETTINGS = [ 'showTwelveHourTimestamps', 'alwaysShowTimestamps', - 'showRedactions', + ]; + static CODE_BLOCKS_SETTINGS = [ 'enableSyntaxHighlightLanguageDetection', 'expandCodeByDefault', - 'scrollToBottomOnMessageSent', 'showCodeLineNumbers', - 'showJoinLeaves', - 'showAvatarChanges', - 'showDisplaynameChanges', - 'showImages', - 'showChatEffects', - 'Pill.shouldShowPillAvatar', - 'ctrlFForSearch', ]; - + static IMAGES_AND_VIDEOS_SETTINGS = [ + 'urlPreviewsEnabled', + 'autoplayGifsAndVideos', + 'showImages', + ]; + static TIMELINE_SETTINGS = [ + 'showTypingNotifications', + 'showRedactions', + 'showReadReceipts', + 'showJoinLeaves', + 'showDisplaynameChanges', + 'showChatEffects', + 'showAvatarChanges', + 'Pill.shouldShowPillAvatar', + 'TextualBody.enableBigEmoji', + 'scrollToBottomOnMessageSent', + ]; static GENERAL_SETTINGS = [ 'TagPanel.enableTagPanel', 'promptBeforeInviteUnknownUsers', @@ -174,7 +184,6 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta }; private renderGroup(settingIds: string[]): React.ReactNodeArray { - const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); return settingIds.filter(SettingsStore.isEnabled).map(i => { return ; }); @@ -222,11 +231,34 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta {this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
    +
    + {_t("Keyboard shortcuts")} + + { _t("To view all keyboard shortcuts, click here.") } + + {this.renderGroup(PreferencesUserSettingsTab.KEYBINDINGS_SETTINGS)} +
    + +
    + {_t("Displaying time")} + {this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)} +
    +
    {_t("Composer")} {this.renderGroup(PreferencesUserSettingsTab.COMPOSER_SETTINGS)}
    +
    + {_t("Code blocks")} + {this.renderGroup(PreferencesUserSettingsTab.CODE_BLOCKS_SETTINGS)} +
    + +
    + {_t("Images, GIFs and videos")} + {this.renderGroup(PreferencesUserSettingsTab.IMAGES_AND_VIDEOS_SETTINGS)} +
    +
    {_t("Timeline")} {this.renderGroup(PreferencesUserSettingsTab.TIMELINE_SETTINGS)} diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js index 79d8c9224e..a03598b21f 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.js @@ -17,6 +17,8 @@ limitations under the License. import React from 'react'; import PropTypes from 'prop-types'; +import { sleep } from "matrix-js-sdk/src/utils"; + import { _t } from "../../../../../languageHandler"; import SdkConfig from "../../../../../SdkConfig"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; @@ -25,7 +27,6 @@ import AccessibleButton from "../../../elements/AccessibleButton"; import Analytics from "../../../../../Analytics"; import Modal from "../../../../../Modal"; import * as sdk from "../../../../.."; -import { sleep } from "../../../../../utils/promise"; import dis from "../../../../../dispatcher/dispatcher"; import { privateShouldBeEncrypted } from "../../../../../createRoom"; import { SettingLevel } from "../../../../../settings/SettingLevel"; diff --git a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx similarity index 50% rename from src/components/views/settings/tabs/user/VoiceUserSettingsTab.js rename to src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index fe6261cb21..86c32cc6cd 100644 --- a/src/components/views/settings/tabs/user/VoiceUserSettingsTab.js +++ b/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -18,41 +18,58 @@ limitations under the License. import React from 'react'; import { _t } from "../../../../../languageHandler"; import SdkConfig from "../../../../../SdkConfig"; -import MediaDeviceHandler from "../../../../../MediaDeviceHandler"; +import MediaDeviceHandler, { IMediaDevices, MediaDeviceKindEnum } from "../../../../../MediaDeviceHandler"; import Field from "../../../elements/Field"; import AccessibleButton from "../../../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; -import * as sdk from "../../../../../index"; import Modal from "../../../../../Modal"; import { SettingLevel } from "../../../../../settings/SettingLevel"; import { replaceableComponent } from "../../../../../utils/replaceableComponent"; +import SettingsFlag from '../../../elements/SettingsFlag'; +import ErrorDialog from '../../../dialogs/ErrorDialog'; + +const getDefaultDevice = (devices: Array>) => { + // Note we're looking for a device with deviceId 'default' but adding a device + // with deviceId == the empty string: this is because Chrome gives us a device + // with deviceId 'default', so we're looking for this, not the one we are adding. + if (!devices.some((i) => i.deviceId === 'default')) { + devices.unshift({ deviceId: '', label: _t('Default Device') }); + return ''; + } else { + return 'default'; + } +}; + +interface IState extends Record { + mediaDevices: IMediaDevices; +} @replaceableComponent("views.settings.tabs.user.VoiceUserSettingsTab") -export default class VoiceUserSettingsTab extends React.Component { - constructor() { - super(); +export default class VoiceUserSettingsTab extends React.Component<{}, IState> { + constructor(props: {}) { + super(props); this.state = { - mediaDevices: false, - activeAudioOutput: null, - activeAudioInput: null, - activeVideoInput: null, + mediaDevices: null, + [MediaDeviceKindEnum.AudioOutput]: null, + [MediaDeviceKindEnum.AudioInput]: null, + [MediaDeviceKindEnum.VideoInput]: null, }; } async componentDidMount() { const canSeeDeviceLabels = await MediaDeviceHandler.hasAnyLabeledDevices(); if (canSeeDeviceLabels) { - this._refreshMediaDevices(); + this.refreshMediaDevices(); } } - _refreshMediaDevices = async (stream) => { + private refreshMediaDevices = async (stream?: MediaStream): Promise => { this.setState({ mediaDevices: await MediaDeviceHandler.getDevices(), - activeAudioOutput: MediaDeviceHandler.getAudioOutput(), - activeAudioInput: MediaDeviceHandler.getAudioInput(), - activeVideoInput: MediaDeviceHandler.getVideoInput(), + [MediaDeviceKindEnum.AudioOutput]: MediaDeviceHandler.getAudioOutput(), + [MediaDeviceKindEnum.AudioInput]: MediaDeviceHandler.getAudioInput(), + [MediaDeviceKindEnum.VideoInput]: MediaDeviceHandler.getVideoInput(), }); if (stream) { // kill stream (after we've enumerated the devices, otherwise we'd get empty labels again) @@ -62,7 +79,7 @@ export default class VoiceUserSettingsTab extends React.Component { } }; - _requestMediaPermissions = async () => { + private requestMediaPermissions = async (): Promise => { let constraints; let stream; let error; @@ -86,7 +103,6 @@ export default class VoiceUserSettingsTab extends React.Component { if (error) { console.log("Failed to list userMedia devices", error); const brand = SdkConfig.get().brand; - const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog'); Modal.createTrackedDialog('No media permissions', '', ErrorDialog, { title: _t('No media permissions'), description: _t( @@ -95,137 +111,93 @@ export default class VoiceUserSettingsTab extends React.Component { ), }); } else { - this._refreshMediaDevices(stream); + this.refreshMediaDevices(stream); } }; - _setAudioOutput = (e) => { - MediaDeviceHandler.instance.setAudioOutput(e.target.value); - this.setState({ - activeAudioOutput: e.target.value, - }); + private setDevice = (deviceId: string, kind: MediaDeviceKindEnum): void => { + MediaDeviceHandler.instance.setDevice(deviceId, kind); + this.setState({ [kind]: deviceId }); }; - _setAudioInput = (e) => { - MediaDeviceHandler.instance.setAudioInput(e.target.value); - this.setState({ - activeAudioInput: e.target.value, - }); - }; - - _setVideoInput = (e) => { - MediaDeviceHandler.instance.setVideoInput(e.target.value); - this.setState({ - activeVideoInput: e.target.value, - }); - }; - - _changeWebRtcMethod = (p2p) => { + private changeWebRtcMethod = (p2p: boolean): void => { MatrixClientPeg.get().setForceTURN(!p2p); }; - _changeFallbackICEServerAllowed = (allow) => { + private changeFallbackICEServerAllowed = (allow: boolean): void => { MatrixClientPeg.get().setFallbackICEServerAllowed(allow); }; - _renderDeviceOptions(devices, category) { + private renderDeviceOptions(devices: Array, category: MediaDeviceKindEnum): Array { return devices.map((d) => { return (); }); } - render() { - const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); + private renderDropdown(kind: MediaDeviceKindEnum, label: string): JSX.Element { + const devices = this.state.mediaDevices[kind].slice(0); + if (devices.length === 0) return null; + const defaultDevice = getDefaultDevice(devices); + return ( + this.setDevice(e.target.value, kind)} + > + { this.renderDeviceOptions(devices, kind) } + + ); + } + + render() { let requestButton = null; let speakerDropdown = null; let microphoneDropdown = null; let webcamDropdown = null; - if (this.state.mediaDevices === false) { + if (!this.state.mediaDevices) { requestButton = (

    {_t("Missing media permissions, click the button below to request.")}

    - + {_t("Request media permissions")}
    ); } else if (this.state.mediaDevices) { - speakerDropdown =

    { _t('No Audio Outputs detected') }

    ; - microphoneDropdown =

    { _t('No Microphones detected') }

    ; - webcamDropdown =

    { _t('No Webcams detected') }

    ; - - const defaultOption = { - deviceId: '', - label: _t('Default Device'), - }; - const getDefaultDevice = (devices) => { - // Note we're looking for a device with deviceId 'default' but adding a device - // with deviceId == the empty string: this is because Chrome gives us a device - // with deviceId 'default', so we're looking for this, not the one we are adding. - if (!devices.some((i) => i.deviceId === 'default')) { - devices.unshift(defaultOption); - return ''; - } else { - return 'default'; - } - }; - - const audioOutputs = this.state.mediaDevices.audioOutput.slice(0); - if (audioOutputs.length > 0) { - const defaultDevice = getDefaultDevice(audioOutputs); - speakerDropdown = ( - - {this._renderDeviceOptions(audioOutputs, 'audioOutput')} - - ); - } - - const audioInputs = this.state.mediaDevices.audioInput.slice(0); - if (audioInputs.length > 0) { - const defaultDevice = getDefaultDevice(audioInputs); - microphoneDropdown = ( - - {this._renderDeviceOptions(audioInputs, 'audioInput')} - - ); - } - - const videoInputs = this.state.mediaDevices.videoInput.slice(0); - if (videoInputs.length > 0) { - const defaultDevice = getDefaultDevice(videoInputs); - webcamDropdown = ( - - {this._renderDeviceOptions(videoInputs, 'videoInput')} - - ); - } + speakerDropdown = ( + this.renderDropdown(MediaDeviceKindEnum.AudioOutput, _t("Audio Output")) || +

    { _t('No Audio Outputs detected') }

    + ); + microphoneDropdown = ( + this.renderDropdown(MediaDeviceKindEnum.AudioInput, _t("Microphone")) || +

    { _t('No Microphones detected') }

    + ); + webcamDropdown = ( + this.renderDropdown(MediaDeviceKindEnum.VideoInput, _t("Camera")) || +

    { _t('No Webcams detected') }

    + ); } return (
    {_t("Voice & Video")}
    - {requestButton} - {speakerDropdown} - {microphoneDropdown} - {webcamDropdown} + { requestButton } + { speakerDropdown } + { microphoneDropdown } + { webcamDropdown }
    diff --git a/src/components/views/spaces/SpaceBasicSettings.tsx b/src/components/views/spaces/SpaceBasicSettings.tsx index f6b98eedec..6d2cc1f5db 100644 --- a/src/components/views/spaces/SpaceBasicSettings.tsx +++ b/src/components/views/spaces/SpaceBasicSettings.tsx @@ -23,7 +23,7 @@ import Field from "../elements/Field"; interface IProps { avatarUrl?: string; avatarDisabled?: boolean; - name?: string, + name?: string; nameDisabled?: boolean; topic?: string; topicDisabled?: boolean; diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 4bb61d7ccb..5f16684fb8 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -220,6 +220,7 @@ const SpaceCreateMenu = ({ onFinished }) => { value={alias} placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")} label={_t("Address")} + disabled={busy} /> : null } diff --git a/src/components/views/spaces/SpacePublicShare.tsx b/src/components/views/spaces/SpacePublicShare.tsx index 4169e40269..39e5115e55 100644 --- a/src/components/views/spaces/SpacePublicShare.tsx +++ b/src/components/views/spaces/SpacePublicShare.tsx @@ -16,11 +16,11 @@ limitations under the License. import React, { useState } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; +import { sleep } from "matrix-js-sdk/src/utils"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; import { copyPlaintext } from "../../../utils/strings"; -import { sleep } from "../../../utils/promise"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { showRoomInviteDialog } from "../../../RoomInvite"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; diff --git a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx index 3afdc629e4..9f4e0ecea7 100644 --- a/src/components/views/spaces/SpaceSettingsGeneralTab.tsx +++ b/src/components/views/spaces/SpaceSettingsGeneralTab.tsx @@ -96,7 +96,7 @@ const SpaceSettingsGeneralTab = ({ matrixClient: cli, space, onFinished }: IProp { error &&
    { error }
    } - onFinished(false)} /> +
    void; + agreedUrls: string[]; // array of URLs the user has accepted + introElement: Node; +} + +interface IState { + policies: Policy[]; + busy: boolean; +} + +interface Policy { + checked: boolean; + url: string; + name: string; +} + @replaceableComponent("views.terms.InlineTermsAgreement") -export default class InlineTermsAgreement extends React.Component { - static propTypes = { - policiesAndServicePairs: PropTypes.array.isRequired, // array of service/policy pairs - agreedUrls: PropTypes.array.isRequired, // array of URLs the user has accepted - onFinished: PropTypes.func.isRequired, // takes an argument of accepted URLs - introElement: PropTypes.node, - }; - - constructor() { - super(); - +export default class InlineTermsAgreement extends React.Component { + constructor(props: IProps) { + super(props); this.state = { policies: [], busy: false, }; } - componentDidMount() { + public componentDidMount(): void { // Build all the terms the user needs to accept const policies = []; // { checked, url, name } for (const servicePolicies of this.props.policiesAndServicePairs) { const availablePolicies = Object.values(servicePolicies.policies); for (const policy of availablePolicies) { const language = pickBestLanguage(Object.keys(policy).filter(p => p !== 'version')); - const renderablePolicy = { + const renderablePolicy: Policy = { checked: false, url: policy[language].url, name: policy[language].name, @@ -59,13 +68,13 @@ export default class InlineTermsAgreement extends React.Component { this.setState({ policies }); } - _togglePolicy = (index) => { + private togglePolicy = (index: number): void => { const policies = objectClone(this.state.policies); policies[index].checked = !policies[index].checked; this.setState({ policies }); }; - _onContinue = () => { + private onContinue = (): void => { const hasUnchecked = !!this.state.policies.some(p => !p.checked); if (hasUnchecked) return; @@ -73,7 +82,7 @@ export default class InlineTermsAgreement extends React.Component { this.props.onFinished(this.state.policies.map(p => p.url)); }; - _renderCheckboxes() { + private renderCheckboxes(): React.ReactNode[] { const rendered = []; for (let i = 0; i < this.state.policies.length; i++) { const policy = this.state.policies[i]; @@ -93,7 +102,7 @@ export default class InlineTermsAgreement extends React.Component {
    {introText}
    - this._togglePolicy(i)} checked={policy.checked}> + this.togglePolicy(i)} checked={policy.checked}> {_t("Accept")}
    @@ -103,16 +112,16 @@ export default class InlineTermsAgreement extends React.Component { return rendered; } - render() { + public render(): React.ReactNode { const AccessibleButton = sdk.getComponent("views.elements.AccessibleButton"); const hasUnchecked = !!this.state.policies.some(p => !p.checked); return (
    {this.props.introElement} - {this._renderCheckboxes()} + {this.renderCheckboxes()} diff --git a/src/components/views/toasts/VerificationRequestToast.tsx b/src/components/views/toasts/VerificationRequestToast.tsx index 8f6b552334..75254d7c62 100644 --- a/src/components/views/toasts/VerificationRequestToast.tsx +++ b/src/components/views/toasts/VerificationRequestToast.tsx @@ -16,7 +16,6 @@ limitations under the License. import React from "react"; -import * as sdk from "../../../index"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; @@ -30,6 +29,7 @@ import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/reque import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; import { Action } from "../../../dispatcher/actions"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import VerificationRequestDialog from "../dialogs/VerificationRequestDialog"; interface IProps { toastKey: string; @@ -123,7 +123,6 @@ export default class VerificationRequestToast extends React.PureComponent { diff --git a/src/components/views/verification/VerificationCancelled.js b/src/components/views/verification/VerificationCancelled.tsx similarity index 90% rename from src/components/views/verification/VerificationCancelled.js rename to src/components/views/verification/VerificationCancelled.tsx index 0d868edaaa..aa34b22382 100644 --- a/src/components/views/verification/VerificationCancelled.js +++ b/src/components/views/verification/VerificationCancelled.tsx @@ -15,18 +15,17 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -@replaceableComponent("views.verification.VerificationCancelled") -export default class VerificationCancelled extends React.Component { - static propTypes = { - onDone: PropTypes.func.isRequired, - } +interface IProps { + onDone: () => void; +} - render() { +@replaceableComponent("views.verification.VerificationCancelled") +export default class VerificationCancelled extends React.Component { + public render(): React.ReactNode { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

    {_t( diff --git a/src/components/views/verification/VerificationComplete.js b/src/components/views/verification/VerificationComplete.tsx similarity index 91% rename from src/components/views/verification/VerificationComplete.js rename to src/components/views/verification/VerificationComplete.tsx index c382a6dde4..7da601fc93 100644 --- a/src/components/views/verification/VerificationComplete.js +++ b/src/components/views/verification/VerificationComplete.tsx @@ -15,18 +15,17 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; import * as sdk from '../../../index'; import { _t } from '../../../languageHandler'; import { replaceableComponent } from "../../../utils/replaceableComponent"; -@replaceableComponent("views.verification.VerificationComplete") -export default class VerificationComplete extends React.Component { - static propTypes = { - onDone: PropTypes.func.isRequired, - } +interface IProps { + onDone: () => void; +} - render() { +@replaceableComponent("views.verification.VerificationComplete") +export default class VerificationComplete extends React.Component { + public render(): React.ReactNode { const DialogButtons = sdk.getComponent('views.elements.DialogButtons'); return

    {_t("Verified!")}

    diff --git a/src/components/views/verification/VerificationQREmojiOptions.js b/src/components/views/verification/VerificationQREmojiOptions.js deleted file mode 100644 index 785d383035..0000000000 --- a/src/components/views/verification/VerificationQREmojiOptions.js +++ /dev/null @@ -1,68 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; -import AccessibleButton from "../elements/AccessibleButton"; -import { replaceableComponent } from "../../../utils/replaceableComponent"; -import VerificationQRCode from "../elements/crypto/VerificationQRCode"; -import Spinner from "../elements/Spinner"; -import { SCAN_QR_CODE_METHOD } from "matrix-js-sdk/src/crypto/verification/QRCode"; - -@replaceableComponent("views.verification.VerificationQREmojiOptions") -export default class VerificationQREmojiOptions extends React.Component { - static propTypes = { - request: PropTypes.object.isRequired, - onCancel: PropTypes.func.isRequired, - onStartEmoji: PropTypes.func.isRequired, - }; - - render() { - const { request } = this.props; - const showQR = request.otherPartySupportsMethod(SCAN_QR_CODE_METHOD); - - let qrCode; - if (showQR) { - qrCode = ; - } else { - qrCode =
    ; - } - - return ( -
    - {_t("Verify this session by completing one of the following:")} -
    -
    -

    {_t("Scan this unique code")}

    - {qrCode} -
    -
    {_t("or")}
    -
    -

    {_t("Compare unique emoji")}

    - {_t("Compare a unique set of emoji if you don't have a camera on either device")} - - {_t("Start")} - -
    -
    - - {_t("Cancel")} - -
    - ); - } -} diff --git a/src/components/views/verification/VerificationShowSas.js b/src/components/views/verification/VerificationShowSas.tsx similarity index 91% rename from src/components/views/verification/VerificationShowSas.js rename to src/components/views/verification/VerificationShowSas.tsx index 3f154b9f01..aaf0ca4848 100644 --- a/src/components/views/verification/VerificationShowSas.js +++ b/src/components/views/verification/VerificationShowSas.tsx @@ -15,7 +15,8 @@ limitations under the License. */ import React from 'react'; -import PropTypes from 'prop-types'; +import { SAS } from "matrix-js-sdk/src/crypto/verification/SAS"; +import { DeviceInfo } from "matrix-js-sdk/src//crypto/deviceinfo"; import { _t, _td } from '../../../languageHandler'; import { PendingActionSpinner } from "../right_panel/EncryptionInfo"; import AccessibleButton from "../elements/AccessibleButton"; @@ -23,24 +24,29 @@ import DialogButtons from "../elements/DialogButtons"; import { fixupColorFonts } from '../../../utils/FontManager'; import { replaceableComponent } from "../../../utils/replaceableComponent"; +interface IProps { + pending?: boolean; + displayName?: string; // required if pending is true + device?: DeviceInfo; + onDone: () => void; + onCancel: () => void; + sas: SAS.sas; + isSelf?: boolean; + inDialog?: boolean; // whether this component is being shown in a dialog and to use DialogButtons +} + +interface IState { + pending: boolean; + cancelling?: boolean; +} + function capFirst(s) { return s.charAt(0).toUpperCase() + s.slice(1); } @replaceableComponent("views.verification.VerificationShowSas") -export default class VerificationShowSas extends React.Component { - static propTypes = { - pending: PropTypes.bool, - displayName: PropTypes.string, // required if pending is true - device: PropTypes.object, - onDone: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, - sas: PropTypes.object.isRequired, - isSelf: PropTypes.bool, - inDialog: PropTypes.bool, // whether this component is being shown in a dialog and to use DialogButtons - }; - - constructor(props) { +export default class VerificationShowSas extends React.Component { + constructor(props: IProps) { super(props); this.state = { @@ -48,19 +54,19 @@ export default class VerificationShowSas extends React.Component { }; } - componentWillMount() { + public componentWillMount(): void { // As this component is also used before login (during complete security), // also make sure we have a working emoji font to display the SAS emojis here. // This is also done from LoggedInView. fixupColorFonts(); } - onMatchClick = () => { + private onMatchClick = (): void => { this.setState({ pending: true }); this.props.onDone(); }; - onDontMatchClick = () => { + private onDontMatchClick = (): void => { this.setState({ cancelling: true }); this.props.onCancel(); }; diff --git a/src/components/views/voip/AudioFeed.tsx b/src/components/views/voip/AudioFeed.tsx index 272d8a06a3..a2ab760c86 100644 --- a/src/components/views/voip/AudioFeed.tsx +++ b/src/components/views/voip/AudioFeed.tsx @@ -20,7 +20,7 @@ import { logger } from 'matrix-js-sdk/src/logger'; import MediaDeviceHandler, { MediaDeviceHandlerEvent } from "../../../MediaDeviceHandler"; interface IProps { - feed: CallFeed, + feed: CallFeed; } export default class AudioFeed extends React.Component { diff --git a/src/components/views/voip/CallPreview.tsx b/src/components/views/voip/CallPreview.tsx index 5d6a564bc2..ddcb9057ec 100644 --- a/src/components/views/voip/CallPreview.tsx +++ b/src/components/views/voip/CallPreview.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from 'react'; +import React, { createRef } from 'react'; import CallView from "./CallView"; import RoomViewStore from '../../../stores/RoomViewStore'; @@ -27,6 +27,22 @@ 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 UIStore from '../../../stores/UIStore'; +import { lerp } from '../../../utils/AnimationUtils'; +import { MarkedExecution } from '../../../utils/MarkedExecution'; + +const PIP_VIEW_WIDTH = 336; +const PIP_VIEW_HEIGHT = 232; + +const MOVING_AMT = 0.2; +const SNAPPING_AMT = 0.05; + +const PADDING = { + top: 58, + bottom: 58, + left: 76, + right: 8, +}; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -49,6 +65,10 @@ interface IState { // Any other call we're displaying: only if the user is on two calls and not viewing either of the rooms // they belong to secondaryCall: MatrixCall; + + // Position of the CallPreview + translationX: number; + translationY: number; } // Splits a list of calls into one 'primary' one and a list @@ -91,6 +111,16 @@ export default class CallPreview extends React.Component { private roomStoreToken: any; private dispatcherRef: string; private settingsWatcherRef: string; + private callViewWrapper = createRef(); + private initX = 0; + private initY = 0; + private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH; + private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH; + private moving = false; + private scheduledUpdate = new MarkedExecution( + () => this.animationCallback(), + () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), + ); constructor(props: IProps) { super(props); @@ -105,12 +135,17 @@ export default class CallPreview extends React.Component { roomId, primaryCall: primaryCall, secondaryCall: secondaryCalls[0], + translationX: UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH, + translationY: UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_WIDTH, }; } public componentDidMount() { CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); + document.addEventListener("mousemove", this.onMoving); + document.addEventListener("mouseup", this.onEndMoving); + window.addEventListener("resize", this.snap); this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); } @@ -118,6 +153,9 @@ export default class CallPreview extends React.Component { public componentWillUnmount() { CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); + document.removeEventListener("mousemove", this.onMoving); + document.removeEventListener("mouseup", this.onEndMoving); + window.removeEventListener("resize", this.snap); if (this.roomStoreToken) { this.roomStoreToken.remove(); } @@ -125,6 +163,83 @@ export default class CallPreview extends React.Component { SettingsStore.unwatchSetting(this.settingsWatcherRef); } + private animationCallback = () => { + // If the PiP isn't being dragged and there is only a tiny difference in + // the desiredTranslation and translation, quit the animationCallback + // loop. If that is the case, it means the PiP has snapped into its + // position and there is nothing to do. Not doing this would cause an + // infinite loop + if ( + !this.moving && + Math.abs(this.state.translationX - this.desiredTranslationX) <= 1 && + Math.abs(this.state.translationY - this.desiredTranslationY) <= 1 + ) return; + + const amt = this.moving ? MOVING_AMT : SNAPPING_AMT; + this.setState({ + translationX: lerp(this.state.translationX, this.desiredTranslationX, amt), + translationY: lerp(this.state.translationY, this.desiredTranslationY, amt), + }); + this.scheduledUpdate.mark(); + }; + + private setTranslation(inTranslationX: number, inTranslationY: number) { + const width = this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH; + const height = this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT; + + // Avoid overflow on the x axis + if (inTranslationX + width >= UIStore.instance.windowWidth) { + this.desiredTranslationX = UIStore.instance.windowWidth - width; + } else if (inTranslationX <= 0) { + this.desiredTranslationX = 0; + } else { + this.desiredTranslationX = inTranslationX; + } + + // Avoid overflow on the y axis + if (inTranslationY + height >= UIStore.instance.windowHeight) { + this.desiredTranslationY = UIStore.instance.windowHeight - height; + } else if (inTranslationY <= 0) { + this.desiredTranslationY = 0; + } else { + this.desiredTranslationY = inTranslationY; + } + } + + private snap = () => { + const translationX = this.desiredTranslationX; + const translationY = this.desiredTranslationY; + // We subtract the PiP size from the window size in order to calculate + // the position to snap to from the PiP center and not its top-left + // corner + const windowWidth = ( + UIStore.instance.windowWidth - + (this.callViewWrapper.current?.clientWidth || PIP_VIEW_WIDTH) + ); + const windowHeight = ( + UIStore.instance.windowHeight - + (this.callViewWrapper.current?.clientHeight || PIP_VIEW_HEIGHT) + ); + + if (translationX >= windowWidth / 2 && translationY >= windowHeight / 2) { + this.desiredTranslationX = windowWidth - PADDING.right; + this.desiredTranslationY = windowHeight - PADDING.bottom; + } else if (translationX >= windowWidth / 2 && translationY <= windowHeight / 2) { + this.desiredTranslationX = windowWidth - PADDING.right; + this.desiredTranslationY = PADDING.top; + } else if (translationX <= windowWidth / 2 && translationY >= windowHeight / 2) { + this.desiredTranslationX = PADDING.left; + this.desiredTranslationY = windowHeight - PADDING.bottom; + } else { + this.desiredTranslationX = PADDING.left; + this.desiredTranslationY = PADDING.top; + } + + // We start animating here because we want the PiP to move when we're + // resizing the window + this.scheduledUpdate.mark(); + }; + private onRoomViewStoreUpdate = (payload) => { if (RoomViewStore.getRoomId() === this.state.roomId) return; @@ -173,10 +288,52 @@ export default class CallPreview extends React.Component { }); }; + private onStartMoving = (event: React.MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + + this.moving = true; + this.initX = event.pageX - this.desiredTranslationX; + this.initY = event.pageY - this.desiredTranslationY; + this.scheduledUpdate.mark(); + }; + + private onMoving = (event: React.MouseEvent | MouseEvent) => { + if (!this.moving) return; + + event.preventDefault(); + event.stopPropagation(); + + this.setTranslation(event.pageX - this.initX, event.pageY - this.initY); + }; + + private onEndMoving = () => { + this.moving = false; + this.snap(); + }; + public render() { if (this.state.primaryCall) { + const translatePixelsX = this.state.translationX + "px"; + const translatePixelsY = this.state.translationY + "px"; + const style = { + transform: `translateX(${translatePixelsX}) + translateY(${translatePixelsY})`, + }; + return ( - +
    + +
    ); } diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index c522116e0a..64c101a284 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -35,10 +35,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { // The call for us to display - call: MatrixCall, + call: MatrixCall; // Another ongoing call to display information about - secondaryCall?: MatrixCall, + secondaryCall?: MatrixCall; // a callback which is called when the content in the CallView changes // in a way that is likely to cause a resize. @@ -49,18 +49,21 @@ interface IProps { // This is sort of a proxy for a number of things but we currently have no // need to control those things separately, so this is simpler. pipMode?: boolean; + + // Used for dragging the PiP CallView + onMouseDownOnHeader?: (event: React.MouseEvent) => void; } interface IState { - isLocalOnHold: boolean, - isRemoteOnHold: boolean, - micMuted: boolean, - vidMuted: boolean, - callState: CallState, - controlsVisible: boolean, - showMoreMenu: boolean, - showDialpad: boolean, - feeds: CallFeed[], + isLocalOnHold: boolean; + isRemoteOnHold: boolean; + micMuted: boolean; + vidMuted: boolean; + callState: CallState; + controlsVisible: boolean; + showMoreMenu: boolean; + showDialpad: boolean; + feeds: CallFeed[]; } function getFullScreenElement() { @@ -698,19 +701,24 @@ export default class CallView extends React.Component { ; } - header =
    - - - -
    -
    {callRoom.name}
    -
    - {callTypeText} - {secondaryCallInfo} + header = ( +
    + + + +
    +
    {callRoom.name}
    +
    + {callTypeText} + {secondaryCallInfo} +
    + {headerControls}
    - {headerControls} -
    ; + ); myClassName = 'mx_CallView_pip'; } diff --git a/src/components/views/voip/CallViewForRoom.tsx b/src/components/views/voip/CallViewForRoom.tsx index 9557fe0e7e..a5aa3e7734 100644 --- a/src/components/views/voip/CallViewForRoom.tsx +++ b/src/components/views/voip/CallViewForRoom.tsx @@ -25,16 +25,16 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { // What room we should display the call for - roomId: string, + roomId: string; // maxHeight style attribute for the video panel maxVideoHeight?: number; - resizeNotifier: ResizeNotifier, + resizeNotifier: ResizeNotifier; } interface IState { - call: MatrixCall, + call: MatrixCall; } /* diff --git a/src/components/views/voip/VideoFeed.tsx b/src/components/views/voip/VideoFeed.tsx index 2f88abe6fb..e5461eb1b4 100644 --- a/src/components/views/voip/VideoFeed.tsx +++ b/src/components/views/voip/VideoFeed.tsx @@ -24,9 +24,9 @@ import MemberAvatar from "../avatars/MemberAvatar"; import { replaceableComponent } from "../../../utils/replaceableComponent"; interface IProps { - call: MatrixCall, + call: MatrixCall; - feed: CallFeed, + feed: CallFeed; // Whether this call view is for picture-in-picture mode // otherwise, it's the larger call view when viewing the room the call is in. @@ -36,7 +36,7 @@ interface IProps { // a callback which is called when the video element is resized // due to a change in video metadata - onResize?: (e: Event) => void, + onResize?: (e: Event) => void; } interface IState { diff --git a/src/createRoom.ts b/src/createRoom.ts index 94efecaa6c..afbeb7fdb9 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -21,7 +21,6 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClientPeg } from './MatrixClientPeg'; import Modal from './Modal'; -import * as sdk from './index'; import { _t } from './languageHandler'; import dis from "./dispatcher/dispatcher"; import * as Rooms from "./Rooms"; @@ -37,6 +36,8 @@ import { makeSpaceParentEvent } from "./utils/space"; import { Action } from "./dispatcher/actions"; import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; import { Preset, Visibility } from "matrix-js-sdk/src/@types/partials"; +import ErrorDialog from "./components/views/dialogs/ErrorDialog"; +import Spinner from "./components/views/elements/Spinner"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -80,9 +81,6 @@ export default function createRoom(opts: IOpts): Promise { const startTime = CountlyAnalytics.getTimestamp(); - const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - const Loader = sdk.getComponent("elements.Spinner"); - const client = MatrixClientPeg.get(); if (client.isGuest()) { dis.dispatch({ action: 'require_registration' }); @@ -153,7 +151,7 @@ export default function createRoom(opts: IOpts): Promise { } let modal; - if (opts.spinner) modal = Modal.createDialog(Loader, null, 'mx_Dialog_spinner'); + if (opts.spinner) modal = Modal.createDialog(Spinner, null, 'mx_Dialog_spinner'); let roomId; return client.createRoom(createOpts).finally(function() { diff --git a/src/customisations/Media.ts b/src/customisations/Media.ts index f8ee834102..ce8f88f6f1 100644 --- a/src/customisations/Media.ts +++ b/src/customisations/Media.ts @@ -75,6 +75,7 @@ export class Media { * The HTTP URL for the source media. */ public get srcHttp(): string { + // eslint-disable-next-line no-restricted-properties return this.client.mxcUrlToHttp(this.srcMxc); } @@ -84,6 +85,7 @@ export class Media { */ public get thumbnailHttp(): string | undefined | null { if (!this.hasThumbnail) return null; + // eslint-disable-next-line no-restricted-properties return this.client.mxcUrlToHttp(this.thumbnailMxc); } @@ -100,6 +102,7 @@ export class Media { // scale using the device pixel ratio to keep images clear width = Math.floor(width * window.devicePixelRatio); height = Math.floor(height * window.devicePixelRatio); + // eslint-disable-next-line no-restricted-properties return this.client.mxcUrlToHttp(this.thumbnailMxc, width, height, mode); } @@ -114,6 +117,7 @@ export class Media { // scale using the device pixel ratio to keep images clear width = Math.floor(width * window.devicePixelRatio); height = Math.floor(height * window.devicePixelRatio); + // eslint-disable-next-line no-restricted-properties return this.client.mxcUrlToHttp(this.srcMxc, width, height, mode); } diff --git a/src/customisations/Security.ts b/src/customisations/Security.ts index e215c5cb24..9083274aa5 100644 --- a/src/customisations/Security.ts +++ b/src/customisations/Security.ts @@ -16,7 +16,7 @@ limitations under the License. import { IMatrixClientCreds } from "../MatrixClientPeg"; import { Kind as SetupEncryptionKind } from "../toasts/SetupEncryptionToast"; -import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/matrix'; +import { ISecretStorageKeyInfo } from 'matrix-js-sdk/src/crypto/api'; /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ function examineLoginResponse( @@ -69,11 +69,11 @@ function setupEncryptionNeeded(kind: SetupEncryptionKind): boolean { export interface ISecurityCustomisations { examineLoginResponse?: typeof examineLoginResponse; persistCredentials?: typeof persistCredentials; - createSecretStorageKey?: typeof createSecretStorageKey, - getSecretStorageKey?: typeof getSecretStorageKey, - catchAccessSecretStorageError?: typeof catchAccessSecretStorageError, - setupEncryptionNeeded?: typeof setupEncryptionNeeded, - getDehydrationKey?: typeof getDehydrationKey, + createSecretStorageKey?: typeof createSecretStorageKey; + getSecretStorageKey?: typeof getSecretStorageKey; + catchAccessSecretStorageError?: typeof catchAccessSecretStorageError; + setupEncryptionNeeded?: typeof setupEncryptionNeeded; + getDehydrationKey?: typeof getDehydrationKey; /** * When false, disables the post-login UI from showing. If there's @@ -83,7 +83,7 @@ export interface ISecurityCustomisations { * encryption is set up some other way which would circumvent the default * UI, such as by presenting alternative UI. */ - SHOW_ENCRYPTION_SETUP_UI?: boolean, // default true + SHOW_ENCRYPTION_SETUP_UI?: boolean; // default true } // A real customisation module will define and export one or more of the diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 6438ecc0f2..a4bfa171cd 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -56,9 +56,21 @@ export enum Action { CheckUpdates = "check_updates", /** - * Focuses the user's cursor to the composer. No additional payload information required. + * Focuses the user's cursor to the send message composer. No additional payload information required. */ - FocusComposer = "focus_composer", + FocusSendMessageComposer = "focus_send_message_composer", + + /** + * Focuses the user's cursor to the edit message composer. No additional payload information required. + */ + FocusEditMessageComposer = "focus_edit_message_composer", + + /** + * Focuses the user's cursor to the edit message composer or send message + * composer based on the current edit state. No additional payload + * information required. + */ + FocusAComposer = "focus_a_composer", /** * Opens the user menu (previously known as the top left menu). No additional payload information required. diff --git a/src/dispatcher/payloads/ComposerInsertPayload.ts b/src/dispatcher/payloads/ComposerInsertPayload.ts index 9702855432..ea5d8a0c53 100644 --- a/src/dispatcher/payloads/ComposerInsertPayload.ts +++ b/src/dispatcher/payloads/ComposerInsertPayload.ts @@ -20,7 +20,7 @@ import { ActionPayload } from "../payloads"; import { Action } from "../actions"; interface IBaseComposerInsertPayload extends ActionPayload { - action: Action.ComposerInsert, + action: Action.ComposerInsert; } interface IComposerInsertMentionPayload extends IBaseComposerInsertPayload { diff --git a/src/editor/model.ts b/src/editor/model.ts index 1e8498a69e..da1c2f47f5 100644 --- a/src/editor/model.ts +++ b/src/editor/model.ts @@ -70,7 +70,7 @@ export default class EditorModel { * on the model that can span multiple parts. Also see `startRange()`. * @param {TransformCallback} transformCallback */ - setTransformCallback(transformCallback: TransformCallback) { + public setTransformCallback(transformCallback: TransformCallback): void { this.transformCallback = transformCallback; } @@ -78,23 +78,23 @@ export default class EditorModel { * Set a callback for rerendering the model after it has been updated. * @param {ModelCallback} updateCallback */ - setUpdateCallback(updateCallback: UpdateCallback) { + public setUpdateCallback(updateCallback: UpdateCallback): void { this.updateCallback = updateCallback; } - get partCreator() { + public get partCreator(): PartCreator { return this._partCreator; } - get isEmpty() { + public get isEmpty(): boolean { return this._parts.reduce((len, part) => len + part.text.length, 0) === 0; } - clone() { + public clone(): EditorModel { return new EditorModel(this._parts, this._partCreator, this.updateCallback); } - private insertPart(index: number, part: Part) { + private insertPart(index: number, part: Part): void { this._parts.splice(index, 0, part); if (this.activePartIdx >= index) { ++this.activePartIdx; @@ -104,7 +104,7 @@ export default class EditorModel { } } - private removePart(index: number) { + private removePart(index: number): void { this._parts.splice(index, 1); if (index === this.activePartIdx) { this.activePartIdx = null; @@ -118,22 +118,22 @@ export default class EditorModel { } } - private replacePart(index: number, part: Part) { + private replacePart(index: number, part: Part): void { this._parts.splice(index, 1, part); } - get parts() { + public get parts(): Part[] { return this._parts; } - get autoComplete() { + public get autoComplete(): AutocompleteWrapperModel { if (this.activePartIdx === this.autoCompletePartIdx) { return this._autoComplete; } return null; } - getPositionAtEnd() { + public getPositionAtEnd(): DocumentPosition { if (this._parts.length) { const index = this._parts.length - 1; const part = this._parts[index]; @@ -144,11 +144,11 @@ export default class EditorModel { } } - serializeParts() { + public serializeParts(): SerializedPart[] { return this._parts.map(p => p.serialize()); } - private diff(newValue: string, inputType: string, caret: DocumentOffset) { + private diff(newValue: string, inputType: string, caret: DocumentOffset): IDiff { const previousValue = this.parts.reduce((text, p) => text + p.text, ""); // can't use caret position with drag and drop if (inputType === "deleteByDrag") { @@ -158,7 +158,7 @@ export default class EditorModel { } } - reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string) { + public reset(serializedParts: SerializedPart[], caret?: Caret, inputType?: string): void { this._parts = serializedParts.map(p => this._partCreator.deserializePart(p)); if (!caret) { caret = this.getPositionAtEnd(); @@ -180,7 +180,7 @@ export default class EditorModel { * @param {DocumentPosition} position the position to start inserting at * @return {Number} the amount of characters added */ - insert(parts: Part[], position: IPosition) { + public insert(parts: Part[], position: IPosition): number { const insertIndex = this.splitAt(position); let newTextLength = 0; for (let i = 0; i < parts.length; ++i) { @@ -191,7 +191,7 @@ export default class EditorModel { return newTextLength; } - update(newValue: string, inputType: string, caret: DocumentOffset) { + public update(newValue: string, inputType: string, caret: DocumentOffset): Promise { const diff = this.diff(newValue, inputType, caret); const position = this.positionForOffset(diff.at, caret.atNodeEnd); let removedOffsetDecrease = 0; @@ -220,7 +220,7 @@ export default class EditorModel { return Number.isFinite(result) ? result as number : 0; } - private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean) { + private setActivePart(pos: DocumentPosition, canOpenAutoComplete: boolean): Promise { const { index } = pos; const part = this._parts[index]; if (part) { @@ -250,7 +250,7 @@ export default class EditorModel { return Promise.resolve(); } - private onAutoComplete = ({ replaceParts, close }: ICallback) => { + private onAutoComplete = ({ replaceParts, close }: ICallback): void => { let pos; if (replaceParts) { this._parts.splice(this.autoCompletePartIdx, this.autoCompletePartCount, ...replaceParts); @@ -270,7 +270,7 @@ export default class EditorModel { this.updateCallback(pos); }; - private mergeAdjacentParts() { + private mergeAdjacentParts(): void { let prevPart; for (let i = 0; i < this._parts.length; ++i) { let part = this._parts[i]; @@ -294,7 +294,7 @@ export default class EditorModel { * @return {Number} how many characters before pos were also removed, * usually because of non-editable parts that can only be removed in their entirety. */ - removeText(pos: IPosition, len: number) { + public removeText(pos: IPosition, len: number): number { let { index, offset } = pos; let removedOffsetDecrease = 0; while (len > 0) { @@ -329,7 +329,7 @@ export default class EditorModel { } // return part index where insertion will insert between at offset - private splitAt(pos: IPosition) { + private splitAt(pos: IPosition): number { if (pos.index === -1) { return 0; } @@ -356,7 +356,7 @@ export default class EditorModel { * @return {Number} how far from position (in characters) the insertion ended. * This can be more than the length of `str` when crossing non-editable parts, which are skipped. */ - private addText(pos: IPosition, str: string, inputType: string) { + private addText(pos: IPosition, str: string, inputType: string): number { let { index } = pos; const { offset } = pos; let addLen = str.length; @@ -390,7 +390,7 @@ export default class EditorModel { return addLen; } - positionForOffset(totalOffset: number, atPartEnd = false) { + public positionForOffset(totalOffset: number, atPartEnd = false): DocumentPosition { let currentOffset = 0; const index = this._parts.findIndex(part => { const partLen = part.text.length; @@ -416,11 +416,11 @@ export default class EditorModel { * @param {DocumentPosition?} positionB the other boundary of the range, optional * @return {Range} */ - startRange(positionA: DocumentPosition, positionB = positionA) { + public startRange(positionA: DocumentPosition, positionB = positionA): Range { return new Range(this, positionA, positionB); } - replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]) { + public replaceRange(startPosition: DocumentPosition, endPosition: DocumentPosition, parts: Part[]): void { // convert end position to offset, so it is independent of how the document is split into parts // which we'll change when splitting up at the start position const endOffset = endPosition.asOffset(this); @@ -445,9 +445,9 @@ export default class EditorModel { * @param {ManualTransformCallback} callback to run the transformations in * @return {Promise} a promise when auto-complete (if applicable) is done updating */ - transform(callback: ManualTransformCallback) { + public transform(callback: ManualTransformCallback): Promise { const pos = callback(); - let acPromise = null; + let acPromise: Promise = null; if (!(pos instanceof Range)) { acPromise = this.setActivePart(pos, true); } else { diff --git a/src/editor/parts.ts b/src/editor/parts.ts index c16a95dbc9..351df5062f 100644 --- a/src/editor/parts.ts +++ b/src/editor/parts.ts @@ -552,7 +552,7 @@ export class PartCreator { // part creator that support auto complete for /commands, // used in SendMessageComposer export class CommandPartCreator extends PartCreator { - createPartForInput(text: string, partIndex: number) { + public createPartForInput(text: string, partIndex: number): Part { // at beginning and starts with /? create if (partIndex === 0 && text[0] === "/") { // text will be inserted by model, so pass empty string @@ -562,11 +562,11 @@ export class CommandPartCreator extends PartCreator { } } - command(text: string) { + public command(text: string): CommandPart { return new CommandPart(text, this.autoCompleteCreator); } - deserializePart(part: Part): Part { + public deserializePart(part: SerializedPart): Part { if (part.type === "command") { return this.command(part.text); } else { @@ -576,7 +576,7 @@ export class CommandPartCreator extends PartCreator { } class CommandPart extends PillCandidatePart { - get type(): IPillCandidatePart["type"] { + public get type(): IPillCandidatePart["type"] { return Type.Command; } } diff --git a/src/effects/confetti/index.ts b/src/effects/confetti/index.ts index 53e5dda5d2..ae2bb822c2 100644 --- a/src/effects/confetti/index.ts +++ b/src/effects/confetti/index.ts @@ -20,34 +20,34 @@ export type ConfettiOptions = { /** * max confetti count */ - maxCount: number, + maxCount: number; /** * particle animation speed */ - speed: number, + speed: number; /** * the confetti animation frame interval in milliseconds */ - frameInterval: number, + frameInterval: number; /** * the alpha opacity of the confetti (between 0 and 1, where 1 is opaque and 0 is invisible) */ - alpha: number, + alpha: number; /** * use gradient instead of solid particle color */ - gradient: boolean, + gradient: boolean; }; type ConfettiParticle = { - color: string, - color2: string, - x: number, - y: number, - diameter: number, - tilt: number, - tiltAngleIncrement: number, - tiltAngle: number, + color: string; + color2: string; + x: number; + y: number; + diameter: number; + tilt: number; + tiltAngleIncrement: number; + tiltAngle: number; }; export const DefaultOptions: ConfettiOptions = { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f0599c7e49..7795bb2610 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -840,8 +840,8 @@ "Enable big emoji in chat": "Enable big emoji in chat", "Send typing notifications": "Send typing notifications", "Show typing notifications": "Show typing notifications", - "Use Command + F to search": "Use Command + F to search", - "Use Ctrl + F to search": "Use Ctrl + F to search", + "Use Command + F to search timeline": "Use Command + F to search timeline", + "Use Ctrl + F to search timeline": "Use Ctrl + F to search timeline", "Use Command + Enter to send a message": "Use Command + Enter to send a message", "Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", @@ -918,19 +918,11 @@ "Silence call": "Silence 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.", "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.": "Secure messages with this user are end-to-end encrypted and not able to be read by third parties.", "Got It": "Got It", - "Verify this session by completing one of the following:": "Verify this session by completing one of the following:", - "Scan this unique code": "Scan this unique code", - "or": "or", - "Compare unique emoji": "Compare unique emoji", - "Compare a unique set of emoji if you don't have a camera on either device": "Compare a unique set of emoji if you don't have a camera on either device", - "Start": "Start", "Confirm the emoji below are displayed on both sessions, in the same order:": "Confirm the emoji below are displayed on both sessions, in the same order:", "Verify this user by confirming the following emoji appear on their screen.": "Verify this user by confirming the following emoji appear on their screen.", "Verify this session by confirming the following number appears on its screen.": "Verify this session by confirming the following number appears on its screen.", @@ -1341,7 +1333,12 @@ "Show tray icon and minimize window to it on close": "Show tray icon and minimize window to it on close", "Preferences": "Preferences", "Room list": "Room list", + "Keyboard shortcuts": "Keyboard shortcuts", + "To view all keyboard shortcuts, click here.": "To view all keyboard shortcuts, click here.", + "Displaying time": "Displaying time", "Composer": "Composer", + "Code blocks": "Code blocks", + "Images, GIFs and videos": "Images, GIFs and videos", "Timeline": "Timeline", "Autocomplete delay (ms)": "Autocomplete delay (ms)", "Read Marker lifetime (ms)": "Read Marker lifetime (ms)", @@ -1367,17 +1364,17 @@ "Where you’re logged in": "Where you’re logged in", "Manage the names of and sign out of your sessions below or verify them in your User Profile.": "Manage the names of and sign out of your sessions below or verify them in your User Profile.", "A session's public name is visible to people you communicate with": "A session's public name is visible to people you communicate with", + "Default Device": "Default Device", "No media permissions": "No media permissions", "You may need to manually permit %(brand)s to access your microphone/webcam": "You may need to manually permit %(brand)s to access your microphone/webcam", "Missing media permissions, click the button below to request.": "Missing media permissions, click the button below to request.", "Request media permissions": "Request media permissions", - "No Audio Outputs detected": "No Audio Outputs detected", - "No Microphones detected": "No Microphones detected", - "No Webcams detected": "No Webcams detected", - "Default Device": "Default Device", "Audio Output": "Audio Output", + "No Audio Outputs detected": "No Audio Outputs detected", "Microphone": "Microphone", + "No Microphones detected": "No Microphones detected", "Camera": "Camera", + "No Webcams detected": "No Webcams detected", "Voice & Video": "Voice & Video", "This room is not accessible by remote Matrix servers": "This room is not accessible by remote Matrix servers", "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.": "Warning: Upgrading a room will not automatically migrate room members to the new version of the room. We'll post a link to the new room in the old version of the room - room members will have to click this link to join the new room.", @@ -1511,6 +1508,8 @@ "Your message was sent": "Your message was sent", "Failed to send": "Failed to send", "Scroll to most recent messages": "Scroll to most recent messages", + "Show %(count)s other previews|other": "Show %(count)s other previews", + "Show %(count)s other previews|one": "Show %(count)s other preview", "Close preview": "Close preview", "and %(count)s others...|other": "and %(count)s others...", "and %(count)s others...|one": "and one other...", @@ -1830,6 +1829,12 @@ "Edit devices": "Edit devices", "Security": "Security", "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.": "The session you are trying to verify doesn't support scanning a QR code or emoji verification, which is what %(brand)s supports. Try with a different client.", + "Scan this unique code": "Scan this unique code", + "Compare unique emoji": "Compare unique emoji", + "Compare a unique set of emoji if you don't have a camera on either device": "Compare a unique set of emoji if you don't have a camera on either device", + "Start": "Start", + "or": "or", + "Verify this session by completing one of the following:": "Verify this session by completing one of the following:", "Verify by scanning": "Verify by scanning", "Ask %(displayName)s to scan your code:": "Ask %(displayName)s to scan your code:", "If you can't scan the code above, verify by comparing unique emoji.": "If you can't scan the code above, verify by comparing unique emoji.", @@ -1868,7 +1873,7 @@ "Ignored attempt to disable encryption": "Ignored attempt to disable encryption", "Encryption not enabled": "Encryption not enabled", "The encryption used by this room isn't supported.": "The encryption used by this room isn't supported.", - "Error decrypting audio": "Error decrypting audio", + "Error processing audio message": "Error processing audio message", "React": "React", "Edit": "Edit", "Retry": "Retry", @@ -2344,15 +2349,6 @@ "Message edits": "Message edits", "Modal Widget": "Modal Widget", "Data on this screen is shared with %(widgetDomain)s": "Data on this screen is shared with %(widgetDomain)s", - "Your account is not secure": "Your account is not secure", - "Your password": "Your password", - "This session, or the other session": "This session, or the other session", - "The internet connection either session is using": "The internet connection either session is using", - "We recommend you change your password and Security Key in Settings immediately": "We recommend you change your password and Security Key in Settings immediately", - "New session": "New session", - "Use this session to verify your new one, granting it access to encrypted messages:": "Use this session to verify your new one, granting it access to encrypted messages:", - "If you didn’t sign in to this session, your account may be compromised.": "If you didn’t sign in to this session, your account may be compromised.", - "This wasn't me": "This wasn't me", "Doesn't look like a valid email address": "Doesn't look like a valid email address", "Continuing without email": "Continuing without email", "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.": "Just a heads up, if you don't add an email and forget your password, you could permanently lose access to your account.", @@ -2602,6 +2598,9 @@ "Use email or phone to optionally be discoverable by existing contacts.": "Use email or phone to optionally be discoverable by existing contacts.", "Use email to optionally be discoverable by existing contacts.": "Use email to optionally be discoverable by existing contacts.", "Sign in with SSO": "Sign in with SSO", + "Unnamed audio": "Unnamed audio", + "Pause": "Pause", + "Play": "Play", "Couldn't load page": "Couldn't load page", "You must register to use this functionality": "You must register to use this functionality", "You must join the room to see its files": "You must join the room to see its files", diff --git a/src/indexing/EventIndex.ts b/src/indexing/EventIndex.ts index 43b3de42ed..76104455f7 100644 --- a/src/indexing/EventIndex.ts +++ b/src/indexing/EventIndex.ts @@ -16,16 +16,16 @@ limitations under the License. import { EventEmitter } from "events"; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; -import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; +import { Direction, EventTimeline } from 'matrix-js-sdk/src/models/event-timeline'; import { Room } from 'matrix-js-sdk/src/models/room'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { EventTimelineSet } from 'matrix-js-sdk/src/models/event-timeline-set'; import { RoomState } from 'matrix-js-sdk/src/models/room-state'; import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window'; +import { sleep } from "matrix-js-sdk/src/utils"; import PlatformPeg from "../PlatformPeg"; import { MatrixClientPeg } from "../MatrixClientPeg"; -import { sleep } from "../utils/promise"; import SettingsStore from "../settings/SettingsStore"; import { SettingLevel } from "../settings/SettingLevel"; import { ICrawlerCheckpoint, ILoadArgs, ISearchArgs } from "./BaseEventIndexManager"; @@ -109,7 +109,7 @@ export default class EventIndex extends EventEmitter { // our message crawler. await Promise.all(encryptedRooms.map(async (room) => { const timeline = room.getLiveTimeline(); - const token = timeline.getPaginationToken("b"); + const token = timeline.getPaginationToken(Direction.Backward); const backCheckpoint: ICrawlerCheckpoint = { roomId: room.roomId, @@ -371,7 +371,7 @@ export default class EventIndex extends EventEmitter { if (!room) return; const timeline = room.getLiveTimeline(); - const token = timeline.getPaginationToken("b"); + const token = timeline.getPaginationToken(Direction.Backward); if (!token) { // The room doesn't contain any tokens, meaning the live timeline @@ -862,7 +862,7 @@ export default class EventIndex extends EventEmitter { * @returns {Promise} Resolves to a boolean which is true if more * events were successfully retrieved. */ - public paginateTimelineWindow(room: Room, timelineWindow: TimelineWindow, direction: string, limit: number) { + public paginateTimelineWindow(room: Room, timelineWindow: TimelineWindow, direction: Direction, limit: number) { const tl = timelineWindow.getTimelineIndex(direction); if (!tl) return Promise.resolve(false); diff --git a/src/notifications/VectorPushRulesDefinitions.js b/src/notifications/VectorPushRulesDefinitions.ts similarity index 91% rename from src/notifications/VectorPushRulesDefinitions.js rename to src/notifications/VectorPushRulesDefinitions.ts index df41f23ec5..38dd88e6c6 100644 --- a/src/notifications/VectorPushRulesDefinitions.js +++ b/src/notifications/VectorPushRulesDefinitions.ts @@ -20,15 +20,25 @@ import { StandardActions } from "./StandardActions"; import { PushRuleVectorState } from "./PushRuleVectorState"; import { NotificationUtils } from "./NotificationUtils"; +interface IProps { + kind: Kind; + description: string; + vectorStateToActions: Action; +} + class VectorPushRuleDefinition { - constructor(opts) { + private kind: Kind; + private description: string; + private vectorStateToActions: Action; + + constructor(opts: IProps) { this.kind = opts.kind; this.description = opts.description; this.vectorStateToActions = opts.vectorStateToActions; } // Translate the rule actions and its enabled value into vector state - ruleToVectorState(rule) { + public ruleToVectorState(rule): VectorPushRuleDefinition { let enabled = false; if (rule) { enabled = rule.enabled; @@ -63,13 +73,24 @@ class VectorPushRuleDefinition { } } +enum Kind { + Override = "override", + Underride = "underride", +} + +interface Action { + on: StandardActions; + loud: StandardActions; + off: StandardActions; +} + /** * The descriptions of rules managed by the Vector UI. */ export const VectorPushRulesDefinitions = { // Messages containing user's display name ".m.rule.contains_display_name": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages containing my display name"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, @@ -80,7 +101,7 @@ export const VectorPushRulesDefinitions = { // Messages containing user's username (localpart/MXID) ".m.rule.contains_user_name": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages containing my username"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, @@ -91,7 +112,7 @@ export const VectorPushRulesDefinitions = { // Messages containing @room ".m.rule.roomnotif": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages containing @room"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, @@ -102,7 +123,7 @@ export const VectorPushRulesDefinitions = { // Messages just sent to the user in a 1:1 room ".m.rule.room_one_to_one": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -113,7 +134,7 @@ export const VectorPushRulesDefinitions = { // Encrypted messages just sent to the user in a 1:1 room ".m.rule.encrypted_room_one_to_one": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Encrypted messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -126,7 +147,7 @@ export const VectorPushRulesDefinitions = { // 1:1 room messages are catched by the .m.rule.room_one_to_one rule if any defined // By opposition, all other room messages are from group chat rooms. ".m.rule.message": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -139,7 +160,7 @@ export const VectorPushRulesDefinitions = { // Encrypted 1:1 room messages are catched by the .m.rule.encrypted_room_one_to_one rule if any defined // By opposition, all other room messages are from group chat rooms. ".m.rule.encrypted": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Encrypted messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -150,7 +171,7 @@ export const VectorPushRulesDefinitions = { // Invitation for the user ".m.rule.invite_for_me": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("When I'm invited to a room"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -161,7 +182,7 @@ export const VectorPushRulesDefinitions = { // Incoming call ".m.rule.call": new VectorPushRuleDefinition({ - kind: "underride", + kind: Kind.Underride, description: _td("Call invitation"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { on: StandardActions.ACTION_NOTIFY, @@ -172,7 +193,7 @@ export const VectorPushRulesDefinitions = { // Notifications from bots ".m.rule.suppress_notices": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("Messages sent by bot"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // .m.rule.suppress_notices is a "negative" rule, we have to invert its enabled value for vector UI @@ -184,7 +205,7 @@ export const VectorPushRulesDefinitions = { // Room upgrades (tombstones) ".m.rule.tombstone": new VectorPushRuleDefinition({ - kind: "override", + kind: Kind.Override, description: _td("When rooms are upgraded"), // passed through _t() translation in src/components/views/settings/Notifications.js vectorStateToActions: { // The actions for each vector state, or null to disable the rule. on: StandardActions.ACTION_NOTIFY, diff --git a/src/notifications/index.js b/src/notifications/index.ts similarity index 100% rename from src/notifications/index.js rename to src/notifications/index.ts diff --git a/src/performance/index.ts b/src/performance/index.ts index 1e24839370..cb808f9173 100644 --- a/src/performance/index.ts +++ b/src/performance/index.ts @@ -17,15 +17,15 @@ limitations under the License. import { PerformanceEntryNames } from "./entry-names"; interface GetEntriesOptions { - name?: string, - type?: string, + name?: string; + type?: string; } type PerformanceCallbackFunction = (entry: PerformanceEntry[]) => void; interface PerformanceDataListener { - entryNames?: string[], - callback: PerformanceCallbackFunction + entryNames?: string[]; + callback: PerformanceCallbackFunction; } export default class PerformanceMonitor { diff --git a/src/rageshake/submit-rageshake.ts b/src/rageshake/submit-rageshake.ts index 5e0b2c3c4d..b629ddafd8 100644 --- a/src/rageshake/submit-rageshake.ts +++ b/src/rageshake/submit-rageshake.ts @@ -93,15 +93,15 @@ async function collectBugReport(opts: IOpts = {}, gzipLogs = true) { body.append("cross_signing_supported_by_hs", String(await client.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing"))); body.append("cross_signing_key", crossSigning.getId()); - body.append("cross_signing_pk_in_secret_storage", + body.append("cross_signing_privkey_in_secret_storage", String(!!(await crossSigning.isStoredInSecretStorage(secretStorage)))); const pkCache = client.getCrossSigningCacheCallbacks(); - body.append("cross_signing_master_pk_cached", + body.append("cross_signing_master_privkey_cached", String(!!(pkCache && await pkCache.getCrossSigningKeyCache("master")))); - body.append("cross_signing_self_signing_pk_cached", + body.append("cross_signing_self_signing_privkey_cached", String(!!(pkCache && await pkCache.getCrossSigningKeyCache("self_signing")))); - body.append("cross_signing_user_signing_pk_cached", + body.append("cross_signing_user_signing_privkey_cached", String(!!(pkCache && await pkCache.getCrossSigningKeyCache("user_signing")))); body.append("secret_storage_ready", String(await client.isSecretStorageReady())); diff --git a/src/ratelimitedfunc.js b/src/ratelimitedfunc.js deleted file mode 100644 index 3df3db615e..0000000000 --- a/src/ratelimitedfunc.js +++ /dev/null @@ -1,47 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * 'debounces' a function to only execute every n milliseconds. - * Useful when react-sdk gets many, many events but only wants - * to update the interface once for all of them. - * - * Note that the function must not take arguments, since the args - * could be different for each invocation of the function. - * - * The returned function has a 'cancelPendingCall' property which can be called - * on unmount or similar to cancel any pending update. - */ - -import {throttle} from "lodash"; - -export default function ratelimitedfunc(fn, time) { - const throttledFn = throttle(fn, time, { - leading: true, - trailing: true, - }); - const _bind = throttledFn.bind; - throttledFn.bind = function() { - const boundFn = _bind.apply(throttledFn, arguments); - boundFn.cancelPendingCall = throttledFn.cancelPendingCall; - return boundFn; - }; - - throttledFn.cancelPendingCall = function() { - throttledFn.cancel(); - }; - return throttledFn; -} diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 96820d0ff2..1751eddb2c 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -455,7 +455,7 @@ export const SETTINGS: {[setting: string]: ISetting} = { }, "ctrlFForSearch": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, - displayName: isMac ? _td("Use Command + F to search") : _td("Use Ctrl + F to search"), + displayName: isMac ? _td("Use Command + F to search timeline") : _td("Use Ctrl + F to search timeline"), default: false, }, "MessageComposerInput.ctrlEnterToSend": { diff --git a/src/stores/RoomScrollStateStore.js b/src/stores/RoomScrollStateStore.js deleted file mode 100644 index 07848283d1..0000000000 --- a/src/stores/RoomScrollStateStore.js +++ /dev/null @@ -1,50 +0,0 @@ -/* -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. -*/ - -/** - * Stores where the user has scrolled to in each room - */ -class RoomScrollStateStore { - constructor() { - // A map from room id to scroll state. - // - // If there is no special scroll state (ie, we are following the live - // timeline), the scroll state is null. Otherwise, it is an object with - // the following properties: - // - // focussedEvent: the ID of the 'focussed' event. Typically this is - // the last event fully visible in the viewport, though if we - // have done an explicit scroll to an explicit event, it will be - // that event. - // - // pixelOffset: the number of pixels the window is scrolled down - // from the focussedEvent. - this._scrollStateMap = {}; - } - - getScrollState(roomId) { - return this._scrollStateMap[roomId]; - } - - setScrollState(roomId, scrollState) { - this._scrollStateMap[roomId] = scrollState; - } -} - -if (global.mx_RoomScrollStateStore === undefined) { - global.mx_RoomScrollStateStore = new RoomScrollStateStore(); -} -export default global.mx_RoomScrollStateStore; diff --git a/src/stores/RoomScrollStateStore.ts b/src/stores/RoomScrollStateStore.ts new file mode 100644 index 0000000000..1d8d4eb8fd --- /dev/null +++ b/src/stores/RoomScrollStateStore.ts @@ -0,0 +1,53 @@ +/* +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. +*/ + +export interface ScrollState { + focussedEvent: string; + pixelOffset: number; +} + +/** + * Stores where the user has scrolled to in each room + */ +export class RoomScrollStateStore { + // A map from room id to scroll state. + // + // If there is no special scroll state (ie, we are following the live + // timeline), the scroll state is null. Otherwise, it is an object with + // the following properties: + // + // focussedEvent: the ID of the 'focussed' event. Typically this is + // the last event fully visible in the viewport, though if we + // have done an explicit scroll to an explicit event, it will be + // that event. + // + // pixelOffset: the number of pixels the window is scrolled down + // from the focussedEvent. + private scrollStateMap = new Map(); + + public getScrollState(roomId: string): ScrollState { + return this.scrollStateMap.get(roomId); + } + + setScrollState(roomId: string, scrollState: ScrollState): void { + this.scrollStateMap.set(roomId, scrollState); + } +} + +if (window.mxRoomScrollStateStore === undefined) { + window.mxRoomScrollStateStore = new RoomScrollStateStore(); +} +export default window.mxRoomScrollStateStore; diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 87978df471..10f42f3166 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -164,6 +164,7 @@ class RoomViewStore extends Store { } break; case 'open_room_settings': { + // FIXME: Using an import will result in test failures const RoomSettingsDialog = sdk.getComponent("dialogs.RoomSettingsDialog"); Modal.createTrackedDialog('Room settings', '', RoomSettingsDialog, { roomId: payload.room_id || this.state.roomId, @@ -340,6 +341,7 @@ class RoomViewStore extends Store { } } + // FIXME: Using an import will result in test failures const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); Modal.createTrackedDialog('Failed to join room', '', ErrorDialog, { title: _t("Failed to join room"), diff --git a/src/stores/SetupEncryptionStore.ts b/src/stores/SetupEncryptionStore.ts index e969c64853..7197374502 100644 --- a/src/stores/SetupEncryptionStore.ts +++ b/src/stores/SetupEncryptionStore.ts @@ -17,7 +17,7 @@ limitations under the License. import EventEmitter from 'events'; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup"; -import { ISecretStorageKeyInfo } from "matrix-js-sdk/src/matrix"; +import { ISecretStorageKeyInfo } from "matrix-js-sdk/src/crypto/api"; import { PHASE_DONE as VERIF_PHASE_DONE } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import { MatrixClientPeg } from '../MatrixClientPeg'; diff --git a/src/stores/ThreepidInviteStore.ts b/src/stores/ThreepidInviteStore.ts index 74a5f5f8ec..d0cf40941c 100644 --- a/src/stores/ThreepidInviteStore.ts +++ b/src/stores/ThreepidInviteStore.ts @@ -45,6 +45,16 @@ export interface IThreepidInvite { inviterName: string; } +// Any data about the room that would normally come from the homeserver +// but has been passed out-of-band, eg. the room name and avatar URL +// from an email invite (a workaround for the fact that we can't +// get this information from the HS using an email invite). +export interface IOOBData { + name?: string; // The room's name + avatarUrl?: string; // The mxc:// avatar URL for the room + inviterName?: string; // The display name of the person who invited us to the room +} + const STORAGE_PREFIX = "mx_threepid_invite_"; export default class ThreepidInviteStore extends EventEmitter { diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index 447f41c7ae..9781c93eb4 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -27,10 +27,10 @@ const TYPING_SERVER_TIMEOUT = 30000; export default class TypingStore { private typingStates: { [roomId: string]: { - isTyping: boolean, - userTimer: Timer, - serverTimer: Timer, - }, + isTyping: boolean; + userTimer: Timer; + serverTimer: Timer; + }; }; constructor() { diff --git a/src/stores/WidgetEchoStore.ts b/src/stores/WidgetEchoStore.ts index 0b0be50541..d3ef9df023 100644 --- a/src/stores/WidgetEchoStore.ts +++ b/src/stores/WidgetEchoStore.ts @@ -26,8 +26,8 @@ import { WidgetType } from "../widgets/WidgetType"; class WidgetEchoStore extends EventEmitter { private roomWidgetEcho: { [roomId: string]: { - [widgetId: string]: IWidget, - }, + [widgetId: string]: IWidget; + }; }; constructor() { diff --git a/src/toasts/SetupEncryptionToast.ts b/src/toasts/SetupEncryptionToast.ts index ade7dfe3f0..bdaeb5142f 100644 --- a/src/toasts/SetupEncryptionToast.ts +++ b/src/toasts/SetupEncryptionToast.ts @@ -15,7 +15,6 @@ limitations under the License. */ import Modal from "../Modal"; -import * as sdk from "../index"; import { _t } from "../languageHandler"; import DeviceListener from "../DeviceListener"; import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog"; @@ -23,6 +22,7 @@ import { accessSecretStorage } from "../SecurityManager"; import ToastStore from "../stores/ToastStore"; import GenericToast from "../components/views/toasts/GenericToast"; import SecurityCustomisations from "../customisations/Security"; +import Spinner from "../components/views/elements/Spinner"; const TOAST_KEY = "setupencryption"; @@ -88,7 +88,6 @@ export const showToast = (kind: Kind) => { Modal.createTrackedDialog("Verify session", "Verify session", SetupEncryptionDialog, {}, null, /* priority = */ false, /* static = */ true); } else { - const Spinner = sdk.getComponent("elements.Spinner"); const modal = Modal.createDialog( Spinner, null, "mx_Dialog_spinner", /* priority */ false, /* static */ true, ); diff --git a/src/utils/AnimationUtils.ts b/src/utils/AnimationUtils.ts new file mode 100644 index 0000000000..61df52826d --- /dev/null +++ b/src/utils/AnimationUtils.ts @@ -0,0 +1,32 @@ +/* +Copyright 2021 Šimon Brandner + +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 { clamp } from "lodash"; + +/** + * This method linearly interpolates between two points (start, end). This is + * most commonly used to find a point some fraction of the way along a line + * between two endpoints (e.g. to move an object gradually between those + * points). + * @param {number} start the starting point + * @param {number} end the ending point + * @param {number} amt the interpolant + * @returns + */ +export function lerp(start: number, end: number, amt: number) { + amt = clamp(amt, 0, 1); + return (1 - amt) * start + amt * end; +} diff --git a/src/utils/EditorStateTransfer.ts b/src/utils/EditorStateTransfer.ts index ba303f9b73..d2ce58f7dc 100644 --- a/src/utils/EditorStateTransfer.ts +++ b/src/utils/EditorStateTransfer.ts @@ -17,7 +17,7 @@ limitations under the License. import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { SerializedPart } from "../editor/parts"; -import { Caret } from "../editor/caret"; +import DocumentOffset from "../editor/offset"; /** * Used while editing, to pass the event, and to preserve editor state @@ -26,28 +26,28 @@ import { Caret } from "../editor/caret"; */ export default class EditorStateTransfer { private serializedParts: SerializedPart[] = null; - private caret: Caret = null; + private caret: DocumentOffset = null; constructor(private readonly event: MatrixEvent) {} - public setEditorState(caret: Caret, serializedParts: SerializedPart[]) { + public setEditorState(caret: DocumentOffset, serializedParts: SerializedPart[]) { this.caret = caret; this.serializedParts = serializedParts; } - public hasEditorState() { + public hasEditorState(): boolean { return !!this.serializedParts; } - public getSerializedParts() { + public getSerializedParts(): SerializedPart[] { return this.serializedParts; } - public getCaret() { + public getCaret(): DocumentOffset { return this.caret; } - public getEvent() { + public getEvent(): MatrixEvent { return this.event; } } diff --git a/src/utils/MultiInviter.ts b/src/utils/MultiInviter.ts index 4e66d4a99f..a7d1accde1 100644 --- a/src/utils/MultiInviter.ts +++ b/src/utils/MultiInviter.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { MatrixError } from "matrix-js-sdk/src/http-api"; +import { defer, IDeferred } from "matrix-js-sdk/src/utils"; import { MatrixClientPeg } from '../MatrixClientPeg'; import { AddressType, getAddressType } from '../UserAddress'; @@ -22,7 +23,6 @@ import GroupStore from '../stores/GroupStore'; import { _t } from "../languageHandler"; import Modal from "../Modal"; import SettingsStore from "../settings/SettingsStore"; -import { defer, IDeferred } from "./promise"; import AskInviteAnywayDialog from "../components/views/dialogs/AskInviteAnywayDialog"; export enum InviteState { diff --git a/src/utils/objects.ts b/src/utils/objects.ts index 561e68e8c5..c2ee6ce100 100644 --- a/src/utils/objects.ts +++ b/src/utils/objects.ts @@ -152,7 +152,7 @@ export function objectClone(obj: O): O { export function objectFromEntries(entries: Iterable<[K, V]>): {[k: K]: V} { const obj: { // @ts-ignore - same as return type - [k: K]: V} = {}; + [k: K]: V;} = {}; for (const e of entries) { // @ts-ignore - same as return type obj[e[0]] = e[1]; diff --git a/src/utils/pillify.js b/src/utils/pillify.tsx similarity index 91% rename from src/utils/pillify.js rename to src/utils/pillify.tsx index 489ba5d504..22240fcda5 100644 --- a/src/utils/pillify.js +++ b/src/utils/pillify.tsx @@ -16,9 +16,11 @@ limitations under the License. import React from "react"; import ReactDOM from 'react-dom'; +import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + import { MatrixClientPeg } from '../MatrixClientPeg'; import SettingsStore from "../settings/SettingsStore"; -import { PushProcessor } from 'matrix-js-sdk/src/pushprocessor'; import Pill from "../components/views/elements/Pill"; import { parseAppLocalLink } from "./permalinks/Permalinks"; @@ -27,15 +29,15 @@ import { parseAppLocalLink } from "./permalinks/Permalinks"; * into pills based on the context of a given room. Returns a list of * the resulting React nodes so they can be unmounted rather than leaking. * - * @param {Node[]} nodes - a list of sibling DOM nodes to traverse to try + * @param {Element[]} nodes - a list of sibling DOM nodes to traverse to try * to turn into pills. * @param {MatrixEvent} mxEvent - the matrix event which the DOM nodes are * part of representing. - * @param {Node[]} pills: an accumulator of the DOM nodes which contain + * @param {Element[]} pills: an accumulator of the DOM nodes which contain * React components which have been mounted as part of this. * The initial caller should pass in an empty array to seed the accumulator. */ -export function pillifyLinks(nodes, mxEvent, pills) { +export function pillifyLinks(nodes: ArrayLike, mxEvent: MatrixEvent, pills: Element[]) { const room = MatrixClientPeg.get().getRoom(mxEvent.getRoomId()); const shouldShowPillAvatar = SettingsStore.getValue("Pill.shouldShowPillAvatar"); let node = nodes[0]; @@ -73,7 +75,7 @@ export function pillifyLinks(nodes, mxEvent, pills) { // to clear the pills from the last run of pillifyLinks !node.parentElement.classList.contains("mx_AtRoomPill") ) { - let currentTextNode = node; + let currentTextNode = node as Node as Text; const roomNotifTextNodes = []; // Take a textNode and break it up to make all the instances of @room their @@ -125,10 +127,10 @@ export function pillifyLinks(nodes, mxEvent, pills) { } if (node.childNodes && node.childNodes.length && !pillified) { - pillifyLinks(node.childNodes, mxEvent, pills); + pillifyLinks(node.childNodes as NodeListOf, mxEvent, pills); } - node = node.nextSibling; + node = node.nextSibling as Element; } } @@ -140,10 +142,10 @@ export function pillifyLinks(nodes, mxEvent, pills) { * emitter on BaseAvatar as per * https://github.com/vector-im/element-web/issues/12417 * - * @param {Node[]} pills - array of pill containers whose React + * @param {Element[]} pills - array of pill containers whose React * components should be unmounted. */ -export function unmountPills(pills) { +export function unmountPills(pills: Element[]) { for (const pillContainer of pills) { ReactDOM.unmountComponentAtNode(pillContainer); } diff --git a/src/utils/promise.ts b/src/utils/promise.ts index 902d3c7e31..853c172269 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -14,11 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -// Returns a promise which resolves with a given value after the given number of ms -export function sleep(ms: number, value?: T): Promise { - return new Promise((resolve => { setTimeout(resolve, ms, value); })); -} - // Returns a promise which resolves when the input promise resolves with its value // or when the timeout of ms is reached with the value of given timeoutValue export async function timeout(promise: Promise, timeoutValue: T, ms: number): Promise { @@ -32,25 +27,6 @@ export async function timeout(promise: Promise, timeoutValue: T, ms: numbe return Promise.race([promise, timeoutPromise]); } -export interface IDeferred { - resolve: (value: T) => void; - reject: (any) => void; - promise: Promise; -} - -// Returns a Deferred -export function defer(): IDeferred { - let resolve; - let reject; - - const promise = new Promise((_resolve, _reject) => { - resolve = _resolve; - reject = _reject; - }); - - return { resolve, reject, promise }; -} - // Helper method to retry a Promise a given number of times or until a predicate fails export async function retry(fn: () => Promise, num: number, predicate?: (e: E) => boolean) { let lastErr: E; diff --git a/src/verification.ts b/src/verification.ts index 22b62461a1..719c0ec5b3 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -19,7 +19,6 @@ import { User } from "matrix-js-sdk/src/models/user"; import { MatrixClientPeg } from './MatrixClientPeg'; import dis from "./dispatcher/dispatcher"; import Modal from './Modal'; -import * as sdk from './index'; import { RightPanelPhases } from "./stores/RightPanelStorePhases"; import { findDMForUser } from './createRoom'; import { accessSecretStorage } from './SecurityManager'; @@ -27,6 +26,7 @@ import { verificationMethods } from 'matrix-js-sdk/src/crypto'; import { Action } from './dispatcher/actions'; import UntrustedDeviceDialog from "./components/views/dialogs/UntrustedDeviceDialog"; import { IDevice } from "./components/views/right_panel/UserInfo"; +import ManualDeviceKeyVerificationDialog from "./components/views/dialogs/ManualDeviceKeyVerificationDialog"; async function enable4SIfNeeded() { const cli = MatrixClientPeg.get(); @@ -71,8 +71,6 @@ export async function verifyDevice(user: User, device: IDevice) { refireParams: { member: user, verificationRequestPromise }, }); } else if (action === "legacy") { - const ManualDeviceKeyVerificationDialog = - sdk.getComponent("dialogs.ManualDeviceKeyVerificationDialog"); Modal.createTrackedDialog("Legacy verify session", "legacy verify session", ManualDeviceKeyVerificationDialog, { diff --git a/src/voice/Playback.ts b/src/voice/Playback.ts index 61da435151..6a120bf924 100644 --- a/src/voice/Playback.ts +++ b/src/voice/Playback.ts @@ -58,6 +58,7 @@ export class Playback extends EventEmitter implements IDestroyable { private resampledWaveform: number[]; private waveformObservable = new SimpleObservable(); private readonly clock: PlaybackClock; + private readonly fileSize: number; /** * Creates a new playback instance from a buffer. @@ -67,12 +68,22 @@ export class Playback extends EventEmitter implements IDestroyable { */ constructor(private buf: ArrayBuffer, seedWaveform = DEFAULT_WAVEFORM) { super(); + // Capture the file size early as reading the buffer will result in a 0-length buffer left behind + this.fileSize = this.buf.byteLength; this.context = createAudioContext(); this.resampledWaveform = arrayFastResample(seedWaveform ?? DEFAULT_WAVEFORM, PLAYBACK_WAVEFORM_SAMPLES); this.waveformObservable.update(this.resampledWaveform); this.clock = new PlaybackClock(this.context); } + /** + * Size of the audio clip in bytes. May be zero if unknown. This is updated + * when the playback goes through phase changes. + */ + public get sizeBytes(): number { + return this.fileSize; + } + /** * Stable waveform for the playback. Values are guaranteed to be between * zero and one, inclusive. @@ -150,16 +161,9 @@ export class Playback extends EventEmitter implements IDestroyable { 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); + this.disconnectSource(); + this.makeNewSourceBuffer(); + this.source.start(); } // We use the context suspend/resume functions because it allows us to pause a source @@ -169,6 +173,18 @@ export class Playback extends EventEmitter implements IDestroyable { this.emit(PlaybackState.Playing); } + private disconnectSource() { + this.source?.disconnect(); + this.source?.removeEventListener("ended", this.onPlaybackEnd); + } + + private makeNewSourceBuffer() { + this.source = this.context.createBufferSource(); + this.source.buffer = this.audioBuf; + this.source.addEventListener("ended", this.onPlaybackEnd); + this.source.connect(this.context.destination); + } + public async pause() { await this.context.suspend(); this.emit(PlaybackState.Paused); @@ -183,4 +199,60 @@ export class Playback extends EventEmitter implements IDestroyable { if (this.isPlaying) await this.pause(); else await this.play(); } + + public async skipTo(timeSeconds: number) { + // Dev note: this function talks a lot about clock desyncs. There is a clock running + // independently to the audio context and buffer so that accurate human-perceptible + // time can be exposed. The PlaybackClock class has more information, but the short + // version is that we need to line up the useful time (clip position) with the context + // time, and avoid as many deviations as possible as otherwise the user could see the + // wrong time, and we stop playback at the wrong time, etc. + + timeSeconds = clamp(timeSeconds, 0, this.clock.durationSeconds); + + // Track playing state so we don't cause seeking to start playing the track. + const isPlaying = this.isPlaying; + + if (isPlaying) { + // Pause first so we can get an accurate measurement of time + await this.context.suspend(); + } + + // We can't simply tell the context/buffer to jump to a time, so we have to + // start a whole new buffer and start it from the new time offset. + const now = this.context.currentTime; + this.disconnectSource(); + this.makeNewSourceBuffer(); + + // We have to resync the clock because it can get confused about where we're + // at in the audio clip. + this.clock.syncTo(now, timeSeconds); + + // Always start the source to queue it up. We have to do this now (and pause + // quickly if we're not supposed to be playing) as otherwise the clock can desync + // when it comes time to the user hitting play. After a couple jumps, the user + // will have desynced the clock enough to be about 10-15 seconds off, while this + // keeps it as close to perfect as humans can perceive. + this.source.start(now, timeSeconds); + + // Dev note: it's critical that the code gap between `this.source.start()` and + // `this.pause()` is as small as possible: we do not want to delay *anything* + // as that could cause a clock desync, or a buggy feeling as a single note plays + // during seeking. + + if (isPlaying) { + // If we were playing before, continue the context so the clock doesn't desync. + await this.context.resume(); + } else { + // As mentioned above, we'll have to pause the clip if we weren't supposed to + // be playing it just yet. If we didn't have this, the audio clip plays but all + // the states will be wrong: clock won't advance, pause state doesn't match the + // blaring noise leaving the user's speakers, etc. + // + // Also as mentioned, if the code gap is small enough then this should be + // executed immediately after the start time, leaving no feasible time for the + // user's speakers to play any sound. + await this.pause(); + } + } } diff --git a/src/voice/PlaybackClock.ts b/src/voice/PlaybackClock.ts index 1eee921429..e3f41930de 100644 --- a/src/voice/PlaybackClock.ts +++ b/src/voice/PlaybackClock.ts @@ -16,8 +16,44 @@ limitations under the License. import { SimpleObservable } from "matrix-widget-api"; import { IDestroyable } from "../utils/IDestroyable"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; -// Because keeping track of time is sufficiently complicated... +/** + * Tracks accurate human-perceptible time for an audio clip, as informed + * by managed playback. This clock is tightly coupled with the operation + * of the Playback class, making assumptions about how the provided + * AudioContext will be used (suspended/resumed to preserve time, etc). + * + * But why do we need a clock? The AudioContext exposes time information, + * and so does the audio buffer, but not in a way that is useful for humans + * to perceive. The audio buffer time is often lagged behind the context + * time due to internal processing delays of the audio API. Additionally, + * the context's time is tracked from when it was first initialized/started, + * not related to positioning within the clip. However, the context time + * is the most accurate time we can use to determine position within the + * clip if we're fast enough to track the pauses and stops. + * + * As a result, we track every play, pause, stop, and seek event from the + * Playback class (kinda: it calls us, which is close enough to the same + * thing). These events are then tracked on the AudioContext time scale, + * with assumptions that code execution will result in negligible desync + * of the clock, or at least no perceptible difference in time. It's + * extremely important that the calling code, and the clock's own code, + * is extremely fast between the event happening and the clock time being + * tracked - anything more than a dozen milliseconds is likely to stack up + * poorly, leading to clock desync. + * + * Clock desync can be dangerous for the stability of the playback controls: + * if the clock thinks the user is somewhere else in the clip, it could + * inform the playback of the wrong place in time, leading to dead air in + * the output or, if severe enough, a clock that won't stop running while + * the audio is paused/stopped. Other examples include the clip stopping at + * 90% time due to playback ending, the clip playing from the wrong spot + * relative to the time, and negative clock time. + * + * Note that the clip duration is fed to the clock: this is to ensure that + * we have the most accurate time possible to present. + */ export class PlaybackClock implements IDestroyable { private clipStart = 0; private stopped = true; @@ -25,12 +61,13 @@ export class PlaybackClock implements IDestroyable { private observable = new SimpleObservable(); private timerId: number; private clipDuration = 0; + private placeholderDuration = 0; public constructor(private context: AudioContext) { } public get durationSeconds(): number { - return this.clipDuration; + return this.clipDuration || this.placeholderDuration; } public set durationSeconds(val: number) { @@ -39,6 +76,12 @@ export class PlaybackClock implements IDestroyable { } public get timeSeconds(): number { + // The modulo is to ensure that we're only looking at the most recent clip + // time, as the context is long-running and multiple plays might not be + // informed to us (if the control is looping, for example). By taking the + // remainder of the division operation, we're assuming that playback is + // incomplete or stopped, thus giving an accurate position within the active + // clip segment. return (this.context.currentTime - this.clipStart) % this.clipDuration; } @@ -47,13 +90,23 @@ export class PlaybackClock implements IDestroyable { } private checkTime = () => { - const now = this.timeSeconds; + const now = this.timeSeconds; // calculated dynamically if (this.lastCheck !== now) { this.observable.update([now, this.durationSeconds]); this.lastCheck = now; } }; + /** + * Populates default information about the audio clip from the event body. + * The placeholders will be overridden once known. + * @param {MatrixEvent} event The event to use for placeholders. + */ + public populatePlaceholdersFrom(event: MatrixEvent) { + const durationSeconds = Number(event.getContent()['info']?.['duration']); + if (Number.isFinite(durationSeconds)) this.placeholderDuration = durationSeconds; + } + /** * Mark the time in the audio context where the clip starts/has been loaded. * This is to ensure the clock isn't skewed into thinking it is ~0.5s into @@ -70,8 +123,9 @@ export class PlaybackClock implements IDestroyable { } if (!this.timerId) { - // case to number because the types are wrong - // 100ms interval to make sure the time is as accurate as possible + // cast to number because the types are wrong + // 100ms interval to make sure the time is as accurate as possible without + // being overly insane this.timerId = setInterval(this.checkTime, 100); } } @@ -80,6 +134,12 @@ export class PlaybackClock implements IDestroyable { this.stopped = true; } + public syncTo(contextTime: number, clipTime: number) { + this.clipStart = contextTime - clipTime; + this.stopped = false; // count as a mid-stream pause (if we were stopped) + this.checkTime(); + } + public destroy() { this.observable.close(); if (this.timerId) clearInterval(this.timerId); diff --git a/test/KeyBindingsManager-test.ts b/test/KeyBindingsManager-test.ts index 694efac7b5..eab1bea2b0 100644 --- a/test/KeyBindingsManager-test.ts +++ b/test/KeyBindingsManager-test.ts @@ -17,10 +17,10 @@ limitations under the License. import { isKeyComboMatch, KeyCombo } from '../src/KeyBindingsManager'; function mockKeyEvent(key: string, modifiers?: { - ctrlKey?: boolean, - altKey?: boolean, - shiftKey?: boolean, - metaKey?: boolean + ctrlKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + metaKey?: boolean; }): KeyboardEvent { return { key, diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index d32970a278..f415b85105 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -1,6 +1,6 @@ /* Copyright 2016 OpenMarket Ltd -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. @@ -26,11 +26,11 @@ import { EventEmitter } from "events"; import sdk from '../../skinned-sdk'; const MessagePanel = sdk.getComponent('structures.MessagePanel'); -import {MatrixClientPeg} from '../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../src/MatrixClientPeg'; import Matrix from 'matrix-js-sdk'; -const test_utils = require('../../test-utils'); -const mockclock = require('../../mock-clock'); +const TestUtilsMatrix = require('../../test-utils'); +import FakeTimers from '@sinonjs/fake-timers'; import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure, mount } from "enzyme"; @@ -72,14 +72,14 @@ class WrappedMessagePanel extends React.Component { } describe('MessagePanel', function() { - const clock = mockclock.clock(); + let clock = null; const realSetTimeout = window.setTimeout; const events = mkEvents(); beforeEach(function() { - test_utils.stubClient(); + TestUtilsMatrix.stubClient(); client = MatrixClientPeg.get(); - client.credentials = {userId: '@me:here'}; + client.credentials = { userId: '@me:here' }; // HACK: We assume all settings want to be disabled SettingsStore.getValue = jest.fn((arg) => { @@ -90,14 +90,17 @@ describe('MessagePanel', function() { }); afterEach(function() { - clock.uninstall(); + if (clock) { + clock.uninstall(); + clock = null; + } }); function mkEvents() { const events = []; const ts0 = Date.now(); for (let i = 0; i < 10; i++) { - events.push(test_utils.mkMessage( + events.push(TestUtilsMatrix.mkMessage( { event: true, room: "!room:id", user: "@user:id", ts: ts0 + i * 1000, @@ -111,7 +114,7 @@ describe('MessagePanel', function() { const events = []; const ts0 = Date.parse('09 May 2004 00:12:00 GMT'); for (let i = 0; i < 10; i++) { - events.push(test_utils.mkMessage( + events.push(TestUtilsMatrix.mkMessage( { event: true, room: "!room:id", user: "@user:id", ts: ts0 + i * 1000, @@ -120,7 +123,6 @@ describe('MessagePanel', function() { return events; } - // make a collection of events with some member events that should be collapsed // with a MemberEventListSummary function mkMelsEvents() { @@ -128,13 +130,13 @@ describe('MessagePanel', function() { const ts0 = Date.now(); let i = 0; - events.push(test_utils.mkMessage({ + events.push(TestUtilsMatrix.mkMessage({ event: true, room: "!room:id", user: "@user:id", ts: ts0 + ++i * 1000, })); for (i = 0; i < 10; i++) { - events.push(test_utils.mkMembership({ + events.push(TestUtilsMatrix.mkMembership({ event: true, room: "!room:id", user: "@user:id", target: { userId: "@user:id", @@ -151,7 +153,7 @@ describe('MessagePanel', function() { })); } - events.push(test_utils.mkMessage({ + events.push(TestUtilsMatrix.mkMessage({ event: true, room: "!room:id", user: "@user:id", ts: ts0 + ++i*1000, })); @@ -167,7 +169,7 @@ describe('MessagePanel', function() { let i = 0; for (i = 0; i < 10; i++) { - events.push(test_utils.mkMembership({ + events.push(TestUtilsMatrix.mkMembership({ event: true, room: "!room:id", user: "@user:id", target: { userId: "@user:id", @@ -189,8 +191,8 @@ describe('MessagePanel', function() { // A list of room creation, encryption, and invite events. function mkCreationEvents() { - const mkEvent = test_utils.mkEvent; - const mkMembership = test_utils.mkMembership; + const mkEvent = TestUtilsMatrix.mkEvent; + const mkMembership = TestUtilsMatrix.mkMembership; const roomId = "!someroom"; const alice = "@alice:example.org"; const ts0 = Date.now(); @@ -363,8 +365,7 @@ describe('MessagePanel', function() { it('shows a ghost read-marker when the read-marker moves', function(done) { // fake the clock so that we can test the velocity animation. - clock.install(); - clock.mockDate(); + clock = FakeTimers.install(); const parentDiv = document.createElement('div'); diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.js b/test/components/views/dialogs/InteractiveAuthDialog-test.js index fa44fc8d92..dcf89afb4d 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.js +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.js @@ -18,12 +18,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import ReactTestUtils from 'react-dom/test-utils'; import MatrixReactTestUtils from 'matrix-react-test-utils'; +import { sleep } from "matrix-js-sdk/src/utils"; import sdk from '../../../skinned-sdk'; -import {MatrixClientPeg} from '../../../../src/MatrixClientPeg'; +import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; -import * as test_utils from '../../../test-utils'; -import {sleep} from "../../../../src/utils/promise"; +import * as TestUtilsMatrix from '../../../test-utils'; const InteractiveAuthDialog = sdk.getComponent( 'views.dialogs.InteractiveAuthDialog', @@ -33,7 +33,7 @@ describe('InteractiveAuthDialog', function() { let parentDiv; beforeEach(function() { - test_utils.stubClient(); + TestUtilsMatrix.stubClient(); parentDiv = document.createElement('div'); document.body.appendChild(parentDiv); }); @@ -45,11 +45,11 @@ describe('InteractiveAuthDialog', function() { it('Should successfully complete a password flow', function() { const onFinished = jest.fn(); - const doRequest = jest.fn().mockResolvedValue({a: 1}); + const doRequest = jest.fn().mockResolvedValue({ a: 1 }); // tell the stub matrixclient to return a real userid const client = MatrixClientPeg.get(); - client.credentials = {userId: "@user:id"}; + client.credentials = { userId: "@user:id" }; const dlg = ReactDOM.render( { expect(onFinished).toBeCalledTimes(1); - expect(onFinished).toBeCalledWith(true, {a: 1}); + expect(onFinished).toBeCalledWith(true, { a: 1 }); }); }); }); diff --git a/test/components/views/messages/TextualBody-test.js b/test/components/views/messages/TextualBody-test.js index c9418fc557..c6a3f3c779 100644 --- a/test/components/views/messages/TextualBody-test.js +++ b/test/components/views/messages/TextualBody-test.js @@ -302,7 +302,7 @@ describe("", () => { event: true, }); - const wrapper = mount(); + const wrapper = mount( {}} />); expect(wrapper.text()).toBe(ev.getContent().body); let widgets = wrapper.find("LinkPreviewWidget"); diff --git a/test/components/views/rooms/MemberList-test.tsx b/test/components/views/rooms/MemberList-test.tsx index a169cd08e6..f720bc7a6d 100644 --- a/test/components/views/rooms/MemberList-test.tsx +++ b/test/components/views/rooms/MemberList-test.tsx @@ -14,18 +14,20 @@ See the License for the specific language governing permissions and limitations under the License. */ +import "../../../skinned-sdk"; + import React from 'react'; import ReactTestUtils from 'react-dom/test-utils'; import ReactDOM from 'react-dom'; import * as TestUtils from '../../../test-utils'; -import sdk from '../../../skinned-sdk'; import { MatrixClientPeg } from '../../../../src/MatrixClientPeg'; import { Room } from 'matrix-js-sdk/src/models/room'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member'; import { User } from "matrix-js-sdk/src/models/user"; import { compare } from "../../../../src/utils/strings"; import MemberList from "../../../../src/components/views/rooms/MemberList"; +import MemberTile from '../../../../src/components/views/rooms/MemberTile'; function generateRoomId() { return '!' + Math.random().toString().slice(2, 10) + ':domain'; @@ -206,7 +208,6 @@ describe('MemberList', () => { } function itDoesOrderMembersCorrectly(enablePresence) { - const MemberTile = sdk.getComponent("rooms.MemberTile"); describe('does order members correctly', () => { // Note: even if presence is disabled, we still expect that the presence // tests will pass. All expectOrderedByPresenceAndPowerLevel does is ensure diff --git a/test/components/views/rooms/SendMessageComposer-test.js b/test/components/views/rooms/SendMessageComposer-test.js index 2fddf8b691..0c4bde76a8 100644 --- a/test/components/views/rooms/SendMessageComposer-test.js +++ b/test/components/views/rooms/SendMessageComposer-test.js @@ -19,6 +19,8 @@ import Adapter from "@wojtekmaj/enzyme-adapter-react-17"; import { configure, mount } from "enzyme"; import React from "react"; import { act } from "react-dom/test-utils"; +import { sleep } from "matrix-js-sdk/src/utils"; + import SendMessageComposer, { createMessageContent, isQuickReaction, @@ -29,7 +31,6 @@ import { createPartCreator, createRenderer } from "../../../editor/mock"; import { createTestClient, mkEvent, mkStubRoom } from "../../../test-utils"; import BasicMessageComposer from "../../../../src/components/views/rooms/BasicMessageComposer"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import { sleep } from "../../../../src/utils/promise"; import SpecPermalinkConstructor from "../../../../src/utils/permalinks/SpecPermalinkConstructor"; import defaultDispatcher from "../../../../src/dispatcher/dispatcher"; @@ -147,7 +148,7 @@ describe('', () => { wrapper.update(); }); - const key = wrapper.find(SendMessageComposer).instance()._editorStateKey; + const key = wrapper.find(SendMessageComposer).instance().editorStateKey; expect(wrapper.text()).toBe("Test Text"); expect(localStorage.getItem(key)).toBeNull(); @@ -188,7 +189,7 @@ describe('', () => { wrapper.update(); }); - const key = wrapper.find(SendMessageComposer).instance()._editorStateKey; + const key = wrapper.find(SendMessageComposer).instance().editorStateKey; expect(wrapper.text()).toBe("Hello World"); expect(localStorage.getItem(key)).toBeNull(); diff --git a/test/end-to-end-tests/element/install.sh b/test/end-to-end-tests/element/install.sh index e38f795df1..e1df709c68 100755 --- a/test/end-to-end-tests/element/install.sh +++ b/test/end-to-end-tests/element/install.sh @@ -12,5 +12,5 @@ unzip -q element.zip rm element.zip mv element-web-${ELEMENT_BRANCH} element-web cd element-web -yarn install +yarn install --pure-lockfile yarn run build diff --git a/test/end-to-end-tests/install.sh b/test/end-to-end-tests/install.sh index bbe7a24c9b..b2254a4ea7 100755 --- a/test/end-to-end-tests/install.sh +++ b/test/end-to-end-tests/install.sh @@ -4,4 +4,4 @@ set -e ./synapse/install.sh # local testing doesn't need a Element fetched from master, # so not installing that by default -yarn install +yarn install --pure-lockfile diff --git a/test/mock-clock.js b/test/mock-clock.js deleted file mode 100644 index 1a4d6086de..0000000000 --- a/test/mock-clock.js +++ /dev/null @@ -1,421 +0,0 @@ -/* -Copyright (c) 2008-2015 Pivotal Labs -Copyright 2019 The Matrix.org Foundation C.I.C. - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -*/ - -/* This is jasmine's implementation of a mock clock, lifted from the depths of - * jasmine-core and exposed as a standalone module. The interface is just the - * same as that of jasmine.clock. For example: - * - * var mock_clock = require("../../mock-clock").clock(); - * mock_clock.install(); - * setTimeout(function() { - * timerCallback(); - * }, 100); - * - * expect(timerCallback).not.toHaveBeenCalled(); - * mock_clock.tick(101); - * expect(timerCallback).toHaveBeenCalled(); - * - * mock_clock.uninstall(); - * - * - * The reason for C&Ping jasmine's clock here is that jasmine itself is - * difficult to webpack, and we don't really want all of it. Sinon also has a - * mock-clock implementation, but again, it is difficult to webpack. - */ - -const j$ = {}; - -j$.Clock = function() { - function Clock(global, delayedFunctionSchedulerFactory, mockDate) { - let self = this, - realTimingFunctions = { - setTimeout: global.setTimeout, - clearTimeout: global.clearTimeout, - setInterval: global.setInterval, - clearInterval: global.clearInterval, - }, - fakeTimingFunctions = { - setTimeout: setTimeout, - clearTimeout: clearTimeout, - setInterval: setInterval, - clearInterval: clearInterval, - }, - installed = false, - delayedFunctionScheduler, - timer; - - - self.install = function() { - if(!originalTimingFunctionsIntact()) { - throw new Error('Jasmine Clock was unable to install over custom global timer functions. Is the clock already installed?'); - } - replace(global, fakeTimingFunctions); - timer = fakeTimingFunctions; - delayedFunctionScheduler = delayedFunctionSchedulerFactory(); - installed = true; - - return self; - }; - - self.uninstall = function() { - delayedFunctionScheduler = null; - mockDate.uninstall(); - replace(global, realTimingFunctions); - - timer = realTimingFunctions; - installed = false; - }; - - self.withMock = function(closure) { - this.install(); - try { - closure(); - } finally { - this.uninstall(); - } - }; - - self.mockDate = function(initialDate) { - mockDate.install(initialDate); - }; - - self.setTimeout = function(fn, delay, params) { - if (legacyIE()) { - if (arguments.length > 2) { - throw new Error('IE < 9 cannot support extra params to setTimeout without a polyfill'); - } - return timer.setTimeout(fn, delay); - } - return Function.prototype.apply.apply(timer.setTimeout, [global, arguments]); - }; - - self.setInterval = function(fn, delay, params) { - if (legacyIE()) { - if (arguments.length > 2) { - throw new Error('IE < 9 cannot support extra params to setInterval without a polyfill'); - } - return timer.setInterval(fn, delay); - } - return Function.prototype.apply.apply(timer.setInterval, [global, arguments]); - }; - - self.clearTimeout = function(id) { - return Function.prototype.call.apply(timer.clearTimeout, [global, id]); - }; - - self.clearInterval = function(id) { - return Function.prototype.call.apply(timer.clearInterval, [global, id]); - }; - - self.tick = function(millis) { - if (installed) { - mockDate.tick(millis); - delayedFunctionScheduler.tick(millis); - } else { - throw new Error('Mock clock is not installed, use jasmine.clock().install()'); - } - }; - - return self; - - function originalTimingFunctionsIntact() { - return global.setTimeout === realTimingFunctions.setTimeout && - global.clearTimeout === realTimingFunctions.clearTimeout && - global.setInterval === realTimingFunctions.setInterval && - global.clearInterval === realTimingFunctions.clearInterval; - } - - function legacyIE() { - //if these methods are polyfilled, apply will be present - return !(realTimingFunctions.setTimeout || realTimingFunctions.setInterval).apply; - } - - function replace(dest, source) { - for (const prop in source) { - dest[prop] = source[prop]; - } - } - - function setTimeout(fn, delay) { - return delayedFunctionScheduler.scheduleFunction(fn, delay, argSlice(arguments, 2)); - } - - function clearTimeout(id) { - return delayedFunctionScheduler.removeFunctionWithId(id); - } - - function setInterval(fn, interval) { - return delayedFunctionScheduler.scheduleFunction(fn, interval, argSlice(arguments, 2), true); - } - - function clearInterval(id) { - return delayedFunctionScheduler.removeFunctionWithId(id); - } - - function argSlice(argsObj, n) { - return Array.prototype.slice.call(argsObj, n); - } - } - - return Clock; -}(); - - -j$.DelayedFunctionScheduler = function() { - function DelayedFunctionScheduler() { - const self = this; - const scheduledLookup = []; - const scheduledFunctions = {}; - let currentTime = 0; - let delayedFnCount = 0; - - self.tick = function(millis) { - millis = millis || 0; - const endTime = currentTime + millis; - - runScheduledFunctions(endTime); - currentTime = endTime; - }; - - self.scheduleFunction = function(funcToCall, millis, params, recurring, timeoutKey, runAtMillis) { - let f; - if (typeof(funcToCall) === 'string') { - /* jshint evil: true */ - f = function() { return eval(funcToCall); }; - /* jshint evil: false */ - } else { - f = funcToCall; - } - - millis = millis || 0; - timeoutKey = timeoutKey || ++delayedFnCount; - runAtMillis = runAtMillis || (currentTime + millis); - - const funcToSchedule = { - runAtMillis: runAtMillis, - funcToCall: f, - recurring: recurring, - params: params, - timeoutKey: timeoutKey, - millis: millis, - }; - - if (runAtMillis in scheduledFunctions) { - scheduledFunctions[runAtMillis].push(funcToSchedule); - } else { - scheduledFunctions[runAtMillis] = [funcToSchedule]; - scheduledLookup.push(runAtMillis); - scheduledLookup.sort(function(a, b) { - return a - b; - }); - } - - return timeoutKey; - }; - - self.removeFunctionWithId = function(timeoutKey) { - for (const runAtMillis in scheduledFunctions) { - const funcs = scheduledFunctions[runAtMillis]; - const i = indexOfFirstToPass(funcs, function(func) { - return func.timeoutKey === timeoutKey; - }); - - if (i > -1) { - if (funcs.length === 1) { - delete scheduledFunctions[runAtMillis]; - deleteFromLookup(runAtMillis); - } else { - funcs.splice(i, 1); - } - - // intervals get rescheduled when executed, so there's never more - // than a single scheduled function with a given timeoutKey - break; - } - } - }; - - return self; - - function indexOfFirstToPass(array, testFn) { - let index = -1; - - for (let i = 0; i < array.length; ++i) { - if (testFn(array[i])) { - index = i; - break; - } - } - - return index; - } - - function deleteFromLookup(key) { - const value = Number(key); - const i = indexOfFirstToPass(scheduledLookup, function(millis) { - return millis === value; - }); - - if (i > -1) { - scheduledLookup.splice(i, 1); - } - } - - function reschedule(scheduledFn) { - self.scheduleFunction(scheduledFn.funcToCall, - scheduledFn.millis, - scheduledFn.params, - true, - scheduledFn.timeoutKey, - scheduledFn.runAtMillis + scheduledFn.millis); - } - - function forEachFunction(funcsToRun, callback) { - for (let i = 0; i < funcsToRun.length; ++i) { - callback(funcsToRun[i]); - } - } - - function runScheduledFunctions(endTime) { - if (scheduledLookup.length === 0 || scheduledLookup[0] > endTime) { - return; - } - - do { - currentTime = scheduledLookup.shift(); - - const funcsToRun = scheduledFunctions[currentTime]; - delete scheduledFunctions[currentTime]; - - forEachFunction(funcsToRun, function(funcToRun) { - if (funcToRun.recurring) { - reschedule(funcToRun); - } - }); - - forEachFunction(funcsToRun, function(funcToRun) { - funcToRun.funcToCall.apply(null, funcToRun.params || []); - }); - } while (scheduledLookup.length > 0 && - // checking first if we're out of time prevents setTimeout(0) - // scheduled in a funcToRun from forcing an extra iteration - currentTime !== endTime && - scheduledLookup[0] <= endTime); - } - } - - return DelayedFunctionScheduler; -}(); - - -j$.MockDate = function() { - function MockDate(global) { - const self = this; - let currentTime = 0; - - if (!global || !global.Date) { - self.install = function() {}; - self.tick = function() {}; - self.uninstall = function() {}; - return self; - } - - const GlobalDate = global.Date; - - self.install = function(mockDate) { - if (mockDate instanceof GlobalDate) { - currentTime = mockDate.getTime(); - } else { - currentTime = new GlobalDate().getTime(); - } - - global.Date = FakeDate; - }; - - self.tick = function(millis) { - millis = millis || 0; - currentTime = currentTime + millis; - }; - - self.uninstall = function() { - currentTime = 0; - global.Date = GlobalDate; - }; - - createDateProperties(); - - return self; - - function FakeDate() { - switch(arguments.length) { - case 0: - return new GlobalDate(currentTime); - case 1: - return new GlobalDate(arguments[0]); - case 2: - return new GlobalDate(arguments[0], arguments[1]); - case 3: - return new GlobalDate(arguments[0], arguments[1], arguments[2]); - case 4: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3]); - case 5: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], - arguments[4]); - case 6: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], - arguments[4], arguments[5]); - default: - return new GlobalDate(arguments[0], arguments[1], arguments[2], arguments[3], - arguments[4], arguments[5], arguments[6]); - } - } - - function createDateProperties() { - FakeDate.prototype = GlobalDate.prototype; - - FakeDate.now = function() { - if (GlobalDate.now) { - return currentTime; - } else { - throw new Error('Browser does not support Date.now()'); - } - }; - - FakeDate.toSource = GlobalDate.toSource; - FakeDate.toString = GlobalDate.toString; - FakeDate.parse = GlobalDate.parse; - FakeDate.UTC = GlobalDate.UTC; - } - } - - return MockDate; -}(); - -const _clock = new j$.Clock(global, function() { return new j$.DelayedFunctionScheduler(); }, new j$.MockDate(global)); - -export function clock() { - return _clock; -} - - diff --git a/test/utils/AnimationUtils-test.ts b/test/utils/AnimationUtils-test.ts new file mode 100644 index 0000000000..b6d75a706f --- /dev/null +++ b/test/utils/AnimationUtils-test.ts @@ -0,0 +1,35 @@ +/* +Copyright 2021 Šimon Brandner + +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 { lerp } from "../../src/utils/AnimationUtils"; + +describe("lerp", () => { + it("correctly interpolates", () => { + expect(lerp(0, 100, 0.5)).toBe(50); + expect(lerp(50, 100, 0.5)).toBe(75); + expect(lerp(0, 1, 0.1)).toBe(0.1); + }); + + it("clamps the interpolant", () => { + expect(lerp(0, 100, 50)).toBe(100); + expect(lerp(0, 100, -50)).toBe(0); + }); + + it("handles negative numbers", () => { + expect(lerp(-100, 0, 0.5)).toBe(-50); + expect(lerp(100, -100, 0.5)).toBe(0); + }); +}); diff --git a/yarn.lock b/yarn.lock index 89e11fcea5..90f415673d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1478,6 +1478,11 @@ resolved "https://registry.yarnpkg.com/@types/classnames/-/classnames-2.2.11.tgz#2521cc86f69d15c5b90664e4829d84566052c1cf" integrity sha512-2koNhpWm3DgWRp5tpkiJ8JGc1xTn2q0l+jUNUE7oMKXUf5NpI9AIdC4kbjGNFBdHtcxBD18LAksoudAVhFKCjw== +"@types/commonmark@^0.27.4": + version "0.27.4" + resolved "https://registry.yarnpkg.com/@types/commonmark/-/commonmark-0.27.4.tgz#8f42990e5cf3b6b95bd99eaa452e157aab679b82" + integrity sha512-7koSjp08QxKoS1/+3T15+kD7+vqOUvZRHvM8PutF3Xsk5aAEkdlIGRsHJ3/XsC3izoqTwBdRW/vH7rzCKkIicA== + "@types/counterpart@^0.18.1": version "0.18.1" resolved "https://registry.yarnpkg.com/@types/counterpart/-/counterpart-0.18.1.tgz#b1b784d9e54d9879f0a8cb12f2caedab65430fe8" @@ -1672,6 +1677,11 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/sanitize-html@^2.3.1": version "2.3.1" resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.3.1.tgz#094d696b83b7394b016e96342bbffa6a028795ce" @@ -2190,6 +2200,11 @@ bluebird@^3.5.0: resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f" integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg== +blurhash@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.1.3.tgz#dc325af7da836d07a0861d830bdd63694382483e" + integrity sha512-yUhPJvXexbqbyijCIE/T2NCXcj9iNPhWmOKbPTuR/cm7Q5snXYIfnVnz6m7MWOXxODMz/Cr3UcVkRdHiuDVRDw== + boolbase@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -2345,9 +2360,9 @@ camelcase@^6.0.0: integrity sha512-c7wVvbw3f37nuobQNtgsgG9POC9qMbNuMQmTCqZv23b6MIz0fcYpBiOlv9gEN/hdLdnZTDQhg6e9Dq5M1vKvfg== caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001173: - version "1.0.30001178" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001178.tgz#3ad813b2b2c7d585b0be0a2440e1e233c6eabdbc" - integrity sha512-VtdZLC0vsXykKni8Uztx45xynytOi71Ufx9T8kHptSw9AL4dpqailUJJHavttuzUe1KYuBYtChiWv+BAb7mPmQ== + version "1.0.30001241" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001241.tgz" + integrity sha512-1uoSZ1Pq1VpH0WerIMqwptXHNNGfdl7d1cJUFs80CwQ/lVzdhTvsFZCeNFslze7AjsQnb4C85tzclPa1VShbeQ== capture-exit@^2.0.0: version "2.0.0" @@ -3222,7 +3237,7 @@ eslint-config-google@^0.14.0: "eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main": version "0.3.2" - resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/28d392822533a7468be0dd806d0a4ba573a45d74" + resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/e8197938dca66849ffdac4baca7c05275df12835" eslint-plugin-react-hooks@^4.2.0: version "4.2.0" @@ -5435,10 +5450,10 @@ mathml-tag-names@^2.1.3: resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== -matrix-js-sdk@12.0.0: - version "12.0.0" - resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.0.0.tgz#8ee7cc37661476341d0c792a1a12bc78b19f9fdd" - integrity sha512-DHeq87Sx9Dv37FYyvZkmA1VYsQUNaVgc3QzMUkFwoHt1T4EZzgyYpdsp3uYruJzUW0ACvVJcwFdrU4e1VS97dQ== +matrix-js-sdk@12.0.1: + version "12.0.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.0.1.tgz#3a63881f743420a4d39474daa39bd0fb90930d43" + integrity sha512-HkOWv8QHojceo3kPbC+vAIFUjsRAig6MBvEY35UygS3g2dL0UcJ5Qx09/2wcXtu6dowlDnWsz2HHk62tS2cklA== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5446,6 +5461,7 @@ matrix-js-sdk@12.0.0: bs58 "^4.0.1" content-type "^1.0.4" loglevel "^1.7.1" + p-retry "^4.5.0" qs "^6.9.6" request "^2.88.2" unhomoglyph "^1.0.6" @@ -5997,6 +6013,14 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-retry@^4.5.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.0.tgz#9de15ae696278cffe86fce2d8f73b7f894f8bc9e" + integrity sha512-SAHbQEwg3X5DRNaLmWjT+DlGc93ba5i+aP3QLfVNDncQEQO4xjbYW4N/lcVTSuP0aJietGfx2t94dJLzfBMpXw== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.13.1" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -6430,6 +6454,11 @@ react-beautiful-dnd@^13.1.0: redux "^4.0.4" use-memo-one "^1.1.1" +react-blurhash@^0.1.3: + version "0.1.3" + resolved "https://registry.yarnpkg.com/react-blurhash/-/react-blurhash-0.1.3.tgz#735f28f8f07fb358d7efe7e7e6dc65a7272bf89e" + integrity sha512-Q9lqbXg92NU6/2DoIl/cBM8YWL+Z4X66OiG4aT9ozOgjBwx104LHFCH5stf6aF+s0Q9Wf310Ul+dG+VXJltmPg== + react-clientside-effect@^1.2.2: version "1.2.3" resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.3.tgz#95c95f520addfb71743608b990bfe01eb002012b" @@ -6806,6 +6835,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"