diff --git a/.eslintrc.js b/.eslintrc.js index 827b373949..9d68942228 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -63,6 +63,11 @@ module.exports = { "@typescript-eslint/ban-ts-comment": "off", }, }], + settings: { + react: { + version: "detect", + } + } }; function buildRestrictedPropertiesOptions(properties, message) { diff --git a/.github/workflows/develop.yml b/.github/workflows/develop.yml index 0ae59da09a..4f9826391a 100644 --- a/.github/workflows/develop.yml +++ b/.github/workflows/develop.yml @@ -10,6 +10,8 @@ on: jobs: end-to-end: runs-on: ubuntu-latest + env: + PR_NUMBER: ${{github.event.number}} container: vectorim/element-web-ci-e2etests-env:latest steps: - name: Checkout code diff --git a/.stylelintrc.js b/.stylelintrc.js index 0e6de7000f..c044b19a63 100644 --- a/.stylelintrc.js +++ b/.stylelintrc.js @@ -17,6 +17,7 @@ module.exports = { "selector-list-comma-newline-after": null, "at-rule-no-unknown": null, "no-descending-specificity": null, + "no-empty-first-line": true, "scss/at-rule-no-unknown": [true, { // https://github.com/vector-im/element-web/issues/10544 "ignoreAtRules": ["define-mixin"], diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f71c1414c..42e186220f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,47 @@ +Changes in [3.29.0](https://github.com/vector-im/element-desktop/releases/tag/v3.29.0) (2021-08-31) +=================================================================================================== + +## ✨ Features + * [Release]Increase general app performance by optimizing layers ([\#6672](https://github.com/matrix-org/matrix-react-sdk/pull/6672)). Fixes vector-im/element-web#18730 and vector-im/element-web#18730. Contributed by [Palid](https://github.com/Palid). + * Add a warning on E2EE rooms if you try to make them public ([\#5698](https://github.com/matrix-org/matrix-react-sdk/pull/5698)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Allow pagination of the space hierarchy and use new APIs ([\#6507](https://github.com/matrix-org/matrix-react-sdk/pull/6507)). Fixes vector-im/element-web#18089 and vector-im/element-web#18427. + * Improve emoji in composer ([\#6650](https://github.com/matrix-org/matrix-react-sdk/pull/6650)). Fixes vector-im/element-web#18593 and vector-im/element-web#18593. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Allow playback of replied-to voice message ([\#6629](https://github.com/matrix-org/matrix-react-sdk/pull/6629)). Fixes vector-im/element-web#18599 and vector-im/element-web#18599. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Format autocomplete suggestions vertically ([\#6620](https://github.com/matrix-org/matrix-react-sdk/pull/6620)). Fixes vector-im/element-web#17574 and vector-im/element-web#17574. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Remember last `MemberList` search query per-room ([\#6640](https://github.com/matrix-org/matrix-react-sdk/pull/6640)). Fixes vector-im/element-web#18613 and vector-im/element-web#18613. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Sentry rageshakes ([\#6597](https://github.com/matrix-org/matrix-react-sdk/pull/6597)). Fixes vector-im/element-web#11111 and vector-im/element-web#11111. Contributed by [novocaine](https://github.com/novocaine). + * Autocomplete has been updated to match modern accessibility standards. Navigate via up/down arrows rather than Tab. Enter or Tab to confirm a suggestion. This should be familiar to Slack & Discord users. You can now use Tab to navigate around the application and do more without touching your mouse. No more accidentally sending half of people's names because the completion didn't fire on Enter! ([\#5659](https://github.com/matrix-org/matrix-react-sdk/pull/5659)). Fixes vector-im/element-web#4872, vector-im/element-web#11071, vector-im/element-web#17171, vector-im/element-web#15646 vector-im/element-web#4872 and vector-im/element-web#4872. + * Add new call tile states ([\#6610](https://github.com/matrix-org/matrix-react-sdk/pull/6610)). Fixes vector-im/element-web#18521 and vector-im/element-web#18521. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Left align call tiles ([\#6609](https://github.com/matrix-org/matrix-react-sdk/pull/6609)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Make loading encrypted images look snappier ([\#6590](https://github.com/matrix-org/matrix-react-sdk/pull/6590)). Fixes vector-im/element-web#17878 and vector-im/element-web#17862. Contributed by [Palid](https://github.com/Palid). + * Offer a way to create a space based on existing community ([\#6543](https://github.com/matrix-org/matrix-react-sdk/pull/6543)). Fixes vector-im/element-web#18092. + * Accessibility improvements in and around Spaces ([\#6569](https://github.com/matrix-org/matrix-react-sdk/pull/6569)). Fixes vector-im/element-web#18094 and vector-im/element-web#18094. + +## 🐛 Bug Fixes + * [Release] Fix commit edit history ([\#6690](https://github.com/matrix-org/matrix-react-sdk/pull/6690)). Fixes vector-im/element-web#18742 and vector-im/element-web#18742. Contributed by [Palid](https://github.com/Palid). + * Fix images not rendering when sent from other clients. ([\#6661](https://github.com/matrix-org/matrix-react-sdk/pull/6661)). Fixes vector-im/element-web#18702 and vector-im/element-web#18702. + * Fix autocomplete scrollbar and make the autocomplete a little smaller ([\#6655](https://github.com/matrix-org/matrix-react-sdk/pull/6655)). Fixes vector-im/element-web#18682 and vector-im/element-web#18682. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix replies on the bubble layout ([\#6451](https://github.com/matrix-org/matrix-react-sdk/pull/6451)). Fixes vector-im/element-web#18184. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Show "Enable encryption in settings" only when the user can do that ([\#6646](https://github.com/matrix-org/matrix-react-sdk/pull/6646)). Fixes vector-im/element-web#18646 and vector-im/element-web#18646. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix cross signing setup from settings screen ([\#6633](https://github.com/matrix-org/matrix-react-sdk/pull/6633)). Fixes vector-im/element-web#17761 and vector-im/element-web#17761. + * Fix call tiles on the bubble layout ([\#6647](https://github.com/matrix-org/matrix-react-sdk/pull/6647)). Fixes vector-im/element-web#18648 and vector-im/element-web#18648. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix error on accessing encrypted media without encryption keys ([\#6625](https://github.com/matrix-org/matrix-react-sdk/pull/6625)). Contributed by [Palid](https://github.com/Palid). + * Fix jitsi widget sometimes being permanently stuck in the bottom-right corner ([\#6632](https://github.com/matrix-org/matrix-react-sdk/pull/6632)). Fixes vector-im/element-web#17226 and vector-im/element-web#17226. Contributed by [Palid](https://github.com/Palid). + * Fix FilePanel pagination in E2EE rooms ([\#6630](https://github.com/matrix-org/matrix-react-sdk/pull/6630)). Fixes vector-im/element-web#18415 and vector-im/element-web#18415. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix call tile buttons ([\#6624](https://github.com/matrix-org/matrix-react-sdk/pull/6624)). Fixes vector-im/element-web#18565 and vector-im/element-web#18565. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix vertical call tile spacing issues ([\#6621](https://github.com/matrix-org/matrix-react-sdk/pull/6621)). Fixes vector-im/element-web#18558 and vector-im/element-web#18558. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix long display names in call tiles ([\#6618](https://github.com/matrix-org/matrix-react-sdk/pull/6618)). Fixes vector-im/element-web#18562 and vector-im/element-web#18562. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Avoid access token overflow ([\#6616](https://github.com/matrix-org/matrix-react-sdk/pull/6616)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Properly handle media errors ([\#6615](https://github.com/matrix-org/matrix-react-sdk/pull/6615)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix glare related regressions ([\#6614](https://github.com/matrix-org/matrix-react-sdk/pull/6614)). Fixes vector-im/element-web#18538 and vector-im/element-web#18538. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix long display names in call toasts ([\#6617](https://github.com/matrix-org/matrix-react-sdk/pull/6617)). Fixes vector-im/element-web#18557 and vector-im/element-web#18557. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix PiP of held calls ([\#6611](https://github.com/matrix-org/matrix-react-sdk/pull/6611)). Fixes vector-im/element-web#18539 and vector-im/element-web#18539. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix call tile behaviour on narrow layouts ([\#6556](https://github.com/matrix-org/matrix-react-sdk/pull/6556)). Fixes vector-im/element-web#18398. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Fix video call persisting when widget removed ([\#6608](https://github.com/matrix-org/matrix-react-sdk/pull/6608)). Fixes vector-im/element-web#15703 and vector-im/element-web#15703. + * Fix toast colors ([\#6606](https://github.com/matrix-org/matrix-react-sdk/pull/6606)). Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Remove tiny scrollbar dot from code blocks ([\#6596](https://github.com/matrix-org/matrix-react-sdk/pull/6596)). Fixes vector-im/element-web#18474. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + * Improve handling of pills in the composer ([\#6353](https://github.com/matrix-org/matrix-react-sdk/pull/6353)). Fixes vector-im/element-web#10134 vector-im/element-web#10896 and vector-im/element-web#15037. Contributed by [SimonBrandner](https://github.com/SimonBrandner). + Changes in [3.28.1](https://github.com/vector-im/element-desktop/releases/tag/v3.28.1) (2021-08-17) =================================================================================================== diff --git a/package.json b/package.json index e0883f5556..6245b2c34e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.28.1", + "version": "3.29.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -83,7 +83,7 @@ "linkifyjs": "^2.1.9", "lodash": "^4.17.20", "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", - "matrix-widget-api": "^0.1.0-beta.15", + "matrix-widget-api": "^0.1.0-beta.16", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", "pako": "^2.0.3", @@ -151,7 +151,7 @@ "@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/parser": "^4.17.0", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", - "allchange": "^1.0.0", + "allchange": "^1.0.2", "babel-jest": "^26.6.3", "chokidar": "^3.5.1", "concurrently": "^5.3.0", diff --git a/res/css/_common.scss b/res/css/_common.scss index 5fcc10add0..a16e7d4d8f 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -381,11 +381,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus { font-size: $font-14px; color: $primary-content; word-wrap: break-word; - - a { - color: $accent-color; - cursor: pointer; - } } .mx_Dialog_buttons { diff --git a/res/css/_components.scss b/res/css/_components.scss index 566b84a7c8..ffaec43b68 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -132,6 +132,7 @@ @import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_EventListSummary.scss"; +@import "./views/elements/_EventTilePreview.scss"; @import "./views/elements/_FacePile.scss"; @import "./views/elements/_Field.scss"; @import "./views/elements/_ImageView.scss"; diff --git a/res/css/structures/_ScrollPanel.scss b/res/css/structures/_ScrollPanel.scss index 82caeae29d..a668594bba 100644 --- a/res/css/structures/_ScrollPanel.scss +++ b/res/css/structures/_ScrollPanel.scss @@ -15,8 +15,6 @@ limitations under the License. */ .mx_ScrollPanel { - contain: strict; - .mx_RoomView_MessageList { position: relative; display: flex; diff --git a/res/css/views/elements/_EventTilePreview.scss b/res/css/views/elements/_EventTilePreview.scss new file mode 100644 index 0000000000..6bb726168f --- /dev/null +++ b/res/css/views/elements/_EventTilePreview.scss @@ -0,0 +1,22 @@ +/* +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_EventTilePreview_loader { + &.mx_IRCLayout, + &.mx_GroupLayout { + padding: 9px 0; + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index c6983ae631..6805036e3d 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -92,6 +92,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/message-bar/reply.svg'); } +.mx_MessageActionBar_threadButton::after { + mask-image: url('$(res)/img/element-icons/message/thread.svg'); +} + .mx_MessageActionBar_editButton::after { mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg'); } diff --git a/res/css/views/right_panel/_RoomSummaryCard.scss b/res/css/views/right_panel/_RoomSummaryCard.scss index fcb83dfd7a..7ac4787111 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.scss +++ b/res/css/views/right_panel/_RoomSummaryCard.scss @@ -232,6 +232,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/files.svg'); } +.mx_RoomSummaryCard_icon_threads::before { + mask-image: url('$(res)/img/element-icons/message/thread.svg'); +} + .mx_RoomSummaryCard_icon_share::before { mask-image: url('$(res)/img/element-icons/room/share.svg'); } diff --git a/res/css/views/rooms/_Autocomplete.scss b/res/css/views/rooms/_Autocomplete.scss index 529936b2b2..8d2b338d9d 100644 --- a/res/css/views/rooms/_Autocomplete.scss +++ b/res/css/views/rooms/_Autocomplete.scss @@ -10,6 +10,7 @@ max-height: 35vh; overflow: clip; display: flex; + flex-direction: column; box-shadow: 0px -16px 32px $composer-shadow-color; } @@ -20,7 +21,7 @@ /* a "block" completion takes up a whole line */ .mx_Autocomplete_Completion_block { - height: 34px; + min-height: 34px; display: flex; padding: 0 12px; user-select: none; diff --git a/res/css/views/rooms/_EventTile.scss b/res/css/views/rooms/_EventTile.scss index dcfb308ac4..351abc5cd9 100644 --- a/res/css/views/rooms/_EventTile.scss +++ b/res/css/views/rooms/_EventTile.scss @@ -480,7 +480,7 @@ $hover-select-border: 4px; } pre code > * { - display: inline-block; + display: inline; } pre { @@ -514,7 +514,7 @@ $hover-select-border: 4px; .mx_EventTile:hover .mx_EventTile_body pre, .mx_EventTile.focus-visible:focus-within .mx_EventTile_body pre { - border: 1px solid #e5e5e5; // deliberate constant as we're behind an invert filter + border: 1px solid $tertiary-content; } .mx_EventTile_pre_container { @@ -643,6 +643,7 @@ $hover-select-border: 4px; // Remove some of the default tile padding so that the error is centered margin-right: 0; + .mx_EventTile_line { padding-left: 0; margin-right: 0; @@ -674,3 +675,62 @@ $hover-select-border: 4px; margin-right: 0; } } + +.mx_ThreadInfo:hover { + cursor: pointer; +} + +.mx_ThreadView { + display: flex; + flex-direction: column; + + .mx_ScrollPanel { + margin-top: 20px; + + .mx_RoomView_MessageList { + padding: 0; + } + } + + .mx_EventTile_senderDetails { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + + a { + flex: 1; + min-width: none; + max-width: 100%; + display: flex; + align-items: center; + + .mx_SenderProfile { + flex: 1; + } + } + } + + .mx_ThreadView_List { + flex: 1; + overflow: scroll; + } + + .mx_EventTile_roomName { + display: none; + } + + .mx_EventTile_line { + padding-left: 0 !important; + order: 10 !important; + } + + .mx_EventTile { + width: 100%; + display: flex; + flex-direction: column; + margin-top: 0; + padding-bottom: 5px; + margin-bottom: 5px; + } +} diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 899549c0fb..5b36d1e9a1 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -349,3 +349,19 @@ limitations under the License. height: 50px; } } + +/** + * Unstable compact mode + */ + +.mx_MessageComposer.mx_MessageComposer--compact { + margin-right: 0; + + .mx_MessageComposer_wrapper { + padding: 0; + } + + .mx_MessageComposer_button:last-child { + margin-right: 0; + } +} diff --git a/res/img/element-icons/message/thread.svg b/res/img/element-icons/message/thread.svg new file mode 100644 index 0000000000..b4a7cc0066 --- /dev/null +++ b/res/img/element-icons/message/thread.svg @@ -0,0 +1,4 @@ + + + + diff --git a/scripts/fetchdep.sh b/scripts/fetchdep.sh index 0990af70ce..ec021236d9 100755 --- a/scripts/fetchdep.sh +++ b/scripts/fetchdep.sh @@ -10,6 +10,7 @@ defbranch="$3" rm -r "$defrepo" || true +# A function that clones a branch of a repo based on the org, repo and branch clone() { org=$1 repo=$2 @@ -21,45 +22,38 @@ clone() { fi } -# Try the PR author's branch in case it exists on the deps as well. -# First we check if GITHUB_HEAD_REF is defined, -# Then we check if BUILDKITE_BRANCH is defined, -# if they aren't we can assume this is a Netlify build -if [ -n "$GITHUB_HEAD_REF" ]; then - head=$GITHUB_HEAD_REF -elif [ -n "$BUILDKITE_BRANCH" ]; then - head=$BUILDKITE_BRANCH -else - # Netlify doesn't give us info about the fork so we have to get it from GitHub API - apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/" - apiEndpoint+=$REVIEW_ID - head=$(curl $apiEndpoint | jq -r '.head.label') -fi +# A function that gets info about a PR from the GitHub API based on its number +getPRInfo() { + number=$1 + if [ -n "$number" ]; then + echo "Getting info about a PR with number $number" -# If head is set, it will contain on Buildkite either: -# * "branch" when the author's branch and target branch are in the same repo -# * "fork:branch" when the author's branch is in their fork or if this is a Netlify build -# We can split on `:` into an array to check. -# For GitHub Actions we need to inspect GITHUB_REPOSITORY and GITHUB_ACTOR -# to determine whether the branch is from a fork or not -BRANCH_ARRAY=(${head//:/ }) -if [[ "${#BRANCH_ARRAY[@]}" == "1" ]]; then + apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/" + apiEndpoint+=$number - if [ -n "$GITHUB_HEAD_REF" ]; then - if [[ "$GITHUB_REPOSITORY" == "$deforg"* ]]; then - clone $deforg $defrepo $GITHUB_HEAD_REF - else - REPO_ARRAY=(${GITHUB_REPOSITORY//\// }) - clone $REPO_ARRAY[0] $defrepo $GITHUB_HEAD_REF - fi - else - clone $deforg $defrepo $BUILDKITE_BRANCH + head=$(curl $apiEndpoint | jq -r '.head.label') fi +} -elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then - clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} +# Some CIs don't give us enough info, so we just get the PR number and ask the +# GH API for more info - "fork:branch". Some give us this directly. +if [ -n "$BUILDKITE_BRANCH" ]; then + # BuildKite + head=$BUILDKITE_BRANCH +elif [ -n "$PR_NUMBER" ]; then + # GitHub + getPRInfo $PR_NUMBER +elif [ -n "$REVIEW_ID" ]; then + # Netlify + getPRInfo $REVIEW_ID fi +# $head will always be in the format "fork:branch", so we split it by ":" into +# an array. The first element will then be the fork and the second the branch. +# Based on that we clone +BRANCH_ARRAY=(${head//:/ }) +clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} + # Try the target branch of the push or PR. if [ -n $GITHUB_BASE_REF ]; then clone $deforg $defrepo $GITHUB_BASE_REF diff --git a/src/CallHandler.tsx b/src/CallHandler.tsx index e11e828864..8fd45c49ae 100644 --- a/src/CallHandler.tsx +++ b/src/CallHandler.tsx @@ -250,7 +250,15 @@ export default class CallHandler extends EventEmitter { * @returns {boolean} */ private areAnyCallsUnsilenced(): boolean { - return this.calls.size > this.silencedCalls.size; + for (const call of this.calls.values()) { + if ( + call.state === CallState.Ringing && + !this.isCallSilenced(call.callId) + ) { + return true; + } + } + return false; } private async checkProtocols(maxTries) { @@ -878,6 +886,8 @@ export default class CallHandler extends EventEmitter { break; case 'hangup': case 'reject': + this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + if (!this.calls.get(payload.room_id)) { return; // no call to hangup } @@ -890,11 +900,15 @@ export default class CallHandler extends EventEmitter { // the hangup event away) break; case 'hangup_all': + this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + for (const call of this.calls.values()) { call.hangup(CallErrorCode.UserHangup, false); } break; case 'answer': { + this.stopRingingIfPossible(this.calls.get(payload.room_id).callId); + if (!this.calls.has(payload.room_id)) { return; // no call to answer } @@ -929,6 +943,12 @@ export default class CallHandler extends EventEmitter { } }; + private stopRingingIfPossible(callId: string): void { + this.silencedCalls.delete(callId); + if (this.areAnyCallsUnsilenced()) return; + this.pause(AudioID.Ring); + } + private async dialNumber(number: string) { const results = await this.pstnLookup(number); if (!results || results.length === 0 || !results[0].userid) { diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index f43351aab2..7d0ff560b7 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -213,6 +213,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours + opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread"); // Connect the matrix client to the dispatcher and setting handlers MatrixActionCreators.start(this.matrixClient); diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index b4deb6d8c4..902c82fff8 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -50,7 +50,6 @@ import CallHandler from "./CallHandler"; import { guessAndSetDMRoom } from "./Rooms"; import { upgradeRoom } from './utils/RoomUpgrade'; 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"; @@ -245,21 +244,6 @@ export const Commands = [ }, category: CommandCategories.messages, }), - new Command({ - command: 'ddg', - args: '', - description: _td('Searches DuckDuckGo for results'), - runFn: function() { - // 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'), - description: _t('To use it, just wait for autocomplete results to load and tab through them.'), - }); - return success(); - }, - category: CommandCategories.actions, - hideCompletionAfterSpace: true, - }), new Command({ command: 'upgraderoom', args: '', diff --git a/src/audio/RecorderWorklet.ts b/src/audio/RecorderWorklet.ts index 2d1bb0bcd2..73b053db93 100644 --- a/src/audio/RecorderWorklet.ts +++ b/src/audio/RecorderWorklet.ts @@ -45,7 +45,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor { process(inputs, outputs, parameters) { const currentSecond = roundTimeToTargetFreq(currentTime); - if (currentSecond === this.nextAmplitudeSecond) { + // We special case the first ping because there's a fairly good chance that we'll miss the zeroth + // update. Firefox for instance takes 0.06 seconds (roughly) to call this function for the first + // time. Edge and Chrome occasionally lag behind too, but for the most part are on time. + // + // When this doesn't work properly we end up producing a waveform of nulls and no live preview + // of the recorded message. + if (currentSecond === this.nextAmplitudeSecond || this.nextAmplitudeSecond === 0) { // We're expecting exactly one mono input source, so just grab the very first frame of // samples for the analysis. const monoChan = inputs[0][0]; diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index acc7846510..4c9e82f290 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -20,7 +20,6 @@ import { Room } from 'matrix-js-sdk/src/models/room'; import CommandProvider from './CommandProvider'; import CommunityProvider from './CommunityProvider'; -import DuckDuckGoProvider from './DuckDuckGoProvider'; import RoomProvider from './RoomProvider'; import UserProvider from './UserProvider'; import EmojiProvider from './EmojiProvider'; @@ -55,7 +54,6 @@ const PROVIDERS = [ EmojiProvider, NotifProvider, CommandProvider, - DuckDuckGoProvider, ]; if (SpaceStore.spacesEnabled) { diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index d56adc026c..143b7e4cdc 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -53,7 +53,7 @@ export default class CommandProvider extends AutocompleteProvider { // The input looks like a command with arguments, perform exact match const name = command[1].substr(1); // strip leading `/` if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) { - // some commands, namely `me` and `ddg` don't suit having the usage shown whilst typing their arguments + // some commands, namely `me` don't suit having the usage shown whilst typing their arguments if (CommandMap.get(name).hideCompletionAfterSpace) return []; matches = [CommandMap.get(name)]; } @@ -95,7 +95,7 @@ export default class CommandProvider extends AutocompleteProvider { renderCompletions(completions: React.ReactNode[]): React.ReactNode { return (
diff --git a/src/autocomplete/DuckDuckGoProvider.tsx b/src/autocomplete/DuckDuckGoProvider.tsx deleted file mode 100644 index c41a91b97f..0000000000 --- a/src/autocomplete/DuckDuckGoProvider.tsx +++ /dev/null @@ -1,115 +0,0 @@ -/* -Copyright 2016 Aviral Dasgupta -Copyright 2017 Vector Creations Ltd -Copyright 2017, 2018 New Vector Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import React from 'react'; -import { _t } from '../languageHandler'; -import AutocompleteProvider from './AutocompleteProvider'; - -import { TextualCompletion } from './Components'; -import { ICompletion, ISelectionRange } from "./Autocompleter"; - -const DDG_REGEX = /\/ddg\s+(.+)$/g; -const REFERRER = 'vector'; - -export default class DuckDuckGoProvider extends AutocompleteProvider { - constructor() { - super(DDG_REGEX); - } - - static getQueryUri(query: string) { - return `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}` - + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; - } - - async getCompletions( - query: string, - selection: ISelectionRange, - force = false, - limit = -1, - ): Promise { - const { command, range } = this.getCurrentCommand(query, selection); - if (!query || !command) { - return []; - } - - const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), { - method: 'GET', - }); - const json = await response.json(); - const maxLength = limit > -1 ? limit : json.Results.length; - const results = json.Results.slice(0, maxLength).map((result) => { - return { - completion: result.Text, - component: ( - - ), - range, - }; - }); - if (json.Answer) { - results.unshift({ - completion: json.Answer, - component: ( - - ), - range, - }); - } - if (json.RelatedTopics && json.RelatedTopics.length > 0) { - results.unshift({ - completion: json.RelatedTopics[0].Text, - component: ( - - ), - range, - }); - } - if (json.AbstractText) { - results.unshift({ - completion: json.AbstractText, - component: ( - - ), - range, - }); - } - return results; - } - - getName() { - return '🔍 ' + _t('Results from DuckDuckGo'); - } - - renderCompletions(completions: React.ReactNode[]): React.ReactNode { - return ( -
- { completions } -
- ); - } -} diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx index 144c0e3051..331e428355 100644 --- a/src/components/structures/LeftPanelWidget.tsx +++ b/src/components/structures/LeftPanelWidget.tsx @@ -115,7 +115,7 @@ const LeftPanelWidget: React.FC = () => { aria-expanded={expanded} aria-level={1} onClick={() => { - setExpanded(e => !e); + setExpanded(!expanded); }} > { this.setStateForNewView({ view: Views.LOGGED_IN, justRegistered, + currentRoomId: null, }); this.setPage(PageTypes.HomePage); this.notifyNewScreen('home'); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 1691d90651..8bf1f5bd5f 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -173,6 +173,8 @@ interface IProps { onUnfillRequest?(backwards: boolean, scrollToken: string): void; getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations; + + hideThreadedMessages?: boolean; } interface IState { @@ -265,6 +267,9 @@ export default class MessagePanel extends React.Component { componentDidMount() { this.calculateRoomMembersCount(); this.props.room?.on("RoomState.members", this.calculateRoomMembersCount); + if (SettingsStore.getValue("feature_thread")) { + this.props.room?.getThreads().forEach(thread => thread.fetchReplyChain()); + } this.isMounted = true; } @@ -443,6 +448,12 @@ export default class MessagePanel extends React.Component { // Always show highlighted event if (this.props.highlightedEventId === mxEv.getId()) return true; + if (mxEv.replyEventId + && this.props.hideThreadedMessages + && SettingsStore.getValue("feature_thread")) { + return false; + } + return !shouldHideEvent(mxEv, this.context); } diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 95d70e913a..67634c63d2 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -45,17 +45,21 @@ import GroupRoomInfo from "../views/groups/GroupRoomInfo"; import UserInfo from "../views/right_panel/UserInfo"; import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; import FilePanel from "./FilePanel"; +import ThreadView from "./ThreadView"; +import ThreadPanel from "./ThreadPanel"; import NotificationPanel from "./NotificationPanel"; import ResizeNotifier from "../../utils/ResizeNotifier"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; import { throttle } from 'lodash'; import SpaceStore from "../../stores/SpaceStore"; +import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; interface IProps { room?: Room; // if showing panels for a given room, this is set groupId?: string; // if showing panels for a given group, this is set user?: User; // used if we know the user ahead of opening the panel resizeNotifier: ResizeNotifier; + permalinkCreator?: RoomPermalinkCreator; } interface IState { @@ -309,6 +313,22 @@ export default class RightPanel extends React.Component { panel = ; break; + case RightPanelPhases.ThreadView: + panel = ; + break; + + case RightPanelPhases.ThreadPanel: + panel = ; + break; + case RightPanelPhases.RoomSummary: panel = ; break; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 474b99262d..baf557ee18 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -2052,7 +2052,10 @@ export default class RoomView extends React.Component { const showRightPanel = this.state.room && this.state.showRightPanel; const rightPanel = showRightPanel - ? + ? : null; const timelineClasses = classNames("mx_RoomView_timeline", { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 767e0999c3..1ce5a53c3e 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { RefObject, useContext, useRef, useState } from "react"; import { EventType } from "matrix-js-sdk/src/@types/event"; -import { Preset, JoinRule } from "matrix-js-sdk/src/@types/partials"; +import { JoinRule, Preset } from "matrix-js-sdk/src/@types/partials"; import { Room } from "matrix-js-sdk/src/models/room"; import { EventSubscription } from "fbemitter"; @@ -505,11 +505,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setError(""); setBusy(true); try { + const isPublic = space.getJoinRule() === JoinRule.Public; const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); await Promise.all(filteredRoomNames.map(name => { return createRoom({ createOpts: { - preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, + preset: isPublic ? Preset.PublicChat : Preset.PrivateChat, name, }, spinner: false, @@ -517,6 +518,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { andView: false, inlineErrors: true, parentSpace: space, + joinRule: !isPublic ? JoinRule.Restricted : undefined, }); })); onFinished(filteredRoomNames.length > 0); diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx new file mode 100644 index 0000000000..a0bccfdce9 --- /dev/null +++ b/src/components/structures/ThreadPanel.tsx @@ -0,0 +1,93 @@ +/* +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, Room } from 'matrix-js-sdk/src'; +import { Thread } from 'matrix-js-sdk/src/models/thread'; + +import BaseCard from "../views/right_panel/BaseCard"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { replaceableComponent } from "../../utils/replaceableComponent"; +import { MatrixClientPeg } from '../../MatrixClientPeg'; + +import ResizeNotifier from '../../utils/ResizeNotifier'; +import EventTile from '../views/rooms/EventTile'; + +interface IProps { + roomId: string; + onClose: () => void; + resizeNotifier: ResizeNotifier; +} + +interface IState { + threads?: Thread[]; +} + +@replaceableComponent("structures.ThreadView") +export default class ThreadPanel extends React.Component { + private room: Room; + + constructor(props: IProps) { + super(props); + this.room = MatrixClientPeg.get().getRoom(this.props.roomId); + } + + public componentDidMount(): void { + this.room.on("Thread.update", this.onThreadEventReceived); + this.room.on("Thread.ready", this.onThreadEventReceived); + } + + public componentWillUnmount(): void { + this.room.removeListener("Thread.update", this.onThreadEventReceived); + this.room.removeListener("Thread.ready", this.onThreadEventReceived); + } + + private onThreadEventReceived = () => this.updateThreads(); + + private updateThreads = (callback?: () => void): void => { + this.setState({ + threads: this.room.getThreads(), + }, callback); + }; + + private renderEventTile(event: MatrixEvent): JSX.Element { + return ; + } + + public render(): JSX.Element { + return ( + + { + this.state?.threads.map((thread: Thread) => { + if (thread.ready) { + return this.renderEventTile(thread.rootEvent); + } + }) + } + + ); + } +} diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx new file mode 100644 index 0000000000..a2595debc8 --- /dev/null +++ b/src/components/structures/ThreadView.tsx @@ -0,0 +1,147 @@ +/* +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, Room } from 'matrix-js-sdk/src'; +import { Thread } from 'matrix-js-sdk/src/models/thread'; + +import BaseCard from "../views/right_panel/BaseCard"; +import { RightPanelPhases } from "../../stores/RightPanelStorePhases"; +import { replaceableComponent } from "../../utils/replaceableComponent"; + +import ResizeNotifier from '../../utils/ResizeNotifier'; +import { TileShape } from '../views/rooms/EventTile'; +import MessageComposer from '../views/rooms/MessageComposer'; +import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks'; +import { Layout } from '../../settings/Layout'; +import TimelinePanel from './TimelinePanel'; +import dis from "../../dispatcher/dispatcher"; +import { ActionPayload } from '../../dispatcher/payloads'; +import { SetRightPanelPhasePayload } from '../../dispatcher/payloads/SetRightPanelPhasePayload'; +import { Action } from '../../dispatcher/actions'; + +interface IProps { + room: Room; + onClose: () => void; + resizeNotifier: ResizeNotifier; + mxEvent: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; +} + +interface IState { + replyToEvent?: MatrixEvent; + thread?: Thread; +} + +@replaceableComponent("structures.ThreadView") +export default class ThreadView extends React.Component { + private dispatcherRef: string; + + constructor(props: IProps) { + super(props); + this.state = {}; + } + + public componentDidMount(): void { + this.setupThread(this.props.mxEvent); + this.dispatcherRef = dis.register(this.onAction); + } + + public componentWillUnmount(): void { + this.teardownThread(); + dis.unregister(this.dispatcherRef); + } + + public componentDidUpdate(prevProps) { + if (prevProps.mxEvent !== this.props.mxEvent) { + this.teardownThread(); + this.setupThread(this.props.mxEvent); + } + + if (prevProps.room !== this.props.room) { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.RoomSummary, + }); + } + } + + private onAction = (payload: ActionPayload): void => { + if (payload.phase == RightPanelPhases.ThreadView && payload.event) { + if (payload.event !== this.props.mxEvent) { + this.teardownThread(); + this.setupThread(payload.event); + } + } + }; + + private setupThread = (mxEv: MatrixEvent) => { + const thread = mxEv.getThread(); + if (thread) { + thread.on("Thread.update", this.updateThread); + thread.once("Thread.ready", this.updateThread); + this.updateThread(thread); + } + }; + + private teardownThread = () => { + if (this.state.thread) { + this.state.thread.removeListener("Thread.update", this.updateThread); + this.state.thread.removeListener("Thread.ready", this.updateThread); + } + }; + + private updateThread = (thread?: Thread) => { + if (thread) { + this.setState({ thread }); + } else { + this.forceUpdate(); + } + }; + + public render(): JSX.Element { + return ( + + { this.state.thread && ( + empty
} + alwaysShowTimestamps={true} + layout={Layout.Group} + hideThreadedMessages={false} + /> + ) } + + + ); + } +} diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index f62676a4fc..e5fa6967dc 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -126,6 +126,8 @@ interface IProps { // callback which is called when we wish to paginate the timeline window. onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise; + + hideThreadedMessages?: boolean; } interface IState { @@ -214,6 +216,7 @@ class TimelinePanel extends React.Component { timelineCap: Number.MAX_VALUE, className: 'mx_RoomView_messagePanel', sendReadReceiptOnLoad: true, + hideThreadedMessages: true, }; private lastRRSentEventId: string = undefined; @@ -1511,6 +1514,7 @@ class TimelinePanel extends React.Component { showReactions={this.props.showReactions} layout={this.props.layout} enableFlair={SettingsStore.getValue(UIFeature.Flair)} + hideThreadedMessages={this.props.hideThreadedMessages} /> ); } diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx index 03927c7d62..d80245918f 100644 --- a/src/components/views/dialogs/CreateSubspaceDialog.tsx +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -79,7 +79,7 @@ const CreateSubspaceDialog: React.FC = ({ space, onAddExistingSpaceClick } try { - await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace }); + await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule }); onFinished(true); } catch (e) { diff --git a/src/components/views/dialogs/LeaveSpaceDialog.tsx b/src/components/views/dialogs/LeaveSpaceDialog.tsx index 6e1e798e9d..3a8cd53945 100644 --- a/src/components/views/dialogs/LeaveSpaceDialog.tsx +++ b/src/components/views/dialogs/LeaveSpaceDialog.tsx @@ -80,7 +80,7 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => { const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => { const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]); - const [state, setState] = useState(RoomsToLeave.All); + const [state, setState] = useState(RoomsToLeave.None); useEffect(() => { if (state === RoomsToLeave.All) { @@ -97,11 +97,11 @@ const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave onChange={setState} definitions={[ { - value: RoomsToLeave.All, - label: _t("Leave all rooms and spaces"), - }, { value: RoomsToLeave.None, label: _t("Don't leave any"), + }, { + value: RoomsToLeave.All, + label: _t("Leave all rooms and spaces"), }, { value: RoomsToLeave.Specific, label: _t("Leave specific rooms and spaces"), diff --git a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx index ebeab191b1..366adb887c 100644 --- a/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx +++ b/src/components/views/dialogs/WidgetCapabilitiesPromptDialog.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. @@ -20,6 +20,7 @@ import { _t } from "../../../languageHandler"; import { IDialogProps } from "./IDialogProps"; import { Capability, + isTimelineCapability, Widget, WidgetEventCapability, WidgetKind, @@ -30,14 +31,7 @@ import DialogButtons from "../elements/DialogButtons"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import { CapabilityText } from "../../../widgets/CapabilityText"; import { replaceableComponent } from "../../../utils/replaceableComponent"; - -export function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { - return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); -} - -function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) { - localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps)); -} +import { lexicographicCompare } from "matrix-js-sdk/src/utils"; interface IProps extends IDialogProps { requestedCapabilities: Set; @@ -95,14 +89,24 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent< }; private closeAndTryRemember(approved: Capability[]) { - if (this.state.rememberSelection) { - setRememberedCapabilitiesForWidget(this.props.widget, approved); - } - this.props.onFinished({ approved }); + this.props.onFinished({ approved, remember: this.state.rememberSelection }); } public render() { - const checkboxRows = Object.entries(this.state.booleanStates).map(([cap, isChecked], i) => { + // We specifically order the timeline capabilities down to the bottom. The capability text + // generation cares strongly about this. + const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => { + const isTimelineA = isTimelineCapability(capA); + const isTimelineB = isTimelineCapability(capB); + + if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB); + if (isTimelineA && !isTimelineB) return 1; + if (!isTimelineA && isTimelineB) return -1; + if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB); + + return 0; + }); + const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => { const text = CapabilityText.for(cap, this.props.widgetKind); const byline = text.byline ? { text.byline } diff --git a/src/components/views/elements/EventTilePreview.tsx b/src/components/views/elements/EventTilePreview.tsx index 68a70133e6..a7ebf40c3a 100644 --- a/src/components/views/elements/EventTilePreview.tsx +++ b/src/components/views/elements/EventTilePreview.tsx @@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import { Layout } from "../../../settings/Layout"; import { UIFeature } from "../../../settings/UIFeature"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import Spinner from './Spinner'; interface IProps { /** @@ -45,7 +46,7 @@ interface IProps { /** * The ID of the displayed user */ - userId: string; + userId?: string; /** * The display name of the displayed user @@ -118,13 +119,16 @@ export default class EventTilePreview extends React.Component { } public render() { - const event = this.fakeEvent(this.state); - const className = classnames(this.props.className, { "mx_IRCLayout": this.props.layout == Layout.IRC, "mx_GroupLayout": this.props.layout == Layout.Group, + "mx_EventTilePreview_loader": !this.props.userId, }); + if (!this.props.userId) return
; + + const event = this.fakeEvent(this.state); + return
{ const avatar = ( { }); return
-
{ this.props.reason }
+
{ this.props.htmlReason ? sanitizedHtmlNode(this.props.htmlReason) : this.props.reason }
diff --git a/src/components/views/emojipicker/EmojiPicker.tsx b/src/components/views/emojipicker/EmojiPicker.tsx index 0884db7101..9cc995a140 100644 --- a/src/components/views/emojipicker/EmojiPicker.tsx +++ b/src/components/views/emojipicker/EmojiPicker.tsx @@ -173,16 +173,16 @@ class EmojiPicker extends React.Component { }; private onChangeFilter = (filter: string) => { - filter = filter.toLowerCase(); // filter is case insensitive stored lower-case + const lcFilter = filter.toLowerCase().trim(); // filter is case insensitive for (const cat of this.categories) { let emojis; // If the new filter string includes the old filter string, we don't have to re-filter the whole dataset. - if (filter.includes(this.state.filter)) { + if (lcFilter.includes(this.state.filter)) { emojis = this.memoizedDataByCategory[cat.id]; } else { emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id]; } - emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, filter)); + emojis = emojis.filter(emoji => this.emojiMatchesFilter(emoji, lcFilter)); this.memoizedDataByCategory[cat.id] = emojis; cat.enabled = emojis.length > 0; // The setState below doesn't re-render the header and we already have the refs for updateVisibility, so... @@ -194,9 +194,12 @@ class EmojiPicker extends React.Component { setTimeout(this.updateVisibility, 0); }; - private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => - [emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)] - .some(x => x?.includes(filter)); + private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => { + return emoji.annotation.toLowerCase().includes(filter) || + emoji.emoticon?.toLowerCase().includes(filter) || + emoji.shortcodes.some(x => x.toLowerCase().includes(filter)) || + emoji.unicode.split(ZERO_WIDTH_JOINER).includes(filter); + }; private onEnterFilter = () => { const btn = this.bodyRef.current.querySelector(".mx_EmojiPicker_item"); diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 7fe0eca697..cb8ea7a50d 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -23,6 +23,8 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event'; import { _t } from '../../../languageHandler'; import * as sdk from '../../../index'; import dis from '../../../dispatcher/dispatcher'; +import { Action } from '../../../dispatcher/actions'; +import { RightPanelPhases } from '../../../stores/RightPanelStorePhases'; import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import RoomContext from "../../../contexts/RoomContext"; @@ -34,6 +36,7 @@ import Resend from "../../../Resend"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import DownloadActionButton from "./DownloadActionButton"; +import SettingsStore from '../../../settings/SettingsStore'; const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); @@ -170,6 +173,17 @@ export default class MessageActionBar extends React.PureComponent { }); }; + onThreadClick = () => { + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.ThreadView, + allowClose: false, + refireParams: { + event: this.props.mxEvent, + }, + }); + } + onEditClick = (ev) => { dis.dispatch({ action: 'edit_event', @@ -254,12 +268,22 @@ export default class MessageActionBar extends React.PureComponent { // The only catch is we do the reply button first so that we can make sure the react // button is the very first button without having to do length checks for `splice()`. if (this.context.canReply) { - toolbarOpts.splice(0, 0, ); + toolbarOpts.splice(0, 0, <> + + { SettingsStore.getValue("feature_thread") && ( + + ) } + ); } if (this.context.canReact) { toolbarOpts.splice(0, 0, { }); }; +const onRoomThreadsClick = () => { + defaultDispatcher.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.ThreadPanel, + }); +}; + const onRoomSettingsClick = () => { defaultDispatcher.dispatch({ action: "open_room_settings" }); }; @@ -273,6 +280,11 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { + { SettingsStore.getValue("feature_thread") && ( + + ) } diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index dd954e46ce..935a349b10 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -21,6 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { Thread } from 'matrix-js-sdk/src/models/thread'; import ReplyThread from "../elements/ReplyThread"; import { _t } from '../../../languageHandler'; @@ -55,6 +56,8 @@ import ReadReceiptMarker from "./ReadReceiptMarker"; import MessageActionBar from "../messages/MessageActionBar"; import ReactionsRow from '../messages/ReactionsRow'; import { getEventDisplayInfo } from '../../../utils/EventUtils'; +import { RightPanelPhases } from "../../../stores/RightPanelStorePhases"; +import SettingsStore from "../../../settings/SettingsStore"; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', @@ -299,6 +302,9 @@ interface IProps { // whether or not to display the sender hideSender?: boolean; + + // whether or not to display thread info + showThreadInfo?: boolean; } interface IState { @@ -315,6 +321,8 @@ interface IState { reactions: Relations; hover: boolean; + + thread?: Thread; } @replaceableComponent("views.rooms.EventTile") @@ -351,6 +359,8 @@ export default class EventTile extends React.Component { reactions: this.getReactions(), hover: false, + + thread: this.props.mxEvent?.getThread(), }; // don't do RR animations until we are mounted @@ -451,8 +461,20 @@ export default class EventTile extends React.Component { client.on("Room.receipt", this.onRoomReceipt); this.isListeningForReceipts = true; } + + if (SettingsStore.getValue("feature_thread")) { + this.props.mxEvent.once("Thread.ready", this.updateThread); + this.props.mxEvent.on("Thread.update", this.updateThread); + } } + private updateThread = (thread) => { + this.setState({ + thread, + }); + this.forceUpdate(); + }; + // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line UNSAFE_componentWillReceiveProps(nextProps) { @@ -463,7 +485,7 @@ export default class EventTile extends React.Component { } } - shouldComponentUpdate(nextProps, nextState) { + shouldComponentUpdate(nextProps, nextState, nextContext) { if (objectHasDiff(this.state, nextState)) { return true; } @@ -491,6 +513,43 @@ export default class EventTile extends React.Component { } } + private renderThreadInfo(): React.ReactNode { + if (!SettingsStore.getValue("feature_thread")) { + return null; + } + + const thread = this.state.thread; + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); + if (!thread || this.props.showThreadInfo === false) { + return null; + } + + const avatars = Array.from(thread.participants).map((mxId: string) => { + const member = room.getMember(mxId); + return ; + }); + + return ( +
{ + dis.dispatch({ + action: Action.SetRightPanelPhase, + phase: RightPanelPhases.ThreadView, + refireParams: { + event: this.props.mxEvent, + }, + }); + }} + > + + { avatars } + + { thread.length - 1 } { thread.length === 2 ? 'reply' : 'replies' } +
+ ); + } + private onRoomReceipt = (ev, room) => { // ignore events for other rooms const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); @@ -1180,6 +1239,7 @@ export default class EventTile extends React.Component { { keyRequestInfo } { actionBar } { this.props.layout === Layout.IRC && (reactionsRow) } + { this.renderThreadInfo() }
{ this.props.layout !== Layout.IRC && (reactionsRow) } { msgOption } diff --git a/src/components/views/rooms/MemberList.tsx b/src/components/views/rooms/MemberList.tsx index 415d7e942b..df4f2d21fa 100644 --- a/src/components/views/rooms/MemberList.tsx +++ b/src/components/views/rooms/MemberList.tsx @@ -185,8 +185,8 @@ export default class MemberList extends React.Component { return { loading: false, members: members, - filteredJoinedMembers: this.filterMembers(members, 'join'), - filteredInvitedMembers: this.filterMembers(members, 'invite'), + filteredJoinedMembers: this.filterMembers(members, 'join', searchQuery), + filteredInvitedMembers: this.filterMembers(members, 'invite', searchQuery), canInvite: this.canInvite, // ideally we'd size this to the page height, but diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 5a1831068f..162a0cc86a 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -192,7 +192,9 @@ interface IProps { resizeNotifier: ResizeNotifier; permalinkCreator: RoomPermalinkCreator; replyToEvent?: MatrixEvent; + showReplyPreview?: boolean; e2eStatus?: E2EStatus; + compact?: boolean; } interface IState { @@ -214,6 +216,11 @@ export default class MessageComposer extends React.Component { private voiceRecordingButton: VoiceRecordComposerTile; private ref: React.RefObject = createRef(); + static defaultProps = { + showReplyPreview: true, + compact: false, + }; + constructor(props) { super(props); VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate); @@ -471,7 +478,7 @@ export default class MessageComposer extends React.Component { render() { const controls = [ - this.state.me ? : null, + this.state.me && !this.props.compact ? : null, this.props.e2eStatus ? : null, @@ -541,8 +548,7 @@ export default class MessageComposer extends React.Component { yOffset={-50} />; } - - controls.push( + controls.push( { const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording; + const classes = classNames({ + "mx_MessageComposer": true, + "mx_GroupLayout": true, + "mx_MessageComposer--compact": this.props.compact, + }); + return ( -
+
{ recordingTooltip }
- + { this.props.showReplyPreview && ( + + ) }
{ controls } { this.renderButtons(menuPosition) } diff --git a/src/components/views/rooms/RoomPreviewBar.js b/src/components/views/rooms/RoomPreviewBar.js index b8a4315e2d..89b493595f 100644 --- a/src/components/views/rooms/RoomPreviewBar.js +++ b/src/components/views/rooms/RoomPreviewBar.js @@ -28,6 +28,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import InviteReason from "../elements/InviteReason"; +const MemberEventHtmlReasonField = "io.element.html_reason"; + const MessageCase = Object.freeze({ NotLoggedIn: "NotLoggedIn", Joining: "Joining", @@ -492,9 +494,13 @@ export default class RoomPreviewBar extends React.Component { } const myUserId = MatrixClientPeg.get().getUserId(); - const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason; - if (reason) { - reasonElement = ; + const memberEventContent = this.props.room.currentState.getMember(myUserId).events.member.getContent(); + + if (memberEventContent.reason) { + reasonElement = ; } primaryActionHandler = this.props.onJoinClick; diff --git a/src/components/views/settings/LayoutSwitcher.tsx b/src/components/views/settings/LayoutSwitcher.tsx index dd7accf9a8..ad8abd0033 100644 --- a/src/components/views/settings/LayoutSwitcher.tsx +++ b/src/components/views/settings/LayoutSwitcher.tsx @@ -26,7 +26,7 @@ import { Layout } from "../../../settings/Layout"; import { SettingLevel } from "../../../settings/SettingLevel"; interface IProps { - userId: string; + userId?: string; displayName: string; avatarUrl: string; messagePreviewText: string; diff --git a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx index d9e97d570b..081b1a8698 100644 --- a/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/SecurityRoomSettingsTab.tsx @@ -149,10 +149,12 @@ export default class SecurityRoomSettingsTab extends React.Componentnew encrypted room for " + "the conversation you plan to have.", null, - { "a": (sub) => { - dialog.close(); - this.createNewRoom(false, true); - }}> { sub } }, + { "a": (sub) => { + dialog.close(); + this.createNewRoom(false, true); + }}> { sub } }, ) }

, @@ -248,10 +250,12 @@ export default class SecurityRoomSettingsTab extends React.Component { - dialog.close(); - this.createNewRoom(true, false); - }}> { sub } , + "a": (sub) => { + dialog.close(); + this.createNewRoom(true, false); + }}> { sub } , }, ) }

, diff --git a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx index cbf0b7916c..bc54a8155c 100644 --- a/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/AppearanceUserSettingsTab.tsx @@ -67,7 +67,7 @@ interface IState extends IThemeState { showAdvanced: boolean; layout: Layout; // User profile data for the message preview - userId: string; + userId?: string; displayName: string; avatarUrl: string; } @@ -92,8 +92,8 @@ export default class AppearanceUserSettingsTab extends React.Component { { space.canInvite(MatrixClientPeg.get()?.getUserId()) ? { - showRoomInviteDialog(space.roomId); if (onFinished) onFinished(); + showRoomInviteDialog(space.roomId); }} >

{ _t("Invite people") }

diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 428024f7a8..91580776a4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -426,9 +426,6 @@ "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message", "Sends a message as plain text, without interpreting it as markdown": "Sends a message as plain text, without interpreting it as markdown", "Sends a message as html, without interpreting it as markdown": "Sends a message as html, without interpreting it as markdown", - "Searches DuckDuckGo for results": "Searches DuckDuckGo for results", - "/ddg is not a command": "/ddg is not a command", - "To use it, just wait for autocomplete results to load and tab through them.": "To use it, just wait for autocomplete results to load and tab through them.", "Upgrades a room to a new version": "Upgrades a room to a new version", "You do not have the required permissions to use this command.": "You do not have the required permissions to use this command.", "Changes your display nickname": "Changes your display nickname", @@ -604,6 +601,8 @@ "See when anyone posts a sticker to your active room": "See when anyone posts a sticker to your active room", "with an empty state key": "with an empty state key", "with state key %(stateKey)s": "with state key %(stateKey)s", + "The above, but in any room you are joined or invited to as well": "The above, but in any room you are joined or invited to as well", + "The above, but in as well": "The above, but in as well", "Send %(eventType)s events as you in this room": "Send %(eventType)s events as you in this room", "See %(eventType)s events posted to this room": "See %(eventType)s events posted to this room", "Send %(eventType)s events as you in your active room": "Send %(eventType)s events as you in your active room", @@ -809,6 +808,7 @@ "Render LaTeX maths in messages": "Render LaTeX maths in messages", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "Message Pinning": "Message Pinning", + "Threaded messaging": "Threaded messaging", "Custom user status messages": "Custom user status messages", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", @@ -1807,6 +1807,7 @@ "%(count)s people|other": "%(count)s people", "%(count)s people|one": "%(count)s person", "Show files": "Show files", + "Show threads": "Show threads", "Share room": "Share room", "Room settings": "Room settings", "Trusted": "Trusted", @@ -1926,6 +1927,7 @@ "React": "React", "Edit": "Edit", "Reply": "Reply", + "Thread": "Thread", "Message Actions": "Message Actions", "Download %(text)s": "Download %(text)s", "Error decrypting attachment": "Error decrypting attachment", @@ -2412,8 +2414,8 @@ "Clear cache and resync": "Clear cache and resync", "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!": "%(brand)s now uses 3-5x less memory, by only loading information about other users when needed. Please wait whilst we resynchronise with the server!", "Updating %(brand)s": "Updating %(brand)s", - "Leave all rooms and spaces": "Leave all rooms and spaces", "Don't leave any": "Don't leave any", + "Leave all rooms and spaces": "Leave all rooms and spaces", "Leave specific rooms and spaces": "Leave specific rooms and spaces", "Search %(spaceName)s": "Search %(spaceName)s", "You won't be able to rejoin unless you are re-invited.": "You won't be able to rejoin unless you are re-invited.", @@ -3003,8 +3005,6 @@ "Commands": "Commands", "Command Autocomplete": "Command Autocomplete", "Community Autocomplete": "Community Autocomplete", - "Results from DuckDuckGo": "Results from DuckDuckGo", - "DuckDuckGo Results": "DuckDuckGo Results", "Emoji": "Emoji", "Emoji Autocomplete": "Emoji Autocomplete", "Notify the whole room": "Notify the whole room", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 28c5b1353f..a0261e9994 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -211,6 +211,15 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_thread": { + isFeature: true, + // Requires a reload as we change an option flag on the `js-sdk` + // And the entire sync history needs to be parsed again + controller: new ReloadOnChangeController(), + displayName: _td("Threaded messaging"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_custom_status": { isFeature: true, displayName: _td("Custom user status messages"), diff --git a/src/settings/handlers/DeviceSettingsHandler.ts b/src/settings/handlers/DeviceSettingsHandler.ts index e4ad4cb51e..e57862a824 100644 --- a/src/settings/handlers/DeviceSettingsHandler.ts +++ b/src/settings/handlers/DeviceSettingsHandler.ts @@ -71,7 +71,13 @@ export default class DeviceSettingsHandler extends SettingsHandler { // Special case for old useIRCLayout setting if (settingName === "layout") { const settings = this.getSettings() || {}; - if (settings["useIRCLayout"]) return Layout.IRC; + if (settings["useIRCLayout"]) { + // Set the new layout setting and delete the old one so that we + // can delete this block of code after some time + settings["layout"] = Layout.IRC; + delete settings["useIRCLayout"]; + localStorage.setItem("mx_local_settings", JSON.stringify(settings)); + } return settings[settingName]; } diff --git a/src/stores/RightPanelStorePhases.ts b/src/stores/RightPanelStorePhases.ts index d62f6c6110..96a585b676 100644 --- a/src/stores/RightPanelStorePhases.ts +++ b/src/stores/RightPanelStorePhases.ts @@ -37,6 +37,10 @@ export enum RightPanelPhases { SpaceMemberList = "SpaceMemberList", SpaceMemberInfo = "SpaceMemberInfo", Space3pidMemberInfo = "Space3pidMemberInfo", + + // Thread stuff + ThreadView = "ThreadView", + ThreadPanel = "ThreadPanel", } // These are the phases that are safe to persist (the ones that don't require additional diff --git a/src/stores/SpaceStore.tsx b/src/stores/SpaceStore.tsx index c08c66714b..8a7d51b60a 100644 --- a/src/stores/SpaceStore.tsx +++ b/src/stores/SpaceStore.tsx @@ -145,9 +145,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient { return this._allRoomsInHome; } - public async setActiveRoomInSpace(space: Room | null): Promise { + public setActiveRoomInSpace(space: Room | null): void { if (space && !space.isSpaceRoom()) return; - if (space !== this.activeSpace) await this.setActiveSpace(space); + if (space !== this.activeSpace) this.setActiveSpace(space); if (space) { const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications(); @@ -190,7 +190,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { * @param contextSwitch whether to switch the user's context, * should not be done when the space switch is done implicitly due to another event like switching room. */ - public async setActiveSpace(space: Room | null, contextSwitch = true) { + public setActiveSpace(space: Room | null, contextSwitch = true) { if (space === this.activeSpace || (space && !space.isSpaceRoom())) return; this._activeSpace = space; @@ -293,11 +293,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } if (space) { - const suggestedRooms = await this.fetchSuggestedRooms(space); - if (this._activeSpace === space) { - this._suggestedRooms = suggestedRooms; - this.emit(SUGGESTED_ROOMS, this._suggestedRooms); - } + this.loadSuggestedRooms(space); + } + } + + private async loadSuggestedRooms(space) { + const suggestedRooms = await this.fetchSuggestedRooms(space); + if (this._activeSpace === space) { + this._suggestedRooms = suggestedRooms; + this.emit(SUGGESTED_ROOMS, this._suggestedRooms); } } @@ -666,6 +670,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.onSpaceUpdate(); this.emit(room.roomId); } + + if (room === this.activeSpace && // current space + this.matrixClient.getRoom(ev.getStateKey())?.getMyMembership() !== "join" && // target not joined + ev.getPrevContent().suggested !== ev.getContent().suggested // suggested flag changed + ) { + this.loadSuggestedRooms(room); + } + break; case EventType.SpaceParent: @@ -678,12 +690,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient { } this.emit(room.roomId); break; + } + }; - case EventType.RoomMember: - if (room.isSpaceRoom()) { - this.onSpaceMembersChange(ev); - } - break; + // listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then + private onRoomStateMembers = (ev: MatrixEvent) => { + const room = this.matrixClient.getRoom(ev.getRoomId()); + if (room?.isSpaceRoom()) { + this.onSpaceMembersChange(ev); } }; @@ -743,6 +757,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("RoomState.events", this.onRoomState); + this.matrixClient.removeListener("RoomState.members", this.onRoomStateMembers); this.matrixClient.removeListener("accountData", this.onAccountData); } await this.reset(); @@ -754,6 +769,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient { this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("RoomState.events", this.onRoomState); + this.matrixClient.on("RoomState.members", this.onRoomStateMembers); this.matrixClient.on("accountData", this.onAccountData); this.matrixClient.getCapabilities().then(capabilities => { diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts index f47458d1b1..0da2c69eb8 100644 --- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts +++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts @@ -20,6 +20,27 @@ import { IAlgorithm } from "./IAlgorithm"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import * as Unread from "../../../../Unread"; import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership"; +import { EventType } from "matrix-js-sdk/src/@types/event"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +export function shouldCauseReorder(event: MatrixEvent): boolean { + const type = event.getType(); + const content = event.getContent(); + const prevContent = event.getPrevContent(); + + // Never ignore membership changes + if (type === EventType.RoomMember && prevContent.membership !== content.membership) return true; + + // Ignore status changes + // XXX: This should be an enum + if (type === "im.vector.user_status") return false; + // Ignore display name changes + if (type === EventType.RoomMember && prevContent.displayname !== content.displayname) return false; + // Ignore avatar changes + if (type === EventType.RoomMember && prevContent.avatar_url !== content.avatar_url) return false; + + return true; +} export const sortRooms = (rooms: Room[]): Room[] => { // We cache the timestamp lookup to avoid iterating forever on the timeline @@ -68,7 +89,10 @@ export const sortRooms = (rooms: Room[]): Room[] => { const ev = r.timeline[i]; if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) - if (ev.getSender() === myUserId || Unread.eventTriggersUnreadCount(ev)) { + if ( + (ev.getSender() === myUserId && shouldCauseReorder(ev)) || + Unread.eventTriggersUnreadCount(ev) + ) { return ev.getTs(); } } diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index daa1e0e787..d3c886d4b8 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020, 2021 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. @@ -55,6 +55,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { ELEMENT_CLIENT_ID } from "../../identifiers"; import { getUserLanguage } from "../../languageHandler"; import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables"; +import { arrayFastClone } from "../../utils/arrays"; // TODO: Destroy all of this code @@ -146,6 +147,7 @@ export class StopGapWidget extends EventEmitter { private scalarToken: string; private roomId?: string; private kind: WidgetKind; + private readUpToMap: {[roomId: string]: string} = {}; // room ID to event ID constructor(private appTileProps: IAppTileProps) { super(); @@ -294,6 +296,14 @@ export class StopGapWidget extends EventEmitter { this.messaging.transport.reply(ev.detail, {}); }); + // Populate the map of "read up to" events for this widget with the current event in every room. + // This is a bit inefficient, but should be okay. We do this for all rooms in case the widget + // requests timeline capabilities in other rooms down the road. It's just easier to manage here. + for (const room of MatrixClientPeg.get().getRooms()) { + // Timelines are most recent last + this.readUpToMap[room.roomId] = arrayFastClone(room.getLiveTimeline().getEvents()).reverse()[0].getId(); + } + // Attach listeners for feeding events - the underlying widget classes handle permissions for us MatrixClientPeg.get().on('event', this.onEvent); MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted); @@ -408,21 +418,56 @@ export class StopGapWidget extends EventEmitter { private onEvent = (ev: MatrixEvent) => { MatrixClientPeg.get().decryptEventIfNeeded(ev); if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; private onEventDecrypted = (ev: MatrixEvent) => { if (ev.isDecryptionFailure()) return; - if (ev.getRoomId() !== this.eventListenerRoomId) return; this.feedEvent(ev); }; private feedEvent(ev: MatrixEvent) { if (!this.messaging) return; + // Check to see if this event would be before or after our "read up to" marker. If it's + // before, or we can't decide, then we assume the widget will have already seen the event. + // If the event is after, or we don't have a marker for the room, then we'll send it through. + // + // This approach of "read up to" prevents widgets receiving decryption spam from startup or + // receiving out-of-order events from backfill and such. + const upToEventId = this.readUpToMap[ev.getRoomId()]; + if (upToEventId) { + // Small optimization for exact match (prevent search) + if (upToEventId === ev.getId()) { + return; + } + + let isBeforeMark = true; + + // Timelines are most recent last, so reverse the order and limit ourselves to 100 events + // to avoid overusing the CPU. + const timeline = MatrixClientPeg.get().getRoom(ev.getRoomId()).getLiveTimeline(); + const events = arrayFastClone(timeline.getEvents()).reverse().slice(0, 100); + + for (const timelineEvent of events) { + if (timelineEvent.getId() === upToEventId) { + break; + } else if (timelineEvent.getId() === ev.getId()) { + isBeforeMark = false; + break; + } + } + + if (isBeforeMark) { + // Ignore the event: it is before our interest. + return; + } + } + + this.readUpToMap[ev.getRoomId()] = ev.getId(); + const raw = ev.getEffectiveEvent(); - this.messaging.feedEvent(raw).catch(e => { + this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => { console.error("Error sending event to widget: ", e); }); } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 13cd260ef0..91a4cf6642 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright 2020 - 2021 The Matrix.org Foundation C.I.C. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,6 +23,7 @@ import { MatrixCapabilities, OpenIDRequestState, SimpleObservable, + Symbols, Widget, WidgetDriver, WidgetEventCapability, @@ -33,9 +34,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg"; import ActiveRoomObserver from "../../ActiveRoomObserver"; import Modal from "../../Modal"; import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; -import WidgetCapabilitiesPromptDialog, { - getRememberedCapabilitiesForWidget, -} from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; +import WidgetCapabilitiesPromptDialog from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog"; import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions"; import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore"; import { WidgetType } from "../../widgets/WidgetType"; @@ -44,10 +43,19 @@ import { CHAT_EFFECTS } from "../../effects"; import { containsEmoji } from "../../effects/utils"; import dis from "../../dispatcher/dispatcher"; import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks"; -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk"; // TODO: Purge this from the universe +function getRememberedCapabilitiesForWidget(widget: Widget): Capability[] { + return JSON.parse(localStorage.getItem(`widget_${widget.id}_approved_caps`) || "[]"); +} + +function setRememberedCapabilitiesForWidget(widget: Widget, caps: Capability[]) { + localStorage.setItem(`widget_${widget.id}_approved_caps`, JSON.stringify(caps)); +} + export class StopGapWidgetDriver extends WidgetDriver { private allowedCapabilities: Set; @@ -100,6 +108,7 @@ export class StopGapWidgetDriver extends WidgetDriver { } } // TODO: Do something when the widget requests new capabilities not yet asked for + let rememberApproved = false; if (missing.size > 0) { try { const [result] = await Modal.createTrackedDialog( @@ -111,17 +120,29 @@ export class StopGapWidgetDriver extends WidgetDriver { widgetKind: this.forWidgetKind, }).finished; (result.approved || []).forEach(cap => allowedSoFar.add(cap)); + rememberApproved = result.remember; } catch (e) { console.error("Non-fatal error getting capabilities: ", e); } } - return new Set(iterableUnion(allowedSoFar, requested)); + const allAllowed = new Set(iterableUnion(allowedSoFar, requested)); + + if (rememberApproved) { + setRememberedCapabilitiesForWidget(this.forWidget, Array.from(allAllowed)); + } + + return allAllowed; } - public async sendEvent(eventType: string, content: any, stateKey: string = null): Promise { + public async sendEvent( + eventType: string, + content: any, + stateKey: string = null, + targetRoomId: string = null, + ): Promise { const client = MatrixClientPeg.get(); - const roomId = ActiveRoomObserver.activeRoomId; + const roomId = targetRoomId || ActiveRoomObserver.activeRoomId; if (!client || !roomId) throw new Error("Not in a room or not attached to a client"); @@ -129,6 +150,9 @@ export class StopGapWidgetDriver extends WidgetDriver { if (stateKey !== null) { // state event r = await client.sendStateEvent(roomId, eventType, content, stateKey); + } else if (eventType === EventType.RoomRedaction) { + // special case: extract the `redacts` property and call redact + r = await client.redactEvent(roomId, content['redacts']); } else { // message event r = await client.sendEvent(roomId, eventType, content); @@ -145,48 +169,68 @@ export class StopGapWidgetDriver extends WidgetDriver { return { roomId, eventId: r.event_id }; } - public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise { - limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice - + private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] { const client = MatrixClientPeg.get(); - const roomId = ActiveRoomObserver.activeRoomId; - const room = client.getRoom(roomId); - if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + if (!client) throw new Error("Not attached to a client"); - const results: MatrixEvent[] = []; - const events = room.getLiveTimeline().getEvents(); // timelines are most recent last - for (let i = events.length - 1; i > 0; i--) { - if (results.length >= limit) break; - - const ev = events[i]; - if (ev.getType() !== eventType || ev.isState()) continue; - if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; - results.push(ev); - } - - return results.map(e => e.getEffectiveEvent()); + const targetRooms = roomIds + ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r))) + : [client.getRoom(ActiveRoomObserver.activeRoomId)]; + return targetRooms.filter(r => !!r); } - public async readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise { - limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice + public async readRoomEvents( + eventType: string, + msgtype: string | undefined, + limitPerRoom: number, + roomIds: (string | Symbols.AnyRoom)[] = null, + ): Promise { + limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary - const client = MatrixClientPeg.get(); - const roomId = ActiveRoomObserver.activeRoomId; - const room = client.getRoom(roomId); - if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client"); + const rooms = this.pickRooms(roomIds); + const allResults: IEvent[] = []; + for (const room of rooms) { + const results: MatrixEvent[] = []; + const events = room.getLiveTimeline().getEvents(); // timelines are most recent last + for (let i = events.length - 1; i > 0; i--) { + if (results.length >= limitPerRoom) break; - const results: MatrixEvent[] = []; - const state: Map = room.currentState.events.get(eventType); - if (state) { - if (stateKey === "" || !!stateKey) { - const forKey = state.get(stateKey); - if (forKey) results.push(forKey); - } else { - results.push(...Array.from(state.values())); + const ev = events[i]; + if (ev.getType() !== eventType || ev.isState()) continue; + if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue; + results.push(ev); } - } - return results.slice(0, limit).map(e => e.event); + results.forEach(e => allResults.push(e.getEffectiveEvent())); + } + return allResults; + } + + public async readStateEvents( + eventType: string, + stateKey: string | undefined, + limitPerRoom: number, + roomIds: (string | Symbols.AnyRoom)[] = null, + ): Promise { + limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary + + const rooms = this.pickRooms(roomIds); + const allResults: IEvent[] = []; + for (const room of rooms) { + const results: MatrixEvent[] = []; + const state: Map = room.currentState.events.get(eventType); + if (state) { + if (stateKey === "" || !!stateKey) { + const forKey = state.get(stateKey); + if (forKey) results.push(forKey); + } else { + results.push(...Array.from(state.values())); + } + } + + results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent())); + } + return allResults; } public async askOpenID(observer: SimpleObservable) { diff --git a/src/widgets/CapabilityText.tsx b/src/widgets/CapabilityText.tsx index 63e34eea7a..8c13a4b2fc 100644 --- a/src/widgets/CapabilityText.tsx +++ b/src/widgets/CapabilityText.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. @@ -14,11 +14,22 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { Capability, EventDirection, MatrixCapabilities, WidgetEventCapability, WidgetKind } from "matrix-widget-api"; +import { + Capability, + EventDirection, + getTimelineRoomIDFromCapability, + isTimelineCapability, + isTimelineCapabilityFor, + MatrixCapabilities, Symbols, + WidgetEventCapability, + WidgetKind, +} from "matrix-widget-api"; import { _t, _td, TranslatedString } from "../languageHandler"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities"; import React from "react"; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import TextWithTooltip from "../components/views/elements/TextWithTooltip"; type GENERIC_WIDGET_KIND = "generic"; // eslint-disable-line @typescript-eslint/naming-convention const GENERIC_WIDGET_KIND: GENERIC_WIDGET_KIND = "generic"; @@ -138,8 +149,31 @@ export class CapabilityText { if (textForKind[GENERIC_WIDGET_KIND]) return { primary: _t(textForKind[GENERIC_WIDGET_KIND]) }; // ... we'll fall through to the generic capability processing at the end of this - // function if we fail to locate a simple string and the capability isn't for an - // event. + // function if we fail to generate a string for the capability. + } + + // Try to handle timeline capabilities. The text here implies that the caller has sorted + // the timeline caps to the end for UI purposes. + if (isTimelineCapability(capability)) { + if (isTimelineCapabilityFor(capability, Symbols.AnyRoom)) { + return { primary: _t("The above, but in any room you are joined or invited to as well") }; + } else { + const roomId = getTimelineRoomIDFromCapability(capability); + const room = MatrixClientPeg.get().getRoom(roomId); + return { + primary: _t("The above, but in as well", {}, { + Room: () => { + if (room) { + return + { room.name } + ; + } else { + return { roomId }; + } + }, + }), + }; + } } // We didn't have a super simple line of text, so try processing the capability as the diff --git a/test/stores/SpaceStore-test.ts b/test/stores/SpaceStore-test.ts index 2e823aa72b..7cfd97b234 100644 --- a/test/stores/SpaceStore-test.ts +++ b/test/stores/SpaceStore-test.ts @@ -562,7 +562,7 @@ describe("SpaceStore", () => { ]); mkSpace(space3).getMyMembership.mockReturnValue("invite"); await run(); - await store.setActiveSpace(null); + store.setActiveSpace(null); expect(store.activeSpace).toBe(null); }); afterEach(() => { @@ -570,31 +570,31 @@ describe("SpaceStore", () => { }); it("switch to home space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); fn.mockClear(); - await store.setActiveSpace(null); + store.setActiveSpace(null); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null); expect(store.activeSpace).toBe(null); }); it("switch to invited space", async () => { const space = client.getRoom(space3); - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(space); }); it("switch to top level space", async () => { const space = client.getRoom(space1); - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(space); }); it("switch to subspace", async () => { const space = client.getRoom(space2); - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(space); }); @@ -602,7 +602,7 @@ describe("SpaceStore", () => { it("switch to unknown space is a nop", async () => { expect(store.activeSpace).toBe(null); const space = client.getRoom(room1); // not a space - await store.setActiveSpace(space); + store.setActiveSpace(space); expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(store.activeSpace).toBe(null); }); @@ -635,59 +635,59 @@ describe("SpaceStore", () => { }; it("last viewed room in target space is the current viewed and in both spaces", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); expect(getCurrentRoom()).toBe(room2); }); it("last viewed room in target space is in the current space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); expect(getCurrentRoom()).toBe(room2); }); it("last viewed room in target space is not in the current space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); viewRoom(room2); - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); expect(getCurrentRoom()).toBe(room1); }); it("last viewed room is target space is not known", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); localStorage.setItem(`mx_space_context_${space2}`, orphan2); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); }); it("last viewed room is target space is no longer in that space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); localStorage.setItem(`mx_space_context_${space2}`, room1); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); // Space home instead of room1 }); it("no last viewed room in target space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); - await store.setActiveSpace(client.getRoom(space2)); + store.setActiveSpace(client.getRoom(space2)); expect(getCurrentRoom()).toBe(space2); }); it("no last viewed room in home space", async () => { - await store.setActiveSpace(client.getRoom(space1)); + store.setActiveSpace(client.getRoom(space1)); viewRoom(room1); - await store.setActiveSpace(null); + store.setActiveSpace(null); expect(getCurrentRoom()).toBeNull(); // Home }); }); @@ -715,28 +715,28 @@ describe("SpaceStore", () => { it("no switch required, room is in current space", async () => { viewRoom(room1); - await store.setActiveSpace(client.getRoom(space1), false); + store.setActiveSpace(client.getRoom(space1), false); viewRoom(room2); expect(store.activeSpace).toBe(client.getRoom(space1)); }); it("switch to canonical parent space for room", async () => { viewRoom(room1); - await store.setActiveSpace(client.getRoom(space2), false); + store.setActiveSpace(client.getRoom(space2), false); viewRoom(room2); expect(store.activeSpace).toBe(client.getRoom(space2)); }); it("switch to first containing space for room", async () => { viewRoom(room2); - await store.setActiveSpace(client.getRoom(space2), false); + store.setActiveSpace(client.getRoom(space2), false); viewRoom(room3); expect(store.activeSpace).toBe(client.getRoom(space1)); }); it("switch to home for orphaned room", async () => { viewRoom(room1); - await store.setActiveSpace(client.getRoom(space1), false); + store.setActiveSpace(client.getRoom(space1), false); viewRoom(orphan1); expect(store.activeSpace).toBeNull(); }); @@ -744,7 +744,7 @@ describe("SpaceStore", () => { it("when switching rooms in the all rooms home space don't switch to related space", async () => { await setShowAllRooms(true); viewRoom(room2); - await store.setActiveSpace(null, false); + store.setActiveSpace(null, false); viewRoom(room1); expect(store.activeSpace).toBeNull(); }); diff --git a/test/stores/room-list/SpaceWatcher-test.ts b/test/stores/room-list/SpaceWatcher-test.ts index 85f79c75b6..474c279fdd 100644 --- a/test/stores/room-list/SpaceWatcher-test.ts +++ b/test/stores/room-list/SpaceWatcher-test.ts @@ -57,7 +57,7 @@ describe("SpaceWatcher", () => { beforeEach(async () => { filter = null; store.removeAllListeners(); - await store.setActiveSpace(null); + store.setActiveSpace(null); client.getVisibleRooms.mockReturnValue(rooms = []); space1 = mkSpace(space1Id); @@ -95,7 +95,7 @@ describe("SpaceWatcher", () => { await setShowAllRooms(true); new SpaceWatcher(mockRoomListStore); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); @@ -114,7 +114,7 @@ describe("SpaceWatcher", () => { await setShowAllRooms(false); new SpaceWatcher(mockRoomListStore); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); @@ -124,22 +124,22 @@ describe("SpaceWatcher", () => { await setShowAllRooms(true); new SpaceWatcher(mockRoomListStore); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); - await SpaceStore.instance.setActiveSpace(null); + SpaceStore.instance.setActiveSpace(null); expect(filter).toBeNull(); }); it("updates filter correctly for space -> home transition", async () => { await setShowAllRooms(false); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); - await SpaceStore.instance.setActiveSpace(null); + SpaceStore.instance.setActiveSpace(null); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(null); @@ -147,12 +147,12 @@ describe("SpaceWatcher", () => { it("updates filter correctly for space -> space transition", async () => { await setShowAllRooms(false); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space1); - await SpaceStore.instance.setActiveSpace(space2); + SpaceStore.instance.setActiveSpace(space2); expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter["space"]).toBe(space2); @@ -160,7 +160,7 @@ describe("SpaceWatcher", () => { it("doesn't change filter when changing showAllRooms mode to true", async () => { await setShowAllRooms(false); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); @@ -173,7 +173,7 @@ describe("SpaceWatcher", () => { it("doesn't change filter when changing showAllRooms mode to false", async () => { await setShowAllRooms(true); - await SpaceStore.instance.setActiveSpace(space1); + SpaceStore.instance.setActiveSpace(space1); new SpaceWatcher(mockRoomListStore); expect(filter).toBeInstanceOf(SpaceFilterCondition); diff --git a/yarn.lock b/yarn.lock index 80c6ac5155..f70f0e75c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2036,10 +2036,10 @@ ajv@^8.0.1: require-from-string "^2.0.2" uri-js "^4.2.2" -allchange@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.1.tgz#f32a75f65ab182d044d18e8baa43bd1c9be982f6" - integrity sha512-lj8HZcvQ04RsNqwLWjCYSDvchrW4nnjlOZ3z+VGhA78M7KootV0eRwlvTlYJec73jsz/Ts59kVArgooEsACOog== +allchange@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.2.tgz#86b9190e12b7ede4f230ae763cbd504c48fd907b" + integrity sha512-qJv1t2yvBThkes8g/dPMt8CGu+04U+q5QjCJn2Ngp92edZU8DJBfKGyGXo7w1iV48LVuQKQDfMsdIWhP7zHdlQ== dependencies: "@actions/core" "^1.4.0" "@actions/github" "^5.0.0" @@ -5792,8 +5792,8 @@ mathml-tag-names@^2.1.3: integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "12.3.1" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/92d822d494cc1179affbf1f14fc8635ed5595b3c" + version "12.4.0" + resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2783d162b77d6629c574f35e88bea9ae29765c34" dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" @@ -5827,10 +5827,10 @@ matrix-react-test-utils@^0.2.3: "@babel/traverse" "^7.13.17" walk "^2.3.14" -matrix-widget-api@^0.1.0-beta.15: - version "0.1.0-beta.15" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.15.tgz#b02511f93fe1a3634868b6e246d736107f182745" - integrity sha512-sWmtb8ZarSbHVbk5ni7IHBR9jOh7m1+5R4soky0fEO9VKl+MN7skT0+qNux3J9WuUAu2D80dZW9xPUT9cxfxbg== +matrix-widget-api@^0.1.0-beta.16: + version "0.1.0-beta.16" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.16.tgz#32655f05cab48239b97fe4111a1d0858f2aad61a" + integrity sha512-9zqaNLaM14YDHfFb7WGSUOivGOjYw+w5Su84ZfOl6A4IUy1xT9QPp0nsSA8wNfz0LpxOIPn3nuoF8Tn/40F5tg== dependencies: "@types/events" "^3.0.0" events "^3.2.0"