Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/fix/17686

 Conflicts:
	src/components/views/avatars/RoomAvatar.tsx
	test/stores/SpaceStore-test.ts
	test/test-utils.js
This commit is contained in:
Michael Telatynski 2021-07-19 16:47:31 +01:00
commit de42a00ca4
104 changed files with 2492 additions and 2123 deletions

View file

@ -1,3 +1,15 @@
<!-- Please read https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md before submitting your pull request --> <!-- Please read https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md before submitting your pull request -->
<!-- Include a Sign-Off as described in https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md#sign-off --> <!-- Include a Sign-Off as described in https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md#sign-off -->
<!-- To specify text for the changelog entry (otherwise the PR title will be used):
Notes:
Changes in this project generate changelog entries in element-web by default.
To suppress this:
element-web notes: none
...or to specify different notes:
element-web notes: <notes>
-->

View file

@ -1,3 +1,152 @@
Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0)
* Fix 'User' type import
[\#6376](https://github.com/matrix-org/matrix-react-sdk/pull/6376)
Changes in [3.26.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0-rc.1) (2021-07-14)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0...v3.26.0-rc.1)
* Fix voice messages in right panels
[\#6370](https://github.com/matrix-org/matrix-react-sdk/pull/6370)
* Use TileShape enum more universally
[\#6369](https://github.com/matrix-org/matrix-react-sdk/pull/6369)
* Translations update from Weblate
[\#6373](https://github.com/matrix-org/matrix-react-sdk/pull/6373)
* Hide world readable history option in encrypted rooms
[\#5947](https://github.com/matrix-org/matrix-react-sdk/pull/5947)
* Make the Image View buttons easier to hit
[\#6372](https://github.com/matrix-org/matrix-react-sdk/pull/6372)
* Reorder buttons in the Image View
[\#6368](https://github.com/matrix-org/matrix-react-sdk/pull/6368)
* Add VS Code to gitignore
[\#6367](https://github.com/matrix-org/matrix-react-sdk/pull/6367)
* Fix inviter exploding due to member being null
[\#6362](https://github.com/matrix-org/matrix-react-sdk/pull/6362)
* Increase sample count in voice message thumbnail
[\#6359](https://github.com/matrix-org/matrix-react-sdk/pull/6359)
* Improve arraySeed utility
[\#6360](https://github.com/matrix-org/matrix-react-sdk/pull/6360)
* Convert FontManager to TS and stub it out for tests
[\#6358](https://github.com/matrix-org/matrix-react-sdk/pull/6358)
* Adjust recording waveform behaviour for voice messages
[\#6357](https://github.com/matrix-org/matrix-react-sdk/pull/6357)
* Do not honor string power levels
[\#6245](https://github.com/matrix-org/matrix-react-sdk/pull/6245)
* Add alias and directory customisation points
[\#6343](https://github.com/matrix-org/matrix-react-sdk/pull/6343)
* Fix multiinviter user already in room and clean up code
[\#6354](https://github.com/matrix-org/matrix-react-sdk/pull/6354)
* Fix right panel not closing user info when changing rooms
[\#6341](https://github.com/matrix-org/matrix-react-sdk/pull/6341)
* Quit sticker picker on m.sticker
[\#5679](https://github.com/matrix-org/matrix-react-sdk/pull/5679)
* Don't autodetect language in inline code blocks
[\#6350](https://github.com/matrix-org/matrix-react-sdk/pull/6350)
* Make ghost button background transparent
[\#6331](https://github.com/matrix-org/matrix-react-sdk/pull/6331)
* only consider valid & loaded url previews for show N more prompt
[\#6346](https://github.com/matrix-org/matrix-react-sdk/pull/6346)
* Extract MXCs from _matrix/media/r0/ URLs for inline images in messages
[\#6335](https://github.com/matrix-org/matrix-react-sdk/pull/6335)
* Fix small visual regression with the site name on url previews
[\#6342](https://github.com/matrix-org/matrix-react-sdk/pull/6342)
* Make PIP CallView draggable/movable
[\#5952](https://github.com/matrix-org/matrix-react-sdk/pull/5952)
* Convert VoiceUserSettingsTab to TS
[\#6340](https://github.com/matrix-org/matrix-react-sdk/pull/6340)
* Simplify typescript definition for Modernizr
[\#6339](https://github.com/matrix-org/matrix-react-sdk/pull/6339)
* Remember the last used server for room directory searches
[\#6322](https://github.com/matrix-org/matrix-react-sdk/pull/6322)
* Focus composer after reacting
[\#6332](https://github.com/matrix-org/matrix-react-sdk/pull/6332)
* Fix bug which prevented more than one event getting pinned
[\#6336](https://github.com/matrix-org/matrix-react-sdk/pull/6336)
* Make DeviceListener also update on megolm key in SSSS
[\#6337](https://github.com/matrix-org/matrix-react-sdk/pull/6337)
* Improve URL previews
[\#6326](https://github.com/matrix-org/matrix-react-sdk/pull/6326)
* Don't close settings dialog when opening spaces feedback prompt
[\#6334](https://github.com/matrix-org/matrix-react-sdk/pull/6334)
* Update import location for types
[\#6330](https://github.com/matrix-org/matrix-react-sdk/pull/6330)
* Improve blurhash rendering performance
[\#6329](https://github.com/matrix-org/matrix-react-sdk/pull/6329)
* Use a proper color scheme for codeblocks
[\#6320](https://github.com/matrix-org/matrix-react-sdk/pull/6320)
* Burn `sdk.getComponent()` with 🔥
[\#6308](https://github.com/matrix-org/matrix-react-sdk/pull/6308)
* Fix instances of the Edit Message Composer's save button being wrongly
disabled
[\#6307](https://github.com/matrix-org/matrix-react-sdk/pull/6307)
* Do not generate a lockfile when running in CI
[\#6327](https://github.com/matrix-org/matrix-react-sdk/pull/6327)
* Update lockfile with correct dependencies
[\#6324](https://github.com/matrix-org/matrix-react-sdk/pull/6324)
* Clarify the keys we use when submitting rageshakes
[\#6321](https://github.com/matrix-org/matrix-react-sdk/pull/6321)
* Fix ImageView context menu
[\#6318](https://github.com/matrix-org/matrix-react-sdk/pull/6318)
* TypeScript migration
[\#6315](https://github.com/matrix-org/matrix-react-sdk/pull/6315)
* Move animation to compositor
[\#6310](https://github.com/matrix-org/matrix-react-sdk/pull/6310)
* Reorganize preferences
[\#5742](https://github.com/matrix-org/matrix-react-sdk/pull/5742)
* Fix being able to un-rotate images
[\#6313](https://github.com/matrix-org/matrix-react-sdk/pull/6313)
* Fix icon size in passphrase prompt
[\#6312](https://github.com/matrix-org/matrix-react-sdk/pull/6312)
* Use sleep & defer from js-sdk instead of duplicating it
[\#6305](https://github.com/matrix-org/matrix-react-sdk/pull/6305)
* Convert EventTimeline, EventTimelineSet and TimelineWindow to TS
[\#6295](https://github.com/matrix-org/matrix-react-sdk/pull/6295)
* Comply with new member-delimiter-style rule
[\#6306](https://github.com/matrix-org/matrix-react-sdk/pull/6306)
* Fix Test Linting
[\#6304](https://github.com/matrix-org/matrix-react-sdk/pull/6304)
* Convert Markdown to TypeScript
[\#6303](https://github.com/matrix-org/matrix-react-sdk/pull/6303)
* Convert RoomHeader to TS
[\#6302](https://github.com/matrix-org/matrix-react-sdk/pull/6302)
* Prevent RoomDirectory from exploding when filterString is wrongly nulled
[\#6296](https://github.com/matrix-org/matrix-react-sdk/pull/6296)
* Add support for blurhash (MSC2448)
[\#5099](https://github.com/matrix-org/matrix-react-sdk/pull/5099)
* Remove rateLimitedFunc
[\#6300](https://github.com/matrix-org/matrix-react-sdk/pull/6300)
* Convert some Key Verification classes to TypeScript
[\#6299](https://github.com/matrix-org/matrix-react-sdk/pull/6299)
* Typescript conversion of Composer components and more
[\#6292](https://github.com/matrix-org/matrix-react-sdk/pull/6292)
* Upgrade browserlist target versions
[\#6298](https://github.com/matrix-org/matrix-react-sdk/pull/6298)
* Fix browser crashing when searching for a malformed HTML tag
[\#6297](https://github.com/matrix-org/matrix-react-sdk/pull/6297)
* Add custom audio player
[\#6264](https://github.com/matrix-org/matrix-react-sdk/pull/6264)
* Lint MXC APIs to centralise access
[\#6293](https://github.com/matrix-org/matrix-react-sdk/pull/6293)
* Remove reminescent references to the tinter
[\#6290](https://github.com/matrix-org/matrix-react-sdk/pull/6290)
* More js-sdk type consolidation
[\#6263](https://github.com/matrix-org/matrix-react-sdk/pull/6263)
* Convert MessagePanel, TimelinePanel, ScrollPanel, and more to Typescript
[\#6243](https://github.com/matrix-org/matrix-react-sdk/pull/6243)
* Migrate to `eslint-plugin-matrix-org`
[\#6285](https://github.com/matrix-org/matrix-react-sdk/pull/6285)
* Avoid cyclic dependencies by moving watchers out of constructor
[\#6287](https://github.com/matrix-org/matrix-react-sdk/pull/6287)
* Add spacing between toast buttons with cross browser support in mind
[\#6284](https://github.com/matrix-org/matrix-react-sdk/pull/6284)
* Deprecate Tinter and TintableSVG
[\#6279](https://github.com/matrix-org/matrix-react-sdk/pull/6279)
* Migrate FilePanel to TypeScript
[\#6283](https://github.com/matrix-org/matrix-react-sdk/pull/6283)
Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05) Changes in [3.25.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.25.0) (2021-07-05)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.25.0-rc.1...v3.25.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.25.0", "version": "3.26.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -79,7 +79,7 @@
"katex": "^0.12.0", "katex": "^0.12.0",
"linkifyjs": "^2.1.9", "linkifyjs": "^2.1.9",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"matrix-js-sdk": "12.0.1", "matrix-js-sdk": "12.1.0",
"matrix-widget-api": "^0.1.0-beta.15", "matrix-widget-api": "^0.1.0-beta.15",
"minimist": "^1.2.5", "minimist": "^1.2.5",
"opus-recorder": "^8.0.3", "opus-recorder": "^8.0.3",

View file

@ -150,6 +150,7 @@
@import "./views/elements/_StyledCheckbox.scss"; @import "./views/elements/_StyledCheckbox.scss";
@import "./views/elements/_StyledRadioButton.scss"; @import "./views/elements/_StyledRadioButton.scss";
@import "./views/elements/_SyntaxHighlight.scss"; @import "./views/elements/_SyntaxHighlight.scss";
@import "./views/elements/_TagComposer.scss";
@import "./views/elements/_TextWithTooltip.scss"; @import "./views/elements/_TextWithTooltip.scss";
@import "./views/elements/_ToggleSwitch.scss"; @import "./views/elements/_ToggleSwitch.scss";
@import "./views/elements/_Tooltip.scss"; @import "./views/elements/_Tooltip.scss";
@ -165,6 +166,7 @@
@import "./views/messages/_MEmoteBody.scss"; @import "./views/messages/_MEmoteBody.scss";
@import "./views/messages/_MFileBody.scss"; @import "./views/messages/_MFileBody.scss";
@import "./views/messages/_MImageBody.scss"; @import "./views/messages/_MImageBody.scss";
@import "./views/messages/_MImageReplyBody.scss";
@import "./views/messages/_MJitsiWidgetEvent.scss"; @import "./views/messages/_MJitsiWidgetEvent.scss";
@import "./views/messages/_MNoticeBody.scss"; @import "./views/messages/_MNoticeBody.scss";
@import "./views/messages/_MStickerBody.scss"; @import "./views/messages/_MStickerBody.scss";
@ -214,6 +216,7 @@
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_ReplyTile.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss";
@import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomHeader.scss";
@import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomList.scss";
@ -262,9 +265,9 @@
@import "./views/toasts/_NonUrgentEchoFailureToast.scss"; @import "./views/toasts/_NonUrgentEchoFailureToast.scss";
@import "./views/verification/_VerificationShowSas.scss"; @import "./views/verification/_VerificationShowSas.scss";
@import "./views/voip/_CallContainer.scss"; @import "./views/voip/_CallContainer.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_CallView.scss"; @import "./views/voip/_CallView.scss";
@import "./views/voip/_CallViewForRoom.scss"; @import "./views/voip/_CallViewForRoom.scss";
@import "./views/voip/_CallPreview.scss";
@import "./views/voip/_DialPad.scss"; @import "./views/voip/_DialPad.scss";
@import "./views/voip/_DialPadContextMenu.scss"; @import "./views/voip/_DialPadContextMenu.scss";
@import "./views/voip/_DialPadModal.scss"; @import "./views/voip/_DialPadModal.scss";

View file

@ -16,22 +16,45 @@ limitations under the License.
.mx_ReplyThread { .mx_ReplyThread {
margin-top: 0; margin-top: 0;
}
.mx_ReplyThread .mx_DateSeparator {
font-size: 1em !important;
margin-top: 0;
margin-bottom: 0;
padding-bottom: 1px;
bottom: -5px;
}
.mx_ReplyThread_show {
cursor: pointer;
}
blockquote.mx_ReplyThread {
margin-left: 0; margin-left: 0;
margin-right: 0;
margin-bottom: 8px;
padding-left: 10px; padding-left: 10px;
border-left: 4px solid $blockquote-bar-color; border-left: 4px solid $button-bg-color;
.mx_ReplyThread_show {
cursor: pointer;
}
&.mx_ReplyThread_color1 {
border-left-color: $username-variant1-color;
}
&.mx_ReplyThread_color2 {
border-left-color: $username-variant2-color;
}
&.mx_ReplyThread_color3 {
border-left-color: $username-variant3-color;
}
&.mx_ReplyThread_color4 {
border-left-color: $username-variant4-color;
}
&.mx_ReplyThread_color5 {
border-left-color: $username-variant5-color;
}
&.mx_ReplyThread_color6 {
border-left-color: $username-variant6-color;
}
&.mx_ReplyThread_color7 {
border-left-color: $username-variant7-color;
}
&.mx_ReplyThread_color8 {
border-left-color: $username-variant8-color;
}
} }

View file

@ -46,7 +46,7 @@ limitations under the License.
width: $font-16px; width: $font-16px;
} }
> input[type=radio] { input[type=radio] {
// Remove the OS's representation // Remove the OS's representation
margin: 0; margin: 0;
padding: 0; padding: 0;
@ -112,6 +112,12 @@ limitations under the License.
} }
} }
} }
.mx_RadioButton_innerLabel {
display: flex;
position: relative;
top: 4px;
}
} }
.mx_RadioButton_outlined { .mx_RadioButton_outlined {

View file

@ -0,0 +1,77 @@
/*
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_TagComposer {
.mx_TagComposer_input {
display: flex;
.mx_Field {
flex: 1;
margin: 0; // override from field styles
}
.mx_AccessibleButton {
min-width: 70px;
padding: 0; // override from button styles
margin-left: 16px; // distance from <Field>
}
.mx_Field, .mx_Field input, .mx_AccessibleButton {
// So they look related to each other by feeling the same
border-radius: 8px;
}
}
.mx_TagComposer_tags {
display: flex;
flex-wrap: wrap;
margin-top: 12px; // this plus 12px from the tags makes 24px from the input
.mx_TagComposer_tag {
padding: 6px 8px 8px 12px;
position: relative;
margin-right: 12px;
margin-top: 12px;
// Cheaty way to get an opacified variable colour background
&::before {
content: '';
border-radius: 20px;
background-color: $tertiary-fg-color;
opacity: 0.15;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
// Pass through the pointer otherwise we have effectively put a whole div
// on top of the component, which makes it hard to interact with buttons.
pointer-events: none;
}
}
.mx_AccessibleButton {
background-image: url('$(res)/img/subtract.svg');
width: 16px;
height: 16px;
margin-left: 8px;
display: inline-block;
vertical-align: middle;
cursor: pointer;
}
}
}

View file

@ -83,12 +83,12 @@ limitations under the License.
mask-size: cover; mask-size: cover;
mask-image: url('$(res)/img/element-icons/room/composer/attach.svg'); mask-image: url('$(res)/img/element-icons/room/composer/attach.svg');
background-color: $message-body-panel-icon-fg-color; background-color: $message-body-panel-icon-fg-color;
width: 13px; width: 15px;
height: 15px; height: 15px;
position: absolute; position: absolute;
top: 8px; top: 8px;
left: 9px; left: 8px;
} }
} }

View file

@ -0,0 +1,37 @@
/*
Copyright 2020 Tulir Asokan <tulir@maunium.net>
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_MImageReplyBody {
display: flex;
.mx_MImageBody_thumbnail_container {
flex: 1;
margin-right: 4px;
}
.mx_MImageReplyBody_info {
flex: 1;
.mx_MImageReplyBody_sender {
grid-area: sender;
}
.mx_MImageReplyBody_filename {
grid-area: filename;
}
}
}

View file

@ -198,8 +198,9 @@ $irc-line-height: $font-18px;
.mx_ReplyThread { .mx_ReplyThread {
margin: 0; margin: 0;
.mx_SenderProfile { .mx_SenderProfile {
order: unset;
max-width: unset;
width: unset; width: unset;
max-width: var(--name-width);
background: transparent; background: transparent;
} }

View file

@ -22,28 +22,34 @@ limitations under the License.
max-height: 50vh; max-height: 50vh;
overflow: auto; overflow: auto;
box-shadow: 0px -16px 32px $composer-shadow-color; box-shadow: 0px -16px 32px $composer-shadow-color;
.mx_ReplyPreview_section {
border-bottom: 1px solid $primary-hairline-color;
.mx_ReplyPreview_header {
margin: 8px;
color: $primary-fg-color;
font-weight: 400;
opacity: 0.4;
}
.mx_ReplyPreview_tile {
margin: 0 8px;
}
.mx_ReplyPreview_title {
float: left;
}
.mx_ReplyPreview_cancel {
float: right;
cursor: pointer;
display: flex;
}
.mx_ReplyPreview_clear {
clear: both;
}
}
} }
.mx_ReplyPreview_section {
border-bottom: 1px solid $primary-hairline-color;
}
.mx_ReplyPreview_header {
margin: 12px;
color: $primary-fg-color;
font-weight: 400;
opacity: 0.4;
}
.mx_ReplyPreview_title {
float: left;
}
.mx_ReplyPreview_cancel {
float: right;
cursor: pointer;
}
.mx_ReplyPreview_clear {
clear: both;
}

View file

@ -0,0 +1,119 @@
/*
Copyright 2020 Tulir Asokan <tulir@maunium.net>
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_ReplyTile {
position: relative;
padding: 2px 0;
font-size: $font-14px;
line-height: $font-16px;
&.mx_ReplyTile_audio .mx_MFileBody_info_icon::before {
mask-image: url("$(res)/img/element-icons/speaker.svg");
}
&.mx_ReplyTile_video .mx_MFileBody_info_icon::before {
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
}
.mx_MFileBody {
.mx_MFileBody_info {
margin: 5px 0;
}
.mx_MFileBody_download {
display: none;
}
}
> a {
display: flex;
flex-direction: column;
text-decoration: none;
color: $primary-fg-color;
}
.mx_RedactedBody {
padding: 4px 0 2px 20px;
&::before {
height: 13px;
width: 13px;
top: 5px;
}
}
// We do reply size limiting with CSS to avoid duplicating the TextualBody component.
.mx_EventTile_content {
$reply-lines: 2;
$line-height: $font-22px;
pointer-events: none;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $reply-lines;
line-height: $line-height;
.mx_EventTile_body.mx_EventTile_bigEmoji {
line-height: $line-height !important;
font-size: $font-14px !important; // Override the big emoji override
}
// Hide line numbers
.mx_EventTile_lineNumbers {
display: none;
}
// Hack to cut content in <pre> tags too
.mx_EventTile_pre_container > pre {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: $reply-lines;
padding: 4px;
}
.markdown-body blockquote,
.markdown-body dl,
.markdown-body ol,
.markdown-body p,
.markdown-body pre,
.markdown-body table,
.markdown-body ul {
margin-bottom: 4px;
}
}
&.mx_ReplyTile_info {
padding-top: 0;
}
.mx_SenderProfile {
font-size: $font-14px;
line-height: $font-17px;
display: inline-block; // anti-zalgo, with overflow hidden
padding: 0;
margin: 0;
// truncate long display names
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}

View file

@ -193,6 +193,10 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/settings.svg'); mask-image: url('$(res)/img/element-icons/settings.svg');
} }
.mx_RoomTile_iconCopyLink::before {
mask-image: url('$(res)/img/element-icons/link.svg');
}
.mx_RoomTile_iconInvite::before { .mx_RoomTile_iconInvite::before {
mask-image: url('$(res)/img/element-icons/room/invite.svg'); mask-image: url('$(res)/img/element-icons/room/invite.svg');
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015 - 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,82 +14,79 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_UserNotifSettings_tableRow { .mx_UserNotifSettings {
display: table-row; color: $primary-fg-color; // override from default settings page styles
}
.mx_UserNotifSettings_inputCell { .mx_UserNotifSettings_pushRulesTable {
display: table-cell; width: calc(100% + 12px); // +12px to line up center of 'Noisy' column with toggle switches
padding-bottom: 8px; table-layout: fixed;
padding-right: 8px; border-collapse: collapse;
width: 16px; border-spacing: 0;
} margin-top: 40px;
.mx_UserNotifSettings_labelCell { tr > th {
padding-bottom: 8px; font-weight: $font-semi-bold;
width: 400px; }
display: table-cell;
}
.mx_UserNotifSettings_pushRulesTableWrapper { tr > th:first-child {
padding-bottom: 8px; text-align: left;
} font-size: $font-18px;
}
.mx_UserNotifSettings_pushRulesTable { tr > th:nth-child(n + 2) {
width: 100%; color: $secondary-fg-color;
table-layout: fixed; font-size: $font-12px;
} vertical-align: middle;
width: 66px;
}
.mx_UserNotifSettings_pushRulesTable thead { tr > td:nth-child(n + 2) {
font-weight: bold; text-align: center;
} }
.mx_UserNotifSettings_pushRulesTable tbody th { tr > td {
font-weight: 400; padding-top: 8px;
} }
.mx_UserNotifSettings_pushRulesTable tbody th:first-child { // Override StyledRadioButton default styles
text-align: left; .mx_RadioButton {
} justify-content: center;
.mx_UserNotifSettings_keywords { .mx_RadioButton_content {
cursor: pointer; display: none;
color: $accent-color; }
}
.mx_UserNotifSettings_devicesTable td { .mx_RadioButton_spacer {
padding-left: 20px; display: none;
padding-right: 20px; }
} }
}
.mx_UserNotifSettings_notifTable { .mx_UserNotifSettings_floatingSection {
display: table; margin-top: 40px;
position: relative;
}
.mx_UserNotifSettings_notifTable .mx_Spinner { & > div:first-child { // section header
position: absolute; font-size: $font-18px;
} font-weight: $font-semi-bold;
}
.mx_NotificationSound_soundUpload { > table {
display: none; border-collapse: collapse;
} border-spacing: 0;
margin-top: 8px;
.mx_NotificationSound_browse { tr > td:first-child {
color: $accent-color; // Just for a bit of spacing
border: 1px solid $accent-color; padding-right: 8px;
background-color: transparent; }
} }
}
.mx_NotificationSound_save { .mx_UserNotifSettings_clearNotifsButton {
margin-left: 5px; margin-top: 8px;
color: white; }
background-color: $accent-color;
}
.mx_NotificationSound_resetSound { .mx_TagComposer {
margin-top: 5px; margin-top: 35px; // lots of distance from the last line of the table
color: white; }
border: $warning-color;
background-color: $warning-color;
} }

View file

@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.97991 1.48403L4 4.80062L1 4.80062C0.447715 4.80062 0 5.24834 0 5.80062V10.2006C0 10.7529 0.447714 11.2006 0.999999 11.2006L4 11.2006L7.97991 14.5172C8.30557 14.7886 8.8 14.557 8.8 14.1331V1.86814C8.8 1.44422 8.30557 1.21265 7.97991 1.48403Z" fill="#737D8C"/>
<path d="M14.1258 2.79107C13.8998 2.50044 13.4809 2.44808 13.1903 2.67413C12.9 2.89992 12.8475 3.3181 13.0726 3.6087L13.0731 3.60935L13.0738 3.61021L13.0829 3.62231C13.0917 3.63418 13.1059 3.65355 13.1248 3.68011C13.1625 3.73326 13.2187 3.81496 13.2872 3.92256C13.4243 4.13812 13.6097 4.45554 13.7955 4.85371C14.169 5.65407 14.5329 6.75597 14.5329 8.00036C14.5329 9.24475 14.169 10.3466 13.7955 11.147C13.6097 11.5452 13.4243 11.8626 13.2872 12.0782C13.2187 12.1858 13.1625 12.2675 13.1248 12.3206C13.1059 12.3472 13.0917 12.3665 13.0829 12.3784L13.0738 12.3905L13.0731 12.3914L13.0725 12.3921C12.8475 12.6827 12.9 13.1008 13.1903 13.3266C13.4809 13.5526 13.8998 13.5003 14.1258 13.2097L13.629 12.8232C14.1258 13.2096 14.1258 13.2097 14.1258 13.2097L14.1272 13.2079L14.1291 13.2055L14.1346 13.1982L14.1523 13.1748C14.1669 13.1552 14.187 13.1277 14.2119 13.0926C14.2617 13.0225 14.3305 12.9221 14.4121 12.794C14.5749 12.5381 14.7895 12.1698 15.0037 11.7109C15.4302 10.7969 15.8663 9.49883 15.8663 8.00036C15.8663 6.50189 15.4302 5.20379 15.0037 4.28987C14.7895 3.83089 14.5749 3.4626 14.4121 3.20673C14.3305 3.07862 14.2617 2.97818 14.2119 2.90811C14.187 2.87306 14.1669 2.84556 14.1523 2.82596L14.1346 2.80249L14.1291 2.79525L14.1272 2.79278L14.1264 2.79183C14.1264 2.79183 14.1258 2.79107 13.5996 3.20036L14.1258 2.79107Z" fill="#737D8C"/>
<path d="M11.7264 5.19121C11.5004 4.90058 11.0815 4.84823 10.7909 5.07427C10.501 5.29973 10.4482 5.71698 10.6722 6.00752L10.6745 6.01057C10.6775 6.01457 10.6831 6.02223 10.691 6.03338C10.7069 6.05572 10.7318 6.09189 10.7628 6.14057C10.8249 6.23827 10.9103 6.38426 10.9961 6.56815C11.1696 6.93993 11.3335 7.44183 11.3335 8.00051C11.3335 8.55918 11.1696 9.06108 10.9961 9.43287C10.9103 9.61675 10.8249 9.76275 10.7628 9.86045C10.7318 9.90912 10.7069 9.94529 10.691 9.96763C10.6831 9.97879 10.6775 9.98645 10.6745 9.99044L10.6722 9.9935C10.4482 10.284 10.501 10.7013 10.7909 10.9267C11.0815 11.1528 11.5004 11.1004 11.7264 10.8098L11.2002 10.4005C11.7264 10.8098 11.7264 10.8098 11.7264 10.8098L11.7276 10.8083L11.7291 10.8064L11.7329 10.8014L11.7439 10.7868C11.7526 10.7751 11.7642 10.7593 11.7781 10.7396C11.806 10.7004 11.8436 10.6455 11.8876 10.5763C11.9755 10.4383 12.0901 10.2414 12.2043 9.99672C12.4308 9.51136 12.6669 8.81326 12.6669 8.00051C12.6669 7.18775 12.4308 6.48965 12.2043 6.0043C12.0901 5.75961 11.9755 5.56275 11.8876 5.42473C11.8436 5.35555 11.806 5.30065 11.7781 5.26138C11.7642 5.24173 11.7526 5.22596 11.7439 5.21422L11.7329 5.19964L11.7291 5.19465L11.7276 5.19274L11.727 5.19193C11.727 5.19193 11.7264 5.19121 11.2002 5.60051L11.7264 5.19121Z" fill="#737D8C"/>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

3
res/img/subtract.svg Normal file
View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8 16C12.4183 16 16 12.4183 16 8C16 3.58167 12.4183 0 8 0C3.58173 0 0 3.58167 0 8C0 12.4183 3.58173 16 8 16ZM3.96967 5.0304L6.93933 8L3.96967 10.9697L5.03033 12.0304L8 9.06067L10.9697 12.0304L12.0303 10.9697L9.06067 8L12.0303 5.0304L10.9697 3.96973L8 6.93945L5.03033 3.96973L3.96967 5.0304Z" fill="#8D97A5"/>
</svg>

After

Width:  |  Height:  |  Size: 461 B

View file

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import RoomViewStore from './stores/RoomViewStore'; import RoomViewStore from './stores/RoomViewStore';
import { EventSubscription } from 'fbemitter';
type Listener = (isActive: boolean) => void; type Listener = (isActive: boolean) => void;
@ -30,7 +31,7 @@ type Listener = (isActive: boolean) => void;
export class ActiveRoomObserver { export class ActiveRoomObserver {
private listeners: {[key: string]: Listener[]} = {}; private listeners: {[key: string]: Listener[]} = {};
private _activeRoomId = RoomViewStore.getRoomId(); private _activeRoomId = RoomViewStore.getRoomId();
private readonly roomStoreToken: string; private readonly roomStoreToken: EventSubscription;
constructor() { constructor() {
// TODO: We could self-destruct when the last listener goes away, or at least stop listening. // TODO: We could self-destruct when the last listener goes away, or at least stop listening.

View file

@ -18,10 +18,11 @@ import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { User } from "matrix-js-sdk/src/models/user"; import { User } from "matrix-js-sdk/src/models/user";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials"; import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { split } from "lodash";
import DMRoomMap from './utils/DMRoomMap'; import DMRoomMap from './utils/DMRoomMap';
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
import SettingsStore from "./settings/SettingsStore"; import SpaceStore from "./stores/SpaceStore";
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember( export function avatarUrlForMember(
@ -122,27 +123,13 @@ export function getInitialLetter(name: string): string {
return undefined; return undefined;
} }
let idx = 0;
const initial = name[0]; const initial = name[0];
if ((initial === '@' || initial === '#' || initial === '+') && name[1]) { if ((initial === '@' || initial === '#' || initial === '+') && name[1]) {
idx++; name = name.substring(1);
} }
// string.codePointAt(0) would do this, but that isn't supported by // rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
// some browsers (notably PhantomJS). return split(name, "", 1)[0];
let chars = 1;
const first = name.charCodeAt(idx);
// check if its the start of a surrogate pair
if (first >= 0xD800 && first <= 0xDBFF && name[idx+1]) {
const second = name.charCodeAt(idx+1);
if (second >= 0xDC00 && second <= 0xDFFF) {
chars++;
}
}
const firstChar = name.substring(idx, idx+chars);
return firstChar.toUpperCase();
} }
export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) { export function avatarUrlForRoom(room: Room, width: number, height: number, resizeMethod?: ResizeMethod) {
@ -153,7 +140,7 @@ export function avatarUrlForRoom(room: Room, width: number, height: number, resi
} }
// space rooms cannot be DMs so skip the rest // space rooms cannot be DMs so skip the rest
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return null; if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return null;
let otherMember = null; let otherMember = null;
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);

View file

@ -25,7 +25,6 @@ import _linkifyElement from 'linkifyjs/element';
import _linkifyString from 'linkifyjs/string'; import _linkifyString from 'linkifyjs/string';
import classNames from 'classnames'; import classNames from 'classnames';
import EMOJIBASE_REGEX from 'emojibase-regex'; import EMOJIBASE_REGEX from 'emojibase-regex';
import url from 'url';
import katex from 'katex'; import katex from 'katex';
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
import { IContent } from 'matrix-js-sdk/src/models/event'; import { IContent } from 'matrix-js-sdk/src/models/event';
@ -153,10 +152,8 @@ export function getHtmlText(insaneHtml: string): string {
*/ */
export function isUrlPermitted(inputUrl: string): boolean { export function isUrlPermitted(inputUrl: string): boolean {
try { try {
const parsed = url.parse(inputUrl);
if (!parsed.protocol) return false;
// URL parser protocol includes the trailing colon // URL parser protocol includes the trailing colon
return PERMITTED_URL_SCHEMES.includes(parsed.protocol.slice(0, -1)); return PERMITTED_URL_SCHEMES.includes(new URL(inputUrl).protocol.slice(0, -1));
} catch (e) { } catch (e) {
return false; return false;
} }

View file

@ -21,6 +21,7 @@ import { createClient } from 'matrix-js-sdk/src/matrix';
import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes";
import { QueryDict } from 'matrix-js-sdk/src/utils';
import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg'; import { IMatrixClientCreds, MatrixClientPeg } from './MatrixClientPeg';
import SecurityCustomisations from "./customisations/Security"; import SecurityCustomisations from "./customisations/Security";
@ -65,7 +66,7 @@ interface ILoadSessionOpts {
guestIsUrl?: string; guestIsUrl?: string;
ignoreGuest?: boolean; ignoreGuest?: boolean;
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
fragmentQueryParams?: Record<string, string>; fragmentQueryParams?: QueryDict;
} }
/** /**
@ -118,8 +119,8 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
) { ) {
console.log("Using guest access credentials"); console.log("Using guest access credentials");
return doSetLoggedIn({ return doSetLoggedIn({
userId: fragmentQueryParams.guest_user_id, userId: fragmentQueryParams.guest_user_id as string,
accessToken: fragmentQueryParams.guest_access_token, accessToken: fragmentQueryParams.guest_access_token as string,
homeserverUrl: guestHsUrl, homeserverUrl: guestHsUrl,
identityServerUrl: guestIsUrl, identityServerUrl: guestIsUrl,
guest: true, guest: true,
@ -173,7 +174,7 @@ export async function getStoredSessionOwner(): Promise<[string, boolean]> {
* login, else false * login, else false
*/ */
export function attemptTokenLogin( export function attemptTokenLogin(
queryParams: Record<string, string>, queryParams: QueryDict,
defaultDeviceDisplayName?: string, defaultDeviceDisplayName?: string,
fragmentAfterLogin?: string, fragmentAfterLogin?: string,
): Promise<boolean> { ): Promise<boolean> {
@ -198,7 +199,7 @@ export function attemptTokenLogin(
homeserver, homeserver,
identityServer, identityServer,
"m.login.token", { "m.login.token", {
token: queryParams.loginToken, token: queryParams.loginToken as string,
initial_device_display_name: defaultDeviceDisplayName, initial_device_display_name: defaultDeviceDisplayName,
}, },
).then(function(creds) { ).then(function(creds) {

View file

@ -328,7 +328,7 @@ export const Notifier = {
onEvent: function(ev: MatrixEvent) { onEvent: function(ev: MatrixEvent) {
if (!this.isSyncing) return; // don't alert for any messages initially if (!this.isSyncing) return; // don't alert for any messages initially
if (ev.sender && ev.sender.userId === MatrixClientPeg.get().credentials.userId) return; if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return;
MatrixClientPeg.get().decryptEventIfNeeded(ev); MatrixClientPeg.get().decryptEventIfNeeded(ev);

View file

@ -13,7 +13,6 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { MatrixClientPeg } from './MatrixClientPeg'; import { MatrixClientPeg } from './MatrixClientPeg';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
@ -32,7 +31,7 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
// any text to display at all. For this reason they return deferred values // any text to display at all. For this reason they return deferred values
// to avoid the expense of looking up translations when they're not needed. // to avoid the expense of looking up translations when they're not needed.
function textForMemberEvent(ev): () => string | null { function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
// XXX: SYJS-16 "sender is sometimes null for join messages" // XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender(); const senderName = ev.sender ? ev.sender.name : ev.getSender();
const targetName = ev.target ? ev.target.name : ev.getStateKey(); const targetName = ev.target ? ev.target.name : ev.getStateKey();
@ -84,7 +83,7 @@ function textForMemberEvent(ev): () => string | null {
return () => _t('%(senderName)s changed their profile picture', { senderName }); return () => _t('%(senderName)s changed their profile picture', { senderName });
} else if (!prevContent.avatar_url && content.avatar_url) { } else if (!prevContent.avatar_url && content.avatar_url) {
return () => _t('%(senderName)s set a profile picture', { senderName }); return () => _t('%(senderName)s set a profile picture', { senderName });
} else if (SettingsStore.getValue("showHiddenEventsInTimeline")) { } else if (showHiddenEvents ?? SettingsStore.getValue("showHiddenEventsInTimeline")) {
// This is a null rejoin, it will only be visible if using 'show hidden events' (labs) // This is a null rejoin, it will only be visible if using 'show hidden events' (labs)
return () => _t("%(senderName)s made no change", { senderName }); return () => _t("%(senderName)s made no change", { senderName });
} else { } else {
@ -127,7 +126,7 @@ function textForMemberEvent(ev): () => string | null {
} }
} }
function textForTopicEvent(ev): () => string | null { function textForTopicEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', { return () => _t('%(senderDisplayName)s changed the topic to "%(topic)s".', {
senderDisplayName, senderDisplayName,
@ -135,7 +134,7 @@ function textForTopicEvent(ev): () => string | null {
}); });
} }
function textForRoomNameEvent(ev): () => string | null { function textForRoomNameEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
if (!ev.getContent().name || ev.getContent().name.trim().length === 0) { if (!ev.getContent().name || ev.getContent().name.trim().length === 0) {
@ -154,12 +153,12 @@ function textForRoomNameEvent(ev): () => string | null {
}); });
} }
function textForTombstoneEvent(ev): () => string | null { function textForTombstoneEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName }); return () => _t('%(senderDisplayName)s upgraded this room.', { senderDisplayName });
} }
function textForJoinRulesEvent(ev): () => string | null { function textForJoinRulesEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().join_rule) { switch (ev.getContent().join_rule) {
case "public": case "public":
@ -179,7 +178,7 @@ function textForJoinRulesEvent(ev): () => string | null {
} }
} }
function textForGuestAccessEvent(ev): () => string | null { function textForGuestAccessEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
switch (ev.getContent().guest_access) { switch (ev.getContent().guest_access) {
case "can_join": case "can_join":
@ -195,7 +194,7 @@ function textForGuestAccessEvent(ev): () => string | null {
} }
} }
function textForRelatedGroupsEvent(ev): () => string | null { function textForRelatedGroupsEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const groups = ev.getContent().groups || []; const groups = ev.getContent().groups || [];
const prevGroups = ev.getPrevContent().groups || []; const prevGroups = ev.getPrevContent().groups || [];
@ -225,7 +224,7 @@ function textForRelatedGroupsEvent(ev): () => string | null {
} }
} }
function textForServerACLEvent(ev): () => string | null { function textForServerACLEvent(ev: MatrixEvent): () => string | null {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const prevContent = ev.getPrevContent(); const prevContent = ev.getPrevContent();
const current = ev.getContent(); const current = ev.getContent();
@ -255,7 +254,7 @@ function textForServerACLEvent(ev): () => string | null {
return getText; return getText;
} }
function textForMessageEvent(ev): () => string | null { function textForMessageEvent(ev: MatrixEvent): () => string | null {
return () => { return () => {
const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderDisplayName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
let message = senderDisplayName + ': ' + ev.getContent().body; let message = senderDisplayName + ': ' + ev.getContent().body;
@ -268,7 +267,7 @@ function textForMessageEvent(ev): () => string | null {
}; };
} }
function textForCanonicalAliasEvent(ev): () => string | null { function textForCanonicalAliasEvent(ev: MatrixEvent): () => string | null {
const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender(); const senderName = ev.sender && ev.sender.name ? ev.sender.name : ev.getSender();
const oldAlias = ev.getPrevContent().alias; const oldAlias = ev.getPrevContent().alias;
const oldAltAliases = ev.getPrevContent().alt_aliases || []; const oldAltAliases = ev.getPrevContent().alt_aliases || [];
@ -319,7 +318,7 @@ function textForCanonicalAliasEvent(ev): () => string | null {
}); });
} }
function textForCallAnswerEvent(event): () => string | null { function textForCallAnswerEvent(event: MatrixEvent): () => string | null {
return () => { return () => {
const senderName = event.sender ? event.sender.name : _t('Someone'); const senderName = event.sender ? event.sender.name : _t('Someone');
const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)'); const supported = MatrixClientPeg.get().supportsVoip() ? '' : _t('(not supported by this browser)');
@ -327,7 +326,7 @@ function textForCallAnswerEvent(event): () => string | null {
}; };
} }
function textForCallHangupEvent(event): () => string | null { function textForCallHangupEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
const eventContent = event.getContent(); const eventContent = event.getContent();
let getReason = () => ""; let getReason = () => "";
@ -364,14 +363,14 @@ function textForCallHangupEvent(event): () => string | null {
return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason(); return () => _t('%(senderName)s ended the call.', { senderName: getSenderName() }) + ' ' + getReason();
} }
function textForCallRejectEvent(event): () => string | null { function textForCallRejectEvent(event: MatrixEvent): () => string | null {
return () => { return () => {
const senderName = event.sender ? event.sender.name : _t('Someone'); const senderName = event.sender ? event.sender.name : _t('Someone');
return _t('%(senderName)s declined the call.', { senderName }); return _t('%(senderName)s declined the call.', { senderName });
}; };
} }
function textForCallInviteEvent(event): () => string | null { function textForCallInviteEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone'); const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event? // FIXME: Find a better way to determine this from the event?
let isVoice = true; let isVoice = true;
@ -403,7 +402,7 @@ function textForCallInviteEvent(event): () => string | null {
} }
} }
function textForThreePidInviteEvent(event): () => string | null { function textForThreePidInviteEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
if (!isValid3pidInvite(event)) { if (!isValid3pidInvite(event)) {
@ -419,7 +418,7 @@ function textForThreePidInviteEvent(event): () => string | null {
}); });
} }
function textForHistoryVisibilityEvent(event): () => string | null { function textForHistoryVisibilityEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
switch (event.getContent().history_visibility) { switch (event.getContent().history_visibility) {
case 'invited': case 'invited':
@ -441,7 +440,7 @@ function textForHistoryVisibilityEvent(event): () => string | null {
} }
// Currently will only display a change if a user's power level is changed // Currently will only display a change if a user's power level is changed
function textForPowerEvent(event): () => string | null { function textForPowerEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender ? event.sender.name : event.getSender(); const senderName = event.sender ? event.sender.name : event.getSender();
if (!event.getPrevContent() || !event.getPrevContent().users || if (!event.getPrevContent() || !event.getPrevContent().users ||
!event.getContent() || !event.getContent().users) { !event.getContent() || !event.getContent().users) {
@ -523,7 +522,7 @@ function textForPinnedEvent(event: MatrixEvent, allowJSX: boolean): () => string
return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName }); return () => _t("%(senderName)s changed the pinned messages for the room.", { senderName });
} }
function textForWidgetEvent(event): () => string | null { function textForWidgetEvent(event: MatrixEvent): () => string | null {
const senderName = event.getSender(); const senderName = event.getSender();
const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent(); const { name: prevName, type: prevType, url: prevUrl } = event.getPrevContent();
const { name, type, url } = event.getContent() || {}; const { name, type, url } = event.getContent() || {};
@ -553,12 +552,12 @@ function textForWidgetEvent(event): () => string | null {
} }
} }
function textForWidgetLayoutEvent(event): () => string | null { function textForWidgetLayoutEvent(event: MatrixEvent): () => string | null {
const senderName = event.sender?.name || event.getSender(); const senderName = event.sender?.name || event.getSender();
return () => _t("%(senderName)s has updated the widget layout", { senderName }); return () => _t("%(senderName)s has updated the widget layout", { senderName });
} }
function textForMjolnirEvent(event): () => string | null { function textForMjolnirEvent(event: MatrixEvent): () => string | null {
const senderName = event.getSender(); const senderName = event.getSender();
const { entity: prevEntity } = event.getPrevContent(); const { entity: prevEntity } = event.getPrevContent();
const { entity, recommendation, reason } = event.getContent(); const { entity, recommendation, reason } = event.getContent();
@ -646,7 +645,9 @@ function textForMjolnirEvent(event): () => string | null {
} }
interface IHandlers { interface IHandlers {
[type: string]: (ev: MatrixEvent, allowJSX?: boolean) => (() => string | JSX.Element | null); [type: string]:
(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean) =>
(() => string | JSX.Element | null);
} }
const handlers: IHandlers = { const handlers: IHandlers = {
@ -682,14 +683,27 @@ for (const evType of ALL_RULE_TYPES) {
stateHandlers[evType] = textForMjolnirEvent; stateHandlers[evType] = textForMjolnirEvent;
} }
export function hasText(ev): boolean { /**
* Determines whether the given event has text to display.
* @param ev The event
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
* to avoid hitting the settings store
*/
export function hasText(ev: MatrixEvent, showHiddenEvents?: boolean): boolean {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return Boolean(handler?.(ev)); return Boolean(handler?.(ev, false, showHiddenEvents));
} }
/**
* Gets the textual content of the given event.
* @param ev The event
* @param allowJSX Whether to output rich JSX content
* @param showHiddenEvents An optional cached setting value for showHiddenEventsInTimeline
* to avoid hitting the settings store
*/
export function textForEvent(ev: MatrixEvent): string; export function textForEvent(ev: MatrixEvent): string;
export function textForEvent(ev: MatrixEvent, allowJSX: true): string | JSX.Element; export function textForEvent(ev: MatrixEvent, allowJSX: true, showHiddenEvents?: boolean): string | JSX.Element;
export function textForEvent(ev: MatrixEvent, allowJSX = false): string | JSX.Element { export function textForEvent(ev: MatrixEvent, allowJSX = false, showHiddenEvents?: boolean): string | JSX.Element {
const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()]; const handler = (ev.isState() ? stateHandlers : handlers)[ev.getType()];
return handler?.(ev, allowJSX)?.() || ''; return handler?.(ev, allowJSX, showHiddenEvents)?.() || '';
} }

View file

@ -30,7 +30,7 @@ import { haveTileForEvent } from "./components/views/rooms/EventTile";
* @returns {boolean} True if the given event should affect the unread message count * @returns {boolean} True if the given event should affect the unread message count
*/ */
export function eventTriggersUnreadCount(ev: MatrixEvent): boolean { export function eventTriggersUnreadCount(ev: MatrixEvent): boolean {
if (ev.sender && ev.sender.userId == MatrixClientPeg.get().credentials.userId) { if (ev.getSender() === MatrixClientPeg.get().credentials.userId) {
return false; return false;
} }
@ -63,9 +63,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
// https://github.com/vector-im/element-web/issues/2427 // https://github.com/vector-im/element-web/issues/2427
// ...and possibly some of the others at // ...and possibly some of the others at
// https://github.com/vector-im/element-web/issues/3363 // https://github.com/vector-im/element-web/issues/3363
if (room.timeline.length && if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
room.timeline[room.timeline.length - 1].sender &&
room.timeline[room.timeline.length - 1].sender.userId === myUserId) {
return false; return false;
} }

View file

@ -27,8 +27,8 @@ import EmojiProvider from './EmojiProvider';
import NotifProvider from './NotifProvider'; import NotifProvider from './NotifProvider';
import { timeout } from "../utils/promise"; import { timeout } from "../utils/promise";
import AutocompleteProvider, { ICommand } from "./AutocompleteProvider"; import AutocompleteProvider, { ICommand } from "./AutocompleteProvider";
import SettingsStore from "../settings/SettingsStore";
import SpaceProvider from "./SpaceProvider"; import SpaceProvider from "./SpaceProvider";
import SpaceStore from "../stores/SpaceStore";
export interface ISelectionRange { export interface ISelectionRange {
beginning?: boolean; // whether the selection is in the first block of the editor or not beginning?: boolean; // whether the selection is in the first block of the editor or not
@ -58,8 +58,7 @@ const PROVIDERS = [
DuckDuckGoProvider, DuckDuckGoProvider,
]; ];
// as the spaces feature is device configurable only, and toggling it refreshes the page, we can do this here if (SpaceStore.spacesEnabled) {
if (SettingsStore.getValue("feature_spaces")) {
PROVIDERS.push(SpaceProvider); PROVIDERS.push(SpaceProvider);
} else { } else {
PROVIDERS.push(CommunityProvider); PROVIDERS.push(CommunityProvider);

View file

@ -28,7 +28,7 @@ import { PillCompletion } from './Components';
import { makeRoomPermalink } from "../utils/permalinks/Permalinks"; import { makeRoomPermalink } from "../utils/permalinks/Permalinks";
import { ICompletion, ISelectionRange } from "./Autocompleter"; import { ICompletion, ISelectionRange } from "./Autocompleter";
import RoomAvatar from '../components/views/avatars/RoomAvatar'; import RoomAvatar from '../components/views/avatars/RoomAvatar';
import SettingsStore from "../settings/SettingsStore"; import SpaceStore from "../stores/SpaceStore";
const ROOM_REGEX = /\B#\S*/g; const ROOM_REGEX = /\B#\S*/g;
@ -59,7 +59,8 @@ export default class RoomProvider extends AutocompleteProvider {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
let rooms = cli.getVisibleRooms(); let rooms = cli.getVisibleRooms();
if (SettingsStore.getValue("feature_spaces")) { // if spaces are enabled then filter them out here as they get their own autocomplete provider
if (SpaceStore.spacesEnabled) {
rooms = rooms.filter(r => !r.isSpaceRoom()); rooms = rooms.filter(r => !r.isSpaceRoom());
} }

View file

@ -63,6 +63,7 @@ import ToastContainer from './ToastContainer';
import MyGroups from "./MyGroups"; import MyGroups from "./MyGroups";
import UserView from "./UserView"; import UserView from "./UserView";
import GroupView from "./GroupView"; import GroupView from "./GroupView";
import SpaceStore from "../../stores/SpaceStore";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -631,7 +632,7 @@ class LoggedInView extends React.Component<IProps, IState> {
> >
<ToastContainer /> <ToastContainer />
<div ref={this._resizeContainer} className={bodyClasses}> <div ref={this._resizeContainer} className={bodyClasses}>
{ SettingsStore.getValue("feature_spaces") ? <SpacePanel /> : null } { SpaceStore.spacesEnabled ? <SpacePanel /> : null }
<LeftPanel <LeftPanel
isMinimized={this.props.collapseLhs || false} isMinimized={this.props.collapseLhs || false}
resizeNotifier={this.props.resizeNotifier} resizeNotifier={this.props.resizeNotifier}

View file

@ -19,7 +19,7 @@ import { createClient } from "matrix-js-sdk/src/matrix";
import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { sleep, defer, IDeferred } from "matrix-js-sdk/src/utils"; import { sleep, defer, IDeferred, QueryDict } from "matrix-js-sdk/src/utils";
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible'; import 'focus-visible';
@ -105,6 +105,8 @@ import VerificationRequestToast from '../views/toasts/VerificationRequestToast';
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore"; import UIStore, { UI_EVENTS } from "../../stores/UIStore";
import SoftLogout from './auth/SoftLogout'; import SoftLogout from './auth/SoftLogout';
import { makeRoomPermalink } from "../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../utils/strings";
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
export enum Views { export enum Views {
@ -153,7 +155,7 @@ const ONBOARDING_FLOW_STARTERS = [
interface IScreen { interface IScreen {
screen: string; screen: string;
params?: object; params?: QueryDict;
} }
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -183,9 +185,9 @@ interface IProps { // TODO type things better
onNewScreen: (screen: string, replaceLast: boolean) => void; onNewScreen: (screen: string, replaceLast: boolean) => void;
enableGuest?: boolean; enableGuest?: boolean;
// the queryParams extracted from the [real] query-string of the URI // the queryParams extracted from the [real] query-string of the URI
realQueryParams?: Record<string, string>; realQueryParams?: QueryDict;
// the initial queryParams extracted from the hash-fragment of the URI // the initial queryParams extracted from the hash-fragment of the URI
startingFragmentQueryParams?: Record<string, string>; startingFragmentQueryParams?: QueryDict;
// called when we have completed a token login // called when we have completed a token login
onTokenLoginCompleted?: () => void; onTokenLoginCompleted?: () => void;
// Represents the screen to display as a result of parsing the initial window.location // Represents the screen to display as a result of parsing the initial window.location
@ -193,7 +195,7 @@ interface IProps { // TODO type things better
// displayname, if any, to set on the device when logging in/registering. // displayname, if any, to set on the device when logging in/registering.
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
// A function that makes a registration URL // A function that makes a registration URL
makeRegistrationUrl: (object) => string; makeRegistrationUrl: (params: QueryDict) => string;
} }
interface IState { interface IState {
@ -296,7 +298,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) { if (this.screenAfterLogin.screen.startsWith("room/") && params['signurl'] && params['email']) {
// probably a threepid invite - try to store it // probably a threepid invite - try to store it
const roomId = this.screenAfterLogin.screen.substring("room/".length); const roomId = this.screenAfterLogin.screen.substring("room/".length);
ThreepidInviteStore.instance.storeInvite(roomId, params as IThreepidInviteWireFormat); ThreepidInviteStore.instance.storeInvite(roomId, params as unknown as IThreepidInviteWireFormat);
} }
} }
@ -627,6 +629,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case 'forget_room': case 'forget_room':
this.forgetRoom(payload.room_id); this.forgetRoom(payload.room_id);
break; break;
case 'copy_room':
this.copyRoom(payload.room_id);
break;
case 'reject_invite': case 'reject_invite':
Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, { Modal.createTrackedDialog('Reject invitation', '', QuestionDialog, {
title: _t('Reject invitation'), title: _t('Reject invitation'),
@ -1099,7 +1104,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private leaveRoomWarnings(roomId: string) { private leaveRoomWarnings(roomId: string) {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications. // Show a warning if there are additional complications.
const warnings = []; const warnings = [];
@ -1137,7 +1142,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const roomToLeave = MatrixClientPeg.get().getRoom(roomId); const roomToLeave = MatrixClientPeg.get().getRoom(roomId);
const warnings = this.leaveRoomWarnings(roomId); const warnings = this.leaveRoomWarnings(roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom(); const isSpace = SpaceStore.spacesEnabled && roomToLeave?.isSpaceRoom();
Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, { Modal.createTrackedDialog(isSpace ? "Leave space" : "Leave room", '', QuestionDialog, {
title: isSpace ? _t("Leave space") : _t("Leave room"), title: isSpace ? _t("Leave space") : _t("Leave room"),
description: ( description: (
@ -1193,6 +1198,17 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
private async copyRoom(roomId: string) {
const roomLink = makeRoomPermalink(roomId);
const success = await copyPlaintext(roomLink);
if (!success) {
Modal.createTrackedDialog("Unable to copy room link", "", ErrorDialog, {
title: _t("Unable to copy room link"),
description: _t("Unable to copy a link to the room to the clipboard."),
});
}
}
/** /**
* Starts a chat with the welcome user, if the user doesn't already have one * Starts a chat with the welcome user, if the user doesn't already have one
* @returns {string} The room ID of the new room, or null if no room was created * @returns {string} The room ID of the new room, or null if no room was created
@ -1687,7 +1703,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const type = screen === "start_sso" ? "sso" : "cas"; const type = screen === "start_sso" ? "sso" : "cas";
PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin()); PlatformPeg.get().startSingleSignOn(cli, type, this.getFragmentAfterLogin());
} else if (screen === 'groups') { } else if (screen === 'groups') {
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" }); dis.dispatch({ action: "view_home_page" });
return; return;
} }
@ -1774,7 +1790,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
subAction: params.action, subAction: params.action,
}); });
} else if (screen.indexOf('group/') === 0) { } else if (screen.indexOf('group/') === 0) {
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
dis.dispatch({ action: "view_home_page" }); dis.dispatch({ action: "view_home_page" });
return; return;
} }
@ -1936,7 +1952,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.setState({ serverConfig }); this.setState({ serverConfig });
}; };
private makeRegistrationUrl = (params: {[key: string]: string}) => { private makeRegistrationUrl = (params: QueryDict) => {
if (this.props.startingFragmentQueryParams.referrer) { if (this.props.startingFragmentQueryParams.referrer) {
params.referrer = this.props.startingFragmentQueryParams.referrer; params.referrer = this.props.startingFragmentQueryParams.referrer;
} }
@ -2091,7 +2107,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin} fragmentAfterLogin={fragmentAfterLogin}
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername} defaultUsername={this.props.startingFragmentQueryParams.defaultUsername as string}
{...this.getServerProperties()} {...this.getServerProperties()}
/> />
); );

View file

@ -54,7 +54,11 @@ const membershipTypes = [EventType.RoomMember, EventType.RoomThirdPartyInvite, E
// check if there is a previous event and it has the same sender as this event // check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): boolean { function shouldFormContinuation(
prevEvent: MatrixEvent,
mxEvent: MatrixEvent,
showHiddenEvents: boolean,
): boolean {
// sanity check inputs // sanity check inputs
if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false; if (!prevEvent || !prevEvent.sender || !mxEvent.sender) return false;
// check if within the max continuation period // check if within the max continuation period
@ -74,7 +78,7 @@ function shouldFormContinuation(prevEvent: MatrixEvent, mxEvent: MatrixEvent): b
mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false; mxEvent.sender.getMxcAvatarUrl() !== prevEvent.sender.getMxcAvatarUrl()) return false;
// if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile // if we don't have tile for previous event then it was shown by showHiddenEvents and has no SenderProfile
if (!haveTileForEvent(prevEvent)) return false; if (!haveTileForEvent(prevEvent, showHiddenEvents)) return false;
return true; return true;
} }
@ -239,7 +243,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
}; };
// Cache hidden events setting on mount since Settings is expensive to // Cache hidden events setting on mount since Settings is expensive to
// query, and we check this in a hot code path. // query, and we check this in a hot code path. This is also cached in
// our RoomContext, however we still need a fallback for roomless MessagePanels.
this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline"); this.showHiddenEventsInTimeline = SettingsStore.getValue("showHiddenEventsInTimeline");
this.showTypingNotificationsWatcherRef = this.showTypingNotificationsWatcherRef =
@ -399,17 +404,21 @@ export default class MessagePanel extends React.Component<IProps, IState> {
return !this.isMounted; return !this.isMounted;
}; };
private get showHiddenEvents(): boolean {
return this.context?.showHiddenEventsInTimeline ?? this.showHiddenEventsInTimeline;
}
// TODO: Implement granular (per-room) hide options // TODO: Implement granular (per-room) hide options
public shouldShowEvent(mxEv: MatrixEvent): boolean { public shouldShowEvent(mxEv: MatrixEvent): boolean {
if (mxEv.sender && MatrixClientPeg.get().isUserIgnored(mxEv.sender.userId)) { if (MatrixClientPeg.get().isUserIgnored(mxEv.getSender())) {
return false; // ignored = no show (only happens if the ignore happens after an event was received) return false; // ignored = no show (only happens if the ignore happens after an event was received)
} }
if (this.showHiddenEventsInTimeline) { if (this.showHiddenEvents) {
return true; return true;
} }
if (!haveTileForEvent(mxEv)) { if (!haveTileForEvent(mxEv, this.showHiddenEvents)) {
return false; // no tile = no show return false; // no tile = no show
} }
@ -569,7 +578,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
if (grouper) { if (grouper) {
if (grouper.shouldGroup(mxEv)) { if (grouper.shouldGroup(mxEv)) {
grouper.add(mxEv); grouper.add(mxEv, this.showHiddenEvents);
continue; continue;
} else { } else {
// not part of group, so get the group tiles, close the // not part of group, so get the group tiles, close the
@ -649,7 +658,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
} }
// is this a continuation of the previous message? // is this a continuation of the previous message?
const continuation = !wantsDateSeparator && shouldFormContinuation(prevEvent, mxEv); const continuation = !wantsDateSeparator &&
shouldFormContinuation(prevEvent, mxEv, this.showHiddenEvents);
const eventId = mxEv.getId(); const eventId = mxEv.getId();
const highlight = (eventId === this.props.highlightedEventId); const highlight = (eventId === this.props.highlightedEventId);
@ -946,7 +956,7 @@ abstract class BaseGrouper {
} }
public abstract shouldGroup(ev: MatrixEvent): boolean; public abstract shouldGroup(ev: MatrixEvent): boolean;
public abstract add(ev: MatrixEvent): void; public abstract add(ev: MatrixEvent, showHiddenEvents?: boolean): void;
public abstract getTiles(): ReactNode[]; public abstract getTiles(): ReactNode[];
public abstract getNewPrevEvent(): MatrixEvent; public abstract getNewPrevEvent(): MatrixEvent;
} }
@ -1200,10 +1210,10 @@ class MemberGrouper extends BaseGrouper {
return membershipTypes.includes(ev.getType() as EventType); return membershipTypes.includes(ev.getType() as EventType);
} }
public add(ev: MatrixEvent): void { public add(ev: MatrixEvent, showHiddenEvents?: boolean): void {
if (ev.getType() === EventType.RoomMember) { if (ev.getType() === EventType.RoomMember) {
// We can ignore any events that don't actually have a message to display // We can ignore any events that don't actually have a message to display
if (!hasText(ev)) return; if (!hasText(ev, showHiddenEvents)) return;
} }
this.readMarker = this.readMarker || this.panel.readMarkerForEvent( this.readMarker = this.readMarker || this.panel.readMarkerForEvent(
ev.getId(), ev.getId(),

View file

@ -48,6 +48,7 @@ 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";
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
@ -107,7 +108,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
return RightPanelPhases.GroupMemberList; return RightPanelPhases.GroupMemberList;
} }
return rps.groupPanelPhase; return rps.groupPanelPhase;
} else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom() } else if (SpaceStore.spacesEnabled && this.props.room?.isSpaceRoom()
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase) && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
) { ) {
return RightPanelPhases.SpaceMemberList; return RightPanelPhases.SpaceMemberList;

View file

@ -89,6 +89,7 @@ import RoomStatusBar from "./RoomStatusBar";
import MessageComposer from '../views/rooms/MessageComposer'; import MessageComposer from '../views/rooms/MessageComposer';
import JumpToBottomButton from "../views/rooms/JumpToBottomButton"; import JumpToBottomButton from "../views/rooms/JumpToBottomButton";
import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar"; import TopUnreadMessagesBar from "../views/rooms/TopUnreadMessagesBar";
import SpaceStore from "../../stores/SpaceStore";
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -165,6 +166,7 @@ export interface IState {
canReply: boolean; canReply: boolean;
layout: Layout; layout: Layout;
lowBandwidth: boolean; lowBandwidth: boolean;
showHiddenEventsInTimeline: boolean;
showReadReceipts: boolean; showReadReceipts: boolean;
showRedactions: boolean; showRedactions: boolean;
showJoinLeaves: boolean; showJoinLeaves: boolean;
@ -229,6 +231,7 @@ export default class RoomView extends React.Component<IProps, IState> {
canReply: false, canReply: false,
layout: SettingsStore.getValue("layout"), layout: SettingsStore.getValue("layout"),
lowBandwidth: SettingsStore.getValue("lowBandwidth"), lowBandwidth: SettingsStore.getValue("lowBandwidth"),
showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline"),
showReadReceipts: true, showReadReceipts: true,
showRedactions: true, showRedactions: true,
showJoinLeaves: true, showJoinLeaves: true,
@ -252,7 +255,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.on("userTrustStatusChanged", this.onUserVerificationChanged); this.context.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.on("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.on("Event.decrypted", this.onEventDecrypted); this.context.on("Event.decrypted", this.onEventDecrypted);
this.context.on("event", this.onEvent);
// Start listening for RoomViewStore updates // Start listening for RoomViewStore updates
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate); this.rightPanelStoreToken = RightPanelStore.getSharedInstance().addListener(this.onRightPanelStoreUpdate);
@ -267,6 +269,9 @@ export default class RoomView extends React.Component<IProps, IState> {
SettingsStore.watchSetting("lowBandwidth", null, () => SettingsStore.watchSetting("lowBandwidth", null, () =>
this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }), this.setState({ lowBandwidth: SettingsStore.getValue("lowBandwidth") }),
), ),
SettingsStore.watchSetting("showHiddenEventsInTimeline", null, () =>
this.setState({ showHiddenEventsInTimeline: SettingsStore.getValue("showHiddenEventsInTimeline") }),
),
]; ];
} }
@ -636,7 +641,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged); this.context.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged); this.context.removeListener("crossSigning.keysChanged", this.onCrossSigningKeysChanged);
this.context.removeListener("Event.decrypted", this.onEventDecrypted); this.context.removeListener("Event.decrypted", this.onEventDecrypted);
this.context.removeListener("event", this.onEvent);
} }
window.removeEventListener('beforeunload', this.onPageUnload); window.removeEventListener('beforeunload', this.onPageUnload);
@ -836,8 +840,7 @@ export default class RoomView extends React.Component<IProps, IState> {
if (this.unmounted) return; if (this.unmounted) return;
// ignore events for other rooms // ignore events for other rooms
if (!room) return; if (!room || room.roomId !== this.state.room?.roomId) return;
if (!this.state.room || room.roomId != this.state.room.roomId) return;
// ignore events from filtered timelines // ignore events from filtered timelines
if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return; if (data.timeline.getTimelineSet() !== room.getUnfilteredTimelineSet()) return;
@ -858,6 +861,10 @@ export default class RoomView extends React.Component<IProps, IState> {
// we'll only be showing a spinner. // we'll only be showing a spinner.
if (this.state.joining) return; if (this.state.joining) return;
if (!ev.isBeingDecrypted() && !ev.isDecryptionFailure()) {
this.handleEffects(ev);
}
if (ev.getSender() !== this.context.credentials.userId) { if (ev.getSender() !== this.context.credentials.userId) {
// update unread count when scrolled up // update unread count when scrolled up
if (!this.state.searchResults && this.state.atEndOfLiveTimeline) { if (!this.state.searchResults && this.state.atEndOfLiveTimeline) {
@ -870,20 +877,14 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
}; };
private onEventDecrypted = (ev) => { private onEventDecrypted = (ev: MatrixEvent) => {
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
if (ev.isDecryptionFailure()) return; if (ev.isDecryptionFailure()) return;
this.handleEffects(ev); this.handleEffects(ev);
}; };
private onEvent = (ev) => { private handleEffects = (ev: MatrixEvent) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev);
};
private handleEffects = (ev) => {
if (!this.state.room || !this.state.matrixClientIsReady) return; // not ready at all
if (ev.getRoomId() !== this.state.room.roomId) return; // not for us
const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room); const notifState = RoomNotificationStateStore.instance.getRoomState(this.state.room);
if (!notifState.isUnread) return; if (!notifState.isUnread) return;
@ -916,6 +917,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// called when state.room is first initialised (either at initial load, // called when state.room is first initialised (either at initial load,
// after a successful peek, or after we join the room). // after a successful peek, or after we join the room).
private onRoomLoaded = (room: Room) => { private onRoomLoaded = (room: Room) => {
if (this.unmounted) return;
// Attach a widget store listener only when we get a room // Attach a widget store listener only when we get a room
WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange); WidgetLayoutStore.instance.on(WidgetLayoutStore.emissionForRoom(room), this.onWidgetLayoutChange);
this.onWidgetLayoutChange(); // provoke an update this.onWidgetLayoutChange(); // provoke an update
@ -930,9 +932,9 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
private async calculateRecommendedVersion(room: Room) { private async calculateRecommendedVersion(room: Room) {
this.setState({ const upgradeRecommendation = await room.getRecommendedVersion();
upgradeRecommendation: await room.getRecommendedVersion(), if (this.unmounted) return;
}); this.setState({ upgradeRecommendation });
} }
private async loadMembersIfJoined(room: Room) { private async loadMembersIfJoined(room: Room) {
@ -1022,23 +1024,19 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
private async updateE2EStatus(room: Room) { private async updateE2EStatus(room: Room) {
if (!this.context.isRoomEncrypted(room.roomId)) { if (!this.context.isRoomEncrypted(room.roomId)) return;
return;
} // If crypto is not currently enabled, we aren't tracking devices at all,
if (!this.context.isCryptoEnabled()) { // so we don't know what the answer is. Let's error on the safe side and show
// If crypto is not currently enabled, we aren't tracking devices at all, // a warning for this case.
// so we don't know what the answer is. Let's error on the safe side and show let e2eStatus = E2EStatus.Warning;
// a warning for this case. if (this.context.isCryptoEnabled()) {
this.setState({ /* At this point, the user has encryption on and cross-signing on */
e2eStatus: E2EStatus.Warning, e2eStatus = await shieldStatusForRoom(this.context, room);
});
return;
} }
/* At this point, the user has encryption on and cross-signing on */ if (this.unmounted) return;
this.setState({ this.setState({ e2eStatus });
e2eStatus: await shieldStatusForRoom(this.context, room),
});
} }
private onAccountData = (event: MatrixEvent) => { private onAccountData = (event: MatrixEvent) => {
@ -1395,7 +1393,7 @@ export default class RoomView extends React.Component<IProps, IState> {
continue; continue;
} }
if (!haveTileForEvent(mxEv)) { if (!haveTileForEvent(mxEv, this.state.showHiddenEventsInTimeline)) {
// XXX: can this ever happen? It will make the result count // XXX: can this ever happen? It will make the result count
// not match the displayed count. // not match the displayed count.
continue; continue;
@ -1748,10 +1746,8 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
const myMembership = this.state.room.getMyMembership(); const myMembership = this.state.room.getMyMembership();
if (myMembership === "invite" // SpaceRoomView handles invites itself
// SpaceRoomView handles invites itself if (myMembership === "invite" && (!SpaceStore.spacesEnabled || !this.state.room.isSpaceRoom())) {
&& (!SettingsStore.getValue("feature_spaces") || !this.state.room.isSpaceRoom())
) {
if (this.state.joining || this.state.rejecting) { if (this.state.joining || this.state.rejecting) {
return ( return (
<ErrorBoundary> <ErrorBoundary>
@ -1882,7 +1878,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room} room={this.state.room}
/> />
); );
if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) { if (!this.state.canPeek && (!SpaceStore.spacesEnabled || !this.state.room?.isSpaceRoom())) {
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
{ previewBar } { previewBar }

View file

@ -62,7 +62,6 @@ import IconizedContextMenu, {
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { BetaPill } from "../views/beta/BetaCard"; import { BetaPill } from "../views/beta/BetaCard";
import { UserTab } from "../views/dialogs/UserSettingsDialog"; import { UserTab } from "../views/dialogs/UserSettingsDialog";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal"; import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
@ -177,7 +176,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const spacesEnabled = SettingsStore.getValue("feature_spaces"); const spacesEnabled = SpaceStore.spacesEnabled;
const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave const cannotJoin = getEffectiveMembership(myMembership) === EffectiveMembership.Leave
&& space.getJoinRule() !== JoinRule.Public; && space.getJoinRule() !== JoinRule.Public;
@ -852,7 +851,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
private renderBody() { private renderBody() {
switch (this.state.phase) { switch (this.state.phase) {
case Phase.Landing: case Phase.Landing:
if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) { if (this.state.myMembership === "join" && SpaceStore.spacesEnabled) {
return <SpaceLanding space={this.props.space} />; return <SpaceLanding space={this.props.space} />;
} else { } else {
return <SpacePreview return <SpacePreview

View file

@ -555,9 +555,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
// more than the timeout on userActiveRecently. // more than the timeout on userActiveRecently.
// //
const myUserId = MatrixClientPeg.get().credentials.userId; const myUserId = MatrixClientPeg.get().credentials.userId;
const sender = ev.sender ? ev.sender.userId : null;
callRMUpdated = false; callRMUpdated = false;
if (sender != myUserId && !UserActivity.sharedInstance().userActiveRecently()) { if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
updatedState.readMarkerVisible = true; updatedState.readMarkerVisible = true;
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
// we know we're stuckAtBottom, so we can advance the RM // we know we're stuckAtBottom, so we can advance the RM
@ -863,7 +862,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
const myUserId = MatrixClientPeg.get().credentials.userId; const myUserId = MatrixClientPeg.get().credentials.userId;
for (i++; i < events.length; i++) { for (i++; i < events.length; i++) {
const ev = events[i]; const ev = events[i];
if (!ev.sender || ev.sender.userId != myUserId) { if (ev.getSender() !== myUserId) {
break; break;
} }
} }
@ -1051,6 +1050,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
{ windowLimit: this.props.timelineCap }); { windowLimit: this.props.timelineCap });
const onLoaded = () => { const onLoaded = () => {
if (this.unmounted) return;
// clear the timeline min-height when // clear the timeline min-height when
// (re)loading the timeline // (re)loading the timeline
if (this.messagePanel.current) { if (this.messagePanel.current) {
@ -1092,6 +1093,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
}; };
const onError = (error) => { const onError = (error) => {
if (this.unmounted) return;
this.setState({ timelineLoading: false }); this.setState({ timelineLoading: false });
console.error( console.error(
`Error loading timeline panel at ${eventId}: ${error}`, `Error loading timeline panel at ${eventId}: ${error}`,
@ -1333,8 +1336,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
} }
const shouldIgnore = !!ev.status || // local echo const shouldIgnore = !!ev.status || // local echo
(ignoreOwn && ev.sender && ev.sender.userId == myUserId); // own message (ignoreOwn && ev.getSender() === myUserId); // own message
const isWithoutTile = !haveTileForEvent(ev) || shouldHideEvent(ev, this.context); const isWithoutTile = !haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline) ||
shouldHideEvent(ev, this.context);
if (isWithoutTile || !node) { if (isWithoutTile || !node) {
// don't start counting if the event should be ignored, // don't start counting if the event should be ignored,

View file

@ -90,7 +90,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
}; };
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
} }
@ -115,7 +115,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef); if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onProfileUpdate);
this.tagStoreRef.remove(); this.tagStoreRef.remove();
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
} }
MatrixClientPeg.get().removeListener("Room", this.onRoom); MatrixClientPeg.get().removeListener("Room", this.onRoom);

View file

@ -24,6 +24,7 @@ import ImageView from '../elements/ImageView';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as Avatar from '../../../Avatar'; import * as Avatar from '../../../Avatar';
import DMRoomMap from "../../../utils/DMRoomMap";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import { IOOBData } from '../../../stores/ThreepidInviteStore'; import { IOOBData } from '../../../stores/ThreepidInviteStore';
@ -135,6 +136,11 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
public render() { public render() {
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props; const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
const roomName = room ? room.name : oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
return ( return (
<BaseAvatar <BaseAvatar
{...otherProps} {...otherProps}
@ -142,7 +148,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(), mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
})} })}
name={room ? room.name : oobData.name} name={room ? room.name : oobData.name}
idName={room ? room.roomId : oobData.roomId} idName={idName}
urls={this.state.urls} urls={this.state.urls}
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick} onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
/> />

View file

@ -105,7 +105,7 @@ const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
</div> </div>
<img src={image} alt="" /> <img src={image} alt="" />
</div> </div>
{ extraSettings && <div className="mx_BetaCard_relatedSettings"> { extraSettings && value && <div className="mx_BetaCard_relatedSettings">
{ extraSettings.map(key => ( { extraSettings.map(key => (
<SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} /> <SettingsFlag key={key} name={key} level={SettingLevel.DEVICE} />
)) } )) }

View file

@ -43,6 +43,7 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList"; import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile"; import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar"; import BaseAvatar from "../avatars/BaseAvatar";
import SpaceStore from "../../../stores/SpaceStore";
const AVATAR_SIZE = 30; const AVATAR_SIZE = 30;
@ -180,7 +181,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase(); const lcQuery = query.toLowerCase();
const spacesEnabled = useFeatureEnabled("feature_spaces"); const spacesEnabled = SpaceStore.spacesEnabled;
const flairEnabled = useFeatureEnabled(UIFeature.Flair); const flairEnabled = useFeatureEnabled(UIFeature.Flair);
const previewLayout = useSettingValue<Layout>("layout"); const previewLayout = useSettingValue<Layout>("layout");

View file

@ -71,6 +71,7 @@ import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import BaseDialog from "./BaseDialog"; import BaseDialog from "./BaseDialog";
import DialPadBackspaceButton from "../elements/DialPadBackspaceButton"; import DialPadBackspaceButton from "../elements/DialPadBackspaceButton";
import SpaceStore from "../../../stores/SpaceStore";
// we have a number of types defined from the Matrix spec which can't reasonably be altered here. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
@ -1407,7 +1408,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</div>; </div>;
} else if (this.props.kind === KIND_INVITE) { } else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom(); const isSpace = SpaceStore.spacesEnabled && room?.isSpaceRoom();
title = isSpace title = isSpace
? _t("Invite to %(spaceName)s", { ? _t("Invite to %(spaceName)s", {
spaceName: room.name || _t("Unnamed Space"), spaceName: room.name || _t("Unnamed Space"),

View file

@ -205,13 +205,14 @@ export default class ServerPickerDialog extends React.PureComponent<IProps, ISta
className="mx_ServerPickerDialog_otherHomeserverRadio" className="mx_ServerPickerDialog_otherHomeserverRadio"
checked={!this.state.defaultChosen} checked={!this.state.defaultChosen}
onChange={this.onOtherChosen} onChange={this.onOtherChosen}
childrenInLabel={false}
> >
<Field <Field
type="text" type="text"
className="mx_ServerPickerDialog_otherHomeserver" className="mx_ServerPickerDialog_otherHomeserver"
label={_t("Other homeserver")} label={_t("Other homeserver")}
onChange={this.onHomeserverChange} onChange={this.onHomeserverChange}
onClick={this.onOtherChosen} onFocus={this.onOtherChosen}
ref={this.fieldRef} ref={this.fieldRef}
onValidate={this.onHomeserverValidate} onValidate={this.onHomeserverValidate}
value={this.state.otherHomeserver} value={this.state.otherHomeserver}

View file

@ -33,6 +33,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { normalizeWheelEvent } from "../../../utils/Mouse"; import { normalizeWheelEvent } from "../../../utils/Mouse";
import { IDialogProps } from '../dialogs/IDialogProps';
// Max scale to keep gaps around the image // Max scale to keep gaps around the image
const MAX_SCALE = 0.95; const MAX_SCALE = 0.95;
@ -43,14 +44,13 @@ const ZOOM_COEFFICIENT = 0.0025;
// If we have moved only this much we can zoom // If we have moved only this much we can zoom
const ZOOM_DISTANCE = 10; const ZOOM_DISTANCE = 10;
interface IProps { interface IProps extends IDialogProps {
src: string; // the source of the image being displayed src: string; // the source of the image being displayed
name?: string; // the main title ('name') for the image name?: string; // the main title ('name') for the image
link?: string; // the link (if any) applied to the name of the image link?: string; // the link (if any) applied to the name of the image
width?: number; // width of the image src in pixels width?: number; // width of the image src in pixels
height?: number; // height of the image src in pixels height?: number; // height of the image src in pixels
fileSize?: number; // size of the image src in bytes fileSize?: number; // size of the image src in bytes
onFinished(): void; // callback when the lightbox is dismissed
// the event (if any) that the Image is displaying. Used for event-specific stuff like // the event (if any) that the Image is displaying. Used for event-specific stuff like
// redactions, senders, timestamps etc. Other descriptors are taken from the explicit // redactions, senders, timestamps etc. Other descriptors are taken from the explicit
@ -488,8 +488,8 @@ export default class ImageView extends React.Component<IProps, IState> {
> >
<img <img
src={this.props.src} src={this.props.src}
title={this.props.name}
style={style} style={style}
alt={this.props.name}
ref={this.image} ref={this.image}
className="mx_ImageView_image" className="mx_ImageView_image"
draggable={true} draggable={true}

View file

@ -14,72 +14,72 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { wantsDateSeparator } from '../../../DateUtils';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { makeUserPermalink, RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { LayoutPropType } from "../../../settings/Layout"; import { Layout } from "../../../settings/Layout";
import escapeHtml from "escape-html"; import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { UIFeature } from "../../../settings/UIFeature";
import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils"; import { PERMITTED_URL_SCHEMES } from "../../../HtmlUtils";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "../rooms/EventTile"; import Spinner from './Spinner';
import ReplyTile from "../rooms/ReplyTile";
import Pill from './Pill';
import { Room } from 'matrix-js-sdk/src/models/room';
interface IProps {
// the latest event in this chain of replies
parentEv?: MatrixEvent;
// called when the ReplyThread contents has changed, including EventTiles thereof
onHeightChanged: () => void;
permalinkCreator: RoomPermalinkCreator;
// Specifies which layout to use.
layout?: Layout;
// Whether to always show a timestamp
alwaysShowTimestamps?: boolean;
}
interface IState {
// The loaded events to be rendered as linear-replies
events: MatrixEvent[];
// The latest loaded event which has not yet been shown
loadedEv: MatrixEvent;
// Whether the component is still loading more events
loading: boolean;
// Whether as error was encountered fetching a replied to event.
err: boolean;
}
// This component does no cycle detection, simply because the only way to make such a cycle would be to // This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
// be low as each event being loaded (after the first) is triggered by an explicit user action. // be low as each event being loaded (after the first) is triggered by an explicit user action.
@replaceableComponent("views.elements.ReplyThread") @replaceableComponent("views.elements.ReplyThread")
export default class ReplyThread extends React.Component { export default class ReplyThread extends React.Component<IProps, IState> {
static propTypes = {
// the latest event in this chain of replies
parentEv: PropTypes.instanceOf(MatrixEvent),
// called when the ReplyThread contents has changed, including EventTiles thereof
onHeightChanged: PropTypes.func.isRequired,
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
// Specifies which layout to use.
layout: LayoutPropType,
// Whether to always show a timestamp
alwaysShowTimestamps: PropTypes.bool,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = false;
private room: Room;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
// The loaded events to be rendered as linear-replies
events: [], events: [],
// The latest loaded event which has not yet been shown
loadedEv: null, loadedEv: null,
// Whether the component is still loading more events
loading: true, loading: true,
// Whether as error was encountered fetching a replied to event.
err: false, err: false,
}; };
this.unmounted = false;
this.context.on("Event.replaced", this.onEventReplaced);
this.room = this.context.getRoom(this.props.parentEv.getRoomId()); this.room = this.context.getRoom(this.props.parentEv.getRoomId());
this.room.on("Room.redaction", this.onRoomRedaction);
this.room.on("Room.redactionCancelled", this.onRoomRedaction);
this.onQuoteClick = this.onQuoteClick.bind(this);
this.canCollapse = this.canCollapse.bind(this);
this.collapse = this.collapse.bind(this);
} }
static getParentEventId(ev) { public static getParentEventId(ev: MatrixEvent): string {
if (!ev || ev.isRedacted()) return; if (!ev || ev.isRedacted()) return;
// XXX: For newer relations (annotations, replacements, etc.), we now // XXX: For newer relations (annotations, replacements, etc.), we now
@ -95,7 +95,7 @@ export default class ReplyThread extends React.Component {
} }
// Part of Replies fallback support // Part of Replies fallback support
static stripPlainReply(body) { public static stripPlainReply(body: string): string {
// Removes lines beginning with `> ` until you reach one that doesn't. // Removes lines beginning with `> ` until you reach one that doesn't.
const lines = body.split('\n'); const lines = body.split('\n');
while (lines.length && lines[0].startsWith('> ')) lines.shift(); while (lines.length && lines[0].startsWith('> ')) lines.shift();
@ -105,7 +105,7 @@ export default class ReplyThread extends React.Component {
} }
// Part of Replies fallback support // Part of Replies fallback support
static stripHTMLReply(html) { public static stripHTMLReply(html: string): string {
// Sanitize the original HTML for inclusion in <mx-reply>. We allow // Sanitize the original HTML for inclusion in <mx-reply>. We allow
// any HTML, since the original sender could use special tags that we // any HTML, since the original sender could use special tags that we
// don't recognize, but want to pass along to any recipients who do // don't recognize, but want to pass along to any recipients who do
@ -127,7 +127,10 @@ export default class ReplyThread extends React.Component {
} }
// Part of Replies fallback support // Part of Replies fallback support
static getNestedReplyText(ev, permalinkCreator) { public static getNestedReplyText(
ev: MatrixEvent,
permalinkCreator: RoomPermalinkCreator,
): { body: string, html: string } {
if (!ev) return null; if (!ev) return null;
let { body, formatted_body: html } = ev.getContent(); let { body, formatted_body: html } = ev.getContent();
@ -203,7 +206,7 @@ export default class ReplyThread extends React.Component {
return { body, html }; return { body, html };
} }
static makeReplyMixIn(ev) { public static makeReplyMixIn(ev: MatrixEvent) {
if (!ev) return {}; if (!ev) return {};
return { return {
'm.relates_to': { 'm.relates_to': {
@ -214,10 +217,15 @@ export default class ReplyThread extends React.Component {
}; };
} }
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout, alwaysShowTimestamps) { public static makeThread(
if (!ReplyThread.getParentEventId(parentEv)) { parentEv: MatrixEvent,
return null; onHeightChanged: () => void,
} permalinkCreator: RoomPermalinkCreator,
ref: React.RefObject<ReplyThread>,
layout: Layout,
alwaysShowTimestamps: boolean,
): JSX.Element {
if (!ReplyThread.getParentEventId(parentEv)) return null;
return <ReplyThread return <ReplyThread
parentEv={parentEv} parentEv={parentEv}
onHeightChanged={onHeightChanged} onHeightChanged={onHeightChanged}
@ -238,37 +246,9 @@ export default class ReplyThread extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener("Event.replaced", this.onEventReplaced);
if (this.room) {
this.room.removeListener("Room.redaction", this.onRoomRedaction);
this.room.removeListener("Room.redactionCancelled", this.onRoomRedaction);
}
} }
updateForEventId = (eventId) => { private async initialize(): Promise<void> {
if (this.state.events.some(event => event.getId() === eventId)) {
this.forceUpdate();
}
};
onEventReplaced = (ev) => {
if (this.unmounted) return;
// If one of the events we are rendering gets replaced, force a re-render
this.updateForEventId(ev.getId());
};
onRoomRedaction = (ev) => {
if (this.unmounted) return;
const eventId = ev.getAssociatedId();
if (!eventId) return;
// If one of the events we are rendering gets redacted, force a re-render
this.updateForEventId(eventId);
};
async initialize() {
const { parentEv } = this.props; const { parentEv } = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv)); const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
@ -287,7 +267,7 @@ export default class ReplyThread extends React.Component {
} }
} }
async getNextEvent(ev) { private async getNextEvent(ev: MatrixEvent): Promise<MatrixEvent> {
try { try {
const inReplyToEventId = ReplyThread.getParentEventId(ev); const inReplyToEventId = ReplyThread.getParentEventId(ev);
return await this.getEvent(inReplyToEventId); return await this.getEvent(inReplyToEventId);
@ -296,7 +276,7 @@ export default class ReplyThread extends React.Component {
} }
} }
async getEvent(eventId) { private async getEvent(eventId: string): Promise<MatrixEvent> {
if (!eventId) return null; if (!eventId) return null;
const event = this.room.findEventById(eventId); const event = this.room.findEventById(eventId);
if (event) return event; if (event) return event;
@ -313,15 +293,15 @@ export default class ReplyThread extends React.Component {
return this.room.findEventById(eventId); return this.room.findEventById(eventId);
} }
canCollapse() { public canCollapse = (): boolean => {
return this.state.events.length > 1; return this.state.events.length > 1;
} };
collapse() { public collapse = (): void => {
this.initialize(); this.initialize();
} };
async onQuoteClick() { private onQuoteClick = async (): Promise<void> => {
const events = [this.state.loadedEv, ...this.state.events]; const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null; let loadedEv = null;
@ -335,6 +315,10 @@ export default class ReplyThread extends React.Component {
}); });
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
};
private getReplyThreadColorClass(ev: MatrixEvent): string {
return getUserNameColorClass(ev.getSender()).replace("Username", "ReplyThread");
} }
render() { render() {
@ -349,9 +333,8 @@ export default class ReplyThread extends React.Component {
</blockquote>; </blockquote>;
} else if (this.state.loadedEv) { } else if (this.state.loadedEv) {
const ev = this.state.loadedEv; const ev = this.state.loadedEv;
const Pill = sdk.getComponent('elements.Pill');
const room = this.context.getRoom(ev.getRoomId()); const room = this.context.getRoom(ev.getRoomId());
header = <blockquote className="mx_ReplyThread"> header = <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`}>
{ {
_t('<a>In reply to</a> <pill>', {}, { _t('<a>In reply to</a> <pill>', {}, {
'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>, 'a': (sub) => <a onClick={this.onQuoteClick} className="mx_ReplyThread_show">{ sub }</a>,
@ -367,33 +350,15 @@ export default class ReplyThread extends React.Component {
} }
</blockquote>; </blockquote>;
} else if (this.state.loading) { } else if (this.state.loading) {
const Spinner = sdk.getComponent("elements.Spinner");
header = <Spinner w={16} h={16} />; header = <Spinner w={16} h={16} />;
} }
const EventTile = sdk.getComponent('views.rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator');
const evTiles = this.state.events.map((ev) => { const evTiles = this.state.events.map((ev) => {
let dateSep = null; return <blockquote className={`mx_ReplyThread ${this.getReplyThreadColorClass(ev)}`} key={ev.getId()}>
<ReplyTile
if (wantsDateSeparator(this.props.parentEv.getDate(), ev.getDate())) {
dateSep = <a href={this.props.url}><DateSeparator ts={ev.getTs()} /></a>;
}
return <blockquote className="mx_ReplyThread" key={ev.getId()}>
{ dateSep }
<EventTile
mxEvent={ev} mxEvent={ev}
tileShape={TileShape.Reply}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
isRedacted={ev.isRedacted()}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
layout={this.props.layout}
alwaysShowTimestamps={this.props.alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
replacingEventId={ev.replacingEventId()}
as="div"
/> />
</blockquote>; </blockquote>;
}); });

View file

@ -1,39 +0,0 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from "prop-types";
import { _t } from "../../../languageHandler";
const Spinner = ({ w = 32, h = 32, message }) => (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
<div
className="mx_Spinner_icon"
style={{ width: w, height: h }}
aria-label={_t("Loading...")}
></div>
</div>
);
Spinner.propTypes = {
w: PropTypes.number,
h: PropTypes.number,
message: PropTypes.node,
};
export default Spinner;

View file

@ -0,0 +1,45 @@
/*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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";
interface IProps {
w?: number;
h?: number;
message?: string;
}
export default class Spinner extends React.PureComponent<IProps> {
public static defaultProps: Partial<IProps> = {
w: 32,
h: 32,
};
public render() {
const { w, h, message } = this.props;
return (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
<div
className="mx_Spinner_icon"
style={{ width: w, height: h }}
aria-label={_t("Loading...")}
/>
</div>
);
}
}

View file

@ -20,6 +20,10 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> { interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
outlined?: boolean; outlined?: boolean;
// If true (default), the children will be contained within a <label> element
// If false, they'll be in a div. Putting interactive components that have labels
// themselves in labels can cause strange bugs like https://github.com/vector-im/element-web/issues/18031
childrenInLabel?: boolean;
} }
interface IState { interface IState {
@ -29,10 +33,11 @@ interface IState {
export default class StyledRadioButton extends React.PureComponent<IProps, IState> { export default class StyledRadioButton extends React.PureComponent<IProps, IState> {
public static readonly defaultProps = { public static readonly defaultProps = {
className: '', className: '',
childrenInLabel: true,
}; };
public render() { public render() {
const { children, className, disabled, outlined, ...otherProps } = this.props; const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
const _className = classnames( const _className = classnames(
'mx_RadioButton', 'mx_RadioButton',
className, className,
@ -42,12 +47,27 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
"mx_RadioButton_checked": this.props.checked, "mx_RadioButton_checked": this.props.checked,
"mx_RadioButton_outlined": outlined, "mx_RadioButton_outlined": outlined,
}); });
return <label className={_className}>
const radioButton = <React.Fragment>
<input type='radio' disabled={disabled} {...otherProps} /> <input type='radio' disabled={disabled} {...otherProps} />
{/* Used to render the radio button circle */} {/* Used to render the radio button circle */}
<div><div /></div> <div><div /></div>
<div className="mx_RadioButton_content">{children}</div> </React.Fragment>;
<div className="mx_RadioButton_spacer" />
</label>; if (childrenInLabel) {
return <label className={_className}>
{radioButton}
<div className="mx_RadioButton_content">{children}</div>
<div className="mx_RadioButton_spacer" />
</label>;
} else {
return <div className={_className}>
<label className="mx_RadioButton_innerLabel">
{radioButton}
</label>
<div className="mx_RadioButton_content">{children}</div>
<div className="mx_RadioButton_spacer" />
</div>;
}
} }
} }

View file

@ -0,0 +1,91 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { ChangeEvent, FormEvent } from "react";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import Field from "./Field";
import { _t } from "../../../languageHandler";
import AccessibleButton from "./AccessibleButton";
interface IProps {
tags: string[];
onAdd: (tag: string) => void;
onRemove: (tag: string) => void;
disabled?: boolean;
label?: string;
placeholder?: string;
}
interface IState {
newTag: string;
}
/**
* A simple, controlled, composer for entering string tags. Contains a simple
* input, add button, and per-tag remove button.
*/
@replaceableComponent("views.elements.TagComposer")
export default class TagComposer extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
newTag: "",
};
}
private onInputChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({ newTag: ev.target.value });
};
private onAdd = (ev: FormEvent) => {
ev.preventDefault();
if (!this.state.newTag) return;
this.props.onAdd(this.state.newTag);
this.setState({ newTag: "" });
};
private onRemove(tag: string) {
// We probably don't need to proxy this, but for
// sanity of `this` we'll do so anyways.
this.props.onRemove(tag);
}
public render() {
return <div className='mx_TagComposer'>
<form className='mx_TagComposer_input' onSubmit={this.onAdd}>
<Field
value={this.state.newTag}
onChange={this.onInputChange}
label={this.props.label || _t("Keyword")}
placeholder={this.props.placeholder || _t("New keyword")}
disabled={this.props.disabled}
autoComplete="off"
/>
<AccessibleButton onClick={this.onAdd} kind='primary' disabled={this.props.disabled}>
{ _t("Add") }
</AccessibleButton>
</form>
<div className='mx_TagComposer_tags'>
{ this.props.tags.map((t, i) => (<div className='mx_TagComposer_tag' key={i}>
<span>{ t }</span>
<AccessibleButton onClick={this.onRemove.bind(this, t)} disabled={this.props.disabled} />
</div>)) }
</div>
</div>;
}
}

View file

@ -90,6 +90,35 @@ function computedStyle(element) {
return cssText; return cssText;
} }
/**
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @param {Object} content The "content" key of the matrix event.
* @param {boolean} withSize Whether to include size information. Default true.
* @return {string} the human readable link text for the attachment.
*/
export function presentableTextForFile(content, withSize = true) {
let linkText = _t("Attachment");
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
// file extension.
linkText = content.body;
}
if (content.info && content.info.size && withSize) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
// The content.info also contains a MIME-type but we don't display
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferrered
// from the file extension.
linkText += ' (' + filesize(content.info.size) + ')';
}
return linkText;
}
@replaceableComponent("views.messages.MFileBody") @replaceableComponent("views.messages.MFileBody")
export default class MFileBody extends React.Component { export default class MFileBody extends React.Component {
static propTypes = { static propTypes = {
@ -120,35 +149,6 @@ export default class MFileBody extends React.Component {
this._dummyLink = createRef(); this._dummyLink = createRef();
} }
/**
* Extracts a human readable label for the file attachment to use as
* link text.
*
* @param {Object} content The "content" key of the matrix event.
* @param {boolean} withSize Whether to include size information. Default true.
* @return {string} the human readable link text for the attachment.
*/
presentableTextForFile(content, withSize = true) {
let linkText = _t("Attachment");
if (content.body && content.body.length > 0) {
// The content body should be the name of the file including a
// file extension.
linkText = content.body;
}
if (content.info && content.info.size && withSize) {
// If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how
// big a file they are downloading.
// The content.info also contains a MIME-type but we don't display
// it since it is "ugly", users generally aren't aware what it
// means and the type of the attachment can usually be inferrered
// from the file extension.
linkText += ' (' + filesize(content.info.size) + ')';
}
return linkText;
}
_getContentUrl() { _getContentUrl() {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp; return media.srcHttp;
@ -162,7 +162,7 @@ export default class MFileBody extends React.Component {
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const text = this.presentableTextForFile(content); const text = presentableTextForFile(content);
const isEncrypted = content.file !== undefined; const isEncrypted = content.file !== undefined;
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment");
const contentUrl = this._getContentUrl(); const contentUrl = this._getContentUrl();
@ -174,7 +174,9 @@ export default class MFileBody extends React.Component {
placeholder = ( placeholder = (
<div className="mx_MFileBody_info"> <div className="mx_MFileBody_info">
<span className="mx_MFileBody_info_icon" /> <span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename">{this.presentableTextForFile(content, false)}</span> <span className="mx_MFileBody_info_filename">
{ presentableTextForFile(content, false) }
</span>
</div> </div>
); );
} }

View file

@ -16,13 +16,11 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { ComponentProps, createRef } from 'react';
import PropTypes from 'prop-types';
import { Blurhash } from "react-blurhash"; import { Blurhash } from "react-blurhash";
import MFileBody from './MFileBody'; import MFileBody from './MFileBody';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as sdk from '../../../index';
import { decryptFile } from '../../../utils/DecryptFile'; import { decryptFile } from '../../../utils/DecryptFile';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -31,36 +29,49 @@ import InlineSpinner from '../elements/InlineSpinner';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { mediaFromContent } from "../../../customisations/Media"; import { mediaFromContent } from "../../../customisations/Media";
import { BLURHASH_FIELD } from "../../../ContentMessages"; import { BLURHASH_FIELD } from "../../../ContentMessages";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { IMediaEventContent } from '../../../customisations/models/IMediaEventContent';
import ImageView from '../elements/ImageView';
import { SyncState } from 'matrix-js-sdk/src/sync.api';
export interface IProps {
/* the MatrixEvent to show */
mxEvent: MatrixEvent;
/* called when the image has loaded */
onHeightChanged(): void;
/* the maximum image height to use */
maxImageHeight?: number;
/* the permalinkCreator */
permalinkCreator?: RoomPermalinkCreator;
}
interface IState {
decryptedUrl?: string;
decryptedThumbnailUrl?: string;
decryptedBlob?: Blob;
error;
imgError: boolean;
imgLoaded: boolean;
loadedImageDimensions?: {
naturalWidth: number;
naturalHeight: number;
};
hover: boolean;
showImage: boolean;
}
@replaceableComponent("views.messages.MImageBody") @replaceableComponent("views.messages.MImageBody")
export default class MImageBody extends React.Component { export default class MImageBody extends React.Component<IProps, IState> {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* called when the image has loaded */
onHeightChanged: PropTypes.func.isRequired,
/* the maximum image height to use */
maxImageHeight: PropTypes.number,
/* the permalinkCreator */
permalinkCreator: PropTypes.object,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted = true;
private image = createRef<HTMLImageElement>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.onImageError = this.onImageError.bind(this);
this.onImageLoad = this.onImageLoad.bind(this);
this.onImageEnter = this.onImageEnter.bind(this);
this.onImageLeave = this.onImageLeave.bind(this);
this.onClientSync = this.onClientSync.bind(this);
this.onClick = this.onClick.bind(this);
this._isGif = this._isGif.bind(this);
this.state = { this.state = {
decryptedUrl: null, decryptedUrl: null,
decryptedThumbnailUrl: null, decryptedThumbnailUrl: null,
@ -72,12 +83,10 @@ export default class MImageBody extends React.Component {
hover: false, hover: false,
showImage: SettingsStore.getValue("showImages"), showImage: SettingsStore.getValue("showImages"),
}; };
this._image = createRef();
} }
// FIXME: factor this out and apply it to MVideoBody and MAudioBody too! // FIXME: factor this out and apply it to MVideoBody and MAudioBody too!
onClientSync(syncState, prevState) { private onClientSync = (syncState: SyncState, prevState: SyncState): void => {
if (this.unmounted) return; if (this.unmounted) return;
// Consider the client reconnected if there is no error with syncing. // Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
@ -88,15 +97,15 @@ export default class MImageBody extends React.Component {
imgError: false, imgError: false,
}); });
} }
} };
showImage() { protected showImage(): void {
localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true"); localStorage.setItem("mx_ShowImage_" + this.props.mxEvent.getId(), "true");
this.setState({ showImage: true }); this.setState({ showImage: true });
this._downloadImage(); this.downloadImage();
} }
onClick(ev) { protected onClick = (ev: React.MouseEvent): void => {
if (ev.button === 0 && !ev.metaKey) { if (ev.button === 0 && !ev.metaKey) {
ev.preventDefault(); ev.preventDefault();
if (!this.state.showImage) { if (!this.state.showImage) {
@ -104,12 +113,11 @@ export default class MImageBody extends React.Component {
return; return;
} }
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const httpUrl = this._getContentUrl(); const httpUrl = this.getContentUrl();
const ImageView = sdk.getComponent("elements.ImageView"); const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
const params = {
src: httpUrl, src: httpUrl,
name: content.body && content.body.length > 0 ? content.body : _t('Attachment'), name: content.body?.length > 0 ? content.body : _t('Attachment'),
mxEvent: this.props.mxEvent, mxEvent: this.props.mxEvent,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
}; };
@ -122,58 +130,54 @@ export default class MImageBody extends React.Component {
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true);
} }
} };
_isGif() { private isGif = (): boolean => {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
return ( return content.info?.mimetype === "image/gif";
content && };
content.info &&
content.info.mimetype === "image/gif"
);
}
onImageEnter(e) { private onImageEnter = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: true }); this.setState({ hover: true });
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
const imgElement = e.target; const imgElement = e.currentTarget;
imgElement.src = this._getContentUrl(); imgElement.src = this.getContentUrl();
} };
onImageLeave(e) { private onImageLeave = (e: React.MouseEvent<HTMLImageElement>): void => {
this.setState({ hover: false }); this.setState({ hover: false });
if (!this.state.showImage || !this._isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) { if (!this.state.showImage || !this.isGif() || SettingsStore.getValue("autoplayGifsAndVideos")) {
return; return;
} }
const imgElement = e.target; const imgElement = e.currentTarget;
imgElement.src = this._getThumbUrl(); imgElement.src = this.getThumbUrl();
} };
onImageError() { private onImageError = (): void => {
this.setState({ this.setState({
imgError: true, imgError: true,
}); });
} };
onImageLoad() { private onImageLoad = (): void => {
this.props.onHeightChanged(); this.props.onHeightChanged();
let loadedImageDimensions; let loadedImageDimensions;
if (this._image.current) { if (this.image.current) {
const { naturalWidth, naturalHeight } = this._image.current; const { naturalWidth, naturalHeight } = this.image.current;
// this is only used as a fallback in case content.info.w/h is missing // this is only used as a fallback in case content.info.w/h is missing
loadedImageDimensions = { naturalWidth, naturalHeight }; loadedImageDimensions = { naturalWidth, naturalHeight };
} }
this.setState({ imgLoaded: true, loadedImageDimensions }); this.setState({ imgLoaded: true, loadedImageDimensions });
} };
_getContentUrl() { protected getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
if (media.isEncrypted) { if (media.isEncrypted) {
return this.state.decryptedUrl; return this.state.decryptedUrl;
@ -182,7 +186,7 @@ export default class MImageBody extends React.Component {
} }
} }
_getThumbUrl() { protected getThumbUrl(): string {
// FIXME: we let images grow as wide as you like, rather than capped to 800x600. // FIXME: we let images grow as wide as you like, rather than capped to 800x600.
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
// thumbnail resolution will be unnecessarily reduced. // thumbnail resolution will be unnecessarily reduced.
@ -190,7 +194,7 @@ export default class MImageBody extends React.Component {
const thumbWidth = 800; const thumbWidth = 800;
const thumbHeight = 600; const thumbHeight = 600;
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
const media = mediaFromContent(content); const media = mediaFromContent(content);
if (media.isEncrypted) { if (media.isEncrypted) {
@ -218,7 +222,7 @@ export default class MImageBody extends React.Component {
// - If there's no sizing info in the event, default to thumbnail // - If there's no sizing info in the event, default to thumbnail
const info = content.info; const info = content.info;
if ( if (
this._isGif() || this.isGif() ||
window.devicePixelRatio === 1.0 || window.devicePixelRatio === 1.0 ||
(!info || !info.w || !info.h || !info.size) (!info || !info.w || !info.h || !info.size)
) { ) {
@ -253,7 +257,7 @@ export default class MImageBody extends React.Component {
} }
} }
_downloadImage() { private downloadImage(): void {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
if (content.file !== undefined && this.state.decryptedUrl === null) { if (content.file !== undefined && this.state.decryptedUrl === null) {
let thumbnailPromise = Promise.resolve(null); let thumbnailPromise = Promise.resolve(null);
@ -297,7 +301,7 @@ export default class MImageBody extends React.Component {
if (showImage) { if (showImage) {
// Don't download anything becaue we don't want to display anything. // Don't download anything becaue we don't want to display anything.
this._downloadImage(); this.downloadImage();
this.setState({ showImage: true }); this.setState({ showImage: true });
} }
@ -312,7 +316,6 @@ export default class MImageBody extends React.Component {
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
this.context.removeListener('sync', this.onClientSync); this.context.removeListener('sync', this.onClientSync);
this._afterComponentWillUnmount();
if (this.state.decryptedUrl) { if (this.state.decryptedUrl) {
URL.revokeObjectURL(this.state.decryptedUrl); URL.revokeObjectURL(this.state.decryptedUrl);
@ -322,12 +325,12 @@ export default class MImageBody extends React.Component {
} }
} }
// To be overridden by subclasses (e.g. MStickerBody) for further protected messageContent(
// cleanup after componentWillUnmount contentUrl: string,
_afterComponentWillUnmount() { thumbUrl: string,
} content: IMediaEventContent,
forcedHeight?: number,
_messageContent(contentUrl, thumbUrl, content) { ): JSX.Element {
let infoWidth; let infoWidth;
let infoHeight; let infoHeight;
@ -348,7 +351,7 @@ export default class MImageBody extends React.Component {
imageElement = <HiddenImagePlaceholder />; imageElement = <HiddenImagePlaceholder />;
} else { } else {
imageElement = ( imageElement = (
<img style={{ display: 'none' }} src={thumbUrl} ref={this._image} <img style={{ display: 'none' }} src={thumbUrl} ref={this.image}
alt={content.body} alt={content.body}
onError={this.onImageError} onError={this.onImageError}
onLoad={this.onImageLoad} onLoad={this.onImageLoad}
@ -362,7 +365,7 @@ export default class MImageBody extends React.Component {
} }
// The maximum height of the thumbnail as it is rendered as an <img> // The maximum height of the thumbnail as it is rendered as an <img>
const maxHeight = Math.min(this.props.maxImageHeight || 600, infoHeight); const maxHeight = forcedHeight || Math.min((this.props.maxImageHeight || 600), infoHeight);
// The maximum width of the thumbnail, as dictated by its natural // The maximum width of the thumbnail, as dictated by its natural
// maximum height. // maximum height.
const maxWidth = infoWidth * maxHeight / infoHeight; const maxWidth = infoWidth * maxHeight / infoHeight;
@ -382,7 +385,7 @@ export default class MImageBody extends React.Component {
// which has the same width as the timeline // which has the same width as the timeline
// mx_MImageBody_thumbnail resizes img to exactly container size // mx_MImageBody_thumbnail resizes img to exactly container size
img = ( img = (
<img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this._image} <img className="mx_MImageBody_thumbnail" src={thumbUrl} ref={this.image}
style={{ maxWidth: maxWidth + "px" }} style={{ maxWidth: maxWidth + "px" }}
alt={content.body} alt={content.body}
onError={this.onImageError} onError={this.onImageError}
@ -393,18 +396,18 @@ export default class MImageBody extends React.Component {
} }
if (!this.state.showImage) { if (!this.state.showImage) {
img = <HiddenImagePlaceholder style={{ maxWidth: maxWidth + "px" }} />; img = <HiddenImagePlaceholder maxWidth={maxWidth} />;
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
} }
if (this._isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) { if (this.isGif() && !SettingsStore.getValue("autoplayGifsAndVideos") && !this.state.hover) {
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>; gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
} }
const thumbnail = ( const thumbnail = (
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px" }} > <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }} >
{ /* Calculate aspect ratio, using %padding will size _container correctly */ } { /* Calculate aspect ratio, using %padding will size _container correctly */ }
<div style={{ paddingBottom: (100 * infoHeight / infoWidth) + '%' }} /> <div style={{ paddingBottom: forcedHeight ? (forcedHeight + "px") : ((100 * infoHeight / infoWidth) + '%') }} />
{ showPlaceholder && { showPlaceholder &&
<div className="mx_MImageBody_thumbnail" style={{ <div className="mx_MImageBody_thumbnail" style={{
// Constrain width here so that spinner appears central to the loaded thumbnail // Constrain width here so that spinner appears central to the loaded thumbnail
@ -427,14 +430,14 @@ export default class MImageBody extends React.Component {
} }
// Overidden by MStickerBody // Overidden by MStickerBody
wrapImage(contentUrl, children) { protected wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
return <a href={contentUrl} onClick={this.onClick}> return <a href={contentUrl} onClick={this.onClick}>
{children} {children}
</a>; </a>;
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getPlaceholder(width, height) { protected getPlaceholder(width: number, height: number): JSX.Element {
const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD]; const blurhash = this.props.mxEvent.getContent().info[BLURHASH_FIELD];
if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />; if (blurhash) return <Blurhash hash={blurhash} width={width} height={height} />;
return <div className="mx_MImageBody_thumbnail_spinner"> return <div className="mx_MImageBody_thumbnail_spinner">
@ -443,17 +446,17 @@ export default class MImageBody extends React.Component {
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getTooltip() { protected getTooltip(): JSX.Element {
return null; return null;
} }
// Overidden by MStickerBody // Overidden by MStickerBody
getFileBody() { protected getFileBody(): JSX.Element {
return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />; return <MFileBody {...this.props} decryptedBlob={this.state.decryptedBlob} showGenericPlaceholder={false} />;
} }
render() { render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent<IMediaEventContent>();
if (this.state.error !== null) { if (this.state.error !== null) {
return ( return (
@ -464,15 +467,15 @@ export default class MImageBody extends React.Component {
); );
} }
const contentUrl = this._getContentUrl(); const contentUrl = this.getContentUrl();
let thumbUrl; let thumbUrl;
if (this._isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) { if (this.isGif() && SettingsStore.getValue("autoplayGifsAndVideos")) {
thumbUrl = contentUrl; thumbUrl = contentUrl;
} else { } else {
thumbUrl = this._getThumbUrl(); thumbUrl = this.getThumbUrl();
} }
const thumbnail = this._messageContent(contentUrl, thumbUrl, content); const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
const fileBody = this.getFileBody(); const fileBody = this.getFileBody();
return <span className="mx_MImageBody"> return <span className="mx_MImageBody">
@ -482,16 +485,18 @@ export default class MImageBody extends React.Component {
} }
} }
export class HiddenImagePlaceholder extends React.PureComponent { interface PlaceholderIProps {
static propTypes = { hover?: boolean;
hover: PropTypes.bool, maxWidth?: number;
}; }
export class HiddenImagePlaceholder extends React.PureComponent<PlaceholderIProps> {
render() { render() {
const maxWidth = this.props.maxWidth ? this.props.maxWidth + "px" : null;
let className = 'mx_HiddenImagePlaceholder'; let className = 'mx_HiddenImagePlaceholder';
if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover'; if (this.props.hover) className += ' mx_HiddenImagePlaceholder_hover';
return ( return (
<div className={className}> <div className={className} style={{ maxWidth: maxWidth }}>
<div className='mx_HiddenImagePlaceholder_button'> <div className='mx_HiddenImagePlaceholder_button'>
<span className='mx_HiddenImagePlaceholder_eye' /> <span className='mx_HiddenImagePlaceholder_eye' />
<span>{_t("Show image")}</span> <span>{_t("Show image")}</span>

View file

@ -0,0 +1,62 @@
/*
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
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 MImageBody from "./MImageBody";
import { presentableTextForFile } from "./MFileBody";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import SenderProfile from "./SenderProfile";
const FORCED_IMAGE_HEIGHT = 44;
export default class MImageReplyBody extends MImageBody {
public onClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
};
public wrapImage(contentUrl: string, children: JSX.Element): JSX.Element {
return children;
}
// Don't show "Download this_file.png ..."
public getFileBody(): JSX.Element {
return presentableTextForFile(this.props.mxEvent.getContent());
}
render() {
if (this.state.error !== null) {
return super.render();
}
const content = this.props.mxEvent.getContent<IMediaEventContent>();
const contentUrl = this.getContentUrl();
const thumbnail = this.messageContent(contentUrl, this.getThumbUrl(), content, FORCED_IMAGE_HEIGHT);
const fileBody = this.getFileBody();
const sender = <SenderProfile
mxEvent={this.props.mxEvent}
enableFlair={false}
/>;
return <div className="mx_MImageReplyBody">
{ thumbnail }
<div className="mx_MImageReplyBody_info">
<div className="mx_MImageReplyBody_sender">{ sender }</div>
<div className="mx_MImageReplyBody_filename">{ fileBody }</div>
</div>
</div>;
}
}

View file

@ -47,6 +47,10 @@ export default class MessageEvent extends React.Component {
/* the maximum image height to use, if the event is an image */ /* the maximum image height to use, if the event is an image */
maxImageHeight: PropTypes.number, maxImageHeight: PropTypes.number,
/* overrides for the msgtype-specific components, used by ReplyTile to override file rendering */
overrideBodyTypes: PropTypes.object,
overrideEventTypes: PropTypes.object,
/* the permalinkCreator */ /* the permalinkCreator */
permalinkCreator: PropTypes.object, permalinkCreator: PropTypes.object,
}; };
@ -74,9 +78,12 @@ export default class MessageEvent extends React.Component {
'm.file': sdk.getComponent('messages.MFileBody'), 'm.file': sdk.getComponent('messages.MFileBody'),
'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'), 'm.audio': sdk.getComponent('messages.MVoiceOrAudioBody'),
'm.video': sdk.getComponent('messages.MVideoBody'), 'm.video': sdk.getComponent('messages.MVideoBody'),
...(this.props.overrideBodyTypes || {}),
}; };
const evTypes = { const evTypes = {
'm.sticker': sdk.getComponent('messages.MStickerBody'), 'm.sticker': sdk.getComponent('messages.MStickerBody'),
...(this.props.overrideEventTypes || {}),
}; };
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
@ -113,7 +120,7 @@ export default class MessageEvent extends React.Component {
} }
} }
return <BodyType return BodyType ? <BodyType
ref={this._body} ref={this._body}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
@ -126,6 +133,6 @@ export default class MessageEvent extends React.Component {
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
onMessageAllowed={this.onTileUpdate} onMessageAllowed={this.onTileUpdate}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
/>; /> : null;
} }
} }

View file

@ -15,12 +15,14 @@
*/ */
import React from 'react'; import React from 'react';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MsgType } from "matrix-js-sdk/src/@types/event";
import Flair from '../elements/Flair'; import Flair from '../elements/Flair';
import FlairStore from '../../../stores/FlairStore'; import FlairStore from '../../../stores/FlairStore';
import { getUserNameColorClass } from '../../../utils/FormattingUtils'; import { getUserNameColorClass } from '../../../utils/FormattingUtils';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -36,7 +38,7 @@ interface IState {
@replaceableComponent("views.messages.SenderProfile") @replaceableComponent("views.messages.SenderProfile")
export default class SenderProfile extends React.Component<IProps, IState> { export default class SenderProfile extends React.Component<IProps, IState> {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private unmounted: boolean; private unmounted = false;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -49,8 +51,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
} }
componentDidMount() { componentDidMount() {
this.unmounted = false; this.updateRelatedGroups();
this._updateRelatedGroups();
if (this.state.userGroups.length === 0) { if (this.state.userGroups.length === 0) {
this.getPublicisedGroups(); this.getPublicisedGroups();
@ -64,35 +65,29 @@ export default class SenderProfile extends React.Component<IProps, IState> {
this.context.removeListener('RoomState.events', this.onRoomStateEvents); this.context.removeListener('RoomState.events', this.onRoomStateEvents);
} }
async getPublicisedGroups() { private async getPublicisedGroups() {
if (!this.unmounted) { const userGroups = await FlairStore.getPublicisedGroupsCached(this.context, this.props.mxEvent.getSender());
const userGroups = await FlairStore.getPublicisedGroupsCached( if (this.unmounted) return;
this.context, this.props.mxEvent.getSender(), this.setState({ userGroups });
);
this.setState({ userGroups });
}
} }
onRoomStateEvents = event => { private onRoomStateEvents = (event: MatrixEvent) => {
if (event.getType() === 'm.room.related_groups' && if (event.getType() === 'm.room.related_groups' && event.getRoomId() === this.props.mxEvent.getRoomId()) {
event.getRoomId() === this.props.mxEvent.getRoomId() this.updateRelatedGroups();
) {
this._updateRelatedGroups();
} }
}; };
_updateRelatedGroups() { private updateRelatedGroups() {
if (this.unmounted) return;
const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const room = this.context.getRoom(this.props.mxEvent.getRoomId());
if (!room) return; if (!room) return;
const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', ''); const relatedGroupsEvent = room.currentState.getStateEvents('m.room.related_groups', '');
this.setState({ this.setState({
relatedGroups: relatedGroupsEvent ? relatedGroupsEvent.getContent().groups || [] : [], relatedGroups: relatedGroupsEvent?.getContent().groups || [],
}); });
} }
_getDisplayedGroups(userGroups, relatedGroups) { private getDisplayedGroups(userGroups?: string[], relatedGroups?: string[]) {
let displayedGroups = userGroups || []; let displayedGroups = userGroups || [];
if (relatedGroups && relatedGroups.length > 0) { if (relatedGroups && relatedGroups.length > 0) {
displayedGroups = relatedGroups.filter((groupId) => { displayedGroups = relatedGroups.filter((groupId) => {
@ -113,7 +108,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || ""; const displayName = mxEvent.sender?.rawDisplayName || mxEvent.getSender() || "";
const mxid = mxEvent.sender?.userId || mxEvent.getSender() || ""; const mxid = mxEvent.sender?.userId || mxEvent.getSender() || "";
if (msgtype === 'm.emote') { if (msgtype === MsgType.Emote) {
return null; // emote message must include the name so don't duplicate it return null; // emote message must include the name so don't duplicate it
} }
@ -128,7 +123,7 @@ export default class SenderProfile extends React.Component<IProps, IState> {
let flair; let flair;
if (this.props.enableFlair) { if (this.props.enableFlair) {
const displayedGroups = this._getDisplayedGroups( const displayedGroups = this.getDisplayedGroups(
this.state.userGroups, this.state.relatedGroups, this.state.userGroups, this.state.relatedGroups,
); );

View file

@ -14,9 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import { MatrixEvent } from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import RoomContext from "../../../contexts/RoomContext";
import * as TextForEvent from "../../../TextForEvent"; import * as TextForEvent from "../../../TextForEvent";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@ -26,11 +27,11 @@ interface IProps {
@replaceableComponent("views.messages.TextualEvent") @replaceableComponent("views.messages.TextualEvent")
export default class TextualEvent extends React.Component<IProps> { export default class TextualEvent extends React.Component<IProps> {
render() { static contextType = RoomContext;
const text = TextForEvent.textForEvent(this.props.mxEvent, true);
if (!text || (text as string).length === 0) return null; public render() {
return ( const text = TextForEvent.textForEvent(this.props.mxEvent, true, this.context?.showHiddenEventsInTimeline);
<div className="mx_TextualEvent">{ text }</div> if (!text) return null;
); return <div className="mx_TextualEvent">{ text }</div>;
} }
} }

View file

@ -69,6 +69,7 @@ import RoomName from "../elements/RoomName";
import { mediaFromMxc } from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import UIStore from "../../../stores/UIStore"; import UIStore from "../../../stores/UIStore";
import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import SpaceStore from "../../../stores/SpaceStore";
export interface IDevice { export interface IDevice {
deviceId: string; deviceId: string;
@ -728,7 +729,7 @@ const MuteToggleButton: React.FC<IBaseRoomProps> = ({ member, room, powerLevels,
// if muting self, warn as it may be irreversible // if muting self, warn as it may be irreversible
if (target === cli.getUserId()) { if (target === cli.getUserId()) {
try { try {
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
return; return;
@ -817,7 +818,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= kickPowerLevel) { if (canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; kickButton = <RoomKickButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
} }
if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { if (me.powerLevel >= redactPowerLevel && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
redactButton = ( redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} /> <RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
); );
@ -1096,7 +1097,7 @@ const PowerLevelEditor: React.FC<{
} else if (myUserId === target) { } else if (myUserId === target) {
// If we are changing our own PL it can only ever be decreasing, which we cannot reverse. // If we are changing our own PL it can only ever be decreasing, which we cannot reverse.
try { try {
if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SpaceStore.spacesEnabled && room?.isSpaceRoom()))) return;
} catch (e) { } catch (e) {
console.error("Failed to warn about self demotion: ", e); console.error("Failed to warn about self demotion: ", e);
} }
@ -1326,10 +1327,10 @@ const BasicUserInfo: React.FC<{
if (!isRoomEncrypted) { if (!isRoomEncrypted) {
if (!cryptoEnabled) { if (!cryptoEnabled) {
text = _t("This client does not support end-to-end encryption."); text = _t("This client does not support end-to-end encryption.");
} else if (room && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) { } else if (room && (!SpaceStore.spacesEnabled || !room.isSpaceRoom())) {
text = _t("Messages in this room are not end-to-end encrypted."); text = _t("Messages in this room are not end-to-end encrypted.");
} }
} else if (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom()) { } else if (!SpaceStore.spacesEnabled || !room.isSpaceRoom()) {
text = _t("Messages in this room are end-to-end encrypted."); text = _t("Messages in this room are end-to-end encrypted.");
} }
@ -1405,7 +1406,7 @@ const BasicUserInfo: React.FC<{
canInvite={roomPermissions.canInvite} canInvite={roomPermissions.canInvite}
isIgnored={isIgnored} isIgnored={isIgnored}
member={member as RoomMember} member={member as RoomMember}
isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()} isSpace={SpaceStore.spacesEnabled && room?.isSpaceRoom()}
/> />
{ adminToolsContainer } { adminToolsContainer }
@ -1568,7 +1569,7 @@ const UserInfo: React.FC<IProps> = ({
previousPhase = RightPanelPhases.RoomMemberInfo; previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = { member: member }; refireParams = { member: member };
} else if (room) { } else if (room) {
previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom() previousPhase = previousPhase = SpaceStore.spacesEnabled && room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList ? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList; : RightPanelPhases.RoomMemberList;
} }
@ -1617,7 +1618,7 @@ const UserInfo: React.FC<IProps> = ({
} }
let scopeHeader; let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} /> <RoomName room={room} />

View file

@ -27,7 +27,6 @@ import { _t } from '../../../languageHandler';
import { hasText } from "../../../TextForEvent"; import { hasText } from "../../../TextForEvent";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import { Layout } from "../../../settings/Layout"; import { Layout } from "../../../settings/Layout";
import { formatTime } from "../../../DateUtils"; import { formatTime } from "../../../DateUtils";
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
@ -54,6 +53,7 @@ import TooltipButton from '../elements/TooltipButton';
import ReadReceiptMarker from "./ReadReceiptMarker"; 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';
const eventTileTypes = { const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent', [EventType.RoomMessage]: 'messages.MessageEvent',
@ -192,8 +192,6 @@ export interface IReadReceiptProps {
export enum TileShape { export enum TileShape {
Notif = "notif", Notif = "notif",
FileGrid = "file_grid", FileGrid = "file_grid",
Reply = "reply",
ReplyPreview = "reply_preview",
Pinned = "pinned", Pinned = "pinned",
} }
@ -322,7 +320,7 @@ export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean; private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean; private isListeningForReceipts: boolean;
private tile = React.createRef(); private tile = React.createRef();
private replyThread = React.createRef(); private replyThread = React.createRef<ReplyThread>();
public readonly ref = createRef<HTMLElement>(); public readonly ref = createRef<HTMLElement>();
@ -848,35 +846,9 @@ export default class EventTile extends React.Component<IProps, IState> {
}; };
render() { render() {
//console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); const msgtype = this.props.mxEvent.getContent().msgtype;
const { tileHandler, isBubbleMessage, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
const content = this.props.mxEvent.getContent();
const msgtype = content.msgtype;
const eventType = this.props.mxEvent.getType();
let tileHandler = getHandlerTile(this.props.mxEvent);
// Info messages are basically information about commands processed on a room
let isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === EventType.RoomCreate) ||
(eventType === EventType.RoomEncryption) ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = (
!isBubbleMessage && eventType !== EventType.RoomMessage &&
eventType !== EventType.Sticker && eventType !== EventType.RoomCreate
);
// If we're showing hidden events in the timeline, we should use the
// source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing).
if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) {
tileHandler = "messages.ViewSourceEvent";
isBubbleMessage = false;
// Reuse info message avatar and sender profile styling
isInfoMessage = true;
}
// This shouldn't happen: the caller should check we support this type // This shouldn't happen: the caller should check we support this type
// before trying to instantiate us // before trying to instantiate us
if (!tileHandler) { if (!tileHandler) {
@ -980,11 +952,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
if (needsSenderProfile) { if (needsSenderProfile) {
if ( if (!this.props.tileShape) {
!this.props.tileShape
|| this.props.tileShape === TileShape.Reply
|| this.props.tileShape === TileShape.ReplyPreview
) {
sender = <SenderProfile onClick={this.onSenderProfileClick} sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
@ -1134,44 +1102,6 @@ export default class EventTile extends React.Component<IProps, IState> {
]); ]);
} }
case TileShape.Reply:
case TileShape.ReplyPreview: {
let thread;
if (this.props.tileShape === TileShape.ReplyPreview) {
thread = ReplyThread.makeThread(
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this.replyThread,
null,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
return React.createElement(this.props.as || "li", {
"className": classes,
"aria-live": ariaLive,
"aria-atomic": true,
"data-scroll-tokens": scrollToken,
}, [
ircTimestamp,
avatar,
sender,
ircPadlock,
<div className="mx_EventTile_reply" key="mx_EventTile_reply">
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false}
/>
</div>,
]);
}
default: { default: {
const thread = ReplyThread.makeThread( const thread = ReplyThread.makeThread(
this.props.mxEvent, this.props.mxEvent,
@ -1193,10 +1123,10 @@ export default class EventTile extends React.Component<IProps, IState> {
"data-scroll-tokens": scrollToken, "data-scroll-tokens": scrollToken,
"onMouseEnter": () => this.setState({ hover: true }), "onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }), "onMouseLeave": () => this.setState({ hover: false }),
}, [ }, <>
ircTimestamp, { ircTimestamp }
sender, { sender }
ircPadlock, { ircPadlock }
<div className="mx_EventTile_line" key="mx_EventTile_line"> <div className="mx_EventTile_line" key="mx_EventTile_line">
{ groupTimestamp } { groupTimestamp }
{ groupPadlock } { groupPadlock }
@ -1214,11 +1144,10 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo } { keyRequestInfo }
{ reactionsRow } { reactionsRow }
{ actionBar } { actionBar }
</div>, </div>
msgOption, { msgOption }
avatar, { avatar }
</>)
])
); );
} }
} }
@ -1231,7 +1160,7 @@ function isMessageEvent(ev) {
return (messageTypes.includes(ev.getType())); return (messageTypes.includes(ev.getType()));
} }
export function haveTileForEvent(e) { export function haveTileForEvent(e: MatrixEvent, showHiddenEvents?: boolean) {
// Only messages have a tile (black-rectangle) if redacted // Only messages have a tile (black-rectangle) if redacted
if (e.isRedacted() && !isMessageEvent(e)) return false; if (e.isRedacted() && !isMessageEvent(e)) return false;
@ -1241,7 +1170,7 @@ export function haveTileForEvent(e) {
const handler = getHandlerTile(e); const handler = getHandlerTile(e);
if (handler === undefined) return false; if (handler === undefined) return false;
if (handler === 'messages.TextualEvent') { if (handler === 'messages.TextualEvent') {
return hasText(e); return hasText(e, showHiddenEvents);
} else if (handler === 'messages.RoomCreate') { } else if (handler === 'messages.RoomCreate') {
return Boolean(e.getContent()['predecessor']); return Boolean(e.getContent()['predecessor']);
} else { } else {

View file

@ -40,10 +40,12 @@ const LinkPreviewGroup: React.FC<IProps> = ({ links, mxEvent, onCancelClick, onH
const ts = mxEvent.getTs(); const ts = mxEvent.getTs();
const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => { const previews = useAsyncMemo<[string, IPreviewUrlResponse][]>(async () => {
return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(link => { return Promise.all<[string, IPreviewUrlResponse] | void>(links.map(async link => {
return cli.getUrlPreview(link, ts).then(preview => [link, preview], error => { try {
return [link, await cli.getUrlPreview(link, ts)];
} catch (error) {
console.error("Failed to get URL preview: " + error); console.error("Failed to get URL preview: " + error);
}); }
})).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>; })).then(a => a.filter(Boolean)) as Promise<[string, IPreviewUrlResponse][]>;
}, [links, ts], []); }, [links, ts], []);

View file

@ -43,6 +43,7 @@ import EntityTile from "./EntityTile";
import MemberTile from "./MemberTile"; import MemberTile from "./MemberTile";
import BaseAvatar from '../avatars/BaseAvatar'; import BaseAvatar from '../avatars/BaseAvatar';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import SpaceStore from "../../../stores/SpaceStore";
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5; const INITIAL_LOAD_NUM_INVITED = 5;
@ -509,7 +510,7 @@ export default class MemberList extends React.Component<IProps, IState> {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat(); const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) { if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community"); inviteButtonText = _t("Invite to this community");
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { } else if (SpaceStore.spacesEnabled && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space"); inviteButtonText = _t("Invite to this space");
} }
@ -549,7 +550,7 @@ export default class MemberList extends React.Component<IProps, IState> {
let previousPhase = RightPanelPhases.RoomSummary; let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space // We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader; let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) { if (SpaceStore.spacesEnabled && room?.isSpaceRoom()) {
previousPhase = undefined; previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} /> <RoomAvatar room={room} height={32} width={32} />

View file

@ -16,15 +16,13 @@ limitations under the License.
import React from 'react'; import React from 'react';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore from "../../../settings/SettingsStore";
import PropTypes from "prop-types";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { TileShape } from "./EventTile"; import ReplyTile from './ReplyTile';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { EventSubscription } from 'fbemitter';
function cancelQuoting() { function cancelQuoting() {
dis.dispatch({ dis.dispatch({
@ -33,47 +31,50 @@ function cancelQuoting() {
}); });
} }
interface IProps {
permalinkCreator: RoomPermalinkCreator;
}
interface IState {
event: MatrixEvent;
}
@replaceableComponent("views.rooms.ReplyPreview") @replaceableComponent("views.rooms.ReplyPreview")
export default class ReplyPreview extends React.Component { export default class ReplyPreview extends React.Component<IProps, IState> {
static propTypes = { private unmounted = false;
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired, private readonly roomStoreToken: EventSubscription;
};
constructor(props) { constructor(props) {
super(props); super(props);
this.unmounted = false;
this.state = { this.state = {
event: RoomViewStore.getQuotingEvent(), event: RoomViewStore.getQuotingEvent(),
}; };
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate);
} }
componentWillUnmount() { componentWillUnmount() {
this.unmounted = true; this.unmounted = true;
// Remove RoomStore listener // Remove RoomStore listener
if (this._roomStoreToken) { if (this.roomStoreToken) {
this._roomStoreToken.remove(); this.roomStoreToken.remove();
} }
} }
_onRoomViewStoreUpdate() { private onRoomViewStoreUpdate = (): void => {
if (this.unmounted) return; if (this.unmounted) return;
const event = RoomViewStore.getQuotingEvent(); const event = RoomViewStore.getQuotingEvent();
if (this.state.event !== event) { if (this.state.event !== event) {
this.setState({ event }); this.setState({ event });
} }
} };
render() { render() {
if (!this.state.event) return null; if (!this.state.event) return null;
const EventTile = sdk.getComponent('rooms.EventTile');
return <div className="mx_ReplyPreview"> return <div className="mx_ReplyPreview">
<div className="mx_ReplyPreview_section"> <div className="mx_ReplyPreview_section">
<div className="mx_ReplyPreview_header mx_ReplyPreview_title"> <div className="mx_ReplyPreview_header mx_ReplyPreview_title">
@ -89,15 +90,12 @@ export default class ReplyPreview extends React.Component {
/> />
</div> </div>
<div className="mx_ReplyPreview_clear" /> <div className="mx_ReplyPreview_clear" />
<EventTile <div className="mx_ReplyPreview_tile">
alwaysShowTimestamps={true} <ReplyTile
tileShape={TileShape.ReplyPreview} mxEvent={this.state.event}
mxEvent={this.state.event} permalinkCreator={this.props.permalinkCreator}
permalinkCreator={this.props.permalinkCreator} />
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} </div>
enableFlair={SettingsStore.getValue(UIFeature.Flair)}
as="div"
/>
</div> </div>
</div>; </div>;
} }

View file

@ -0,0 +1,155 @@
/*
Copyright 2020-2021 Tulir Asokan <tulir@maunium.net>
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 classNames from 'classnames';
import { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import SenderProfile from "../messages/SenderProfile";
import MImageReplyBody from "../messages/MImageReplyBody";
import * as sdk from '../../../index';
import { EventType, MsgType } from 'matrix-js-sdk/src/@types/event';
import { replaceableComponent } from '../../../utils/replaceableComponent';
import { getEventDisplayInfo } from '../../../utils/EventUtils';
import MFileBody from "../messages/MFileBody";
interface IProps {
mxEvent: MatrixEvent;
permalinkCreator?: RoomPermalinkCreator;
highlights?: string[];
highlightLink?: string;
onHeightChanged?(): void;
}
@replaceableComponent("views.rooms.ReplyTile")
export default class ReplyTile extends React.PureComponent<IProps> {
static defaultProps = {
onHeightChanged: () => {},
};
componentDidMount() {
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
this.props.mxEvent.on("Event.beforeRedaction", this.onEventRequiresUpdate);
this.props.mxEvent.on("Event.replaced", this.onEventRequiresUpdate);
}
componentWillUnmount() {
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
this.props.mxEvent.removeListener("Event.beforeRedaction", this.onEventRequiresUpdate);
this.props.mxEvent.removeListener("Event.replaced", this.onEventRequiresUpdate);
}
private onDecrypted = (): void => {
this.forceUpdate();
if (this.props.onHeightChanged) {
this.props.onHeightChanged();
}
};
private onEventRequiresUpdate = (): void => {
// Force update when necessary - redactions and edits
this.forceUpdate();
};
private onClick = (e: React.MouseEvent): void => {
// This allows the permalink to be opened in a new tab/window or copied as
// matrix.to, but also for it to enable routing within Riot when clicked.
e.preventDefault();
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
};
render() {
const mxEvent = this.props.mxEvent;
const msgType = mxEvent.getContent().msgtype;
const evType = mxEvent.getType() as EventType;
const { tileHandler, isInfoMessage } = getEventDisplayInfo(this.props.mxEvent);
// This shouldn't happen: the caller should check we support this type
// before trying to instantiate us
if (!tileHandler) {
const { mxEvent } = this.props;
console.warn(`Event type not supported: type:${mxEvent.getType()} isState:${mxEvent.isState()}`);
return <div className="mx_ReplyTile mx_ReplyTile_info mx_MNoticeBody">
{ _t('This event could not be displayed') }
</div>;
}
const EventTileType = sdk.getComponent(tileHandler);
const classes = classNames("mx_ReplyTile", {
mx_ReplyTile_info: isInfoMessage && !this.props.mxEvent.isRedacted(),
mx_ReplyTile_audio: msgType === MsgType.Audio,
mx_ReplyTile_video: msgType === MsgType.Video,
});
let permalink = "#";
if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
let sender;
const needsSenderProfile = (
!isInfoMessage &&
msgType !== MsgType.Image &&
tileHandler !== EventType.RoomCreate &&
evType !== EventType.Sticker
);
if (needsSenderProfile) {
sender = <SenderProfile
mxEvent={this.props.mxEvent}
enableFlair={false}
/>;
}
const msgtypeOverrides = {
[MsgType.Image]: MImageReplyBody,
// Override audio and video body with file body. We also hide the download/decrypt button using CSS
[MsgType.Audio]: MFileBody,
[MsgType.Video]: MFileBody,
};
const evOverrides = {
// Use MImageReplyBody so that the sticker isn't taking up a lot of space
[EventType.Sticker]: MImageReplyBody,
};
return (
<div className={classes}>
<a href={permalink} onClick={this.onClick}>
{ sender }
<EventTileType
ref="tile"
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
showUrlPreview={false}
overrideBodyTypes={msgtypeOverrides}
overrideEventTypes={evOverrides}
replacingEventId={this.props.mxEvent.replacingEventId()}
maxImageHeight={96} />
</a>
</div>
);
}
}

View file

@ -417,7 +417,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
} }
private renderCommunityInvites(): ReactComponentElement<typeof ExtraTile>[] { private renderCommunityInvites(): ReactComponentElement<typeof ExtraTile>[] {
if (SettingsStore.getValue("feature_spaces")) return []; if (SpaceStore.spacesEnabled) return [];
// TODO: Put community invites in a more sensible place (not in the room list) // TODO: Put community invites in a more sensible place (not in the room list)
// See https://github.com/vector-im/element-web/issues/14456 // See https://github.com/vector-im/element-web/issues/14456
return MatrixClientPeg.get().getGroups().filter(g => { return MatrixClientPeg.get().getGroups().filter(g => {

View file

@ -408,10 +408,10 @@ export default class RoomSublist extends React.Component<IProps, IState> {
this.setState({ addRoomContextMenuPosition: null }); this.setState({ addRoomContextMenuPosition: null });
}; };
private onUnreadFirstChanged = async () => { private onUnreadFirstChanged = () => {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance; const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
await RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm); RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
}; };

View file

@ -358,6 +358,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.setState({ generalMenuPosition: null }); // hide the menu this.setState({ generalMenuPosition: null }); // hide the menu
}; };
private onCopyRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'copy_room',
room_id: this.props.room.roomId,
});
this.setState({ generalMenuPosition: null }); // hide the menu
};
private onInviteClick = (ev: ButtonEvent) => { private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -408,7 +419,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
> >
<IconizedContextMenuOptionList first> <IconizedContextMenuOptionList first>
<IconizedContextMenuRadio <IconizedContextMenuRadio
label={_t("Use default")} label={_t("Global")}
active={state === ALL_MESSAGES} active={state === ALL_MESSAGES}
iconClassName="mx_RoomTile_iconBell" iconClassName="mx_RoomTile_iconBell"
onClick={this.onClickAllNotifs} onClick={this.onClickAllNotifs}
@ -517,6 +528,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
iconClassName="mx_RoomTile_iconInvite" iconClassName="mx_RoomTile_iconInvite"
/> />
) : null} ) : null}
<IconizedContextMenuOption
onClick={this.onCopyRoomClick}
label={_t("Copy Room Link")}
iconClassName="mx_RoomTile_iconCopyLink"
/>
<IconizedContextMenuOption <IconizedContextMenuOption
onClick={this.onOpenRoomSettings} onClick={this.onOpenRoomSettings}
label={_t("Settings")} label={_t("Settings")}

View file

@ -15,14 +15,15 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import { SearchResult } from "matrix-js-sdk/src/models/search-result"; import { SearchResult } from "matrix-js-sdk/src/models/search-result";
import EventTile, { haveTileForEvent } from "./EventTile"; import RoomContext from "../../../contexts/RoomContext";
import DateSeparator from '../messages/DateSeparator';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import DateSeparator from "../messages/DateSeparator";
import EventTile, { haveTileForEvent } from "./EventTile";
interface IProps { interface IProps {
// a matrix-js-sdk SearchResult containing the details of this result // a matrix-js-sdk SearchResult containing the details of this result
@ -37,6 +38,8 @@ interface IProps {
@replaceableComponent("views.rooms.SearchResultTile") @replaceableComponent("views.rooms.SearchResultTile")
export default class SearchResultTile extends React.Component<IProps> { export default class SearchResultTile extends React.Component<IProps> {
static contextType = RoomContext;
public render() { public render() {
const result = this.props.searchResult; const result = this.props.searchResult;
const mxEv = result.context.getEvent(); const mxEv = result.context.getEvent();
@ -44,7 +47,10 @@ export default class SearchResultTile extends React.Component<IProps> {
const ts1 = mxEv.getTs(); const ts1 = mxEv.getTs();
const ret = [<DateSeparator key={ts1 + "-search"} ts={ts1} />]; const ret = [<DateSeparator key={ts1 + "-search"} ts={ts1} />];
const layout = SettingsStore.getValue("layout");
const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps"); const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps");
const enableFlair = SettingsStore.getValue(UIFeature.Flair);
const timeline = result.context.getTimeline(); const timeline = result.context.getTimeline();
for (let j = 0; j < timeline.length; j++) { for (let j = 0; j < timeline.length; j++) {
@ -54,26 +60,25 @@ export default class SearchResultTile extends React.Component<IProps> {
if (!contextual) { if (!contextual) {
highlights = this.props.searchHighlights; highlights = this.props.searchHighlights;
} }
if (haveTileForEvent(ev)) { if (haveTileForEvent(ev, this.context?.showHiddenEventsInTimeline)) {
ret.push(( ret.push(
<EventTile <EventTile
key={`${eventId}+${j}`} key={`${eventId}+${j}`}
mxEvent={ev} mxEvent={ev}
layout={layout}
contextual={contextual} contextual={contextual}
highlights={highlights} highlights={highlights}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
highlightLink={this.props.resultLink} highlightLink={this.props.resultLink}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} isTwelveHour={isTwelveHour}
alwaysShowTimestamps={alwaysShowTimestamps} alwaysShowTimestamps={alwaysShowTimestamps}
enableFlair={SettingsStore.getValue(UIFeature.Flair)} enableFlair={enableFlair}
/> />,
)); );
} }
} }
return (
<li data-scroll-tokens={eventId}> return <li data-scroll-tokens={eventId}>{ ret }</li>;
{ ret }
</li>);
} }
} }

View file

@ -25,9 +25,9 @@ import { isValid3pidInvite } from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import ErrorDialog from '../dialogs/ErrorDialog'; import ErrorDialog from '../dialogs/ErrorDialog';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import SpaceStore from "../../../stores/SpaceStore";
interface IProps { interface IProps {
event: MatrixEvent; event: MatrixEvent;
@ -134,7 +134,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
} }
let scopeHeader; let scopeHeader;
if (SettingsStore.getValue("feature_spaces") && this.room.isSpaceRoom()) { if (SpaceStore.spacesEnabled && this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader"> scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} /> <RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} /> <RoomName room={this.room} />

View file

@ -1,917 +0,0 @@
/*
Copyright 2016 OpenMarket Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from 'react';
import * as sdk from '../../../index';
import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg';
import SettingsStore from '../../../settings/SettingsStore';
import Modal from '../../../Modal';
import {
NotificationUtils,
VectorPushRulesDefinitions,
PushRuleVectorState,
ContentRules,
} from '../../../notifications';
import SdkConfig from "../../../SdkConfig";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton";
import { SettingLevel } from "../../../settings/SettingLevel";
import { UIFeature } from "../../../settings/UIFeature";
import { replaceableComponent } from "../../../utils/replaceableComponent";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
// TODO: this component also does a lot of direct poking into this.state, which
// is VERY NAUGHTY.
/**
* Rules that Vector used to set in order to override the actions of default rules.
* These are used to port peoples existing overrides to match the current API.
* These can be removed and forgotten once everyone has moved to the new client.
*/
const LEGACY_RULES = {
"im.vector.rule.contains_display_name": ".m.rule.contains_display_name",
"im.vector.rule.room_one_to_one": ".m.rule.room_one_to_one",
"im.vector.rule.room_message": ".m.rule.message",
"im.vector.rule.invite_for_me": ".m.rule.invite_for_me",
"im.vector.rule.call": ".m.rule.call",
"im.vector.rule.notices": ".m.rule.suppress_notices",
};
function portLegacyActions(actions) {
const decoded = NotificationUtils.decodeActions(actions);
if (decoded !== null) {
return NotificationUtils.encodeActions(decoded);
} else {
// We don't recognise one of the actions here, so we don't try to
// canonicalise them.
return actions;
}
}
@replaceableComponent("views.settings.Notifications")
export default class Notifications extends React.Component {
static phases = {
LOADING: "LOADING", // The component is loading or sending data to the hs
DISPLAY: "DISPLAY", // The component is ready and display data
ERROR: "ERROR", // There was an error
};
state = {
phase: Notifications.phases.LOADING,
masterPushRule: undefined, // The master rule ('.m.rule.master')
vectorPushRules: [], // HS default push rules displayed in Vector UI
vectorContentRules: { // Keyword push rules displayed in Vector UI
vectorState: PushRuleVectorState.ON,
rules: [],
},
externalPushRules: [], // Push rules (except content rule) that have been defined outside Vector UI
externalContentRules: [], // Keyword push rules that have been defined outside Vector UI
threepids: [], // used for email notifications
};
componentDidMount() {
this._refreshFromServer();
}
onEnableNotificationsChange = (checked) => {
const self = this;
this.setState({
phase: Notifications.phases.LOADING,
});
MatrixClientPeg.get().setPushRuleEnabled(
'global', self.state.masterPushRule.kind, self.state.masterPushRule.rule_id, !checked,
).then(function() {
self._refreshFromServer();
});
};
onEnableDesktopNotificationsChange = (checked) => {
SettingsStore.setValue(
"notificationsEnabled", null,
SettingLevel.DEVICE,
checked,
).finally(() => {
this.forceUpdate();
});
};
onEnableDesktopNotificationBodyChange = (checked) => {
SettingsStore.setValue(
"notificationBodyEnabled", null,
SettingLevel.DEVICE,
checked,
).finally(() => {
this.forceUpdate();
});
};
onEnableAudioNotificationsChange = (checked) => {
SettingsStore.setValue(
"audioNotificationsEnabled", null,
SettingLevel.DEVICE,
checked,
).finally(() => {
this.forceUpdate();
});
};
/*
* Returns the email pusher (pusher of type 'email') for a given
* email address. Email pushers all have the same app ID, so since
* pushers are unique over (app ID, pushkey), there will be at most
* one such pusher.
*/
getEmailPusher(pushers, address) {
if (pushers === undefined) {
return undefined;
}
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return pushers[i];
}
}
return undefined;
}
onEnableEmailNotificationsChange = (address, checked) => {
let emailPusherPromise;
if (checked) {
const data = {};
data['brand'] = SdkConfig.get().brand;
emailPusherPromise = MatrixClientPeg.get().setPusher({
kind: 'email',
app_id: 'm.email',
pushkey: address,
app_display_name: 'Email Notifications',
device_display_name: address,
lang: navigator.language,
data: data,
append: true, // We always append for email pushers since we don't want to stop other accounts notifying to the same email address
});
} else {
const emailPusher = this.getEmailPusher(this.state.pushers, address);
emailPusher.kind = null;
emailPusherPromise = MatrixClientPeg.get().setPusher(emailPusher);
}
emailPusherPromise.then(() => {
this._refreshFromServer();
}, (error) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Error saving email notification preferences', '', ErrorDialog, {
title: _t('Error saving email notification preferences'),
description: _t('An error occurred whilst saving your email notification preferences.'),
});
});
};
onNotifStateButtonClicked = (event) => {
// FIXME: use .bind() rather than className metadata here surely
const vectorRuleId = event.target.className.split("-")[0];
const newPushRuleVectorState = event.target.className.split("-")[1];
if ("_keywords" === vectorRuleId) {
this._setKeywordsPushRuleVectorState(newPushRuleVectorState);
} else {
const rule = this.getRule(vectorRuleId);
if (rule) {
this._setPushRuleVectorState(rule, newPushRuleVectorState);
}
}
};
onKeywordsClicked = (event) => {
// Compute the keywords list to display
let keywords = [];
for (const i in this.state.vectorContentRules.rules) {
const rule = this.state.vectorContentRules.rules[i];
keywords.push(rule.pattern);
}
if (keywords.length) {
// As keeping the order of per-word push rules hs side is a bit tricky to code,
// display the keywords in alphabetical order to the user
keywords.sort();
keywords = keywords.join(", ");
} else {
keywords = "";
}
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
Modal.createTrackedDialog('Keywords Dialog', '', TextInputDialog, {
title: _t('Keywords'),
description: _t('Enter keywords separated by a comma:'),
button: _t('OK'),
value: keywords,
onFinished: (shouldLeave, newValue) => {
if (shouldLeave && newValue !== keywords) {
let newKeywords = newValue.split(',');
for (const i in newKeywords) {
newKeywords[i] = newKeywords[i].trim();
}
// Remove duplicates and empty
newKeywords = newKeywords.reduce(function(array, keyword) {
if (keyword !== "" && array.indexOf(keyword) < 0) {
array.push(keyword);
}
return array;
}, []);
this._setKeywords(newKeywords);
}
},
});
};
getRule(vectorRuleId) {
for (const i in this.state.vectorPushRules) {
const rule = this.state.vectorPushRules[i];
if (rule.vectorRuleId === vectorRuleId) {
return rule;
}
}
}
_setPushRuleVectorState(rule, newPushRuleVectorState) {
if (rule && rule.vectorState !== newPushRuleVectorState) {
this.setState({
phase: Notifications.phases.LOADING,
});
const self = this;
const cli = MatrixClientPeg.get();
const deferreds = [];
const ruleDefinition = VectorPushRulesDefinitions[rule.vectorRuleId];
if (rule.rule) {
const actions = ruleDefinition.vectorStateToActions[newPushRuleVectorState];
if (!actions) {
// The new state corresponds to disabling the rule.
deferreds.push(cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false));
} else {
// The new state corresponds to enabling the rule and setting specific actions
deferreds.push(this._updatePushRuleActions(rule.rule, actions, true));
}
}
Promise.all(deferreds).then(function() {
self._refreshFromServer();
}, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to change settings: " + error);
Modal.createTrackedDialog('Failed to change settings', '', ErrorDialog, {
title: _t('Failed to change settings'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer,
});
});
}
}
_setKeywordsPushRuleVectorState(newPushRuleVectorState) {
// Is there really a change?
if (this.state.vectorContentRules.vectorState === newPushRuleVectorState
|| this.state.vectorContentRules.rules.length === 0) {
return;
}
const self = this;
const cli = MatrixClientPeg.get();
this.setState({
phase: Notifications.phases.LOADING,
});
// Update all rules in self.state.vectorContentRules
const deferreds = [];
for (const i in this.state.vectorContentRules.rules) {
const rule = this.state.vectorContentRules.rules[i];
let enabled; let actions;
switch (newPushRuleVectorState) {
case PushRuleVectorState.ON:
if (rule.actions.length !== 1) {
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.ON);
}
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
enabled = true;
}
break;
case PushRuleVectorState.LOUD:
if (rule.actions.length !== 3) {
actions = PushRuleVectorState.actionsFor(PushRuleVectorState.LOUD);
}
if (this.state.vectorContentRules.vectorState === PushRuleVectorState.OFF) {
enabled = true;
}
break;
case PushRuleVectorState.OFF:
enabled = false;
break;
}
if (actions) {
// Note that the workaround in _updatePushRuleActions will automatically
// enable the rule
deferreds.push(this._updatePushRuleActions(rule, actions, enabled));
} else if (enabled != undefined) {
deferreds.push(cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled));
}
}
Promise.all(deferreds).then(function(resps) {
self._refreshFromServer();
}, function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Can't update user notification settings: " + error);
Modal.createTrackedDialog('Can\'t update user notifcation settings', '', ErrorDialog, {
title: _t('Can\'t update user notification settings'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer,
});
});
}
_setKeywords(newKeywords) {
this.setState({
phase: Notifications.phases.LOADING,
});
const self = this;
const cli = MatrixClientPeg.get();
const removeDeferreds = [];
// Remove per-word push rules of keywords that are no more in the list
const vectorContentRulesPatterns = [];
for (const i in self.state.vectorContentRules.rules) {
const rule = self.state.vectorContentRules.rules[i];
vectorContentRulesPatterns.push(rule.pattern);
if (newKeywords.indexOf(rule.pattern) < 0) {
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
}
}
// If the keyword is part of `externalContentRules`, remove the rule
// before recreating it in the right Vector path
for (const i in self.state.externalContentRules) {
const rule = self.state.externalContentRules[i];
if (newKeywords.indexOf(rule.pattern) >= 0) {
removeDeferreds.push(cli.deletePushRule('global', rule.kind, rule.rule_id));
}
}
const onError = function(error) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
console.error("Failed to update keywords: " + error);
Modal.createTrackedDialog('Failed to update keywords', '', ErrorDialog, {
title: _t('Failed to update keywords'),
description: ((error && error.message) ? error.message : _t('Operation failed')),
onFinished: self._refreshFromServer,
});
};
// Then, add the new ones
Promise.all(removeDeferreds).then(function(resps) {
const deferreds = [];
let pushRuleVectorStateKind = self.state.vectorContentRules.vectorState;
if (pushRuleVectorStateKind === PushRuleVectorState.OFF) {
// When the current global keywords rule is OFF, we need to look at
// the flavor of rules in 'vectorContentRules' to apply the same actions
// when creating the new rule.
// Thus, this new rule will join the 'vectorContentRules' set.
if (self.state.vectorContentRules.rules.length) {
pushRuleVectorStateKind = PushRuleVectorState.contentRuleVectorStateKind(
self.state.vectorContentRules.rules[0],
);
} else {
// ON is default
pushRuleVectorStateKind = PushRuleVectorState.ON;
}
}
for (const i in newKeywords) {
const keyword = newKeywords[i];
if (vectorContentRulesPatterns.indexOf(keyword) < 0) {
if (self.state.vectorContentRules.vectorState !== PushRuleVectorState.OFF) {
deferreds.push(cli.addPushRule('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword,
}));
} else {
deferreds.push(self._addDisabledPushRule('global', 'content', keyword, {
actions: PushRuleVectorState.actionsFor(pushRuleVectorStateKind),
pattern: keyword,
}));
}
}
}
Promise.all(deferreds).then(function(resps) {
self._refreshFromServer();
}, onError);
}, onError);
}
// Create a push rule but disabled
_addDisabledPushRule(scope, kind, ruleId, body) {
const cli = MatrixClientPeg.get();
return cli.addPushRule(scope, kind, ruleId, body).then(() =>
cli.setPushRuleEnabled(scope, kind, ruleId, false),
);
}
// Check if any legacy im.vector rules need to be ported to the new API
// for overriding the actions of default rules.
_portRulesToNewAPI(rulesets) {
const needsUpdate = [];
const cli = MatrixClientPeg.get();
for (const kind in rulesets.global) {
const ruleset = rulesets.global[kind];
for (let i = 0; i < ruleset.length; ++i) {
const rule = ruleset[i];
if (rule.rule_id in LEGACY_RULES) {
console.log("Porting legacy rule", rule);
needsUpdate.push( function(kind, rule) {
return cli.setPushRuleActions(
'global', kind, LEGACY_RULES[rule.rule_id], portLegacyActions(rule.actions),
).then(() =>
cli.deletePushRule('global', kind, rule.rule_id),
).catch( (e) => {
console.warn(`Error when porting legacy rule: ${e}`);
});
}(kind, rule));
}
}
}
if (needsUpdate.length > 0) {
// If some of the rules need to be ported then wait for the porting
// to happen and then fetch the rules again.
return Promise.all(needsUpdate).then(() =>
cli.getPushRules(),
);
} else {
// Otherwise return the rules that we already have.
return rulesets;
}
}
_refreshFromServer = () => {
const self = this;
const pushRulesPromise = MatrixClientPeg.get().getPushRules().then(
self._portRulesToNewAPI,
).then(function(rulesets) {
/// XXX seriously? wtf is this?
MatrixClientPeg.get().pushRules = rulesets;
// Get homeserver default rules and triage them by categories
const ruleCategories = {
// The master rule (all notifications disabling)
'.m.rule.master': 'master',
// The default push rules displayed by Vector UI
'.m.rule.contains_display_name': 'vector',
'.m.rule.contains_user_name': 'vector',
'.m.rule.roomnotif': 'vector',
'.m.rule.room_one_to_one': 'vector',
'.m.rule.encrypted_room_one_to_one': 'vector',
'.m.rule.message': 'vector',
'.m.rule.encrypted': 'vector',
'.m.rule.invite_for_me': 'vector',
//'.m.rule.member_event': 'vector',
'.m.rule.call': 'vector',
'.m.rule.suppress_notices': 'vector',
'.m.rule.tombstone': 'vector',
// Others go to others
};
// HS default rules
const defaultRules = { master: [], vector: {}, others: [] };
for (const kind in rulesets.global) {
for (let i = 0; i < Object.keys(rulesets.global[kind]).length; ++i) {
const r = rulesets.global[kind][i];
const cat = ruleCategories[r.rule_id];
r.kind = kind;
if (r.rule_id[0] === '.') {
if (cat === 'vector') {
defaultRules.vector[r.rule_id] = r;
} else if (cat === 'master') {
defaultRules.master.push(r);
} else {
defaultRules['others'].push(r);
}
}
}
}
// Get the master rule if any defined by the hs
if (defaultRules.master.length > 0) {
self.state.masterPushRule = defaultRules.master[0];
}
// parse the keyword rules into our state
const contentRules = ContentRules.parseContentRules(rulesets);
self.state.vectorContentRules = {
vectorState: contentRules.vectorState,
rules: contentRules.rules,
};
self.state.externalContentRules = contentRules.externalRules;
// Build the rules displayed in the Vector UI matrix table
self.state.vectorPushRules = [];
self.state.externalPushRules = [];
const vectorRuleIds = [
'.m.rule.contains_display_name',
'.m.rule.contains_user_name',
'.m.rule.roomnotif',
'_keywords',
'.m.rule.room_one_to_one',
'.m.rule.encrypted_room_one_to_one',
'.m.rule.message',
'.m.rule.encrypted',
'.m.rule.invite_for_me',
//'im.vector.rule.member_event',
'.m.rule.call',
'.m.rule.suppress_notices',
'.m.rule.tombstone',
];
for (const i in vectorRuleIds) {
const vectorRuleId = vectorRuleIds[i];
if (vectorRuleId === '_keywords') {
// keywords needs a special handling
// For Vector UI, this is a single global push rule but translated in Matrix,
// it corresponds to all content push rules (stored in self.state.vectorContentRule)
self.state.vectorPushRules.push({
"vectorRuleId": "_keywords",
"description": (
<span>
{ _t('Messages containing <span>keywords</span>',
{},
{ 'span': (sub) =>
<span className="mx_UserNotifSettings_keywords" onClick={ self.onKeywordsClicked }>{sub}</span>,
},
)}
</span>
),
"vectorState": self.state.vectorContentRules.vectorState,
});
} else {
const ruleDefinition = VectorPushRulesDefinitions[vectorRuleId];
const rule = defaultRules.vector[vectorRuleId];
const vectorState = ruleDefinition.ruleToVectorState(rule);
//console.log("Refreshing vectorPushRules for " + vectorRuleId +", "+ ruleDefinition.description +", " + rule +", " + vectorState);
self.state.vectorPushRules.push({
"vectorRuleId": vectorRuleId,
"description": _t(ruleDefinition.description), // Text from VectorPushRulesDefinitions.js
"rule": rule,
"vectorState": vectorState,
});
// if there was a rule which we couldn't parse, add it to the external list
if (rule && !vectorState) {
rule.description = ruleDefinition.description;
self.state.externalPushRules.push(rule);
}
}
}
// Build the rules not managed by Vector UI
const otherRulesDescriptions = {
'.m.rule.message': _t('Notify for all other messages/rooms'),
'.m.rule.fallback': _t('Notify me for anything else'),
};
for (const i in defaultRules.others) {
const rule = defaultRules.others[i];
const ruleDescription = otherRulesDescriptions[rule.rule_id];
// Show enabled default rules that was modified by the user
if (ruleDescription && rule.enabled && !rule.default) {
rule.description = ruleDescription;
self.state.externalPushRules.push(rule);
}
}
});
const pushersPromise = MatrixClientPeg.get().getPushers().then(function(resp) {
self.setState({ pushers: resp.pushers });
});
Promise.all([pushRulesPromise, pushersPromise]).then(function() {
self.setState({
phase: Notifications.phases.DISPLAY,
});
}, function(error) {
console.error(error);
self.setState({
phase: Notifications.phases.ERROR,
});
}).finally(() => {
// actually explicitly update our state having been deep-manipulating it
self.setState({
masterPushRule: self.state.masterPushRule,
vectorContentRules: self.state.vectorContentRules,
vectorPushRules: self.state.vectorPushRules,
externalContentRules: self.state.externalContentRules,
externalPushRules: self.state.externalPushRules,
});
});
MatrixClientPeg.get().getThreePids().then((r) => this.setState({ threepids: r.threepids }));
};
_onClearNotifications = () => {
const cli = MatrixClientPeg.get();
cli.getRooms().forEach(r => {
if (r.getUnreadNotificationCount() > 0) {
const events = r.getLiveTimeline().getEvents();
if (events.length) cli.sendReadReceipt(events.pop());
}
});
};
_updatePushRuleActions(rule, actions, enabled) {
const cli = MatrixClientPeg.get();
return cli.setPushRuleActions(
'global', rule.kind, rule.rule_id, actions,
).then( function() {
// Then, if requested, enabled or disabled the rule
if (undefined != enabled) {
return cli.setPushRuleEnabled(
'global', rule.kind, rule.rule_id, enabled,
);
}
});
}
renderNotifRulesTableRow(title, className, pushRuleVectorState) {
return (
<tr key={ className }>
<th>
{ title }
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.OFF}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.OFF }
onChange={ this.onNotifStateButtonClicked } />
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.ON}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.ON }
onChange={ this.onNotifStateButtonClicked } />
</th>
<th>
<input className= {className + "-" + PushRuleVectorState.LOUD}
type="radio"
checked={ pushRuleVectorState === PushRuleVectorState.LOUD }
onChange={ this.onNotifStateButtonClicked } />
</th>
</tr>
);
}
renderNotifRulesTableRows() {
const rows = [];
for (const i in this.state.vectorPushRules) {
const rule = this.state.vectorPushRules[i];
if (rule.rule === undefined && rule.vectorRuleId.startsWith(".m.")) {
console.warn(`Skipping render of rule ${rule.vectorRuleId} due to no underlying rule`);
continue;
}
//console.log("rendering: " + rule.description + ", " + rule.vectorRuleId + ", " + rule.vectorState);
rows.push(this.renderNotifRulesTableRow(rule.description, rule.vectorRuleId, rule.vectorState));
}
return rows;
}
hasEmailPusher(pushers, address) {
if (pushers === undefined) {
return false;
}
for (let i = 0; i < pushers.length; ++i) {
if (pushers[i].kind === 'email' && pushers[i].pushkey === address) {
return true;
}
}
return false;
}
emailNotificationsRow(address, label) {
return <LabelledToggleSwitch value={this.hasEmailPusher(this.state.pushers, address)}
onChange={this.onEnableEmailNotificationsChange.bind(this, address)}
label={label} key={`emailNotif_${label}`} />;
}
render() {
let spinner;
if (this.state.phase === Notifications.phases.LOADING) {
const Loader = sdk.getComponent("elements.Spinner");
spinner = <Loader />;
}
let masterPushRuleDiv;
if (this.state.masterPushRule) {
masterPushRuleDiv = <LabelledToggleSwitch value={!this.state.masterPushRule.enabled}
onChange={this.onEnableNotificationsChange}
label={_t('Enable notifications for this account')} />;
}
let clearNotificationsButton;
if (MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)) {
clearNotificationsButton = <AccessibleButton onClick={this._onClearNotifications} kind='danger'>
{_t("Clear notifications")}
</AccessibleButton>;
}
// When enabled, the master rule inhibits all existing rules
// So do not show all notification settings
if (this.state.masterPushRule && this.state.masterPushRule.enabled) {
return (
<div>
{masterPushRuleDiv}
<div className="mx_UserNotifSettings_notifTable">
{ _t('All notifications are currently disabled for all targets.') }
</div>
{clearNotificationsButton}
</div>
);
}
const emailThreepids = this.state.threepids.filter((tp) => tp.medium === "email");
let emailNotificationsRows;
if (emailThreepids.length > 0) {
emailNotificationsRows = emailThreepids.map((threePid) => this.emailNotificationsRow(
threePid.address, `${_t('Enable email notifications')} (${threePid.address})`,
));
} else if (SettingsStore.getValue(UIFeature.ThirdPartyID)) {
emailNotificationsRows = <div>
{ _t('Add an email address to configure email notifications') }
</div>;
}
// Build external push rules
const externalRules = [];
for (const i in this.state.externalPushRules) {
const rule = this.state.externalPushRules[i];
externalRules.push(<li>{ _t(rule.description) }</li>);
}
// Show keywords not displayed by the vector UI as a single external push rule
let externalKeywords = [];
for (const i in this.state.externalContentRules) {
const rule = this.state.externalContentRules[i];
externalKeywords.push(rule.pattern);
}
if (externalKeywords.length) {
externalKeywords = externalKeywords.join(", ");
externalRules.push(<li>
{_t('Notifications on the following keywords follow rules which cant be displayed here:') }
{ externalKeywords }
</li>);
}
let devicesSection;
if (this.state.pushers === undefined) {
devicesSection = <div className="error">{ _t('Unable to fetch notification target list') }</div>;
} else if (this.state.pushers.length === 0) {
devicesSection = null;
} else {
// TODO: It would be great to be able to delete pushers from here too,
// and this wouldn't be hard to add.
const rows = [];
for (let i = 0; i < this.state.pushers.length; ++i) {
rows.push(<tr key={ i }>
<td>{this.state.pushers[i].app_display_name}</td>
<td>{this.state.pushers[i].device_display_name}</td>
</tr>);
}
devicesSection = (<table className="mx_UserNotifSettings_devicesTable">
<tbody>
{rows}
</tbody>
</table>);
}
if (devicesSection) {
devicesSection = (<div>
<h3>{ _t('Notification targets') }</h3>
{ devicesSection }
</div>);
}
let advancedSettings;
if (externalRules.length) {
const brand = SdkConfig.get().brand;
advancedSettings = (
<div>
<h3>{ _t('Advanced notification settings') }</h3>
{ _t('There are advanced notifications which are not shown here.') }<br />
{_t(
'You might have configured them in a client other than %(brand)s. ' +
'You cannot tune them in %(brand)s but they still apply.',
{ brand },
)}
<ul>
{ externalRules }
</ul>
</div>
);
}
return (
<div>
{masterPushRuleDiv}
<div className="mx_UserNotifSettings_notifTable">
{ spinner }
<LabelledToggleSwitch value={SettingsStore.getValue("notificationsEnabled")}
onChange={this.onEnableDesktopNotificationsChange}
label={_t('Enable desktop notifications for this session')} />
<LabelledToggleSwitch value={SettingsStore.getValue("notificationBodyEnabled")}
onChange={this.onEnableDesktopNotificationBodyChange}
label={_t('Show message in desktop notification')} />
<LabelledToggleSwitch value={SettingsStore.getValue("audioNotificationsEnabled")}
onChange={this.onEnableAudioNotificationsChange}
label={_t('Enable audible notifications for this session')} />
{ emailNotificationsRows }
<div className="mx_UserNotifSettings_pushRulesTableWrapper">
<table className="mx_UserNotifSettings_pushRulesTable">
<thead>
<tr>
<th width="55%"></th>
<th width="15%">{ _t('Off') }</th>
<th width="15%">{ _t('On') }</th>
<th width="15%">{ _t('Noisy') }</th>
</tr>
</thead>
<tbody>
{ this.renderNotifRulesTableRows() }
</tbody>
</table>
</div>
{ advancedSettings }
{ devicesSection }
{ clearNotificationsButton }
</div>
</div>
);
}
}

View file

@ -0,0 +1,647 @@
/*
Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
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 Spinner from "../elements/Spinner";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { IAnnotatedPushRule, IPusher, PushRuleAction, PushRuleKind, RuleId } from "matrix-js-sdk/src/@types/PushRules";
import {
ContentRules,
IContentRules,
PushRuleVectorState,
VectorPushRulesDefinitions,
VectorState,
} from "../../../notifications";
import { _t, TranslatedString } from "../../../languageHandler";
import { IThreepid, ThreepidMedium } from "matrix-js-sdk/src/@types/threepids";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
import { SettingLevel } from "../../../settings/SettingLevel";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import SdkConfig from "../../../SdkConfig";
import AccessibleButton from "../elements/AccessibleButton";
import TagComposer from "../elements/TagComposer";
import { objectClone } from "../../../utils/objects";
import { arrayDiff } from "../../../utils/arrays";
// TODO: this "view" component still has far too much application logic in it,
// which should be factored out to other files.
enum Phase {
Loading = "loading",
Ready = "ready",
Persisting = "persisting", // technically a meta-state for Ready, but whatever
Error = "error",
}
enum RuleClass {
Master = "master",
// The vector sections map approximately to UI sections
VectorGlobal = "vector_global",
VectorMentions = "vector_mentions",
VectorOther = "vector_other",
Other = "other", // unknown rules, essentially
}
const KEYWORD_RULE_ID = "_keywords"; // used as a placeholder "Rule ID" throughout this component
const KEYWORD_RULE_CATEGORY = RuleClass.VectorMentions;
// This array doesn't care about categories: it's just used for a simple sort
const RULE_DISPLAY_ORDER: string[] = [
// Global
RuleId.DM,
RuleId.EncryptedDM,
RuleId.Message,
RuleId.EncryptedMessage,
// Mentions
RuleId.ContainsDisplayName,
RuleId.ContainsUserName,
RuleId.AtRoomNotification,
// Other
RuleId.InviteToSelf,
RuleId.IncomingCall,
RuleId.SuppressNotices,
RuleId.Tombstone,
];
interface IVectorPushRule {
ruleId: RuleId | typeof KEYWORD_RULE_ID | string;
rule?: IAnnotatedPushRule;
description: TranslatedString | string;
vectorState: VectorState;
}
interface IProps {}
interface IState {
phase: Phase;
// Optional stuff is required when `phase === Ready`
masterPushRule?: IAnnotatedPushRule;
vectorKeywordRuleInfo?: IContentRules;
vectorPushRules?: {
[category in RuleClass]?: IVectorPushRule[];
};
pushers?: IPusher[];
threepids?: IThreepid[];
}
export default class Notifications extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
phase: Phase.Loading,
};
}
private get isInhibited(): boolean {
// Caution: The master rule's enabled state is inverted from expectation. When
// the master rule is *enabled* it means all other rules are *disabled* (or
// inhibited). Conversely, when the master rule is *disabled* then all other rules
// are *enabled* (or operate fine).
return this.state.masterPushRule?.enabled;
}
public componentDidMount() {
// noinspection JSIgnoredPromiseFromCall
this.refreshFromServer();
}
private async refreshFromServer() {
try {
const newState = (await Promise.all([
this.refreshRules(),
this.refreshPushers(),
this.refreshThreepids(),
])).reduce((p, c) => Object.assign(c, p), {});
this.setState({
...newState,
phase: Phase.Ready,
});
} catch (e) {
console.error("Error setting up notifications for settings: ", e);
this.setState({ phase: Phase.Error });
}
}
private async refreshRules(): Promise<Partial<IState>> {
const ruleSets = await MatrixClientPeg.get().getPushRules();
const categories = {
[RuleId.Master]: RuleClass.Master,
[RuleId.DM]: RuleClass.VectorGlobal,
[RuleId.EncryptedDM]: RuleClass.VectorGlobal,
[RuleId.Message]: RuleClass.VectorGlobal,
[RuleId.EncryptedMessage]: RuleClass.VectorGlobal,
[RuleId.ContainsDisplayName]: RuleClass.VectorMentions,
[RuleId.ContainsUserName]: RuleClass.VectorMentions,
[RuleId.AtRoomNotification]: RuleClass.VectorMentions,
[RuleId.InviteToSelf]: RuleClass.VectorOther,
[RuleId.IncomingCall]: RuleClass.VectorOther,
[RuleId.SuppressNotices]: RuleClass.VectorOther,
[RuleId.Tombstone]: RuleClass.VectorOther,
// Everything maps to a generic "other" (unknown rule)
};
const defaultRules: {
[k in RuleClass]: IAnnotatedPushRule[];
} = {
[RuleClass.Master]: [],
[RuleClass.VectorGlobal]: [],
[RuleClass.VectorMentions]: [],
[RuleClass.VectorOther]: [],
[RuleClass.Other]: [],
};
for (const k in ruleSets.global) {
// noinspection JSUnfilteredForInLoop
const kind = k as PushRuleKind;
for (const r of ruleSets.global[kind]) {
const rule: IAnnotatedPushRule = Object.assign(r, { kind });
const category = categories[rule.rule_id] ?? RuleClass.Other;
if (rule.rule_id[0] === '.') {
defaultRules[category].push(rule);
}
}
}
const preparedNewState: Partial<IState> = {};
if (defaultRules.master.length > 0) {
preparedNewState.masterPushRule = defaultRules.master[0];
} else {
// XXX: Can this even happen? How do we safely recover?
throw new Error("Failed to locate a master push rule");
}
// Parse keyword rules
preparedNewState.vectorKeywordRuleInfo = ContentRules.parseContentRules(ruleSets);
// Prepare rendering for all of our known rules
preparedNewState.vectorPushRules = {};
const vectorCategories = [RuleClass.VectorGlobal, RuleClass.VectorMentions, RuleClass.VectorOther];
for (const category of vectorCategories) {
preparedNewState.vectorPushRules[category] = [];
for (const rule of defaultRules[category]) {
const definition = VectorPushRulesDefinitions[rule.rule_id];
const vectorState = definition.ruleToVectorState(rule);
preparedNewState.vectorPushRules[category].push({
ruleId: rule.rule_id,
rule, vectorState,
description: _t(definition.description),
});
}
// Quickly sort the rules for display purposes
preparedNewState.vectorPushRules[category].sort((a, b) => {
let idxA = RULE_DISPLAY_ORDER.indexOf(a.ruleId);
let idxB = RULE_DISPLAY_ORDER.indexOf(b.ruleId);
// Assume unknown things go at the end
if (idxA < 0) idxA = RULE_DISPLAY_ORDER.length;
if (idxB < 0) idxB = RULE_DISPLAY_ORDER.length;
return idxA - idxB;
});
if (category === KEYWORD_RULE_CATEGORY) {
preparedNewState.vectorPushRules[category].push({
ruleId: KEYWORD_RULE_ID,
description: _t("Messages containing keywords"),
vectorState: preparedNewState.vectorKeywordRuleInfo.vectorState,
});
}
}
return preparedNewState;
}
private refreshPushers(): Promise<Partial<IState>> {
return MatrixClientPeg.get().getPushers();
}
private refreshThreepids(): Promise<Partial<IState>> {
return MatrixClientPeg.get().getThreePids();
}
private showSaveError() {
Modal.createTrackedDialog('Error saving notification preferences', '', ErrorDialog, {
title: _t('Error saving notification preferences'),
description: _t('An error occurred whilst saving your notification preferences.'),
});
}
private onMasterRuleChanged = async (checked: boolean) => {
this.setState({ phase: Phase.Persisting });
try {
const masterRule = this.state.masterPushRule;
await MatrixClientPeg.get().setPushRuleEnabled('global', masterRule.kind, masterRule.rule_id, !checked);
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating master push rule:", e);
this.showSaveError();
}
};
private onEmailNotificationsChanged = async (email: string, checked: boolean) => {
this.setState({ phase: Phase.Persisting });
try {
if (checked) {
await MatrixClientPeg.get().setPusher({
kind: "email",
app_id: "m.email",
pushkey: email,
app_display_name: "Email Notifications",
device_display_name: email,
lang: navigator.language,
data: {
brand: SdkConfig.get().brand,
},
// We always append for email pushers since we don't want to stop other
// accounts notifying to the same email address
append: true,
});
} else {
const pusher = this.state.pushers.find(p => p.kind === "email" && p.pushkey === email);
pusher.kind = null; // flag for delete
await MatrixClientPeg.get().setPusher(pusher);
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating email pusher:", e);
this.showSaveError();
}
};
private onDesktopNotificationsChanged = async (checked: boolean) => {
await SettingsStore.setValue("notificationsEnabled", null, SettingLevel.DEVICE, checked);
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
};
private onDesktopShowBodyChanged = async (checked: boolean) => {
await SettingsStore.setValue("notificationBodyEnabled", null, SettingLevel.DEVICE, checked);
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
};
private onAudioNotificationsChanged = async (checked: boolean) => {
await SettingsStore.setValue("audioNotificationsEnabled", null, SettingLevel.DEVICE, checked);
this.forceUpdate(); // the toggle is controlled by SettingsStore#getValue()
};
private onRadioChecked = async (rule: IVectorPushRule, checkedState: VectorState) => {
this.setState({ phase: Phase.Persisting });
try {
const cli = MatrixClientPeg.get();
if (rule.ruleId === KEYWORD_RULE_ID) {
// Update all the keywords
for (const rule of this.state.vectorKeywordRuleInfo.rules) {
let enabled: boolean;
let actions: PushRuleAction[];
if (checkedState === VectorState.On) {
if (rule.actions.length !== 1) { // XXX: Magic number
actions = PushRuleVectorState.actionsFor(checkedState);
}
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
enabled = true;
}
} else if (checkedState === VectorState.Loud) {
if (rule.actions.length !== 3) { // XXX: Magic number
actions = PushRuleVectorState.actionsFor(checkedState);
}
if (this.state.vectorKeywordRuleInfo.vectorState === VectorState.Off) {
enabled = true;
}
} else {
enabled = false;
}
if (actions) {
await cli.setPushRuleActions('global', rule.kind, rule.rule_id, actions);
}
if (enabled !== undefined) {
await cli.setPushRuleEnabled('global', rule.kind, rule.rule_id, enabled);
}
}
} else {
const definition = VectorPushRulesDefinitions[rule.ruleId];
const actions = definition.vectorStateToActions[checkedState];
if (!actions) {
await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, false);
} else {
await cli.setPushRuleActions('global', rule.rule.kind, rule.rule.rule_id, actions);
await cli.setPushRuleEnabled('global', rule.rule.kind, rule.rule.rule_id, true);
}
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating push rule:", e);
this.showSaveError();
}
};
private onClearNotificationsClicked = () => {
MatrixClientPeg.get().getRooms().forEach(r => {
if (r.getUnreadNotificationCount() > 0) {
const events = r.getLiveTimeline().getEvents();
if (events.length) {
// noinspection JSIgnoredPromiseFromCall
MatrixClientPeg.get().sendReadReceipt(events[events.length - 1]);
}
}
});
};
private async setKeywords(keywords: string[], originalRules: IAnnotatedPushRule[]) {
try {
// De-duplicate and remove empties
keywords = Array.from(new Set(keywords)).filter(k => !!k);
const oldKeywords = Array.from(new Set(originalRules.map(r => r.pattern))).filter(k => !!k);
// Note: Technically because of the UI interaction (at the time of writing), the diff
// will only ever be +/-1 so we don't really have to worry about efficiently handling
// tons of keyword changes.
const diff = arrayDiff(oldKeywords, keywords);
for (const word of diff.removed) {
for (const rule of originalRules.filter(r => r.pattern === word)) {
await MatrixClientPeg.get().deletePushRule('global', rule.kind, rule.rule_id);
}
}
let ruleVectorState = this.state.vectorKeywordRuleInfo.vectorState;
if (ruleVectorState === VectorState.Off) {
// When the current global keywords rule is OFF, we need to look at
// the flavor of existing rules to apply the same actions
// when creating the new rule.
if (originalRules.length) {
ruleVectorState = PushRuleVectorState.contentRuleVectorStateKind(originalRules[0]);
} else {
ruleVectorState = VectorState.On; // default
}
}
const kind = PushRuleKind.ContentSpecific;
for (const word of diff.added) {
await MatrixClientPeg.get().addPushRule('global', kind, word, {
actions: PushRuleVectorState.actionsFor(ruleVectorState),
pattern: word,
});
if (ruleVectorState === VectorState.Off) {
await MatrixClientPeg.get().setPushRuleEnabled('global', kind, word, false);
}
}
await this.refreshFromServer();
} catch (e) {
this.setState({ phase: Phase.Error });
console.error("Error updating keyword push rules:", e);
this.showSaveError();
}
}
private onKeywordAdd = (keyword: string) => {
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We add the keyword immediately as a sort of local echo effect
this.setState({
phase: Phase.Persisting,
vectorKeywordRuleInfo: {
...this.state.vectorKeywordRuleInfo,
rules: [
...this.state.vectorKeywordRuleInfo.rules,
// XXX: Horrible assumption that we don't need the remaining fields
{ pattern: keyword } as IAnnotatedPushRule,
],
},
}, async () => {
await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
});
};
private onKeywordRemove = (keyword: string) => {
const originalRules = objectClone(this.state.vectorKeywordRuleInfo.rules);
// We remove the keyword immediately as a sort of local echo effect
this.setState({
phase: Phase.Persisting,
vectorKeywordRuleInfo: {
...this.state.vectorKeywordRuleInfo,
rules: this.state.vectorKeywordRuleInfo.rules.filter(r => r.pattern !== keyword),
},
}, async () => {
await this.setKeywords(this.state.vectorKeywordRuleInfo.rules.map(r => r.pattern), originalRules);
});
};
private renderTopSection() {
const masterSwitch = <LabelledToggleSwitch
value={!this.isInhibited}
label={_t("Enable for this account")}
onChange={this.onMasterRuleChanged}
disabled={this.state.phase === Phase.Persisting}
/>;
// If all the rules are inhibited, don't show anything.
if (this.isInhibited) {
return masterSwitch;
}
const emailSwitches = this.state.threepids.filter(t => t.medium === ThreepidMedium.Email)
.map(e => <LabelledToggleSwitch
key={e.address}
value={this.state.pushers.some(p => p.kind === "email" && p.pushkey === e.address)}
label={_t("Enable email notifications for %(email)s", { email: e.address })}
onChange={this.onEmailNotificationsChanged.bind(this, e.address)}
disabled={this.state.phase === Phase.Persisting}
/>);
return <>
{ masterSwitch }
<LabelledToggleSwitch
value={SettingsStore.getValue("notificationsEnabled")}
onChange={this.onDesktopNotificationsChanged}
label={_t('Enable desktop notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
value={SettingsStore.getValue("notificationBodyEnabled")}
onChange={this.onDesktopShowBodyChanged}
label={_t('Show message in desktop notification')}
disabled={this.state.phase === Phase.Persisting}
/>
<LabelledToggleSwitch
value={SettingsStore.getValue("audioNotificationsEnabled")}
onChange={this.onAudioNotificationsChanged}
label={_t('Enable audible notifications for this session')}
disabled={this.state.phase === Phase.Persisting}
/>
{ emailSwitches }
</>;
}
private renderCategory(category: RuleClass) {
if (category !== RuleClass.VectorOther && this.isInhibited) {
return null; // nothing to show for the section
}
let clearNotifsButton: JSX.Element;
if (
category === RuleClass.VectorOther
&& MatrixClientPeg.get().getRooms().some(r => r.getUnreadNotificationCount() > 0)
) {
clearNotifsButton = <AccessibleButton
onClick={this.onClearNotificationsClicked}
kind='danger'
className='mx_UserNotifSettings_clearNotifsButton'
>{ _t("Clear notifications") }</AccessibleButton>;
}
if (category === RuleClass.VectorOther && this.isInhibited) {
// only render the utility buttons (if needed)
if (clearNotifsButton) {
return <div className='mx_UserNotifSettings_floatingSection'>
<div>{ _t("Other") }</div>
{ clearNotifsButton }
</div>;
}
return null;
}
let keywordComposer: JSX.Element;
if (category === RuleClass.VectorMentions) {
keywordComposer = <TagComposer
tags={this.state.vectorKeywordRuleInfo?.rules.map(r => r.pattern)}
onAdd={this.onKeywordAdd}
onRemove={this.onKeywordRemove}
disabled={this.state.phase === Phase.Persisting}
label={_t("Keyword")}
placeholder={_t("New keyword")}
/>;
}
const makeRadio = (r: IVectorPushRule, s: VectorState) => (
<StyledRadioButton
key={r.ruleId}
name={r.ruleId}
checked={r.vectorState === s}
onChange={this.onRadioChecked.bind(this, r, s)}
disabled={this.state.phase === Phase.Persisting}
/>
);
const rows = this.state.vectorPushRules[category].map(r => <tr key={category + r.ruleId}>
<td>{ r.description }</td>
<td>{ makeRadio(r, VectorState.On) }</td>
<td>{ makeRadio(r, VectorState.Off) }</td>
<td>{ makeRadio(r, VectorState.Loud) }</td>
</tr>);
let sectionName: TranslatedString;
switch (category) {
case RuleClass.VectorGlobal:
sectionName = _t("Global");
break;
case RuleClass.VectorMentions:
sectionName = _t("Mentions & keywords");
break;
case RuleClass.VectorOther:
sectionName = _t("Other");
break;
default:
throw new Error("Developer error: Unnamed notifications section: " + category);
}
return <>
<table className='mx_UserNotifSettings_pushRulesTable'>
<thead>
<tr>
<th>{ sectionName }</th>
<th>{ _t("On") }</th>
<th>{ _t("Off") }</th>
<th>{ _t("Noisy") }</th>
</tr>
</thead>
<tbody>
{ rows }
</tbody>
</table>
{ clearNotifsButton }
{ keywordComposer }
</>;
}
private renderTargets() {
if (this.isInhibited) return null; // no targets if there's no notifications
const rows = this.state.pushers.map(p => <tr key={p.kind+p.pushkey}>
<td>{ p.app_display_name }</td>
<td>{ p.device_display_name }</td>
</tr>);
if (!rows.length) return null; // no targets to show
return <div className='mx_UserNotifSettings_floatingSection'>
<div>{ _t("Notification targets") }</div>
<table>
<tbody>
{ rows }
</tbody>
</table>
</div>;
}
public render() {
if (this.state.phase === Phase.Loading) {
// Ends up default centered
return <Spinner />;
} else if (this.state.phase === Phase.Error) {
return <p>{ _t("There was an error loading your notification settings.") }</p>;
}
return <div className='mx_UserNotifSettings'>
{ this.renderTopSection() }
{ this.renderCategory(RuleClass.VectorGlobal) }
{ this.renderCategory(RuleClass.VectorMentions) }
{ this.renderCategory(RuleClass.VectorOther) }
{ this.renderTargets() }
</div>;
}
}

View file

@ -76,6 +76,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
private readonly MESSAGE_PREVIEW_TEXT = _t("Hey you. You're the best!"); private readonly MESSAGE_PREVIEW_TEXT = _t("Hey you. You're the best!");
private themeTimer: number; private themeTimer: number;
private unmounted = false;
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
@ -101,6 +102,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const userId = client.getUserId(); const userId = client.getUserId();
const profileInfo = await client.getProfileInfo(userId); const profileInfo = await client.getProfileInfo(userId);
if (this.unmounted) return;
this.setState({ this.setState({
userId, userId,
@ -109,6 +111,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
}); });
} }
componentWillUnmount() {
this.unmounted = true;
}
private calculateThemeState(): IThemeState { private calculateThemeState(): IThemeState {
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we // We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
// show the right values for things. // show the right values for things.

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); 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.
@ -16,17 +16,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
import * as sdk from "../../../../../index";
import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import Notifications from "../../Notifications";
@replaceableComponent("views.settings.tabs.user.NotificationUserSettingsTab") @replaceableComponent("views.settings.tabs.user.NotificationUserSettingsTab")
export default class NotificationUserSettingsTab extends React.Component { export default class NotificationUserSettingsTab extends React.Component {
constructor() {
super();
}
render() { render() {
const Notifications = sdk.getComponent("views.settings.Notifications");
return ( return (
<div className="mx_SettingsTab mx_NotificationUserSettingsTab"> <div className="mx_SettingsTab mx_NotificationUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Notifications")}</div> <div className="mx_SettingsTab_heading">{_t("Notifications")}</div>

View file

@ -42,7 +42,6 @@ import {
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState"; import { NotificationState } from "../../../stores/notifications/NotificationState";
import SettingsStore from "../../../settings/SettingsStore";
interface IButtonProps { interface IButtonProps {
space?: Room; space?: Room;
@ -134,7 +133,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
const [invites, spaces, activeSpace] = useSpaces(); const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : []; const activeSpaces = activeSpace ? [activeSpace] : [];
const homeNotificationState = SettingsStore.getValue("feature_spaces.all_rooms") const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE); ? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return <div className="mx_SpaceTreeLevel"> return <div className="mx_SpaceTreeLevel">
@ -142,7 +141,7 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
className="mx_SpaceButton_home" className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)} onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace} selected={!activeSpace}
tooltip={SettingsStore.getValue("feature_spaces.all_rooms") ? _t("All rooms") : _t("Home")} tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState} notificationState={homeNotificationState}
isNarrow={isPanelCollapsed} isNarrow={isPanelCollapsed}
/> />

View file

@ -30,6 +30,7 @@ import { replaceableComponent } from "../../../utils/replaceableComponent";
import UIStore from '../../../stores/UIStore'; import UIStore from '../../../stores/UIStore';
import { lerp } from '../../../utils/AnimationUtils'; import { lerp } from '../../../utils/AnimationUtils';
import { MarkedExecution } from '../../../utils/MarkedExecution'; import { MarkedExecution } from '../../../utils/MarkedExecution';
import { EventSubscription } from 'fbemitter';
const PIP_VIEW_WIDTH = 336; const PIP_VIEW_WIDTH = 336;
const PIP_VIEW_HEIGHT = 232; const PIP_VIEW_HEIGHT = 232;
@ -108,7 +109,7 @@ function getPrimarySecondaryCalls(calls: MatrixCall[]): [MatrixCall, MatrixCall[
*/ */
@replaceableComponent("views.voip.CallPreview") @replaceableComponent("views.voip.CallPreview")
export default class CallPreview extends React.Component<IProps, IState> { export default class CallPreview extends React.Component<IProps, IState> {
private roomStoreToken: any; private roomStoreToken: EventSubscription;
private dispatcherRef: string; private dispatcherRef: string;
private settingsWatcherRef: string; private settingsWatcherRef: string;
private callViewWrapper = createRef<HTMLDivElement>(); private callViewWrapper = createRef<HTMLDivElement>();
@ -240,7 +241,7 @@ export default class CallPreview extends React.Component<IProps, IState> {
this.scheduledUpdate.mark(); this.scheduledUpdate.mark();
}; };
private onRoomViewStoreUpdate = (payload) => { private onRoomViewStoreUpdate = () => {
if (RoomViewStore.getRoomId() === this.state.roomId) return; if (RoomViewStore.getRoomId() === this.state.roomId) return;
const roomId = RoomViewStore.getRoomId(); const roomId = RoomViewStore.getRoomId();

View file

@ -41,6 +41,7 @@ const RoomContext = createContext<IState>({
canReply: false, canReply: false,
layout: Layout.Group, layout: Layout.Group,
lowBandwidth: false, lowBandwidth: false,
showHiddenEventsInTimeline: false,
showReadReceipts: true, showReadReceipts: true,
showRedactions: true, showRedactions: true,
showJoinLeaves: true, showJoinLeaves: true,

View file

@ -32,11 +32,16 @@ export interface IEncryptedFile {
} }
export interface IMediaEventContent { export interface IMediaEventContent {
body?: string;
url?: string; // required on unencrypted media url?: string; // required on unencrypted media
file?: IEncryptedFile; // required for *encrypted* media file?: IEncryptedFile; // required for *encrypted* media
info?: { info?: {
thumbnail_url?: string; // eslint-disable-line camelcase thumbnail_url?: string; // eslint-disable-line camelcase
thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase thumbnail_file?: IEncryptedFile; // eslint-disable-line camelcase
mimetype: string;
w?: number;
h?: number;
size?: number;
}; };
} }

View file

@ -1142,33 +1142,24 @@
"Connecting to integration manager...": "Connecting to integration manager...", "Connecting to integration manager...": "Connecting to integration manager...",
"Cannot connect to integration manager": "Cannot connect to integration manager", "Cannot connect to integration manager": "Cannot connect to integration manager",
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
"Error saving email notification preferences": "Error saving email notification preferences", "Messages containing keywords": "Messages containing keywords",
"An error occurred whilst saving your email notification preferences.": "An error occurred whilst saving your email notification preferences.", "Error saving notification preferences": "Error saving notification preferences",
"Keywords": "Keywords", "An error occurred whilst saving your notification preferences.": "An error occurred whilst saving your notification preferences.",
"Enter keywords separated by a comma:": "Enter keywords separated by a comma:", "Enable for this account": "Enable for this account",
"Failed to change settings": "Failed to change settings", "Enable email notifications for %(email)s": "Enable email notifications for %(email)s",
"Can't update user notification settings": "Can't update user notification settings",
"Failed to update keywords": "Failed to update keywords",
"Messages containing <span>keywords</span>": "Messages containing <span>keywords</span>",
"Notify for all other messages/rooms": "Notify for all other messages/rooms",
"Notify me for anything else": "Notify me for anything else",
"Enable notifications for this account": "Enable notifications for this account",
"Clear notifications": "Clear notifications",
"All notifications are currently disabled for all targets.": "All notifications are currently disabled for all targets.",
"Enable email notifications": "Enable email notifications",
"Add an email address to configure email notifications": "Add an email address to configure email notifications",
"Notifications on the following keywords follow rules which cant be displayed here:": "Notifications on the following keywords follow rules which cant be displayed here:",
"Unable to fetch notification target list": "Unable to fetch notification target list",
"Notification targets": "Notification targets",
"Advanced notification settings": "Advanced notification settings",
"There are advanced notifications which are not shown here.": "There are advanced notifications which are not shown here.",
"You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.": "You might have configured them in a client other than %(brand)s. You cannot tune them in %(brand)s but they still apply.",
"Enable desktop notifications for this session": "Enable desktop notifications for this session", "Enable desktop notifications for this session": "Enable desktop notifications for this session",
"Show message in desktop notification": "Show message in desktop notification", "Show message in desktop notification": "Show message in desktop notification",
"Enable audible notifications for this session": "Enable audible notifications for this session", "Enable audible notifications for this session": "Enable audible notifications for this session",
"Off": "Off", "Clear notifications": "Clear notifications",
"Keyword": "Keyword",
"New keyword": "New keyword",
"Global": "Global",
"Mentions & keywords": "Mentions & keywords",
"On": "On", "On": "On",
"Off": "Off",
"Noisy": "Noisy", "Noisy": "Noisy",
"Notification targets": "Notification targets",
"There was an error loading your notification settings.": "There was an error loading your notification settings.",
"Failed to save your profile": "Failed to save your profile", "Failed to save your profile": "Failed to save your profile",
"The operation could not be completed": "The operation could not be completed", "The operation could not be completed": "The operation could not be completed",
"<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain", "<a>Upgrade</a> to your own domain": "<a>Upgrade</a> to your own domain",
@ -1675,7 +1666,6 @@
"Show %(count)s more|other": "Show %(count)s more", "Show %(count)s more|other": "Show %(count)s more",
"Show %(count)s more|one": "Show %(count)s more", "Show %(count)s more|one": "Show %(count)s more",
"Show less": "Show less", "Show less": "Show less",
"Use default": "Use default",
"All messages": "All messages", "All messages": "All messages",
"Mentions & Keywords": "Mentions & Keywords", "Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options", "Notification options": "Notification options",
@ -1684,6 +1674,7 @@
"Favourite": "Favourite", "Favourite": "Favourite",
"Low Priority": "Low Priority", "Low Priority": "Low Priority",
"Invite People": "Invite People", "Invite People": "Invite People",
"Copy Room Link": "Copy Room Link",
"Leave Room": "Leave Room", "Leave Room": "Leave Room",
"Room options": "Room options", "Room options": "Room options",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
@ -2709,6 +2700,8 @@
"Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?", "Are you sure you want to leave the space '%(spaceName)s'?": "Are you sure you want to leave the space '%(spaceName)s'?",
"Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?", "Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
"Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s", "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s",
"Unable to copy room link": "Unable to copy room link",
"Unable to copy a link to the room to the clipboard.": "Unable to copy a link to the room to the clipboard.",
"Signed Out": "Signed Out", "Signed Out": "Signed Out",
"For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.", "For security, this session has been signed out. Please sign in again.": "For security, this session has been signed out. Please sign in again.",
"Terms and Conditions": "Terms and Conditions", "Terms and Conditions": "Terms and Conditions",

View file

@ -67,7 +67,6 @@ export default class EventIndex extends EventEmitter {
client.on('sync', this.onSync); client.on('sync', this.onSync);
client.on('Room.timeline', this.onRoomTimeline); client.on('Room.timeline', this.onRoomTimeline);
client.on('Event.decrypted', this.onEventDecrypted);
client.on('Room.timelineReset', this.onTimelineReset); client.on('Room.timelineReset', this.onTimelineReset);
client.on('Room.redaction', this.onRedaction); client.on('Room.redaction', this.onRedaction);
client.on('RoomState.events', this.onRoomStateEvent); client.on('RoomState.events', this.onRoomStateEvent);
@ -82,7 +81,6 @@ export default class EventIndex extends EventEmitter {
client.removeListener('sync', this.onSync); client.removeListener('sync', this.onSync);
client.removeListener('Room.timeline', this.onRoomTimeline); client.removeListener('Room.timeline', this.onRoomTimeline);
client.removeListener('Event.decrypted', this.onEventDecrypted);
client.removeListener('Room.timelineReset', this.onTimelineReset); client.removeListener('Room.timelineReset', this.onTimelineReset);
client.removeListener('Room.redaction', this.onRedaction); client.removeListener('Room.redaction', this.onRedaction);
client.removeListener('RoomState.events', this.onRoomStateEvent); client.removeListener('RoomState.events', this.onRoomStateEvent);
@ -221,18 +219,6 @@ export default class EventIndex extends EventEmitter {
} }
}; };
/*
* The Event.decrypted listener.
*
* Checks if the event was marked for addition in the Room.timeline
* listener, if so queues it up to be added to the index.
*/
private onEventDecrypted = async (ev: MatrixEvent, err: Error) => {
// If the event isn't in our live event set, ignore it.
if (err) return;
await this.addLiveEventToIndex(ev);
};
/* /*
* The Room.redaction listener. * The Room.redaction listener.
* *

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 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.
@ -15,13 +14,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { PushRuleVectorState, State } from "./PushRuleVectorState"; import { PushRuleVectorState, VectorState } from "./PushRuleVectorState";
import { IExtendedPushRule, IRuleSets } from "./types"; import { IAnnotatedPushRule, IPushRules, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
export interface IContentRules { export interface IContentRules {
vectorState: State; vectorState: VectorState;
rules: IExtendedPushRule[]; rules: IAnnotatedPushRule[];
externalRules: IExtendedPushRule[]; externalRules: IAnnotatedPushRule[];
} }
export const SCOPE = "global"; export const SCOPE = "global";
@ -39,9 +38,9 @@ export class ContentRules {
* externalRules: a list of other keyword rules, with states other than * externalRules: a list of other keyword rules, with states other than
* vectorState * vectorState
*/ */
static parseContentRules(rulesets: IRuleSets): IContentRules { public static parseContentRules(rulesets: IPushRules): IContentRules {
// first categorise the keyword rules in terms of their actions // first categorise the keyword rules in terms of their actions
const contentRules = this._categoriseContentRules(rulesets); const contentRules = ContentRules.categoriseContentRules(rulesets);
// Decide which content rules to display in Vector UI. // Decide which content rules to display in Vector UI.
// Vector displays a single global rule for a list of keywords // Vector displays a single global rule for a list of keywords
@ -59,7 +58,7 @@ export class ContentRules {
if (contentRules.loud.length) { if (contentRules.loud.length) {
return { return {
vectorState: State.Loud, vectorState: VectorState.Loud,
rules: contentRules.loud, rules: contentRules.loud,
externalRules: [ externalRules: [
...contentRules.loud_but_disabled, ...contentRules.loud_but_disabled,
@ -70,33 +69,33 @@ export class ContentRules {
}; };
} else if (contentRules.loud_but_disabled.length) { } else if (contentRules.loud_but_disabled.length) {
return { return {
vectorState: State.Off, vectorState: VectorState.Off,
rules: contentRules.loud_but_disabled, rules: contentRules.loud_but_disabled,
externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other], externalRules: [...contentRules.on, ...contentRules.on_but_disabled, ...contentRules.other],
}; };
} else if (contentRules.on.length) { } else if (contentRules.on.length) {
return { return {
vectorState: State.On, vectorState: VectorState.On,
rules: contentRules.on, rules: contentRules.on,
externalRules: [...contentRules.on_but_disabled, ...contentRules.other], externalRules: [...contentRules.on_but_disabled, ...contentRules.other],
}; };
} else if (contentRules.on_but_disabled.length) { } else if (contentRules.on_but_disabled.length) {
return { return {
vectorState: State.Off, vectorState: VectorState.Off,
rules: contentRules.on_but_disabled, rules: contentRules.on_but_disabled,
externalRules: contentRules.other, externalRules: contentRules.other,
}; };
} else { } else {
return { return {
vectorState: State.On, vectorState: VectorState.On,
rules: [], rules: [],
externalRules: contentRules.other, externalRules: contentRules.other,
}; };
} }
} }
static _categoriseContentRules(rulesets: IRuleSets) { private static categoriseContentRules(rulesets: IPushRules) {
const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IExtendedPushRule[]> = { const contentRules: Record<"on"|"on_but_disabled"|"loud"|"loud_but_disabled"|"other", IAnnotatedPushRule[]> = {
on: [], on: [],
on_but_disabled: [], on_but_disabled: [],
loud: [], loud: [],
@ -109,7 +108,7 @@ export class ContentRules {
const r = rulesets.global[kind][i]; const r = rulesets.global[kind][i];
// check it's not a default rule // check it's not a default rule
if (r.rule_id[0] === '.' || kind !== "content") { if (r.rule_id[0] === '.' || kind !== PushRuleKind.ContentSpecific) {
continue; continue;
} }
@ -117,14 +116,14 @@ export class ContentRules {
r.kind = kind; r.kind = kind;
switch (PushRuleVectorState.contentRuleVectorStateKind(r)) { switch (PushRuleVectorState.contentRuleVectorStateKind(r)) {
case State.On: case VectorState.On:
if (r.enabled) { if (r.enabled) {
contentRules.on.push(r); contentRules.on.push(r);
} else { } else {
contentRules.on_but_disabled.push(r); contentRules.on_but_disabled.push(r);
} }
break; break;
case State.Loud: case VectorState.Loud:
if (r.enabled) { if (r.enabled) {
contentRules.loud.push(r); contentRules.loud.push(r);
} else { } else {

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 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.
@ -15,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { Action, Actions } from "./types"; import { PushRuleAction, PushRuleActionName, TweakHighlight, TweakSound } from "matrix-js-sdk/src/@types/PushRules";
interface IEncodedActions { interface IEncodedActions {
notify: boolean; notify: boolean;
@ -30,23 +29,23 @@ export class NotificationUtils {
// "highlight: true/false, // "highlight: true/false,
// } // }
// to a list of push actions. // to a list of push actions.
static encodeActions(action: IEncodedActions) { static encodeActions(action: IEncodedActions): PushRuleAction[] {
const notify = action.notify; const notify = action.notify;
const sound = action.sound; const sound = action.sound;
const highlight = action.highlight; const highlight = action.highlight;
if (notify) { if (notify) {
const actions: Action[] = [Actions.Notify]; const actions: PushRuleAction[] = [PushRuleActionName.Notify];
if (sound) { if (sound) {
actions.push({ "set_tweak": "sound", "value": sound }); actions.push({ "set_tweak": "sound", "value": sound } as TweakSound);
} }
if (highlight) { if (highlight) {
actions.push({ "set_tweak": "highlight" }); actions.push({ "set_tweak": "highlight" } as TweakHighlight);
} else { } else {
actions.push({ "set_tweak": "highlight", "value": false }); actions.push({ "set_tweak": "highlight", "value": false } as TweakHighlight);
} }
return actions; return actions;
} else { } else {
return [Actions.DontNotify]; return [PushRuleActionName.DontNotify];
} }
} }
@ -56,16 +55,16 @@ export class NotificationUtils {
// "highlight: true/false, // "highlight: true/false,
// } // }
// If the actions couldn't be decoded then returns null. // If the actions couldn't be decoded then returns null.
static decodeActions(actions: Action[]): IEncodedActions { static decodeActions(actions: PushRuleAction[]): IEncodedActions {
let notify = false; let notify = false;
let sound = null; let sound = null;
let highlight = false; let highlight = false;
for (let i = 0; i < actions.length; ++i) { for (let i = 0; i < actions.length; ++i) {
const action = actions[i]; const action = actions[i];
if (action === Actions.Notify) { if (action === PushRuleActionName.Notify) {
notify = true; notify = true;
} else if (action === Actions.DontNotify) { } else if (action === PushRuleActionName.DontNotify) {
notify = false; notify = false;
} else if (typeof action === "object") { } else if (typeof action === "object") {
if (action.set_tweak === "sound") { if (action.set_tweak === "sound") {

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019, 2020 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.
@ -17,9 +16,9 @@ limitations under the License.
import { StandardActions } from "./StandardActions"; import { StandardActions } from "./StandardActions";
import { NotificationUtils } from "./NotificationUtils"; import { NotificationUtils } from "./NotificationUtils";
import { IPushRule } from "./types"; import { IPushRule } from "matrix-js-sdk/src/@types/PushRules";
export enum State { export enum VectorState {
/** The push rule is disabled */ /** The push rule is disabled */
Off = "off", Off = "off",
/** The user will receive push notification for this rule */ /** The user will receive push notification for this rule */
@ -31,26 +30,26 @@ export enum State {
export class PushRuleVectorState { export class PushRuleVectorState {
// Backwards compatibility (things should probably be using the enum above instead) // Backwards compatibility (things should probably be using the enum above instead)
static OFF = State.Off; static OFF = VectorState.Off;
static ON = State.On; static ON = VectorState.On;
static LOUD = State.Loud; static LOUD = VectorState.Loud;
/** /**
* Enum for state of a push rule as defined by the Vector UI. * Enum for state of a push rule as defined by the Vector UI.
* @readonly * @readonly
* @enum {string} * @enum {string}
*/ */
static states = State; static states = VectorState;
/** /**
* Convert a PushRuleVectorState to a list of actions * Convert a PushRuleVectorState to a list of actions
* *
* @return [object] list of push-rule actions * @return [object] list of push-rule actions
*/ */
static actionsFor(pushRuleVectorState: State) { static actionsFor(pushRuleVectorState: VectorState) {
if (pushRuleVectorState === State.On) { if (pushRuleVectorState === VectorState.On) {
return StandardActions.ACTION_NOTIFY; return StandardActions.ACTION_NOTIFY;
} else if (pushRuleVectorState === State.Loud) { } else if (pushRuleVectorState === VectorState.Loud) {
return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND; return StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND;
} }
} }
@ -62,7 +61,7 @@ export class PushRuleVectorState {
* category or in PushRuleVectorState.LOUD, regardless of its enabled * category or in PushRuleVectorState.LOUD, regardless of its enabled
* state. Returns null if it does not match these categories. * state. Returns null if it does not match these categories.
*/ */
static contentRuleVectorStateKind(rule: IPushRule): State { static contentRuleVectorStateKind(rule: IPushRule): VectorState {
const decoded = NotificationUtils.decodeActions(rule.actions); const decoded = NotificationUtils.decodeActions(rule.actions);
if (!decoded) { if (!decoded) {
@ -80,10 +79,10 @@ export class PushRuleVectorState {
let stateKind = null; let stateKind = null;
switch (tweaks) { switch (tweaks) {
case 0: case 0:
stateKind = State.On; stateKind = VectorState.On;
break; break;
case 2: case 2:
stateKind = State.Loud; stateKind = VectorState.Loud;
break; break;
} }
return stateKind; return stateKind;

View file

@ -1,6 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 - 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 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.
@ -17,19 +16,24 @@ limitations under the License.
import { _td } from '../languageHandler'; import { _td } from '../languageHandler';
import { StandardActions } from "./StandardActions"; import { StandardActions } from "./StandardActions";
import { PushRuleVectorState } from "./PushRuleVectorState"; import { PushRuleVectorState, VectorState } from "./PushRuleVectorState";
import { NotificationUtils } from "./NotificationUtils"; import { NotificationUtils } from "./NotificationUtils";
import { PushRuleAction, PushRuleKind } from "matrix-js-sdk/src/@types/PushRules";
type StateToActionsMap = {
[state in VectorState]?: PushRuleAction[];
};
interface IProps { interface IProps {
kind: Kind; kind: PushRuleKind;
description: string; description: string;
vectorStateToActions: Action; vectorStateToActions: StateToActionsMap;
} }
class VectorPushRuleDefinition { class VectorPushRuleDefinition {
private kind: Kind; private kind: PushRuleKind;
private description: string; private description: string;
private vectorStateToActions: Action; public readonly vectorStateToActions: StateToActionsMap;
constructor(opts: IProps) { constructor(opts: IProps) {
this.kind = opts.kind; this.kind = opts.kind;
@ -73,73 +77,62 @@ class VectorPushRuleDefinition {
} }
} }
enum Kind {
Override = "override",
Underride = "underride",
}
interface Action {
on: StandardActions;
loud: StandardActions;
off: StandardActions;
}
/** /**
* The descriptions of rules managed by the Vector UI. * The descriptions of rules managed by the Vector UI.
*/ */
export const VectorPushRulesDefinitions = { export const VectorPushRulesDefinitions = {
// Messages containing user's display name // Messages containing user's display name
".m.rule.contains_display_name": new VectorPushRuleDefinition({ ".m.rule.contains_display_name": new VectorPushRuleDefinition({
kind: Kind.Override, kind: PushRuleKind.Override,
description: _td("Messages containing my display name"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Messages containing my display name"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { // The actions for each vector state, or null to disable the rule. vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
off: StandardActions.ACTION_DISABLED, [VectorState.Off]: StandardActions.ACTION_DISABLED,
}, },
}), }),
// Messages containing user's username (localpart/MXID) // Messages containing user's username (localpart/MXID)
".m.rule.contains_user_name": new VectorPushRuleDefinition({ ".m.rule.contains_user_name": new VectorPushRuleDefinition({
kind: Kind.Override, kind: PushRuleKind.Override,
description: _td("Messages containing my username"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Messages containing my username"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { // The actions for each vector state, or null to disable the rule. vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT_DEFAULT_SOUND,
off: StandardActions.ACTION_DISABLED, [VectorState.Off]: StandardActions.ACTION_DISABLED,
}, },
}), }),
// Messages containing @room // Messages containing @room
".m.rule.roomnotif": new VectorPushRuleDefinition({ ".m.rule.roomnotif": new VectorPushRuleDefinition({
kind: Kind.Override, kind: PushRuleKind.Override,
description: _td("Messages containing @room"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Messages containing @room"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { // The actions for each vector state, or null to disable the rule. vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_HIGHLIGHT, [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT,
off: StandardActions.ACTION_DISABLED, [VectorState.Off]: StandardActions.ACTION_DISABLED,
}, },
}), }),
// Messages just sent to the user in a 1:1 room // Messages just sent to the user in a 1:1 room
".m.rule.room_one_to_one": new VectorPushRuleDefinition({ ".m.rule.room_one_to_one": new VectorPushRuleDefinition({
kind: Kind.Underride, kind: PushRuleKind.Underride,
description: _td("Messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { vectorStateToActions: {
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
off: StandardActions.ACTION_DONT_NOTIFY, [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
}, },
}), }),
// Encrypted messages just sent to the user in a 1:1 room // Encrypted messages just sent to the user in a 1:1 room
".m.rule.encrypted_room_one_to_one": new VectorPushRuleDefinition({ ".m.rule.encrypted_room_one_to_one": new VectorPushRuleDefinition({
kind: Kind.Underride, kind: PushRuleKind.Underride,
description: _td("Encrypted messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Encrypted messages in one-to-one chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { vectorStateToActions: {
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
off: StandardActions.ACTION_DONT_NOTIFY, [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
}, },
}), }),
@ -147,12 +140,12 @@ export const VectorPushRulesDefinitions = {
// 1:1 room messages are catched by the .m.rule.room_one_to_one rule if any defined // 1:1 room messages are catched by the .m.rule.room_one_to_one rule if any defined
// By opposition, all other room messages are from group chat rooms. // By opposition, all other room messages are from group chat rooms.
".m.rule.message": new VectorPushRuleDefinition({ ".m.rule.message": new VectorPushRuleDefinition({
kind: Kind.Underride, kind: PushRuleKind.Underride,
description: _td("Messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { vectorStateToActions: {
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
off: StandardActions.ACTION_DONT_NOTIFY, [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
}, },
}), }),
@ -160,57 +153,57 @@ export const VectorPushRulesDefinitions = {
// Encrypted 1:1 room messages are catched by the .m.rule.encrypted_room_one_to_one rule if any defined // Encrypted 1:1 room messages are catched by the .m.rule.encrypted_room_one_to_one rule if any defined
// By opposition, all other room messages are from group chat rooms. // By opposition, all other room messages are from group chat rooms.
".m.rule.encrypted": new VectorPushRuleDefinition({ ".m.rule.encrypted": new VectorPushRuleDefinition({
kind: Kind.Underride, kind: PushRuleKind.Underride,
description: _td("Encrypted messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Encrypted messages in group chats"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { vectorStateToActions: {
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
off: StandardActions.ACTION_DONT_NOTIFY, [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
}, },
}), }),
// Invitation for the user // Invitation for the user
".m.rule.invite_for_me": new VectorPushRuleDefinition({ ".m.rule.invite_for_me": new VectorPushRuleDefinition({
kind: Kind.Underride, kind: PushRuleKind.Underride,
description: _td("When I'm invited to a room"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("When I'm invited to a room"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { vectorStateToActions: {
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
off: StandardActions.ACTION_DISABLED, [VectorState.Off]: StandardActions.ACTION_DISABLED,
}, },
}), }),
// Incoming call // Incoming call
".m.rule.call": new VectorPushRuleDefinition({ ".m.rule.call": new VectorPushRuleDefinition({
kind: Kind.Underride, kind: PushRuleKind.Underride,
description: _td("Call invitation"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Call invitation"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { vectorStateToActions: {
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_NOTIFY_RING_SOUND, [VectorState.Loud]: StandardActions.ACTION_NOTIFY_RING_SOUND,
off: StandardActions.ACTION_DISABLED, [VectorState.Off]: StandardActions.ACTION_DISABLED,
}, },
}), }),
// Notifications from bots // Notifications from bots
".m.rule.suppress_notices": new VectorPushRuleDefinition({ ".m.rule.suppress_notices": new VectorPushRuleDefinition({
kind: Kind.Override, kind: PushRuleKind.Override,
description: _td("Messages sent by bot"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("Messages sent by bot"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { vectorStateToActions: {
// .m.rule.suppress_notices is a "negative" rule, we have to invert its enabled value for vector UI // .m.rule.suppress_notices is a "negative" rule, we have to invert its enabled value for vector UI
on: StandardActions.ACTION_DISABLED, [VectorState.On]: StandardActions.ACTION_DISABLED,
loud: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND, [VectorState.Loud]: StandardActions.ACTION_NOTIFY_DEFAULT_SOUND,
off: StandardActions.ACTION_DONT_NOTIFY, [VectorState.Off]: StandardActions.ACTION_DONT_NOTIFY,
}, },
}), }),
// Room upgrades (tombstones) // Room upgrades (tombstones)
".m.rule.tombstone": new VectorPushRuleDefinition({ ".m.rule.tombstone": new VectorPushRuleDefinition({
kind: Kind.Override, kind: PushRuleKind.Override,
description: _td("When rooms are upgraded"), // passed through _t() translation in src/components/views/settings/Notifications.js description: _td("When rooms are upgraded"), // passed through _t() translation in src/components/views/settings/Notifications.js
vectorStateToActions: { // The actions for each vector state, or null to disable the rule. vectorStateToActions: { // The actions for each vector state, or null to disable the rule.
on: StandardActions.ACTION_NOTIFY, [VectorState.On]: StandardActions.ACTION_NOTIFY,
loud: StandardActions.ACTION_HIGHLIGHT, [VectorState.Loud]: StandardActions.ACTION_HIGHLIGHT,
off: StandardActions.ACTION_DISABLED, [VectorState.Off]: StandardActions.ACTION_DISABLED,
}, },
}), }),
}; };

View file

@ -1,114 +0,0 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export enum NotificationSetting {
AllMessages = "all_messages", // .m.rule.message = notify
DirectMessagesMentionsKeywords = "dm_mentions_keywords", // .m.rule.message = mark_unread. This is the new default.
MentionsKeywordsOnly = "mentions_keywords", // .m.rule.message = mark_unread; .m.rule.room_one_to_one = mark_unread
Never = "never", // .m.rule.master = enabled (dont_notify)
}
export interface ISoundTweak {
// eslint-disable-next-line camelcase
set_tweak: "sound";
value: string;
}
export interface IHighlightTweak {
// eslint-disable-next-line camelcase
set_tweak: "highlight";
value?: boolean;
}
export type Tweak = ISoundTweak | IHighlightTweak;
export enum Actions {
Notify = "notify",
DontNotify = "dont_notify", // no-op
Coalesce = "coalesce", // unused
MarkUnread = "mark_unread", // new
}
export type Action = Actions | Tweak;
// Push rule kinds in descending priority order
export enum Kind {
Override = "override",
ContentSpecific = "content",
RoomSpecific = "room",
SenderSpecific = "sender",
Underride = "underride",
}
export interface IEventMatchCondition {
kind: "event_match";
key: string;
pattern: string;
}
export interface IContainsDisplayNameCondition {
kind: "contains_display_name";
}
export interface IRoomMemberCountCondition {
kind: "room_member_count";
is: string;
}
export interface ISenderNotificationPermissionCondition {
kind: "sender_notification_permission";
key: string;
}
export type Condition =
IEventMatchCondition |
IContainsDisplayNameCondition |
IRoomMemberCountCondition |
ISenderNotificationPermissionCondition;
export enum RuleIds {
MasterRule = ".m.rule.master", // The master rule (all notifications disabling)
MessageRule = ".m.rule.message",
EncryptedMessageRule = ".m.rule.encrypted",
RoomOneToOneRule = ".m.rule.room_one_to_one",
EncryptedRoomOneToOneRule = ".m.rule.room_one_to_one",
}
export interface IPushRule {
enabled: boolean;
// eslint-disable-next-line camelcase
rule_id: RuleIds | string;
actions: Action[];
default: boolean;
conditions?: Condition[]; // only applicable to `underride` and `override` rules
pattern?: string; // only applicable to `content` rules
}
// push rule extended with kind, used by ContentRules and js-sdk's pushprocessor
export interface IExtendedPushRule extends IPushRule {
kind: Kind;
}
export interface IPushRuleSet {
override: IPushRule[];
content: IPushRule[];
room: IPushRule[];
sender: IPushRule[];
underride: IPushRule[];
}
export interface IRuleSets {
global: IPushRuleSet;
}

View file

@ -22,6 +22,7 @@ import defaultDispatcher from "../dispatcher/dispatcher";
import { arrayHasDiff } from "../utils/arrays"; import { arrayHasDiff } from "../utils/arrays";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { SettingLevel } from "../settings/SettingLevel"; import { SettingLevel } from "../settings/SettingLevel";
import SpaceStore from "./SpaceStore";
const MAX_ROOMS = 20; // arbitrary const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
@ -122,7 +123,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
private async appendRoom(room: Room) { private async appendRoom(room: Room) {
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) return; // hide space rooms if (SpaceStore.spacesEnabled && room.isSpaceRoom()) return; // hide space rooms
let updated = false; let updated = false;
const rooms = (this.state.rooms || []).slice(); // cheap clone const rooms = (this.state.rooms || []).slice(); // cheap clone

View file

@ -429,7 +429,7 @@ class RoomViewStore extends Store<ActionPayload> {
} }
} }
let singletonRoomViewStore = null; let singletonRoomViewStore: RoomViewStore = null;
if (!singletonRoomViewStore) { if (!singletonRoomViewStore) {
singletonRoomViewStore = new RoomViewStore(); singletonRoomViewStore = new RoomViewStore();
} }

View file

@ -68,7 +68,13 @@ export interface ISuggestedRoom extends ISpaceSummaryRoom {
const MAX_SUGGESTED_ROOMS = 20; const MAX_SUGGESTED_ROOMS = 20;
const homeSpaceKey = SettingsStore.getValue("feature_spaces.all_rooms") ? "ALL_ROOMS" : "HOME_SPACE"; // All of these settings cause the page to reload and can be costly if read frequently, so read them here only
const spacesEnabled = SettingsStore.getValue("feature_spaces");
const spacesTweakAllRoomsEnabled = SettingsStore.getValue("feature_spaces.all_rooms");
const spacesTweakSpaceMemberDMsEnabled = SettingsStore.getValue("feature_spaces.space_member_dms");
const spacesTweakSpaceDMBadgesEnabled = SettingsStore.getValue("feature_spaces.space_dm_badges");
const homeSpaceKey = spacesTweakAllRoomsEnabled ? "ALL_ROOMS" : "HOME_SPACE";
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`; const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`;
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
@ -337,7 +343,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => { public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
if (!space && SettingsStore.getValue("feature_spaces.all_rooms")) { if (!space && spacesTweakAllRoomsEnabled) {
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
} }
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
@ -434,7 +440,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}; };
private showInHomeSpace = (room: Room) => { private showInHomeSpace = (room: Room) => {
if (SettingsStore.getValue("feature_spaces.all_rooms")) return true; if (spacesTweakAllRoomsEnabled) return true;
if (room.isSpaceRoom()) return false; if (room.isSpaceRoom()) return false;
return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space
|| DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
@ -466,7 +472,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const oldFilteredRooms = this.spaceFilteredRooms; const oldFilteredRooms = this.spaceFilteredRooms;
this.spaceFilteredRooms = new Map(); this.spaceFilteredRooms = new Map();
if (!SettingsStore.getValue("feature_spaces.all_rooms")) { if (!spacesTweakAllRoomsEnabled) {
// put all room invites in the Home Space // put all room invites in the Home Space
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId))); this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
@ -493,7 +499,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const roomIds = new Set(childRooms.map(r => r.roomId)); const roomIds = new Set(childRooms.map(r => r.roomId));
const space = this.matrixClient?.getRoom(spaceId); const space = this.matrixClient?.getRoom(spaceId);
if (SettingsStore.getValue("feature_spaces.space_member_dms")) { if (spacesTweakSpaceMemberDMsEnabled) {
// Add relevant DMs // Add relevant DMs
space?.getMembers().forEach(member => { space?.getMembers().forEach(member => {
if (member.membership !== "join" && member.membership !== "invite") return; if (member.membership !== "join" && member.membership !== "invite") return;
@ -527,7 +533,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// Update NotificationStates // Update NotificationStates
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => {
if (roomIds.has(room.roomId)) { if (roomIds.has(room.roomId)) {
if (s !== HOME_SPACE && SettingsStore.getValue("feature_spaces.space_dm_badges")) return true; if (s !== HOME_SPACE && spacesTweakSpaceDMBadgesEnabled) return true;
return !DMRoomMap.shared().getUserIdForRoomId(room.roomId) return !DMRoomMap.shared().getUserIdForRoomId(room.roomId)
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite); || RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite);
@ -626,7 +632,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// TODO confirm this after implementing parenting behaviour // TODO confirm this after implementing parenting behaviour
if (room.isSpaceRoom()) { if (room.isSpaceRoom()) {
this.onSpaceUpdate(); this.onSpaceUpdate();
} else if (!SettingsStore.getValue("feature_spaces.all_rooms")) { } else if (!spacesTweakAllRoomsEnabled) {
this.onRoomUpdate(room); this.onRoomUpdate(room);
} }
this.emit(room.roomId); this.emit(room.roomId);
@ -650,7 +656,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (order !== lastOrder) { if (order !== lastOrder) {
this.notifyIfOrderChanged(); this.notifyIfOrderChanged();
} }
} else if (ev.getType() === EventType.Tag && !SettingsStore.getValue("feature_spaces.all_rooms")) { } else if (ev.getType() === EventType.Tag && !spacesTweakAllRoomsEnabled) {
// If the room was in favourites and now isn't or the opposite then update its position in the trees // If the room was in favourites and now isn't or the opposite then update its position in the trees
const oldTags = lastEv?.getContent()?.tags || {}; const oldTags = lastEv?.getContent()?.tags || {};
const newTags = ev.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {};
@ -690,13 +696,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
protected async onNotReady() { protected async onNotReady() {
if (!SettingsStore.getValue("feature_spaces")) return; if (!SpaceStore.spacesEnabled) return;
if (this.matrixClient) { if (this.matrixClient) {
this.matrixClient.removeListener("Room", this.onRoom); this.matrixClient.removeListener("Room", this.onRoom);
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);
if (!SettingsStore.getValue("feature_spaces.all_rooms")) { if (!spacesTweakAllRoomsEnabled) {
this.matrixClient.removeListener("accountData", this.onAccountData); this.matrixClient.removeListener("accountData", this.onAccountData);
} }
} }
@ -704,12 +710,12 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
protected async onReady() { protected async onReady() {
if (!SettingsStore.getValue("feature_spaces")) return; if (!spacesEnabled) return;
this.matrixClient.on("Room", this.onRoom); this.matrixClient.on("Room", this.onRoom);
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);
if (!SettingsStore.getValue("feature_spaces.all_rooms")) { if (!spacesTweakAllRoomsEnabled) {
this.matrixClient.on("accountData", this.onAccountData); this.matrixClient.on("accountData", this.onAccountData);
} }
@ -728,7 +734,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
protected async onAction(payload: ActionPayload) { protected async onAction(payload: ActionPayload) {
if (!SettingsStore.getValue("feature_spaces")) return; if (!spacesEnabled) return;
switch (payload.action) { switch (payload.action) {
case "view_room": { case "view_room": {
// Don't auto-switch rooms when reacting to a context-switch // Don't auto-switch rooms when reacting to a context-switch
@ -742,7 +748,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// as it will cause you to end up in the wrong room // as it will cause you to end up in the wrong room
this.setActiveSpace(room, false); this.setActiveSpace(room, false);
} else if ( } else if (
(!SettingsStore.getValue("feature_spaces.all_rooms") || this.activeSpace) && (!spacesTweakAllRoomsEnabled || this.activeSpace) &&
!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId) !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)
) { ) {
this.switchToRelatedSpace(roomId); this.switchToRelatedSpace(roomId);
@ -834,6 +840,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
export default class SpaceStore { export default class SpaceStore {
public static spacesEnabled = spacesEnabled;
public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled;
public static spacesTweakSpaceMemberDMsEnabled = spacesTweakSpaceMemberDMsEnabled;
public static spacesTweakSpaceDMBadgesEnabled = spacesTweakSpaceDMBadgesEnabled;
private static internalInstance = new SpaceStoreClass(); private static internalInstance = new SpaceStoreClass();
public static get instance(): SpaceStoreClass { public static get instance(): SpaceStoreClass {

View file

@ -35,6 +35,7 @@ import { NameFilterCondition } from "./filters/NameFilterCondition";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { VisibilityProvider } from "./filters/VisibilityProvider"; import { VisibilityProvider } from "./filters/VisibilityProvider";
import { SpaceWatcher } from "./SpaceWatcher"; import { SpaceWatcher } from "./SpaceWatcher";
import SpaceStore from "../SpaceStore";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -73,10 +74,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
constructor() { constructor() {
super(defaultDispatcher); super(defaultDispatcher);
this.setMaxListeners(20); // CustomRoomTagStore + RoomList + LeftPanel + 8xRoomSubList + spares
} }
private setupWatchers() { private setupWatchers() {
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
this.spaceWatcher = new SpaceWatcher(this); this.spaceWatcher = new SpaceWatcher(this);
} else { } else {
this.tagWatcher = new TagWatcher(this); this.tagWatcher = new TagWatcher(this);
@ -130,8 +132,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
// Update any settings here, as some may have happened before we were logically ready. // Update any settings here, as some may have happened before we were logically ready.
console.log("Regenerating room lists: Startup"); console.log("Regenerating room lists: Startup");
await this.readAndCacheSettingsFromStore(); await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists({ trigger: false }); this.regenerateAllLists({ trigger: false });
await this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed this.handleRVSUpdate({ trigger: false }); // fake an RVS update to adjust sticky room, if needed
this.updateFn.mark(); // we almost certainly want to trigger an update. this.updateFn.mark(); // we almost certainly want to trigger an update.
this.updateFn.trigger(); this.updateFn.trigger();
@ -148,7 +150,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
await this.updateState({ await this.updateState({
tagsEnabled, tagsEnabled,
}); });
await this.updateAlgorithmInstances(); this.updateAlgorithmInstances();
} }
/** /**
@ -156,23 +158,23 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
* @param trigger Set to false to prevent a list update from being sent. Should only * @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update. * be used if the calling code will manually trigger the update.
*/ */
private async handleRVSUpdate({ trigger = true }) { private handleRVSUpdate({ trigger = true }) {
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
const activeRoomId = RoomViewStore.getRoomId(); const activeRoomId = RoomViewStore.getRoomId();
if (!activeRoomId && this.algorithm.stickyRoom) { if (!activeRoomId && this.algorithm.stickyRoom) {
await this.algorithm.setStickyRoom(null); this.algorithm.setStickyRoom(null);
} else if (activeRoomId) { } else if (activeRoomId) {
const activeRoom = this.matrixClient.getRoom(activeRoomId); const activeRoom = this.matrixClient.getRoom(activeRoomId);
if (!activeRoom) { if (!activeRoom) {
console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`); console.warn(`${activeRoomId} is current in RVS but missing from client - clearing sticky room`);
await this.algorithm.setStickyRoom(null); this.algorithm.setStickyRoom(null);
} else if (activeRoom !== this.algorithm.stickyRoom) { } else if (activeRoom !== this.algorithm.stickyRoom) {
if (SettingsStore.getValue("advancedRoomListLogging")) { if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Changing sticky room to ${activeRoomId}`); console.log(`Changing sticky room to ${activeRoomId}`);
} }
await this.algorithm.setStickyRoom(activeRoom); this.algorithm.setStickyRoom(activeRoom);
} }
} }
@ -224,7 +226,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
console.log("Regenerating room lists: Settings changed"); console.log("Regenerating room lists: Settings changed");
await this.readAndCacheSettingsFromStore(); await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists({ trigger: false }); // regenerate the lists now this.regenerateAllLists({ trigger: false }); // regenerate the lists now
this.updateFn.trigger(); this.updateFn.trigger();
} }
} }
@ -366,7 +368,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`); console.log(`[RoomListDebug] Clearing sticky room due to room upgrade`);
} }
await this.algorithm.setStickyRoom(null); this.algorithm.setStickyRoom(null);
} }
// Note: we hit the algorithm instead of our handleRoomUpdate() function to // Note: we hit the algorithm instead of our handleRoomUpdate() function to
@ -375,7 +377,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`[RoomListDebug] Removing previous room from room list`); console.log(`[RoomListDebug] Removing previous room from room list`);
} }
await this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved); this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
} }
} }
@ -431,7 +433,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
return; // don't do anything on new/moved rooms which ought not to be shown return; // don't do anything on new/moved rooms which ought not to be shown
} }
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause); const shouldUpdate = this.algorithm.handleRoomUpdate(room, cause);
if (shouldUpdate) { if (shouldUpdate) {
if (SettingsStore.getValue("advancedRoomListLogging")) { if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
@ -460,13 +462,13 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
// Reset the sticky room before resetting the known rooms so the algorithm // Reset the sticky room before resetting the known rooms so the algorithm
// doesn't freak out. // doesn't freak out.
await this.algorithm.setStickyRoom(null); this.algorithm.setStickyRoom(null);
await this.algorithm.setKnownRooms(rooms); this.algorithm.setKnownRooms(rooms);
// Set the sticky room back, if needed, now that we have updated the store. // Set the sticky room back, if needed, now that we have updated the store.
// This will use relative stickyness to the new room set. // This will use relative stickyness to the new room set.
if (stickyIsStillPresent) { if (stickyIsStillPresent) {
await this.algorithm.setStickyRoom(currentSticky); this.algorithm.setStickyRoom(currentSticky);
} }
// Finally, mark an update and resume updates from the algorithm // Finally, mark an update and resume updates from the algorithm
@ -475,12 +477,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
} }
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.setAndPersistTagSorting(tagId, sort); this.setAndPersistTagSorting(tagId, sort);
this.updateFn.trigger(); this.updateFn.trigger();
} }
private async setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm) { private setAndPersistTagSorting(tagId: TagID, sort: SortAlgorithm) {
await this.algorithm.setTagSorting(tagId, sort); this.algorithm.setTagSorting(tagId, sort);
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114 // TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
localStorage.setItem(`mx_tagSort_${tagId}`, sort); localStorage.setItem(`mx_tagSort_${tagId}`, sort);
} }
@ -518,13 +520,13 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
return tagSort; return tagSort;
} }
public async setListOrder(tagId: TagID, order: ListAlgorithm) { public setListOrder(tagId: TagID, order: ListAlgorithm) {
await this.setAndPersistListOrder(tagId, order); this.setAndPersistListOrder(tagId, order);
this.updateFn.trigger(); this.updateFn.trigger();
} }
private async setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) { private setAndPersistListOrder(tagId: TagID, order: ListAlgorithm) {
await this.algorithm.setListOrdering(tagId, order); this.algorithm.setListOrdering(tagId, order);
// TODO: Per-account? https://github.com/vector-im/element-web/issues/14114 // TODO: Per-account? https://github.com/vector-im/element-web/issues/14114
localStorage.setItem(`mx_listOrder_${tagId}`, order); localStorage.setItem(`mx_listOrder_${tagId}`, order);
} }
@ -561,7 +563,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
return listOrder; return listOrder;
} }
private async updateAlgorithmInstances() { private updateAlgorithmInstances() {
// We'll require an update, so mark for one. Marking now also prevents the calls // We'll require an update, so mark for one. Marking now also prevents the calls
// to setTagSorting and setListOrder from causing triggers. // to setTagSorting and setListOrder from causing triggers.
this.updateFn.mark(); this.updateFn.mark();
@ -574,10 +576,10 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
const listOrder = this.calculateListOrder(tag); const listOrder = this.calculateListOrder(tag);
if (tagSort !== definedSort) { if (tagSort !== definedSort) {
await this.setAndPersistTagSorting(tag, tagSort); this.setAndPersistTagSorting(tag, tagSort);
} }
if (listOrder !== definedOrder) { if (listOrder !== definedOrder) {
await this.setAndPersistListOrder(tag, listOrder); this.setAndPersistListOrder(tag, listOrder);
} }
} }
} }
@ -608,9 +610,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
// if spaces are enabled only consider the prefilter conditions when there are no runtime conditions // if spaces are enabled only consider the prefilter conditions when there are no runtime conditions
// for the search all spaces feature // for the search all spaces feature
if (this.prefilterConditions.length > 0 if (this.prefilterConditions.length > 0 && (!SpaceStore.spacesEnabled || !this.filterConditions.length)) {
&& (!SettingsStore.getValue("feature_spaces") || !this.filterConditions.length)
) {
rooms = rooms.filter(r => { rooms = rooms.filter(r => {
for (const filter of this.prefilterConditions) { for (const filter of this.prefilterConditions) {
if (!filter.isVisible(r)) { if (!filter.isVisible(r)) {
@ -632,7 +632,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
* @param trigger Set to false to prevent a list update from being sent. Should only * @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update. * be used if the calling code will manually trigger the update.
*/ */
public async regenerateAllLists({ trigger = true }) { public regenerateAllLists({ trigger = true }) {
console.warn("Regenerating all room lists"); console.warn("Regenerating all room lists");
const rooms = this.getPlausibleRooms(); const rooms = this.getPlausibleRooms();
@ -656,8 +656,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
RoomListLayoutStore.instance.ensureLayoutExists(tagId); RoomListLayoutStore.instance.ensureLayoutExists(tagId);
} }
await this.algorithm.populateTags(sorts, orders); this.algorithm.populateTags(sorts, orders);
await this.algorithm.setKnownRooms(rooms); this.algorithm.setKnownRooms(rooms);
this.initialListsGenerated = true; this.initialListsGenerated = true;
@ -682,7 +682,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
} else { } else {
this.filterConditions.push(filter); this.filterConditions.push(filter);
// Runtime filters with spaces disable prefiltering for the search all spaces feature // Runtime filters with spaces disable prefiltering for the search all spaces feature
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
// this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below // this has to be awaited so that `setKnownRooms` is called in time for the `addFilterCondition` below
// this way the runtime filters are only evaluated on one dataset and not both. // this way the runtime filters are only evaluated on one dataset and not both.
await this.recalculatePrefiltering(); await this.recalculatePrefiltering();
@ -715,7 +715,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
this.algorithm.removeFilterCondition(filter); this.algorithm.removeFilterCondition(filter);
} }
// Runtime filters with spaces disable prefiltering for the search all spaces feature // Runtime filters with spaces disable prefiltering for the search all spaces feature
if (SettingsStore.getValue("feature_spaces")) { if (SpaceStore.spacesEnabled) {
promise = this.recalculatePrefiltering(); promise = this.recalculatePrefiltering();
} }
} }

View file

@ -19,7 +19,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomListStoreClass } from "./RoomListStore"; import { RoomListStoreClass } from "./RoomListStore";
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore";
import SettingsStore from "../../settings/SettingsStore";
/** /**
* Watches for changes in spaces to manage the filter on the provided RoomListStore * Watches for changes in spaces to manage the filter on the provided RoomListStore
@ -29,7 +28,7 @@ export class SpaceWatcher {
private activeSpace: Room = SpaceStore.instance.activeSpace; private activeSpace: Room = SpaceStore.instance.activeSpace;
constructor(private store: RoomListStoreClass) { constructor(private store: RoomListStoreClass) {
if (!SettingsStore.getValue("feature_spaces.all_rooms")) { if (!SpaceStore.spacesTweakAllRoomsEnabled) {
this.filter = new SpaceFilterCondition(); this.filter = new SpaceFilterCondition();
this.updateFilter(); this.updateFilter();
store.addFilter(this.filter); store.addFilter(this.filter);
@ -41,7 +40,7 @@ export class SpaceWatcher {
this.activeSpace = activeSpace; this.activeSpace = activeSpace;
if (this.filter) { if (this.filter) {
if (activeSpace || !SettingsStore.getValue("feature_spaces.all_rooms")) { if (activeSpace || !SpaceStore.spacesTweakAllRoomsEnabled) {
this.updateFilter(); this.updateFilter();
} else { } else {
this.store.removeFilter(this.filter); this.store.removeFilter(this.filter);

View file

@ -16,8 +16,9 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import DMRoomMap from "../../../utils/DMRoomMap";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import DMRoomMap from "../../../utils/DMRoomMap";
import { arrayDiff, arrayHasDiff } from "../../../utils/arrays"; import { arrayDiff, arrayHasDiff } from "../../../utils/arrays";
import { DefaultTagID, RoomUpdateCause, TagID } from "../models"; import { DefaultTagID, RoomUpdateCause, TagID } from "../models";
import { import {
@ -34,6 +35,7 @@ import { OrderingAlgorithm } from "./list-ordering/OrderingAlgorithm";
import { getListAlgorithmInstance } from "./list-ordering"; import { getListAlgorithmInstance } from "./list-ordering";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { VisibilityProvider } from "../filters/VisibilityProvider"; import { VisibilityProvider } from "../filters/VisibilityProvider";
import SpaceStore from "../../SpaceStore";
/** /**
* Fired when the Algorithm has determined a list has been updated. * Fired when the Algorithm has determined a list has been updated.
@ -121,8 +123,12 @@ export class Algorithm extends EventEmitter {
* Awaitable version of the sticky room setter. * Awaitable version of the sticky room setter.
* @param val The new room to sticky. * @param val The new room to sticky.
*/ */
public async setStickyRoom(val: Room) { public setStickyRoom(val: Room) {
await this.updateStickyRoom(val); try {
this.updateStickyRoom(val);
} catch (e) {
console.warn("Failed to update sticky room", e);
}
} }
public getTagSorting(tagId: TagID): SortAlgorithm { public getTagSorting(tagId: TagID): SortAlgorithm {
@ -130,13 +136,13 @@ export class Algorithm extends EventEmitter {
return this.sortAlgorithms[tagId]; return this.sortAlgorithms[tagId];
} }
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) { public setTagSorting(tagId: TagID, sort: SortAlgorithm) {
if (!tagId) throw new Error("Tag ID must be defined"); if (!tagId) throw new Error("Tag ID must be defined");
if (!sort) throw new Error("Algorithm must be defined"); if (!sort) throw new Error("Algorithm must be defined");
this.sortAlgorithms[tagId] = sort; this.sortAlgorithms[tagId] = sort;
const algorithm: OrderingAlgorithm = this.algorithms[tagId]; const algorithm: OrderingAlgorithm = this.algorithms[tagId];
await algorithm.setSortAlgorithm(sort); algorithm.setSortAlgorithm(sort);
this._cachedRooms[tagId] = algorithm.orderedRooms; this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
@ -147,7 +153,7 @@ export class Algorithm extends EventEmitter {
return this.listAlgorithms[tagId]; return this.listAlgorithms[tagId];
} }
public async setListOrdering(tagId: TagID, order: ListAlgorithm) { public setListOrdering(tagId: TagID, order: ListAlgorithm) {
if (!tagId) throw new Error("Tag ID must be defined"); if (!tagId) throw new Error("Tag ID must be defined");
if (!order) throw new Error("Algorithm must be defined"); if (!order) throw new Error("Algorithm must be defined");
this.listAlgorithms[tagId] = order; this.listAlgorithms[tagId] = order;
@ -155,7 +161,7 @@ export class Algorithm extends EventEmitter {
const algorithm = getListAlgorithmInstance(order, tagId, this.sortAlgorithms[tagId]); const algorithm = getListAlgorithmInstance(order, tagId, this.sortAlgorithms[tagId]);
this.algorithms[tagId] = algorithm; this.algorithms[tagId] = algorithm;
await algorithm.setRooms(this._cachedRooms[tagId]); algorithm.setRooms(this._cachedRooms[tagId]);
this._cachedRooms[tagId] = algorithm.orderedRooms; this._cachedRooms[tagId] = algorithm.orderedRooms;
this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list this.recalculateFilteredRoomsForTag(tagId); // update filter to re-sort the list
this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed this.recalculateStickyRoom(tagId); // update sticky room to make sure it appears if needed
@ -182,31 +188,25 @@ export class Algorithm extends EventEmitter {
} }
} }
private async handleFilterChange() { private handleFilterChange() {
await this.recalculateFilteredRooms(); this.recalculateFilteredRooms();
// re-emit the update so the list store can fire an off-cycle update if needed // re-emit the update so the list store can fire an off-cycle update if needed
if (this.updatesInhibited) return; if (this.updatesInhibited) return;
this.emit(FILTER_CHANGED); this.emit(FILTER_CHANGED);
} }
private async updateStickyRoom(val: Room) { private updateStickyRoom(val: Room) {
try { this.doUpdateStickyRoom(val);
return await this.doUpdateStickyRoom(val); this._lastStickyRoom = null; // clear to indicate we're done changing
} finally {
this._lastStickyRoom = null; // clear to indicate we're done changing
}
} }
private async doUpdateStickyRoom(val: Room) { private doUpdateStickyRoom(val: Room) {
if (SettingsStore.getValue("feature_spaces") && val?.isSpaceRoom() && val.getMyMembership() !== "invite") { if (SpaceStore.spacesEnabled && val?.isSpaceRoom() && val.getMyMembership() !== "invite") {
// no-op sticky rooms for spaces - they're effectively virtual rooms // no-op sticky rooms for spaces - they're effectively virtual rooms
val = null; val = null;
} }
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms.
if (val && !VisibilityProvider.instance.isRoomVisible(val)) { if (val && !VisibilityProvider.instance.isRoomVisible(val)) {
val = null; // the room isn't visible - lie to the rest of this function val = null; // the room isn't visible - lie to the rest of this function
} }
@ -222,7 +222,7 @@ export class Algorithm extends EventEmitter {
this._stickyRoom = null; // clear before we go to update the algorithm this._stickyRoom = null; // clear before we go to update the algorithm
// Lie to the algorithm and re-add the room to the algorithm // Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom); this.handleRoomUpdate(stickyRoom, RoomUpdateCause.NewRoom);
return; return;
} }
return; return;
@ -268,10 +268,10 @@ export class Algorithm extends EventEmitter {
// referential checks as the references can differ through the lifecycle. // referential checks as the references can differ through the lifecycle.
if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) { if (lastStickyRoom && lastStickyRoom.room && lastStickyRoom.room.roomId !== val.roomId) {
// Lie to the algorithm and re-add the room to the algorithm // Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom); this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
} }
// Lie to the algorithm and remove the room from it's field of view // Lie to the algorithm and remove the room from it's field of view
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved); this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// Check for tag & position changes while we're here. We also check the room to ensure // Check for tag & position changes while we're here. We also check the room to ensure
// it is still the same room. // it is still the same room.
@ -461,9 +461,8 @@ export class Algorithm extends EventEmitter {
* them. * them.
* @param {ITagSortingMap} tagSortingMap The tags to generate. * @param {ITagSortingMap} tagSortingMap The tags to generate.
* @param {IListOrderingMap} listOrderingMap The ordering of those tags. * @param {IListOrderingMap} listOrderingMap The ordering of those tags.
* @returns {Promise<*>} A promise which resolves when complete.
*/ */
public async populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): Promise<any> { public populateTags(tagSortingMap: ITagSortingMap, listOrderingMap: IListOrderingMap): void {
if (!tagSortingMap) throw new Error(`Sorting map cannot be null or empty`); if (!tagSortingMap) throw new Error(`Sorting map cannot be null or empty`);
if (!listOrderingMap) throw new Error(`Ordering ma cannot be null or empty`); if (!listOrderingMap) throw new Error(`Ordering ma cannot be null or empty`);
if (arrayHasDiff(Object.keys(tagSortingMap), Object.keys(listOrderingMap))) { if (arrayHasDiff(Object.keys(tagSortingMap), Object.keys(listOrderingMap))) {
@ -512,9 +511,8 @@ export class Algorithm extends EventEmitter {
* Seeds the Algorithm with a set of rooms. The algorithm will discard all * Seeds the Algorithm with a set of rooms. The algorithm will discard all
* previously known information and instead use these rooms instead. * previously known information and instead use these rooms instead.
* @param {Room[]} rooms The rooms to force the algorithm to use. * @param {Room[]} rooms The rooms to force the algorithm to use.
* @returns {Promise<*>} A promise which resolves when complete.
*/ */
public async setKnownRooms(rooms: Room[]): Promise<any> { public setKnownRooms(rooms: Room[]): void {
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`); if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`); if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
@ -528,7 +526,7 @@ export class Algorithm extends EventEmitter {
// Before we go any further we need to clear (but remember) the sticky room to // Before we go any further we need to clear (but remember) the sticky room to
// avoid accidentally duplicating it in the list. // avoid accidentally duplicating it in the list.
const oldStickyRoom = this._stickyRoom; const oldStickyRoom = this._stickyRoom;
await this.updateStickyRoom(null); if (oldStickyRoom) this.updateStickyRoom(null);
this.rooms = rooms; this.rooms = rooms;
@ -540,7 +538,7 @@ export class Algorithm extends EventEmitter {
// If we can avoid doing work, do so. // If we can avoid doing work, do so.
if (!rooms.length) { if (!rooms.length) {
await this.generateFreshTags(newTags); // just in case it wants to do something this.generateFreshTags(newTags); // just in case it wants to do something
this.cachedRooms = newTags; this.cachedRooms = newTags;
return; return;
} }
@ -577,7 +575,7 @@ export class Algorithm extends EventEmitter {
} }
} }
await this.generateFreshTags(newTags); this.generateFreshTags(newTags);
this.cachedRooms = newTags; // this recalculates the filtered rooms for us this.cachedRooms = newTags; // this recalculates the filtered rooms for us
this.updateTagsFromCache(); this.updateTagsFromCache();
@ -586,7 +584,7 @@ export class Algorithm extends EventEmitter {
// it was. It's entirely possible that it changed lists though, so if it did then // it was. It's entirely possible that it changed lists though, so if it did then
// we also have to update the position of it. // we also have to update the position of it.
if (oldStickyRoom && oldStickyRoom.room) { if (oldStickyRoom && oldStickyRoom.room) {
await this.updateStickyRoom(oldStickyRoom.room); this.updateStickyRoom(oldStickyRoom.room);
if (this._stickyRoom && this._stickyRoom.room) { // just in case the update doesn't go according to plan if (this._stickyRoom && this._stickyRoom.room) { // just in case the update doesn't go according to plan
if (this._stickyRoom.tag !== oldStickyRoom.tag) { if (this._stickyRoom.tag !== oldStickyRoom.tag) {
// We put the sticky room at the top of the list to treat it as an obvious tag change. // We put the sticky room at the top of the list to treat it as an obvious tag change.
@ -651,16 +649,15 @@ export class Algorithm extends EventEmitter {
* @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag * @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag
* will already have the rooms which belong to it - they just need ordering. Must * will already have the rooms which belong to it - they just need ordering. Must
* be mutated in place. * be mutated in place.
* @returns {Promise<*>} A promise which resolves when complete.
*/ */
private async generateFreshTags(updatedTagMap: ITagMap): Promise<any> { private generateFreshTags(updatedTagMap: ITagMap): void {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
for (const tag of Object.keys(updatedTagMap)) { for (const tag of Object.keys(updatedTagMap)) {
const algorithm: OrderingAlgorithm = this.algorithms[tag]; const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`); if (!algorithm) throw new Error(`No algorithm for ${tag}`);
await algorithm.setRooms(updatedTagMap[tag]); algorithm.setRooms(updatedTagMap[tag]);
updatedTagMap[tag] = algorithm.orderedRooms; updatedTagMap[tag] = algorithm.orderedRooms;
} }
} }
@ -672,11 +669,10 @@ export class Algorithm extends EventEmitter {
* may no-op this request if no changes are required. * may no-op this request if no changes are required.
* @param {Room} room The room which might have affected sorting. * @param {Room} room The room which might have affected sorting.
* @param {RoomUpdateCause} cause The reason for the update being triggered. * @param {RoomUpdateCause} cause The reason for the update being triggered.
* @returns {Promise<boolean>} A promise which resolve to true or false * @returns {Promise<boolean>} A boolean of whether or not getOrderedRooms()
* depending on whether or not getOrderedRooms() should be called after * should be called after processing.
* processing.
*/ */
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> { public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
if (SettingsStore.getValue("advancedRoomListLogging")) { if (SettingsStore.getValue("advancedRoomListLogging")) {
// TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602 // TODO: Remove debug: https://github.com/vector-im/element-web/issues/14602
console.log(`Handle room update for ${room.roomId} called with cause ${cause}`); console.log(`Handle room update for ${room.roomId} called with cause ${cause}`);
@ -684,9 +680,9 @@ export class Algorithm extends EventEmitter {
if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from"); if (!this.algorithms) throw new Error("Not ready: no algorithms to determine tags from");
// Note: check the isSticky against the room ID just in case the reference is wrong // Note: check the isSticky against the room ID just in case the reference is wrong
const isSticky = this._stickyRoom && this._stickyRoom.room && this._stickyRoom.room.roomId === room.roomId; const isSticky = this._stickyRoom?.room?.roomId === room.roomId;
if (cause === RoomUpdateCause.NewRoom) { if (cause === RoomUpdateCause.NewRoom) {
const isForLastSticky = this._lastStickyRoom && this._lastStickyRoom.room === room; const isForLastSticky = this._lastStickyRoom?.room === room;
const roomTags = this.roomIdsToTags[room.roomId]; const roomTags = this.roomIdsToTags[room.roomId];
const hasTags = roomTags && roomTags.length > 0; const hasTags = roomTags && roomTags.length > 0;
@ -743,7 +739,7 @@ export class Algorithm extends EventEmitter {
} }
const algorithm: OrderingAlgorithm = this.algorithms[rmTag]; const algorithm: OrderingAlgorithm = this.algorithms[rmTag];
if (!algorithm) throw new Error(`No algorithm for ${rmTag}`); if (!algorithm) throw new Error(`No algorithm for ${rmTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved); algorithm.handleRoomUpdate(room, RoomUpdateCause.RoomRemoved);
this._cachedRooms[rmTag] = algorithm.orderedRooms; this._cachedRooms[rmTag] = algorithm.orderedRooms;
this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list this.recalculateFilteredRoomsForTag(rmTag); // update filter to re-sort the list
this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed this.recalculateStickyRoom(rmTag); // update sticky room to make sure it moves if needed
@ -755,7 +751,7 @@ export class Algorithm extends EventEmitter {
} }
const algorithm: OrderingAlgorithm = this.algorithms[addTag]; const algorithm: OrderingAlgorithm = this.algorithms[addTag];
if (!algorithm) throw new Error(`No algorithm for ${addTag}`); if (!algorithm) throw new Error(`No algorithm for ${addTag}`);
await algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom); algorithm.handleRoomUpdate(room, RoomUpdateCause.NewRoom);
this._cachedRooms[addTag] = algorithm.orderedRooms; this._cachedRooms[addTag] = algorithm.orderedRooms;
} }
@ -788,7 +784,7 @@ export class Algorithm extends EventEmitter {
}; };
} else { } else {
// We have to clear the lock as the sticky room change will trigger updates. // We have to clear the lock as the sticky room change will trigger updates.
await this.setStickyRoom(room); this.setStickyRoom(room);
} }
} }
} }
@ -851,7 +847,7 @@ export class Algorithm extends EventEmitter {
const algorithm: OrderingAlgorithm = this.algorithms[tag]; const algorithm: OrderingAlgorithm = this.algorithms[tag];
if (!algorithm) throw new Error(`No algorithm for ${tag}`); if (!algorithm) throw new Error(`No algorithm for ${tag}`);
await algorithm.handleRoomUpdate(room, cause); algorithm.handleRoomUpdate(room, cause);
this._cachedRooms[tag] = algorithm.orderedRooms; this._cachedRooms[tag] = algorithm.orderedRooms;
// Flag that we've done something // Flag that we've done something

View file

@ -94,15 +94,15 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
return state.color; return state.color;
} }
public async setRooms(rooms: Room[]): Promise<any> { public setRooms(rooms: Room[]): void {
if (this.sortingAlgorithm === SortAlgorithm.Manual) { if (this.sortingAlgorithm === SortAlgorithm.Manual) {
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm); this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
} else { } else {
// Every other sorting type affects the categories, not the whole tag. // Every other sorting type affects the categories, not the whole tag.
const categorized = this.categorizeRooms(rooms); const categorized = this.categorizeRooms(rooms);
for (const category of Object.keys(categorized)) { for (const category of Object.keys(categorized)) {
const roomsToOrder = categorized[category]; const roomsToOrder = categorized[category];
categorized[category] = await sortRoomsWithAlgorithm(roomsToOrder, this.tagId, this.sortingAlgorithm); categorized[category] = sortRoomsWithAlgorithm(roomsToOrder, this.tagId, this.sortingAlgorithm);
} }
const newlyOrganized: Room[] = []; const newlyOrganized: Room[] = [];
@ -118,12 +118,12 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
} }
} }
private async handleSplice(room: Room, cause: RoomUpdateCause): Promise<boolean> { private handleSplice(room: Room, cause: RoomUpdateCause): boolean {
if (cause === RoomUpdateCause.NewRoom) { if (cause === RoomUpdateCause.NewRoom) {
const category = this.getRoomCategory(room); const category = this.getRoomCategory(room);
this.alterCategoryPositionBy(category, 1, this.indices); this.alterCategoryPositionBy(category, 1, this.indices);
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted) this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
await this.sortCategory(category); this.sortCategory(category);
} else if (cause === RoomUpdateCause.RoomRemoved) { } else if (cause === RoomUpdateCause.RoomRemoved) {
const roomIdx = this.getRoomIndex(room); const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) { if (roomIdx === -1) {
@ -141,55 +141,49 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
return true; return true;
} }
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> { public handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean {
try { if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
await this.updateLock.acquireAsync(); return this.handleSplice(room, cause);
if (cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved) {
return this.handleSplice(room, cause);
}
if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
throw new Error(`Unsupported update cause: ${cause}`);
}
const category = this.getRoomCategory(room);
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
return; // Nothing to do here.
}
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// Sort the category now that we've dumped the room in
await this.sortCategory(category);
return true; // change made
} finally {
await this.updateLock.release();
} }
if (cause !== RoomUpdateCause.Timeline && cause !== RoomUpdateCause.ReadReceipt) {
throw new Error(`Unsupported update cause: ${cause}`);
}
const category = this.getRoomCategory(room);
if (this.sortingAlgorithm === SortAlgorithm.Manual) {
return; // Nothing to do here.
}
const roomIdx = this.getRoomIndex(room);
if (roomIdx === -1) {
throw new Error(`Room ${room.roomId} has no index in ${this.tagId}`);
}
// Try to avoid doing array operations if we don't have to: only move rooms within
// the categories if we're jumping categories
const oldCategory = this.getCategoryFromIndices(roomIdx, this.indices);
if (oldCategory !== category) {
// Move the room and update the indices
this.moveRoomIndexes(1, oldCategory, category, this.indices);
this.cachedOrderedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
this.cachedOrderedRooms.splice(this.indices[category], 0, room); // splice in the new room (pre-adjusted)
// Note: if moveRoomIndexes() is called after the splice then the insert operation
// will happen in the wrong place. Because we would have already adjusted the index
// for the category, we don't need to determine how the room is moving in the list.
// If we instead tried to insert before updating the indices, we'd have to determine
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
// current position, as it'll affect the category's start index after we remove the
// room from the array.
}
// Sort the category now that we've dumped the room in
this.sortCategory(category);
return true; // change made
} }
private async sortCategory(category: NotificationColor) { private sortCategory(category: NotificationColor) {
// This should be relatively quick because the room is usually inserted at the top of the // This should be relatively quick because the room is usually inserted at the top of the
// category, and most popular sorting algorithms will deal with trying to keep the active // category, and most popular sorting algorithms will deal with trying to keep the active
// room at the top/start of the category. For the few algorithms that will have to move the // room at the top/start of the category. For the few algorithms that will have to move the
@ -201,7 +195,7 @@ export class ImportanceAlgorithm extends OrderingAlgorithm {
const startIdx = this.indices[category]; const startIdx = this.indices[category];
const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
const unsortedSlice = this.cachedOrderedRooms.splice(startIdx, numSort); const unsortedSlice = this.cachedOrderedRooms.splice(startIdx, numSort);
const sorted = await sortRoomsWithAlgorithm(unsortedSlice, this.tagId, this.sortingAlgorithm); const sorted = sortRoomsWithAlgorithm(unsortedSlice, this.tagId, this.sortingAlgorithm);
this.cachedOrderedRooms.splice(startIdx, 0, ...sorted); this.cachedOrderedRooms.splice(startIdx, 0, ...sorted);
} }

View file

@ -29,42 +29,32 @@ export class NaturalAlgorithm extends OrderingAlgorithm {
super(tagId, initialSortingAlgorithm); super(tagId, initialSortingAlgorithm);
} }
public async setRooms(rooms: Room[]): Promise<any> { public setRooms(rooms: Room[]): void {
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm); this.cachedOrderedRooms = sortRoomsWithAlgorithm(rooms, this.tagId, this.sortingAlgorithm);
} }
public async handleRoomUpdate(room, cause): Promise<boolean> { public handleRoomUpdate(room, cause): boolean {
try { const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved;
await this.updateLock.acquireAsync(); const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt;
if (!isSplice && !isInPlace) {
const isSplice = cause === RoomUpdateCause.NewRoom || cause === RoomUpdateCause.RoomRemoved; throw new Error(`Unsupported update cause: ${cause}`);
const isInPlace = cause === RoomUpdateCause.Timeline || cause === RoomUpdateCause.ReadReceipt;
if (!isSplice && !isInPlace) {
throw new Error(`Unsupported update cause: ${cause}`);
}
if (cause === RoomUpdateCause.NewRoom) {
this.cachedOrderedRooms.push(room);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const idx = this.getRoomIndex(room);
if (idx >= 0) {
this.cachedOrderedRooms.splice(idx, 1);
} else {
console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
}
}
// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedOrderedRooms = await sortRoomsWithAlgorithm(
this.cachedOrderedRooms,
this.tagId,
this.sortingAlgorithm,
);
return true;
} finally {
await this.updateLock.release();
} }
if (cause === RoomUpdateCause.NewRoom) {
this.cachedOrderedRooms.push(room);
} else if (cause === RoomUpdateCause.RoomRemoved) {
const idx = this.getRoomIndex(room);
if (idx >= 0) {
this.cachedOrderedRooms.splice(idx, 1);
} else {
console.warn(`Tried to remove unknown room from ${this.tagId}: ${room.roomId}`);
}
}
// TODO: Optimize this to avoid useless operations: https://github.com/vector-im/element-web/issues/14457
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedOrderedRooms = sortRoomsWithAlgorithm(this.cachedOrderedRooms, this.tagId, this.sortingAlgorithm);
return true;
} }
} }

View file

@ -17,7 +17,6 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomUpdateCause, TagID } from "../../models"; import { RoomUpdateCause, TagID } from "../../models";
import { SortAlgorithm } from "../models"; import { SortAlgorithm } from "../models";
import AwaitLock from "await-lock";
/** /**
* Represents a list ordering algorithm. Subclasses should populate the * Represents a list ordering algorithm. Subclasses should populate the
@ -26,7 +25,6 @@ import AwaitLock from "await-lock";
export abstract class OrderingAlgorithm { export abstract class OrderingAlgorithm {
protected cachedOrderedRooms: Room[]; protected cachedOrderedRooms: Room[];
protected sortingAlgorithm: SortAlgorithm; protected sortingAlgorithm: SortAlgorithm;
protected readonly updateLock = new AwaitLock();
protected constructor(protected tagId: TagID, initialSortingAlgorithm: SortAlgorithm) { protected constructor(protected tagId: TagID, initialSortingAlgorithm: SortAlgorithm) {
// noinspection JSIgnoredPromiseFromCall // noinspection JSIgnoredPromiseFromCall
@ -45,21 +43,20 @@ export abstract class OrderingAlgorithm {
* @param newAlgorithm The new algorithm. Must be defined. * @param newAlgorithm The new algorithm. Must be defined.
* @returns Resolves when complete. * @returns Resolves when complete.
*/ */
public async setSortAlgorithm(newAlgorithm: SortAlgorithm) { public setSortAlgorithm(newAlgorithm: SortAlgorithm) {
if (!newAlgorithm) throw new Error("A sorting algorithm must be defined"); if (!newAlgorithm) throw new Error("A sorting algorithm must be defined");
this.sortingAlgorithm = newAlgorithm; this.sortingAlgorithm = newAlgorithm;
// Force regeneration of the rooms // Force regeneration of the rooms
await this.setRooms(this.orderedRooms); this.setRooms(this.orderedRooms);
} }
/** /**
* Sets the rooms the algorithm should be handling, implying a reconstruction * Sets the rooms the algorithm should be handling, implying a reconstruction
* of the ordering. * of the ordering.
* @param rooms The rooms to use going forward. * @param rooms The rooms to use going forward.
* @returns Resolves when complete.
*/ */
public abstract setRooms(rooms: Room[]): Promise<any>; public abstract setRooms(rooms: Room[]): void;
/** /**
* Handle a room update. The Algorithm will only call this for causes which * Handle a room update. The Algorithm will only call this for causes which
@ -69,7 +66,7 @@ export abstract class OrderingAlgorithm {
* @param cause The cause of the update. * @param cause The cause of the update.
* @returns True if the update requires the Algorithm to update the presentation layers. * @returns True if the update requires the Algorithm to update the presentation layers.
*/ */
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>; public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): boolean;
protected getRoomIndex(room: Room): number { protected getRoomIndex(room: Room): number {
let roomIdx = this.cachedOrderedRooms.indexOf(room); let roomIdx = this.cachedOrderedRooms.indexOf(room);

View file

@ -23,7 +23,7 @@ import { compare } from "../../../../utils/strings";
* Sorts rooms according to the browser's determination of alphabetic. * Sorts rooms according to the browser's determination of alphabetic.
*/ */
export class AlphabeticAlgorithm implements IAlgorithm { export class AlphabeticAlgorithm implements IAlgorithm {
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> { public sortRooms(rooms: Room[], tagId: TagID): Room[] {
return rooms.sort((a, b) => { return rooms.sort((a, b) => {
return compare(a.name, b.name); return compare(a.name, b.name);
}); });

View file

@ -25,7 +25,7 @@ export interface IAlgorithm {
* Sorts the given rooms according to the sorting rules of the algorithm. * Sorts the given rooms according to the sorting rules of the algorithm.
* @param {Room[]} rooms The rooms to sort. * @param {Room[]} rooms The rooms to sort.
* @param {TagID} tagId The tag ID in which the rooms are being sorted. * @param {TagID} tagId The tag ID in which the rooms are being sorted.
* @returns {Promise<Room[]>} Resolves to the sorted rooms. * @returns {Room[]} Returns the sorted rooms.
*/ */
sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]>; sortRooms(rooms: Room[], tagId: TagID): Room[];
} }

View file

@ -22,7 +22,7 @@ import { IAlgorithm } from "./IAlgorithm";
* Sorts rooms according to the tag's `order` property on the room. * Sorts rooms according to the tag's `order` property on the room.
*/ */
export class ManualAlgorithm implements IAlgorithm { export class ManualAlgorithm implements IAlgorithm {
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> { public sortRooms(rooms: Room[], tagId: TagID): Room[] {
const getOrderProp = (r: Room) => r.tags[tagId].order || 0; const getOrderProp = (r: Room) => r.tags[tagId].order || 0;
return rooms.sort((a, b) => { return rooms.sort((a, b) => {
return getOrderProp(a) - getOrderProp(b); return getOrderProp(a) - getOrderProp(b);

View file

@ -97,7 +97,7 @@ export const sortRooms = (rooms: Room[]): Room[] => {
* useful to the user. * useful to the user.
*/ */
export class RecentAlgorithm implements IAlgorithm { export class RecentAlgorithm implements IAlgorithm {
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> { public sortRooms(rooms: Room[], tagId: TagID): Room[] {
return sortRooms(rooms); return sortRooms(rooms);
} }
} }

View file

@ -46,8 +46,8 @@ export function getSortingAlgorithmInstance(algorithm: SortAlgorithm): IAlgorith
* @param {Room[]} rooms The rooms to sort. * @param {Room[]} rooms The rooms to sort.
* @param {TagID} tagId The tag in which the sorting is occurring. * @param {TagID} tagId The tag in which the sorting is occurring.
* @param {SortAlgorithm} algorithm The algorithm to use for sorting. * @param {SortAlgorithm} algorithm The algorithm to use for sorting.
* @returns {Promise<Room[]>} Resolves to the sorted rooms. * @returns {Room[]} Returns the sorted rooms.
*/ */
export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Promise<Room[]> { export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Room[] {
return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId); return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId);
} }

View file

@ -18,7 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import CallHandler from "../../../CallHandler"; import CallHandler from "../../../CallHandler";
import { RoomListCustomisations } from "../../../customisations/RoomList"; import { RoomListCustomisations } from "../../../customisations/RoomList";
import VoipUserMapper from "../../../VoipUserMapper"; import VoipUserMapper from "../../../VoipUserMapper";
import SettingsStore from "../../../settings/SettingsStore"; import SpaceStore from "../../SpaceStore";
export class VisibilityProvider { export class VisibilityProvider {
private static internalInstance: VisibilityProvider; private static internalInstance: VisibilityProvider;
@ -50,7 +50,7 @@ export class VisibilityProvider {
} }
// hide space rooms as they'll be shown in the SpacePanel // hide space rooms as they'll be shown in the SpacePanel
if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) { if (SpaceStore.spacesEnabled && room.isSpaceRoom()) {
return false; return false;
} }

View file

@ -19,6 +19,9 @@ import { MatrixEvent, EventStatus } from 'matrix-js-sdk/src/models/event';
import { MatrixClientPeg } from '../MatrixClientPeg'; import { MatrixClientPeg } from '../MatrixClientPeg';
import shouldHideEvent from "../shouldHideEvent"; import shouldHideEvent from "../shouldHideEvent";
import { getHandlerTile, haveTileForEvent } from "../components/views/rooms/EventTile";
import SettingsStore from "../settings/SettingsStore";
import { EventType } from "matrix-js-sdk/src/@types/event";
/** /**
* Returns whether an event should allow actions like reply, reactions, edit, etc. * Returns whether an event should allow actions like reply, reactions, edit, etc.
@ -96,3 +99,38 @@ export function findEditableEvent(room: Room, isForward: boolean, fromEventId: s
} }
} }
export function getEventDisplayInfo(mxEvent: MatrixEvent): {
isInfoMessage: boolean;
tileHandler: string;
isBubbleMessage: boolean;
} {
const content = mxEvent.getContent();
const msgtype = content.msgtype;
const eventType = mxEvent.getType();
let tileHandler = getHandlerTile(mxEvent);
// Info messages are basically information about commands processed on a room
let isBubbleMessage = eventType.startsWith("m.key.verification") ||
(eventType === EventType.RoomMessage && msgtype && msgtype.startsWith("m.key.verification")) ||
(eventType === EventType.RoomCreate) ||
(eventType === EventType.RoomEncryption) ||
(tileHandler === "messages.MJitsiWidgetEvent");
let isInfoMessage = (
!isBubbleMessage && eventType !== EventType.RoomMessage &&
eventType !== EventType.Sticker && eventType !== EventType.RoomCreate
);
// If we're showing hidden events in the timeline, we should use the
// source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing).
if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(mxEvent)) {
tileHandler = "messages.ViewSourceEvent";
isBubbleMessage = false;
// Reuse info message avatar and sender profile styling
isInfoMessage = true;
}
return { tileHandler, isInfoMessage, isBubbleMessage };
}

View file

@ -14,9 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import url from 'url';
import qs from 'qs';
import SdkConfig from '../SdkConfig'; import SdkConfig from '../SdkConfig';
import { MatrixClientPeg } from '../MatrixClientPeg'; import { MatrixClientPeg } from '../MatrixClientPeg';
@ -28,11 +25,8 @@ export function getHostingLink(campaign) {
if (MatrixClientPeg.get().getDomain() !== 'matrix.org') return null; if (MatrixClientPeg.get().getDomain() !== 'matrix.org') return null;
try { try {
const hostingUrl = url.parse(hostingLink); const hostingUrl = new URL(hostingLink);
const params = qs.parse(hostingUrl.query); hostingUrl.searchParams.set("utm_campaign", campaign);
params.utm_campaign = campaign;
hostingUrl.search = undefined;
hostingUrl.query = params;
return hostingUrl.format(); return hostingUrl.format();
} catch (e) { } catch (e) {
return hostingLink; return hostingLink;

View file

@ -23,6 +23,7 @@ import { mkEvent, mkStubRoom } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import * as languageHandler from "../../../../src/languageHandler"; import * as languageHandler from "../../../../src/languageHandler";
import * as TestUtils from "../../../test-utils"; import * as TestUtils from "../../../test-utils";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
const _TextualBody = sdk.getComponent("views.messages.TextualBody"); const _TextualBody = sdk.getComponent("views.messages.TextualBody");
const TextualBody = TestUtils.wrapInMatrixClientContext(_TextualBody); const TextualBody = TestUtils.wrapInMatrixClientContext(_TextualBody);
@ -41,6 +42,7 @@ describe("<TextualBody />", () => {
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s, mxcUrlToHttp: (s) => s,
}; };
DMRoomMap.makeShared();
const ev = mkEvent({ const ev = mkEvent({
type: "m.room.message", type: "m.room.message",
@ -66,6 +68,7 @@ describe("<TextualBody />", () => {
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s, mxcUrlToHttp: (s) => s,
}; };
DMRoomMap.makeShared();
const ev = mkEvent({ const ev = mkEvent({
type: "m.room.message", type: "m.room.message",
@ -92,6 +95,7 @@ describe("<TextualBody />", () => {
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s, mxcUrlToHttp: (s) => s,
}; };
DMRoomMap.makeShared();
}); });
it("simple message renders as expected", () => { it("simple message renders as expected", () => {
@ -146,6 +150,7 @@ describe("<TextualBody />", () => {
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s, mxcUrlToHttp: (s) => s,
}; };
DMRoomMap.makeShared();
}); });
it("italics, bold, underline and strikethrough render as expected", () => { it("italics, bold, underline and strikethrough render as expected", () => {
@ -292,6 +297,7 @@ describe("<TextualBody />", () => {
isGuest: () => false, isGuest: () => false,
mxcUrlToHttp: (s) => s, mxcUrlToHttp: (s) => s,
}; };
DMRoomMap.makeShared();
const ev = mkEvent({ const ev = mkEvent({
type: "m.room.message", type: "m.room.message",

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "../skinned-sdk"; // Must be first for skinning to work
import { getLineAndNodePosition } from "../../src/editor/caret"; import { getLineAndNodePosition } from "../../src/editor/caret";
import EditorModel from "../../src/editor/model"; import EditorModel from "../../src/editor/model";
import { createPartCreator } from "./mock"; import { createPartCreator } from "./mock";

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "../skinned-sdk"; // Must be first for skinning to work
import EditorModel from "../../src/editor/model"; import EditorModel from "../../src/editor/model";
import { createPartCreator, createRenderer } from "./mock"; import { createPartCreator, createRenderer } from "./mock";

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "../skinned-sdk"; // Must be first for skinning to work
import EditorModel from "../../src/editor/model"; import EditorModel from "../../src/editor/model";
import { createPartCreator, createRenderer } from "./mock"; import { createPartCreator, createRenderer } from "./mock";
import { toggleInlineFormat } from "../../src/editor/operations"; import { toggleInlineFormat } from "../../src/editor/operations";

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "../skinned-sdk"; // Must be first for skinning to work
import EditorModel from "../../src/editor/model"; import EditorModel from "../../src/editor/model";
import { createPartCreator } from "./mock"; import { createPartCreator } from "./mock";

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "../skinned-sdk"; // Must be first for skinning to work
import EditorModel from "../../src/editor/model"; import EditorModel from "../../src/editor/model";
import { createPartCreator, createRenderer } from "./mock"; import { createPartCreator, createRenderer } from "./mock";

View file

@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import "../skinned-sdk"; // Must be first for skinning to work
import EditorModel from "../../src/editor/model"; import EditorModel from "../../src/editor/model";
import { htmlSerializeIfNeeded } from "../../src/editor/serialize"; import { htmlSerializeIfNeeded } from "../../src/editor/serialize";
import { createPartCreator } from "./mock"; import { createPartCreator } from "./mock";

Some files were not shown because too many files have changed in this diff Show more