Merge remote-tracking branch 'upstream/develop' into fix/12652/screen-share

Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Šimon Brandner 2021-06-04 08:19:29 +02:00
commit 1d3bf91e83
No known key found for this signature in database
GPG key ID: 9760693FDD98A790
165 changed files with 6632 additions and 2938 deletions

View file

@ -30,6 +30,24 @@ module.exports = {
"quotes": "off", "quotes": "off",
"no-extra-boolean-cast": "off", "no-extra-boolean-cast": "off",
"no-restricted-properties": [
"error",
...buildRestrictedPropertiesOptions(
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead",
),
],
}, },
}], }],
}; };
function buildRestrictedPropertiesOptions(properties, message) {
return properties.map(prop => {
const [object, property] = prop.split(".");
return {
object,
property,
message,
};
});
}

View file

@ -1,3 +1,116 @@
Changes in [3.22.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0) (2021-05-24)
=====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.22.0-rc.1...v3.22.0)
* Upgrade to JS SDK 11.1.0
* [Release] Bump libolm version
[\#6087](https://github.com/matrix-org/matrix-react-sdk/pull/6087)
Changes in [3.22.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.22.0-rc.1) (2021-05-19)
===============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0...v3.22.0-rc.1)
* Upgrade to JS SDK 11.1.0-rc.1
* Translations update from Weblate
[\#6068](https://github.com/matrix-org/matrix-react-sdk/pull/6068)
* Show DMs in space for invited members too, to match Android impl
[\#6062](https://github.com/matrix-org/matrix-react-sdk/pull/6062)
* Support filtering by alias in add existing to space dialog
[\#6057](https://github.com/matrix-org/matrix-react-sdk/pull/6057)
* Fix issue when a room without a name or alias is marked as suggested
[\#6064](https://github.com/matrix-org/matrix-react-sdk/pull/6064)
* Fix space room hierarchy not updating when removing a room
[\#6055](https://github.com/matrix-org/matrix-react-sdk/pull/6055)
* Revert "Try putting room list handling behind a lock"
[\#6060](https://github.com/matrix-org/matrix-react-sdk/pull/6060)
* Stop assuming encrypted messages are decrypted ahead of time
[\#6052](https://github.com/matrix-org/matrix-react-sdk/pull/6052)
* Add error detail when languges fail to load
[\#6059](https://github.com/matrix-org/matrix-react-sdk/pull/6059)
* Add space invaders chat effect
[\#6053](https://github.com/matrix-org/matrix-react-sdk/pull/6053)
* Create SpaceProvider and hide Spaces from the RoomProvider autocompleter
[\#6051](https://github.com/matrix-org/matrix-react-sdk/pull/6051)
* Don't mark a room as unread when redacted event is present
[\#6049](https://github.com/matrix-org/matrix-react-sdk/pull/6049)
* Add support for MSC2873: Client information for Widgets
[\#6023](https://github.com/matrix-org/matrix-react-sdk/pull/6023)
* Support UI for MSC2762: Widgets reading events from rooms
[\#5960](https://github.com/matrix-org/matrix-react-sdk/pull/5960)
* Fix crash on opening notification panel
[\#6047](https://github.com/matrix-org/matrix-react-sdk/pull/6047)
* Remove custom LoggedInView::shouldComponentUpdate logic
[\#6046](https://github.com/matrix-org/matrix-react-sdk/pull/6046)
* Fix edge cases with the new add reactions prompt button
[\#6045](https://github.com/matrix-org/matrix-react-sdk/pull/6045)
* Add ids to homeserver and passphrase fields
[\#6043](https://github.com/matrix-org/matrix-react-sdk/pull/6043)
* Update space order field validity requirements to match msc update
[\#6042](https://github.com/matrix-org/matrix-react-sdk/pull/6042)
* Try putting room list handling behind a lock
[\#6024](https://github.com/matrix-org/matrix-react-sdk/pull/6024)
* Improve progress bar progression for smaller voice messages
[\#6035](https://github.com/matrix-org/matrix-react-sdk/pull/6035)
* Fix share space edge case where space is public but not invitable
[\#6039](https://github.com/matrix-org/matrix-react-sdk/pull/6039)
* Add missing 'rel' to image view download button
[\#6033](https://github.com/matrix-org/matrix-react-sdk/pull/6033)
* Improve visible waveform for voice messages
[\#6034](https://github.com/matrix-org/matrix-react-sdk/pull/6034)
* Fix roving tab index intercepting home/end in space create menu
[\#6040](https://github.com/matrix-org/matrix-react-sdk/pull/6040)
* Decorate room avatars with publicity in add existing to space flow
[\#6030](https://github.com/matrix-org/matrix-react-sdk/pull/6030)
* Improve Spaces "Just Me" wizard
[\#6025](https://github.com/matrix-org/matrix-react-sdk/pull/6025)
* Increase hover feedback on room sub list buttons
[\#6037](https://github.com/matrix-org/matrix-react-sdk/pull/6037)
* Show alternative button during space creation wizard if no rooms
[\#6029](https://github.com/matrix-org/matrix-react-sdk/pull/6029)
* Swap rotation buttons in the image viewer
[\#6032](https://github.com/matrix-org/matrix-react-sdk/pull/6032)
* Typo: initilisation -> initialisation
[\#5915](https://github.com/matrix-org/matrix-react-sdk/pull/5915)
* Save edited state of a message when switching rooms
[\#6001](https://github.com/matrix-org/matrix-react-sdk/pull/6001)
* Fix shield icon in Untrusted Device Dialog
[\#6022](https://github.com/matrix-org/matrix-react-sdk/pull/6022)
* Do not eagerly decrypt breadcrumb rooms
[\#6028](https://github.com/matrix-org/matrix-react-sdk/pull/6028)
* Update spaces.png
[\#6031](https://github.com/matrix-org/matrix-react-sdk/pull/6031)
* Encourage more diverse reactions to content
[\#6027](https://github.com/matrix-org/matrix-react-sdk/pull/6027)
* Wrap decodeURIComponent in try-catch to protect against malformed URIs
[\#6026](https://github.com/matrix-org/matrix-react-sdk/pull/6026)
* Iterate beta feedback dialog
[\#6021](https://github.com/matrix-org/matrix-react-sdk/pull/6021)
* Disable space fields whilst their form is busy
[\#6020](https://github.com/matrix-org/matrix-react-sdk/pull/6020)
* Add missing space on beta feedback dialog
[\#6018](https://github.com/matrix-org/matrix-react-sdk/pull/6018)
* Fix colours used for the back button in space create menu
[\#6017](https://github.com/matrix-org/matrix-react-sdk/pull/6017)
* Prioritise and reduce the amount of events decrypted on application startup
[\#5980](https://github.com/matrix-org/matrix-react-sdk/pull/5980)
* Linkify topics in space room directory results
[\#6015](https://github.com/matrix-org/matrix-react-sdk/pull/6015)
* Persistent space collapsed states
[\#5972](https://github.com/matrix-org/matrix-react-sdk/pull/5972)
* Catch another instance of unlabeled avatars.
[\#6010](https://github.com/matrix-org/matrix-react-sdk/pull/6010)
* Rescale and smooth voice message playback waveform to better match
expectation
[\#5996](https://github.com/matrix-org/matrix-react-sdk/pull/5996)
* Scale voice message clock with user's font size
[\#5993](https://github.com/matrix-org/matrix-react-sdk/pull/5993)
* Remove "in development" flag from voice messages
[\#5995](https://github.com/matrix-org/matrix-react-sdk/pull/5995)
* Support voice messages on Safari
[\#5989](https://github.com/matrix-org/matrix-react-sdk/pull/5989)
* Translations update from Weblate
[\#6011](https://github.com/matrix-org/matrix-react-sdk/pull/6011)
Changes in [3.21.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0) (2021-05-17) Changes in [3.21.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.21.0) (2021-05-17)
===================================================================================================== =====================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0-rc.1...v3.21.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v3.21.0-rc.1...v3.21.0)

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.21.0", "version": "3.22.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -121,6 +121,7 @@
"@babel/preset-typescript": "^7.12.7", "@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10", "@babel/register": "^7.12.10",
"@babel/traverse": "^7.12.12", "@babel/traverse": "^7.12.12",
"@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz",
"@peculiar/webcrypto": "^1.1.4", "@peculiar/webcrypto": "^1.1.4",
"@sinonjs/fake-timers": "^7.0.2", "@sinonjs/fake-timers": "^7.0.2",
"@types/classnames": "^2.2.11", "@types/classnames": "^2.2.11",
@ -161,7 +162,6 @@
"matrix-mock-request": "^1.2.3", "matrix-mock-request": "^1.2.3",
"matrix-react-test-utils": "^0.2.2", "matrix-react-test-utils": "^0.2.2",
"matrix-web-i18n": "github:matrix-org/matrix-web-i18n", "matrix-web-i18n": "github:matrix-org/matrix-web-i18n",
"olm": "https://packages.matrix.org/npm/olm/olm-3.2.1.tgz",
"react-test-renderer": "^16.14.0", "react-test-renderer": "^16.14.0",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"stylelint": "^13.9.0", "stylelint": "^13.9.0",

View file

@ -45,6 +45,8 @@ html {
N.B. Breaks things when we have legitimate horizontal overscroll */ N.B. Breaks things when we have legitimate horizontal overscroll */
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
// Stop similar overscroll bounce in Firefox Nightly for macOS
overscroll-behavior: none;
} }
body { body {
@ -289,6 +291,7 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
.mx_Dialog_staticWrapper .mx_Dialog { .mx_Dialog_staticWrapper .mx_Dialog {
z-index: 4010; z-index: 4010;
contain: content;
} }
.mx_Dialog_background { .mx_Dialog_background {

View file

@ -179,6 +179,7 @@
@import "./views/messages/_common_CryptoEvent.scss"; @import "./views/messages/_common_CryptoEvent.scss";
@import "./views/right_panel/_BaseCard.scss"; @import "./views/right_panel/_BaseCard.scss";
@import "./views/right_panel/_EncryptionInfo.scss"; @import "./views/right_panel/_EncryptionInfo.scss";
@import "./views/right_panel/_PinnedMessagesCard.scss";
@import "./views/right_panel/_RoomSummaryCard.scss"; @import "./views/right_panel/_RoomSummaryCard.scss";
@import "./views/right_panel/_UserInfo.scss"; @import "./views/right_panel/_UserInfo.scss";
@import "./views/right_panel/_VerificationPanel.scss"; @import "./views/right_panel/_VerificationPanel.scss";
@ -203,7 +204,6 @@
@import "./views/rooms/_NewRoomIntro.scss"; @import "./views/rooms/_NewRoomIntro.scss";
@import "./views/rooms/_NotificationBadge.scss"; @import "./views/rooms/_NotificationBadge.scss";
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PinnedEventsPanel.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReplyPreview.scss"; @import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss"; @import "./views/rooms/_RoomBreadcrumbs.scss";

View file

@ -38,6 +38,7 @@ limitations under the License.
position: absolute; position: absolute;
font-size: $font-14px; font-size: $font-14px;
z-index: 5001; z-index: 5001;
contain: content;
} }
.mx_ContextualMenu_right { .mx_ContextualMenu_right {
@ -115,8 +116,3 @@ limitations under the License.
border-top: 8px solid $menu-bg-color; border-top: 8px solid $menu-bg-color;
border-right: 8px solid transparent; border-right: 8px solid transparent;
} }
.mx_ContextualMenu_spinner {
display: block;
margin: 0 auto;
}

View file

@ -25,6 +25,7 @@ $roomListCollapsedWidth: 68px;
// Create a row-based flexbox for the GroupFilterPanel and the room list // Create a row-based flexbox for the GroupFilterPanel and the room list
display: flex; display: flex;
contain: content;
.mx_LeftPanel_GroupFilterPanelContainer { .mx_LeftPanel_GroupFilterPanelContainer {
flex-grow: 0; flex-grow: 0;
@ -70,6 +71,7 @@ $roomListCollapsedWidth: 68px;
// aligned correctly. This is also a row-based flexbox. // aligned correctly. This is also a row-based flexbox.
display: flex; display: flex;
align-items: center; align-items: center;
contain: content;
&.mx_IndicatorScrollbar_leftOverflow { &.mx_IndicatorScrollbar_leftOverflow {
mask-image: linear-gradient(90deg, transparent, black 5%); mask-image: linear-gradient(90deg, transparent, black 5%);

View file

@ -25,6 +25,7 @@ limitations under the License.
padding: 4px 0; padding: 4px 0;
box-sizing: border-box; box-sizing: border-box;
height: 100%; height: 100%;
contain: strict;
.mx_RoomView_MessageList { .mx_RoomView_MessageList {
padding: 14px 18px; // top and bottom is 4px smaller to balance with the padding set above padding: 14px 18px; // top and bottom is 4px smaller to balance with the padding set above
@ -98,6 +99,48 @@ limitations under the License.
mask-position: center; mask-position: center;
} }
$dot-size: 8px;
$pulse-color: $pinned-unread-color;
.mx_RightPanel_pinnedMessagesButton {
&::before {
mask-image: url('$(res)/img/element-icons/room/pin.svg');
mask-position: center;
}
.mx_RightPanel_pinnedMessagesButton_unreadIndicator {
position: absolute;
right: 0;
top: 0;
margin: 4px;
width: $dot-size;
height: $dot-size;
border-radius: 50%;
transform: scale(1);
background: rgba($pulse-color, 1);
box-shadow: 0 0 0 0 rgba($pulse-color, 1);
animation: mx_RightPanel_indicator_pulse 2s infinite;
animation-iteration-count: 1;
}
}
@keyframes mx_RightPanel_indicator_pulse {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 10px rgba($pulse-color, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba($pulse-color, 0);
}
}
.mx_RightPanel_headerButton_highlight { .mx_RightPanel_headerButton_highlight {
&::before { &::before {
background-color: $accent-color !important; background-color: $accent-color !important;

View file

@ -61,6 +61,39 @@ limitations under the License.
.mx_RoomDirectory_tableWrapper { .mx_RoomDirectory_tableWrapper {
overflow-y: auto; overflow-y: auto;
flex: 1 1 0; flex: 1 1 0;
.mx_RoomDirectory_footer {
margin-top: 24px;
text-align: center;
> h5 {
margin: 0;
font-weight: $font-semi-bold;
font-size: $font-15px;
line-height: $font-18px;
color: $primary-fg-color;
}
> p {
margin: 40px auto 60px;
font-size: $font-14px;
line-height: $font-20px;
color: $secondary-fg-color;
max-width: 464px; // easier reading
}
> hr {
margin: 0;
border: none;
height: 1px;
background-color: $header-panel-bg-color;
}
.mx_RoomDirectory_newRoom {
margin: 24px auto 0;
width: max-content;
}
}
} }
.mx_RoomDirectory_table { .mx_RoomDirectory_table {
@ -138,11 +171,6 @@ limitations under the License.
color: $settings-grey-fg-color; color: $settings-grey-fg-color;
} }
.mx_RoomDirectory_table tr {
padding-bottom: 10px;
cursor: pointer;
}
.mx_RoomDirectory .mx_RoomView_MessageList { .mx_RoomDirectory .mx_RoomView_MessageList {
padding: 0; padding: 0;
} }

View file

@ -152,6 +152,7 @@ limitations under the License.
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
contain: content;
} }
.mx_RoomView_statusArea { .mx_RoomView_statusArea {
@ -237,6 +238,7 @@ hr.mx_RoomView_myReadMarker {
position: relative; position: relative;
top: -1px; top: -1px;
z-index: 1; z-index: 1;
will-change: width;
transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s; transition: width 400ms easeinsine 1s, opacity 400ms easeinsine 1s;
width: 99%; width: 99%;
opacity: 1; opacity: 1;

View file

@ -21,5 +21,8 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
content-visibility: auto;
contain-intrinsic-size: 50px;
} }
} }

View file

@ -328,6 +328,7 @@ $SpaceRoomViewInnerWidth: 428px;
font-size: $font-15px; font-size: $font-15px;
margin-top: 12px; margin-top: 12px;
margin-bottom: 16px; margin-bottom: 16px;
white-space: pre;
} }
> hr { > hr {

View file

@ -16,6 +16,7 @@ limitations under the License.
.mx_DecoratedRoomAvatar, .mx_ExtraTile { .mx_DecoratedRoomAvatar, .mx_ExtraTile {
position: relative; position: relative;
contain: content;
&.mx_DecoratedRoomAvatar_cutout .mx_BaseAvatar { &.mx_DecoratedRoomAvatar_cutout .mx_BaseAvatar {
mask-image: url('$(res)/img/element-icons/roomlist/decorated-avatar-mask.svg'); mask-image: url('$(res)/img/element-icons/roomlist/decorated-avatar-mask.svg');

View file

@ -18,7 +18,11 @@ limitations under the License.
display: inline; display: inline;
} }
.mx_InlineSpinner_spin img { .mx_InlineSpinner img, .mx_InlineSpinner_icon {
margin: 0px 6px; margin: 0px 6px;
vertical-align: -3px; vertical-align: -3px;
} }
.mx_InlineSpinner_icon {
display: inline-block;
}

View file

@ -28,8 +28,7 @@ limitations under the License.
top: 0; top: 0;
} }
&::before, &::after { .mx_MiniAvatarUploader_indicator {
content: '';
position: absolute; position: absolute;
height: 26px; height: 26px;
@ -37,27 +36,22 @@ limitations under the License.
right: -6px; right: -6px;
bottom: -6px; bottom: -6px;
}
&::before {
background-color: $primary-bg-color; background-color: $primary-bg-color;
border-radius: 50%; border-radius: 50%;
z-index: 1; z-index: 1;
}
&::after { .mx_MiniAvatarUploader_cameraIcon {
background-color: $secondary-fg-color; height: 100%;
mask-position: center; width: 100%;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/element-icons/camera.svg');
mask-size: 16px;
z-index: 2;
}
&.mx_MiniAvatarUploader_busy::after { background-color: $secondary-fg-color;
background: url("$(res)/img/spinner.gif") no-repeat center; mask-position: center;
background-size: 80%; mask-repeat: no-repeat;
mask: unset; mask-image: url('$(res)/img/element-icons/camera.svg');
mask-size: 16px;
z-index: 2;
}
} }
} }

View file

@ -26,3 +26,19 @@ limitations under the License.
.mx_MatrixChat_middlePanel .mx_Spinner { .mx_MatrixChat_middlePanel .mx_Spinner {
height: auto; height: auto;
} }
@keyframes spin {
from {
transform: rotateZ(0deg);
}
to {
transform: rotateZ(360deg);
}
}
.mx_Spinner_icon {
background-color: $primary-fg-color;
mask: url('$(res)/img/spinner.svg');
mask-size: contain;
animation: 1.1s steps(12, end) infinite spin;
}

View file

@ -20,11 +20,12 @@ limitations under the License.
visibility: hidden; visibility: hidden;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
height: 24px; height: 32px;
line-height: $font-24px; line-height: $font-24px;
border-radius: 4px; border-radius: 8px;
background: $message-action-bar-bg-color; background: $primary-bg-color;
top: -26px; border: 1px solid $input-border-color;
top: -32px;
right: 8px; right: 8px;
user-select: none; user-select: none;
// Ensure the action bar appears above over things, like the read marker. // Ensure the action bar appears above over things, like the read marker.
@ -51,31 +52,19 @@ limitations under the License.
white-space: nowrap; white-space: nowrap;
display: inline-block; display: inline-block;
position: relative; position: relative;
border: 1px solid $message-action-bar-border-color; margin: 2px;
margin-left: -1px;
&:hover { &:hover {
border-color: $message-action-bar-hover-border-color; background: $roomlist-button-bg-color;
border-radius: 6px;
z-index: 1; z-index: 1;
} }
&:first-child {
border-radius: 3px 0 0 3px;
}
&:last-child {
border-radius: 0 3px 3px 0;
}
&:only-child {
border-radius: 3px;
}
} }
} }
.mx_MessageActionBar_maskButton { .mx_MessageActionBar_maskButton {
width: 27px; width: 28px;
height: 28px;
} }
.mx_MessageActionBar_maskButton::after { .mx_MessageActionBar_maskButton::after {
@ -88,7 +77,11 @@ limitations under the License.
mask-size: 18px; mask-size: 18px;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-position: center; mask-position: center;
background-color: $message-action-bar-fg-color; background-color: $secondary-fg-color;
}
.mx_MessageActionBar_maskButton:hover::after {
background-color: $primary-fg-color;
} }
.mx_MessageActionBar_reactButton::after { .mx_MessageActionBar_reactButton::after {

View file

@ -0,0 +1,35 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_PinnedMessagesCard {
padding-top: 0;
.mx_BaseCard_header {
text-align: center;
margin-top: 0;
border-bottom: 1px solid $menu-border-color;
> h2 {
font-weight: $font-semi-bold;
font-size: $font-18px;
margin: 8px 0;
}
.mx_BaseCard_close {
margin-right: 6px;
}
}
}

View file

@ -104,7 +104,7 @@ $left-gutter: 64px;
.mx_EventTile_line, .mx_EventTile_reply { .mx_EventTile_line, .mx_EventTile_reply {
position: relative; position: relative;
padding-left: $left-gutter; padding-left: $left-gutter;
border-radius: 4px; border-radius: 8px;
} }
.mx_RoomView_timeline_rr_enabled, .mx_RoomView_timeline_rr_enabled,
@ -280,6 +280,7 @@ $left-gutter: 64px;
height: $font-14px; height: $font-14px;
width: $font-14px; width: $font-14px;
will-change: left, top;
transition: transition:
left var(--transition-short) ease-out, left var(--transition-short) ease-out,
top var(--transition-standard) ease-out; top var(--transition-standard) ease-out;

View file

@ -115,8 +115,7 @@ $irc-line-height: $font-18px;
.mx_EventTile_line { .mx_EventTile_line {
.mx_EventTile_e2eIcon, .mx_EventTile_e2eIcon,
.mx_TextualEvent, .mx_TextualEvent,
.mx_MTextBody, .mx_MTextBody {
.mx_ReplyThread_wrapper_empty {
display: inline-block; display: inline-block;
} }
} }
@ -177,16 +176,13 @@ $irc-line-height: $font-18px;
.mx_SenderProfile_hover { .mx_SenderProfile_hover {
background-color: $primary-bg-color; background-color: $primary-bg-color;
overflow: hidden; overflow: hidden;
display: flex;
> span { > .mx_SenderProfile_name {
display: flex; overflow: hidden;
text-overflow: ellipsis;
> .mx_SenderProfile_name { min-width: var(--name-width);
overflow: hidden; text-align: end;
text-overflow: ellipsis;
min-width: var(--name-width);
text-align: end;
}
} }
} }

View file

@ -52,6 +52,7 @@ limitations under the License.
.mx_JumpToBottomButton_scrollDown { .mx_JumpToBottomButton_scrollDown {
position: relative; position: relative;
display: block;
height: 38px; height: 38px;
border-radius: 19px; border-radius: 19px;
box-sizing: border-box; box-sizing: border-box;

View file

@ -18,8 +18,8 @@ limitations under the License.
margin: 40px 0 48px 64px; margin: 40px 0 48px 64px;
.mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) { .mx_MiniAvatarUploader_hasAvatar:not(.mx_MiniAvatarUploader_busy):not(:hover) {
&::before, &::after { .mx_MiniAvatarUploader_indicator {
content: unset; display: none;
} }
} }

View file

@ -16,62 +16,91 @@ limitations under the License.
.mx_PinnedEventTile { .mx_PinnedEventTile {
min-height: 40px; min-height: 40px;
margin-bottom: 5px;
width: 100%; width: 100%;
border-radius: 5px; // for the hover padding: 0 4px 12px;
}
.mx_PinnedEventTile:hover { display: grid;
background-color: $event-selected-color; grid-template-areas:
} "avatar name remove"
"content content content"
"footer footer footer";
grid-template-rows: max-content auto max-content;
grid-template-columns: 24px auto 24px;
grid-row-gap: 12px;
grid-column-gap: 8px;
.mx_PinnedEventTile .mx_PinnedEventTile_sender, & + .mx_PinnedEventTile {
.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { padding: 12px 4px;
color: #868686; border-top: 1px solid $menu-border-color;
font-size: 0.8em; }
vertical-align: top;
display: inline-block;
padding-bottom: 3px;
}
.mx_PinnedEventTile .mx_PinnedEventTile_timestamp { .mx_PinnedEventTile_senderAvatar {
padding-left: 15px; grid-area: avatar;
display: none; }
}
.mx_PinnedEventTile .mx_PinnedEventTile_senderAvatar .mx_BaseAvatar { .mx_PinnedEventTile_sender {
float: left; grid-area: name;
margin-right: 10px; font-weight: $font-semi-bold;
} font-size: $font-15px;
line-height: $font-24px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_PinnedEventTile_actions { .mx_PinnedEventTile_unpinButton {
float: right; visibility: hidden;
margin-right: 10px; grid-area: remove;
display: none; position: relative;
} width: 24px;
height: 24px;
border-radius: 8px;
.mx_PinnedEventTile:hover .mx_PinnedEventTile_timestamp { &:hover {
display: inline-block; background-color: $roomheader-addroom-bg-color;
} }
.mx_PinnedEventTile:hover .mx_PinnedEventTile_actions { &::before {
display: block; content: "";
} position: absolute;
//top: 0;
//left: 0;
height: inherit;
width: inherit;
background: $secondary-fg-color;
mask-position: center;
mask-size: 8px;
mask-repeat: no-repeat;
mask-image: url('$(res)/img/image-view/close.svg');
}
}
.mx_PinnedEventTile_unpinButton { .mx_PinnedEventTile_message {
display: inline-block; grid-area: content;
cursor: pointer; }
margin-left: 10px;
}
.mx_PinnedEventTile_gotoButton { .mx_PinnedEventTile_footer {
display: inline-block; grid-area: footer;
font-size: 0.7em; // Smaller text to avoid conflicting with the layout font-size: 10px;
} line-height: 12px;
.mx_PinnedEventTile_message { .mx_PinnedEventTile_timestamp {
margin-left: 50px; font-size: inherit;
position: relative; line-height: inherit;
top: 0; color: $secondary-fg-color;
left: 0; }
.mx_AccessibleButton_kind_link {
padding: 0;
margin-left: 12px;
font-size: inherit;
line-height: inherit;
}
}
&:hover {
.mx_PinnedEventTile_unpinButton {
visibility: visible;
}
}
} }

View file

@ -32,14 +32,14 @@ limitations under the License.
// first triggering the enter state with the newest breadcrumb off screen (-40px) then // first triggering the enter state with the newest breadcrumb off screen (-40px) then
// sliding it into view. // sliding it into view.
&.mx_RoomBreadcrumbs-enter { &.mx_RoomBreadcrumbs-enter {
margin-left: -40px; // 32px for the avatar, 8px for the margin transform: translateX(-40px); // 32px for the avatar, 8px for the margin
} }
&.mx_RoomBreadcrumbs-enter-active { &.mx_RoomBreadcrumbs-enter-active {
margin-left: 0; transform: translateX(0);
// Timing function is as-requested by design. // Timing function is as-requested by design.
// NOTE: The transition time MUST match the value passed to CSSTransition! // NOTE: The transition time MUST match the value passed to CSSTransition!
transition: margin-left 640ms cubic-bezier(0.66, 0.02, 0.36, 1); transition: transform 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
} }
.mx_RoomBreadcrumbs_placeholder { .mx_RoomBreadcrumbs_placeholder {

View file

@ -277,24 +277,6 @@ limitations under the License.
margin-top: 18px; margin-top: 18px;
} }
.mx_RoomHeader_pinnedButton::before {
mask-image: url('$(res)/img/element-icons/room/pin.svg');
}
.mx_RoomHeader_pinsIndicator {
position: absolute;
right: 0;
bottom: 4px;
width: 8px;
height: 8px;
border-radius: 8px;
background-color: $pinned-color;
}
.mx_RoomHeader_pinsIndicatorUnread {
background-color: $pinned-unread-color;
}
@media only screen and (max-width: 480px) { @media only screen and (max-width: 480px) {
.mx_RoomHeader_wrapper { .mx_RoomHeader_wrapper {
padding: 0; padding: 0;

View file

@ -61,8 +61,8 @@ limitations under the License.
&.mx_RoomSublist_headerContainer_sticky { &.mx_RoomSublist_headerContainer_sticky {
position: fixed; position: fixed;
height: 32px; // to match the header container height: 32px; // to match the header container
// width set by JS // width set by JS because of a compat issue between Firefox and Chrome
width: calc(100% - 22px); width: calc(100% - 15px);
} }
// We don't have a top style because the top is dependent on the room list header's // We don't have a top style because the top is dependent on the room list header's
@ -198,6 +198,7 @@ limitations under the License.
// as the box model should be top aligned. Happens in both FF and Chromium // as the box model should be top aligned. Happens in both FF and Chromium
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-self: stretch;
mask-image: linear-gradient(0deg, transparent, black 4px); mask-image: linear-gradient(0deg, transparent, black 4px);
} }

View file

@ -19,6 +19,10 @@ limitations under the License.
margin-bottom: 4px; margin-bottom: 4px;
padding: 4px; padding: 4px;
contain: content; // Not strict as it will break when resizing a sublist vertically
height: 40px;
box-sizing: border-box;
// The tile is also a flexbox row itself // The tile is also a flexbox row itself
display: flex; display: flex;

View file

@ -1,7 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18.5151 20.0831L15.6941 17.2621L17.2621 15.6941L20.0831 18.5151C21.5741 20.0061 22.1529 21.7793 21.9661 21.9661C21.7793 22.1529 20.0061 21.5741 18.5151 20.0831Z" fill="black"/> <path d="M18.5151 20.0831L15.6941 17.2621L17.2621 15.6941L20.0831 18.5151C21.5741 20.0061 22.1529 21.7793 21.9661 21.9661C21.7793 22.1529 20.0061 21.5741 18.5151 20.0831Z" fill="#737D8C"/>
<path d="M7.46196 11.3821C7.07677 11.5059 5.49073 12.0989 3.63366 12.0744C1.77658 12.0499 1.67795 10.8941 2.46811 10.1039L6.28598 6.28602L9.42196 9.42203L7.46196 11.3821Z" fill="black"/> <path d="M7.46196 11.3821C7.07677 11.5059 5.49073 12.0989 3.63366 12.0744C1.77658 12.0499 1.67795 10.8941 2.46811 10.1039L6.28598 6.28602L9.42196 9.42203L7.46196 11.3821Z" fill="#737D8C"/>
<path d="M11.3821 7.46202C11.5059 7.07682 12.0989 5.49077 12.0744 3.63368C12.0499 1.77658 10.8941 1.67795 10.1039 2.46812L6.28598 6.28602L9.42196 9.42203L11.3821 7.46202Z" fill="black"/> <path d="M11.3821 7.46202C11.5059 7.07682 12.0989 5.49077 12.0744 3.63368C12.0499 1.77658 10.8941 1.67795 10.1039 2.46812L6.28598 6.28602L9.42196 9.42203L11.3821 7.46202Z" fill="#737D8C"/>
<path d="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" fill="black"/> <path d="M7.40596 11.438L11.4379 7.40602L14.9099 10.206L10.2059 14.9101L7.40596 11.438Z" fill="#737D8C"/>
<path d="M11.774 11.774C9.31114 14.2369 8.61779 17.7115 9.83827 20.3213C10.3104 21.3308 11.6288 21.3273 12.4169 20.5392L20.5391 12.4169C21.3271 11.6289 21.3307 10.3104 20.3212 9.83829C17.7114 8.61779 14.2369 9.31115 11.774 11.774Z" fill="black"/> <path d="M11.774 11.774C9.31114 14.2369 8.61779 17.7115 9.83827 20.3213C10.3104 21.3308 11.6288 21.3273 12.4169 20.5392L20.5391 12.4169C21.3271 11.6289 21.3307 10.3104 20.3212 9.83829C17.7114 8.61779 14.2369 9.31115 11.774 11.774Z" fill="#737D8C"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1,015 B

After

Width:  |  Height:  |  Size: 1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

View file

@ -1,141 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink= "http://www.w3.org/1999/xlink" preserveAspectRatio="none" viewBox="0 0 375 375" style="background-color:#FFFFFF00; overflow:visible"> <svg
<title>start</title> xmlns:dc="http://purl.org/dc/elements/1.1/"
<!-- Layers --> xmlns:cc="http://creativecommons.org/ns#"
<!-- Layer: Icon --> xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
<svg x="188" y="187" width="0.01" height="0.01" style ="overflow:visible" opacity="1"> xmlns:svg="http://www.w3.org/2000/svg"
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 1 1;0 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="188;187.75;187.5"/> xmlns="http://www.w3.org/2000/svg"
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 1 1;0 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="187;187.25;187.5"/> xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
<g transform="scale(1 1)"> xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
<g transform="rotate(0)"> width="128"
<animateTransform attributeName="transform" calcMode="spline" dur="2" fill="freeze" keySplines="0.42 0 1 1;0 0 0.58 1" keyTimes="0;0.5;1" height="128"
repeatCount="indefinite" type="rotate" values="0;180;360"/> viewBox="0 0 33.866666 33.866668"
<svg x="-100" y="-100" width="200" height="200" style ="overflow:visible" opacity="1"> version="1.1"
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 1 1;0 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-100;-117.5;-100"/> id="svg920"
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 1 1;0 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-100;-117.5;-100"/> inkscape:version="1.0.2 (e86c870879, 2021-01-15)"
<animate attributeName="width" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 1 1;0 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="200;235;200"/> sodipodi:docname="spinner.svg">
<animate attributeName="height" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 1 1;0 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="200;235;200"/> <defs
<g clip-path=""> id="defs914" />
<g filter=""> <metadata
<!-- Layer: 1024@2x --> id="metadata917">
<svg x="100" y="100" width="0.01" height="0.01" style ="overflow:visible" opacity="1"> <rdf:RDF>
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="100;117.5;100"/> <cc:Work
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="100;117.5;100"/> rdf:about="">
<g transform="scale(1 1)"> <dc:format>image/svg+xml</dc:format>
<g transform="rotate(0)"> <dc:type
<svg x="-100" y="-100" width="200" height="200" style ="overflow:visible" opacity="1"> rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-100;-117.5;-100"/> <dc:title />
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-100;-117.5;-100"/> </cc:Work>
<animate attributeName="width" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="200;235;200"/> </rdf:RDF>
<animate attributeName="height" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="200;235;200"/> </metadata>
<g clip-path=""> <g
<g filter=""> inkscape:label="Layer 1"
<!-- Layer: Path --> inkscape:groupmode="layer"
<svg x="118" y="46" width="0.01" height="0.01" style ="overflow:visible" opacity="1"> id="layer1">
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="118;138.65;118"/> <path
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="46;54.05;46"/> style="stroke-width:0;fill-opacity:0.30000001"
<g transform="scale(1 1)"> d="M 59,95.605469 V 123 c 0,2.77 2.23,5 5,5 2.77,0 5,-2.23 5,-5 V 95.605469 A 31.999998,31.999998 0 0 1 64,96 31.999998,31.999998 0 0 1 59,95.605469 Z"
<g transform="rotate(0)"> transform="scale(0.26458333)"
<svg x="-46" y="-46" width="92" height="92" style ="overflow:visible" opacity="1"> id="path2350" />
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/> <path
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/> style="stroke-width:0;fill-opacity:0.7020452"
<animate attributeName="width" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/> d="M 64,0 C 61.23,0 59,2.2300001 59,5 V 32.394531 A 31.999998,31.999998 0 0 1 64,32 31.999998,31.999998 0 0 1 69,32.394531 V 5 C 69,2.2300001 66.77,0 64,0 Z"
<animate attributeName="height" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/> transform="scale(0.26458333)"
<g clip-path=""> id="rect2283" />
<g filter=""> <path
<path d="M0,12c0,-6.627,5.373,-12,12,-12 44.183,0,80,35.817,80,80 0,6.627,-5.373,12,-12,12 -6.627,0,-12,-5.373,-12,-12 0,-30.928,-25.072,-56,-56,-56 -6.627,0,-12,-5.373,-12,-12zM0,12" fill="#0DBD8B" id="path" stroke="#00000000" stroke-dasharray="0" stroke-dashoffset="0" stroke-miterlimit="10" stroke-width="0"> style="stroke-width:0;fill-opacity:0.30000001"
<animate attributeName="d" calcMode="spline" dur="2s" fill="freeze" href="#path" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="M0,12c0,-6.627,5.373,-12,12,-12 44.183,0,80,35.817,80,80 0,6.627,-5.373,12,-12,12 -6.627,0,-12,-5.373,-12,-12 0,-30.928,-25.072,-56,-56,-56 -6.627,0,-12,-5.373,-12,-12zM0,12;M0,14.1c0,-7.787,6.313,-14.1,14.1,-14.1 51.915,0,94,42.085,94,94 0,7.787,-6.313,14.1,-14.1,14.1 -7.787,0,-14.1,-6.313,-14.1,-14.1 0,-36.34,-29.46,-65.8,-65.8,-65.8 -7.787,0,-14.1,-6.313,-14.1,-14.1zM0,14.1;M0,12c0,-6.627,5.373,-12,12,-12 44.183,0,80,35.817,80,80 0,6.627,-5.373,12,-12,12 -6.627,0,-12,-5.373,-12,-12 0,-30.928,-25.072,-56,-56,-56 -6.627,0,-12,-5.373,-12,-12zM0,12"/> d="M 43.867188,88.871094 30.169922,112.5957 c -1.385,2.39889 -0.568812,5.44508 1.830078,6.83008 2.39889,1.385 5.445078,0.56881 6.830078,-1.83008 L 52.527344,93.873047 a 31.999998,31.999998 0 0 1 -8.660156,-5.001953 z"
</path> transform="scale(0.26458333)"
id="path2346" />
</g> <path
</g> style="stroke-width:0;fill-opacity:0.80019373"
</svg> d="m 93.150391,7.9121094 c -1.599848,0.111837 -3.114844,0.992881 -3.980469,2.4921876 L 75.472656,34.126953 a 31.999998,31.999998 0 0 1 8.660156,5.001953 L 97.830078,15.404297 C 99.215078,13.005407 98.39889,9.9592187 96,8.5742188 95.100416,8.0548438 94.110299,7.8450072 93.150391,7.9121094 Z"
</g> transform="scale(0.26458333)"
</g> id="rect2285" />
</svg> <path
<!-- Layer: Path --> style="stroke-width:0;fill-opacity:0.30000001"
<svg x="82" y="154" width="0.01" height="0.01" style ="overflow:visible" opacity="1"> d="M 34.126953,75.474609 10.404297,89.169922 C 8.0054066,90.554922 7.1892188,93.60111 8.5742188,96 c 1.3849999,2.39889 4.4311882,3.215078 6.8300782,1.830078 L 39.128906,84.132812 a 31.999998,31.999998 0 0 1 -5.001953,-8.658203 z"
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="82;96.35;82"/> transform="scale(0.26458333)"
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="154;180.95;154"/> id="path2342" />
<g transform="scale(1 1)"> <path
<g transform="rotate(0)"> style="stroke-width:0;fill-opacity:0.90226436"
<svg x="-46" y="-46" width="92" height="92" style ="overflow:visible" opacity="1"> d="m 115.44531,29.507812 c -0.95991,-0.0671 -1.95002,0.142735 -2.84961,0.66211 L 88.871094,43.867188 a 31.999998,31.999998 0 0 1 5.001953,8.658203 L 117.5957,38.830078 c 2.39889,-1.385 3.21508,-4.431188 1.83008,-6.830078 -0.86562,-1.499306 -2.38062,-2.38035 -3.98047,-2.492188 z"
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/> transform="scale(0.26458333)"
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/> id="rect2287" />
<animate attributeName="width" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/> <path
<animate attributeName="height" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/> style="stroke-width:0;fill-opacity:1"
<g clip-path=""> d="M 95.605469,59 A 31.999998,31.999998 0 0 1 96,64 31.999998,31.999998 0 0 1 95.605469,69 H 123 c 2.77,0 5,-2.23 5,-5 0,-2.77 -2.23,-5 -5,-5 z"
<g filter=""> transform="scale(0.26458333)"
<path d="M92,80c0,6.627,-5.373,12,-12,12 -44.183,0,-80,-35.817,-80,-80 0,-6.627,5.373,-12,12,-12 6.627,0,12,5.373,12,12 0,30.928,25.072,56,56,56 6.627,0,12,5.373,12,12zM92,80" fill="#0DBD8B" id="path_1" stroke="#00000000" stroke-dasharray="0" stroke-dashoffset="0" stroke-miterlimit="10" stroke-width="0"> id="path2338" />
<animate attributeName="d" calcMode="spline" dur="2s" fill="freeze" href="#path_1" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="M92,80c0,6.627,-5.373,12,-12,12 -44.183,0,-80,-35.817,-80,-80 0,-6.627,5.373,-12,12,-12 6.627,0,12,5.373,12,12 0,30.928,25.072,56,56,56 6.627,0,12,5.373,12,12zM92,80;M108.1,94c0,7.787,-6.313,14.1,-14.1,14.1 -51.915,0,-94,-42.085,-94,-94 0,-7.787,6.313,-14.1,14.1,-14.1 7.787,0,14.1,6.313,14.1,14.1 0,36.34,29.46,65.8,65.8,65.8 7.787,0,14.1,6.313,14.1,14.1zM108.1,94;M92,80c0,6.627,-5.373,12,-12,12 -44.183,0,-80,-35.817,-80,-80 0,-6.627,5.373,-12,12,-12 6.627,0,12,5.373,12,12 0,30.928,25.072,56,56,56 6.627,0,12,5.373,12,12zM92,80"/> <path
</path> style="stroke-width:0;fill-opacity:0.40288368"
d="m 5,59 c -2.7699999,0 -5,2.23 -5,5 0,2.77 2.2300001,5 5,5 H 32.394531 A 31.999998,31.999998 0 0 1 32,64 31.999998,31.999998 0 0 1 32.394531,59 Z"
</g> transform="scale(0.26458333)"
</g> id="rect2289" />
</svg> <path
</g> style="stroke-width:0;fill-opacity:0.30000001"
</g> d="m 93.873047,75.472656 a 31.999998,31.999998 0 0 1 -5.001953,8.660156 L 112.5957,97.830078 c 2.39889,1.385 5.44508,0.568812 6.83008,-1.830078 1.385,-2.39889 0.56881,-5.445078 -1.83008,-6.830078 z"
</svg> transform="scale(0.26458333)"
<!-- Layer: Path --> id="path2334" />
<svg x="46" y="82" width="0.01" height="0.01" style ="overflow:visible" opacity="1"> <path
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="46;54.05;46"/> style="stroke-width:0;fill-opacity:0.49898377"
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="82;96.35;82"/> d="M 12.554688,29.507812 C 10.95484,29.61965 9.4398437,30.500694 8.5742188,32 c -1.385,2.39889 -0.5688122,5.445078 1.8300782,6.830078 l 23.722656,13.697266 a 31.999998,31.999998 0 0 1 5.001953,-8.660156 L 15.404297,30.169922 c -0.899584,-0.519375 -1.889701,-0.729212 -2.849609,-0.66211 z"
<g transform="scale(1 1)"> transform="scale(0.26458333)"
<g transform="rotate(0)"> id="rect2291" />
<svg x="-46" y="-46" width="92" height="92" style ="overflow:visible" opacity="1"> <path
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/> style="stroke-width:0;fill-opacity:0.30000001"
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/> d="m 84.132812,88.871094 a 31.999998,31.999998 0 0 1 -8.658203,5.001953 L 89.169922,117.5957 c 1.385,2.39889 4.431188,3.21508 6.830078,1.83008 2.39889,-1.385 3.215078,-4.43119 1.830078,-6.83008 z"
<animate attributeName="width" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/> transform="scale(0.26458333)"
<animate attributeName="height" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/> id="path2330" />
<g clip-path=""> <path
<g filter=""> style="stroke-width:0;fill-opacity:0.5998317"
<path d="M12,92c-6.627,0,-12,-5.373,-12,-12 0,-44.183,35.817,-80,80,-80 6.627,0,12,5.373,12,12 0,6.627,-5.373,12,-12,12 -30.928,0,-56,25.072,-56,56 0,6.627,-5.373,12,-12,12zM12,92" fill="#0DBD8B" id="path_2" stroke="#00000000" stroke-dasharray="0" stroke-dashoffset="0" stroke-miterlimit="10" stroke-width="0"> d="M 34.849609,7.9121094 C 33.889701,7.8450072 32.899584,8.0548438 32,8.5742188 29.60111,9.9592187 28.784922,13.005407 30.169922,15.404297 l 13.697266,23.724609 a 31.999998,31.999998 0 0 1 8.658203,-5.001953 L 38.830078,10.404297 C 37.964453,8.9049904 36.449457,8.0239464 34.849609,7.9121094 Z"
<animate attributeName="d" calcMode="spline" dur="2s" fill="freeze" href="#path_2" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="M12,92c-6.627,0,-12,-5.373,-12,-12 0,-44.183,35.817,-80,80,-80 6.627,0,12,5.373,12,12 0,6.627,-5.373,12,-12,12 -30.928,0,-56,25.072,-56,56 0,6.627,-5.373,12,-12,12zM12,92;M14.1,108.1c-7.787,0,-14.1,-6.313,-14.1,-14.1 0,-51.915,42.085,-94,94,-94 7.787,0,14.1,6.313,14.1,14.1 0,7.787,-6.313,14.1,-14.1,14.1 -36.34,0,-65.8,29.46,-65.8,65.8 0,7.787,-6.313,14.1,-14.1,14.1zM14.1,108.1;M12,92c-6.627,0,-12,-5.373,-12,-12 0,-44.183,35.817,-80,80,-80 6.627,0,12,5.373,12,12 0,6.627,-5.373,12,-12,12 -30.928,0,-56,25.072,-56,56 0,6.627,-5.373,12,-12,12zM12,92"/> transform="scale(0.26458333)"
</path> id="rect2293" />
</g>
</g>
</g>
</svg>
</g>
</g>
</svg>
<!-- Layer: Path -->
<svg x="154" y="118" width="0.01" height="0.01" style ="overflow:visible" opacity="1">
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="154;180.95;154"/>
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="118;138.65;118"/>
<g transform="scale(1 1)">
<g transform="rotate(0)">
<svg x="-46" y="-46" width="92" height="92" style ="overflow:visible" opacity="1">
<animate attributeName="x" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/>
<animate attributeName="y" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="-46;-54.05;-46"/>
<animate attributeName="width" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/>
<animate attributeName="height" calcMode="spline" dur="2s" fill="freeze" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="92;108.1;92"/>
<g clip-path="">
<g filter="">
<path d="M80,0c6.627,0,12,5.373,12,12 0,44.183,-35.817,80,-80,80 -6.627,0,-12,-5.373,-12,-12 0,-6.627,5.373,-12,12,-12 30.928,0,56,-25.072,56,-56 0,-6.627,5.373,-12,12,-12zM80,0" fill="#0DBD8B" id="path_3" stroke="#00000000" stroke-dasharray="0" stroke-dashoffset="0" stroke-miterlimit="10" stroke-width="0">
<animate attributeName="d" calcMode="spline" dur="2s" fill="freeze" href="#path_3" keySplines="0.42 0 0.58 1;0.42 0 0.58 1" keyTimes="0;0.5;1" repeatCount="indefinite" values="M80,0c6.627,0,12,5.373,12,12 0,44.183,-35.817,80,-80,80 -6.627,0,-12,-5.373,-12,-12 0,-6.627,5.373,-12,12,-12 30.928,0,56,-25.072,56,-56 0,-6.627,5.373,-12,12,-12zM80,0;M94,0c7.787,0,14.1,6.313,14.1,14.1 0,51.915,-42.085,94,-94,94 -7.787,0,-14.1,-6.313,-14.1,-14.1 0,-7.787,6.313,-14.1,14.1,-14.1 36.34,0,65.8,-29.46,65.8,-65.8 0,-7.787,6.313,-14.1,14.1,-14.1zM94,0;M80,0c6.627,0,12,5.373,12,12 0,44.183,-35.817,80,-80,80 -6.627,0,-12,-5.373,-12,-12 0,-6.627,5.373,-12,12,-12 30.928,0,56,-25.072,56,-56 0,-6.627,5.373,-12,12,-12zM80,0"/>
</path>
</g>
</g>
</svg>
</g>
</g>
</svg>
</g>
</g>
</svg>
</g>
</g>
</svg>
</g>
</g>
</svg>
</g>
</g>
</svg>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Before After
Before After

View file

@ -43,6 +43,7 @@ import TypingStore from "../stores/TypingStore";
import { EventIndexPeg } from "../indexing/EventIndexPeg"; import { EventIndexPeg } from "../indexing/EventIndexPeg";
import {VoiceRecordingStore} from "../stores/VoiceRecordingStore"; import {VoiceRecordingStore} from "../stores/VoiceRecordingStore";
import PerformanceMonitor from "../performance"; import PerformanceMonitor from "../performance";
import UIStore from "../stores/UIStore";
declare global { declare global {
interface Window { interface Window {
@ -82,6 +83,7 @@ declare global {
mxEventIndexPeg: EventIndexPeg; mxEventIndexPeg: EventIndexPeg;
mxPerformanceMonitor: PerformanceMonitor; mxPerformanceMonitor: PerformanceMonitor;
mxPerformanceEntryNames: any; mxPerformanceEntryNames: any;
mxUIStore: UIStore;
} }
interface Document { interface Document {

View file

@ -257,7 +257,7 @@ export default class CallHandler extends EventEmitter {
} }
public getSupportsVirtualRooms() { public getSupportsVirtualRooms() {
return this.supportsPstnProtocol; return this.supportsSipNativeVirtual;
} }
public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> { public pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
@ -455,6 +455,9 @@ export default class CallHandler extends EventEmitter {
if (call.hangupReason === CallErrorCode.UserHangup) { if (call.hangupReason === CallErrorCode.UserHangup) {
title = _t("Call Declined"); title = _t("Call Declined");
description = _t("The other party declined the call."); description = _t("The other party declined the call.");
} else if (call.hangupReason === CallErrorCode.UserBusy) {
title = _t("User Busy");
description = _t("The user you called is busy.");
} else if (call.hangupReason === CallErrorCode.InviteTimeout) { } else if (call.hangupReason === CallErrorCode.InviteTimeout) {
title = _t("Call Failed"); title = _t("Call Failed");
// XXX: full stop appended as some relic here, but these // XXX: full stop appended as some relic here, but these
@ -511,7 +514,9 @@ export default class CallHandler extends EventEmitter {
let newNativeAssertedIdentity = newAssertedIdentity; let newNativeAssertedIdentity = newAssertedIdentity;
if (newAssertedIdentity) { if (newAssertedIdentity) {
const response = await this.sipNativeLookup(newAssertedIdentity); const response = await this.sipNativeLookup(newAssertedIdentity);
if (response.length) newNativeAssertedIdentity = response[0].userid; if (response.length && response[0].fields.lookup_success) {
newNativeAssertedIdentity = response[0].userid;
}
} }
console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`); console.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
@ -773,7 +778,10 @@ export default class CallHandler extends EventEmitter {
const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call); const mappedRoomId = CallHandler.sharedInstance().roomIdForCall(call);
if (this.getCallForRoom(mappedRoomId)) { if (this.getCallForRoom(mappedRoomId)) {
// ignore multiple incoming calls to the same room console.log(
"Got incoming call for room " + mappedRoomId +
" but there's already a call for this room: ignoring",
);
return; return;
} }
@ -830,9 +838,43 @@ export default class CallHandler extends EventEmitter {
}); });
break; break;
} }
case Action.DialNumber:
this.dialNumber(payload.number);
break;
} }
} }
private async dialNumber(number: string) {
const results = await this.pstnLookup(number);
if (!results || results.length === 0 || !results[0].userid) {
Modal.createTrackedDialog('', '', ErrorDialog, {
title: _t("Unable to look up phone number"),
description: _t("There was an error looking up the phone number"),
});
return;
}
const userId = results[0].userid;
// Now check to see if this is a virtual user, in which case we should find the
// native user
let nativeUserId;
if (this.getSupportsVirtualRooms()) {
const nativeLookupResults = await this.sipNativeLookup(userId);
const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success;
nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId;
console.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId);
} else {
nativeUserId = userId;
}
const roomId = await ensureDMExists(MatrixClientPeg.get(), nativeUserId);
dis.dispatch({
action: 'view_room',
room_id: roomId,
});
}
setActiveCallRoomId(activeCallRoomId: string) { setActiveCallRoomId(activeCallRoomId: string) {
logger.info("Setting call in room " + activeCallRoomId + " active"); logger.info("Setting call in room " + activeCallRoomId + " active");

View file

@ -40,6 +40,7 @@ import {
UploadStartedPayload, UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload"; } from "./dispatcher/payloads/UploadPayload";
import {IUpload} from "./models/IUpload"; import {IUpload} from "./models/IUpload";
import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
const MAX_WIDTH = 800; const MAX_WIDTH = 800;
const MAX_HEIGHT = 600; const MAX_HEIGHT = 600;
@ -208,12 +209,12 @@ function infoForImageFile(matrixClient, roomId, imageFile) {
} }
let imageInfo; let imageInfo;
return loadImageElement(imageFile).then(function(r) { return loadImageElement(imageFile).then((r) => {
return createThumbnail(r.img, r.width, r.height, thumbnailType); return createThumbnail(r.img, r.width, r.height, thumbnailType);
}).then(function(result) { }).then((result) => {
imageInfo = result.info; imageInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail); return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) { }).then((result) => {
imageInfo.thumbnail_url = result.url; imageInfo.thumbnail_url = result.url;
imageInfo.thumbnail_file = result.file; imageInfo.thumbnail_file = result.file;
return imageInfo; return imageInfo;
@ -264,12 +265,12 @@ function infoForVideoFile(matrixClient, roomId, videoFile) {
const thumbnailType = "image/jpeg"; const thumbnailType = "image/jpeg";
let videoInfo; let videoInfo;
return loadVideoElement(videoFile).then(function(video) { return loadVideoElement(videoFile).then((video) => {
return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType); return createThumbnail(video, video.videoWidth, video.videoHeight, thumbnailType);
}).then(function(result) { }).then((result) => {
videoInfo = result.info; videoInfo = result.info;
return uploadFile(matrixClient, roomId, result.thumbnail); return uploadFile(matrixClient, roomId, result.thumbnail);
}).then(function(result) { }).then((result) => {
videoInfo.thumbnail_url = result.url; videoInfo.thumbnail_url = result.url;
videoInfo.thumbnail_file = result.file; videoInfo.thumbnail_file = result.file;
return videoInfo; return videoInfo;
@ -308,7 +309,12 @@ function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
* If the file is unencrypted then the object will have a "url" key. * If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key. * If the file is encrypted then the object will have a "file" key.
*/ */
function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blob, progressHandler?: any) { function uploadFile(
matrixClient: MatrixClient,
roomId: string,
file: File | Blob,
progressHandler?: any, // TODO: Types
): Promise<{url?: string, file?: any}> { // TODO: Types
let canceled = false; let canceled = false;
if (matrixClient.isRoomEncrypted(roomId)) { if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it. // If the room is encrypted then encrypt the file before uploading it.
@ -355,7 +361,7 @@ function uploadFile(matrixClient: MatrixClient, roomId: string, file: File | Blo
// If the attachment isn't encrypted then include the URL directly. // If the attachment isn't encrypted then include the URL directly.
return {"url": url}; return {"url": url};
}); });
promise1.abort = () => { (promise1 as any).abort = () => {
canceled = true; canceled = true;
MatrixClientPeg.get().cancelUpload(basePromise); MatrixClientPeg.get().cancelUpload(basePromise);
}; };
@ -367,7 +373,7 @@ export default class ContentMessages {
private inprogress: IUpload[] = []; private inprogress: IUpload[] = [];
private mediaConfig: IMediaConfig = null; private mediaConfig: IMediaConfig = null;
sendStickerContentToRoom(url: string, roomId: string, info: string, text: string, matrixClient: MatrixClient) { sendStickerContentToRoom(url: string, roomId: string, info: IImageInfo, text: string, matrixClient: MatrixClient) {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => { const prom = MatrixClientPeg.get().sendStickerMessage(roomId, url, info, text).catch((e) => {
console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e); console.warn(`Failed to send content with URL ${url} to room ${roomId}`, e);
@ -441,7 +447,7 @@ export default class ContentMessages {
let uploadAll = false; let uploadAll = false;
// Promise to complete before sending next file into room, used for synchronisation of file-sending // Promise to complete before sending next file into room, used for synchronisation of file-sending
// to match the order the files were specified in // to match the order the files were specified in
let promBefore = Promise.resolve(); let promBefore: Promise<any> = Promise.resolve();
for (let i = 0; i < okFiles.length; ++i) { for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i]; const file = okFiles[i];
if (!uploadAll) { if (!uploadAll) {

View file

@ -22,6 +22,7 @@ import SdkConfig from './SdkConfig';
import {MatrixClientPeg} from "./MatrixClientPeg"; import {MatrixClientPeg} from "./MatrixClientPeg";
import {sleep} from "./utils/promise"; import {sleep} from "./utils/promise";
import RoomViewStore from "./stores/RoomViewStore"; import RoomViewStore from "./stores/RoomViewStore";
import { Action } from "./dispatcher/actions";
// polyfill textencoder if necessary // polyfill textencoder if necessary
import * as TextEncodingUtf8 from 'text-encoding-utf-8'; import * as TextEncodingUtf8 from 'text-encoding-utf-8';
@ -265,7 +266,7 @@ interface ICreateRoomEvent extends IEvent {
} }
interface IJoinRoomEvent extends IEvent { interface IJoinRoomEvent extends IEvent {
key: "join_room"; key: Action.JoinRoom;
dur: number; // how long it took to join (until remote echo) dur: number; // how long it took to join (until remote echo)
segmentation: { segmentation: {
room_id: string; // hashed room_id: string; // hashed
@ -684,7 +685,9 @@ export default class CountlyAnalytics {
} }
private getOrientation = (): Orientation => { private getOrientation = (): Orientation => {
return window.innerWidth > window.innerHeight ? Orientation.Landscape : Orientation.Portrait; return window.matchMedia("(orientation: landscape)").matches
? Orientation.Landscape
: Orientation.Portrait
}; };
private reportOrientation = () => { private reportOrientation = () => {
@ -813,7 +816,9 @@ export default class CountlyAnalytics {
window.addEventListener("mousemove", this.onUserActivity); window.addEventListener("mousemove", this.onUserActivity);
window.addEventListener("click", this.onUserActivity); window.addEventListener("click", this.onUserActivity);
window.addEventListener("keydown", this.onUserActivity); window.addEventListener("keydown", this.onUserActivity);
window.addEventListener("scroll", this.onUserActivity); // Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
window.addEventListener("scroll", this.onUserActivity, { passive: true });
this.activityIntervalId = setInterval(() => { this.activityIntervalId = setInterval(() => {
this.inactivityCounter++; this.inactivityCounter++;
@ -858,7 +863,7 @@ export default class CountlyAnalytics {
} }
public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) { public trackRoomJoin(startTime: number, roomId: string, type: IJoinRoomEvent["segmentation"]["type"]) {
this.track<IJoinRoomEvent>("join_room", { type }, roomId, { this.track<IJoinRoomEvent>(Action.JoinRoom, { type }, roomId, {
dur: CountlyAnalytics.getTimestamp() - startTime, dur: CountlyAnalytics.getTimestamp() - startTime,
}); });
} }

View file

@ -21,7 +21,6 @@ import MultiInviter from './utils/MultiInviter';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import {MatrixClientPeg} from './MatrixClientPeg'; import {MatrixClientPeg} from './MatrixClientPeg';
import GroupStore from './stores/GroupStore'; import GroupStore from './stores/GroupStore';
import {allSettled} from "./utils/promise";
import StyledCheckbox from './components/views/elements/StyledCheckbox'; import StyledCheckbox from './components/views/elements/StyledCheckbox';
export function showGroupInviteDialog(groupId) { export function showGroupInviteDialog(groupId) {
@ -120,7 +119,7 @@ function _onGroupInviteFinished(groupId, addrs) {
function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) { function _onGroupAddRoomFinished(groupId, addrs, addRoomsPublicly) {
const matrixClient = MatrixClientPeg.get(); const matrixClient = MatrixClientPeg.get();
const errorList = []; const errorList = [];
return allSettled(addrs.map((addr) => { return Promise.allSettled(addrs.map((addr) => {
return GroupStore return GroupStore
.addRoomToGroup(groupId, addr.address, addRoomsPublicly) .addRoomToGroup(groupId, addr.address, addRoomsPublicly)
.catch(() => { errorList.push(addr.address); }) .catch(() => { errorList.push(addr.address); })

View file

@ -31,12 +31,12 @@ interface IPasswordFlow {
} }
export enum IdentityProviderBrand { export enum IdentityProviderBrand {
Gitlab = "org.matrix.gitlab", Gitlab = "gitlab",
Github = "org.matrix.github", Github = "github",
Apple = "org.matrix.apple", Apple = "apple",
Google = "org.matrix.google", Google = "google",
Facebook = "org.matrix.facebook", Facebook = "facebook",
Twitter = "org.matrix.twitter", Twitter = "twitter",
} }
export interface IIdentityProvider { export interface IIdentityProvider {
@ -48,7 +48,8 @@ export interface IIdentityProvider {
export interface ISSOFlow { export interface ISSOFlow {
type: "m.login.sso" | "m.login.cas"; type: "m.login.sso" | "m.login.cas";
"org.matrix.msc2858.identity_providers": IIdentityProvider[]; // Unstable prefix for MSC2858 // eslint-disable-next-line camelcase
identity_providers: IIdentityProvider[];
} }
export type LoginFlow = ISSOFlow | IPasswordFlow; export type LoginFlow = ISSOFlow | IPasswordFlow;

View file

@ -98,7 +98,7 @@ class Presence {
} }
try { try {
await MatrixClientPeg.get().setPresence(this.state); await MatrixClientPeg.get().setPresence({presence: this.state});
console.info("Presence:", newState); console.info("Presence:", newState);
} catch (err) { } catch (err) {
console.error("Failed to set presence:", err); console.error("Failed to set presence:", err);

View file

@ -66,7 +66,7 @@ async function serverSideSearchProcess(term, roomId = undefined) {
highlights: [], highlights: [],
}; };
return client._processRoomEventsSearch(searchResult, result.response); return client.processRoomEventsSearch(searchResult, result.response);
} }
function compareEvents(a, b) { function compareEvents(a, b) {
@ -131,7 +131,7 @@ async function combinedSearch(searchTerm) {
}, },
}; };
const result = client._processRoomEventsSearch(emptyResult, response); const result = client.processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(result.results); restoreEncryptionInfo(result.results);
@ -185,7 +185,7 @@ async function localSearchProcess(searchTerm, roomId = undefined) {
}, },
}; };
const processedResult = MatrixClientPeg.get()._processRoomEventsSearch(emptyResult, response); const processedResult = MatrixClientPeg.get().processRoomEventsSearch(emptyResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
restoreEncryptionInfo(processedResult.results); restoreEncryptionInfo(processedResult.results);
@ -210,7 +210,7 @@ async function localPagination(searchResult) {
}, },
}; };
const result = MatrixClientPeg.get()._processRoomEventsSearch(searchResult, response); const result = MatrixClientPeg.get().processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0)); const newSlice = result.results.slice(Math.max(result.results.length - newResultCount, 0));
@ -520,7 +520,7 @@ async function combinedPagination(searchResult) {
const oldResultCount = searchResult.results ? searchResult.results.length : 0; const oldResultCount = searchResult.results ? searchResult.results.length : 0;
// Let the client process the combined result. // Let the client process the combined result.
const result = client._processRoomEventsSearch(searchResult, response); const result = client.processRoomEventsSearch(searchResult, response);
// Restore our encryption info so we can properly re-verify the events. // Restore our encryption info so we can properly re-verify the events.
const newResultCount = result.results.length - oldResultCount; const newResultCount = result.results.length - oldResultCount;

View file

@ -271,7 +271,7 @@ async function onSecretRequested(
} }
return key && encodeBase64(key); return key && encodeBase64(key);
} else if (name === "m.megolm_backup.v1") { } else if (name === "m.megolm_backup.v1") {
const key = await client._crypto.getSessionBackupPrivateKey(); const key = await client.crypto.getSessionBackupPrivateKey();
if (!key) { if (!key) {
console.log( console.log(
`session backup key requested by ${deviceId}, but not found in cache`, `session backup key requested by ${deviceId}, but not found in cache`,

View file

@ -36,14 +36,18 @@ export class Service {
} }
} }
interface Policy { export interface LocalisedPolicy {
name: string;
url: string;
}
export interface Policy {
// @ts-ignore: No great way to express indexed types together with other keys // @ts-ignore: No great way to express indexed types together with other keys
version: string; version: string;
[lang: string]: { [lang: string]: LocalisedPolicy;
url: string;
};
} }
type Policies = {
export type Policies = {
[policy: string]: Policy, [policy: string]: Policy,
}; };
@ -99,7 +103,7 @@ export async function startTermsFlow(
// fetch the set of agreed policy URLs from account data // fetch the set of agreed policy URLs from account data
const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms'); const currentAcceptedTerms = await MatrixClientPeg.get().getAccountData('m.accepted_terms');
let agreedUrlSet; let agreedUrlSet: Set<string>;
if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) { if (!currentAcceptedTerms || !currentAcceptedTerms.getContent() || !currentAcceptedTerms.getContent().accepted) {
agreedUrlSet = new Set(); agreedUrlSet = new Set();
} else { } else {

View file

@ -33,7 +33,7 @@ export default class VoipUserMapper {
private async userToVirtualUser(userId: string): Promise<string> { private async userToVirtualUser(userId: string): Promise<string> {
const results = await CallHandler.sharedInstance().sipVirtualLookup(userId); const results = await CallHandler.sharedInstance().sipVirtualLookup(userId);
if (results.length === 0) return null; if (results.length === 0 || !results[0].fields.lookup_success) return null;
return results[0].userid; return results[0].userid;
} }
@ -82,14 +82,14 @@ export default class VoipUserMapper {
return Boolean(claimedNativeRoomId); return Boolean(claimedNativeRoomId);
} }
public async onNewInvitedRoom(invitedRoom: Room) { public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return; if (!CallHandler.sharedInstance().getSupportsVirtualRooms()) return;
const inviterId = invitedRoom.getDMInviter(); const inviterId = invitedRoom.getDMInviter();
console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); console.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId); const result = await CallHandler.sharedInstance().sipNativeLookup(inviterId);
if (result.length === 0) { if (result.length === 0) {
return true; return;
} }
if (result[0].fields.is_virtual) { if (result[0].fields.is_virtual) {

View file

@ -1,51 +0,0 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
export default class AutoHideScrollbar extends React.Component {
constructor(props) {
super(props);
this._collectContainerRef = this._collectContainerRef.bind(this);
}
_collectContainerRef(ref) {
if (ref && !this.containerRef) {
this.containerRef = ref;
}
if (this.props.wrappedRef) {
this.props.wrappedRef(ref);
}
}
getScrollTop() {
return this.containerRef.scrollTop;
}
render() {
return (<div
ref={this._collectContainerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onScroll={this.props.onScroll}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}
}

View file

@ -0,0 +1,65 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
interface IProps {
className?: string;
onScroll?: () => void;
onWheel?: () => void;
style?: React.CSSProperties
tabIndex?: number,
wrappedRef?: (ref: HTMLDivElement) => void;
}
export default class AutoHideScrollbar extends React.Component<IProps> {
private containerRef: React.RefObject<HTMLDivElement> = React.createRef();
public componentDidMount() {
if (this.containerRef.current && this.props.onScroll) {
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.containerRef.current.addEventListener("scroll", this.props.onScroll, { passive: true });
}
if (this.props.wrappedRef) {
this.props.wrappedRef(this.containerRef.current);
}
}
public componentWillUnmount() {
if (this.containerRef.current && this.props.onScroll) {
this.containerRef.current.removeEventListener("scroll", this.props.onScroll);
}
}
public getScrollTop(): number {
return this.containerRef.current.scrollTop;
}
public render() {
return (<div
ref={this.containerRef}
style={this.props.style}
className={["mx_AutoHideScrollbar", this.props.className].join(" ")}
onWheel={this.props.onWheel}
tabIndex={this.props.tabIndex}
>
{ this.props.children }
</div>);
}
}

View file

@ -23,6 +23,7 @@ import classNames from "classnames";
import {Key} from "../../Keyboard"; import {Key} from "../../Keyboard";
import {Writeable} from "../../@types/common"; import {Writeable} from "../../@types/common";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import UIStore from "../../stores/UIStore";
// Shamelessly ripped off Modal.js. There's probably a better way // Shamelessly ripped off Modal.js. There's probably a better way
// of doing reusable widgets like dialog boxes & menus where we go and // of doing reusable widgets like dialog boxes & menus where we go and
@ -410,12 +411,12 @@ export const aboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFace.None
const buttonBottom = elementRect.bottom + window.pageYOffset; const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset; const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button // Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight; menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available. // Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) { if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding; menuOptions.top = buttonBottom + vPadding;
} else { } else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
} }
return menuOptions; return menuOptions;
@ -430,12 +431,12 @@ export const alwaysAboveLeftOf = (elementRect: DOMRect, chevronFace = ChevronFac
const buttonBottom = elementRect.bottom + window.pageYOffset; const buttonBottom = elementRect.bottom + window.pageYOffset;
const buttonTop = elementRect.top + window.pageYOffset; const buttonTop = elementRect.top + window.pageYOffset;
// Align the right edge of the menu to the right edge of the button // Align the right edge of the menu to the right edge of the button
menuOptions.right = window.innerWidth - buttonRight; menuOptions.right = UIStore.instance.windowWidth - buttonRight;
// Align the menu vertically on whichever side of the button has more space available. // Align the menu vertically on whichever side of the button has more space available.
if (buttonBottom < window.innerHeight / 2) { if (buttonBottom < UIStore.instance.windowHeight / 2) {
menuOptions.top = buttonBottom + vPadding; menuOptions.top = buttonBottom + vPadding;
} else { } else {
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
} }
return menuOptions; return menuOptions;
@ -451,7 +452,7 @@ export const alwaysAboveRightOf = (elementRect: DOMRect, chevronFace = ChevronFa
// Align the left edge of the menu to the left edge of the button // Align the left edge of the menu to the left edge of the button
menuOptions.left = buttonLeft; menuOptions.left = buttonLeft;
// Align the menu vertically above the menu // Align the menu vertically above the menu
menuOptions.bottom = (window.innerHeight - buttonTop) + vPadding; menuOptions.bottom = (UIStore.instance.windowHeight - buttonTop) + vPadding;
return menuOptions; return menuOptions;
}; };

View file

@ -36,7 +36,7 @@ import FlairStore from '../../stores/FlairStore';
import { showGroupAddRoomDialog } from '../../GroupAddressPicker'; import { showGroupAddRoomDialog } from '../../GroupAddressPicker';
import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks"; import {makeGroupPermalink, makeUserPermalink} from "../../utils/permalinks/Permalinks";
import {Group} from "matrix-js-sdk/src/models/group"; import {Group} from "matrix-js-sdk/src/models/group";
import {allSettled, sleep} from "../../utils/promise"; import {sleep} from "../../utils/promise";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import AutoHideScrollbar from "./AutoHideScrollbar"; import AutoHideScrollbar from "./AutoHideScrollbar";
import {mediaFromMxc} from "../../customisations/Media"; import {mediaFromMxc} from "../../customisations/Media";
@ -99,7 +99,7 @@ class CategoryRoomList extends React.Component {
onFinished: (success, addrs) => { onFinished: (success, addrs) => {
if (!success) return; if (!success) return;
const errorList = []; const errorList = [];
allSettled(addrs.map((addr) => { Promise.allSettled(addrs.map((addr) => {
return GroupStore return GroupStore
.addRoomToGroupSummary(this.props.groupId, addr.address) .addRoomToGroupSummary(this.props.groupId, addr.address)
.catch(() => { errorList.push(addr.address); }); .catch(() => { errorList.push(addr.address); });
@ -274,7 +274,7 @@ class RoleUserList extends React.Component {
onFinished: (success, addrs) => { onFinished: (success, addrs) => {
if (!success) return; if (!success) return;
const errorList = []; const errorList = [];
allSettled(addrs.map((addr) => { Promise.allSettled(addrs.map((addr) => {
return GroupStore return GroupStore
.addUserToGroupSummary(addr.address) .addUserToGroupSummary(addr.address)
.catch(() => { errorList.push(addr.address); }); .catch(() => { errorList.push(addr.address); });

View file

@ -59,7 +59,9 @@ export default class IndicatorScrollbar extends React.Component {
_collectScroller(scroller) { _collectScroller(scroller) {
if (scroller && !this._scrollElement) { if (scroller && !this._scrollElement) {
this._scrollElement = scroller; this._scrollElement = scroller;
this._scrollElement.addEventListener("scroll", this.checkOverflow); // Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this._scrollElement.addEventListener("scroll", this.checkOverflow, { passive: true });
this.checkOverflow(); this.checkOverflow();
} }
} }

View file

@ -43,6 +43,7 @@ import {replaceableComponent} from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media"; import {mediaFromMxc} from "../../customisations/Media";
import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore"; import SpaceStore, {UPDATE_SELECTED_SPACE} from "../../stores/SpaceStore";
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager"; import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
import UIStore from "../../stores/UIStore";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -66,6 +67,7 @@ const cssClasses = [
@replaceableComponent("structures.LeftPanel") @replaceableComponent("structures.LeftPanel")
export default class LeftPanel extends React.Component<IProps, IState> { export default class LeftPanel extends React.Component<IProps, IState> {
private ref: React.RefObject<HTMLDivElement> = createRef();
private listContainerRef: React.RefObject<HTMLDivElement> = createRef(); private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
private groupFilterPanelWatcherRef: string; private groupFilterPanelWatcherRef: string;
private bgImageWatcherRef: string; private bgImageWatcherRef: string;
@ -90,10 +92,14 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { this.groupFilterPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); this.setState({showGroupFilterPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
}); });
}
// We watch the middle panel because we don't actually get resized, the middle panel does. public componentDidMount() {
// We listen to the noisy channel to avoid choppy reaction times. UIStore.instance.trackElementDimensions("ListContainer", this.listContainerRef.current);
this.props.resizeNotifier.on("middlePanelResizedNoisy", this.onResize); UIStore.instance.on("ListContainer", this.refreshStickyHeaders);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.listContainerRef.current?.addEventListener("scroll", this.onScroll, { passive: true });
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -103,7 +109,15 @@ export default class LeftPanel extends React.Component<IProps, IState> {
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate); RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate); OwnProfileStore.instance.off(UPDATE_EVENT, this.onBackgroundImageUpdate);
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.updateActiveSpace);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); UIStore.instance.stopTrackingElementDimensions("ListContainer");
UIStore.instance.removeListener("ListContainer", this.refreshStickyHeaders);
this.listContainerRef.current?.removeEventListener("scroll", this.onScroll);
}
public componentDidUpdate(prevProps: IProps, prevState: IState): void {
if (prevState.activeSpace !== this.state.activeSpace) {
this.refreshStickyHeaders();
}
} }
private updateActiveSpace = (activeSpace: Room) => { private updateActiveSpace = (activeSpace: Room) => {
@ -114,6 +128,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
dis.fire(Action.ViewRoomDirectory); dis.fire(Action.ViewRoomDirectory);
}; };
private refreshStickyHeaders = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
}
private onBreadcrumbsUpdate = () => { private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible; const newVal = BreadcrumbsStore.instance.visible;
if (newVal !== this.state.showBreadcrumbs) { if (newVal !== this.state.showBreadcrumbs) {
@ -156,9 +175,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
const bottomEdge = list.offsetHeight + list.scrollTop; const bottomEdge = list.offsetHeight + list.scrollTop;
const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)"); const sublists = list.querySelectorAll<HTMLDivElement>(".mx_RoomSublist:not(.mx_RoomSublist_hidden)");
const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = list.clientWidth - headerRightMargin;
// We track which styles we want on a target before making the changes to avoid // We track which styles we want on a target before making the changes to avoid
// excessive layout updates. // excessive layout updates.
const targetStyles = new Map<HTMLDivElement, { const targetStyles = new Map<HTMLDivElement, {
@ -228,7 +244,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist_headerContainer_stickyBottom"); header.classList.add("mx_RoomSublist_headerContainer_stickyBottom");
} }
const offset = window.innerHeight - (list.parentElement.offsetTop + list.parentElement.offsetHeight); const offset = UIStore.instance.windowHeight -
(list.parentElement.offsetTop + list.parentElement.offsetHeight);
const newBottom = `${offset}px`; const newBottom = `${offset}px`;
if (header.style.bottom !== newBottom) { if (header.style.bottom !== newBottom) {
header.style.bottom = newBottom; header.style.bottom = newBottom;
@ -247,14 +264,20 @@ export default class LeftPanel extends React.Component<IProps, IState> {
header.classList.add("mx_RoomSublist_headerContainer_sticky"); header.classList.add("mx_RoomSublist_headerContainer_sticky");
} }
const newWidth = `${headerStickyWidth}px`; const listDimensions = UIStore.instance.getElementDimensions("ListContainer");
if (header.style.width !== newWidth) { if (listDimensions) {
header.style.width = newWidth; const headerRightMargin = 15; // calculated from margins and widths to align with non-sticky tiles
const headerStickyWidth = listDimensions.width - headerRightMargin;
const newWidth = `${headerStickyWidth}px`;
if (header.style.width !== newWidth) {
header.style.width = newWidth;
}
} }
} else if (!style.stickyTop && !style.stickyBottom) { } else if (!style.stickyTop && !style.stickyBottom) {
if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) { if (header.classList.contains("mx_RoomSublist_headerContainer_sticky")) {
header.classList.remove("mx_RoomSublist_headerContainer_sticky"); header.classList.remove("mx_RoomSublist_headerContainer_sticky");
} }
if (header.style.width) { if (header.style.width) {
header.style.removeProperty('width'); header.style.removeProperty('width');
} }
@ -276,16 +299,11 @@ export default class LeftPanel extends React.Component<IProps, IState> {
} }
} }
private onScroll = (ev: React.MouseEvent<HTMLDivElement>) => { private onScroll = (ev: Event) => {
const list = ev.target as HTMLDivElement; const list = ev.target as HTMLDivElement;
this.handleStickyHeaders(list); this.handleStickyHeaders(list);
}; };
private onResize = () => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
};
private onFocus = (ev: React.FocusEvent) => { private onFocus = (ev: React.FocusEvent) => {
this.focusedElement = ev.target; this.focusedElement = ev.target;
}; };
@ -420,8 +438,8 @@ export default class LeftPanel extends React.Component<IProps, IState> {
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
onResize={this.onResize}
activeSpace={this.state.activeSpace} activeSpace={this.state.activeSpace}
onListCollapse={this.refreshStickyHeaders}
/>; />;
const containerClasses = classNames({ const containerClasses = classNames({
@ -435,17 +453,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
); );
return ( return (
<div className={containerClasses}> <div className={containerClasses} ref={this.ref}>
{leftLeftPanel} {leftLeftPanel}
<aside className="mx_LeftPanel_roomListContainer"> <aside className="mx_LeftPanel_roomListContainer">
{this.renderHeader()} {this.renderHeader()}
{this.renderSearchExplore()} {this.renderSearchExplore()}
{this.renderBreadcrumbs()} {this.renderBreadcrumbs()}
<RoomListNumResults /> <RoomListNumResults onVisibilityChange={this.refreshStickyHeaders} />
<div className="mx_LeftPanel_roomListWrapper"> <div className="mx_LeftPanel_roomListWrapper">
<div <div
className={roomListClasses} className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef} ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to // Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order. // overflow:scroll;, so force it out of tab order.
@ -454,7 +471,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
{roomList} {roomList}
</div> </div>
</div> </div>
{ !this.props.isMinimized && <LeftPanelWidget onResize={this.onResize} /> } { !this.props.isMinimized && <LeftPanelWidget /> }
</aside> </aside>
</div> </div>
); );

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, {useContext, useEffect, useMemo} from "react"; import React, {useContext, useMemo} from "react";
import {Resizable} from "re-resizable"; import {Resizable} from "re-resizable";
import classNames from "classnames"; import classNames from "classnames";
@ -27,16 +27,13 @@ import WidgetUtils, {IWidgetEvent} from "../../utils/WidgetUtils";
import {useAccountData} from "../../hooks/useAccountData"; import {useAccountData} from "../../hooks/useAccountData";
import AppTile from "../views/elements/AppTile"; import AppTile from "../views/elements/AppTile";
import {useSettingValue} from "../../hooks/useSettings"; import {useSettingValue} from "../../hooks/useSettings";
import UIStore from "../../stores/UIStore";
interface IProps {
onResize(): void;
}
const MIN_HEIGHT = 100; const MIN_HEIGHT = 100;
const MAX_HEIGHT = 500; // or 50% of the window height const MAX_HEIGHT = 500; // or 50% of the window height
const INITIAL_HEIGHT = 280; const INITIAL_HEIGHT = 280;
const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => { const LeftPanelWidget: React.FC = () => {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets"); const mWidgetsEvent = useAccountData<Record<string, IWidgetEvent>>(cli, "m.widgets");
@ -56,7 +53,6 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT); const [height, setHeight] = useLocalStorageState("left-panel-widget-height", INITIAL_HEIGHT);
const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true); const [expanded, setExpanded] = useLocalStorageState("left-panel-widget-expanded", true);
useEffect(onResize, [expanded, onResize]);
const [onFocus, isActive, ref] = useRovingTabIndex(); const [onFocus, isActive, ref] = useRovingTabIndex();
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
@ -68,8 +64,7 @@ const LeftPanelWidget: React.FC<IProps> = ({ onResize }) => {
content = <Resizable content = <Resizable
size={{height} as any} size={{height} as any}
minHeight={MIN_HEIGHT} minHeight={MIN_HEIGHT}
maxHeight={Math.min(window.innerHeight / 2, MAX_HEIGHT)} maxHeight={Math.min(UIStore.instance.windowHeight / 2, MAX_HEIGHT)}
onResize={onResize}
onResizeStop={(e, dir, ref, d) => { onResizeStop={(e, dir, ref, d) => {
setHeight(height + d.height); setHeight(height + d.height);
}} }}

View file

@ -358,7 +358,7 @@ class LoggedInView extends React.Component<IProps, IState> {
const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM); const pinnedEventIds = pinStateEvent.getContent().pinned.slice(0, MAX_PINNED_NOTICES_PER_ROOM);
for (const eventId of pinnedEventIds) { for (const eventId of pinnedEventIds) {
const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId, 0); const timeline = await this._matrixClient.getEventTimeline(room.getUnfilteredTimelineSet(), eventId);
const event = timeline.getEvents().find(ev => ev.getId() === eventId); const event = timeline.getEvents().find(ev => ev.getId() === eventId);
if (event) events.push(event); if (event) events.push(event);
} }

View file

@ -87,6 +87,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import SecurityCustomisations from "../../customisations/Security"; import SecurityCustomisations from "../../customisations/Security";
import PerformanceMonitor, { PerformanceEntryNames } from "../../performance"; import PerformanceMonitor, { PerformanceEntryNames } from "../../performance";
import UIStore, { UI_EVENTS } from "../../stores/UIStore";
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
export enum Views { export enum Views {
@ -225,13 +226,13 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
firstSyncPromise: IDeferred<void>; firstSyncPromise: IDeferred<void>;
private screenAfterLogin?: IScreen; private screenAfterLogin?: IScreen;
private windowWidth: number;
private pageChanging: boolean; private pageChanging: boolean;
private tokenLogin?: boolean; private tokenLogin?: boolean;
private accountPassword?: string; private accountPassword?: string;
private accountPasswordTimer?: NodeJS.Timeout; private accountPasswordTimer?: NodeJS.Timeout;
private focusComposer: boolean; private focusComposer: boolean;
private subTitleStatus: string; private subTitleStatus: string;
private prevWindowWidth: number;
private readonly loggedInView: React.RefObject<LoggedInViewType>; private readonly loggedInView: React.RefObject<LoggedInViewType>;
private readonly dispatcherRef: any; private readonly dispatcherRef: any;
@ -277,9 +278,8 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
} }
this.windowWidth = 10000; this.prevWindowWidth = UIStore.instance.windowWidth || 1000;
this.handleResize(); UIStore.instance.on(UI_EVENTS.Resize, this.handleResize);
window.addEventListener('resize', this.handleResize);
this.pageChanging = false; this.pageChanging = false;
@ -378,7 +378,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.onLoggedIn(); this.onLoggedIn();
} }
const promisesList = [this.firstSyncPromise.promise]; const promisesList: Promise<any>[] = [this.firstSyncPromise.promise];
if (cryptoEnabled) { if (cryptoEnabled) {
// wait for the client to finish downloading cross-signing keys for us so we // wait for the client to finish downloading cross-signing keys for us so we
// know whether or not we have keys set up on this account // know whether or not we have keys set up on this account
@ -436,7 +436,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.unregister(this.dispatcherRef); dis.unregister(this.dispatcherRef);
this.themeWatcher.stop(); this.themeWatcher.stop();
this.fontWatcher.stop(); this.fontWatcher.stop();
window.removeEventListener('resize', this.handleResize); UIStore.destroy();
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize); this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer); if (this.accountPasswordTimer !== null) clearTimeout(this.accountPasswordTimer);
@ -665,7 +665,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
break; break;
} }
case 'view_create_room': case 'view_create_room':
this.createRoom(payload.public); this.createRoom(payload.public, payload.defaultName);
break; break;
case 'view_create_group': { case 'view_create_group': {
let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog") let CreateGroupDialog = sdk.getComponent("dialogs.CreateGroupDialog")
@ -1011,7 +1011,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
private async createRoom(defaultPublic = false) { private async createRoom(defaultPublic = false, defaultName?: string) {
const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId(); const communityId = CommunityPrototypeStore.instance.getSelectedCommunityId();
if (communityId) { if (communityId) {
// double check the user will have permission to associate this room with the community // double check the user will have permission to associate this room with the community
@ -1025,7 +1025,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog'); const CreateRoomDialog = sdk.getComponent('dialogs.CreateRoomDialog');
const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, { defaultPublic }); const modal = Modal.createTrackedDialog('Create Room', '', CreateRoomDialog, {
defaultPublic,
defaultName,
});
const [shouldCreate, opts] = await modal.finished; const [shouldCreate, opts] = await modal.finished;
if (shouldCreate) { if (shouldCreate) {
@ -1817,18 +1820,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
handleResize = () => { handleResize = () => {
const hideLhsThreshold = 1000; const LHS_THRESHOLD = 1000;
const showLhsThreshold = 1000; const width = UIStore.instance.windowWidth;
if (this.windowWidth > hideLhsThreshold && window.innerWidth <= hideLhsThreshold) { if (this.prevWindowWidth < LHS_THRESHOLD && width >= LHS_THRESHOLD) {
dis.dispatch({ action: 'hide_left_panel' });
}
if (this.windowWidth <= showLhsThreshold && window.innerWidth > showLhsThreshold) {
dis.dispatch({ action: 'show_left_panel' }); dis.dispatch({ action: 'show_left_panel' });
} }
if (this.prevWindowWidth >= LHS_THRESHOLD && width < LHS_THRESHOLD) {
dis.dispatch({ action: 'hide_left_panel' });
}
this.prevWindowWidth = width;
this.state.resizeNotifier.notifyWindowResized(); this.state.resizeNotifier.notifyWindowResized();
this.windowWidth = window.innerWidth;
}; };
private dispatchTimelineResize() { private dispatchTimelineResize() {
@ -2087,6 +2091,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined} onForgotPasswordClick={showPasswordReset ? this.onForgotPasswordClick : undefined}
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
fragmentAfterLogin={fragmentAfterLogin} fragmentAfterLogin={fragmentAfterLogin}
defaultUsername={this.props.startingFragmentQueryParams.defaultUsername}
{...this.getServerProperties()} {...this.getServerProperties()}
/> />
); );

View file

@ -121,6 +121,9 @@ export default class MessagePanel extends React.Component {
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: PropTypes.func, onScroll: PropTypes.func,
// callback which is called when the user interacts with the room timeline
onUserScroll: PropTypes.func,
// callback which is called when more content is needed. // callback which is called when more content is needed.
onFillRequest: PropTypes.func, onFillRequest: PropTypes.func,
@ -645,39 +648,37 @@ export default class MessagePanel extends React.Component {
// use txnId as key if available so that we don't remount during sending // use txnId as key if available so that we don't remount during sending
ret.push( ret.push(
<li <TileErrorBoundary key={mxEv.getTxnId() || eventId} mxEvent={mxEv}>
key={mxEv.getTxnId() || eventId} <EventTile
ref={this._collectEventNode.bind(this, eventId)} as="li"
data-scroll-tokens={scrollToken} data-scroll-tokens={scrollToken}
> ref={this._collectEventNode.bind(this, eventId)}
<TileErrorBoundary mxEvent={mxEv}> alwaysShowTimestamps={this.props.alwaysShowTimestamps}
<EventTile mxEvent={mxEv}
mxEvent={mxEv} continuation={continuation}
continuation={continuation} isRedacted={mxEv.isRedacted()}
isRedacted={mxEv.isRedacted()} replacingEventId={mxEv.replacingEventId()}
replacingEventId={mxEv.replacingEventId()} editState={isEditing && this.props.editState}
editState={isEditing && this.props.editState} onHeightChanged={this._onHeightChanged}
onHeightChanged={this._onHeightChanged} readReceipts={readReceipts}
readReceipts={readReceipts} readReceiptMap={this._readReceiptMap}
readReceiptMap={this._readReceiptMap} showUrlPreview={this.props.showUrlPreview}
showUrlPreview={this.props.showUrlPreview} checkUnmounting={this._isUnmounting}
checkUnmounting={this._isUnmounting} eventSendStatus={mxEv.getAssociatedStatus()}
eventSendStatus={mxEv.getAssociatedStatus()} tileShape={this.props.tileShape}
tileShape={this.props.tileShape} isTwelveHour={this.props.isTwelveHour}
isTwelveHour={this.props.isTwelveHour} permalinkCreator={this.props.permalinkCreator}
permalinkCreator={this.props.permalinkCreator} last={last}
last={last} lastInSection={willWantDateSeparator}
lastInSection={willWantDateSeparator} lastSuccessful={isLastSuccessful}
lastSuccessful={isLastSuccessful} isSelectedEvent={highlight}
isSelectedEvent={highlight} getRelationsForEvent={this.props.getRelationsForEvent}
getRelationsForEvent={this.props.getRelationsForEvent} showReactions={this.props.showReactions}
showReactions={this.props.showReactions} layout={this.props.layout}
layout={this.props.layout} enableFlair={this.props.enableFlair}
enableFlair={this.props.enableFlair} showReadReceipts={this.props.showReadReceipts}
showReadReceipts={this.props.showReadReceipts} />
/> </TileErrorBoundary>,
</TileErrorBoundary>
</li>,
); );
return ret; return ret;
@ -779,7 +780,7 @@ export default class MessagePanel extends React.Component {
} }
_collectEventNode = (eventId, node) => { _collectEventNode = (eventId, node) => {
this.eventNodes[eventId] = node; this.eventNodes[eventId] = node?.ref?.current;
} }
// once dynamic content in the events load, make the scrollPanel check the // once dynamic content in the events load, make the scrollPanel check the
@ -885,6 +886,7 @@ export default class MessagePanel extends React.Component {
ref={this._scrollPanel} ref={this._scrollPanel}
className={className} className={className}
onScroll={this.props.onScroll} onScroll={this.props.onScroll}
onUserScroll={this.props.onUserScroll}
onResize={this.onResize} onResize={this.onResize}
onFillRequest={this.props.onFillRequest} onFillRequest={this.props.onFillRequest}
onUnfillRequest={this.props.onUnfillRequest} onUnfillRequest={this.props.onUnfillRequest}

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016, 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,29 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import 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 BaseCard from "../views/right_panel/BaseCard"; import BaseCard from "../views/right_panel/BaseCard";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import TimelinePanel from "./TimelinePanel";
import Spinner from "../views/elements/Spinner";
interface IProps {
onClose(): void;
}
/* /*
* Component which shows the global notification list using a TimelinePanel * Component which shows the global notification list using a TimelinePanel
*/ */
@replaceableComponent("structures.NotificationPanel") @replaceableComponent("structures.NotificationPanel")
class NotificationPanel extends React.Component { export default class NotificationPanel extends React.PureComponent<IProps> {
static propTypes = {
onClose: PropTypes.func.isRequired,
};
render() { render() {
// wrap a TimelinePanel with the jump-to-event bits turned off.
const TimelinePanel = sdk.getComponent("structures.TimelinePanel");
const Loader = sdk.getComponent("elements.Spinner");
const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty"> const emptyState = (<div className="mx_RightPanel_empty mx_NotificationPanel_empty">
<h2>{_t('Youre all caught up')}</h2> <h2>{_t('Youre all caught up')}</h2>
<p>{_t('You have no visible notifications.')}</p> <p>{_t('You have no visible notifications.')}</p>
@ -47,6 +41,7 @@ class NotificationPanel extends React.Component {
let content; let content;
const timelineSet = MatrixClientPeg.get().getNotifTimelineSet(); const timelineSet = MatrixClientPeg.get().getNotifTimelineSet();
if (timelineSet) { if (timelineSet) {
// wrap a TimelinePanel with the jump-to-event bits turned off.
content = ( content = (
<TimelinePanel <TimelinePanel
manageReadReceipts={false} manageReadReceipts={false}
@ -59,7 +54,7 @@ class NotificationPanel extends React.Component {
); );
} else { } else {
console.error("No notifTimelineSet available!"); console.error("No notifTimelineSet available!");
content = <Loader />; content = <Spinner />;
} }
return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer> return <BaseCard className="mx_NotificationPanel" onClose={this.props.onClose} withoutScrollContainer>
@ -67,5 +62,3 @@ class NotificationPanel extends React.Component {
</BaseCard>; </BaseCard>;
} }
} }
export default NotificationPanel;

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015 - 2020 The Matrix.org Foundation C.I.C. Copyright 2015 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,70 +16,92 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import { Room } from "matrix-js-sdk/src/models/room";
import {Room} from "matrix-js-sdk/src/models/room"; import { User } from "matrix-js-sdk/src/models/user";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import * as sdk from '../../index';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import RateLimitedFunc from '../../ratelimitedfunc'; import RateLimitedFunc from '../../ratelimitedfunc';
import { showGroupInviteDialog, showGroupAddRoomDialog } from '../../GroupAddressPicker';
import GroupStore from '../../stores/GroupStore'; import GroupStore from '../../stores/GroupStore';
import { import {
RightPanelPhases,
RIGHT_PANEL_PHASES_NO_ARGS, RIGHT_PANEL_PHASES_NO_ARGS,
RIGHT_PANEL_SPACE_PHASES, RIGHT_PANEL_SPACE_PHASES,
RightPanelPhases,
} from "../../stores/RightPanelStorePhases"; } from "../../stores/RightPanelStorePhases";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import {Action} from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard"; import WidgetCard from "../views/right_panel/WidgetCard";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import { ActionPayload } from "../../dispatcher/payloads";
import MemberList from "../views/rooms/MemberList";
import GroupMemberList from "../views/groups/GroupMemberList";
import GroupRoomList from "../views/groups/GroupRoomList";
import GroupRoomInfo from "../views/groups/GroupRoomInfo";
import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
import FilePanel from "./FilePanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
interface IProps {
room?: Room; // if showing panels for a given room, this is set
groupId?: string; // if showing panels for a given group, this is set
user?: User; // used if we know the user ahead of opening the panel
resizeNotifier: ResizeNotifier;
}
interface IState {
phase: RightPanelPhases;
isUserPrivilegedInGroup?: boolean;
member?: RoomMember;
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
space?: Room;
widgetId?: string;
groupRoomId?: string;
groupId?: string;
event: MatrixEvent;
}
@replaceableComponent("structures.RightPanel") @replaceableComponent("structures.RightPanel")
export default class RightPanel extends React.Component { export default class RightPanel extends React.Component<IProps, IState> {
static get propTypes() {
return {
room: PropTypes.instanceOf(Room), // if showing panels for a given room, this is set
groupId: PropTypes.string, // if showing panels for a given group, this is set
user: PropTypes.object, // used if we know the user ahead of opening the panel
};
}
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private readonly delayedUpdate: RateLimitedFunc;
private dispatcherRef: string;
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
...RightPanelStore.getSharedInstance().roomPanelPhaseParams, ...RightPanelStore.getSharedInstance().roomPanelPhaseParams,
phase: this._getPhaseFromProps(), phase: this.getPhaseFromProps(),
isUserPrivilegedInGroup: null, isUserPrivilegedInGroup: null,
member: this._getUserForPanel(), member: this.getUserForPanel(),
}; };
this.onAction = this.onAction.bind(this);
this.onRoomStateMember = this.onRoomStateMember.bind(this);
this.onGroupStoreUpdated = this.onGroupStoreUpdated.bind(this);
this.onInviteToGroupButtonClick = this.onInviteToGroupButtonClick.bind(this);
this.onAddRoomToGroupButtonClick = this.onAddRoomToGroupButtonClick.bind(this);
this._delayedUpdate = new RateLimitedFunc(() => { this.delayedUpdate = new RateLimitedFunc(() => {
this.forceUpdate(); this.forceUpdate();
}, 500); }, 500);
} }
// Helper function to split out the logic for _getPhaseFromProps() and the constructor // Helper function to split out the logic for getPhaseFromProps() and the constructor
// as both are called at the same time in the constructor. // as both are called at the same time in the constructor.
_getUserForPanel() { private getUserForPanel() {
if (this.state && this.state.member) return this.state.member; if (this.state && this.state.member) return this.state.member;
const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams; const lastParams = RightPanelStore.getSharedInstance().roomPanelPhaseParams;
return this.props.user || lastParams['member']; return this.props.user || lastParams['member'];
} }
// gets the current phase from the props and also maybe the store // gets the current phase from the props and also maybe the store
_getPhaseFromProps() { private getPhaseFromProps() {
const rps = RightPanelStore.getSharedInstance(); const rps = RightPanelStore.getSharedInstance();
const userForPanel = this._getUserForPanel(); const userForPanel = this.getUserForPanel();
if (this.props.groupId) { if (this.props.groupId) {
if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) { if (!RIGHT_PANEL_PHASES_NO_ARGS.includes(rps.groupPanelPhase)) {
dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList}); dis.dispatch({action: Action.SetRightPanelPhase, phase: RightPanelPhases.GroupMemberList});
@ -118,7 +140,7 @@ export default class RightPanel extends React.Component {
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
const cli = this.context; const cli = this.context;
cli.on("RoomState.members", this.onRoomStateMember); cli.on("RoomState.members", this.onRoomStateMember);
this._initGroupStore(this.props.groupId); this.initGroupStore(this.props.groupId);
} }
componentWillUnmount() { componentWillUnmount() {
@ -126,61 +148,47 @@ export default class RightPanel extends React.Component {
if (this.context) { if (this.context) {
this.context.removeListener("RoomState.members", this.onRoomStateMember); this.context.removeListener("RoomState.members", this.onRoomStateMember);
} }
this._unregisterGroupStore(this.props.groupId); this.unregisterGroupStore();
} }
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event // TODO: [REACT-WARNING] Replace with appropriate lifecycle event
UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase UNSAFE_componentWillReceiveProps(newProps) { // eslint-disable-line camelcase
if (newProps.groupId !== this.props.groupId) { if (newProps.groupId !== this.props.groupId) {
this._unregisterGroupStore(this.props.groupId); this.unregisterGroupStore();
this._initGroupStore(newProps.groupId); this.initGroupStore(newProps.groupId);
} }
} }
_initGroupStore(groupId) { private initGroupStore(groupId: string) {
if (!groupId) return; if (!groupId) return;
GroupStore.registerListener(groupId, this.onGroupStoreUpdated); GroupStore.registerListener(groupId, this.onGroupStoreUpdated);
} }
_unregisterGroupStore() { private unregisterGroupStore() {
GroupStore.unregisterListener(this.onGroupStoreUpdated); GroupStore.unregisterListener(this.onGroupStoreUpdated);
} }
onGroupStoreUpdated() { private onGroupStoreUpdated = () => {
this.setState({ this.setState({
isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId), isUserPrivilegedInGroup: GroupStore.isUserPrivileged(this.props.groupId),
}); });
} };
onInviteToGroupButtonClick() { private onRoomStateMember = (ev: MatrixEvent, _, member: RoomMember) => {
showGroupInviteDialog(this.props.groupId).then(() => {
this.setState({
phase: RightPanelPhases.GroupMemberList,
});
});
}
onAddRoomToGroupButtonClick() {
showGroupAddRoomDialog(this.props.groupId).then(() => {
this.forceUpdate();
});
}
onRoomStateMember(ev, state, member) {
if (!this.props.room || member.roomId !== this.props.room.roomId) { if (!this.props.room || member.roomId !== this.props.room.roomId) {
return; return;
} }
// redraw the badge on the membership list // redraw the badge on the membership list
if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) { if (this.state.phase === RightPanelPhases.RoomMemberList && member.roomId === this.props.room.roomId) {
this._delayedUpdate(); this.delayedUpdate();
} else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId && } else if (this.state.phase === RightPanelPhases.RoomMemberInfo && member.roomId === this.props.room.roomId &&
member.userId === this.state.member.userId) { member.userId === this.state.member.userId) {
// refresh the member info (e.g. new power level) // refresh the member info (e.g. new power level)
this._delayedUpdate(); this.delayedUpdate();
} }
} };
onAction(payload) { private onAction = (payload: ActionPayload) => {
if (payload.action === Action.AfterRightPanelPhaseChange) { if (payload.action === Action.AfterRightPanelPhaseChange) {
this.setState({ this.setState({
phase: payload.phase, phase: payload.phase,
@ -194,9 +202,9 @@ export default class RightPanel extends React.Component {
space: payload.space, space: payload.space,
}); });
} }
} };
onClose = () => { private onClose = () => {
// XXX: There are three different ways of 'closing' this panel depending on what state // XXX: There are three different ways of 'closing' this panel depending on what state
// things are in... this knows far more than it should do about the state of the rest // things are in... this knows far more than it should do about the state of the rest
// of the app and is generally a bit silly. // of the app and is generally a bit silly.
@ -224,16 +232,6 @@ export default class RightPanel extends React.Component {
}; };
render() { render() {
const MemberList = sdk.getComponent('rooms.MemberList');
const UserInfo = sdk.getComponent('right_panel.UserInfo');
const ThirdPartyMemberInfo = sdk.getComponent('rooms.ThirdPartyMemberInfo');
const NotificationPanel = sdk.getComponent('structures.NotificationPanel');
const FilePanel = sdk.getComponent('structures.FilePanel');
const GroupMemberList = sdk.getComponent('groups.GroupMemberList');
const GroupRoomList = sdk.getComponent('groups.GroupRoomList');
const GroupRoomInfo = sdk.getComponent('groups.GroupRoomInfo');
let panel = <div />; let panel = <div />;
const roomId = this.props.room ? this.props.room.roomId : undefined; const roomId = this.props.room ? this.props.room.roomId : undefined;
@ -285,6 +283,7 @@ export default class RightPanel extends React.Component {
user={this.state.member} user={this.state.member}
groupId={this.props.groupId} groupId={this.props.groupId}
key={this.state.member.userId} key={this.state.member.userId}
phase={this.state.phase}
onClose={this.onClose} />; onClose={this.onClose} />;
break; break;
@ -299,6 +298,12 @@ export default class RightPanel extends React.Component {
panel = <NotificationPanel onClose={this.onClose} />; panel = <NotificationPanel onClose={this.onClose} />;
break; break;
case RightPanelPhases.PinnedMessages:
if (SettingsStore.getValue("feature_pinning")) {
panel = <PinnedMessagesCard room={this.props.room} onClose={this.onClose} />;
}
break;
case RightPanelPhases.FilePanel: case RightPanelPhases.FilePanel:
panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />; panel = <FilePanel roomId={roomId} resizeNotifier={this.props.resizeNotifier} onClose={this.onClose} />;
break; break;

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C. Copyright 2015, 2016, 2019, 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,39 +15,90 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import {MatrixClientPeg} from "../../MatrixClientPeg";
import * as sdk from "../../index"; import { MatrixClientPeg } from "../../MatrixClientPeg";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import Modal from "../../Modal"; import Modal from "../../Modal";
import { linkifyAndSanitizeHtml } from '../../HtmlUtils'; import { linkifyAndSanitizeHtml } from '../../HtmlUtils';
import PropTypes from 'prop-types';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import SdkConfig from '../../SdkConfig'; import SdkConfig from '../../SdkConfig';
import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils'; import { instanceForInstanceId, protocolNameForInstanceId } from '../../utils/DirectoryUtils';
import Analytics from '../../Analytics'; import Analytics from '../../Analytics';
import {ALL_ROOMS} from "../views/directory/NetworkDropdown"; import {ALL_ROOMS, IFieldType, IInstance, IProtocol, Protocols} from "../views/directory/NetworkDropdown";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore"; import GroupFilterOrderStore from "../../stores/GroupFilterOrderStore";
import GroupStore from "../../stores/GroupStore"; import GroupStore from "../../stores/GroupStore";
import FlairStore from "../../stores/FlairStore"; import FlairStore from "../../stores/FlairStore";
import CountlyAnalytics from "../../CountlyAnalytics"; import CountlyAnalytics from "../../CountlyAnalytics";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import {mediaFromMxc} from "../../customisations/Media"; import { mediaFromMxc } from "../../customisations/Media";
import { IDialogProps } from "../views/dialogs/IDialogProps";
import AccessibleButton, {ButtonEvent} from "../views/elements/AccessibleButton";
import BaseAvatar from "../views/avatars/BaseAvatar";
import ErrorDialog from "../views/dialogs/ErrorDialog";
import QuestionDialog from "../views/dialogs/QuestionDialog";
import BaseDialog from "../views/dialogs/BaseDialog";
import DirectorySearchBox from "../views/elements/DirectorySearchBox";
import NetworkDropdown from "../views/directory/NetworkDropdown";
import ScrollPanel from "./ScrollPanel";
import Spinner from "../views/elements/Spinner";
import { ActionPayload } from "../../dispatcher/payloads";
const MAX_NAME_LENGTH = 80; const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800; const MAX_TOPIC_LENGTH = 800;
function track(action) { function track(action: string) {
Analytics.trackEvent('RoomDirectory', action); Analytics.trackEvent('RoomDirectory', action);
} }
interface IProps extends IDialogProps {
initialText?: string;
}
interface IState {
publicRooms: IRoom[];
loading: boolean;
protocolsLoading: boolean;
error?: string;
instanceId: string | symbol;
roomServer: string;
filterString: string;
selectedCommunityId?: string;
communityName?: string;
}
/* eslint-disable camelcase */
interface IRoom {
room_id: string;
name?: string;
avatar_url?: string;
topic?: string;
canonical_alias?: string;
aliases?: string[];
world_readable: boolean;
guest_can_join: boolean;
num_joined_members: number;
}
interface IPublicRoomsRequest {
limit?: number;
since?: string;
server?: string;
filter?: object;
include_all_networks?: boolean;
third_party_instance_id?: string;
}
/* eslint-enable camelcase */
@replaceableComponent("structures.RoomDirectory") @replaceableComponent("structures.RoomDirectory")
export default class RoomDirectory extends React.Component { export default class RoomDirectory extends React.Component<IProps, IState> {
static propTypes = { private readonly startTime: number;
initialText: PropTypes.string, private unmounted = false
onFinished: PropTypes.func.isRequired, private nextBatch: string = null;
}; private filterTimeout: NodeJS.Timeout;
private protocols: Protocols;
constructor(props) { constructor(props) {
super(props); super(props);
@ -56,41 +106,21 @@ export default class RoomDirectory extends React.Component {
CountlyAnalytics.instance.trackRoomDirectoryBegin(); CountlyAnalytics.instance.trackRoomDirectoryBegin();
this.startTime = CountlyAnalytics.getTimestamp(); this.startTime = CountlyAnalytics.getTimestamp();
const selectedCommunityId = GroupFilterOrderStore.getSelectedTags()[0]; const selectedCommunityId = SettingsStore.getValue("feature_communities_v2_prototypes")
this.state = { ? GroupFilterOrderStore.getSelectedTags()[0]
publicRooms: [], : null;
loading: true,
protocolsLoading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId: SettingsStore.getValue("feature_communities_v2_prototypes")
? selectedCommunityId
: null,
communityName: null,
};
this._unmounted = false; let protocolsLoading = true;
this.nextBatch = null;
this.filterTimeout = null;
this.scrollPanel = null;
this.protocols = null;
this.state.protocolsLoading = true;
if (!MatrixClientPeg.get()) { if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page // We may not have a client yet when invoked from welcome page
this.state.protocolsLoading = false; protocolsLoading = false;
return; } else if (!selectedCommunityId) {
}
if (!this.state.selectedCommunityId) {
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => { MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
this.protocols = response; this.protocols = response;
this.setState({protocolsLoading: false}); this.setState({ protocolsLoading: false });
}, (err) => { }, (err) => {
console.warn(`error loading third party protocols: ${err}`); console.warn(`error loading third party protocols: ${err}`);
this.setState({protocolsLoading: false}); this.setState({ protocolsLoading: false });
if (MatrixClientPeg.get().isGuest()) { if (MatrixClientPeg.get().isGuest()) {
// Guests currently aren't allowed to use this API, so // Guests currently aren't allowed to use this API, so
// ignore this as otherwise this error is literally the // ignore this as otherwise this error is literally the
@ -103,19 +133,31 @@ export default class RoomDirectory extends React.Component {
error: _t( error: _t(
'%(brand)s failed to get the protocol list from the homeserver. ' + '%(brand)s failed to get the protocol list from the homeserver. ' +
'The homeserver may be too old to support third party networks.', 'The homeserver may be too old to support third party networks.',
{brand}, { brand },
), ),
}); });
}); });
} else { } else {
// We don't use the protocols in the communities v2 prototype experience // We don't use the protocols in the communities v2 prototype experience
this.state.protocolsLoading = false; protocolsLoading = false;
// Grab the profile info async // Grab the profile info async
FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => { FlairStore.getGroupProfileCached(MatrixClientPeg.get(), this.state.selectedCommunityId).then(profile => {
this.setState({communityName: profile.name}); this.setState({ communityName: profile.name });
}); });
} }
this.state = {
publicRooms: [],
loading: true,
error: null,
instanceId: undefined,
roomServer: MatrixClientPeg.getHomeserverName(),
filterString: this.props.initialText || "",
selectedCommunityId,
communityName: null,
protocolsLoading,
};
} }
componentDidMount() { componentDidMount() {
@ -126,10 +168,10 @@ export default class RoomDirectory extends React.Component {
if (this.filterTimeout) { if (this.filterTimeout) {
clearTimeout(this.filterTimeout); clearTimeout(this.filterTimeout);
} }
this._unmounted = true; this.unmounted = true;
} }
refreshRoomList = () => { private refreshRoomList = () => {
if (this.state.selectedCommunityId) { if (this.state.selectedCommunityId) {
this.setState({ this.setState({
publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => { publicRooms: GroupStore.getGroupRooms(this.state.selectedCommunityId).map(r => {
@ -165,7 +207,7 @@ export default class RoomDirectory extends React.Component {
this.getMoreRooms(); this.getMoreRooms();
}; };
getMoreRooms() { private getMoreRooms() {
if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms if (this.state.selectedCommunityId) return Promise.resolve(); // no more rooms
if (!MatrixClientPeg.get()) return Promise.resolve(); if (!MatrixClientPeg.get()) return Promise.resolve();
@ -173,34 +215,34 @@ export default class RoomDirectory extends React.Component {
loading: true, loading: true,
}); });
const my_filter_string = this.state.filterString; const filterString = this.state.filterString;
const my_server = this.state.roomServer; const roomServer = this.state.roomServer;
// remember the next batch token when we sent the request // remember the next batch token when we sent the request
// too. If it's changed, appending to the list will corrupt it. // too. If it's changed, appending to the list will corrupt it.
const my_next_batch = this.nextBatch; const nextBatch = this.nextBatch;
const opts = {limit: 20}; const opts: IPublicRoomsRequest = { limit: 20 };
if (my_server != MatrixClientPeg.getHomeserverName()) { if (roomServer != MatrixClientPeg.getHomeserverName()) {
opts.server = my_server; opts.server = roomServer;
} }
if (this.state.instanceId === ALL_ROOMS) { if (this.state.instanceId === ALL_ROOMS) {
opts.include_all_networks = true; opts.include_all_networks = true;
} else if (this.state.instanceId) { } else if (this.state.instanceId) {
opts.third_party_instance_id = this.state.instanceId; opts.third_party_instance_id = this.state.instanceId as string;
} }
if (this.nextBatch) opts.since = this.nextBatch; if (this.nextBatch) opts.since = this.nextBatch;
if (my_filter_string) opts.filter = { generic_search_term: my_filter_string }; if (filterString) opts.filter = { generic_search_term: filterString };
return MatrixClientPeg.get().publicRooms(opts).then((data) => { return MatrixClientPeg.get().publicRooms(opts).then((data) => {
if ( if (
my_filter_string != this.state.filterString || filterString != this.state.filterString ||
my_server != this.state.roomServer || roomServer != this.state.roomServer ||
my_next_batch != this.nextBatch) { nextBatch != this.nextBatch) {
// if the filter or server has changed since this request was sent, // if the filter or server has changed since this request was sent,
// throw away the result (don't even clear the busy flag // throw away the result (don't even clear the busy flag
// since we must still have a request in flight) // since we must still have a request in flight)
return; return;
} }
if (this._unmounted) { if (this.unmounted) {
// if we've been unmounted, we don't care either. // if we've been unmounted, we don't care either.
return; return;
} }
@ -211,23 +253,23 @@ export default class RoomDirectory extends React.Component {
} }
this.nextBatch = data.next_batch; this.nextBatch = data.next_batch;
this.setState((s) => { this.setState((s) => ({
s.publicRooms.push(...(data.chunk || [])); ...s,
s.loading = false; publicRooms: [...s.publicRooms, ...(data.chunk || [])],
return s; loading: false,
}); }));
return Boolean(data.next_batch); return Boolean(data.next_batch);
}, (err) => { }, (err) => {
if ( if (
my_filter_string != this.state.filterString || filterString != this.state.filterString ||
my_server != this.state.roomServer || roomServer != this.state.roomServer ||
my_next_batch != this.nextBatch) { nextBatch != this.nextBatch) {
// as above: we don't care about errors for old // as above: we don't care about errors for old
// requests either // requests either
return; return;
} }
if (this._unmounted) { if (this.unmounted) {
// if we've been unmounted, we don't care either. // if we've been unmounted, we don't care either.
return; return;
} }
@ -252,13 +294,10 @@ export default class RoomDirectory extends React.Component {
* HS admins to do this through the RoomSettings interface, but * HS admins to do this through the RoomSettings interface, but
* this needs SPEC-417. * this needs SPEC-417.
*/ */
removeFromDirectory(room) { private removeFromDirectory(room: IRoom) {
const alias = get_display_alias_for_room(room); const alias = getDisplayAliasForRoom(room);
const name = room.name || alias || _t('Unnamed room'); const name = room.name || alias || _t('Unnamed room');
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
let desc; let desc;
if (alias) { if (alias) {
desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name}); desc = _t('Delete the room address %(alias)s and remove %(name)s from the directory?', {alias, name});
@ -269,11 +308,10 @@ export default class RoomDirectory extends React.Component {
Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, { Modal.createTrackedDialog('Remove from Directory', '', QuestionDialog, {
title: _t('Remove from Directory'), title: _t('Remove from Directory'),
description: desc, description: desc,
onFinished: (should_delete) => { onFinished: (shouldDelete: boolean) => {
if (!should_delete) return; if (!shouldDelete) return;
const Loader = sdk.getComponent("elements.Spinner"); const modal = Modal.createDialog(Spinner);
const modal = Modal.createDialog(Loader);
let step = _t('remove %(name)s from the directory.', {name: name}); let step = _t('remove %(name)s from the directory.', {name: name});
MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => { MatrixClientPeg.get().setRoomDirectoryVisibility(room.room_id, 'private').then(() => {
@ -289,14 +327,16 @@ export default class RoomDirectory extends React.Component {
console.error("Failed to " + step + ": " + err); console.error("Failed to " + step + ": " + err);
Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, { Modal.createTrackedDialog('Remove from Directory Error', '', ErrorDialog, {
title: _t('Error'), title: _t('Error'),
description: ((err && err.message) ? err.message : _t('The server may be unavailable or overloaded')), description: (err && err.message)
? err.message
: _t('The server may be unavailable or overloaded'),
}); });
}); });
}, },
}); });
} }
onRoomClicked = (room, ev) => { private onRoomClicked = (room: IRoom, ev: ButtonEvent) => {
if (ev.shiftKey && !this.state.selectedCommunityId) { if (ev.shiftKey && !this.state.selectedCommunityId) {
ev.preventDefault(); ev.preventDefault();
this.removeFromDirectory(room); this.removeFromDirectory(room);
@ -305,7 +345,7 @@ export default class RoomDirectory extends React.Component {
} }
}; };
onOptionChange = (server, instanceId) => { private onOptionChange = (server: string, instanceId?: string | symbol) => {
// clear next batch so we don't try to load more rooms // clear next batch so we don't try to load more rooms
this.nextBatch = null; this.nextBatch = null;
this.setState({ this.setState({
@ -325,13 +365,13 @@ export default class RoomDirectory extends React.Component {
// Easiest to just blow away the state & re-fetch. // Easiest to just blow away the state & re-fetch.
}; };
onFillRequest = (backwards) => { private onFillRequest = (backwards: boolean) => {
if (backwards || !this.nextBatch) return Promise.resolve(false); if (backwards || !this.nextBatch) return Promise.resolve(false);
return this.getMoreRooms(); return this.getMoreRooms();
}; };
onFilterChange = (alias) => { private onFilterChange = (alias: string) => {
this.setState({ this.setState({
filterString: alias || null, filterString: alias || null,
}); });
@ -349,7 +389,7 @@ export default class RoomDirectory extends React.Component {
}, 700); }, 700);
}; };
onFilterClear = () => { private onFilterClear = () => {
// update immediately // update immediately
this.setState({ this.setState({
filterString: null, filterString: null,
@ -360,7 +400,7 @@ export default class RoomDirectory extends React.Component {
} }
}; };
onJoinFromSearchClick = (alias) => { private onJoinFromSearchClick = (alias: string) => {
// If we don't have a particular instance id selected, just show that rooms alias // If we don't have a particular instance id selected, just show that rooms alias
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
// If the user specified an alias without a domain, add on whichever server is selected // If the user specified an alias without a domain, add on whichever server is selected
@ -373,9 +413,10 @@ export default class RoomDirectory extends React.Component {
// This is a 3rd party protocol. Let's see if we can join it // This is a 3rd party protocol. Let's see if we can join it
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
const instance = instanceForInstanceId(this.protocols, this.state.instanceId); const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
const fields = protocolName ? this._getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance) : null; const fields = protocolName
? this.getFieldsForThirdPartyLocation(alias, this.protocols[protocolName], instance)
: null;
if (!fields) { if (!fields) {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
const brand = SdkConfig.get().brand; const brand = SdkConfig.get().brand;
Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, { Modal.createTrackedDialog('Unable to join network', '', ErrorDialog, {
title: _t('Unable to join network'), title: _t('Unable to join network'),
@ -387,14 +428,12 @@ export default class RoomDirectory extends React.Component {
if (resp.length > 0 && resp[0].alias) { if (resp.length > 0 && resp[0].alias) {
this.showRoomAlias(resp[0].alias, true); this.showRoomAlias(resp[0].alias, true);
} else { } else {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Room not found', '', ErrorDialog, { Modal.createTrackedDialog('Room not found', '', ErrorDialog, {
title: _t('Room not found'), title: _t('Room not found'),
description: _t('Couldn\'t find a matching Matrix room'), description: _t('Couldn\'t find a matching Matrix room'),
}); });
} }
}, (e) => { }, (e) => {
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, { Modal.createTrackedDialog('Fetching third party location failed', '', ErrorDialog, {
title: _t('Fetching third party location failed'), title: _t('Fetching third party location failed'),
description: _t('Unable to look up room ID from server'), description: _t('Unable to look up room ID from server'),
@ -403,36 +442,37 @@ export default class RoomDirectory extends React.Component {
} }
}; };
onPreviewClick = (ev, room) => { private onPreviewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, false, true); this.showRoom(room, null, false, true);
ev.stopPropagation(); ev.stopPropagation();
}; };
onViewClick = (ev, room) => { private onViewClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room); this.showRoom(room);
ev.stopPropagation(); ev.stopPropagation();
}; };
onJoinClick = (ev, room) => { private onJoinClick = (ev: ButtonEvent, room: IRoom) => {
this.showRoom(room, null, true); this.showRoom(room, null, true);
ev.stopPropagation(); ev.stopPropagation();
}; };
onCreateRoomClick = room => { private onCreateRoomClick = () => {
this.onFinished(); this.onFinished();
dis.dispatch({ dis.dispatch({
action: 'view_create_room', action: 'view_create_room',
public: true, public: true,
defaultName: this.state.filterString.trim(),
}); });
}; };
showRoomAlias(alias, autoJoin=false) { private showRoomAlias(alias: string, autoJoin = false) {
this.showRoom(null, alias, autoJoin); this.showRoom(null, alias, autoJoin);
} }
showRoom(room, room_alias, autoJoin = false, shouldPeek = false) { private showRoom(room: IRoom, roomAlias?: string, autoJoin = false, shouldPeek = false) {
this.onFinished(); this.onFinished();
const payload = { const payload: ActionPayload = {
action: 'view_room', action: 'view_room',
auto_join: autoJoin, auto_join: autoJoin,
should_peek: shouldPeek, should_peek: shouldPeek,
@ -449,15 +489,15 @@ export default class RoomDirectory extends React.Component {
} }
} }
if (!room_alias) { if (!roomAlias) {
room_alias = get_display_alias_for_room(room); roomAlias = getDisplayAliasForRoom(room);
} }
payload.oob_data = { payload.oob_data = {
avatarUrl: room.avatar_url, avatarUrl: room.avatar_url,
// XXX: This logic is duplicated from the JS SDK which // XXX: This logic is duplicated from the JS SDK which
// would normally decide what the name is. // would normally decide what the name is.
name: room.name || room_alias || _t('Unnamed room'), name: room.name || roomAlias || _t('Unnamed room'),
}; };
if (this.state.roomServer) { if (this.state.roomServer) {
@ -471,21 +511,19 @@ export default class RoomDirectory extends React.Component {
// which servers to start querying. However, there's no other way to join rooms in // which servers to start querying. However, there's no other way to join rooms in
// this list without aliases at present, so if roomAlias isn't set here we have no // this list without aliases at present, so if roomAlias isn't set here we have no
// choice but to supply the ID. // choice but to supply the ID.
if (room_alias) { if (roomAlias) {
payload.room_alias = room_alias; payload.room_alias = roomAlias;
} else { } else {
payload.room_id = room.room_id; payload.room_id = room.room_id;
} }
dis.dispatch(payload); dis.dispatch(payload);
} }
createRoomCells(room) { private createRoomCells(room: IRoom) {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const clientRoom = client.getRoom(room.room_id); const clientRoom = client.getRoom(room.room_id);
const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join"; const hasJoinedRoom = clientRoom && clientRoom.getMyMembership() === "join";
const isGuest = client.isGuest(); const isGuest = client.isGuest();
const BaseAvatar = sdk.getComponent('avatars.BaseAvatar');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let previewButton; let previewButton;
let joinOrViewButton; let joinOrViewButton;
@ -495,20 +533,26 @@ export default class RoomDirectory extends React.Component {
// it is readable, the preview appears as normal. // it is readable, the preview appears as normal.
if (!hasJoinedRoom && (room.world_readable || isGuest)) { if (!hasJoinedRoom && (room.world_readable || isGuest)) {
previewButton = ( previewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>{_t("Preview")}</AccessibleButton> <AccessibleButton kind="secondary" onClick={(ev) => this.onPreviewClick(ev, room)}>
{ _t("Preview") }
</AccessibleButton>
); );
} }
if (hasJoinedRoom) { if (hasJoinedRoom) {
joinOrViewButton = ( joinOrViewButton = (
<AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>{_t("View")}</AccessibleButton> <AccessibleButton kind="secondary" onClick={(ev) => this.onViewClick(ev, room)}>
{ _t("View") }
</AccessibleButton>
); );
} else if (!isGuest) { } else if (!isGuest) {
joinOrViewButton = ( joinOrViewButton = (
<AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>{_t("Join")}</AccessibleButton> <AccessibleButton kind="primary" onClick={(ev) => this.onJoinClick(ev, room)}>
{ _t("Join") }
</AccessibleButton>
); );
} }
let name = room.name || get_display_alias_for_room(room) || _t('Unnamed room'); let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
if (name.length > MAX_NAME_LENGTH) { if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`; name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
} }
@ -531,9 +575,13 @@ export default class RoomDirectory extends React.Component {
onMouseDown={(ev) => {ev.preventDefault();}} onMouseDown={(ev) => {ev.preventDefault();}}
className="mx_RoomDirectory_roomAvatar" className="mx_RoomDirectory_roomAvatar"
> >
<BaseAvatar width={32} height={32} resizeMethod='crop' <BaseAvatar
name={ name } idName={ name } width={32}
url={ avatarUrl } height={32}
resizeMethod='crop'
name={name}
idName={name}
url={avatarUrl}
/> />
</div>, </div>,
<div key={ `${room.room_id}_description` } <div key={ `${room.room_id}_description` }
@ -547,7 +595,7 @@ export default class RoomDirectory extends React.Component {
onClick={ (ev) => { ev.stopPropagation(); } } onClick={ (ev) => { ev.stopPropagation(); } }
dangerouslySetInnerHTML={{ __html: topic }} dangerouslySetInnerHTML={{ __html: topic }}
/> />
<div className="mx_RoomDirectory_alias">{ get_display_alias_for_room(room) }</div> <div className="mx_RoomDirectory_alias">{ getDisplayAliasForRoom(room) }</div>
</div>, </div>,
<div key={ `${room.room_id}_memberCount` } <div key={ `${room.room_id}_memberCount` }
onClick={(ev) => this.onRoomClicked(room, ev)} onClick={(ev) => this.onRoomClicked(room, ev)}
@ -576,20 +624,16 @@ export default class RoomDirectory extends React.Component {
]; ];
} }
collectScrollPanel = (element) => { private stringLooksLikeId(s: string, fieldType: IFieldType) {
this.scrollPanel = element;
};
_stringLooksLikeId(s, field_type) {
let pat = /^#[^\s]+:[^\s]/; let pat = /^#[^\s]+:[^\s]/;
if (field_type && field_type.regexp) { if (fieldType && fieldType.regexp) {
pat = new RegExp(field_type.regexp); pat = new RegExp(fieldType.regexp);
} }
return pat.test(s); return pat.test(s);
} }
_getFieldsForThirdPartyLocation(userInput, protocol, instance) { private getFieldsForThirdPartyLocation(userInput: string, protocol: IProtocol, instance: IInstance) {
// make an object with the fields specified by that protocol. We // make an object with the fields specified by that protocol. We
// require that the values of all but the last field come from the // require that the values of all but the last field come from the
// instance. The last is the user input. // instance. The last is the user input.
@ -605,71 +649,73 @@ export default class RoomDirectory extends React.Component {
return fields; return fields;
} }
/** private onFinished = () => {
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
*/
handleScrollKey = ev => {
if (this.scrollPanel) {
this.scrollPanel.handleScrollKey(ev);
}
};
onFinished = () => {
CountlyAnalytics.instance.trackRoomDirectory(this.startTime); CountlyAnalytics.instance.trackRoomDirectory(this.startTime);
this.props.onFinished(); this.props.onFinished(false);
}; };
render() { render() {
const Loader = sdk.getComponent("elements.Spinner");
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
let content; let content;
if (this.state.error) { if (this.state.error) {
content = this.state.error; content = this.state.error;
} else if (this.state.protocolsLoading) { } else if (this.state.protocolsLoading) {
content = <Loader />; content = <Spinner />;
} else { } else {
const cells = (this.state.publicRooms || []) const cells = (this.state.publicRooms || [])
.reduce((cells, room) => cells.concat(this.createRoomCells(room)), [],); .reduce((cells, room) => cells.concat(this.createRoomCells(room)), []);
// we still show the scrollpanel, at least for now, because // we still show the scrollpanel, at least for now, because
// otherwise we don't fetch more because we don't get a fill // otherwise we don't fetch more because we don't get a fill
// request from the scrollpanel because there isn't one // request from the scrollpanel because there isn't one
let spinner; let spinner;
if (this.state.loading) { if (this.state.loading) {
spinner = <Loader />; spinner = <Spinner />;
} }
let scrollpanel_content; const createNewButton = <>
<hr />
<AccessibleButton kind="primary" onClick={this.onCreateRoomClick} className="mx_RoomDirectory_newRoom">
{ _t("Create new room") }
</AccessibleButton>
</>;
let scrollPanelContent;
let footer;
if (cells.length === 0 && !this.state.loading) { if (cells.length === 0 && !this.state.loading) {
scrollpanel_content = <i>{ _t('No rooms to show') }</i>; footer = <>
<h5>{ _t('No results for "%(query)s"', { query: this.state.filterString.trim() }) }</h5>
<p>
{ _t("Try different words or check for typos. " +
"Some results may not be visible as they're private and you need an invite to join them.") }
</p>
{ createNewButton }
</>;
} else { } else {
scrollpanel_content = <div className="mx_RoomDirectory_table"> scrollPanelContent = <div className="mx_RoomDirectory_table">
{ cells } { cells }
</div>; </div>;
if (!this.state.loading && !this.nextBatch) {
footer = createNewButton;
}
} }
const ScrollPanel = sdk.getComponent("structures.ScrollPanel"); content = <ScrollPanel
content = <ScrollPanel ref={this.collectScrollPanel}
className="mx_RoomDirectory_tableWrapper" className="mx_RoomDirectory_tableWrapper"
onFillRequest={ this.onFillRequest } onFillRequest={this.onFillRequest}
stickyBottom={false} stickyBottom={false}
startAtBottom={false} startAtBottom={false}
> >
{ scrollpanel_content } { scrollPanelContent }
{ spinner } { spinner }
{ footer && <div className="mx_RoomDirectory_footer">
{ footer }
</div> }
</ScrollPanel>; </ScrollPanel>;
} }
let listHeader; let listHeader;
if (!this.state.protocolsLoading) { if (!this.state.protocolsLoading) {
const NetworkDropdown = sdk.getComponent('directory.NetworkDropdown');
const DirectorySearchBox = sdk.getComponent('elements.DirectorySearchBox');
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId); const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
let instance_expected_field_type; let instanceExpectedFieldType;
if ( if (
protocolName && protocolName &&
this.protocols && this.protocols &&
@ -677,21 +723,27 @@ export default class RoomDirectory extends React.Component {
this.protocols[protocolName].location_fields.length > 0 && this.protocols[protocolName].location_fields.length > 0 &&
this.protocols[protocolName].field_types this.protocols[protocolName].field_types
) { ) {
const last_field = this.protocols[protocolName].location_fields.slice(-1)[0]; const lastField = this.protocols[protocolName].location_fields.slice(-1)[0];
instance_expected_field_type = this.protocols[protocolName].field_types[last_field]; instanceExpectedFieldType = this.protocols[protocolName].field_types[lastField];
} }
let placeholder = _t('Find a room…'); let placeholder = _t('Find a room…');
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) { if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {exampleRoom: "#example:" + this.state.roomServer}); placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
} else if (instance_expected_field_type) { exampleRoom: "#example:" + this.state.roomServer,
placeholder = instance_expected_field_type.placeholder; });
} else if (instanceExpectedFieldType) {
placeholder = instanceExpectedFieldType.placeholder;
} }
let showJoinButton = this._stringLooksLikeId(this.state.filterString, instance_expected_field_type); let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
if (protocolName) { if (protocolName) {
const instance = instanceForInstanceId(this.protocols, this.state.instanceId); const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
if (this._getFieldsForThirdPartyLocation(this.state.filterString, this.protocols[protocolName], instance) === null) { if (this.getFieldsForThirdPartyLocation(
this.state.filterString,
this.protocols[protocolName],
instance,
) === null) {
showJoinButton = false; showJoinButton = false;
} }
} }
@ -723,12 +775,11 @@ export default class RoomDirectory extends React.Component {
} }
const explanation = const explanation =
_t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null, _t("If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", null,
{a: sub => { {a: sub => (
return (<AccessibleButton <AccessibleButton kind="secondary" onClick={this.onCreateRoomClick}>
kind="secondary" { sub }
onClick={this.onCreateRoomClick} </AccessibleButton>
>{sub}</AccessibleButton>); )},
}},
); );
const title = this.state.selectedCommunityId const title = this.state.selectedCommunityId
@ -756,6 +807,6 @@ export default class RoomDirectory extends React.Component {
// Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom // Similar to matrix-react-sdk's MatrixTools.getDisplayAliasForRoom
// but works with the objects we get from the public room list // but works with the objects we get from the public room list
function get_display_alias_for_room(room) { function getDisplayAliasForRoom(room: IRoom) {
return room.canonical_alias || (room.aliases ? room.aliases[0] : ""); return room.canonical_alias || room.aliases?.[0] || "";
} }

View file

@ -46,7 +46,7 @@ import RoomViewStore from '../../stores/RoomViewStore';
import RoomScrollStateStore from '../../stores/RoomScrollStateStore'; import RoomScrollStateStore from '../../stores/RoomScrollStateStore';
import WidgetEchoStore from '../../stores/WidgetEchoStore'; import WidgetEchoStore from '../../stores/WidgetEchoStore';
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import {Layout} from "../../settings/Layout"; import { Layout } from "../../settings/Layout";
import AccessibleButton from "../views/elements/AccessibleButton"; import AccessibleButton from "../views/elements/AccessibleButton";
import RightPanelStore from "../../stores/RightPanelStore"; import RightPanelStore from "../../stores/RightPanelStore";
import { haveTileForEvent } from "../views/rooms/EventTile"; import { haveTileForEvent } from "../views/rooms/EventTile";
@ -54,7 +54,6 @@ import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils'; import { E2EStatus, shieldStatusForRoom } from '../../utils/ShieldUtils';
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import { SettingLevel } from "../../settings/SettingLevel";
import { IMatrixClientCreds } from "../../MatrixClientPeg"; import { IMatrixClientCreds } from "../../MatrixClientPeg";
import ScrollPanel from "./ScrollPanel"; import ScrollPanel from "./ScrollPanel";
import TimelinePanel from "./TimelinePanel"; import TimelinePanel from "./TimelinePanel";
@ -63,7 +62,6 @@ import RoomPreviewBar from "../views/rooms/RoomPreviewBar";
import ForwardMessage from "../views/rooms/ForwardMessage"; import ForwardMessage from "../views/rooms/ForwardMessage";
import SearchBar from "../views/rooms/SearchBar"; import SearchBar from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar"; import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import PinnedEventsPanel from "../views/rooms/PinnedEventsPanel";
import AuxPanel from "../views/rooms/AuxPanel"; import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader from "../views/rooms/RoomHeader"; import RoomHeader from "../views/rooms/RoomHeader";
import { XOR } from "../../@types/common"; import { XOR } from "../../@types/common";
@ -82,7 +80,9 @@ import { getKeyBindingsManager, RoomAction } from '../../KeyBindingsManager';
import { objectHasDiff } from "../../utils/objects"; import { objectHasDiff } from "../../utils/objects";
import SpaceRoomView from "./SpaceRoomView"; import SpaceRoomView from "./SpaceRoomView";
import { IOpts } from "../../createRoom"; import { IOpts } from "../../createRoom";
import {replaceableComponent} from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { omit } from 'lodash';
import UIStore from "../../stores/UIStore";
const DEBUG = false; const DEBUG = false;
let debuglog = function(msg: string) {}; let debuglog = function(msg: string) {};
@ -155,7 +155,6 @@ export interface IState {
canPeek: boolean; canPeek: boolean;
showApps: boolean; showApps: boolean;
isPeeking: boolean; isPeeking: boolean;
showingPinned: boolean;
showReadReceipts: boolean; showReadReceipts: boolean;
showRightPanel: boolean; showRightPanel: boolean;
// error object, as from the matrix client/server API // error object, as from the matrix client/server API
@ -175,6 +174,7 @@ export interface IState {
statusBarVisible: boolean; statusBarVisible: boolean;
// We load this later by asking the js-sdk to suggest a version for us. // We load this later by asking the js-sdk to suggest a version for us.
// This object is the result of Room#getRecommendedVersion() // This object is the result of Room#getRecommendedVersion()
upgradeRecommendation?: { upgradeRecommendation?: {
version: string; version: string;
needsUpgrade: boolean; needsUpgrade: boolean;
@ -232,7 +232,6 @@ export default class RoomView extends React.Component<IProps, IState> {
canPeek: false, canPeek: false,
showApps: false, showApps: false,
isPeeking: false, isPeeking: false,
showingPinned: false,
showReadReceipts: true, showReadReceipts: true,
showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom, showRightPanel: RightPanelStore.getSharedInstance().isOpenForRoom,
joining: false, joining: false,
@ -327,7 +326,6 @@ export default class RoomView extends React.Component<IProps, IState> {
forwardingEvent: RoomViewStore.getForwardingEvent(), forwardingEvent: RoomViewStore.getForwardingEvent(),
// we should only peek once we have a ready client // we should only peek once we have a ready client
shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(), shouldPeek: this.state.matrixClientIsReady && RoomViewStore.shouldPeek(),
showingPinned: SettingsStore.getValue("PinnedEvents.isOpen", roomId),
showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId), showReadReceipts: SettingsStore.getValue("showReadReceipts", roomId),
wasContextSwitch: RoomViewStore.getWasContextSwitch(), wasContextSwitch: RoomViewStore.getWasContextSwitch(),
}; };
@ -528,7 +526,20 @@ export default class RoomView extends React.Component<IProps, IState> {
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
return (objectHasDiff(this.props, nextProps) || objectHasDiff(this.state, nextState)); const hasPropsDiff = objectHasDiff(this.props, nextProps);
// React only shallow comparison and we only want to trigger
// a component re-render if a room requires an upgrade
const newUpgradeRecommendation = nextState.upgradeRecommendation || {}
const state = omit(this.state, ['upgradeRecommendation']);
const newState = omit(nextState, ['upgradeRecommendation'])
const hasStateDiff =
objectHasDiff(state, newState) ||
(newUpgradeRecommendation.needsUpgrade === true)
return hasPropsDiff || hasStateDiff;
} }
componentDidUpdate() { componentDidUpdate() {
@ -641,6 +652,17 @@ export default class RoomView extends React.Component<IProps, IState> {
SettingsStore.unwatchSetting(this.layoutWatcherRef); SettingsStore.unwatchSetting(this.layoutWatcherRef);
} }
private onUserScroll = () => {
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
dis.dispatch({
action: 'view_room',
room_id: this.state.room.roomId,
event_id: this.state.initialEventId,
highlighted: false,
});
}
}
private onLayoutChange = () => { private onLayoutChange = () => {
this.setState({ this.setState({
layout: SettingsStore.getValue("layout"), layout: SettingsStore.getValue("layout"),
@ -811,7 +833,7 @@ export default class RoomView extends React.Component<IProps, IState> {
}; };
private onEvent = (ev) => { private onEvent = (ev) => {
if (ev.isBeingDecrypted() || ev.isDecryptionFailure() || ev.shouldAttemptDecryption()) return; if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
this.handleEffects(ev); this.handleEffects(ev);
}; };
@ -1114,7 +1136,8 @@ export default class RoomView extends React.Component<IProps, IState> {
Promise.resolve().then(() => { Promise.resolve().then(() => {
const signUrl = this.props.threepidInvite?.signUrl; const signUrl = this.props.threepidInvite?.signUrl;
dis.dispatch({ dis.dispatch({
action: 'join_room', action: Action.JoinRoom,
roomId: this.getRoomId(),
opts: { inviteSignUrl: signUrl }, opts: { inviteSignUrl: signUrl },
_type: "unknown", // TODO: instrumentation _type: "unknown", // TODO: instrumentation
}); });
@ -1375,13 +1398,6 @@ export default class RoomView extends React.Component<IProps, IState> {
return ret; return ret;
} }
private onPinnedClick = () => {
const nowShowingPinned = !this.state.showingPinned;
const roomId = this.state.room.roomId;
this.setState({showingPinned: nowShowingPinned, searching: false});
SettingsStore.setValue("PinnedEvents.isOpen", roomId, SettingLevel.ROOM_DEVICE, nowShowingPinned);
};
private onCallPlaced = (type: PlaceCallType) => { private onCallPlaced = (type: PlaceCallType) => {
dis.dispatch({ dis.dispatch({
action: 'place_call', action: 'place_call',
@ -1498,7 +1514,6 @@ export default class RoomView extends React.Component<IProps, IState> {
private onSearchClick = () => { private onSearchClick = () => {
this.setState({ this.setState({
searching: !this.state.searching, searching: !this.state.searching,
showingPinned: false,
}); });
}; };
@ -1511,8 +1526,10 @@ export default class RoomView extends React.Component<IProps, IState> {
// jump down to the bottom of this room, where new events are arriving // jump down to the bottom of this room, where new events are arriving
private jumpToLiveTimeline = () => { private jumpToLiveTimeline = () => {
this.messagePanel.jumpToLiveTimeline(); dis.dispatch({
dis.fire(Action.FocusComposer); action: 'view_room',
room_id: this.state.room.roomId,
});
}; };
// jump up to wherever our read marker is // jump up to wherever our read marker is
@ -1585,7 +1602,7 @@ export default class RoomView extends React.Component<IProps, IState> {
// a maxHeight on the underlying remote video tag. // a maxHeight on the underlying remote video tag.
// header + footer + status + give us at least 120px of scrollback at all times. // header + footer + status + give us at least 120px of scrollback at all times.
let auxPanelMaxHeight = window.innerHeight - let auxPanelMaxHeight = UIStore.instance.windowHeight -
(54 + // height of RoomHeader (54 + // height of RoomHeader
36 + // height of the status area 36 + // height of the status area
51 + // minimum height of the message compmoser 51 + // minimum height of the message compmoser
@ -1598,33 +1615,6 @@ export default class RoomView extends React.Component<IProps, IState> {
this.setState({auxPanelMaxHeight: auxPanelMaxHeight}); this.setState({auxPanelMaxHeight: auxPanelMaxHeight});
}; };
private onFullscreenClick = () => {
dis.dispatch({
action: 'video_fullscreen',
fullscreen: true,
}, true);
};
private onMuteAudioClick = () => {
const call = this.getCallForRoom();
if (!call) {
return;
}
const newState = !call.isMicrophoneMuted();
call.setMicrophoneMuted(newState);
this.forceUpdate(); // TODO: just update the voip buttons
};
private onMuteVideoClick = () => {
const call = this.getCallForRoom();
if (!call) {
return;
}
const newState = !call.isLocalVideoMuted();
call.setLocalVideoMuted(newState);
this.forceUpdate(); // TODO: just update the voip buttons
};
private onStatusBarVisible = () => { private onStatusBarVisible = () => {
if (this.unmounted) return; if (this.unmounted) return;
this.setState({ this.setState({
@ -1640,24 +1630,6 @@ export default class RoomView extends React.Component<IProps, IState> {
}); });
}; };
/**
* called by the parent component when PageUp/Down/etc is pressed.
*
* We pass it down to the scroll panel.
*/
private handleScrollKey = ev => {
let panel;
if (this.searchResultsPanel.current) {
panel = this.searchResultsPanel.current;
} else if (this.messagePanel) {
panel = this.messagePanel;
}
if (panel) {
panel.handleScrollKey(ev);
}
};
/** /**
* get any current call for this room * get any current call for this room
*/ */
@ -1870,9 +1842,6 @@ export default class RoomView extends React.Component<IProps, IState> {
} else if (showRoomUpgradeBar) { } else if (showRoomUpgradeBar) {
aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />; aux = <RoomUpgradeWarningBar room={this.state.room} recommendation={roomVersionRecommendation} />;
hideCancel = true; hideCancel = true;
} else if (this.state.showingPinned) {
hideCancel = true; // has own cancel
aux = <PinnedEventsPanel room={this.state.room} onCancelClick={this.onPinnedClick} />;
} else if (myMembership !== "join") { } else if (myMembership !== "join") {
// We do have a room object for this room, but we're not currently in it. // We do have a room object for this room, but we're not currently in it.
// We may have a 3rd party invite to it. // We may have a 3rd party invite to it.
@ -2028,6 +1997,7 @@ export default class RoomView extends React.Component<IProps, IState> {
eventId={this.state.initialEventId} eventId={this.state.initialEventId}
eventPixelOffset={this.state.initialEventPixelOffset} eventPixelOffset={this.state.initialEventPixelOffset}
onScroll={this.onMessageListScroll} onScroll={this.onMessageListScroll}
onUserScroll={this.onUserScroll}
onReadMarkerUpdated={this.updateTopUnreadMessagesBar} onReadMarkerUpdated={this.updateTopUnreadMessagesBar}
showUrlPreview = {this.state.showUrlPreview} showUrlPreview = {this.state.showUrlPreview}
className={messagePanelClassNames} className={messagePanelClassNames}
@ -2054,6 +2024,7 @@ export default class RoomView extends React.Component<IProps, IState> {
highlight={this.state.room.getUnreadNotificationCount('highlight') > 0} highlight={this.state.room.getUnreadNotificationCount('highlight') > 0}
numUnreadMessages={this.state.numUnreadMessages} numUnreadMessages={this.state.numUnreadMessages}
onScrollToBottomClick={this.jumpToLiveTimeline} onScrollToBottomClick={this.jumpToLiveTimeline}
roomId={this.state.roomId}
/>); />);
} }
@ -2090,7 +2061,6 @@ export default class RoomView extends React.Component<IProps, IState> {
inRoom={myMembership === 'join'} inRoom={myMembership === 'join'}
onSearchClick={this.onSearchClick} onSearchClick={this.onSearchClick}
onSettingsClick={this.onSettingsClick} onSettingsClick={this.onSettingsClick}
onPinnedClick={this.onPinnedClick}
onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null} onCancelClick={(aux && !hideCancel) ? this.onCancelClick : null}
onForgetClick={(myMembership === "leave") ? this.onForgetClick : null} onForgetClick={(myMembership === "leave") ? this.onForgetClick : null}
onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null} onLeaveClick={(myMembership === "join") ? this.onLeaveClick : null}

View file

@ -133,6 +133,10 @@ export default class ScrollPanel extends React.Component {
*/ */
onScroll: PropTypes.func, onScroll: PropTypes.func,
/* onUserScroll: callback which is called when the user interacts with the room timeline
*/
onUserScroll: PropTypes.func,
/* className: classnames to add to the top-level div /* className: classnames to add to the top-level div
*/ */
className: PropTypes.string, className: PropTypes.string,
@ -535,21 +539,29 @@ export default class ScrollPanel extends React.Component {
* @param {object} ev the keyboard event * @param {object} ev the keyboard event
*/ */
handleScrollKey = ev => { handleScrollKey = ev => {
let isScrolling = false;
const roomAction = getKeyBindingsManager().getRoomAction(ev); const roomAction = getKeyBindingsManager().getRoomAction(ev);
switch (roomAction) { switch (roomAction) {
case RoomAction.ScrollUp: case RoomAction.ScrollUp:
this.scrollRelative(-1); this.scrollRelative(-1);
isScrolling = true;
break; break;
case RoomAction.RoomScrollDown: case RoomAction.RoomScrollDown:
this.scrollRelative(1); this.scrollRelative(1);
isScrolling = true;
break; break;
case RoomAction.JumpToFirstMessage: case RoomAction.JumpToFirstMessage:
this.scrollToTop(); this.scrollToTop();
isScrolling = true;
break; break;
case RoomAction.JumpToLatestMessage: case RoomAction.JumpToLatestMessage:
this.scrollToBottom(); this.scrollToBottom();
isScrolling = true;
break; break;
} }
if (isScrolling && this.props.onUserScroll) {
this.props.onUserScroll(ev);
}
}; };
/* Scroll the panel to bring the DOM node with the scroll token /* Scroll the panel to bring the DOM node with the scroll token
@ -888,9 +900,8 @@ export default class ScrollPanel extends React.Component {
<AutoHideScrollbar <AutoHideScrollbar
wrappedRef={this._collectScroll} wrappedRef={this._collectScroll}
onScroll={this.onScroll} onScroll={this.onScroll}
className={`mx_ScrollPanel ${this.props.className}`} onWheel={this.props.onUserScroll}
style={this.props.style} className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
>
{ this.props.fixedChildren } { this.props.fixedChildren }
<div className="mx_RoomView_messageListWrapper"> <div className="mx_RoomView_messageListWrapper">
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list"> <ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">

View file

@ -76,7 +76,7 @@ export interface ISpaceSummaryEvent {
order?: string; order?: string;
suggested?: boolean; suggested?: boolean;
auto_join?: boolean; auto_join?: boolean;
via?: string; via?: string[];
}; };
} }
/* eslint-enable camelcase */ /* eslint-enable camelcase */
@ -101,15 +101,13 @@ const Tile: React.FC<ITileProps> = ({
numChildRooms, numChildRooms,
children, children,
}) => { }) => {
const name = room.name || room.canonical_alias || room.aliases?.[0] const cli = MatrixClientPeg.get();
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]
|| (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);
const cli = MatrixClientPeg.get();
const cliRoom = cli.getRoom(room.room_id);
const myMembership = cliRoom?.getMyMembership();
const onPreviewClick = (ev: ButtonEvent) => { const onPreviewClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -122,7 +120,7 @@ const Tile: React.FC<ITileProps> = ({
} }
let button; let button;
if (myMembership === "join") { if (joinedRoom) {
button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline"> button = <AccessibleButton onClick={onPreviewClick} kind="primary_outline">
{ _t("View") } { _t("View") }
</AccessibleButton>; </AccessibleButton>;
@ -146,17 +144,27 @@ const Tile: React.FC<ITileProps> = ({
} }
} }
let url: string; let avatar;
if (room.avatar_url) { if (joinedRoom) {
url = mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20); avatar = <RoomAvatar room={joinedRoom} width={20} height={20} />;
} else {
avatar = <BaseAvatar
name={name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>;
} }
let description = _t("%(count)s members", { count: room.num_joined_members }); let description = _t("%(count)s members", { count: room.num_joined_members });
if (numChildRooms !== undefined) { if (numChildRooms !== undefined) {
description += " · " + _t("%(count)s rooms", { count: numChildRooms }); description += " · " + _t("%(count)s rooms", { count: numChildRooms });
} }
if (room.topic) {
description += " · " + room.topic; const topic = joinedRoom?.currentState?.getStateEvents(EventType.RoomTopic, "")?.getContent()?.topic || room.topic;
if (topic) {
description += " · " + topic;
} }
let suggestedSection; let suggestedSection;
@ -167,7 +175,7 @@ const Tile: React.FC<ITileProps> = ({
} }
const content = <React.Fragment> const content = <React.Fragment>
<BaseAvatar name={name} idName={room.room_id} url={url} width={20} height={20} /> { avatar }
<div className="mx_SpaceRoomDirectory_roomTile_name"> <div className="mx_SpaceRoomDirectory_roomTile_name">
{ name } { name }
{ suggestedSection } { suggestedSection }
@ -311,7 +319,7 @@ export const HierarchyLevel = ({
key={roomId} key={roomId}
room={rooms.get(roomId)} room={rooms.get(roomId)}
numChildRooms={Array.from(relations.get(roomId)?.values() || []) numChildRooms={Array.from(relations.get(roomId)?.values() || [])
.filter(ev => rooms.get(ev.state_key)?.room_type !== RoomType.Space).length} .filter(ev => rooms.has(ev.state_key) && !rooms.get(ev.state_key).room_type).length}
suggested={relations.get(spaceId)?.get(roomId)?.content.suggested} suggested={relations.get(spaceId)?.get(roomId)?.content.suggested}
selected={selectedMap?.get(spaceId)?.has(roomId)} selected={selectedMap?.get(spaceId)?.has(roomId)}
onViewRoomClick={(autoJoin) => { onViewRoomClick={(autoJoin) => {
@ -356,9 +364,9 @@ export const useSpaceSummary = (cli: MatrixClient, space: Room, refreshToken?: a
parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev); parentChildRelations.getOrCreate(ev.room_id, new Map()).set(ev.state_key, ev);
childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id); childParentRelations.getOrCreate(ev.state_key, new Set()).add(ev.room_id);
} }
if (Array.isArray(ev.content["via"])) { if (Array.isArray(ev.content.via)) {
const set = viaMap.getOrCreate(ev.state_key, new Set()); const set = viaMap.getOrCreate(ev.state_key, new Set());
ev.content["via"].forEach(via => set.add(via)); ev.content.via.forEach(via => set.add(via));
} }
}); });
@ -429,7 +437,7 @@ export const SpaceHierarchy: React.FC<IHierarchyProps> = ({
let content; let content;
if (roomsMap) { if (roomsMap) {
const numRooms = Array.from(roomsMap.values()).filter(r => r.room_type !== RoomType.Space).length; const numRooms = Array.from(roomsMap.values()).filter(r => !r.room_type).length;
const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at const numSpaces = roomsMap.size - numRooms - 1; // -1 at the end to exclude the space we are looking at
let countsStr; let countsStr;

View file

@ -417,9 +417,13 @@ const SpaceLanding = ({ space }) => {
{ inviteButton } { inviteButton }
{ settingsButton } { settingsButton }
</div> </div>
<div className="mx_SpaceRoomView_landing_topic"> <RoomTopic room={space}>
<RoomTopic room={space} /> {(topic, ref) => (
</div> <div className="mx_SpaceRoomView_landing_topic" ref={ref}>
{ topic }
</div>
)}
</RoomTopic>
<SpaceFeedbackPrompt /> <SpaceFeedbackPrompt />
<hr /> <hr />
@ -437,7 +441,6 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => {
const [error, setError] = useState(""); const [error, setError] = useState("");
const numFields = 3; const numFields = 3;
const placeholders = [_t("General"), _t("Random"), _t("Support")]; const placeholders = [_t("General"), _t("Random"), _t("Support")];
// TODO vary default prefills for "Just Me" spaces
const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]); const [roomNames, setRoomName] = useStateArray(numFields, [_t("General"), _t("Random"), ""]);
const fields = new Array(numFields).fill(0).map((_, i) => { const fields = new Array(numFields).fill(0).map((_, i) => {
const name = "roomName" + i; const name = "roomName" + i;
@ -806,7 +809,7 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
let suggestedRooms = SpaceStore.instance.suggestedRooms; let suggestedRooms = SpaceStore.instance.suggestedRooms;
if (SpaceStore.instance.activeSpace !== this.props.space) { if (SpaceStore.instance.activeSpace !== this.props.space) {
// the space store has the suggested rooms loaded for a different space, fetch the right ones // the space store has the suggested rooms loaded for a different space, fetch the right ones
suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1)).rooms; suggestedRooms = (await SpaceStore.instance.fetchSuggestedRooms(this.props.space, 1));
} }
if (suggestedRooms.length) { if (suggestedRooms.length) {
@ -814,9 +817,11 @@ export default class SpaceRoomView extends React.PureComponent<IProps, IState> {
defaultDispatcher.dispatch({ defaultDispatcher.dispatch({
action: "view_room", action: "view_room",
room_id: room.room_id, room_id: room.room_id,
room_alias: room.canonical_alias || room.aliases?.[0],
via_servers: room.viaServers,
oobData: { oobData: {
avatarUrl: room.avatar_url, avatarUrl: room.avatar_url,
name: room.name || room.canonical_alias || room.aliases.pop() || _t("Empty room"), name: room.name || room.canonical_alias || room.aliases?.[0] || _t("Empty room"),
}, },
}); });
return; return;

View file

@ -36,7 +36,6 @@ import shouldHideEvent from '../../shouldHideEvent';
import EditorStateTransfer from '../../utils/EditorStateTransfer'; import EditorStateTransfer from '../../utils/EditorStateTransfer';
import {haveTileForEvent} from "../views/rooms/EventTile"; import {haveTileForEvent} from "../views/rooms/EventTile";
import {UIFeature} from "../../settings/UIFeature"; import {UIFeature} from "../../settings/UIFeature";
import {objectHasDiff} from "../../utils/objects";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import { arrayFastClone } from "../../utils/arrays"; import { arrayFastClone } from "../../utils/arrays";
@ -94,6 +93,9 @@ class TimelinePanel extends React.Component {
// callback which is called when the panel is scrolled. // callback which is called when the panel is scrolled.
onScroll: PropTypes.func, onScroll: PropTypes.func,
// callback which is called when the user interacts with the room timeline
onUserScroll: PropTypes.func,
// callback which is called when the read-up-to mark is updated. // callback which is called when the read-up-to mark is updated.
onReadMarkerUpdated: PropTypes.func, onReadMarkerUpdated: PropTypes.func,
@ -258,37 +260,15 @@ class TimelinePanel extends React.Component {
console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue"); console.warn("Replacing timelineSet on a TimelinePanel - confusion may ensue");
} }
if (newProps.eventId != this.props.eventId) { const differentEventId = newProps.eventId != this.props.eventId;
const differentHighlightedEventId = newProps.highlightedEventId != this.props.highlightedEventId;
if (differentEventId || differentHighlightedEventId) {
console.log("TimelinePanel switching to eventId " + newProps.eventId + console.log("TimelinePanel switching to eventId " + newProps.eventId +
" (was " + this.props.eventId + ")"); " (was " + this.props.eventId + ")");
return this._initTimeline(newProps); return this._initTimeline(newProps);
} }
} }
shouldComponentUpdate(nextProps, nextState) {
if (objectHasDiff(this.props, nextProps)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: props change");
console.log("props before:", this.props);
console.log("props after:", nextProps);
console.groupEnd();
}
return true;
}
if (objectHasDiff(this.state, nextState)) {
if (DEBUG) {
console.group("Timeline.shouldComponentUpdate: state change");
console.log("state before:", this.state);
console.log("state after:", nextState);
console.groupEnd();
}
return true;
}
return false;
}
componentWillUnmount() { componentWillUnmount() {
// set a boolean to say we've been unmounted, which any pending // set a boolean to say we've been unmounted, which any pending
// promises can use to throw away their results. // promises can use to throw away their results.
@ -1456,6 +1436,7 @@ class TimelinePanel extends React.Component {
ourUserId={MatrixClientPeg.get().credentials.userId} ourUserId={MatrixClientPeg.get().credentials.userId}
stickyBottom={stickyBottom} stickyBottom={stickyBottom}
onScroll={this.onMessageListScroll} onScroll={this.onMessageListScroll}
onUserScroll={this.props.onUserScroll}
onFillRequest={this.onMessageListFillRequest} onFillRequest={this.onMessageListFillRequest}
onUnfillRequest={this.onMessageListUnfillRequest} onUnfillRequest={this.onMessageListUnfillRequest}
isTwelveHour={this.state.isTwelveHour} isTwelveHour={this.state.isTwelveHour}

View file

@ -55,6 +55,7 @@ export default class ToastContainer extends React.Component<{}, IState> {
const totalCount = this.state.toasts.length; const totalCount = this.state.toasts.length;
const isStacked = totalCount > 1; const isStacked = totalCount > 1;
let toast; let toast;
let containerClasses;
if (totalCount !== 0) { if (totalCount !== 0) {
const topToast = this.state.toasts[0]; const topToast = this.state.toasts[0];
const {title, icon, key, component, className, props} = topToast; const {title, icon, key, component, className, props} = topToast;
@ -79,16 +80,17 @@ export default class ToastContainer extends React.Component<{}, IState> {
</div> </div>
<div className="mx_Toast_body">{React.createElement(component, toastProps)}</div> <div className="mx_Toast_body">{React.createElement(component, toastProps)}</div>
</div>); </div>);
containerClasses = classNames("mx_ToastContainer", {
"mx_ToastContainer_stacked": isStacked,
});
} }
return toast
const containerClasses = classNames("mx_ToastContainer", { ? (
"mx_ToastContainer_stacked": isStacked, <div className={containerClasses} role="alert">
}); {toast}
</div>
return ( )
<div className={containerClasses} role="alert"> : null;
{toast}
</div>
);
} }
} }

View file

@ -57,7 +57,8 @@ import { IHostSignupConfig } from "../views/dialogs/HostSignupDialogTypes";
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore"; import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
import RoomName from "../views/elements/RoomName"; import RoomName from "../views/elements/RoomName";
import {replaceableComponent} from "../../utils/replaceableComponent"; import {replaceableComponent} from "../../utils/replaceableComponent";
import InlineSpinner from "../views/elements/InlineSpinner";
import TooltipButton from "../views/elements/TooltipButton";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
} }
@ -68,6 +69,7 @@ interface IState {
contextMenuPosition: PartialDOMRect; contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean; isDarkTheme: boolean;
selectedSpace?: Room; selectedSpace?: Room;
pendingRoomJoin: Set<string>;
} }
@replaceableComponent("structures.UserMenu") @replaceableComponent("structures.UserMenu")
@ -84,6 +86,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.state = { this.state = {
contextMenuPosition: null, contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(), isDarkTheme: this.isUserOnDarkTheme(),
pendingRoomJoin: new Set<string>(),
}; };
OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate); OwnProfileStore.instance.on(UPDATE_EVENT, this.onProfileUpdate);
@ -103,6 +106,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
this.dispatcherRef = defaultDispatcher.register(this.onAction); this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged); this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate); this.tagStoreRef = GroupFilterOrderStore.addListener(this.onTagStoreUpdate);
MatrixClientPeg.get().on("Room", this.onRoom);
} }
public componentWillUnmount() { public componentWillUnmount() {
@ -114,6 +118,11 @@ export default class UserMenu extends React.Component<IProps, IState> {
if (SettingsStore.getValue("feature_spaces")) { if (SettingsStore.getValue("feature_spaces")) {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdate);
} }
MatrixClientPeg.get().removeListener("Room", this.onRoom);
}
private onRoom = (room: Room): void => {
this.removePendingJoinRoom(room.roomId);
} }
private onTagStoreUpdate = () => { private onTagStoreUpdate = () => {
@ -147,15 +156,39 @@ export default class UserMenu extends React.Component<IProps, IState> {
}; };
private onAction = (ev: ActionPayload) => { private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested switch (ev.action) {
case Action.ToggleUserMenu:
if (this.state.contextMenuPosition) { if (this.state.contextMenuPosition) {
this.setState({contextMenuPosition: null}); this.setState({contextMenuPosition: null});
} else { } else {
if (this.buttonRef.current) this.buttonRef.current.click(); if (this.buttonRef.current) this.buttonRef.current.click();
}
break;
case Action.JoinRoom:
this.addPendingJoinRoom(ev.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
this.removePendingJoinRoom(ev.roomId);
break;
} }
}; };
private addPendingJoinRoom(roomId: string): void {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin)
.add(roomId),
});
}
private removePendingJoinRoom(roomId: string): void {
if (this.state.pendingRoomJoin.delete(roomId)) {
this.setState({
pendingRoomJoin: new Set<string>(this.state.pendingRoomJoin),
})
}
}
private onOpenMenuClick = (ev: React.MouseEvent) => { private onOpenMenuClick = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
@ -617,6 +650,14 @@ export default class UserMenu extends React.Component<IProps, IState> {
/> />
</span> </span>
{name} {name}
{this.state.pendingRoomJoin.size > 0 && (
<InlineSpinner>
<TooltipButton helpText={_t(
"Currently joining %(count)s rooms",
{ count: this.state.pendingRoomJoin.size },
)} />
</InlineSpinner>
)}
{dnd} {dnd}
{buttons} {buttons}
</div> </div>

View file

@ -59,6 +59,7 @@ interface IProps {
fallbackHsUrl?: string; fallbackHsUrl?: string;
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
fragmentAfterLogin?: string; fragmentAfterLogin?: string;
defaultUsername?: string;
// Called when the user has logged in. Params: // Called when the user has logged in. Params:
// - The object returned by the login API // - The object returned by the login API
@ -119,7 +120,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
flows: null, flows: null,
username: "", username: props.defaultUsername? props.defaultUsername: '',
phoneCountry: null, phoneCountry: null,
phoneNumber: "", phoneNumber: "",

View file

@ -61,7 +61,7 @@ interface IProps {
is_url?: string; is_url?: string;
session_id: string; session_id: string;
/* eslint-enable camelcase */ /* eslint-enable camelcase */
}): void; }): string;
// registration shouldn't know or care how login is done. // registration shouldn't know or care how login is done.
onLoginClick(): void; onLoginClick(): void;
onServerConfigChange(config: ValidatedServerConfig): void; onServerConfigChange(config: ValidatedServerConfig): void;
@ -223,7 +223,8 @@ export default class Registration extends React.Component<IProps, IState> {
this.setState({ this.setState({
flows: e.data.flows, flows: e.data.flows,
}); });
} else if (e.httpStatus === 403 && e.errcode === "M_UNKNOWN") { } else if (e.httpStatus === 403 || e.errcode === "M_FORBIDDEN") {
// Check for 403 or M_FORBIDDEN, Synapse used to send 403 M_UNKNOWN but now sends 403 M_FORBIDDEN.
// At this point registration is pretty much disabled, but before we do that let's // At this point registration is pretty much disabled, but before we do that let's
// quickly check to see if the server supports SSO instead. If it does, we'll send // quickly check to see if the server supports SSO instead. If it does, we'll send
// the user off to the login page to figure their account out. // the user off to the login page to figure their account out.
@ -467,7 +468,7 @@ export default class Registration extends React.Component<IProps, IState> {
let ssoSection; let ssoSection;
if (this.state.ssoFlow) { if (this.state.ssoFlow) {
let continueWithSection; let continueWithSection;
const providers = this.state.ssoFlow["org.matrix.msc2858.identity_providers"] || []; const providers = this.state.ssoFlow.identity_providers || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text // when there is only a single (or 0) providers we show a wide button with `Continue with X` text
if (providers.length > 1) { if (providers.length > 1) {
// i18n: ssoButtons is a placeholder to help translators understand context // i18n: ssoButtons is a placeholder to help translators understand context

View file

@ -1,7 +1,5 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016-2021 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,9 +14,9 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {createRef} from 'react'; import React, { ChangeEvent, createRef, FormEvent, MouseEvent } from 'react';
import PropTypes from 'prop-types'; import classNames from 'classnames';
import classnames from 'classnames'; import { MatrixClient } from "matrix-js-sdk/src/client";
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -27,6 +25,7 @@ import AccessibleButton from "../elements/AccessibleButton";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import CountlyAnalytics from "../../../CountlyAnalytics"; import CountlyAnalytics from "../../../CountlyAnalytics";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import { LocalisedPolicy, Policies } from '../../../Terms';
/* This file contains a collection of components which are used by the /* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed * InteractiveAuth to prompt the user to enter the information needed
@ -74,36 +73,72 @@ import {replaceableComponent} from "../../../utils/replaceableComponent";
* focus: set the input focus appropriately in the form. * focus: set the input focus appropriately in the form.
*/ */
enum AuthType {
Password = "m.login.password",
Recaptcha = "m.login.recaptcha",
Terms = "m.login.terms",
Email = "m.login.email.identity",
Msisdn = "m.login.msisdn",
Sso = "m.login.sso",
SsoUnstable = "org.matrix.login.sso",
}
/* eslint-disable camelcase */
interface IAuthDict {
type?: AuthType;
// TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
user?: string;
identifier?: any;
password?: string;
response?: string;
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds?: any;
threepidCreds?: any;
}
/* eslint-enable camelcase */
export const DEFAULT_PHASE = 0; export const DEFAULT_PHASE = 0;
@replaceableComponent("views.auth.PasswordAuthEntry") interface IAuthEntryProps {
export class PasswordAuthEntry extends React.Component { matrixClient: MatrixClient;
static LOGIN_TYPE = "m.login.password"; loginType: string;
authSessionId: string;
errorText?: string;
// Is the auth logic currently waiting for something to happen?
busy?: boolean;
onPhaseChange: (phase: number) => void;
submitAuthDict: (auth: IAuthDict) => void;
}
static propTypes = { interface IPasswordAuthEntryState {
matrixClient: PropTypes.object.isRequired, password: string;
submitAuthDict: PropTypes.func.isRequired, }
errorText: PropTypes.string,
// is the auth logic currently waiting for something to @replaceableComponent("views.auth.PasswordAuthEntry")
// happen? export class PasswordAuthEntry extends React.Component<IAuthEntryProps, IPasswordAuthEntryState> {
busy: PropTypes.bool, static LOGIN_TYPE = AuthType.Password;
onPhaseChange: PropTypes.func.isRequired,
}; constructor(props) {
super(props);
this.state = {
password: "",
};
}
componentDidMount() { componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE); this.props.onPhaseChange(DEFAULT_PHASE);
} }
state = { private onSubmit = (e: FormEvent) => {
password: "",
};
_onSubmit = e => {
e.preventDefault(); e.preventDefault();
if (this.props.busy) return; if (this.props.busy) return;
this.props.submitAuthDict({ this.props.submitAuthDict({
type: PasswordAuthEntry.LOGIN_TYPE, type: AuthType.Password,
// TODO: Remove `user` once servers support proper UIA // TODO: Remove `user` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312 // See https://github.com/vector-im/element-web/issues/10312
user: this.props.matrixClient.credentials.userId, user: this.props.matrixClient.credentials.userId,
@ -115,7 +150,7 @@ export class PasswordAuthEntry extends React.Component {
}); });
}; };
_onPasswordFieldChange = ev => { private onPasswordFieldChange = (ev: ChangeEvent<HTMLInputElement>) => {
// enable the submit button iff the password is non-empty // enable the submit button iff the password is non-empty
this.setState({ this.setState({
password: ev.target.value, password: ev.target.value,
@ -123,7 +158,7 @@ export class PasswordAuthEntry extends React.Component {
}; };
render() { render() {
const passwordBoxClass = classnames({ const passwordBoxClass = classNames({
"error": this.props.errorText, "error": this.props.errorText,
}); });
@ -155,7 +190,7 @@ export class PasswordAuthEntry extends React.Component {
return ( return (
<div> <div>
<p>{ _t("Confirm your identity by entering your account password below.") }</p> <p>{ _t("Confirm your identity by entering your account password below.") }</p>
<form onSubmit={this._onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection"> <form onSubmit={this.onSubmit} className="mx_InteractiveAuthEntryComponents_passwordSection">
<Field <Field
className={passwordBoxClass} className={passwordBoxClass}
type="password" type="password"
@ -163,7 +198,7 @@ export class PasswordAuthEntry extends React.Component {
label={_t('Password')} label={_t('Password')}
autoFocus={true} autoFocus={true}
value={this.state.password} value={this.state.password}
onChange={this._onPasswordFieldChange} onChange={this.onPasswordFieldChange}
/> />
<div className="mx_button_row"> <div className="mx_button_row">
{ submitButtonOrSpinner } { submitButtonOrSpinner }
@ -175,26 +210,26 @@ export class PasswordAuthEntry extends React.Component {
} }
} }
@replaceableComponent("views.auth.RecaptchaAuthEntry") /* eslint-disable camelcase */
export class RecaptchaAuthEntry extends React.Component { interface IRecaptchaAuthEntryProps extends IAuthEntryProps {
static LOGIN_TYPE = "m.login.recaptcha"; stageParams?: {
public_key?: string;
static propTypes = {
submitAuthDict: PropTypes.func.isRequired,
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
}; };
}
/* eslint-enable camelcase */
@replaceableComponent("views.auth.RecaptchaAuthEntry")
export class RecaptchaAuthEntry extends React.Component<IRecaptchaAuthEntryProps> {
static LOGIN_TYPE = AuthType.Recaptcha;
componentDidMount() { componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE); this.props.onPhaseChange(DEFAULT_PHASE);
} }
_onCaptchaResponse = response => { private onCaptchaResponse = (response: string) => {
CountlyAnalytics.instance.track("onboarding_grecaptcha_submit"); CountlyAnalytics.instance.track("onboarding_grecaptcha_submit");
this.props.submitAuthDict({ this.props.submitAuthDict({
type: RecaptchaAuthEntry.LOGIN_TYPE, type: AuthType.Recaptcha,
response: response, response: response,
}); });
}; };
@ -230,7 +265,7 @@ export class RecaptchaAuthEntry extends React.Component {
return ( return (
<div> <div>
<CaptchaForm sitePublicKey={sitePublicKey} <CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this._onCaptchaResponse} onCaptchaResponse={this.onCaptchaResponse}
/> />
{ errorSection } { errorSection }
</div> </div>
@ -238,18 +273,28 @@ export class RecaptchaAuthEntry extends React.Component {
} }
} }
@replaceableComponent("views.auth.TermsAuthEntry") interface ITermsAuthEntryProps extends IAuthEntryProps {
export class TermsAuthEntry extends React.Component { stageParams?: {
static LOGIN_TYPE = "m.login.terms"; policies?: Policies;
static propTypes = {
submitAuthDict: PropTypes.func.isRequired,
stageParams: PropTypes.object.isRequired,
errorText: PropTypes.string,
busy: PropTypes.bool,
showContinue: PropTypes.bool,
onPhaseChange: PropTypes.func.isRequired,
}; };
showContinue: boolean;
}
interface LocalisedPolicyWithId extends LocalisedPolicy {
id: string;
}
interface ITermsAuthEntryState {
policies: LocalisedPolicyWithId[];
toggledPolicies: {
[policy: string]: boolean;
};
errorText?: string;
}
@replaceableComponent("views.auth.TermsAuthEntry")
export class TermsAuthEntry extends React.Component<ITermsAuthEntryProps, ITermsAuthEntryState> {
static LOGIN_TYPE = AuthType.Terms;
constructor(props) { constructor(props) {
super(props); super(props);
@ -294,8 +339,11 @@ export class TermsAuthEntry extends React.Component {
initToggles[policyId] = false; initToggles[policyId] = false;
langPolicy.id = policyId; pickedPolicies.push({
pickedPolicies.push(langPolicy); id: policyId,
name: langPolicy.name,
url: langPolicy.url,
});
} }
this.state = { this.state = {
@ -311,11 +359,11 @@ export class TermsAuthEntry extends React.Component {
this.props.onPhaseChange(DEFAULT_PHASE); this.props.onPhaseChange(DEFAULT_PHASE);
} }
tryContinue = () => { public tryContinue = () => {
this._trySubmit(); this.trySubmit();
}; };
_togglePolicy(policyId) { private togglePolicy(policyId: string) {
const newToggles = {}; const newToggles = {};
for (const policy of this.state.policies) { for (const policy of this.state.policies) {
let checked = this.state.toggledPolicies[policy.id]; let checked = this.state.toggledPolicies[policy.id];
@ -326,7 +374,7 @@ export class TermsAuthEntry extends React.Component {
this.setState({"toggledPolicies": newToggles}); this.setState({"toggledPolicies": newToggles});
} }
_trySubmit = () => { private trySubmit = () => {
let allChecked = true; let allChecked = true;
for (const policy of this.state.policies) { for (const policy of this.state.policies) {
const checked = this.state.toggledPolicies[policy.id]; const checked = this.state.toggledPolicies[policy.id];
@ -334,7 +382,7 @@ export class TermsAuthEntry extends React.Component {
} }
if (allChecked) { if (allChecked) {
this.props.submitAuthDict({type: TermsAuthEntry.LOGIN_TYPE}); this.props.submitAuthDict({type: AuthType.Terms});
CountlyAnalytics.instance.track("onboarding_terms_complete"); CountlyAnalytics.instance.track("onboarding_terms_complete");
} else { } else {
this.setState({errorText: _t("Please review and accept all of the homeserver's policies")}); this.setState({errorText: _t("Please review and accept all of the homeserver's policies")});
@ -356,7 +404,7 @@ export class TermsAuthEntry extends React.Component {
checkboxes.push( checkboxes.push(
// XXX: replace with StyledCheckbox // XXX: replace with StyledCheckbox
<label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy"> <label key={"policy_checkbox_" + policy.id} className="mx_InteractiveAuthEntryComponents_termsPolicy">
<input type="checkbox" onChange={() => this._togglePolicy(policy.id)} checked={checked} /> <input type="checkbox" onChange={() => this.togglePolicy(policy.id)} checked={checked} />
<a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a> <a href={policy.url} target="_blank" rel="noreferrer noopener">{ policy.name }</a>
</label>, </label>,
); );
@ -375,7 +423,7 @@ export class TermsAuthEntry extends React.Component {
if (this.props.showContinue !== false) { if (this.props.showContinue !== false) {
// XXX: button classes // XXX: button classes
submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton" submitButton = <button className="mx_InteractiveAuthEntryComponents_termsSubmit mx_GeneralButton"
onClick={this._trySubmit} disabled={!allChecked}>{_t("Accept")}</button>; onClick={this.trySubmit} disabled={!allChecked}>{_t("Accept")}</button>;
} }
return ( return (
@ -389,21 +437,18 @@ export class TermsAuthEntry extends React.Component {
} }
} }
@replaceableComponent("views.auth.EmailIdentityAuthEntry") interface IEmailIdentityAuthEntryProps extends IAuthEntryProps {
export class EmailIdentityAuthEntry extends React.Component { inputs?: {
static LOGIN_TYPE = "m.login.email.identity"; emailAddress?: string;
static propTypes = {
matrixClient: PropTypes.object.isRequired,
submitAuthDict: PropTypes.func.isRequired,
authSessionId: PropTypes.string.isRequired,
clientSecret: PropTypes.string.isRequired,
inputs: PropTypes.object.isRequired,
stageState: PropTypes.object.isRequired,
fail: PropTypes.func.isRequired,
setEmailSid: PropTypes.func.isRequired,
onPhaseChange: PropTypes.func.isRequired,
}; };
stageState?: {
emailSid: string;
};
}
@replaceableComponent("views.auth.EmailIdentityAuthEntry")
export class EmailIdentityAuthEntry extends React.Component<IEmailIdentityAuthEntryProps> {
static LOGIN_TYPE = AuthType.Email;
componentDidMount() { componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE); this.props.onPhaseChange(DEFAULT_PHASE);
@ -427,7 +472,7 @@ export class EmailIdentityAuthEntry extends React.Component {
return ( return (
<div className="mx_InteractiveAuthEntryComponents_emailWrapper"> <div className="mx_InteractiveAuthEntryComponents_emailWrapper">
<p>{ _t("A confirmation email has been sent to %(emailAddress)s", <p>{ _t("A confirmation email has been sent to %(emailAddress)s",
{ emailAddress: (sub) => <b>{ this.props.inputs.emailAddress }</b> }, { emailAddress: <b>{ this.props.inputs.emailAddress }</b> },
) } ) }
</p> </p>
<p>{ _t("Open the link in the email to continue registration.") }</p> <p>{ _t("Open the link in the email to continue registration.") }</p>
@ -437,37 +482,44 @@ export class EmailIdentityAuthEntry extends React.Component {
} }
} }
interface IMsisdnAuthEntryProps extends IAuthEntryProps {
inputs: {
phoneCountry: string;
phoneNumber: string;
};
clientSecret: string;
fail: (error: Error) => void;
}
interface IMsisdnAuthEntryState {
token: string;
requestingToken: boolean;
errorText: string;
}
@replaceableComponent("views.auth.MsisdnAuthEntry") @replaceableComponent("views.auth.MsisdnAuthEntry")
export class MsisdnAuthEntry extends React.Component { export class MsisdnAuthEntry extends React.Component<IMsisdnAuthEntryProps, IMsisdnAuthEntryState> {
static LOGIN_TYPE = "m.login.msisdn"; static LOGIN_TYPE = AuthType.Msisdn;
static propTypes = { private submitUrl: string;
inputs: PropTypes.shape({ private sid: string;
phoneCountry: PropTypes.string, private msisdn: string;
phoneNumber: PropTypes.string,
}),
fail: PropTypes.func,
clientSecret: PropTypes.func,
submitAuthDict: PropTypes.func.isRequired,
matrixClient: PropTypes.object,
onPhaseChange: PropTypes.func.isRequired,
};
state = { constructor(props) {
token: '', super(props);
requestingToken: false,
}; this.state = {
token: '',
requestingToken: false,
errorText: '',
};
}
componentDidMount() { componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE); this.props.onPhaseChange(DEFAULT_PHASE);
this._submitUrl = null;
this._sid = null;
this._msisdn = null;
this._tokenBox = null;
this.setState({requestingToken: true}); this.setState({requestingToken: true});
this._requestMsisdnToken().catch((e) => { this.requestMsisdnToken().catch((e) => {
this.props.fail(e); this.props.fail(e);
}).finally(() => { }).finally(() => {
this.setState({requestingToken: false}); this.setState({requestingToken: false});
@ -477,26 +529,26 @@ export class MsisdnAuthEntry extends React.Component {
/* /*
* Requests a verification token by SMS. * Requests a verification token by SMS.
*/ */
_requestMsisdnToken() { private requestMsisdnToken(): Promise<void> {
return this.props.matrixClient.requestRegisterMsisdnToken( return this.props.matrixClient.requestRegisterMsisdnToken(
this.props.inputs.phoneCountry, this.props.inputs.phoneCountry,
this.props.inputs.phoneNumber, this.props.inputs.phoneNumber,
this.props.clientSecret, this.props.clientSecret,
1, // TODO: Multiple send attempts? 1, // TODO: Multiple send attempts?
).then((result) => { ).then((result) => {
this._submitUrl = result.submit_url; this.submitUrl = result.submit_url;
this._sid = result.sid; this.sid = result.sid;
this._msisdn = result.msisdn; this.msisdn = result.msisdn;
}); });
} }
_onTokenChange = e => { private onTokenChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({ this.setState({
token: e.target.value, token: e.target.value,
}); });
}; };
_onFormSubmit = async e => { private onFormSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (this.state.token == '') return; if (this.state.token == '') return;
@ -506,20 +558,20 @@ export class MsisdnAuthEntry extends React.Component {
try { try {
let result; let result;
if (this._submitUrl) { if (this.submitUrl) {
result = await this.props.matrixClient.submitMsisdnTokenOtherUrl( result = await this.props.matrixClient.submitMsisdnTokenOtherUrl(
this._submitUrl, this._sid, this.props.clientSecret, this.state.token, this.submitUrl, this.sid, this.props.clientSecret, this.state.token,
); );
} else { } else {
throw new Error("The registration with MSISDN flow is misconfigured"); throw new Error("The registration with MSISDN flow is misconfigured");
} }
if (result.success) { if (result.success) {
const creds = { const creds = {
sid: this._sid, sid: this.sid,
client_secret: this.props.clientSecret, client_secret: this.props.clientSecret,
}; };
this.props.submitAuthDict({ this.props.submitAuthDict({
type: MsisdnAuthEntry.LOGIN_TYPE, type: AuthType.Msisdn,
// TODO: Remove `threepid_creds` once servers support proper UIA // TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/vector-im/element-web/issues/10312 // See https://github.com/vector-im/element-web/issues/10312
// See https://github.com/matrix-org/matrix-doc/issues/2220 // See https://github.com/matrix-org/matrix-doc/issues/2220
@ -543,7 +595,7 @@ export class MsisdnAuthEntry extends React.Component {
return <Loader />; return <Loader />;
} else { } else {
const enableSubmit = Boolean(this.state.token); const enableSubmit = Boolean(this.state.token);
const submitClasses = classnames({ const submitClasses = classNames({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true, mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_GeneralButton: true, mx_GeneralButton: true,
}); });
@ -558,16 +610,16 @@ export class MsisdnAuthEntry extends React.Component {
return ( return (
<div> <div>
<p>{ _t("A text message has been sent to %(msisdn)s", <p>{ _t("A text message has been sent to %(msisdn)s",
{ msisdn: <i>{ this._msisdn }</i> }, { msisdn: <i>{ this.msisdn }</i> },
) } ) }
</p> </p>
<p>{ _t("Please enter the code it contains:") }</p> <p>{ _t("Please enter the code it contains:") }</p>
<div className="mx_InteractiveAuthEntryComponents_msisdnWrapper"> <div className="mx_InteractiveAuthEntryComponents_msisdnWrapper">
<form onSubmit={this._onFormSubmit}> <form onSubmit={this.onFormSubmit}>
<input type="text" <input type="text"
className="mx_InteractiveAuthEntryComponents_msisdnEntry" className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token} value={this.state.token}
onChange={this._onTokenChange} onChange={this.onTokenChange}
aria-label={ _t("Code")} aria-label={ _t("Code")}
/> />
<br /> <br />
@ -584,40 +636,40 @@ export class MsisdnAuthEntry extends React.Component {
} }
} }
@replaceableComponent("views.auth.SSOAuthEntry") interface ISSOAuthEntryProps extends IAuthEntryProps {
export class SSOAuthEntry extends React.Component { continueText?: string;
static propTypes = { continueKind?: string;
matrixClient: PropTypes.object.isRequired, onCancel?: () => void;
authSessionId: PropTypes.string.isRequired, }
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
continueText: PropTypes.string,
continueKind: PropTypes.string,
onCancel: PropTypes.func,
};
static LOGIN_TYPE = "m.login.sso"; interface ISSOAuthEntryState {
static UNSTABLE_LOGIN_TYPE = "org.matrix.login.sso"; phase: number;
attemptFailed: boolean;
}
@replaceableComponent("views.auth.SSOAuthEntry")
export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEntryState> {
static LOGIN_TYPE = AuthType.Sso;
static UNSTABLE_LOGIN_TYPE = AuthType.SsoUnstable;
static PHASE_PREAUTH = 1; // button to start SSO static PHASE_PREAUTH = 1; // button to start SSO
static PHASE_POSTAUTH = 2; // button to confirm SSO completed static PHASE_POSTAUTH = 2; // button to confirm SSO completed
_ssoUrl: string; private ssoUrl: string;
private popupWindow: Window;
constructor(props) { constructor(props) {
super(props); super(props);
// We actually send the user through fallback auth so we don't have to // We actually send the user through fallback auth so we don't have to
// deal with a redirect back to us, losing application context. // deal with a redirect back to us, losing application context.
this._ssoUrl = props.matrixClient.getFallbackAuthUrl( this.ssoUrl = props.matrixClient.getFallbackAuthUrl(
this.props.loginType, this.props.loginType,
this.props.authSessionId, this.props.authSessionId,
); );
this._popupWindow = null; this.popupWindow = null;
window.addEventListener("message", this._onReceiveMessage); window.addEventListener("message", this.onReceiveMessage);
this.state = { this.state = {
phase: SSOAuthEntry.PHASE_PREAUTH, phase: SSOAuthEntry.PHASE_PREAUTH,
@ -625,44 +677,44 @@ export class SSOAuthEntry extends React.Component {
}; };
} }
componentDidMount(): void { componentDidMount() {
this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH); this.props.onPhaseChange(SSOAuthEntry.PHASE_PREAUTH);
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage); window.removeEventListener("message", this.onReceiveMessage);
if (this._popupWindow) { if (this.popupWindow) {
this._popupWindow.close(); this.popupWindow.close();
this._popupWindow = null; this.popupWindow = null;
} }
} }
attemptFailed = () => { public attemptFailed = () => {
this.setState({ this.setState({
attemptFailed: true, attemptFailed: true,
}); });
}; };
_onReceiveMessage = event => { private onReceiveMessage = (event: MessageEvent) => {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) { if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
if (this._popupWindow) { if (this.popupWindow) {
this._popupWindow.close(); this.popupWindow.close();
this._popupWindow = null; this.popupWindow = null;
} }
} }
}; };
onStartAuthClick = () => { private onStartAuthClick = () => {
// Note: We don't use PlatformPeg's startSsoAuth functions because we almost // Note: We don't use PlatformPeg's startSsoAuth functions because we almost
// certainly will need to open the thing in a new tab to avoid losing application // certainly will need to open the thing in a new tab to avoid losing application
// context. // context.
this._popupWindow = window.open(this._ssoUrl, "_blank"); this.popupWindow = window.open(this.ssoUrl, "_blank");
this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH}); this.setState({phase: SSOAuthEntry.PHASE_POSTAUTH});
this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH); this.props.onPhaseChange(SSOAuthEntry.PHASE_POSTAUTH);
}; };
onConfirmClick = () => { private onConfirmClick = () => {
this.props.submitAuthDict({}); this.props.submitAuthDict({});
}; };
@ -716,46 +768,37 @@ export class SSOAuthEntry extends React.Component {
} }
@replaceableComponent("views.auth.FallbackAuthEntry") @replaceableComponent("views.auth.FallbackAuthEntry")
export class FallbackAuthEntry extends React.Component { export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
static propTypes = { private popupWindow: Window;
matrixClient: PropTypes.object.isRequired, private fallbackButton = createRef<HTMLAnchorElement>();
authSessionId: PropTypes.string.isRequired,
loginType: PropTypes.string.isRequired,
submitAuthDict: PropTypes.func.isRequired,
errorText: PropTypes.string,
onPhaseChange: PropTypes.func.isRequired,
};
constructor(props) { constructor(props) {
super(props); super(props);
// we have to make the user click a button, as browsers will block // we have to make the user click a button, as browsers will block
// the popup if we open it immediately. // the popup if we open it immediately.
this._popupWindow = null; this.popupWindow = null;
window.addEventListener("message", this._onReceiveMessage); window.addEventListener("message", this.onReceiveMessage);
this._fallbackButton = createRef();
} }
componentDidMount() { componentDidMount() {
this.props.onPhaseChange(DEFAULT_PHASE); this.props.onPhaseChange(DEFAULT_PHASE);
} }
componentWillUnmount() { componentWillUnmount() {
window.removeEventListener("message", this._onReceiveMessage); window.removeEventListener("message", this.onReceiveMessage);
if (this._popupWindow) { if (this.popupWindow) {
this._popupWindow.close(); this.popupWindow.close();
} }
} }
focus = () => { public focus = () => {
if (this._fallbackButton.current) { if (this.fallbackButton.current) {
this._fallbackButton.current.focus(); this.fallbackButton.current.focus();
} }
}; };
_onShowFallbackClick = e => { private onShowFallbackClick = (e: MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -763,10 +806,10 @@ export class FallbackAuthEntry extends React.Component {
this.props.loginType, this.props.loginType,
this.props.authSessionId, this.props.authSessionId,
); );
this._popupWindow = window.open(url, "_blank"); this.popupWindow = window.open(url, "_blank");
}; };
_onReceiveMessage = event => { private onReceiveMessage = (event: MessageEvent) => {
if ( if (
event.data === "authDone" && event.data === "authDone" &&
event.origin === this.props.matrixClient.getHomeserverUrl() event.origin === this.props.matrixClient.getHomeserverUrl()
@ -786,27 +829,31 @@ export class FallbackAuthEntry extends React.Component {
} }
return ( return (
<div> <div>
<a href="" ref={this._fallbackButton} onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a> <a href="" ref={this.fallbackButton} onClick={this.onShowFallbackClick}>{
_t("Start authentication")
}</a>
{errorSection} {errorSection}
</div> </div>
); );
} }
} }
const AuthEntryComponents = [ export default function getEntryComponentForLoginType(loginType: AuthType): typeof React.Component {
PasswordAuthEntry, switch (loginType) {
RecaptchaAuthEntry, case AuthType.Password:
EmailIdentityAuthEntry, return PasswordAuthEntry;
MsisdnAuthEntry, case AuthType.Recaptcha:
TermsAuthEntry, return RecaptchaAuthEntry;
SSOAuthEntry, case AuthType.Email:
]; return EmailIdentityAuthEntry;
case AuthType.Msisdn:
export default function getEntryComponentForLoginType(loginType) { return MsisdnAuthEntry;
for (const c of AuthEntryComponents) { case AuthType.Terms:
if (c.LOGIN_TYPE === loginType || c.UNSTABLE_LOGIN_TYPE === loginType) { return TermsAuthEntry;
return c; case AuthType.Sso:
} case AuthType.SsoUnstable:
return SSOAuthEntry;
default:
return FallbackAuthEntry;
} }
return FallbackAuthEntry;
} }

View file

@ -119,7 +119,10 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
if (this.props.room.roomId !== room.roomId) return; if (this.props.room.roomId !== room.roomId) return;
if (ev.getType() === 'm.room.join_rules' || ev.getType() === 'm.room.member') { if (ev.getType() === 'm.room.join_rules' || ev.getType() === 'm.room.member') {
this.setState({icon: this.calculateIcon()}); const newIcon = this.calculateIcon();
if (newIcon !== this.state.icon) {
this.setState({icon: newIcon});
}
} }
}; };

View file

@ -17,9 +17,9 @@ limitations under the License.
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import {EventStatus} from 'matrix-js-sdk/src/models/event'; import { EventStatus } from 'matrix-js-sdk/src/models/event';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -28,9 +28,10 @@ import Resend from '../../../Resend';
import SettingsStore from '../../../settings/SettingsStore'; import SettingsStore from '../../../settings/SettingsStore';
import { isUrlPermitted } from '../../../HtmlUtils'; import { isUrlPermitted } from '../../../HtmlUtils';
import { isContentActionable } from '../../../utils/EventUtils'; import { isContentActionable } from '../../../utils/EventUtils';
import {MenuItem} from "../../structures/ContextMenu"; import { MenuItem } from "../../structures/ContextMenu";
import {EventType} from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { ReadPinsEventId } from "../right_panel/PinnedMessagesCard";
export function canCancel(eventStatus) { export function canCancel(eventStatus) {
return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT; return eventStatus === EventStatus.QUEUED || eventStatus === EventStatus.NOT_SENT;
@ -82,7 +83,7 @@ export default class MessageContextMenu extends React.Component {
const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId) const canRedact = room.currentState.maySendRedactionForEvent(this.props.mxEvent, cli.credentials.userId)
&& this.props.mxEvent.getType() !== EventType.RoomServerAcl && this.props.mxEvent.getType() !== EventType.RoomServerAcl
&& this.props.mxEvent.getType() !== EventType.RoomEncryption; && this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = room.currentState.mayClientSendStateEvent('m.room.pinned_events', cli); let canPin = room.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality // HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.getValue("feature_pinning")) canPin = false; if (!SettingsStore.getValue("feature_pinning")) canPin = false;
@ -92,7 +93,7 @@ export default class MessageContextMenu extends React.Component {
_isPinned() { _isPinned() {
const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room.currentState.getStateEvents('m.room.pinned_events', ''); const pinnedEvent = room.currentState.getStateEvents(EventType.RoomPinnedEvents, '');
if (!pinnedEvent) return false; if (!pinnedEvent) return false;
const content = pinnedEvent.getContent(); const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId()); return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
@ -165,25 +166,23 @@ export default class MessageContextMenu extends React.Component {
}; };
onPinClick = () => { onPinClick = () => {
MatrixClientPeg.get().getStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', '') const cli = MatrixClientPeg.get();
.catch((e) => { const room = cli.getRoom(this.props.mxEvent.getRoomId());
// Intercept the Event Not Found error and fall through the promise chain with no event. const eventId = this.props.mxEvent.getId();
if (e.errcode === "M_NOT_FOUND") return null;
throw e;
})
.then((event) => {
const eventIds = (event ? event.pinned : []) || [];
if (!eventIds.includes(this.props.mxEvent.getId())) {
// Not pinned - add
eventIds.push(this.props.mxEvent.getId());
} else {
// Pinned - remove
eventIds.splice(eventIds.indexOf(this.props.mxEvent.getId()), 1);
}
const cli = MatrixClientPeg.get(); const pinnedIds = room?.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.pinned || [];
cli.sendStateEvent(this.props.mxEvent.getRoomId(), 'm.room.pinned_events', {pinned: eventIds}, ''); if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [
...room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids,
eventId,
],
}); });
}
cli.sendStateEvent(this.props.mxEvent.getRoomId(), EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
this.closeMenu(); this.closeMenu();
}; };

View file

@ -212,7 +212,7 @@ export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
autoComplete={true} autoComplete={true}
autoFocus={true} autoFocus={true}
/> />
<AutoHideScrollbar className="mx_AddExistingToSpace_content" id="mx_AddExistingToSpace"> <AutoHideScrollbar className="mx_AddExistingToSpace_content">
{ rooms.length > 0 ? ( { rooms.length > 0 ? (
<div className="mx_AddExistingToSpace_section"> <div className="mx_AddExistingToSpace_section">
<h3>{ _t("Rooms") }</h3> <h3>{ _t("Rooms") }</h3>

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,27 +15,46 @@ 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, createRef, KeyboardEvent, SyntheticEvent} from "react";
import PropTypes from 'prop-types';
import {Room} from "matrix-js-sdk/src/models/room"; import {Room} from "matrix-js-sdk/src/models/room";
import * as sdk from '../../../index';
import SdkConfig from '../../../SdkConfig'; import SdkConfig from '../../../SdkConfig';
import withValidation from '../elements/Validation'; import withValidation, {IFieldState} from '../elements/Validation';
import { _t } from '../../../languageHandler'; import {_t} from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import {privateShouldBeEncrypted} from "../../../createRoom"; import {IOpts, Preset, privateShouldBeEncrypted, Visibility} from "../../../createRoom";
import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore"; import {CommunityPrototypeStore} from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
interface IProps {
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
onFinished(proceed: boolean, opts?: IOpts): void;
}
interface IState {
isPublic: boolean;
isEncrypted: boolean;
name: string;
topic: string;
alias: string;
detailsOpen: boolean;
noFederate: boolean;
nameIsValid: boolean;
canChangeEncryption: boolean;
}
@replaceableComponent("views.dialogs.CreateRoomDialog") @replaceableComponent("views.dialogs.CreateRoomDialog")
export default class CreateRoomDialog extends React.Component { export default class CreateRoomDialog extends React.Component<IProps, IState> {
static propTypes = { private nameField = createRef<Field>();
onFinished: PropTypes.func.isRequired, private aliasField = createRef<RoomAliasField>();
defaultPublic: PropTypes.bool,
parentSpace: PropTypes.instanceOf(Room),
};
constructor(props) { constructor(props) {
super(props); super(props);
@ -44,7 +63,7 @@ export default class CreateRoomDialog extends React.Component {
this.state = { this.state = {
isPublic: this.props.defaultPublic || false, isPublic: this.props.defaultPublic || false,
isEncrypted: privateShouldBeEncrypted(), isEncrypted: privateShouldBeEncrypted(),
name: "", name: this.props.defaultName || "",
topic: "", topic: "",
alias: "", alias: "",
detailsOpen: false, detailsOpen: false,
@ -54,26 +73,25 @@ export default class CreateRoomDialog extends React.Component {
}; };
MatrixClientPeg.get().doesServerForceEncryptionForPreset("private") MatrixClientPeg.get().doesServerForceEncryptionForPreset("private")
.then(isForced => this.setState({canChangeEncryption: !isForced})); .then(isForced => this.setState({ canChangeEncryption: !isForced }));
} }
_roomCreateOptions() { private roomCreateOptions() {
const opts = {}; const opts: IOpts = {};
const createOpts = opts.createOpts = {}; const createOpts: IOpts["createOpts"] = opts.createOpts = {};
createOpts.name = this.state.name; createOpts.name = this.state.name;
if (this.state.isPublic) { if (this.state.isPublic) {
createOpts.visibility = "public"; createOpts.visibility = Visibility.Public;
createOpts.preset = "public_chat"; createOpts.preset = Preset.PublicChat;
opts.guestAccess = false; opts.guestAccess = false;
const {alias} = this.state; const { alias } = this.state;
const localPart = alias.substr(1, alias.indexOf(":") - 1); createOpts.room_alias_name = alias.substr(1, alias.indexOf(":") - 1);
createOpts['room_alias_name'] = localPart;
} }
if (this.state.topic) { if (this.state.topic) {
createOpts.topic = this.state.topic; createOpts.topic = this.state.topic;
} }
if (this.state.noFederate) { if (this.state.noFederate) {
createOpts.creation_content = {'m.federate': false}; createOpts.creation_content = { 'm.federate': false };
} }
if (!this.state.isPublic) { if (!this.state.isPublic) {
@ -98,16 +116,14 @@ export default class CreateRoomDialog extends React.Component {
} }
componentDidMount() { componentDidMount() {
this._detailsRef.addEventListener("toggle", this.onDetailsToggled);
// move focus to first field when showing dialog // move focus to first field when showing dialog
this._nameFieldRef.focus(); this.nameField.current.focus();
} }
componentWillUnmount() { componentWillUnmount() {
this._detailsRef.removeEventListener("toggle", this.onDetailsToggled);
} }
_onKeyDown = event => { private onKeyDown = (event: KeyboardEvent) => {
if (event.key === Key.ENTER) { if (event.key === Key.ENTER) {
this.onOk(); this.onOk();
event.preventDefault(); event.preventDefault();
@ -115,26 +131,26 @@ export default class CreateRoomDialog extends React.Component {
} }
}; };
onOk = async () => { private onOk = async () => {
const activeElement = document.activeElement; const activeElement = document.activeElement as HTMLElement;
if (activeElement) { if (activeElement) {
activeElement.blur(); activeElement.blur();
} }
await this._nameFieldRef.validate({allowEmpty: false}); await this.nameField.current.validate({allowEmpty: false});
if (this._aliasFieldRef) { if (this.aliasField.current) {
await this._aliasFieldRef.validate({allowEmpty: false}); await this.aliasField.current.validate({allowEmpty: false});
} }
// Validation and state updates are async, so we need to wait for them to complete // Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve. // first. Queue a `setState` callback and wait for it to resolve.
await new Promise(resolve => this.setState({}, resolve)); await new Promise<void>(resolve => this.setState({}, resolve));
if (this.state.nameIsValid && (!this._aliasFieldRef || this._aliasFieldRef.isValid)) { if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) {
this.props.onFinished(true, this._roomCreateOptions()); this.props.onFinished(true, this.roomCreateOptions());
} else { } else {
let field; let field;
if (!this.state.nameIsValid) { if (!this.state.nameIsValid) {
field = this._nameFieldRef; field = this.nameField.current;
} else if (this._aliasFieldRef && !this._aliasFieldRef.isValid) { } else if (this.aliasField.current && !this.aliasField.current.isValid) {
field = this._aliasFieldRef; field = this.aliasField.current;
} }
if (field) { if (field) {
field.focus(); field.focus();
@ -143,49 +159,45 @@ export default class CreateRoomDialog extends React.Component {
} }
}; };
onCancel = () => { private onCancel = () => {
this.props.onFinished(false); this.props.onFinished(false);
}; };
onNameChange = ev => { private onNameChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({name: ev.target.value}); this.setState({ name: ev.target.value });
}; };
onTopicChange = ev => { private onTopicChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({topic: ev.target.value}); this.setState({ topic: ev.target.value });
}; };
onPublicChange = isPublic => { private onPublicChange = (isPublic: boolean) => {
this.setState({isPublic}); this.setState({ isPublic });
}; };
onEncryptedChange = isEncrypted => { private onEncryptedChange = (isEncrypted: boolean) => {
this.setState({isEncrypted}); this.setState({ isEncrypted });
}; };
onAliasChange = alias => { private onAliasChange = (alias: string) => {
this.setState({alias}); this.setState({ alias });
}; };
onDetailsToggled = ev => { private onDetailsToggled = (ev: SyntheticEvent<HTMLDetailsElement>) => {
this.setState({detailsOpen: ev.target.open}); this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open });
}; };
onNoFederateChange = noFederate => { private onNoFederateChange = (noFederate: boolean) => {
this.setState({noFederate}); this.setState({ noFederate });
}; };
collectDetailsRef = ref => { private onNameValidate = async (fieldState: IFieldState) => {
this._detailsRef = ref; const result = await CreateRoomDialog.validateRoomName(fieldState);
};
onNameValidate = async fieldState => {
const result = await CreateRoomDialog._validateRoomName(fieldState);
this.setState({nameIsValid: result.valid}); this.setState({nameIsValid: result.valid});
return result; return result;
}; };
static _validateRoomName = withValidation({ private static validateRoomName = withValidation({
rules: [ rules: [
{ {
key: "required", key: "required",
@ -196,18 +208,17 @@ export default class CreateRoomDialog extends React.Component {
}); });
render() { render() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
const Field = sdk.getComponent('views.elements.Field');
const LabelledToggleSwitch = sdk.getComponent('views.elements.LabelledToggleSwitch');
const RoomAliasField = sdk.getComponent('views.elements.RoomAliasField');
let aliasField; let aliasField;
if (this.state.isPublic) { if (this.state.isPublic) {
const domain = MatrixClientPeg.get().getDomain(); const domain = MatrixClientPeg.get().getDomain();
aliasField = ( aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer"> <div className="mx_CreateRoomDialog_aliasContainer">
<RoomAliasField ref={ref => this._aliasFieldRef = ref} onChange={this.onAliasChange} domain={domain} value={this.state.alias} /> <RoomAliasField
ref={this.aliasField}
onChange={this.onAliasChange}
domain={domain}
value={this.state.alias}
/>
</div> </div>
); );
} }
@ -270,16 +281,34 @@ export default class CreateRoomDialog extends React.Component {
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished} <BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
title={title} title={title}
> >
<form onSubmit={this.onOk} onKeyDown={this._onKeyDown}> <form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
<Field ref={ref => this._nameFieldRef = ref} label={ _t('Name') } onChange={this.onNameChange} onValidate={this.onNameValidate} value={this.state.name} className="mx_CreateRoomDialog_name" /> <Field
<Field label={ _t('Topic (optional)') } onChange={this.onTopicChange} value={this.state.topic} className="mx_CreateRoomDialog_topic" /> ref={this.nameField}
<LabelledToggleSwitch label={ _t("Make this room public")} onChange={this.onPublicChange} value={this.state.isPublic} /> label={_t('Name')}
onChange={this.onNameChange}
onValidate={this.onNameValidate}
value={this.state.name}
className="mx_CreateRoomDialog_name"
/>
<Field
label={_t('Topic (optional)')}
onChange={this.onTopicChange}
value={this.state.topic}
className="mx_CreateRoomDialog_topic"
/>
<LabelledToggleSwitch
label={_t("Make this room public")}
onChange={this.onPublicChange}
value={this.state.isPublic}
/>
{ publicPrivateLabel } { publicPrivateLabel }
{ e2eeSection } { e2eeSection }
{ aliasField } { aliasField }
<details ref={this.collectDetailsRef} className="mx_CreateRoomDialog_details"> <details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }</summary> <summary className="mx_CreateRoomDialog_details_summary">
{ this.state.detailsOpen ? _t('Hide advanced') : _t('Show advanced') }
</summary>
<LabelledToggleSwitch <LabelledToggleSwitch
label={_t( label={_t(
"Block anyone not part of %(serverName)s from ever joining this room.", "Block anyone not part of %(serverName)s from ever joining this room.",

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com> Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,14 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useState, useEffect} from 'react'; import React, { useState, useEffect, ChangeEvent, MouseEvent } from 'react';
import PropTypes from 'prop-types';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import SyntaxHighlight from '../elements/SyntaxHighlight'; import SyntaxHighlight from '../elements/SyntaxHighlight';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import Field from "../elements/Field"; import Field from "../elements/Field";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import { useEventEmitter } from "../../../hooks/useEventEmitter";
import { import {
PHASE_UNSENT, PHASE_UNSENT,
@ -30,27 +30,33 @@ import {
PHASE_DONE, PHASE_DONE,
PHASE_STARTED, PHASE_STARTED,
PHASE_CANCELLED, PHASE_CANCELLED,
VerificationRequest,
} from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import WidgetStore from "../../../stores/WidgetStore"; import WidgetStore, { IApp } from "../../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import {SETTINGS} from "../../../settings/Settings"; import { SETTINGS } from "../../../settings/Settings";
import SettingsStore, {LEVEL_ORDER} from "../../../settings/SettingsStore"; import SettingsStore, { LEVEL_ORDER } from "../../../settings/SettingsStore";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog"; import ErrorDialog from "./ErrorDialog";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import {Room} from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import {MatrixEvent} from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SettingLevel } from '../../../settings/SettingLevel';
class GenericEditor extends React.PureComponent { interface IGenericEditorProps {
// static propTypes = {onBack: PropTypes.func.isRequired}; onBack: () => void;
}
constructor(props) { interface IGenericEditorState {
super(props); message?: string;
this._onChange = this._onChange.bind(this); [inputId: string]: boolean | string;
this.onBack = this.onBack.bind(this); }
}
onBack() { abstract class GenericEditor<
P extends IGenericEditorProps = IGenericEditorProps,
S extends IGenericEditorState = IGenericEditorState,
> extends React.PureComponent<P, S> {
protected onBack = () => {
if (this.state.message) { if (this.state.message) {
this.setState({ message: null }); this.setState({ message: null });
} else { } else {
@ -58,47 +64,60 @@ class GenericEditor extends React.PureComponent {
} }
} }
_onChange(e) { protected onChange = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
// @ts-ignore: Unsure how to convince TS this is okay when the state
// type can be extended.
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
} }
_buttons() { protected abstract send();
protected buttons(): React.ReactNode {
return <div className="mx_Dialog_buttons"> return <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> } { !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
</div>; </div>;
} }
textInput(id, label) { protected textInput(id: string, label: string): React.ReactNode {
return <Field return <Field
id={id} id={id}
label={label} label={label}
size="42" size={42}
autoFocus={true} autoFocus={true}
type="text" type="text"
autoComplete="on" autoComplete="on"
value={this.state[id]} value={this.state[id] as string}
onChange={this._onChange} onChange={this.onChange}
/>; />;
} }
} }
export class SendCustomEvent extends GenericEditor { interface ISendCustomEventProps extends IGenericEditorProps {
static getLabel() { return _t('Send Custom Event'); } room: Room;
forceStateEvent?: boolean;
static propTypes = { forceGeneralEvent?: boolean;
onBack: PropTypes.func.isRequired, inputs?: {
room: PropTypes.instanceOf(Room).isRequired, eventType?: string;
forceStateEvent: PropTypes.bool, stateKey?: string;
forceGeneralEvent: PropTypes.bool, evContent?: string;
inputs: PropTypes.object,
}; };
}
interface ISendCustomEventState extends IGenericEditorState {
isStateEvent: boolean;
eventType: string;
stateKey: string;
evContent: string;
}
export class SendCustomEvent extends GenericEditor<ISendCustomEventProps, ISendCustomEventState> {
static getLabel() { return _t('Send Custom Event'); }
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
constructor(props) { constructor(props) {
super(props); super(props);
this._send = this._send.bind(this);
const {eventType, stateKey, evContent} = Object.assign({ const {eventType, stateKey, evContent} = Object.assign({
eventType: '', eventType: '',
@ -115,7 +134,7 @@ export class SendCustomEvent extends GenericEditor {
}; };
} }
send(content) { private doSend(content: object): Promise<void> {
const cli = this.context; const cli = this.context;
if (this.state.isStateEvent) { if (this.state.isStateEvent) {
return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey); return cli.sendStateEvent(this.props.room.roomId, this.state.eventType, content, this.state.stateKey);
@ -124,7 +143,7 @@ export class SendCustomEvent extends GenericEditor {
} }
} }
async _send() { protected send = async () => {
if (this.state.eventType === '') { if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') }); this.setState({ message: _t('You must specify an event type!') });
return; return;
@ -133,7 +152,7 @@ export class SendCustomEvent extends GenericEditor {
let message; let message;
try { try {
const content = JSON.parse(this.state.evContent); const content = JSON.parse(this.state.evContent);
await this.send(content); await this.doSend(content);
message = _t('Event sent!'); message = _t('Event sent!');
} catch (e) { } catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
@ -147,7 +166,7 @@ export class SendCustomEvent extends GenericEditor {
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
{ this.state.message } { this.state.message }
</div> </div>
{ this._buttons() } { this.buttons() }
</div>; </div>;
} }
@ -163,35 +182,51 @@ export class SendCustomEvent extends GenericEditor {
<br /> <br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea" <Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" /> autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> } { !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ showTglFlip && <div style={{float: "right"}}> { showTglFlip && <div style={{float: "right"}}>
<input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isStateEvent} /> <input id="isStateEvent" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<label className="mx_DevTools_tgl-btn" data-tg-off="Event" data-tg-on="State Event" htmlFor="isStateEvent" /> type="checkbox"
checked={this.state.isStateEvent}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Event"
data-tg-on="State Event"
htmlFor="isStateEvent"
/>
</div> } </div> }
</div> </div>
</div>; </div>;
} }
} }
class SendAccountData extends GenericEditor { interface ISendAccountDataProps extends IGenericEditorProps {
static getLabel() { return _t('Send Account Data'); } room: Room;
isRoomAccountData: boolean;
static propTypes = { forceMode: boolean;
room: PropTypes.instanceOf(Room).isRequired, inputs?: {
isRoomAccountData: PropTypes.bool, eventType?: string;
forceMode: PropTypes.bool, evContent?: string;
inputs: PropTypes.object,
}; };
}
interface ISendAccountDataState extends IGenericEditorState {
isRoomAccountData: boolean;
eventType: string;
evContent: string;
}
class SendAccountData extends GenericEditor<ISendAccountDataProps, ISendAccountDataState> {
static getLabel() { return _t('Send Account Data'); }
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
constructor(props) { constructor(props) {
super(props); super(props);
this._send = this._send.bind(this);
const {eventType, evContent} = Object.assign({ const {eventType, evContent} = Object.assign({
eventType: '', eventType: '',
@ -206,7 +241,7 @@ class SendAccountData extends GenericEditor {
}; };
} }
send(content) { private doSend(content: object): Promise<void> {
const cli = this.context; const cli = this.context;
if (this.state.isRoomAccountData) { if (this.state.isRoomAccountData) {
return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content); return cli.setRoomAccountData(this.props.room.roomId, this.state.eventType, content);
@ -214,7 +249,7 @@ class SendAccountData extends GenericEditor {
return cli.setAccountData(this.state.eventType, content); return cli.setAccountData(this.state.eventType, content);
} }
async _send() { protected send = async () => {
if (this.state.eventType === '') { if (this.state.eventType === '') {
this.setState({ message: _t('You must specify an event type!') }); this.setState({ message: _t('You must specify an event type!') });
return; return;
@ -223,7 +258,7 @@ class SendAccountData extends GenericEditor {
let message; let message;
try { try {
const content = JSON.parse(this.state.evContent); const content = JSON.parse(this.state.evContent);
await this.send(content); await this.doSend(content);
message = _t('Event sent!'); message = _t('Event sent!');
} catch (e) { } catch (e) {
message = _t('Failed to send custom event.') + ' (' + e.toString() + ')'; message = _t('Failed to send custom event.') + ' (' + e.toString() + ')';
@ -237,7 +272,7 @@ class SendAccountData extends GenericEditor {
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
{ this.state.message } { this.state.message }
</div> </div>
{ this._buttons() } { this.buttons() }
</div>; </div>;
} }
@ -247,14 +282,23 @@ class SendAccountData extends GenericEditor {
<br /> <br />
<Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea" <Field id="evContent" label={_t("Event Content")} type="text" className="mx_DevTools_textarea"
autoComplete="off" value={this.state.evContent} onChange={this._onChange} element="textarea" /> autoComplete="off" value={this.state.evContent} onChange={this.onChange} element="textarea" />
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <button onClick={this._send}>{ _t('Send') }</button> } { !this.state.message && <button onClick={this.send}>{ _t('Send') }</button> }
{ !this.state.message && <div style={{float: "right"}}> { !this.state.message && <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} disabled={this.props.forceMode} /> <input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" /> type="checkbox"
checked={this.state.isRoomAccountData}
disabled={this.props.forceMode}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Account Data"
data-tg-on="Room Data"
htmlFor="isRoomAccountData"
/>
</div> } </div> }
</div> </div>
</div>; </div>;
@ -264,17 +308,22 @@ class SendAccountData extends GenericEditor {
const INITIAL_LOAD_TILES = 20; const INITIAL_LOAD_TILES = 20;
const LOAD_TILES_STEP_SIZE = 50; const LOAD_TILES_STEP_SIZE = 50;
class FilteredList extends React.PureComponent { interface IFilteredListProps {
static propTypes = { children: React.ReactElement[];
children: PropTypes.any, query: string;
query: PropTypes.string, onChange: (value: string) => void;
onChange: PropTypes.func, }
};
static filterChildren(children, query) { interface IFilteredListState {
filteredChildren: React.ReactElement[];
truncateAt: number;
}
class FilteredList extends React.PureComponent<IFilteredListProps, IFilteredListState> {
static filterChildren(children: React.ReactElement[], query: string): React.ReactElement[] {
if (!query) return children; if (!query) return children;
const lcQuery = query.toLowerCase(); const lcQuery = query.toLowerCase();
return children.filter((child) => child.key.toLowerCase().includes(lcQuery)); return children.filter((child) => child.key.toString().toLowerCase().includes(lcQuery));
} }
constructor(props) { constructor(props) {
@ -295,27 +344,27 @@ class FilteredList extends React.PureComponent {
}); });
} }
showAll = () => { private showAll = () => {
this.setState({ this.setState({
truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE, truncateAt: this.state.truncateAt + LOAD_TILES_STEP_SIZE,
}); });
}; };
createOverflowElement = (overflowCount: number, totalCount: number) => { private createOverflowElement = (overflowCount: number, totalCount: number) => {
return <button className="mx_DevTools_RoomStateExplorer_button" onClick={this.showAll}> return <button className="mx_DevTools_RoomStateExplorer_button" onClick={this.showAll}>
{ _t("and %(count)s others...", { count: overflowCount }) } { _t("and %(count)s others...", { count: overflowCount }) }
</button>; </button>;
}; };
onQuery = (ev) => { private onQuery = (ev: ChangeEvent<HTMLInputElement>) => {
if (this.props.onChange) this.props.onChange(ev.target.value); if (this.props.onChange) this.props.onChange(ev.target.value);
}; };
getChildren = (start: number, end: number) => { private getChildren = (start: number, end: number): React.ReactElement[] => {
return this.state.filteredChildren.slice(start, end); return this.state.filteredChildren.slice(start, end);
}; };
getChildCount = (): number => { private getChildCount = (): number => {
return this.state.filteredChildren.length; return this.state.filteredChildren.length;
}; };
@ -336,28 +385,31 @@ class FilteredList extends React.PureComponent {
} }
} }
class RoomStateExplorer extends React.PureComponent { interface IExplorerProps {
static getLabel() { return _t('Explore Room State'); } room: Room;
onBack: () => void;
}
static propTypes = { interface IRoomStateExplorerState {
onBack: PropTypes.func.isRequired, eventType?: string;
room: PropTypes.instanceOf(Room).isRequired, event?: MatrixEvent;
}; editing: boolean;
queryEventType: string;
queryStateKey: string;
}
class RoomStateExplorer extends React.PureComponent<IExplorerProps, IRoomStateExplorerState> {
static getLabel() { return _t('Explore Room State'); }
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
roomStateEvents: Map<string, Map<string, MatrixEvent>>; private roomStateEvents: Map<string, Map<string, MatrixEvent>>;
constructor(props) { constructor(props) {
super(props); super(props);
this.roomStateEvents = this.props.room.currentState.events; this.roomStateEvents = this.props.room.currentState.events;
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.onQueryStateKey = this.onQueryStateKey.bind(this);
this.state = { this.state = {
eventType: null, eventType: null,
event: null, event: null,
@ -368,19 +420,19 @@ class RoomStateExplorer extends React.PureComponent {
}; };
} }
browseEventType(eventType) { private browseEventType(eventType: string) {
return () => { return () => {
this.setState({ eventType }); this.setState({ eventType });
}; };
} }
onViewSourceClick(event) { private onViewSourceClick(event: MatrixEvent) {
return () => { return () => {
this.setState({ event }); this.setState({ event });
}; };
} }
onBack() { private onBack = () => {
if (this.state.editing) { if (this.state.editing) {
this.setState({ editing: false }); this.setState({ editing: false });
} else if (this.state.event) { } else if (this.state.event) {
@ -392,15 +444,15 @@ class RoomStateExplorer extends React.PureComponent {
} }
} }
editEv() { private editEv = () => {
this.setState({ editing: true }); this.setState({ editing: true });
} }
onQueryEventType(filterEventType) { private onQueryEventType = (filterEventType: string) => {
this.setState({ queryEventType: filterEventType }); this.setState({ queryEventType: filterEventType });
} }
onQueryStateKey(filterStateKey) { private onQueryStateKey = (filterStateKey: string) => {
this.setState({ queryStateKey: filterStateKey }); this.setState({ queryStateKey: filterStateKey });
} }
@ -472,24 +524,22 @@ class RoomStateExplorer extends React.PureComponent {
} }
} }
class AccountDataExplorer extends React.PureComponent { interface IAccountDataExplorerState {
static getLabel() { return _t('Explore Account Data'); } isRoomAccountData: boolean;
event?: MatrixEvent;
editing: boolean;
queryEventType: string;
[inputId: string]: boolean | string;
}
static propTypes = { class AccountDataExplorer extends React.PureComponent<IExplorerProps, IAccountDataExplorerState> {
onBack: PropTypes.func.isRequired, static getLabel() { return _t('Explore Account Data'); }
room: PropTypes.instanceOf(Room).isRequired,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
constructor(props) { constructor(props) {
super(props); super(props);
this.onBack = this.onBack.bind(this);
this.editEv = this.editEv.bind(this);
this._onChange = this._onChange.bind(this);
this.onQueryEventType = this.onQueryEventType.bind(this);
this.state = { this.state = {
isRoomAccountData: false, isRoomAccountData: false,
event: null, event: null,
@ -499,20 +549,20 @@ class AccountDataExplorer extends React.PureComponent {
}; };
} }
getData() { private getData(): Record<string, MatrixEvent> {
if (this.state.isRoomAccountData) { if (this.state.isRoomAccountData) {
return this.props.room.accountData; return this.props.room.accountData;
} }
return this.context.store.accountData; return this.context.store.accountData;
} }
onViewSourceClick(event) { private onViewSourceClick(event: MatrixEvent) {
return () => { return () => {
this.setState({ event }); this.setState({ event });
}; };
} }
onBack() { private onBack = () => {
if (this.state.editing) { if (this.state.editing) {
this.setState({ editing: false }); this.setState({ editing: false });
} else if (this.state.event) { } else if (this.state.event) {
@ -522,15 +572,15 @@ class AccountDataExplorer extends React.PureComponent {
} }
} }
_onChange(e) { private onChange = (e: ChangeEvent<HTMLInputElement>) => {
this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value}); this.setState({[e.target.id]: e.target.type === 'checkbox' ? e.target.checked : e.target.value});
} }
editEv() { private editEv = () => {
this.setState({ editing: true }); this.setState({ editing: true });
} }
onQueryEventType(queryEventType) { private onQueryEventType = (queryEventType: string) => {
this.setState({ queryEventType }); this.setState({ queryEventType });
} }
@ -580,30 +630,39 @@ class AccountDataExplorer extends React.PureComponent {
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={this.onBack}>{ _t('Back') }</button> <button onClick={this.onBack}>{ _t('Back') }</button>
{ !this.state.message && <div style={{float: "right"}}> <div style={{float: "right"}}>
<input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip" type="checkbox" onChange={this._onChange} checked={this.state.isRoomAccountData} /> <input id="isRoomAccountData" className="mx_DevTools_tgl mx_DevTools_tgl-flip"
<label className="mx_DevTools_tgl-btn" data-tg-off="Account Data" data-tg-on="Room Data" htmlFor="isRoomAccountData" /> type="checkbox"
</div> } checked={this.state.isRoomAccountData}
onChange={this.onChange}
/>
<label className="mx_DevTools_tgl-btn"
data-tg-off="Account Data"
data-tg-on="Room Data"
htmlFor="isRoomAccountData"
/>
</div>
</div> </div>
</div>; </div>;
} }
} }
class ServersInRoomList extends React.PureComponent { interface IServersInRoomListState {
query: string;
}
class ServersInRoomList extends React.PureComponent<IExplorerProps, IServersInRoomListState> {
static getLabel() { return _t('View Servers in Room'); } static getLabel() { return _t('View Servers in Room'); }
static propTypes = {
onBack: PropTypes.func.isRequired,
room: PropTypes.instanceOf(Room).isRequired,
};
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
private servers: React.ReactElement[];
constructor(props) { constructor(props) {
super(props); super(props);
const room = this.props.room; const room = this.props.room;
const servers = new Set(); const servers = new Set<string>();
room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1])); room.currentState.getStateEvents("m.room.member").forEach(ev => servers.add(ev.getSender().split(":")[1]));
this.servers = Array.from(servers).map(s => this.servers = Array.from(servers).map(s =>
<button key={s} className="mx_DevTools_ServersInRoomList_button"> <button key={s} className="mx_DevTools_ServersInRoomList_button">
@ -615,7 +674,7 @@ class ServersInRoomList extends React.PureComponent {
}; };
} }
onQuery = (query) => { private onQuery = (query: string) => {
this.setState({ query }); this.setState({ query });
} }
@ -642,7 +701,10 @@ const PHASE_MAP = {
[PHASE_CANCELLED]: "cancelled", [PHASE_CANCELLED]: "cancelled",
}; };
function VerificationRequest({txnId, request}) { const VerificationRequestExplorer: React.FC<{
txnId: string;
request: VerificationRequest;
}> = ({txnId, request}) => {
const [, updateState] = useState(); const [, updateState] = useState();
const [timeout, setRequestTimeout] = useState(request.timeout); const [timeout, setRequestTimeout] = useState(request.timeout);
@ -679,7 +741,7 @@ function VerificationRequest({txnId, request}) {
</div>); </div>);
} }
class VerificationExplorer extends React.Component { class VerificationExplorer extends React.PureComponent<IExplorerProps> {
static getLabel() { static getLabel() {
return _t("Verification Requests"); return _t("Verification Requests");
} }
@ -687,7 +749,7 @@ class VerificationExplorer extends React.Component {
/* Ensure this.context is the cli */ /* Ensure this.context is the cli */
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
onNewRequest = () => { private onNewRequest = () => {
this.forceUpdate(); this.forceUpdate();
} }
@ -704,13 +766,13 @@ class VerificationExplorer extends React.Component {
render() { render() {
const cli = this.context; const cli = this.context;
const room = this.props.room; const room = this.props.room;
const inRoomChannel = cli._crypto._inRoomVerificationRequests; const inRoomChannel = cli.crypto._inRoomVerificationRequests;
const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map(); const inRoomRequests = (inRoomChannel._requestsByRoomId || new Map()).get(room.roomId) || new Map();
return (<div> return (<div>
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
{Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) => {Array.from(inRoomRequests.entries()).reverse().map(([txnId, request]) =>
<VerificationRequest txnId={txnId} request={request} key={txnId} />, <VerificationRequestExplorer txnId={txnId} request={request} key={txnId} />,
)} )}
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
@ -720,7 +782,12 @@ class VerificationExplorer extends React.Component {
} }
} }
class WidgetExplorer extends React.Component { interface IWidgetExplorerState {
query: string;
editWidget?: IApp;
}
class WidgetExplorer extends React.Component<IExplorerProps, IWidgetExplorerState> {
static getLabel() { static getLabel() {
return _t("Active Widgets"); return _t("Active Widgets");
} }
@ -734,19 +801,19 @@ class WidgetExplorer extends React.Component {
}; };
} }
onWidgetStoreUpdate = () => { private onWidgetStoreUpdate = () => {
this.forceUpdate(); this.forceUpdate();
}; };
onQueryChange = (query) => { private onQueryChange = (query: string) => {
this.setState({query}); this.setState({query});
}; };
onEditWidget = (widget) => { private onEditWidget = (widget: IApp) => {
this.setState({editWidget: widget}); this.setState({editWidget: widget});
}; };
onBack = () => { private onBack = () => {
const widgets = WidgetStore.instance.getApps(this.props.room.roomId); const widgets = WidgetStore.instance.getApps(this.props.room.roomId);
if (this.state.editWidget && widgets.includes(this.state.editWidget)) { if (this.state.editWidget && widgets.includes(this.state.editWidget)) {
this.setState({editWidget: null}); this.setState({editWidget: null});
@ -769,8 +836,11 @@ class WidgetExplorer extends React.Component {
const editWidget = this.state.editWidget; const editWidget = this.state.editWidget;
const widgets = WidgetStore.instance.getApps(room.roomId); const widgets = WidgetStore.instance.getApps(room.roomId);
if (editWidget && widgets.includes(editWidget)) { if (editWidget && widgets.includes(editWidget)) {
const allState = Array.from(Array.from(room.currentState.events.values()).map(e => e.values())) const allState = Array.from(
.reduce((p, c) => {p.push(...c); return p;}, []); Array.from(room.currentState.events.values()).map((e: Map<string, MatrixEvent>) => {
return e.values();
}),
).reduce((p, c) => { p.push(...c); return p; }, []);
const stateEv = allState.find(ev => ev.getId() === editWidget.eventId); const stateEv = allState.find(ev => ev.getId() === editWidget.eventId);
if (!stateEv) { // "should never happen" if (!stateEv) { // "should never happen"
return <div> return <div>
@ -811,7 +881,15 @@ class WidgetExplorer extends React.Component {
} }
} }
class SettingsExplorer extends React.Component { interface ISettingsExplorerState {
query: string;
editSetting?: string;
viewSetting?: string;
explicitValues?: string;
explicitRoomValues?: string;
}
class SettingsExplorer extends React.PureComponent<IExplorerProps, ISettingsExplorerState> {
static getLabel() { static getLabel() {
return _t("Settings Explorer"); return _t("Settings Explorer");
} }
@ -829,19 +907,19 @@ class SettingsExplorer extends React.Component {
}; };
} }
onQueryChange = (ev) => { private onQueryChange = (ev: ChangeEvent<HTMLInputElement>) => {
this.setState({query: ev.target.value}); this.setState({query: ev.target.value});
}; };
onExplValuesEdit = (ev) => { private onExplValuesEdit = (ev: ChangeEvent<HTMLTextAreaElement>) => {
this.setState({explicitValues: ev.target.value}); this.setState({explicitValues: ev.target.value});
}; };
onExplRoomValuesEdit = (ev) => { private onExplRoomValuesEdit = (ev: ChangeEvent<HTMLTextAreaElement>) => {
this.setState({explicitRoomValues: ev.target.value}); this.setState({explicitRoomValues: ev.target.value});
}; };
onBack = () => { private onBack = () => {
if (this.state.editSetting) { if (this.state.editSetting) {
this.setState({editSetting: null}); this.setState({editSetting: null});
} else if (this.state.viewSetting) { } else if (this.state.viewSetting) {
@ -851,12 +929,12 @@ class SettingsExplorer extends React.Component {
} }
}; };
onViewClick = (ev, settingId) => { private onViewClick = (ev: MouseEvent, settingId: string) => {
ev.preventDefault(); ev.preventDefault();
this.setState({viewSetting: settingId}); this.setState({viewSetting: settingId});
}; };
onEditClick = (ev, settingId) => { private onEditClick = (ev: MouseEvent, settingId: string) => {
ev.preventDefault(); ev.preventDefault();
this.setState({ this.setState({
editSetting: settingId, editSetting: settingId,
@ -865,7 +943,7 @@ class SettingsExplorer extends React.Component {
}); });
}; };
onSaveClick = async () => { private onSaveClick = async () => {
try { try {
const settingId = this.state.editSetting; const settingId = this.state.editSetting;
const parsedExplicit = JSON.parse(this.state.explicitValues); const parsedExplicit = JSON.parse(this.state.explicitValues);
@ -874,7 +952,7 @@ class SettingsExplorer extends React.Component {
console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`); console.log(`[Devtools] Setting value of ${settingId} at ${level} from user input`);
try { try {
const val = parsedExplicit[level]; const val = parsedExplicit[level];
await SettingsStore.setValue(settingId, null, level, val); await SettingsStore.setValue(settingId, null, level as SettingLevel, val);
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
} }
@ -884,7 +962,7 @@ class SettingsExplorer extends React.Component {
console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`); console.log(`[Devtools] Setting value of ${settingId} at ${level} in ${roomId} from user input`);
try { try {
const val = parsedExplicitRoom[level]; const val = parsedExplicitRoom[level];
await SettingsStore.setValue(settingId, roomId, level, val); await SettingsStore.setValue(settingId, roomId, level as SettingLevel, val);
} catch (e) { } catch (e) {
console.warn(e); console.warn(e);
} }
@ -901,7 +979,7 @@ class SettingsExplorer extends React.Component {
} }
}; };
renderSettingValue(val) { private renderSettingValue(val: any): string {
// Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us // Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us
const toStringTypes = ['boolean', 'number']; const toStringTypes = ['boolean', 'number'];
if (toStringTypes.includes(typeof(val))) { if (toStringTypes.includes(typeof(val))) {
@ -911,7 +989,7 @@ class SettingsExplorer extends React.Component {
} }
} }
renderExplicitSettingValues(setting, roomId) { private renderExplicitSettingValues(setting: string, roomId: string): string {
const vals = {}; const vals = {};
for (const level of LEVEL_ORDER) { for (const level of LEVEL_ORDER) {
try { try {
@ -926,7 +1004,7 @@ class SettingsExplorer extends React.Component {
return JSON.stringify(vals, null, 4); return JSON.stringify(vals, null, 4);
} }
renderCanEditLevel(roomId, level) { private renderCanEditLevel(roomId: string, level: SettingLevel): React.ReactNode {
const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level); const canEdit = SettingsStore.canSetValue(this.state.editSetting, roomId, level);
const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable'; const className = canEdit ? 'mx_DevTools_SettingsExplorer_mutable' : 'mx_DevTools_SettingsExplorer_immutable';
return <td className={className}><code>{canEdit.toString()}</code></td>; return <td className={className}><code>{canEdit.toString()}</code></td>;
@ -1062,27 +1140,37 @@ class SettingsExplorer extends React.Component {
<div> <div>
{_t("Value:")}&nbsp; {_t("Value:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting))}</code> <code>{this.renderSettingValue(
SettingsStore.getValue(this.state.viewSetting),
)}</code>
</div> </div>
<div> <div>
{_t("Value in this room:")}&nbsp; {_t("Value in this room:")}&nbsp;
<code>{this.renderSettingValue(SettingsStore.getValue(this.state.viewSetting, room.roomId))}</code> <code>{this.renderSettingValue(
SettingsStore.getValue(this.state.viewSetting, room.roomId),
)}</code>
</div> </div>
<div> <div>
{_t("Values at explicit levels:")} {_t("Values at explicit levels:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, null)}</code></pre> <pre><code>{this.renderExplicitSettingValues(
this.state.viewSetting, null,
)}</code></pre>
</div> </div>
<div> <div>
{_t("Values at explicit levels in this room:")} {_t("Values at explicit levels in this room:")}
<pre><code>{this.renderExplicitSettingValues(this.state.viewSetting, room.roomId)}</code></pre> <pre><code>{this.renderExplicitSettingValues(
this.state.viewSetting, room.roomId,
)}</code></pre>
</div> </div>
</div> </div>
<div className="mx_Dialog_buttons"> <div className="mx_Dialog_buttons">
<button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{_t("Edit Values")}</button> <button onClick={(e) => this.onEditClick(e, this.state.viewSetting)}>{
_t("Edit Values")
}</button>
<button onClick={this.onBack}>{_t("Back")}</button> <button onClick={this.onBack}>{_t("Back")}</button>
</div> </div>
</div> </div>
@ -1091,7 +1179,11 @@ class SettingsExplorer extends React.Component {
} }
} }
const Entries = [ type DevtoolsDialogEntry = React.JSXElementConstructor<any> & {
getLabel: () => string;
};
const Entries: DevtoolsDialogEntry[] = [
SendCustomEvent, SendCustomEvent,
RoomStateExplorer, RoomStateExplorer,
SendAccountData, SendAccountData,
@ -1102,43 +1194,36 @@ const Entries = [
SettingsExplorer, SettingsExplorer,
]; ];
@replaceableComponent("views.dialogs.DevtoolsDialog") interface IProps {
export default class DevtoolsDialog extends React.PureComponent { roomId: string;
static propTypes = { onFinished: (finished: boolean) => void;
roomId: PropTypes.string.isRequired, }
onFinished: PropTypes.func.isRequired,
};
interface IState {
mode?: DevtoolsDialogEntry;
}
@replaceableComponent("views.dialogs.DevtoolsDialog")
export default class DevtoolsDialog extends React.PureComponent<IProps, IState> {
constructor(props) { constructor(props) {
super(props); super(props);
this.onBack = this.onBack.bind(this);
this.onCancel = this.onCancel.bind(this);
this.state = { this.state = {
mode: null, mode: null,
}; };
} }
componentWillUnmount() { private setMode(mode: DevtoolsDialogEntry) {
this._unmounted = true;
}
_setMode(mode) {
return () => { return () => {
this.setState({ mode }); this.setState({ mode });
}; };
} }
onBack() { private onBack = () => {
if (this.prevMode) { this.setState({ mode: null });
this.setState({ mode: this.prevMode });
this.prevMode = null;
} else {
this.setState({ mode: null });
}
} }
onCancel() { private onCancel = () => {
this.props.onFinished(false); this.props.onFinished(false);
} }
@ -1165,7 +1250,7 @@ export default class DevtoolsDialog extends React.PureComponent {
<div className="mx_Dialog_content"> <div className="mx_Dialog_content">
{ Entries.map((Entry) => { { Entries.map((Entry) => {
const label = Entry.getLabel(); const label = Entry.getLabel();
const onClick = this._setMode(Entry); const onClick = this.setMode(Entry);
return <button className={classes} key={label} onClick={onClick}>{ label }</button>; return <button className={classes} key={label} onClick={onClick}>{ label }</button>;
}) } }) }
</div> </div>

View file

@ -47,10 +47,19 @@ import { MatrixCall } from 'matrix-js-sdk/src/webrtc/call';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import {mediaFromMxc} from "../../../customisations/Media"; import {mediaFromMxc} from "../../../customisations/Media";
import {getAddressType} from "../../../UserAddress"; import {getAddressType} from "../../../UserAddress";
import BaseAvatar from '../avatars/BaseAvatar';
import AccessibleButton from '../elements/AccessibleButton';
import { compare } from '../../../utils/strings';
// we have a number of types defined from the Matrix spec which can't reasonably be altered here. // we have a number of types defined from the Matrix spec which can't reasonably be altered here.
/* eslint-disable camelcase */ /* eslint-disable camelcase */
interface IRecentUser {
userId: string,
user: RoomMember,
lastActive: number,
}
export const KIND_DM = "dm"; export const KIND_DM = "dm";
export const KIND_INVITE = "invite"; export const KIND_INVITE = "invite";
export const KIND_CALL_TRANSFER = "call_transfer"; export const KIND_CALL_TRANSFER = "call_transfer";
@ -61,43 +70,41 @@ const INCREMENT_ROOMS_SHOWN = 5; // Number of rooms to add when 'show more' is c
// This is the interface that is expected by various components in this file. It is a bit // This is the interface that is expected by various components in this file. It is a bit
// awkward because it also matches the RoomMember class from the js-sdk with some extra support // awkward because it also matches the RoomMember class from the js-sdk with some extra support
// for 3PIDs/email addresses. // for 3PIDs/email addresses.
// abstract class Member {
// XXX: We should use TypeScript interfaces instead of this weird "abstract" class.
class Member {
/** /**
* The display name of this Member. For users this should be their profile's display * The display name of this Member. For users this should be their profile's display
* name or user ID if none set. For 3PIDs this should be the 3PID address (email). * name or user ID if none set. For 3PIDs this should be the 3PID address (email).
*/ */
get name(): string { throw new Error("Member class not implemented"); } public abstract get name(): string;
/** /**
* The ID of this Member. For users this should be their user ID. For 3PIDs this should * The ID of this Member. For users this should be their user ID. For 3PIDs this should
* be the 3PID address (email). * be the 3PID address (email).
*/ */
get userId(): string { throw new Error("Member class not implemented"); } public abstract get userId(): string;
/** /**
* Gets the MXC URL of this Member's avatar. For users this should be their profile's * Gets the MXC URL of this Member's avatar. For users this should be their profile's
* avatar MXC URL or null if none set. For 3PIDs this should always be null. * avatar MXC URL or null if none set. For 3PIDs this should always be null.
*/ */
getMxcAvatarUrl(): string { throw new Error("Member class not implemented"); } public abstract getMxcAvatarUrl(): string;
} }
class DirectoryMember extends Member { class DirectoryMember extends Member {
_userId: string; private readonly _userId: string;
_displayName: string; private readonly displayName: string;
_avatarUrl: string; private readonly avatarUrl: string;
constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) { constructor(userDirResult: {user_id: string, display_name: string, avatar_url: string}) {
super(); super();
this._userId = userDirResult.user_id; this._userId = userDirResult.user_id;
this._displayName = userDirResult.display_name; this.displayName = userDirResult.display_name;
this._avatarUrl = userDirResult.avatar_url; this.avatarUrl = userDirResult.avatar_url;
} }
// These next class members are for the Member interface // These next class members are for the Member interface
get name(): string { get name(): string {
return this._displayName || this._userId; return this.displayName || this._userId;
} }
get userId(): string { get userId(): string {
@ -105,32 +112,32 @@ class DirectoryMember extends Member {
} }
getMxcAvatarUrl(): string { getMxcAvatarUrl(): string {
return this._avatarUrl; return this.avatarUrl;
} }
} }
class ThreepidMember extends Member { class ThreepidMember extends Member {
_id: string; private readonly id: string;
constructor(id: string) { constructor(id: string) {
super(); super();
this._id = id; this.id = id;
} }
// This is a getter that would be falsey on all other implementations. Until we have // This is a getter that would be falsey on all other implementations. Until we have
// better type support in the react-sdk we can use this trick to determine the kind // better type support in the react-sdk we can use this trick to determine the kind
// of 3PID we're dealing with, if any. // of 3PID we're dealing with, if any.
get isEmail(): boolean { get isEmail(): boolean {
return this._id.includes('@'); return this.id.includes('@');
} }
// These next class members are for the Member interface // These next class members are for the Member interface
get name(): string { get name(): string {
return this._id; return this.id;
} }
get userId(): string { get userId(): string {
return this._id; return this.id;
} }
getMxcAvatarUrl(): string { getMxcAvatarUrl(): string {
@ -140,11 +147,11 @@ class ThreepidMember extends Member {
interface IDMUserTileProps { interface IDMUserTileProps {
member: RoomMember; member: RoomMember;
onRemove: (RoomMember) => any; onRemove(member: RoomMember): void;
} }
class DMUserTile extends React.PureComponent<IDMUserTileProps> { class DMUserTile extends React.PureComponent<IDMUserTileProps> {
_onRemove = (e) => { private onRemove = (e) => {
// Stop the browser from highlighting text // Stop the browser from highlighting text
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -153,9 +160,6 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
}; };
render() { render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
const AccessibleButton = sdk.getComponent("elements.AccessibleButton");
const avatarSize = 20; const avatarSize = 20;
const avatar = this.props.member.isEmail const avatar = this.props.member.isEmail
? <img ? <img
@ -177,7 +181,7 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
closeButton = ( closeButton = (
<AccessibleButton <AccessibleButton
className='mx_InviteDialog_userTile_remove' className='mx_InviteDialog_userTile_remove'
onClick={this._onRemove} onClick={this.onRemove}
> >
<img src={require("../../../../res/img/icon-pill-remove.svg")} <img src={require("../../../../res/img/icon-pill-remove.svg")}
alt={_t('Remove')} width={8} height={8} alt={_t('Remove')} width={8} height={8}
@ -201,13 +205,13 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
interface IDMRoomTileProps { interface IDMRoomTileProps {
member: RoomMember; member: RoomMember;
lastActiveTs: number; lastActiveTs: number;
onToggle: (RoomMember) => any; onToggle(member: RoomMember): void;
highlightWord: string; highlightWord: string;
isSelected: boolean; isSelected: boolean;
} }
class DMRoomTile extends React.PureComponent<IDMRoomTileProps> { class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
_onClick = (e) => { private onClick = (e) => {
// Stop the browser from highlighting text // Stop the browser from highlighting text
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@ -215,7 +219,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
this.props.onToggle(this.props.member); this.props.onToggle(this.props.member);
}; };
_highlightName(str: string) { private highlightName(str: string) {
if (!this.props.highlightWord) return str; if (!this.props.highlightWord) return str;
// We convert things to lowercase for index searching, but pull substrings from // We convert things to lowercase for index searching, but pull substrings from
@ -252,8 +256,6 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
} }
render() { render() {
const BaseAvatar = sdk.getComponent("views.avatars.BaseAvatar");
let timestamp = null; let timestamp = null;
if (this.props.lastActiveTs) { if (this.props.lastActiveTs) {
const humanTs = humanizeTime(this.props.lastActiveTs); const humanTs = humanizeTime(this.props.lastActiveTs);
@ -291,13 +293,13 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
const caption = this.props.member.isEmail const caption = this.props.member.isEmail
? _t("Invite by email") ? _t("Invite by email")
: this._highlightName(this.props.member.userId); : this.highlightName(this.props.member.userId);
return ( return (
<div className='mx_InviteDialog_roomTile' onClick={this._onClick}> <div className='mx_InviteDialog_roomTile' onClick={this.onClick}>
{stackedAvatar} {stackedAvatar}
<span className="mx_InviteDialog_roomTile_nameStack"> <span className="mx_InviteDialog_roomTile_nameStack">
<div className='mx_InviteDialog_roomTile_name'>{this._highlightName(this.props.member.name)}</div> <div className='mx_InviteDialog_roomTile_name'>{this.highlightName(this.props.member.name)}</div>
<div className='mx_InviteDialog_roomTile_userId'>{caption}</div> <div className='mx_InviteDialog_roomTile_userId'>{caption}</div>
</span> </span>
{timestamp} {timestamp}
@ -308,7 +310,7 @@ class DMRoomTile extends React.PureComponent<IDMRoomTileProps> {
interface IInviteDialogProps { interface IInviteDialogProps {
// Takes an array of user IDs/emails to invite. // Takes an array of user IDs/emails to invite.
onFinished: (toInvite?: string[]) => any; onFinished: (toInvite?: string[]) => void;
// The kind of invite being performed. Assumed to be KIND_DM if // The kind of invite being performed. Assumed to be KIND_DM if
// not provided. // not provided.
@ -349,8 +351,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
initialText: "", initialText: "",
}; };
_debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser private debounceTimer: NodeJS.Timeout = null; // actually number because we're in the browser
_editorRef: any = null; private editorRef = createRef<HTMLInputElement>();
private unmounted = false;
constructor(props) { constructor(props) {
super(props); super(props);
@ -378,7 +381,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
filterText: this.props.initialText, filterText: this.props.initialText,
recents: InviteDialog.buildRecents(alreadyInvited), recents: InviteDialog.buildRecents(alreadyInvited),
numRecentsShown: INITIAL_ROOMS_SHOWN, numRecentsShown: INITIAL_ROOMS_SHOWN,
suggestions: this._buildSuggestions(alreadyInvited), suggestions: this.buildSuggestions(alreadyInvited),
numSuggestionsShown: INITIAL_ROOMS_SHOWN, numSuggestionsShown: INITIAL_ROOMS_SHOWN,
serverResultsMixin: [], serverResultsMixin: [],
threepidResultsMixin: [], threepidResultsMixin: [],
@ -390,21 +393,23 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
busy: false, busy: false,
errorText: null, errorText: null,
}; };
this._editorRef = createRef();
} }
componentDidMount() { componentDidMount() {
if (this.props.initialText) { if (this.props.initialText) {
this._updateSuggestions(this.props.initialText); this.updateSuggestions(this.props.initialText);
} }
} }
componentWillUnmount() {
this.unmounted = true;
}
private onConsultFirstChange = (ev) => { private onConsultFirstChange = (ev) => {
this.setState({consultFirst: ev.target.checked}); this.setState({consultFirst: ev.target.checked});
} }
static buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number}[] { public static buildRecents(excludedTargetIds: Set<string>): IRecentUser[] {
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the // Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
@ -467,7 +472,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return recents; return recents;
} }
_buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] { private buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
const maxConsideredMembers = 200; const maxConsideredMembers = 200;
const joinedRooms = MatrixClientPeg.get().getRooms() const joinedRooms = MatrixClientPeg.get().getRooms()
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers); .filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
@ -574,7 +579,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
members.sort((a, b) => { members.sort((a, b) => {
if (a.score === b.score) { if (a.score === b.score) {
if (a.numRooms === b.numRooms) { if (a.numRooms === b.numRooms) {
return a.member.userId.localeCompare(b.member.userId); return compare(a.member.userId, b.member.userId);
} }
return b.numRooms - a.numRooms; return b.numRooms - a.numRooms;
@ -585,7 +590,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return members.map(m => ({userId: m.member.userId, user: m.member})); return members.map(m => ({userId: m.member.userId, user: m.member}));
} }
_shouldAbortAfterInviteError(result): boolean { private shouldAbortAfterInviteError(result): boolean {
const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error'); const failedUsers = Object.keys(result.states).filter(a => result.states[a] === 'error');
if (failedUsers.length > 0) { if (failedUsers.length > 0) {
console.log("Failed to invite users: ", result); console.log("Failed to invite users: ", result);
@ -600,7 +605,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return false; return false;
} }
_convertFilter(): Member[] { private convertFilter(): Member[] {
// Check to see if there's anything to convert first // Check to see if there's anything to convert first
if (!this.state.filterText || !this.state.filterText.includes('@')) return this.state.targets || []; if (!this.state.filterText || !this.state.filterText.includes('@')) return this.state.targets || [];
@ -617,10 +622,10 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return newTargets; return newTargets;
} }
_startDm = async () => { private startDm = async () => {
this.setState({busy: true}); this.setState({busy: true});
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const targets = this._convertFilter(); const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId); const targetIds = targets.map(t => t.userId);
// Check if there is already a DM with these people and reuse it if possible. // Check if there is already a DM with these people and reuse it if possible.
@ -694,11 +699,11 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_inviteUsers = async () => { private inviteUsers = async () => {
const startTime = CountlyAnalytics.getTimestamp(); const startTime = CountlyAnalytics.getTimestamp();
this.setState({busy: true}); this.setState({busy: true});
this._convertFilter(); this.convertFilter();
const targets = this._convertFilter(); const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId); const targetIds = targets.map(t => t.userId);
const cli = MatrixClientPeg.get(); const cli = MatrixClientPeg.get();
@ -715,7 +720,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
try { try {
const result = await inviteMultipleToRoom(this.props.roomId, targetIds) const result = await inviteMultipleToRoom(this.props.roomId, targetIds)
CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length); CountlyAnalytics.instance.trackSendInvite(startTime, this.props.roomId, targetIds.length);
if (!this._shouldAbortAfterInviteError(result)) { // handles setting error message too if (!this.shouldAbortAfterInviteError(result)) { // handles setting error message too
this.props.onFinished(); this.props.onFinished();
} }
@ -749,9 +754,9 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_transferCall = async () => { private transferCall = async () => {
this._convertFilter(); this.convertFilter();
const targets = this._convertFilter(); const targets = this.convertFilter();
const targetIds = targets.map(t => t.userId); const targetIds = targets.map(t => t.userId);
if (targetIds.length > 1) { if (targetIds.length > 1) {
this.setState({ this.setState({
@ -790,26 +795,26 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_onKeyDown = (e) => { private onKeyDown = (e) => {
if (this.state.busy) return; if (this.state.busy) return;
const value = e.target.value.trim(); const value = e.target.value.trim();
const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey; const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey;
if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) { if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) {
// when the field is empty and the user hits backspace remove the right-most target // when the field is empty and the user hits backspace remove the right-most target
e.preventDefault(); e.preventDefault();
this._removeMember(this.state.targets[this.state.targets.length - 1]); this.removeMember(this.state.targets[this.state.targets.length - 1]);
} else if (value && e.key === Key.ENTER && !hasModifiers) { } else if (value && e.key === Key.ENTER && !hasModifiers) {
// when the user hits enter with something in their field try to convert it // when the user hits enter with something in their field try to convert it
e.preventDefault(); e.preventDefault();
this._convertFilter(); this.convertFilter();
} else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) { } else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) {
// when the user hits space and their input looks like an e-mail/MXID then try to convert it // when the user hits space and their input looks like an e-mail/MXID then try to convert it
e.preventDefault(); e.preventDefault();
this._convertFilter(); this.convertFilter();
} }
}; };
_updateSuggestions = async (term) => { private updateSuggestions = async (term) => {
MatrixClientPeg.get().searchUserDirectory({term}).then(async r => { MatrixClientPeg.get().searchUserDirectory({term}).then(async r => {
if (term !== this.state.filterText) { if (term !== this.state.filterText) {
// Discard the results - we were probably too slow on the server-side to make // Discard the results - we were probably too slow on the server-side to make
@ -918,30 +923,30 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
}; };
_updateFilter = (e) => { private updateFilter = (e) => {
const term = e.target.value; const term = e.target.value;
this.setState({filterText: term}); this.setState({filterText: term});
// Debounce server lookups to reduce spam. We don't clear the existing server // Debounce server lookups to reduce spam. We don't clear the existing server
// results because they might still be vaguely accurate, likewise for races which // results because they might still be vaguely accurate, likewise for races which
// could happen here. // could happen here.
if (this._debounceTimer) { if (this.debounceTimer) {
clearTimeout(this._debounceTimer); clearTimeout(this.debounceTimer);
} }
this._debounceTimer = setTimeout(() => { this.debounceTimer = setTimeout(() => {
this._updateSuggestions(term); this.updateSuggestions(term);
}, 150); // 150ms debounce (human reaction time + some) }, 150); // 150ms debounce (human reaction time + some)
}; };
_showMoreRecents = () => { private showMoreRecents = () => {
this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN}); this.setState({numRecentsShown: this.state.numRecentsShown + INCREMENT_ROOMS_SHOWN});
}; };
_showMoreSuggestions = () => { private showMoreSuggestions = () => {
this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN}); this.setState({numSuggestionsShown: this.state.numSuggestionsShown + INCREMENT_ROOMS_SHOWN});
}; };
_toggleMember = (member: Member) => { private toggleMember = (member: Member) => {
if (!this.state.busy) { if (!this.state.busy) {
let filterText = this.state.filterText; let filterText = this.state.filterText;
const targets = this.state.targets.map(t => t); // cheap clone for mutation const targets = this.state.targets.map(t => t); // cheap clone for mutation
@ -954,13 +959,13 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} }
this.setState({targets, filterText}); this.setState({targets, filterText});
if (this._editorRef && this._editorRef.current) { if (this.editorRef && this.editorRef.current) {
this._editorRef.current.focus(); this.editorRef.current.focus();
} }
} }
}; };
_removeMember = (member: Member) => { private removeMember = (member: Member) => {
const targets = this.state.targets.map(t => t); // cheap clone for mutation const targets = this.state.targets.map(t => t); // cheap clone for mutation
const idx = targets.indexOf(member); const idx = targets.indexOf(member);
if (idx >= 0) { if (idx >= 0) {
@ -968,12 +973,12 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({targets}); this.setState({targets});
} }
if (this._editorRef && this._editorRef.current) { if (this.editorRef && this.editorRef.current) {
this._editorRef.current.focus(); this.editorRef.current.focus();
} }
}; };
_onPaste = async (e) => { private onPaste = async (e) => {
if (this.state.filterText) { if (this.state.filterText) {
// if the user has already typed something, just let them // if the user has already typed something, just let them
// paste normally. // paste normally.
@ -1027,6 +1032,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
failed.push(address); failed.push(address);
} }
} }
if (this.unmounted) return;
if (failed.length > 0) { if (failed.length > 0) {
const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog'); const QuestionDialog = sdk.getComponent('dialogs.QuestionDialog');
@ -1043,17 +1049,17 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({targets: [...this.state.targets, ...toAdd]}); this.setState({targets: [...this.state.targets, ...toAdd]});
}; };
_onClickInputArea = (e) => { private onClickInputArea = (e) => {
// Stop the browser from highlighting text // Stop the browser from highlighting text
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
if (this._editorRef && this._editorRef.current) { if (this.editorRef && this.editorRef.current) {
this._editorRef.current.focus(); this.editorRef.current.focus();
} }
}; };
_onUseDefaultIdentityServerClick = (e) => { private onUseDefaultIdentityServerClick = (e) => {
e.preventDefault(); e.preventDefault();
// Update the IS in account data. Actually using it may trigger terms. // Update the IS in account data. Actually using it may trigger terms.
@ -1062,21 +1068,21 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
this.setState({canUseIdentityServer: true, tryingIdentityServer: false}); this.setState({canUseIdentityServer: true, tryingIdentityServer: false});
}; };
_onManageSettingsClick = (e) => { private onManageSettingsClick = (e) => {
e.preventDefault(); e.preventDefault();
dis.fire(Action.ViewUserSettings); dis.fire(Action.ViewUserSettings);
this.props.onFinished(); this.props.onFinished();
}; };
_onCommunityInviteClick = (e) => { private onCommunityInviteClick = (e) => {
this.props.onFinished(); this.props.onFinished();
showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId()); showCommunityInviteDialog(CommunityPrototypeStore.instance.getSelectedCommunityId());
}; };
_renderSection(kind: "recents"|"suggestions") { private renderSection(kind: "recents"|"suggestions") {
let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions; let sourceMembers = kind === 'recents' ? this.state.recents : this.state.suggestions;
let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown; let showNum = kind === 'recents' ? this.state.numRecentsShown : this.state.numSuggestionsShown;
const showMoreFn = kind === 'recents' ? this._showMoreRecents.bind(this) : this._showMoreSuggestions.bind(this); const showMoreFn = kind === 'recents' ? this.showMoreRecents.bind(this) : this.showMoreSuggestions.bind(this);
const lastActive = (m) => kind === 'recents' ? m.lastActive : null; const lastActive = (m) => kind === 'recents' ? m.lastActive : null;
let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions"); let sectionName = kind === 'recents' ? _t("Recent Conversations") : _t("Suggestions");
let sectionSubname = null; let sectionSubname = null;
@ -1156,7 +1162,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
member={r.user} member={r.user}
lastActiveTs={lastActive(r)} lastActiveTs={lastActive(r)}
key={r.userId} key={r.userId}
onToggle={this._toggleMember} onToggle={this.toggleMember}
highlightWord={this.state.filterText} highlightWord={this.state.filterText}
isSelected={this.state.targets.some(t => t.userId === r.userId)} isSelected={this.state.targets.some(t => t.userId === r.userId)}
/> />
@ -1171,32 +1177,32 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
); );
} }
_renderEditor() { private renderEditor() {
const targets = this.state.targets.map(t => ( const targets = this.state.targets.map(t => (
<DMUserTile member={t} onRemove={!this.state.busy && this._removeMember} key={t.userId} /> <DMUserTile member={t} onRemove={!this.state.busy && this.removeMember} key={t.userId} />
)); ));
const input = ( const input = (
<input <input
type="text" type="text"
onKeyDown={this._onKeyDown} onKeyDown={this.onKeyDown}
onChange={this._updateFilter} onChange={this.updateFilter}
value={this.state.filterText} value={this.state.filterText}
ref={this._editorRef} ref={this.editorRef}
onPaste={this._onPaste} onPaste={this.onPaste}
autoFocus={true} autoFocus={true}
disabled={this.state.busy} disabled={this.state.busy}
autoComplete="off" autoComplete="off"
/> />
); );
return ( return (
<div className='mx_InviteDialog_editor' onClick={this._onClickInputArea}> <div className='mx_InviteDialog_editor' onClick={this.onClickInputArea}>
{targets} {targets}
{input} {input}
</div> </div>
); );
} }
_renderIdentityServerWarning() { private renderIdentityServerWarning() {
if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer || if (!this.state.tryingIdentityServer || this.state.canUseIdentityServer ||
!SettingsStore.getValue(UIFeature.IdentityServer) !SettingsStore.getValue(UIFeature.IdentityServer)
) { ) {
@ -1214,8 +1220,8 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl), defaultIdentityServerName: abbreviateUrl(defaultIdentityServerUrl),
}, },
{ {
default: sub => <a href="#" onClick={this._onUseDefaultIdentityServerClick}>{sub}</a>, default: sub => <a href="#" onClick={this.onUseDefaultIdentityServerClick}>{sub}</a>,
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>, settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
}, },
)}</div> )}</div>
); );
@ -1225,7 +1231,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
"Use an identity server to invite by email. " + "Use an identity server to invite by email. " +
"Manage in <settings>Settings</settings>.", "Manage in <settings>Settings</settings>.",
{}, { {}, {
settings: sub => <a href="#" onClick={this._onManageSettingsClick}>{sub}</a>, settings: sub => <a href="#" onClick={this.onManageSettingsClick}>{sub}</a>,
}, },
)}</div> )}</div>
); );
@ -1298,7 +1304,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
return ( return (
<AccessibleButton <AccessibleButton
kind="link" kind="link"
onClick={this._onCommunityInviteClick} onClick={this.onCommunityInviteClick}
>{sub}</AccessibleButton> >{sub}</AccessibleButton>
); );
}, },
@ -1309,7 +1315,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</React.Fragment>; </React.Fragment>;
} }
buttonText = _t("Go"); buttonText = _t("Go");
goButtonFn = this._startDm; goButtonFn = this.startDm;
} else if (this.props.kind === KIND_INVITE) { } else if (this.props.kind === KIND_INVITE) {
const room = MatrixClientPeg.get()?.getRoom(this.props.roomId); const room = MatrixClientPeg.get()?.getRoom(this.props.roomId);
const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom(); const isSpace = SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom();
@ -1348,7 +1354,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
}); });
buttonText = _t("Invite"); buttonText = _t("Invite");
goButtonFn = this._inviteUsers; goButtonFn = this.inviteUsers;
if (cli.isRoomEncrypted(this.props.roomId)) { if (cli.isRoomEncrypted(this.props.roomId)) {
const room = cli.getRoom(this.props.roomId); const room = cli.getRoom(this.props.roomId);
@ -1370,7 +1376,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
} else if (this.props.kind === KIND_CALL_TRANSFER) { } else if (this.props.kind === KIND_CALL_TRANSFER) {
title = _t("Transfer"); title = _t("Transfer");
buttonText = _t("Transfer"); buttonText = _t("Transfer");
goButtonFn = this._transferCall; goButtonFn = this.transferCall;
consultSection = <div> consultSection = <div>
<label> <label>
<input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} /> <input type="checkbox" checked={this.state.consultFirst} onChange={this.onConsultFirstChange} />
@ -1393,7 +1399,7 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
<div className='mx_InviteDialog_content'> <div className='mx_InviteDialog_content'>
<p className='mx_InviteDialog_helpText'>{helpText}</p> <p className='mx_InviteDialog_helpText'>{helpText}</p>
<div className='mx_InviteDialog_addressBar'> <div className='mx_InviteDialog_addressBar'>
{this._renderEditor()} {this.renderEditor()}
<div className='mx_InviteDialog_buttonAndSpinner'> <div className='mx_InviteDialog_buttonAndSpinner'>
<AccessibleButton <AccessibleButton
kind="primary" kind="primary"
@ -1407,11 +1413,11 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
</div> </div>
</div> </div>
{keySharingWarning} {keySharingWarning}
{this._renderIdentityServerWarning()} {this.renderIdentityServerWarning()}
<div className='error'>{this.state.errorText}</div> <div className='error'>{this.state.errorText}</div>
<div className='mx_InviteDialog_userSections'> <div className='mx_InviteDialog_userSections'>
{this._renderSection('recents')} {this.renderSection('recents')}
{this._renderSection('suggestions')} {this.renderSection('suggestions')}
</div> </div>
{consultSection} {consultSection}
</div> </div>

View file

@ -30,7 +30,6 @@ import ToggleSwitch from "../elements/ToggleSwitch";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import {allSettled} from "../../../utils/promise";
import {useDispatcher} from "../../../hooks/useDispatcher"; import {useDispatcher} from "../../../hooks/useDispatcher";
import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView"; import {SpaceFeedbackPrompt} from "../../structures/SpaceRoomView";
@ -74,9 +73,13 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
const promises = []; const promises = [];
if (avatarChanged) { if (avatarChanged) {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, { if (newAvatar) {
url: await cli.uploadContent(newAvatar), promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {
}, "")); url: await cli.uploadContent(newAvatar),
}, ""));
} else {
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomAvatar, {}, ""));
}
} }
if (nameChanged) { if (nameChanged) {
@ -91,7 +94,7 @@ const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFin
promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, "")); promises.push(cli.sendStateEvent(space.roomId, EventType.RoomJoinRules, { join_rule: joinRule }, ""));
} }
const results = await allSettled(promises); const results = await Promise.allSettled(promises);
setBusy(false); setBusy(false);
const failures = results.filter(r => r.status === "rejected"); const failures = results.filter(r => r.status === "rejected");
if (failures.length > 0) { if (failures.length > 0) {

View file

@ -1,7 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2016, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,39 +15,44 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useEffect, useState} from 'react'; import React, { useEffect, useState } from "react";
import PropTypes from 'prop-types'; import { MatrixError } from "matrix-js-sdk/src/http-api";
import {MatrixClientPeg} from '../../../MatrixClientPeg'; import { MatrixClientPeg } from '../../../MatrixClientPeg';
import {instanceForInstanceId} from '../../../utils/DirectoryUtils'; import { instanceForInstanceId } from '../../../utils/DirectoryUtils';
import { import {
ChevronFace,
ContextMenu, ContextMenu,
useContextMenu,
ContextMenuButton, ContextMenuButton,
MenuItemRadio,
MenuItem,
MenuGroup, MenuGroup,
MenuItem,
MenuItemRadio,
useContextMenu,
} from "../../structures/ContextMenu"; } from "../../structures/ContextMenu";
import {_t} from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import {useSettingValue} from "../../../hooks/useSettings"; import { useSettingValue } from "../../../hooks/useSettings";
import * as sdk from "../../../index";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import withValidation from "../elements/Validation"; import withValidation from "../elements/Validation";
import { SettingLevel } from "../../../settings/SettingLevel";
import TextInputDialog from "../dialogs/TextInputDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import UIStore from "../../../stores/UIStore";
import { compare } from "../../../utils/strings";
export const ALL_ROOMS = Symbol("ALL_ROOMS"); export const ALL_ROOMS = Symbol("ALL_ROOMS");
const SETTING_NAME = "room_directory_servers"; const SETTING_NAME = "room_directory_servers";
const inPlaceOf = (elementRect) => ({ const inPlaceOf = (elementRect: Pick<DOMRect, "right" | "top">) => ({
right: window.innerWidth - elementRect.right, right: UIStore.instance.windowWidth - elementRect.right,
top: elementRect.top, top: elementRect.top,
chevronOffset: 0, chevronOffset: 0,
chevronFace: "none", chevronFace: ChevronFace.None,
}); });
const validServer = withValidation({ const validServer = withValidation<undefined, { error?: MatrixError }>({
deriveData: async ({ value }) => { deriveData: async ({ value }) => {
try { try {
// check if we can successfully load this server's room directory // check if we can successfully load this server's room directory
@ -78,15 +82,49 @@ const validServer = withValidation({
], ],
}); });
/* eslint-disable camelcase */
export interface IFieldType {
regexp: string;
placeholder: string;
}
export interface IInstance {
desc: string;
icon?: string;
fields: object;
network_id: string;
// XXX: this is undocumented but we rely on it.
// we inject a fake entry with a symbolic instance_id.
instance_id: string | symbol;
}
export interface IProtocol {
user_fields: string[];
location_fields: string[];
icon: string;
field_types: Record<string, IFieldType>;
instances: IInstance[];
}
/* eslint-enable camelcase */
export type Protocols = Record<string, IProtocol>;
interface IProps {
protocols: Protocols;
selectedServerName: string;
selectedInstanceId: string | symbol;
onOptionChange(server: string, instanceId?: string | symbol): void;
}
// This dropdown sources homeservers from three places: // This dropdown sources homeservers from three places:
// + your currently connected homeserver // + your currently connected homeserver
// + homeservers in config.json["roomDirectory"] // + homeservers in config.json["roomDirectory"]
// + homeservers in SettingsStore["room_directory_servers"] // + homeservers in SettingsStore["room_directory_servers"]
// if a server exists in multiple, only keep the top-most entry. // if a server exists in multiple, only keep the top-most entry.
const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, selectedInstanceId}) => { const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const _userDefinedServers = useSettingValue(SETTING_NAME); const _userDefinedServers: string[] = useSettingValue(SETTING_NAME);
const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers); const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
const handlerFactory = (server, instanceId) => { const handlerFactory = (server, instanceId) => {
@ -98,7 +136,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const setUserDefinedServers = servers => { const setUserDefinedServers = servers => {
_setUserDefinedServers(servers); _setUserDefinedServers(servers);
SettingsStore.setValue(SETTING_NAME, null, "account", servers); SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, servers);
}; };
// keep local echo up to date with external changes // keep local echo up to date with external changes
useEffect(() => { useEffect(() => {
@ -112,7 +150,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const roomDirectory = config.roomDirectory || {}; const roomDirectory = config.roomDirectory || {};
const hsName = MatrixClientPeg.getHomeserverName(); const hsName = MatrixClientPeg.getHomeserverName();
const configServers = new Set(roomDirectory.servers); const configServers = new Set<string>(roomDirectory.servers);
// configured servers take preference over user-defined ones, if one occurs in both ignore the latter one. // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName)); const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
@ -136,15 +174,21 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
// add a fake protocol with the ALL_ROOMS symbol // add a fake protocol with the ALL_ROOMS symbol
protocolsList.push({ protocolsList.push({
instances: [{ instances: [{
fields: [],
network_id: "",
instance_id: ALL_ROOMS, instance_id: ALL_ROOMS,
desc: _t("All rooms"), desc: _t("All rooms"),
}], }],
location_fields: [],
user_fields: [],
field_types: {},
icon: "",
}); });
} }
protocolsList.forEach(({instances=[]}) => { protocolsList.forEach(({instances=[]}) => {
[...instances].sort((b, a) => { [...instances].sort((b, a) => {
return a.desc.localeCompare(b.desc); return compare(a.desc, b.desc);
}).forEach(({desc, instance_id: instanceId}) => { }).forEach(({desc, instance_id: instanceId}) => {
entries.push( entries.push(
<MenuItemRadio <MenuItemRadio
@ -172,7 +216,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
if (removableServers.has(server)) { if (removableServers.has(server)) {
const onClick = async () => { const onClick = async () => {
closeMenu(); closeMenu();
const QuestionDialog = sdk.getComponent("dialogs.QuestionDialog");
const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, { const {finished} = Modal.createTrackedDialog("Network Dropdown", "Remove server", QuestionDialog, {
title: _t("Are you sure?"), title: _t("Are you sure?"),
description: _t("Are you sure you want to remove <b>%(serverName)s</b>", { description: _t("Are you sure you want to remove <b>%(serverName)s</b>", {
@ -191,7 +234,7 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
setUserDefinedServers(servers.filter(s => s !== server)); setUserDefinedServers(servers.filter(s => s !== server));
// the selected server is being removed, reset to our HS // the selected server is being removed, reset to our HS
if (serverSelected === server) { if (serverSelected) {
onOptionChange(hsName, undefined); onOptionChange(hsName, undefined);
} }
}; };
@ -223,7 +266,6 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
const onClick = async () => { const onClick = async () => {
closeMenu(); closeMenu();
const TextInputDialog = sdk.getComponent("dialogs.TextInputDialog");
const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, { const { finished } = Modal.createTrackedDialog("Network Dropdown", "Add a new server", TextInputDialog, {
title: _t("Add a new server"), title: _t("Add a new server"),
description: _t("Enter the name of a new server you want to explore."), description: _t("Enter the name of a new server you want to explore."),
@ -284,9 +326,4 @@ const NetworkDropdown = ({onOptionChange, protocols = {}, selectedServerName, se
</div>; </div>;
}; };
NetworkDropdown.propTypes = {
onOptionChange: PropTypes.func.isRequired,
protocols: PropTypes.object,
};
export default NetworkDropdown; export default NetworkDropdown;

View file

@ -73,7 +73,7 @@ export default class AccessibleTooltipButton extends React.PureComponent<IToolti
tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)} tooltipClassName={classNames("mx_AccessibleTooltipButton_tooltip", tooltipClassName)}
label={tooltip || title} label={tooltip || title}
yOffset={yOffset} yOffset={yOffset}
/> : <div />; /> : null;
return ( return (
<AccessibleButton <AccessibleButton
{...props} {...props}

View file

@ -17,7 +17,8 @@
import React, { FunctionComponent, useEffect, useRef } from 'react'; import React, { FunctionComponent, useEffect, useRef } from 'react';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import ICanvasEffect from '../../../effects/ICanvasEffect'; import ICanvasEffect from '../../../effects/ICanvasEffect';
import {CHAT_EFFECTS} from '../../../effects' import { CHAT_EFFECTS } from '../../../effects'
import UIStore, { UI_EVENTS } from "../../../stores/UIStore";
interface IProps { interface IProps {
roomWidth: number; roomWidth: number;
@ -45,8 +46,8 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
useEffect(() => { useEffect(() => {
const resize = () => { const resize = () => {
if (canvasRef.current) { if (canvasRef.current && canvasRef.current?.height !== UIStore.instance.windowHeight) {
canvasRef.current.height = window.innerHeight; canvasRef.current.height = UIStore.instance.windowHeight;
} }
}; };
const onAction = (payload: { action: string }) => { const onAction = (payload: { action: string }) => {
@ -58,12 +59,12 @@ const EffectsOverlay: FunctionComponent<IProps> = ({ roomWidth }) => {
} }
const dispatcherRef = dis.register(onAction); const dispatcherRef = dis.register(onAction);
const canvas = canvasRef.current; const canvas = canvasRef.current;
canvas.height = window.innerHeight; canvas.height = UIStore.instance.windowHeight;
window.addEventListener('resize', resize, true); UIStore.instance.on(UI_EVENTS.Resize, resize);
return () => { return () => {
dis.unregister(dispatcherRef); dis.unregister(dispatcherRef);
window.removeEventListener('resize', resize); UIStore.instance.off(UI_EVENTS.Resize, resize);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored const currentEffects = effectsRef.current; // this is not a react node ref, warning can be safely ignored
for (const effect in currentEffects) { for (const effect in currentEffects) {

View file

@ -116,7 +116,7 @@ export default class Flair extends React.Component {
render() { render() {
if (this.state.profiles.length === 0) { if (this.state.profiles.length === 0) {
return <span className="mx_Flair" />; return null;
} }
const avatars = this.state.profiles.map((profile, index) => { const avatars = this.state.profiles.map((profile, index) => {
return <FlairAvatar key={index} groupProfile={profile} />; return <FlairAvatar key={index} groupProfile={profile} />;

View file

@ -16,32 +16,31 @@ limitations under the License.
import React from "react"; import React from "react";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
interface IProps {
w?: number;
h?: number;
children?: React.ReactNode;
}
@replaceableComponent("views.elements.InlineSpinner") @replaceableComponent("views.elements.InlineSpinner")
export default class InlineSpinner extends React.Component { export default class InlineSpinner extends React.PureComponent<IProps> {
static defaultProps = {
w: 16,
h: 16,
}
render() { render() {
const w = this.props.w || 16;
const h = this.props.h || 16;
const imgClass = this.props.imgClassName || "";
let imageSource;
if (SettingsStore.getValue('feature_new_spinner')) {
imageSource = require("../../../../res/img/spinner.svg");
} else {
imageSource = require("../../../../res/img/spinner.gif");
}
return ( return (
<div className="mx_InlineSpinner"> <div className="mx_InlineSpinner">
<img <div
src={imageSource} className="mx_InlineSpinner_icon mx_Spinner_icon"
width={w} style={{width: this.props.w, height: this.props.h}}
height={h}
className={imgClass}
aria-label={_t("Loading...")} aria-label={_t("Loading...")}
/> >
{this.props.children}
</div>
</div> </div>
); );
} }

View file

@ -19,6 +19,7 @@ import {EventType} from 'matrix-js-sdk/src/@types/event';
import classNames from 'classnames'; import classNames from 'classnames';
import AccessibleButton from "./AccessibleButton"; import AccessibleButton from "./AccessibleButton";
import Spinner from "./Spinner";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {useTimeout} from "../../../hooks/useTimeout"; import {useTimeout} from "../../../hooks/useTimeout";
import Analytics from "../../../Analytics"; import Analytics from "../../../Analytics";
@ -88,6 +89,12 @@ const MiniAvatarUploader: React.FC<IProps> = ({ hasAvatar, hasAvatarLabel, noAva
> >
{ children } { children }
<div className="mx_MiniAvatarUploader_indicator">
{ busy ?
<Spinner w={20} h={20} /> :
<div className="mx_MiniAvatarUploader_cameraIcon"></div> }
</div>
<div className={classNames("mx_Tooltip", { <div className={classNames("mx_Tooltip", {
"mx_Tooltip_visible": visible, "mx_Tooltip_visible": visible,
"mx_Tooltip_invisible": !visible, "mx_Tooltip_invisible": !visible,

View file

@ -214,7 +214,7 @@ export default class ReplyThread extends React.Component {
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) { static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, layout) {
if (!ReplyThread.getParentEventId(parentEv)) { if (!ReplyThread.getParentEventId(parentEv)) {
return <div className="mx_ReplyThread_wrapper_empty" />; return null;
} }
return <ReplyThread return <ReplyThread
parentEv={parentEv} parentEv={parentEv}
@ -269,36 +269,27 @@ export default class ReplyThread extends React.Component {
const {parentEv} = this.props; const {parentEv} = this.props;
// at time of making this component we checked that props.parentEv has a parentEventId // at time of making this component we checked that props.parentEv has a parentEventId
const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv)); const ev = await this.getEvent(ReplyThread.getParentEventId(parentEv));
if (this.unmounted) return; if (this.unmounted) return;
if (ev) { if (ev) {
const loadedEv = await this.getNextEvent(ev);
this.setState({ this.setState({
events: [ev], events: [ev],
}, this.loadNextEvent); loadedEv,
loading: false,
});
} else { } else {
this.setState({err: true}); this.setState({err: true});
} }
} }
async loadNextEvent() { async getNextEvent(ev) {
if (this.unmounted) return; try {
const ev = this.state.events[0]; const inReplyToEventId = ReplyThread.getParentEventId(ev);
const inReplyToEventId = ReplyThread.getParentEventId(ev); return await this.getEvent(inReplyToEventId);
} catch (e) {
if (!inReplyToEventId) { return null;
this.setState({
loading: false,
});
return;
}
const loadedEv = await this.getEvent(inReplyToEventId);
if (this.unmounted) return;
if (loadedEv) {
this.setState({loadedEv});
} else {
this.setState({err: true});
} }
} }
@ -326,13 +317,18 @@ export default class ReplyThread extends React.Component {
this.initialize(); this.initialize();
} }
onQuoteClick() { async onQuoteClick() {
const events = [this.state.loadedEv, ...this.state.events]; const events = [this.state.loadedEv, ...this.state.events];
let loadedEv = null;
if (events.length > 0) {
loadedEv = await this.getNextEvent(events[0]);
}
this.setState({ this.setState({
loadedEv: null, loadedEv,
events, events,
}, this.loadNextEvent); });
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
} }

View file

@ -112,7 +112,7 @@ interface IProps {
const MAX_PER_ROW = 6; const MAX_PER_ROW = 6;
const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => { const SSOButtons: React.FC<IProps> = ({matrixClient, flow, loginType, fragmentAfterLogin, primary}) => {
const providers = flow["org.matrix.msc2858.identity_providers"] || []; const providers = flow.identity_providers || [];
if (providers.length < 2) { if (providers.length < 2) {
return <div className="mx_SSOButtons"> return <div className="mx_SSOButtons">
<SSOButton <SSOButton

View file

@ -18,33 +18,21 @@ limitations under the License.
import React from "react"; import React from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import {_t} from "../../../languageHandler"; import {_t} from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
const Spinner = ({w = 32, h = 32, imgClassName, message}) => { const Spinner = ({w = 32, h = 32, message}) => (
let imageSource; <div className="mx_Spinner">
if (SettingsStore.getValue('feature_new_spinner')) { { message && <React.Fragment><div className="mx_Spinner_Msg">{ message }</div>&nbsp;</React.Fragment> }
imageSource = require("../../../../res/img/spinner.svg"); <div
} else { className="mx_Spinner_icon"
imageSource = require("../../../../res/img/spinner.gif"); style={{width: w, height: h}}
} aria-label={_t("Loading...")}
></div>
</div>
);
return (
<div className="mx_Spinner">
{ message && <React.Fragment><div className="mx_Spinner_Msg">{ message}</div>&nbsp;</React.Fragment> }
<img
src={imageSource}
width={w}
height={h}
className={imgClassName}
aria-label={_t("Loading...")}
/>
</div>
);
};
Spinner.propTypes = { Spinner.propTypes = {
w: PropTypes.number, w: PropTypes.number,
h: PropTypes.number, h: PropTypes.number,
imgClassName: PropTypes.string,
message: PropTypes.node, message: PropTypes.node,
}; };

View file

@ -22,6 +22,7 @@ import React, {Component, CSSProperties} from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
const MIN_TOOLTIP_HEIGHT = 25; const MIN_TOOLTIP_HEIGHT = 25;
@ -69,7 +70,10 @@ export default class Tooltip extends React.Component<IProps> {
this.tooltipContainer = document.createElement("div"); this.tooltipContainer = document.createElement("div");
this.tooltipContainer.className = "mx_Tooltip_wrapper"; this.tooltipContainer.className = "mx_Tooltip_wrapper";
document.body.appendChild(this.tooltipContainer); document.body.appendChild(this.tooltipContainer);
window.addEventListener('scroll', this.renderTooltip, true); window.addEventListener('scroll', this.renderTooltip, {
passive: true,
capture: true,
});
this.parent = ReactDOM.findDOMNode(this).parentNode as Element; this.parent = ReactDOM.findDOMNode(this).parentNode as Element;
@ -84,7 +88,9 @@ export default class Tooltip extends React.Component<IProps> {
public componentWillUnmount() { public componentWillUnmount() {
ReactDOM.unmountComponentAtNode(this.tooltipContainer); ReactDOM.unmountComponentAtNode(this.tooltipContainer);
document.body.removeChild(this.tooltipContainer); document.body.removeChild(this.tooltipContainer);
window.removeEventListener('scroll', this.renderTooltip, true); window.removeEventListener('scroll', this.renderTooltip, {
capture: true,
});
} }
private updatePosition(style: CSSProperties) { private updatePosition(style: CSSProperties) {
@ -97,15 +103,15 @@ export default class Tooltip extends React.Component<IProps> {
// we need so that we're still centered. // we need so that we're still centered.
offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT); offset = Math.floor(parentBox.height - MIN_TOOLTIP_HEIGHT);
} }
const width = UIStore.instance.windowWidth;
const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset; const baseTop = (parentBox.top - 2 + this.props.yOffset) + window.pageYOffset;
const top = baseTop + offset; const top = baseTop + offset;
const right = window.innerWidth - parentBox.right - window.pageXOffset - 16; const right = width - parentBox.right - window.pageXOffset - 16;
const left = parentBox.right + window.pageXOffset + 6; const left = parentBox.right + window.pageXOffset + 6;
const horizontalCenter = parentBox.right - window.pageXOffset - (parentBox.width / 2); const horizontalCenter = parentBox.right - window.pageXOffset - (parentBox.width / 2);
switch (this.props.alignment) { switch (this.props.alignment) {
case Alignment.Natural: case Alignment.Natural:
if (parentBox.right > window.innerWidth / 2) { if (parentBox.right > width / 2) {
style.right = right; style.right = right;
style.top = top; style.top = top;
break; break;

View file

@ -19,19 +19,30 @@ import React from 'react';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.elements.TooltipButton") interface IProps {
export default class TooltipButton extends React.Component { helpText: string;
state = { }
hover: false,
};
onMouseOver = () => { interface IState {
hover: boolean;
}
@replaceableComponent("views.elements.TooltipButton")
export default class TooltipButton extends React.Component<IProps, IState> {
constructor(props) {
super(props);
this.state = {
hover: false,
};
}
private onMouseOver = () => {
this.setState({ this.setState({
hover: true, hover: true,
}); });
}; };
onMouseLeave = () => { private onMouseLeave = () => {
this.setState({ this.setState({
hover: false, hover: false,
}); });

View file

@ -71,10 +71,14 @@ export default class MVoiceMessageBody extends React.PureComponent<IProps, IStat
// We should have a buffer to work with now: let's set it up // We should have a buffer to work with now: let's set it up
const playback = new Playback(buffer, waveform); const playback = new Playback(buffer, waveform);
this.setState({playback}); this.setState({ playback });
// Note: the RecordingPlayback component will handle preparing the Playback class for us. // Note: the RecordingPlayback component will handle preparing the Playback class for us.
} }
public componentWillUnmount() {
this.state.playback?.destroy();
}
public render() { public render() {
if (this.state.error) { if (this.state.error) {
// TODO: @@TR: Verify error state // TODO: @@TR: Verify error state

View file

@ -81,19 +81,39 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
if (props.reactions) {
props.reactions.on("Relations.add", this.onReactionsChange);
props.reactions.on("Relations.remove", this.onReactionsChange);
props.reactions.on("Relations.redaction", this.onReactionsChange);
}
this.state = { this.state = {
myReactions: this.getMyReactions(), myReactions: this.getMyReactions(),
showAll: false, showAll: false,
}; };
} }
componentDidUpdate(prevProps) { componentDidMount() {
const { mxEvent, reactions } = this.props;
if (mxEvent.isBeingDecrypted() || mxEvent.shouldAttemptDecryption()) {
mxEvent.once("Event.decrypted", this.onDecrypted);
}
if (reactions) {
reactions.on("Relations.add", this.onReactionsChange);
reactions.on("Relations.remove", this.onReactionsChange);
reactions.on("Relations.redaction", this.onReactionsChange);
}
}
componentWillUnmount() {
const { mxEvent, reactions } = this.props;
mxEvent.off("Event.decrypted", this.onDecrypted);
if (reactions) {
reactions.off("Relations.add", this.onReactionsChange);
reactions.off("Relations.remove", this.onReactionsChange);
reactions.off("Relations.redaction", this.onReactionsChange);
}
}
componentDidUpdate(prevProps: IProps) {
if (prevProps.reactions !== this.props.reactions) { if (prevProps.reactions !== this.props.reactions) {
this.props.reactions.on("Relations.add", this.onReactionsChange); this.props.reactions.on("Relations.add", this.onReactionsChange);
this.props.reactions.on("Relations.remove", this.onReactionsChange); this.props.reactions.on("Relations.remove", this.onReactionsChange);
@ -102,24 +122,12 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
} }
} }
componentWillUnmount() { private onDecrypted = () => {
if (this.props.reactions) { // Decryption changes whether the event is actionable
this.props.reactions.removeListener( this.forceUpdate();
"Relations.add",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.remove",
this.onReactionsChange,
);
this.props.reactions.removeListener(
"Relations.redaction",
this.onReactionsChange,
);
}
} }
onReactionsChange = () => { private onReactionsChange = () => {
// TODO: Call `onHeightChanged` as needed // TODO: Call `onHeightChanged` as needed
this.setState({ this.setState({
myReactions: this.getMyReactions(), myReactions: this.getMyReactions(),
@ -130,7 +138,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
this.forceUpdate(); this.forceUpdate();
} }
getMyReactions() { private getMyReactions() {
const reactions = this.props.reactions; const reactions = this.props.reactions;
if (!reactions) { if (!reactions) {
return null; return null;
@ -143,7 +151,7 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
return [...myReactions.values()]; return [...myReactions.values()];
} }
onShowAllClick = () => { private onShowAllClick = () => {
this.setState({ this.setState({
showAll: true, showAll: true,
}); });
@ -198,7 +206,8 @@ export default class ReactionsRow extends React.PureComponent<IProps, IState> {
const cli = this.context; const cli = this.context;
let addReactionButton; let addReactionButton;
if (cli.getRoom(mxEvent.getRoomId()).currentState.maySendEvent(EventType.Reaction, cli.getUserId())) { const room = cli.getRoom(mxEvent.getRoomId());
if (room.getMyMembership() === "join" && room.currentState.maySendEvent(EventType.Reaction, cli.getUserId())) {
addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />; addReactionButton = <ReactButton mxEvent={mxEvent} reactions={reactions} />;
} }

View file

@ -31,21 +31,23 @@ export default class SenderProfile extends React.Component {
static contextType = MatrixClientContext; static contextType = MatrixClientContext;
state = { constructor(props) {
userGroups: null, super(props);
relatedGroups: [], const senderId = this.props.mxEvent.getSender();
};
this.state = {
userGroups: FlairStore.cachedPublicisedGroups(senderId) || [],
relatedGroups: [],
};
}
componentDidMount() { componentDidMount() {
this.unmounted = false; this.unmounted = false;
this._updateRelatedGroups(); this._updateRelatedGroups();
FlairStore.getPublicisedGroupsCached( if (this.state.userGroups.length === 0) {
this.context, this.props.mxEvent.getSender(), this.getPublicisedGroups();
).then((userGroups) => { }
if (this.unmounted) return;
this.setState({userGroups});
});
this.context.on('RoomState.events', this.onRoomStateEvents); this.context.on('RoomState.events', this.onRoomStateEvents);
} }
@ -55,6 +57,15 @@ export default class SenderProfile extends React.Component {
this.context.removeListener('RoomState.events', this.onRoomStateEvents); this.context.removeListener('RoomState.events', this.onRoomStateEvents);
} }
async getPublicisedGroups() {
if (!this.unmounted) {
const userGroups = await FlairStore.getPublicisedGroupsCached(
this.context, this.props.mxEvent.getSender(),
);
this.setState({userGroups});
}
}
onRoomStateEvents = event => { onRoomStateEvents = event => {
if (event.getType() === 'm.room.related_groups' && if (event.getType() === 'm.room.related_groups' &&
event.getRoomId() === this.props.mxEvent.getRoomId() event.getRoomId() === this.props.mxEvent.getRoomId()
@ -93,10 +104,10 @@ export default class SenderProfile extends React.Component {
const {msgtype} = mxEvent.getContent(); const {msgtype} = mxEvent.getContent();
if (msgtype === 'm.emote') { if (msgtype === 'm.emote') {
return <span />; // emote message must include the name so don't duplicate it return null; // emote message must include the name so don't duplicate it
} }
let flair = <div />; let flair = null;
if (this.props.enableFlair) { if (this.props.enableFlair) {
const displayedGroups = this._getDisplayedGroups( const displayedGroups = this._getDisplayedGroups(
this.state.userGroups, this.state.relatedGroups, this.state.userGroups, this.state.relatedGroups,
@ -110,19 +121,12 @@ export default class SenderProfile extends React.Component {
const nameElem = name || ''; const nameElem = name || '';
// Name + flair
const nameFlair = <span>
<span className={`mx_SenderProfile_name ${colorClass}`}>
{ nameElem }
</span>
{ flair }
</span>;
return ( return (
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}> <div className="mx_SenderProfile mx_SenderProfile_hover" dir="auto" onClick={this.props.onClick}>
<div className="mx_SenderProfile_hover"> <span className={`mx_SenderProfile_name ${colorClass}`}>
{ nameFlair } { nameElem }
</div> </span>
{ flair }
</div> </div>
); );
} }

View file

@ -36,6 +36,7 @@ import {toRightOf} from "../../structures/ContextMenu";
import {copyPlaintext} from "../../../utils/strings"; import {copyPlaintext} from "../../../utils/strings";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
@replaceableComponent("views.messages.TextualBody") @replaceableComponent("views.messages.TextualBody")
export default class TextualBody extends React.Component { export default class TextualBody extends React.Component {
@ -143,7 +144,7 @@ export default class TextualBody extends React.Component {
_addCodeExpansionButton(div, pre) { _addCodeExpansionButton(div, pre) {
// Calculate how many percent does the pre element take up. // Calculate how many percent does the pre element take up.
// If it's less than 30% we don't add the expansion button. // If it's less than 30% we don't add the expansion button.
const percentageOfViewport = pre.offsetHeight / window.innerHeight * 100; const percentageOfViewport = pre.offsetHeight / UIStore.instance.windowHeight * 100;
if (percentageOfViewport < 30) return; if (percentageOfViewport < 30) return;
const button = document.createElement("span"); const button = document.createElement("span");
@ -277,15 +278,15 @@ export default class TextualBody extends React.Component {
// pass only the first child which is the event tile otherwise this recurses on edited events // pass only the first child which is the event tile otherwise this recurses on edited events
let links = this.findLinks([this._content.current]); let links = this.findLinks([this._content.current]);
if (links.length) { if (links.length) {
// de-dup the links (but preserve ordering) // de-duplicate the links after stripping hashes as they don't affect the preview
const seen = new Set(); // using a set here maintains the order
links = links.filter((link) => { links = Array.from(new Set(links.map(link => {
if (seen.has(link)) return false; const url = new URL(link);
seen.add(link); url.hash = "";
return true; return url.toString();
}); })));
this.setState({ links: links }); this.setState({ links });
// lazy-load the hidden state of the preview widget from localstorage // lazy-load the hidden state of the preview widget from localstorage
if (global.localStorage) { if (global.localStorage) {

View file

@ -21,12 +21,12 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton'; import HeaderButton from './HeaderButton';
import HeaderButtons, {HeaderKind} from './HeaderButtons'; import HeaderButtons, { HeaderKind } from './HeaderButtons';
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import {ActionPayload} from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import {ViewUserPayload} from "../../../dispatcher/payloads/ViewUserPayload"; import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
const GROUP_PHASES = [ const GROUP_PHASES = [
RightPanelPhases.GroupMemberInfo, RightPanelPhases.GroupMemberInfo,
@ -84,19 +84,21 @@ export default class GroupHeaderButtons extends HeaderButtons {
}; };
renderButtons() { renderButtons() {
return [ return <>
<HeaderButton key="groupMembersButton" name="groupMembersButton" <HeaderButton
name="groupMembersButton"
title={_t('Members')} title={_t('Members')}
isHighlighted={this.isPhase(GROUP_PHASES)} isHighlighted={this.isPhase(GROUP_PHASES)}
onClick={this.onMembersClicked} onClick={this.onMembersClicked}
analytics={['Right Panel', 'Group Member List Button', 'click']} analytics={['Right Panel', 'Group Member List Button', 'click']}
/>, />
<HeaderButton key="roomsButton" name="roomsButton" <HeaderButton
name="roomsButton"
title={_t('Rooms')} title={_t('Rooms')}
isHighlighted={this.isPhase(ROOM_PHASES)} isHighlighted={this.isPhase(ROOM_PHASES)}
onClick={this.onRoomsClicked} onClick={this.onRoomsClicked}
analytics={['Right Panel', 'Group Room List Button', 'click']} analytics={['Right Panel', 'Group Room List Button', 'click']}
/>, />
]; </>;
} }
} }

View file

@ -22,15 +22,13 @@ import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import Analytics from '../../../Analytics'; import Analytics from '../../../Analytics';
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps { interface IProps {
// Whether this button is highlighted // Whether this button is highlighted
isHighlighted: boolean; isHighlighted: boolean;
// click handler // click handler
onClick: () => void; onClick: () => void;
// The badge to display above the icon
badge?: React.ReactNode;
// The parameters to track the click event // The parameters to track the click event
analytics: Parameters<typeof Analytics.trackEvent>; analytics: Parameters<typeof Analytics.trackEvent>;
@ -40,31 +38,29 @@ interface IProps {
title: string; title: string;
} }
// TODO: replace this, the composer buttons and the right panel buttons with a unified // TODO: replace this, the composer buttons and the right panel buttons with a unified representation
// representation
@replaceableComponent("views.right_panel.HeaderButton") @replaceableComponent("views.right_panel.HeaderButton")
export default class HeaderButton extends React.Component<IProps> { export default class HeaderButton extends React.Component<IProps> {
constructor(props: IProps) { private onClick = () => {
super(props);
this.onClick = this.onClick.bind(this);
}
private onClick() {
Analytics.trackEvent(...this.props.analytics); Analytics.trackEvent(...this.props.analytics);
this.props.onClick(); this.props.onClick();
} };
public render() { public render() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const {isHighlighted, onClick, analytics, name, title, ...props} = this.props;
const classes = classNames({ const classes = classNames({
mx_RightPanel_headerButton: true, mx_RightPanel_headerButton: true,
mx_RightPanel_headerButton_highlight: this.props.isHighlighted, mx_RightPanel_headerButton_highlight: isHighlighted,
[`mx_RightPanel_${this.props.name}`]: true, [`mx_RightPanel_${name}`]: true,
}); });
return <AccessibleTooltipButton return <AccessibleTooltipButton
aria-selected={this.props.isHighlighted} {...props}
aria-selected={isHighlighted}
role="tab" role="tab"
title={this.props.title} title={title}
className={classes} className={classes}
onClick={this.onClick} onClick={this.onClick}
/>; />;

View file

@ -21,14 +21,14 @@ limitations under the License.
import React from 'react'; import React from 'react';
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import RightPanelStore from "../../../stores/RightPanelStore"; import RightPanelStore from "../../../stores/RightPanelStore";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import {Action} from '../../../dispatcher/actions'; import { Action } from '../../../dispatcher/actions';
import { import {
SetRightPanelPhasePayload, SetRightPanelPhasePayload,
SetRightPanelPhaseRefireParams, SetRightPanelPhaseRefireParams,
} from '../../../dispatcher/payloads/SetRightPanelPhasePayload'; } from '../../../dispatcher/payloads/SetRightPanelPhasePayload';
import {EventSubscription} from "fbemitter"; import type { EventSubscription } from "fbemitter";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
export enum HeaderKind { export enum HeaderKind {
Room = "room", Room = "room",
@ -43,11 +43,11 @@ interface IState {
interface IProps {} interface IProps {}
@replaceableComponent("views.right_panel.HeaderButtons") @replaceableComponent("views.right_panel.HeaderButtons")
export default abstract class HeaderButtons extends React.Component<IProps, IState> { export default abstract class HeaderButtons<P = {}> extends React.Component<IProps & P, IState> {
private storeToken: EventSubscription; private storeToken: EventSubscription;
private dispatcherRef: string; private dispatcherRef: string;
constructor(props: IProps, kind: HeaderKind) { constructor(props: IProps & P, kind: HeaderKind) {
super(props); super(props);
const rps = RightPanelStore.getSharedInstance(); const rps = RightPanelStore.getSharedInstance();
@ -95,7 +95,7 @@ export default abstract class HeaderButtons extends React.Component<IProps, ISta
} }
// XXX: Make renderButtons a prop // XXX: Make renderButtons a prop
public abstract renderButtons(): JSX.Element[]; public abstract renderButtons(): JSX.Element;
public render() { public render() {
return <div className="mx_HeaderButtons"> return <div className="mx_HeaderButtons">

View file

@ -0,0 +1,176 @@
/*
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, {useCallback, useContext, useEffect, useState} from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomState } from "matrix-js-sdk/src/models/room-state";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from 'matrix-js-sdk/src/@types/event';
import { _t } from "../../../languageHandler";
import BaseCard from "./BaseCard";
import Spinner from "../elements/Spinner";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
import PinningUtils from "../../../utils/PinningUtils";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import PinnedEventTile from "../rooms/PinnedEventTile";
interface IProps {
room: Room;
onClose(): void;
}
export const usePinnedEvents = (room: Room): string[] => {
const [pinnedEvents, setPinnedEvents] = useState<string[]>([]);
const update = useCallback((ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
setPinnedEvents(room.currentState.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent()?.pinned || []);
}, [room]);
useEventEmitter(room?.currentState, "RoomState.events", update);
useEffect(() => {
update();
return () => {
setPinnedEvents([]);
};
}, [update]);
return pinnedEvents;
};
export const ReadPinsEventId = "im.vector.room.read_pins";
export const useReadPinnedEvents = (room: Room): Set<string> => {
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
const update = useCallback((ev?: MatrixEvent) => {
if (!room) return;
if (ev && ev.getType() !== ReadPinsEventId) return;
const readPins = room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids;
setReadPinnedEvents(new Set(readPins || []));
}, [room]);
useEventEmitter(room, "Room.accountData", update);
useEffect(() => {
update();
return () => {
setReadPinnedEvents(new Set());
};
}, [update]);
return readPinnedEvents;
};
const useRoomState = <T extends any>(room: Room, mapper: (state: RoomState) => T): T => {
const [value, setValue] = useState<T>(room ? mapper(room.currentState) : undefined);
const update = useCallback(() => {
if (!room) return;
setValue(mapper(room.currentState));
}, [room, mapper]);
useEventEmitter(room?.currentState, "RoomState.events", update);
useEffect(() => {
update();
return () => {
setValue(undefined);
};
}, [update]);
return value;
};
const PinnedMessagesCard = ({ room, onClose }: IProps) => {
const cli = useContext(MatrixClientContext);
const canUnpin = useRoomState(room, state => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli));
const pinnedEventIds = usePinnedEvents(room);
const readPinnedEvents = useReadPinnedEvents(room);
useEffect(() => {
const newlyRead = pinnedEventIds.filter(id => !readPinnedEvents.has(id));
if (newlyRead.length > 0) {
// clear out any read pinned events which no longer are pinned
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: pinnedEventIds,
});
}
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
const pinnedEvents = useAsyncMemo(() => {
const promises = pinnedEventIds.map(async eventId => {
const timelineSet = room.getUnfilteredTimelineSet();
const localEvent = timelineSet?.getTimelineForEvent(eventId)?.getEvents().find(e => e.getId() === eventId);
if (localEvent) return localEvent;
try {
const evJson = await cli.fetchRoomEvent(room.roomId, eventId);
const event = new MatrixEvent(evJson);
if (event.isEncrypted()) {
await cli.decryptEventIfNeeded(event); // TODO await?
}
if (event && PinningUtils.isPinnable(event)) {
return event;
}
} catch (err) {
console.error("Error looking up pinned event " + eventId + " in room " + room.roomId);
console.error(err);
}
return null;
});
return Promise.all(promises);
}, [cli, room, pinnedEventIds], null);
let content;
if (!pinnedEvents) {
content = <Spinner />;
} else if (pinnedEvents.length > 0) {
let onUnpinClicked;
if (canUnpin) {
onUnpinClicked = async (event: MatrixEvent) => {
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
if (pinnedEvents?.getContent()?.pinned) {
const pinned = pinnedEvents.getContent().pinned;
const index = pinned.indexOf(event.getId());
if (index !== -1) {
pinned.splice(index, 1);
await cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
}
}
};
}
// show them in reverse, with latest pinned at the top
content = pinnedEvents.filter(Boolean).reverse().map(ev => (
<PinnedEventTile key={ev.getId()} room={room} event={ev} onUnpinClicked={onUnpinClicked} />
));
} else {
content = <div className="mx_RightPanel_empty mx_PinnedMessagesCard_empty">
<h2>{_t("Youre all caught up")}</h2>
<p>{_t("You have no visible notifications.")}</p>
</div>;
}
return <BaseCard
header={<h2>{ _t("Pinned messages") }</h2>}
className="mx_PinnedMessagesCard"
onClose={onClose}
>
{ content }
</BaseCard>;
};
export default PinnedMessagesCard;

View file

@ -18,15 +18,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from 'react'; import React from "react";
import {_t} from '../../../languageHandler'; import { Room } from "matrix-js-sdk/src/models/room";
import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton'; import HeaderButton from './HeaderButton';
import HeaderButtons, {HeaderKind} from './HeaderButtons'; import HeaderButtons, { HeaderKind } from './HeaderButtons';
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import {Action} from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import {ActionPayload} from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import RightPanelStore from "../../../stores/RightPanelStore"; import RightPanelStore from "../../../stores/RightPanelStore";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { useSettingValue } from "../../../hooks/useSettings";
import { useReadPinnedEvents, usePinnedEvents } from './PinnedMessagesCard';
const ROOM_INFO_PHASES = [ const ROOM_INFO_PHASES = [
RightPanelPhases.RoomSummary, RightPanelPhases.RoomSummary,
@ -38,9 +42,35 @@ const ROOM_INFO_PHASES = [
RightPanelPhases.Room3pidMemberInfo, RightPanelPhases.Room3pidMemberInfo,
]; ];
const PinnedMessagesHeaderButton = ({ room, isHighlighted, onClick }) => {
const pinningEnabled = useSettingValue("feature_pinning");
const pinnedEvents = usePinnedEvents(pinningEnabled && room);
const readPinnedEvents = useReadPinnedEvents(pinningEnabled && room);
if (!pinningEnabled) return null;
let unreadIndicator;
if (pinnedEvents.some(id => !readPinnedEvents.has(id))) {
unreadIndicator = <div className="mx_RightPanel_pinnedMessagesButton_unreadIndicator" />;
}
return <HeaderButton
name="pinnedMessagesButton"
title={_t("Pinned messages")}
isHighlighted={isHighlighted}
onClick={onClick}
analytics={["Right Panel", "Pinned Messages Button", "click"]}
>
{ unreadIndicator }
</HeaderButton>;
};
interface IProps {
room?: Room;
}
@replaceableComponent("views.right_panel.RoomHeaderButtons") @replaceableComponent("views.right_panel.RoomHeaderButtons")
export default class RoomHeaderButtons extends HeaderButtons { export default class RoomHeaderButtons extends HeaderButtons<IProps> {
constructor(props) { constructor(props: IProps) {
super(props, HeaderKind.Room); super(props, HeaderKind.Room);
} }
@ -80,24 +110,32 @@ export default class RoomHeaderButtons extends HeaderButtons {
this.setPhase(RightPanelPhases.NotificationPanel); this.setPhase(RightPanelPhases.NotificationPanel);
}; };
private onPinnedMessagesClicked = () => {
// This toggles for us, if needed
this.setPhase(RightPanelPhases.PinnedMessages);
};
public renderButtons() { public renderButtons() {
return [ return <>
<PinnedMessagesHeaderButton
room={this.props.room}
isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
onClick={this.onPinnedMessagesClicked}
/>
<HeaderButton <HeaderButton
key="notifsButton"
name="notifsButton" name="notifsButton"
title={_t('Notifications')} title={_t('Notifications')}
isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)} isHighlighted={this.isPhase(RightPanelPhases.NotificationPanel)}
onClick={this.onNotificationsClicked} onClick={this.onNotificationsClicked}
analytics={['Right Panel', 'Notification List Button', 'click']} analytics={['Right Panel', 'Notification List Button', 'click']}
/>, />
<HeaderButton <HeaderButton
key="roomSummaryButton"
name="roomSummaryButton" name="roomSummaryButton"
title={_t('Room Info')} title={_t('Room Info')}
isHighlighted={this.isPhase(ROOM_INFO_PHASES)} isHighlighted={this.isPhase(ROOM_INFO_PHASES)}
onClick={this.onRoomSummaryClicked} onClick={this.onRoomSummaryClicked}
analytics={['Right Panel', 'Room Summary Button', 'click']} analytics={['Right Panel', 'Room Summary Button', 'click']}
/>, />
]; </>;
} }
} }

View file

@ -46,6 +46,7 @@ import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import {useRoomMemberCount} from "../../../hooks/useRoomMembers"; import {useRoomMemberCount} from "../../../hooks/useRoomMembers";
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import UIStore from "../../../stores/UIStore";
interface IProps { interface IProps {
room: Room; room: Room;
@ -116,8 +117,8 @@ const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
const rect = handle.current.getBoundingClientRect(); const rect = handle.current.getBoundingClientRect();
contextMenu = <WidgetContextMenu contextMenu = <WidgetContextMenu
chevronFace={ChevronFace.None} chevronFace={ChevronFace.None}
right={window.innerWidth - rect.right} right={UIStore.instance.windowWidth - rect.right}
bottom={window.innerHeight - rect.top} bottom={UIStore.instance.windowHeight - rect.top}
onFinished={closeMenu} onFinished={closeMenu}
app={app} app={app}
/>; />;

View file

@ -17,18 +17,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, {useCallback, useContext, useEffect, useMemo, useState} from 'react'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import {MatrixClient} from 'matrix-js-sdk/src/client'; import { MatrixClient } from 'matrix-js-sdk/src/client';
import {RoomMember} from 'matrix-js-sdk/src/models/room-member'; import { RoomMember } from 'matrix-js-sdk/src/models/room-member';
import {User} from 'matrix-js-sdk/src/models/user'; import { User } from 'matrix-js-sdk/src/models/user';
import {Room} from 'matrix-js-sdk/src/models/room'; import { Room } from 'matrix-js-sdk/src/models/room';
import {EventTimeline} from 'matrix-js-sdk/src/models/event-timeline'; import { EventTimeline } from 'matrix-js-sdk/src/models/event-timeline';
import {MatrixEvent} from 'matrix-js-sdk/src/models/event'; import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
import {_t} from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom'; import createRoom, { findDMForUser, privateShouldBeEncrypted } from '../../../createRoom';
import DMRoomMap from '../../../utils/DMRoomMap'; import DMRoomMap from '../../../utils/DMRoomMap';
import AccessibleButton from '../elements/AccessibleButton'; import AccessibleButton from '../elements/AccessibleButton';
@ -39,18 +40,18 @@ import MultiInviter from "../../../utils/MultiInviter";
import GroupStore from "../../../stores/GroupStore"; import GroupStore from "../../../stores/GroupStore";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import E2EIcon from "../rooms/E2EIcon"; import E2EIcon from "../rooms/E2EIcon";
import {useEventEmitter} from "../../../hooks/useEventEmitter"; import { useEventEmitter } from "../../../hooks/useEventEmitter";
import {textualPowerLevel} from '../../../Roles'; import { textualPowerLevel } from '../../../Roles';
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {RightPanelPhases} from "../../../stores/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/RightPanelStorePhases";
import EncryptionPanel from "./EncryptionPanel"; import EncryptionPanel from "./EncryptionPanel";
import {useAsyncMemo} from '../../../hooks/useAsyncMemo'; import { useAsyncMemo } from '../../../hooks/useAsyncMemo';
import {legacyVerifyUser, verifyDevice, verifyUser} from '../../../verification'; import { legacyVerifyUser, verifyDevice, verifyUser } from '../../../verification';
import {Action} from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { USER_SECURITY_TAB } from "../dialogs/UserSettingsDialog"; import { USER_SECURITY_TAB } from "../dialogs/UserSettingsDialog";
import {useIsEncrypted} from "../../../hooks/useIsEncrypted"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard"; import BaseCard from "./BaseCard";
import {E2EStatus} from "../../../utils/ShieldUtils"; import { E2EStatus } from "../../../utils/ShieldUtils";
import ImageView from "../elements/ImageView"; import ImageView from "../elements/ImageView";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import PowerSelector from "../elements/PowerSelector"; import PowerSelector from "../elements/PowerSelector";
@ -65,7 +66,8 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload"; import { SetRightPanelPhasePayload } from "../../../dispatcher/payloads/SetRightPanelPhasePayload";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import {mediaFromMxc} from "../../../customisations/Media"; import { mediaFromMxc } from "../../../customisations/Media";
import UIStore from "../../../stores/UIStore";
export interface IDevice { export interface IDevice {
deviceId: string; deviceId: string;
@ -513,9 +515,6 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => {
} else { } else {
setPowerLevels({}); setPowerLevels({});
} }
return () => {
setPowerLevels({});
};
}, [room]); }, [room]);
useEventEmitter(cli, "RoomState.events", update); useEventEmitter(cli, "RoomState.events", update);
@ -1307,7 +1306,7 @@ const BasicUserInfo: React.FC<{
} }
if (pendingUpdateCount > 0) { if (pendingUpdateCount > 0) {
spinner = <Spinner imgClassName="mx_ContextualMenu_spinner" />; spinner = <Spinner />;
} }
let memberDetails; let memberDetails;
@ -1448,8 +1447,8 @@ const UserInfoHeader: React.FC<{
<MemberAvatar <MemberAvatar
key={member.userId} // to instantly blank the avatar when UserInfo changes members key={member.userId} // to instantly blank the avatar when UserInfo changes members
member={member} member={member}
width={2 * 0.3 * window.innerHeight} // 2x@30vh width={2 * 0.3 * UIStore.instance.windowHeight} // 2x@30vh
height={2 * 0.3 * window.innerHeight} // 2x@30vh height={2 * 0.3 * UIStore.instance.windowHeight} // 2x@30vh
resizeMethod="scale" resizeMethod="scale"
fallbackUserId={member.userId} fallbackUserId={member.userId}
onClick={onMemberAvatarClick} onClick={onMemberAvatarClick}
@ -1529,21 +1528,16 @@ interface IProps {
user: Member; user: Member;
groupId?: string; groupId?: string;
room?: Room; room?: Room;
phase: RightPanelPhases.RoomMemberInfo | RightPanelPhases.GroupMemberInfo | RightPanelPhases.SpaceMemberInfo; phase: RightPanelPhases.RoomMemberInfo
| RightPanelPhases.GroupMemberInfo
| RightPanelPhases.SpaceMemberInfo
| RightPanelPhases.EncryptionPanel;
onClose(): void; onClose(): void;
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
} }
interface IPropsWithEncryptionPanel extends React.ComponentProps<typeof EncryptionPanel> { const UserInfo: React.FC<IProps> = ({
user: Member;
groupId: void;
room: Room;
phase: RightPanelPhases.EncryptionPanel;
onClose(): void;
}
type Props = IProps | IPropsWithEncryptionPanel;
const UserInfo: React.FC<Props> = ({
user, user,
groupId, groupId,
room, room,

View file

@ -30,6 +30,7 @@ import { Action } from "../../../dispatcher/actions";
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import WidgetContextMenu from "../context_menus/WidgetContextMenu"; import WidgetContextMenu from "../context_menus/WidgetContextMenu";
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import UIStore from "../../../stores/UIStore";
interface IProps { interface IProps {
room: Room; room: Room;
@ -65,7 +66,7 @@ const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
contextMenu = ( contextMenu = (
<WidgetContextMenu <WidgetContextMenu
chevronFace={ChevronFace.None} chevronFace={ChevronFace.None}
right={window.innerWidth - rect.right - 12} right={UIStore.instance.windowWidth - rect.right - 12}
top={rect.bottom + 12} top={rect.bottom + 12}
onFinished={closeMenu} onFinished={closeMenu}
app={app} app={app}

View file

@ -36,6 +36,7 @@ import {Container, WidgetLayoutStore} from "../../../stores/widgets/WidgetLayout
import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers"; import {clamp, percentageOf, percentageWithin} from "../../../utils/numbers";
import {useStateCallback} from "../../../hooks/useStateCallback"; import {useStateCallback} from "../../../hooks/useStateCallback";
import {replaceableComponent} from "../../../utils/replaceableComponent"; import {replaceableComponent} from "../../../utils/replaceableComponent";
import UIStore from "../../../stores/UIStore";
@replaceableComponent("views.rooms.AppsDrawer") @replaceableComponent("views.rooms.AppsDrawer")
export default class AppsDrawer extends React.Component { export default class AppsDrawer extends React.Component {
@ -290,7 +291,7 @@ const PersistentVResizer = ({
// Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window. // Arbitrary defaults to avoid NaN problems. 100 px or 3/4 of the visible window.
if (!minHeight) minHeight = 100; if (!minHeight) minHeight = 100;
if (!maxHeight) maxHeight = (window.innerHeight / 4) * 3; if (!maxHeight) maxHeight = (UIStore.instance.windowHeight / 4) * 3;
// Convert from percentage to height. Note that the default height is 280px. // Convert from percentage to height. Note that the default height is 280px.
if (defaultHeight) { if (defaultHeight) {

View file

@ -168,6 +168,7 @@ export default class EditMessageComposer extends React.Component {
if (nextEvent) { if (nextEvent) {
dis.dispatch({action: 'edit_event', event: nextEvent}); dis.dispatch({action: 'edit_event', event: nextEvent});
} else { } else {
this._clearStoredEditorState();
dis.dispatch({action: 'edit_event', event: null}); dis.dispatch({action: 'edit_event', event: null});
dis.fire(Action.FocusComposer); dis.fire(Action.FocusComposer);
} }

View file

@ -277,6 +277,12 @@ interface IProps {
// Helper to build permalinks for the room // Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator; permalinkCreator?: RoomPermalinkCreator;
// Symbol of the root node
as?: string
// whether or not to always show timestamps
alwaysShowTimestamps?: boolean
} }
interface IState { interface IState {
@ -291,12 +297,15 @@ interface IState {
previouslyRequestedKeys: boolean; previouslyRequestedKeys: boolean;
// The Relations model from the JS SDK for reactions to `mxEvent` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions: Relations; reactions: Relations;
hover: boolean;
} }
@replaceableComponent("views.rooms.EventTile") @replaceableComponent("views.rooms.EventTile")
export default class EventTile extends React.Component<IProps, IState> { export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean; private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean; private isListeningForReceipts: boolean;
private ref: React.RefObject<unknown>;
private tile = React.createRef(); private tile = React.createRef();
private replyThread = React.createRef(); private replyThread = React.createRef();
@ -322,6 +331,8 @@ export default class EventTile extends React.Component<IProps, IState> {
previouslyRequestedKeys: false, previouslyRequestedKeys: false,
// The Relations model from the JS SDK for reactions to `mxEvent` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(), reactions: this.getReactions(),
hover: false,
}; };
// don't do RR animations until we are mounted // don't do RR animations until we are mounted
@ -333,6 +344,8 @@ export default class EventTile extends React.Component<IProps, IState> {
// to determine if we've already subscribed and use a combination of other flags to find // to determine if we've already subscribed and use a combination of other flags to find
// out if we should even be subscribed at all. // out if we should even be subscribed at all.
this.isListeningForReceipts = false; this.isListeningForReceipts = false;
this.ref = React.createRef();
} }
/** /**
@ -631,7 +644,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// return early if there are no read receipts // return early if there are no read receipts
if (!this.props.readReceipts || this.props.readReceipts.length === 0) { if (!this.props.readReceipts || this.props.readReceipts.length === 0) {
return (<span className="mx_EventTile_readAvatars" />); return null;
} }
const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker'); const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker');
@ -640,6 +653,11 @@ export default class EventTile extends React.Component<IProps, IState> {
let left = 0; let left = 0;
const receipts = this.props.readReceipts || []; const receipts = this.props.readReceipts || [];
if (receipts.length === 0) {
return null;
}
for (let i = 0; i < receipts.length; ++i) { for (let i = 0; i < receipts.length; ++i) {
const receipt = receipts[i]; const receipt = receipts[i];
@ -690,10 +708,14 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
} }
return <span className="mx_EventTile_readAvatars"> return (
{ remText } <div className="mx_EventTile_msgOption">
{ avatars } <span className="mx_EventTile_readAvatars">
</span>; { remText }
{ avatars }
</span>
</div>
)
} }
onSenderProfileClick = event => { onSenderProfileClick = event => {
@ -790,13 +812,6 @@ export default class EventTile extends React.Component<IProps, IState> {
return null; return null;
} }
const eventId = this.props.mxEvent.getId(); const eventId = this.props.mxEvent.getId();
if (!eventId) {
// XXX: Temporary diagnostic logging for https://github.com/vector-im/element-web/issues/11120
console.error("EventTile attempted to get relations for an event without an ID");
// Use event's special `toJSON` method to log key data.
console.log(JSON.stringify(this.props.mxEvent, null, 4));
console.trace("Stacktrace for https://github.com/vector-im/element-web/issues/11120");
}
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction"); return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
}; };
@ -960,7 +975,8 @@ export default class EventTile extends React.Component<IProps, IState> {
onFocusChange={this.onActionBarFocusChange} onFocusChange={this.onActionBarFocusChange}
/> : undefined; /> : undefined;
const timestamp = this.props.mxEvent.getTs() ? const showTimestamp = this.props.mxEvent.getTs() && (this.props.alwaysShowTimestamps || this.state.hover);
const timestamp = showTimestamp ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null; <MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null;
const keyRequestHelpText = const keyRequestHelpText =
@ -1023,11 +1039,7 @@ export default class EventTile extends React.Component<IProps, IState> {
let msgOption; let msgOption;
if (this.props.showReadReceipts) { if (this.props.showReadReceipts) {
const readAvatars = this.getReadAvatars(); const readAvatars = this.getReadAvatars();
msgOption = ( msgOption = readAvatars;
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
);
} }
switch (this.props.tileShape) { switch (this.props.tileShape) {
@ -1131,11 +1143,20 @@ export default class EventTile extends React.Component<IProps, IState> {
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return ( return (
<div className={classes} tabIndex={-1} aria-live={ariaLive} aria-atomic="true"> React.createElement(this.props.as || "div", {
{ ircTimestamp } "ref": this.ref,
{ sender } "className": classes,
{ ircPadlock } "tabIndex": -1,
<div className="mx_EventTile_line"> "aria-live": ariaLive,
"aria-atomic": "true",
"data-scroll-tokens": this.props["data-scroll-tokens"],
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
}, [
ircTimestamp,
sender,
ircPadlock,
<div className="mx_EventTile_line" key="mx_EventTile_line">
{ groupTimestamp } { groupTimestamp }
{ groupPadlock } { groupPadlock }
{ thread } { thread }
@ -1152,16 +1173,12 @@ export default class EventTile extends React.Component<IProps, IState> {
{ keyRequestInfo } { keyRequestInfo }
{ reactionsRow } { reactionsRow }
{ actionBar } { actionBar }
</div> </div>,
{msgOption} msgOption,
{ avatar,
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids ])
// the need for further z-indexing chaos) )
}
{ avatar }
</div>
);
} }
} }
} }

View file

@ -238,6 +238,8 @@ export default class MemberList extends React.Component {
member.user = cli.getUser(member.userId); member.user = cli.getUser(member.userId);
} }
member.sortName = (member.name[0] === '@' ? member.name.substr(1) : member.name).replace(SORT_REGEX, "");
// XXX: this user may have no lastPresenceTs value! // XXX: this user may have no lastPresenceTs value!
// the right solution here is to fix the race rather than leave it as 0 // the right solution here is to fix the race rather than leave it as 0
}); });
@ -252,6 +254,8 @@ export default class MemberList extends React.Component {
m.membership === 'join' || m.membership === 'invite' m.membership === 'join' || m.membership === 'invite'
); );
}); });
const language = SettingsStore.getValue("language");
this.collator = new Intl.Collator(language, { sensitivity: 'base', usePunctuation: true });
filteredAndSortedMembers.sort(this.memberSort); filteredAndSortedMembers.sort(this.memberSort);
return filteredAndSortedMembers; return filteredAndSortedMembers;
} }
@ -351,13 +355,7 @@ export default class MemberList extends React.Component {
} }
// Fourth by name (alphabetical) // Fourth by name (alphabetical)
const nameA = (memberA.name[0] === '@' ? memberA.name.substr(1) : memberA.name).replace(SORT_REGEX, ""); return this.collator.compare(memberA.sortName, memberB.sortName);
const nameB = (memberB.name[0] === '@' ? memberB.name.substr(1) : memberB.name).replace(SORT_REGEX, "");
// console.log(`Comparing userA_name=${nameA} against userB_name=${nameB} - returning`);
return nameA.localeCompare(nameB, {
ignorePunctuation: true,
sensitivity: "base",
});
}; };
onSearchQueryChanged = searchQuery => { onSearchQueryChanged = searchQuery => {
@ -422,7 +420,7 @@ export default class MemberList extends React.Component {
} else { } else {
// Is a 3pid invite // Is a 3pid invite
return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true} return <EntityTile key={m.getStateKey()} name={m.getContent().display_name} suppressOnHover={true}
onClick={() => this._onPending3pidInviteClick(m)} />; onClick={() => this._onPending3pidInviteClick(m)} />;
} }
}); });
} }
@ -484,10 +482,10 @@ export default class MemberList extends React.Component {
if (this._getChildCountInvited() > 0) { if (this._getChildCountInvited() > 0) {
invitedHeader = <h2>{ _t("Invited") }</h2>; invitedHeader = <h2>{ _t("Invited") }</h2>;
invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited} invitedSection = <TruncatedList className="mx_MemberList_section mx_MemberList_invited" truncateAt={this.state.truncateAtInvited}
createOverflowElement={this._createOverflowTileInvited} createOverflowElement={this._createOverflowTileInvited}
getChildren={this._getChildrenInvited} getChildren={this._getChildrenInvited}
getChildCount={this._getChildCountInvited} getChildCount={this._getChildCountInvited}
/>; />;
} }
const footer = ( const footer = (
@ -520,9 +518,9 @@ export default class MemberList extends React.Component {
> >
<div className="mx_MemberList_wrapper"> <div className="mx_MemberList_wrapper">
<TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined} <TruncatedList className="mx_MemberList_section mx_MemberList_joined" truncateAt={this.state.truncateAtJoined}
createOverflowElement={this._createOverflowTileJoined} createOverflowElement={this._createOverflowTileJoined}
getChildren={this._getChildrenJoined} getChildren={this._getChildrenJoined}
getChildCount={this._getChildCountJoined} /> getChildCount={this._getChildCountJoined} />
{ invitedHeader } { invitedHeader }
{ invitedSection } { invitedSection }
</div> </div>

View file

@ -1,111 +0,0 @@
/*
Copyright 2017 Travis Ralston
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from '../../../languageHandler';
import {formatFullDate} from '../../../DateUtils';
import {replaceableComponent} from "../../../utils/replaceableComponent";
@replaceableComponent("views.rooms.PinnedEventTile")
export default class PinnedEventTile extends React.Component {
static propTypes = {
mxRoom: PropTypes.object.isRequired,
mxEvent: PropTypes.object.isRequired,
onUnpinned: PropTypes.func,
};
onTileClicked = () => {
dis.dispatch({
action: 'view_room',
event_id: this.props.mxEvent.getId(),
highlighted: true,
room_id: this.props.mxEvent.getRoomId(),
});
};
onUnpinClicked = () => {
const pinnedEvents = this.props.mxRoom.currentState.getStateEvents("m.room.pinned_events", "");
if (!pinnedEvents || !pinnedEvents.getContent().pinned) {
// Nothing to do: already unpinned
if (this.props.onUnpinned) this.props.onUnpinned();
} else {
const pinned = pinnedEvents.getContent().pinned;
const index = pinned.indexOf(this.props.mxEvent.getId());
if (index !== -1) {
pinned.splice(index, 1);
MatrixClientPeg.get().sendStateEvent(this.props.mxRoom.roomId, 'm.room.pinned_events', {pinned}, '')
.then(() => {
if (this.props.onUnpinned) this.props.onUnpinned();
});
} else if (this.props.onUnpinned) this.props.onUnpinned();
}
};
_canUnpin() {
return this.props.mxRoom.currentState.mayClientSendStateEvent('m.room.pinned_events', MatrixClientPeg.get());
}
render() {
const sender = this.props.mxEvent.getSender();
// Get the latest sender profile rather than historical
const senderProfile = this.props.mxRoom.getMember(sender);
const avatarSize = 40;
let unpinButton = null;
if (this._canUnpin()) {
unpinButton = (
<AccessibleButton onClick={this.onUnpinClicked} className="mx_PinnedEventTile_unpinButton">
<img src={require("../../../../res/img/cancel-red.svg")} width="8" height="8" alt={_t('Unpin Message')} title={_t('Unpin Message')} />
</AccessibleButton>
);
}
return (
<div className="mx_PinnedEventTile">
<div className="mx_PinnedEventTile_actions">
<AccessibleButton className="mx_PinnedEventTile_gotoButton mx_textButton" onClick={this.onTileClicked}>
{ _t("Jump to message") }
</AccessibleButton>
{ unpinButton }
</div>
<span className="mx_PinnedEventTile_senderAvatar">
<MemberAvatar member={senderProfile} width={avatarSize} height={avatarSize} fallbackUserId={sender} />
</span>
<span className="mx_PinnedEventTile_sender">
{ senderProfile ? senderProfile.name : sender }
</span>
<span className="mx_PinnedEventTile_timestamp">
{ formatFullDate(new Date(this.props.mxEvent.getTs())) }
</span>
<div className="mx_PinnedEventTile_message">
<MessageEvent
mxEvent={this.props.mxEvent}
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
/>
</div>
</div>
);
}
}

View file

@ -0,0 +1,104 @@
/*
Copyright 2017 Travis Ralston
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 { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import dis from "../../../dispatcher/dispatcher";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from '../../../languageHandler';
import { formatDate } from '../../../DateUtils';
import { replaceableComponent } from "../../../utils/replaceableComponent";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
interface IProps {
room: Room;
event: MatrixEvent;
onUnpinClicked?(): void;
}
const AVATAR_SIZE = 24;
@replaceableComponent("views.rooms.PinnedEventTile")
export default class PinnedEventTile extends React.Component<IProps> {
public static contextType = MatrixClientContext;
private onTileClicked = () => {
dis.dispatch({
action: 'view_room',
event_id: this.props.event.getId(),
highlighted: true,
room_id: this.props.event.getRoomId(),
});
};
render() {
const sender = this.props.event.getSender();
const senderProfile = this.props.room.getMember(sender);
let unpinButton = null;
if (this.props.onUnpinClicked) {
unpinButton = (
<AccessibleTooltipButton
onClick={this.props.onUnpinClicked}
className="mx_PinnedEventTile_unpinButton"
title={_t("Unpin")}
/>
);
}
return <div className="mx_PinnedEventTile">
<MemberAvatar
className="mx_PinnedEventTile_senderAvatar"
member={senderProfile}
width={AVATAR_SIZE}
height={AVATAR_SIZE}
fallbackUserId={sender}
/>
<span className={"mx_PinnedEventTile_sender " + getUserNameColorClass(sender)}>
{ senderProfile?.name || sender }
</span>
{ unpinButton }
<div className="mx_PinnedEventTile_message">
<MessageEvent
mxEvent={this.props.event}
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
/>
</div>
<div className="mx_PinnedEventTile_footer">
<span className="mx_PinnedEventTile_timestamp">
{ formatDate(new Date(this.props.event.getTs())) }
</span>
<AccessibleButton onClick={this.onTileClicked} kind="link">
{ _t("View message") }
</AccessibleButton>
</div>
</div>;
}
}

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