Merge remote-tracking branch 'upstream/develop' into fix/mirroring/5633

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-08-03 16:06:12 +02:00
commit 65451805a2
No known key found for this signature in database
GPG key ID: CC823428E9B582FB
53 changed files with 1554 additions and 719 deletions

View file

@ -1,3 +1,122 @@
Changes in [3.27.0](https://github.com/vector-im/element-desktop/releases/tag/v3.27.0) (2021-07-02)
===================================================================================================
## 🔒 SECURITY FIXES
* Sanitize untrusted variables from message previews before translation
Fixes vector-im/element-web#18314
## ✨ Features
* Fix editing of `<sub>` & `<sup`> & `<u>`
[\#6469](https://github.com/matrix-org/matrix-react-sdk/pull/6469)
Fixes vector-im/element-web#18211
* Zoom images in lightbox to where the cursor points
[\#6418](https://github.com/matrix-org/matrix-react-sdk/pull/6418)
Fixes vector-im/element-web#17870
* Avoid hitting the settings store from TextForEvent
[\#6205](https://github.com/matrix-org/matrix-react-sdk/pull/6205)
Fixes vector-im/element-web#17650
* Initial MSC3083 + MSC3244 support
[\#6212](https://github.com/matrix-org/matrix-react-sdk/pull/6212)
Fixes vector-im/element-web#17686 and vector-im/element-web#17661
* Navigate to the first room with notifications when clicked on space notification dot
[\#5974](https://github.com/matrix-org/matrix-react-sdk/pull/5974)
* Add matrix: to the list of permitted URL schemes
[\#6388](https://github.com/matrix-org/matrix-react-sdk/pull/6388)
* Add "Copy Link" to room context menu
[\#6374](https://github.com/matrix-org/matrix-react-sdk/pull/6374)
* 💭 Message bubble layout
[\#6291](https://github.com/matrix-org/matrix-react-sdk/pull/6291)
Fixes vector-im/element-web#4635, vector-im/element-web#17773 vector-im/element-web#16220 and vector-im/element-web#7687
* Play only one audio file at a time
[\#6417](https://github.com/matrix-org/matrix-react-sdk/pull/6417)
Fixes vector-im/element-web#17439
* Move download button for media to the action bar
[\#6386](https://github.com/matrix-org/matrix-react-sdk/pull/6386)
Fixes vector-im/element-web#17943
* Improved display of one-to-one call history with summary boxes for each call
[\#6121](https://github.com/matrix-org/matrix-react-sdk/pull/6121)
Fixes vector-im/element-web#16409
* Notification settings UI refresh
[\#6352](https://github.com/matrix-org/matrix-react-sdk/pull/6352)
Fixes vector-im/element-web#17782
* Fix EventIndex double handling events and erroring
[\#6385](https://github.com/matrix-org/matrix-react-sdk/pull/6385)
Fixes vector-im/element-web#18008
* Improve reply rendering
[\#3553](https://github.com/matrix-org/matrix-react-sdk/pull/3553)
Fixes vector-im/riot-web#9217, vector-im/riot-web#7633, vector-im/riot-web#7530, vector-im/riot-web#7169, vector-im/riot-web#7151, vector-im/riot-web#6692 vector-im/riot-web#6579 and vector-im/element-web#17440
## 🐛 Bug Fixes
* Fix CreateRoomDialog exploding when making public room outside of a space
[\#6493](https://github.com/matrix-org/matrix-react-sdk/pull/6493)
* Fix regression where registration would soft-crash on captcha
[\#6505](https://github.com/matrix-org/matrix-react-sdk/pull/6505)
Fixes vector-im/element-web#18284
* only send join rule event if we have a join rule to put in it
[\#6517](https://github.com/matrix-org/matrix-react-sdk/pull/6517)
* Improve the new download button's discoverability and interactions.
[\#6510](https://github.com/matrix-org/matrix-react-sdk/pull/6510)
* Fix voice recording UI looking broken while microphone permissions are being requested.
[\#6479](https://github.com/matrix-org/matrix-react-sdk/pull/6479)
Fixes vector-im/element-web#18223
* Match colors of room and user avatars in DMs
[\#6393](https://github.com/matrix-org/matrix-react-sdk/pull/6393)
Fixes vector-im/element-web#2449
* Fix onPaste handler to work with copying files from Finder
[\#5389](https://github.com/matrix-org/matrix-react-sdk/pull/5389)
Fixes vector-im/element-web#15536 and vector-im/element-web#16255
* Fix infinite pagination loop when offline
[\#6478](https://github.com/matrix-org/matrix-react-sdk/pull/6478)
Fixes vector-im/element-web#18242
* Fix blurhash rounded corners missing regression
[\#6467](https://github.com/matrix-org/matrix-react-sdk/pull/6467)
Fixes vector-im/element-web#18110
* Fix position of the space hierarchy spinner
[\#6462](https://github.com/matrix-org/matrix-react-sdk/pull/6462)
Fixes vector-im/element-web#18182
* Fix display of image messages that lack thumbnails
[\#6456](https://github.com/matrix-org/matrix-react-sdk/pull/6456)
Fixes vector-im/element-web#18175
* Fix crash with large audio files.
[\#6436](https://github.com/matrix-org/matrix-react-sdk/pull/6436)
Fixes vector-im/element-web#18149
* Make diff colors in codeblocks more pleasant
[\#6355](https://github.com/matrix-org/matrix-react-sdk/pull/6355)
Fixes vector-im/element-web#17939
* Show the correct audio file duration while loading the file.
[\#6435](https://github.com/matrix-org/matrix-react-sdk/pull/6435)
Fixes vector-im/element-web#18160
* Fix various timeline settings not applying immediately.
[\#6261](https://github.com/matrix-org/matrix-react-sdk/pull/6261)
Fixes vector-im/element-web#17748
* Fix issues with room list duplication
[\#6391](https://github.com/matrix-org/matrix-react-sdk/pull/6391)
Fixes vector-im/element-web#14508
* Fix grecaptcha throwing useless error sometimes
[\#6401](https://github.com/matrix-org/matrix-react-sdk/pull/6401)
Fixes vector-im/element-web#15142
* Update Emojibase and Twemoji and switch to IamCal (Slack-style) shortcodes
[\#6347](https://github.com/matrix-org/matrix-react-sdk/pull/6347)
Fixes vector-im/element-web#13857 and vector-im/element-web#13334
* Respect compound emojis in default avatar initial generation
[\#6397](https://github.com/matrix-org/matrix-react-sdk/pull/6397)
Fixes vector-im/element-web#18040
* Fix bug where the 'other homeserver' field in the server selection dialog would become briefly focus and then unfocus when clicked.
[\#6394](https://github.com/matrix-org/matrix-react-sdk/pull/6394)
Fixes vector-im/element-web#18031
* Standardise spelling and casing of homeserver, identity server, and integration manager
[\#6365](https://github.com/matrix-org/matrix-react-sdk/pull/6365)
* Fix widgets not receiving decrypted events when they have permission.
[\#6371](https://github.com/matrix-org/matrix-react-sdk/pull/6371)
Fixes vector-im/element-web#17615
* Prevent client hangs when calculating blurhashes
[\#6366](https://github.com/matrix-org/matrix-react-sdk/pull/6366)
Fixes vector-im/element-web#17945
* Exclude state events from widgets reading room events
[\#6378](https://github.com/matrix-org/matrix-react-sdk/pull/6378)
* Cache feature_spaces\* flags to improve performance
[\#6381](https://github.com/matrix-org/matrix-react-sdk/pull/6381)
Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19) Changes in [3.26.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.26.0) (2021-07-19)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.26.0-rc.1...v3.26.0)

View file

@ -34,7 +34,7 @@ All code lands on the `develop` branch - `master` is only used for stable releas
**Please file PRs against `develop`!!** **Please file PRs against `develop`!!**
Please follow the standard Matrix contributor's guide: Please follow the standard Matrix contributor's guide:
https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.rst https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md
Please follow the Matrix JS/React code style as per: Please follow the Matrix JS/React code style as per:
https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md https://github.com/matrix-org/matrix-react-sdk/blob/master/code_style.md

View file

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

View file

@ -297,7 +297,7 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpaceButton:hover, .mx_SpaceButton:hover,
.mx_SpaceButton:focus-within, .mx_SpaceButton:focus-within,
.mx_SpaceButton_hasMenuOpen { .mx_SpaceButton_hasMenuOpen {
&:not(.mx_SpaceButton_home):not(.mx_SpaceButton_invite) { &:not(.mx_SpaceButton_invite) {
// Hide the badge container on hover because it'll be a menu button // Hide the badge container on hover because it'll be a menu button
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
width: 0; width: 0;
@ -368,6 +368,14 @@ $activeBorderColor: $secondary-fg-color;
.mx_SpacePanel_iconExplore::before { .mx_SpacePanel_iconExplore::before {
mask-image: url('$(res)/img/element-icons/roomlist/browse.svg'); mask-image: url('$(res)/img/element-icons/roomlist/browse.svg');
} }
.mx_SpacePanel_noIcon {
display: none;
& + .mx_IconizedContextMenu_label {
padding-left: 5px !important; // override default iconized label style to align with header
}
}
} }

View file

@ -149,12 +149,17 @@ limitations under the License.
} }
} }
.mx_IconizedContextMenu_checked { .mx_IconizedContextMenu_checked,
.mx_IconizedContextMenu_unchecked {
margin-left: 16px; margin-left: 16px;
margin-right: -5px; margin-right: -5px;
}
&::before { .mx_IconizedContextMenu_checked::before {
mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg'); mask-image: url('$(res)/img/element-icons/roomlist/checkmark.svg');
} }
.mx_IconizedContextMenu_unchecked::before {
content: unset;
} }
} }

View file

@ -23,7 +23,7 @@ limitations under the License.
background-color: $dark-panel-bg-color; background-color: $dark-panel-bg-color;
border-radius: 8px; border-radius: 8px;
margin: 10px auto; margin: 10px auto;
max-width: 75%; width: 75%;
box-sizing: border-box; box-sizing: border-box;
height: 60px; height: 60px;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016, 2021 The Matrix.org Foundation C.I.C. Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -60,6 +60,8 @@ limitations under the License.
} }
.mx_MFileBody_info { .mx_MFileBody_info {
cursor: pointer;
.mx_MFileBody_info_icon { .mx_MFileBody_info_icon {
background-color: $message-body-panel-icon-bg-color; background-color: $message-body-panel-icon-bg-color;
border-radius: 20px; border-radius: 20px;

View file

@ -280,6 +280,11 @@ limitations under the License.
margin-right: 5px; margin-right: 5px;
} }
.mx_EventTile_line,
.mx_EventTile_info {
min-width: 100%;
}
.mx_EventTile_e2eIcon { .mx_EventTile_e2eIcon {
margin-left: 9px; margin-left: 9px;
} }

View file

@ -459,8 +459,14 @@ $hover-select-border: 4px;
/* Various markdown overrides */ /* Various markdown overrides */
.mx_EventTile_body pre { .mx_EventTile_body {
border: 1px solid transparent; a:hover {
text-decoration: underline;
}
pre {
border: 1px solid transparent;
}
} }
.mx_EventTile_content .markdown-body { .mx_EventTile_content .markdown-body {

View file

@ -16,6 +16,7 @@ limitations under the License.
.mx_ProfileSettings_controls_topic { .mx_ProfileSettings_controls_topic {
& > textarea { & > textarea {
font-family: inherit;
resize: vertical; resize: vertical;
} }
} }

View file

@ -72,6 +72,13 @@ limitations under the License.
padding-right: 10px; padding-right: 10px;
} }
.mx_SettingsTab_section .mx_SettingsFlag .mx_SettingsFlag_microcopy {
margin-top: 4px;
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-fg-color;
}
.mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch { .mx_SettingsTab_section .mx_SettingsFlag .mx_ToggleSwitch {
float: right; float: right;
} }

View file

@ -76,16 +76,22 @@ limitations under the License.
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
// We don't want to collide with the call controls that have 52px of height // We don't want to collide with the call controls that have 52px of height
padding-bottom: 52px; margin-bottom: 52px;
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
} }
&.mx_VideoFeed_video { .mx_VideoFeed_video {
height: 100%;
background-color: #000; background-color: #000;
} }
.mx_VideoFeed_mic {
left: 10px;
bottom: 10px;
}
} }
} }

View file

@ -35,12 +35,23 @@ limitations under the License.
width: 100%; width: 100%;
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
border-radius: 4px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
} }
.mx_VideoFeed_video {
border-radius: 4px;
}
.mx_VideoFeed_mic {
left: 6px;
bottom: 6px;
}
} }
&.mx_CallViewSidebar_pipMode { &.mx_CallViewSidebar_pipMode {

View file

@ -69,7 +69,6 @@ limitations under the License.
overflow: hidden; overflow: hidden;
max-width: 185px; max-width: 185px;
text-align: left; text-align: left;
direction: rtl;
padding: 8px 0px; padding: 8px 0px;
background-color: rgb(0, 0, 0, 0); background-color: rgb(0, 0, 0, 0);
} }

View file

@ -15,18 +15,52 @@ limitations under the License.
*/ */
.mx_VideoFeed { .mx_VideoFeed {
border-radius: 4px; overflow: hidden;
position: relative;
&.mx_VideoFeed_voice { &.mx_VideoFeed_voice {
background-color: $inverted-bg-color; background-color: $inverted-bg-color;
} }
&.mx_VideoFeed_video { .mx_VideoFeed_video {
width: 100%;
background-color: transparent; background-color: transparent;
&.mx_VideoFeed_video_mirror {
transform: scale(-1, 1);
}
}
.mx_VideoFeed_mic {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background-color: rgba(0, 0, 0, 0.5); // Same on both themes
border-radius: 100%;
&::before {
position: absolute;
content: "";
width: 16px;
height: 16px;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
background-color: white; // Same on both themes
border-radius: 7px;
}
&.mx_VideoFeed_mic_muted::before {
mask-image: url('$(res)/img/voip/mic-muted.svg');
}
&.mx_VideoFeed_mic_unmuted::before {
mask-image: url('$(res)/img/voip/mic-unmuted.svg');
}
} }
} }
.mx_VideoFeed_mirror {
transform: scale(-1, 1);
}

View file

@ -0,0 +1,5 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.9206 1.0544C1.68141 0.815201 1.29359 0.815201 1.0544 1.0544C0.815201 1.29359 0.815201 1.68141 1.0544 1.9206L4.55 5.41621V7C4.55 8.3531 5.6469 9.45 7 9.45C7.45436 9.45 7.87983 9.32632 8.24458 9.11079L9.12938 9.99558C8.52863 10.4234 7.7937 10.675 7 10.675C4.97035 10.675 3.325 9.02965 3.325 7C3.325 6.66173 3.05077 6.3875 2.7125 6.3875C2.37423 6.3875 2.1 6.66173 2.1 7C2.1 9.49877 3.97038 11.5607 6.3875 11.8621V12.5125C6.3875 12.8508 6.66173 13.125 7 13.125C7.33827 13.125 7.6125 12.8508 7.6125 12.5125V11.8621C8.50718 11.7505 9.32696 11.3978 10.0047 10.8709L12.0794 12.9456C12.3186 13.1848 12.7064 13.1848 12.9456 12.9456C13.1848 12.7064 13.1848 12.3186 12.9456 12.0794L1.9206 1.0544Z" fill="white"/>
<path d="M10.5474 7.96338L11.5073 8.92525C11.7601 8.33424 11.9 7.68346 11.9 7C11.9 6.66173 11.6258 6.3875 11.2875 6.3875C10.9492 6.3875 10.675 6.66173 10.675 7C10.675 7.33336 10.6306 7.65634 10.5474 7.96338Z" fill="white"/>
<path d="M4.81385 2.21784L9.45 6.86366V3.325C9.45 1.9719 8.3531 0.875 7 0.875C6.04532 0.875 5.21818 1.42104 4.81385 2.21784Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,4 @@
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.4645 3.29384C4.4645 1.95795 5.59973 0.875 7.0001 0.875C8.40048 0.875 9.53571 1.95795 9.53571 3.29384V6.91127C9.53571 8.24716 8.40048 9.33011 7.0001 9.33011C5.59973 9.33011 4.4645 8.24716 4.4645 6.91127V3.29384Z" fill="white"/>
<path d="M2.56269 6.1391C3.01153 6.1391 3.37539 6.4862 3.37539 6.91437C3.37539 8.81701 4.99198 10.3617 6.99032 10.3666C6.99359 10.3666 6.99686 10.3666 7.00014 10.3666C7.0034 10.3666 7.00665 10.3666 7.0099 10.3666C9.00814 10.3616 10.6246 8.81694 10.6246 6.91437C10.6246 6.4862 10.9885 6.1391 11.4373 6.1391C11.8861 6.1391 12.25 6.4862 12.25 6.91437C12.25 9.41469 10.3257 11.4854 7.81283 11.8576V12.3497C7.81283 12.7779 7.44898 13.125 7.00014 13.125C6.5513 13.125 6.18744 12.7779 6.18744 12.3497V11.8576C3.67448 11.4855 1.75 9.41478 1.75 6.91437C1.75 6.4862 2.11386 6.1391 2.56269 6.1391Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 945 B

View file

@ -25,11 +25,44 @@ import { Action } from './dispatcher/actions';
import defaultDispatcher from './dispatcher/dispatcher'; import defaultDispatcher from './dispatcher/dispatcher';
import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload'; import { SetRightPanelPhasePayload } from './dispatcher/payloads/SetRightPanelPhasePayload';
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { MatrixClientPeg } from "./MatrixClientPeg";
// These functions are frequently used just to check whether an event has // These functions are frequently used just to check whether an event has
// any text to display at all. For this reason they return deferred values // any text to display at all. For this reason they return deferred values
// to avoid the expense of looking up translations when they're not needed. // to avoid the expense of looking up translations when they're not needed.
function textForCallInviteEvent(event: MatrixEvent): () => string | null {
const getSenderName = () => event.sender ? event.sender.name : _t('Someone');
// FIXME: Find a better way to determine this from the event?
let isVoice = true;
if (event.getContent().offer && event.getContent().offer.sdp &&
event.getContent().offer.sdp.indexOf('m=video') !== -1) {
isVoice = false;
}
const isSupported = MatrixClientPeg.get().supportsVoip();
// This ladder could be reduced down to a couple string variables, however other languages
// can have a hard time translating those strings. In an effort to make translations easier
// and more accurate, we break out the string-based variables to a couple booleans.
if (isVoice && isSupported) {
return () => _t("%(senderName)s placed a voice call.", {
senderName: getSenderName(),
});
} else if (isVoice && !isSupported) {
return () => _t("%(senderName)s placed a voice call. (not supported by this browser)", {
senderName: getSenderName(),
});
} else if (!isVoice && isSupported) {
return () => _t("%(senderName)s placed a video call.", {
senderName: getSenderName(),
});
} else if (!isVoice && !isSupported) {
return () => _t("%(senderName)s placed a video call. (not supported by this browser)", {
senderName: getSenderName(),
});
}
}
function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null { function textForMemberEvent(ev: MatrixEvent, allowJSX: boolean, showHiddenEvents?: boolean): () => string | null {
// XXX: SYJS-16 "sender is sometimes null for join messages" // XXX: SYJS-16 "sender is sometimes null for join messages"
const senderName = ev.sender ? ev.sender.name : ev.getSender(); const senderName = ev.sender ? ev.sender.name : ev.getSender();
@ -567,6 +600,7 @@ interface IHandlers {
const handlers: IHandlers = { const handlers: IHandlers = {
'm.room.message': textForMessageEvent, 'm.room.message': textForMessageEvent,
'm.call.invite': textForCallInviteEvent,
}; };
const stateHandlers: IHandlers = { const stateHandlers: IHandlers = {

View file

@ -16,7 +16,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { CSSProperties, RefObject, useRef, useState } from "react"; import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import classNames from "classnames"; import classNames from "classnames";
@ -471,10 +471,14 @@ type ContextMenuTuple<T> = [boolean, RefObject<T>, () => void, () => void, (val:
export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => { export const useContextMenu = <T extends any = HTMLElement>(): ContextMenuTuple<T> => {
const button = useRef<T>(null); const button = useRef<T>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const open = () => { const open = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(true); setIsOpen(true);
}; };
const close = () => { const close = (ev?: SyntheticEvent) => {
ev?.preventDefault();
ev?.stopPropagation();
setIsOpen(false); setIsOpen(false);
}; };

View file

@ -49,6 +49,13 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
this.props.onFinished(); this.props.onFinished();
}; };
onKeyDown = (ev) => {
// Prevent Backspace and Delete keys from functioning in the entry field
if (ev.code === "Backspace" || ev.code === "Delete") {
ev.preventDefault();
}
};
onChange = (ev) => { onChange = (ev) => {
this.setState({ value: ev.target.value }); this.setState({ value: ev.target.value });
}; };
@ -64,6 +71,7 @@ export default class DialpadContextMenu extends React.Component<IProps, IState>
className="mx_DialPadContextMenu_dialled" className="mx_DialPadContextMenu_dialled"
value={this.state.value} value={this.state.value}
autoFocus={true} autoFocus={true}
onKeyDown={this.onKeyDown}
onChange={this.onChange} onChange={this.onChange}
/> />
</div> </div>

View file

@ -86,7 +86,10 @@ export const IconizedContextMenuCheckbox: React.FC<ICheckboxProps> = ({
> >
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} /> <span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span> <span className="mx_IconizedContextMenu_label">{ label }</span>
{ active && <span className="mx_IconizedContextMenu_icon mx_IconizedContextMenu_checked" /> } <span className={classNames("mx_IconizedContextMenu_icon", {
mx_IconizedContextMenu_checked: active,
mx_IconizedContextMenu_unchecked: !active,
})} />
</MenuItemCheckbox>; </MenuItemCheckbox>;
}; };

View file

@ -0,0 +1,216 @@
/*
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, { useContext } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import {
IProps as IContextMenuProps,
} from "../../structures/ContextMenu";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { _t } from "../../../languageHandler";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { ButtonEvent } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { Action } from "../../../dispatcher/actions";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { BetaPill } from "../beta/BetaCard";
interface IProps extends IContextMenuProps {
space: Room;
}
const SpaceContextMenu = ({ space, onFinished, ...props }: IProps) => {
const cli = useContext(MatrixClientContext);
const userId = cli.getUserId();
let inviteOption;
if (space.getJoinRule() === "public" || space.canInvite(userId)) {
const onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(space);
onFinished();
};
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(space)) {
const onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(space);
onFinished();
};
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={onSettingsClick}
/>
);
} else {
const onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
leaveSpace(space);
onFinished();
};
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
const onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(space);
onFinished();
};
const onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(space);
onFinished();
};
const onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewSubspace(space);
onFinished();
};
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
const onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: space },
});
onFinished();
};
const onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: space.roomId,
});
onFinished();
};
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
};
export default SpaceContextMenu;

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { IKeyBackupInfo } from "matrix-js-sdk/src/crypto/keybackup";
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
@ -24,19 +25,25 @@ import { MatrixClientPeg } from '../../../MatrixClientPeg';
import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog'; import RestoreKeyBackupDialog from './security/RestoreKeyBackupDialog';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps {
onFinished: (success: boolean) => void;
}
interface IState {
shouldLoadBackupStatus: boolean;
loading: boolean;
backupInfo: IKeyBackupInfo;
error?: string;
}
@replaceableComponent("views.dialogs.LogoutDialog") @replaceableComponent("views.dialogs.LogoutDialog")
export default class LogoutDialog extends React.Component { export default class LogoutDialog extends React.Component<IProps, IState> {
defaultProps = { static defaultProps = {
onFinished: function() {}, onFinished: function() {},
}; };
constructor() { constructor(props) {
super(); super(props);
this._onSettingsLinkClick = this._onSettingsLinkClick.bind(this);
this._onExportE2eKeysClicked = this._onExportE2eKeysClicked.bind(this);
this._onFinished = this._onFinished.bind(this);
this._onSetRecoveryMethodClick = this._onSetRecoveryMethodClick.bind(this);
this._onLogoutConfirm = this._onLogoutConfirm.bind(this);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled(); const shouldLoadBackupStatus = cli.isCryptoEnabled() && !cli.getKeyBackupEnabled();
@ -49,11 +56,11 @@ export default class LogoutDialog extends React.Component {
}; };
if (shouldLoadBackupStatus) { if (shouldLoadBackupStatus) {
this._loadBackupStatus(); this.loadBackupStatus();
} }
} }
async _loadBackupStatus() { private async loadBackupStatus() {
try { try {
const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion(); const backupInfo = await MatrixClientPeg.get().getKeyBackupVersion();
this.setState({ this.setState({
@ -69,29 +76,29 @@ export default class LogoutDialog extends React.Component {
} }
} }
_onSettingsLinkClick() { private onSettingsLinkClick = (): void => {
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(true);
} };
_onExportE2eKeysClicked() { private onExportE2eKeysClicked = (): void => {
Modal.createTrackedDialogAsync('Export E2E Keys', '', Modal.createTrackedDialogAsync('Export E2E Keys', '',
import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'), import('../../../async-components/views/dialogs/security/ExportE2eKeysDialog'),
{ {
matrixClient: MatrixClientPeg.get(), matrixClient: MatrixClientPeg.get(),
}, },
); );
} };
_onFinished(confirmed) { private onFinished = (confirmed: boolean): void => {
if (confirmed) { if (confirmed) {
dis.dispatch({ action: 'logout' }); dis.dispatch({ action: 'logout' });
} }
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(confirmed);
} };
_onSetRecoveryMethodClick() { private onSetRecoveryMethodClick = (): void => {
if (this.state.backupInfo) { if (this.state.backupInfo) {
// A key backup exists for this account, but the creating device is not // A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and // verified, so restore the backup which will give us the keys from it and
@ -108,15 +115,15 @@ export default class LogoutDialog extends React.Component {
} }
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(true);
} };
_onLogoutConfirm() { private onLogoutConfirm = (): void => {
dis.dispatch({ action: 'logout' }); dis.dispatch({ action: 'logout' });
// close dialog // close dialog
this.props.onFinished(); this.props.onFinished(true);
} };
render() { render() {
if (this.state.shouldLoadBackupStatus) { if (this.state.shouldLoadBackupStatus) {
@ -152,16 +159,16 @@ export default class LogoutDialog extends React.Component {
</div> </div>
<DialogButtons primaryButton={setupButtonCaption} <DialogButtons primaryButton={setupButtonCaption}
hasCancel={false} hasCancel={false}
onPrimaryButtonClick={this._onSetRecoveryMethodClick} onPrimaryButtonClick={this.onSetRecoveryMethodClick}
focus={true} focus={true}
> >
<button onClick={this._onLogoutConfirm}> <button onClick={this.onLogoutConfirm}>
{ _t("I don't want my encrypted messages") } { _t("I don't want my encrypted messages") }
</button> </button>
</DialogButtons> </DialogButtons>
<details> <details>
<summary>{ _t("Advanced") }</summary> <summary>{ _t("Advanced") }</summary>
<p><button onClick={this._onExportE2eKeysClicked}> <p><button onClick={this.onExportE2eKeysClicked}>
{ _t("Manually export keys") } { _t("Manually export keys") }
</button></p> </button></p>
</details> </details>
@ -174,7 +181,7 @@ export default class LogoutDialog extends React.Component {
title={_t("You'll lose access to your encrypted messages")} title={_t("You'll lose access to your encrypted messages")}
contentId='mx_Dialog_content' contentId='mx_Dialog_content'
hasCancel={true} hasCancel={true}
onFinished={this._onFinished} onFinished={this.onFinished}
> >
{ dialogContent } { dialogContent }
</BaseDialog>); </BaseDialog>);
@ -187,7 +194,7 @@ export default class LogoutDialog extends React.Component {
"Are you sure you want to sign out?", "Are you sure you want to sign out?",
)} )}
button={_t("Sign out")} button={_t("Sign out")}
onFinished={this._onFinished} onFinished={this.onFinished}
/>); />);
} }
} }

View file

@ -16,12 +16,13 @@ limitations under the License.
import { MatrixEvent } from "matrix-js-sdk/src"; import { MatrixEvent } from "matrix-js-sdk/src";
import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import React, { createRef } from "react"; import React from "react";
import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex"; import { RovingAccessibleTooltipButton } from "../../../accessibility/RovingTabIndex";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import classNames from "classnames"; import classNames from "classnames";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { FileDownloader } from "../../../utils/FileDownloader";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -39,7 +40,7 @@ interface IState {
@replaceableComponent("views.messages.DownloadActionButton") @replaceableComponent("views.messages.DownloadActionButton")
export default class DownloadActionButton extends React.PureComponent<IProps, IState> { export default class DownloadActionButton extends React.PureComponent<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef(); private downloader = new FileDownloader();
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@ -56,27 +57,21 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
if (this.state.blob) { if (this.state.blob) {
// Cheat and trigger a download, again. // Cheat and trigger a download, again.
return this.onFrameLoad(); return this.doDownload();
} }
const blob = await this.props.mediaEventHelperGet().sourceBlob.value; const blob = await this.props.mediaEventHelperGet().sourceBlob.value;
this.setState({ blob }); this.setState({ blob });
await this.doDownload();
}; };
private onFrameLoad = () => { private async doDownload() {
this.setState({ loading: false }); await this.downloader.download({
// we aren't showing the iframe, so we can send over the bare minimum styles and such.
this.iframe.current.contentWindow.postMessage({
imgSrc: "", // no image
imgStyle: null,
style: "",
blob: this.state.blob, blob: this.state.blob,
download: this.props.mediaEventHelperGet().fileName, name: this.props.mediaEventHelperGet().fileName,
textContent: "", });
auto: true, // autodownload this.setState({ loading: false });
}, '*'); }
};
public render() { public render() {
let spinner: JSX.Element; let spinner: JSX.Element;
@ -92,18 +87,11 @@ export default class DownloadActionButton extends React.PureComponent<IProps, IS
return <RovingAccessibleTooltipButton return <RovingAccessibleTooltipButton
className={classes} className={classes}
title={spinner ? _t("Downloading") : _t("Download")} title={spinner ? _t("Decrypting") : _t("Download")}
onClick={this.onDownloadClick} onClick={this.onDownloadClick}
disabled={!!spinner} disabled={!!spinner}
> >
{ spinner } { spinner }
{ this.state.blob && <iframe
src="usercontent/" // XXX: Like MFileBody, this should come from the skin
ref={this.iframe}
onLoad={this.onFrameLoad}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation"
style={{ display: "none" }}
/> }
</RovingAccessibleTooltipButton>; </RovingAccessibleTooltipButton>;
} }
} }

View file

@ -26,6 +26,8 @@ import { TileShape } from "../rooms/EventTile";
import { presentableTextForFile } from "../../../utils/FileUtils"; import { presentableTextForFile } from "../../../utils/FileUtils";
import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent"; import { IMediaEventContent } from "../../../customisations/models/IMediaEventContent";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
import { FileDownloader } from "../../../utils/FileDownloader";
import TextWithTooltip from "../elements/TextWithTooltip";
export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on export let DOWNLOAD_ICON_URL; // cached copy of the download.svg asset for the sandboxed iframe later on
@ -111,6 +113,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
private iframe: React.RefObject<HTMLIFrameElement> = createRef(); private iframe: React.RefObject<HTMLIFrameElement> = createRef();
private dummyLink: React.RefObject<HTMLAnchorElement> = createRef(); private dummyLink: React.RefObject<HTMLAnchorElement> = createRef();
private userDidClick = false; private userDidClick = false;
private fileDownloader: FileDownloader = new FileDownloader(() => this.iframe.current);
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@ -118,6 +121,32 @@ export default class MFileBody extends React.Component<IProps, IState> {
this.state = {}; this.state = {};
} }
private get content(): IMediaEventContent {
return this.props.mxEvent.getContent<IMediaEventContent>();
}
private get fileName(): string {
return this.content.body && this.content.body.length > 0 ? this.content.body : _t("Attachment");
}
private get linkText(): string {
return presentableTextForFile(this.content);
}
private downloadFile(fileName: string, text: string) {
this.fileDownloader.download({
blob: this.state.decryptedBlob,
name: fileName,
autoDownload: this.userDidClick,
opts: {
imgSrc: DOWNLOAD_ICON_URL,
imgStyle: null,
style: computedStyle(this.dummyLink.current),
textContent: _t("Download %(text)s", { text }),
},
});
}
private getContentUrl(): string { private getContentUrl(): string {
const media = mediaFromContent(this.props.mxEvent.getContent()); const media = mediaFromContent(this.props.mxEvent.getContent());
return media.srcHttp; return media.srcHttp;
@ -129,24 +158,56 @@ export default class MFileBody extends React.Component<IProps, IState> {
} }
} }
public render() { private decryptFile = async (): Promise<void> => {
const content = this.props.mxEvent.getContent<IMediaEventContent>(); if (this.state.decryptedBlob) {
const text = presentableTextForFile(content); return;
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted; }
const fileName = content.body && content.body.length > 0 ? content.body : _t("Attachment"); try {
const contentUrl = this.getContentUrl(); this.userDidClick = true;
const fileSize = content.info ? content.info.size : null; this.setState({
const fileType = content.info ? content.info.mimetype : "application/octet-stream"; decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
let placeholder = null; private onPlaceholderClick = async () => {
const mediaHelper = this.props.mediaEventHelper;
if (mediaHelper.media.isEncrypted) {
await this.decryptFile();
this.downloadFile(this.fileName, this.linkText);
} else {
// As a button we're missing the `download` attribute for styling reasons, so
// download with the file downloader.
this.fileDownloader.download({
blob: await mediaHelper.sourceBlob.value,
name: this.fileName,
});
}
};
public render() {
const isEncrypted = this.props.mediaEventHelper.media.isEncrypted;
const contentUrl = this.getContentUrl();
const fileSize = this.content.info ? this.content.info.size : null;
const fileType = this.content.info ? this.content.info.mimetype : "application/octet-stream";
let placeholder: React.ReactNode = null;
if (this.props.showGenericPlaceholder) { if (this.props.showGenericPlaceholder) {
placeholder = ( placeholder = (
<div className="mx_MediaBody mx_MFileBody_info"> <AccessibleButton className="mx_MediaBody mx_MFileBody_info" onClick={this.onPlaceholderClick}>
<span className="mx_MFileBody_info_icon" /> <span className="mx_MFileBody_info_icon" />
<span className="mx_MFileBody_info_filename"> <TextWithTooltip tooltip={presentableTextForFile(this.content, _t("Attachment"), true)}>
{ presentableTextForFile(content, _t("Attachment"), false) } <span className="mx_MFileBody_info_filename">
</span> { presentableTextForFile(this.content, _t("Attachment"), true, true) }
</div> </span>
</TextWithTooltip>
</AccessibleButton>
); );
} }
@ -157,20 +218,6 @@ export default class MFileBody extends React.Component<IProps, IState> {
// Need to decrypt the attachment // Need to decrypt the attachment
// Wait for the user to click on the link before downloading // Wait for the user to click on the link before downloading
// and decrypting the attachment. // and decrypting the attachment.
const decrypt = async () => {
try {
this.userDidClick = true;
this.setState({
decryptedBlob: await this.props.mediaEventHelper.sourceBlob.value,
});
} catch (err) {
console.warn("Unable to decrypt attachment: ", err);
Modal.createTrackedDialog('Error decrypting attachment', '', ErrorDialog, {
title: _t("Error"),
description: _t("Error decrypting attachment"),
});
}
};
// This button should actually Download because usercontent/ will try to click itself // This button should actually Download because usercontent/ will try to click itself
// but it is not guaranteed between various browsers' settings. // but it is not guaranteed between various browsers' settings.
@ -178,31 +225,14 @@ export default class MFileBody extends React.Component<IProps, IState> {
<span className="mx_MFileBody"> <span className="mx_MFileBody">
{ placeholder } { placeholder }
{ showDownloadLink && <div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<AccessibleButton onClick={decrypt}> <AccessibleButton onClick={this.decryptFile}>
{ _t("Decrypt %(text)s", { text: text }) } { _t("Decrypt %(text)s", { text: this.linkText }) }
</AccessibleButton> </AccessibleButton>
</div> } </div> }
</span> </span>
); );
} }
// When the iframe loads we tell it to render a download link
const onIframeLoad = (ev) => {
ev.target.contentWindow.postMessage({
imgSrc: DOWNLOAD_ICON_URL,
imgStyle: null, // it handles this internally for us. Useful if a downstream changes the icon.
style: computedStyle(this.dummyLink.current),
blob: this.state.decryptedBlob,
// Set a download attribute for encrypted files so that the file
// will have the correct name when the user tries to download it.
// We can't provide a Content-Disposition header like we would for HTTP.
download: fileName,
textContent: _t("Download %(text)s", { text: text }),
// only auto-download if a user triggered this iframe explicitly
auto: this.userDidClick,
}, "*");
};
const url = "usercontent/"; // XXX: this path should probably be passed from the skin const url = "usercontent/"; // XXX: this path should probably be passed from the skin
// If the attachment is encrypted then put the link inside an iframe. // If the attachment is encrypted then put the link inside an iframe.
@ -218,9 +248,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
*/ } */ }
<a ref={this.dummyLink} /> <a ref={this.dummyLink} />
</div> </div>
{ /*
TODO: Move iframe (and dummy link) into FileDownloader.
We currently have it set up this way because of styles applied to the iframe
itself which cannot be easily handled/overridden by the FileDownloader. In
future, the download link may disappear entirely at which point it could also
be suitable to just remove this bit of code.
*/ }
<iframe <iframe
src={url} src={url}
onLoad={onIframeLoad} onLoad={() => this.downloadFile(this.fileName, this.linkText)}
ref={this.iframe} ref={this.iframe}
sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" /> sandbox="allow-scripts allow-downloads allow-downloads-without-user-activation" />
</div> } </div> }
@ -259,7 +296,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
// We have to create an anchor to download the file // We have to create an anchor to download the file
const tempAnchor = document.createElement('a'); const tempAnchor = document.createElement('a');
tempAnchor.download = fileName; tempAnchor.download = this.fileName;
tempAnchor.href = blobUrl; tempAnchor.href = blobUrl;
document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068 document.body.appendChild(tempAnchor); // for firefox: https://stackoverflow.com/a/32226068
tempAnchor.click(); tempAnchor.click();
@ -268,7 +305,7 @@ export default class MFileBody extends React.Component<IProps, IState> {
}; };
} else { } else {
// Else we are hoping the browser will do the right thing // Else we are hoping the browser will do the right thing
downloadProps["download"] = fileName; downloadProps["download"] = this.fileName;
} }
return ( return (
@ -277,16 +314,16 @@ export default class MFileBody extends React.Component<IProps, IState> {
{ showDownloadLink && <div className="mx_MFileBody_download"> { showDownloadLink && <div className="mx_MFileBody_download">
<a {...downloadProps}> <a {...downloadProps}>
<span className="mx_MFileBody_download_icon" /> <span className="mx_MFileBody_download_icon" />
{ _t("Download %(text)s", { text: text }) } { _t("Download %(text)s", { text: this.linkText }) }
</a> </a>
{ this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size"> { this.props.tileShape === TileShape.FileGrid && <div className="mx_MImageBody_size">
{ content.info && content.info.size ? filesize(content.info.size) : "" } { this.content.info && this.content.info.size ? filesize(this.content.info.size) : "" }
</div> } </div> }
</div> } </div> }
</span> </span>
); );
} else { } else {
const extra = text ? (': ' + text) : ''; const extra = this.linkText ? (': ' + this.linkText) : '';
return <span className="mx_MFileBody"> return <span className="mx_MFileBody">
{ placeholder } { placeholder }
{ _t("Invalid file%(extra)s", { extra: extra }) } { _t("Invalid file%(extra)s", { extra: extra }) }

View file

@ -366,7 +366,7 @@ export default class MImageBody extends React.Component<IBodyProps, IState> {
} }
const thumbnail = ( const thumbnail = (
<div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight + "px", maxWidth: maxWidth + "px" }}> <div className="mx_MImageBody_thumbnail_container" style={{ maxHeight: maxHeight, maxWidth: maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}>
{ showPlaceholder && { showPlaceholder &&
<div <div
className="mx_MImageBody_thumbnail" className="mx_MImageBody_thumbnail"

View file

@ -17,7 +17,6 @@ limitations under the License.
import React from "react"; import React from "react";
import MAudioBody from "./MAudioBody"; import MAudioBody from "./MAudioBody";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
import MVoiceMessageBody from "./MVoiceMessageBody"; import MVoiceMessageBody from "./MVoiceMessageBody";
import { IBodyProps } from "./IBodyProps"; import { IBodyProps } from "./IBodyProps";
@ -27,8 +26,7 @@ export default class MVoiceOrAudioBody extends React.PureComponent<IBodyProps> {
// MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245 // MSC2516 is a legacy identifier. See https://github.com/matrix-org/matrix-doc/pull/3245
const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice'] const isVoiceMessage = !!this.props.mxEvent.getContent()['org.matrix.msc2516.voice']
|| !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice']; || !!this.props.mxEvent.getContent()['org.matrix.msc3245.voice'];
const voiceMessagesEnabled = SettingsStore.getValue("feature_voice_messages"); if (isVoiceMessage) {
if (isVoiceMessage && voiceMessagesEnabled) {
return <MVoiceMessageBody {...this.props} />; return <MVoiceMessageBody {...this.props} />;
} else { } else {
return <MAudioBody {...this.props} />; return <MAudioBody {...this.props} />;

View file

@ -394,12 +394,10 @@ export default class MessageComposer extends React.Component<IProps, IState> {
controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />); controls.push(<Stickerpicker key="stickerpicker_controls_button" room={this.props.room} />);
} }
if (SettingsStore.getValue("feature_voice_messages")) { controls.push(<VoiceRecordComposerTile
controls.push(<VoiceRecordComposerTile key="controls_voice_record"
key="controls_voice_record" ref={c => this.voiceRecordingButton = c}
ref={c => this.voiceRecordingButton = c} room={this.props.room} />);
room={this.props.room} />);
}
if (!this.state.isComposerEmpty || this.state.haveRecording) { if (!this.state.isComposerEmpty || this.state.haveRecording) {
controls.push( controls.push(

View file

@ -26,6 +26,7 @@ import { replaceableComponent } from "../../../../../utils/replaceableComponent"
import SettingsFlag from '../../../elements/SettingsFlag'; import SettingsFlag from '../../../elements/SettingsFlag';
import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts"; import * as KeyboardShortcuts from "../../../../../accessibility/KeyboardShortcuts";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
interface IState { interface IState {
autoLaunch: boolean; autoLaunch: boolean;
@ -47,6 +48,10 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
'breadcrumbs', 'breadcrumbs',
]; ];
static SPACES_SETTINGS = [
"Spaces.allRoomsInHome",
];
static KEYBINDINGS_SETTINGS = [ static KEYBINDINGS_SETTINGS = [
'ctrlFForSearch', 'ctrlFForSearch',
]; ];
@ -231,6 +236,11 @@ export default class PreferencesUserSettingsTab extends React.Component<{}, ISta
{ this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) } { this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS) }
</div> </div>
{ SpaceStore.spacesEnabled && <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Spaces") }</span>
{ this.renderGroup(PreferencesUserSettingsTab.SPACES_SETTINGS) }
</div> }
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
<span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span> <span className="mx_SettingsTab_subheading">{ _t("Keyboard shortcuts") }</span>
<AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}> <AccessibleButton className="mx_SettingsFlag" onClick={KeyboardShortcuts.toggleDialog}>

View file

@ -14,115 +14,46 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react"; import React, { ComponentProps, Dispatch, ReactNode, SetStateAction, useEffect, useState } from "react";
import { DragDropContext, Droppable, Draggable } from "react-beautiful-dnd"; import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import RoomAvatar from "../avatars/RoomAvatar";
import { useContextMenu } from "../../structures/ContextMenu"; import { useContextMenu } from "../../structures/ContextMenu";
import SpaceCreateMenu from "./SpaceCreateMenu"; import SpaceCreateMenu from "./SpaceCreateMenu";
import { SpaceItem } from "./SpaceTreeLevel"; import { SpaceButton, SpaceItem } from "./SpaceTreeLevel";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import { useEventEmitter } from "../../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import SpaceStore, { import SpaceStore, {
HOME_SPACE, HOME_SPACE,
UPDATE_HOME_BEHAVIOUR,
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 NotificationBadge from "../rooms/NotificationBadge"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import {
RovingAccessibleButton,
RovingAccessibleTooltipButton,
RovingTabIndexProvider,
} from "../../../accessibility/RovingTabIndex";
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationState } from "../../../stores/notifications/NotificationState"; import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import IconizedContextMenu, {
interface IButtonProps { IconizedContextMenuCheckbox,
space?: Room; IconizedContextMenuOptionList,
className?: string; } from "../context_menus/IconizedContextMenu";
selected?: boolean; import SettingsStore from "../../../settings/SettingsStore";
tooltip?: string; import { SettingLevel } from "../../../settings/SettingLevel";
notificationState?: NotificationState;
isNarrow?: boolean;
onClick(): void;
}
const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
tooltip,
notificationState,
isNarrow,
children,
}) => {
const classes = classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_narrow: isNarrow,
});
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={32} height={32} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
let button;
if (isNarrow) {
button = (
<RovingAccessibleTooltipButton className={classes} title={tooltip} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ notifBadge }
{ children }
</div>
</RovingAccessibleTooltipButton>
);
} else {
button = (
<RovingAccessibleButton className={classes} onClick={onClick} role="treeitem">
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
<span className="mx_SpaceButton_name">{ tooltip }</span>
{ notifBadge }
{ children }
</div>
</RovingAccessibleButton>
);
}
return <li className={classNames({
"mx_SpaceItem": true,
"collapsed": isNarrow,
})}>
{ button }
</li>;
};
const useSpaces = (): [Room[], Room[], Room | null] => { const useSpaces = (): [Room[], Room[], Room | null] => {
const [invites, setInvites] = useState<Room[]>(SpaceStore.instance.invitedSpaces); const invites = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_INVITED_SPACES, () => {
useEventEmitter(SpaceStore.instance, UPDATE_INVITED_SPACES, setInvites); return SpaceStore.instance.invitedSpaces;
const [spaces, setSpaces] = useState<Room[]>(SpaceStore.instance.spacePanelSpaces); });
useEventEmitter(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, setSpaces); const spaces = useEventEmitterState<Room[]>(SpaceStore.instance, UPDATE_TOP_LEVEL_SPACES, () => {
const [activeSpace, setActiveSpace] = useState<Room>(SpaceStore.instance.activeSpace); return SpaceStore.instance.spacePanelSpaces;
useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, setActiveSpace); });
const activeSpace = useEventEmitterState<Room>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpace;
});
return [invites, spaces, activeSpace]; return [invites, spaces, activeSpace];
}; };
@ -132,23 +63,108 @@ interface IInnerSpacePanelProps {
setPanelCollapsed: Dispatch<SetStateAction<boolean>>; setPanelCollapsed: Dispatch<SetStateAction<boolean>>;
} }
const HomeButtonContextMenu = ({ onFinished, ...props }: ComponentProps<typeof SpaceContextMenu>) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
return <IconizedContextMenu
{...props}
onFinished={onFinished}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ _t("Home") }
</div>
<IconizedContextMenuOptionList first>
<IconizedContextMenuCheckbox
iconClassName="mx_SpacePanel_noIcon"
label={_t("Show all rooms")}
active={allRoomsInHome}
onClick={() => {
SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.ACCOUNT, !allRoomsInHome);
}}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>;
};
interface IHomeButtonProps {
selected: boolean;
isPanelCollapsed: boolean;
}
const HomeButton = ({ selected, isPanelCollapsed }: IHomeButtonProps) => {
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
<SpaceButton
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={selected}
label={allRoomsInHome ? _t("All rooms") : _t("Home")}
notificationState={allRoomsInHome
? RoomNotificationStateStore.instance.globalState
: SpaceStore.instance.getNotificationState(HOME_SPACE)}
isNarrow={isPanelCollapsed}
ContextMenuComponent={HomeButtonContextMenu}
contextMenuTooltip={_t("Options")}
/>
</li>;
};
const CreateSpaceButton = ({
isPanelCollapsed,
setPanelCollapsed,
}: Pick<IInnerSpacePanelProps, "isPanelCollapsed" | "setPanelCollapsed">) => {
// We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
closeMenu();
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return <li className={classNames("mx_SpaceItem", {
"collapsed": isPanelCollapsed,
})}>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
label={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
{ contextMenu }
</li>;
};
// Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation // Optimisation based on https://github.com/atlassian/react-beautiful-dnd/blob/master/docs/api/droppable.md#recommended-droppable--performance-optimisation
const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => { const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCollapsed, setPanelCollapsed }) => {
const [invites, spaces, activeSpace] = useSpaces(); const [invites, spaces, activeSpace] = useSpaces();
const activeSpaces = activeSpace ? [activeSpace] : []; const activeSpaces = activeSpace ? [activeSpace] : [];
const homeNotificationState = SpaceStore.spacesTweakAllRoomsEnabled
? RoomNotificationStateStore.instance.globalState : SpaceStore.instance.getNotificationState(HOME_SPACE);
return <div className="mx_SpaceTreeLevel"> return <div className="mx_SpaceTreeLevel">
<SpaceButton <HomeButton selected={!activeSpace} isPanelCollapsed={isPanelCollapsed} />
className="mx_SpaceButton_home"
onClick={() => SpaceStore.instance.setActiveSpace(null)}
selected={!activeSpace}
tooltip={SpaceStore.spacesTweakAllRoomsEnabled ? _t("All rooms") : _t("Home")}
notificationState={homeNotificationState}
isNarrow={isPanelCollapsed}
/>
{ invites.map(s => ( { invites.map(s => (
<SpaceItem <SpaceItem
key={s.roomId} key={s.roomId}
@ -178,26 +194,13 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(({ children, isPanelCo
</Draggable> </Draggable>
)) } )) }
{ children } { children }
<CreateSpaceButton isPanelCollapsed={isPanelCollapsed} setPanelCollapsed={setPanelCollapsed} />
</div>; </div>;
}); });
const SpacePanel = () => { const SpacePanel = () => {
// We don't need the handle as we position the menu in a constant location
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<void>();
const [isPanelCollapsed, setPanelCollapsed] = useState(true); const [isPanelCollapsed, setPanelCollapsed] = useState(true);
useEffect(() => {
if (!isPanelCollapsed && menuDisplayed) {
closeMenu();
}
}, [isPanelCollapsed]); // eslint-disable-line react-hooks/exhaustive-deps
let contextMenu = null;
if (menuDisplayed) {
contextMenu = <SpaceCreateMenu onFinished={closeMenu} />;
}
const onKeyDown = (ev: React.KeyboardEvent) => { const onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true; let handled = true;
@ -259,11 +262,6 @@ const SpacePanel = () => {
} }
}; };
const onNewClick = menuDisplayed ? closeMenu : () => {
if (!isPanelCollapsed) setPanelCollapsed(true);
openMenu();
};
return ( return (
<DragDropContext onDragEnd={result => { <DragDropContext onDragEnd={result => {
if (!result.destination) return; // dropped outside the list if (!result.destination) return; // dropped outside the list
@ -291,15 +289,6 @@ const SpacePanel = () => {
> >
{ provided.placeholder } { provided.placeholder }
</InnerSpacePanel> </InnerSpacePanel>
<SpaceButton
className={classNames("mx_SpaceButton_new", {
mx_SpaceButton_newCancel: menuDisplayed,
})}
tooltip={menuDisplayed ? _t("Cancel") : _t("Create a space")}
onClick={onNewClick}
isNarrow={isPanelCollapsed}
/>
</AutoHideScrollbar> </AutoHideScrollbar>
) } ) }
</Droppable> </Droppable>
@ -308,7 +297,6 @@ const SpacePanel = () => {
onClick={() => setPanelCollapsed(!isPanelCollapsed)} onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")} title={isPanelCollapsed ? _t("Expand space panel") : _t("Collapse space panel")}
/> />
{ contextMenu }
</ul> </ul>
) } ) }
</RovingTabIndexProvider> </RovingTabIndexProvider>

View file

@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef, InputHTMLAttributes, LegacyRef } from "react"; import React, {
createRef,
MouseEvent,
InputHTMLAttributes,
LegacyRef,
ComponentProps,
ComponentType,
} from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
@ -23,34 +30,104 @@ import SpaceStore from "../../../stores/SpaceStore";
import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore"; import SpaceTreeLevelLayoutStore from "../../../stores/SpaceTreeLevelLayoutStore";
import NotificationBadge from "../rooms/NotificationBadge"; import NotificationBadge from "../rooms/NotificationBadge";
import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton"; import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton";
import { toRightOf } from "../../structures/ContextMenu"; import { toRightOf, useContextMenu } from "../../structures/ContextMenu";
import {
leaveSpace,
shouldShowSpaceSettings,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
showSpaceSettings,
} from "../../../utils/space";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import RoomViewStore from "../../../stores/RoomViewStore";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState"; import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager"; import { getKeyBindingsManager, RoomListAction } from "../../../KeyBindingsManager";
import { BetaPill } from "../beta/BetaCard"; import { NotificationState } from "../../../stores/notifications/NotificationState";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
interface IButtonProps extends Omit<ComponentProps<typeof RovingAccessibleTooltipButton>, "title"> {
space?: Room;
className?: string;
selected?: boolean;
label: string;
contextMenuTooltip?: string;
notificationState?: NotificationState;
isNarrow?: boolean;
avatarSize?: number;
ContextMenuComponent?: ComponentType<ComponentProps<typeof SpaceContextMenu>>;
onClick(ev: MouseEvent): void;
}
export const SpaceButton: React.FC<IButtonProps> = ({
space,
className,
selected,
onClick,
label,
contextMenuTooltip,
notificationState,
avatarSize,
isNarrow,
children,
ContextMenuComponent,
...props
}) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>();
let avatar = <div className="mx_SpaceButton_avatarPlaceholder"><div className="mx_SpaceButton_icon" /></div>;
if (space) {
avatar = <RoomAvatar width={avatarSize} height={avatarSize} room={space} />;
}
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space || null)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
let contextMenu: JSX.Element;
if (menuDisplayed && ContextMenuComponent) {
contextMenu = <ContextMenuComponent
{...toRightOf(handle.current?.getBoundingClientRect(), 0)}
space={space}
onFinished={closeMenu}
/>;
}
return (
<RovingAccessibleTooltipButton
{...props}
className={classNames("mx_SpaceButton", className, {
mx_SpaceButton_active: selected,
mx_SpaceButton_hasMenuOpen: menuDisplayed,
mx_SpaceButton_narrow: isNarrow,
})}
title={label}
onClick={onClick}
onContextMenu={openMenu}
forceHide={!isNarrow || menuDisplayed}
role="treeitem"
inputRef={handle}
>
{ children }
<div className="mx_SpaceButton_selectionWrapper">
{ avatar }
{ !isNarrow && <span className="mx_SpaceButton_name">{ label }</span> }
{ notifBadge }
{ ContextMenuComponent && <ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={openMenu}
title={contextMenuTooltip}
isExpanded={menuDisplayed}
/> }
{ contextMenu }
</div>
</RovingAccessibleTooltipButton>
);
};
interface IItemProps extends InputHTMLAttributes<HTMLLIElement> { interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
space?: Room; space?: Room;
@ -64,7 +141,6 @@ interface IItemProps extends InputHTMLAttributes<HTMLLIElement> {
interface IItemState { interface IItemState {
collapsed: boolean; collapsed: boolean;
contextMenuPosition: Pick<DOMRect, "right" | "top" | "height">;
childSpaces: Room[]; childSpaces: Room[];
} }
@ -84,7 +160,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
this.state = { this.state = {
collapsed: collapsed, collapsed: collapsed,
contextMenuPosition: null,
childSpaces: this.childSpaces, childSpaces: this.childSpaces,
}; };
@ -127,19 +202,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
evt.stopPropagation(); evt.stopPropagation();
}; };
private onContextMenu = (ev: React.MouseEvent) => {
if (this.props.space.getMyMembership() !== "join") return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
right: ev.clientX,
top: ev.clientY,
height: 0,
},
});
};
private onKeyDown = (ev: React.KeyboardEvent) => { private onKeyDown = (ev: React.KeyboardEvent) => {
let handled = true; let handled = true;
const action = getKeyBindingsManager().getRoomListAction(ev); const action = getKeyBindingsManager().getRoomListAction(ev);
@ -183,200 +245,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
SpaceStore.instance.setActiveSpace(this.props.space); SpaceStore.instance.setActiveSpace(this.props.space);
}; };
private onMenuOpenClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onMenuClose = () => {
this.setState({ contextMenuPosition: null });
};
private onInviteClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceInvite(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onSettingsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showSpaceSettings(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onLeaveClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
leaveSpace(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onNewRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewRoom(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onAddExistingRoomClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showAddExistingRooms(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onNewSubspaceClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
showCreateNewSubspace(this.props.space);
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onMembersClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
if (!RoomViewStore.getRoomId()) {
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
}, true);
}
defaultDispatcher.dispatch<SetRightPanelPhasePayload>({
action: Action.SetRightPanelPhase,
phase: RightPanelPhases.SpaceMemberList,
refireParams: { space: this.props.space },
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private onExploreRoomsClick = (ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
defaultDispatcher.dispatch({
action: "view_room",
room_id: this.props.space.roomId,
});
this.setState({ contextMenuPosition: null }); // also close the menu
};
private renderContextMenu(): React.ReactElement {
if (this.props.space.getMyMembership() !== "join") return null;
let contextMenu = null;
if (this.state.contextMenuPosition) {
const userId = this.context.getUserId();
let inviteOption;
if (this.props.space.getJoinRule() === "public" || this.props.space.canInvite(userId)) {
inviteOption = (
<IconizedContextMenuOption
className="mx_SpacePanel_contextMenu_inviteButton"
iconClassName="mx_SpacePanel_iconInvite"
label={_t("Invite people")}
onClick={this.onInviteClick}
/>
);
}
let settingsOption;
let leaveSection;
if (shouldShowSpaceSettings(this.props.space)) {
settingsOption = (
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconSettings"
label={_t("Settings")}
onClick={this.onSettingsClick}
/>
);
} else {
leaveSection = <IconizedContextMenuOptionList red first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconLeave"
label={_t("Leave space")}
onClick={this.onLeaveClick}
/>
</IconizedContextMenuOptionList>;
}
const canAddRooms = this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId);
let newRoomSection;
if (this.props.space.currentState.maySendStateEvent(EventType.SpaceChild, userId)) {
newRoomSection = <IconizedContextMenuOptionList first>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Create new room")}
onClick={this.onNewRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconHash"
label={_t("Add existing room")}
onClick={this.onAddExistingRoomClick}
/>
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconPlus"
label={_t("Add space")}
onClick={this.onNewSubspaceClick}
>
<BetaPill />
</IconizedContextMenuOption>
</IconizedContextMenuOptionList>;
}
contextMenu = <IconizedContextMenu
{...toRightOf(this.state.contextMenuPosition, 0)}
onFinished={this.onMenuClose}
className="mx_SpacePanel_contextMenu"
compact
>
<div className="mx_SpacePanel_contextMenu_header">
{ this.props.space.name }
</div>
<IconizedContextMenuOptionList first>
{ inviteOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconMembers"
label={_t("Members")}
onClick={this.onMembersClick}
/>
{ settingsOption }
<IconizedContextMenuOption
iconClassName="mx_SpacePanel_iconExplore"
label={canAddRooms ? _t("Manage & explore rooms") : _t("Explore rooms")}
onClick={this.onExploreRoomsClick}
/>
</IconizedContextMenuOptionList>
{ newRoomSection }
{ leaveSection }
</IconizedContextMenu>;
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_SpaceButton_menuButton"
onClick={this.onMenuOpenClick}
title={_t("Space options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{ contextMenu }
</React.Fragment>
);
}
render() { render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef, const { space, activeSpaces, isNested, isPanelCollapsed, onExpand, parents, innerRef,
@ -384,7 +252,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
const collapsed = this.isCollapsed; const collapsed = this.isCollapsed;
const isActive = activeSpaces.includes(space);
const itemClasses = classNames(this.props.className, { const itemClasses = classNames(this.props.className, {
"mx_SpaceItem": true, "mx_SpaceItem": true,
"mx_SpaceItem_narrow": isPanelCollapsed, "mx_SpaceItem_narrow": isPanelCollapsed,
@ -393,12 +260,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
}); });
const isInvite = space.getMyMembership() === "invite"; const isInvite = space.getMyMembership() === "invite";
const classes = classNames("mx_SpaceButton", {
mx_SpaceButton_active: isActive,
mx_SpaceButton_hasMenuOpen: !!this.state.contextMenuPosition,
mx_SpaceButton_narrow: isPanelCollapsed,
mx_SpaceButton_invite: isInvite,
});
const notificationState = isInvite const notificationState = isInvite
? StaticNotificationState.forSymbol("!", NotificationColor.Red) ? StaticNotificationState.forSymbol("!", NotificationColor.Red)
: SpaceStore.instance.getNotificationState(space.roomId); : SpaceStore.instance.getNotificationState(space.roomId);
@ -413,19 +275,6 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/>; />;
} }
let notifBadge;
if (notificationState) {
notifBadge = <div className="mx_SpacePanel_badgeContainer">
<NotificationBadge
onClick={() => SpaceStore.instance.setActiveRoomInSpace(space)}
forceCount={false}
notification={notificationState}
/>
</div>;
}
const avatarSize = isNested ? 24 : 32;
const toggleCollapseButton = this.state.childSpaces?.length ? const toggleCollapseButton = this.state.childSpaces?.length ?
<AccessibleButton <AccessibleButton
className="mx_SpaceButton_toggleCollapse" className="mx_SpaceButton_toggleCollapse"
@ -436,25 +285,23 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
return ( return (
<li {...otherProps} className={itemClasses} ref={innerRef}> <li {...otherProps} className={itemClasses} ref={innerRef}>
<RovingAccessibleTooltipButton <SpaceButton
className={classes} space={space}
title={space.name} className={isInvite ? "mx_SpaceButton_invite" : undefined}
selected={activeSpaces.includes(space)}
label={space.name}
contextMenuTooltip={_t("Space options")}
notificationState={notificationState}
isNarrow={isPanelCollapsed}
avatarSize={isNested ? 24 : 32}
onClick={this.onClick} onClick={this.onClick}
onContextMenu={this.onContextMenu}
forceHide={!isPanelCollapsed || !!this.state.contextMenuPosition}
role="treeitem"
aria-expanded={!collapsed}
inputRef={this.buttonRef}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
aria-expanded={!collapsed}
ContextMenuComponent={this.props.space.getMyMembership() === "join"
? SpaceContextMenu : undefined}
> >
{ toggleCollapseButton } { toggleCollapseButton }
<div className="mx_SpaceButton_selectionWrapper"> </SpaceButton>
<RoomAvatar width={avatarSize} height={avatarSize} room={space} />
{ !isPanelCollapsed && <span className="mx_SpaceButton_name">{ space.name }</span> }
{ notifBadge }
{ this.renderContextMenu() }
</div>
</RovingAccessibleTooltipButton>
{ childItems } { childItems }
</li> </li>

View file

@ -48,7 +48,7 @@ interface IState {
} }
@replaceableComponent("views.voip.VideoFeed") @replaceableComponent("views.voip.VideoFeed")
export default class VideoFeed extends React.Component<IProps, IState> { export default class VideoFeed extends React.PureComponent<IProps, IState> {
private element: HTMLVideoElement; private element: HTMLVideoElement;
constructor(props: IProps) { constructor(props: IProps) {
@ -69,8 +69,15 @@ export default class VideoFeed extends React.Component<IProps, IState> {
this.updateFeed(this.props.feed, null); this.updateFeed(this.props.feed, null);
} }
componentDidUpdate(prevProps: IProps) { componentDidUpdate(prevProps: IProps, prevState: IState) {
this.updateFeed(prevProps.feed, this.props.feed); this.updateFeed(prevProps.feed, this.props.feed);
// If the mutes state has changed, we try to playMedia()
if (
prevState.videoMuted !== this.state.videoMuted ||
prevProps.feed.stream !== this.props.feed.stream
) {
this.playMedia();
}
} }
static getDerivedStateFromProps(props: IProps) { static getDerivedStateFromProps(props: IProps) {
@ -95,10 +102,12 @@ export default class VideoFeed extends React.Component<IProps, IState> {
if (oldFeed) { if (oldFeed) {
this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream); this.props.feed.removeListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.removeListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
this.stopMedia(); this.stopMedia();
} }
if (newFeed) { if (newFeed) {
this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream); this.props.feed.addListener(CallFeedEvent.NewStream, this.onNewStream);
this.props.feed.addListener(CallFeedEvent.MuteStateChanged, this.onMuteStateChanged);
this.playMedia(); this.playMedia();
} }
} }
@ -144,7 +153,13 @@ export default class VideoFeed extends React.Component<IProps, IState> {
audioMuted: this.props.feed.isAudioMuted(), audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(), videoMuted: this.props.feed.isVideoMuted(),
}); });
this.playMedia(); };
private onMuteStateChanged = () => {
this.setState({
audioMuted: this.props.feed.isAudioMuted(),
videoMuted: this.props.feed.isVideoMuted(),
});
}; };
private onResize = (e) => { private onResize = (e) => {
@ -154,40 +169,59 @@ export default class VideoFeed extends React.Component<IProps, IState> {
}; };
render() { render() {
const videoClasses = { const { pipMode, primary, feed } = this.props;
mx_VideoFeed: true,
const wrapperClasses = classnames("mx_VideoFeed", {
mx_VideoFeed_voice: this.state.videoMuted, mx_VideoFeed_voice: this.state.videoMuted,
mx_VideoFeed_video: !this.state.videoMuted, });
mx_VideoFeed_mirror: ( const micIconClasses = classnames("mx_VideoFeed_mic", {
this.props.feed.isLocal() && mx_VideoFeed_mic_muted: this.state.audioMuted,
this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia && mx_VideoFeed_mic_unmuted: !this.state.audioMuted,
SettingsStore.getValue('VideoView.flipVideoHorizontally') });
),
};
const { pipMode, primary } = this.props; let micIcon;
if (feed.purpose !== SDPStreamMetadataPurpose.Screenshare && !pipMode) {
micIcon = (
<div className={micIconClasses} />
);
}
let content;
if (this.state.videoMuted) { if (this.state.videoMuted) {
const member = this.props.feed.getMember(); const member = this.props.feed.getMember();
let avatarSize; let avatarSize;
if (pipMode && primary) avatarSize = 76; if (pipMode && primary) avatarSize = 76;
else if (pipMode && !primary) avatarSize = 16; else if (pipMode && !primary) avatarSize = 16;
else if (!pipMode && primary) avatarSize = 160; else if (!pipMode && primary) avatarSize = 160;
else; // TBD else; // TBD
return ( content =(
<div className={classnames(videoClasses)}> <MemberAvatar
<MemberAvatar member={member}
member={member} height={avatarSize}
height={avatarSize} width={avatarSize}
width={avatarSize} />
/>
</div>
); );
} else { } else {
return ( const videoClasses = classnames("mx_VideoFeed_video", {
<video className={classnames(videoClasses)} ref={this.setElementRef} /> mx_VideoFeed_video_mirror: (
this.props.feed.isLocal() &&
this.props.feed.purpose === SDPStreamMetadataPurpose.Usermedia &&
SettingsStore.getValue('VideoView.flipVideoHorizontally')
),
});
content= (
<video className={videoClasses} ref={this.setElementRef} />
); );
} }
return (
<div className={wrapperClasses}>
{ micIcon }
{ content }
</div>
);
} }
} }

View file

@ -184,7 +184,8 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
} }
} }
if (opts.joinRule !== JoinRule.Restricted) { // we handle the restricted join rule in the parentSpace handling block above
if (opts.joinRule && opts.joinRule !== JoinRule.Restricted) {
createOpts.initial_state.push({ createOpts.initial_state.push({
type: EventType.RoomJoinRules, type: EventType.RoomJoinRules,
content: { join_rule: opts.joinRule }, content: { join_rule: opts.joinRule },

View file

@ -198,4 +198,11 @@ export enum Action {
* Signals to the visible space hierarchy that a change has occurred an that it should refresh. * Signals to the visible space hierarchy that a change has occurred an that it should refresh.
*/ */
UpdateSpaceHierarchy = "update_space_hierarchy", UpdateSpaceHierarchy = "update_space_hierarchy",
/**
* Fires when a monitored setting is updated,
* see SettingsStore::monitorSetting for more details.
* Should be used with SettingUpdatedPayload.
*/
SettingUpdated = "setting_updated",
} }

View file

@ -0,0 +1,29 @@
/*
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 { ActionPayload } from "../payloads";
import { Action } from "../actions";
import { SettingLevel } from "../../settings/SettingLevel";
export interface SettingUpdatedPayload extends ActionPayload {
action: Action.SettingUpdated;
settingName: string;
roomId: string;
level: SettingLevel;
newValueAtLevel: SettingLevel;
newValue: any;
}

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 { useRef, useEffect } from "react"; import { useRef, useEffect, useState, useCallback } from "react";
import type { EventEmitter } from "events"; import type { EventEmitter } from "events";
type Handler = (...args: any[]) => void; type Handler = (...args: any[]) => void;
@ -48,3 +48,14 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo
[eventName, emitter], // Re-run if eventName or emitter changes [eventName, emitter], // Re-run if eventName or emitter changes
); );
}; };
type Mapper<T> = (...args: any[]) => T;
export const useEventEmitterState = <T>(emitter: EventEmitter, eventName: string | symbol, fn: Mapper<T>): T => {
const [value, setValue] = useState<T>(fn());
const handler = useCallback((...args: any[]) => {
setValue(fn(...args));
}, [fn]);
useEventEmitter(emitter, eventName, handler);
return value;
};

View file

@ -487,6 +487,11 @@
"Converts the room to a DM": "Converts the room to a DM", "Converts the room to a DM": "Converts the room to a DM",
"Converts the DM to a room": "Converts the DM to a room", "Converts the DM to a room": "Converts the DM to a room",
"Displays action": "Displays action", "Displays action": "Displays action",
"Someone": "Someone",
"%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.",
"%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)",
"%(senderName)s placed a video call.": "%(senderName)s placed a video call.",
"%(senderName)s placed a video call. (not supported by this browser)": "%(senderName)s placed a video call. (not supported by this browser)",
"%(targetName)s accepted the invitation for %(displayName)s": "%(targetName)s accepted the invitation for %(displayName)s", "%(targetName)s accepted the invitation for %(displayName)s": "%(targetName)s accepted the invitation for %(displayName)s",
"%(targetName)s accepted an invitation": "%(targetName)s accepted an invitation", "%(targetName)s accepted an invitation": "%(targetName)s accepted an invitation",
"%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s", "%(senderName)s invited %(targetName)s": "%(senderName)s invited %(targetName)s",
@ -536,7 +541,6 @@
"%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s changed the main and alternative addresses for this room.", "%(senderName)s changed the main and alternative addresses for this room.": "%(senderName)s changed the main and alternative addresses for this room.",
"%(senderName)s changed the addresses for this room.": "%(senderName)s changed the addresses for this room.", "%(senderName)s changed the addresses for this room.": "%(senderName)s changed the addresses for this room.",
"%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.", "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.": "%(senderName)s revoked the invitation for %(targetDisplayName)s to join the room.",
"Someone": "Someone",
"%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.", "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.": "%(senderName)s sent an invitation to %(targetDisplayName)s to join the room.",
"%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s made future room history visible to all room members, from the point they are invited.", "%(senderName)s made future room history visible to all room members, from the point they are invited.": "%(senderName)s made future room history visible to all room members, from the point they are invited.",
"%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s made future room history visible to all room members, from the point they joined.", "%(senderName)s made future room history visible to all room members, from the point they joined.": "%(senderName)s made future room history visible to all room members, from the point they joined.",
@ -796,9 +800,7 @@
"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.", "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.", "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.",
"Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.", "Your feedback will help make spaces better. The more detail you can go into, the better.": "Your feedback will help make spaces better. The more detail you can go into, the better.",
"Show all rooms in Home": "Show all rooms in Home",
"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": "Send and receive voice messages",
"Render LaTeX maths in messages": "Render LaTeX maths in messages", "Render LaTeX maths in messages": "Render LaTeX maths in messages",
"Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.", "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.": "Communities v2 prototypes. Requires compatible homeserver. Highly experimental - use with caution.",
"Message Pinning": "Message Pinning", "Message Pinning": "Message Pinning",
@ -867,6 +869,8 @@
"Manually verify all remote sessions": "Manually verify all remote sessions", "Manually verify all remote sessions": "Manually verify all remote sessions",
"IRC display name width": "IRC display name width", "IRC display name width": "IRC display name width",
"Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)", "Show chat effects (animations when receiving e.g. confetti)": "Show chat effects (animations when receiving e.g. confetti)",
"Show all rooms in Home": "Show all rooms in Home",
"All rooms you're in will appear in Home.": "All rooms you're in will appear in Home.",
"Collecting app version information": "Collecting app version information", "Collecting app version information": "Collecting app version information",
"Collecting logs": "Collecting logs", "Collecting logs": "Collecting logs",
"Uploading logs": "Uploading logs", "Uploading logs": "Uploading logs",
@ -1025,8 +1029,10 @@
"You can change these anytime.": "You can change these anytime.", "You can change these anytime.": "You can change these anytime.",
"Creating...": "Creating...", "Creating...": "Creating...",
"Create": "Create", "Create": "Create",
"All rooms": "All rooms",
"Home": "Home", "Home": "Home",
"Show all rooms": "Show all rooms",
"All rooms": "All rooms",
"Options": "Options",
"Expand space panel": "Expand space panel", "Expand space panel": "Expand space panel",
"Collapse space panel": "Collapse space panel", "Collapse space panel": "Collapse space panel",
"Click to copy": "Click to copy", "Click to copy": "Click to copy",
@ -1056,17 +1062,9 @@
"Preview Space": "Preview Space", "Preview Space": "Preview Space",
"Allow people to preview your space before they join.": "Allow people to preview your space before they join.", "Allow people to preview your space before they join.": "Allow people to preview your space before they join.",
"Recommended for public spaces.": "Recommended for public spaces.", "Recommended for public spaces.": "Recommended for public spaces.",
"Settings": "Settings",
"Leave space": "Leave space",
"Create new room": "Create new room",
"Add existing room": "Add existing room",
"Add space": "Add space",
"Members": "Members",
"Manage & explore rooms": "Manage & explore rooms",
"Explore rooms": "Explore rooms",
"Space options": "Space options",
"Expand": "Expand", "Expand": "Expand",
"Collapse": "Collapse", "Collapse": "Collapse",
"Space options": "Space options",
"Remove": "Remove", "Remove": "Remove",
"This bridge was provisioned by <user />.": "This bridge was provisioned by <user />.", "This bridge was provisioned by <user />.": "This bridge was provisioned by <user />.",
"This bridge is managed by <user />.": "This bridge is managed by <user />.", "This bridge is managed by <user />.": "This bridge is managed by <user />.",
@ -1592,8 +1590,11 @@
"Start chat": "Start chat", "Start chat": "Start chat",
"Rooms": "Rooms", "Rooms": "Rooms",
"Add room": "Add room", "Add room": "Add room",
"Create new room": "Create new room",
"You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space", "You do not have permissions to create new rooms in this space": "You do not have permissions to create new rooms in this space",
"Add existing room": "Add existing room",
"You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space", "You do not have permissions to add rooms to this space": "You do not have permissions to add rooms to this space",
"Explore rooms": "Explore rooms",
"Explore community rooms": "Explore community rooms", "Explore community rooms": "Explore community rooms",
"Explore public rooms": "Explore public rooms", "Explore public rooms": "Explore public rooms",
"Low priority": "Low priority", "Low priority": "Low priority",
@ -1671,6 +1672,7 @@
"Low Priority": "Low Priority", "Low Priority": "Low Priority",
"Invite People": "Invite People", "Invite People": "Invite People",
"Copy Room Link": "Copy Room Link", "Copy Room Link": "Copy Room Link",
"Settings": "Settings",
"Leave Room": "Leave Room", "Leave Room": "Leave Room",
"Room options": "Room options", "Room options": "Room options",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
@ -1764,13 +1766,13 @@
"The homeserver the user youre verifying is connected to": "The homeserver the user youre verifying is connected to", "The homeserver the user youre verifying is connected to": "The homeserver the user youre verifying is connected to",
"Yours, or the other users internet connection": "Yours, or the other users internet connection", "Yours, or the other users internet connection": "Yours, or the other users internet connection",
"Yours, or the other users session": "Yours, or the other users session", "Yours, or the other users session": "Yours, or the other users session",
"Members": "Members",
"Nothing pinned, yet": "Nothing pinned, yet", "Nothing pinned, yet": "Nothing pinned, yet",
"If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.", "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
"Pinned messages": "Pinned messages", "Pinned messages": "Pinned messages",
"Room Info": "Room Info", "Room Info": "Room Info",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets", "You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
"Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel", "Unpin a widget to view it in this panel": "Unpin a widget to view it in this panel",
"Options": "Options",
"Set my room layout for everyone": "Set my room layout for everyone", "Set my room layout for everyone": "Set my room layout for everyone",
"Widgets": "Widgets", "Widgets": "Widgets",
"Edit widgets, bridges & bots": "Edit widgets, bridges & bots", "Edit widgets, bridges & bots": "Edit widgets, bridges & bots",
@ -1889,7 +1891,7 @@
"Saturday": "Saturday", "Saturday": "Saturday",
"Today": "Today", "Today": "Today",
"Yesterday": "Yesterday", "Yesterday": "Yesterday",
"Downloading": "Downloading", "Decrypting": "Decrypting",
"Download": "Download", "Download": "Download",
"View Source": "View Source", "View Source": "View Source",
"Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.", "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.": "Messages here are end-to-end encrypted. Verify %(displayName)s in their profile - tap on their avatar.",
@ -1904,9 +1906,9 @@
"Retry": "Retry", "Retry": "Retry",
"Reply": "Reply", "Reply": "Reply",
"Message Actions": "Message Actions", "Message Actions": "Message Actions",
"Download %(text)s": "Download %(text)s",
"Error decrypting attachment": "Error decrypting attachment", "Error decrypting attachment": "Error decrypting attachment",
"Decrypt %(text)s": "Decrypt %(text)s", "Decrypt %(text)s": "Decrypt %(text)s",
"Download %(text)s": "Download %(text)s",
"Invalid file%(extra)s": "Invalid file%(extra)s", "Invalid file%(extra)s": "Invalid file%(extra)s",
"Error decrypting image": "Error decrypting image", "Error decrypting image": "Error decrypting image",
"Show image": "Show image", "Show image": "Show image",
@ -2385,6 +2387,7 @@
"You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.", "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.": "You're the only admin of some of the rooms or spaces you wish to leave. Leaving them will leave them without any admins.",
"Leave %(spaceName)s": "Leave %(spaceName)s", "Leave %(spaceName)s": "Leave %(spaceName)s",
"Are you sure you want to leave <spaceName/>?": "Are you sure you want to leave <spaceName/>?", "Are you sure you want to leave <spaceName/>?": "Are you sure you want to leave <spaceName/>?",
"Leave space": "Leave space",
"Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.", "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.": "Encrypted messages are secured with end-to-end encryption. Only you and the recipient(s) have the keys to read these messages.",
"Start using Key Backup": "Start using Key Backup", "Start using Key Backup": "Start using Key Backup",
"I don't want my encrypted messages": "I don't want my encrypted messages", "I don't want my encrypted messages": "I don't want my encrypted messages",
@ -2593,6 +2596,8 @@
"Source URL": "Source URL", "Source URL": "Source URL",
"Collapse reply thread": "Collapse reply thread", "Collapse reply thread": "Collapse reply thread",
"Report": "Report", "Report": "Report",
"Add space": "Add space",
"Manage & explore rooms": "Manage & explore rooms",
"Clear status": "Clear status", "Clear status": "Clear status",
"Update status": "Update status", "Update status": "Update status",
"Set status": "Set status", "Set status": "Set status",

View file

@ -160,6 +160,17 @@ export function _t(text: string, variables?: IVariables, tags?: Tags): Translate
} }
} }
/**
* Sanitizes unsafe text for the sanitizer, ensuring references to variables will not be considered
* replaceable by the translation functions.
* @param {string} text The text to sanitize.
* @returns {string} The sanitized text.
*/
export function sanitizeForTranslation(text: string): string {
// Add a non-breaking space so the regex doesn't trigger when translating.
return text.replace(/%\(([^)]*)\)/g, '%\xa0($1)');
}
/* /*
* Similar to _t(), except only does substitutions, and no translation * Similar to _t(), except only does substitutions, and no translation
* @param {string} text The text, e.g "click <a>here</a> now to %(foo)s". * @param {string} text The text, e.g "click <a>here</a> now to %(foo)s".

View file

@ -180,29 +180,14 @@ export const SETTINGS: {[setting: string]: ISetting} = {
feedbackSubheading: _td("Your feedback will help make spaces better. " + feedbackSubheading: _td("Your feedback will help make spaces better. " +
"The more detail you can go into, the better."), "The more detail you can go into, the better."),
feedbackLabel: "spaces-feedback", feedbackLabel: "spaces-feedback",
extraSettings: [
"feature_spaces.all_rooms",
],
}, },
}, },
"feature_spaces.all_rooms": {
displayName: _td("Show all rooms in Home"),
supportedLevels: LEVELS_FEATURE,
default: true,
controller: new ReloadOnChangeController(),
},
"feature_dnd": { "feature_dnd": {
isFeature: true, isFeature: true,
displayName: _td("Show options to enable 'Do not disturb' mode"), displayName: _td("Show options to enable 'Do not disturb' mode"),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
"feature_voice_messages": {
isFeature: true,
displayName: _td("Send and receive voice messages"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_latex_maths": { "feature_latex_maths": {
isFeature: true, isFeature: true,
displayName: _td("Render LaTeX maths in messages"), displayName: _td("Render LaTeX maths in messages"),
@ -758,6 +743,12 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null, default: null,
}, },
"Spaces.allRoomsInHome": {
displayName: _td("Show all rooms in Home"),
description: _td("All rooms you're in will appear in Home."),
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: false,
},
[UIFeature.RoomHistorySettings]: { [UIFeature.RoomHistorySettings]: {
supportedLevels: LEVELS_UI_FEATURE, supportedLevels: LEVELS_UI_FEATURE,
default: true, default: true,

View file

@ -29,6 +29,8 @@ import LocalEchoWrapper from "./handlers/LocalEchoWrapper";
import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager"; import { WatchManager, CallbackFn as WatchCallbackFn } from "./WatchManager";
import { SettingLevel } from "./SettingLevel"; import { SettingLevel } from "./SettingLevel";
import SettingsHandler from "./handlers/SettingsHandler"; import SettingsHandler from "./handlers/SettingsHandler";
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
import { Action } from "../dispatcher/actions";
const defaultWatchManager = new WatchManager(); const defaultWatchManager = new WatchManager();
@ -147,7 +149,7 @@ export default class SettingsStore {
* if the change in value is worthwhile enough to react upon. * if the change in value is worthwhile enough to react upon.
* @returns {string} A reference to the watcher that was employed. * @returns {string} A reference to the watcher that was employed.
*/ */
public static watchSetting(settingName: string, roomId: string, callbackFn: CallbackFn): string { public static watchSetting(settingName: string, roomId: string | null, callbackFn: CallbackFn): string {
const setting = SETTINGS[settingName]; const setting = SETTINGS[settingName];
const originalSettingName = settingName; const originalSettingName = settingName;
if (!setting) throw new Error(`${settingName} is not a setting`); if (!setting) throw new Error(`${settingName} is not a setting`);
@ -193,7 +195,7 @@ export default class SettingsStore {
* @param {string} settingName The setting name to monitor. * @param {string} settingName The setting name to monitor.
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms. * @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
*/ */
public static monitorSetting(settingName: string, roomId: string) { public static monitorSetting(settingName: string, roomId: string | null) {
roomId = roomId || null; // the thing wants null specifically to work, so appease it. roomId = roomId || null; // the thing wants null specifically to work, so appease it.
if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map()); if (!this.monitors.has(settingName)) this.monitors.set(settingName, new Map());
@ -201,8 +203,8 @@ export default class SettingsStore {
const registerWatcher = () => { const registerWatcher = () => {
this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting( this.monitors.get(settingName).set(roomId, SettingsStore.watchSetting(
settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => { settingName, roomId, (settingName, inRoomId, level, newValueAtLevel, newValue) => {
dis.dispatch({ dis.dispatch<SettingUpdatedPayload>({
action: 'setting_updated', action: Action.SettingUpdated,
settingName, settingName,
roomId: inRoomId, roomId: inRoomId,
level, level,

View file

@ -23,6 +23,8 @@ import { arrayHasDiff } from "../utils/arrays";
import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { SettingLevel } from "../settings/SettingLevel"; import { SettingLevel } from "../settings/SettingLevel";
import SpaceStore from "./SpaceStore"; import SpaceStore from "./SpaceStore";
import { Action } from "../dispatcher/actions";
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
const MAX_ROOMS = 20; // arbitrary const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
@ -63,10 +65,11 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
protected async onAction(payload: ActionPayload) { protected async onAction(payload: ActionPayload) {
if (!this.matrixClient) return; if (!this.matrixClient) return;
if (payload.action === 'setting_updated') { if (payload.action === Action.SettingUpdated) {
if (payload.settingName === 'breadcrumb_rooms') { const settingUpdatedPayload = payload as SettingUpdatedPayload;
if (settingUpdatedPayload.settingName === 'breadcrumb_rooms') {
await this.updateRooms(); await this.updateRooms();
} else if (payload.settingName === 'breadcrumbs') { } else if (settingUpdatedPayload.settingName === 'breadcrumbs') {
await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) }); await this.updateState({ enabled: SettingsStore.getValue("breadcrumbs", null) });
} }
} else if (payload.action === 'view_room') { } else if (payload.action === 'view_room') {

View file

@ -56,7 +56,7 @@ class LifecycleStore extends Store<ActionPayload> {
deferredAction: null, deferredAction: null,
}); });
break; break;
case 'syncstate': { case 'sync_state': {
if (payload.state !== 'PREPARED') { if (payload.state !== 'PREPARED') {
break; break;
} }

View file

@ -37,9 +37,8 @@ import { EnhancedMap, mapDiff } from "../utils/maps";
import { setHasDiff } from "../utils/sets"; import { setHasDiff } from "../utils/sets";
import RoomViewStore from "./RoomViewStore"; import RoomViewStore from "./RoomViewStore";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import { arrayHasDiff } from "../utils/arrays"; import { arrayHasDiff, arrayHasOrderChange } from "../utils/arrays";
import { objectDiff } from "../utils/objects"; import { objectDiff } from "../utils/objects";
import { arrayHasOrderChange } from "../utils/arrays";
import { reorderLexicographically } from "../utils/stringOrderField"; import { reorderLexicographically } from "../utils/stringOrderField";
import { TAG_ORDER } from "../components/views/rooms/RoomList"; import { TAG_ORDER } from "../components/views/rooms/RoomList";
import { shouldShowSpaceSettings } from "../utils/space"; import { shouldShowSpaceSettings } from "../utils/space";
@ -48,6 +47,7 @@ import { _t } from "../languageHandler";
import GenericToast from "../components/views/toasts/GenericToast"; import GenericToast from "../components/views/toasts/GenericToast";
import Modal from "../Modal"; import Modal from "../Modal";
import InfoDialog from "../components/views/dialogs/InfoDialog"; import InfoDialog from "../components/views/dialogs/InfoDialog";
import { SettingUpdatedPayload } from "../dispatcher/payloads/SettingUpdatedPayload";
type SpaceKey = string | symbol; type SpaceKey = string | symbol;
@ -61,6 +61,7 @@ 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");
export const UPDATE_HOME_BEHAVIOUR = Symbol("home-behaviour");
// Space Room ID/HOME_SPACE will be emitted when a Space's children change // Space Room ID/HOME_SPACE will be emitted when a Space's children change
export interface ISuggestedRoom extends ISpaceSummaryRoom { export interface ISuggestedRoom extends ISpaceSummaryRoom {
@ -69,12 +70,10 @@ export interface ISuggestedRoom extends ISpaceSummaryRoom {
const MAX_SUGGESTED_ROOMS = 20; const MAX_SUGGESTED_ROOMS = 20;
// All of these settings cause the page to reload and can be costly if read frequently, so read them here only // This setting causes the page to reload and can be costly if read frequently, so read it here only
const spacesEnabled = SettingsStore.getValue("feature_spaces"); const spacesEnabled = SettingsStore.getValue("feature_spaces");
const spacesTweakAllRoomsEnabled = SettingsStore.getValue("feature_spaces.all_rooms");
const homeSpaceKey = spacesTweakAllRoomsEnabled ? "ALL_ROOMS" : "HOME_SPACE"; const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || "HOME_SPACE"}`;
const getSpaceContextKey = (space?: Room) => `mx_space_context_${space?.roomId || homeSpaceKey}`;
const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms] const partitionSpacesAndRooms = (arr: Room[]): [Room[], Room[]] => { // [spaces, rooms]
return arr.reduce((result, room: Room) => { return arr.reduce((result, room: Room) => {
@ -102,10 +101,6 @@ const getRoomFn: FetchRoomFn = (room: Room) => {
}; };
export class SpaceStoreClass extends AsyncStoreWithClient<IState> { export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
constructor() {
super(defaultDispatcher, {});
}
// 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 // The list of rooms not present in any currently joined spaces
@ -122,6 +117,13 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
private _invitedSpaces = new Set<Room>(); private _invitedSpaces = new Set<Room>();
private spaceOrderLocalEchoMap = new Map<string, string>(); private spaceOrderLocalEchoMap = new Map<string, string>();
private _restrictedJoinRuleSupport?: IRoomCapability; private _restrictedJoinRuleSupport?: IRoomCapability;
private _allRoomsInHome: boolean = SettingsStore.getValue("Spaces.allRoomsInHome");
constructor() {
super(defaultDispatcher, {});
SettingsStore.monitorSetting("Spaces.allRoomsInHome", null);
}
public get invitedSpaces(): Room[] { public get invitedSpaces(): Room[] {
return Array.from(this._invitedSpaces); return Array.from(this._invitedSpaces);
@ -139,13 +141,16 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
return this._suggestedRooms; return this._suggestedRooms;
} }
public get allRoomsInHome(): boolean {
return this._allRoomsInHome;
}
public async setActiveRoomInSpace(space: Room | null): Promise<void> { public async setActiveRoomInSpace(space: Room | null): Promise<void> {
if (space && !space.isSpaceRoom()) return; if (space && !space.isSpaceRoom()) return;
if (space !== this.activeSpace) await this.setActiveSpace(space); if (space !== this.activeSpace) await this.setActiveSpace(space);
if (space) { if (space) {
const notificationState = this.getNotificationState(space.roomId); const roomId = this.getNotificationState(space.roomId).getFirstRoomWithNotifications();
const roomId = notificationState.getFirstRoomWithNotifications();
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: "view_room", action: "view_room",
room_id: roomId, room_id: roomId,
@ -200,7 +205,8 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// else if the last viewed room in this space is joined then view that // else if the last viewed room in this space is joined then view that
// else view space home or home depending on what is being clicked on // else view space home or home depending on what is being clicked on
if (space?.getMyMembership() !== "invite" && if (space?.getMyMembership() !== "invite" &&
this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" this.matrixClient?.getRoom(roomId)?.getMyMembership() === "join" &&
this.getSpaceFilteredRoomIds(space).has(roomId)
) { ) {
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: "view_room", action: "view_room",
@ -377,7 +383,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
} }
public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => { public getSpaceFilteredRoomIds = (space: Room | null): Set<string> => {
if (!space && spacesTweakAllRoomsEnabled) { if (!space && this.allRoomsInHome) {
return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId)); return new Set(this.matrixClient.getVisibleRooms().map(r => r.roomId));
} }
return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set(); return this.spaceFilteredRooms.get(space?.roomId || HOME_SPACE) || new Set();
@ -474,7 +480,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}; };
private showInHomeSpace = (room: Room) => { private showInHomeSpace = (room: Room) => {
if (spacesTweakAllRoomsEnabled) return true; if (this.allRoomsInHome) return true;
if (room.isSpaceRoom()) return false; if (room.isSpaceRoom()) return false;
return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space return !this.parentMap.get(room.roomId)?.size // put all orphaned rooms in the Home Space
|| DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space || DMRoomMap.shared().getUserIdForRoomId(room.roomId) // put all DMs in the Home Space
@ -506,7 +512,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
const oldFilteredRooms = this.spaceFilteredRooms; const oldFilteredRooms = this.spaceFilteredRooms;
this.spaceFilteredRooms = new Map(); this.spaceFilteredRooms = new Map();
if (!spacesTweakAllRoomsEnabled) { if (!this.allRoomsInHome) {
// put all room invites in the Home Space // put all room invites in the Home Space
const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite"); const invites = visibleRooms.filter(r => !r.isSpaceRoom() && r.getMyMembership() === "invite");
this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId))); this.spaceFilteredRooms.set(HOME_SPACE, new Set<string>(invites.map(room => room.roomId)));
@ -562,8 +568,10 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}); });
this.spaceFilteredRooms.forEach((roomIds, s) => { this.spaceFilteredRooms.forEach((roomIds, s) => {
if (this.allRoomsInHome && s === HOME_SPACE) return; // we'll be using the global notification state, skip
// Update NotificationStates // Update NotificationStates
this.getNotificationState(s)?.setRooms(visibleRooms.filter(room => { this.getNotificationState(s).setRooms(visibleRooms.filter(room => {
if (!roomIds.has(room.roomId)) return false; if (!roomIds.has(room.roomId)) return false;
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) { if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
@ -663,7 +671,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// TODO confirm this after implementing parenting behaviour // TODO confirm this after implementing parenting behaviour
if (room.isSpaceRoom()) { if (room.isSpaceRoom()) {
this.onSpaceUpdate(); this.onSpaceUpdate();
} else if (!spacesTweakAllRoomsEnabled) { } else if (!this.allRoomsInHome) {
this.onRoomUpdate(room); this.onRoomUpdate(room);
} }
this.emit(room.roomId); this.emit(room.roomId);
@ -687,7 +695,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
if (order !== lastOrder) { if (order !== lastOrder) {
this.notifyIfOrderChanged(); this.notifyIfOrderChanged();
} }
} else if (ev.getType() === EventType.Tag && !spacesTweakAllRoomsEnabled) { } else if (ev.getType() === EventType.Tag && !this.allRoomsInHome) {
// If the room was in favourites and now isn't or the opposite then update its position in the trees // If the room was in favourites and now isn't or the opposite then update its position in the trees
const oldTags = lastEv?.getContent()?.tags || {}; const oldTags = lastEv?.getContent()?.tags || {};
const newTags = ev.getContent()?.tags || {}; const newTags = ev.getContent()?.tags || {};
@ -698,7 +706,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}; };
private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => { private onAccountData = (ev: MatrixEvent, lastEvent: MatrixEvent) => {
if (ev.getType() === EventType.Direct) { if (!this.allRoomsInHome && ev.getType() === EventType.Direct) {
const lastContent = lastEvent.getContent(); const lastContent = lastEvent.getContent();
const content = ev.getContent(); const content = ev.getContent();
@ -733,9 +741,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.removeListener("Room.myMembership", this.onRoom); this.matrixClient.removeListener("Room.myMembership", this.onRoom);
this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData); this.matrixClient.removeListener("Room.accountData", this.onRoomAccountData);
this.matrixClient.removeListener("RoomState.events", this.onRoomState); this.matrixClient.removeListener("RoomState.events", this.onRoomState);
if (!spacesTweakAllRoomsEnabled) { this.matrixClient.removeListener("accountData", this.onAccountData);
this.matrixClient.removeListener("accountData", this.onAccountData);
}
} }
await this.reset(); await this.reset();
} }
@ -746,9 +752,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
this.matrixClient.on("Room.myMembership", this.onRoom); this.matrixClient.on("Room.myMembership", this.onRoom);
this.matrixClient.on("Room.accountData", this.onRoomAccountData); this.matrixClient.on("Room.accountData", this.onRoomAccountData);
this.matrixClient.on("RoomState.events", this.onRoomState); this.matrixClient.on("RoomState.events", this.onRoomState);
if (!spacesTweakAllRoomsEnabled) { this.matrixClient.on("accountData", this.onAccountData);
this.matrixClient.on("accountData", this.onAccountData);
}
this.matrixClient.getCapabilities().then(capabilities => { this.matrixClient.getCapabilities().then(capabilities => {
this._restrictedJoinRuleSupport = capabilities this._restrictedJoinRuleSupport = capabilities
@ -779,7 +783,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
// as it will cause you to end up in the wrong room // as it will cause you to end up in the wrong room
this.setActiveSpace(room, false); this.setActiveSpace(room, false);
} else if ( } else if (
(!spacesTweakAllRoomsEnabled || this.activeSpace) && (!this.allRoomsInHome || this.activeSpace) &&
!this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId) !this.getSpaceFilteredRoomIds(this.activeSpace).has(roomId)
) { ) {
this.switchToRelatedSpace(roomId); this.switchToRelatedSpace(roomId);
@ -791,17 +795,33 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id); window.localStorage.setItem(getSpaceContextKey(this.activeSpace), payload.room_id);
break; break;
} }
case "after_leave_room": case "after_leave_room":
if (this._activeSpace && payload.room_id === this._activeSpace.roomId) { if (this._activeSpace && payload.room_id === this._activeSpace.roomId) {
this.setActiveSpace(null, false); this.setActiveSpace(null, false);
} }
break; break;
case Action.SwitchSpace: case Action.SwitchSpace:
if (payload.num === 0) { if (payload.num === 0) {
this.setActiveSpace(null); this.setActiveSpace(null);
} else if (this.spacePanelSpaces.length >= payload.num) { } else if (this.spacePanelSpaces.length >= payload.num) {
this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]); this.setActiveSpace(this.spacePanelSpaces[payload.num - 1]);
} }
break;
case Action.SettingUpdated: {
const settingUpdatedPayload = payload as SettingUpdatedPayload;
if (settingUpdatedPayload.settingName === "Spaces.allRoomsInHome") {
const newValue = SettingsStore.getValue("Spaces.allRoomsInHome");
if (this.allRoomsInHome !== newValue) {
this._allRoomsInHome = newValue;
this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome);
this.rebuild(); // rebuild everything
}
}
break;
}
} }
} }
@ -872,7 +892,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
export default class SpaceStore { export default class SpaceStore {
public static spacesEnabled = spacesEnabled; public static spacesEnabled = spacesEnabled;
public static spacesTweakAllRoomsEnabled = spacesTweakAllRoomsEnabled;
private static internalInstance = new SpaceStoreClass(); private static internalInstance = new SpaceStoreClass();

View file

@ -36,6 +36,8 @@ import { RoomNotificationStateStore } from "../notifications/RoomNotificationSta
import { VisibilityProvider } from "./filters/VisibilityProvider"; import { VisibilityProvider } from "./filters/VisibilityProvider";
import { SpaceWatcher } from "./SpaceWatcher"; import { SpaceWatcher } from "./SpaceWatcher";
import SpaceStore from "../SpaceStore"; import SpaceStore from "../SpaceStore";
import { Action } from "../../dispatcher/actions";
import { SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -213,10 +215,11 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
const logicallyReady = this.matrixClient && this.initialListsGenerated; const logicallyReady = this.matrixClient && this.initialListsGenerated;
if (!logicallyReady) return; if (!logicallyReady) return;
if (payload.action === 'setting_updated') { if (payload.action === Action.SettingUpdated) {
if (this.watchedSettings.includes(payload.settingName)) { const settingUpdatedPayload = payload as SettingUpdatedPayload;
if (this.watchedSettings.includes(settingUpdatedPayload.settingName)) {
// TODO: Remove with https://github.com/vector-im/element-web/issues/14602 // TODO: Remove with https://github.com/vector-im/element-web/issues/14602
if (payload.settingName === "advancedRoomListLogging") { if (settingUpdatedPayload.settingName === "advancedRoomListLogging") {
// Log when the setting changes so we know when it was turned on in the rageshake // Log when the setting changes so we know when it was turned on in the rageshake
const enabled = SettingsStore.getValue("advancedRoomListLogging"); const enabled = SettingsStore.getValue("advancedRoomListLogging");
console.warn("Advanced room list logging is enabled? " + enabled); console.warn("Advanced room list logging is enabled? " + enabled);
@ -708,6 +711,7 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
} }
let promise = Promise.resolve(); let promise = Promise.resolve();
let idx = this.filterConditions.indexOf(filter); let idx = this.filterConditions.indexOf(filter);
let removed = false;
if (idx >= 0) { if (idx >= 0) {
this.filterConditions.splice(idx, 1); this.filterConditions.splice(idx, 1);
@ -718,14 +722,20 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> {
if (SpaceStore.spacesEnabled) { if (SpaceStore.spacesEnabled) {
promise = this.recalculatePrefiltering(); promise = this.recalculatePrefiltering();
} }
removed = true;
} }
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);
this.prefilterConditions.splice(idx, 1); this.prefilterConditions.splice(idx, 1);
promise = this.recalculatePrefiltering(); promise = this.recalculatePrefiltering();
removed = true;
}
if (removed) {
promise.then(() => this.updateFn.trigger());
} }
promise.then(() => this.updateFn.trigger());
} }
/** /**

View file

@ -18,39 +18,47 @@ import { Room } from "matrix-js-sdk/src/models/room";
import { RoomListStoreClass } from "./RoomListStore"; import { RoomListStoreClass } from "./RoomListStore";
import { SpaceFilterCondition } from "./filters/SpaceFilterCondition"; import { SpaceFilterCondition } from "./filters/SpaceFilterCondition";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../SpaceStore"; import SpaceStore, { UPDATE_HOME_BEHAVIOUR, 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: SpaceFilterCondition; private readonly filter = new SpaceFilterCondition();
// we track these separately to the SpaceStore as we need to observe transitions
private activeSpace: Room = SpaceStore.instance.activeSpace; private activeSpace: Room = SpaceStore.instance.activeSpace;
private allRoomsInHome: boolean = SpaceStore.instance.allRoomsInHome;
constructor(private store: RoomListStoreClass) { constructor(private store: RoomListStoreClass) {
if (!SpaceStore.spacesTweakAllRoomsEnabled) { if (!this.allRoomsInHome || this.activeSpace) {
this.filter = new SpaceFilterCondition();
this.updateFilter(); this.updateFilter();
store.addFilter(this.filter); store.addFilter(this.filter);
} }
SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated); SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated);
SpaceStore.instance.on(UPDATE_HOME_BEHAVIOUR, this.onHomeBehaviourUpdated);
} }
private onSelectedSpaceUpdated = (activeSpace?: Room) => { private onSelectedSpaceUpdated = (activeSpace?: Room, allRoomsInHome = this.allRoomsInHome) => {
this.activeSpace = activeSpace; if (activeSpace === this.activeSpace && allRoomsInHome === this.allRoomsInHome) return; // nop
if (this.filter) { const oldActiveSpace = this.activeSpace;
if (activeSpace || !SpaceStore.spacesTweakAllRoomsEnabled) { const oldAllRoomsInHome = this.allRoomsInHome;
this.updateFilter(); this.activeSpace = activeSpace;
} else { this.allRoomsInHome = allRoomsInHome;
this.store.removeFilter(this.filter);
this.filter = null; if (activeSpace || !allRoomsInHome) {
}
} else if (activeSpace) {
this.filter = new SpaceFilterCondition();
this.updateFilter(); this.updateFilter();
this.store.addFilter(this.filter);
} }
if (oldAllRoomsInHome && !oldActiveSpace) {
this.store.addFilter(this.filter);
} else if (allRoomsInHome && !activeSpace) {
this.store.removeFilter(this.filter);
}
};
private onHomeBehaviourUpdated = (allRoomsInHome: boolean) => {
this.onSelectedSpaceUpdated(this.activeSpace, allRoomsInHome);
}; };
private updateFilter = () => { private updateFilter = () => {

View file

@ -17,7 +17,7 @@ limitations under the License.
import { IPreview } from "./IPreview"; import { IPreview } from "./IPreview";
import { TagID } from "../models"; import { TagID } from "../models";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { _t } from "../../../languageHandler"; import { _t, sanitizeForTranslation } from "../../../languageHandler";
import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils"; import { getSenderName, isSelf, shouldPrefixMessagesIn } from "./utils";
import ReplyThread from "../../../components/views/elements/ReplyThread"; import ReplyThread from "../../../components/views/elements/ReplyThread";
import { getHtmlText } from "../../../HtmlUtils"; import { getHtmlText } from "../../../HtmlUtils";
@ -58,6 +58,8 @@ export class MessageEventPreview implements IPreview {
body = getHtmlText(body); body = getHtmlText(body);
} }
body = sanitizeForTranslation(body);
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {
return _t("* %(senderName)s %(emote)s", { senderName: getSenderName(event), emote: body }); return _t("* %(senderName)s %(emote)s", { senderName: getSenderName(event), emote: body });
} }

103
src/utils/FileDownloader.ts Normal file
View file

@ -0,0 +1,103 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type getIframeFn = () => HTMLIFrameElement; // eslint-disable-line @typescript-eslint/naming-convention
export const DEFAULT_STYLES = {
imgSrc: "",
imgStyle: null, // css props
style: "",
textContent: "",
};
type DownloadOptions = {
blob: Blob;
name: string;
autoDownload?: boolean;
opts?: typeof DEFAULT_STYLES;
};
// set up the iframe as a singleton so we don't have to figure out destruction of it down the line.
let managedIframe: HTMLIFrameElement;
let onLoadPromise: Promise<void>;
function getManagedIframe(): { iframe: HTMLIFrameElement, onLoadPromise: Promise<void> } {
if (managedIframe) return { iframe: managedIframe, onLoadPromise };
managedIframe = document.createElement("iframe");
// Need to append the iframe in order for the browser to load it.
document.body.appendChild(managedIframe);
// Dev note: the reassignment warnings are entirely incorrect here.
// @ts-ignore
// noinspection JSConstantReassignment
managedIframe.style = { display: "none" };
// @ts-ignore
// noinspection JSConstantReassignment
managedIframe.sandbox = "allow-scripts allow-downloads allow-downloads-without-user-activation";
onLoadPromise = new Promise(resolve => {
managedIframe.onload = () => {
resolve();
};
managedIframe.src = "usercontent/"; // XXX: Should come from the skin
});
return { iframe: managedIframe, onLoadPromise };
}
// TODO: If we decide to keep the download link behaviour, we should bring the style management into here.
/**
* Helper to handle safe file downloads. This operates off an iframe for reasons described
* by the blob helpers. By default, this will use a hidden iframe to manage the download
* through a user content wrapper, but can be given an iframe reference if the caller needs
* additional control over the styling/position of the iframe itself.
*/
export class FileDownloader {
private onLoadPromise: Promise<void>;
/**
* Creates a new file downloader
* @param iframeFn Function to get a pre-configured iframe. Set to null to have the downloader
* use a generic, hidden, iframe.
*/
constructor(private iframeFn: getIframeFn = null) {
}
private get iframe(): HTMLIFrameElement {
const iframe = this.iframeFn?.();
if (!iframe) {
const managed = getManagedIframe();
this.onLoadPromise = managed.onLoadPromise;
return managed.iframe;
}
this.onLoadPromise = null;
return iframe;
}
public async download({ blob, name, autoDownload = true, opts = DEFAULT_STYLES }: DownloadOptions) {
const iframe = this.iframe; // get the iframe first just in case we need to await onload
if (this.onLoadPromise) await this.onLoadPromise;
iframe.contentWindow.postMessage({
...opts,
blob: blob,
download: name,
auto: autoDownload,
}, '*');
}
}

View file

@ -26,12 +26,14 @@ import { _t } from '../languageHandler';
* @param {IMediaEventContent} content The "content" key of the matrix event. * @param {IMediaEventContent} content The "content" key of the matrix event.
* @param {string} fallbackText The fallback text * @param {string} fallbackText The fallback text
* @param {boolean} withSize Whether to include size information. Default true. * @param {boolean} withSize Whether to include size information. Default true.
* @param {boolean} shortened Ensure the extension of the file name is visible. Default false.
* @return {string} the human readable link text for the attachment. * @return {string} the human readable link text for the attachment.
*/ */
export function presentableTextForFile( export function presentableTextForFile(
content: IMediaEventContent, content: IMediaEventContent,
fallbackText = _t("Attachment"), fallbackText = _t("Attachment"),
withSize = true, withSize = true,
shortened = false,
): string { ): string {
let text = fallbackText; let text = fallbackText;
if (content.body && content.body.length > 0) { if (content.body && content.body.length > 0) {
@ -40,6 +42,21 @@ export function presentableTextForFile(
text = content.body; text = content.body;
} }
// We shorten to 15 characters somewhat arbitrarily, and assume most files
// will have a 3 character (plus full stop) extension. The goal is to knock
// the label down to 15-25 characters, not perfect accuracy.
if (shortened && text.length > 19) {
const parts = text.split('.');
let fileName = parts.slice(0, parts.length - 1).join('.').substring(0, 15);
const extension = parts[parts.length - 1];
// Trim off any full stops from the file name to avoid a case where we
// add an ellipsis that looks really funky.
fileName = fileName.replace(/\.*$/g, '');
text = `${fileName}...${extension}`;
}
if (content.info && content.info.size && withSize) { if (content.info && content.info.size && withSize) {
// If we know the size of the file then add it as human readable // If we know the size of the file then add it as human readable
// string to the end of the link text so that the user knows how // string to the end of the link text so that the user knows how

View file

@ -18,4 +18,3 @@ limitations under the License.
// SpaceStore reads the SettingsStore which needs the localStorage values set at init time. // SpaceStore reads the SettingsStore which needs the localStorage values set at init time.
localStorage.setItem("mx_labs_feature_feature_spaces", "true"); localStorage.setItem("mx_labs_feature_feature_spaces", "true");
localStorage.setItem("mx_labs_feature_feature_spaces.all_rooms", "true");

View file

@ -16,41 +16,26 @@ limitations under the License.
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import "./SpaceStore-setup"; // enable space lab import "./SpaceStore-setup"; // enable space lab
import "../skinned-sdk"; // Must be first for skinning to work import "../skinned-sdk"; // Must be first for skinning to work
import SpaceStore, { import SpaceStore, {
UPDATE_HOME_BEHAVIOUR,
UPDATE_INVITED_SPACES, UPDATE_INVITED_SPACES,
UPDATE_SELECTED_SPACE, UPDATE_SELECTED_SPACE,
UPDATE_TOP_LEVEL_SPACES, UPDATE_TOP_LEVEL_SPACES,
} from "../../src/stores/SpaceStore"; } from "../../src/stores/SpaceStore";
import { resetAsyncStoreWithClient, setupAsyncStoreWithClient } from "../utils/test-utils"; import * as testUtils from "../utils/test-utils";
import { mkEvent, mkStubRoom, stubClient } from "../test-utils"; import { mkEvent, stubClient } from "../test-utils";
import { EnhancedMap } from "../../src/utils/maps";
import DMRoomMap from "../../src/utils/DMRoomMap"; import DMRoomMap from "../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../src/MatrixClientPeg"; import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import defaultDispatcher from "../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../src/dispatcher/dispatcher";
import SettingsStore from "../../src/settings/SettingsStore";
import { SettingLevel } from "../../src/settings/SettingLevel";
jest.useFakeTimers(); jest.useFakeTimers();
const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
events.forEach(event => {
stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event);
});
return (eventType: string, stateKey?: string) => {
if (stateKey || stateKey === "") {
return stateMap.get(eventType)?.get(stateKey) || null;
}
return Array.from(stateMap.get(eventType)?.values() || []);
};
};
const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r));
const testUserId = "@test:user"; const testUserId = "@test:user";
const getUserIdForRoomId = jest.fn(); const getUserIdForRoomId = jest.fn();
@ -87,45 +72,30 @@ describe("SpaceStore", () => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
let rooms = []; let rooms = [];
const mkRoom = (roomId: string) => testUtils.mkRoom(client, roomId, rooms);
const mkRoom = (roomId: string) => { const mkSpace = (spaceId: string, children: string[] = []) => testUtils.mkSpace(client, spaceId, rooms, children);
const room = mkStubRoom(roomId, roomId, client);
room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([]));
rooms.push(room);
return room;
};
const mkSpace = (spaceId: string, children: string[] = []) => {
const space = mkRoom(spaceId);
space.isSpaceRoom.mockReturnValue(true);
space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId =>
mkEvent({
event: true,
type: EventType.SpaceChild,
room: spaceId,
user: testUserId,
skey: roomId,
content: { via: [] },
ts: Date.now(),
}),
)));
return space;
};
const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true); const viewRoom = roomId => defaultDispatcher.dispatch({ action: "view_room", room_id: roomId }, true);
const run = async () => { const run = async () => {
client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId)); client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId));
await setupAsyncStoreWithClient(store, client); await testUtils.setupAsyncStoreWithClient(store, client);
jest.runAllTimers(); jest.runAllTimers();
}; };
const setShowAllRooms = async (value: boolean) => {
if (store.allRoomsInHome === value) return;
const emitProm = testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR);
await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, value);
jest.runAllTimers(); // run async dispatch
await emitProm;
};
beforeEach(() => { beforeEach(() => {
jest.runAllTimers(); jest.runAllTimers(); // run async dispatch
client.getVisibleRooms.mockReturnValue(rooms = []); client.getVisibleRooms.mockReturnValue(rooms = []);
}); });
afterEach(async () => { afterEach(async () => {
await resetAsyncStoreWithClient(store); await testUtils.resetAsyncStoreWithClient(store);
}); });
describe("static hierarchy resolution tests", () => { describe("static hierarchy resolution tests", () => {
@ -387,10 +357,16 @@ describe("SpaceStore", () => {
expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy(); expect(store.getSpaceFilteredRoomIds(null).has(invite2)).toBeTruthy();
}); });
it("home space does contain rooms/low priority even if they are also shown in a space", () => { it("all rooms space does contain rooms/low priority even if they are also shown in a space", async () => {
await setShowAllRooms(true);
expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy(); expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeTruthy();
}); });
it("home space doesn't contain rooms/low priority if they are also shown in a space", async () => {
await setShowAllRooms(false);
expect(store.getSpaceFilteredRoomIds(null).has(room1)).toBeFalsy();
});
it("space contains child rooms", () => { it("space contains child rooms", () => {
const space = client.getRoom(space1); const space = client.getRoom(space1);
expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy(); expect(store.getSpaceFilteredRoomIds(space).has(fav1)).toBeTruthy();
@ -488,7 +464,7 @@ describe("SpaceStore", () => {
await run(); await run();
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
const space = mkSpace(space1); const space = mkSpace(space1);
const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES);
emitter.emit("Room", space); emitter.emit("Room", space);
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.spacePanelSpaces).toStrictEqual([space]);
@ -501,7 +477,7 @@ describe("SpaceStore", () => {
expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.spacePanelSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("leave"); space.getMyMembership.mockReturnValue("leave");
const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES);
emitter.emit("Room.myMembership", space, "leave", "join"); emitter.emit("Room.myMembership", space, "leave", "join");
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
@ -513,7 +489,7 @@ describe("SpaceStore", () => {
expect(store.invitedSpaces).toStrictEqual([]); expect(store.invitedSpaces).toStrictEqual([]);
const space = mkSpace(space1); const space = mkSpace(space1);
space.getMyMembership.mockReturnValue("invite"); space.getMyMembership.mockReturnValue("invite");
const prom = emitPromise(store, UPDATE_INVITED_SPACES); const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES);
emitter.emit("Room", space); emitter.emit("Room", space);
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
@ -528,7 +504,7 @@ describe("SpaceStore", () => {
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("join"); space.getMyMembership.mockReturnValue("join");
const prom = emitPromise(store, UPDATE_TOP_LEVEL_SPACES); const prom = testUtils.emitPromise(store, UPDATE_TOP_LEVEL_SPACES);
emitter.emit("Room.myMembership", space, "join", "invite"); emitter.emit("Room.myMembership", space, "join", "invite");
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([space]); expect(store.spacePanelSpaces).toStrictEqual([space]);
@ -543,7 +519,7 @@ describe("SpaceStore", () => {
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
expect(store.invitedSpaces).toStrictEqual([space]); expect(store.invitedSpaces).toStrictEqual([space]);
space.getMyMembership.mockReturnValue("leave"); space.getMyMembership.mockReturnValue("leave");
const prom = emitPromise(store, UPDATE_INVITED_SPACES); const prom = testUtils.emitPromise(store, UPDATE_INVITED_SPACES);
emitter.emit("Room.myMembership", space, "leave", "invite"); emitter.emit("Room.myMembership", space, "leave", "invite");
await prom; await prom;
expect(store.spacePanelSpaces).toStrictEqual([]); expect(store.spacePanelSpaces).toStrictEqual([]);
@ -563,7 +539,7 @@ describe("SpaceStore", () => {
const invite = mkRoom(invite1); const invite = mkRoom(invite1);
invite.getMyMembership.mockReturnValue("invite"); invite.getMyMembership.mockReturnValue("invite");
const prom = emitPromise(store, space1); const prom = testUtils.emitPromise(store, space1);
emitter.emit("Room", space); emitter.emit("Room", space);
await prom; await prom;
@ -633,20 +609,30 @@ describe("SpaceStore", () => {
}); });
describe("context switching tests", () => { describe("context switching tests", () => {
const fn = jest.spyOn(defaultDispatcher, "dispatch"); let dispatcherRef;
let currentRoom = null;
beforeEach(async () => { beforeEach(async () => {
[room1, room2, orphan1].forEach(mkRoom); [room1, room2, orphan1].forEach(mkRoom);
mkSpace(space1, [room1, room2]); mkSpace(space1, [room1, room2]);
mkSpace(space2, [room2]); mkSpace(space2, [room2]);
await run(); await run();
dispatcherRef = defaultDispatcher.register(payload => {
if (payload.action === "view_room" || payload.action === "view_home_page") {
currentRoom = payload.room_id || null;
}
});
}); });
afterEach(() => { afterEach(() => {
fn.mockClear();
localStorage.clear(); localStorage.clear();
defaultDispatcher.unregister(dispatcherRef);
}); });
const getCurrentRoom = () => fn.mock.calls.reverse().find(([p]) => p.action === "view_room")?.[0].room_id; const getCurrentRoom = () => {
jest.runAllTimers();
return currentRoom;
};
it("last viewed room in target space is the current viewed and in both spaces", async () => { it("last viewed room in target space is the current viewed and in both spaces", async () => {
await store.setActiveSpace(client.getRoom(space1)); await store.setActiveSpace(client.getRoom(space1));
@ -683,6 +669,14 @@ describe("SpaceStore", () => {
expect(getCurrentRoom()).toBe(space2); expect(getCurrentRoom()).toBe(space2);
}); });
it("last viewed room is target space is no longer in that space", async () => {
await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1);
localStorage.setItem(`mx_space_context_${space2}`, room1);
await store.setActiveSpace(client.getRoom(space2));
expect(getCurrentRoom()).toBe(space2); // Space home instead of room1
});
it("no last viewed room in target space", async () => { it("no last viewed room in target space", async () => {
await store.setActiveSpace(client.getRoom(space1)); await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1); viewRoom(room1);
@ -694,7 +688,7 @@ describe("SpaceStore", () => {
await store.setActiveSpace(client.getRoom(space1)); await store.setActiveSpace(client.getRoom(space1));
viewRoom(room1); viewRoom(room1);
await store.setActiveSpace(null); await store.setActiveSpace(null);
expect(fn.mock.calls[fn.mock.calls.length - 1][0]).toStrictEqual({ action: "view_home_page" }); expect(getCurrentRoom()).toBeNull(); // Home
}); });
}); });
@ -704,7 +698,8 @@ describe("SpaceStore", () => {
mkSpace(space1, [room1, room2, room3]); mkSpace(space1, [room1, room2, room3]);
mkSpace(space2, [room1, room2]); mkSpace(space2, [room1, room2]);
client.getRoom(room2).currentState.getStateEvents.mockImplementation(mockStateEventImplementation([ const cliRoom2 = client.getRoom(room2);
cliRoom2.currentState.getStateEvents.mockImplementation(testUtils.mockStateEventImplementation([
mkEvent({ mkEvent({
event: true, event: true,
type: EventType.SpaceParent, type: EventType.SpaceParent,
@ -747,6 +742,7 @@ describe("SpaceStore", () => {
}); });
it("when switching rooms in the all rooms home space don't switch to related space", async () => { it("when switching rooms in the all rooms home space don't switch to related space", async () => {
await setShowAllRooms(true);
viewRoom(room2); viewRoom(room2);
await store.setActiveSpace(null, false); await store.setActiveSpace(null, false);
viewRoom(room1); viewRoom(room1);

View file

@ -0,0 +1,186 @@
/*
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 "../SpaceStore-setup"; // enable space lab
import "../../skinned-sdk"; // Must be first for skinning to work
import { SpaceWatcher } from "../../../src/stores/room-list/SpaceWatcher";
import type { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore";
import SettingsStore from "../../../src/settings/SettingsStore";
import SpaceStore, { UPDATE_HOME_BEHAVIOUR } from "../../../src/stores/SpaceStore";
import { stubClient } from "../../test-utils";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { setupAsyncStoreWithClient } from "../../utils/test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import * as testUtils from "../../utils/test-utils";
import { SpaceFilterCondition } from "../../../src/stores/room-list/filters/SpaceFilterCondition";
let filter: SpaceFilterCondition = null;
const mockRoomListStore = {
addFilter: f => filter = f,
removeFilter: () => filter = null,
} as unknown as RoomListStoreClass;
const space1Id = "!space1:server";
const space2Id = "!space2:server";
describe("SpaceWatcher", () => {
stubClient();
const store = SpaceStore.instance;
const client = MatrixClientPeg.get();
let rooms = [];
const mkSpace = (spaceId: string, children: string[] = []) => testUtils.mkSpace(client, spaceId, rooms, children);
const setShowAllRooms = async (value: boolean) => {
if (store.allRoomsInHome === value) return;
await SettingsStore.setValue("Spaces.allRoomsInHome", null, SettingLevel.DEVICE, value);
await testUtils.emitPromise(store, UPDATE_HOME_BEHAVIOUR);
};
let space1;
let space2;
beforeEach(async () => {
filter = null;
store.removeAllListeners();
await store.setActiveSpace(null);
client.getVisibleRooms.mockReturnValue(rooms = []);
space1 = mkSpace(space1Id);
space2 = mkSpace(space2Id);
client.getRoom.mockImplementation(roomId => rooms.find(room => room.roomId === roomId));
await setupAsyncStoreWithClient(store, client);
});
it("initialises sanely with home behaviour", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
});
it("initialises sanely with all behaviour", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeNull();
});
it("sets space=null filter for all -> home transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
await setShowAllRooms(false);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBeNull();
});
it("sets filter correctly for all -> space transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
await SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
});
it("removes filter for home -> all transition", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
await setShowAllRooms(true);
expect(filter).toBeNull();
});
it("sets filter correctly for home -> space transition", async () => {
await setShowAllRooms(false);
new SpaceWatcher(mockRoomListStore);
await SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
});
it("removes filter for space -> all transition", async () => {
await setShowAllRooms(true);
new SpaceWatcher(mockRoomListStore);
await SpaceStore.instance.setActiveSpace(space1);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
await SpaceStore.instance.setActiveSpace(null);
expect(filter).toBeNull();
});
it("updates filter correctly for space -> home transition", async () => {
await setShowAllRooms(false);
await SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
await SpaceStore.instance.setActiveSpace(null);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(null);
});
it("updates filter correctly for space -> space transition", async () => {
await setShowAllRooms(false);
await SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
await SpaceStore.instance.setActiveSpace(space2);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space2);
});
it("doesn't change filter when changing showAllRooms mode to true", async () => {
await setShowAllRooms(false);
await SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
await setShowAllRooms(true);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
});
it("doesn't change filter when changing showAllRooms mode to false", async () => {
await setShowAllRooms(true);
await SpaceStore.instance.setActiveSpace(space1);
new SpaceWatcher(mockRoomListStore);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
await setShowAllRooms(false);
expect(filter).toBeInstanceOf(SpaceFilterCondition);
expect(filter["space"]).toBe(space1);
});
});

View file

@ -15,7 +15,13 @@ limitations under the License.
*/ */
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient"; import { AsyncStoreWithClient } from "../../src/stores/AsyncStoreWithClient";
import { mkEvent, mkStubRoom } from "../test-utils";
import { EnhancedMap } from "../../src/utils/maps";
import { EventEmitter } from "events";
// These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent // These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent
// ready state without needing to wire up a dispatcher and pretend to be a js-sdk client. // ready state without needing to wire up a dispatcher and pretend to be a js-sdk client.
@ -31,3 +37,48 @@ export const resetAsyncStoreWithClient = async (store: AsyncStoreWithClient<any>
// @ts-ignore // @ts-ignore
await store.onNotReady(); await store.onNotReady();
}; };
export const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
events.forEach(event => {
stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event);
});
return (eventType: string, stateKey?: string) => {
if (stateKey || stateKey === "") {
return stateMap.get(eventType)?.get(stateKey) || null;
}
return Array.from(stateMap.get(eventType)?.values() || []);
};
};
export const mkRoom = (client: MatrixClient, roomId: string, rooms?: ReturnType<typeof mkStubRoom>[]) => {
const room = mkStubRoom(roomId, roomId, client);
room.currentState.getStateEvents.mockImplementation(mockStateEventImplementation([]));
rooms?.push(room);
return room;
};
export const mkSpace = (
client: MatrixClient,
spaceId: string,
rooms?: ReturnType<typeof mkStubRoom>[],
children: string[] = [],
) => {
const space = mkRoom(client, spaceId, rooms);
space.isSpaceRoom.mockReturnValue(true);
space.currentState.getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId =>
mkEvent({
event: true,
type: EventType.SpaceChild,
room: spaceId,
user: "@user:server",
skey: roomId,
content: { via: [] },
ts: Date.now(),
}),
)));
return space;
};
export const emitPromise = (e: EventEmitter, k: string | symbol) => new Promise(r => e.once(k, r));

View file

@ -5445,10 +5445,10 @@ mathml-tag-names@^2.1.3:
resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3" resolved "https://registry.yarnpkg.com/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz#4ddadd67308e780cf16a47685878ee27b736a0a3"
integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg== integrity sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==
matrix-js-sdk@12.1.0: matrix-js-sdk@12.2.0:
version "12.1.0" version "12.2.0"
resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.1.0.tgz#7d159dd9bc03701e45a6b2777f1fa582a7e8b970" resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-12.2.0.tgz#e1dc7ddac054289cb24ee3d11dba8a5ba5ddecf5"
integrity sha512-/fSqOjD+mTlMD+/B3s3Ja6BfI46FnTDl43ojzGDUOsHRRmSYUmoONb83qkH5Fjm8cI2q5ZBJMsBfjuZwLVeiZw== integrity sha512-foSs3uKRc6uvFNhgY35eErBvLWVDd5RNIxxsdFKlmU3B+70YUf3BP3petyBNW34ORyOqNdX36IiApfLo3npNEw==
dependencies: dependencies:
"@babel/runtime" "^7.12.5" "@babel/runtime" "^7.12.5"
another-json "^0.2.0" another-json "^0.2.0"