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

This commit is contained in:
Michael Telatynski 2021-09-16 10:05:20 +01:00
commit e17318a364
52 changed files with 1571 additions and 1266 deletions

View file

@ -1,3 +1,70 @@
Changes in [3.30.0](https://github.com/vector-im/element-desktop/releases/tag/v3.30.0) (2021-09-14)
===================================================================================================
## ✨ Features
* Add bubble highlight styling ([\#6582](https://github.com/matrix-org/matrix-react-sdk/pull/6582)). Fixes vector-im/element-web#18295 and vector-im/element-web#18295. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* [Release] Add config option to turn on in-room event sending timing metrics ([\#6773](https://github.com/matrix-org/matrix-react-sdk/pull/6773)).
* Create narrow mode for Composer ([\#6682](https://github.com/matrix-org/matrix-react-sdk/pull/6682)). Fixes vector-im/element-web#18533 and vector-im/element-web#18533.
* Prefer matrix.to alias links over room id in spaces & share ([\#6745](https://github.com/matrix-org/matrix-react-sdk/pull/6745)). Fixes vector-im/element-web#18796 and vector-im/element-web#18796.
* Stop automatic playback of voice messages if a non-voice message is encountered ([\#6728](https://github.com/matrix-org/matrix-react-sdk/pull/6728)). Fixes vector-im/element-web#18850 and vector-im/element-web#18850.
* Show call length during a call ([\#6700](https://github.com/matrix-org/matrix-react-sdk/pull/6700)). Fixes vector-im/element-web#18566 and vector-im/element-web#18566. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Serialize and retry mass-leave when leaving space ([\#6737](https://github.com/matrix-org/matrix-react-sdk/pull/6737)). Fixes vector-im/element-web#18789 and vector-im/element-web#18789.
* Improve form handling in and around space creation ([\#6739](https://github.com/matrix-org/matrix-react-sdk/pull/6739)). Fixes vector-im/element-web#18775 and vector-im/element-web#18775.
* Split autoplay GIFs and videos into different settings ([\#6726](https://github.com/matrix-org/matrix-react-sdk/pull/6726)). Fixes vector-im/element-web#5771 and vector-im/element-web#5771. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Add autoplay for voice messages ([\#6710](https://github.com/matrix-org/matrix-react-sdk/pull/6710)). Fixes vector-im/element-web#18804, vector-im/element-web#18715, vector-im/element-web#18714 vector-im/element-web#17961 and vector-im/element-web#18804.
* Allow to use basic html to format invite messages ([\#6703](https://github.com/matrix-org/matrix-react-sdk/pull/6703)). Fixes vector-im/element-web#15738 and vector-im/element-web#15738. Contributed by [skolmer](https://github.com/skolmer).
* Allow widgets, when eligible, to interact with more rooms as per MSC2762 ([\#6684](https://github.com/matrix-org/matrix-react-sdk/pull/6684)).
* Remove arbitrary limits from send/receive events for widgets ([\#6719](https://github.com/matrix-org/matrix-react-sdk/pull/6719)). Fixes vector-im/element-web#17994 and vector-im/element-web#17994.
* Reload suggested rooms if we see the state change down /sync ([\#6715](https://github.com/matrix-org/matrix-react-sdk/pull/6715)). Fixes vector-im/element-web#18761 and vector-im/element-web#18761.
* When creating private spaces, make the initial rooms restricted if supported ([\#6721](https://github.com/matrix-org/matrix-react-sdk/pull/6721)). Fixes vector-im/element-web#18722 and vector-im/element-web#18722.
* Threading exploration work ([\#6658](https://github.com/matrix-org/matrix-react-sdk/pull/6658)). Fixes vector-im/element-web#18532 and vector-im/element-web#18532.
* Default to `Don't leave any` when leaving a space ([\#6697](https://github.com/matrix-org/matrix-react-sdk/pull/6697)). Fixes vector-im/element-web#18592 and vector-im/element-web#18592. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Special case redaction event sending from widgets per MSC2762 ([\#6686](https://github.com/matrix-org/matrix-react-sdk/pull/6686)). Fixes vector-im/element-web#18573 and vector-im/element-web#18573.
* Add active speaker indicators ([\#6639](https://github.com/matrix-org/matrix-react-sdk/pull/6639)). Fixes vector-im/element-web#17627 and vector-im/element-web#17627. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Increase general app performance by optimizing layers ([\#6644](https://github.com/matrix-org/matrix-react-sdk/pull/6644)). Fixes vector-im/element-web#18730 and vector-im/element-web#18730. Contributed by [Palid](https://github.com/Palid).
## 🐛 Bug Fixes
* Fix autocomplete not having y-scroll ([\#6802](https://github.com/matrix-org/matrix-react-sdk/pull/6802)).
* Fix emoji picker and stickerpicker not appearing correctly when opened ([\#6801](https://github.com/matrix-org/matrix-react-sdk/pull/6801)).
* Debounce read marker update on scroll ([\#6774](https://github.com/matrix-org/matrix-react-sdk/pull/6774)).
* Fix Space creation wizard go to my first room button behaviour ([\#6748](https://github.com/matrix-org/matrix-react-sdk/pull/6748)). Fixes vector-im/element-web#18764 and vector-im/element-web#18764.
* Fix scroll being stuck at bottom ([\#6751](https://github.com/matrix-org/matrix-react-sdk/pull/6751)). Fixes vector-im/element-web#18903 and vector-im/element-web#18903.
* Fix widgets not remembering identity verification when asked to. ([\#6742](https://github.com/matrix-org/matrix-react-sdk/pull/6742)). Fixes vector-im/element-web#15631 and vector-im/element-web#15631.
* Add missing pluralisation i18n strings for Spaces ([\#6738](https://github.com/matrix-org/matrix-react-sdk/pull/6738)). Fixes vector-im/element-web#18780 and vector-im/element-web#18780.
* Make ForgotPassword UX slightly more user friendly ([\#6636](https://github.com/matrix-org/matrix-react-sdk/pull/6636)). Fixes vector-im/element-web#11531 and vector-im/element-web#11531. Contributed by [Palid](https://github.com/Palid).
* Don't context switch room on SpaceStore ready as it can break permalinks ([\#6730](https://github.com/matrix-org/matrix-react-sdk/pull/6730)). Fixes vector-im/element-web#17974 and vector-im/element-web#17974.
* Fix explore rooms button not working during space creation wizard ([\#6729](https://github.com/matrix-org/matrix-react-sdk/pull/6729)). Fixes vector-im/element-web#18762 and vector-im/element-web#18762.
* Fix bug where one party's media would sometimes not be shown ([\#6731](https://github.com/matrix-org/matrix-react-sdk/pull/6731)).
* Only make the initial space rooms suggested by default ([\#6714](https://github.com/matrix-org/matrix-react-sdk/pull/6714)). Fixes vector-im/element-web#18760 and vector-im/element-web#18760.
* Replace fake username in EventTilePreview with a proper loading state ([\#6702](https://github.com/matrix-org/matrix-react-sdk/pull/6702)). Fixes vector-im/element-web#15897 and vector-im/element-web#15897. Contributed by [skolmer](https://github.com/skolmer).
* Don't send prehistorical events to widgets during decryption at startup ([\#6695](https://github.com/matrix-org/matrix-react-sdk/pull/6695)). Fixes vector-im/element-web#18060 and vector-im/element-web#18060.
* When creating subspaces properly set restricted join rule ([\#6725](https://github.com/matrix-org/matrix-react-sdk/pull/6725)). Fixes vector-im/element-web#18797 and vector-im/element-web#18797.
* Fix the Image View not openning for some pinned messages ([\#6723](https://github.com/matrix-org/matrix-react-sdk/pull/6723)). Fixes vector-im/element-web#18422 and vector-im/element-web#18422. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Show autocomplete sections vertically ([\#6722](https://github.com/matrix-org/matrix-react-sdk/pull/6722)). Fixes vector-im/element-web#18860 and vector-im/element-web#18860. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix EmojiPicker filtering to lower case emojibase data strings ([\#6717](https://github.com/matrix-org/matrix-react-sdk/pull/6717)). Fixes vector-im/element-web#18686 and vector-im/element-web#18686.
* Clear currentRoomId when viewing home page, fixing document title ([\#6716](https://github.com/matrix-org/matrix-react-sdk/pull/6716)). Fixes vector-im/element-web#18668 and vector-im/element-web#18668.
* Fix membership updates to Spaces not applying in real-time ([\#6713](https://github.com/matrix-org/matrix-react-sdk/pull/6713)). Fixes vector-im/element-web#18737 and vector-im/element-web#18737.
* Don't show a double stacked invite modals when inviting to Spaces ([\#6698](https://github.com/matrix-org/matrix-react-sdk/pull/6698)). Fixes vector-im/element-web#18745 and vector-im/element-web#18745. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Remove non-functional DuckDuckGo Autocomplete Provider ([\#6712](https://github.com/matrix-org/matrix-react-sdk/pull/6712)). Fixes vector-im/element-web#18778 and vector-im/element-web#18778.
* Filter members on `MemberList` load ([\#6708](https://github.com/matrix-org/matrix-react-sdk/pull/6708)). Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix improper voice messages being produced in Firefox and sometimes other browsers. ([\#6696](https://github.com/matrix-org/matrix-react-sdk/pull/6696)). Fixes vector-im/element-web#18587 and vector-im/element-web#18587.
* Fix client forgetting which capabilities a widget was approved for ([\#6685](https://github.com/matrix-org/matrix-react-sdk/pull/6685)). Fixes vector-im/element-web#18786 and vector-im/element-web#18786.
* Fix left panel widgets not remembering collapsed state ([\#6687](https://github.com/matrix-org/matrix-react-sdk/pull/6687)). Fixes vector-im/element-web#17803 and vector-im/element-web#17803.
* Fix changelog link colour back to blue ([\#6692](https://github.com/matrix-org/matrix-react-sdk/pull/6692)). Fixes vector-im/element-web#18726 and vector-im/element-web#18726.
* Soften codeblock border color ([\#6564](https://github.com/matrix-org/matrix-react-sdk/pull/6564)). Fixes vector-im/element-web#18367 and vector-im/element-web#18367. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Pause ringing more aggressively ([\#6691](https://github.com/matrix-org/matrix-react-sdk/pull/6691)). Fixes vector-im/element-web#18588 and vector-im/element-web#18588. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix command autocomplete ([\#6680](https://github.com/matrix-org/matrix-react-sdk/pull/6680)). Fixes vector-im/element-web#18670 and vector-im/element-web#18670. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Don't re-sort the room-list based on profile/status changes ([\#6595](https://github.com/matrix-org/matrix-react-sdk/pull/6595)). Fixes vector-im/element-web#110 and vector-im/element-web#110. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Fix codeblock formatting with syntax highlighting on ([\#6681](https://github.com/matrix-org/matrix-react-sdk/pull/6681)). Fixes vector-im/element-web#18739 vector-im/element-web#18365 and vector-im/element-web#18739. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
* Add padding to the Add button in the notification settings ([\#6665](https://github.com/matrix-org/matrix-react-sdk/pull/6665)). Fixes vector-im/element-web#18706 and vector-im/element-web#18706. Contributed by [SimonBrandner](https://github.com/SimonBrandner).
Changes in [3.29.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.29.1) (2021-09-13)
===================================================================================================
## 🔒 SECURITY FIXES
* Fix a security issue with message key sharing. See https://matrix.org/blog/2021/09/13/vulnerability-disclosure-key-sharing
for details.
Changes in [3.29.0](https://github.com/vector-im/element-desktop/releases/tag/v3.29.0) (2021-08-31) Changes in [3.29.0](https://github.com/vector-im/element-desktop/releases/tag/v3.29.0) (2021-08-31)
=================================================================================================== ===================================================================================================

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.29.0", "version": "3.30.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -93,10 +93,10 @@
"prop-types": "^15.7.2", "prop-types": "^15.7.2",
"qrcode": "^1.4.4", "qrcode": "^1.4.4",
"re-resizable": "^6.9.0", "re-resizable": "^6.9.0",
"react": "^17.0.2", "react": "17.0.2",
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-blurhash": "^0.1.3", "react-blurhash": "^0.1.3",
"react-dom": "^17.0.2", "react-dom": "17.0.2",
"react-focus-lock": "^2.5.0", "react-focus-lock": "^2.5.0",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
@ -142,9 +142,9 @@
"@types/pako": "^1.0.1", "@types/pako": "^1.0.1",
"@types/parse5": "^6.0.0", "@types/parse5": "^6.0.0",
"@types/qrcode": "^1.3.5", "@types/qrcode": "^1.3.5",
"@types/react": "^17.0.2", "@types/react": "17.0.14",
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.2", "@types/react-dom": "17.0.9",
"@types/react-transition-group": "^4.4.0", "@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "^2.3.1", "@types/sanitize-html": "^2.3.1",
"@types/zxcvbn": "^4.4.0", "@types/zxcvbn": "^4.4.0",
@ -175,9 +175,12 @@
"stylelint": "^13.9.0", "stylelint": "^13.9.0",
"stylelint-config-standard": "^20.0.0", "stylelint-config-standard": "^20.0.0",
"stylelint-scss": "^3.18.0", "stylelint-scss": "^3.18.0",
"typescript": "^4.1.3", "typescript": "4.3.5",
"walk": "^2.3.14" "walk": "^2.3.14"
}, },
"resolutions": {
"@types/react": "17.0.14"
},
"jest": { "jest": {
"testEnvironment": "./__test-utils__/environment.js", "testEnvironment": "./__test-utils__/environment.js",
"testMatch": [ "testMatch": [

View file

@ -149,7 +149,6 @@ $activeBorderColor: $secondary-content;
&:not(.mx_SpaceButton_narrow) { &:not(.mx_SpaceButton_narrow) {
.mx_SpaceButton_selectionWrapper { .mx_SpaceButton_selectionWrapper {
width: 100%; width: 100%;
padding-right: 16px;
overflow: hidden; overflow: hidden;
} }
} }
@ -161,7 +160,6 @@ $activeBorderColor: $secondary-content;
display: block; display: block;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
padding-right: 8px;
font-size: $font-14px; font-size: $font-14px;
line-height: $font-18px; line-height: $font-18px;
} }
@ -230,8 +228,7 @@ $activeBorderColor: $secondary-content;
margin-top: auto; margin-top: auto;
margin-bottom: auto; margin-bottom: auto;
display: none; display: none;
position: absolute; position: relative;
right: 4px;
&::before { &::before {
top: 2px; top: 2px;
@ -250,8 +247,6 @@ $activeBorderColor: $secondary-content;
} }
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
position: absolute;
// Create a flexbox to make aligning dot badges easier // Create a flexbox to make aligning dot badges easier
display: flex; display: flex;
align-items: center; align-items: center;
@ -269,6 +264,7 @@ $activeBorderColor: $secondary-content;
&.collapsed { &.collapsed {
.mx_SpaceButton { .mx_SpaceButton {
.mx_SpacePanel_badgeContainer { .mx_SpacePanel_badgeContainer {
position: absolute;
right: 0; right: 0;
top: 0; top: 0;
@ -298,19 +294,12 @@ $activeBorderColor: $secondary-content;
} }
&:not(.collapsed) { &:not(.collapsed) {
.mx_SpacePanel_badgeContainer {
position: absolute;
right: 4px;
}
.mx_SpaceButton:hover, .mx_SpaceButton:hover,
.mx_SpaceButton:focus-within, .mx_SpaceButton:focus-within,
.mx_SpaceButton_hasMenuOpen { .mx_SpaceButton_hasMenuOpen {
&: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;
height: 0;
display: none; display: none;
} }

View file

@ -98,14 +98,14 @@ limitations under the License.
transition: transition:
font-size 0.25s ease-out 0.1s, font-size 0.25s ease-out 0.1s,
color 0.25s ease-out 0.1s, color 0.25s ease-out 0.1s,
top 0.25s ease-out 0.1s, transform 0.25s ease-out 0.1s,
background-color 0.25s ease-out 0.1s; background-color 0.25s ease-out 0.1s;
color: $primary-content; color: $primary-content;
background-color: transparent; background-color: transparent;
font-size: $font-14px; font-size: $font-14px;
transform: translateY(0);
position: absolute; position: absolute;
left: 0px; left: 0px;
top: 0px;
margin: 7px 8px; margin: 7px 8px;
padding: 2px; padding: 2px;
pointer-events: none; // Allow clicks to fall through to the input pointer-events: none; // Allow clicks to fall through to the input
@ -124,10 +124,10 @@ limitations under the License.
transition: transition:
font-size 0.25s ease-out 0s, font-size 0.25s ease-out 0s,
color 0.25s ease-out 0s, color 0.25s ease-out 0s,
top 0.25s ease-out 0s, transform 0.25s ease-out 0s,
background-color 0.25s ease-out 0s; background-color 0.25s ease-out 0s;
font-size: $font-10px; font-size: $font-10px;
top: -13px; transform: translateY(-13px);
padding: 0 2px; padding: 0 2px;
background-color: $field-focused-label-bg-color; background-color: $field-focused-label-bg-color;
pointer-events: initial; pointer-events: initial;

View file

@ -7,7 +7,6 @@
background: $background; background: $background;
border-bottom: none; border-bottom: none;
border-radius: 8px 8px 0 0; border-radius: 8px 8px 0 0;
max-height: 35vh;
overflow: clip; overflow: clip;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -64,6 +63,7 @@
margin: 12px; margin: 12px;
height: 100%; height: 100%;
overflow-y: scroll; overflow-y: scroll;
max-height: 35vh;
} }
.mx_Autocomplete_Completion_container_truncate { .mx_Autocomplete_Completion_container_truncate {

View file

@ -23,11 +23,11 @@ limitations under the License.
} }
.mx_EventTile[data-layout=bubble] { .mx_EventTile[data-layout=bubble] {
position: relative; position: relative;
margin-top: var(--gutterSize); margin-top: var(--gutterSize);
margin-left: 50px; margin-left: 49px;
margin-right: 100px; margin-right: 100px;
font-size: $font-14px;
&.mx_EventTile_continuation { &.mx_EventTile_continuation {
margin-top: 2px; margin-top: 2px;
@ -77,10 +77,11 @@ limitations under the License.
max-width: 70%; max-width: 70%;
} }
.mx_SenderProfile { > .mx_SenderProfile {
position: relative; position: relative;
top: -2px; top: -2px;
left: 2px; left: 2px;
font-size: $font-15px;
} }
&[data-self=false] { &[data-self=false] {
@ -113,8 +114,6 @@ limitations under the License.
.mx_ReplyTile .mx_SenderProfile { .mx_ReplyTile .mx_SenderProfile {
display: block; display: block;
top: unset;
left: unset;
} }
.mx_ReactionsRow { .mx_ReactionsRow {
@ -287,6 +286,8 @@ limitations under the License.
.mx_EventTile_line, .mx_EventTile_line,
.mx_EventTile_info { .mx_EventTile_info {
min-width: 100%; min-width: 100%;
// Preserve alignment with left edge of text in bubbles
margin: 0;
} }
.mx_EventTile_e2eIcon { .mx_EventTile_e2eIcon {
@ -294,9 +295,10 @@ limitations under the License.
} }
.mx_EventTile_line > a { .mx_EventTile_line > a {
// Align timestamps with those of normal bubble tiles
right: auto; right: auto;
top: -15px; top: -11px;
left: -68px; left: -95px;
} }
} }
@ -326,11 +328,10 @@ limitations under the License.
} }
.mx_EventTile_line { .mx_EventTile_line {
margin: 0 5px; margin: 0;
> a { > a {
left: auto; // Align timestamps with those of normal bubble tiles
right: 0; left: -76px;
transform: translateX(calc(100% + 5px));
} }
} }
@ -340,7 +341,8 @@ limitations under the License.
} }
.mx_EventListSummary[data-expanded=false][data-layout=bubble] { .mx_EventListSummary[data-expanded=false][data-layout=bubble] {
padding: 0 34px; // Align with left edge of bubble tiles
padding: 0 49px;
} }
/* events that do not require bubble layout */ /* events that do not require bubble layout */

View file

@ -172,14 +172,12 @@ limitations under the License.
} }
} }
// In the general case, we leave height of headers alone even if sticky, so // In the general case, we reserve space for each sublist header to prevent
// that the sublists below them do not jump. However, that leaves a gap // scroll jumps when they become sticky. However, that leaves a gap when
// when scrolled to the top above the first sublist (whose header can only // scrolled to the top above the first sublist (whose header can only ever
// ever stick to top), so we force height to 0 for only that first header. // stick to top), so we make sure to exclude the first visible sublist.
// See also https://github.com/vector-im/element-web/issues/14429. &:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_headerContainer {
&:first-child .mx_RoomSublist_headerContainer { height: 24px;
height: 0;
padding-bottom: 4px;
} }
.mx_RoomSublist_resizeBox { .mx_RoomSublist_resizeBox {

View file

@ -184,6 +184,9 @@ $visual-bell-bg-color: #800;
$room-warning-bg-color: $header-panel-bg-color; $room-warning-bg-color: $header-panel-bg-color;
$authpage-body-bg-color: $background;
$authpage-primary-color: $primary-content;
$dark-panel-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color;
$panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1); $panel-gradient: rgba(34, 38, 46, 0), rgba(34, 38, 46, 1);

View file

@ -82,6 +82,8 @@ $tab-label-fg-color: var(--timeline-text-color);
// was #4e5054 // was #4e5054
$authpage-lang-color: var(--timeline-text-color); $authpage-lang-color: var(--timeline-text-color);
$roomheader-color: var(--timeline-text-color); $roomheader-color: var(--timeline-text-color);
// was #232f32
$authpage-primary-color: var(--timeline-text-color);
// --roomlist-text-secondary-color // --roomlist-text-secondary-color
$roomtile-preview-color: var(--roomlist-text-secondary-color); $roomtile-preview-color: var(--roomlist-text-secondary-color);
$roomlist-header-color: var(--roomlist-text-secondary-color); $roomlist-header-color: var(--roomlist-text-secondary-color);

View file

@ -93,6 +93,26 @@ declare global {
mxSetupEncryptionStore?: SetupEncryptionStore; mxSetupEncryptionStore?: SetupEncryptionStore;
mxRoomScrollStateStore?: RoomScrollStateStore; mxRoomScrollStateStore?: RoomScrollStateStore;
mxOnRecaptchaLoaded?: () => void; mxOnRecaptchaLoaded?: () => void;
electron?: Electron;
}
interface DesktopCapturerSource {
id: string;
name: string;
thumbnailURL: string;
}
interface GetSourcesOptions {
types: Array<string>;
thumbnailSize?: {
height: number;
width: number;
};
fetchWindowIcons?: boolean;
}
interface Electron {
getDesktopCapturerSources(options: GetSourcesOptions): Promise<Array<DesktopCapturerSource>>;
} }
interface Document { interface Document {

View file

@ -574,11 +574,12 @@ async function doSetLoggedIn(
await abortLogin(); await abortLogin();
} }
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl); Analytics.setLoggedIn(credentials.guest, credentials.homeserverUrl);
MatrixClientPeg.replaceUsingCreds(credentials); MatrixClientPeg.replaceUsingCreds(credentials);
PosthogAnalytics.instance.updateAnonymityFromSettings(credentials.userId);
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) { if (credentials.freshLogin && SettingsStore.getValue("feature_dehydration")) {

View file

@ -17,8 +17,8 @@ limitations under the License.
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { SettingLevel } from "./settings/SettingLevel"; import { SettingLevel } from "./settings/SettingLevel";
import { setMatrixCallAudioInput, setMatrixCallVideoInput } from "matrix-js-sdk/src/matrix";
import EventEmitter from 'events'; import EventEmitter from 'events';
import { MatrixClientPeg } from "./MatrixClientPeg";
// XXX: MediaDeviceKind is a union type, so we make our own enum // XXX: MediaDeviceKind is a union type, so we make our own enum
export enum MediaDeviceKindEnum { export enum MediaDeviceKindEnum {
@ -74,8 +74,8 @@ export default class MediaDeviceHandler extends EventEmitter {
const audioDeviceId = SettingsStore.getValue("webrtc_audioinput"); const audioDeviceId = SettingsStore.getValue("webrtc_audioinput");
const videoDeviceId = SettingsStore.getValue("webrtc_videoinput"); const videoDeviceId = SettingsStore.getValue("webrtc_videoinput");
setMatrixCallAudioInput(audioDeviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(audioDeviceId);
setMatrixCallVideoInput(videoDeviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(videoDeviceId);
} }
public setAudioOutput(deviceId: string): void { public setAudioOutput(deviceId: string): void {
@ -90,7 +90,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setAudioInput(deviceId: string): void { public setAudioInput(deviceId: string): void {
SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_audioinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallAudioInput(deviceId); MatrixClientPeg.get().getMediaHandler().setAudioInput(deviceId);
} }
/** /**
@ -100,7 +100,7 @@ export default class MediaDeviceHandler extends EventEmitter {
*/ */
public setVideoInput(deviceId: string): void { public setVideoInput(deviceId: string): void {
SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId); SettingsStore.setValue("webrtc_videoinput", null, SettingLevel.DEVICE, deviceId);
setMatrixCallVideoInput(deviceId); MatrixClientPeg.get().getMediaHandler().setVideoInput(deviceId);
} }
public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void { public setDevice(deviceId: string, kind: MediaDeviceKindEnum): void {

View file

@ -18,6 +18,8 @@ import posthog, { PostHog } from 'posthog-js';
import PlatformPeg from './PlatformPeg'; import PlatformPeg from './PlatformPeg';
import SdkConfig from './SdkConfig'; import SdkConfig from './SdkConfig';
import SettingsStore from './settings/SettingsStore'; import SettingsStore from './settings/SettingsStore';
import { MatrixClientPeg } from "./MatrixClientPeg";
import { MatrixClient } from "matrix-js-sdk/src/client";
/* Posthog analytics tracking. /* Posthog analytics tracking.
* *
@ -27,10 +29,11 @@ import SettingsStore from './settings/SettingsStore';
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is * - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
* enabled, events are not sent (this detection is built into posthog and turned on via the * enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`). * `respect_dnt` flag being passed to `posthog.init`).
* - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously, i.e. * - If the `feature_pseudonymous_analytics_opt_in` labs flag is `true`, track pseudonomously by maintaining
* hash all matrix identifiers in tracking events (user IDs, room IDs etc) using SHA-256. * a randomised analytics ID in account_data for that user (shared between devices) and sending it to posthog to
* - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. identify the user.
* redact all matrix identifiers in tracking events. * - Otherwise, if the existing `analyticsOptIn` flag is `true`, track anonymously, i.e. do not identify the user
using any identifier that would be consistent across devices.
* - If both flags are false or not set, events are not sent. * - If both flags are false or not set, events are not sent.
*/ */
@ -71,12 +74,6 @@ interface IPageView extends IAnonymousEvent {
}; };
} }
const hashHex = async (input: string): Promise<string> => {
const buf = new TextEncoder().encode(input);
const digestBuf = await window.crypto.subtle.digest("sha-256", buf);
return [...new Uint8Array(digestBuf)].map((b: number) => b.toString(16).padStart(2, "0")).join("");
};
const whitelistedScreens = new Set([ const whitelistedScreens = new Set([
"register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory", "register", "login", "forgot_password", "soft_logout", "new", "settings", "welcome", "home", "start", "directory",
"start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group", "start_sso", "start_cas", "groups", "complete_security", "post_registration", "room", "user", "group",
@ -89,7 +86,6 @@ export async function getRedactedCurrentLocation(
anonymity: Anonymity, anonymity: Anonymity,
): Promise<string> { ): Promise<string> {
// Redact PII from the current location. // Redact PII from the current location.
// If anonymous is true, redact entirely, if false, substitute it with a hash.
// For known screens, assumes a URL structure of /<screen name>/might/be/pii // For known screens, assumes a URL structure of /<screen name>/might/be/pii
if (origin.startsWith('file://')) { if (origin.startsWith('file://')) {
pathname = "/<redacted_file_scheme_url>/"; pathname = "/<redacted_file_scheme_url>/";
@ -99,17 +95,13 @@ export async function getRedactedCurrentLocation(
if (hash == "") { if (hash == "") {
hashStr = ""; hashStr = "";
} else { } else {
let [beforeFirstSlash, screen, ...parts] = hash.split("/"); let [beforeFirstSlash, screen] = hash.split("/");
if (!whitelistedScreens.has(screen)) { if (!whitelistedScreens.has(screen)) {
screen = "<redacted_screen_name>"; screen = "<redacted_screen_name>";
} }
for (let i = 0; i < parts.length; i++) { hashStr = `${beforeFirstSlash}/${screen}/<redacted>`;
parts[i] = anonymity === Anonymity.Anonymous ? `<redacted>` : await hashHex(parts[i]);
}
hashStr = `${beforeFirstSlash}/${screen}/${parts.join("/")}`;
} }
return origin + pathname + hashStr; return origin + pathname + hashStr;
} }
@ -123,15 +115,15 @@ export class PosthogAnalytics {
/* Wrapper for Posthog analytics. /* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity * 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog * - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means all identifers will be redacted before being passed to posthog * - Anonymity.Anonymous means no identifier is passed to posthog
* - Anonymity.Pseudonymous means all identifiers will be hashed via SHA-256 before being passed * - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
* to Posthog * is passed to posthog.
* *
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity(). * To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
* *
* To pass an event to Posthog: * To pass an event to Posthog:
* *
* 1. Declare a type for the event, extending IAnonymousEvent, IPseudonymousEvent or IRoomEvent. * 1. Declare a type for the event, extending IAnonymousEvent or IPseudonymousEvent.
* 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is * 2. Call the appropriate track*() method. Pseudonymous events will be dropped when anonymity is
* Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled. * Anonymous or Disabled; Anonymous events will be dropped when anonymity is Disabled.
*/ */
@ -141,6 +133,7 @@ export class PosthogAnalytics {
private enabled = false; private enabled = false;
private static _instance = null; private static _instance = null;
private platformSuperProperties = {}; private platformSuperProperties = {};
private static ANALYTICS_ID_EVENT_TYPE = "im.vector.web.analytics_id";
public static get instance(): PosthogAnalytics { public static get instance(): PosthogAnalytics {
if (!this._instance) { if (!this._instance) {
@ -274,9 +267,32 @@ export class PosthogAnalytics {
this.anonymity = anonymity; this.anonymity = anonymity;
} }
public async identifyUser(userId: string): Promise<void> { private static getRandomAnalyticsId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))].map((c) => c.toString(16)).join('');
}
public async identifyUser(client: MatrixClient, analyticsIdGenerator: () => string): Promise<void> {
if (this.anonymity == Anonymity.Pseudonymous) { if (this.anonymity == Anonymity.Pseudonymous) {
this.posthog.identify(await hashHex(userId)); // Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
// different devices to send the same ID.
try {
const accountData = await client.getAccountDataFromServer(PosthogAnalytics.ANALYTICS_ID_EVENT_TYPE);
let analyticsID = accountData?.id;
if (!analyticsID) {
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
// Note there's a race condition here - if two devices do these steps at the same time, last write
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
analyticsID = analyticsIdGenerator();
await client.setAccountData("im.vector.web.analytics_id", { id: analyticsID });
}
this.posthog.identify(analyticsID);
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
console.log("Unable to identify user for tracking" + e.toString());
}
} }
} }
@ -307,18 +323,6 @@ export class PosthogAnalytics {
await this.capture(eventName, properties); await this.capture(eventName, properties);
} }
public async trackRoomEvent<E extends IRoomEvent>(
eventName: E["eventName"],
roomId: string,
properties: Omit<E["properties"], "roomId">,
): Promise<void> {
const updatedProperties = {
...properties,
hashedRoomId: roomId ? await hashHex(roomId) : null,
};
await this.trackPseudonymousEvent(eventName, updatedProperties);
}
public async trackPageView(durationMs: number): Promise<void> { public async trackPageView(durationMs: number): Promise<void> {
const hash = window.location.hash; const hash = window.location.hash;
@ -349,7 +353,7 @@ export class PosthogAnalytics {
// Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous // Identify the user (via hashed user ID) to posthog if anonymity is pseudonmyous
this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings()); this.setAnonymity(PosthogAnalytics.getAnonymityFromSettings());
if (userId && this.getAnonymity() == Anonymity.Pseudonymous) { if (userId && this.getAnonymity() == Anonymity.Pseudonymous) {
await this.identifyUser(userId); await this.identifyUser(MatrixClientPeg.get(), PosthogAnalytics.getRandomAnalyticsId);
} }
} }
} }

View file

@ -48,11 +48,6 @@ export default class Resend {
// XXX: temporary logging to try to diagnose // XXX: temporary logging to try to diagnose
// https://github.com/vector-im/element-web/issues/3148 // https://github.com/vector-im/element-web/issues/3148
console.log('Resend got send failure: ' + err.name + '(' + err + ')'); console.log('Resend got send failure: ' + err.name + '(' + err + ')');
dis.dispatch({
action: 'message_send_failed',
event: event,
});
}); });
} }

View file

@ -322,10 +322,16 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
const menuClasses = classNames({ const menuClasses = classNames({
'mx_ContextualMenu': true, 'mx_ContextualMenu': true,
'mx_ContextualMenu_left': !hasChevron && position.left, /**
'mx_ContextualMenu_right': !hasChevron && position.right, * In some cases we may get the number of 0, which still means that we're supposed to properly
'mx_ContextualMenu_top': !hasChevron && position.top, * add the specific position class, but as it was falsy things didn't work as intended.
'mx_ContextualMenu_bottom': !hasChevron && position.bottom, * In addition, defensively check for counter cases where we may get more than one value,
* even if we shouldn't.
*/
'mx_ContextualMenu_left': !hasChevron && position.left !== undefined && !position.right,
'mx_ContextualMenu_right': !hasChevron && position.right !== undefined && !position.left,
'mx_ContextualMenu_top': !hasChevron && position.top !== undefined && !position.bottom,
'mx_ContextualMenu_bottom': !hasChevron && position.bottom !== undefined && !position.top,
'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left, 'mx_ContextualMenu_withChevron_left': chevronFace === ChevronFace.Left,
'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right, 'mx_ContextualMenu_withChevron_right': chevronFace === ChevronFace.Right,
'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top, 'mx_ContextualMenu_withChevron_top': chevronFace === ChevronFace.Top,
@ -404,17 +410,27 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
} }
} }
export type ToRightOf = {
left: number;
top: number;
chevronOffset: number;
};
// Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset // Placement method for <ContextMenu /> to position context menu to right of elementRect with chevronOffset
export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12) => { export const toRightOf = (elementRect: Pick<DOMRect, "right" | "top" | "height">, chevronOffset = 12): ToRightOf => {
const left = elementRect.right + window.pageXOffset + 3; const left = elementRect.right + window.pageXOffset + 3;
let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset; let top = elementRect.top + (elementRect.height / 2) + window.pageYOffset;
top -= chevronOffset + 8; // where 8 is half the height of the chevron top -= chevronOffset + 8; // where 8 is half the height of the chevron
return { left, top, chevronOffset }; return { left, top, chevronOffset };
}; };
export type AboveLeftOf = IPosition & {
chevronFace: ChevronFace;
};
// Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect, // Placement method for <ContextMenu /> to position context menu right-aligned and flowing to the left of elementRect,
// and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?) // and either above or below: wherever there is more space (maybe this should be aboveOrBelowLeftOf?)
export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0) => { export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None, vPadding = 0): AboveLeftOf => {
const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace }; const menuOptions: IPosition & { chevronFace: ChevronFace } = { chevronFace };
const buttonRight = elementRect.right + window.pageXOffset; const buttonRight = elementRect.right + window.pageXOffset;

View file

@ -143,7 +143,7 @@ export enum Views {
SOFT_LOGOUT, SOFT_LOGOUT,
} }
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas"]; const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
// Actions that are redirected through the onboarding process prior to being // Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require // re-dispatched. NOTE: some actions are non-trivial and would require
@ -1892,15 +1892,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onSendEvent(roomId: string, event: MatrixEvent) { onSendEvent(roomId: string, event: MatrixEvent) {
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
if (!cli) { if (!cli) return;
dis.dispatch({ action: 'message_send_failed' });
return;
}
cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => { cli.sendEvent(roomId, event.getType(), event.getContent()).then(() => {
dis.dispatch({ action: 'message_sent' }); dis.dispatch({ action: 'message_sent' });
}, (err) => {
dis.dispatch({ action: 'message_send_failed' });
}); });
} }

View file

@ -57,6 +57,7 @@ import { Key } from "../../Keyboard";
import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex";
import { getDisplayAliasForRoom } from "./RoomDirectory"; import { getDisplayAliasForRoom } from "./RoomDirectory";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { useEventEmitterState } from "../../hooks/useEventEmitter";
interface IProps { interface IProps {
space: Room; space: Room;
@ -87,7 +88,8 @@ const Tile: React.FC<ITileProps> = ({
}) => { }) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null; const joinedRoom = cli.getRoom(room.room_id)?.getMyMembership() === "join" ? cli.getRoom(room.room_id) : null;
const name = joinedRoom?.name || room.name || room.canonical_alias || room.aliases?.[0] const joinedRoomName = useEventEmitterState(joinedRoom, "Room.name", room => room?.name);
const name = joinedRoomName || room.name || room.canonical_alias || room.aliases?.[0]
|| (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room")); || (room.room_type === RoomType.Space ? _t("Unnamed Space") : _t("Unnamed Room"));
const [showChildren, toggleShowChildren] = useStateToggle(true); const [showChildren, toggleShowChildren] = useStateToggle(true);

View file

@ -78,6 +78,7 @@ import { CreateEventField, IGroupSummary } from "../views/dialogs/CreateSpaceFro
import { useAsyncMemo } from "../../hooks/useAsyncMemo"; import { useAsyncMemo } from "../../hooks/useAsyncMemo";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
import GroupAvatar from "../views/avatars/GroupAvatar"; import GroupAvatar from "../views/avatars/GroupAvatar";
import { useDispatcher } from "../../hooks/useDispatcher";
interface IProps { interface IProps {
space: Room; space: Room;
@ -191,6 +192,11 @@ interface ISpacePreviewProps {
const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => { const SpacePreview = ({ space, onJoinButtonClicked, onRejectButtonClicked }: ISpacePreviewProps) => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const myMembership = useMyRoomMembership(space); const myMembership = useMyRoomMembership(space);
useDispatcher(defaultDispatcher, payload => {
if (payload.action === Action.JoinRoomError && payload.roomId === space.roomId) {
setBusy(false); // stop the spinner, join failed
}
});
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);

View file

@ -15,43 +15,48 @@ limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef } from 'react';
import PropTypes from 'prop-types';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import MemberAvatar from '../avatars/MemberAvatar'; import MemberAvatar from '../avatars/MemberAvatar';
import classNames from 'classnames'; import classNames from 'classnames';
import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu"; import StatusMessageContextMenu from "../context_menus/StatusMessageContextMenu";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenu, ContextMenuButton } from "../../structures/ContextMenu";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
interface IProps {
member: RoomMember;
width?: number;
height?: number;
resizeMethod?: ResizeMethod;
}
interface IState {
hasStatus: boolean;
menuDisplayed: boolean;
}
@replaceableComponent("views.avatars.MemberStatusMessageAvatar") @replaceableComponent("views.avatars.MemberStatusMessageAvatar")
export default class MemberStatusMessageAvatar extends React.Component { export default class MemberStatusMessageAvatar extends React.Component<IProps, IState> {
static propTypes = { public static defaultProps: Partial<IProps> = {
member: PropTypes.object.isRequired,
width: PropTypes.number,
height: PropTypes.number,
resizeMethod: PropTypes.string,
};
static defaultProps = {
width: 40, width: 40,
height: 40, height: 40,
resizeMethod: 'crop', resizeMethod: 'crop',
}; };
private button = createRef<HTMLDivElement>();
constructor(props) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
menuDisplayed: false, menuDisplayed: false,
}; };
this._button = createRef();
} }
componentDidMount() { public componentDidMount(): void {
if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) { if (this.props.member.userId !== MatrixClientPeg.get().getUserId()) {
throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user"); throw new Error("Cannot use MemberStatusMessageAvatar on anyone but the logged in user");
} }
@ -62,44 +67,44 @@ export default class MemberStatusMessageAvatar extends React.Component {
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get hasStatus() { private get hasStatus(): boolean {
const { user } = this.props.member; const { user } = this.props.member;
if (!user) { if (!user) {
return false; return false;
} }
return !!user._unstable_statusMessage; return !!user.unstable_statusMessage;
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
hasStatus: this.hasStatus, hasStatus: this.hasStatus,
}); });
}; };
openMenu = () => { private openMenu = (): void => {
this.setState({ menuDisplayed: true }); this.setState({ menuDisplayed: true });
}; };
closeMenu = () => { private closeMenu = (): void => {
this.setState({ menuDisplayed: false }); this.setState({ menuDisplayed: false });
}; };
render() { public render(): JSX.Element {
const avatar = <MemberAvatar const avatar = <MemberAvatar
member={this.props.member} member={this.props.member}
width={this.props.width} width={this.props.width}
@ -118,7 +123,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
let contextMenu; let contextMenu;
if (this.state.menuDisplayed) { if (this.state.menuDisplayed) {
const elementRect = this._button.current.getBoundingClientRect(); const elementRect = this.button.current.getBoundingClientRect();
const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom const chevronWidth = 16; // See .mx_ContextualMenu_chevron_bottom
const chevronMargin = 1; // Add some spacing away from target const chevronMargin = 1; // Add some spacing away from target
@ -126,13 +131,13 @@ export default class MemberStatusMessageAvatar extends React.Component {
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronOffset={(elementRect.width - chevronWidth) / 2} chevronOffset={(elementRect.width - chevronWidth) / 2}
chevronFace="bottom" chevronFace={ChevronFace.Bottom}
left={elementRect.left + window.pageXOffset} left={elementRect.left + window.pageXOffset}
top={elementRect.top + window.pageYOffset - chevronMargin} top={elementRect.top + window.pageYOffset - chevronMargin}
menuWidth={226} menuWidth={226}
onFinished={this.closeMenu} onFinished={this.closeMenu}
> >
<StatusMessageContextMenu user={this.props.member.user} onFinished={this.closeMenu} /> <StatusMessageContextMenu user={this.props.member.user} />
</ContextMenu> </ContextMenu>
); );
} }
@ -140,7 +145,7 @@ export default class MemberStatusMessageAvatar extends React.Component {
return <React.Fragment> return <React.Fragment>
<ContextMenuButton <ContextMenuButton
className={classes} className={classes}
inputRef={this._button} inputRef={this.button}
onClick={this.openMenu} onClick={this.openMenu}
isExpanded={this.state.menuDisplayed} isExpanded={this.state.menuDisplayed}
label={_t("User Status")} label={_t("User Status")}

View file

@ -15,45 +15,41 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
/* interface IProps {
element: React.ReactNode;
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position.
onResize?: () => void;
}
/**
* This component can be used to display generic HTML content in a contextual * This component can be used to display generic HTML content in a contextual
* menu. * menu.
*/ */
@replaceableComponent("views.context_menus.GenericElementContextMenu") @replaceableComponent("views.context_menus.GenericElementContextMenu")
export default class GenericElementContextMenu extends React.Component { export default class GenericElementContextMenu extends React.Component<IProps> {
static propTypes = { constructor(props: IProps) {
element: PropTypes.element.isRequired,
// Function to be called when the parent window is resized
// This can be used to reposition or close the menu on resize and
// ensure that it is not displayed in a stale position.
onResize: PropTypes.func,
};
constructor(props) {
super(props); super(props);
this.resize = this.resize.bind(this);
} }
componentDidMount() { public componentDidMount(): void {
this.resize = this.resize.bind(this);
window.addEventListener("resize", this.resize); window.addEventListener("resize", this.resize);
} }
componentWillUnmount() { public componentWillUnmount(): void {
window.removeEventListener("resize", this.resize); window.removeEventListener("resize", this.resize);
} }
resize() { private resize = (): void => {
if (this.props.onResize) { if (this.props.onResize) {
this.props.onResize(); this.props.onResize();
} }
} };
render() { public render(): JSX.Element {
return <div>{ this.props.element }</div>; return <div>{ this.props.element }</div>;
} }
} }

View file

@ -15,16 +15,15 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
@replaceableComponent("views.context_menus.GenericTextContextMenu") interface IProps {
export default class GenericTextContextMenu extends React.Component { message: string;
static propTypes = { }
message: PropTypes.string.isRequired,
};
render() { @replaceableComponent("views.context_menus.GenericTextContextMenu")
export default class GenericTextContextMenu extends React.Component<IProps> {
public render(): JSX.Element {
return <div>{ this.props.message }</div>; return <div>{ this.props.message }</div>;
} }
} }

View file

@ -14,53 +14,59 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React, { ChangeEvent } from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import * as sdk from '../../../index'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
import AccessibleButton from '../elements/AccessibleButton';
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { User } from "matrix-js-sdk/src/models/user";
import Spinner from "../elements/Spinner";
interface IProps {
// js-sdk User object. Not required because it might not exist.
user?: User;
}
interface IState {
message: string;
waiting: boolean;
}
@replaceableComponent("views.context_menus.StatusMessageContextMenu") @replaceableComponent("views.context_menus.StatusMessageContextMenu")
export default class StatusMessageContextMenu extends React.Component { export default class StatusMessageContextMenu extends React.Component<IProps, IState> {
static propTypes = { constructor(props: IProps) {
// js-sdk User object. Not required because it might not exist.
user: PropTypes.object,
};
constructor(props) {
super(props); super(props);
this.state = { this.state = {
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
waiting: false,
}; };
} }
componentDidMount() { public componentDidMount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.on("User._unstable_statusMessage", this._onStatusMessageCommitted); user.on("User._unstable_statusMessage", this.onStatusMessageCommitted);
} }
componentWillUnmount() { public componentWillUnmount(): void {
const { user } = this.props; const { user } = this.props;
if (!user) { if (!user) {
return; return;
} }
user.removeListener( user.removeListener(
"User._unstable_statusMessage", "User._unstable_statusMessage",
this._onStatusMessageCommitted, this.onStatusMessageCommitted,
); );
} }
get comittedStatusMessage() { get comittedStatusMessage(): string {
return this.props.user ? this.props.user._unstable_statusMessage : ""; return this.props.user ? this.props.user.unstable_statusMessage : "";
} }
_onStatusMessageCommitted = () => { private onStatusMessageCommitted = (): void => {
// The `User` object has observed a status message change. // The `User` object has observed a status message change.
this.setState({ this.setState({
message: this.comittedStatusMessage, message: this.comittedStatusMessage,
@ -68,14 +74,14 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onClearClick = (e) => { private onClearClick = (): void=> {
MatrixClientPeg.get()._unstable_setStatusMessage(""); MatrixClientPeg.get()._unstable_setStatusMessage("");
this.setState({ this.setState({
waiting: true, waiting: true,
}); });
}; };
_onSubmit = (e) => { private onSubmit = (e: ButtonEvent): void => {
e.preventDefault(); e.preventDefault();
MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message); MatrixClientPeg.get()._unstable_setStatusMessage(this.state.message);
this.setState({ this.setState({
@ -83,27 +89,25 @@ export default class StatusMessageContextMenu extends React.Component {
}); });
}; };
_onStatusChange = (e) => { private onStatusChange = (e: ChangeEvent): void => {
// The input field's value was changed. // The input field's value was changed.
this.setState({ this.setState({
message: e.target.value, message: (e.target as HTMLInputElement).value,
}); });
}; };
render() { public render(): JSX.Element {
const Spinner = sdk.getComponent('views.elements.Spinner');
let actionButton; let actionButton;
if (this.comittedStatusMessage) { if (this.comittedStatusMessage) {
if (this.state.message === this.comittedStatusMessage) { if (this.state.message === this.comittedStatusMessage) {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_clear"
onClick={this._onClearClick} onClick={this.onClearClick}
> >
<span>{ _t("Clear status") }</span> <span>{ _t("Clear status") }</span>
</AccessibleButton>; </AccessibleButton>;
} else { } else {
actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit" actionButton = <AccessibleButton className="mx_StatusMessageContextMenu_submit"
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Update status") }</span> <span>{ _t("Update status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -112,7 +116,7 @@ export default class StatusMessageContextMenu extends React.Component {
actionButton = <AccessibleButton actionButton = <AccessibleButton
className="mx_StatusMessageContextMenu_submit" className="mx_StatusMessageContextMenu_submit"
disabled={!this.state.message} disabled={!this.state.message}
onClick={this._onSubmit} onClick={this.onSubmit}
> >
<span>{ _t("Set status") }</span> <span>{ _t("Set status") }</span>
</AccessibleButton>; </AccessibleButton>;
@ -120,13 +124,13 @@ export default class StatusMessageContextMenu extends React.Component {
let spinner = null; let spinner = null;
if (this.state.waiting) { if (this.state.waiting) {
spinner = <Spinner w="24" h="24" />; spinner = <Spinner w={24} h={24} />;
} }
const form = <form const form = <form
className="mx_StatusMessageContextMenu_form" className="mx_StatusMessageContextMenu_form"
autoComplete="off" autoComplete="off"
onSubmit={this._onSubmit} onSubmit={this.onSubmit}
> >
<input <input
type="text" type="text"
@ -134,9 +138,9 @@ export default class StatusMessageContextMenu extends React.Component {
key="message" key="message"
placeholder={_t("Set a new status...")} placeholder={_t("Set a new status...")}
autoFocus={true} autoFocus={true}
maxLength="60" maxLength={60}
value={this.state.message} value={this.state.message}
onChange={this._onStatusChange} onChange={this.onStatusChange}
/> />
<div className="mx_StatusMessageContextMenu_actionContainer"> <div className="mx_StatusMessageContextMenu_actionContainer">
{ actionButton } { actionButton }

View file

@ -29,10 +29,12 @@ import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab"; import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
export enum SpaceSettingsTab { export enum SpaceSettingsTab {
General = "SPACE_GENERAL_TAB", General = "SPACE_GENERAL_TAB",
Visibility = "SPACE_VISIBILITY_TAB", Visibility = "SPACE_VISIBILITY_TAB",
Roles = "SPACE_ROLES_TAB",
Advanced = "SPACE_ADVANCED_TAB", Advanced = "SPACE_ADVANCED_TAB",
} }
@ -60,7 +62,13 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
SpaceSettingsTab.Visibility, SpaceSettingsTab.Visibility,
_td("Visibility"), _td("Visibility"),
"mx_SpaceSettingsDialog_visibilityIcon", "mx_SpaceSettingsDialog_visibilityIcon",
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} />, <SpaceSettingsVisibilityTab matrixClient={cli} space={space} closeSettingsFn={onFinished} />,
),
new Tab(
SpaceSettingsTab.Roles,
_td("Roles & Permissions"),
"mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab roomId={space.roomId} />,
), ),
SettingsStore.getValue(UIFeature.AdvancedSettings) SettingsStore.getValue(UIFeature.AdvancedSettings)
? new Tab( ? new Tab(

View file

@ -20,14 +20,21 @@ import BaseDialog from "..//dialogs/BaseDialog";
import DialogButtons from "./DialogButtons"; import DialogButtons from "./DialogButtons";
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleButton from './AccessibleButton'; import AccessibleButton from './AccessibleButton';
import { getDesktopCapturerSources } from "matrix-js-sdk/src/webrtc/call";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView'; import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
export interface DesktopCapturerSource { export function getDesktopCapturerSources(): Promise<Array<DesktopCapturerSource>> {
id: string; const options: GetSourcesOptions = {
name: string; thumbnailSize: {
thumbnailURL; height: 176,
width: 312,
},
types: [
"screen",
"window",
],
};
return window.electron.getDesktopCapturerSources(options);
} }
export enum Tabs { export enum Tabs {
@ -78,7 +85,7 @@ export interface PickerIState {
selectedSource: DesktopCapturerSource | null; selectedSource: DesktopCapturerSource | null;
} }
export interface PickerIProps { export interface PickerIProps {
onFinished(source: DesktopCapturerSource): void; onFinished(sourceId: string): void;
} }
@replaceableComponent("views.elements.DesktopCapturerSourcePicker") @replaceableComponent("views.elements.DesktopCapturerSourcePicker")
@ -123,7 +130,7 @@ export default class DesktopCapturerSourcePicker extends React.Component<
}; };
private onShare = (): void => { private onShare = (): void => {
this.props.onFinished(this.state.selectedSource); this.props.onFinished(this.state.selectedSource.id);
}; };
private onTabChange = (): void => { private onTabChange = (): void => {

View file

@ -826,7 +826,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
if (canAffectUser && me.powerLevel >= banPowerLevel) { if (canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />; banButton = <BanToggleButton member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />;
} }
if (canAffectUser && me.powerLevel >= editPowerLevel) { if (canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
muteButton = ( muteButton = (
<MuteToggleButton <MuteToggleButton
member={member} member={member}

View file

@ -50,7 +50,8 @@ import { AutocompleteAction, getKeyBindingsManager, MessageComposerAction } from
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
// matches emoticons which follow the start of a line or whitespace // matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$'); const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s|:^$');
export const REGEX_EMOTICON = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1; const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -161,7 +162,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} }
private replaceEmoticon = (caretPosition: DocumentPosition): number => { public replaceEmoticon(caretPosition: DocumentPosition, regex: RegExp): number {
const { model } = this.props; const { model } = this.props;
const range = model.startRange(caretPosition); const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition, // expand range max 8 characters backwards from caretPosition,
@ -170,9 +171,9 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
range.expandBackwardsWhile((index, offset) => { range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index]; const part = model.parts[index];
n -= 1; n -= 1;
return n >= 0 && (part.type === Type.Plain || part.type === Type.PillCandidate); return n >= 0 && [Type.Plain, Type.PillCandidate, Type.Newline].includes(part.type);
}); });
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text); const emoticonMatch = regex.exec(range.text);
if (emoticonMatch) { if (emoticonMatch) {
const query = emoticonMatch[1].replace("-", ""); const query = emoticonMatch[1].replace("-", "");
// try both exact match and lower-case, this means that xd won't match xD but :P will match :p // try both exact match and lower-case, this means that xd won't match xD but :P will match :p
@ -180,18 +181,23 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
if (data) { if (data) {
const { partCreator } = model; const { partCreator } = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " "; const moveStart = emoticonMatch[0][0] === " " ? 1 : 0;
const moveEnd = emoticonMatch[0].length - emoticonMatch.length - moveStart;
// we need the range to only comprise of the emoticon // we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji, // because we'll replace the whole range with an emoji,
// so move the start forward to the start of the emoticon. // so move the start forward to the start of the emoticon.
// Take + 1 because index is reported without the possible preceding space. // Take + 1 because index is reported without the possible preceding space.
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); range.moveStartForwards(emoticonMatch.index + moveStart);
// and move end backwards so that we don't replace the trailing space/newline
range.moveEndBackwards(moveEnd);
// this returns the amount of added/removed characters during the replace // this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted. // so the caret position can be adjusted.
return range.replace([partCreator.plain(data.unicode + " ")]); return range.replace([partCreator.plain(data.unicode)]);
} }
} }
}; }
private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => { private updateEditorState = (selection: Caret, inputType?: string, diff?: IDiff): void => {
renderModel(this.editorRef.current, this.props.model); renderModel(this.editorRef.current, this.props.model);
@ -607,8 +613,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}; };
private configureEmoticonAutoReplace = (): void => { private configureEmoticonAutoReplace = (): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji'); this.props.model.setTransformCallback(this.transform);
this.props.model.setTransformCallback(shouldReplace ? this.replaceEmoticon : null);
}; };
private configureShouldShowPillAvatar = (): void => { private configureShouldShowPillAvatar = (): void => {
@ -621,6 +626,11 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ surroundWith }); this.setState({ surroundWith });
}; };
private transform = (documentPosition: DocumentPosition): void => {
const shouldReplace = SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji');
if (shouldReplace) this.replaceEmoticon(documentPosition, REGEX_EMOTICON_WHITESPACE);
};
componentWillUnmount() { componentWillUnmount() {
document.removeEventListener("selectionchange", this.onSelectionChange); document.removeEventListener("selectionchange", this.onSelectionChange);
this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("input", this.onInput, true);

View file

@ -1192,14 +1192,19 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
default: { default: {
const thread = ReplyThread.makeThread( let thread;
this.props.mxEvent, // When the "showHiddenEventsInTimeline" lab is enabled,
this.props.onHeightChanged, // avoid showing replies for hidden events (events without tiles)
this.props.permalinkCreator, if (haveTileForEvent(this.props.mxEvent)) {
this.replyThread, thread = ReplyThread.makeThread(
this.props.layout, this.props.mxEvent,
this.props.alwaysShowTimestamps || this.state.hover, this.props.onHeightChanged,
); this.props.permalinkCreator,
this.replyThread,
this.props.layout,
this.props.alwaysShowTimestamps || this.state.hover,
);
}
const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId(); const isOwnEvent = this.props.mxEvent?.sender?.userId === MatrixClientPeg.get().getUserId();

View file

@ -32,6 +32,7 @@ import {
ContextMenu, ContextMenu,
useContextMenu, useContextMenu,
MenuItem, MenuItem,
AboveLeftOf,
} from "../../structures/ContextMenu"; } from "../../structures/ContextMenu";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview"; import ReplyPreview from "./ReplyPreview";
@ -56,7 +57,7 @@ let instanceCount = 0;
const NARROW_MODE_BREAKPOINT = 500; const NARROW_MODE_BREAKPOINT = 500;
interface IComposerAvatarProps { interface IComposerAvatarProps {
me: object; me: RoomMember;
} }
function ComposerAvatar(props: IComposerAvatarProps) { function ComposerAvatar(props: IComposerAvatarProps) {
@ -511,7 +512,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
null, null,
]; ];
let menuPosition; let menuPosition: AboveLeftOf | undefined;
if (this.ref.current) { if (this.ref.current) {
const contentRect = this.ref.current.getBoundingClientRect(); const contentRect = this.ref.current.getBoundingClientRect();
menuPosition = aboveLeftOf(contentRect); menuPosition = aboveLeftOf(contentRect);

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { MouseEvent } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { formatCount } from "../../../utils/FormattingUtils"; import { formatCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
@ -22,6 +22,9 @@ import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common"; import { XOR } from "../../../@types/common";
import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState"; import { NOTIFICATION_STATE_UPDATE, NotificationState } from "../../../stores/notifications/NotificationState";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { _t } from "../../../languageHandler";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
interface IProps { interface IProps {
notification: NotificationState; notification: NotificationState;
@ -39,6 +42,7 @@ interface IProps {
} }
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> { interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
showUnsentTooltip?: boolean;
/** /**
* If specified will return an AccessibleButton instead of a div. * If specified will return an AccessibleButton instead of a div.
*/ */
@ -47,6 +51,7 @@ interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
interface IState { interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
showTooltip: boolean;
} }
@replaceableComponent("views.rooms.NotificationBadge") @replaceableComponent("views.rooms.NotificationBadge")
@ -59,6 +64,7 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.state = { this.state = {
showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId), showCounts: SettingsStore.getValue("Notifications.alwaysShowBadgeCounts", this.roomId),
showTooltip: false,
}; };
this.countWatcherRef = SettingsStore.watchSetting( this.countWatcherRef = SettingsStore.watchSetting(
@ -93,9 +99,22 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
this.forceUpdate(); // notification state changed - update this.forceUpdate(); // notification state changed - update
}; };
private onMouseOver = (e: MouseEvent) => {
e.stopPropagation();
this.setState({
showTooltip: true,
});
};
private onMouseLeave = () => {
this.setState({
showTooltip: false,
});
};
public render(): React.ReactElement { public render(): React.ReactElement {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { notification, forceCount, roomId, onClick, ...props } = this.props; const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props;
// Don't show a badge if we don't need to // Don't show a badge if we don't need to
if (notification.isIdle) return null; if (notification.isIdle) return null;
@ -124,9 +143,24 @@ export default class NotificationBadge extends React.PureComponent<XOR<IProps, I
}); });
if (onClick) { if (onClick) {
let label: string;
let tooltip: JSX.Element;
if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
label = _t("Message didn't send. Click for info.");
tooltip = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
}
return ( return (
<AccessibleButton {...props} className={classes} onClick={onClick}> <AccessibleButton
aria-label={label}
{...props}
className={classes}
onClick={onClick}
onMouseOver={this.onMouseOver}
onMouseLeave={this.onMouseLeave}
>
<span className="mx_NotificationBadge_count">{ symbol }</span> <span className="mx_NotificationBadge_count">{ symbol }</span>
{ tooltip }
</AccessibleButton> </AccessibleButton>
); );
} }

View file

@ -547,7 +547,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || []; const unfilteredHistorical = unfilteredLists[DefaultTagID.Archived] || [];
const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || []; const unfilteredFavourite = unfilteredLists[DefaultTagID.Favourite] || [];
// show a prompt to join/create rooms if the user is in 0 rooms and no historical // show a prompt to join/create rooms if the user is in 0 rooms and no historical
if (unfilteredRooms.length < 1 && unfilteredHistorical < 1 && unfilteredFavourite < 1) { if (unfilteredRooms.length < 1 && unfilteredHistorical.length < 1 && unfilteredFavourite.length < 1) {
explorePrompt = <div className="mx_RoomList_explorePrompt"> explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Use the + to make a new room or explore existing ones below") }</div> <div>{ _t("Use the + to make a new room or explore existing ones below") }</div>
<AccessibleButton <AccessibleButton

View file

@ -670,6 +670,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
onClick={this.onBadgeClick} onClick={this.onBadgeClick}
tabIndex={tabIndex} tabIndex={tabIndex}
aria-label={ariaLabel} aria-label={ariaLabel}
showUnsentTooltip={true}
/> />
); );

View file

@ -17,7 +17,6 @@ limitations under the License.
import React, { createRef } from "react"; import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -51,8 +50,6 @@ import IconizedContextMenu, {
} from "../context_menus/IconizedContextMenu"; } from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore"; import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
interface IProps { interface IProps {
room: Room; room: Room;
@ -68,7 +65,6 @@ interface IState {
notificationsMenuPosition: PartialDOMRect; notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect; generalMenuPosition: PartialDOMRect;
messagePreview?: string; messagePreview?: string;
hasUnsentEvents: boolean;
} }
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`; const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -95,7 +91,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null, notificationsMenuPosition: null,
generalMenuPosition: null, generalMenuPosition: null,
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled // generatePreview() will return nothing if the user has previews disabled
messagePreview: "", messagePreview: "",
@ -106,11 +101,7 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.roomProps = EchoChamber.forRoom(this.props.room); this.roomProps = EchoChamber.forRoom(this.props.room);
} }
private countUnsentEvents(): number { private onRoomNameUpdate = (room: Room) => {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => {
this.forceUpdate(); this.forceUpdate();
}; };
@ -118,11 +109,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update this.forceUpdate(); // notification state changed - update
}; };
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (room?.roomId !== this.props.room.roomId) return;
this.setState({ hasUnsentEvents: this.countUnsentEvents() > 0 });
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => { private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate(); if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile // else ignore - not important for this tile
@ -178,12 +164,11 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
); );
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate); this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate); this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.on("Room.name", this.onRoomNameUpdate); this.props.room?.on("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.on( CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate, this.onCommunityUpdate,
); );
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -208,7 +193,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId), CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate, this.onCommunityUpdate,
); );
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
} }
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
@ -587,30 +571,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
/>; />;
let badge: React.ReactNode; let badge: React.ReactNode;
if (!this.props.isMinimized) { if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
if (this.state.hasUnsentEvents) { badge = (
// hardcode the badge to a danger state when there's unsent messages <div className="mx_RoomTile_badgeContainer" aria-hidden="true">
badge = ( <NotificationBadge
<div className="mx_RoomTile_badgeContainer" aria-hidden="true"> notification={this.notificationState}
<NotificationBadge forceCount={false}
notification={StaticNotificationState.RED_EXCLAMATION} roomId={this.props.room.roomId}
forceCount={false} />
roomId={this.props.room.roomId} </div>
/> );
</div>
);
} else if (this.notificationState) {
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
} }
let messagePreview = null; let messagePreview = null;

View file

@ -31,8 +31,8 @@ import {
textSerialize, textSerialize,
unescapeMessage, unescapeMessage,
} from '../../../editor/serialize'; } from '../../../editor/serialize';
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts'; import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from '../../../editor/parts';
import BasicMessageComposer from "./BasicMessageComposer";
import ReplyThread from "../elements/ReplyThread"; import ReplyThread from "../elements/ReplyThread";
import { findEditableEvent } from '../../../utils/EventUtils'; import { findEditableEvent } from '../../../utils/EventUtils';
import SendHistoryManager from "../../../SendHistoryManager"; import SendHistoryManager from "../../../SendHistoryManager";
@ -347,15 +347,24 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
public async sendMessage(): Promise<void> { public async sendMessage(): Promise<void> {
if (this.model.isEmpty) { const model = this.model;
if (model.isEmpty) {
return; return;
} }
// Replace emoticon at the end of the message
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
const caret = this.editorRef.current?.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
}
const replyToEvent = this.props.replyToEvent; const replyToEvent = this.props.replyToEvent;
let shouldSend = true; let shouldSend = true;
let content; let content;
if (!containsEmote(this.model) && this.isSlashCommand()) { if (!containsEmote(model) && this.isSlashCommand()) {
const [cmd, args, commandText] = this.getSlashCommand(); const [cmd, args, commandText] = this.getSlashCommand();
if (cmd) { if (cmd) {
if (cmd.category === CommandCategories.messages) { if (cmd.category === CommandCategories.messages) {
@ -400,7 +409,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
} }
} }
if (isQuickReaction(this.model)) { if (isQuickReaction(model)) {
shouldSend = false; shouldSend = false;
this.sendQuickReaction(); this.sendQuickReaction();
} }
@ -410,7 +419,7 @@ export default class SendMessageComposer extends React.Component<IProps> {
const { roomId } = this.props.room; const { roomId } = this.props.room;
if (!content) { if (!content) {
content = createMessageContent( content = createMessageContent(
this.model, model,
replyToEvent, replyToEvent,
this.props.replyInThread, this.props.replyInThread,
this.props.permalinkCreator, this.props.permalinkCreator,
@ -446,9 +455,9 @@ export default class SendMessageComposer extends React.Component<IProps> {
CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content); CountlyAnalytics.instance.trackSendMessage(startTime, prom, roomId, false, !!replyToEvent, content);
} }
this.sendHistoryManager.save(this.model, replyToEvent); this.sendHistoryManager.save(model, replyToEvent);
// clear composer // clear composer
this.model.reset([]); model.reset([]);
this.editorRef.current?.clearUndoHistory(); this.editorRef.current?.clearUndoHistory();
this.editorRef.current?.focus(); this.editorRef.current?.focus();
this.clearStoredEditorState(); this.clearStoredEditorState();

View file

@ -0,0 +1,269 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { IJoinRuleEventContent, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials";
import { Room } from "matrix-js-sdk/src/models/room";
import { EventType } from "matrix-js-sdk/src/@types/event";
import StyledRadioGroup, { IDefinition } from "../elements/StyledRadioGroup";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import RoomAvatar from "../avatars/RoomAvatar";
import SpaceStore from "../../../stores/SpaceStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import ManageRestrictedJoinRuleDialog from "../dialogs/ManageRestrictedJoinRuleDialog";
import RoomUpgradeWarningDialog from "../dialogs/RoomUpgradeWarningDialog";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../utils/arrays";
import { useLocalEcho } from "../../../hooks/useLocalEcho";
import dis from "../../../dispatcher/dispatcher";
import { ROOM_SECURITY_TAB } from "../dialogs/RoomSettingsDialog";
interface IProps {
room: Room;
promptUpgrade?: boolean;
closeSettingsFn(): void;
onError(error: Error): void;
beforeChange?(joinRule: JoinRule): Promise<boolean>; // if returns false then aborts the change
}
const JoinRuleSettings = ({ room, promptUpgrade, onError, beforeChange, closeSettingsFn }: IProps) => {
const cli = room.client;
const restrictedRoomCapabilities = SpaceStore.instance.restrictedJoinRuleSupport;
const roomSupportsRestricted = Array.isArray(restrictedRoomCapabilities?.support)
&& restrictedRoomCapabilities.support.includes(room.getVersion());
const preferredRestrictionVersion = !roomSupportsRestricted && promptUpgrade
? restrictedRoomCapabilities?.preferred
: undefined;
const disabled = !room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, cli);
const [content, setContent] = useLocalEcho<IJoinRuleEventContent>(
() => room.currentState.getStateEvents(EventType.RoomJoinRules, "")?.getContent(),
content => cli.sendStateEvent(room.roomId, EventType.RoomJoinRules, content, ""),
onError,
);
const { join_rule: joinRule } = content;
const restrictedAllowRoomIds = joinRule === JoinRule.Restricted
? content.allow.filter(o => o.type === RestrictedAllowType.RoomMembership).map(o => o.room_id)
: undefined;
const editRestrictedRoomIds = async (): Promise<string[] | undefined> => {
let selected = restrictedAllowRoomIds;
if (!selected?.length && SpaceStore.instance.activeSpace) {
selected = [SpaceStore.instance.activeSpace.roomId];
}
const matrixClient = MatrixClientPeg.get();
const { finished } = Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, {
matrixClient,
room,
selected,
}, "mx_ManageRestrictedJoinRuleDialog_wrapper");
const [roomIds] = await finished;
return roomIds;
};
const definitions: IDefinition<JoinRule>[] = [{
value: JoinRule.Invite,
label: _t("Private (invite only)"),
description: _t("Only invited people can join."),
checked: joinRule === JoinRule.Invite || (joinRule === JoinRule.Restricted && !restrictedAllowRoomIds?.length),
}, {
value: JoinRule.Public,
label: _t("Public"),
description: _t("Anyone can find and join."),
}];
if (roomSupportsRestricted || preferredRestrictionVersion || joinRule === JoinRule.Restricted) {
let upgradeRequiredPill;
if (preferredRestrictionVersion) {
upgradeRequiredPill = <span className="mx_SecurityRoomSettingsTab_upgradeRequired">
{ _t("Upgrade required") }
</span>;
}
let description;
if (joinRule === JoinRule.Restricted && restrictedAllowRoomIds?.length) {
// only show the first 4 spaces we know about, so that the UI doesn't grow out of proportion there are lots.
const shownSpaces = restrictedAllowRoomIds
.map(roomId => cli.getRoom(roomId))
.filter(room => room?.isSpaceRoom())
.slice(0, 4);
let moreText;
if (shownSpaces.length < restrictedAllowRoomIds.length) {
if (shownSpaces.length > 0) {
moreText = _t("& %(count)s more", {
count: restrictedAllowRoomIds.length - shownSpaces.length,
});
} else {
moreText = _t("Currently, %(count)s spaces have access", {
count: restrictedAllowRoomIds.length,
});
}
}
const onRestrictedRoomIdsChange = (newAllowRoomIds: string[]) => {
if (!arrayHasDiff(restrictedAllowRoomIds || [], newAllowRoomIds)) return;
if (!newAllowRoomIds.length) {
setContent({
join_rule: JoinRule.Invite,
});
return;
}
setContent({
join_rule: JoinRule.Restricted,
allow: newAllowRoomIds.map(roomId => ({
"type": RestrictedAllowType.RoomMembership,
"room_id": roomId,
})),
});
};
const onEditRestrictedClick = async () => {
const restrictedAllowRoomIds = await editRestrictedRoomIds();
if (!Array.isArray(restrictedAllowRoomIds)) return;
if (restrictedAllowRoomIds.length > 0) {
onRestrictedRoomIdsChange(restrictedAllowRoomIds);
} else {
onChange(JoinRule.Invite);
}
};
description = <div>
<span>
{ _t("Anyone in a space can find and join. <a>Edit which spaces can access here.</a>", {}, {
a: sub => <AccessibleButton
disabled={disabled}
onClick={onEditRestrictedClick}
kind="link"
>
{ sub }
</AccessibleButton>,
}) }
</span>
<div className="mx_SecurityRoomSettingsTab_spacesWithAccess">
<h4>{ _t("Spaces with access") }</h4>
{ shownSpaces.map(room => {
return <span key={room.roomId}>
<RoomAvatar room={room} height={32} width={32} />
{ room.name }
</span>;
}) }
{ moreText && <span>{ moreText }</span> }
</div>
</div>;
} else if (SpaceStore.instance.activeSpace) {
description = _t("Anyone in <spaceName/> can find and join. You can select other spaces too.", {}, {
spaceName: () => <b>{ SpaceStore.instance.activeSpace.name }</b>,
});
} else {
description = _t("Anyone in a space can find and join. You can select multiple spaces.");
}
definitions.splice(1, 0, {
value: JoinRule.Restricted,
label: <>
{ _t("Space members") }
{ upgradeRequiredPill }
</>,
description,
// if there are 0 allowed spaces then render it as invite only instead
checked: joinRule === JoinRule.Restricted && !!restrictedAllowRoomIds?.length,
});
}
const onChange = async (joinRule: JoinRule) => {
const beforeJoinRule = content.join_rule;
let restrictedAllowRoomIds: string[];
if (joinRule === JoinRule.Restricted) {
if (beforeJoinRule === JoinRule.Restricted || roomSupportsRestricted) {
// Have the user pick which spaces to allow joins from
restrictedAllowRoomIds = await editRestrictedRoomIds();
if (!Array.isArray(restrictedAllowRoomIds)) return;
} else if (preferredRestrictionVersion) {
// Block this action on a room upgrade otherwise it'd make their room unjoinable
const targetVersion = preferredRestrictionVersion;
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
roomId: room.roomId,
targetVersion,
description: _t("This upgrade will allow members of selected spaces " +
"access to this room without an invite."),
onFinished: async (resp) => {
if (!resp?.continue) return;
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
closeSettingsFn();
// switch to the new room in the background
dis.dispatch({
action: "view_room",
room_id: roomId,
});
// open new settings on this tab
dis.dispatch({
action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB,
});
},
});
return;
}
// when setting to 0 allowed rooms/spaces set to invite only instead as per the note
if (!restrictedAllowRoomIds.length) {
joinRule = JoinRule.Invite;
}
}
if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return;
if (beforeChange && !await beforeChange(joinRule)) return;
const newContent: IJoinRuleEventContent = {
join_rule: joinRule,
};
// pre-set the accepted spaces with the currently viewed one as per the microcopy
if (joinRule === JoinRule.Restricted) {
newContent.allow = restrictedAllowRoomIds.map(roomId => ({
"type": RestrictedAllowType.RoomMembership,
"room_id": roomId,
}));
}
setContent(newContent);
};
return (
<StyledRadioGroup
name="joinRule"
value={joinRule}
onChange={onChange}
definitions={definitions}
disabled={disabled}
/>
);
};
export default JoinRuleSettings;

View file

@ -28,36 +28,31 @@ import { compare } from "../../../../../utils/strings";
import ErrorDialog from '../../../dialogs/ErrorDialog'; import ErrorDialog from '../../../dialogs/ErrorDialog';
import PowerSelector from "../../../elements/PowerSelector"; import PowerSelector from "../../../elements/PowerSelector";
const plEventsToLabels = { interface IEventShowOpts {
// These will be translated for us later. isState?: boolean;
[EventType.RoomAvatar]: _td("Change room avatar"), hideForSpace?: boolean;
[EventType.RoomName]: _td("Change room name"), }
[EventType.RoomCanonicalAlias]: _td("Change main address for the room"),
[EventType.RoomHistoryVisibility]: _td("Change history visibility"),
[EventType.RoomPowerLevels]: _td("Change permissions"),
[EventType.RoomTopic]: _td("Change topic"),
[EventType.RoomTombstone]: _td("Upgrade the room"),
[EventType.RoomEncryption]: _td("Enable room encryption"),
[EventType.RoomServerAcl]: _td("Change server ACLs"),
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) interface IPowerLevelDescriptor {
"im.vector.modular.widgets": _td("Modify widgets"), desc: string;
}; defaultValue: number;
hideForSpace?: boolean;
}
const plEventsToShow = { const plEventsToShow: Record<string, IEventShowOpts> = {
// If an event is listed here, it will be shown in the PL settings. Defaults will be calculated. // If an event is listed here, it will be shown in the PL settings. Defaults will be calculated.
[EventType.RoomAvatar]: { isState: true }, [EventType.RoomAvatar]: { isState: true },
[EventType.RoomName]: { isState: true }, [EventType.RoomName]: { isState: true },
[EventType.RoomCanonicalAlias]: { isState: true }, [EventType.RoomCanonicalAlias]: { isState: true },
[EventType.RoomHistoryVisibility]: { isState: true }, [EventType.RoomHistoryVisibility]: { isState: true, hideForSpace: true },
[EventType.RoomPowerLevels]: { isState: true }, [EventType.RoomPowerLevels]: { isState: true },
[EventType.RoomTopic]: { isState: true }, [EventType.RoomTopic]: { isState: true },
[EventType.RoomTombstone]: { isState: true }, [EventType.RoomTombstone]: { isState: true, hideForSpace: true },
[EventType.RoomEncryption]: { isState: true }, [EventType.RoomEncryption]: { isState: true, hideForSpace: true },
[EventType.RoomServerAcl]: { isState: true }, [EventType.RoomServerAcl]: { isState: true, hideForSpace: true },
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": { isState: true }, "im.vector.modular.widgets": { isState: true, hideForSpace: true },
}; };
// parse a string as an integer; if the input is undefined, or cannot be parsed // parse a string as an integer; if the input is undefined, or cannot be parsed
@ -145,7 +140,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => { private onPowerLevelsChanged = (inputValue: string, powerLevelKey: string) => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
let plContent = plEvent ? (plEvent.getContent() || {}) : {}; let plContent = plEvent ? (plEvent.getContent() || {}) : {};
// Clone the power levels just in case // Clone the power levels just in case
@ -173,7 +168,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
parentObj[keyPath[keyPath.length - 1]] = value; parentObj[keyPath[keyPath.length - 1]] = value;
} }
client.sendStateEvent(this.props.roomId, "m.room.power_levels", plContent).catch(e => { client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch(e => {
console.error(e); console.error(e);
Modal.createTrackedDialog('Power level requirement change failed', '', ErrorDialog, { Modal.createTrackedDialog('Power level requirement change failed', '', ErrorDialog, {
@ -189,7 +184,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => { private onUserPowerLevelChanged = (value: string, powerLevelKey: string) => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
let plContent = plEvent ? (plEvent.getContent() || {}) : {}; let plContent = plEvent ? (plEvent.getContent() || {}) : {};
// Clone the power levels just in case // Clone the power levels just in case
@ -199,7 +194,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
if (!plContent['users']) plContent['users'] = {}; if (!plContent['users']) plContent['users'] = {};
plContent['users'][powerLevelKey] = value; plContent['users'][powerLevelKey] = value;
client.sendStateEvent(this.props.roomId, "m.room.power_levels", plContent).catch(e => { client.sendStateEvent(this.props.roomId, EventType.RoomPowerLevels, plContent).catch(e => {
console.error(e); console.error(e);
Modal.createTrackedDialog('Power level change failed', '', ErrorDialog, { Modal.createTrackedDialog('Power level change failed', '', ErrorDialog, {
@ -215,11 +210,31 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
render() { render() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const plEvent = room.currentState.getStateEvents('m.room.power_levels', ''); const isSpaceRoom = room.isSpaceRoom();
const plContent = plEvent ? (plEvent.getContent() || {}) : {};
const canChangeLevels = room.currentState.mayClientSendStateEvent('m.room.power_levels', client);
const powerLevelDescriptors = { const plEvent = room.currentState.getStateEvents(EventType.RoomPowerLevels, '');
const plContent = plEvent ? (plEvent.getContent() || {}) : {};
const canChangeLevels = room.currentState.mayClientSendStateEvent(EventType.RoomPowerLevels, client);
const plEventsToLabels = {
// These will be translated for us later.
[EventType.RoomAvatar]: isSpaceRoom ? _td("Change space avatar") : _td("Change room avatar"),
[EventType.RoomName]: isSpaceRoom ? _td("Change space name") : _td("Change room name"),
[EventType.RoomCanonicalAlias]: isSpaceRoom
? _td("Change main address for the space")
: _td("Change main address for the room"),
[EventType.RoomHistoryVisibility]: _td("Change history visibility"),
[EventType.RoomPowerLevels]: _td("Change permissions"),
[EventType.RoomTopic]: isSpaceRoom ? _td("Change description") : _td("Change topic"),
[EventType.RoomTombstone]: _td("Upgrade the room"),
[EventType.RoomEncryption]: _td("Enable room encryption"),
[EventType.RoomServerAcl]: _td("Change server ACLs"),
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": isSpaceRoom ? null : _td("Modify widgets"),
};
const powerLevelDescriptors: Record<string, IPowerLevelDescriptor> = {
"users_default": { "users_default": {
desc: _t('Default role'), desc: _t('Default role'),
defaultValue: 0, defaultValue: 0,
@ -227,6 +242,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
"events_default": { "events_default": {
desc: _t('Send messages'), desc: _t('Send messages'),
defaultValue: 0, defaultValue: 0,
hideForSpace: true,
}, },
"invite": { "invite": {
desc: _t('Invite users'), desc: _t('Invite users'),
@ -247,10 +263,12 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
"redact": { "redact": {
desc: _t('Remove messages sent by others'), desc: _t('Remove messages sent by others'),
defaultValue: 50, defaultValue: 50,
hideForSpace: true,
}, },
"notifications.room": { "notifications.room": {
desc: _t('Notify everyone'), desc: _t('Notify everyone'),
defaultValue: 50, defaultValue: 50,
hideForSpace: true,
}, },
}; };
@ -361,6 +379,9 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
const powerSelectors = Object.keys(powerLevelDescriptors).map((key, index) => { const powerSelectors = Object.keys(powerLevelDescriptors).map((key, index) => {
const descriptor = powerLevelDescriptors[key]; const descriptor = powerLevelDescriptors[key];
if (isSpaceRoom && descriptor.hideForSpace) {
return null;
}
const keyPath = key.split('.'); const keyPath = key.split('.');
let currentObj = plContent; let currentObj = plContent;
@ -382,14 +403,18 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
onChange={this.onPowerLevelsChanged} onChange={this.onPowerLevelsChanged}
/> />
</div>; </div>;
}); }).filter(Boolean);
// hide the power level selector for enabling E2EE if it the room is already encrypted // hide the power level selector for enabling E2EE if it the room is already encrypted
if (client.isRoomEncrypted(this.props.roomId)) { if (client.isRoomEncrypted(this.props.roomId)) {
delete eventsLevels["m.room.encryption"]; delete eventsLevels[EventType.RoomEncryption];
} }
const eventPowerSelectors = Object.keys(eventsLevels).map((eventType, i) => { const eventPowerSelectors = Object.keys(eventsLevels).map((eventType, i) => {
if (isSpaceRoom && plEventsToShow[eventType].hideForSpace) {
return null;
}
let label = plEventsToLabels[eventType]; let label = plEventsToLabels[eventType];
if (label) { if (label) {
label = _t(label); label = _t(label);
@ -408,7 +433,7 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
/> />
</div> </div>
); );
}); }).filter(Boolean);
return ( return (
<div className="mx_SettingsTab mx_RolesRoomSettingsTab"> <div className="mx_SettingsTab mx_RolesRoomSettingsTab">
@ -418,7 +443,10 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
{ bannedUsersSection } { bannedUsersSection }
<div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'> <div className='mx_SettingsTab_section mx_SettingsTab_subsectionText'>
<span className='mx_SettingsTab_subheading'>{ _t("Permissions") }</span> <span className='mx_SettingsTab_subheading'>{ _t("Permissions") }</span>
<p>{ _t('Select the roles required to change various parts of the room') }</p> <p>{ isSpaceRoom
? _t('Select the roles required to change various parts of the space')
: _t('Select the roles required to change various parts of the room')
}</p>
{ powerSelectors } { powerSelectors }
{ eventPowerSelectors } { eventPowerSelectors }
</div> </div>

View file

@ -16,7 +16,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials"; import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "matrix-js-sdk/src/@types/partials";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from 'matrix-js-sdk/src/@types/event'; import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from "../../../../../languageHandler"; import { _t } from "../../../../../languageHandler";
@ -24,23 +24,17 @@ import { MatrixClientPeg } from "../../../../../MatrixClientPeg";
import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch";
import Modal from "../../../../../Modal"; import Modal from "../../../../../Modal";
import QuestionDialog from "../../../dialogs/QuestionDialog"; import QuestionDialog from "../../../dialogs/QuestionDialog";
import StyledRadioGroup, { IDefinition } from '../../../elements/StyledRadioGroup'; import StyledRadioGroup from '../../../elements/StyledRadioGroup';
import { SettingLevel } from "../../../../../settings/SettingLevel"; import { SettingLevel } from "../../../../../settings/SettingLevel";
import SettingsStore from "../../../../../settings/SettingsStore"; import SettingsStore from "../../../../../settings/SettingsStore";
import { UIFeature } from "../../../../../settings/UIFeature"; import { UIFeature } from "../../../../../settings/UIFeature";
import { replaceableComponent } from "../../../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../../../utils/replaceableComponent";
import AccessibleButton from "../../../elements/AccessibleButton"; import AccessibleButton from "../../../elements/AccessibleButton";
import SpaceStore from "../../../../../stores/SpaceStore";
import RoomAvatar from "../../../avatars/RoomAvatar";
import ManageRestrictedJoinRuleDialog from '../../../dialogs/ManageRestrictedJoinRuleDialog';
import RoomUpgradeWarningDialog from '../../../dialogs/RoomUpgradeWarningDialog';
import { upgradeRoom } from "../../../../../utils/RoomUpgrade";
import { arrayHasDiff } from "../../../../../utils/arrays";
import SettingsFlag from '../../../elements/SettingsFlag'; import SettingsFlag from '../../../elements/SettingsFlag';
import createRoom, { IOpts } from '../../../../../createRoom'; import createRoom, { IOpts } from '../../../../../createRoom';
import CreateRoomDialog from '../../../dialogs/CreateRoomDialog'; import CreateRoomDialog from '../../../dialogs/CreateRoomDialog';
import dis from "../../../../../dispatcher/dispatcher"; import JoinRuleSettings from "../../JoinRuleSettings";
import { ROOM_SECURITY_TAB } from "../../../dialogs/RoomSettingsDialog"; import ErrorDialog from "../../../dialogs/ErrorDialog";
interface IProps { interface IProps {
roomId: string; roomId: string;
@ -48,14 +42,11 @@ interface IProps {
} }
interface IState { interface IState {
joinRule: JoinRule;
restrictedAllowRoomIds?: string[]; restrictedAllowRoomIds?: string[];
guestAccess: GuestAccess; guestAccess: GuestAccess;
history: HistoryVisibility; history: HistoryVisibility;
hasAliases: boolean; hasAliases: boolean;
encrypted: boolean; encrypted: boolean;
roomSupportsRestricted?: boolean;
preferredRestrictionVersion?: string;
showAdvancedSection: boolean; showAdvancedSection: boolean;
} }
@ -65,7 +56,6 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
super(props); super(props);
this.state = { this.state = {
joinRule: JoinRule.Invite,
guestAccess: GuestAccess.Forbidden, guestAccess: GuestAccess.Forbidden,
history: HistoryVisibility.Shared, history: HistoryVisibility.Shared,
hasAliases: false, hasAliases: false,
@ -106,12 +96,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
); );
const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId); const encrypted = MatrixClientPeg.get().isRoomEncrypted(this.props.roomId);
const restrictedRoomCapabilities = SpaceStore.instance.restrictedJoinRuleSupport; this.setState({ restrictedAllowRoomIds, guestAccess, history, encrypted });
const roomSupportsRestricted = Array.isArray(restrictedRoomCapabilities?.support)
&& restrictedRoomCapabilities.support.includes(room.getVersion());
const preferredRestrictionVersion = roomSupportsRestricted ? undefined : restrictedRoomCapabilities?.preferred;
this.setState({ joinRule, restrictedAllowRoomIds, guestAccess, history, encrypted,
roomSupportsRestricted, preferredRestrictionVersion });
this.hasAliases().then(hasAliases => this.setState({ hasAliases })); this.hasAliases().then(hasAliases => this.setState({ hasAliases }));
} }
@ -135,7 +120,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
}; };
private onEncryptionChange = async () => { private onEncryptionChange = async () => {
if (this.state.joinRule == "public") { if (MatrixClientPeg.get().getRoom(this.props.roomId)?.getJoinRule() === JoinRule.Public) {
const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, { const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
title: _t('Are you sure you want to add encryption to this public room?'), title: _t('Are you sure you want to add encryption to this public room?'),
description: <div> description: <div>
@ -202,128 +187,6 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
}); });
}; };
private onJoinRuleChange = async (joinRule: JoinRule) => {
const beforeJoinRule = this.state.joinRule;
let restrictedAllowRoomIds: string[];
if (joinRule === JoinRule.Restricted) {
const matrixClient = MatrixClientPeg.get();
const roomId = this.props.roomId;
const room = matrixClient.getRoom(roomId);
if (beforeJoinRule === JoinRule.Restricted || this.state.roomSupportsRestricted) {
// Have the user pick which spaces to allow joins from
restrictedAllowRoomIds = await this.editRestrictedRoomIds();
if (!Array.isArray(restrictedAllowRoomIds)) return;
} else if (this.state.preferredRestrictionVersion) {
// Block this action on a room upgrade otherwise it'd make their room unjoinable
const targetVersion = this.state.preferredRestrictionVersion;
Modal.createTrackedDialog('Restricted join rule upgrade', '', RoomUpgradeWarningDialog, {
roomId,
targetVersion,
description: _t("This upgrade will allow members of selected spaces " +
"access to this room without an invite."),
onFinished: async (resp) => {
if (!resp?.continue) return;
const roomId = await upgradeRoom(room, targetVersion, resp.invite, true, true, true);
this.props.closeSettingsFn();
// switch to the new room in the background
dis.dispatch({
action: "view_room",
room_id: roomId,
});
// open new settings on this tab
dis.dispatch({
action: "open_room_settings",
initial_tab_id: ROOM_SECURITY_TAB,
});
},
});
return;
}
}
if (
this.state.encrypted &&
this.state.joinRule !== JoinRule.Public &&
joinRule === JoinRule.Public
) {
const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
title: _t("Are you sure you want to make this encrypted room public?"),
description: <div>
<p> { _t(
"<b>It's not recommended to make encrypted rooms public.</b> " +
"It will mean anyone can find and join the room, so anyone can read messages. " +
"You'll get none of the benefits of encryption. Encrypting messages in a public " +
"room will make receiving and sending messages slower.",
null,
{ "b": (sub) => <b>{ sub }</b> },
) } </p>
<p> { _t(
"To avoid these issues, create a <a>new public room</a> for the conversation " +
"you plan to have.",
null,
{
"a": (sub) => <a
className="mx_linkButton"
onClick={() => {
dialog.close();
this.createNewRoom(true, false);
}}> { sub } </a>,
},
) } </p>
</div>,
});
const { finished } = dialog;
const [confirm] = await finished;
if (!confirm) return;
}
if (beforeJoinRule === joinRule && !restrictedAllowRoomIds) return;
const content: IContent = {
join_rule: joinRule,
};
// pre-set the accepted spaces with the currently viewed one as per the microcopy
if (joinRule === JoinRule.Restricted) {
content.allow = restrictedAllowRoomIds.map(roomId => ({
"type": RestrictedAllowType.RoomMembership,
"room_id": roomId,
}));
}
this.setState({ joinRule, restrictedAllowRoomIds });
const client = MatrixClientPeg.get();
client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, content, "").catch((e) => {
console.error(e);
this.setState({
joinRule: beforeJoinRule,
restrictedAllowRoomIds: undefined,
});
});
};
private onRestrictedRoomIdsChange = (restrictedAllowRoomIds: string[]) => {
const beforeRestrictedAllowRoomIds = this.state.restrictedAllowRoomIds;
if (!arrayHasDiff(beforeRestrictedAllowRoomIds || [], restrictedAllowRoomIds)) return;
this.setState({ restrictedAllowRoomIds });
const client = MatrixClientPeg.get();
client.sendStateEvent(this.props.roomId, EventType.RoomJoinRules, {
join_rule: JoinRule.Restricted,
allow: restrictedAllowRoomIds.map(roomId => ({
"type": RestrictedAllowType.RoomMembership,
"room_id": roomId,
})),
}, "").catch((e) => {
console.error(e);
this.setState({ restrictedAllowRoomIds: beforeRestrictedAllowRoomIds });
});
};
private onGuestAccessChange = (allowed: boolean) => { private onGuestAccessChange = (allowed: boolean) => {
const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden; const guestAccess = allowed ? GuestAccess.CanJoin : GuestAccess.Forbidden;
const beforeGuestAccess = this.state.guestAccess; const beforeGuestAccess = this.state.guestAccess;
@ -385,42 +248,12 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
} }
} }
private editRestrictedRoomIds = async (): Promise<string[] | undefined> => {
let selected = this.state.restrictedAllowRoomIds;
if (!selected?.length && SpaceStore.instance.activeSpace) {
selected = [SpaceStore.instance.activeSpace.roomId];
}
const matrixClient = MatrixClientPeg.get();
const { finished } = Modal.createTrackedDialog('Edit restricted', '', ManageRestrictedJoinRuleDialog, {
matrixClient,
room: matrixClient.getRoom(this.props.roomId),
selected,
}, "mx_ManageRestrictedJoinRuleDialog_wrapper");
const [restrictedAllowRoomIds] = await finished;
return restrictedAllowRoomIds;
};
private onEditRestrictedClick = async () => {
const restrictedAllowRoomIds = await this.editRestrictedRoomIds();
if (!Array.isArray(restrictedAllowRoomIds)) return;
if (restrictedAllowRoomIds.length > 0) {
this.onRestrictedRoomIdsChange(restrictedAllowRoomIds);
} else {
this.onJoinRuleChange(JoinRule.Invite);
}
};
private renderJoinRule() { private renderJoinRule() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(this.props.roomId); const room = client.getRoom(this.props.roomId);
const joinRule = this.state.joinRule;
const canChangeJoinRule = room.currentState.mayClientSendStateEvent(EventType.RoomJoinRules, client);
let aliasWarning = null; let aliasWarning = null;
if (joinRule === JoinRule.Public && !this.state.hasAliases) { if (room.getJoinRule() === JoinRule.Public && !this.state.hasAliases) {
aliasWarning = ( aliasWarning = (
<div className='mx_SecurityRoomSettingsTab_warning'> <div className='mx_SecurityRoomSettingsTab_warning'>
<img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} /> <img src={require("../../../../../../res/img/warning.svg")} width={15} height={15} />
@ -431,111 +264,68 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
); );
} }
const radioDefinitions: IDefinition<JoinRule>[] = [{ return <div className="mx_SecurityRoomSettingsTab_joinRule">
value: JoinRule.Invite, <div className="mx_SettingsTab_subsectionText">
label: _t("Private (invite only)"), <span>{ _t("Decide who can join %(roomName)s.", {
description: _t("Only invited people can join."), roomName: room?.name,
checked: this.state.joinRule === JoinRule.Invite }) }</span>
|| (this.state.joinRule === JoinRule.Restricted && !this.state.restrictedAllowRoomIds?.length), </div>
}, {
value: JoinRule.Public,
label: _t("Public"),
description: _t("Anyone can find and join."),
}];
if (this.state.roomSupportsRestricted || { aliasWarning }
this.state.preferredRestrictionVersion ||
joinRule === JoinRule.Restricted
) {
let upgradeRequiredPill;
if (this.state.preferredRestrictionVersion) {
upgradeRequiredPill = <span className="mx_SecurityRoomSettingsTab_upgradeRequired">
{ _t("Upgrade required") }
</span>;
}
let description; <JoinRuleSettings
if (joinRule === JoinRule.Restricted && this.state.restrictedAllowRoomIds?.length) { room={room}
const shownSpaces = this.state.restrictedAllowRoomIds beforeChange={this.onBeforeJoinRuleChange}
.map(roomId => client.getRoom(roomId)) onError={this.onJoinRuleChangeError}
.filter(room => room?.isSpaceRoom()) closeSettingsFn={this.props.closeSettingsFn}
.slice(0, 4); promptUpgrade={true}
/>
</div>;
}
let moreText; private onJoinRuleChangeError = (error: Error) => {
if (shownSpaces.length < this.state.restrictedAllowRoomIds.length) { Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
if (shownSpaces.length > 0) { title: _t("Failed to update the join rules"),
moreText = _t("& %(count)s more", { description: error.message ?? _t("Unknown failure"),
count: this.state.restrictedAllowRoomIds.length - shownSpaces.length, });
}); };
} else {
moreText = _t("Currently, %(count)s spaces have access", {
count: this.state.restrictedAllowRoomIds.length,
});
}
}
description = <div> private onBeforeJoinRuleChange = async (joinRule: JoinRule): Promise<boolean> => {
<span> if (this.state.encrypted && joinRule === JoinRule.Public) {
{ _t("Anyone in a space can find and join. <a>Edit which spaces can access here.</a>", {}, { const dialog = Modal.createTrackedDialog('Confirm Public Encrypted Room', '', QuestionDialog, {
a: sub => <AccessibleButton title: _t("Are you sure you want to make this encrypted room public?"),
disabled={!canChangeJoinRule} description: <div>
onClick={this.onEditRestrictedClick} <p> { _t(
kind="link" "<b>It's not recommended to make encrypted rooms public.</b> " +
> "It will mean anyone can find and join the room, so anyone can read messages. " +
{ sub } "You'll get none of the benefits of encryption. Encrypting messages in a public " +
</AccessibleButton>, "room will make receiving and sending messages slower.",
}) } null,
</span> { "b": (sub) => <b>{ sub }</b> },
) } </p>
<div className="mx_SecurityRoomSettingsTab_spacesWithAccess"> <p> { _t(
<h4>{ _t("Spaces with access") }</h4> "To avoid these issues, create a <a>new public room</a> for the conversation " +
{ shownSpaces.map(room => { "you plan to have.",
return <span key={room.roomId}> null,
<RoomAvatar room={room} height={32} width={32} /> {
{ room.name } "a": (sub) => <a
</span>; className="mx_linkButton"
}) } onClick={() => {
{ moreText && <span>{ moreText }</span> } dialog.close();
</div> this.createNewRoom(true, false);
</div>; }}> { sub } </a>,
} else if (SpaceStore.instance.activeSpace) { },
description = _t("Anyone in %(spaceName)s can find and join. You can select other spaces too.", { ) } </p>
spaceName: SpaceStore.instance.activeSpace.name, </div>,
});
} else {
description = _t("Anyone in a space can find and join. You can select multiple spaces.");
}
radioDefinitions.splice(1, 0, {
value: JoinRule.Restricted,
label: <>
{ _t("Space members") }
{ upgradeRequiredPill }
</>,
description,
// if there are 0 allowed spaces then render it as invite only instead
checked: this.state.joinRule === JoinRule.Restricted && !!this.state.restrictedAllowRoomIds?.length,
}); });
const { finished } = dialog;
const [confirm] = await finished;
if (!confirm) return false;
} }
return ( return true;
<div className="mx_SecurityRoomSettingsTab_joinRule"> };
<div className="mx_SettingsTab_subsectionText">
<span>{ _t("Decide who can join %(roomName)s.", {
roomName: client.getRoom(this.props.roomId)?.name,
}) }</span>
</div>
{ aliasWarning }
<StyledRadioGroup
name="joinRule"
value={joinRule}
onChange={this.onJoinRuleChange}
definitions={radioDefinitions}
disabled={!canChangeJoinRule}
/>
</div>
);
}
private renderHistory() { private renderHistory() {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
@ -635,7 +425,7 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
} }
let advanced; let advanced;
if (this.state.joinRule === JoinRule.Public) { if (room.getJoinRule() === JoinRule.Public) {
advanced = ( advanced = (
<> <>
<AccessibleButton <AccessibleButton

View file

@ -56,7 +56,7 @@ export const createSpace = async (
power_level_content_override: { power_level_content_override: {
// Only allow Admins to write to the timeline to prevent hidden sync spam // Only allow Admins to write to the timeline to prevent hidden sync spam
events_default: 100, events_default: 100,
...isPublic ? { invite: 0 } : {}, invite: isPublic ? 0 : 50,
}, },
room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined, room_alias_name: isPublic && alias ? alias.substr(1, alias.indexOf(":") - 1) : undefined,
topic, topic,
@ -97,9 +97,8 @@ const spaceNameValidator = withValidation({
], ],
}); });
const nameToAlias = (name: string, domain: string): string => { const nameToLocalpart = (name: string): string => {
const localpart = name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, ""); return name.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^a-z0-9_-]+/gi, "");
return `#${localpart}:${domain}`;
}; };
// XXX: Temporary for the Spaces release only // XXX: Temporary for the Spaces release only
@ -174,8 +173,9 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
value={name} value={name}
onChange={ev => { onChange={ev => {
const newName = ev.target.value; const newName = ev.target.value;
if (!alias || alias === nameToAlias(name, domain)) { if (!alias || alias === `#${nameToLocalpart(name)}:${domain}`) {
setAlias(nameToAlias(newName, domain)); setAlias(`#${nameToLocalpart(newName)}:${domain}`);
aliasFieldRef.current?.validate({ allowEmpty: true });
} }
setName(newName); setName(newName);
}} }}
@ -192,7 +192,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
onChange={setAlias} onChange={setAlias}
domain={domain} domain={domain}
value={alias} value={alias}
placeholder={name ? nameToAlias(name, domain) : _t("e.g. my-space")} placeholder={name ? nameToLocalpart(name) : _t("e.g. my-space")}
label={_t("Address")} label={_t("Address")}
disabled={busy} disabled={busy}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
@ -215,6 +215,7 @@ export const SpaceCreateForm: React.FC<ISpaceCreateFormProps> = ({
}; };
const SpaceCreateMenu = ({ onFinished }) => { const SpaceCreateMenu = ({ onFinished }) => {
const cli = useContext(MatrixClientContext);
const [visibility, setVisibility] = useState<Visibility>(null); const [visibility, setVisibility] = useState<Visibility>(null);
const [busy, setBusy] = useState<boolean>(false); const [busy, setBusy] = useState<boolean>(false);
@ -231,14 +232,18 @@ const SpaceCreateMenu = ({ onFinished }) => {
setBusy(true); setBusy(true);
// require & validate the space name field // require & validate the space name field
if (!await spaceNameField.current.validate({ allowEmpty: false })) { if (!(await spaceNameField.current.validate({ allowEmpty: false }))) {
spaceNameField.current.focus(); spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true }); spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false); setBusy(false);
return; return;
} }
// validate the space name alias field but do not require it
if (visibility === Visibility.Public && !await spaceAliasField.current.validate({ allowEmpty: true })) { // validate the space alias field but do not require it
const aliasLocalpart = alias.substring(1, alias.length - cli.getDomain().length - 1);
if (visibility === Visibility.Public && aliasLocalpart &&
(await spaceAliasField.current.validate({ allowEmpty: true })) === false
) {
spaceAliasField.current.focus(); spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true }); spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false); setBusy(false);
@ -246,7 +251,13 @@ const SpaceCreateMenu = ({ onFinished }) => {
} }
try { try {
await createSpace(name, visibility === Visibility.Public, alias, topic, avatar); await createSpace(
name,
visibility === Visibility.Public,
aliasLocalpart ? alias : undefined,
topic,
avatar,
);
onFinished(); onFinished();
} catch (e) { } catch (e) {

View file

@ -25,49 +25,22 @@ import AccessibleButton from "../elements/AccessibleButton";
import AliasSettings from "../room_settings/AliasSettings"; import AliasSettings from "../room_settings/AliasSettings";
import { useStateToggle } from "../../../hooks/useStateToggle"; import { useStateToggle } from "../../../hooks/useStateToggle";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import StyledRadioGroup from "../elements/StyledRadioGroup"; import { useLocalEcho } from "../../../hooks/useLocalEcho";
import JoinRuleSettings from "../settings/JoinRuleSettings";
import { useRoomState } from "../../../hooks/useRoomState";
interface IProps { interface IProps {
matrixClient: MatrixClient; matrixClient: MatrixClient;
space: Room; space: Room;
closeSettingsFn(): void;
} }
enum SpaceVisibility { const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space, closeSettingsFn }: IProps) => {
Unlisted = "unlisted",
Private = "private",
}
const useLocalEcho = <T extends any>(
currentFactory: () => T,
setterFn: (value: T) => Promise<unknown>,
errorFn: (error: Error) => void,
): [value: T, handler: (value: T) => void] => {
const [value, setValue] = useState(currentFactory);
const handler = async (value: T) => {
setValue(value);
try {
await setterFn(value);
} catch (e) {
setValue(currentFactory());
errorFn(e);
}
};
return [value, handler];
};
const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
const [error, setError] = useState(""); const [error, setError] = useState("");
const userId = cli.getUserId(); const userId = cli.getUserId();
const [visibility, setVisibility] = useLocalEcho<SpaceVisibility>( const joinRule = useRoomState(space, state => state.getJoinRule());
() => space.getJoinRule() === JoinRule.Invite ? SpaceVisibility.Private : SpaceVisibility.Unlisted,
visibility => cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, {
join_rule: visibility === SpaceVisibility.Unlisted ? JoinRule.Public : JoinRule.Invite,
}, ""),
() => setError(_t("Failed to update the visibility of this space")),
);
const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho<boolean>( const [guestAccessEnabled, setGuestAccessEnabled] = useLocalEcho<boolean>(
() => space.currentState.getStateEvents(EventType.RoomGuestAccess, "") () => space.currentState.getStateEvents(EventType.RoomGuestAccess, "")
?.getContent()?.guest_access === GuestAccess.CanJoin, ?.getContent()?.guest_access === GuestAccess.CanJoin,
@ -87,14 +60,13 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
const [showAdvancedSection, toggleAdvancedSection] = useStateToggle(); const [showAdvancedSection, toggleAdvancedSection] = useStateToggle();
const canSetJoinRule = space.currentState.maySendStateEvent(EventType.RoomJoinRules, userId);
const canSetGuestAccess = space.currentState.maySendStateEvent(EventType.RoomGuestAccess, userId); const canSetGuestAccess = space.currentState.maySendStateEvent(EventType.RoomGuestAccess, userId);
const canSetHistoryVisibility = space.currentState.maySendStateEvent(EventType.RoomHistoryVisibility, userId); const canSetHistoryVisibility = space.currentState.maySendStateEvent(EventType.RoomHistoryVisibility, userId);
const canSetCanonical = space.currentState.mayClientSendStateEvent(EventType.RoomCanonicalAlias, cli); const canSetCanonical = space.currentState.mayClientSendStateEvent(EventType.RoomCanonicalAlias, cli);
const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); const canonicalAliasEv = space.currentState.getStateEvents(EventType.RoomCanonicalAlias, "");
let advancedSection; let advancedSection;
if (visibility === SpaceVisibility.Unlisted) { if (joinRule === JoinRule.Public) {
if (showAdvancedSection) { if (showAdvancedSection) {
advancedSection = <> advancedSection = <>
<AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced"> <AccessibleButton onClick={toggleAdvancedSection} kind="link" className="mx_SettingsTab_showAdvanced">
@ -123,7 +95,7 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
} }
let addressesSection; let addressesSection;
if (visibility !== SpaceVisibility.Private) { if (space.getJoinRule() === JoinRule.Public) {
addressesSection = <> addressesSection = <>
<span className="mx_SettingsTab_subheading">{ _t("Address") }</span> <span className="mx_SettingsTab_subheading">{ _t("Address") }</span>
<div className="mx_SettingsTab_section mx_SettingsTab_subsectionText"> <div className="mx_SettingsTab_section mx_SettingsTab_subsectionText">
@ -149,22 +121,10 @@ const SpaceSettingsVisibilityTab = ({ matrixClient: cli, space }: IProps) => {
</div> </div>
<div> <div>
<StyledRadioGroup <JoinRuleSettings
name="spaceVisibility" room={space}
value={visibility} onError={() => setError(_t("Failed to update the visibility of this space"))}
onChange={setVisibility} closeSettingsFn={closeSettingsFn}
disabled={!canSetJoinRule}
definitions={[
{
value: SpaceVisibility.Unlisted,
label: _t("Public"),
description: _t("anyone with the link can view and join"),
}, {
value: SpaceVisibility.Private,
label: _t("Invite only"),
description: _t("only invited people can view and join"),
},
]}
/> />
</div> </div>

View file

@ -93,6 +93,7 @@ export const SpaceButton: React.FC<IButtonProps> = ({
notification={notificationState} notification={notificationState}
aria-label={ariaLabel} aria-label={ariaLabel}
tabIndex={tabIndex} tabIndex={tabIndex}
showUnsentTooltip={true}
/> />
</div>; </div>;
} }
@ -260,7 +261,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
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, dragHandleProps,
...otherProps } = this.props; ...otherProps } = this.props;
const collapsed = this.isCollapsed; const collapsed = this.isCollapsed;
@ -299,7 +300,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
/> : null; /> : null;
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tabIndex, ...dragHandleProps } = this.props.dragHandleProps || {}; const { tabIndex, ...restDragHandleProps } = dragHandleProps || {};
return ( return (
<li <li
@ -310,7 +311,7 @@ export class SpaceItem extends React.PureComponent<IItemProps, IItemState> {
role="treeitem" role="treeitem"
> >
<SpaceButton <SpaceButton
{...dragHandleProps} {...restDragHandleProps}
space={space} space={space}
className={isInvite ? "mx_SpaceButton_invite" : undefined} className={isInvite ? "mx_SpaceButton_invite" : undefined}
selected={activeSpaces.includes(space)} selected={activeSpaces.includes(space)}

View file

@ -273,14 +273,18 @@ export default class CallView extends React.Component<IProps, IState> {
}; };
private onScreenshareClick = async (): Promise<void> => { private onScreenshareClick = async (): Promise<void> => {
const isScreensharing = await this.props.call.setScreensharingEnabled( let isScreensharing;
!this.state.screensharing, if (this.state.screensharing) {
async (): Promise<DesktopCapturerSource> => { isScreensharing = await this.props.call.setScreensharingEnabled(false);
} else {
if (window.electron?.getDesktopCapturerSources) {
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker); const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished; const [source] = await finished;
return source; isScreensharing = await this.props.call.setScreensharingEnabled(true, source);
}, } else {
); isScreensharing = await this.props.call.setScreensharingEnabled(true);
}
}
this.setState({ this.setState({
sidebarShown: true, sidebarShown: true,

View file

@ -32,13 +32,20 @@ export default class Range {
this._end = bIsLarger ? positionB : positionA; this._end = bIsLarger ? positionB : positionA;
} }
public moveStart(delta: number): void { public moveStartForwards(delta: number): void {
this._start = this._start.forwardsWhile(this.model, () => { this._start = this._start.forwardsWhile(this.model, () => {
delta -= 1; delta -= 1;
return delta >= 0; return delta >= 0;
}); });
} }
public moveEndBackwards(delta: number): void {
this._end = this._end.backwardsWhile(this.model, () => {
delta -= 1;
return delta >= 0;
});
}
public trim(): void { public trim(): void {
this._start = this._start.forwardsWhile(this.model, whitespacePredicate); this._start = this._start.forwardsWhile(this.model, whitespacePredicate);
this._end = this._end.backwardsWhile(this.model, whitespacePredicate); this._end = this._end.backwardsWhile(this.model, whitespacePredicate);

View file

@ -20,7 +20,11 @@ import type { EventEmitter } from "events";
type Handler = (...args: any[]) => void; type Handler = (...args: any[]) => void;
// Hook to wrap event emitter on and removeListener in hook lifecycle // Hook to wrap event emitter on and removeListener in hook lifecycle
export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbol, handler: Handler) => { export const useEventEmitter = (
emitter: EventEmitter | undefined,
eventName: string | symbol,
handler: Handler,
) => {
// Create a ref that stores handler // Create a ref that stores handler
const savedHandler = useRef(handler); const savedHandler = useRef(handler);
@ -51,7 +55,11 @@ export const useEventEmitter = (emitter: EventEmitter, eventName: string | symbo
type Mapper<T> = (...args: any[]) => T; type Mapper<T> = (...args: any[]) => T;
export const useEventEmitterState = <T>(emitter: EventEmitter, eventName: string | symbol, fn: Mapper<T>): T => { export const useEventEmitterState = <T>(
emitter: EventEmitter | undefined,
eventName: string | symbol,
fn: Mapper<T>,
): T => {
const [value, setValue] = useState<T>(fn()); const [value, setValue] = useState<T>(fn());
const handler = useCallback((...args: any[]) => { const handler = useCallback((...args: any[]) => {
setValue(fn(...args)); setValue(fn(...args));

36
src/hooks/useLocalEcho.ts Normal file
View file

@ -0,0 +1,36 @@
/*
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 { useState } from "react";
export const useLocalEcho = <T extends any>(
currentFactory: () => T,
setterFn: (value: T) => Promise<unknown>,
errorFn: (error: Error) => void,
): [value: T, handler: (value: T) => void] => {
const [value, setValue] = useState(currentFactory);
const handler = async (value: T) => {
setValue(value);
try {
await setterFn(value);
} catch (e) {
setValue(currentFactory());
errorFn(e);
}
};
return [value, handler];
};

View file

@ -25,7 +25,7 @@ const defaultMapper: Mapper<RoomState> = (roomState: RoomState) => roomState;
// Hook to simplify watching Matrix Room state // Hook to simplify watching Matrix Room state
export const useRoomState = <T extends any = RoomState>( export const useRoomState = <T extends any = RoomState>(
room: Room, room?: Room,
mapper: Mapper<T> = defaultMapper as Mapper<T>, mapper: Mapper<T> = defaultMapper as Mapper<T>,
): T => { ): T => {
const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined); const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined);

View file

@ -1060,7 +1060,6 @@
"Saving...": "Saving...", "Saving...": "Saving...",
"Save Changes": "Save Changes", "Save Changes": "Save Changes",
"Leave Space": "Leave Space", "Leave Space": "Leave Space",
"Failed to update the visibility of this space": "Failed to update the visibility of this space",
"Failed to update the guest access of this space": "Failed to update the guest access of this space", "Failed to update the guest access of this space": "Failed to update the guest access of this space",
"Failed to update the history visibility of this space": "Failed to update the history visibility of this space", "Failed to update the history visibility of this space": "Failed to update the history visibility of this space",
"Hide advanced": "Hide advanced", "Hide advanced": "Hide advanced",
@ -1070,9 +1069,7 @@
"Show advanced": "Show advanced", "Show advanced": "Show advanced",
"Visibility": "Visibility", "Visibility": "Visibility",
"Decide who can view and join %(spaceName)s.": "Decide who can view and join %(spaceName)s.", "Decide who can view and join %(spaceName)s.": "Decide who can view and join %(spaceName)s.",
"anyone with the link can view and join": "anyone with the link can view and join", "Failed to update the visibility of this space": "Failed to update the visibility of this space",
"Invite only": "Invite only",
"only invited people can view and join": "only invited people can view and join",
"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.",
@ -1146,6 +1143,20 @@
"Connecting to integration manager...": "Connecting to integration manager...", "Connecting to integration manager...": "Connecting to integration manager...",
"Cannot connect to integration manager": "Cannot connect to integration manager", "Cannot connect to integration manager": "Cannot connect to integration manager",
"The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.", "The integration manager is offline or it cannot reach your homeserver.": "The integration manager is offline or it cannot reach your homeserver.",
"Private (invite only)": "Private (invite only)",
"Only invited people can join.": "Only invited people can join.",
"Anyone can find and join.": "Anyone can find and join.",
"Upgrade required": "Upgrade required",
"& %(count)s more|other": "& %(count)s more",
"& %(count)s more|one": "& %(count)s more",
"Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access",
"Currently, %(count)s spaces have access|one": "Currently, a space has access",
"Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>",
"Spaces with access": "Spaces with access",
"Anyone in <spaceName/> can find and join. You can select other spaces too.": "Anyone in <spaceName/> can find and join. You can select other spaces too.",
"Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.",
"Space members": "Space members",
"This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.",
"Message layout": "Message layout", "Message layout": "Message layout",
"IRC": "IRC", "IRC": "IRC",
"Modern": "Modern", "Modern": "Modern",
@ -1416,16 +1427,6 @@
"Notification sound": "Notification sound", "Notification sound": "Notification sound",
"Set a new custom sound": "Set a new custom sound", "Set a new custom sound": "Set a new custom sound",
"Browse": "Browse", "Browse": "Browse",
"Change room avatar": "Change room avatar",
"Change room name": "Change room name",
"Change main address for the room": "Change main address for the room",
"Change history visibility": "Change history visibility",
"Change permissions": "Change permissions",
"Change topic": "Change topic",
"Upgrade the room": "Upgrade the room",
"Enable room encryption": "Enable room encryption",
"Change server ACLs": "Change server ACLs",
"Modify widgets": "Modify widgets",
"Failed to unban": "Failed to unban", "Failed to unban": "Failed to unban",
"Unban": "Unban", "Unban": "Unban",
"Banned by %(displayName)s": "Banned by %(displayName)s", "Banned by %(displayName)s": "Banned by %(displayName)s",
@ -1434,6 +1435,20 @@
"An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.", "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.": "An error occurred changing the room's power level requirements. Ensure you have sufficient permissions and try again.",
"Error changing power level": "Error changing power level", "Error changing power level": "Error changing power level",
"An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.", "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.": "An error occurred changing the user's power level. Ensure you have sufficient permissions and try again.",
"Change space avatar": "Change space avatar",
"Change room avatar": "Change room avatar",
"Change space name": "Change space name",
"Change room name": "Change room name",
"Change main address for the space": "Change main address for the space",
"Change main address for the room": "Change main address for the room",
"Change history visibility": "Change history visibility",
"Change permissions": "Change permissions",
"Change description": "Change description",
"Change topic": "Change topic",
"Upgrade the room": "Upgrade the room",
"Enable room encryption": "Enable room encryption",
"Change server ACLs": "Change server ACLs",
"Modify widgets": "Modify widgets",
"Default role": "Default role", "Default role": "Default role",
"Send messages": "Send messages", "Send messages": "Send messages",
"Invite users": "Invite users", "Invite users": "Invite users",
@ -1448,31 +1463,20 @@
"Banned users": "Banned users", "Banned users": "Banned users",
"Send %(eventType)s events": "Send %(eventType)s events", "Send %(eventType)s events": "Send %(eventType)s events",
"Permissions": "Permissions", "Permissions": "Permissions",
"Select the roles required to change various parts of the space": "Select the roles required to change various parts of the space",
"Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room", "Select the roles required to change various parts of the room": "Select the roles required to change various parts of the room",
"Are you sure you want to add encryption to this public room?": "Are you sure you want to add encryption to this public room?", "Are you sure you want to add encryption to this public room?": "Are you sure you want to add encryption to this public room?",
"<b>It's not recommended to add encryption to public rooms.</b>Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "<b>It's not recommended to add encryption to public rooms.</b>Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.", "<b>It's not recommended to add encryption to public rooms.</b>Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.": "<b>It's not recommended to add encryption to public rooms.</b>Anyone can find and join public rooms, so anyone can read messages in them. You'll get none of the benefits of encryption, and you won't be able to turn it off later. Encrypting messages in a public room will make receiving and sending messages slower.",
"To avoid these issues, create a <a>new encrypted room</a> for the conversation you plan to have.": "To avoid these issues, create a <a>new encrypted room</a> for the conversation you plan to have.", "To avoid these issues, create a <a>new encrypted room</a> for the conversation you plan to have.": "To avoid these issues, create a <a>new encrypted room</a> for the conversation you plan to have.",
"Enable encryption?": "Enable encryption?", "Enable encryption?": "Enable encryption?",
"Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>", "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>": "Once enabled, encryption for a room cannot be disabled. Messages sent in an encrypted room cannot be seen by the server, only by the participants of the room. Enabling encryption may prevent many bots and bridges from working correctly. <a>Learn more about encryption.</a>",
"This upgrade will allow members of selected spaces access to this room without an invite.": "This upgrade will allow members of selected spaces access to this room without an invite.", "To link to this room, please add an address.": "To link to this room, please add an address.",
"Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.",
"Failed to update the join rules": "Failed to update the join rules",
"Unknown failure": "Unknown failure",
"Are you sure you want to make this encrypted room public?": "Are you sure you want to make this encrypted room public?", "Are you sure you want to make this encrypted room public?": "Are you sure you want to make this encrypted room public?",
"<b>It's not recommended to make encrypted rooms public.</b> It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "<b>It's not recommended to make encrypted rooms public.</b> It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.", "<b>It's not recommended to make encrypted rooms public.</b> It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.": "<b>It's not recommended to make encrypted rooms public.</b> It will mean anyone can find and join the room, so anyone can read messages. You'll get none of the benefits of encryption. Encrypting messages in a public room will make receiving and sending messages slower.",
"To avoid these issues, create a <a>new public room</a> for the conversation you plan to have.": "To avoid these issues, create a <a>new public room</a> for the conversation you plan to have.", "To avoid these issues, create a <a>new public room</a> for the conversation you plan to have.": "To avoid these issues, create a <a>new public room</a> for the conversation you plan to have.",
"To link to this room, please add an address.": "To link to this room, please add an address.",
"Private (invite only)": "Private (invite only)",
"Only invited people can join.": "Only invited people can join.",
"Anyone can find and join.": "Anyone can find and join.",
"Upgrade required": "Upgrade required",
"& %(count)s more|other": "& %(count)s more",
"& %(count)s more|one": "& %(count)s more",
"Currently, %(count)s spaces have access|other": "Currently, %(count)s spaces have access",
"Currently, %(count)s spaces have access|one": "Currently, a space has access",
"Anyone in a space can find and join. <a>Edit which spaces can access here.</a>": "Anyone in a space can find and join. <a>Edit which spaces can access here.</a>",
"Spaces with access": "Spaces with access",
"Anyone in %(spaceName)s can find and join. You can select other spaces too.": "Anyone in %(spaceName)s can find and join. You can select other spaces too.",
"Anyone in a space can find and join. You can select multiple spaces.": "Anyone in a space can find and join. You can select multiple spaces.",
"Space members": "Space members",
"Decide who can join %(roomName)s.": "Decide who can join %(roomName)s.",
"Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)", "Members only (since the point in time of selecting this option)": "Members only (since the point in time of selecting this option)",
"Members only (since they were invited)": "Members only (since they were invited)", "Members only (since they were invited)": "Members only (since they were invited)",
"Members only (since they joined)": "Members only (since they joined)", "Members only (since they joined)": "Members only (since they joined)",
@ -1589,6 +1593,7 @@
"Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.", "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.": "Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.",
"Enable encryption in settings.": "Enable encryption in settings.", "Enable encryption in settings.": "Enable encryption in settings.",
"End-to-end encryption isn't enabled": "End-to-end encryption isn't enabled", "End-to-end encryption isn't enabled": "End-to-end encryption isn't enabled",
"Message didn't send. Click for info.": "Message didn't send. Click for info.",
"Unpin": "Unpin", "Unpin": "Unpin",
"View message": "View message", "View message": "View message",
"%(duration)ss": "%(duration)ss", "%(duration)ss": "%(duration)ss",

View file

@ -629,11 +629,18 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
}; };
private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => { private onRoom = (room: Room, newMembership?: string, oldMembership?: string) => {
const membership = newMembership || room.getMyMembership(); const roomMembership = room.getMyMembership();
if (!roomMembership) {
// room is still being baked in the js-sdk, we'll process it at Room.myMembership instead
return;
}
const membership = newMembership || roomMembership;
if (!room.isSpaceRoom()) { if (!room.isSpaceRoom()) {
// this.onRoomUpdate(room); // this.onRoomUpdate(room);
this.onRoomsUpdate(); // this.onRoomsUpdate();
// ideally we only need onRoomsUpdate here but it doesn't rebuild parentMap so always adds new rooms to Home
this.rebuild();
if (membership === "join") { if (membership === "join") {
// the user just joined a room, remove it from the suggested list if it was there // the user just joined a room, remove it from the suggested list if it was there
@ -845,10 +852,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<IState> {
break; break;
case Action.SwitchSpace: case Action.SwitchSpace:
if (payload.num === 0) { // 1 is Home, 2-9 are the spaces after Home
if (payload.num === 1) {
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 - 2]);
} }
break; break;

View file

@ -32,7 +32,7 @@ export class ListNotificationState extends NotificationState {
} }
public get symbol(): string { public get symbol(): string {
return null; // This notification state doesn't support symbols return this._color === NotificationColor.Unsent ? "!" : null;
} }
public setRooms(rooms: Room[]) { public setRooms(rooms: Room[]) {

View file

@ -21,4 +21,5 @@ export enum NotificationColor {
Bold, // no badge, show as unread Bold, // no badge, show as unread
Grey, // unread notified messages Grey, // unread notified messages
Red, // unread pings Red, // unread pings
Unsent, // some messages failed to send
} }

View file

@ -24,6 +24,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import * as RoomNotifs from '../../RoomNotifs'; import * as RoomNotifs from '../../RoomNotifs';
import * as Unread from '../../Unread'; import * as Unread from '../../Unread';
import { NotificationState } from "./NotificationState"; import { NotificationState } from "./NotificationState";
import { getUnsentMessages } from "../../components/structures/RoomStatusBar";
export class RoomNotificationState extends NotificationState implements IDestroyable { export class RoomNotificationState extends NotificationState implements IDestroyable {
constructor(public readonly room: Room) { constructor(public readonly room: Room) {
@ -32,6 +33,7 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.room.on("Room.timeline", this.handleRoomEventUpdate); this.room.on("Room.timeline", this.handleRoomEventUpdate);
this.room.on("Room.redaction", this.handleRoomEventUpdate); this.room.on("Room.redaction", this.handleRoomEventUpdate);
this.room.on("Room.myMembership", this.handleMembershipUpdate); this.room.on("Room.myMembership", this.handleMembershipUpdate);
this.room.on("Room.localEchoUpdated", this.handleLocalEchoUpdated);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate); MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("accountData", this.handleAccountDataUpdate); MatrixClientPeg.get().on("accountData", this.handleAccountDataUpdate);
this.updateNotificationState(); this.updateNotificationState();
@ -47,12 +49,17 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate); this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate); this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
this.room.removeListener("Room.myMembership", this.handleMembershipUpdate); this.room.removeListener("Room.myMembership", this.handleMembershipUpdate);
this.room.removeListener("Room.localEchoUpdated", this.handleLocalEchoUpdated);
if (MatrixClientPeg.get()) { if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate); MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
MatrixClientPeg.get().removeListener("accountData", this.handleAccountDataUpdate); MatrixClientPeg.get().removeListener("accountData", this.handleAccountDataUpdate);
} }
} }
private handleLocalEchoUpdated = () => {
this.updateNotificationState();
};
private handleReadReceipt = (event: MatrixEvent, room: Room) => { private handleReadReceipt = (event: MatrixEvent, room: Room) => {
if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore if (!readReceiptChangeIsFor(event, MatrixClientPeg.get())) return; // not our own - ignore
if (room.roomId !== this.room.roomId) return; // not for us - ignore if (room.roomId !== this.room.roomId) return; // not for us - ignore
@ -79,7 +86,12 @@ export class RoomNotificationState extends NotificationState implements IDestroy
private updateNotificationState() { private updateNotificationState() {
const snapshot = this.snapshot(); const snapshot = this.snapshot();
if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) { if (getUnsentMessages(this.room).length > 0) {
// When there are unsent messages we show a red `!`
this._color = NotificationColor.Unsent;
this._symbol = "!";
this._count = 1; // not used, technically
} else if (RoomNotifs.getRoomNotifsState(this.room.roomId) === RoomNotifs.MUTE) {
// When muted we suppress all notification states, even if we have context on them. // When muted we suppress all notification states, even if we have context on them.
this._color = NotificationColor.None; this._color = NotificationColor.None;
this._symbol = null; this._symbol = null;

View file

@ -31,7 +31,7 @@ export class SpaceNotificationState extends NotificationState {
} }
public get symbol(): string { public get symbol(): string {
return null; // This notification state doesn't support symbols return this._color === NotificationColor.Unsent ? "!" : null;
} }
public setRooms(rooms: Room[]) { public setRooms(rooms: Room[]) {
@ -54,7 +54,7 @@ export class SpaceNotificationState extends NotificationState {
} }
public getFirstRoomWithNotifications() { public getFirstRoomWithNotifications() {
return this.rooms.find((room) => room.getUnreadNotificationCount() > 0).roomId; return Object.values(this.states).find(state => state.color >= this.color)?.room.roomId;
} }
public destroy() { public destroy() {
@ -83,4 +83,3 @@ export class SpaceNotificationState extends NotificationState {
this.emitIfUpdated(snapshot); this.emitIfUpdated(snapshot);
} }
} }

View file

@ -136,18 +136,6 @@ describe("PosthogAnalytics", () => {
expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar"); expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar");
}); });
it("Should pass trackRoomEvent to posthog", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
const roomId = "42";
await analytics.trackRoomEvent<IRoomEvent>("jest_test_event", roomId, {
foo: "bar",
});
expect(fakePosthog.capture.mock.calls[0][0]).toBe("jest_test_event");
expect(fakePosthog.capture.mock.calls[0][1]["foo"]).toEqual("bar");
expect(fakePosthog.capture.mock.calls[0][1]["hashedRoomId"])
.toEqual("73475cb40a568e8da8a045ced110137e159f890ac4da883b6b17dc651b3a8049");
});
it("Should pass trackPseudonymousEvent() to posthog", async () => { it("Should pass trackPseudonymousEvent() to posthog", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous); analytics.setAnonymity(Anonymity.Pseudonymous);
await analytics.trackPseudonymousEvent<ITestEvent>("jest_test_pseudo_event", { await analytics.trackPseudonymousEvent<ITestEvent>("jest_test_pseudo_event", {
@ -173,9 +161,6 @@ describe("PosthogAnalytics", () => {
await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", { await analytics.trackAnonymousEvent<ITestEvent>("jest_test_event", {
foo: "bar", foo: "bar",
}); });
await analytics.trackRoomEvent<ITestRoomEvent>("room id", "foo", {
foo: "bar",
});
await analytics.trackPageView(200); await analytics.trackPageView(200);
expect(fakePosthog.capture.mock.calls.length).toBe(0); expect(fakePosthog.capture.mock.calls.length).toBe(0);
}); });
@ -183,31 +168,25 @@ describe("PosthogAnalytics", () => {
it("Should pseudonymise a location of a known screen", async () => { it("Should pseudonymise a location of a known screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous); "https://foo.bar", "#/register/some/pii", "/", Anonymity.Pseudonymous);
expect(location).toBe( expect(location).toBe("https://foo.bar/#/register/<redacted>");
`https://foo.bar/#/register/\
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
}); });
it("Should anonymise a location of a known screen", async () => { it("Should anonymise a location of a known screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous); "https://foo.bar", "#/register/some/pii", "/", Anonymity.Anonymous);
expect(location).toBe("https://foo.bar/#/register/<redacted>/<redacted>"); expect(location).toBe("https://foo.bar/#/register/<redacted>");
}); });
it("Should pseudonymise a location of an unknown screen", async () => { it("Should pseudonymise a location of an unknown screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous); "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Pseudonymous);
expect(location).toBe( expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>");
`https://foo.bar/#/<redacted_screen_name>/\
a6b46dd0d1ae5e86cbc8f37e75ceeb6760230c1ca4ffbcb0c97b96dd7d9c464b/\
bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
}); });
it("Should anonymise a location of an unknown screen", async () => { it("Should anonymise a location of an unknown screen", async () => {
const location = await getRedactedCurrentLocation( const location = await getRedactedCurrentLocation(
"https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous); "https://foo.bar", "#/not_a_screen_name/some/pii", "/", Anonymity.Anonymous);
expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>/<redacted>"); expect(location).toBe("https://foo.bar/#/<redacted_screen_name>/<redacted>");
}); });
it("Should handle an empty hash", async () => { it("Should handle an empty hash", async () => {
@ -218,15 +197,28 @@ bd75b3e080945674c0351f75e0db33d1e90986fa07b318ea7edf776f5eef38d4`);
it("Should identify the user to posthog if pseudonymous", async () => { it("Should identify the user to posthog if pseudonymous", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous); analytics.setAnonymity(Anonymity.Pseudonymous);
await analytics.identifyUser("foo"); class FakeClient {
expect(fakePosthog.identify.mock.calls[0][0]) getAccountDataFromServer = jest.fn().mockResolvedValue(null);
.toBe("2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"); setAccountData = jest.fn().mockResolvedValue({});
}
await analytics.identifyUser(new FakeClient(), () => "analytics_id" );
expect(fakePosthog.identify.mock.calls[0][0]).toBe("analytics_id");
}); });
it("Should not identify the user to posthog if anonymous", async () => { it("Should not identify the user to posthog if anonymous", async () => {
analytics.setAnonymity(Anonymity.Anonymous); analytics.setAnonymity(Anonymity.Anonymous);
await analytics.identifyUser("foo"); await analytics.identifyUser(null);
expect(fakePosthog.identify.mock.calls.length).toBe(0); expect(fakePosthog.identify.mock.calls.length).toBe(0);
}); });
it("Should identify using the server's analytics id if present", async () => {
analytics.setAnonymity(Anonymity.Pseudonymous);
class FakeClient {
getAccountDataFromServer = jest.fn().mockResolvedValue({ id: "existing_analytics_id" });
setAccountData = jest.fn().mockResolvedValue({});
}
await analytics.identifyUser(new FakeClient(), () => "new_analytics_id" );
expect(fakePosthog.identify.mock.calls[0][0]).toBe("existing_analytics_id");
});
}); });
}); });

1107
yarn.lock

File diff suppressed because it is too large Load diff