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

 Conflicts:
	src/i18n/strings/en_EN.json
This commit is contained in:
Michael Telatynski 2021-05-11 14:07:47 +01:00
commit df72fbb22a
83 changed files with 2120 additions and 886 deletions

View File

@ -1,3 +1,115 @@
Changes in [3.20.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0) (2021-05-10)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.20.0-rc.1...v3.20.0)
* Upgrade to JS SDK 10.1.0
* [Release] Don't use the event's metadata to calc the scale of an image
[\#6004](https://github.com/matrix-org/matrix-react-sdk/pull/6004)
Changes in [3.20.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.20.0-rc.1) (2021-05-04)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0...v3.20.0-rc.1)
* Upgrade to JS SDK 10.1.0-rc.1
* Translations update from Weblate
[\#5966](https://github.com/matrix-org/matrix-react-sdk/pull/5966)
* Fix more space panel layout and hover behaviour issues
[\#5965](https://github.com/matrix-org/matrix-react-sdk/pull/5965)
* Fix edge case with space panel alignment with subspaces on ff
[\#5964](https://github.com/matrix-org/matrix-react-sdk/pull/5964)
* Fix saving room pill part to history
[\#5951](https://github.com/matrix-org/matrix-react-sdk/pull/5951)
* Generate room preview even when minimized
[\#5948](https://github.com/matrix-org/matrix-react-sdk/pull/5948)
* Another change from recovery passphrase to Security Phrase
[\#5934](https://github.com/matrix-org/matrix-react-sdk/pull/5934)
* Sort rooms in the add existing to space dialog based on recency
[\#5943](https://github.com/matrix-org/matrix-react-sdk/pull/5943)
* Inhibit sending RR when context switching to a room
[\#5944](https://github.com/matrix-org/matrix-react-sdk/pull/5944)
* Prevent room list keyboard handling from landing focus on hidden nodes
[\#5950](https://github.com/matrix-org/matrix-react-sdk/pull/5950)
* Make the text filter search all spaces instead of just the selected one
[\#5942](https://github.com/matrix-org/matrix-react-sdk/pull/5942)
* Enable indent rule and fix indent
[\#5931](https://github.com/matrix-org/matrix-react-sdk/pull/5931)
* Prevent peeking members from reacting
[\#5946](https://github.com/matrix-org/matrix-react-sdk/pull/5946)
* Disallow inline display maths
[\#5939](https://github.com/matrix-org/matrix-react-sdk/pull/5939)
* Space creation prompt user to add existing rooms for "Just Me" spaces
[\#5923](https://github.com/matrix-org/matrix-react-sdk/pull/5923)
* Add test coverage collection script
[\#5937](https://github.com/matrix-org/matrix-react-sdk/pull/5937)
* Fix joining room using via servers regression
[\#5936](https://github.com/matrix-org/matrix-react-sdk/pull/5936)
* Revert "Fixes the two Todays problem in Redaction"
[\#5938](https://github.com/matrix-org/matrix-react-sdk/pull/5938)
* Handle encoded matrix URLs
[\#5903](https://github.com/matrix-org/matrix-react-sdk/pull/5903)
* Render ignored users setting regardless of if there are any
[\#5860](https://github.com/matrix-org/matrix-react-sdk/pull/5860)
* Fix inserting trailing colon after mention/pill
[\#5830](https://github.com/matrix-org/matrix-react-sdk/pull/5830)
* Fixes the two Todays problem in Redaction
[\#5917](https://github.com/matrix-org/matrix-react-sdk/pull/5917)
* Fix page up/down scrolling only half a page
[\#5920](https://github.com/matrix-org/matrix-react-sdk/pull/5920)
* Voice messages: Composer controls
[\#5935](https://github.com/matrix-org/matrix-react-sdk/pull/5935)
* Support MSC3086 asserted identity
[\#5886](https://github.com/matrix-org/matrix-react-sdk/pull/5886)
* Handle possible edge case with getting stuck in "unsent messages" bar
[\#5930](https://github.com/matrix-org/matrix-react-sdk/pull/5930)
* Fix suggested rooms not showing up regression from room list optimisation
[\#5932](https://github.com/matrix-org/matrix-react-sdk/pull/5932)
* Broadcast language change to ElectronPlatform
[\#5913](https://github.com/matrix-org/matrix-react-sdk/pull/5913)
* Fix VoIP PIP frame color
[\#5701](https://github.com/matrix-org/matrix-react-sdk/pull/5701)
* Convert some Flow-typed files to TypeScript
[\#5912](https://github.com/matrix-org/matrix-react-sdk/pull/5912)
* Initial SpaceStore tests work
[\#5906](https://github.com/matrix-org/matrix-react-sdk/pull/5906)
* Fix issues with space hierarchy in layout and with incompatible servers
[\#5926](https://github.com/matrix-org/matrix-react-sdk/pull/5926)
* Scale all mxc thumbs using device pixel ratio for hidpi
[\#5928](https://github.com/matrix-org/matrix-react-sdk/pull/5928)
* Fix add existing to space dialog no longer showing rooms for public spaces
[\#5918](https://github.com/matrix-org/matrix-react-sdk/pull/5918)
* Disable spaces context switching for when exploring a space
[\#5924](https://github.com/matrix-org/matrix-react-sdk/pull/5924)
* Autofocus search box in the add existing to space dialog
[\#5921](https://github.com/matrix-org/matrix-react-sdk/pull/5921)
* Use label element in add existing to space dialog for easier hit target
[\#5922](https://github.com/matrix-org/matrix-react-sdk/pull/5922)
* Dynamic max and min zoom in the new ImageView
[\#5916](https://github.com/matrix-org/matrix-react-sdk/pull/5916)
* Improve message error states
[\#5897](https://github.com/matrix-org/matrix-react-sdk/pull/5897)
* Check for null room in `VisibilityProvider`
[\#5914](https://github.com/matrix-org/matrix-react-sdk/pull/5914)
* Add unit tests for various collection-based utility functions
[\#5910](https://github.com/matrix-org/matrix-react-sdk/pull/5910)
* Spaces visual fixes
[\#5909](https://github.com/matrix-org/matrix-react-sdk/pull/5909)
* Remove reliance on DOM API to generated message preview
[\#5908](https://github.com/matrix-org/matrix-react-sdk/pull/5908)
* Expand upon voice message event & include overall waveform
[\#5888](https://github.com/matrix-org/matrix-react-sdk/pull/5888)
* Use floats for image background opacity
[\#5905](https://github.com/matrix-org/matrix-react-sdk/pull/5905)
* Show invites to spaces at the top of the space panel
[\#5902](https://github.com/matrix-org/matrix-react-sdk/pull/5902)
* Improve edge cases with spaces context switching
[\#5899](https://github.com/matrix-org/matrix-react-sdk/pull/5899)
* Fix spaces notification dots wrongly including upgraded (hidden) rooms
[\#5900](https://github.com/matrix-org/matrix-react-sdk/pull/5900)
* Iterate the spaces face pile design
[\#5898](https://github.com/matrix-org/matrix-react-sdk/pull/5898)
* Fix alignment issue with nested spaces being cut off wrong
[\#5890](https://github.com/matrix-org/matrix-react-sdk/pull/5890)
Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26) Changes in [3.19.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.19.0) (2021-04-26)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.19.0-rc.1...v3.19.0)

View File

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.19.0", "version": "3.20.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -58,7 +58,7 @@
"blueimp-canvas-to-blob": "^3.28.0", "blueimp-canvas-to-blob": "^3.28.0",
"browser-encrypt-attachment": "^0.3.0", "browser-encrypt-attachment": "^0.3.0",
"browser-request": "^0.3.3", "browser-request": "^0.3.3",
"cheerio": "^1.0.0-rc.5", "cheerio": "^1.0.0-rc.9",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"commonmark": "^0.29.3", "commonmark": "^0.29.3",
"counterpart": "^0.18.6", "counterpart": "^0.18.6",
@ -97,7 +97,7 @@
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
"sanitize-html": "github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db", "sanitize-html": "^2.3.2",
"tar-js": "^0.3.0", "tar-js": "^0.3.0",
"text-encoding-utf-8": "^1.0.2", "text-encoding-utf-8": "^1.0.2",
"url": "^0.11.0", "url": "^0.11.0",
@ -132,11 +132,12 @@
"@types/modernizr": "^3.5.3", "@types/modernizr": "^3.5.3",
"@types/node": "^14.14.22", "@types/node": "^14.14.22",
"@types/pako": "^1.0.1", "@types/pako": "^1.0.1",
"@types/parse5": "^6.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "^16.9", "@types/react": "^16.9",
"@types/react-dom": "^16.9.10", "@types/react-dom": "^16.9.10",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^1.27.0", "@types/sanitize-html": "^2.3.1",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
"@typescript-eslint/eslint-plugin": "^4.14.0", "@typescript-eslint/eslint-plugin": "^4.14.0",
"@typescript-eslint/parser": "^4.14.0", "@typescript-eslint/parser": "^4.14.0",

View File

@ -54,6 +54,7 @@
@import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss";
@import "./views/avatars/_PulsedAvatar.scss"; @import "./views/avatars/_PulsedAvatar.scss";
@import "./views/avatars/_WidgetAvatar.scss"; @import "./views/avatars/_WidgetAvatar.scss";
@import "./views/beta/_BetaCard.scss";
@import "./views/context_menus/_CallContextMenu.scss"; @import "./views/context_menus/_CallContextMenu.scss";
@import "./views/context_menus/_IconizedContextMenu.scss"; @import "./views/context_menus/_IconizedContextMenu.scss";
@import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss";
@ -237,6 +238,7 @@
@import "./views/settings/tabs/user/_AppearanceUserSettingsTab.scss"; @import "./views/settings/tabs/user/_AppearanceUserSettingsTab.scss";
@import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss"; @import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss";
@import "./views/settings/tabs/user/_HelpUserSettingsTab.scss"; @import "./views/settings/tabs/user/_HelpUserSettingsTab.scss";
@import "./views/settings/tabs/user/_LabsUserSettingsTab.scss";
@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss"; @import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss";
@import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss"; @import "./views/settings/tabs/user/_NotificationUserSettingsTab.scss";
@import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss"; @import "./views/settings/tabs/user/_PreferencesUserSettingsTab.scss";

View File

@ -56,6 +56,12 @@ limitations under the License.
.mx_GroupFilterPanel .mx_TagTile { .mx_GroupFilterPanel .mx_TagTile {
// opacity: 0.5; // opacity: 0.5;
position: relative; position: relative;
.mx_BetaDot {
position: absolute;
right: -13px;
top: -11px;
}
} }
.mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype { .mx_GroupFilterPanel .mx_TagTile.mx_TagTile_prototype {

View File

@ -17,6 +17,11 @@ limitations under the License.
.mx_MyGroups { .mx_MyGroups {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
.mx_BetaCard {
margin: 0 72px;
max-width: 760px;
}
} }
.mx_MyGroups .mx_RoomHeader_simpleHeader { .mx_MyGroups .mx_RoomHeader_simpleHeader {
@ -30,7 +35,7 @@ limitations under the License.
flex-wrap: wrap; flex-wrap: wrap;
} }
.mx_MyGroups > :not(.mx_RoomHeader) { .mx_MyGroups > :not(.mx_RoomHeader):not(.mx_BetaCard) {
max-width: 960px; max-width: 960px;
margin: 40px; margin: 40px;
} }

View File

@ -237,7 +237,6 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
position: absolute; position: absolute;
height: 16px;
// Create a flexbox to make aligning dot badges easier // Create a flexbox to make aligning dot badges easier
display: flex; display: flex;
@ -249,23 +248,37 @@ $activeBorderColor: $secondary-fg-color;
.mx_NotificationBadge_dot { .mx_NotificationBadge_dot {
// make the smaller dot occupy the same width for centering // make the smaller dot occupy the same width for centering
margin-left: 7px; margin: 0 7px;
margin-right: 7px;
} }
} }
&.collapsed { &.collapsed {
.mx_SpaceButton { .mx_SpaceButton {
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
right: -3px; right: 0;
top: -3px; top: 0;
.mx_NotificationBadge {
background-clip: padding-box;
}
.mx_NotificationBadge_dot {
margin: 0 -1px 0 0;
border: 3px solid $groupFilterPanel-bg-color;
}
.mx_NotificationBadge_2char,
.mx_NotificationBadge_3char {
margin: -5px -5px 0 0;
border: 2px solid $groupFilterPanel-bg-color;
}
} }
&.mx_SpaceButton_active .mx_SpacePanel_badgeContainer { &.mx_SpaceButton_active .mx_SpacePanel_badgeContainer {
// when we draw the selection border we move the relative bounds of our parent // when we draw the selection border we move the relative bounds of our parent
// so update our position within the bounds of the parent to maintain position overall // so update our position within the bounds of the parent to maintain position overall
right: -6px; right: -3px;
top: -6px; top: -3px;
} }
} }
} }

View File

@ -86,7 +86,7 @@ limitations under the License.
color: $primary-fg-color; color: $primary-fg-color;
.mx_AccessibleButton { .mx_AccessibleButton {
padding: 2px 8px; padding: 4px 12px;
font-weight: normal; font-weight: normal;
& + .mx_AccessibleButton { & + .mx_AccessibleButton {
@ -94,6 +94,11 @@ limitations under the License.
} }
} }
.mx_AccessibleButton_kind_danger_outline,
.mx_AccessibleButton_kind_primary_outline {
padding: 3px 12px; // to account for the 1px border
}
> span { > span {
margin-left: auto; margin-left: auto;
} }
@ -246,11 +251,17 @@ limitations under the License.
grid-row: 1/3; grid-row: 1/3;
.mx_AccessibleButton { .mx_AccessibleButton {
padding: 8px 18px; line-height: $font-24px;
padding: 4px 16px;
display: inline-block; display: inline-block;
visibility: hidden; visibility: hidden;
} }
.mx_AccessibleButton_kind_danger_outline,
.mx_AccessibleButton_kind_primary_outline {
padding: 3px 16px; // to account for the 1px border
}
.mx_Checkbox { .mx_Checkbox {
display: inline-flex; display: inline-flex;
vertical-align: middle; vertical-align: middle;

View File

@ -103,6 +103,10 @@ $SpaceRoomViewInnerWidth: 428px;
padding: 8px 22px; padding: 8px 22px;
margin-left: 16px; margin-left: 16px;
} }
input.mx_AccessibleButton {
border: none; // override default styles
}
} }
.mx_Field { .mx_Field {
@ -133,6 +137,44 @@ $SpaceRoomViewInnerWidth: 428px;
box-sizing: border-box; box-sizing: border-box;
box-shadow: 2px 15px 30px $dialog-shadow-color; box-shadow: 2px 15px 30px $dialog-shadow-color;
border-radius: 8px; border-radius: 8px;
position: relative;
// XXX remove this when spaces leaves Beta
.mx_BetaCard_betaPill {
position: absolute;
right: 24px;
top: 32px;
}
// XXX remove this when spaces leaves Beta
.mx_SpaceRoomView_preview_spaceBetaPrompt {
font-weight: $font-semi-bold;
font-size: $font-14px;
line-height: $font-24px;
color: $primary-fg-color;
margin-top: 24px;
position: relative;
padding-left: 24px;
.mx_AccessibleButton_kind_link {
display: inline;
padding: 0;
font-size: inherit;
line-height: inherit;
}
&::before {
content: "";
position: absolute;
height: $font-24px;
width: 20px;
left: 0;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
mask-image: url('$(res)/img/element-icons/room/room-summary.svg');
background-color: $secondary-fg-color;
}
}
.mx_SpaceRoomView_preview_inviter { .mx_SpaceRoomView_preview_inviter {
display: flex; display: flex;
@ -238,7 +280,8 @@ $SpaceRoomViewInnerWidth: 428px;
.mx_SpaceRoomView_landing_inviteButton { .mx_SpaceRoomView_landing_inviteButton {
position: relative; position: relative;
padding-left: 40px; padding: 4px 18px 4px 40px;
line-height: $font-24px;
height: min-content; height: min-content;
&::before { &::before {
@ -254,6 +297,27 @@ $SpaceRoomViewInnerWidth: 428px;
mask-image: url('$(res)/img/element-icons/room/invite.svg'); mask-image: url('$(res)/img/element-icons/room/invite.svg');
} }
} }
.mx_SpaceRoomView_landing_settingsButton {
position: relative;
margin-left: 16px;
width: 24px;
height: 24px;
&::before {
position: absolute;
content: "";
left: 0;
top: 0;
height: 24px;
width: 24px;
background: $tertiary-fg-color;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/element-icons/settings.svg');
}
}
} }
.mx_SpaceRoomView_landing_topic { .mx_SpaceRoomView_landing_topic {
@ -268,80 +332,6 @@ $SpaceRoomViewInnerWidth: 428px;
background-color: $groupFilterPanel-bg-color; background-color: $groupFilterPanel-bg-color;
} }
.mx_SpaceRoomView_landing_adminButtons {
margin-top: 24px;
.mx_AccessibleButton {
position: relative;
width: 160px;
height: 124px;
box-sizing: border-box;
padding: 72px 16px 0;
border-radius: 12px;
border: 1px solid $input-border-color;
margin-right: 28px;
margin-bottom: 20px;
font-size: $font-14px;
display: inline-block;
vertical-align: bottom;
&:last-child {
margin-right: 0;
}
&:hover {
background-color: rgba(141, 151, 165, 0.1);
}
&::before, &::after {
position: absolute;
content: "";
left: 16px;
top: 16px;
height: 40px;
width: 40px;
border-radius: 20px;
}
&::after {
mask-position: center;
mask-size: 30px;
mask-repeat: no-repeat;
background: #ffffff; // white icon fill
}
&.mx_SpaceRoomView_landing_addButton {
&::before {
background-color: #ac3ba8;
}
&::after {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
}
&.mx_SpaceRoomView_landing_createButton {
&::before {
background-color: #368bd6;
}
&::after {
mask-image: url('$(res)/img/element-icons/roomlist/explore.svg');
}
}
&.mx_SpaceRoomView_landing_settingsButton {
&::before {
background-color: #5c56f5;
}
&::after {
mask-image: url('$(res)/img/element-icons/settings.svg');
}
}
}
}
.mx_SearchBox { .mx_SearchBox {
margin: 0 0 20px; margin: 0 0 20px;
} }
@ -362,6 +352,23 @@ $SpaceRoomViewInnerWidth: 428px;
} }
.mx_SpaceRoomView_inviteTeammates { .mx_SpaceRoomView_inviteTeammates {
// XXX remove this when spaces leaves Beta
.mx_SpaceRoomView_inviteTeammates_betaDisclaimer {
padding: 58px 16px 16px;
position: relative;
border-radius: 8px;
background-color: $header-panel-bg-color;
max-width: $SpaceRoomViewInnerWidth;
margin: 20px 0 30px;
box-sizing: border-box;
.mx_BetaCard_betaPill {
position: absolute;
left: 16px;
top: 16px;
}
}
.mx_SpaceRoomView_inviteTeammates_buttons { .mx_SpaceRoomView_inviteTeammates_buttons {
color: $secondary-fg-color; color: $secondary-fg-color;
margin-top: 28px; margin-top: 28px;

View File

@ -0,0 +1,112 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_BetaCard {
margin-bottom: 20px;
padding: 24px;
background-color: $settings-profile-placeholder-bg-color;
border-radius: 8px;
display: flex;
box-sizing: border-box;
> div {
.mx_BetaCard_title {
font-weight: $font-semi-bold;
font-size: $font-18px;
line-height: $font-22px;
color: $primary-fg-color;
margin: 4px 0 14px;
.mx_BetaCard_betaPill {
margin-left: 12px;
}
}
.mx_BetaCard_caption {
font-size: $font-15px;
line-height: $font-20px;
color: $secondary-fg-color;
}
.mx_AccessibleButton {
display: block;
margin: 20px 0;
padding: 12px 40px;
width: max-content;
}
.mx_BetaCard_disclaimer {
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
}
}
> img {
margin: auto 0 auto 20px;
width: 300px;
object-fit: contain;
height: 100%;
}
}
.mx_BetaCard_betaPill {
background-color: $accent-color-alt;
padding: 4px 10px;
border-radius: 8px;
text-transform: uppercase;
font-size: 12px;
line-height: 15px;
color: #FFFFFF;
display: inline-block;
vertical-align: text-bottom;
&.mx_BetaCard_betaPill_clickable {
cursor: pointer;
}
}
$pulse-color: $accent-color-alt;
$dot-size: 12px;
.mx_BetaDot {
border-radius: 50%;
margin: 10px;
height: $dot-size;
width: $dot-size;
transform: scale(1);
background: rgba($pulse-color, 1);
box-shadow: 0 0 0 0 rgba($pulse-color, 1);
animation: mx_Beta_bluePulse 2s infinite;
animation-iteration-count: 20;
}
@keyframes mx_Beta_bluePulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba($pulse-color, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0);
}
}

View File

@ -76,12 +76,16 @@ limitations under the License.
border: 1px solid $button-danger-bg-color; border: 1px solid $button-danger-bg-color;
} }
.mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled, .mx_AccessibleButton_kind_danger.mx_AccessibleButton_disabled {
.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled {
color: $button-danger-disabled-fg-color; color: $button-danger-disabled-fg-color;
background-color: $button-danger-disabled-bg-color; background-color: $button-danger-disabled-bg-color;
} }
.mx_AccessibleButton_kind_danger_outline.mx_AccessibleButton_disabled {
color: $button-danger-disabled-bg-color;
border-color: $button-danger-disabled-bg-color;
}
.mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm { .mx_AccessibleButton_hasKind.mx_AccessibleButton_kind_danger_sm {
padding: 5px 12px; padding: 5px 12px;
color: $button-danger-fg-color; color: $button-danger-fg-color;

View File

@ -61,9 +61,9 @@ limitations under the License.
.mx_MFileBody_info { .mx_MFileBody_info {
background-color: $message-body-panel-bg-color; background-color: $message-body-panel-bg-color;
border-radius: 4px; border-radius: 12px;
width: 270px; width: 243px; // same width as a playable voice message, accounting for padding
padding: 8px; padding: 6px 12px;
color: $message-body-panel-fg-color; color: $message-body-panel-fg-color;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
@ -82,7 +82,7 @@ limitations under the License.
mask-position: center; mask-position: center;
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-fg-color; background-color: $message-body-panel-icon-fg-color;
width: 13px; width: 13px;
height: 15px; height: 15px;

View File

@ -39,23 +39,25 @@ limitations under the License.
width: 14px; // w&h are size of icon width: 14px; // w&h are size of icon
height: 18px; height: 18px;
vertical-align: middle; vertical-align: middle;
margin-right: 7px; // distance from left edge of waveform container (container has some margin too) margin-right: 11px; // distance from left edge of waveform container (container has some margin too)
background-color: $voice-record-icon-color; background-color: $voice-record-icon-color;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: contain; mask-size: contain;
mask-image: url('$(res)/img/element-icons/trashcan.svg'); mask-image: url('$(res)/img/element-icons/trashcan.svg');
} }
.mx_VoiceMessagePrimaryContainer { .mx_MessageComposer_row .mx_VoiceMessagePrimaryContainer {
// Note: remaining class properties are in the PlayerContainer CSS. // Note: remaining class properties are in the PlayerContainer CSS.
margin: 6px; // force the composer area to put a gutter around us margin: 6px; // force the composer area to put a gutter around us
margin-right: 12px; // isolate from stop button margin-right: 12px; // isolate from stop/send button
position: relative; // important for the live circle position: relative; // important for the live circle
&.mx_VoiceRecordComposerTile_recording { &.mx_VoiceRecordComposerTile_recording {
padding-left: 16px; // +10px for the live circle, +6px for regular padding // We are putting the circle in this padding, so we need +10px from the regular
// padding on the left side.
padding-left: 22px;
&::before { &::before {
animation: recording-pulse 2s infinite; animation: recording-pulse 2s infinite;
@ -65,8 +67,8 @@ limitations under the License.
width: 10px; width: 10px;
height: 10px; height: 10px;
position: absolute; position: absolute;
left: 8px; left: 12px; // 12px from the left edge for container padding
top: 16px; // vertically center top: 18px; // vertically center (middle align with clock)
border-radius: 10px; border-radius: 10px;
} }
} }

View File

@ -0,0 +1,25 @@
/*
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_LabsUserSettingsTab {
.mx_SettingsTab_section {
margin-top: 32px;
.mx_SettingsFlag {
margin-right: 0; // remove right margin to align with beta cards
}
}
}

View File

@ -29,6 +29,7 @@ $spacePanelWidth: 71px;
width: 480px; width: 480px;
box-sizing: border-box; box-sizing: border-box;
background-color: $primary-bg-color; background-color: $primary-bg-color;
position: relative;
> div { > div {
> h2 { > h2 {
@ -44,6 +45,13 @@ $spacePanelWidth: 71px;
} }
} }
// XXX remove this when spaces leaves Beta
.mx_BetaCard_betaPill {
position: absolute;
top: 24px;
right: 24px;
}
.mx_SpaceCreateMenuType { .mx_SpaceCreateMenuType {
@mixin SpacePillButton; @mixin SpacePillButton;
} }

View File

@ -19,8 +19,9 @@ limitations under the License.
// Container for live recording and playback controls // Container for live recording and playback controls
.mx_VoiceMessagePrimaryContainer { .mx_VoiceMessagePrimaryContainer {
padding: 6px; // makes us 4px taller than the send/stop button // 7px top and bottom for visual design. 12px left & right, but the waveform (right)
padding-right: 5px; // there's 1px from the waveform itself, so account for that // has a 1px padding on it that we want to account for.
padding: 7px 12px 7px 11px;
background-color: $voice-record-waveform-bg-color; background-color: $voice-record-waveform-bg-color;
border-radius: 12px; border-radius: 12px;
@ -30,11 +31,9 @@ limitations under the License.
color: $voice-record-waveform-fg-color; color: $voice-record-waveform-fg-color;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-24px;
.mx_Waveform { .mx_Waveform {
// We want the bars to be 2px shorter than the play/pause button in the waveform control
height: 28px; // default is 30px, so we're subtracting the 2px border off the bars
.mx_Waveform_bar { .mx_Waveform_bar {
background-color: $voice-record-waveform-incomplete-fg-color; background-color: $voice-record-waveform-incomplete-fg-color;
@ -47,8 +46,8 @@ limitations under the License.
} }
.mx_Clock { .mx_Clock {
padding-right: 4px; // isolate from waveform width: 42px; // we're not using a monospace font, so fake it
padding-left: 8px; // isolate from live circle padding-right: 6px; // with the fixed width this ends up as a visual 8px most of the time, as intended.
width: 40px; // we're not using a monospace font, so fake it padding-left: 8px; // isolate from recording circle / play control
} }
} }

View File

@ -65,14 +65,17 @@ limitations under the License.
} }
} }
.mx_CallView_voice { .mx_CallView_content {
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column; border-radius: 8px;
}
.mx_CallView_voice {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column;
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
border-radius: 8px;
} }
.mx_CallView_voice_avatarsContainer { .mx_CallView_voice_avatarsContainer {
@ -109,9 +112,7 @@ limitations under the License.
.mx_CallView_video { .mx_CallView_video {
width: 100%; width: 100%;
height: 100%; height: 100%;
position: relative;
z-index: 30; z-index: 30;
border-radius: 8px;
overflow: hidden; overflow: hidden;
} }

View File

@ -14,21 +14,37 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_VideoFeed_voice {
// We don't want to collide with the call controls that have 52px of height
padding-bottom: 52px;
background-color: $inverted-bg-color;
}
.mx_VideoFeed_remote { .mx_VideoFeed_remote {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: flex;
justify-content: center;
align-items: center;
&.mx_VideoFeed_video {
background-color: #000; background-color: #000;
z-index: 50; }
} }
.mx_VideoFeed_local { .mx_VideoFeed_local {
width: 25%; max-width: 25%;
height: 25%; max-height: 25%;
position: absolute; position: absolute;
right: 10px; right: 10px;
top: 10px; top: 10px;
z-index: 100; z-index: 100;
border-radius: 4px; border-radius: 4px;
&.mx_VideoFeed_video {
background-color: transparent;
}
} }
.mx_VideoFeed_mirror { .mx_VideoFeed_mirror {

BIN
res/img/betas/spaces.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -9,6 +9,7 @@ $header-panel-text-primary-color: #B9BEC6;
$header-panel-text-secondary-color: #c8c8cd; $header-panel-text-secondary-color: #c8c8cd;
$text-primary-color: #ffffff; $text-primary-color: #ffffff;
$text-secondary-color: #B9BEC6; $text-secondary-color: #B9BEC6;
$quaternary-fg-color: #6F7882;
$search-bg-color: #181b21; $search-bg-color: #181b21;
$search-placeholder-color: #61708b; $search-placeholder-color: #61708b;
$room-highlight-color: #343a46; $room-highlight-color: #343a46;
@ -42,14 +43,6 @@ $preview-bar-bg-color: $header-panel-bg-color;
$groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82); $groupFilterPanel-bg-color: rgba(38, 39, 43, 0.82);
$inverted-bg-color: $base-color; $inverted-bg-color: $base-color;
$voice-record-stop-border-color: #6F7882; // "Quarterly"
$voice-record-waveform-bg-color: #394049; // "Dark Tile"
$voice-record-waveform-fg-color: $tertiary-fg-color;
$voice-record-waveform-incomplete-fg-color: #5b646d;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $tertiary-fg-color;
$voice-playback-button-fg-color: $bg-color;
// used by AddressSelector // used by AddressSelector
$selected-color: $room-highlight-color; $selected-color: $room-highlight-color;
@ -213,9 +206,18 @@ $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #21262c82; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #8e99a4; $message-body-panel-bg-color: #394049; // "Dark Tile"
$message-body-panel-fg-color: $primary-fg-color; $message-body-panel-icon-fg-color: #21262C; // "Separator"
$message-body-panel-icon-bg-color: $tertiary-fg-color;
$voice-record-stop-border-color: $quaternary-fg-color;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $quaternary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// Appearance tab colors // Appearance tab colors
$appearance-tab-border-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;

View File

@ -124,15 +124,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #3e444c 0%, #3e444c00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
// See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882;
$voice-record-waveform-bg-color: #394049;
$voice-record-waveform-fg-color: $tertiary-fg-color;
$voice-record-waveform-incomplete-fg-color: #5b646d;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $tertiary-fg-color;
$voice-playback-button-fg-color: $bg-color;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #1A1D23; $roomtile-selected-bg-color: #1A1D23;
@ -209,9 +200,19 @@ $breadcrumb-placeholder-bg-color: #272c35;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #21262c82; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #8e99a4; $message-body-panel-bg-color: #394049;
$message-body-panel-fg-color: $primary-fg-color; $message-body-panel-icon-fg-color: $primary-bg-color;
$message-body-panel-icon-bg-color: $secondary-fg-color;
// See non-legacy dark for variable information
$voice-record-stop-border-color: #6F7882;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #6F7882;
$voice-record-icon-color: #6F7882;
$voice-playback-button-bg-color: $tertiary-fg-color;
$voice-playback-button-fg-color: #21262C;
// Appearance tab colors // Appearance tab colors
$appearance-tab-border-color: $room-highlight-color; $appearance-tab-border-color: $room-highlight-color;

View File

@ -191,17 +191,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
// See non-legacy _light for variable information
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-live-circle-color: #ff4b55;
$voice-record-icon-color: $muted-fg-color;
$voice-playback-button-bg-color: $primary-bg-color;
$voice-playback-button-fg-color: $muted-fg-color;
$roomtile-preview-color: #9e9e9e; $roomtile-preview-color: #9e9e9e;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #fff; $roomtile-selected-bg-color: #fff;
@ -334,9 +323,21 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #e3e8f082; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #ffffff; $message-body-panel-bg-color: #E3E8F0;
$message-body-panel-fg-color: $muted-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color;
// See non-legacy _light for variable information
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View File

@ -21,6 +21,7 @@ $notice-primary-bg-color: rgba(255, 75, 85, 0.16);
$primary-fg-color: #2e2f32; $primary-fg-color: #2e2f32;
$secondary-fg-color: #737D8C; $secondary-fg-color: #737D8C;
$tertiary-fg-color: #8D99A5; $tertiary-fg-color: #8D99A5;
$quaternary-fg-color: #C1C6CD;
$header-panel-bg-color: #f3f8fd; $header-panel-bg-color: #f3f8fd;
// typical text (dark-on-white in light skin) // typical text (dark-on-white in light skin)
@ -182,16 +183,6 @@ $roomsublist-skeleton-ui-bg: linear-gradient(180deg, #ffffff 0%, #ffffff00 100%)
$groupFilterPanel-divider-color: $roomlist-header-color; $groupFilterPanel-divider-color: $roomlist-header-color;
$voice-record-stop-border-color: #E3E8F0;
$voice-record-stop-symbol-color: #ff4b55; // $warning-color, but without letting people change it in themes
$voice-record-waveform-bg-color: #E3E8F0;
$voice-record-waveform-fg-color: $muted-fg-color;
$voice-record-waveform-incomplete-fg-color: #C1C6CD;
$voice-record-live-circle-color: #ff4b55; // $warning-color, but without letting people change it in themes
$voice-record-icon-color: $muted-fg-color;
$voice-playback-button-bg-color: $primary-bg-color;
$voice-playback-button-fg-color: $muted-fg-color;
$roomtile-preview-color: $secondary-fg-color; $roomtile-preview-color: $secondary-fg-color;
$roomtile-default-badge-bg-color: #61708b; $roomtile-default-badge-bg-color: #61708b;
$roomtile-selected-bg-color: #FFF; $roomtile-selected-bg-color: #FFF;
@ -331,9 +322,23 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
$user-tile-hover-bg-color: $header-panel-bg-color; $user-tile-hover-bg-color: $header-panel-bg-color;
$message-body-panel-bg-color: #e3e8f082; $message-body-panel-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: #ffffff; $message-body-panel-bg-color: #E3E8F0; // "Separator"
$message-body-panel-fg-color: $muted-fg-color; $message-body-panel-icon-fg-color: $secondary-fg-color;
$message-body-panel-icon-bg-color: $primary-bg-color;
// These two don't change between themes. They are the $warning-color, but we don't
// want custom themes to affect them by accident.
$voice-record-stop-symbol-color: #ff4b55;
$voice-record-live-circle-color: #ff4b55;
$voice-record-stop-border-color: #E3E8F0; // "Separator"
$voice-record-waveform-bg-color: $message-body-panel-bg-color;
$voice-record-waveform-fg-color: $message-body-panel-fg-color;
$voice-record-waveform-incomplete-fg-color: $quaternary-fg-color;
$voice-record-icon-color: $tertiary-fg-color;
$voice-playback-button-bg-color: $message-body-panel-icon-bg-color;
$voice-playback-button-fg-color: $message-body-panel-icon-fg-color;
// FontSlider colors // FontSlider colors
$appearance-tab-border-color: $input-darker-bg-color; $appearance-tab-border-color: $input-darker-bg-color;

View File

@ -118,6 +118,16 @@ declare global {
interface HTMLAudioElement { interface HTMLAudioElement {
type?: string; type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
}
interface HTMLVideoElement {
type?: string;
// sinkId & setSinkId are experimental and typescript doesn't know about them
sinkId: string;
setSinkId(outputId: string);
} }
interface Element { interface Element {

View File

@ -20,6 +20,7 @@ import {Room} from "matrix-js-sdk/src/models/room";
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";
export type ResizeMethod = "crop" | "scale"; export type ResizeMethod = "crop" | "scale";
@ -143,7 +144,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 (room.isSpaceRoom()) return null; if (SettingsStore.getValue("feature_spaces") && 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

@ -85,6 +85,7 @@ import { Action } from './dispatcher/actions';
import VoipUserMapper from './VoipUserMapper'; import VoipUserMapper from './VoipUserMapper';
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid'; import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from './widgets/ManagedHybrid';
import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring"; import { randomUppercaseString, randomLowercaseString } from "matrix-js-sdk/src/randomstring";
import EventEmitter from 'events';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import { ensureDMExists, findDMForUser } from './createRoom'; import { ensureDMExists, findDMForUser } from './createRoom';
@ -138,22 +139,12 @@ export enum PlaceCallType {
ScreenSharing = 'screensharing', ScreenSharing = 'screensharing',
} }
function getRemoteAudioElement(): HTMLAudioElement { export enum CallHandlerEvent {
// this needs to be somewhere at the top of the DOM which CallsChanged = "calls_changed",
// always exists to avoid audio interruptions. CallChangeRoom = "call_change_room",
// Might as well just use DOM.
const remoteAudioElement = document.getElementById("remoteAudio") as HTMLAudioElement;
if (!remoteAudioElement) {
console.error(
"Failed to find remoteAudio element - cannot play audio!" +
"You need to add an <audio/> to the DOM.",
);
return null;
}
return remoteAudioElement;
} }
export default class CallHandler { export default class CallHandler extends EventEmitter {
private calls = new Map<string, MatrixCall>(); // roomId -> call private calls = new Map<string, MatrixCall>(); // roomId -> call
// Calls started as an attended transfer, ie. with the intention of transferring another // Calls started as an attended transfer, ie. with the intention of transferring another
// call with a different party to this one. // call with a different party to this one.
@ -514,6 +505,7 @@ export default class CallHandler {
} }
this.calls.set(mappedRoomId, newCall); this.calls.set(mappedRoomId, newCall);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(newCall); this.setCallListeners(newCall);
this.setCallState(newCall, newCall.state); this.setCallState(newCall, newCall.state);
}); });
@ -546,10 +538,7 @@ export default class CallHandler {
this.removeCallForRoom(mappedRoomId); this.removeCallForRoom(mappedRoomId);
mappedRoomId = newMappedRoomId; mappedRoomId = newMappedRoomId;
this.calls.set(mappedRoomId, call); this.calls.set(mappedRoomId, call);
dis.dispatch({ this.emit(CallHandlerEvent.CallChangeRoom, call);
action: Action.CallChangeRoom,
call,
});
} }
} }
}); });
@ -598,11 +587,6 @@ export default class CallHandler {
} }
} }
private setCallAudioElement(call: MatrixCall) {
const audioElement = getRemoteAudioElement();
if (audioElement) call.setRemoteAudioElement(audioElement);
}
private setCallState(call: MatrixCall, status: CallState) { private setCallState(call: MatrixCall, status: CallState) {
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
@ -619,6 +603,7 @@ export default class CallHandler {
private removeCallForRoom(roomId: string) { private removeCallForRoom(roomId: string) {
this.calls.delete(roomId); this.calls.delete(roomId);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
} }
private showICEFallbackPrompt() { private showICEFallbackPrompt() {
@ -679,11 +664,7 @@ export default class CallHandler {
}, null, true); }, null, true);
} }
private async placeCall( private async placeCall(roomId: string, type: PlaceCallType, transferee: MatrixCall) {
roomId: string, type: PlaceCallType,
localElement: HTMLVideoElement, remoteElement: HTMLVideoElement,
transferee: MatrixCall,
) {
Analytics.trackEvent('voip', 'placeCall', 'type', type); Analytics.trackEvent('voip', 'placeCall', 'type', type);
CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false); CountlyAnalytics.instance.trackStartCall(roomId, type === PlaceCallType.Video, false);
@ -695,22 +676,19 @@ export default class CallHandler {
const call = MatrixClientPeg.get().createCall(mappedRoomId); const call = MatrixClientPeg.get().createCall(mappedRoomId);
this.calls.set(roomId, call); this.calls.set(roomId, call);
this.emit(CallHandlerEvent.CallsChanged, this.calls);
if (transferee) { if (transferee) {
this.transferees[call.callId] = transferee; this.transferees[call.callId] = transferee;
} }
this.setCallListeners(call); this.setCallListeners(call);
this.setCallAudioElement(call);
this.setActiveCallRoomId(roomId); this.setActiveCallRoomId(roomId);
if (type === PlaceCallType.Voice) { if (type === PlaceCallType.Voice) {
call.placeVoiceCall(); call.placeVoiceCall();
} else if (type === 'video') { } else if (type === 'video') {
call.placeVideoCall( call.placeVideoCall();
remoteElement,
localElement,
);
} else if (type === PlaceCallType.ScreenSharing) { } else if (type === PlaceCallType.ScreenSharing) {
const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString(); const screenCapErrorString = PlatformPeg.get().screenCaptureErrorString();
if (screenCapErrorString) { if (screenCapErrorString) {
@ -724,13 +702,12 @@ export default class CallHandler {
} }
call.placeScreenSharingCall( call.placeScreenSharingCall(
remoteElement,
localElement,
async (): Promise<DesktopCapturerSource> => { async (): Promise<DesktopCapturerSource> => {
const {finished} = Modal.createDialog(DesktopCapturerSourcePicker); const {finished} = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
return source; return source;
}); },
);
} else { } else {
console.error("Unknown conf call type: " + type); console.error("Unknown conf call type: " + type);
} }
@ -787,17 +764,12 @@ export default class CallHandler {
} else if (members.length === 2) { } else if (members.length === 2) {
console.info(`Place ${payload.type} call in ${payload.room_id}`); console.info(`Place ${payload.type} call in ${payload.room_id}`);
this.placeCall( this.placeCall(payload.room_id, payload.type, payload.transferee);
payload.room_id, payload.type, payload.local_element, payload.remote_element,
payload.transferee,
);
} else { // > 2 } else { // > 2
dis.dispatch({ dis.dispatch({
action: "place_conference_call", action: "place_conference_call",
room_id: payload.room_id, room_id: payload.room_id,
type: payload.type, type: payload.type,
remote_element: payload.remote_element,
local_element: payload.local_element,
}); });
} }
} }
@ -833,6 +805,7 @@ export default class CallHandler {
Analytics.trackEvent('voip', 'receiveCall', 'type', call.type); Analytics.trackEvent('voip', 'receiveCall', 'type', call.type);
this.calls.set(mappedRoomId, call) this.calls.set(mappedRoomId, call)
this.emit(CallHandlerEvent.CallsChanged, this.calls);
this.setCallListeners(call); this.setCallListeners(call);
// get ready to send encrypted events in the room, so if the user does answer // get ready to send encrypted events in the room, so if the user does answer
@ -875,7 +848,6 @@ export default class CallHandler {
const call = this.calls.get(payload.room_id); const call = this.calls.get(payload.room_id);
call.answer(); call.answer();
this.setCallAudioElement(call);
this.setActiveCallRoomId(payload.room_id); this.setActiveCallRoomId(payload.room_id);
CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false); CountlyAnalytics.instance.trackJoinCall(payload.room_id, call.type === CallType.Video, false);
dis.dispatch({ dis.dispatch({

View File

@ -16,7 +16,7 @@
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import {SettingLevel} from "./settings/SettingLevel"; import {SettingLevel} from "./settings/SettingLevel";
import {setMatrixCallAudioInput, setMatrixCallAudioOutput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix"; import {setMatrixCallAudioInput, setMatrixCallVideoInput} from "matrix-js-sdk/src/matrix";
export default { export default {
hasAnyLabeledDevices: async function() { hasAnyLabeledDevices: async function() {
@ -50,18 +50,15 @@ export default {
}, },
loadDevices: function() { loadDevices: function() {
const audioOutDeviceId = SettingsStore.getValue("webrtc_audiooutput");
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioOutput(audioOutDeviceId);
setMatrixCallAudioInput(audioDeviceId); setMatrixCallAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId); setMatrixCallVideoInput(videoDeviceId);
}, },
setAudioOutput: function(deviceId) { setAudioOutput: function(deviceId) {
SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_audiooutput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioOutput(deviceId);
}, },
setAudioInput: function(deviceId) { setAudioInput: function(deviceId) {

View File

@ -422,8 +422,12 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
safeBody = sanitizeHtml(formattedBody, sanitizeParams); safeBody = sanitizeHtml(formattedBody, sanitizeParams);
if (SettingsStore.getValue("feature_latex_maths")) { if (SettingsStore.getValue("feature_latex_maths")) {
const phtml = cheerio.load(safeBody, const phtml = cheerio.load(safeBody, {
{ _useHtmlParser2: true, decodeEntities: false }) // @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
// @ts-ignore - The types for `replaceWith` wrongly expect // @ts-ignore - The types for `replaceWith` wrongly expect
// Cheerio instance to be returned. // Cheerio instance to be returned.
phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) { phtml('div, span[data-mx-maths!=""]').replaceWith(function(i, e) {
@ -431,6 +435,7 @@ export function bodyToHtml(content: IContent, highlights: string[], opts: IOpts
AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')), AllHtmlEntities.decode(phtml(e).attr('data-mx-maths')),
{ {
throwOnError: false, throwOnError: false,
// @ts-ignore - `e` can be an Element, not just a Node
displayMode: e.name == 'div', displayMode: e.name == 'div',
output: "htmlAndMathml", output: "htmlAndMathml",
}); });

View File

@ -38,7 +38,7 @@ import {isPermalinkHost, parsePermalink} from "./utils/permalinks/Permalinks";
import {inviteUsersToRoom} from "./RoomInvite"; import {inviteUsersToRoom} from "./RoomInvite";
import { WidgetType } from "./widgets/WidgetType"; import { WidgetType } from "./widgets/WidgetType";
import { Jitsi } from "./widgets/Jitsi"; import { Jitsi } from "./widgets/Jitsi";
import { parseFragment as parseHtml } from "parse5"; import { parseFragment as parseHtml, Element as ChildElement } from "parse5";
import BugReportDialog from "./components/views/dialogs/BugReportDialog"; import BugReportDialog from "./components/views/dialogs/BugReportDialog";
import { ensureDMExists } from "./createRoom"; import { ensureDMExists } from "./createRoom";
import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload"; import { ViewUserPayload } from "./dispatcher/payloads/ViewUserPayload";
@ -856,7 +856,7 @@ export const Commands = [
// some superfast regex over the text so we don't have to. // some superfast regex over the text so we don't have to.
const embed = parseHtml(widgetUrl); const embed = parseHtml(widgetUrl);
if (embed && embed.childNodes && embed.childNodes.length === 1) { if (embed && embed.childNodes && embed.childNodes.length === 1) {
const iframe = embed.childNodes[0]; const iframe = embed.childNodes[0] as ChildElement;
if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) { if (iframe.tagName.toLowerCase() === 'iframe' && iframe.attrs) {
const srcAttr = iframe.attrs.find(a => a.name === 'src'); const srcAttr = iframe.attrs.find(a => a.name === 'src');
console.log("Pulling URL out of iframe (embed code)"); console.log("Pulling URL out of iframe (embed code)");

View File

@ -123,12 +123,19 @@ class GroupFilterPanel extends React.Component {
mx_GroupFilterPanel_items_selected: itemsSelected, mx_GroupFilterPanel_items_selected: itemsSelected,
}); });
let betaDot;
if (SettingsStore.getBetaInfo("feature_spaces") && !localStorage.getItem("mx_seenSpacesBeta")) {
betaDot = <div className="mx_BetaDot" />;
}
let createButton = ( let createButton = (
<ActionButton <ActionButton
tooltip tooltip
label={_t("Communities")} label={_t("Communities")}
action="toggle_my_groups" action="toggle_my_groups"
className="mx_TagTile mx_TagTile_plus" /> className="mx_TagTile mx_TagTile_plus">
{ betaDot }
</ActionButton>
); );
if (SettingsStore.getValue("feature_communities_v2_prototypes")) { if (SettingsStore.getValue("feature_communities_v2_prototypes")) {

View File

@ -59,6 +59,9 @@ import { getKeyBindingsManager, NavigationAction, RoomAction } from '../../KeyBi
import { IOpts } from "../../createRoom"; import { IOpts } from "../../createRoom";
import SpacePanel from "../views/spaces/SpacePanel"; import SpacePanel from "../views/spaces/SpacePanel";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import CallHandler, { CallHandlerEvent } from '../../CallHandler';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import AudioFeedArrayForCall from '../views/voip/AudioFeedArrayForCall';
// 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.
@ -119,6 +122,7 @@ interface IState {
usageLimitEventContent?: IUsageLimit; usageLimitEventContent?: IUsageLimit;
usageLimitEventTs?: number; usageLimitEventTs?: number;
useCompactLayout: boolean; useCompactLayout: boolean;
activeCalls: Array<MatrixCall>;
} }
/** /**
@ -160,6 +164,7 @@ class LoggedInView extends React.Component<IProps, IState> {
// use compact timeline view // use compact timeline view
useCompactLayout: SettingsStore.getValue('useCompactLayout'), useCompactLayout: SettingsStore.getValue('useCompactLayout'),
usageLimitDismissed: false, usageLimitDismissed: false,
activeCalls: [],
}; };
// stash the MatrixClient in case we log out before we are unmounted // stash the MatrixClient in case we log out before we are unmounted
@ -175,6 +180,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentDidMount() { componentDidMount() {
document.addEventListener('keydown', this._onNativeKeyDown, false); document.addEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._updateServerNoticeEvents(); this._updateServerNoticeEvents();
@ -199,6 +205,7 @@ class LoggedInView extends React.Component<IProps, IState> {
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener('keydown', this._onNativeKeyDown, false); document.removeEventListener('keydown', this._onNativeKeyDown, false);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallsChanged, this.onCallsChanged);
this._matrixClient.removeListener("accountData", this.onAccountData); this._matrixClient.removeListener("accountData", this.onAccountData);
this._matrixClient.removeListener("sync", this.onSync); this._matrixClient.removeListener("sync", this.onSync);
this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents); this._matrixClient.removeListener("RoomState.events", this.onRoomStateEvents);
@ -206,6 +213,12 @@ class LoggedInView extends React.Component<IProps, IState> {
this.resizer.detach(); this.resizer.detach();
} }
private onCallsChanged = () => {
this.setState({
activeCalls: CallHandler.sharedInstance().getAllActiveCalls(),
});
};
// Child components assume that the client peg will not be null, so give them some // Child components assume that the client peg will not be null, so give them some
// sort of assurance here by only allowing a re-render if the client is truthy. // sort of assurance here by only allowing a re-render if the client is truthy.
// //
@ -661,6 +674,12 @@ class LoggedInView extends React.Component<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout'; bodyClasses += ' mx_MatrixChat_useCompactLayout';
} }
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return (
<AudioFeedArrayForCall call={call} key={call.callId} />
);
});
return ( return (
<MatrixClientContext.Provider value={this._matrixClient}> <MatrixClientContext.Provider value={this._matrixClient}>
<div <div
@ -685,6 +704,7 @@ class LoggedInView extends React.Component<IProps, IState> {
<CallContainer /> <CallContainer />
<NonUrgentToastContainer /> <NonUrgentToastContainer />
<HostSignupContainer /> <HostSignupContainer />
{audioFeedArraysForCalls}
</MatrixClientContext.Provider> </MatrixClientContext.Provider>
); );
} }

View File

@ -740,6 +740,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.showScreenAfterLogin(); this.showScreenAfterLogin();
break; break;
case 'toggle_my_groups': case 'toggle_my_groups':
// persist that the user has interacted with this, use it to dismiss the beta dot
localStorage.setItem("mx_seenSpacesBeta", "1");
// We just dispatch the page change rather than have to worry about // We just dispatch the page change rather than have to worry about
// what the logic is for each of these branches. // what the logic is for each of these branches.
if (this.state.page_type === PageTypes.MyGroups) { if (this.state.page_type === PageTypes.MyGroups) {
@ -1094,7 +1096,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 = roomToLeave?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && roomToLeave?.isSpaceRoom();
// Show a warning if there are additional complications. // Show a warning if there are additional complications.
const warnings = []; const warnings = [];
@ -1133,7 +1135,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 = roomToLeave?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && 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: (
@ -1684,6 +1686,10 @@ 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")) {
dis.dispatch({ action: "view_home_page" });
return;
}
dis.dispatch({ dis.dispatch({
action: 'view_my_groups', action: 'view_my_groups',
}); });
@ -1767,6 +1773,11 @@ 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")) {
dis.dispatch({ action: "view_home_page" });
return;
}
const groupId = screen.substring(6); const groupId = screen.substring(6);
// TODO: Check valid group ID // TODO: Check valid group ID

View File

@ -544,11 +544,13 @@ export default class MessagePanel extends React.Component {
} }
if (!grouper) { if (!grouper) {
const wantTile = this._shouldShowEvent(mxEv); const wantTile = this._shouldShowEvent(mxEv);
const isGrouped = false;
if (wantTile) { if (wantTile) {
// make sure we unpack the array returned by _getTilesForEvent, // make sure we unpack the array returned by _getTilesForEvent,
// otherwise react will auto-generate keys and we will end up // otherwise react will auto-generate keys and we will end up
// replacing all of the DOM elements every time we paginate. // replacing all of the DOM elements every time we paginate.
ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextTile)); ret.push(...this._getTilesForEvent(prevEvent, mxEv, last, isGrouped,
nextEvent, nextTile));
prevEvent = mxEv; prevEvent = mxEv;
} }
@ -564,7 +566,7 @@ export default class MessagePanel extends React.Component {
return ret; return ret;
} }
_getTilesForEvent(prevEvent, mxEv, last, nextEvent, nextEventWithTile) { _getTilesForEvent(prevEvent, mxEv, last, isGrouped=false, nextEvent, nextEventWithTile) {
const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary'); const TileErrorBoundary = sdk.getComponent('messages.TileErrorBoundary');
const EventTile = sdk.getComponent('rooms.EventTile'); const EventTile = sdk.getComponent('rooms.EventTile');
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
@ -584,7 +586,7 @@ export default class MessagePanel extends React.Component {
// do we need a date separator since the last event? // do we need a date separator since the last event?
const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate); const wantsDateSeparator = this._wantsDateSeparator(prevEvent, eventDate);
if (wantsDateSeparator) { if (wantsDateSeparator && !isGrouped) {
const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>; const dateSeparator = <li key={ts1}><DateSeparator key={ts1} ts={ts1} /></li>;
ret.push(dateSeparator); ret.push(dateSeparator);
} }
@ -968,9 +970,9 @@ class CreationGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const panel = this.panel; const panel = this.panel;
const ret = []; const ret = [];
const isGrouped = true;
const createEvent = this.createEvent; const createEvent = this.createEvent;
const lastShownEvent = this.lastShownEvent; const lastShownEvent = this.lastShownEvent;
@ -984,12 +986,12 @@ class CreationGrouper {
// If this m.room.create event should be shown (room upgrade) then show it before the summary // If this m.room.create event should be shown (room upgrade) then show it before the summary
if (panel._shouldShowEvent(createEvent)) { if (panel._shouldShowEvent(createEvent)) {
// pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered // pass in the createEvent as prevEvent as well so no extra DateSeparator is rendered
ret.push(...panel._getTilesForEvent(createEvent, createEvent, false)); ret.push(...panel._getTilesForEvent(createEvent, createEvent));
} }
for (const ejected of this.ejectedEvents) { for (const ejected of this.ejectedEvents) {
ret.push(...panel._getTilesForEvent( ret.push(...panel._getTilesForEvent(
createEvent, ejected, createEvent === lastShownEvent, createEvent, ejected, createEvent === lastShownEvent, isGrouped,
)); ));
} }
@ -998,7 +1000,7 @@ class CreationGrouper {
// of EventListSummary, render each member event as if the previous // of EventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the // one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted. // timestamp of the current event, and no DateSeparator is inserted.
return panel._getTilesForEvent(e, e, e === lastShownEvent); return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
}).reduce((a, b) => a.concat(b), []); }).reduce((a, b) => a.concat(b), []);
// Get sender profile from the latest event in the summary as the m.room.create doesn't contain one // Get sender profile from the latest event in the summary as the m.room.create doesn't contain one
const ev = this.events[this.events.length - 1]; const ev = this.events[this.events.length - 1];
@ -1083,7 +1085,7 @@ class RedactionGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const EventListSummary = sdk.getComponent('views.elements.EventListSummary'); const EventListSummary = sdk.getComponent('views.elements.EventListSummary');
const isGrouped = true;
const panel = this.panel; const panel = this.panel;
const ret = []; const ret = [];
const lastShownEvent = this.lastShownEvent; const lastShownEvent = this.lastShownEvent;
@ -1103,7 +1105,8 @@ class RedactionGrouper {
let eventTiles = this.events.map((e, i) => { let eventTiles = this.events.map((e, i) => {
senders.add(e.sender); senders.add(e.sender);
const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1]; const prevEvent = i === 0 ? this.prevEvent : this.events[i - 1];
return panel._getTilesForEvent(prevEvent, e, e === lastShownEvent, this.nextEvent, this.nextEventTile); return panel._getTilesForEvent(
prevEvent, e, e === lastShownEvent, isGrouped, this.nextEvent, this.nextEventTile);
}).reduce((a, b) => a.concat(b), []); }).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) { if (eventTiles.length === 0) {
@ -1182,7 +1185,7 @@ class MemberGrouper {
const DateSeparator = sdk.getComponent('messages.DateSeparator'); const DateSeparator = sdk.getComponent('messages.DateSeparator');
const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary'); const MemberEventListSummary = sdk.getComponent('views.elements.MemberEventListSummary');
const isGrouped = true;
const panel = this.panel; const panel = this.panel;
const lastShownEvent = this.lastShownEvent; const lastShownEvent = this.lastShownEvent;
const ret = []; const ret = [];
@ -1215,7 +1218,7 @@ class MemberGrouper {
// of MemberEventListSummary, render each member event as if the previous // of MemberEventListSummary, render each member event as if the previous
// one was itself. This way, the timestamp of the previous event === the // one was itself. This way, the timestamp of the previous event === the
// timestamp of the current event, and no DateSeparator is inserted. // timestamp of the current event, and no DateSeparator is inserted.
return panel._getTilesForEvent(e, e, e === lastShownEvent); return panel._getTilesForEvent(e, e, e === lastShownEvent, isGrouped);
}).reduce((a, b) => a.concat(b), []); }).reduce((a, b) => a.concat(b), []);
if (eventTiles.length === 0) { if (eventTiles.length === 0) {

View File

@ -25,6 +25,7 @@ import AccessibleButton from '../views/elements/AccessibleButton';
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import BetaCard from "../views/beta/BetaCard";
@replaceableComponent("structures.MyGroups") @replaceableComponent("structures.MyGroups")
export default class MyGroups extends React.Component { export default class MyGroups extends React.Component {
@ -139,6 +140,7 @@ export default class MyGroups extends React.Component {
</div> </div>
</div>*/} </div>*/}
</div> </div>
<BetaCard featureId="feature_spaces" title={_t("Communities are changing to Spaces")} />
<div className="mx_MyGroups_content"> <div className="mx_MyGroups_content">
{ contentHeader } { contentHeader }
{ content } { content }

View File

@ -35,6 +35,7 @@ import {Action} from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard"; import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import SettingsStore from "../../settings/SettingsStore";
@replaceableComponent("structures.RightPanel") @replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component { export default class RightPanel extends React.Component {
@ -85,7 +86,9 @@ export default class RightPanel extends React.Component {
return RightPanelPhases.GroupMemberList; return RightPanelPhases.GroupMemberList;
} }
return rps.groupPanelPhase; return rps.groupPanelPhase;
} else if (this.props.room?.isSpaceRoom() && !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)) { } else if (SettingsStore.getValue("feature_spaces") && this.props.room?.isSpaceRoom()
&& !RIGHT_PANEL_SPACE_PHASES.includes(rps.roomPanelPhase)
) {
return RightPanelPhases.SpaceMemberList; return RightPanelPhases.SpaceMemberList;
} else if (userForPanel) { } else if (userForPanel) {
// XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state // XXX FIXME AAAAAARGH: What is going on with this class!? It takes some of its state

View File

@ -27,8 +27,8 @@ import { Action } from "../../dispatcher/actions";
import RoomListStore from "../../stores/room-list/RoomListStore"; import RoomListStore from "../../stores/room-list/RoomListStore";
import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition"; import { NameFilterCondition } from "../../stores/room-list/filters/NameFilterCondition";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore, {UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES} from "../../stores/SpaceStore"; import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../../stores/SpaceStore";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;

View File

@ -1750,7 +1750,10 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
const myMembership = this.state.room.getMyMembership(); const myMembership = this.state.room.getMyMembership();
if (myMembership === "invite" && !this.state.room.isSpaceRoom()) { // SpaceRoomView handles invites itself if (myMembership === "invite"
// SpaceRoomView handles invites itself
&& (!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>
@ -1892,7 +1895,7 @@ export default class RoomView extends React.Component<IProps, IState> {
room={this.state.room} room={this.state.room}
/> />
); );
if (!this.state.canPeek && !this.state.room?.isSpaceRoom()) { if (!this.state.canPeek && (!SettingsStore.getValue("feature_spaces") || !this.state.room?.isSpaceRoom())) {
return ( return (
<div className="mx_RoomView"> <div className="mx_RoomView">
{ previewBar } { previewBar }
@ -1914,7 +1917,7 @@ export default class RoomView extends React.Component<IProps, IState> {
); );
} }
if (SettingsStore.getValue("feature_spaces") && this.state.room?.isSpaceRoom()) { if (this.state.room?.isSpaceRoom()) {
return <SpaceRoomView return <SpaceRoomView
space={this.state.room} space={this.state.room}
justCreatedOpts={this.props.justCreatedOpts} justCreatedOpts={this.props.justCreatedOpts}

View File

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useMemo, useState} from "react"; import React, {ReactNode, useMemo, useState} from "react";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClient} from "matrix-js-sdk/src/client"; import {MatrixClient} from "matrix-js-sdk/src/client";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
@ -24,7 +24,7 @@ import {sortBy} from "lodash";
import {MatrixClientPeg} from "../../MatrixClientPeg"; import {MatrixClientPeg} from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import {_t} from "../../languageHandler"; import {_t} from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseDialog from "../views/dialogs/BaseDialog"; import BaseDialog from "../views/dialogs/BaseDialog";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import SearchBox from "./SearchBox"; import SearchBox from "./SearchBox";
@ -39,11 +39,14 @@ import {mediaFromMxc} from "../../customisations/Media";
import InfoTooltip from "../views/elements/InfoTooltip"; import InfoTooltip from "../views/elements/InfoTooltip";
import TextWithTooltip from "../views/elements/TextWithTooltip"; import TextWithTooltip from "../views/elements/TextWithTooltip";
import {useStateToggle} from "../../hooks/useStateToggle"; import {useStateToggle} from "../../hooks/useStateToggle";
import {getOrder} from "../../stores/SpaceStore";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
interface IHierarchyProps { interface IHierarchyProps {
space: Room; space: Room;
initialText?: string; initialText?: string;
refreshToken?: any; refreshToken?: any;
additionalButtons?: ReactNode;
showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void; showRoom(room: ISpaceSummaryRoom, viaServers?: string[], autoJoin?: boolean): void;
} }
@ -106,8 +109,16 @@ const Tile: React.FC<ITileProps> = ({
const cliRoom = cli.getRoom(room.room_id); const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership(); const myMembership = cliRoom?.getMyMembership();
const onPreviewClick = () => onViewRoomClick(false); const onPreviewClick = (ev: ButtonEvent) => {
const onJoinClick = () => onViewRoomClick(true); ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(false);
}
const onJoinClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
onViewRoomClick(true);
}
let button; let button;
if (myMembership === "join") { if (myMembership === "join") {
@ -254,7 +265,11 @@ export const HierarchyLevel = ({
const space = cli.getRoom(spaceId); const space = cli.getRoom(spaceId);
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
const sortedChildren = sortBy([...(relations.get(spaceId)?.values() || [])], ev => ev.content.order || null); const children = Array.from(relations.get(spaceId)?.values() || []);
const sortedChildren = sortBy(children, ev => {
// XXX: Space Summary API doesn't give the child origin_server_ts but once it does we should use it for sorting
return getOrder(ev.content.order, null, ev.state_key);
});
const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => { const [subspaces, childRooms] = sortedChildren.reduce((result, ev: ISpaceSummaryEvent) => {
const roomId = ev.state_key; const roomId = ev.state_key;
if (!rooms.has(roomId)) return result; if (!rooms.has(roomId)) return result;
@ -350,6 +365,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
initialText = "", initialText = "",
showRoom, showRoom,
refreshToken, refreshToken,
additionalButtons,
children, children,
}) => { }) => {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -415,22 +431,31 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces }); countsStr = _t("%(count)s rooms", { count: numRooms, numSpaces });
} }
let editSection; let manageButtons;
if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) { if (space.getMyMembership() === "join" && space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const selectedRelations = Array.from(selected.keys()).flatMap(parentId => { const selectedRelations = Array.from(selected.keys()).flatMap(parentId => {
return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][]; return [...selected.get(parentId).values()].map(childId => [parentId, childId]) as [string, string][];
}); });
let buttons;
if (selectedRelations.length) {
const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => { const selectionAllSuggested = selectedRelations.every(([parentId, childId]) => {
return parentChildMap.get(parentId)?.get(childId)?.content.suggested; return parentChildMap.get(parentId)?.get(childId)?.content.suggested;
}); });
const disabled = removing || saving; const disabled = !selectedRelations.length || removing || saving;
buttons = <> let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
<AccessibleButton let props = {};
if (!selectedRelations.length) {
Button = AccessibleTooltipButton;
props = {
tooltip: _t("Select a room below first"),
yOffset: -40,
};
}
manageButtons = <>
<Button
{...props}
onClick={async () => { onClick={async () => {
setRemoving(true); setRemoving(true);
try { try {
@ -448,8 +473,9 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
disabled={disabled} disabled={disabled}
> >
{ removing ? _t("Removing...") : _t("Remove") } { removing ? _t("Removing...") : _t("Remove") }
</AccessibleButton> </Button>
<AccessibleButton <Button
{...props}
onClick={async () => { onClick={async () => {
setSaving(true); setSaving(true);
try { try {
@ -480,15 +506,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
? _t("Saving...") ? _t("Saving...")
: (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested")) : (selectionAllSuggested ? _t("Mark as not suggested") : _t("Mark as suggested"))
} }
</AccessibleButton> </Button>
</>; </>;
} }
editSection = <span>
{ buttons }
</span>;
}
let results; let results;
if (roomsMap.size) { if (roomsMap.size) {
const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId()); const hasPermissions = space?.currentState.maySendStateEvent(EventType.SpaceChild, cli.getUserId());
@ -532,7 +553,10 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
content = <> content = <>
<div className="mx_SpaceRoomDirectory_listHeader"> <div className="mx_SpaceRoomDirectory_listHeader">
{ countsStr } { countsStr }
{ editSection } <span>
{ additionalButtons }
{ manageButtons }
</span>
</div> </div>
{ error && <div className="mx_SpaceRoomDirectory_error"> { error && <div className="mx_SpaceRoomDirectory_error">
{ error } { error }

View File

@ -54,6 +54,16 @@ import FacePile from "../views/elements/FacePile";
import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog"; import {AddExistingToSpace} from "../views/dialogs/AddExistingToSpaceDialog";
import {sleep} from "../../utils/promise"; import {sleep} from "../../utils/promise";
import {calculateRoomVia} from "../../utils/permalinks/Permalinks"; import {calculateRoomVia} from "../../utils/permalinks/Permalinks";
import {ChevronFace, ContextMenuButton, useContextMenu} from "./ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../views/context_menus/IconizedContextMenu";
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import {BetaPill} from "../views/beta/BetaCard";
import {USER_LABS_TAB} from "../views/dialogs/UserSettingsDialog";
import SettingsStore from "../../settings/SettingsStore";
import dis from "../../dispatcher/dispatcher";
interface IProps { interface IProps {
space: Room; space: Room;
@ -130,15 +140,39 @@ const SpaceInfo = ({ space }) => {
</div> </div>
}; };
const onBetaClick = () => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: USER_LABS_TAB,
});
};
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => { const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space); const myMembership = useMyRoomMembership(space);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const spacesEnabled = SettingsStore.getValue("feature_spaces");
let inviterSection; let inviterSection;
let joinButtons; let joinButtons;
if (myMembership === "invite") { if (myMembership === "join") {
// XXX remove this when spaces leaves Beta
joinButtons = (
<AccessibleButton
kind="danger_outline"
onClick={() => {
dis.dispatch({
action: "leave_room",
room_id: space.roomId,
});
}}
>
{ _t("Leave") }
</AccessibleButton>
);
} else if (myMembership === "invite") {
const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender(); const inviteSender = space.getMember(cli.getUserId())?.events.member?.getSender();
const inviter = inviteSender && space.getMember(inviteSender); const inviter = inviteSender && space.getMember(inviteSender);
@ -174,6 +208,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true); setBusy(true);
onJoinButtonClicked(); onJoinButtonClicked();
}} }}
disabled={!spacesEnabled}
> >
{ _t("Accept") } { _t("Accept") }
</AccessibleButton> </AccessibleButton>
@ -186,10 +221,11 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
setBusy(true); setBusy(true);
onJoinButtonClicked(); onJoinButtonClicked();
}} }}
disabled={!spacesEnabled}
> >
{ _t("Join") } { _t("Join") }
</AccessibleButton> </AccessibleButton>
) );
} }
if (busy) { if (busy) {
@ -197,6 +233,7 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
} }
return <div className="mx_SpaceRoomView_preview"> return <div className="mx_SpaceRoomView_preview">
<BetaPill onClick={onBetaClick} />
{ inviterSection } { inviterSection }
<RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} /> <RoomAvatar room={space} height={80} width={80} viewAvatarOnClick={true} />
<h1 className="mx_SpaceRoomView_preview_name"> <h1 className="mx_SpaceRoomView_preview_name">
@ -214,9 +251,84 @@ const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }) =>
<div className="mx_SpaceRoomView_preview_joinButtons"> <div className="mx_SpaceRoomView_preview_joinButtons">
{ joinButtons } { joinButtons }
</div> </div>
{ !spacesEnabled && <div className="mx_SpaceRoomView_preview_spaceBetaPrompt">
{ myMembership === "join"
? _t("To view %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
})
: _t("To join %(spaceName)s, turn on the <a>Spaces beta</a>", {
spaceName: space.name,
}, {
a: sub => <AccessibleButton onClick={onBetaClick} kind="link">{ sub }</AccessibleButton>,
})
}
</div> }
</div>; </div>;
}; };
const SpaceLandingAddButton = ({ space, onNewRoomAdded }) => {
const cli = useContext(MatrixClientContext);
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
let contextMenu;
if (menuDisplayed) {
const rect = handle.current.getBoundingClientRect();
contextMenu = <IconizedContextMenu
left={rect.left + window.pageXOffset + 0}
top={rect.bottom + window.pageYOffset + 8}
chevronFace={ChevronFace.None}
onFinished={closeMenu}
className="mx_RoomTile_contextMenu"
compact
>
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("Create new room")}
iconClassName="mx_RoomList_iconPlus"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
if (await showCreateNewRoom(cli, space)) {
onNewRoomAdded();
}
}}
/>
<IconizedContextMenuOption
label={_t("Add existing room")}
iconClassName="mx_RoomList_iconHash"
onClick={async (e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
const [added] = await showAddExistingRooms(cli, space);
if (added) {
onNewRoomAdded();
}
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
}
return <>
<ContextMenuButton
kind="primary"
inputRef={handle}
onClick={openMenu}
isExpanded={menuDisplayed}
label={_t("Add")}
>
{ _t("Add") }
</ContextMenuButton>
{ contextMenu }
</>;
};
const SpaceLanding = ({ space }) => { const SpaceLanding = ({ space }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space); const myMembership = useMyRoomMembership(space);
@ -241,32 +353,20 @@ const SpaceLanding = ({ space }) => {
const [refreshToken, forceUpdate] = useStateToggle(false); const [refreshToken, forceUpdate] = useStateToggle(false);
let addRoomButtons; let addRoomButton;
if (canAddRooms) { if (canAddRooms) {
addRoomButtons = <React.Fragment> addRoomButton = <SpaceLandingAddButton space={space} onNewRoomAdded={forceUpdate} />;
<AccessibleButton className="mx_SpaceRoomView_landing_addButton" onClick={async () => {
const [added] = await showAddExistingRooms(cli, space);
if (added) {
forceUpdate();
}
}}>
{ _t("Add existing rooms & spaces") }
</AccessibleButton>
<AccessibleButton className="mx_SpaceRoomView_landing_createButton" onClick={() => {
showCreateNewRoom(cli, space);
}}>
{ _t("Create a new room") }
</AccessibleButton>
</React.Fragment>;
} }
let settingsButton; let settingsButton;
if (shouldShowSpaceSettings(cli, space)) { if (shouldShowSpaceSettings(cli, space)) {
settingsButton = <AccessibleButton className="mx_SpaceRoomView_landing_settingsButton" onClick={() => { settingsButton = <AccessibleTooltipButton
className="mx_SpaceRoomView_landing_settingsButton"
onClick={() => {
showSpaceSettings(cli, space); showSpaceSettings(cli, space);
}}> }}
{ _t("Settings") } title={_t("Settings")}
</AccessibleButton>; />;
} }
const onMembersClick = () => { const onMembersClick = () => {
@ -293,17 +393,19 @@ const SpaceLanding = ({ space }) => {
<SpaceInfo space={space} /> <SpaceInfo space={space} />
<FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} /> <FacePile room={space} onlyKnownUsers={false} numShown={7} onClick={onMembersClick} />
{ inviteButton } { inviteButton }
{ settingsButton }
</div> </div>
<div className="mx_SpaceRoomView_landing_topic"> <div className="mx_SpaceRoomView_landing_topic">
<RoomTopic room={space} /> <RoomTopic room={space} />
</div> </div>
<hr /> <hr />
<div className="mx_SpaceRoomView_landing_adminButtons">
{ addRoomButtons }
{ settingsButton }
</div>
<SpaceHierarchy space={space} showRoom={showRoom} refreshToken={refreshToken} /> <SpaceHierarchy
space={space}
showRoom={showRoom}
refreshToken={refreshToken}
additionalButtons={addRoomButton}
/>
</div>; </div>;
}; };
@ -328,7 +430,9 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
/>; />;
}); });
const onNextClick = async () => { const onNextClick = async (ev) => {
ev.preventDefault();
if (busy) return;
setError(""); setError("");
setBusy(true); setBusy(true);
try { try {
@ -353,7 +457,10 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
setBusy(false); setBusy(false);
}; };
let onClick = onFinished; let onClick = (ev) => {
ev.preventDefault();
onFinished();
};
let buttonLabel = _t("Skip for now"); let buttonLabel = _t("Skip for now");
if (roomNames.some(name => name.trim())) { if (roomNames.some(name => name.trim())) {
onClick = onNextClick; onClick = onNextClick;
@ -365,16 +472,20 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
<div className="mx_SpaceRoomView_description">{ description }</div> <div className="mx_SpaceRoomView_description">{ description }</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> } { error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<form onSubmit={onClick} id="mx_SpaceSetupFirstRooms">
{ fields } { fields }
</form>
<div className="mx_SpaceRoomView_buttons"> <div className="mx_SpaceRoomView_buttons">
<AccessibleButton <AccessibleButton
kind="primary" kind="primary"
disabled={busy} disabled={busy}
onClick={onClick} onClick={onClick}
> element="input"
{ buttonLabel } type="submit"
</AccessibleButton> form="mx_SpaceSetupFirstRooms"
value={buttonLabel}
/>
</div> </div>
</div>; </div>;
}; };
@ -447,9 +558,11 @@ const SpaceAddExistingRooms = ({ space, onFinished }) => {
</div>; </div>;
}; };
const SpaceSetupPublicShare = ({ space, onFinished }) => { const SpaceSetupPublicShare = ({ justCreatedOpts, space, onFinished }) => {
return <div className="mx_SpaceRoomView_publicShare"> return <div className="mx_SpaceRoomView_publicShare">
<h1>{ _t("Share %(name)s", { name: space.name }) }</h1> <h1>{ _t("Share %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }</h1>
<div className="mx_SpaceRoomView_description"> <div className="mx_SpaceRoomView_description">
{ _t("It's just you at the moment, it will be even better with others.") } { _t("It's just you at the moment, it will be even better with others.") }
</div> </div>
@ -464,11 +577,13 @@ const SpaceSetupPublicShare = ({ space, onFinished }) => {
</div>; </div>;
}; };
const SpaceSetupPrivateScope = ({ space, onFinished }) => { const SpaceSetupPrivateScope = ({ space, justCreatedOpts, onFinished }) => {
return <div className="mx_SpaceRoomView_privateScope"> return <div className="mx_SpaceRoomView_privateScope">
<h1>{ _t("Who are you working with?") }</h1> <h1>{ _t("Who are you working with?") }</h1>
<div className="mx_SpaceRoomView_description"> <div className="mx_SpaceRoomView_description">
{ _t("Make sure the right people have access to %(name)s", { name: space.name }) } { _t("Make sure the right people have access to %(name)s", {
name: justCreatedOpts?.createOpts?.name || space.name,
}) }
</div> </div>
<AccessibleButton <AccessibleButton
@ -518,7 +633,9 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
/>; />;
}); });
const onNextClick = async () => { const onNextClick = async (ev) => {
ev.preventDefault();
if (busy) return;
setError(""); setError("");
for (let i = 0; i < fieldRefs.length; i++) { for (let i = 0; i < fieldRefs.length; i++) {
const fieldRef = fieldRefs[i]; const fieldRef = fieldRefs[i];
@ -552,7 +669,10 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
setBusy(false); setBusy(false);
}; };
let onClick = onFinished; let onClick = (ev) => {
ev.preventDefault();
onFinished();
};
let buttonLabel = _t("Skip for now"); let buttonLabel = _t("Skip for now");
if (emailAddresses.some(name => name.trim())) { if (emailAddresses.some(name => name.trim())) {
onClick = onNextClick; onClick = onNextClick;
@ -565,8 +685,21 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
{ _t("Make sure the right people have access. You can invite more later.") } { _t("Make sure the right people have access. You can invite more later.") }
</div> </div>
<div className="mx_SpaceRoomView_inviteTeammates_betaDisclaimer">
<BetaPill onClick={onBetaClick} />
{ _t("<b>This is an experimental feature.</b> For now, " +
"new users receiving an invite will have to open the invite on <link/> to actually join.", {}, {
b: sub => <b>{ sub }</b>,
link: () => <a href="https://app.element.io/" rel="noreferrer noopener" target="_blank">
app.element.io
</a>,
}) }
</div>
{ error && <div className="mx_SpaceRoomView_errorText">{ error }</div> } { error && <div className="mx_SpaceRoomView_errorText">{ error }</div> }
<form onSubmit={onClick} id="mx_SpaceSetupPrivateInvite">
{ fields } { fields }
</form>
<div className="mx_SpaceRoomView_inviteTeammates_buttons"> <div className="mx_SpaceRoomView_inviteTeammates_buttons">
<AccessibleButton <AccessibleButton
@ -578,9 +711,15 @@ const SpaceSetupPrivateInvite = ({ space, onFinished }) => {
</div> </div>
<div className="mx_SpaceRoomView_buttons"> <div className="mx_SpaceRoomView_buttons">
<AccessibleButton kind="primary" disabled={busy} onClick={onClick}> <AccessibleButton
{ buttonLabel } kind="primary"
</AccessibleButton> disabled={busy}
onClick={onClick}
element="input"
type="submit"
form="mx_SpaceSetupPrivateInvite"
value={buttonLabel}
/>
</div> </div>
</div>; </div>;
}; };
@ -702,7 +841,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") { if (this.state.myMembership === "join" && SettingsStore.getValue("feature_spaces")) {
return <SpaceLanding space={this.props.space} />; return <SpaceLanding space={this.props.space} />;
} else { } else {
return <SpacePreview return <SpacePreview
@ -715,7 +854,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
return <SpaceSetupFirstRooms return <SpaceSetupFirstRooms
space={this.props.space} space={this.props.space}
title={_t("What are some things you want to discuss in %(spaceName)s?", { title={_t("What are some things you want to discuss in %(spaceName)s?", {
spaceName: this.props.space.name, spaceName: this.props.justCreatedOpts?.createOpts?.name || this.props.space.name,
})} })}
description={ description={
_t("Let's create a room for each of them.") + "\n" + _t("Let's create a room for each of them.") + "\n" +
@ -724,11 +863,16 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
onFinished={() => this.setState({ phase: Phase.PublicShare })} onFinished={() => this.setState({ phase: Phase.PublicShare })}
/>; />;
case Phase.PublicShare: case Phase.PublicShare:
return <SpaceSetupPublicShare space={this.props.space} onFinished={this.goToFirstRoom} />; return <SpaceSetupPublicShare
justCreatedOpts={this.props.justCreatedOpts}
space={this.props.space}
onFinished={this.goToFirstRoom}
/>;
case Phase.PrivateScope: case Phase.PrivateScope:
return <SpaceSetupPrivateScope return <SpaceSetupPrivateScope
space={this.props.space} space={this.props.space}
justCreatedOpts={this.props.justCreatedOpts}
onFinished={(invite: boolean) => { onFinished={(invite: boolean) => {
this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms }); this.setState({ phase: invite ? Phase.PrivateInvite : Phase.PrivateExistingRooms });
}} }}

View File

@ -0,0 +1,90 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import {_t} from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import SettingsStore from "../../../settings/SettingsStore";
import {SettingLevel} from "../../../settings/SettingLevel";
import TextWithTooltip from "../elements/TextWithTooltip";
interface IProps {
title?: string;
featureId: string;
}
export const BetaPill = ({ onClick }: { onClick?: () => void }) => {
if (onClick) {
return <TextWithTooltip
class={classNames("mx_BetaCard_betaPill", {
mx_BetaCard_betaPill_clickable: !!onClick,
})}
tooltip={<div>
<div className="mx_Tooltip_title">
{ _t("Spaces is a beta feature") }
</div>
<div className="mx_Tooltip_sub">
{ _t("Tap for more info") }
</div>
</div>}
onClick={onClick}
tooltipProps={{ yOffset: -10 }}
>
{ _t("Beta") }
</TextWithTooltip>;
}
return <span
className={classNames("mx_BetaCard_betaPill", {
mx_BetaCard_betaPill_clickable: !!onClick,
})}
onClick={onClick}
>
{ _t("Beta") }
</span>;
};
const BetaCard = ({ title: titleOverride, featureId }: IProps) => {
const info = SettingsStore.getBetaInfo(featureId);
if (!info) return null; // Beta is invalid/disabled
const { title, caption, disclaimer, image } = info;
const value = SettingsStore.getValue(featureId);
return <div className="mx_BetaCard">
<div>
<h3 className="mx_BetaCard_title">
{ titleOverride || _t(title) }
<BetaPill />
</h3>
<span className="mx_BetaCard_caption">{ _t(caption) }</span>
<AccessibleButton
onClick={() => SettingsStore.setValue(featureId, null, SettingLevel.DEVICE, !value)}
kind="primary"
>
{ value ? _t("Leave the beta") : _t("Join the beta") }
</AccessibleButton>
{ disclaimer && <div className="mx_BetaCard_disclaimer">
{ disclaimer(value) }
</div> }
</div>
<img src={image} alt="" />
</div>;
};
export default BetaCard;

View File

@ -78,8 +78,10 @@ export default class MessageContextMenu extends React.Component {
// We explicitly decline to show the redact option on ACL events as it has a potential // We explicitly decline to show the redact option on ACL events as it has a potential
// to obliterate the room - https://github.com/matrix-org/synapse/issues/4042 // to obliterate the room - https://github.com/matrix-org/synapse/issues/4042
// Similarly for encryption events, since redacting them "breaks everything"
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl; && this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality

View File

@ -1312,7 +1312,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
goButtonFn = this._startDm; goButtonFn = this._startDm;
} 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 = room?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && 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

@ -1,5 +1,5 @@
/* /*
Copyright 2019 New Vector Ltd Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
@ -16,20 +16,23 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import filesize from "filesize"; import filesize from "filesize";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getBlobSafeMimeType } from '../../../utils/blobs';
interface IProps {
file: File;
currentIndex: number;
totalFiles?: number;
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
}
@replaceableComponent("views.dialogs.UploadConfirmDialog") @replaceableComponent("views.dialogs.UploadConfirmDialog")
export default class UploadConfirmDialog extends React.Component { export default class UploadConfirmDialog extends React.Component<IProps> {
static propTypes = { private objectUrl: string;
file: PropTypes.object.isRequired, private mimeType: string;
currentIndex: PropTypes.number,
totalFiles: PropTypes.number,
onFinished: PropTypes.func.isRequired,
}
static defaultProps = { static defaultProps = {
totalFiles: 1, totalFiles: 1,
@ -38,22 +41,28 @@ export default class UploadConfirmDialog extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
this._objectUrl = URL.createObjectURL(props.file); // Create a fresh `Blob` for previewing (even though `File` already is
// one) so we can adjust the MIME type if needed.
this.mimeType = getBlobSafeMimeType(props.file.type);
const blob = new Blob([props.file], { type:
this.mimeType,
});
this.objectUrl = URL.createObjectURL(blob);
} }
componentWillUnmount() { componentWillUnmount() {
if (this._objectUrl) URL.revokeObjectURL(this._objectUrl); if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
} }
_onCancelClick = () => { private onCancelClick = () => {
this.props.onFinished(false); this.props.onFinished(false);
} }
_onUploadClick = () => { private onUploadClick = () => {
this.props.onFinished(true); this.props.onFinished(true);
} }
_onUploadAllClick = () => { private onUploadAllClick = () => {
this.props.onFinished(true, true); this.props.onFinished(true, true);
} }
@ -75,10 +84,10 @@ export default class UploadConfirmDialog extends React.Component {
} }
let preview; let preview;
if (this.props.file.type.startsWith('image/')) { if (this.mimeType.startsWith('image/')) {
preview = <div className="mx_UploadConfirmDialog_previewOuter"> preview = <div className="mx_UploadConfirmDialog_previewOuter">
<div className="mx_UploadConfirmDialog_previewInner"> <div className="mx_UploadConfirmDialog_previewInner">
<div><img className="mx_UploadConfirmDialog_imagePreview" src={this._objectUrl} /></div> <div><img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} /></div>
<div>{this.props.file.name} ({filesize(this.props.file.size)})</div> <div>{this.props.file.name} ({filesize(this.props.file.size)})</div>
</div> </div>
</div>; </div>;
@ -95,7 +104,7 @@ export default class UploadConfirmDialog extends React.Component {
let uploadAllButton; let uploadAllButton;
if (this.props.currentIndex + 1 < this.props.totalFiles) { if (this.props.currentIndex + 1 < this.props.totalFiles) {
uploadAllButton = <button onClick={this._onUploadAllClick}> uploadAllButton = <button onClick={this.onUploadAllClick}>
{_t("Upload all")} {_t("Upload all")}
</button>; </button>;
} }
@ -103,7 +112,7 @@ export default class UploadConfirmDialog extends React.Component {
return ( return (
<BaseDialog className='mx_UploadConfirmDialog' <BaseDialog className='mx_UploadConfirmDialog'
fixedWidth={false} fixedWidth={false}
onFinished={this._onCancelClick} onFinished={this.onCancelClick}
title={title} title={title}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
> >
@ -113,7 +122,7 @@ export default class UploadConfirmDialog extends React.Component {
<DialogButtons primaryButton={_t('Upload')} <DialogButtons primaryButton={_t('Upload')}
hasCancel={false} hasCancel={false}
onPrimaryButtonClick={this._onUploadClick} onPrimaryButtonClick={this.onUploadClick}
focus={true} focus={true}
> >
{uploadAllButton} {uploadAllButton}

View File

@ -125,7 +125,10 @@ export default class UserSettingsDialog extends React.Component {
"mx_UserSettingsDialog_securityIcon", "mx_UserSettingsDialog_securityIcon",
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />, <SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
)); ));
if (SdkConfig.get()['showLabsSettings']) { // Show the Labs tab if enabled or if there are any active betas
if (SdkConfig.get()['showLabsSettings']
|| SettingsStore.getFeatureSettingNames().some(k => SettingsStore.getBetaInfo(k))
) {
tabs.push(new Tab( tabs.push(new Tab(
USER_LABS_TAB, USER_LABS_TAB,
_td("Labs"), _td("Labs"),

View File

@ -49,6 +49,18 @@ const inPlaceOf = (elementRect) => ({
}); });
const validServer = withValidation({ const validServer = withValidation({
deriveData: async ({ value }) => {
try {
// check if we can successfully load this server's room directory
await MatrixClientPeg.get().publicRooms({
limit: 1,
server: value,
});
return {};
} catch (error) {
return { error };
}
},
rules: [ rules: [
{ {
key: "required", key: "required",
@ -57,21 +69,11 @@ const validServer = withValidation({
}, { }, {
key: "available", key: "available",
final: true, final: true,
test: async ({ value }) => { test: async (_, { error }) => !error,
try {
const opts = {
limit: 1,
server: value,
};
// check if we can successfully load this server's room directory
await MatrixClientPeg.get().publicRooms(opts);
return true;
} catch (e) {
return false;
}
},
valid: () => _t("Looks good"), valid: () => _t("Looks good"),
invalid: () => _t("Can't find this server or its room list"), invalid: ({ error }) => error.errcode === "M_FORBIDDEN"
? _t("You are not allowed to view this server's rooms list")
: _t("Can't find this server or its room list"),
}, },
], ],
}); });

View File

@ -32,6 +32,7 @@ export default class ActionButton extends React.Component {
label: PropTypes.string.isRequired, label: PropTypes.string.isRequired,
iconPath: PropTypes.string, iconPath: PropTypes.string,
className: PropTypes.string, className: PropTypes.string,
children: PropTypes.node,
}; };
static defaultProps = { static defaultProps = {
@ -79,7 +80,8 @@ export default class ActionButton extends React.Component {
} }
return ( return (
<AccessibleButton className={classNames.join(" ")} <AccessibleButton
className={classNames.join(" ")}
onClick={this._onClick} onClick={this._onClick}
onMouseEnter={this._onMouseEnter} onMouseEnter={this._onMouseEnter}
onMouseLeave={this._onMouseLeave} onMouseLeave={this._onMouseLeave}
@ -87,6 +89,7 @@ export default class ActionButton extends React.Component {
> >
{ icon } { icon }
{ tooltip } { tooltip }
{ this.props.children }
</AccessibleButton> </AccessibleButton>
); );
} }

View File

@ -108,8 +108,6 @@ export default class ImageView extends React.Component<IProps, IState> {
window.addEventListener("resize", this.calculateZoom); window.addEventListener("resize", this.calculateZoom);
// After the image loads for the first time we want to calculate the zoom // After the image loads for the first time we want to calculate the zoom
this.image.current.addEventListener("load", this.calculateZoom); this.image.current.addEventListener("load", this.calculateZoom);
// Try to precalculate the zoom from width and height props
this.calculateZoom();
} }
componentWillUnmount() { componentWillUnmount() {
@ -122,11 +120,8 @@ export default class ImageView extends React.Component<IProps, IState> {
const image = this.image.current; const image = this.image.current;
const imageWrapper = this.imageWrapper.current; const imageWrapper = this.imageWrapper.current;
const width = this.props.width || image.naturalWidth; const zoomX = imageWrapper.clientWidth / image.naturalWidth;
const height = this.props.height || image.naturalHeight; const zoomY = imageWrapper.clientHeight / image.naturalHeight;
const zoomX = imageWrapper.clientWidth / width;
const zoomY = imageWrapper.clientHeight / height;
// If the image is smaller in both dimensions set its the zoom to 1 to // If the image is smaller in both dimensions set its the zoom to 1 to
// display it in its original size // display it in its original size

View File

@ -187,9 +187,15 @@ function DeviceItem({userId, device}: {userId: string, device: IDevice}) {
verifyDevice(cli.getUser(userId), device); verifyDevice(cli.getUser(userId), device);
}; };
const deviceName = device.ambiguous ? let deviceName;
(device.getDisplayName() ? device.getDisplayName() : "") + " (" + device.deviceId + ")" : if (!device.getDisplayName()?.trim()) {
deviceName = device.deviceId;
} else {
deviceName = device.ambiguous ?
device.getDisplayName() + " (" + device.deviceId + ")" :
device.getDisplayName(); device.getDisplayName();
}
let trustedLabel = null; let trustedLabel = null;
if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted"); if (userTrust.isVerified()) trustedLabel = isVerified ? _t("Trusted") : _t("Not trusted");
@ -440,7 +446,7 @@ const UserOptionsSection: React.FC<{
); );
}; };
const warnSelfDemote = async (isSpace) => { const warnSelfDemote = async (isSpace: boolean) => {
const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, { const {finished} = Modal.createTrackedDialog('Demoting Self', '', QuestionDialog, {
title: _t("Demote yourself?"), title: _t("Demote yourself?"),
description: description:
@ -727,7 +733,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(room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && 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;
@ -816,7 +822,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 && !room.isSpaceRoom()) { if (me.powerLevel >= redactPowerLevel && (!SettingsStore.getValue("feature_spaces") || !room.isSpaceRoom())) {
redactButton = ( redactButton = (
<RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} /> <RedactMessagesButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
); );
@ -1095,7 +1101,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(room?.isSpaceRoom()))) return; if (!(await warnSelfDemote(SettingsStore.getValue("feature_spaces") && 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);
} }
@ -1325,10 +1331,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 && !room.isSpaceRoom()) { } else if (room && (!SettingsStore.getValue("feature_spaces") || !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 (!room.isSpaceRoom()) { } else if (!SettingsStore.getValue("feature_spaces") || !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 +1411,7 @@ const BasicUserInfo: React.FC<{
canInvite={roomPermissions.canInvite} canInvite={roomPermissions.canInvite}
isIgnored={isIgnored} isIgnored={isIgnored}
member={member} member={member}
isSpace={room?.isSpaceRoom()} isSpace={SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()}
/> />
{ adminToolsContainer } { adminToolsContainer }
@ -1567,7 +1573,7 @@ const UserInfo: React.FC<Props> = ({
previousPhase = RightPanelPhases.RoomMemberInfo; previousPhase = RightPanelPhases.RoomMemberInfo;
refireParams = {member: member}; refireParams = {member: member};
} else if (room) { } else if (room) {
previousPhase = previousPhase = room.isSpaceRoom() previousPhase = previousPhase = SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()
? RightPanelPhases.SpaceMemberList ? RightPanelPhases.SpaceMemberList
: RightPanelPhases.RoomMemberList; : RightPanelPhases.RoomMemberList;
} }
@ -1616,7 +1622,7 @@ const UserInfo: React.FC<Props> = ({
} }
let scopeHeader; let scopeHeader;
if (room?.isSpaceRoom()) { if (SettingsStore.getValue("feature_spaces") && 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

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import {_t} from '../../../languageHandler'; import {_t, _td} from '../../../languageHandler';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import EditorModel from '../../../editor/model'; import EditorModel from '../../../editor/model';
@ -24,16 +24,18 @@ import {getCaretOffsetAndText} from '../../../editor/dom';
import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize'; import {htmlSerializeIfNeeded, textSerialize, containsEmote, stripEmoteCommand} from '../../../editor/serialize';
import {findEditableEvent} from '../../../utils/EventUtils'; import {findEditableEvent} from '../../../utils/EventUtils';
import {parseEvent} from '../../../editor/deserialize'; import {parseEvent} from '../../../editor/deserialize';
import {PartCreator} from '../../../editor/parts'; import {CommandPartCreator} from '../../../editor/parts';
import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../../utils/EditorStateTransfer';
import classNames from 'classnames'; import classNames from 'classnames';
import {EventStatus} from 'matrix-js-sdk/src/models/event'; import {EventStatus} from 'matrix-js-sdk/src/models/event';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {CommandCategories, getCommand} from '../../../SlashCommands';
import {Action} from "../../../dispatcher/actions"; import {Action} from "../../../dispatcher/actions";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager'; import {getKeyBindingsManager, MessageComposerAction} from '../../../KeyBindingsManager';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import Modal from '../../../Modal';
function _isReply(mxEvent) { function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"]; const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -178,6 +180,22 @@ export default class EditMessageComposer extends React.Component {
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
} }
_isSlashCommand() {
const parts = this.model.parts;
const firstPart = parts[0];
if (firstPart) {
if (firstPart.type === "command" && firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")) {
return true;
}
if (firstPart.text.startsWith("/") && !firstPart.text.startsWith("//")
&& (firstPart.type === "plain" || firstPart.type === "pill-candidate")) {
return true;
}
}
return false;
}
_isContentModified(newContent) { _isContentModified(newContent) {
// if nothing has changed then bail // if nothing has changed then bail
const oldContent = this.props.editState.getEvent().getContent(); const oldContent = this.props.editState.getEvent().getContent();
@ -190,20 +208,113 @@ export default class EditMessageComposer extends React.Component {
return true; return true;
} }
_sendEdit = () => { _getSlashCommand() {
const commandText = this.model.parts.reduce((text, part) => {
// use mxid to textify user pills in a command
if (part.type === "user-pill") {
return text + part.resourceId;
}
return text + part.text;
}, "");
const {cmd, args} = getCommand(commandText);
return [cmd, args, commandText];
}
async _runSlashCommand(cmd, args, roomId) {
const result = cmd.run(roomId, args);
let messageContent;
let error = result.error;
if (result.promise) {
try {
if (cmd.category === CommandCategories.messages) {
messageContent = await result.promise;
} else {
await result.promise;
}
} catch (err) {
error = err;
}
}
if (error) {
console.error("Command failure: %s", error);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
// assume the error is a server error when the command is async
const isServerError = !!result.promise;
const title = isServerError ? _td("Server error") : _td("Command error");
let errText;
if (typeof error === 'string') {
errText = error;
} else if (error.message) {
errText = error.message;
} else {
errText = _t("Server unavailable, overloaded, or something else went wrong.");
}
Modal.createTrackedDialog(title, '', ErrorDialog, {
title: _t(title),
description: errText,
});
} else {
console.log("Command success.");
if (messageContent) return messageContent;
}
}
_sendEdit = async () => {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
const editedEvent = this.props.editState.getEvent(); const editedEvent = this.props.editState.getEvent();
const editContent = createEditContent(this.model, editedEvent); const editContent = createEditContent(this.model, editedEvent);
const newContent = editContent["m.new_content"]; const newContent = editContent["m.new_content"];
let shouldSend = true;
// If content is modified then send an updated event into the room // If content is modified then send an updated event into the room
if (this._isContentModified(newContent)) { if (this._isContentModified(newContent)) {
const roomId = editedEvent.getRoomId(); const roomId = editedEvent.getRoomId();
if (!containsEmote(this.model) && this._isSlashCommand()) {
const [cmd, args, commandText] = this._getSlashCommand();
if (cmd) {
if (cmd.category === CommandCategories.messages) {
editContent["m.new_content"] = await this._runSlashCommand(cmd, args, roomId);
} else {
this._runSlashCommand(cmd, args, roomId);
shouldSend = false;
}
} else {
// ask the user if their unknown command should be sent as a message
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Unknown command", "", QuestionDialog, {
title: _t("Unknown Command"),
description: <div>
<p>
{ _t("Unrecognised command: %(commandText)s", {commandText}) }
</p>
<p>
{ _t("You can use <code>/help</code> to list available commands. " +
"Did you mean to send this as a message?", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
<p>
{ _t("Hint: Begin your message with <code>//</code> to start it with a slash.", {}, {
code: t => <code>{ t }</code>,
}) }
</p>
</div>,
button: _t('Send as message'),
});
const [sendAnyway] = await finished;
// if !sendAnyway bail to let the user edit the composer and try again
if (!sendAnyway) return;
}
}
if (shouldSend) {
this._cancelPreviousPendingEdit(); this._cancelPreviousPendingEdit();
const prom = this.context.sendMessage(roomId, editContent); const prom = this.context.sendMessage(roomId, editContent);
dis.dispatch({action: "message_sent"}); dis.dispatch({action: "message_sent"});
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, true, false, editContent);
} }
}
// close the event editing and focus composer // close the event editing and focus composer
dis.dispatch({action: "edit_event", event: null}); dis.dispatch({action: "edit_event", event: null});
@ -240,7 +351,7 @@ export default class EditMessageComposer extends React.Component {
_createEditorModel() { _createEditorModel() {
const {editState} = this.props; const {editState} = this.props;
const room = this._getRoom(); const room = this._getRoom();
const partCreator = new PartCreator(room, this.context); const partCreator = new CommandPartCreator(room, this.context);
let parts; let parts;
if (editState.hasEditorState()) { if (editState.hasEditorState()) {
// if restoring state from a previous editor, // if restoring state from a previous editor,

View File

@ -30,6 +30,7 @@ import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
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";
const INITIAL_LOAD_NUM_MEMBERS = 30; const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5; const INITIAL_LOAD_NUM_INVITED = 5;
@ -460,7 +461,7 @@ export default class MemberList extends React.Component {
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 (room.isSpaceRoom()) { } else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space"); inviteButtonText = _t("Invite to this space");
} }
@ -492,7 +493,7 @@ export default class MemberList extends React.Component {
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 (room?.isSpaceRoom()) { if (SettingsStore.getValue("feature_spaces") && 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

@ -26,6 +26,7 @@ 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";
interface IProps { interface IProps {
event: MatrixEvent; event: MatrixEvent;
@ -135,7 +136,7 @@ export default class ThirdPartyMemberInfo extends React.Component<IProps, IState
} }
let scopeHeader; let scopeHeader;
if (this.room.isSpaceRoom()) { if (SettingsStore.getValue("feature_spaces") && 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

@ -22,6 +22,8 @@ import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import * as sdk from "../../../../../index"; import * as sdk from "../../../../../index";
import {SettingLevel} from "../../../../../settings/SettingLevel"; import {SettingLevel} from "../../../../../settings/SettingLevel";
import {replaceableComponent} from "../../../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../../../utils/replaceableComponent";
import SdkConfig from "../../../../../SdkConfig";
import BetaCard from "../../../beta/BetaCard";
export class LabsSettingToggle extends React.Component { export class LabsSettingToggle extends React.Component {
static propTypes = { static propTypes = {
@ -48,14 +50,40 @@ export default class LabsUserSettingsTab extends React.Component {
} }
render() { render() {
const features = SettingsStore.getFeatureSettingNames();
const [labs, betas] = features.reduce((arr, f) => {
arr[SettingsStore.getBetaInfo(f) ? 1 : 0].push(f);
return arr;
}, [[], []]);
let betaSection;
if (betas.length) {
betaSection = <div className="mx_SettingsTab_section">
{ betas.map(f => <BetaCard key={f} featureId={f} /> ) }
</div>;
}
let labsSection;
if (SdkConfig.get()['showLabsSettings']) {
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag"); const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
const flags = SettingsStore.getFeatureSettingNames().map(f => <LabsSettingToggle featureId={f} key={f} />); const flags = labs.map(f => <LabsSettingToggle featureId={f} key={f} />);
labsSection = <div className="mx_SettingsTab_section">
{flags}
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
<SettingsFlag name="lowBandwidth" level={SettingLevel.DEVICE} />
<SettingsFlag name="advancedRoomListLogging" level={SettingLevel.DEVICE} />
</div>;
}
return ( return (
<div className="mx_SettingsTab"> <div className="mx_SettingsTab mx_LabsUserSettingsTab">
<div className="mx_SettingsTab_heading">{_t("Labs")}</div> <div className="mx_SettingsTab_heading">{_t("Labs")}</div>
<div className='mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_subsectionText'>
{ {
_t('Customise your experience with experimental labs features. ' + _t('Feeling experimental? Labs are the best way to get things early, ' +
'test out new features and help shape them before they actually launch. ' +
'<a>Learn more</a>.', {}, { '<a>Learn more</a>.', {}, {
'a': (sub) => { 'a': (sub) => {
return <a href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md" return <a href="https://github.com/vector-im/element-web/blob/develop/docs/labs.md"
@ -64,13 +92,8 @@ export default class LabsUserSettingsTab extends React.Component {
}) })
} }
</div> </div>
<div className="mx_SettingsTab_section"> { betaSection }
{flags} { labsSection }
<SettingsFlag name={"enableWidgetScreenshots"} level={SettingLevel.ACCOUNT} />
<SettingsFlag name={"showHiddenEventsInTimeline"} level={SettingLevel.DEVICE} />
<SettingsFlag name={"lowBandwidth"} level={SettingLevel.DEVICE} />
<SettingsFlag name={"advancedRoomListLogging"} level={SettingLevel.DEVICE} />
</div>
</div> </div>
); );
} }

View File

@ -32,17 +32,11 @@ interface IProps {
setTopic(topic: string): void; setTopic(topic: string): void;
} }
const SpaceBasicSettings = ({ export const SpaceAvatar = ({
avatarUrl, avatarUrl,
avatarDisabled = false, avatarDisabled = false,
setAvatar, setAvatar,
name = "", }: Pick<IProps, "avatarUrl" | "avatarDisabled" | "setAvatar">) => {
nameDisabled = false,
setName,
topic = "",
topicDisabled = false,
setTopic,
}: IProps) => {
const avatarUploadRef = useRef<HTMLInputElement>(); const avatarUploadRef = useRef<HTMLInputElement>();
const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache const [avatar, setAvatarDataUrl] = useState(avatarUrl); // avatar data url cache
@ -81,8 +75,7 @@ const SpaceBasicSettings = ({
} }
} }
return <div className="mx_SpaceBasicSettings"> return <div className="mx_SpaceBasicSettings_avatarContainer">
<div className="mx_SpaceBasicSettings_avatarContainer">
{ avatarSection } { avatarSection }
<input type="file" ref={avatarUploadRef} onChange={(e) => { <input type="file" ref={avatarUploadRef} onChange={(e) => {
if (!e.target.files?.length) return; if (!e.target.files?.length) return;
@ -94,7 +87,22 @@ const SpaceBasicSettings = ({
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
}} accept="image/*" /> }} accept="image/*" />
</div> </div>;
};
const SpaceBasicSettings = ({
avatarUrl,
avatarDisabled = false,
setAvatar,
name = "",
nameDisabled = false,
setName,
topic = "",
topicDisabled = false,
setTopic,
}: IProps) => {
return <div className="mx_SpaceBasicSettings">
<SpaceAvatar avatarUrl={avatarUrl} avatarDisabled={avatarDisabled} setAvatar={setAvatar} />
<Field <Field
name="spaceName" name="spaceName"

View File

@ -14,18 +14,24 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useContext, useState} from "react"; import React, {useContext, useRef, useState} from "react";
import classNames from "classnames"; import classNames from "classnames";
import {EventType, RoomType, RoomCreateTypeField} from "matrix-js-sdk/src/@types/event"; import {EventType, RoomType, RoomCreateTypeField} from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {ChevronFace, ContextMenu} from "../../structures/ContextMenu"; import {ChevronFace, ContextMenu} from "../../structures/ContextMenu";
import createRoom, {IStateEvent, Preset} from "../../../createRoom"; import createRoom, {IStateEvent, Preset} from "../../../createRoom";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import SpaceBasicSettings from "./SpaceBasicSettings"; import {SpaceAvatar} from "./SpaceBasicSettings";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import FocusLock from "react-focus-lock"; import {BetaPill} from "../beta/BetaCard";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import {Action} from "../../../dispatcher/actions";
import {USER_LABS_TAB} from "../dialogs/UserSettingsDialog";
import Field from "../elements/Field";
import withValidation from "../elements/Validation";
const SpaceCreateMenuType = ({ title, description, className, onClick }) => { const SpaceCreateMenuType = ({ title, description, className, onClick }) => {
return ( return (
@ -41,17 +47,39 @@ enum Visibility {
Private, Private,
} }
const spaceNameValidator = withValidation({
rules: [
{
key: "required",
test: async ({ value }) => !!value,
invalid: () => _t("Please enter a name for the space"),
},
],
});
const SpaceCreateMenu = ({ onFinished }) => { const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null); const [visibility, setVisibility] = useState<Visibility>(null);
const [name, setName] = useState("");
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
const onSpaceCreateClick = async () => { const [name, setName] = useState("");
const spaceNameField = useRef<Field>();
const [avatar, setAvatar] = useState<File>(null);
const [topic, setTopic] = useState<string>("");
const onSpaceCreateClick = async (e) => {
e.preventDefault();
if (busy) return; if (busy) return;
setBusy(true); setBusy(true);
// require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
const initialState: IStateEvent[] = [ const initialState: IStateEvent[] = [
{ {
type: EventType.RoomHistoryVisibility, type: EventType.RoomHistoryVisibility,
@ -107,7 +135,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
if (visibility === null) { if (visibility === null) {
body = <React.Fragment> body = <React.Fragment>
<h2>{ _t("Create a space") }</h2> <h2>{ _t("Create a space") }</h2>
<p>{ _t("Spaces are new ways to group rooms and people. " + <p>{ _t("Spaces are a new way to group rooms and people. " +
"To join an existing space you'll need an invite.") }</p> "To join an existing space you'll need an invite.") }</p>
<SpaceCreateMenuType <SpaceCreateMenuType
@ -146,9 +174,30 @@ const SpaceCreateMenu = ({ onFinished }) => {
} }
</p> </p>
<SpaceBasicSettings setAvatar={setAvatar} name={name} setName={setName} topic={topic} setTopic={setTopic} /> <form className="mx_SpaceBasicSettings" onSubmit={onSpaceCreateClick}>
<SpaceAvatar setAvatar={setAvatar} />
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={!name || busy}> <Field
name="spaceName"
label={_t("Name")}
autoFocus={true}
value={name}
onChange={ev => setName(ev.target.value)}
ref={spaceNameField}
onValidate={spaceNameValidator}
/>
<Field
name="spaceTopic"
element="textarea"
label={_t("Description")}
value={topic}
onChange={ev => setTopic(ev.target.value)}
rows={3}
/>
</form>
<AccessibleButton kind="primary" onClick={onSpaceCreateClick} disabled={busy}>
{ busy ? _t("Creating...") : _t("Create") } { busy ? _t("Creating...") : _t("Create") }
</AccessibleButton> </AccessibleButton>
</React.Fragment>; </React.Fragment>;
@ -164,6 +213,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
managed={false} managed={false}
> >
<FocusLock returnFocus={true}> <FocusLock returnFocus={true}>
<BetaPill onClick={() => {
onFinished();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: USER_LABS_TAB,
});
}} />
{ body } { body }
</FocusLock> </FocusLock>
</ContextMenu>; </ContextMenu>;

View File

@ -26,13 +26,11 @@ import {SpaceItem} from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import {useEventEmitter} from "../../../hooks/useEventEmitter";
import SpaceStore, { import SpaceStore, {
HOME_SPACE,
UPDATE_INVITED_SPACES, UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE, UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES, UPDATE_TOP_LEVEL_SPACES,
} from "../../../stores/SpaceStore"; } from "../../../stores/SpaceStore";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import {SpaceNotificationState} from "../../../stores/notifications/SpaceNotificationState";
import NotificationBadge from "../rooms/NotificationBadge"; import NotificationBadge from "../rooms/NotificationBadge";
import { import {
RovingAccessibleButton, RovingAccessibleButton,
@ -40,13 +38,15 @@ import {
RovingTabIndexProvider, RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex"; } from "../../../accessibility/RovingTabIndex";
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import {RoomNotificationStateStore} from "../../../stores/notifications/RoomNotificationStateStore";
import {NotificationState} from "../../../stores/notifications/NotificationState";
interface IButtonProps { interface IButtonProps {
space?: Room; space?: Room;
className?: string; className?: string;
selected?: boolean; selected?: boolean;
tooltip?: string; tooltip?: string;
notificationState?: SpaceNotificationState; notificationState?: NotificationState;
isNarrow?: boolean; isNarrow?: boolean;
onClick(): void; onClick(): void;
} }
@ -212,8 +212,8 @@ const SpacePanel = () => {
className="mx_SpaceButton_home" className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)} onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace} selected={!activeSpace}
tooltip={_t("Home")} tooltip={_t("All rooms")}
notificationState={SpaceStore.instance.getNotificationState(HOME_SPACE)} notificationState={RoomNotificationStateStore.instance.globalState}
isNarrow={isPanelCollapsed} isNarrow={isPanelCollapsed}
/> />
{ invites.map(s => <SpaceItem { invites.map(s => <SpaceItem

View File

@ -15,12 +15,11 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import {IRecordingUpdate, VoiceRecording} from "../../../voice/VoiceRecording"; import {IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES, VoiceRecording} from "../../../voice/VoiceRecording";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {arrayFastResample, arraySeed} from "../../../utils/arrays"; import {arrayFastResample, arraySeed} from "../../../utils/arrays";
import {percentageOf} from "../../../utils/numbers"; import {percentageOf} from "../../../utils/numbers";
import Waveform from "./Waveform"; import Waveform from "./Waveform";
import {PLAYBACK_WAVEFORM_SAMPLES} from "../../../voice/Playback";
interface IProps { interface IProps {
recorder: VoiceRecording; recorder: VoiceRecording;
@ -38,14 +37,14 @@ export default class LiveRecordingWaveform extends React.PureComponent<IProps, I
public constructor(props) { public constructor(props) {
super(props); super(props);
this.state = {heights: arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES)}; this.state = {heights: arraySeed(0, RECORDING_PLAYBACK_SAMPLES)};
this.props.recorder.liveData.onUpdate(this.onRecordingUpdate); this.props.recorder.liveData.onUpdate(this.onRecordingUpdate);
} }
private onRecordingUpdate = (update: IRecordingUpdate) => { private onRecordingUpdate = (update: IRecordingUpdate) => {
// The waveform and the downsample target are pretty close, so we should be fine to // The waveform and the downsample target are pretty close, so we should be fine to
// do this, despite the docs on arrayFastResample. // do this, despite the docs on arrayFastResample.
const bars = arrayFastResample(Array.from(update.waveform), PLAYBACK_WAVEFORM_SAMPLES); const bars = arrayFastResample(Array.from(update.waveform), RECORDING_PLAYBACK_SAMPLES);
this.setState({ this.setState({
// The incoming data is between zero and one, but typically even screaming into a // The incoming data is between zero and one, but typically even screaming into a
// microphone won't send you over 0.6, so we artificially adjust the gain for the // microphone won't send you over 0.6, so we artificially adjust the gain for the

View File

@ -0,0 +1,97 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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, {createRef} from 'react';
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import CallMediaHandler from "../../../CallMediaHandler";
interface IProps {
feed: CallFeed,
}
export default class AudioFeed extends React.Component<IProps> {
private element = createRef<HTMLAudioElement>();
componentDidMount() {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.playMedia();
}
componentWillUnmount() {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.stopMedia();
}
private playMedia() {
const element = this.element.current;
const audioOutput = CallMediaHandler.getAudioOutput();
if (audioOutput) {
try {
// This seems quite unreliable in Chrome, although I haven't yet managed to make a jsfiddle where
// it fails.
// It seems reliable if you set the sink ID after setting the srcObject and then set the sink ID
// back to the default after the call is over - Dave
element.setSinkId(audioOutput);
} catch (e) {
console.error("Couldn't set requested audio output device: using default", e);
logger.warn("Couldn't set requested audio output device: using default", e);
}
}
element.muted = false;
element.srcObject = this.props.feed.stream;
element.autoplay = true;
try {
// A note on calling methods on media elements:
// We used to have queues per media element to serialise all calls on those elements.
// The reason given for this was that load() and play() were racing. However, we now
// never call load() explicitly so this seems unnecessary. However, serialising every
// operation was causing bugs where video would not resume because some play command
// had got stuck and all media operations were queued up behind it. If necessary, we
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play()
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
}
}
private stopMedia() {
const element = this.element.current;
element.pause();
element.src = null;
// As per comment in componentDidMount, setting the sink ID back to the
// default once the call is over makes setSinkId work reliably. - Dave
// Since we are not using the same element anymore, the above doesn't
// seem to be necessary - Šimon
}
private onNewStream = () => {
this.playMedia();
};
render() {
return (
<audio ref={this.element} />
);
}
}

View File

@ -0,0 +1,60 @@
/*
Copyright 2021 Šimon Brandner <simon.bra.ag@gmail.com>
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 AudioFeed from "./AudioFeed"
import { CallEvent, MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import { CallFeed } from "matrix-js-sdk/src/webrtc/callFeed";
interface IProps {
call: MatrixCall;
}
interface IState {
feeds: Array<CallFeed>;
}
export default class AudioFeedArrayForCall extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
feeds: [],
};
}
componentDidMount() {
this.props.call.addListener(CallEvent.FeedsChanged, this.onFeedsChanged);
}
componentWillUnmount() {
this.props.call.removeListener(CallEvent.FeedsChanged, this.onFeedsChanged);
}
onFeedsChanged = () => {
this.setState({
feeds: this.props.call.getRemoteFeeds(),
});
}
render() {
return this.state.feeds.map((feed, i) => {
return (
<AudioFeed feed={feed} key={i} />
);
});
}
}

View File

@ -19,7 +19,7 @@ import React from 'react';
import CallView from "./CallView"; import CallView from "./CallView";
import RoomViewStore from '../../../stores/RoomViewStore'; import RoomViewStore from '../../../stores/RoomViewStore';
import CallHandler from '../../../CallHandler'; import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from '../../../dispatcher/payloads'; import { ActionPayload } from '../../../dispatcher/payloads';
import PersistentApp from "../elements/PersistentApp"; import PersistentApp from "../elements/PersistentApp";
@ -27,7 +27,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallEvent, CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
const SHOW_CALL_IN_STATES = [ const SHOW_CALL_IN_STATES = [
CallState.Connected, CallState.Connected,
@ -110,12 +109,14 @@ export default class CallPreview extends React.Component<IProps, IState> {
} }
public componentDidMount() { public componentDidMount() {
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate); this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); MatrixClientPeg.get().on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
} }
public componentWillUnmount() { public componentWillUnmount() {
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
if (this.roomStoreToken) { if (this.roomStoreToken) {
this.roomStoreToken.remove(); this.roomStoreToken.remove();
@ -143,8 +144,14 @@ export default class CallPreview extends React.Component<IProps, IState> {
switch (payload.action) { switch (payload.action) {
// listen for call state changes to prod the render method, which // listen for call state changes to prod the render method, which
// may hide the global CallView if the call it is tracking is dead // may hide the global CallView if the call it is tracking is dead
case Action.CallChangeRoom:
case 'call_state': { case 'call_state': {
this.updateCalls();
break;
}
}
};
private updateCalls = () => {
const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls( const [primaryCall, secondaryCalls] = getPrimarySecondaryCalls(
CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId), CallHandler.sharedInstance().getAllActiveCallsNotInRoom(this.state.roomId),
); );
@ -153,9 +160,6 @@ export default class CallPreview extends React.Component<IProps, IState> {
primaryCall: primaryCall, primaryCall: primaryCall,
secondaryCall: secondaryCalls[0], secondaryCall: secondaryCalls[0],
}); });
break;
}
}
}; };
private onCallRemoteHold = () => { private onCallRemoteHold = () => {

View File

@ -20,10 +20,9 @@ import dis from '../../../dispatcher/dispatcher';
import CallHandler from '../../../CallHandler'; import CallHandler from '../../../CallHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import { _t, _td } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import VideoFeed, { VideoFeedType } from "./VideoFeed"; import VideoFeed from './VideoFeed';
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import { CallState, CallType, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallState, CallType, MatrixCall, CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import { CallEvent } from 'matrix-js-sdk/src/webrtc/call';
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard'; import {isOnlyCtrlOrCmdKeyEvent, Key} from '../../../Keyboard';
@ -31,6 +30,7 @@ import {alwaysAboveLeftOf, alwaysAboveRightOf, ChevronFace, ContextMenuButton} f
import CallContextMenu from '../context_menus/CallContextMenu'; import CallContextMenu from '../context_menus/CallContextMenu';
import { avatarUrlForMember } from '../../../Avatar'; import { avatarUrlForMember } from '../../../Avatar';
import DialpadContextMenu from '../context_menus/DialpadContextMenu'; import DialpadContextMenu from '../context_menus/DialpadContextMenu';
import { CallFeed } from 'matrix-js-sdk/src/webrtc/callFeed';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
@ -40,11 +40,11 @@ interface IProps {
// Another ongoing call to display information about // Another ongoing call to display information about
secondaryCall?: MatrixCall, secondaryCall?: MatrixCall,
// a callback which is called when the content in the callview changes // a callback which is called when the content in the CallView changes
// in a way that is likely to cause a resize. // in a way that is likely to cause a resize.
onResize?: any; onResize?: any;
// Whether this call view is for picture-in-pictue mode // Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in. // otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no // This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler. // need to control those things separately, so this is simpler.
@ -60,6 +60,7 @@ interface IState {
controlsVisible: boolean, controlsVisible: boolean,
showMoreMenu: boolean, showMoreMenu: boolean,
showDialpad: boolean, showDialpad: boolean,
feeds: CallFeed[],
} }
function getFullScreenElement() { function getFullScreenElement() {
@ -115,6 +116,7 @@ export default class CallView extends React.Component<IProps, IState> {
controlsVisible: true, controlsVisible: true,
showMoreMenu: false, showMoreMenu: false,
showDialpad: false, showDialpad: false,
feeds: this.props.call.getFeeds(),
} }
this.updateCallListeners(null, this.props.call); this.updateCallListeners(null, this.props.call);
@ -172,11 +174,13 @@ export default class CallView extends React.Component<IProps, IState> {
oldCall.removeListener(CallEvent.State, this.onCallState); oldCall.removeListener(CallEvent.State, this.onCallState);
oldCall.removeListener(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold); oldCall.removeListener(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
oldCall.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold); oldCall.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
oldCall.removeListener(CallEvent.FeedsChanged, this.onFeedsChanged);
} }
if (newCall) { if (newCall) {
newCall.on(CallEvent.State, this.onCallState); newCall.on(CallEvent.State, this.onCallState);
newCall.on(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold); newCall.on(CallEvent.LocalHoldUnhold, this.onCallLocalHoldUnhold);
newCall.on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold); newCall.on(CallEvent.RemoteHoldUnhold, this.onCallRemoteHoldUnhold);
newCall.on(CallEvent.FeedsChanged, this.onFeedsChanged);
} }
} }
@ -186,6 +190,10 @@ export default class CallView extends React.Component<IProps, IState> {
}); });
}; };
private onFeedsChanged = (newFeeds: Array<CallFeed>) => {
this.setState({feeds: newFeeds});
};
private onCallLocalHoldUnhold = () => { private onCallLocalHoldUnhold = () => {
this.setState({ this.setState({
isLocalOnHold: this.props.call.isLocalOnHold(), isLocalOnHold: this.props.call.isLocalOnHold(),
@ -304,7 +312,7 @@ export default class CallView extends React.Component<IProps, IState> {
} }
// we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire // we register global shortcuts here, they *must not conflict* with local shortcuts elsewhere or both will fire
// Note that this assumes we always have a callview on screen at any given time // Note that this assumes we always have a CallView on screen at any given time
// CallHandler would probably be a better place for this // CallHandler would probably be a better place for this
private onNativeKeyDown = ev => { private onNativeKeyDown = ev => {
let handled = false; let handled = false;
@ -474,6 +482,8 @@ export default class CallView extends React.Component<IProps, IState> {
{contextMenuButton} {contextMenuButton}
</div>; </div>;
const avatarSize = this.props.pipMode ? 76 : 160;
// The 'content' for the call, ie. the videos for a video call and profile picture // The 'content' for the call, ie. the videos for a video call and profile picture
// for voice calls (fills the bg) // for voice calls (fills the bg)
let contentView: React.ReactNode; let contentView: React.ReactNode;
@ -524,41 +534,85 @@ export default class CallView extends React.Component<IProps, IState> {
</div>; </div>;
} }
// This is a bit messy. I can't see a reason to have two onHold/transfer screens
if (isOnHold || transfereeCall) {
if (this.props.call.type === CallType.Video) { if (this.props.call.type === CallType.Video) {
let localVideoFeed = null;
let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const containerClasses = classNames({ const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true, mx_CallView_video: true,
mx_CallView_video_hold: isOnHold, mx_CallView_video_hold: isOnHold,
}); });
if (isOnHold) { let onHoldBackground = null;
const backgroundStyle: CSSProperties = {};
const backgroundAvatarUrl = avatarUrlForMember( const backgroundAvatarUrl = avatarUrlForMember(
// is it worth getting the size of the div to pass here? // is it worth getting the size of the div to pass here?
this.props.call.getOpponentMember(), 1024, 1024, 'crop', this.props.call.getOpponentMember(), 1024, 1024, 'crop',
); );
backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')'; backgroundStyle.backgroundImage = 'url(' + backgroundAvatarUrl + ')';
onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />; onHoldBackground = <div className="mx_CallView_video_holdBackground" style={backgroundStyle} />;
}
if (!this.state.vidMuted) {
localVideoFeed = <VideoFeed type={VideoFeedType.Local} call={this.props.call} />;
}
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}> contentView = (
<div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{onHoldBackground} {onHoldBackground}
<VideoFeed type={VideoFeedType.Remote} call={this.props.call} onResize={this.props.onResize} />
{localVideoFeed}
{holdTransferContent} {holdTransferContent}
{callControls} {callControls}
</div>; </div>
);
} else { } else {
const avatarSize = this.props.pipMode ? 76 : 160;
const classes = classNames({ const classes = classNames({
mx_CallView_content: true,
mx_CallView_voice: true, mx_CallView_voice: true,
mx_CallView_voice_hold: isOnHold, mx_CallView_voice_hold: isOnHold,
}); });
contentView =(
<div className={classes} onMouseMove={this.onMouseMove}>
<div className="mx_CallView_voice_avatarsContainer">
<div
className="mx_CallView_voice_avatarContainer"
style={{width: avatarSize, height: avatarSize}}
>
<RoomAvatar
room={callRoom}
height={avatarSize}
width={avatarSize}
/>
</div>
</div>
{holdTransferContent}
{callControls}
</div>
);
}
} else if (this.props.call.noIncomingFeeds()) {
// Here we're reusing the css classes from voice on hold, because
// I am lazy. If this gets merged, the CallView might be subject
// to change anyway - I might take an axe to this file in order to
// try to get other things working
const classes = classNames({
mx_CallView_content: true,
mx_CallView_voice: true,
});
const feeds = this.props.call.getLocalFeeds().map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted()) return;
return (
<VideoFeed
key={i}
feed={feed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
/>
);
});
// Saying "Connecting" here isn't really true, but the best thing
// I can come up with, but this might be subject to change as well
contentView = <div className={classes} onMouseMove={this.onMouseMove}> contentView = <div className={classes} onMouseMove={this.onMouseMove}>
{feeds}
<div className="mx_CallView_voice_avatarsContainer"> <div className="mx_CallView_voice_avatarsContainer">
<div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}> <div className="mx_CallView_voice_avatarContainer" style={{width: avatarSize, height: avatarSize}}>
<RoomAvatar <RoomAvatar
@ -568,7 +622,35 @@ export default class CallView extends React.Component<IProps, IState> {
/> />
</div> </div>
</div> </div>
{holdTransferContent} <div className="mx_CallView_holdTransferContent">{_t("Connecting")}</div>
{callControls}
</div>;
} else {
const containerClasses = classNames({
mx_CallView_content: true,
mx_CallView_video: true,
});
// TODO: Later the CallView should probably be reworked to support
// any number of feeds but now we can always expect there to be two
// feeds. This is because the js-sdk ignores any new incoming streams
const feeds = this.state.feeds.map((feed, i) => {
// Here we check to hide local audio feeds to achieve the same UI/UX
// as before. But once again this might be subject to change
if (feed.isVideoMuted() && feed.isLocal()) return;
return (
<VideoFeed
key={i}
feed={feed}
call={this.props.call}
pipMode={this.props.pipMode}
onResize={this.props.onResize}
/>
);
});
contentView = <div className={containerClasses} ref={this.contentRef} onMouseMove={this.onMouseMove}>
{feeds}
{callControls} {callControls}
</div>; </div>;
} }

View File

@ -16,13 +16,12 @@ limitations under the License.
import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { CallState, MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React from 'react'; import React from 'react';
import CallHandler from '../../../CallHandler'; import CallHandler, { CallHandlerEvent } from '../../../CallHandler';
import CallView from './CallView'; import CallView from './CallView';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import {Resizable} from "re-resizable"; import {Resizable} from "re-resizable";
import ResizeNotifier from "../../../utils/ResizeNotifier"; import ResizeNotifier from "../../../utils/ResizeNotifier";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import { Action } from '../../../dispatcher/actions';
interface IProps { interface IProps {
// What room we should display the call for // What room we should display the call for
@ -55,23 +54,28 @@ export default class CallViewForRoom extends React.Component<IProps, IState> {
public componentDidMount() { public componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
CallHandler.sharedInstance().addListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
} }
public componentWillUnmount() { public componentWillUnmount() {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
CallHandler.sharedInstance().removeListener(CallHandlerEvent.CallChangeRoom, this.updateCall);
} }
private onAction = (payload) => { private onAction = (payload) => {
switch (payload.action) { switch (payload.action) {
case Action.CallChangeRoom:
case 'call_state': { case 'call_state': {
this.updateCall();
break;
}
}
};
private updateCall = () => {
const newCall = this.getCall(); const newCall = this.getCall();
if (newCall !== this.state.call) { if (newCall !== this.state.call) {
this.setState({call: newCall}); this.setState({call: newCall});
} }
break;
}
}
}; };
private getCall(): MatrixCall { private getCall(): MatrixCall {

View File

@ -18,52 +18,102 @@ import classnames from 'classnames';
import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call'; import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import React, {createRef} from 'react'; import React, {createRef} from 'react';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { CallFeed, CallFeedEvent } from 'matrix-js-sdk/src/webrtc/callFeed';
import { logger } from 'matrix-js-sdk/src/logger';
import MemberAvatar from "../avatars/MemberAvatar"
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
export enum VideoFeedType {
Local,
Remote,
}
interface IProps { interface IProps {
call: MatrixCall, call: MatrixCall,
type: VideoFeedType, feed: CallFeed,
// Whether this call view is for picture-in-picture mode
// otherwise, it's the larger call view when viewing the room the call is in.
// This is sort of a proxy for a number of things but we currently have no
// need to control those things separately, so this is simpler.
pipMode?: boolean;
// a callback which is called when the video element is resized // a callback which is called when the video element is resized
// due to a change in video metadata // due to a change in video metadata
onResize?: (e: Event) => void, onResize?: (e: Event) => void,
} }
interface IState {
audioMuted: boolean;
videoMuted: boolean;
}
@replaceableComponent("views.voip.VideoFeed") @replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps> { export default class VideoFeed extends React.Component<IProps, IState> {
private vid = createRef<HTMLVideoElement>(); private element = createRef<HTMLVideoElement>();
constructor(props: IProps) {
super(props);
this.state = {
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
};
}
componentDidMount() { componentDidMount() {
this.vid.current.addEventListener('resize', this.onResize); this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.setVideoElement(); this.playMedia();
}
componentDidUpdate(prevProps) {
if (this.props.call !== prevProps.call) {
this.setVideoElement();
}
} }
componentWillUnmount() { componentWillUnmount() {
this.vid.current.removeEventListener('resize', this.onResize); this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.element.current?.removeEventListener('resize', this.onResize);
this.stopMedia();
} }
private setVideoElement() { private playMedia() {
if (this.props.type === VideoFeedType.Local) { const element = this.element.current;
this.props.call.setLocalVideoElement(this.vid.current); if (!element) return;
} else { // We play audio in AudioFeed, not here
this.props.call.setRemoteVideoElement(this.vid.current); element.muted = true;
element.srcObject = this.props.feed.stream;
element.autoplay = true;
try {
// A note on calling methods on media elements:
// We used to have queues per media element to serialise all calls on those elements.
// The reason given for this was that load() and play() were racing. However, we now
// never call load() explicitly so this seems unnecessary. However, serialising every
// operation was causing bugs where video would not resume because some play command
// had got stuck and all media operations were queued up behind it. If necessary, we
// should serialise the ones that need to be serialised but then be able to interrupt
// them with another load() which will cancel the pending one, but since we don't call
// load() explicitly, it shouldn't be a problem. - Dave
element.play()
} catch (e) {
logger.info("Failed to play media element with feed", this.props.feed, e);
} }
} }
onResize = (e) => { private stopMedia() {
if (this.props.onResize) { const element = this.element.current;
if (!element) return;
element.pause();
element.src = null;
// As per comment in componentDidMount, setting the sink ID back to the
// default once the call is over makes setSinkId work reliably. - Dave
// Since we are not using the same element anymore, the above doesn't
// seem to be necessary - Šimon
}
private onNewStream = () => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
this.playMedia();
};
private onResize = (e) => {
if (this.props.onResize && !this.props.feed.isLocal()) {
this.props.onResize(e); this.props.onResize(e);
} }
}; };
@ -71,14 +121,33 @@ export default class VideoFeed extends React.Component<IProps> {
render() { render() {
const videoClasses = { const videoClasses = {
mx_VideoFeed: true, mx_VideoFeed: true,
mx_VideoFeed_local: this.props.type === VideoFeedType.Local, mx_VideoFeed_local: this.props.feed.isLocal(),
mx_VideoFeed_remote: this.props.type === VideoFeedType.Remote, mx_VideoFeed_remote: !this.props.feed.isLocal(),
mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted,
mx_VideoFeed_mirror: ( mx_VideoFeed_mirror: (
this.props.type === VideoFeedType.Local && this.props.feed.isLocal() &&
SettingsStore.getValue('VideoView.flipVideoHorizontally') SettingsStore.getValue('VideoView.flipVideoHorizontally')
), ),
}; };
return <video className={classnames(videoClasses)} ref={this.vid} />; if (this.state.videoMuted) {
const member = this.props.feed.getMember();
const avatarSize = this.props.pipMode ? 76 : 160;
return (
<div className={classnames(videoClasses)} >
<MemberAvatar
member={member}
height={avatarSize}
width={avatarSize}
/>
</div>
);
} else {
return (
<video className={classnames(videoClasses)} ref={this.element} />
);
}
} }
} }

View File

@ -114,9 +114,6 @@ export enum Action {
*/ */
VirtualRoomSupportUpdated = "virtual_room_support_updated", VirtualRoomSupportUpdated = "virtual_room_support_updated",
// Probably would be better to have a VoIP states in a store and have the store emit changes
CallChangeRoom = "call_change_room",
/** /**
* Fired when an upload has started. Should be used with UploadStartedPayload. * Fired when an upload has started. Should be used with UploadStartedPayload.
*/ */

View File

@ -116,14 +116,22 @@ export function htmlSerializeIfNeeded(model: EditorModel, {forceHTML = false} =
const parser = new Markdown(md); const parser = new Markdown(md);
if (!parser.isPlainText() || forceHTML) { if (!parser.isPlainText() || forceHTML) {
// feed Markdown output to HTML parser // feed Markdown output to HTML parser
const phtml = cheerio.load(parser.toHTML(), const phtml = cheerio.load(parser.toHTML(), {
{ _useHtmlParser2: true, decodeEntities: false }); // @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
if (SettingsStore.getValue("feature_latex_maths")) { if (SettingsStore.getValue("feature_latex_maths")) {
// original Markdown without LaTeX replacements // original Markdown without LaTeX replacements
const parserOrig = new Markdown(orig); const parserOrig = new Markdown(orig);
const phtmlOrig = cheerio.load(parserOrig.toHTML(), const phtmlOrig = cheerio.load(parserOrig.toHTML(), {
{ _useHtmlParser2: true, decodeEntities: false }); // @ts-ignore: The `_useHtmlParser2` internal option is the
// simplest way to both parse and render using `htmlparser2`.
_useHtmlParser2: true,
decodeEntities: false,
});
// since maths delimiters are handled before Markdown, // since maths delimiters are handled before Markdown,
// code blocks could contain mangled content. // code blocks could contain mangled content.

View File

@ -785,6 +785,13 @@
"%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s", "%(senderName)s: %(stickerName)s": "%(senderName)s: %(stickerName)s",
"Change notification settings": "Change notification settings", "Change notification settings": "Change notification settings",
"Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.", "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.": "Spaces prototype. Incompatible with Communities, Communities v2 and Custom Tags. Requires compatible homeserver for some features.",
"Spaces": "Spaces",
"Spaces are a new way to group rooms and people.": "Spaces are a new way to group rooms and people.",
"%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.": "%(brand)s will reload with Spaces disabled. Communities and custom tags will be visible again.",
"Beta available for web, desktop and Android. Thank you for trying the beta.": "Beta available for web, desktop and Android. Thank you for trying the beta.",
"%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.": "%(brand)s will reload with Spaces enabled. Communities and custom tags will be hidden.",
"You can leave the beta any time from settings or tapping on a beta badge, like the one above.": "You can leave the beta any time from settings or tapping on a beta badge, like the one above.",
"Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.": "Beta available for web, desktop and Android. Some features may be unavailable on your homeserver.",
"Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode", "Show options to enable 'Do not disturb' mode": "Show options to enable 'Do not disturb' mode",
"Send and receive voice messages (in development)": "Send and receive voice messages (in development)", "Send and receive voice messages (in development)": "Send and receive voice messages (in development)",
"Render LaTeX maths in messages": "Render LaTeX maths in messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages",
@ -885,6 +892,7 @@
"You held the call <a>Switch</a>": "You held the call <a>Switch</a>", "You held the call <a>Switch</a>": "You held the call <a>Switch</a>",
"You held the call <a>Resume</a>": "You held the call <a>Resume</a>", "You held the call <a>Resume</a>": "You held the call <a>Resume</a>",
"%(peerName)s held the call": "%(peerName)s held the call", "%(peerName)s held the call": "%(peerName)s held the call",
"Connecting": "Connecting",
"Video Call": "Video Call", "Video Call": "Video Call",
"Voice Call": "Voice Call", "Voice Call": "Voice Call",
"Fill Screen": "Fill Screen", "Fill Screen": "Fill Screen",
@ -995,8 +1003,9 @@
"Upload": "Upload", "Upload": "Upload",
"Name": "Name", "Name": "Name",
"Description": "Description", "Description": "Description",
"Please enter a name for the space": "Please enter a name for the space",
"Create a space": "Create a space", "Create a space": "Create a space",
"Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.": "Spaces are new ways to group rooms and people. To join an existing space you'll need an invite.", "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.": "Spaces are a new way to group rooms and people. To join an existing space you'll need an invite.",
"Public": "Public", "Public": "Public",
"Open space for anyone, best for communities": "Open space for anyone, best for communities", "Open space for anyone, best for communities": "Open space for anyone, best for communities",
"Private": "Private", "Private": "Private",
@ -1011,7 +1020,7 @@
"Create": "Create", "Create": "Create",
"Expand space panel": "Expand space panel", "Expand space panel": "Expand space panel",
"Collapse space panel": "Collapse space panel", "Collapse space panel": "Collapse space panel",
"Home": "Home", "All rooms": "All rooms",
"Click to copy": "Click to copy", "Click to copy": "Click to copy",
"Copied!": "Copied!", "Copied!": "Copied!",
"Failed to copy": "Failed to copy", "Failed to copy": "Failed to copy",
@ -1257,7 +1266,7 @@
"Copy": "Copy", "Copy": "Copy",
"Clear cache and reload": "Clear cache and reload", "Clear cache and reload": "Clear cache and reload",
"Labs": "Labs", "Labs": "Labs",
"Customise your experience with experimental labs features. <a>Learn more</a>.": "Customise your experience with experimental labs features. <a>Learn more</a>.", "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.": "Feeling experimental? Labs are the best way to get things early, test out new features and help shape them before they actually launch. <a>Learn more</a>.",
"Ignored/Blocked": "Ignored/Blocked", "Ignored/Blocked": "Ignored/Blocked",
"Error adding ignored user/server": "Error adding ignored user/server", "Error adding ignored user/server": "Error adding ignored user/server",
"Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.", "Something went wrong. Please try again or view your console for hints.": "Something went wrong. Please try again or view your console for hints.",
@ -1440,6 +1449,13 @@
"Someone is using an unknown session": "Someone is using an unknown session", "Someone is using an unknown session": "Someone is using an unknown session",
"This room is end-to-end encrypted": "This room is end-to-end encrypted", "This room is end-to-end encrypted": "This room is end-to-end encrypted",
"Everyone in this room is verified": "Everyone in this room is verified", "Everyone in this room is verified": "Everyone in this room is verified",
"Server error": "Server error",
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
"Unknown Command": "Unknown Command",
"Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s",
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
"Send as message": "Send as message",
"Edit message": "Edit message", "Edit message": "Edit message",
"Mod": "Mod", "Mod": "Mod",
"This event could not be displayed": "This event could not be displayed", "This event could not be displayed": "This event could not be displayed",
@ -1630,13 +1646,6 @@
"This Room": "This Room", "This Room": "This Room",
"All Rooms": "All Rooms", "All Rooms": "All Rooms",
"Search…": "Search…", "Search…": "Search…",
"Server error": "Server error",
"Server unavailable, overloaded, or something else went wrong.": "Server unavailable, overloaded, or something else went wrong.",
"Unknown Command": "Unknown Command",
"Unrecognised command: %(commandText)s": "Unrecognised command: %(commandText)s",
"You can use <code>/help</code> to list available commands. Did you mean to send this as a message?": "You can use <code>/help</code> to list available commands. Did you mean to send this as a message?",
"Hint: Begin your message with <code>//</code> to start it with a slash.": "Hint: Begin your message with <code>//</code> to start it with a slash.",
"Send as message": "Send as message",
"Failed to connect to integration manager": "Failed to connect to integration manager", "Failed to connect to integration manager": "Failed to connect to integration manager",
"You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled", "You don't currently have any stickerpacks enabled": "You don't currently have any stickerpacks enabled",
"Add some now": "Add some now", "Add some now": "Add some now",
@ -2015,10 +2024,11 @@
"Continue with %(provider)s": "Continue with %(provider)s", "Continue with %(provider)s": "Continue with %(provider)s",
"Sign in with single sign-on": "Sign in with single sign-on", "Sign in with single sign-on": "Sign in with single sign-on",
"And %(count)s more...|other": "And %(count)s more...", "And %(count)s more...|other": "And %(count)s more...",
"Home": "Home",
"Enter a server name": "Enter a server name", "Enter a server name": "Enter a server name",
"Looks good": "Looks good", "Looks good": "Looks good",
"You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list",
"Can't find this server or its room list": "Can't find this server or its room list", "Can't find this server or its room list": "Can't find this server or its room list",
"All rooms": "All rooms",
"Your server": "Your server", "Your server": "Your server",
"Are you sure you want to remove <b>%(serverName)s</b>": "Are you sure you want to remove <b>%(serverName)s</b>", "Are you sure you want to remove <b>%(serverName)s</b>": "Are you sure you want to remove <b>%(serverName)s</b>",
"Remove server": "Remove server", "Remove server": "Remove server",
@ -2030,9 +2040,6 @@
"%(networkName)s rooms": "%(networkName)s rooms", "%(networkName)s rooms": "%(networkName)s rooms",
"Matrix rooms": "Matrix rooms", "Matrix rooms": "Matrix rooms",
"Filter your rooms and spaces": "Filter your rooms and spaces", "Filter your rooms and spaces": "Filter your rooms and spaces",
"Spaces": "Spaces",
"Feeling experimental?": "Feeling experimental?",
"You can add existing spaces to a space.": "You can add existing spaces to a space.",
"Direct Messages": "Direct Messages", "Direct Messages": "Direct Messages",
"Space selection": "Space selection", "Space selection": "Space selection",
"Add existing rooms": "Add existing rooms", "Add existing rooms": "Add existing rooms",
@ -2465,6 +2472,11 @@
"Revoke permissions": "Revoke permissions", "Revoke permissions": "Revoke permissions",
"Move left": "Move left", "Move left": "Move left",
"Move right": "Move right", "Move right": "Move right",
"Spaces is a beta feature": "Spaces is a beta feature",
"Tap for more info": "Tap for more info",
"Beta": "Beta",
"Leave the beta": "Leave the beta",
"Join the beta": "Join the beta",
"Avatar": "Avatar", "Avatar": "Avatar",
"This room is public": "This room is public", "This room is public": "This room is public",
"Away": "Away", "Away": "Away",
@ -2598,6 +2610,7 @@
"Error whilst fetching joined communities": "Error whilst fetching joined communities", "Error whilst fetching joined communities": "Error whilst fetching joined communities",
"Create a new community": "Create a new community", "Create a new community": "Create a new community",
"Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.", "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.": "Create a community to group together users and rooms! Build a custom homepage to mark out your space in the Matrix universe.",
"Communities are changing to Spaces": "Communities are changing to Spaces",
"Youre all caught up": "Youre all caught up", "Youre all caught up": "Youre all caught up",
"You have no visible notifications.": "You have no visible notifications.", "You have no visible notifications.": "You have no visible notifications.",
"%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.", "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.": "%(brand)s failed to get the protocol list from the homeserver. The homeserver may be too old to support third party networks.",
@ -2658,6 +2671,7 @@
"%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces", "%(count)s rooms and %(numSpaces)s spaces|one": "%(count)s room and %(numSpaces)s spaces",
"%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space", "%(count)s rooms and 1 space|other": "%(count)s rooms and 1 space",
"%(count)s rooms and 1 space|one": "%(count)s room and 1 space", "%(count)s rooms and 1 space|one": "%(count)s room and 1 space",
"Select a room below first": "Select a room below first",
"Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later", "Failed to remove some rooms. Try again later": "Failed to remove some rooms. Try again later",
"Removing...": "Removing...", "Removing...": "Removing...",
"Mark as not suggested": "Mark as not suggested", "Mark as not suggested": "Mark as not suggested",
@ -2670,7 +2684,8 @@
"Public space": "Public space", "Public space": "Public space",
"Private space": "Private space", "Private space": "Private space",
"<inviter/> invites you": "<inviter/> invites you", "<inviter/> invites you": "<inviter/> invites you",
"Add existing rooms & spaces": "Add existing rooms & spaces", "To view %(spaceName)s, turn on the <a>Spaces beta</a>": "To view %(spaceName)s, turn on the <a>Spaces beta</a>",
"To join %(spaceName)s, turn on the <a>Spaces beta</a>": "To join %(spaceName)s, turn on the <a>Spaces beta</a>",
"Welcome to <name/>": "Welcome to <name/>", "Welcome to <name/>": "Welcome to <name/>",
"Random": "Random", "Random": "Random",
"Support": "Support", "Support": "Support",
@ -2695,6 +2710,7 @@
"Inviting...": "Inviting...", "Inviting...": "Inviting...",
"Invite your teammates": "Invite your teammates", "Invite your teammates": "Invite your teammates",
"Make sure the right people have access. You can invite more later.": "Make sure the right people have access. You can invite more later.", "Make sure the right people have access. You can invite more later.": "Make sure the right people have access. You can invite more later.",
"<b>This is an experimental feature.</b> For now, new users receiving an invite will have to open the invite on <link/> to actually join.": "<b>This is an experimental feature.</b> For now, new users receiving an invite will have to open the invite on <link/> to actually join.",
"Invite by username": "Invite by username", "Invite by username": "Invite by username",
"What are some things you want to discuss in %(spaceName)s?": "What are some things you want to discuss in %(spaceName)s?", "What are some things you want to discuss in %(spaceName)s?": "What are some things you want to discuss in %(spaceName)s?",
"Let's create a room for each of them.": "Let's create a room for each of them.", "Let's create a room for each of them.": "Let's create a room for each of them.",

View File

@ -16,8 +16,9 @@ limitations under the License.
*/ */
import { MatrixClient } from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import React, { ReactNode } from "react";
import { _td } from '../languageHandler'; import { _t, _td } from '../languageHandler';
import { import {
NotificationBodyEnabledController, NotificationBodyEnabledController,
NotificationsEnabledController, NotificationsEnabledController,
@ -39,6 +40,7 @@ import { OrderedMultiController } from "./controllers/OrderedMultiController";
import { Layout } from "./Layout"; import { Layout } from "./Layout";
import ReducedMotionController from './controllers/ReducedMotionController'; import ReducedMotionController from './controllers/ReducedMotionController';
import IncompatibleController from "./controllers/IncompatibleController"; import IncompatibleController from "./controllers/IncompatibleController";
import SdkConfig from "../SdkConfig";
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times // These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
const LEVELS_ROOM_SETTINGS = [ const LEVELS_ROOM_SETTINGS = [
@ -117,6 +119,13 @@ export interface ISetting {
// historical settings which we don't want existing user's values be wiped. Do // historical settings which we don't want existing user's values be wiped. Do
// not use this for new settings. // not use this for new settings.
invertedSettingName?: string; invertedSettingName?: string;
betaInfo?: {
title: string; // _td
caption: string; // _td
disclaimer?: (enabled: boolean) => ReactNode;
image: string; // require(...)
};
} }
export const SETTINGS: {[setting: string]: ISetting} = { export const SETTINGS: {[setting: string]: ISetting} = {
@ -127,6 +136,33 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
controller: new ReloadOnChangeController(), controller: new ReloadOnChangeController(),
betaInfo: {
title: _td("Spaces"),
caption: _td("Spaces are a new way to group rooms and people."),
disclaimer: (enabled) => {
if (enabled) {
return <>
<p>{ _t("%(brand)s will reload with Spaces disabled. " +
"Communities and custom tags will be visible again.", {
brand: SdkConfig.get().brand,
}) }</p>
<p>{ _t("Beta available for web, desktop and Android. Thank you for trying the beta.") }</p>
</>;
}
return <>
<p>{ _t("%(brand)s will reload with Spaces enabled. " +
"Communities and custom tags will be hidden.", {
brand: SdkConfig.get().brand,
}) }</p>
<b>{ _t("You can leave the beta any time from settings or tapping on a beta badge, " +
"like the one above.") }</b>
<p>{ _t("Beta available for web, desktop and Android. " +
"Some features may be unavailable on your homeserver.") }</p>
</>;
},
image: require("../../res/img/betas/spaces.png"),
},
}, },
"feature_dnd": { "feature_dnd": {
isFeature: true, isFeature: true,

View File

@ -257,6 +257,15 @@ export default class SettingsStore {
return SETTINGS[settingName].isFeature; return SETTINGS[settingName].isFeature;
} }
public static getBetaInfo(settingName: string) {
// consider a beta disabled if the config is explicitly set to false, in which case treat as normal Labs flag
if (SettingsStore.isFeature(settingName)
&& SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, null, true, true) !== false
) {
return SETTINGS[settingName]?.betaInfo;
}
}
/** /**
* Determines if a setting is enabled. * Determines if a setting is enabled.
* If a setting is disabled then it should be hidden from the user. * If a setting is disabled then it should be hidden from the user.
@ -445,8 +454,8 @@ export default class SettingsStore {
throw new Error("Setting '" + settingName + "' does not appear to be a setting."); throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
} }
// When features are specified in the config.json, we force them as enabled or disabled. // When non-beta features are specified in the config.json, we force them as enabled or disabled.
if (SettingsStore.isFeature(settingName)) { if (SettingsStore.isFeature(settingName) && !SETTINGS[settingName]?.betaInfo) {
const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true); const configVal = SettingsStore.getValueAt(SettingLevel.CONFIG, settingName, roomId, true, true);
if (configVal === true || configVal === false) return false; if (configVal === true || configVal === false) return false;
} }

View File

@ -122,7 +122,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
private async appendRoom(room: Room) { private async appendRoom(room: Room) {
if (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) return; // hide space rooms if (SettingsStore.getValue("feature_spaces") && 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

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {sortBy, throttle} from "lodash"; import {ListIteratee, Many, sortBy, throttle} from "lodash";
import {EventType, RoomType} from "matrix-js-sdk/src/@types/event"; import {EventType, RoomType} from "matrix-js-sdk/src/@types/event";
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import {MatrixEvent} from "matrix-js-sdk/src/models/event";
@ -31,28 +31,23 @@ import {RoomNotificationStateStore} from "./notifications/RoomNotificationStateS
import {DefaultTagID} from "./room-list/models"; import {DefaultTagID} from "./room-list/models";
import {EnhancedMap, mapDiff} from "../utils/maps"; import {EnhancedMap, mapDiff} from "../utils/maps";
import {setHasDiff} from "../utils/sets"; import {setHasDiff} from "../utils/sets";
import {objectDiff} from "../utils/objects";
import {arrayHasDiff} from "../utils/arrays";
import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory"; import {ISpaceSummaryEvent, ISpaceSummaryRoom} from "../components/structures/SpaceRoomDirectory";
import RoomViewStore from "./RoomViewStore"; import RoomViewStore from "./RoomViewStore";
type SpaceKey = string | symbol;
interface IState {} interface IState {}
const ACTIVE_SPACE_LS_KEY = "mx_active_space"; const ACTIVE_SPACE_LS_KEY = "mx_active_space";
export const HOME_SPACE = Symbol("home-space");
export const SUGGESTED_ROOMS = Symbol("suggested-rooms"); export const SUGGESTED_ROOMS = Symbol("suggested-rooms");
export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces"); export const UPDATE_TOP_LEVEL_SPACES = Symbol("top-level-spaces");
export const UPDATE_INVITED_SPACES = Symbol("invited-spaces"); export const UPDATE_INVITED_SPACES = Symbol("invited-spaces");
export const UPDATE_SELECTED_SPACE = Symbol("selected-space"); export const UPDATE_SELECTED_SPACE = Symbol("selected-space");
// Space Room ID/HOME_SPACE will be emitted when a Space's children change // Space Room ID will be emitted when a Space's children change
const MAX_SUGGESTED_ROOMS = 20; const MAX_SUGGESTED_ROOMS = 20;
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "home_space"}`; const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "ALL_ROOMS"}`;
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
return arr.reduce((result, room: Room) => { return arr.reduce((result, room: Room) => {
@ -61,15 +56,18 @@ const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces,
}, [[], []]); }, [[], []]);
}; };
const getOrder = (ev: MatrixEvent): string | null => { // For sorting space children using a validated `order`, `m.room.create`'s `origin_server_ts`, `room_id`
const content = ev.getContent(); export const getOrder = (order: string, creationTs: number, roomId: string): Array<Many<ListIteratee<any>>> => {
if (typeof content.order === "string" && Array.from(content.order).every((c: string) => { let validatedOrder: string = null;
if (typeof order === "string" && Array.from(order).every((c: string) => {
const charCode = c.charCodeAt(0); const charCode = c.charCodeAt(0);
return charCode >= 0x20 && charCode <= 0x7F; return charCode >= 0x20 && charCode <= 0x7F;
})) { })) {
return content.order; validatedOrder = order;
} }
return null;
return [validatedOrder, creationTs, roomId];
} }
const getRoomFn: FetchRoomFn = (room: Room) => { const getRoomFn: FetchRoomFn = (room: Room) => {
@ -83,15 +81,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// The spaces representing the roots of the various tree-like hierarchies // The spaces representing the roots of the various tree-like hierarchies
private rootSpaces: Room[] = []; private rootSpaces: Room[] = [];
// The list of rooms not present in any currently joined spaces
private orphanedRooms = new Set<string>();
// Map from room ID to set of spaces which list it as a child // Map from room ID to set of spaces which list it as a child
private parentMap = new EnhancedMap<string, Set<string>>(); private parentMap = new EnhancedMap<string, Set<string>>();
// Map from space key to SpaceNotificationState instance representing that space // Map from spaceId to SpaceNotificationState instance representing that space
private notificationStateMap = new Map<SpaceKey, SpaceNotificationState>(); private notificationStateMap = new Map<string, SpaceNotificationState>();
// Map from space key to Set of room IDs that should be shown as part of that space's filter // Map from space key to Set of room IDs that should be shown as part of that space's filter
private spaceFilteredRooms = new Map<string | symbol, Set<string>>(); private spaceFilteredRooms = new Map<string, Set<string>>();
// The space currently selected in the Space Panel - if null then `Home` is selected // The space currently selected in the Space Panel - if null then All Rooms is selected
private _activeSpace?: Room = null; private _activeSpace?: Room = null;
private _suggestedRooms: ISpaceSummaryRoom[] = []; private _suggestedRooms: ISpaceSummaryRoom[] = [];
private _invitedSpaces = new Set<Room>(); private _invitedSpaces = new Set<Room>();
@ -200,9 +196,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
private getChildren(spaceId: string): Room[] { private getChildren(spaceId: string): Room[] {
const room = this.matrixClient?.getRoom(spaceId); const room = this.matrixClient?.getRoom(spaceId);
const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via); const childEvents = room?.currentState.getStateEvents(EventType.SpaceChild).filter(ev => ev.getContent()?.via);
return sortBy(childEvents, getOrder) return sortBy(childEvents, ev => {
.map(ev => this.matrixClient.getRoom(ev.getStateKey())) const roomId = ev.getStateKey();
.filter(room => room?.getMyMembership() === "join" || room?.getMyMembership() === "invite") || []; const childRoom = this.matrixClient?.getRoom(roomId);
const createTs = childRoom?.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs();
return getOrder(ev.getContent().order, createTs, roomId);
}).map(ev => {
return this.matrixClient.getRoom(ev.getStateKey());
}).filter(room => {
return room?.getMyMembership() === "join" || room?.getMyMembership() === "invite";
}) || [];
} }
public getChildRooms(spaceId: string): Room[] { public getChildRooms(spaceId: string): Room[] {
@ -234,7 +237,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => { public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); if (!space) {
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
}
return this.spaceFilteredRooms.get(space.roomId) || new Set();
}; };
private rebuild = throttle(() => { private rebuild = throttle(() => {
@ -265,7 +271,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}); });
}); });
const [rootSpaces, orphanedRooms] = partitionSpacesAndRooms(Array.from(unseenChildren)); const [rootSpaces] = partitionSpacesAndRooms(Array.from(unseenChildren));
// somewhat algorithm to handle full-cycles // somewhat algorithm to handle full-cycles
const detachedNodes = new Set<Room>(spaces); const detachedNodes = new Set<Room>(spaces);
@ -306,7 +312,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// rootSpaces.push(space); // rootSpaces.push(space);
// }); // });
this.orphanedRooms = new Set(orphanedRooms);
this.rootSpaces = rootSpaces; this.rootSpaces = rootSpaces;
this.parentMap = backrefs; this.parentMap = backrefs;
@ -327,25 +332,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.rebuild(); this.rebuild();
} }
private showInHomeSpace = (room: Room) => {
if (room.isSpaceRoom()) return false;
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
|| RoomListStore.instance.getTagsForRoom(room).includes(DefaultTagID.Favourite) // show all favourites
};
// Update a given room due to its tag changing (e.g DM-ness or Fav-ness)
// This can only change whether it shows up in the HOME_SPACE or not
private onRoomUpdate = (room: Room) => {
if (this.showInHomeSpace(room)) {
this.spaceFilteredRooms.get(HOME_SPACE)?.add(room.roomId);
this.emit(HOME_SPACE);
} else if (!this.orphanedRooms.has(room.roomId)) {
this.spaceFilteredRooms.get(HOME_SPACE)?.delete(room.roomId);
this.emit(HOME_SPACE);
}
};
private onSpaceMembersChange = (ev: MatrixEvent) => { private onSpaceMembersChange = (ev: MatrixEvent) => {
// skip this update if we do not have a DM with this user // skip this update if we do not have a DM with this user
if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return; if (DMRoomMap.shared().getDMRoomsForUserId(ev.getStateKey()).length < 1) return;
@ -359,16 +345,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const oldFilteredRooms = this.spaceFilteredRooms; const oldFilteredRooms = this.spaceFilteredRooms;
this.spaceFilteredRooms = new Map(); this.spaceFilteredRooms = new Map();
// put all room invites in the Home Space
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
visibleRooms.forEach(room => {
if (this.showInHomeSpace(room)) {
this.spaceFilteredRooms.get(HOME_SPACE).add(room.roomId);
}
});
this.rootSpaces.forEach(s => { this.rootSpaces.forEach(s => {
// traverse each space tree in DFS to build up the supersets as you go up, // traverse each space tree in DFS to build up the supersets as you go up,
// reusing results from like subtrees. // reusing results from like subtrees.
@ -415,15 +391,10 @@ 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)) {
// Don't aggregate notifications for DMs except in the Home Space
if (s !== HOME_SPACE) {
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);
} }
return true;
}
return false; return false;
})); }));
}); });
@ -503,8 +474,6 @@ 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 {
this.onRoomUpdate(room);
} }
this.emit(room.roomId); this.emit(room.roomId);
break; break;
@ -517,38 +486,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
}; };
private onRoomAccountData = (ev: MatrixEvent, room: Room, lastEvent?: MatrixEvent) => {
if (ev.getType() === EventType.Tag && !room.isSpaceRoom()) {
// If the room was in favourites and now isn't or the opposite then update its position in the trees
const oldTags = lastEvent?.getContent()?.tags || {};
const newTags = ev.getContent()?.tags || {};
if (!!oldTags[DefaultTagID.Favourite] !== !!newTags[DefaultTagID.Favourite]) {
this.onRoomUpdate(room);
}
}
}
private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => {
if (ev.getType() === EventType.Direct) {
const lastContent = lastEvent.getContent();
const content = ev.getContent();
const diff = objectDiff<Record<string, string[]>>(lastContent, content);
// filter out keys which changed by reference only by checking whether the sets differ
const changed = diff.changed.filter(k => arrayHasDiff(lastContent[k], content[k]));
// DM tag changes, refresh relevant rooms
new Set([...diff.added, ...diff.removed, ...changed]).forEach(roomId => {
const room = this.matrixClient?.getRoom(roomId);
if (room) {
this.onRoomUpdate(room);
}
});
}
};
protected async reset() { protected async reset() {
this.rootSpaces = []; this.rootSpaces = [];
this.orphanedRooms = new Set();
this.parentMap = new EnhancedMap(); this.parentMap = new EnhancedMap();
this.notificationStateMap = new Map(); this.notificationStateMap = new Map();
this.spaceFilteredRooms = new Map(); this.spaceFilteredRooms = new Map();
@ -563,8 +502,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
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("RoomState.events", this.onRoomState); this.matrixClient.removeListener("RoomState.events", this.onRoomState);
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("accountData", this.onAccountData);
} }
await this.reset(); await this.reset();
} }
@ -574,8 +511,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
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("RoomState.events", this.onRoomState); this.matrixClient.on("RoomState.events", this.onRoomState);
this.matrixClient.on("Room.accountData", this.onRoomAccountData);
this.matrixClient.on("accountData", this.onAccountData);
await this.onSpaceUpdate(); // trigger an initial update await this.onSpaceUpdate(); // trigger an initial update
@ -600,7 +535,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// Don't context switch when navigating to the space room // Don't context switch when navigating to the space room
// 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 (!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) { } else if (this.activeSpace && !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)) {
this.switchToRelatedSpace(roomId); this.switchToRelatedSpace(roomId);
} }
@ -618,7 +553,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
} }
public getNotificationState(key: SpaceKey): SpaceNotificationState { public getNotificationState(key: string): SpaceNotificationState {
if (this.notificationStateMap.has(key)) { if (this.notificationStateMap.has(key)) {
return this.notificationStateMap.get(key); return this.notificationStateMap.get(key);
} }

View File

@ -668,7 +668,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
* and thus might not cause an update to the store immediately. * and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add. * @param {IFilterCondition} filter The filter condition to add.
*/ */
public addFilter(filter: IFilterCondition): void { public async addFilter(filter: IFilterCondition): Promise<void> {
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("Adding filter condition:", filter); console.log("Adding filter condition:", filter);
@ -680,13 +680,15 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
promise = this.recalculatePrefiltering(); promise = this.recalculatePrefiltering();
} else { } else {
this.filterConditions.push(filter); this.filterConditions.push(filter);
// Runtime filters with spaces disable prefiltering for the search all spaces feature
if (SettingsStore.getValue("feature_spaces")) {
// 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.
await this.recalculatePrefiltering();
}
if (this.algorithm) { if (this.algorithm) {
this.algorithm.addFilterCondition(filter); this.algorithm.addFilterCondition(filter);
} }
// Runtime filters with spaces disable prefiltering for the search all spaces effect
if (SettingsStore.getValue("feature_spaces")) {
promise = this.recalculatePrefiltering();
}
} }
promise.then(() => this.updateFn.trigger()); promise.then(() => this.updateFn.trigger());
} }
@ -710,12 +712,12 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
if (this.algorithm) { if (this.algorithm) {
this.algorithm.removeFilterCondition(filter); this.algorithm.removeFilterCondition(filter);
// Runtime filters with spaces disable prefiltering for the search all spaces effect }
// Runtime filters with spaces disable prefiltering for the search all spaces feature
if (SettingsStore.getValue("feature_spaces")) { if (SettingsStore.getValue("feature_spaces")) {
promise = this.recalculatePrefiltering(); promise = this.recalculatePrefiltering();
} }
} }
}
idx = this.prefilterConditions.indexOf(filter); idx = this.prefilterConditions.indexOf(filter);
if (idx >= 0) { if (idx >= 0) {
filter.off(FILTER_CHANGED, this.onPrefilterUpdated); filter.off(FILTER_CHANGED, this.onPrefilterUpdated);

View File

@ -24,26 +24,34 @@ import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore";
* 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
*/ */
export class SpaceWatcher { export class SpaceWatcher {
private filter = new SpaceFilterCondition(); private filter: SpaceFilterCondition;
private activeSpace: Room = SpaceStore.instance.activeSpace; private activeSpace: Room = SpaceStore.instance.activeSpace;
constructor(private store: RoomListStoreClass) { constructor(private store: RoomListStoreClass) {
this.updateFilter(); // get the filter into a consistent state
store.addFilter(this.filter);
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
} }
private onSelectedSpaceUpdated = (activeSpace: Room) => { private onSelectedSpaceUpdated = (activeSpace?: Room) => {
this.activeSpace = activeSpace; this.activeSpace = activeSpace;
if (this.filter) {
if (activeSpace) {
this.updateFilter(); this.updateFilter();
} else {
this.store.removeFilter(this.filter);
this.filter = null;
}
} else if (activeSpace) {
this.filter = new SpaceFilterCondition();
this.updateFilter();
this.store.addFilter(this.filter);
}
}; };
private updateFilter = () => { private updateFilter = () => {
if (this.activeSpace) {
SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => { SpaceStore.instance.traverseSpace(this.activeSpace.roomId, roomId => {
this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded(); this.store.matrixClient?.getRoom(roomId)?.loadMembersIfNeeded();
}); });
}
this.filter.updateSpace(this.activeSpace); this.filter.updateSpace(this.activeSpace);
}; };
} }

View File

@ -199,8 +199,10 @@ export class Algorithm extends EventEmitter {
} }
private async doUpdateStickyRoom(val: Room) { private async doUpdateStickyRoom(val: Room) {
if (SettingsStore.getValue("feature_spaces") && 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
if (val?.isSpaceRoom() && val.getMyMembership() !== "invite") val = null; val = null;
}
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing, // Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms. // otherwise we risk duplicating rooms.
@ -577,9 +579,8 @@ export class Algorithm extends EventEmitter {
await this.generateFreshTags(newTags); await this.generateFreshTags(newTags);
this.cachedRooms = newTags; this.cachedRooms = newTags; // this recalculates the filtered rooms for us
this.updateTagsFromCache(); this.updateTagsFromCache();
this.recalculateFilteredRooms();
// Now that we've finished generation, we need to update the sticky room to what // Now that we've finished generation, we need to update the sticky room to what
// 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

View File

@ -19,7 +19,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition"; import { FILTER_CHANGED, FilterKind, IFilterCondition } from "./IFilterCondition";
import { IDestroyable } from "../../../utils/IDestroyable"; import { IDestroyable } from "../../../utils/IDestroyable";
import SpaceStore, {HOME_SPACE} from "../../SpaceStore"; import SpaceStore from "../../SpaceStore";
import { setHasDiff } from "../../../utils/sets"; import { setHasDiff } from "../../../utils/sets";
/** /**
@ -55,10 +55,12 @@ export class SpaceFilterCondition extends EventEmitter implements IFilterConditi
} }
}; };
private getSpaceEventKey = (space: Room | null) => space ? space.roomId : HOME_SPACE; private getSpaceEventKey = (space: Room) => space.roomId;
public updateSpace(space: Room) { public updateSpace(space: Room) {
if (this.space) {
SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate); SpaceStore.instance.off(this.getSpaceEventKey(this.space), this.onStoreUpdate);
}
SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate); SpaceStore.instance.on(this.getSpaceEventKey(this.space = space), this.onStoreUpdate);
this.onStoreUpdate(); // initial update from the change to the space this.onStoreUpdate(); // initial update from the change to the space
} }

View File

@ -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 (room.isSpaceRoom() && SettingsStore.getValue("feature_spaces")) { if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
return false; return false;
} }

View File

@ -17,63 +17,8 @@ limitations under the License.
// Pull in the encryption lib so that we can decrypt attachments. // Pull in the encryption lib so that we can decrypt attachments.
import encrypt from 'browser-encrypt-attachment'; import encrypt from 'browser-encrypt-attachment';
import {mediaFromContent} from "../customisations/Media"; import {mediaFromContent} from "../customisations/Media";
import {IEncryptedFile} from "../customisations/models/IMediaEventContent"; import { IEncryptedFile } from "../customisations/models/IMediaEventContent";
import { getBlobSafeMimeType } from "./blobs";
// WARNING: We have to be very careful about what mime-types we allow into blobs,
// as for performance reasons these are now rendered via URL.createObjectURL()
// rather than by converting into data: URIs.
//
// This means that the content is rendered using the origin of the script which
// called createObjectURL(), and so if the content contains any scripting then it
// will pose a XSS vulnerability when the browser renders it. This is particularly
// bad if the user right-clicks the URI and pastes it into a new window or tab,
// as the blob will then execute with access to Element's full JS environment(!)
//
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
// for details.
//
// We mitigate this by only allowing mime-types into blobs which we know don't
// contain any scripting, and instantiate all others as application/octet-stream
// regardless of what mime-type the event claimed. Even if the payload itself
// is some malicious HTML, the fact we instantiate it with a media mimetype or
// application/octet-stream means the browser doesn't try to render it as such.
//
// One interesting edge case is image/svg+xml, which empirically *is* rendered
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
// *even if the mimetype is application/octet-stream*. However, empirically JS
// in the SVG isn't executed in this scenario, so we seem to be okay.
//
// Tested on Chrome 65 and Firefox 60
//
// The list below is taken mainly from
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
// events, so we pick the ones which HTML5 browsers should be able to display
//
// For the record, mime-types which must NEVER enter this list below include:
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
const ALLOWED_BLOB_MIMETYPES = [
'image/jpeg',
'image/gif',
'image/png',
'video/mp4',
'video/webm',
'video/ogg',
'audio/mp4',
'audio/webm',
'audio/aac',
'audio/mpeg',
'audio/ogg',
'audio/wave',
'audio/wav',
'audio/x-wav',
'audio/x-pn-wav',
'audio/flac',
'audio/x-flac',
];
/** /**
* Decrypt a file attached to a matrix event. * Decrypt a file attached to a matrix event.
@ -100,9 +45,7 @@ export function decryptFile(file: IEncryptedFile): Promise<Blob> {
// browser (e.g. by copying the URI into a new tab or window.) // browser (e.g. by copying the URI into a new tab or window.)
// See warning at top of file. // See warning at top of file.
let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : ''; let mimetype = file.mimetype ? file.mimetype.split(";")[0].trim() : '';
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) { mimetype = getBlobSafeMimeType(mimetype);
mimetype = 'application/octet-stream';
}
return new Blob([dataArray], {type: mimetype}); return new Blob([dataArray], {type: mimetype});
}); });

78
src/utils/blobs.ts Normal file
View File

@ -0,0 +1,78 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// WARNING: We have to be very careful about what mime-types we allow into blobs,
// as for performance reasons these are now rendered via URL.createObjectURL()
// rather than by converting into data: URIs.
//
// This means that the content is rendered using the origin of the script which
// called createObjectURL(), and so if the content contains any scripting then it
// will pose a XSS vulnerability when the browser renders it. This is particularly
// bad if the user right-clicks the URI and pastes it into a new window or tab,
// as the blob will then execute with access to Element's full JS environment(!)
//
// See https://github.com/matrix-org/matrix-react-sdk/pull/1820#issuecomment-385210647
// for details.
//
// We mitigate this by only allowing mime-types into blobs which we know don't
// contain any scripting, and instantiate all others as application/octet-stream
// regardless of what mime-type the event claimed. Even if the payload itself
// is some malicious HTML, the fact we instantiate it with a media mimetype or
// application/octet-stream means the browser doesn't try to render it as such.
//
// One interesting edge case is image/svg+xml, which empirically *is* rendered
// correctly if the blob is set to the src attribute of an img tag (for thumbnails)
// *even if the mimetype is application/octet-stream*. However, empirically JS
// in the SVG isn't executed in this scenario, so we seem to be okay.
//
// Tested on Chrome 65 and Firefox 60
//
// The list below is taken mainly from
// https://developer.mozilla.org/en-US/docs/Web/HTML/Supported_media_formats
// N.B. Matrix doesn't currently specify which mimetypes are valid in given
// events, so we pick the ones which HTML5 browsers should be able to display
//
// For the record, mime-types which must NEVER enter this list below include:
// text/html, text/xhtml, image/svg, image/svg+xml, image/pdf, and similar.
const ALLOWED_BLOB_MIMETYPES = [
'image/jpeg',
'image/gif',
'image/png',
'video/mp4',
'video/webm',
'video/ogg',
'audio/mp4',
'audio/webm',
'audio/aac',
'audio/mpeg',
'audio/ogg',
'audio/wave',
'audio/wav',
'audio/x-wav',
'audio/x-pn-wav',
'audio/flac',
'audio/x-flac',
];
export function getBlobSafeMimeType(mimetype: string): string {
if (!ALLOWED_BLOB_MIMETYPES.includes(mimetype)) {
return 'application/octet-stream';
}
return mimetype;
}

View File

@ -83,6 +83,7 @@ export const showCreateNewRoom = async (cli: MatrixClient, space: Room) => {
if (shouldCreate) { if (shouldCreate) {
await createRoom(opts); await createRoom(opts);
} }
return shouldCreate;
}; };
export const showSpaceInvite = (space: Room, initialText = "") => { export const showSpaceInvite = (space: Room, initialText = "") => {

View File

@ -29,7 +29,7 @@ export enum PlaybackState {
Playing = "playing", // active progress through timeline Playing = "playing", // active progress through timeline
} }
export const PLAYBACK_WAVEFORM_SAMPLES = 35; export const PLAYBACK_WAVEFORM_SAMPLES = 39;
const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES); const DEFAULT_WAVEFORM = arraySeed(0, PLAYBACK_WAVEFORM_SAMPLES);
export class Playback extends EventEmitter implements IDestroyable { export class Playback extends EventEmitter implements IDestroyable {

View File

@ -33,6 +33,8 @@ const BITRATE = 24000; // 24kbps is pretty high quality for our use case in opus
const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files. const TARGET_MAX_LENGTH = 120; // 2 minutes in seconds. Somewhat arbitrary, though longer == larger files.
const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary. const TARGET_WARN_TIME_LEFT = 10; // 10 seconds, also somewhat arbitrary.
export const RECORDING_PLAYBACK_SAMPLES = 44;
export interface IRecordingUpdate { export interface IRecordingUpdate {
waveform: number[]; // floating points between 0 (low) and 1 (high). waveform: number[]; // floating points between 0 (low) and 1 (high).
timeSeconds: number; // float timeSeconds: number; // float

View File

@ -16,7 +16,7 @@ limitations under the License.
import './skinned-sdk'; import './skinned-sdk';
import CallHandler, { PlaceCallType } from '../src/CallHandler'; import CallHandler, { PlaceCallType, CallHandlerEvent } from '../src/CallHandler';
import { stubClient, mkStubRoom } from './test-utils'; import { stubClient, mkStubRoom } from './test-utils';
import { MatrixClientPeg } from '../src/MatrixClientPeg'; import { MatrixClientPeg } from '../src/MatrixClientPeg';
import dis from '../src/dispatcher/dispatcher'; import dis from '../src/dispatcher/dispatcher';
@ -172,11 +172,9 @@ describe('CallHandler', () => {
let callRoomChangeEventCount = 0; let callRoomChangeEventCount = 0;
const roomChangePromise = new Promise<void>(resolve => { const roomChangePromise = new Promise<void>(resolve => {
dispatchHandle = dis.register(payload => { callHandler.addListener(CallHandlerEvent.CallChangeRoom, () => {
if (payload.action === Action.CallChangeRoom) {
++callRoomChangeEventCount; ++callRoomChangeEventCount;
resolve(); resolve();
}
}); });
}); });
@ -201,7 +199,7 @@ describe('CallHandler', () => {
fakeCall.emit(CallEvent.AssertedIdentityChanged); fakeCall.emit(CallEvent.AssertedIdentityChanged);
await roomChangePromise; await roomChangePromise;
dis.unregister(dispatchHandle); callHandler.removeAllListeners();
// If everything's gone well, we should have seen only one room change // If everything's gone well, we should have seen only one room change
// event and the call should now be in user 3's room. // event and the call should now be in user 3's room.

View File

@ -77,7 +77,7 @@ describe('MessagePanel', function() {
DMRoomMap.makeShared(); DMRoomMap.makeShared();
}); });
afterEach(function() { afterEach(function () {
clock.uninstall(); clock.uninstall();
}); });
@ -88,7 +88,21 @@ describe('MessagePanel', function() {
events.push(test_utils.mkMessage( events.push(test_utils.mkMessage(
{ {
event: true, room: "!room:id", user: "@user:id", event: true, room: "!room:id", user: "@user:id",
ts: ts0 + i*1000, ts: ts0 + i * 1000,
}));
}
return events;
}
// Just to avoid breaking Dateseparator tests that might run at 00hrs
function mkOneDayEvents() {
const events = [];
const ts0 = Date.parse('09 May 2004 00:12:00 GMT');
for (let i = 0; i < 10; i++) {
events.push(test_utils.mkMessage(
{
event: true, room: "!room:id", user: "@user:id",
ts: ts0 + i * 1000,
})); }));
} }
return events; return events;
@ -104,7 +118,7 @@ describe('MessagePanel', function() {
let i = 0; let i = 0;
events.push(test_utils.mkMessage({ events.push(test_utils.mkMessage({
event: true, room: "!room:id", user: "@user:id", event: true, room: "!room:id", user: "@user:id",
ts: ts0 + ++i*1000, ts: ts0 + ++i * 1000,
})); }));
for (i = 0; i < 10; i++) { for (i = 0; i < 10; i++) {
@ -151,7 +165,7 @@ describe('MessagePanel', function() {
}, },
getMxcAvatarUrl: () => 'mxc://avatar.url/image.png', getMxcAvatarUrl: () => 'mxc://avatar.url/image.png',
}, },
ts: ts0 + i*1000, ts: ts0 + i * 1000,
mship: 'join', mship: 'join',
prevMship: 'join', prevMship: 'join',
name: 'A user', name: 'A user',
@ -250,7 +264,6 @@ describe('MessagePanel', function() {
}), }),
]; ];
} }
function isReadMarkerVisible(rmContainer) { function isReadMarkerVisible(rmContainer) {
return rmContainer && rmContainer.children.length > 0; return rmContainer && rmContainer.children.length > 0;
} }
@ -437,4 +450,17 @@ describe('MessagePanel', function() {
// read marker should be hidden given props and at the last event // read marker should be hidden given props and at the last event
expect(isReadMarkerVisible(rm)).toBeFalsy(); expect(isReadMarkerVisible(rm)).toBeFalsy();
}); });
it('should render Date separators for the events', function () {
const events = mkOneDayEvents();
const res = mount(
<WrappedMessagePanel
className="cls"
events={events}
/>,
);
const Dates = res.find(sdk.getComponent('messages.DateSeparator'));
expect(Dates.length).toEqual(1);
});
}); });

View File

@ -435,9 +435,9 @@ jsprim@^1.2.2:
verror "1.10.0" verror "1.10.0"
lodash@^4.15.0, lodash@^4.17.11: lodash@^4.15.0, lodash@^4.17.11:
version "4.17.19" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
mime-db@~1.38.0: mime-db@~1.38.0:
version "1.38.0" version "1.38.0"

View File

@ -101,6 +101,7 @@ const invite1 = "!invite1:server";
const invite2 = "!invite2:server"; const invite2 = "!invite2:server";
const room1 = "!room1:server"; const room1 = "!room1:server";
const room2 = "!room2:server"; const room2 = "!room2:server";
const room3 = "!room3:server";
const space1 = "!space1:server"; const space1 = "!space1:server";
const space2 = "!space2:server"; const space2 = "!space2:server";
const space3 = "!space3:server"; const space3 = "!space3:server";
@ -361,8 +362,8 @@ describe("SpaceStore", () => {
expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy();
}); });
it("home space does not contain rooms/low priority from rooms within spaces", () => { it("home space does contain rooms/low priority even if they are also shown in a space", () => {
expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy(); expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy();
}); });
it("space contains child rooms", () => { it("space contains child rooms", () => {
@ -614,8 +615,8 @@ describe("SpaceStore", () => {
describe("space auto switching tests", () => { describe("space auto switching tests", () => {
beforeEach(async () => { beforeEach(async () => {
[room1, room2, orphan1].forEach(mkRoom); [room1, room2, room3, orphan1].forEach(mkRoom);
mkSpace(space1, [room1, room2]); mkSpace(space1, [room1, room2, room3]);
mkSpace(space2, [room1, room2]); mkSpace(space2, [room1, room2]);
client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([
@ -641,15 +642,15 @@ describe("SpaceStore", () => {
it("switch to canonical parent space for room", async () => { it("switch to canonical parent space for room", async () => {
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(null, false); await store.setActiveSpace(client.getRoom(space2), false);
viewRoom(room2); viewRoom(room2);
expect(store.activeSpace).toBe(client.getRoom(space2)); expect(store.activeSpace).toBe(client.getRoom(space2));
}); });
it("switch to first containing space for room", async () => { it("switch to first containing space for room", async () => {
viewRoom(room2); viewRoom(room2);
await store.setActiveSpace(null, false); await store.setActiveSpace(client.getRoom(space2), false);
viewRoom(room1); viewRoom(room3);
expect(store.activeSpace).toBe(client.getRoom(space1)); expect(store.activeSpace).toBe(client.getRoom(space1));
}); });
@ -659,6 +660,13 @@ describe("SpaceStore", () => {
viewRoom(orphan1); viewRoom(orphan1);
expect(store.activeSpace).toBeNull(); expect(store.activeSpace).toBeNull();
}); });
it("when switching rooms in the all rooms home space don't switch to related space", async () => {
viewRoom(room2);
await store.setActiveSpace(null, false);
viewRoom(room1);
expect(store.activeSpace).toBeNull();
});
}); });
describe("traverseSpace", () => { describe("traverseSpace", () => {

202
yarn.lock
View File

@ -1594,6 +1594,11 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/parse5@^6.0.0":
version "6.0.0"
resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.0.tgz#38590dc2c3cf5717154064e3ee9b6947ee21b299"
integrity sha512-oPwPSj4a1wu9rsXTEGIJz91ISU725t0BmSnUhb57sI+M8XEmvUop84lzuiYdq0Y5M6xLY8DBPg0C2xEQKLyvBA==
"@types/prettier@^2.0.0": "@types/prettier@^2.0.0":
version "2.1.6" version "2.1.6"
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03" resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.1.6.tgz#f4b1efa784e8db479cdb8b14403e2144b1e9ff03"
@ -1633,12 +1638,12 @@
"@types/prop-types" "*" "@types/prop-types" "*"
csstype "^3.0.2" csstype "^3.0.2"
"@types/sanitize-html@^1.27.0": "@types/sanitize-html@^2.3.1":
version "1.27.0" version "2.3.1"
resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-1.27.0.tgz#77702dc856f16efecc005014c1d2e45b1f2cbc56" resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.3.1.tgz#094d696b83b7394b016e96342bbffa6a028795ce"
integrity sha512-j7Vnh3P7W4ZcoRsHNO2HpwA2m1d0c2+l39xqSQqH0+WlfcvKypgZp45eCC7NJ75ZyXPxNb2PSbIL6LtZ6E0Qbw== integrity sha512-+UT/XRluJuCunRftwO6OzG6WOBgJ+J3sROIoSJWX+7PB2FtTJTEJLrHCcNwzCQc0r60bej3WAbaigK+VZtZCGw==
dependencies: dependencies:
htmlparser2 "^4.1.0" htmlparser2 "^6.0.0"
"@types/stack-utils@^1.0.1": "@types/stack-utils@^1.0.1":
version "1.0.1" version "1.0.1"
@ -2396,29 +2401,29 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
cheerio-select-tmp@^0.1.0: cheerio-select@^1.4.0:
version "0.1.1" version "1.4.0"
resolved "https://registry.yarnpkg.com/cheerio-select-tmp/-/cheerio-select-tmp-0.1.1.tgz#55bbef02a4771710195ad736d5e346763ca4e646" resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-1.4.0.tgz#3a16f21e37a2ef0f211d6d1aa4eff054bb22cdc9"
integrity sha512-YYs5JvbpU19VYJyj+F7oYrIE2BOll1/hRU7rEy/5+v9BzkSo3bK81iAeeQEMI92vRIxz677m72UmJUiVwwgjfQ== integrity sha512-sobR3Yqz27L553Qa7cK6rtJlMDbiKPdNywtR95Sj/YgfpLfy0u6CGJuaBKe5YE/vTc23SCRKxWSdlon/w6I/Ew==
dependencies: dependencies:
css-select "^3.1.2" css-select "^4.1.2"
css-what "^4.0.0" css-what "^5.0.0"
domelementtype "^2.1.0" domelementtype "^2.2.0"
domhandler "^4.0.0" domhandler "^4.2.0"
domutils "^2.4.4" domutils "^2.6.0"
cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.5: cheerio@^1.0.0-rc.3, cheerio@^1.0.0-rc.9:
version "1.0.0-rc.5" version "1.0.0-rc.9"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.5.tgz#88907e1828674e8f9fee375188b27dadd4f0fa2f" resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.9.tgz#a3ae6b7ce7af80675302ff836f628e7cb786a67f"
integrity sha512-yoqps/VCaZgN4pfXtenwHROTp8NG6/Hlt4Jpz2FEP0ZJQ+ZUkVDd0hAPDNKhj3nakpfPt/CNs57yEtxD1bXQiw== integrity sha512-QF6XVdrLONO6DXRF5iaolY+odmhj2CLj+xzNod7INPWMi/x9X4SOylH0S/vaPpX+AUU6t04s34SQNh7DbkuCng==
dependencies: dependencies:
cheerio-select-tmp "^0.1.0" cheerio-select "^1.4.0"
dom-serializer "~1.2.0" dom-serializer "^1.3.1"
domhandler "^4.0.0" domhandler "^4.2.0"
entities "~2.1.0" htmlparser2 "^6.1.0"
htmlparser2 "^6.0.0" parse5 "^6.0.1"
parse5 "^6.0.0" parse5-htmlparser2-tree-adapter "^6.0.1"
parse5-htmlparser2-tree-adapter "^6.0.0" tslib "^2.2.0"
chokidar@^3.4.0, chokidar@^3.5.1: chokidar@^3.4.0, chokidar@^3.5.1:
version "3.5.1" version "3.5.1"
@ -2700,21 +2705,21 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2:
shebang-command "^2.0.0" shebang-command "^2.0.0"
which "^2.0.1" which "^2.0.1"
css-select@^3.1.2: css-select@^4.1.2:
version "3.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/css-select/-/css-select-3.1.2.tgz#d52cbdc6fee379fba97fb0d3925abbd18af2d9d8" resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.1.2.tgz#8b52b6714ed3a80d8221ec971c543f3b12653286"
integrity sha512-qmss1EihSuBNWNNhHjxzxSfJoFBM/lERB/Q4EnsJQQC62R2evJDW481091oAdOr9uh46/0n4nrg0It5cAnj1RA== integrity sha512-nu5ye2Hg/4ISq4XqdLY2bEatAcLIdt3OYGFc9Tm9n7VSlFBcfRv0gBNksHRgSdUDQGtN3XrZ94ztW+NfzkFSUw==
dependencies: dependencies:
boolbase "^1.0.0" boolbase "^1.0.0"
css-what "^4.0.0" css-what "^5.0.0"
domhandler "^4.0.0" domhandler "^4.2.0"
domutils "^2.4.3" domutils "^2.6.0"
nth-check "^2.0.0" nth-check "^2.0.0"
css-what@^4.0.0: css-what@^5.0.0:
version "4.0.0" version "5.0.0"
resolved "https://registry.yarnpkg.com/css-what/-/css-what-4.0.0.tgz#35e73761cab2eeb3d3661126b23d7aa0e8432233" resolved "https://registry.yarnpkg.com/css-what/-/css-what-5.0.0.tgz#f0bf4f8bac07582722346ab243f6a35b512cfc47"
integrity sha512-teijzG7kwYfNVsUh2H/YN62xW3KK9YhXEgSlbxMlcyjPNvdKJqFx5lrwlJgoFP1ZHlB89iGDlo/JyshKeRhv5A== integrity sha512-qxyKHQvgKwzwDWC/rGbT821eJalfupxYW2qbSJSAtdSTimsr/MlaGONoNLllaUPZWf8QnbcKM/kPVYUQuEKAFA==
cssesc@^3.0.0: cssesc@^3.0.0:
version "3.0.0" version "3.0.0"
@ -2920,9 +2925,9 @@ doctrine@^3.0.0:
esutils "^2.0.2" esutils "^2.0.2"
dom-helpers@^5.0.1: dom-helpers@^5.0.1:
version "5.2.0" version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.0.tgz#57fd054c5f8f34c52a3eeffdb7e7e93cd357d95b" resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
integrity sha512-Ru5o9+V8CpunKnz5LGgWXkmrH/20cGKwcHwS4m73zIvs54CN9epEmT/HLqFJW3kXpakAFkEdzgy1hzlJe3E4OQ== integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==
dependencies: dependencies:
"@babel/runtime" "^7.8.7" "@babel/runtime" "^7.8.7"
csstype "^3.0.2" csstype "^3.0.2"
@ -2935,10 +2940,10 @@ dom-serializer@0:
domelementtype "^2.0.1" domelementtype "^2.0.1"
entities "^2.0.0" entities "^2.0.0"
dom-serializer@^1.0.1, dom-serializer@~1.2.0: dom-serializer@^1.0.1, dom-serializer@^1.3.1:
version "1.2.0" version "1.3.1"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.2.0.tgz#3433d9136aeb3c627981daa385fc7f32d27c48f1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.3.1.tgz#d845a1565d7c041a95e5dab62184ab41e3a519be"
integrity sha512-n6kZFH/KlCrqs/1GHMOd5i2fd/beQHuehKdWvNNffbGHTr/almdhuVvTVFb3V7fglz+nC50fFusu3lY33h12pA== integrity sha512-Pv2ZluG5ife96udGgEDovOOOA5UELkltfJpnIExPrAk1LTvecolUGn6lIaoLh86d83GiB86CjzciMd9BuRB71Q==
dependencies: dependencies:
domelementtype "^2.0.1" domelementtype "^2.0.1"
domhandler "^4.0.0" domhandler "^4.0.0"
@ -2949,10 +2954,10 @@ domelementtype@1, domelementtype@^1.3.1:
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-1.3.1.tgz#d048c44b37b0d10a7f2a3d5fee3f4333d790481f"
integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w== integrity sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==
domelementtype@^2.0.1, domelementtype@^2.1.0: domelementtype@^2.0.1, domelementtype@^2.2.0:
version "2.1.0" version "2.2.0"
resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.1.0.tgz#a851c080a6d1c3d94344aed151d99f669edf585e" resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.2.0.tgz#9a0b6c2782ed6a1c7323d42267183df9bd8b1d57"
integrity sha512-LsTgx/L5VpD+Q8lmsXSHW2WpA+eBlZ9HPf3erD1IoPF00/3JKHZ3BknUVA2QGDNu69ZNmyFmCWBSO45XjYKC5w== integrity sha512-DtBMo82pv1dFtUmHyr48beiuq792Sxohr+8Hm9zoxklYPfa6n0Z3Byjj2IV7bmr2IyqClnqEQhfgHJJ5QF0R5A==
domexception@^2.0.1: domexception@^2.0.1:
version "2.0.1" version "2.0.1"
@ -2968,19 +2973,12 @@ domhandler@^2.3.0:
dependencies: dependencies:
domelementtype "1" domelementtype "1"
domhandler@^3.0.0: domhandler@^4.0.0, domhandler@^4.2.0:
version "3.3.0" version "4.2.0"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-3.3.0.tgz#6db7ea46e4617eb15cf875df68b2b8524ce0037a" resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.2.0.tgz#f9768a5f034be60a89a27c2e4d0f74eba0d8b059"
integrity sha512-J1C5rIANUbuYK+FuFL98650rihynUOEzRLxW+90bKZRWB6A1X1Tf82GxR1qAWLyfNPRvjqfip3Q5tdYlmAa9lA== integrity sha512-zk7sgt970kzPks2Bf+dwT/PLzghLnsivb9CcxkvR8Mzr66Olr0Ofd8neSbglHJHaHa2MadfoSdNlKYAaafmWfA==
dependencies: dependencies:
domelementtype "^2.0.1" domelementtype "^2.2.0"
domhandler@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.0.0.tgz#01ea7821de996d85f69029e81fa873c21833098e"
integrity sha512-KPTbnGQ1JeEMQyO1iYXoagsI6so/C96HZiFyByU3T6iAzpXn8EGEvct6unm1ZGoed8ByO2oirxgwxBmqKF9haA==
dependencies:
domelementtype "^2.1.0"
domutils@^1.5.1: domutils@^1.5.1:
version "1.7.0" version "1.7.0"
@ -2990,14 +2988,14 @@ domutils@^1.5.1:
dom-serializer "0" dom-serializer "0"
domelementtype "1" domelementtype "1"
domutils@^2.0.0, domutils@^2.4.3, domutils@^2.4.4: domutils@^2.4.4, domutils@^2.5.2, domutils@^2.6.0:
version "2.4.4" version "2.6.0"
resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.4.4.tgz#282739c4b150d022d34699797369aad8d19bbbd3" resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.6.0.tgz#2e15c04185d43fb16ae7057cb76433c6edb938b7"
integrity sha512-jBC0vOsECI4OMdD0GC9mGn7NXPLb+Qt6KW1YDQzeQYRUFKmNG8lh7mO5HiELfr+lLQE7loDVI4QcAxV80HS+RA== integrity sha512-y0BezHuy4MDYxh6OvolXYsH+1EMGmFbwv5FKW7ovwMG6zTPWqNPq3WF9ayZssFq+UlKdffGLbOEaghNdaOm1WA==
dependencies: dependencies:
dom-serializer "^1.0.1" dom-serializer "^1.0.1"
domelementtype "^2.0.1" domelementtype "^2.2.0"
domhandler "^4.0.0" domhandler "^4.2.0"
ecc-jsbn@~0.1.1: ecc-jsbn@~0.1.1:
version "0.1.2" version "0.1.2"
@ -3063,7 +3061,7 @@ entities@^1.1.1:
resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.2.tgz#bdfa735299664dfafd34529ed4f8522a275fea56"
integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w== integrity sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==
entities@^2.0.0, entities@~2.1.0: entities@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5" resolved "https://registry.yarnpkg.com/entities/-/entities-2.1.0.tgz#992d3129cf7df6870b96c57858c249a120f8b8b5"
integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w== integrity sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==
@ -4221,9 +4219,9 @@ hoist-non-react-statics@^3.3.0:
react-is "^16.7.0" react-is "^16.7.0"
hosted-git-info@^2.1.4: hosted-git-info@^2.1.4:
version "2.8.8" version "2.8.9"
resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.8.tgz#7539bd4bc1e0e0a895815a2e0262420b12858488" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
integrity sha512-f/wzC2QaWBs7t9IYqB4T3sR1xviIViXJRJTWBlx2Gf3g0Xi5vI7Yy4koXQ1c9OYDGHN9sBy1DQ2AB8fqZBWhUg== integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
hosted-git-info@^3.0.6: hosted-git-info@^3.0.6:
version "3.0.7" version "3.0.7"
@ -4273,16 +4271,6 @@ htmlparser2@^3.10.0:
inherits "^2.0.1" inherits "^2.0.1"
readable-stream "^3.1.1" readable-stream "^3.1.1"
htmlparser2@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-4.1.0.tgz#9a4ef161f2e4625ebf7dfbe6c0a2f52d18a59e78"
integrity sha512-4zDq1a1zhE4gQso/c5LP1OtrhYTncXNSpvJYtWJBtXAETPlMfi3IFNjGuQbYLuVY4ZR0QMqRVvo4Pdy9KLyP8Q==
dependencies:
domelementtype "^2.0.1"
domhandler "^3.0.0"
domutils "^2.0.0"
entities "^2.0.0"
htmlparser2@^6.0.0: htmlparser2@^6.0.0:
version "6.0.0" version "6.0.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.0.0.tgz#c2da005030390908ca4c91e5629e418e0665ac01" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.0.0.tgz#c2da005030390908ca4c91e5629e418e0665ac01"
@ -4293,6 +4281,16 @@ htmlparser2@^6.0.0:
domutils "^2.4.4" domutils "^2.4.4"
entities "^2.0.0" entities "^2.0.0"
htmlparser2@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7"
integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==
dependencies:
domelementtype "^2.0.1"
domhandler "^4.0.0"
domutils "^2.5.2"
entities "^2.0.0"
http-signature@~1.2.0: http-signature@~1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1"
@ -5580,9 +5578,9 @@ lodash.sortby@^4.7.0:
integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=
lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.2.1: lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.2.1:
version "4.17.20" version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
log-symbols@^4.0.0: log-symbols@^4.0.0:
version "4.0.0" version "4.0.0"
@ -5672,8 +5670,8 @@ mathml-tag-names@^2.1.3:
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": "matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop":
version "10.0.0" version "10.1.0"
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/c8f69c0b7937b9064938c134d708c4d064b71315" resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/2d73805ca3d8c5a140fe05e574f826696de1656a"
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
another-json "^0.2.0" another-json "^0.2.0"
@ -6302,7 +6300,12 @@ parse-json@^5.0.0:
json-parse-even-better-errors "^2.3.0" json-parse-even-better-errors "^2.3.0"
lines-and-columns "^1.1.6" lines-and-columns "^1.1.6"
parse5-htmlparser2-tree-adapter@^6.0.0: parse-srcset@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1"
integrity sha1-8r0iH2zJcKk42IVWq8WJyqqiveE=
parse5-htmlparser2-tree-adapter@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6" resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz#2cdf9ad823321140370d4dbf5d3e92c7c8ddc6e6"
integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA== integrity sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==
@ -6314,7 +6317,7 @@ parse5@5.1.1:
resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178" resolved "https://registry.yarnpkg.com/parse5/-/parse5-5.1.1.tgz#f68e4e5ba1852ac2cadc00f4555fff6c2abb6178"
integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug== integrity sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug==
parse5@^6.0.0, parse5@^6.0.1: parse5@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b"
integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==
@ -7243,17 +7246,18 @@ sane@^4.0.3:
minimist "^1.1.1" minimist "^1.1.1"
walker "~1.0.5" walker "~1.0.5"
"sanitize-html@github:apostrophecms/sanitize-html#3c7f93f2058f696f5359e3e58d464161647226db": sanitize-html@^2.3.2:
version "2.0.0-rc.3" version "2.3.3"
resolved "https://codeload.github.com/apostrophecms/sanitize-html/tar.gz/3c7f93f2058f696f5359e3e58d464161647226db" resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.3.3.tgz#3db382c9a621cce4c46d90f10c64f1e9da9e8353"
integrity sha512-DCFXPt7Di0c6JUnlT90eIgrjs6TsJl/8HYU3KLdmrVclFN4O0heTcVbJiMa23OKVr6aR051XYtsgd8EWwEBwUA==
dependencies: dependencies:
deepmerge "^4.2.2" deepmerge "^4.2.2"
escape-string-regexp "^4.0.0" escape-string-regexp "^4.0.0"
htmlparser2 "^4.1.0" htmlparser2 "^6.0.0"
is-plain-object "^5.0.0" is-plain-object "^5.0.0"
klona "^2.0.3" klona "^2.0.3"
parse-srcset "^1.0.2"
postcss "^8.0.2" postcss "^8.0.2"
srcset "^3.0.0"
saxes@^5.0.0: saxes@^5.0.0:
version "5.0.1" version "5.0.1"
@ -7510,11 +7514,6 @@ sprintf-js@~1.0.2:
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw= integrity sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=
srcset@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/srcset/-/srcset-3.0.0.tgz#8afd8b971362dfc129ae9c1a99b3897301ce6441"
integrity sha512-D59vF08Qzu/C4GAOXVgMTLfgryt5fyWo93FZyhEWANo0PokFz/iWdDe13mX3O5TRf6l8vMTqckAfR4zPiaH0yQ==
sshpk@^1.7.0: sshpk@^1.7.0:
version "1.16.1" version "1.16.1"
resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877" resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.1.tgz#fb661c0bef29b39db40769ee39fa70093d6f6877"
@ -7999,6 +7998,11 @@ tslib@^2.0.0, tslib@^2.0.1, tslib@^2.0.3:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A== integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
tslib@^2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c"
integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w==
tsutils@^3.17.1: tsutils@^3.17.1:
version "3.19.1" version "3.19.1"
resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.19.1.tgz#d8566e0c51c82f32f9c25a4d367cd62409a547a9" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.19.1.tgz#d8566e0c51c82f32f9c25a4d367cd62409a547a9"
@ -8070,9 +8074,9 @@ typescript@^4.1.3:
integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg==
ua-parser-js@^0.7.18: ua-parser-js@^0.7.18:
version "0.7.23" version "0.7.28"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.23.tgz#704d67f951e13195fbcd3d78818577f5bc1d547b" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.28.tgz#8ba04e653f35ce210239c64661685bf9121dec31"
integrity sha512-m4hvMLxgGHXG3O3fQVAyyAQpZzDOvwnhOTjYz5Xmr7r/+LpkNy3vJXdVRWgd1TkAb7NGROZuSy96CrlNVjA7KA== integrity sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==
unhomoglyph@^1.0.6: unhomoglyph@^1.0.6:
version "1.0.6" version "1.0.6"