Merge branch 'develop' into gsouquet/compact-composer-18533

This commit is contained in:
Germain Souquet 2021-09-02 08:31:29 +01:00
commit e416952c90
61 changed files with 1009 additions and 367 deletions

View file

@ -63,6 +63,11 @@ module.exports = {
"@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/ban-ts-comment": "off",
}, },
}], }],
settings: {
react: {
version: "detect",
}
}
}; };
function buildRestrictedPropertiesOptions(properties, message) { function buildRestrictedPropertiesOptions(properties, message) {

View file

@ -10,6 +10,8 @@ on:
jobs: jobs:
end-to-end: end-to-end:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
PR_NUMBER: ${{github.event.number}}
container: vectorim/element-web-ci-e2etests-env:latest container: vectorim/element-web-ci-e2etests-env:latest
steps: steps:
- name: Checkout code - name: Checkout code

View file

@ -17,6 +17,7 @@ module.exports = {
"selector-list-comma-newline-after": null, "selector-list-comma-newline-after": null,
"at-rule-no-unknown": null, "at-rule-no-unknown": null,
"no-descending-specificity": null, "no-descending-specificity": null,
"no-empty-first-line": true,
"scss/at-rule-no-unknown": [true, { "scss/at-rule-no-unknown": [true, {
// https://github.com/vector-im/element-web/issues/10544 // https://github.com/vector-im/element-web/issues/10544
"ignoreAtRules": ["define-mixin"], "ignoreAtRules": ["define-mixin"],

View file

@ -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) Changes in [3.28.1](https://github.com/vector-im/element-desktop/releases/tag/v3.28.1) (2021-08-17)
=================================================================================================== ===================================================================================================

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.28.1", "version": "3.29.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -83,7 +83,7 @@
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "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", "minimist": "^1.2.5",
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",
"pako": "^2.0.3", "pako": "^2.0.3",
@ -151,7 +151,7 @@
"@typescript-eslint/eslint-plugin": "^4.17.0", "@typescript-eslint/eslint-plugin": "^4.17.0",
"@typescript-eslint/parser": "^4.17.0", "@typescript-eslint/parser": "^4.17.0",
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1",
"allchange": "^1.0.0", "allchange": "^1.0.2",
"babel-jest": "^26.6.3", "babel-jest": "^26.6.3",
"chokidar": "^3.5.1", "chokidar": "^3.5.1",
"concurrently": "^5.3.0", "concurrently": "^5.3.0",

View file

@ -381,11 +381,6 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
font-size: $font-14px; font-size: $font-14px;
color: $primary-content; color: $primary-content;
word-wrap: break-word; word-wrap: break-word;
a {
color: $accent-color;
cursor: pointer;
}
} }
.mx_Dialog_buttons { .mx_Dialog_buttons {

View file

@ -132,6 +132,7 @@
@import "./views/elements/_EditableItemList.scss"; @import "./views/elements/_EditableItemList.scss";
@import "./views/elements/_ErrorBoundary.scss"; @import "./views/elements/_ErrorBoundary.scss";
@import "./views/elements/_EventListSummary.scss"; @import "./views/elements/_EventListSummary.scss";
@import "./views/elements/_EventTilePreview.scss";
@import "./views/elements/_FacePile.scss"; @import "./views/elements/_FacePile.scss";
@import "./views/elements/_Field.scss"; @import "./views/elements/_Field.scss";
@import "./views/elements/_ImageView.scss"; @import "./views/elements/_ImageView.scss";

View file

@ -15,8 +15,6 @@ limitations under the License.
*/ */
.mx_ScrollPanel { .mx_ScrollPanel {
contain: strict;
.mx_RoomView_MessageList { .mx_RoomView_MessageList {
position: relative; position: relative;
display: flex; display: flex;

View file

@ -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;
}
}

View file

@ -92,6 +92,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/room/message-bar/reply.svg'); 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 { .mx_MessageActionBar_editButton::after {
mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg'); mask-image: url('$(res)/img/element-icons/room/message-bar/edit.svg');
} }

View file

@ -232,6 +232,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/room/files.svg'); 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 { .mx_RoomSummaryCard_icon_share::before {
mask-image: url('$(res)/img/element-icons/room/share.svg'); mask-image: url('$(res)/img/element-icons/room/share.svg');
} }

View file

@ -10,6 +10,7 @@
max-height: 35vh; max-height: 35vh;
overflow: clip; overflow: clip;
display: flex; display: flex;
flex-direction: column;
box-shadow: 0px -16px 32px $composer-shadow-color; box-shadow: 0px -16px 32px $composer-shadow-color;
} }
@ -20,7 +21,7 @@
/* a "block" completion takes up a whole line */ /* a "block" completion takes up a whole line */
.mx_Autocomplete_Completion_block { .mx_Autocomplete_Completion_block {
height: 34px; min-height: 34px;
display: flex; display: flex;
padding: 0 12px; padding: 0 12px;
user-select: none; user-select: none;

View file

@ -480,7 +480,7 @@ $hover-select-border: 4px;
} }
pre code > * { pre code > * {
display: inline-block; display: inline;
} }
pre { pre {
@ -514,7 +514,7 @@ $hover-select-border: 4px;
.mx_EventTile:hover .mx_EventTile_body pre, .mx_EventTile:hover .mx_EventTile_body pre,
.mx_EventTile.focus-visible:focus-within .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 { .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 // Remove some of the default tile padding so that the error is centered
margin-right: 0; margin-right: 0;
.mx_EventTile_line { .mx_EventTile_line {
padding-left: 0; padding-left: 0;
margin-right: 0; margin-right: 0;
@ -674,3 +675,62 @@ $hover-select-border: 4px;
margin-right: 0; 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;
}
}

View file

@ -349,3 +349,19 @@ limitations under the License.
height: 50px; 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;
}
}

View file

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 8V8C1.89543 8 1 7.10457 1 6V3C1 1.89543 1.89543 1 3 1H15C16.1046 1 17 1.89484 17 2.9994C17 3.88147 17 4.95392 17 6.00008C17 7.10465 16.1046 8 15 8H10.5" stroke="#737D8C" stroke-width="1.5" stroke-linecap="round"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.2011 16.7176C12.9087 17.011 12.9088 17.4866 13.2012 17.78C13.4936 18.0734 13.9677 18.0733 14.2601 17.78C14.9484 17.0894 15.6519 16.3829 16.1834 15.8491L16.8282 15.2014L17.0099 15.0188L17.0579 14.9706L17.0702 14.9582L17.0733 14.955L17.0741 14.9542L17.0743 14.954L17.0743 14.954L16.5444 14.4233L17.0744 14.954C17.3663 14.6606 17.3661 14.1855 17.0741 13.8922L14.2539 11.061C13.9616 10.7675 13.4875 10.7674 13.195 11.0606C12.9024 11.3539 12.9023 11.8295 13.1946 12.123L14.7442 13.6787L10.1137 13.6787C8.69795 13.6787 7.49996 12.4759 7.49996 10.9288L7.49996 7.00002C7.49996 6.58581 7.16417 6.25002 6.74996 6.25002C6.33574 6.25002 5.99996 6.58581 5.99996 7.00002L5.99996 10.9288C5.99996 13.2476 7.81395 15.1787 10.1137 15.1787H14.7341C14.2713 15.6436 13.7316 16.1854 13.2011 16.7176Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -10,6 +10,7 @@ defbranch="$3"
rm -r "$defrepo" || true rm -r "$defrepo" || true
# A function that clones a branch of a repo based on the org, repo and branch
clone() { clone() {
org=$1 org=$1
repo=$2 repo=$2
@ -21,45 +22,38 @@ clone() {
fi fi
} }
# Try the PR author's branch in case it exists on the deps as well. # A function that gets info about a PR from the GitHub API based on its number
# First we check if GITHUB_HEAD_REF is defined, getPRInfo() {
# Then we check if BUILDKITE_BRANCH is defined, number=$1
# if they aren't we can assume this is a Netlify build if [ -n "$number" ]; then
if [ -n "$GITHUB_HEAD_REF" ]; then echo "Getting info about a PR with number $number"
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
# If head is set, it will contain on Buildkite either: apiEndpoint="https://api.github.com/repos/matrix-org/matrix-react-sdk/pulls/"
# * "branch" when the author's branch and target branch are in the same repo apiEndpoint+=$number
# * "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
if [ -n "$GITHUB_HEAD_REF" ]; then head=$(curl $apiEndpoint | jq -r '.head.label')
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
fi fi
}
elif [[ "${#BRANCH_ARRAY[@]}" == "2" ]]; then # Some CIs don't give us enough info, so we just get the PR number and ask the
clone ${BRANCH_ARRAY[0]} $defrepo ${BRANCH_ARRAY[1]} # 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 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. # Try the target branch of the push or PR.
if [ -n $GITHUB_BASE_REF ]; then if [ -n $GITHUB_BASE_REF ]; then
clone $deforg $defrepo $GITHUB_BASE_REF clone $deforg $defrepo $GITHUB_BASE_REF

View file

@ -250,7 +250,15 @@ export default class CallHandler extends EventEmitter {
* @returns {boolean} * @returns {boolean}
*/ */
private areAnyCallsUnsilenced(): 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) { private async checkProtocols(maxTries) {
@ -878,6 +886,8 @@ export default class CallHandler extends EventEmitter {
break; break;
case 'hangup': case 'hangup':
case 'reject': case 'reject':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.get(payload.room_id)) { if (!this.calls.get(payload.room_id)) {
return; // no call to hangup return; // no call to hangup
} }
@ -890,11 +900,15 @@ export default class CallHandler extends EventEmitter {
// the hangup event away) // the hangup event away)
break; break;
case 'hangup_all': case 'hangup_all':
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
for (const call of this.calls.values()) { for (const call of this.calls.values()) {
call.hangup(CallErrorCode.UserHangup, false); call.hangup(CallErrorCode.UserHangup, false);
} }
break; break;
case 'answer': { case 'answer': {
this.stopRingingIfPossible(this.calls.get(payload.room_id).callId);
if (!this.calls.has(payload.room_id)) { if (!this.calls.has(payload.room_id)) {
return; // no call to answer 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) { private async dialNumber(number: string) {
const results = await this.pstnLookup(number); const results = await this.pstnLookup(number);
if (!results || results.length === 0 || !results[0].userid) { if (!results || results.length === 0 || !results[0].userid) {

View file

@ -213,6 +213,7 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.pendingEventOrdering = PendingEventOrdering.Detached;
opts.lazyLoadMembers = true; opts.lazyLoadMembers = true;
opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours
opts.experimentalThreadSupport = SettingsStore.getValue("feature_thread");
// Connect the matrix client to the dispatcher and setting handlers // Connect the matrix client to the dispatcher and setting handlers
MatrixActionCreators.start(this.matrixClient); MatrixActionCreators.start(this.matrixClient);

View file

@ -50,7 +50,6 @@ import CallHandler from "./CallHandler";
import { guessAndSetDMRoom } from "./Rooms"; import { guessAndSetDMRoom } from "./Rooms";
import { upgradeRoom } from './utils/RoomUpgrade'; import { upgradeRoom } from './utils/RoomUpgrade';
import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog'; import UploadConfirmDialog from './components/views/dialogs/UploadConfirmDialog';
import ErrorDialog from './components/views/dialogs/ErrorDialog';
import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog'; import DevtoolsDialog from './components/views/dialogs/DevtoolsDialog';
import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog"; import RoomUpgradeWarningDialog from "./components/views/dialogs/RoomUpgradeWarningDialog";
import InfoDialog from "./components/views/dialogs/InfoDialog"; import InfoDialog from "./components/views/dialogs/InfoDialog";
@ -245,21 +244,6 @@ export const Commands = [
}, },
category: CommandCategories.messages, category: CommandCategories.messages,
}), }),
new Command({
command: 'ddg',
args: '<query>',
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({ new Command({
command: 'upgraderoom', command: 'upgraderoom',
args: '<new_version>', args: '<new_version>',

View file

@ -45,7 +45,13 @@ class MxVoiceWorklet extends AudioWorkletProcessor {
process(inputs, outputs, parameters) { process(inputs, outputs, parameters) {
const currentSecond = roundTimeToTargetFreq(currentTime); 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 // We're expecting exactly one mono input source, so just grab the very first frame of
// samples for the analysis. // samples for the analysis.
const monoChan = inputs[0][0]; const monoChan = inputs[0][0];

View file

@ -20,7 +20,6 @@ import { Room } from 'matrix-js-sdk/src/models/room';
import CommandProvider from './CommandProvider'; import CommandProvider from './CommandProvider';
import CommunityProvider from './CommunityProvider'; import CommunityProvider from './CommunityProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider'; import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider'; import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider'; import EmojiProvider from './EmojiProvider';
@ -55,7 +54,6 @@ const PROVIDERS = [
EmojiProvider, EmojiProvider,
NotifProvider, NotifProvider,
CommandProvider, CommandProvider,
DuckDuckGoProvider,
]; ];
if (SpaceStore.spacesEnabled) { if (SpaceStore.spacesEnabled) {

View file

@ -53,7 +53,7 @@ export default class CommandProvider extends AutocompleteProvider {
// The input looks like a command with arguments, perform exact match // The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/` const name = command[1].substr(1); // strip leading `/`
if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) { 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 []; if (CommandMap.get(name).hideCompletionAfterSpace) return [];
matches = [CommandMap.get(name)]; matches = [CommandMap.get(name)];
} }
@ -95,7 +95,7 @@ export default class CommandProvider extends AutocompleteProvider {
renderCompletions(completions: React.ReactNode[]): React.ReactNode { renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return ( return (
<div <div
className="mx_Autocomplete_Completion_container_block" className="mx_Autocomplete_Completion_container_pill"
role="presentation" role="presentation"
aria-label={_t("Command Autocomplete")} aria-label={_t("Command Autocomplete")}
> >

View file

@ -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<ICompletion[]> {
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: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
}
getName() {
return '🔍 ' + _t('Results from DuckDuckGo');
}
renderCompletions(completions: React.ReactNode[]): React.ReactNode {
return (
<div
className="mx_Autocomplete_Completion_container_block"
role="presentation"
aria-label={_t("DuckDuckGo Results")}
>
{ completions }
</div>
);
}
}

View file

@ -115,7 +115,7 @@ const LeftPanelWidget: React.FC = () => {
aria-expanded={expanded} aria-expanded={expanded}
aria-level={1} aria-level={1}
onClick={() => { onClick={() => {
setExpanded(e => !e); setExpanded(!expanded);
}} }}
> >
<span className={classNames({ <span className={classNames({

View file

@ -1016,6 +1016,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setStateForNewView({ this.setStateForNewView({
view: Views.LOGGED_IN, view: Views.LOGGED_IN,
justRegistered, justRegistered,
currentRoomId: null,
}); });
this.setPage(PageTypes.HomePage); this.setPage(PageTypes.HomePage);
this.notifyNewScreen('home'); this.notifyNewScreen('home');

View file

@ -173,6 +173,8 @@ interface IProps {
onUnfillRequest?(backwards: boolean, scrollToken: string): void; onUnfillRequest?(backwards: boolean, scrollToken: string): void;
getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations; getRelationsForEvent?(eventId: string, relationType: string, eventType: string): Relations;
hideThreadedMessages?: boolean;
} }
interface IState { interface IState {
@ -265,6 +267,9 @@ export default class MessagePanel extends React.Component<IProps, IState> {
componentDidMount() { componentDidMount() {
this.calculateRoomMembersCount(); this.calculateRoomMembersCount();
this.props.room?.on("RoomState.members", 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; this.isMounted = true;
} }
@ -443,6 +448,12 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// Always show highlighted event // Always show highlighted event
if (this.props.highlightedEventId === mxEv.getId()) return true; 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); return !shouldHideEvent(mxEv, this.context);
} }

View file

@ -45,17 +45,21 @@ import GroupRoomInfo from "../views/groups/GroupRoomInfo";
import UserInfo from "../views/right_panel/UserInfo"; import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel"; import FilePanel from "./FilePanel";
import ThreadView from "./ThreadView";
import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel"; import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard"; import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import SpaceStore from "../../stores/SpaceStore"; import SpaceStore from "../../stores/SpaceStore";
import { RoomPermalinkCreator } from '../../utils/permalinks/Permalinks';
interface IProps { interface IProps {
room?: Room; // if showing panels for a given room, this is set room?: Room; // if showing panels for a given room, this is set
groupId?: string; // if showing panels for a given group, 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 user?: User; // used if we know the user ahead of opening the panel
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
permalinkCreator?: RoomPermalinkCreator;
} }
interface IState { interface IState {
@ -309,6 +313,22 @@ export default class RightPanel extends React.Component<IProps, IState> {
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />; panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break; break;
case RightPanelPhases.ThreadView:
panel = <ThreadView
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose}
mxEvent={this.state.event}
permalinkCreator={this.props.permalinkCreator} />;
break;
case RightPanelPhases.ThreadPanel:
panel = <ThreadPanel
roomId={roomId}
resizeNotifier={this.props.resizeNotifier}
onClose={this.onClose} />;
break;
case RightPanelPhases.RoomSummary: case RightPanelPhases.RoomSummary:
panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />; panel = <RoomSummaryCard room={this.props.room} onClose={this.onClose} />;
break; break;

View file

@ -2052,7 +2052,10 @@ export default class RoomView extends React.Component<IProps, IState> {
const showRightPanel = this.state.room && this.state.showRightPanel; const showRightPanel = this.state.room && this.state.showRightPanel;
const rightPanel = showRightPanel const rightPanel = showRightPanel
? <RightPanel room={this.state.room} resizeNotifier={this.props.resizeNotifier} /> ? <RightPanel
room={this.state.room}
resizeNotifier={this.props.resizeNotifier}
permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} />
: null; : null;
const timelineClasses = classNames("mx_RoomView_timeline", { const timelineClasses = classNames("mx_RoomView_timeline", {

View file

@ -16,7 +16,7 @@ limitations under the License.
import React, { RefObject, useContext, useRef, useState } from "react"; import React, { RefObject, useContext, useRef, useState } from "react";
import { EventType } from "matrix-js-sdk/src/@types/event"; 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 { Room } from "matrix-js-sdk/src/models/room";
import { EventSubscription } from "fbemitter"; import { EventSubscription } from "fbemitter";
@ -505,11 +505,12 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setError(""); setError("");
setBusy(true); setBusy(true);
try { try {
const isPublic = space.getJoinRule() === JoinRule.Public;
const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean); const filteredRoomNames = roomNames.map(name => name.trim()).filter(Boolean);
await Promise.all(filteredRoomNames.map(name => { await Promise.all(filteredRoomNames.map(name => {
return createRoom({ return createRoom({
createOpts: { createOpts: {
preset: space.getJoinRule() === "public" ? Preset.PublicChat : Preset.PrivateChat, preset: isPublic ? Preset.PublicChat : Preset.PrivateChat,
name, name,
}, },
spinner: false, spinner: false,
@ -517,6 +518,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
andView: false, andView: false,
inlineErrors: true, inlineErrors: true,
parentSpace: space, parentSpace: space,
joinRule: !isPublic ? JoinRule.Restricted : undefined,
}); });
})); }));
onFinished(filteredRoomNames.length > 0); onFinished(filteredRoomNames.length > 0);

View file

@ -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<IProps, IState> {
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 <EventTile
key={event.getId()}
mxEvent={event}
enableFlair={false}
showReadReceipts={false}
as="div"
/>;
}
public render(): JSX.Element {
return (
<BaseCard
className="mx_ThreadPanel"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
>
{
this.state?.threads.map((thread: Thread) => {
if (thread.ready) {
return this.renderEventTile(thread.rootEvent);
}
})
}
</BaseCard>
);
}
}

View file

@ -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<IProps, IState> {
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<SetRightPanelPhasePayload>({
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 (
<BaseCard
className="mx_ThreadView"
onClose={this.props.onClose}
previousPhase={RightPanelPhases.RoomSummary}
withoutScrollContainer={true}
>
{ this.state.thread && (
<TimelinePanel
manageReadReceipts={false}
manageReadMarkers={false}
timelineSet={this.state?.thread?.timelineSet}
showUrlPreview={false}
tileShape={TileShape.Notif}
empty={<div>empty</div>}
alwaysShowTimestamps={true}
layout={Layout.Group}
hideThreadedMessages={false}
/>
) }
<MessageComposer
room={this.props.room}
resizeNotifier={this.props.resizeNotifier}
replyToEvent={this.state?.thread?.replyToEvent}
showReplyPreview={false}
permalinkCreator={this.props.permalinkCreator}
compact={true}
/>
</BaseCard>
);
}
}

View file

@ -126,6 +126,8 @@ interface IProps {
// callback which is called when we wish to paginate the timeline window. // callback which is called when we wish to paginate the timeline window.
onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>; onPaginationRequest?(timelineWindow: TimelineWindow, direction: string, size: number): Promise<boolean>;
hideThreadedMessages?: boolean;
} }
interface IState { interface IState {
@ -214,6 +216,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
timelineCap: Number.MAX_VALUE, timelineCap: Number.MAX_VALUE,
className: 'mx_RoomView_messagePanel', className: 'mx_RoomView_messagePanel',
sendReadReceiptOnLoad: true, sendReadReceiptOnLoad: true,
hideThreadedMessages: true,
}; };
private lastRRSentEventId: string = undefined; private lastRRSentEventId: string = undefined;
@ -1511,6 +1514,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
showReactions={this.props.showReactions} showReactions={this.props.showReactions}
layout={this.props.layout} layout={this.props.layout}
enableFlair={SettingsStore.getValue(UIFeature.Flair)} enableFlair={SettingsStore.getValue(UIFeature.Flair)}
hideThreadedMessages={this.props.hideThreadedMessages}
/> />
); );
} }

View file

@ -79,7 +79,7 @@ const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick
} }
try { try {
await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace }); await createSpace(name, joinRule === JoinRule.Public, alias, topic, avatar, {}, { parentSpace, joinRule });
onFinished(true); onFinished(true);
} catch (e) { } catch (e) {

View file

@ -80,7 +80,7 @@ const SpaceChildPicker = ({ filterPlaceholder, rooms, selected, onChange }) => {
const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => { const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave }) => {
const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]); const selected = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
const [state, setState] = useState<string>(RoomsToLeave.All); const [state, setState] = useState<string>(RoomsToLeave.None);
useEffect(() => { useEffect(() => {
if (state === RoomsToLeave.All) { if (state === RoomsToLeave.All) {
@ -97,11 +97,11 @@ const LeaveRoomsPicker = ({ space, spaceChildren, roomsToLeave, setRoomsToLeave
onChange={setState} onChange={setState}
definitions={[ definitions={[
{ {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, {
value: RoomsToLeave.None, value: RoomsToLeave.None,
label: _t("Don't leave any"), label: _t("Don't leave any"),
}, {
value: RoomsToLeave.All,
label: _t("Leave all rooms and spaces"),
}, { }, {
value: RoomsToLeave.Specific, value: RoomsToLeave.Specific,
label: _t("Leave specific rooms and spaces"), label: _t("Leave specific rooms and spaces"),

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 { IDialogProps } from "./IDialogProps";
import { import {
Capability, Capability,
isTimelineCapability,
Widget, Widget,
WidgetEventCapability, WidgetEventCapability,
WidgetKind, WidgetKind,
@ -30,14 +31,7 @@ import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText"; import { CapabilityText } from "../../../widgets/CapabilityText";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { lexicographicCompare } from "matrix-js-sdk/src/utils";
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));
}
interface IProps extends IDialogProps { interface IProps extends IDialogProps {
requestedCapabilities: Set<Capability>; requestedCapabilities: Set<Capability>;
@ -95,14 +89,24 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
}; };
private closeAndTryRemember(approved: Capability[]) { private closeAndTryRemember(approved: Capability[]) {
if (this.state.rememberSelection) { this.props.onFinished({ approved, remember: this.state.rememberSelection });
setRememberedCapabilitiesForWidget(this.props.widget, approved);
}
this.props.onFinished({ approved });
} }
public render() { 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 text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline const byline = text.byline
? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{ text.byline }</span> ? <span className="mx_WidgetCapabilitiesPromptDialog_byline">{ text.byline }</span>

View file

@ -25,6 +25,7 @@ import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout"; import { Layout } from "../../../settings/Layout";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Spinner from './Spinner';
interface IProps { interface IProps {
/** /**
@ -45,7 +46,7 @@ interface IProps {
/** /**
* The ID of the displayed user * The ID of the displayed user
*/ */
userId: string; userId?: string;
/** /**
* The display name of the displayed user * The display name of the displayed user
@ -118,13 +119,16 @@ export default class EventTilePreview extends React.Component<IProps, IState> {
} }
public render() { public render() {
const event = this.fakeEvent(this.state);
const className = classnames(this.props.className, { const className = classnames(this.props.className, {
"mx_IRCLayout": this.props.layout == Layout.IRC, "mx_IRCLayout": this.props.layout == Layout.IRC,
"mx_GroupLayout": this.props.layout == Layout.Group, "mx_GroupLayout": this.props.layout == Layout.Group,
"mx_EventTilePreview_loader": !this.props.userId,
}); });
if (!this.props.userId) return <div className={className}><Spinner /></div>;
const event = this.fakeEvent(this.state);
return <div className={className}> return <div className={className}>
<EventTile <EventTile
mxEvent={event} mxEvent={event}

View file

@ -419,6 +419,7 @@ export default class ImageView extends React.Component<IProps, IState> {
const avatar = ( const avatar = (
<MemberAvatar <MemberAvatar
member={mxEvent.sender} member={mxEvent.sender}
fallbackUserId={mxEvent.getSender()}
width={32} width={32}
height={32} height={32}
viewUserOnClick={true} viewUserOnClick={true}

View file

@ -16,11 +16,13 @@ limitations under the License.
import classNames from "classnames"; import classNames from "classnames";
import React from "react"; import React from "react";
import { sanitizedHtmlNode } from "../../../HtmlUtils";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
reason: string; reason: string;
htmlReason?: string;
} }
interface IState { interface IState {
@ -51,7 +53,7 @@ export default class InviteReason extends React.PureComponent<IProps, IState> {
}); });
return <div className={classes}> return <div className={classes}>
<div className="mx_InviteReason_reason">{ this.props.reason }</div> <div className="mx_InviteReason_reason">{ this.props.htmlReason ? sanitizedHtmlNode(this.props.htmlReason) : this.props.reason }</div>
<div className="mx_InviteReason_view" <div className="mx_InviteReason_view"
onClick={this.onViewClick} onClick={this.onViewClick}
> >

View file

@ -173,16 +173,16 @@ class EmojiPicker extends React.Component<IProps, IState> {
}; };
private onChangeFilter = (filter: string) => { 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) { for (const cat of this.categories) {
let emojis; let emojis;
// If the new filter string includes the old filter string, we don't have to re-filter the whole dataset. // 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]; emojis = this.memoizedDataByCategory[cat.id];
} else { } else {
emojis = cat.id === "recent" ? this.recentlyUsed : DATA_BY_CATEGORY[cat.id]; 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; this.memoizedDataByCategory[cat.id] = emojis;
cat.enabled = emojis.length > 0; cat.enabled = emojis.length > 0;
// The setState below doesn't re-render the header and we already have the refs for updateVisibility, so... // 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<IProps, IState> {
setTimeout(this.updateVisibility, 0); setTimeout(this.updateVisibility, 0);
}; };
private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => private emojiMatchesFilter = (emoji: IEmoji, filter: string): boolean => {
[emoji.annotation, ...emoji.shortcodes, emoji.emoticon, ...emoji.unicode.split(ZERO_WIDTH_JOINER)] return emoji.annotation.toLowerCase().includes(filter) ||
.some(x => x?.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 = () => { private onEnterFilter = () => {
const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item"); const btn = this.bodyRef.current.querySelector<HTMLButtonElement>(".mx_EmojiPicker_item");

View file

@ -23,6 +23,8 @@ import { EventStatus } from 'matrix-js-sdk/src/models/event';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Action } from '../../../dispatcher/actions';
import { RightPanelPhases } from '../../../stores/RightPanelStorePhases';
import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu'; import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from '../../structures/ContextMenu';
import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils';
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
@ -34,6 +36,7 @@ import Resend from "../../../Resend";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import DownloadActionButton from "./DownloadActionButton"; import DownloadActionButton from "./DownloadActionButton";
import SettingsStore from '../../../settings/SettingsStore';
const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => { const OptionsButton = ({ mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange }) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); 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) => { onEditClick = (ev) => {
dis.dispatch({ dis.dispatch({
action: 'edit_event', 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 // 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()`. // button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) { if (this.context.canReply) {
toolbarOpts.splice(0, 0, <RovingAccessibleTooltipButton toolbarOpts.splice(0, 0, <>
className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton" <RovingAccessibleTooltipButton
title={_t("Reply")} className="mx_MessageActionBar_maskButton mx_MessageActionBar_replyButton"
onClick={this.onReplyClick} title={_t("Reply")}
key="reply" onClick={this.onReplyClick}
/>); key="reply"
/>
{ SettingsStore.getValue("feature_thread") && (
<RovingAccessibleTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_threadButton"
title={_t("Thread")}
onClick={this.onThreadClick}
key="thread"
/>
) }
</>);
} }
if (this.context.canReact) { if (this.context.canReact) {
toolbarOpts.splice(0, 0, <ReactButton toolbarOpts.splice(0, 0, <ReactButton

View file

@ -220,6 +220,13 @@ const onRoomFilesClick = () => {
}); });
}; };
const onRoomThreadsClick = () => {
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadPanel,
});
};
const onRoomSettingsClick = () => { const onRoomSettingsClick = () => {
defaultDispatcher.dispatch({ action: "open_room_settings" }); defaultDispatcher.dispatch({ action: "open_room_settings" });
}; };
@ -273,6 +280,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
<Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}> <Button className="mx_RoomSummaryCard_icon_files" onClick={onRoomFilesClick}>
{ _t("Show files") } { _t("Show files") }
</Button> </Button>
{ SettingsStore.getValue("feature_thread") && (
<Button className="mx_RoomSummaryCard_icon_threads" onClick={onRoomThreadsClick}>
{ _t("Show threads") }
</Button>
) }
<Button className="mx_RoomSummaryCard_icon_share" onClick={onShareRoomClick}> <Button className="mx_RoomSummaryCard_icon_share" onClick={onShareRoomClick}>
{ _t("Share room") } { _t("Share room") }
</Button> </Button>

View file

@ -21,6 +21,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations"; import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; 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 ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -55,6 +56,8 @@ import ReadReceiptMarker from "./ReadReceiptMarker";
import MessageActionBar from "../messages/MessageActionBar"; import MessageActionBar from "../messages/MessageActionBar";
import ReactionsRow from '../messages/ReactionsRow'; import ReactionsRow from '../messages/ReactionsRow';
import { getEventDisplayInfo } from '../../../utils/EventUtils'; import { getEventDisplayInfo } from '../../../utils/EventUtils';
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import SettingsStore from "../../../settings/SettingsStore";
const eventTileTypes = { const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent', [EventType.RoomMessage]: 'messages.MessageEvent',
@ -299,6 +302,9 @@ interface IProps {
// whether or not to display the sender // whether or not to display the sender
hideSender?: boolean; hideSender?: boolean;
// whether or not to display thread info
showThreadInfo?: boolean;
} }
interface IState { interface IState {
@ -315,6 +321,8 @@ interface IState {
reactions: Relations; reactions: Relations;
hover: boolean; hover: boolean;
thread?: Thread;
} }
@replaceableComponent("views.rooms.EventTile") @replaceableComponent("views.rooms.EventTile")
@ -351,6 +359,8 @@ export default class EventTile extends React.Component<IProps, IState> {
reactions: this.getReactions(), reactions: this.getReactions(),
hover: false, hover: false,
thread: this.props.mxEvent?.getThread(),
}; };
// don't do RR animations until we are mounted // don't do RR animations until we are mounted
@ -451,8 +461,20 @@ export default class EventTile extends React.Component<IProps, IState> {
client.on("Room.receipt", this.onRoomReceipt); client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true; 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 // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line // eslint-disable-next-line
UNSAFE_componentWillReceiveProps(nextProps) { UNSAFE_componentWillReceiveProps(nextProps) {
@ -463,7 +485,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState, nextContext) {
if (objectHasDiff(this.state, nextState)) { if (objectHasDiff(this.state, nextState)) {
return true; return true;
} }
@ -491,6 +513,43 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
} }
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 <MemberAvatar key={member.userId} member={member} width={14} height={14} />;
});
return (
<div
className="mx_ThreadInfo"
onClick={() => {
dis.dispatch({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.ThreadView,
refireParams: {
event: this.props.mxEvent,
},
});
}}
>
<span className="mx_EventListSummary_avatars">
{ avatars }
</span>
{ thread.length - 1 } { thread.length === 2 ? 'reply' : 'replies' }
</div>
);
}
private onRoomReceipt = (ev, room) => { private onRoomReceipt = (ev, room) => {
// ignore events for other rooms // ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
@ -1180,6 +1239,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo } { keyRequestInfo }
{ actionBar } { actionBar }
{ this.props.layout === Layout.IRC && (reactionsRow) } { this.props.layout === Layout.IRC && (reactionsRow) }
{ this.renderThreadInfo() }
</div> </div>
{ this.props.layout !== Layout.IRC && (reactionsRow) } { this.props.layout !== Layout.IRC && (reactionsRow) }
{ msgOption } { msgOption }

View file

@ -185,8 +185,8 @@ export default class MemberList extends React.Component<IProps, IState> {
return { return {
loading: false, loading: false,
members: members, members: members,
filteredJoinedMembers: this.filterMembers(members, 'join'), filteredJoinedMembers: this.filterMembers(members, 'join', searchQuery),
filteredInvitedMembers: this.filterMembers(members, 'invite'), filteredInvitedMembers: this.filterMembers(members, 'invite', searchQuery),
canInvite: this.canInvite, canInvite: this.canInvite,
// ideally we'd size this to the page height, but // ideally we'd size this to the page height, but

View file

@ -192,7 +192,9 @@ interface IProps {
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent; replyToEvent?: MatrixEvent;
showReplyPreview?: boolean;
e2eStatus?: E2EStatus; e2eStatus?: E2EStatus;
compact?: boolean;
} }
interface IState { interface IState {
@ -214,6 +216,11 @@ export default class MessageComposer extends React.Component<IProps, IState> {
private voiceRecordingButton: VoiceRecordComposerTile; private voiceRecordingButton: VoiceRecordComposerTile;
private ref: React.RefObject<HTMLDivElement> = createRef(); private ref: React.RefObject<HTMLDivElement> = createRef();
static defaultProps = {
showReplyPreview: true,
compact: false,
};
constructor(props) { constructor(props) {
super(props); super(props);
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate); VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
@ -471,7 +478,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
render() { render() {
const controls = [ const controls = [
this.state.me ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null, this.state.me && !this.props.compact ? <ComposerAvatar key="controls_avatar" me={this.state.me} /> : null,
this.props.e2eStatus ? this.props.e2eStatus ?
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> : <E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
null, null,
@ -541,8 +548,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
yOffset={-50} yOffset={-50}
/>; />;
} }
controls.push(
controls.push(
<Stickerpicker <Stickerpicker
room={this.props.room} room={this.props.room}
showStickers={this.state.showStickers} showStickers={this.state.showStickers}
@ -552,11 +558,19 @@ export default class MessageComposer extends React.Component<IProps, IState> {
const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording; const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording;
const classes = classNames({
"mx_MessageComposer": true,
"mx_GroupLayout": true,
"mx_MessageComposer--compact": this.props.compact,
});
return ( return (
<div className="mx_MessageComposer mx_GroupLayout" ref={this.ref}> <div className={classes}>
{ recordingTooltip } { recordingTooltip }
<div className="mx_MessageComposer_wrapper"> <div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} /> { this.props.showReplyPreview && (
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
) }
<div className="mx_MessageComposer_row"> <div className="mx_MessageComposer_row">
{ controls } { controls }
{ this.renderButtons(menuPosition) } { this.renderButtons(menuPosition) }

View file

@ -28,6 +28,8 @@ import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import InviteReason from "../elements/InviteReason"; import InviteReason from "../elements/InviteReason";
const MemberEventHtmlReasonField = "io.element.html_reason";
const MessageCase = Object.freeze({ const MessageCase = Object.freeze({
NotLoggedIn: "NotLoggedIn", NotLoggedIn: "NotLoggedIn",
Joining: "Joining", Joining: "Joining",
@ -492,9 +494,13 @@ export default class RoomPreviewBar extends React.Component {
} }
const myUserId = MatrixClientPeg.get().getUserId(); const myUserId = MatrixClientPeg.get().getUserId();
const reason = this.props.room.currentState.getMember(myUserId).events.member.event.content.reason; const memberEventContent = this.props.room.currentState.getMember(myUserId).events.member.getContent();
if (reason) {
reasonElement = <InviteReason reason={reason} />; if (memberEventContent.reason) {
reasonElement = <InviteReason
reason={memberEventContent.reason}
htmlReason={memberEventContent[MemberEventHtmlReasonField]}
/>;
} }
primaryActionHandler = this.props.onJoinClick; primaryActionHandler = this.props.onJoinClick;

View file

@ -26,7 +26,7 @@ import { Layout } from "../../../settings/Layout";
import { SettingLevel } from "../../../settings/SettingLevel"; import { SettingLevel } from "../../../settings/SettingLevel";
interface IProps { interface IProps {
userId: string; userId?: string;
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
messagePreviewText: string; messagePreviewText: string;

View file

@ -149,10 +149,12 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
"To avoid these issues, create a <a>new encrypted room</a> for " + "To avoid these issues, create a <a>new encrypted room</a> for " +
"the conversation you plan to have.", "the conversation you plan to have.",
null, null,
{ "a": (sub) => <a onClick={() => { { "a": (sub) => <a
dialog.close(); className="mx_linkButton"
this.createNewRoom(false, true); onClick={() => {
}}> { sub } </a> }, dialog.close();
this.createNewRoom(false, true);
}}> { sub } </a> },
) } </p> ) } </p>
</div>, </div>,
@ -248,10 +250,12 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
"you plan to have.", "you plan to have.",
null, null,
{ {
"a": (sub) => <a onClick={() => { "a": (sub) => <a
dialog.close(); className="mx_linkButton"
this.createNewRoom(true, false); onClick={() => {
}}> { sub } </a>, dialog.close();
this.createNewRoom(true, false);
}}> { sub } </a>,
}, },
) } </p> ) } </p>
</div>, </div>,

View file

@ -67,7 +67,7 @@ interface IState extends IThemeState {
showAdvanced: boolean; showAdvanced: boolean;
layout: Layout; layout: Layout;
// User profile data for the message preview // User profile data for the message preview
userId: string; userId?: string;
displayName: string; displayName: string;
avatarUrl: string; avatarUrl: string;
} }
@ -92,8 +92,8 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
systemFont: SettingsStore.getValue("systemFont"), systemFont: SettingsStore.getValue("systemFont"),
showAdvanced: false, showAdvanced: false,
layout: SettingsStore.getValue("layout"), layout: SettingsStore.getValue("layout"),
userId: "@erim:fink.fink", userId: null,
displayName: "Erimayas Fink", displayName: null,
avatarUrl: null, avatarUrl: null,
}; };
} }

View file

@ -54,8 +54,8 @@ const SpacePublicShare = ({ space, onFinished }: IProps) => {
{ space.canInvite(MatrixClientPeg.get()?.getUserId()) ? <AccessibleButton { space.canInvite(MatrixClientPeg.get()?.getUserId()) ? <AccessibleButton
className="mx_SpacePublicShare_inviteButton" className="mx_SpacePublicShare_inviteButton"
onClick={() => { onClick={() => {
showRoomInviteDialog(space.roomId);
if (onFinished) onFinished(); if (onFinished) onFinished();
showRoomInviteDialog(space.roomId);
}} }}
> >
<h3>{ _t("Invite people") }</h3> <h3>{ _t("Invite people") }</h3>

View file

@ -426,9 +426,6 @@
"Prepends ( ͡° ͜ʖ ͡°) to a plain-text message": "Prepends ( ͡° ͜ʖ ͡°) to a plain-text message", "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 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", "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", "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.", "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", "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", "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 an empty state key": "with an empty state key",
"with state key %(stateKey)s": "with state key %(stateKey)s", "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 <Room /> as well": "The above, but in <Room /> as well",
"Send <b>%(eventType)s</b> events as you in this room": "Send <b>%(eventType)s</b> events as you in this room", "Send <b>%(eventType)s</b> events as you in this room": "Send <b>%(eventType)s</b> events as you in this room",
"See <b>%(eventType)s</b> events posted to this room": "See <b>%(eventType)s</b> events posted to this room", "See <b>%(eventType)s</b> events posted to this room": "See <b>%(eventType)s</b> events posted to this room",
"Send <b>%(eventType)s</b> events as you in your active room": "Send <b>%(eventType)s</b> events as you in your active room", "Send <b>%(eventType)s</b> events as you in your active room": "Send <b>%(eventType)s</b> events as you in your active room",
@ -809,6 +808,7 @@
"Render LaTeX maths in messages": "Render LaTeX maths in messages", "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.", "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", "Message Pinning": "Message Pinning",
"Threaded messaging": "Threaded messaging",
"Custom user status messages": "Custom user status messages", "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)", "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", "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|other": "%(count)s people",
"%(count)s people|one": "%(count)s person", "%(count)s people|one": "%(count)s person",
"Show files": "Show files", "Show files": "Show files",
"Show threads": "Show threads",
"Share room": "Share room", "Share room": "Share room",
"Room settings": "Room settings", "Room settings": "Room settings",
"Trusted": "Trusted", "Trusted": "Trusted",
@ -1926,6 +1927,7 @@
"React": "React", "React": "React",
"Edit": "Edit", "Edit": "Edit",
"Reply": "Reply", "Reply": "Reply",
"Thread": "Thread",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Download %(text)s": "Download %(text)s", "Download %(text)s": "Download %(text)s",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
@ -2412,8 +2414,8 @@
"Clear cache and resync": "Clear cache and resync", "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!", "%(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", "Updating %(brand)s": "Updating %(brand)s",
"Leave all rooms and spaces": "Leave all rooms and spaces",
"Don't leave any": "Don't leave any", "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", "Leave specific rooms and spaces": "Leave specific rooms and spaces",
"Search %(spaceName)s": "Search %(spaceName)s", "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.", "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", "Commands": "Commands",
"Command Autocomplete": "Command Autocomplete", "Command Autocomplete": "Command Autocomplete",
"Community Autocomplete": "Community Autocomplete", "Community Autocomplete": "Community Autocomplete",
"Results from DuckDuckGo": "Results from DuckDuckGo",
"DuckDuckGo Results": "DuckDuckGo Results",
"Emoji": "Emoji", "Emoji": "Emoji",
"Emoji Autocomplete": "Emoji Autocomplete", "Emoji Autocomplete": "Emoji Autocomplete",
"Notify the whole room": "Notify the whole room", "Notify the whole room": "Notify the whole room",

View file

@ -211,6 +211,15 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, 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": { "feature_custom_status": {
isFeature: true, isFeature: true,
displayName: _td("Custom user status messages"), displayName: _td("Custom user status messages"),

View file

@ -71,7 +71,13 @@ export default class DeviceSettingsHandler extends SettingsHandler {
// Special case for old useIRCLayout setting // Special case for old useIRCLayout setting
if (settingName === "layout") { if (settingName === "layout") {
const settings = this.getSettings() || {}; 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]; return settings[settingName];
} }

View file

@ -37,6 +37,10 @@ export enum RightPanelPhases {
SpaceMemberList = "SpaceMemberList", SpaceMemberList = "SpaceMemberList",
SpaceMemberInfo = "SpaceMemberInfo", SpaceMemberInfo = "SpaceMemberInfo",
Space3pidMemberInfo = "Space3pidMemberInfo", Space3pidMemberInfo = "Space3pidMemberInfo",
// Thread stuff
ThreadView = "ThreadView",
ThreadPanel = "ThreadPanel",
} }
// These are the phases that are safe to persist (the ones that don't require additional // These are the phases that are safe to persist (the ones that don't require additional

View file

@ -145,9 +145,9 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._allRoomsInHome; return this._allRoomsInHome;
} }
public async setActiveRoomInSpace(space: Room | null): Promise<void> { public setActiveRoomInSpace(space: Room | null): void {
if (space && !space.isSpaceRoom()) return; if (space && !space.isSpaceRoom()) return;
if (space !== this.activeSpace) await this.setActiveSpace(space); if (space !== this.activeSpace) this.setActiveSpace(space);
if (space) { if (space) {
const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications(); const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications();
@ -190,7 +190,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
* @param contextSwitch whether to switch the user's context, * @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. * 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; if (space === this.activeSpace || (space && !space.isSpaceRoom())) return;
this._activeSpace = space; this._activeSpace = space;
@ -293,11 +293,15 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
if (space) { if (space) {
const suggestedRooms = await this.fetchSuggestedRooms(space); this.loadSuggestedRooms(space);
if (this._activeSpace === space) { }
this._suggestedRooms = suggestedRooms; }
this.emit(SUGGESTED_ROOMS, this._suggestedRooms);
} 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<IState> {
this.onSpaceUpdate(); this.onSpaceUpdate();
this.emit(room.roomId); 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; break;
case EventType.SpaceParent: case EventType.SpaceParent:
@ -678,12 +690,14 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
this.emit(room.roomId); this.emit(room.roomId);
break; break;
}
};
case EventType.RoomMember: // listening for m.room.member events in onRoomState above doesn't work as the Member object isn't updated by then
if (room.isSpaceRoom()) { private onRoomStateMembers = (ev: MatrixEvent) => {
this.onSpaceMembersChange(ev); const room = this.matrixClient.getRoom(ev.getRoomId());
} if (room?.isSpaceRoom()) {
break; this.onSpaceMembersChange(ev);
} }
}; };
@ -743,6 +757,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom);
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("RoomState.events", this.onRoomState); this.matrixClient.removeListener("RoomState.events", this.onRoomState);
this.matrixClient.removeListener("RoomState.members", this.onRoomStateMembers);
this.matrixClient.removeListener("accountData", this.onAccountData); this.matrixClient.removeListener("accountData", this.onAccountData);
} }
await this.reset(); await this.reset();
@ -754,6 +769,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom);
this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("Room.accountData", this.onRoomAccountData);
this.matrixClient.on("RoomState.events", this.onRoomState); this.matrixClient.on("RoomState.events", this.onRoomState);
this.matrixClient.on("RoomState.members", this.onRoomStateMembers);
this.matrixClient.on("accountData", this.onAccountData); this.matrixClient.on("accountData", this.onAccountData);
this.matrixClient.getCapabilities().then(capabilities => { this.matrixClient.getCapabilities().then(capabilities => {

View file

@ -20,6 +20,27 @@ import { IAlgorithm } from "./IAlgorithm";
import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import * as Unread from "../../../../Unread"; import * as Unread from "../../../../Unread";
import { EffectiveMembership, getEffectiveMembership } from "../../../../utils/membership"; 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[] => { export const sortRooms = (rooms: Room[]): Room[] => {
// We cache the timestamp lookup to avoid iterating forever on the timeline // 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]; const ev = r.timeline[i];
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?) 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(); return ev.getTs();
} }
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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 { ELEMENT_CLIENT_ID } from "../../identifiers";
import { getUserLanguage } from "../../languageHandler"; import { getUserLanguage } from "../../languageHandler";
import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables"; import { WidgetVariableCustomisations } from "../../customisations/WidgetVariables";
import { arrayFastClone } from "../../utils/arrays";
// TODO: Destroy all of this code // TODO: Destroy all of this code
@ -146,6 +147,7 @@ export class StopGapWidget extends EventEmitter {
private scalarToken: string; private scalarToken: string;
private roomId?: string; private roomId?: string;
private kind: WidgetKind; private kind: WidgetKind;
private readUpToMap: {[roomId: string]: string} = {}; // room ID to event ID
constructor(private appTileProps: IAppTileProps) { constructor(private appTileProps: IAppTileProps) {
super(); super();
@ -294,6 +296,14 @@ export class StopGapWidget extends EventEmitter {
this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{}); this.messaging.transport.reply(ev.detail, <IWidgetApiRequestEmptyData>{});
}); });
// 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 // Attach listeners for feeding events - the underlying widget classes handle permissions for us
MatrixClientPeg.get().on('event', this.onEvent); MatrixClientPeg.get().on('event', this.onEvent);
MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted); MatrixClientPeg.get().on('Event.decrypted', this.onEventDecrypted);
@ -408,21 +418,56 @@ export class StopGapWidget extends EventEmitter {
private onEvent = (ev: MatrixEvent) => { private onEvent = (ev: MatrixEvent) => {
MatrixClientPeg.get().decryptEventIfNeeded(ev); MatrixClientPeg.get().decryptEventIfNeeded(ev);
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return; if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
if (ev.getRoomId() !== this.eventListenerRoomId) return;
this.feedEvent(ev); this.feedEvent(ev);
}; };
private onEventDecrypted = (ev: MatrixEvent) => { private onEventDecrypted = (ev: MatrixEvent) => {
if (ev.isDecryptionFailure()) return; if (ev.isDecryptionFailure()) return;
if (ev.getRoomId() !== this.eventListenerRoomId) return;
this.feedEvent(ev); this.feedEvent(ev);
}; };
private feedEvent(ev: MatrixEvent) { private feedEvent(ev: MatrixEvent) {
if (!this.messaging) return; 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(); 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); console.error("Error sending event to widget: ", e);
}); });
} }

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -23,6 +23,7 @@ import {
MatrixCapabilities, MatrixCapabilities,
OpenIDRequestState, OpenIDRequestState,
SimpleObservable, SimpleObservable,
Symbols,
Widget, Widget,
WidgetDriver, WidgetDriver,
WidgetEventCapability, WidgetEventCapability,
@ -33,9 +34,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
import ActiveRoomObserver from "../../ActiveRoomObserver"; import ActiveRoomObserver from "../../ActiveRoomObserver";
import Modal from "../../Modal"; import Modal from "../../Modal";
import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog"; import WidgetOpenIDPermissionsDialog from "../../components/views/dialogs/WidgetOpenIDPermissionsDialog";
import WidgetCapabilitiesPromptDialog, { import WidgetCapabilitiesPromptDialog from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
getRememberedCapabilitiesForWidget,
} from "../../components/views/dialogs/WidgetCapabilitiesPromptDialog";
import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions"; import { WidgetPermissionCustomisations } from "../../customisations/WidgetPermissions";
import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore"; import { OIDCState, WidgetPermissionStore } from "./WidgetPermissionStore";
import { WidgetType } from "../../widgets/WidgetType"; import { WidgetType } from "../../widgets/WidgetType";
@ -44,10 +43,19 @@ import { CHAT_EFFECTS } from "../../effects";
import { containsEmoji } from "../../effects/utils"; import { containsEmoji } from "../../effects/utils";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { tryTransformPermalinkToLocalHref } from "../../utils/permalinks/Permalinks"; 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 // 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 { export class StopGapWidgetDriver extends WidgetDriver {
private allowedCapabilities: Set<Capability>; private allowedCapabilities: Set<Capability>;
@ -100,6 +108,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
} }
} }
// TODO: Do something when the widget requests new capabilities not yet asked for // TODO: Do something when the widget requests new capabilities not yet asked for
let rememberApproved = false;
if (missing.size > 0) { if (missing.size > 0) {
try { try {
const [result] = await Modal.createTrackedDialog( const [result] = await Modal.createTrackedDialog(
@ -111,17 +120,29 @@ export class StopGapWidgetDriver extends WidgetDriver {
widgetKind: this.forWidgetKind, widgetKind: this.forWidgetKind,
}).finished; }).finished;
(result.approved || []).forEach(cap => allowedSoFar.add(cap)); (result.approved || []).forEach(cap => allowedSoFar.add(cap));
rememberApproved = result.remember;
} catch (e) { } catch (e) {
console.error("Non-fatal error getting capabilities: ", 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<ISendEventDetails> { public async sendEvent(
eventType: string,
content: any,
stateKey: string = null,
targetRoomId: string = null,
): Promise<ISendEventDetails> {
const client = MatrixClientPeg.get(); 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"); 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) { if (stateKey !== null) {
// state event // state event
r = await client.sendStateEvent(roomId, eventType, content, stateKey); 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 { } else {
// message event // message event
r = await client.sendEvent(roomId, eventType, content); r = await client.sendEvent(roomId, eventType, content);
@ -145,48 +169,68 @@ export class StopGapWidgetDriver extends WidgetDriver {
return { roomId, eventId: r.event_id }; return { roomId, eventId: r.event_id };
} }
public async readRoomEvents(eventType: string, msgtype: string | undefined, limit: number): Promise<object[]> { private pickRooms(roomIds: (string | Symbols.AnyRoom)[] = null): Room[] {
limit = limit > 0 ? Math.min(limit, 25) : 25; // arbitrary choice
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const roomId = ActiveRoomObserver.activeRoomId; if (!client) throw new Error("Not attached to a client");
const room = client.getRoom(roomId);
if (!client || !roomId || !room) throw new Error("Not in a room or not attached to a client");
const results: MatrixEvent[] = []; const targetRooms = roomIds
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last ? (roomIds.includes(Symbols.AnyRoom) ? client.getVisibleRooms() : roomIds.map(r => client.getRoom(r)))
for (let i = events.length - 1; i > 0; i--) { : [client.getRoom(ActiveRoomObserver.activeRoomId)];
if (results.length >= limit) break; return targetRooms.filter(r => !!r);
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());
} }
public async readStateEvents(eventType: string, stateKey: string | undefined, limit: number): Promise<object[]> { public async readRoomEvents(
limit = limit > 0 ? Math.min(limit, 100) : 100; // arbitrary choice eventType: string,
msgtype: string | undefined,
limitPerRoom: number,
roomIds: (string | Symbols.AnyRoom)[] = null,
): Promise<object[]> {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const client = MatrixClientPeg.get(); const rooms = this.pickRooms(roomIds);
const roomId = ActiveRoomObserver.activeRoomId; const allResults: IEvent[] = [];
const room = client.getRoom(roomId); for (const room of rooms) {
if (!client || !roomId || !room) throw new Error("Not in a room or 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 >= limitPerRoom) break;
const results: MatrixEvent[] = []; const ev = events[i];
const state: Map<string, MatrixEvent> = room.currentState.events.get(eventType); if (ev.getType() !== eventType || ev.isState()) continue;
if (state) { if (eventType === EventType.RoomMessage && msgtype && msgtype !== ev.getContent()['msgtype']) continue;
if (stateKey === "" || !!stateKey) { results.push(ev);
const forKey = state.get(stateKey);
if (forKey) results.push(forKey);
} else {
results.push(...Array.from(state.values()));
} }
}
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<object[]> {
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<string, MatrixEvent> = 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<IOpenIDUpdate>) { public async askOpenID(observer: SimpleObservable<IOpenIDUpdate>) {

View file

@ -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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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. 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 { _t, _td, TranslatedString } from "../languageHandler";
import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities"; import { ElementWidgetCapabilities } from "../stores/widgets/ElementWidgetCapabilities";
import React from "react"; 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 type GENERIC_WIDGET_KIND = "generic"; // eslint-disable-line @typescript-eslint/naming-convention
const GENERIC_WIDGET_KIND: GENERIC_WIDGET_KIND = "generic"; 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]) }; 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 // ... 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 // function if we fail to generate a string for the capability.
// event. }
// 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 <Room /> as well", {}, {
Room: () => {
if (room) {
return <TextWithTooltip tooltip={room.getCanonicalAlias() ?? roomId}>
<b>{ room.name }</b>
</TextWithTooltip>;
} else {
return <b><code>{ roomId }</code></b>;
}
},
}),
};
}
} }
// We didn't have a super simple line of text, so try processing the capability as the // We didn't have a super simple line of text, so try processing the capability as the

View file

@ -562,7 +562,7 @@ describe("SpaceStore", () => {
]); ]);
mkSpace(space3).getMyMembership.mockReturnValue("invite"); mkSpace(space3).getMyMembership.mockReturnValue("invite");
await run(); await run();
await store.setActiveSpace(null); store.setActiveSpace(null);
expect(store.activeSpace).toBe(null); expect(store.activeSpace).toBe(null);
}); });
afterEach(() => { afterEach(() => {
@ -570,31 +570,31 @@ describe("SpaceStore", () => {
}); });
it("switch to home space", async () => { it("switch to home space", async () => {
await store.setActiveSpace(client.getRoom(space1)); store.setActiveSpace(client.getRoom(space1));
fn.mockClear(); fn.mockClear();
await store.setActiveSpace(null); store.setActiveSpace(null);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, null);
expect(store.activeSpace).toBe(null); expect(store.activeSpace).toBe(null);
}); });
it("switch to invited space", async () => { it("switch to invited space", async () => {
const space = client.getRoom(space3); const space = client.getRoom(space3);
await store.setActiveSpace(space); store.setActiveSpace(space);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(space); expect(store.activeSpace).toBe(space);
}); });
it("switch to top level space", async () => { it("switch to top level space", async () => {
const space = client.getRoom(space1); const space = client.getRoom(space1);
await store.setActiveSpace(space); store.setActiveSpace(space);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(space); expect(store.activeSpace).toBe(space);
}); });
it("switch to subspace", async () => { it("switch to subspace", async () => {
const space = client.getRoom(space2); const space = client.getRoom(space2);
await store.setActiveSpace(space); store.setActiveSpace(space);
expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(fn).toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(space); expect(store.activeSpace).toBe(space);
}); });
@ -602,7 +602,7 @@ describe("SpaceStore", () => {
it("switch to unknown space is a nop", async () => { it("switch to unknown space is a nop", async () => {
expect(store.activeSpace).toBe(null); expect(store.activeSpace).toBe(null);
const space = client.getRoom(room1); // not a space const space = client.getRoom(room1); // not a space
await store.setActiveSpace(space); store.setActiveSpace(space);
expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space); expect(fn).not.toHaveBeenCalledWith(UPDATE_SELECTED_SPACE, space);
expect(store.activeSpace).toBe(null); 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 () => { 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); viewRoom(room2);
await store.setActiveSpace(client.getRoom(space2)); store.setActiveSpace(client.getRoom(space2));
viewRoom(room2); viewRoom(room2);
await store.setActiveSpace(client.getRoom(space1)); store.setActiveSpace(client.getRoom(space1));
expect(getCurrentRoom()).toBe(room2); expect(getCurrentRoom()).toBe(room2);
}); });
it("last viewed room in target space is in the current space", async () => { 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); viewRoom(room2);
await store.setActiveSpace(client.getRoom(space2)); store.setActiveSpace(client.getRoom(space2));
expect(getCurrentRoom()).toBe(space2); expect(getCurrentRoom()).toBe(space2);
await store.setActiveSpace(client.getRoom(space1)); store.setActiveSpace(client.getRoom(space1));
expect(getCurrentRoom()).toBe(room2); expect(getCurrentRoom()).toBe(room2);
}); });
it("last viewed room in target space is not in the current space", async () => { 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); viewRoom(room1);
await store.setActiveSpace(client.getRoom(space2)); store.setActiveSpace(client.getRoom(space2));
viewRoom(room2); viewRoom(room2);
await store.setActiveSpace(client.getRoom(space1)); store.setActiveSpace(client.getRoom(space1));
expect(getCurrentRoom()).toBe(room1); expect(getCurrentRoom()).toBe(room1);
}); });
it("last viewed room is target space is not known", async () => { it("last viewed room is target space is not known", async () => {
await store.setActiveSpace(client.getRoom(space1)); store.setActiveSpace(client.getRoom(space1));
viewRoom(room1); viewRoom(room1);
localStorage.setItem(`mx_space_context_${space2}`, orphan2); localStorage.setItem(`mx_space_context_${space2}`, orphan2);
await store.setActiveSpace(client.getRoom(space2)); store.setActiveSpace(client.getRoom(space2));
expect(getCurrentRoom()).toBe(space2); expect(getCurrentRoom()).toBe(space2);
}); });
it("last viewed room is target space is no longer in that space", async () => { 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); viewRoom(room1);
localStorage.setItem(`mx_space_context_${space2}`, 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 expect(getCurrentRoom()).toBe(space2); // Space home instead of room1
}); });
it("no last viewed room in target space", async () => { it("no last viewed room in target space", async () => {
await store.setActiveSpace(client.getRoom(space1)); store.setActiveSpace(client.getRoom(space1));
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(client.getRoom(space2)); store.setActiveSpace(client.getRoom(space2));
expect(getCurrentRoom()).toBe(space2); expect(getCurrentRoom()).toBe(space2);
}); });
it("no last viewed room in home space", async () => { it("no last viewed room in home space", async () => {
await store.setActiveSpace(client.getRoom(space1)); store.setActiveSpace(client.getRoom(space1));
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(null); store.setActiveSpace(null);
expect(getCurrentRoom()).toBeNull(); // Home expect(getCurrentRoom()).toBeNull(); // Home
}); });
}); });
@ -715,28 +715,28 @@ describe("SpaceStore", () => {
it("no switch required, room is in current space", async () => { it("no switch required, room is in current space", async () => {
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(client.getRoom(space1), false); store.setActiveSpace(client.getRoom(space1), false);
viewRoom(room2); viewRoom(room2);
expect(store.activeSpace).toBe(client.getRoom(space1)); expect(store.activeSpace).toBe(client.getRoom(space1));
}); });
it("switch to canonical parent space for room", async () => { it("switch to canonical parent space for room", async () => {
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(client.getRoom(space2), false); store.setActiveSpace(client.getRoom(space2), false);
viewRoom(room2); viewRoom(room2);
expect(store.activeSpace).toBe(client.getRoom(space2)); expect(store.activeSpace).toBe(client.getRoom(space2));
}); });
it("switch to first containing space for room", async () => { it("switch to first containing space for room", async () => {
viewRoom(room2); viewRoom(room2);
await store.setActiveSpace(client.getRoom(space2), false); store.setActiveSpace(client.getRoom(space2), false);
viewRoom(room3); viewRoom(room3);
expect(store.activeSpace).toBe(client.getRoom(space1)); expect(store.activeSpace).toBe(client.getRoom(space1));
}); });
it("switch to home for orphaned room", async () => { it("switch to home for orphaned room", async () => {
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(client.getRoom(space1), false); store.setActiveSpace(client.getRoom(space1), false);
viewRoom(orphan1); viewRoom(orphan1);
expect(store.activeSpace).toBeNull(); 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 () => { it("when switching rooms in the all rooms home space don't switch to related space", async () => {
await setShowAllRooms(true); await setShowAllRooms(true);
viewRoom(room2); viewRoom(room2);
await store.setActiveSpace(null, false); store.setActiveSpace(null, false);
viewRoom(room1); viewRoom(room1);
expect(store.activeSpace).toBeNull(); expect(store.activeSpace).toBeNull();
}); });

View file

@ -57,7 +57,7 @@ describe("SpaceWatcher", () => {
beforeEach(async () => { beforeEach(async () => {
filter = null; filter = null;
store.removeAllListeners(); store.removeAllListeners();
await store.setActiveSpace(null); store.setActiveSpace(null);
client.getVisibleRooms.mockReturnValue(rooms = []); client.getVisibleRooms.mockReturnValue(rooms = []);
space1 = mkSpace(space1Id); space1 = mkSpace(space1Id);
@ -95,7 +95,7 @@ describe("SpaceWatcher", () => {
await setShowAllRooms(true); await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore); new SpaceWatcher(mockRoomListStore);
await SpaceStore.instance.setActiveSpace(space1); SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1); expect(filter["space"]).toBe(space1);
@ -114,7 +114,7 @@ describe("SpaceWatcher", () => {
await setShowAllRooms(false); await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore); new SpaceWatcher(mockRoomListStore);
await SpaceStore.instance.setActiveSpace(space1); SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1); expect(filter["space"]).toBe(space1);
@ -124,22 +124,22 @@ describe("SpaceWatcher", () => {
await setShowAllRooms(true); await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore); new SpaceWatcher(mockRoomListStore);
await SpaceStore.instance.setActiveSpace(space1); SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1); expect(filter["space"]).toBe(space1);
await SpaceStore.instance.setActiveSpace(null); SpaceStore.instance.setActiveSpace(null);
expect(filter).toBeNull(); expect(filter).toBeNull();
}); });
it("updates filter correctly for space -> home transition", async () => { it("updates filter correctly for space -> home transition", async () => {
await setShowAllRooms(false); await setShowAllRooms(false);
await SpaceStore.instance.setActiveSpace(space1); SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore); new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1); expect(filter["space"]).toBe(space1);
await SpaceStore.instance.setActiveSpace(null); SpaceStore.instance.setActiveSpace(null);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(null); expect(filter["space"]).toBe(null);
@ -147,12 +147,12 @@ describe("SpaceWatcher", () => {
it("updates filter correctly for space -> space transition", async () => { it("updates filter correctly for space -> space transition", async () => {
await setShowAllRooms(false); await setShowAllRooms(false);
await SpaceStore.instance.setActiveSpace(space1); SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore); new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1); expect(filter["space"]).toBe(space1);
await SpaceStore.instance.setActiveSpace(space2); SpaceStore.instance.setActiveSpace(space2);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space2); expect(filter["space"]).toBe(space2);
@ -160,7 +160,7 @@ describe("SpaceWatcher", () => {
it("doesn't change filter when changing showAllRooms mode to true", async () => { it("doesn't change filter when changing showAllRooms mode to true", async () => {
await setShowAllRooms(false); await setShowAllRooms(false);
await SpaceStore.instance.setActiveSpace(space1); SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore); new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);
@ -173,7 +173,7 @@ describe("SpaceWatcher", () => {
it("doesn't change filter when changing showAllRooms mode to false", async () => { it("doesn't change filter when changing showAllRooms mode to false", async () => {
await setShowAllRooms(true); await setShowAllRooms(true);
await SpaceStore.instance.setActiveSpace(space1); SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore); new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition); expect(filter).toBeInstanceOf(SpaceFilterCondition);

View file

@ -2036,10 +2036,10 @@ ajv@^8.0.1:
require-from-string "^2.0.2" require-from-string "^2.0.2"
uri-js "^4.2.2" uri-js "^4.2.2"
allchange@^1.0.0: allchange@^1.0.2:
version "1.0.1" version "1.0.2"
resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.1.tgz#f32a75f65ab182d044d18e8baa43bd1c9be982f6" resolved "https://registry.yarnpkg.com/allchange/-/allchange-1.0.2.tgz#86b9190e12b7ede4f230ae763cbd504c48fd907b"
integrity sha512-lj8HZcvQ04RsNqwLWjCYSDvchrW4nnjlOZ3z+VGhA78M7KootV0eRwlvTlYJec73jsz/Ts59kVArgooEsACOog== integrity sha512-qJv1t2yvBThkes8g/dPMt8CGu+04U+q5QjCJn2Ngp92edZU8DJBfKGyGXo7w1iV48LVuQKQDfMsdIWhP7zHdlQ==
dependencies: dependencies:
"@actions/core" "^1.4.0" "@actions/core" "^1.4.0"
"@actions/github" "^5.0.0" "@actions/github" "^5.0.0"
@ -5792,8 +5792,8 @@ mathml-tag-names@^2.1.3:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "12.3.1" version "12.4.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/92d822d494cc1179affbf1f14fc8635ed5595b3c" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2783d162b77d6629c574f35e88bea9ae29765c34"
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
another-json "^0.2.0" another-json "^0.2.0"
@ -5827,10 +5827,10 @@ matrix-react-test-utils@^0.2.3:
"@babel/traverse" "^7.13.17" "@babel/traverse" "^7.13.17"
walk "^2.3.14" walk "^2.3.14"
matrix-widget-api@^0.1.0-beta.15: matrix-widget-api@^0.1.0-beta.16:
version "0.1.0-beta.15" version "0.1.0-beta.16"
resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.15.tgz#b02511f93fe1a3634868b6e246d736107f182745" resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-0.1.0-beta.16.tgz#32655f05cab48239b97fe4111a1d0858f2aad61a"
integrity sha512-sWmtb8ZarSbHVbk5ni7IHBR9jOh7m1+5R4soky0fEO9VKl+MN7skT0+qNux3J9WuUAu2D80dZW9xPUT9cxfxbg== integrity sha512-9zqaNLaM14YDHfFb7WGSUOivGOjYw+w5Su84ZfOl6A4IUy1xT9QPp0nsSA8wNfz0LpxOIPn3nuoF8Tn/40F5tg==
dependencies: dependencies:
"@types/events" "^3.0.0" "@types/events" "^3.0.0"
events "^3.2.0" events "^3.2.0"