Merge branch 'develop' into poljar/seshat-search-pagination
This commit is contained in:
commit
eaca8310d3
82 changed files with 3355 additions and 1313 deletions
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -1,7 +1,20 @@
|
||||||
|
Changes in [2.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.1) (2020-06-05)
|
||||||
|
===================================================================================================
|
||||||
|
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.0...v2.7.1)
|
||||||
|
|
||||||
|
* Upgrade to JS SDK 6.2.1
|
||||||
|
* Fix exceptions from Tooltip
|
||||||
|
[\#4716](https://github.com/matrix-org/matrix-react-sdk/pull/4716)
|
||||||
|
* Fix not being able to dismiss new login toasts
|
||||||
|
[\#4715](https://github.com/matrix-org/matrix-react-sdk/pull/4715)
|
||||||
|
* Fix compact layout regression
|
||||||
|
[\#4714](https://github.com/matrix-org/matrix-react-sdk/pull/4714)
|
||||||
|
|
||||||
Changes in [2.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.0) (2020-06-04)
|
Changes in [2.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.0) (2020-06-04)
|
||||||
===================================================================================================
|
===================================================================================================
|
||||||
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.0-rc.2...v2.7.0)
|
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.0-rc.2...v2.7.0)
|
||||||
|
|
||||||
|
* Upgrade to JS SDK 6.2.0
|
||||||
* Prevent (double) 4S bootstrap from RestoreKeyBackupDialog
|
* Prevent (double) 4S bootstrap from RestoreKeyBackupDialog
|
||||||
[\#4703](https://github.com/matrix-org/matrix-react-sdk/pull/4703)
|
[\#4703](https://github.com/matrix-org/matrix-react-sdk/pull/4703)
|
||||||
* Fix checkbox bleed
|
* Fix checkbox bleed
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "matrix-react-sdk",
|
"name": "matrix-react-sdk",
|
||||||
"version": "2.7.0",
|
"version": "2.7.1",
|
||||||
"description": "SDK for matrix.org using React",
|
"description": "SDK for matrix.org using React",
|
||||||
"author": "matrix.org",
|
"author": "matrix.org",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
@ -93,6 +93,7 @@
|
||||||
"react-beautiful-dnd": "^4.0.1",
|
"react-beautiful-dnd": "^4.0.1",
|
||||||
"react-dom": "^16.9.0",
|
"react-dom": "^16.9.0",
|
||||||
"react-focus-lock": "^2.2.1",
|
"react-focus-lock": "^2.2.1",
|
||||||
|
"react-resizable": "^1.10.1",
|
||||||
"resize-observer-polyfill": "^1.5.0",
|
"resize-observer-polyfill": "^1.5.0",
|
||||||
"sanitize-html": "^1.18.4",
|
"sanitize-html": "^1.18.4",
|
||||||
"text-encoding-utf-8": "^1.0.1",
|
"text-encoding-utf-8": "^1.0.1",
|
||||||
|
|
|
@ -19,7 +19,7 @@ limitations under the License.
|
||||||
@import "./_font-sizes.scss";
|
@import "./_font-sizes.scss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
font-size: 15px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
|
|
|
@ -12,12 +12,14 @@
|
||||||
@import "./structures/_HeaderButtons.scss";
|
@import "./structures/_HeaderButtons.scss";
|
||||||
@import "./structures/_HomePage.scss";
|
@import "./structures/_HomePage.scss";
|
||||||
@import "./structures/_LeftPanel.scss";
|
@import "./structures/_LeftPanel.scss";
|
||||||
|
@import "./structures/_LeftPanel2.scss";
|
||||||
@import "./structures/_MainSplit.scss";
|
@import "./structures/_MainSplit.scss";
|
||||||
@import "./structures/_MatrixChat.scss";
|
@import "./structures/_MatrixChat.scss";
|
||||||
@import "./structures/_MyGroups.scss";
|
@import "./structures/_MyGroups.scss";
|
||||||
@import "./structures/_NotificationPanel.scss";
|
@import "./structures/_NotificationPanel.scss";
|
||||||
@import "./structures/_RightPanel.scss";
|
@import "./structures/_RightPanel.scss";
|
||||||
@import "./structures/_RoomDirectory.scss";
|
@import "./structures/_RoomDirectory.scss";
|
||||||
|
@import "./structures/_RoomSearch.scss";
|
||||||
@import "./structures/_RoomStatusBar.scss";
|
@import "./structures/_RoomStatusBar.scss";
|
||||||
@import "./structures/_RoomSubList.scss";
|
@import "./structures/_RoomSubList.scss";
|
||||||
@import "./structures/_RoomView.scss";
|
@import "./structures/_RoomView.scss";
|
||||||
|
@ -28,6 +30,7 @@
|
||||||
@import "./structures/_ToastContainer.scss";
|
@import "./structures/_ToastContainer.scss";
|
||||||
@import "./structures/_TopLeftMenuButton.scss";
|
@import "./structures/_TopLeftMenuButton.scss";
|
||||||
@import "./structures/_UploadBar.scss";
|
@import "./structures/_UploadBar.scss";
|
||||||
|
@import "./structures/_UserMenuButton.scss";
|
||||||
@import "./structures/_ViewSource.scss";
|
@import "./structures/_ViewSource.scss";
|
||||||
@import "./structures/auth/_CompleteSecurity.scss";
|
@import "./structures/auth/_CompleteSecurity.scss";
|
||||||
@import "./structures/auth/_Login.scss";
|
@import "./structures/auth/_Login.scss";
|
||||||
|
@ -169,6 +172,7 @@
|
||||||
@import "./views/rooms/_MemberList.scss";
|
@import "./views/rooms/_MemberList.scss";
|
||||||
@import "./views/rooms/_MessageComposer.scss";
|
@import "./views/rooms/_MessageComposer.scss";
|
||||||
@import "./views/rooms/_MessageComposerFormatBar.scss";
|
@import "./views/rooms/_MessageComposerFormatBar.scss";
|
||||||
|
@import "./views/rooms/_NotificationBadge.scss";
|
||||||
@import "./views/rooms/_PinnedEventTile.scss";
|
@import "./views/rooms/_PinnedEventTile.scss";
|
||||||
@import "./views/rooms/_PinnedEventsPanel.scss";
|
@import "./views/rooms/_PinnedEventsPanel.scss";
|
||||||
@import "./views/rooms/_PresenceLabel.scss";
|
@import "./views/rooms/_PresenceLabel.scss";
|
||||||
|
@ -177,9 +181,12 @@
|
||||||
@import "./views/rooms/_RoomDropTarget.scss";
|
@import "./views/rooms/_RoomDropTarget.scss";
|
||||||
@import "./views/rooms/_RoomHeader.scss";
|
@import "./views/rooms/_RoomHeader.scss";
|
||||||
@import "./views/rooms/_RoomList.scss";
|
@import "./views/rooms/_RoomList.scss";
|
||||||
|
@import "./views/rooms/_RoomList2.scss";
|
||||||
@import "./views/rooms/_RoomPreviewBar.scss";
|
@import "./views/rooms/_RoomPreviewBar.scss";
|
||||||
@import "./views/rooms/_RoomRecoveryReminder.scss";
|
@import "./views/rooms/_RoomRecoveryReminder.scss";
|
||||||
|
@import "./views/rooms/_RoomSublist2.scss";
|
||||||
@import "./views/rooms/_RoomTile.scss";
|
@import "./views/rooms/_RoomTile.scss";
|
||||||
|
@import "./views/rooms/_RoomTile2.scss";
|
||||||
@import "./views/rooms/_RoomUpgradeWarningBar.scss";
|
@import "./views/rooms/_RoomUpgradeWarningBar.scss";
|
||||||
@import "./views/rooms/_SearchBar.scss";
|
@import "./views/rooms/_SearchBar.scss";
|
||||||
@import "./views/rooms/_SendMessageComposer.scss";
|
@import "./views/rooms/_SendMessageComposer.scss";
|
||||||
|
|
|
@ -14,59 +14,59 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$font-1px: 0.067rem;
|
$font-1px: 0.1rem;
|
||||||
$font-1-5px: 0.100rem;
|
$font-1-5px: 0.15rem;
|
||||||
$font-2px: 0.133rem;
|
$font-2px: 0.2rem;
|
||||||
$font-3px: 0.200rem;
|
$font-3px: 0.3rem;
|
||||||
$font-4px: 0.267rem;
|
$font-4px: 0.4rem;
|
||||||
$font-5px: 0.333rem;
|
$font-5px: 0.5rem;
|
||||||
$font-6px: 0.400rem;
|
$font-6px: 0.6rem;
|
||||||
$font-7px: 0.467rem;
|
$font-7px: 0.7rem;
|
||||||
$font-8px: 0.533rem;
|
$font-8px: 0.8rem;
|
||||||
$font-9px: 0.600rem;
|
$font-9px: 0.9rem;
|
||||||
$font-10px: 0.667rem;
|
$font-10px: 1.0rem;
|
||||||
$font-10-4px: 0.693rem;
|
$font-10-4px: 1.04rem;
|
||||||
$font-11px: 0.733rem;
|
$font-11px: 1.1rem;
|
||||||
$font-12px: 0.800rem;
|
$font-12px: 1.2rem;
|
||||||
$font-13px: 0.867rem;
|
$font-13px: 1.3rem;
|
||||||
$font-14px: 0.933rem;
|
$font-14px: 1.4rem;
|
||||||
$font-15px: 1.000rem;
|
$font-15px: 1.5rem;
|
||||||
$font-16px: 1.067rem;
|
$font-16px: 1.6rem;
|
||||||
$font-17px: 1.133rem;
|
$font-17px: 1.7rem;
|
||||||
$font-18px: 1.200rem;
|
$font-18px: 1.8rem;
|
||||||
$font-19px: 1.267rem;
|
$font-19px: 1.9rem;
|
||||||
$font-20px: 1.3333333rem;
|
$font-20px: 2.0rem;
|
||||||
$font-21px: 1.400rem;
|
$font-21px: 2.1rem;
|
||||||
$font-22px: 1.467rem;
|
$font-22px: 2.2rem;
|
||||||
$font-23px: 1.533rem;
|
$font-23px: 2.3rem;
|
||||||
$font-24px: 1.600rem;
|
$font-24px: 2.4rem;
|
||||||
$font-25px: 1.667rem;
|
$font-25px: 2.5rem;
|
||||||
$font-26px: 1.733rem;
|
$font-26px: 2.6rem;
|
||||||
$font-27px: 1.800rem;
|
$font-27px: 2.7rem;
|
||||||
$font-28px: 1.867rem;
|
$font-28px: 2.8rem;
|
||||||
$font-29px: 1.933rem;
|
$font-29px: 2.9rem;
|
||||||
$font-30px: 2.000rem;
|
$font-30px: 3.0rem;
|
||||||
$font-31px: 2.067rem;
|
$font-31px: 3.1rem;
|
||||||
$font-32px: 2.133rem;
|
$font-32px: 3.2rem;
|
||||||
$font-33px: 2.200rem;
|
$font-33px: 3.3rem;
|
||||||
$font-34px: 2.267rem;
|
$font-34px: 3.4rem;
|
||||||
$font-35px: 2.333rem;
|
$font-35px: 3.5rem;
|
||||||
$font-36px: 2.400rem;
|
$font-36px: 3.6rem;
|
||||||
$font-37px: 2.467rem;
|
$font-37px: 3.7rem;
|
||||||
$font-38px: 2.533rem;
|
$font-38px: 3.8rem;
|
||||||
$font-39px: 2.600rem;
|
$font-39px: 3.9rem;
|
||||||
$font-40px: 2.667rem;
|
$font-40px: 4.0rem;
|
||||||
$font-41px: 2.733rem;
|
$font-41px: 4.1rem;
|
||||||
$font-42px: 2.800rem;
|
$font-42px: 4.2rem;
|
||||||
$font-43px: 2.867rem;
|
$font-43px: 4.3rem;
|
||||||
$font-44px: 2.933rem;
|
$font-44px: 4.4rem;
|
||||||
$font-45px: 3.000rem;
|
$font-45px: 4.5rem;
|
||||||
$font-46px: 3.067rem;
|
$font-46px: 4.6rem;
|
||||||
$font-47px: 3.133rem;
|
$font-47px: 4.7rem;
|
||||||
$font-48px: 3.200rem;
|
$font-48px: 4.8rem;
|
||||||
$font-49px: 3.267rem;
|
$font-49px: 4.9rem;
|
||||||
$font-50px: 3.333rem;
|
$font-50px: 5.0rem;
|
||||||
$font-51px: 3.400rem;
|
$font-51px: 5.1rem;
|
||||||
$font-52px: 3.467rem;
|
$font-52px: 5.2rem;
|
||||||
$font-88px: 5.887rem;
|
$font-88px: 8.8rem;
|
||||||
$font-400px: 26.667rem;
|
$font-400px: 40rem;
|
||||||
|
|
136
res/css/structures/_LeftPanel2.scss
Normal file
136
res/css/structures/_LeftPanel2.scss
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: Rename to mx_LeftPanel during replacement of old component
|
||||||
|
|
||||||
|
// TODO: Put these variables in the right place, or namespace them.
|
||||||
|
$tagPanelWidth: 70px;
|
||||||
|
$roomListMinimizedWidth: 50px;
|
||||||
|
|
||||||
|
.mx_LeftPanel2 {
|
||||||
|
background-color: $header-panel-bg-color;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 50%;
|
||||||
|
|
||||||
|
// Create a row-based flexbox for the TagPanel and the room list
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
.mx_LeftPanel2_tagPanelContainer {
|
||||||
|
flex-grow: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-basis: $tagPanelWidth;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
// Create another flexbox so the TagPanel fills the container
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
// TagPanel handles its own CSS
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: The 'room list' in this context is actually everything that isn't the tag
|
||||||
|
// panel, such as the menu options, breadcrumbs, filtering, etc
|
||||||
|
.mx_LeftPanel2_roomListContainer {
|
||||||
|
width: calc(100% - $tagPanelWidth);
|
||||||
|
|
||||||
|
// Create another flexbox (this time a column) for the room list components
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.mx_LeftPanel2_userHeader {
|
||||||
|
padding: 14px 12px 20px; // 14px top, 12px sides, 20px bottom
|
||||||
|
|
||||||
|
// Create another flexbox column for the rows to stack within
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// There's 2 rows when breadcrumbs are present: the top bit and the breadcrumbs
|
||||||
|
.mx_LeftPanel2_headerRow {
|
||||||
|
// Create yet another flexbox, this time within the row, to ensure items stay
|
||||||
|
// aligned correctly. This is also a row-based flexbox.
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel2_userAvatarContainer {
|
||||||
|
position: relative; // to make default avatars work
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel2_userName {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-20px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel2_headerButtons {
|
||||||
|
// No special styles: the rest of the layout happens to make it work.
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel2_breadcrumbsContainer {
|
||||||
|
// TODO: Improve CSS for breadcrumbs (currently shoved into the view rather than placed)
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel2_filterContainer {
|
||||||
|
margin-left: 12px;
|
||||||
|
margin-right: 12px;
|
||||||
|
|
||||||
|
// Create a flexbox to organize the inputs
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
|
||||||
|
// Cheaty way to return the occupied space to the filter input
|
||||||
|
margin: 0;
|
||||||
|
width: 0;
|
||||||
|
|
||||||
|
// Don't forget to hide the masked ::before icon
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel2_exploreButton {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: #fff; // TODO: Variable and theme
|
||||||
|
position: relative;
|
||||||
|
margin-left: 8px;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 6px;
|
||||||
|
left: 6px;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
mask-image: url('$(res)/img/feather-customised/compass.svg');
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
background: $primary-fg-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_LeftPanel2_actualRoomListContainer {
|
||||||
|
flex-grow: 1; // fill the available space
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -66,7 +66,7 @@ limitations under the License.
|
||||||
}
|
}
|
||||||
|
|
||||||
/* not the left panel, and not the resize handle, so the roomview/groupview/... */
|
/* not the left panel, and not the resize handle, so the roomview/groupview/... */
|
||||||
.mx_MatrixChat > :not(.mx_LeftPanel_container):not(.mx_ResizeHandle) {
|
.mx_MatrixChat > :not(.mx_LeftPanel_container):not(.mx_LeftPanel2):not(.mx_ResizeHandle) {
|
||||||
background-color: $primary-bg-color;
|
background-color: $primary-bg-color;
|
||||||
|
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
|
|
70
res/css/structures/_RoomSearch.scss
Normal file
70
res/css/structures/_RoomSearch.scss
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Note: this component expects to be contained within a flexbox
|
||||||
|
.mx_RoomSearch {
|
||||||
|
flex: 1;
|
||||||
|
border-radius: 20px;
|
||||||
|
background-color: #fff; // TODO: Variable & theme
|
||||||
|
height: 26px;
|
||||||
|
padding: 2px;
|
||||||
|
|
||||||
|
// Create a flexbox for the icons (easier to manage)
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mx_RoomSearch_icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
mask: url('$(res)/img/feather-customised/search-input.svg');
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
background: $primary-fg-color;
|
||||||
|
margin-left: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomSearch_input {
|
||||||
|
border: none !important; // !important to override default app-wide styles
|
||||||
|
flex: 1 !important; // !important to override default app-wide styles
|
||||||
|
color: $primary-fg-color !important; // !important to override default app-wide styles
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-size: $font-12px;
|
||||||
|
line-height: $font-16px;
|
||||||
|
|
||||||
|
&:not(.mx_RoomSearch_inputExpanded)::placeholder {
|
||||||
|
color: $primary-fg-color !important; // !important to override default app-wide styles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_RoomSearch_expanded {
|
||||||
|
.mx_RoomSearch_clearButton {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
mask-image: url('$(res)/img/feather-customised/x.svg');
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
background: $primary-fg-color;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomSearch_clearButton {
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
}
|
162
res/css/structures/_UserMenuButton.scss
Normal file
162
res/css/structures/_UserMenuButton.scss
Normal file
|
@ -0,0 +1,162 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_UserMenuButton {
|
||||||
|
// No special styles on the button itself
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserMenuButton_contextMenu {
|
||||||
|
width: 231px;
|
||||||
|
|
||||||
|
// Put 20px of padding around the whole menu. We do this instead of a
|
||||||
|
// simple `padding: 20px` rule so the horizontal rules added by the
|
||||||
|
// optionLists is rendered correctly (full width).
|
||||||
|
> * {
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
padding-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserMenuButton_contextMenu_header {
|
||||||
|
// Create a flexbox to organize the header a bit easier
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
&:nth-child(n + 1) {
|
||||||
|
// The first header will have appropriate padding, subsequent ones need a margin.
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserMenuButton_contextMenu_name {
|
||||||
|
// Create another flexbox of columns to handle large user IDs
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
// fit the container
|
||||||
|
flex: 1;
|
||||||
|
width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button
|
||||||
|
|
||||||
|
* {
|
||||||
|
// Automatically grow all subelements to fit the container
|
||||||
|
flex: 1;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
// Ellipsize any text overflow
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserMenuButton_contextMenu_displayName {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserMenuButton_contextMenu_userId {
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserMenuButton_contextMenu_themeButton {
|
||||||
|
min-width: 32px;
|
||||||
|
max-width: 32px;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-left: 8px;
|
||||||
|
border-radius: 32px;
|
||||||
|
background-color: $theme-button-bg-color;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
// to make alignment easier, create flexbox for the image
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_UserMenuButton_contextMenu_optionList {
|
||||||
|
margin-top: 20px;
|
||||||
|
|
||||||
|
// This is a bit of a hack when we could just use a simple border-top property,
|
||||||
|
// however we have a (kinda) good reason for doing it this way: we need opacity.
|
||||||
|
// To get the right color, we need an opacity modifier which means we have to work
|
||||||
|
// around the problem. PostCSS doesn't support the opacity() function, and if we
|
||||||
|
// use something like postcss-functions we quickly run into an issue where the
|
||||||
|
// function we would define gets passed a CSS variable for custom themes, which
|
||||||
|
// can't be converted easily even when considering https://stackoverflow.com/a/41265350/7037379
|
||||||
|
//
|
||||||
|
// Therefore, we just hack in a line and border the thing ourselves
|
||||||
|
&::before {
|
||||||
|
border-top: 1px solid $primary-fg-color;
|
||||||
|
opacity: 0.1;
|
||||||
|
content: '';
|
||||||
|
|
||||||
|
// Counteract the padding problems (width: 100% ignores the 40px padding,
|
||||||
|
// unless we position it absolutely then it does the right thing).
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px 0 0;
|
||||||
|
|
||||||
|
.mx_AccessibleButton {
|
||||||
|
text-decoration: none;
|
||||||
|
color: $primary-fg-color;
|
||||||
|
font-size: $font-15px;
|
||||||
|
line-height: $font-24px;
|
||||||
|
|
||||||
|
// Create a flexbox to more easily define the list items
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
img { // icons
|
||||||
|
width: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
max-width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
span { // labels
|
||||||
|
padding-left: 14px;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
// Ellipsize any text overflow
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,7 +18,6 @@ limitations under the License.
|
||||||
$left-gutter: 65px;
|
$left-gutter: 65px;
|
||||||
|
|
||||||
.mx_GroupLayout {
|
.mx_GroupLayout {
|
||||||
|
|
||||||
.mx_EventTile {
|
.mx_EventTile {
|
||||||
> .mx_SenderProfile {
|
> .mx_SenderProfile {
|
||||||
line-height: $font-17px;
|
line-height: $font-17px;
|
||||||
|
@ -53,14 +52,14 @@ $left-gutter: 65px;
|
||||||
/* Compact layout overrides */
|
/* Compact layout overrides */
|
||||||
|
|
||||||
.mx_MatrixChat_useCompactLayout {
|
.mx_MatrixChat_useCompactLayout {
|
||||||
.mx_EventTile_line, .mx_EventTile_reply {
|
|
||||||
padding-top: 0px;
|
|
||||||
padding-bottom: 0px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx_EventTile {
|
.mx_EventTile {
|
||||||
padding-top: 4px;
|
padding-top: 4px;
|
||||||
|
|
||||||
|
.mx_EventTile_line, .mx_EventTile_reply {
|
||||||
|
padding-top: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
&.mx_EventTile_info {
|
&.mx_EventTile_info {
|
||||||
// same as the padding for non-compact .mx_EventTile.mx_EventTile_info
|
// same as the padding for non-compact .mx_EventTile.mx_EventTile_info
|
||||||
padding-top: 0px;
|
padding-top: 0px;
|
||||||
|
|
72
res/css/views/rooms/_NotificationBadge.scss
Normal file
72
res/css/views/rooms/_NotificationBadge.scss
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.mx_NotificationBadge {
|
||||||
|
&:not(.mx_NotificationBadge_visible) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badges are structured a bit weirdly to work around issues with non-monospace
|
||||||
|
// font styles. The badge pill is actually a background div and the count floats
|
||||||
|
// within that. For example:
|
||||||
|
//
|
||||||
|
// ( 99+ ) <-- Rounded pill is a _bg class.
|
||||||
|
// ^- The count is an element floating within that.
|
||||||
|
|
||||||
|
&.mx_NotificationBadge_visible {
|
||||||
|
background-color: $roomtile2-badge-color;
|
||||||
|
margin-right: 14px;
|
||||||
|
|
||||||
|
// Create a flexbox to order the count a bit easier
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&.mx_NotificationBadge_highlighted {
|
||||||
|
// TODO: Use a more specific variable
|
||||||
|
background-color: $warning-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are the 3 background types
|
||||||
|
|
||||||
|
&.mx_NotificationBadge_dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-right: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_NotificationBadge_2char {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.mx_NotificationBadge_3char {
|
||||||
|
width: 26px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The following is the floating badge
|
||||||
|
|
||||||
|
.mx_NotificationBadge_count {
|
||||||
|
font-size: $font-10px;
|
||||||
|
line-height: $font-14px;
|
||||||
|
color: #fff; // TODO: Variable
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,6 +15,10 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
.mx_RoomList.mx_RoomList2 {
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_RoomList {
|
.mx_RoomList {
|
||||||
/* take up remaining space below TopLeftMenu */
|
/* take up remaining space below TopLeftMenu */
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
25
res/css/views/rooms/_RoomList2.scss
Normal file
25
res/css/views/rooms/_RoomList2.scss
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: Rename to mx_RoomList during replacement of old component
|
||||||
|
|
||||||
|
.mx_RoomList2 {
|
||||||
|
// Create a column-based flexbox for the sublists. That's pretty much all we have to
|
||||||
|
// worry about in this stylesheet.
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
80
res/css/views/rooms/_RoomSublist2.scss
Normal file
80
res/css/views/rooms/_RoomSublist2.scss
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: Rename to mx_RoomSublist during replacement of old component
|
||||||
|
|
||||||
|
// TODO: Just use the 3 selectors we need from this instead of importing it.
|
||||||
|
// We're going to end up with heavy modifications anyways.
|
||||||
|
@import "../../../../node_modules/react-resizable/css/styles.css";
|
||||||
|
|
||||||
|
.mx_RoomSublist2 {
|
||||||
|
// The sublist is a column of rows, essentially
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
margin-left: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.mx_RoomSublist2_headerContainer {
|
||||||
|
// Create a flexbox to make ordering easy
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.mx_RoomSublist2_badgeContainer {
|
||||||
|
opacity: 0.8;
|
||||||
|
padding-right: 7px;
|
||||||
|
|
||||||
|
// Create another flexbox row because it's super easy to position the badge at
|
||||||
|
// the end this way.
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomSublist2_headerText {
|
||||||
|
text-transform: uppercase;
|
||||||
|
opacity: 0.5;
|
||||||
|
line-height: $font-16px;
|
||||||
|
font-size: $font-12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
|
// Ellipsize any text overflow
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomSublist2_resizeBox {
|
||||||
|
// Create another flexbox column for the tiles
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.mx_RoomSublist2_showMoreButton {
|
||||||
|
height: 44px; // 1 room tile high
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
// We create a flexbox to cheat at alignment
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
77
res/css/views/rooms/_RoomTile2.scss
Normal file
77
res/css/views/rooms/_RoomTile2.scss
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// TODO: Rename to mx_RoomTile during replacement of old component
|
||||||
|
|
||||||
|
// Note: the room tile expects to be in a flexbox column container
|
||||||
|
.mx_RoomTile2 {
|
||||||
|
width: calc(100% - 11px); // 8px for padding (4px on either side), 3px for margin
|
||||||
|
margin-bottom: 4px;
|
||||||
|
margin-right: 3px;
|
||||||
|
padding: 4px;
|
||||||
|
|
||||||
|
// The tile is also a flexbox row itself
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&.mx_RoomTile2_selected {
|
||||||
|
background-color: $roomtile2-selected-bg-color;
|
||||||
|
border-radius: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomTile2_avatarContainer {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomTile2_nameContainer {
|
||||||
|
// Create a new column layout flexbox for the name parts
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.mx_RoomTile2_name,
|
||||||
|
.mx_RoomTile2_messagePreview {
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Ellipsis on the name and preview
|
||||||
|
|
||||||
|
.mx_RoomTile2_name {
|
||||||
|
font-size: $font-14px;
|
||||||
|
line-height: $font-19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomTile2_messagePreview {
|
||||||
|
font-size: $font-13px;
|
||||||
|
line-height: $font-18px;
|
||||||
|
color: $roomtile2-preview-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mx_RoomTile2_badgeContainer {
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
// Create another flexbox row because it's super easy to position the badge at
|
||||||
|
// the end this way.
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
|
@ -43,3 +43,7 @@ limitations under the License.
|
||||||
padding-left: 20px;
|
padding-left: 20px;
|
||||||
padding-right: 5px;
|
padding-right: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_SettingsTab_customFontSizeField {
|
||||||
|
margin-left: calc($font-16px + 10px);
|
||||||
|
}
|
||||||
|
|
Binary file not shown.
Binary file not shown.
1
res/img/feather-customised/archive.svg
Normal file
1
res/img/feather-customised/archive.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-archive"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>
|
After Width: | Height: | Size: 361 B |
1
res/img/feather-customised/compass.svg
Normal file
1
res/img/feather-customised/compass.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-compass"><circle cx="12" cy="12" r="10"></circle><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon></svg>
|
After Width: | Height: | Size: 342 B |
1
res/img/feather-customised/more-horizontal.svg
Normal file
1
res/img/feather-customised/more-horizontal.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-horizontal"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>
|
After Width: | Height: | Size: 343 B |
1
res/img/feather-customised/sun.svg
Normal file
1
res/img/feather-customised/sun.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-sun"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>
|
After Width: | Height: | Size: 650 B |
|
@ -172,6 +172,13 @@ $header-divider-color: #91A1C0;
|
||||||
|
|
||||||
// ********************
|
// ********************
|
||||||
|
|
||||||
|
// TODO: Update variables for new room list
|
||||||
|
// TODO: Dark theme
|
||||||
|
$roomtile2-preview-color: #9e9e9e;
|
||||||
|
$roomtile2-badge-color: #61708b;
|
||||||
|
$roomtile2-selected-bg-color: #FFF;
|
||||||
|
$theme-button-bg-color: #e3e8f0;
|
||||||
|
|
||||||
$roomtile-name-color: #61708b;
|
$roomtile-name-color: #61708b;
|
||||||
$roomtile-badge-fg-color: $accent-fg-color;
|
$roomtile-badge-fg-color: $accent-fg-color;
|
||||||
$roomtile-selected-color: #212121;
|
$roomtile-selected-color: #212121;
|
||||||
|
|
|
@ -27,7 +27,7 @@ import RoomViewStore from './stores/RoomViewStore';
|
||||||
*/
|
*/
|
||||||
class ActiveRoomObserver {
|
class ActiveRoomObserver {
|
||||||
constructor() {
|
constructor() {
|
||||||
this._listeners = {};
|
this._listeners = {}; // key=roomId, value=function(isActive:boolean)
|
||||||
|
|
||||||
this._activeRoomId = RoomViewStore.getRoomId();
|
this._activeRoomId = RoomViewStore.getRoomId();
|
||||||
// TODO: We could self-destruct when the last listener goes away, or at least
|
// TODO: We could self-destruct when the last listener goes away, or at least
|
||||||
|
@ -35,6 +35,10 @@ class ActiveRoomObserver {
|
||||||
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
|
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get activeRoomId(): string {
|
||||||
|
return this._activeRoomId;
|
||||||
|
}
|
||||||
|
|
||||||
addListener(roomId, listener) {
|
addListener(roomId, listener) {
|
||||||
if (!this._listeners[roomId]) this._listeners[roomId] = [];
|
if (!this._listeners[roomId]) this._listeners[roomId] = [];
|
||||||
this._listeners[roomId].push(listener);
|
this._listeners[roomId].push(listener);
|
||||||
|
@ -51,23 +55,23 @@ class ActiveRoomObserver {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_emit(roomId) {
|
_emit(roomId, isActive: boolean) {
|
||||||
if (!this._listeners[roomId]) return;
|
if (!this._listeners[roomId]) return;
|
||||||
|
|
||||||
for (const l of this._listeners[roomId]) {
|
for (const l of this._listeners[roomId]) {
|
||||||
l.call();
|
l.call(null, isActive);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_onRoomViewStoreUpdate() {
|
_onRoomViewStoreUpdate() {
|
||||||
// emit for the old room ID
|
// emit for the old room ID
|
||||||
if (this._activeRoomId) this._emit(this._activeRoomId);
|
if (this._activeRoomId) this._emit(this._activeRoomId, false);
|
||||||
|
|
||||||
// update our cache
|
// update our cache
|
||||||
this._activeRoomId = RoomViewStore.getRoomId();
|
this._activeRoomId = RoomViewStore.getRoomId();
|
||||||
|
|
||||||
// and emit for the new one
|
// and emit for the new one
|
||||||
if (this._activeRoomId) this._emit(this._activeRoomId);
|
if (this._activeRoomId) this._emit(this._activeRoomId, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner";
|
||||||
|
|
||||||
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
|
// Polyfill for Canvas.toBlob API using Canvas.toDataURL
|
||||||
import "blueimp-canvas-to-blob";
|
import "blueimp-canvas-to-blob";
|
||||||
|
import { Action } from "./dispatcher/actions";
|
||||||
|
|
||||||
const MAX_WIDTH = 800;
|
const MAX_WIDTH = 800;
|
||||||
const MAX_HEIGHT = 600;
|
const MAX_HEIGHT = 600;
|
||||||
|
@ -529,7 +530,7 @@ export default class ContentMessages {
|
||||||
dis.dispatch({action: 'upload_started'});
|
dis.dispatch({action: 'upload_started'});
|
||||||
|
|
||||||
// Focus the composer view
|
// Focus the composer view
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
|
|
||||||
function onProgress(ev) {
|
function onProgress(ev) {
|
||||||
upload.total = ev.total;
|
upload.total = ev.total;
|
||||||
|
|
|
@ -481,6 +481,29 @@ async function combinedPagination(searchResult) {
|
||||||
|
|
||||||
searchResult.pendingRequest = null;
|
searchResult.pendingRequest = null;
|
||||||
|
|
||||||
|
// Restore our encryption info so we can properly re-verify the events.
|
||||||
|
for (let i = 0; i < result.results.length; i++) {
|
||||||
|
const timeline = result.results[i].context.getTimeline();
|
||||||
|
|
||||||
|
for (let j = 0; j < timeline.length; j++) {
|
||||||
|
const ev = timeline[j];
|
||||||
|
if (ev.event.curve25519Key) {
|
||||||
|
ev.makeEncrypted(
|
||||||
|
"m.room.encrypted",
|
||||||
|
{ algorithm: ev.event.algorithm },
|
||||||
|
ev.event.curve25519Key,
|
||||||
|
ev.event.ed25519Key,
|
||||||
|
);
|
||||||
|
ev._forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain;
|
||||||
|
|
||||||
|
delete ev.event.curve25519Key;
|
||||||
|
delete ev.event.ed25519Key;
|
||||||
|
delete ev.event.algorithm;
|
||||||
|
delete ev.event.forwardingCurve25519KeyChain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -96,6 +96,17 @@ export default class WidgetMessaging {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells the widget that it should terminate now.
|
||||||
|
* @returns {Promise<*>} Resolves when widget has acknowledged the message.
|
||||||
|
*/
|
||||||
|
terminate() {
|
||||||
|
return this.messageToWidget({
|
||||||
|
api: OUTBOUND_API_NAME,
|
||||||
|
action: KnownWidgetActions.Terminate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request a screenshot from a widget
|
* Request a screenshot from a widget
|
||||||
* @return {Promise} To be resolved with screenshot data when it has been generated
|
* @return {Promise} To be resolved with screenshot data when it has been generated
|
||||||
|
|
|
@ -122,7 +122,7 @@ export default class EmojiProvider extends AutocompleteProvider {
|
||||||
completion: unicode,
|
completion: unicode,
|
||||||
component: (
|
component: (
|
||||||
<PillCompletion title={shortname} aria-label={unicode}>
|
<PillCompletion title={shortname} aria-label={unicode}>
|
||||||
<span style={{maxWidth: '1em'}}>{ unicode }</span>
|
<span>{ unicode }</span>
|
||||||
</PillCompletion>
|
</PillCompletion>
|
||||||
),
|
),
|
||||||
range,
|
range,
|
||||||
|
|
|
@ -22,9 +22,10 @@ import { _t } from "../../languageHandler";
|
||||||
import SdkConfig from "../../SdkConfig";
|
import SdkConfig from "../../SdkConfig";
|
||||||
import * as sdk from "../../index";
|
import * as sdk from "../../index";
|
||||||
import dis from "../../dispatcher/dispatcher";
|
import dis from "../../dispatcher/dispatcher";
|
||||||
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
|
||||||
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
|
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
|
||||||
const onClickExplore = () => dis.dispatch({action: 'view_room_directory'});
|
const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
|
||||||
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
|
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
|
||||||
|
|
||||||
const HomePage = () => {
|
const HomePage = () => {
|
||||||
|
|
|
@ -26,7 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler';
|
||||||
import SettingsStore from '../../settings/SettingsStore';
|
import SettingsStore from '../../settings/SettingsStore';
|
||||||
import {_t} from "../../languageHandler";
|
import {_t} from "../../languageHandler";
|
||||||
import Analytics from "../../Analytics";
|
import Analytics from "../../Analytics";
|
||||||
import RoomList2 from "../views/rooms/RoomList2";
|
import {Action} from "../../dispatcher/actions";
|
||||||
|
|
||||||
|
|
||||||
const LeftPanel = createReactClass({
|
const LeftPanel = createReactClass({
|
||||||
|
@ -198,7 +198,7 @@ const LeftPanel = createReactClass({
|
||||||
|
|
||||||
onSearchCleared: function(source) {
|
onSearchCleared: function(source) {
|
||||||
if (source === "keyboard") {
|
if (source === "keyboard") {
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
}
|
}
|
||||||
this.setState({searchExpanded: false});
|
this.setState({searchExpanded: false});
|
||||||
},
|
},
|
||||||
|
@ -252,7 +252,7 @@ const LeftPanel = createReactClass({
|
||||||
if (!this.props.collapsed) {
|
if (!this.props.collapsed) {
|
||||||
exploreButton = (
|
exploreButton = (
|
||||||
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
|
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
|
||||||
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton>
|
<AccessibleButton onClick={() => dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}</AccessibleButton>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -274,19 +274,7 @@ const LeftPanel = createReactClass({
|
||||||
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
let roomList = null;
|
const roomList = <RoomList
|
||||||
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
|
|
||||||
roomList = <RoomList2
|
|
||||||
onKeyDown={this._onKeyDown}
|
|
||||||
resizeNotifier={this.props.resizeNotifier}
|
|
||||||
collapsed={this.props.collapsed}
|
|
||||||
searchFilter={this.state.searchFilter}
|
|
||||||
ref={this.collectRoomList}
|
|
||||||
onFocus={this._onFocus}
|
|
||||||
onBlur={this._onBlur}
|
|
||||||
/>;
|
|
||||||
} else {
|
|
||||||
roomList = <RoomList
|
|
||||||
onKeyDown={this._onKeyDown}
|
onKeyDown={this._onKeyDown}
|
||||||
onFocus={this._onFocus}
|
onFocus={this._onFocus}
|
||||||
onBlur={this._onBlur}
|
onBlur={this._onBlur}
|
||||||
|
@ -295,7 +283,6 @@ const LeftPanel = createReactClass({
|
||||||
collapsed={this.props.collapsed}
|
collapsed={this.props.collapsed}
|
||||||
searchFilter={this.state.searchFilter}
|
searchFilter={this.state.searchFilter}
|
||||||
ConferenceHandler={VectorConferenceHandler} />;
|
ConferenceHandler={VectorConferenceHandler} />;
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={containerClasses}>
|
<div className={containerClasses}>
|
||||||
|
|
166
src/components/structures/LeftPanel2.tsx
Normal file
166
src/components/structures/LeftPanel2.tsx
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
/*
|
||||||
|
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 * as React from "react";
|
||||||
|
import TagPanel from "./TagPanel";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import dis from "../../dispatcher/dispatcher";
|
||||||
|
import { _t } from "../../languageHandler";
|
||||||
|
import SearchBox from "./SearchBox";
|
||||||
|
import RoomList2 from "../views/rooms/RoomList2";
|
||||||
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||||
|
import BaseAvatar from '../views/avatars/BaseAvatar';
|
||||||
|
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
|
||||||
|
import UserMenuButton from "./UserMenuButton";
|
||||||
|
import RoomSearch from "./RoomSearch";
|
||||||
|
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||||
|
|
||||||
|
/*******************************************************************
|
||||||
|
* CAUTION *
|
||||||
|
*******************************************************************
|
||||||
|
* This is a work in progress implementation and isn't complete or *
|
||||||
|
* even useful as a component. Please avoid using it until this *
|
||||||
|
* warning disappears. *
|
||||||
|
*******************************************************************/
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
// TODO: Support collapsed state
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
searchFilter: string; // TODO: Move search into room list?
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class LeftPanel2 extends React.Component<IProps, IState> {
|
||||||
|
// TODO: Properly support TagPanel
|
||||||
|
// TODO: Properly support searching/filtering
|
||||||
|
// TODO: Properly support breadcrumbs
|
||||||
|
// TODO: a11y
|
||||||
|
// TODO: actually make this useful in general (match design proposals)
|
||||||
|
// TODO: Fadable support (is this still needed?)
|
||||||
|
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
searchFilter: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private onSearch = (term: string): void => {
|
||||||
|
this.setState({searchFilter: term});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onExplore = () => {
|
||||||
|
dis.fire(Action.ViewRoomDirectory);
|
||||||
|
};
|
||||||
|
|
||||||
|
private renderHeader(): React.ReactNode {
|
||||||
|
// TODO: Update when profile info changes
|
||||||
|
// TODO: Presence
|
||||||
|
// TODO: Breadcrumbs toggle
|
||||||
|
// TODO: Menu button
|
||||||
|
const avatarSize = 32;
|
||||||
|
// TODO: Don't do this profile lookup in render()
|
||||||
|
const client = MatrixClientPeg.get();
|
||||||
|
let displayName = client.getUserId();
|
||||||
|
let avatarUrl: string = null;
|
||||||
|
const myUser = client.getUser(client.getUserId());
|
||||||
|
if (myUser) {
|
||||||
|
displayName = myUser.rawDisplayName;
|
||||||
|
avatarUrl = myUser.avatarUrl;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="mx_LeftPanel2_userHeader">
|
||||||
|
<div className="mx_LeftPanel2_headerRow">
|
||||||
|
<span className="mx_LeftPanel2_userAvatarContainer">
|
||||||
|
<BaseAvatar
|
||||||
|
idName={MatrixClientPeg.get().getUserId()}
|
||||||
|
name={displayName}
|
||||||
|
url={avatarUrl}
|
||||||
|
width={avatarSize}
|
||||||
|
height={avatarSize}
|
||||||
|
resizeMethod="crop"
|
||||||
|
className="mx_LeftPanel2_userAvatar"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="mx_LeftPanel2_userName">{displayName}</span>
|
||||||
|
<span className="mx_LeftPanel2_headerButtons">
|
||||||
|
<UserMenuButton />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
|
||||||
|
<RoomBreadcrumbs />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private renderSearchExplore(): React.ReactNode {
|
||||||
|
// TODO: Collapsed support
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx_LeftPanel2_filterContainer">
|
||||||
|
<RoomSearch onQueryUpdate={this.onSearch} />
|
||||||
|
<AccessibleButton
|
||||||
|
tabIndex={-1}
|
||||||
|
className='mx_LeftPanel2_exploreButton'
|
||||||
|
onClick={this.onExplore}
|
||||||
|
alt={_t("Explore rooms")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render(): React.ReactNode {
|
||||||
|
const tagPanel = (
|
||||||
|
<div className="mx_LeftPanel2_tagPanelContainer">
|
||||||
|
<TagPanel/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Improve props for RoomList2
|
||||||
|
const roomList = <RoomList2
|
||||||
|
onKeyDown={() => {/*TODO*/}}
|
||||||
|
resizeNotifier={null}
|
||||||
|
collapsed={false}
|
||||||
|
searchFilter={this.state.searchFilter}
|
||||||
|
onFocus={() => {/*TODO*/}}
|
||||||
|
onBlur={() => {/*TODO*/}}
|
||||||
|
/>;
|
||||||
|
|
||||||
|
// TODO: Breadcrumbs
|
||||||
|
// TODO: Conference handling / calls
|
||||||
|
|
||||||
|
const containerClasses = classNames({
|
||||||
|
"mx_LeftPanel2": true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={containerClasses}>
|
||||||
|
{tagPanel}
|
||||||
|
<aside className="mx_LeftPanel2_roomListContainer">
|
||||||
|
{this.renderHeader()}
|
||||||
|
{this.renderSearchExplore()}
|
||||||
|
<div className="mx_LeftPanel2_actualRoomListContainer">
|
||||||
|
{roomList}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -51,6 +51,8 @@ import {
|
||||||
showToast as showServerLimitToast,
|
showToast as showServerLimitToast,
|
||||||
hideToast as hideServerLimitToast
|
hideToast as hideServerLimitToast
|
||||||
} from "../../toasts/ServerLimitToast";
|
} from "../../toasts/ServerLimitToast";
|
||||||
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
import LeftPanel2 from "./LeftPanel2";
|
||||||
|
|
||||||
// We need to fetch each pinned message individually (if we don't already have it)
|
// We need to fetch each pinned message individually (if we don't already have it)
|
||||||
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
// so each pinned message may trigger a request. Limit the number per room for sanity.
|
||||||
|
@ -358,7 +360,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||||
// refocusing during a paste event will make the
|
// refocusing during a paste event will make the
|
||||||
// paste end up in the newly focused element,
|
// paste end up in the newly focused element,
|
||||||
// so dispatch synchronously before paste happens
|
// so dispatch synchronously before paste happens
|
||||||
dis.dispatch({action: 'focus_composer'}, true);
|
dis.fire(Action.FocusComposer, true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -450,9 +452,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||||
// composer, so CTRL+` it is
|
// composer, so CTRL+` it is
|
||||||
|
|
||||||
if (ctrlCmdOnly) {
|
if (ctrlCmdOnly) {
|
||||||
dis.dispatch({
|
dis.fire(Action.ToggleUserMenu);
|
||||||
action: 'toggle_top_left_menu',
|
|
||||||
});
|
|
||||||
handled = true;
|
handled = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -508,7 +508,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||||
|
|
||||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||||
// synchronous dispatch so we focus before key generates input
|
// synchronous dispatch so we focus before key generates input
|
||||||
dis.dispatch({action: 'focus_composer'}, true);
|
dis.fire(Action.FocusComposer, true);
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
// we should *not* preventDefault() here as
|
// we should *not* preventDefault() here as
|
||||||
// that would prevent typing in the now-focussed composer
|
// that would prevent typing in the now-focussed composer
|
||||||
|
@ -667,6 +667,20 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||||
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
bodyClasses += ' mx_MatrixChat_useCompactLayout';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let leftPanel = (
|
||||||
|
<LeftPanel
|
||||||
|
resizeNotifier={this.props.resizeNotifier}
|
||||||
|
collapsed={this.props.collapseLhs || false}
|
||||||
|
disabled={this.props.leftDisabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
|
||||||
|
// TODO: Supply props like collapsed and disabled to LeftPanel2
|
||||||
|
leftPanel = (
|
||||||
|
<LeftPanel2 />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||||
<div
|
<div
|
||||||
|
@ -680,11 +694,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
||||||
<ToastContainer />
|
<ToastContainer />
|
||||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||||
<LeftPanel
|
{ leftPanel }
|
||||||
resizeNotifier={this.props.resizeNotifier}
|
|
||||||
collapsed={this.props.collapseLhs || false}
|
|
||||||
disabled={this.props.leftDisabled}
|
|
||||||
/>
|
|
||||||
<ResizeHandle />
|
<ResizeHandle />
|
||||||
{ pageElement }
|
{ pageElement }
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -72,6 +72,7 @@ import {
|
||||||
hideToast as hideAnalyticsToast
|
hideToast as hideAnalyticsToast
|
||||||
} from "../../toasts/AnalyticsToast";
|
} from "../../toasts/AnalyticsToast";
|
||||||
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
||||||
|
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||||
|
|
||||||
/** constants for MatrixChat.state.view */
|
/** constants for MatrixChat.state.view */
|
||||||
export enum Views {
|
export enum Views {
|
||||||
|
@ -347,7 +348,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
Analytics.trackPageChange(durationMs);
|
Analytics.trackPageChange(durationMs);
|
||||||
}
|
}
|
||||||
if (this.focusComposer) {
|
if (this.focusComposer) {
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
this.focusComposer = false;
|
this.focusComposer = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -604,9 +605,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
this.viewIndexedRoom(payload.roomIndex);
|
this.viewIndexedRoom(payload.roomIndex);
|
||||||
break;
|
break;
|
||||||
case Action.ViewUserSettings: {
|
case Action.ViewUserSettings: {
|
||||||
|
const tabPayload = payload as OpenToTabPayload;
|
||||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
|
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
|
||||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
{initialTabId: tabPayload.initialTabId},
|
||||||
|
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true
|
||||||
|
);
|
||||||
|
|
||||||
// View the welcome or home page if we need something to look at
|
// View the welcome or home page if we need something to look at
|
||||||
this.viewSomethingBehindModal();
|
this.viewSomethingBehindModal();
|
||||||
|
@ -620,7 +624,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'view_room_directory': {
|
case Action.ViewRoomDirectory: {
|
||||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
||||||
'mx_RoomDirectory_dialogWrapper', false, true);
|
'mx_RoomDirectory_dialogWrapper', false, true);
|
||||||
|
@ -1363,7 +1367,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
showNotificationsToast();
|
showNotificationsToast();
|
||||||
}
|
}
|
||||||
|
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
this.setState({
|
this.setState({
|
||||||
ready: true,
|
ready: true,
|
||||||
});
|
});
|
||||||
|
@ -1607,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||||
action: 'require_registration',
|
action: 'require_registration',
|
||||||
});
|
});
|
||||||
} else if (screen === 'directory') {
|
} else if (screen === 'directory') {
|
||||||
dis.dispatch({
|
dis.fire(Action.ViewRoomDirectory);
|
||||||
action: 'view_room_directory',
|
|
||||||
});
|
|
||||||
} else if (screen === 'groups') {
|
} else if (screen === 'groups') {
|
||||||
dis.dispatch({
|
dis.dispatch({
|
||||||
action: 'view_my_groups',
|
action: 'view_my_groups',
|
||||||
|
|
144
src/components/structures/RoomSearch.tsx
Normal file
144
src/components/structures/RoomSearch.tsx
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
/*
|
||||||
|
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 * as React from "react";
|
||||||
|
import { createRef } from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
|
import { _t } from "../../languageHandler";
|
||||||
|
import { ActionPayload } from "../../dispatcher/payloads";
|
||||||
|
import { throttle } from 'lodash';
|
||||||
|
import { Key } from "../../Keyboard";
|
||||||
|
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||||
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
|
||||||
|
/*******************************************************************
|
||||||
|
* CAUTION *
|
||||||
|
*******************************************************************
|
||||||
|
* This is a work in progress implementation and isn't complete or *
|
||||||
|
* even useful as a component. Please avoid using it until this *
|
||||||
|
* warning disappears. *
|
||||||
|
*******************************************************************/
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
onQueryUpdate: (newQuery: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
query: string;
|
||||||
|
focused: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
||||||
|
private dispatcherRef: string;
|
||||||
|
private inputRef: React.RefObject<HTMLInputElement> = createRef();
|
||||||
|
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
query: "",
|
||||||
|
focused: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount() {
|
||||||
|
defaultDispatcher.unregister(this.dispatcherRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onAction = (payload: ActionPayload) => {
|
||||||
|
if (payload.action === 'view_room' && payload.clear_search) {
|
||||||
|
this.clearInput();
|
||||||
|
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
|
||||||
|
this.inputRef.current.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private clearInput = () => {
|
||||||
|
if (!this.inputRef.current) return;
|
||||||
|
this.inputRef.current.value = "";
|
||||||
|
this.onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onChange = () => {
|
||||||
|
if (!this.inputRef.current) return;
|
||||||
|
this.setState({query: this.inputRef.current.value});
|
||||||
|
this.onSearchUpdated();
|
||||||
|
};
|
||||||
|
|
||||||
|
// it wants this at the top of the file, but we know better
|
||||||
|
// tslint:disable-next-line
|
||||||
|
private onSearchUpdated = throttle(
|
||||||
|
() => {
|
||||||
|
// We can't use the state variable because it can lag behind the input.
|
||||||
|
// The lag is most obvious when deleting/clearing text with the keyboard.
|
||||||
|
this.props.onQueryUpdate(this.inputRef.current.value);
|
||||||
|
}, 200, {trailing: true, leading: true},
|
||||||
|
);
|
||||||
|
|
||||||
|
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
|
||||||
|
this.setState({focused: true});
|
||||||
|
ev.target.select();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onBlur = () => {
|
||||||
|
this.setState({focused: false});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||||
|
if (ev.key === Key.ESCAPE) {
|
||||||
|
this.clearInput();
|
||||||
|
defaultDispatcher.fire(Action.FocusComposer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(): React.ReactNode {
|
||||||
|
const classes = classNames({
|
||||||
|
'mx_RoomSearch': true,
|
||||||
|
'mx_RoomSearch_expanded': this.state.query || this.state.focused,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputClasses = classNames({
|
||||||
|
'mx_RoomSearch_input': true,
|
||||||
|
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
<div className='mx_RoomSearch_icon'/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
ref={this.inputRef}
|
||||||
|
className={inputClasses}
|
||||||
|
value={this.state.query}
|
||||||
|
onFocus={this.onFocus}
|
||||||
|
onBlur={this.onBlur}
|
||||||
|
onChange={this.onChange}
|
||||||
|
onKeyDown={this.onKeyDown}
|
||||||
|
placeholder={_t("Search")}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<AccessibleButton
|
||||||
|
tabIndex={-1}
|
||||||
|
className='mx_RoomSearch_clearButton'
|
||||||
|
onClick={this.clearInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -26,6 +26,7 @@ import {MatrixClientPeg} from '../../MatrixClientPeg';
|
||||||
import Resend from '../../Resend';
|
import Resend from '../../Resend';
|
||||||
import dis from '../../dispatcher/dispatcher';
|
import dis from '../../dispatcher/dispatcher';
|
||||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||||
|
import {Action} from "../../dispatcher/actions";
|
||||||
|
|
||||||
const STATUS_BAR_HIDDEN = 0;
|
const STATUS_BAR_HIDDEN = 0;
|
||||||
const STATUS_BAR_EXPANDED = 1;
|
const STATUS_BAR_EXPANDED = 1;
|
||||||
|
@ -127,12 +128,12 @@ export default createReactClass({
|
||||||
|
|
||||||
_onResendAllClick: function() {
|
_onResendAllClick: function() {
|
||||||
Resend.resendUnsentEvents(this.props.room);
|
Resend.resendUnsentEvents(this.props.room);
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
},
|
},
|
||||||
|
|
||||||
_onCancelAllClick: function() {
|
_onCancelAllClick: function() {
|
||||||
Resend.cancelUnsentEvents(this.props.room);
|
Resend.cancelUnsentEvents(this.props.room);
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
},
|
},
|
||||||
|
|
||||||
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
|
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
|
||||||
|
|
|
@ -55,6 +55,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||||
import RoomContext from "../../contexts/RoomContext";
|
import RoomContext from "../../contexts/RoomContext";
|
||||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||||
import { shieldStatusForRoom } from '../../utils/ShieldUtils';
|
import { shieldStatusForRoom } from '../../utils/ShieldUtils';
|
||||||
|
import {Action} from "../../dispatcher/actions";
|
||||||
|
|
||||||
const DEBUG = false;
|
const DEBUG = false;
|
||||||
let debuglog = function() {};
|
let debuglog = function() {};
|
||||||
|
@ -1161,7 +1162,7 @@ export default createReactClass({
|
||||||
ev.dataTransfer.files, this.state.room.roomId, this.context,
|
ev.dataTransfer.files, this.state.room.roomId, this.context,
|
||||||
);
|
);
|
||||||
this.setState({ draggingFile: false });
|
this.setState({ draggingFile: false });
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
},
|
},
|
||||||
|
|
||||||
onDragLeaveOrEnd: function(ev) {
|
onDragLeaveOrEnd: function(ev) {
|
||||||
|
@ -1367,7 +1368,7 @@ export default createReactClass({
|
||||||
event: null,
|
event: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
},
|
},
|
||||||
|
|
||||||
onLeaveClick: function() {
|
onLeaveClick: function() {
|
||||||
|
@ -1456,9 +1457,7 @@ export default createReactClass({
|
||||||
// using /leave rather than /join. In the short term though, we
|
// using /leave rather than /join. In the short term though, we
|
||||||
// just ignore them.
|
// just ignore them.
|
||||||
// https://github.com/vector-im/vector-web/issues/1134
|
// https://github.com/vector-im/vector-web/issues/1134
|
||||||
dis.dispatch({
|
dis.fire(Action.ViewRoomDirectory);
|
||||||
action: 'view_room_directory',
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onSearchClick: function() {
|
onSearchClick: function() {
|
||||||
|
@ -1478,7 +1477,7 @@ export default createReactClass({
|
||||||
// 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
|
||||||
jumpToLiveTimeline: function() {
|
jumpToLiveTimeline: function() {
|
||||||
this._messagePanel.jumpToLiveTimeline();
|
this._messagePanel.jumpToLiveTimeline();
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
},
|
},
|
||||||
|
|
||||||
// jump up to wherever our read marker is
|
// jump up to wherever our read marker is
|
||||||
|
|
|
@ -27,25 +27,20 @@ import { ReactNode } from "react";
|
||||||
* Represents a tab for the TabbedView.
|
* Represents a tab for the TabbedView.
|
||||||
*/
|
*/
|
||||||
export class Tab {
|
export class Tab {
|
||||||
public label: string;
|
|
||||||
public icon: string;
|
|
||||||
public body: React.ReactNode;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new tab.
|
* Creates a new tab.
|
||||||
* @param {string} tabLabel The untranslated tab label.
|
* @param {string} id The tab's ID.
|
||||||
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
|
* @param {string} label The untranslated tab label.
|
||||||
* @param {React.ReactNode} tabJsx The JSX for the tab container.
|
* @param {string} icon The class for the tab icon. This should be a simple mask.
|
||||||
|
* @param {React.ReactNode} body The JSX for the tab container.
|
||||||
*/
|
*/
|
||||||
constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) {
|
constructor(public id: string, public label: string, public icon: string, public body: React.ReactNode) {
|
||||||
this.label = tabLabel;
|
|
||||||
this.icon = tabIconClass;
|
|
||||||
this.body = tabJsx;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
|
initialTabId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
@ -53,16 +48,17 @@ interface IState {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class TabbedView extends React.Component<IProps, IState> {
|
export default class TabbedView extends React.Component<IProps, IState> {
|
||||||
static propTypes = {
|
|
||||||
// The tabs to show
|
|
||||||
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
|
let activeTabIndex = 0;
|
||||||
|
if (props.initialTabId) {
|
||||||
|
const tabIndex = props.tabs.findIndex(t => t.id === props.initialTabId);
|
||||||
|
if (tabIndex >= 0) activeTabIndex = tabIndex;
|
||||||
|
}
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
activeTabIndex: 0,
|
activeTabIndex,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ import * as Avatar from '../../Avatar';
|
||||||
import { _t } from '../../languageHandler';
|
import { _t } from '../../languageHandler';
|
||||||
import dis from "../../dispatcher/dispatcher";
|
import dis from "../../dispatcher/dispatcher";
|
||||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||||
|
import {Action} from "../../dispatcher/actions";
|
||||||
|
|
||||||
const AVATAR_SIZE = 28;
|
const AVATAR_SIZE = 28;
|
||||||
|
|
||||||
|
@ -75,7 +76,7 @@ export default class TopLeftMenuButton extends React.Component {
|
||||||
|
|
||||||
onAction = (payload) => {
|
onAction = (payload) => {
|
||||||
// For accessibility
|
// For accessibility
|
||||||
if (payload.action === "toggle_top_left_menu") {
|
if (payload.action === Action.ToggleUserMenu) {
|
||||||
if (this._buttonRef) this._buttonRef.click();
|
if (this._buttonRef) this._buttonRef.click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
270
src/components/structures/UserMenuButton.tsx
Normal file
270
src/components/structures/UserMenuButton.tsx
Normal file
|
@ -0,0 +1,270 @@
|
||||||
|
/*
|
||||||
|
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 * as React from "react";
|
||||||
|
import {User} from "matrix-js-sdk/src/models/user";
|
||||||
|
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||||
|
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
|
import { ActionPayload } from "../../dispatcher/payloads";
|
||||||
|
import { Action } from "../../dispatcher/actions";
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { _t } from "../../languageHandler";
|
||||||
|
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||||
|
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
|
||||||
|
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||||
|
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
|
||||||
|
import Modal from "../../Modal";
|
||||||
|
import LogoutDialog from "../views/dialogs/LogoutDialog";
|
||||||
|
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
|
||||||
|
import {getCustomTheme} from "../../theme";
|
||||||
|
import {getHostingLink} from "../../utils/HostingLink";
|
||||||
|
import AccessibleButton from "../views/elements/AccessibleButton";
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
user: User;
|
||||||
|
menuDisplayed: boolean;
|
||||||
|
isDarkTheme: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class UserMenuButton extends React.Component<IProps, IState> {
|
||||||
|
private dispatcherRef: string;
|
||||||
|
private themeWatcherRef: string;
|
||||||
|
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
|
||||||
|
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
menuDisplayed: false,
|
||||||
|
user: MatrixClientPeg.get().getUser(MatrixClientPeg.get().getUserId()),
|
||||||
|
isDarkTheme: this.isUserOnDarkTheme(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private get displayName(): string {
|
||||||
|
if (MatrixClientPeg.get().isGuest()) {
|
||||||
|
return _t("Guest");
|
||||||
|
} else if (this.state.user) {
|
||||||
|
return this.state.user.displayName;
|
||||||
|
} else {
|
||||||
|
return MatrixClientPeg.get().getUserId();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidMount() {
|
||||||
|
this.dispatcherRef = defaultDispatcher.register(this.onAction);
|
||||||
|
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentWillUnmount() {
|
||||||
|
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
|
||||||
|
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUserOnDarkTheme(): boolean {
|
||||||
|
const theme = SettingsStore.getValue("theme");
|
||||||
|
if (theme.startsWith("custom-")) {
|
||||||
|
return getCustomTheme(theme.substring(0, 7)).is_dark;
|
||||||
|
}
|
||||||
|
return theme === "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
private onThemeChanged = () => {
|
||||||
|
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onAction = (ev: ActionPayload) => {
|
||||||
|
if (ev.action !== Action.ToggleUserMenu) return; // not interested
|
||||||
|
|
||||||
|
// For accessibility
|
||||||
|
if (this.buttonRef.current) this.buttonRef.current.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
private onOpenMenuClick = (ev: InputEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
this.setState({menuDisplayed: true});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onCloseMenu = () => {
|
||||||
|
this.setState({menuDisplayed: false});
|
||||||
|
};
|
||||||
|
|
||||||
|
private onSwitchThemeClick = () => {
|
||||||
|
// Disable system theme matching if the user hits this button
|
||||||
|
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
|
||||||
|
|
||||||
|
const newTheme = this.state.isDarkTheme ? "light" : "dark";
|
||||||
|
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
private onSettingsOpen = (ev: React.MouseEvent, tabId: string) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
|
||||||
|
defaultDispatcher.dispatch(payload);
|
||||||
|
this.setState({menuDisplayed: false}); // also close the menu
|
||||||
|
};
|
||||||
|
|
||||||
|
private onShowArchived = (ev: React.MouseEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
// TODO: Archived room view (deferred)
|
||||||
|
console.log("TODO: Show archived rooms");
|
||||||
|
};
|
||||||
|
|
||||||
|
private onProvideFeedback = (ev: React.MouseEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
|
||||||
|
this.setState({menuDisplayed: false}); // also close the menu
|
||||||
|
};
|
||||||
|
|
||||||
|
private onSignOutClick = (ev: React.MouseEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
|
||||||
|
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
|
||||||
|
this.setState({menuDisplayed: false}); // also close the menu
|
||||||
|
};
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
let contextMenu;
|
||||||
|
if (this.state.menuDisplayed) {
|
||||||
|
let hostingLink;
|
||||||
|
const signupLink = getHostingLink("user-context-menu");
|
||||||
|
if (signupLink) {
|
||||||
|
hostingLink = (
|
||||||
|
<div className="mx_UserMenuButton_contextMenu_header">
|
||||||
|
{_t(
|
||||||
|
"<a>Upgrade</a> to your own domain", {},
|
||||||
|
{
|
||||||
|
a: sub => (
|
||||||
|
<a
|
||||||
|
href={signupLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
tabIndex={-1}
|
||||||
|
>{sub}</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elementRect = this.buttonRef.current.getBoundingClientRect();
|
||||||
|
contextMenu = (
|
||||||
|
<ContextMenu
|
||||||
|
chevronFace="none"
|
||||||
|
left={elementRect.left}
|
||||||
|
top={elementRect.top + elementRect.height}
|
||||||
|
onFinished={this.onCloseMenu}
|
||||||
|
>
|
||||||
|
<div className="mx_UserMenuButton_contextMenu">
|
||||||
|
<div className="mx_UserMenuButton_contextMenu_header">
|
||||||
|
<div className="mx_UserMenuButton_contextMenu_name">
|
||||||
|
<span className="mx_UserMenuButton_contextMenu_displayName">
|
||||||
|
{this.displayName}
|
||||||
|
</span>
|
||||||
|
<span className="mx_UserMenuButton_contextMenu_userId">
|
||||||
|
{MatrixClientPeg.get().getUserId()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="mx_UserMenuButton_contextMenu_themeButton"
|
||||||
|
onClick={this.onSwitchThemeClick}
|
||||||
|
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={require("../../../res/img/feather-customised/sun.svg")}
|
||||||
|
alt={_t("Switch theme")}
|
||||||
|
width={16}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hostingLink}
|
||||||
|
<div className="mx_UserMenuButton_contextMenu_optionList">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
|
||||||
|
<img src={require("../../../res/img/feather-customised/notifications.svg")} width={16} />
|
||||||
|
<span>{_t("Notification settings")}</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
|
||||||
|
<img src={require("../../../res/img/feather-customised/lock.svg")} width={16} />
|
||||||
|
<span>{_t("Security & privacy")}</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
|
||||||
|
<img src={require("../../../res/img/feather-customised/settings.svg")} width={16} />
|
||||||
|
<span>{_t("All settings")}</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<AccessibleButton onClick={this.onShowArchived}>
|
||||||
|
<img src={require("../../../res/img/feather-customised/archive.svg")} width={16} />
|
||||||
|
<span>{_t("Archived rooms")}</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<AccessibleButton onClick={this.onProvideFeedback}>
|
||||||
|
<img src={require("../../../res/img/feather-customised/message-circle.svg")} width={16} />
|
||||||
|
<span>{_t("Feedback")}</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="mx_UserMenuButton_contextMenu_optionList">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<AccessibleButton onClick={this.onSignOutClick}>
|
||||||
|
<img src={require("../../../res/img/feather-customised/sign-out.svg")} width={16} />
|
||||||
|
<span>{_t("Sign out")}</span>
|
||||||
|
</AccessibleButton>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ContextMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<ContextMenuButton
|
||||||
|
className="mx_UserMenuButton"
|
||||||
|
onClick={this.onOpenMenuClick}
|
||||||
|
inputRef={this.buttonRef}
|
||||||
|
label={_t("Account settings")}
|
||||||
|
isExpanded={this.state.menuDisplayed}
|
||||||
|
>
|
||||||
|
<img src={require("../../../res/img/feather-customised/more-horizontal.svg")} alt="..." width={14} />
|
||||||
|
</ContextMenuButton>
|
||||||
|
{contextMenu}
|
||||||
|
</React.Fragment>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,6 +30,13 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
|
|
||||||
|
export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB";
|
||||||
|
export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB";
|
||||||
|
export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
|
||||||
|
export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
|
||||||
|
export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB";
|
||||||
|
export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB";
|
||||||
|
|
||||||
export default class RoomSettingsDialog extends React.Component {
|
export default class RoomSettingsDialog extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
roomId: PropTypes.string.isRequired,
|
roomId: PropTypes.string.isRequired,
|
||||||
|
@ -56,21 +63,25 @@ export default class RoomSettingsDialog extends React.Component {
|
||||||
const tabs = [];
|
const tabs = [];
|
||||||
|
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
ROOM_GENERAL_TAB,
|
||||||
_td("General"),
|
_td("General"),
|
||||||
"mx_RoomSettingsDialog_settingsIcon",
|
"mx_RoomSettingsDialog_settingsIcon",
|
||||||
<GeneralRoomSettingsTab roomId={this.props.roomId} />,
|
<GeneralRoomSettingsTab roomId={this.props.roomId} />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
ROOM_SECURITY_TAB,
|
||||||
_td("Security & Privacy"),
|
_td("Security & Privacy"),
|
||||||
"mx_RoomSettingsDialog_securityIcon",
|
"mx_RoomSettingsDialog_securityIcon",
|
||||||
<SecurityRoomSettingsTab roomId={this.props.roomId} />,
|
<SecurityRoomSettingsTab roomId={this.props.roomId} />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
ROOM_ROLES_TAB,
|
||||||
_td("Roles & Permissions"),
|
_td("Roles & Permissions"),
|
||||||
"mx_RoomSettingsDialog_rolesIcon",
|
"mx_RoomSettingsDialog_rolesIcon",
|
||||||
<RolesRoomSettingsTab roomId={this.props.roomId} />,
|
<RolesRoomSettingsTab roomId={this.props.roomId} />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
ROOM_NOTIFICATIONS_TAB,
|
||||||
_td("Notifications"),
|
_td("Notifications"),
|
||||||
"mx_RoomSettingsDialog_notificationsIcon",
|
"mx_RoomSettingsDialog_notificationsIcon",
|
||||||
<NotificationSettingsTab roomId={this.props.roomId} />,
|
<NotificationSettingsTab roomId={this.props.roomId} />,
|
||||||
|
@ -78,6 +89,7 @@ export default class RoomSettingsDialog extends React.Component {
|
||||||
|
|
||||||
if (SettingsStore.isFeatureEnabled("feature_bridge_state")) {
|
if (SettingsStore.isFeatureEnabled("feature_bridge_state")) {
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
ROOM_BRIDGES_TAB,
|
||||||
_td("Bridges"),
|
_td("Bridges"),
|
||||||
"mx_RoomSettingsDialog_bridgesIcon",
|
"mx_RoomSettingsDialog_bridgesIcon",
|
||||||
<BridgeSettingsTab roomId={this.props.roomId} />,
|
<BridgeSettingsTab roomId={this.props.roomId} />,
|
||||||
|
@ -85,6 +97,7 @@ export default class RoomSettingsDialog extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
ROOM_ADVANCED_TAB,
|
||||||
_td("Advanced"),
|
_td("Advanced"),
|
||||||
"mx_RoomSettingsDialog_warningIcon",
|
"mx_RoomSettingsDialog_warningIcon",
|
||||||
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
|
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
|
||||||
|
|
|
@ -33,9 +33,21 @@ import * as sdk from "../../../index";
|
||||||
import SdkConfig from "../../../SdkConfig";
|
import SdkConfig from "../../../SdkConfig";
|
||||||
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
|
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
|
||||||
|
|
||||||
|
export const USER_GENERAL_TAB = "USER_GENERAL_TAB";
|
||||||
|
export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB";
|
||||||
|
export const USER_FLAIR_TAB = "USER_FLAIR_TAB";
|
||||||
|
export const USER_NOTIFICATIONS_TAB = "USER_NOTIFICATIONS_TAB";
|
||||||
|
export const USER_PREFERENCES_TAB = "USER_PREFERENCES_TAB";
|
||||||
|
export const USER_VOICE_TAB = "USER_VOICE_TAB";
|
||||||
|
export const USER_SECURITY_TAB = "USER_SECURITY_TAB";
|
||||||
|
export const USER_LABS_TAB = "USER_LABS_TAB";
|
||||||
|
export const USER_MJOLNIR_TAB = "USER_MJOLNIR_TAB";
|
||||||
|
export const USER_HELP_TAB = "USER_HELP_TAB";
|
||||||
|
|
||||||
export default class UserSettingsDialog extends React.Component {
|
export default class UserSettingsDialog extends React.Component {
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
onFinished: PropTypes.func.isRequired,
|
onFinished: PropTypes.func.isRequired,
|
||||||
|
initialTabId: PropTypes.string,
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -63,42 +75,50 @@ export default class UserSettingsDialog extends React.Component {
|
||||||
const tabs = [];
|
const tabs = [];
|
||||||
|
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_GENERAL_TAB,
|
||||||
_td("General"),
|
_td("General"),
|
||||||
"mx_UserSettingsDialog_settingsIcon",
|
"mx_UserSettingsDialog_settingsIcon",
|
||||||
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_APPEARANCE_TAB,
|
||||||
_td("Appearance"),
|
_td("Appearance"),
|
||||||
"mx_UserSettingsDialog_appearanceIcon",
|
"mx_UserSettingsDialog_appearanceIcon",
|
||||||
<AppearanceUserSettingsTab />,
|
<AppearanceUserSettingsTab />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_FLAIR_TAB,
|
||||||
_td("Flair"),
|
_td("Flair"),
|
||||||
"mx_UserSettingsDialog_flairIcon",
|
"mx_UserSettingsDialog_flairIcon",
|
||||||
<FlairUserSettingsTab />,
|
<FlairUserSettingsTab />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_NOTIFICATIONS_TAB,
|
||||||
_td("Notifications"),
|
_td("Notifications"),
|
||||||
"mx_UserSettingsDialog_bellIcon",
|
"mx_UserSettingsDialog_bellIcon",
|
||||||
<NotificationUserSettingsTab />,
|
<NotificationUserSettingsTab />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_PREFERENCES_TAB,
|
||||||
_td("Preferences"),
|
_td("Preferences"),
|
||||||
"mx_UserSettingsDialog_preferencesIcon",
|
"mx_UserSettingsDialog_preferencesIcon",
|
||||||
<PreferencesUserSettingsTab />,
|
<PreferencesUserSettingsTab />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_VOICE_TAB,
|
||||||
_td("Voice & Video"),
|
_td("Voice & Video"),
|
||||||
"mx_UserSettingsDialog_voiceIcon",
|
"mx_UserSettingsDialog_voiceIcon",
|
||||||
<VoiceUserSettingsTab />,
|
<VoiceUserSettingsTab />,
|
||||||
));
|
));
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_SECURITY_TAB,
|
||||||
_td("Security & Privacy"),
|
_td("Security & Privacy"),
|
||||||
"mx_UserSettingsDialog_securityIcon",
|
"mx_UserSettingsDialog_securityIcon",
|
||||||
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||||
));
|
));
|
||||||
if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) {
|
if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) {
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_LABS_TAB,
|
||||||
_td("Labs"),
|
_td("Labs"),
|
||||||
"mx_UserSettingsDialog_labsIcon",
|
"mx_UserSettingsDialog_labsIcon",
|
||||||
<LabsUserSettingsTab />,
|
<LabsUserSettingsTab />,
|
||||||
|
@ -106,12 +126,14 @@ export default class UserSettingsDialog extends React.Component {
|
||||||
}
|
}
|
||||||
if (this.state.mjolnirEnabled) {
|
if (this.state.mjolnirEnabled) {
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_MJOLNIR_TAB,
|
||||||
_td("Ignored users"),
|
_td("Ignored users"),
|
||||||
"mx_UserSettingsDialog_mjolnirIcon",
|
"mx_UserSettingsDialog_mjolnirIcon",
|
||||||
<MjolnirUserSettingsTab />,
|
<MjolnirUserSettingsTab />,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
tabs.push(new Tab(
|
tabs.push(new Tab(
|
||||||
|
USER_HELP_TAB,
|
||||||
_td("Help & About"),
|
_td("Help & About"),
|
||||||
"mx_UserSettingsDialog_helpIcon",
|
"mx_UserSettingsDialog_helpIcon",
|
||||||
<HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
<HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||||
|
@ -127,7 +149,7 @@ export default class UserSettingsDialog extends React.Component {
|
||||||
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
|
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
|
||||||
onFinished={this.props.onFinished} title={_t("Settings")}>
|
onFinished={this.props.onFinished} title={_t("Settings")}>
|
||||||
<div className='ms_SettingsDialog_content'>
|
<div className='ms_SettingsDialog_content'>
|
||||||
<TabbedView tabs={this._getTabs()} />
|
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
|
||||||
</div>
|
</div>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
);
|
);
|
||||||
|
|
|
@ -39,6 +39,8 @@ import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||||
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||||
import PersistedElement from "./PersistedElement";
|
import PersistedElement from "./PersistedElement";
|
||||||
import {WidgetType} from "../../../widgets/WidgetType";
|
import {WidgetType} from "../../../widgets/WidgetType";
|
||||||
|
import {Capability} from "../../../widgets/WidgetApi";
|
||||||
|
import {sleep} from "../../../utils/promise";
|
||||||
|
|
||||||
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
|
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
|
||||||
const ENABLE_REACT_PERF = false;
|
const ENABLE_REACT_PERF = false;
|
||||||
|
@ -341,8 +343,21 @@ export default class AppTile extends React.Component {
|
||||||
/**
|
/**
|
||||||
* Ends all widget interaction, such as cancelling calls and disabling webcams.
|
* Ends all widget interaction, such as cancelling calls and disabling webcams.
|
||||||
* @private
|
* @private
|
||||||
|
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
|
||||||
*/
|
*/
|
||||||
_endWidgetActions() {
|
_endWidgetActions() {
|
||||||
|
let terminationPromise;
|
||||||
|
|
||||||
|
if (this._hasCapability(Capability.ReceiveTerminate)) {
|
||||||
|
// Wait for widget to terminate within a timeout
|
||||||
|
const timeout = 2000;
|
||||||
|
const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
|
||||||
|
terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
|
||||||
|
} else {
|
||||||
|
terminationPromise = Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
return terminationPromise.finally(() => {
|
||||||
// HACK: This is a really dirty way to ensure that Jitsi cleans up
|
// HACK: This is a really dirty way to ensure that Jitsi cleans up
|
||||||
// its hold on the webcam. Without this, the widget holds a media
|
// its hold on the webcam. Without this, the widget holds a media
|
||||||
// stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351
|
// stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351
|
||||||
|
@ -358,6 +373,7 @@ export default class AppTile extends React.Component {
|
||||||
|
|
||||||
// Delete the widget from the persisted store for good measure.
|
// Delete the widget from the persisted store for good measure.
|
||||||
PersistedElement.destroyElement(this._persistKey);
|
PersistedElement.destroyElement(this._persistKey);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If user has permission to modify widgets, delete the widget,
|
/* If user has permission to modify widgets, delete the widget,
|
||||||
|
@ -381,12 +397,12 @@ export default class AppTile extends React.Component {
|
||||||
}
|
}
|
||||||
this.setState({deleting: true});
|
this.setState({deleting: true});
|
||||||
|
|
||||||
this._endWidgetActions();
|
this._endWidgetActions().then(() => {
|
||||||
|
return WidgetUtils.setRoomWidget(
|
||||||
WidgetUtils.setRoomWidget(
|
|
||||||
this.props.room.roomId,
|
this.props.room.roomId,
|
||||||
this.props.app.id,
|
this.props.app.id,
|
||||||
).catch((e) => {
|
);
|
||||||
|
}).catch((e) => {
|
||||||
console.error('Failed to delete widget', e);
|
console.error('Failed to delete widget', e);
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||||
|
|
||||||
|
@ -669,6 +685,17 @@ export default class AppTile extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
_onPopoutWidgetClick() {
|
_onPopoutWidgetClick() {
|
||||||
|
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
|
||||||
|
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
|
||||||
|
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
|
||||||
|
this._endWidgetActions().then(() => {
|
||||||
|
if (this._appFrame.current) {
|
||||||
|
// Reload iframe
|
||||||
|
this._appFrame.current.src = this._getRenderedUrl();
|
||||||
|
this.setState({});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
// Using Object.assign workaround as the following opens in a new window instead of a new tab.
|
||||||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||||||
Object.assign(document.createElement('a'),
|
Object.assign(document.createElement('a'),
|
||||||
|
|
|
@ -26,6 +26,7 @@ import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import escapeHtml from "escape-html";
|
import escapeHtml from "escape-html";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import {Action} from "../../../dispatcher/actions";
|
||||||
|
|
||||||
// This component does no cycle detection, simply because the only way to make such a cycle would be to
|
// This component does no cycle detection, simply because the only way to make such a cycle would be to
|
||||||
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
|
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
|
||||||
|
@ -290,7 +291,7 @@ export default class ReplyThread extends React.Component {
|
||||||
events,
|
events,
|
||||||
}, this.loadNextEvent);
|
}, this.loadNextEvent);
|
||||||
|
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
|
@ -18,11 +18,12 @@ import React from 'react';
|
||||||
import * as sdk from '../../../index';
|
import * as sdk from '../../../index';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { _t } from '../../../languageHandler';
|
import { _t } from '../../../languageHandler';
|
||||||
|
import {Action} from "../../../dispatcher/actions";
|
||||||
|
|
||||||
const RoomDirectoryButton = function(props) {
|
const RoomDirectoryButton = function(props) {
|
||||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||||
return (
|
return (
|
||||||
<ActionButton action="view_room_directory"
|
<ActionButton action={Action.ViewRoomDirectory}
|
||||||
mouseOverAction={props.callout ? "callout_room_directory" : null}
|
mouseOverAction={props.callout ? "callout_room_directory" : null}
|
||||||
label={_t("Room directory")}
|
label={_t("Room directory")}
|
||||||
iconPath={require("../../../../res/img/icons-directory.svg")}
|
iconPath={require("../../../../res/img/icons-directory.svg")}
|
||||||
|
|
|
@ -94,7 +94,7 @@ export default class Tooltip extends React.Component<IProps> {
|
||||||
return style;
|
return style;
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderTooltip() {
|
private renderTooltip = () => {
|
||||||
// Add the parent's position to the tooltips, so it's correctly
|
// Add the parent's position to the tooltips, so it's correctly
|
||||||
// positioned, also taking into account any window zoom
|
// positioned, also taking into account any window zoom
|
||||||
// NOTE: The additional 6 pixels for the left position, is to take account of the
|
// NOTE: The additional 6 pixels for the left position, is to take account of the
|
||||||
|
|
|
@ -27,8 +27,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
|
||||||
if (!data) {
|
if (!data) {
|
||||||
throw new Error(`Emoji ${emoji} doesn't exist in emojibase`);
|
throw new Error(`Emoji ${emoji} doesn't exist in emojibase`);
|
||||||
}
|
}
|
||||||
// Prefer our unicode value for quick reactions as we sometimes use variation selectors.
|
return data;
|
||||||
return Object.assign({}, data, { unicode: emoji });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
class QuickReactions extends React.Component {
|
class QuickReactions extends React.Component {
|
||||||
|
|
|
@ -359,6 +359,8 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
_onSelectionChange = () => {
|
_onSelectionChange = () => {
|
||||||
|
const {isEmpty} = this.props.model;
|
||||||
|
|
||||||
this._refreshLastCaretIfNeeded();
|
this._refreshLastCaretIfNeeded();
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
if (this._hasTextSelected && selection.isCollapsed) {
|
if (this._hasTextSelected && selection.isCollapsed) {
|
||||||
|
@ -366,7 +368,7 @@ export default class BasicMessageEditor extends React.Component {
|
||||||
if (this._formatBarRef) {
|
if (this._formatBarRef) {
|
||||||
this._formatBarRef.hide();
|
this._formatBarRef.hide();
|
||||||
}
|
}
|
||||||
} else if (!selection.isCollapsed) {
|
} else if (!selection.isCollapsed && !isEmpty) {
|
||||||
this._hasTextSelected = true;
|
this._hasTextSelected = true;
|
||||||
if (this._formatBarRef) {
|
if (this._formatBarRef) {
|
||||||
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
|
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
|
||||||
|
|
|
@ -31,6 +31,7 @@ import {EventStatus} from 'matrix-js-sdk';
|
||||||
import BasicMessageComposer from "./BasicMessageComposer";
|
import BasicMessageComposer from "./BasicMessageComposer";
|
||||||
import {Key} from "../../../Keyboard";
|
import {Key} from "../../../Keyboard";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import {Action} from "../../../dispatcher/actions";
|
||||||
|
|
||||||
function _isReply(mxEvent) {
|
function _isReply(mxEvent) {
|
||||||
const relatesTo = mxEvent.getContent()["m.relates_to"];
|
const relatesTo = mxEvent.getContent()["m.relates_to"];
|
||||||
|
@ -157,7 +158,7 @@ export default class EditMessageComposer extends React.Component {
|
||||||
dis.dispatch({action: 'edit_event', event: nextEvent});
|
dis.dispatch({action: 'edit_event', event: nextEvent});
|
||||||
} else {
|
} else {
|
||||||
dis.dispatch({action: 'edit_event', event: null});
|
dis.dispatch({action: 'edit_event', event: null});
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
@ -165,7 +166,7 @@ export default class EditMessageComposer extends React.Component {
|
||||||
|
|
||||||
_cancelEdit = () => {
|
_cancelEdit = () => {
|
||||||
dis.dispatch({action: "edit_event", event: null});
|
dis.dispatch({action: "edit_event", event: null});
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
}
|
}
|
||||||
|
|
||||||
_isContentModified(newContent) {
|
_isContentModified(newContent) {
|
||||||
|
@ -195,7 +196,7 @@ export default class EditMessageComposer extends React.Component {
|
||||||
|
|
||||||
// close the event editing and focus composer
|
// close the event editing and focus composer
|
||||||
dis.dispatch({action: "edit_event", event: null});
|
dis.dispatch({action: "edit_event", event: null});
|
||||||
dis.dispatch({action: 'focus_composer'});
|
dis.fire(Action.FocusComposer);
|
||||||
};
|
};
|
||||||
|
|
||||||
_cancelPreviousPendingEdit() {
|
_cancelPreviousPendingEdit() {
|
||||||
|
|
280
src/components/views/rooms/NotificationBadge.tsx
Normal file
280
src/components/views/rooms/NotificationBadge.tsx
Normal file
|
@ -0,0 +1,280 @@
|
||||||
|
/*
|
||||||
|
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
|
||||||
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
|
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||||
|
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||||
|
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||||
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
|
import { Key } from "../../../Keyboard";
|
||||||
|
import * as RoomNotifs from '../../../RoomNotifs';
|
||||||
|
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
||||||
|
import * as Unread from '../../../Unread';
|
||||||
|
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||||
|
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
|
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
import { arrayDiff } from "../../../utils/arrays";
|
||||||
|
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||||
|
|
||||||
|
export const NOTIFICATION_STATE_UPDATE = "update";
|
||||||
|
|
||||||
|
export enum NotificationColor {
|
||||||
|
// Inverted (None -> Red) because we do integer comparisons on this
|
||||||
|
None, // nothing special
|
||||||
|
Bold, // no badge, show as unread
|
||||||
|
Grey, // unread notified messages
|
||||||
|
Red, // unread pings
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface INotificationState extends EventEmitter {
|
||||||
|
symbol?: string;
|
||||||
|
count: number;
|
||||||
|
color: NotificationColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IProps {
|
||||||
|
notification: INotificationState;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If true, the badge will conditionally display a badge without count for the user.
|
||||||
|
*/
|
||||||
|
allowNoCount: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IState {
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class NotificationBadge extends React.PureComponent<IProps, IState> {
|
||||||
|
constructor(props: IProps) {
|
||||||
|
super(props);
|
||||||
|
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidUpdate(prevProps: Readonly<IProps>) {
|
||||||
|
if (prevProps.notification) {
|
||||||
|
prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onNotificationUpdate = () => {
|
||||||
|
this.forceUpdate(); // notification state changed - update
|
||||||
|
};
|
||||||
|
|
||||||
|
public render(): React.ReactElement {
|
||||||
|
// Don't show a badge if we don't need to
|
||||||
|
if (this.props.notification.color <= NotificationColor.Bold) return null;
|
||||||
|
|
||||||
|
const hasNotif = this.props.notification.color >= NotificationColor.Red;
|
||||||
|
const hasCount = this.props.notification.color >= NotificationColor.Grey;
|
||||||
|
const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount");
|
||||||
|
|
||||||
|
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
|
||||||
|
if (isEmptyBadge) symbol = "";
|
||||||
|
|
||||||
|
const classes = classNames({
|
||||||
|
'mx_NotificationBadge': true,
|
||||||
|
'mx_NotificationBadge_visible': hasCount,
|
||||||
|
'mx_NotificationBadge_highlighted': hasNotif,
|
||||||
|
'mx_NotificationBadge_dot': isEmptyBadge,
|
||||||
|
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
|
||||||
|
'mx_NotificationBadge_3char': symbol.length > 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes}>
|
||||||
|
<span className="mx_NotificationBadge_count">{symbol}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoomNotificationState extends EventEmitter implements IDestroyable {
|
||||||
|
private _symbol: string;
|
||||||
|
private _count: number;
|
||||||
|
private _color: NotificationColor;
|
||||||
|
|
||||||
|
constructor(private room: Room) {
|
||||||
|
super();
|
||||||
|
this.room.on("Room.receipt", this.handleRoomEventUpdate);
|
||||||
|
this.room.on("Room.timeline", this.handleRoomEventUpdate);
|
||||||
|
this.room.on("Room.redaction", this.handleRoomEventUpdate);
|
||||||
|
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
||||||
|
this.updateNotificationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get symbol(): string {
|
||||||
|
return this._symbol;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get count(): number {
|
||||||
|
return this._count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get color(): NotificationColor {
|
||||||
|
return this._color;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get roomIsInvite(): boolean {
|
||||||
|
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
|
||||||
|
}
|
||||||
|
|
||||||
|
public destroy(): void {
|
||||||
|
this.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
|
||||||
|
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
||||||
|
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
||||||
|
if (MatrixClientPeg.get()) {
|
||||||
|
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRoomEventUpdate = (event: MatrixEvent) => {
|
||||||
|
const roomId = event.getRoomId();
|
||||||
|
|
||||||
|
if (roomId !== this.room.roomId) return; // ignore - not for us
|
||||||
|
this.updateNotificationState();
|
||||||
|
};
|
||||||
|
|
||||||
|
private updateNotificationState() {
|
||||||
|
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||||
|
|
||||||
|
if (this.roomIsInvite) {
|
||||||
|
this._color = NotificationColor.Red;
|
||||||
|
this._symbol = "!";
|
||||||
|
this._count = 1; // not used, technically
|
||||||
|
} else {
|
||||||
|
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight');
|
||||||
|
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total');
|
||||||
|
|
||||||
|
// For a 'true count' we pick the grey notifications first because they include the
|
||||||
|
// red notifications. If we don't have a grey count for some reason we use the red
|
||||||
|
// count. If that count is broken for some reason, assume zero. This avoids us showing
|
||||||
|
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
|
||||||
|
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
|
||||||
|
|
||||||
|
// Note: we only set the symbol if we have an actual count. We don't want to show
|
||||||
|
// zero on badges.
|
||||||
|
|
||||||
|
if (redNotifs > 0) {
|
||||||
|
this._color = NotificationColor.Red;
|
||||||
|
this._count = trueCount;
|
||||||
|
this._symbol = null; // symbol calculated by component
|
||||||
|
} else if (greyNotifs > 0) {
|
||||||
|
this._color = NotificationColor.Grey;
|
||||||
|
this._count = trueCount;
|
||||||
|
this._symbol = null; // symbol calculated by component
|
||||||
|
} else {
|
||||||
|
// We don't have any notified messages, but we might have unread messages. Let's
|
||||||
|
// find out.
|
||||||
|
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room);
|
||||||
|
if (hasUnread) {
|
||||||
|
this._color = NotificationColor.Bold;
|
||||||
|
} else {
|
||||||
|
this._color = NotificationColor.None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// no symbol or count for this state
|
||||||
|
this._count = 0;
|
||||||
|
this._symbol = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finally, publish an update if needed
|
||||||
|
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||||
|
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||||
|
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListNotificationState extends EventEmitter {
|
||||||
|
private _count: number;
|
||||||
|
private _color: NotificationColor;
|
||||||
|
private rooms: Room[] = [];
|
||||||
|
private states: { [roomId: string]: RoomNotificationState } = {};
|
||||||
|
|
||||||
|
constructor(private byTileCount = false) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
public get symbol(): string {
|
||||||
|
return null; // This notification state doesn't support symbols
|
||||||
|
}
|
||||||
|
|
||||||
|
public get count(): number {
|
||||||
|
return this._count;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get color(): NotificationColor {
|
||||||
|
return this._color;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setRooms(rooms: Room[]) {
|
||||||
|
// If we're only concerned about the tile count, don't bother setting up listeners.
|
||||||
|
if (this.byTileCount) {
|
||||||
|
this.rooms = rooms;
|
||||||
|
this.calculateTotalState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldRooms = this.rooms;
|
||||||
|
const diff = arrayDiff(oldRooms, rooms);
|
||||||
|
for (const oldRoom of diff.removed) {
|
||||||
|
const state = this.states[oldRoom.roomId];
|
||||||
|
delete this.states[oldRoom.roomId];
|
||||||
|
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||||
|
state.destroy();
|
||||||
|
}
|
||||||
|
for (const newRoom of diff.added) {
|
||||||
|
const state = new RoomNotificationState(newRoom);
|
||||||
|
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
|
||||||
|
this.states[newRoom.roomId] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.calculateTotalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onRoomNotificationStateUpdate = () => {
|
||||||
|
this.calculateTotalState();
|
||||||
|
};
|
||||||
|
|
||||||
|
private calculateTotalState() {
|
||||||
|
const before = {count: this.count, symbol: this.symbol, color: this.color};
|
||||||
|
|
||||||
|
if (this.byTileCount) {
|
||||||
|
this._color = NotificationColor.Red;
|
||||||
|
this._count = this.rooms.length;
|
||||||
|
} else {
|
||||||
|
this._count = 0;
|
||||||
|
this._color = NotificationColor.None;
|
||||||
|
for (const state of Object.values(this.states)) {
|
||||||
|
this._count += state.count;
|
||||||
|
this._color = Math.max(this.color, state.color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// finally, publish an update if needed
|
||||||
|
const after = {count: this.count, symbol: this.symbol, color: this.color};
|
||||||
|
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||||
|
this.emit(NOTIFICATION_STATE_UPDATE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
|
||||||
import {formatDate} from '../../../DateUtils';
|
import {formatDate} from '../../../DateUtils';
|
||||||
import Velociraptor from "../../../Velociraptor";
|
import Velociraptor from "../../../Velociraptor";
|
||||||
import * as sdk from "../../../index";
|
import * as sdk from "../../../index";
|
||||||
import {toRem} from "../../../utils/units";
|
import {toPx} from "../../../utils/units";
|
||||||
|
|
||||||
let bounce = false;
|
let bounce = false;
|
||||||
try {
|
try {
|
||||||
|
@ -149,7 +149,7 @@ export default createReactClass({
|
||||||
// start at the old height and in the old h pos
|
// start at the old height and in the old h pos
|
||||||
|
|
||||||
startStyles.push({ top: startTopOffset+"px",
|
startStyles.push({ top: startTopOffset+"px",
|
||||||
left: toRem(oldInfo.left) });
|
left: toPx(oldInfo.left) });
|
||||||
|
|
||||||
const reorderTransitionOpts = {
|
const reorderTransitionOpts = {
|
||||||
duration: 100,
|
duration: 100,
|
||||||
|
@ -182,7 +182,7 @@ export default createReactClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
left: toRem(this.props.leftOffset),
|
left: toPx(this.props.leftOffset),
|
||||||
top: '0px',
|
top: '0px',
|
||||||
visibility: this.props.hidden ? 'hidden' : 'visible',
|
visibility: this.props.hidden ? 'hidden' : 'visible',
|
||||||
};
|
};
|
||||||
|
|
|
@ -18,18 +18,17 @@ limitations under the License.
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { _t, _td } from "../../../languageHandler";
|
import { _t, _td } from "../../../languageHandler";
|
||||||
import { Layout } from '../../../resizer/distributors/roomsublist2';
|
|
||||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||||
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
|
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
|
||||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2";
|
import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStore2 } from "../../../stores/room-list/RoomListStore2";
|
||||||
import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
||||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||||
import { Dispatcher } from "flux";
|
import { Dispatcher } from "flux";
|
||||||
import dis from "../../../dispatcher/dispatcher";
|
import dis from "../../../dispatcher/dispatcher";
|
||||||
import RoomSublist2 from "./RoomSublist2";
|
import RoomSublist2 from "./RoomSublist2";
|
||||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||||
import { IFilterCondition } from "../../../stores/room-list/filters/IFilterCondition";
|
|
||||||
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
|
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
|
||||||
|
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||||
|
|
||||||
/*******************************************************************
|
/*******************************************************************
|
||||||
* CAUTION *
|
* CAUTION *
|
||||||
|
@ -50,6 +49,7 @@ interface IProps {
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
sublists: ITagMap;
|
sublists: ITagMap;
|
||||||
|
layouts: Map<TagID, ListLayout>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_ORDER: TagID[] = [
|
const TAG_ORDER: TagID[] = [
|
||||||
|
@ -96,7 +96,7 @@ const TAG_AESTHETICS: {
|
||||||
defaultHidden: false,
|
defaultHidden: false,
|
||||||
},
|
},
|
||||||
[DefaultTagID.DM]: {
|
[DefaultTagID.DM]: {
|
||||||
sectionLabel: _td("Direct Messages"),
|
sectionLabel: _td("People"),
|
||||||
isInvite: false,
|
isInvite: false,
|
||||||
defaultHidden: false,
|
defaultHidden: false,
|
||||||
addRoomLabel: _td("Start chat"),
|
addRoomLabel: _td("Start chat"),
|
||||||
|
@ -127,19 +127,15 @@ const TAG_AESTHETICS: {
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class RoomList2 extends React.Component<IProps, IState> {
|
export default class RoomList2 extends React.Component<IProps, IState> {
|
||||||
private sublistRefs: { [tagId: string]: React.RefObject<RoomSublist2> } = {};
|
|
||||||
private sublistSizes: { [tagId: string]: number } = {};
|
|
||||||
private sublistCollapseStates: { [tagId: string]: boolean } = {};
|
|
||||||
private unfilteredLayout: Layout;
|
|
||||||
private filteredLayout: Layout;
|
|
||||||
private searchFilter: NameFilterCondition = new NameFilterCondition();
|
private searchFilter: NameFilterCondition = new NameFilterCondition();
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {sublists: {}};
|
this.state = {
|
||||||
this.loadSublistSizes();
|
sublists: {},
|
||||||
this.prepareLayouts();
|
layouts: new Map<TagID, ListLayout>(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||||
|
@ -158,49 +154,16 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentDidMount(): void {
|
public componentDidMount(): void {
|
||||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => {
|
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store: RoomListStore2) => {
|
||||||
console.log("new lists", store.orderedLists);
|
const newLists = store.orderedLists;
|
||||||
this.setState({sublists: store.orderedLists});
|
console.log("new lists", newLists);
|
||||||
});
|
|
||||||
|
const layoutMap = new Map<TagID, ListLayout>();
|
||||||
|
for (const tagId of Object.keys(newLists)) {
|
||||||
|
layoutMap.set(tagId, new ListLayout(tagId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadSublistSizes() {
|
this.setState({sublists: newLists, layouts: layoutMap});
|
||||||
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
|
|
||||||
if (sizesJson) this.sublistSizes = JSON.parse(sizesJson);
|
|
||||||
|
|
||||||
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
|
|
||||||
if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveSublistSizes() {
|
|
||||||
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes));
|
|
||||||
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates));
|
|
||||||
}
|
|
||||||
|
|
||||||
private prepareLayouts() {
|
|
||||||
// TODO: Change layout engine for FTUE support
|
|
||||||
this.unfilteredLayout = new Layout((tagId: string, height: number) => {
|
|
||||||
const sublist = this.sublistRefs[tagId];
|
|
||||||
if (sublist) sublist.current.setHeight(height);
|
|
||||||
|
|
||||||
// TODO: Check overflow (see old impl)
|
|
||||||
|
|
||||||
// Don't store a height for collapsed sublists
|
|
||||||
if (!this.sublistCollapseStates[tagId]) {
|
|
||||||
this.sublistSizes[tagId] = height;
|
|
||||||
this.saveSublistSizes();
|
|
||||||
}
|
|
||||||
}, this.sublistSizes, this.sublistCollapseStates, {
|
|
||||||
allowWhitespace: false,
|
|
||||||
handleHeight: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.filteredLayout = new Layout((tagId: string, height: number) => {
|
|
||||||
const sublist = this.sublistRefs[tagId];
|
|
||||||
if (sublist) sublist.current.setHeight(height);
|
|
||||||
}, null, null, {
|
|
||||||
allowWhitespace: false,
|
|
||||||
handleHeight: 0,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -226,7 +189,8 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
||||||
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
||||||
|
|
||||||
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
||||||
components.push(<RoomSublist2
|
components.push(
|
||||||
|
<RoomSublist2
|
||||||
key={`sublist-${orderedTagId}`}
|
key={`sublist-${orderedTagId}`}
|
||||||
forRooms={true}
|
forRooms={true}
|
||||||
rooms={orderedRooms}
|
rooms={orderedRooms}
|
||||||
|
@ -235,7 +199,10 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
||||||
onAddRoom={onAddRoomFn}
|
onAddRoom={onAddRoomFn}
|
||||||
addRoomLabel={aesthetics.addRoomLabel}
|
addRoomLabel={aesthetics.addRoomLabel}
|
||||||
isInvite={aesthetics.isInvite}
|
isInvite={aesthetics.isInvite}
|
||||||
/>);
|
layout={this.state.layouts.get(orderedTagId)}
|
||||||
|
showMessagePreviews={orderedTagId === DefaultTagID.DM}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return components;
|
return components;
|
||||||
|
@ -250,7 +217,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
||||||
onFocus={this.props.onFocus}
|
onFocus={this.props.onFocus}
|
||||||
onBlur={this.props.onBlur}
|
onBlur={this.props.onBlur}
|
||||||
onKeyDown={onKeyDownHandler}
|
onKeyDown={onKeyDownHandler}
|
||||||
className="mx_RoomList"
|
className="mx_RoomList2"
|
||||||
role="tree"
|
role="tree"
|
||||||
aria-label={_t("Rooms")}
|
aria-label={_t("Rooms")}
|
||||||
// Firefox sometimes makes this element focusable due to
|
// Firefox sometimes makes this element focusable due to
|
||||||
|
|
|
@ -20,14 +20,13 @@ import * as React from "react";
|
||||||
import { createRef } from "react";
|
import { createRef } from "react";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
|
|
||||||
import * as RoomNotifs from '../../../RoomNotifs';
|
|
||||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||||
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
|
|
||||||
import * as FormattingUtils from '../../../utils/FormattingUtils';
|
|
||||||
import RoomTile2 from "./RoomTile2";
|
import RoomTile2 from "./RoomTile2";
|
||||||
|
import { ResizableBox, ResizeCallbackData } from "react-resizable";
|
||||||
|
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||||
|
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
|
||||||
|
|
||||||
/*******************************************************************
|
/*******************************************************************
|
||||||
* CAUTION *
|
* CAUTION *
|
||||||
|
@ -42,12 +41,13 @@ interface IProps {
|
||||||
rooms?: Room[];
|
rooms?: Room[];
|
||||||
startAsHidden: boolean;
|
startAsHidden: boolean;
|
||||||
label: string;
|
label: string;
|
||||||
|
showMessagePreviews: boolean;
|
||||||
onAddRoom?: () => void;
|
onAddRoom?: () => void;
|
||||||
addRoomLabel: string;
|
addRoomLabel: string;
|
||||||
isInvite: boolean;
|
isInvite: boolean;
|
||||||
|
layout: ListLayout;
|
||||||
|
|
||||||
// TODO: Collapsed state
|
// TODO: Collapsed state
|
||||||
// TODO: Height
|
|
||||||
// TODO: Group invites
|
// TODO: Group invites
|
||||||
// TODO: Calls
|
// TODO: Calls
|
||||||
// TODO: forceExpand?
|
// TODO: forceExpand?
|
||||||
|
@ -56,17 +56,19 @@ interface IProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
|
notificationState: ListNotificationState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
private headerButton = createRef();
|
private headerButton = createRef();
|
||||||
|
|
||||||
public setHeight(size: number) {
|
constructor(props: IProps) {
|
||||||
// TODO: Do a thing (maybe - height changes are different in FTUE)
|
super(props);
|
||||||
}
|
|
||||||
|
|
||||||
private hasTiles(): boolean {
|
this.state = {
|
||||||
return this.numTiles > 0;
|
notificationState: new ListNotificationState(this.props.isInvite),
|
||||||
|
};
|
||||||
|
this.state.notificationState.setRooms(this.props.rooms);
|
||||||
}
|
}
|
||||||
|
|
||||||
private get numTiles(): number {
|
private get numTiles(): number {
|
||||||
|
@ -74,17 +76,39 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
return (this.props.rooms || []).length;
|
return (this.props.rooms || []).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public componentDidUpdate() {
|
||||||
|
this.state.notificationState.setRooms(this.props.rooms);
|
||||||
|
}
|
||||||
|
|
||||||
private onAddRoom = (e) => {
|
private onAddRoom = (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => {
|
||||||
|
const direction = e.movementY < 0 ? -1 : +1;
|
||||||
|
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction;
|
||||||
|
this.props.layout.visibleTiles += tileDiff;
|
||||||
|
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||||
|
};
|
||||||
|
|
||||||
|
private onShowAllClick = () => {
|
||||||
|
this.props.layout.visibleTiles = this.numTiles;
|
||||||
|
this.forceUpdate(); // because the layout doesn't trigger a re-render
|
||||||
|
};
|
||||||
|
|
||||||
private renderTiles(): React.ReactElement[] {
|
private renderTiles(): React.ReactElement[] {
|
||||||
const tiles: React.ReactElement[] = [];
|
const tiles: React.ReactElement[] = [];
|
||||||
|
|
||||||
if (this.props.rooms) {
|
if (this.props.rooms) {
|
||||||
for (const room of this.props.rooms) {
|
for (const room of this.props.rooms) {
|
||||||
tiles.push(<RoomTile2 room={room} key={`room-${room.roomId}`}/>);
|
tiles.push(
|
||||||
|
<RoomTile2
|
||||||
|
room={room}
|
||||||
|
key={`room-${room.roomId}`}
|
||||||
|
showMessagePreview={this.props.showMessagePreviews}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -92,25 +116,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private renderHeader(): React.ReactElement {
|
private renderHeader(): React.ReactElement {
|
||||||
const notifications = !this.props.isInvite
|
|
||||||
? RoomNotifs.aggregateNotificationCount(this.props.rooms)
|
|
||||||
: {count: 0, highlight: true};
|
|
||||||
const notifCount = notifications.count;
|
|
||||||
const notifHighlight = notifications.highlight;
|
|
||||||
|
|
||||||
// TODO: Title on collapsed
|
// TODO: Title on collapsed
|
||||||
// TODO: Incoming call box
|
// TODO: Incoming call box
|
||||||
|
|
||||||
let chevron = null;
|
|
||||||
if (this.hasTiles()) {
|
|
||||||
const chevronClasses = classNames({
|
|
||||||
'mx_RoomSubList_chevron': true,
|
|
||||||
'mx_RoomSubList_chevronRight': false, // isCollapsed
|
|
||||||
'mx_RoomSubList_chevronDown': true, // !isCollapsed
|
|
||||||
});
|
|
||||||
chevron = (<div className={chevronClasses}/>);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||||
{({onFocus, isActive, ref}) => {
|
{({onFocus, isActive, ref}) => {
|
||||||
|
@ -118,68 +126,37 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
const tabIndex = isActive ? 0 : -1;
|
const tabIndex = isActive ? 0 : -1;
|
||||||
|
|
||||||
// TODO: Collapsed state
|
// TODO: Collapsed state
|
||||||
let badge;
|
|
||||||
if (true) { // !isCollapsed
|
|
||||||
const badgeClasses = classNames({
|
|
||||||
'mx_RoomSubList_badge': true,
|
|
||||||
'mx_RoomSubList_badgeHighlight': notifHighlight,
|
|
||||||
});
|
|
||||||
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
|
||||||
if (notifCount > 0) {
|
|
||||||
badge = (
|
|
||||||
<AccessibleButton
|
|
||||||
tabIndex={tabIndex}
|
|
||||||
className={badgeClasses}
|
|
||||||
aria-label={_t("Jump to first unread room.")}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{FormattingUtils.formatCount(notifCount)}
|
|
||||||
</div>
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
} else if (this.props.isInvite && this.hasTiles()) {
|
|
||||||
// Render the `!` badge for invites
|
|
||||||
badge = (
|
|
||||||
<AccessibleButton
|
|
||||||
tabIndex={tabIndex}
|
|
||||||
className={badgeClasses}
|
|
||||||
aria-label={_t("Jump to first invite.")}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{FormattingUtils.formatCount(this.numTiles)}
|
|
||||||
</div>
|
|
||||||
</AccessibleButton>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let addRoomButton = null;
|
const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>;
|
||||||
if (!!this.props.onAddRoom) {
|
|
||||||
addRoomButton = (
|
// TODO: Aux button
|
||||||
<AccessibleTooltipButton
|
// let addRoomButton = null;
|
||||||
tabIndex={tabIndex}
|
// if (!!this.props.onAddRoom) {
|
||||||
onClick={this.onAddRoom}
|
// addRoomButton = (
|
||||||
className="mx_RoomSubList_addRoom"
|
// <AccessibleTooltipButton
|
||||||
title={this.props.addRoomLabel || _t("Add room")}
|
// tabIndex={tabIndex}
|
||||||
/>
|
// onClick={this.onAddRoom}
|
||||||
);
|
// className="mx_RoomSublist2_addButton"
|
||||||
}
|
// title={this.props.addRoomLabel || _t("Add room")}
|
||||||
|
// />
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
// TODO: a11y (see old component)
|
// TODO: a11y (see old component)
|
||||||
return (
|
return (
|
||||||
<div className={"mx_RoomSubList_labelContainer"}>
|
<div className={"mx_RoomSublist2_headerContainer"}>
|
||||||
<AccessibleButton
|
<AccessibleButton
|
||||||
inputRef={ref}
|
inputRef={ref}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
className={"mx_RoomSubList_label"}
|
className={"mx_RoomSublist2_headerText"}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
aria-level="1"
|
aria-level="1"
|
||||||
>
|
>
|
||||||
{chevron}
|
|
||||||
<span>{this.props.label}</span>
|
<span>{this.props.label}</span>
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
|
<div className="mx_RoomSublist2_badgeContainer">
|
||||||
{badge}
|
{badge}
|
||||||
{addRoomButton}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
@ -195,19 +172,65 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
const classes = classNames({
|
const classes = classNames({
|
||||||
// TODO: Proper collapse support
|
// TODO: Proper collapse support
|
||||||
'mx_RoomSubList': true,
|
'mx_RoomSublist2': true,
|
||||||
'mx_RoomSubList_hidden': false, // len && isCollapsed
|
'mx_RoomSublist2_collapsed': false, // len && isCollapsed
|
||||||
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let content = null;
|
let content = null;
|
||||||
if (tiles.length > 0) {
|
if (tiles.length > 0) {
|
||||||
// TODO: Lazy list rendering
|
// TODO: Lazy list rendering
|
||||||
// TODO: Whatever scrolling magic needs to happen here
|
// TODO: Whatever scrolling magic needs to happen here
|
||||||
|
const layout = this.props.layout; // to shorten calls
|
||||||
|
const minTilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.minVisibleTiles));
|
||||||
|
const maxTilesPx = layout.tilesToPixels(tiles.length);
|
||||||
|
const tilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.visibleTiles));
|
||||||
|
let handles = ['s'];
|
||||||
|
if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) {
|
||||||
|
handles = []; // no handles, we're at a minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: This might need adjustment, however for now it is fine as a round.
|
||||||
|
const nVisible = Math.round(layout.visibleTiles);
|
||||||
|
const visibleTiles = tiles.slice(0, nVisible);
|
||||||
|
|
||||||
|
// If we're hiding rooms, show a 'show more' button to the user. This button
|
||||||
|
// replaces the last visible tile, so will always show 2+ rooms. We do this
|
||||||
|
// because if it said "show 1 more room" we had might as well show that room
|
||||||
|
// instead. We also replace the last item so we don't have to adjust our math
|
||||||
|
// on pixel heights, etc. It's much easier to pretend the button is a tile.
|
||||||
|
if (tiles.length > nVisible) {
|
||||||
|
// we have a cutoff condition - add the button to show all
|
||||||
|
|
||||||
|
// we +1 to account for the room we're about to hide with our 'show more' button
|
||||||
|
// this results in the button always being 1+, and not needing an i18n `count`.
|
||||||
|
const numMissing = (tiles.length - visibleTiles.length) + 1;
|
||||||
|
|
||||||
|
// TODO: CSS TBD
|
||||||
|
// TODO: Make this an actual tile
|
||||||
|
// TODO: This is likely to pop out of the list, consider that.
|
||||||
|
visibleTiles.splice(visibleTiles.length - 1, 1, (
|
||||||
|
<div
|
||||||
|
onClick={this.onShowAllClick}
|
||||||
|
className='mx_RoomSublist2_showMoreButton'
|
||||||
|
key='showall'
|
||||||
|
>
|
||||||
|
{_t("Show %(n)s more", {n: numMissing})}
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
}
|
||||||
content = (
|
content = (
|
||||||
<IndicatorScrollbar className='mx_RoomSubList_scroll'>
|
<ResizableBox
|
||||||
{tiles}
|
width={-1}
|
||||||
</IndicatorScrollbar>
|
height={tilesPx}
|
||||||
|
axis="y"
|
||||||
|
minConstraints={[-1, minTilesPx]}
|
||||||
|
maxConstraints={[-1, maxTilesPx]}
|
||||||
|
resizeHandles={handles}
|
||||||
|
onResize={this.onResize}
|
||||||
|
className="mx_RoomSublist2_resizeBox"
|
||||||
|
>
|
||||||
|
{visibleTiles}
|
||||||
|
</ResizableBox>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -23,15 +23,10 @@ import classNames from "classnames";
|
||||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||||
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||||
import Tooltip from "../../views/elements/Tooltip";
|
|
||||||
import dis from '../../../dispatcher/dispatcher';
|
import dis from '../../../dispatcher/dispatcher';
|
||||||
import { Key } from "../../../Keyboard";
|
import { Key } from "../../../Keyboard";
|
||||||
import * as RoomNotifs from '../../../RoomNotifs';
|
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||||
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
|
||||||
import * as Unread from '../../../Unread';
|
|
||||||
import * as FormattingUtils from "../../../utils/FormattingUtils";
|
|
||||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
|
||||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|
||||||
|
|
||||||
/*******************************************************************
|
/*******************************************************************
|
||||||
* CAUTION *
|
* CAUTION *
|
||||||
|
@ -41,30 +36,19 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||||
* warning disappears. *
|
* warning disappears. *
|
||||||
*******************************************************************/
|
*******************************************************************/
|
||||||
|
|
||||||
enum NotificationColor {
|
|
||||||
// Inverted (None -> Red) because we do integer comparisons on this
|
|
||||||
None, // nothing special
|
|
||||||
Bold, // no badge, show as unread
|
|
||||||
Grey, // unread notified messages
|
|
||||||
Red, // unread pings
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
showMessagePreview: boolean;
|
||||||
|
|
||||||
// TODO: Allow falsifying counts (for invites and stuff)
|
// TODO: Allow falsifying counts (for invites and stuff)
|
||||||
// TODO: Transparency? Was this ever used?
|
// TODO: Transparency? Was this ever used?
|
||||||
// TODO: Incoming call boxes?
|
// TODO: Incoming call boxes?
|
||||||
}
|
}
|
||||||
|
|
||||||
interface INotificationState {
|
|
||||||
symbol: string;
|
|
||||||
color: NotificationColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
hover: boolean;
|
hover: boolean;
|
||||||
notificationState: INotificationState;
|
notificationState: INotificationState;
|
||||||
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
|
@ -86,86 +70,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
hover: false,
|
hover: false,
|
||||||
notificationState: this.getNotificationState(),
|
notificationState: new RoomNotificationState(this.props.room),
|
||||||
|
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.props.room.on("Room.receipt", this.handleRoomEventUpdate);
|
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||||
this.props.room.on("Room.timeline", this.handleRoomEventUpdate);
|
|
||||||
this.props.room.on("Room.redaction", this.handleRoomEventUpdate);
|
|
||||||
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public componentWillUnmount() {
|
public componentWillUnmount() {
|
||||||
if (this.props.room) {
|
if (this.props.room) {
|
||||||
this.props.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
|
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||||
this.props.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
|
||||||
this.props.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
|
||||||
}
|
}
|
||||||
if (MatrixClientPeg.get()) {
|
|
||||||
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// XXX: This is a bit of an awful-looking hack. We should probably be using state for
|
|
||||||
// this, but instead we're kinda forced to either duplicate the code or thread a variable
|
|
||||||
// through the code paths. This feels like the least evil option.
|
|
||||||
private get roomIsInvite(): boolean {
|
|
||||||
return getEffectiveMembership(this.props.room.getMyMembership()) === EffectiveMembership.Invite;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleRoomEventUpdate = (event: MatrixEvent) => {
|
|
||||||
const roomId = event.getRoomId();
|
|
||||||
|
|
||||||
// Sanity check: should never happen
|
|
||||||
if (roomId !== this.props.room.roomId) return;
|
|
||||||
|
|
||||||
this.updateNotificationState();
|
|
||||||
};
|
|
||||||
|
|
||||||
private updateNotificationState() {
|
|
||||||
this.setState({notificationState: this.getNotificationState()});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNotificationState(): INotificationState {
|
|
||||||
const state: INotificationState = {
|
|
||||||
color: NotificationColor.None,
|
|
||||||
symbol: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.roomIsInvite) {
|
|
||||||
state.color = NotificationColor.Red;
|
|
||||||
state.symbol = "!";
|
|
||||||
} else {
|
|
||||||
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
|
|
||||||
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'total');
|
|
||||||
|
|
||||||
// For a 'true count' we pick the grey notifications first because they include the
|
|
||||||
// red notifications. If we don't have a grey count for some reason we use the red
|
|
||||||
// count. If that count is broken for some reason, assume zero. This avoids us showing
|
|
||||||
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
|
|
||||||
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
|
|
||||||
|
|
||||||
// Note: we only set the symbol if we have an actual count. We don't want to show
|
|
||||||
// zero on badges.
|
|
||||||
|
|
||||||
if (redNotifs > 0) {
|
|
||||||
state.color = NotificationColor.Red;
|
|
||||||
state.symbol = FormattingUtils.formatCount(trueCount);
|
|
||||||
} else if (greyNotifs > 0) {
|
|
||||||
state.color = NotificationColor.Grey;
|
|
||||||
state.symbol = FormattingUtils.formatCount(trueCount);
|
|
||||||
} else {
|
|
||||||
// We don't have any notified messages, but we might have unread messages. Let's
|
|
||||||
// find out.
|
|
||||||
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.props.room);
|
|
||||||
if (hasUnread) {
|
|
||||||
state.color = NotificationColor.Bold;
|
|
||||||
// no symbol for this state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return state;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private onTileMouseEnter = () => {
|
private onTileMouseEnter = () => {
|
||||||
|
@ -186,60 +101,44 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private onActiveRoomUpdate = (isActive: boolean) => {
|
||||||
|
this.setState({selected: isActive});
|
||||||
|
};
|
||||||
|
|
||||||
public render(): React.ReactElement {
|
public render(): React.ReactElement {
|
||||||
// TODO: Collapsed state
|
// TODO: Collapsed state
|
||||||
// TODO: Invites
|
// TODO: Invites
|
||||||
// TODO: a11y proper
|
// TODO: a11y proper
|
||||||
// TODO: Render more than bare minimum
|
// TODO: Render more than bare minimum
|
||||||
|
|
||||||
const hasBadge = this.state.notificationState.color > NotificationColor.Bold;
|
|
||||||
const isUnread = this.state.notificationState.color > NotificationColor.None;
|
|
||||||
const classes = classNames({
|
const classes = classNames({
|
||||||
'mx_RoomTile': true,
|
'mx_RoomTile2': true,
|
||||||
// 'mx_RoomTile_selected': this.state.selected,
|
'mx_RoomTile2_selected': this.state.selected,
|
||||||
'mx_RoomTile_unread': isUnread,
|
|
||||||
'mx_RoomTile_unreadNotify': this.state.notificationState.color >= NotificationColor.Grey,
|
|
||||||
'mx_RoomTile_highlight': this.state.notificationState.color >= NotificationColor.Red,
|
|
||||||
'mx_RoomTile_invited': this.roomIsInvite,
|
|
||||||
// 'mx_RoomTile_menuDisplayed': isMenuDisplayed,
|
|
||||||
'mx_RoomTile_noBadges': !hasBadge,
|
|
||||||
// 'mx_RoomTile_transparent': this.props.transparent,
|
|
||||||
// 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const avatarClasses = classNames({
|
const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
|
||||||
'mx_RoomTile_avatar': true,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
let badge;
|
|
||||||
if (hasBadge) {
|
|
||||||
const badgeClasses = classNames({
|
|
||||||
'mx_RoomTile_badge': true,
|
|
||||||
'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed
|
|
||||||
});
|
|
||||||
badge = <div className={badgeClasses}>{this.state.notificationState.symbol}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||||
let name = this.props.room.name;
|
let name = this.props.room.name;
|
||||||
if (typeof name !== 'string') name = '';
|
if (typeof name !== 'string') name = '';
|
||||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||||
|
|
||||||
|
// TODO: Support collapsed state properly
|
||||||
|
// TODO: Tooltip?
|
||||||
|
|
||||||
|
let messagePreview = null;
|
||||||
|
if (this.props.showMessagePreview) {
|
||||||
|
// TODO: Actually get the real message preview from state
|
||||||
|
messagePreview = <div className="mx_RoomTile2_messagePreview">I just ate a pie.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
const nameClasses = classNames({
|
const nameClasses = classNames({
|
||||||
'mx_RoomTile_name': true,
|
"mx_RoomTile2_name": true,
|
||||||
'mx_RoomTile_invite': this.roomIsInvite,
|
"mx_RoomTile2_nameWithPreview": !!messagePreview,
|
||||||
'mx_RoomTile_badgeShown': hasBadge,
|
"mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
|
||||||
});
|
});
|
||||||
|
|
||||||
// TODO: Support collapsed state properly
|
const avatarSize = 32;
|
||||||
let tooltip = null;
|
|
||||||
if (false) { // isCollapsed
|
|
||||||
if (this.state.hover) {
|
|
||||||
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<RovingTabIndexWrapper inputRef={this.roomTile}>
|
<RovingTabIndexWrapper inputRef={this.roomTile}>
|
||||||
|
@ -254,20 +153,18 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||||
onClick={this.onTileClick}
|
onClick={this.onTileClick}
|
||||||
role="treeitem"
|
role="treeitem"
|
||||||
>
|
>
|
||||||
<div className={avatarClasses}>
|
<div className="mx_RoomTile2_avatarContainer">
|
||||||
<div className="mx_RoomTile_avatar_container">
|
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize}/>
|
||||||
<RoomAvatar room={this.props.room} width={24} height={24}/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mx_RoomTile2_nameContainer">
|
||||||
<div className="mx_RoomTile_nameContainer">
|
|
||||||
<div className="mx_RoomTile_labelContainer">
|
|
||||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||||
{name}
|
{name}
|
||||||
</div>
|
</div>
|
||||||
|
{messagePreview}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mx_RoomTile2_badgeContainer">
|
||||||
{badge}
|
{badge}
|
||||||
</div>
|
</div>
|
||||||
{tooltip}
|
|
||||||
</AccessibleButton>
|
</AccessibleButton>
|
||||||
}
|
}
|
||||||
</RovingTabIndexWrapper>
|
</RovingTabIndexWrapper>
|
||||||
|
|
|
@ -44,6 +44,7 @@ import {Key} from "../../../Keyboard";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
import RateLimitedFunc from '../../../ratelimitedfunc';
|
import RateLimitedFunc from '../../../ratelimitedfunc';
|
||||||
|
import {Action} from "../../../dispatcher/actions";
|
||||||
|
|
||||||
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
||||||
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
||||||
|
@ -364,7 +365,7 @@ export default class SendMessageComposer extends React.Component {
|
||||||
onAction = (payload) => {
|
onAction = (payload) => {
|
||||||
switch (payload.action) {
|
switch (payload.action) {
|
||||||
case 'reply_to_event':
|
case 'reply_to_event':
|
||||||
case 'focus_composer':
|
case Action.FocusComposer:
|
||||||
this._editorRef && this._editorRef.focus();
|
this._editorRef && this._editorRef.focus();
|
||||||
break;
|
break;
|
||||||
case 'insert_mention':
|
case 'insert_mention':
|
||||||
|
|
|
@ -62,7 +62,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
fontSize: SettingsStore.getValue("fontSize", null).toString(),
|
fontSize: (SettingsStore.getValue("baseFontSize", null) + FontWatcher.SIZE_DIFF).toString(),
|
||||||
...this.calculateThemeState(),
|
...this.calculateThemeState(),
|
||||||
customThemeUrl: "",
|
customThemeUrl: "",
|
||||||
customThemeMessage: {isError: false, text: ""},
|
customThemeMessage: {isError: false, text: ""},
|
||||||
|
@ -132,13 +132,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
|
|
||||||
private onFontSizeChanged = (size: number): void => {
|
private onFontSizeChanged = (size: number): void => {
|
||||||
this.setState({fontSize: size.toString()});
|
this.setState({fontSize: size.toString()});
|
||||||
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, size);
|
SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, size - FontWatcher.SIZE_DIFF);
|
||||||
};
|
};
|
||||||
|
|
||||||
private onValidateFontSize = async ({value}: Pick<IFieldState, "value">): Promise<IValidationResult> => {
|
private onValidateFontSize = async ({value}: Pick<IFieldState, "value">): Promise<IValidationResult> => {
|
||||||
const parsedSize = parseFloat(value);
|
const parsedSize = parseFloat(value);
|
||||||
const min = FontWatcher.MIN_SIZE;
|
const min = FontWatcher.MIN_SIZE + FontWatcher.SIZE_DIFF;
|
||||||
const max = FontWatcher.MAX_SIZE;
|
const max = FontWatcher.MAX_SIZE + FontWatcher.SIZE_DIFF;
|
||||||
|
|
||||||
if (isNaN(parsedSize)) {
|
if (isNaN(parsedSize)) {
|
||||||
return {valid: false, feedback: _t("Size must be a number")};
|
return {valid: false, feedback: _t("Size must be a number")};
|
||||||
|
@ -151,7 +151,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, value);
|
SettingsStore.setValue(
|
||||||
|
"baseFontSize",
|
||||||
|
null,
|
||||||
|
SettingLevel.DEVICE,
|
||||||
|
parseInt(value, 10) - FontWatcher.SIZE_DIFF
|
||||||
|
);
|
||||||
|
|
||||||
return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})};
|
return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,7 +281,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
values={[13, 15, 16, 18, 20]}
|
values={[13, 15, 16, 18, 20]}
|
||||||
value={parseInt(this.state.fontSize, 10)}
|
value={parseInt(this.state.fontSize, 10)}
|
||||||
onSelectionChange={this.onFontSizeChanged}
|
onSelectionChange={this.onFontSizeChanged}
|
||||||
displayFunc={value => ""}
|
displayFunc={_ => ""}
|
||||||
disabled={this.state.useCustomFontSize}
|
disabled={this.state.useCustomFontSize}
|
||||||
/>
|
/>
|
||||||
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
|
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
|
||||||
|
@ -284,9 +290,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
name="useCustomFontSize"
|
name="useCustomFontSize"
|
||||||
level={SettingLevel.ACCOUNT}
|
level={SettingLevel.ACCOUNT}
|
||||||
onChange={(checked) => this.setState({useCustomFontSize: checked})}
|
onChange={(checked) => this.setState({useCustomFontSize: checked})}
|
||||||
|
useCheckbox={true}
|
||||||
/>
|
/>
|
||||||
<Field
|
<Field
|
||||||
type="text"
|
type="number"
|
||||||
label={_t("Font size")}
|
label={_t("Font size")}
|
||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
placeholder={this.state.fontSize.toString()}
|
placeholder={this.state.fontSize.toString()}
|
||||||
|
@ -295,6 +302,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
||||||
onValidate={this.onValidateFontSize}
|
onValidate={this.onValidateFontSize}
|
||||||
onChange={(value) => this.setState({fontSize: value.target.value})}
|
onChange={(value) => this.setState({fontSize: value.target.value})}
|
||||||
disabled={!this.state.useCustomFontSize}
|
disabled={!this.state.useCustomFontSize}
|
||||||
|
className="mx_SettingsTab_customFontSizeField"
|
||||||
/>
|
/>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,9 +36,15 @@ export enum Action {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the user settings. No additional payload information required.
|
* Open the user settings. No additional payload information required.
|
||||||
|
* Optionally can include an OpenToTabPayload.
|
||||||
*/
|
*/
|
||||||
ViewUserSettings = "view_user_settings",
|
ViewUserSettings = "view_user_settings",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the room directory. No additional payload information required.
|
||||||
|
*/
|
||||||
|
ViewRoomDirectory = "view_room_directory",
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the current tooltip. Should be use with ViewTooltipPayload.
|
* Sets the current tooltip. Should be use with ViewTooltipPayload.
|
||||||
*/
|
*/
|
||||||
|
@ -53,4 +59,14 @@ export enum Action {
|
||||||
* Provide status information for an ongoing update check. Should be used with a CheckUpdatesPayload.
|
* Provide status information for an ongoing update check. Should be used with a CheckUpdatesPayload.
|
||||||
*/
|
*/
|
||||||
CheckUpdates = "check_updates",
|
CheckUpdates = "check_updates",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focuses the user's cursor to the composer. No additional payload information required.
|
||||||
|
*/
|
||||||
|
FocusComposer = "focus_composer",
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the user menu (previously known as the top left menu). No additional payload information required.
|
||||||
|
*/
|
||||||
|
ToggleUserMenu = "toggle_user_menu",
|
||||||
}
|
}
|
||||||
|
|
27
src/dispatcher/payloads/OpenToTabPayload.ts
Normal file
27
src/dispatcher/payloads/OpenToTabPayload.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
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 { ActionPayload } from "../payloads";
|
||||||
|
import { Action } from "../actions";
|
||||||
|
|
||||||
|
export interface OpenToTabPayload extends ActionPayload {
|
||||||
|
action: Action.ViewUserSettings | string, // TODO: Add room settings action
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The tab ID to open in the settings view to start, if possible.
|
||||||
|
*/
|
||||||
|
initialTabId?: string;
|
||||||
|
}
|
|
@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// @ts-ignore - import * as EMOJIBASE actually breaks this
|
|
||||||
import EMOJIBASE from 'emojibase-data/en/compact.json';
|
import EMOJIBASE from 'emojibase-data/en/compact.json';
|
||||||
|
|
||||||
export interface IEmoji {
|
export interface IEmoji {
|
||||||
|
@ -63,6 +62,8 @@ export const DATA_BY_CATEGORY = {
|
||||||
"flags": [],
|
"flags": [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ZERO_WIDTH_JOINER = "\u200D";
|
||||||
|
|
||||||
// Store various mappings from unicode/emoticon/shortcode to the Emoji objects
|
// Store various mappings from unicode/emoticon/shortcode to the Emoji objects
|
||||||
EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
|
EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
|
||||||
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
|
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
|
||||||
|
@ -70,7 +71,8 @@ EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
|
||||||
DATA_BY_CATEGORY[categoryId].push(emoji);
|
DATA_BY_CATEGORY[categoryId].push(emoji);
|
||||||
}
|
}
|
||||||
// This is used as the string to match the query against when filtering emojis
|
// This is used as the string to match the query against when filtering emojis
|
||||||
emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}`.toLowerCase();
|
emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}\n` +
|
||||||
|
`${emoji.unicode.split(ZERO_WIDTH_JOINER).join("\n")}`.toLowerCase();
|
||||||
|
|
||||||
// Add mapping from unicode to Emoji object
|
// Add mapping from unicode to Emoji object
|
||||||
// The 'unicode' field that we use in emojibase has either
|
// The 'unicode' field that we use in emojibase has either
|
||||||
|
|
|
@ -1090,6 +1090,7 @@
|
||||||
"Low priority": "Low priority",
|
"Low priority": "Low priority",
|
||||||
"Historical": "Historical",
|
"Historical": "Historical",
|
||||||
"System Alerts": "System Alerts",
|
"System Alerts": "System Alerts",
|
||||||
|
"People": "People",
|
||||||
"This room": "This room",
|
"This room": "This room",
|
||||||
"Joining room …": "Joining room …",
|
"Joining room …": "Joining room …",
|
||||||
"Loading …": "Loading …",
|
"Loading …": "Loading …",
|
||||||
|
@ -1133,9 +1134,7 @@
|
||||||
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
|
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
|
||||||
"Not now": "Not now",
|
"Not now": "Not now",
|
||||||
"Don't ask me again": "Don't ask me again",
|
"Don't ask me again": "Don't ask me again",
|
||||||
"Jump to first unread room.": "Jump to first unread room.",
|
"Show %(n)s more": "Show %(n)s more",
|
||||||
"Jump to first invite.": "Jump to first invite.",
|
|
||||||
"Add room": "Add room",
|
|
||||||
"Options": "Options",
|
"Options": "Options",
|
||||||
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
||||||
"%(count)s unread messages including mentions.|one": "1 unread mention.",
|
"%(count)s unread messages including mentions.|one": "1 unread mention.",
|
||||||
|
@ -1959,6 +1958,7 @@
|
||||||
"Explore": "Explore",
|
"Explore": "Explore",
|
||||||
"Filter": "Filter",
|
"Filter": "Filter",
|
||||||
"Filter rooms…": "Filter rooms…",
|
"Filter rooms…": "Filter rooms…",
|
||||||
|
"Explore rooms": "Explore rooms",
|
||||||
"Failed to reject invitation": "Failed to reject invitation",
|
"Failed to reject invitation": "Failed to reject invitation",
|
||||||
"This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.",
|
"This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.",
|
||||||
"Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
|
"Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
|
||||||
|
@ -2004,7 +2004,6 @@
|
||||||
"Find a room…": "Find a room…",
|
"Find a room…": "Find a room…",
|
||||||
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
|
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
|
||||||
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",
|
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",
|
||||||
"Explore rooms": "Explore rooms",
|
|
||||||
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
|
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
|
||||||
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
|
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
|
||||||
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
|
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
|
||||||
|
@ -2016,6 +2015,9 @@
|
||||||
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
|
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
|
||||||
"Active call": "Active call",
|
"Active call": "Active call",
|
||||||
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
|
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
|
||||||
|
"Jump to first unread room.": "Jump to first unread room.",
|
||||||
|
"Jump to first invite.": "Jump to first invite.",
|
||||||
|
"Add room": "Add room",
|
||||||
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
|
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
|
||||||
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
|
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
|
||||||
"Search failed": "Search failed",
|
"Search failed": "Search failed",
|
||||||
|
@ -2040,6 +2042,14 @@
|
||||||
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
|
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
|
||||||
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
|
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
|
||||||
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
|
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
|
||||||
|
"Switch to light mode": "Switch to light mode",
|
||||||
|
"Switch to dark mode": "Switch to dark mode",
|
||||||
|
"Switch theme": "Switch theme",
|
||||||
|
"Security & privacy": "Security & privacy",
|
||||||
|
"All settings": "All settings",
|
||||||
|
"Archived rooms": "Archived rooms",
|
||||||
|
"Feedback": "Feedback",
|
||||||
|
"Account settings": "Account settings",
|
||||||
"Could not load user profile": "Could not load user profile",
|
"Could not load user profile": "Could not load user profile",
|
||||||
"Verify this login": "Verify this login",
|
"Verify this login": "Verify this login",
|
||||||
"Session verified": "Session verified",
|
"Session verified": "Session verified",
|
||||||
|
|
|
@ -290,6 +290,33 @@ export default class EventIndex extends EventEmitter {
|
||||||
return validEventType && validMsgType && hasContentValue;
|
return validEventType && validMsgType && hasContentValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
eventToJson(ev) {
|
||||||
|
const jsonEvent = ev.toJSON();
|
||||||
|
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
||||||
|
|
||||||
|
if (ev.isEncrypted()) {
|
||||||
|
// Let us store some additional data so we can re-verify the event.
|
||||||
|
// The js-sdk checks if an event is encrypted using the algorithm,
|
||||||
|
// the sender key and ed25519 signing key are used to find the
|
||||||
|
// correct device that sent the event which allows us to check the
|
||||||
|
// verification state of the event, either directly or using cross
|
||||||
|
// signing.
|
||||||
|
e.curve25519Key = ev.getSenderKey();
|
||||||
|
e.ed25519Key = ev.getClaimedEd25519Key();
|
||||||
|
e.algorithm = ev.getWireContent().algorithm;
|
||||||
|
e.forwardingCurve25519KeyChain = ev.getForwardingCurve25519KeyChain();
|
||||||
|
} else {
|
||||||
|
// Make sure that unencrypted events don't contain any of that data,
|
||||||
|
// despite what the server might give to us.
|
||||||
|
delete e.curve25519Key;
|
||||||
|
delete e.ed25519Key;
|
||||||
|
delete e.algorithm;
|
||||||
|
delete e.forwardingCurve25519KeyChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Queue up live events to be added to the event index.
|
* Queue up live events to be added to the event index.
|
||||||
*
|
*
|
||||||
|
@ -300,8 +327,7 @@ export default class EventIndex extends EventEmitter {
|
||||||
|
|
||||||
if (!this.isValidEvent(ev)) return;
|
if (!this.isValidEvent(ev)) return;
|
||||||
|
|
||||||
const jsonEvent = ev.toJSON();
|
const e = this.eventToJson(ev);
|
||||||
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
|
||||||
|
|
||||||
const profile = {
|
const profile = {
|
||||||
displayname: ev.sender.rawDisplayName,
|
displayname: ev.sender.rawDisplayName,
|
||||||
|
@ -477,8 +503,7 @@ export default class EventIndex extends EventEmitter {
|
||||||
// Let us convert the events back into a format that EventIndex can
|
// Let us convert the events back into a format that EventIndex can
|
||||||
// consume.
|
// consume.
|
||||||
const events = filteredEvents.map((ev) => {
|
const events = filteredEvents.map((ev) => {
|
||||||
const jsonEvent = ev.toJSON();
|
const e = this.eventToJson(ev);
|
||||||
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
|
||||||
|
|
||||||
let profile = {};
|
let profile = {};
|
||||||
if (e.sender in profiles) profile = profiles[e.sender];
|
if (e.sender in profiles) profile = profiles[e.sender];
|
||||||
|
|
|
@ -145,6 +145,10 @@ export default async function sendBugReport(bugReportEndpoint: string, opts: IOp
|
||||||
if (enabledLabs.length) {
|
if (enabledLabs.length) {
|
||||||
body.append('enabled_labs', enabledLabs.join(', '));
|
body.append('enabled_labs', enabledLabs.join(', '));
|
||||||
}
|
}
|
||||||
|
// if low bandwidth mode is enabled, say so over rageshake, it causes many issues
|
||||||
|
if (SettingsStore.getValue("lowBandwidth")) {
|
||||||
|
body.append("lowBandwidth", "enabled");
|
||||||
|
}
|
||||||
|
|
||||||
// add storage persistence/quota information
|
// add storage persistence/quota information
|
||||||
if (navigator.storage && navigator.storage.persisted) {
|
if (navigator.storage && navigator.storage.persisted) {
|
||||||
|
|
|
@ -170,10 +170,10 @@ export const SETTINGS = {
|
||||||
displayName: _td("Show info about bridges in room settings"),
|
displayName: _td("Show info about bridges in room settings"),
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
"fontSize": {
|
"baseFontSize": {
|
||||||
displayName: _td("Font size"),
|
displayName: _td("Font size"),
|
||||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||||
default: 15,
|
default: 10,
|
||||||
controller: new FontSizeController(),
|
controller: new FontSizeController(),
|
||||||
},
|
},
|
||||||
"useCustomFontSize": {
|
"useCustomFontSize": {
|
||||||
|
|
|
@ -20,8 +20,10 @@ import IWatcher from "./Watcher";
|
||||||
import { toPx } from '../../utils/units';
|
import { toPx } from '../../utils/units';
|
||||||
|
|
||||||
export class FontWatcher implements IWatcher {
|
export class FontWatcher implements IWatcher {
|
||||||
public static readonly MIN_SIZE = 13;
|
public static readonly MIN_SIZE = 8;
|
||||||
public static readonly MAX_SIZE = 20;
|
public static readonly MAX_SIZE = 15;
|
||||||
|
// Externally we tell the user the font is size 15. Internally we use 10.
|
||||||
|
public static readonly SIZE_DIFF = 5;
|
||||||
|
|
||||||
private dispatcherRef: string;
|
private dispatcherRef: string;
|
||||||
|
|
||||||
|
@ -30,7 +32,7 @@ export class FontWatcher implements IWatcher {
|
||||||
}
|
}
|
||||||
|
|
||||||
public start() {
|
public start() {
|
||||||
this.setRootFontSize(SettingsStore.getValue("fontSize"));
|
this.setRootFontSize(SettingsStore.getValue("baseFontSize"));
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -48,7 +50,7 @@ export class FontWatcher implements IWatcher {
|
||||||
const fontSize = Math.max(Math.min(FontWatcher.MAX_SIZE, size), FontWatcher.MIN_SIZE);
|
const fontSize = Math.max(Math.min(FontWatcher.MAX_SIZE, size), FontWatcher.MIN_SIZE);
|
||||||
|
|
||||||
if (fontSize !== size) {
|
if (fontSize !== size) {
|
||||||
SettingsStore.setValue("fontSize", null, SettingLevel.Device, fontSize);
|
SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, fontSize);
|
||||||
}
|
}
|
||||||
(<HTMLElement>document.querySelector(":root")).style.fontSize = toPx(fontSize);
|
(<HTMLElement>document.querySelector(":root")).style.fontSize = toPx(fontSize);
|
||||||
};
|
};
|
||||||
|
|
71
src/stores/room-list/ListLayout.ts
Normal file
71
src/stores/room-list/ListLayout.ts
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
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 { TagID } from "./models";
|
||||||
|
|
||||||
|
const TILE_HEIGHT_PX = 44;
|
||||||
|
|
||||||
|
interface ISerializedListLayout {
|
||||||
|
numTiles: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ListLayout {
|
||||||
|
private _n = 0;
|
||||||
|
|
||||||
|
constructor(public readonly tagId: TagID) {
|
||||||
|
const serialized = localStorage.getItem(this.key);
|
||||||
|
if (serialized) {
|
||||||
|
// We don't use the setters as they cause writes.
|
||||||
|
const parsed = <ISerializedListLayout>JSON.parse(serialized);
|
||||||
|
this._n = parsed.numTiles;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public get tileHeight(): number {
|
||||||
|
return TILE_HEIGHT_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
private get key(): string {
|
||||||
|
return `mx_sublist_layout_${this.tagId}_boxed`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public get visibleTiles(): number {
|
||||||
|
return Math.max(this._n, this.minVisibleTiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public set visibleTiles(v: number) {
|
||||||
|
this._n = v;
|
||||||
|
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public get minVisibleTiles(): number {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
public tilesToPixels(n: number): number {
|
||||||
|
return n * this.tileHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public pixelsToTiles(px: number): number {
|
||||||
|
return px / this.tileHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serialize(): ISerializedListLayout {
|
||||||
|
return {
|
||||||
|
numTiles: this.visibleTiles,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -74,29 +74,29 @@ gets applied to each category in a sub-sub-list fashion. This should result in t
|
||||||
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
|
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
|
||||||
collectively the tag will be sorted into categories with red being at the top.
|
collectively the tag will be sorted into categories with red being at the top.
|
||||||
|
|
||||||
<!-- TODO: Implement sticky rooms as described below -->
|
### Sticky rooms
|
||||||
|
|
||||||
The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing.
|
When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
|
||||||
The sticky room will remain in position on the room list regardless of other factors going on as typically
|
From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
|
||||||
clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms
|
manages which room is sticky. This is to ensure that all algorithms handle it the same.
|
||||||
above the selected room at all times, where N is the number of rooms above the selected rooms when it was
|
|
||||||
selected.
|
|
||||||
|
|
||||||
For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one
|
The sticky flag is simply to say it will not move higher or lower down the list while it is active. For
|
||||||
room above their selection at all times. If they receive another notification, and the tag ordering is
|
example, if using the importance algorithm, the room would naturally become idle once viewed and thus
|
||||||
specified as Recent, they'll see the new notification go to the top position, and the one that was previously
|
would normally fly down the list out of sight. The sticky room concept instead holds it in place, never
|
||||||
there fall behind the sticky room.
|
letting it fly down until the user moves to another room.
|
||||||
|
|
||||||
The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the
|
Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky
|
||||||
tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another
|
room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and
|
||||||
room, the previous sticky room gets recalculated to determine which category it needs to be in as the user
|
selects the middle room, they will see exactly one room above their selection at all times. If they
|
||||||
could have been scrolled up while new messages were received.
|
receive another notification which causes the room to move into the topmost position, the room that was
|
||||||
|
above the sticky room will move underneath to allow for the new room to take the top slot, maintaining
|
||||||
|
the sticky room's position.
|
||||||
|
|
||||||
Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what
|
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
|
||||||
kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user
|
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
|
||||||
selecting the third room (leaving 2 above it), and then having the rooms above it read on another device.
|
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
|
||||||
This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain
|
the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
|
||||||
2 rooms above the sticky room.
|
above the sticky room as it will try to maintain 2 rooms above the sticky room.
|
||||||
|
|
||||||
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
|
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
|
||||||
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
|
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain
|
||||||
|
|
|
@ -29,6 +29,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||||
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
|
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
|
||||||
import { IFilterCondition } from "./filters/IFilterCondition";
|
import { IFilterCondition } from "./filters/IFilterCondition";
|
||||||
import { TagWatcher } from "./TagWatcher";
|
import { TagWatcher } from "./TagWatcher";
|
||||||
|
import RoomViewStore from "../RoomViewStore";
|
||||||
|
|
||||||
interface IState {
|
interface IState {
|
||||||
tagsEnabled?: boolean;
|
tagsEnabled?: boolean;
|
||||||
|
@ -62,6 +63,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
|
||||||
|
|
||||||
this.checkEnabled();
|
this.checkEnabled();
|
||||||
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
|
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
|
||||||
|
RoomViewStore.addListener(this.onRVSUpdate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public get orderedLists(): ITagMap {
|
public get orderedLists(): ITagMap {
|
||||||
|
@ -93,6 +95,23 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
|
||||||
this.setAlgorithmClass();
|
this.setAlgorithmClass();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onRVSUpdate = () => {
|
||||||
|
if (!this.enabled) return; // TODO: Remove enabled flag when RoomListStore2 takes over
|
||||||
|
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
|
||||||
|
|
||||||
|
const activeRoomId = RoomViewStore.getRoomId();
|
||||||
|
if (!activeRoomId && this.algorithm.stickyRoom) {
|
||||||
|
this.algorithm.stickyRoom = null;
|
||||||
|
} else if (activeRoomId) {
|
||||||
|
const activeRoom = this.matrixClient.getRoom(activeRoomId);
|
||||||
|
if (!activeRoom) throw new Error(`${activeRoomId} is current in RVS but missing from client`);
|
||||||
|
if (activeRoom !== this.algorithm.stickyRoom) {
|
||||||
|
console.log(`Changing sticky room to ${activeRoomId}`);
|
||||||
|
this.algorithm.stickyRoom = activeRoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
protected async onDispatch(payload: ActionPayload) {
|
protected async onDispatch(payload: ActionPayload) {
|
||||||
if (payload.action === 'MatrixActions.sync') {
|
if (payload.action === 'MatrixActions.sync') {
|
||||||
// Filter out anything that isn't the first PREPARED sync.
|
// Filter out anything that isn't the first PREPARED sync.
|
||||||
|
@ -110,6 +129,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
|
||||||
console.log("Regenerating room lists: Startup");
|
console.log("Regenerating room lists: Startup");
|
||||||
await this.readAndCacheSettingsFromStore();
|
await this.readAndCacheSettingsFromStore();
|
||||||
await this.regenerateAllLists();
|
await this.regenerateAllLists();
|
||||||
|
this.onRVSUpdate(); // fake an RVS update to adjust sticky room, if needed
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Remove this once the RoomListStore becomes default
|
// TODO: Remove this once the RoomListStore becomes default
|
||||||
|
@ -145,13 +165,19 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
|
||||||
// First see if the receipt event is for our own user. If it was, trigger
|
// First see if the receipt event is for our own user. If it was, trigger
|
||||||
// a room update (we probably read the room on a different device).
|
// a room update (we probably read the room on a different device).
|
||||||
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
|
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
|
||||||
// TODO: Update room now that it's been read
|
console.log(`[RoomListDebug] Got own read receipt in ${payload.event.roomId}`);
|
||||||
console.log(payload);
|
const room = this.matrixClient.getRoom(payload.event.roomId);
|
||||||
|
if (!room) {
|
||||||
|
console.warn(`Own read receipt was in unknown room ${payload.event.roomId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else if (payload.action === 'MatrixActions.Room.tags') {
|
} else if (payload.action === 'MatrixActions.Room.tags') {
|
||||||
// TODO: Update room from tags
|
const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||||
console.log(payload);
|
console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
|
||||||
|
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
|
||||||
} else if (payload.action === 'MatrixActions.Room.timeline') {
|
} else if (payload.action === 'MatrixActions.Room.timeline') {
|
||||||
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||||
|
|
||||||
|
@ -189,26 +215,39 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
|
||||||
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
|
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
|
||||||
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
||||||
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
|
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
|
||||||
// TODO: Update DMs
|
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||||
console.log(payload);
|
console.log(`[RoomListDebug] Received updated DM map`);
|
||||||
} else if (payload.action === 'MatrixActions.Room.myMembership') {
|
const dmMap = eventPayload.event.getContent();
|
||||||
// TODO: Improve new room check
|
for (const userId of Object.keys(dmMap)) {
|
||||||
const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
|
const roomIds = dmMap[userId];
|
||||||
if (!membershipPayload.oldMembership && membershipPayload.membership === "join") {
|
for (const roomId of roomIds) {
|
||||||
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
|
const room = this.matrixClient.getRoom(roomId);
|
||||||
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
|
if (!room) {
|
||||||
|
console.warn(`${roomId} was found in DMs but the room is not in the store`);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Update room from membership change
|
// We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
|
||||||
console.log(payload);
|
// the user to have hundreds of rooms to update in one event. As such, we just hammer
|
||||||
} else if (payload.action === 'MatrixActions.Room') {
|
// away at updates until the problem is solved. If we were expecting more than a couple
|
||||||
// TODO: Improve new room check
|
// of rooms to be updated at once, we would consider batching the rooms up.
|
||||||
// const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
|
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
|
||||||
// console.log(`[RoomListDebug] Handling new room ${roomPayload.room.roomId}`);
|
}
|
||||||
// await this.algorithm.handleRoomUpdate(roomPayload.room, RoomUpdateCause.NewRoom);
|
}
|
||||||
} else if (payload.action === 'view_room') {
|
} else if (payload.action === 'MatrixActions.Room.myMembership') {
|
||||||
// TODO: Update sticky room
|
const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||||
console.log(payload);
|
if (membershipPayload.oldMembership !== "join" && membershipPayload.membership === "join") {
|
||||||
|
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
|
||||||
|
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's not a join, it's transitioning into a different list (possibly historical)
|
||||||
|
if (membershipPayload.oldMembership !== membershipPayload.membership) {
|
||||||
|
console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`);
|
||||||
|
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,8 +20,11 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
|
||||||
import { EffectiveMembership, splitRoomsByMembership } from "../../membership";
|
import { EffectiveMembership, splitRoomsByMembership } from "../../membership";
|
||||||
import { ITagMap, ITagSortingMap } from "../models";
|
import { ITagMap, ITagSortingMap } from "../models";
|
||||||
import DMRoomMap from "../../../../utils/DMRoomMap";
|
import DMRoomMap from "../../../../utils/DMRoomMap";
|
||||||
import { FILTER_CHANGED, IFilterCondition } from "../../filters/IFilterCondition";
|
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../../filters/IFilterCondition";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
|
import { UPDATE_EVENT } from "../../../AsyncStore";
|
||||||
|
import { ArrayUtil } from "../../../../utils/arrays";
|
||||||
|
import { getEnumValues } from "../../../../utils/enums";
|
||||||
|
|
||||||
// TODO: Add locking support to avoid concurrent writes?
|
// TODO: Add locking support to avoid concurrent writes?
|
||||||
|
|
||||||
|
@ -30,6 +33,12 @@ import { EventEmitter } from "events";
|
||||||
*/
|
*/
|
||||||
export const LIST_UPDATED_EVENT = "list_updated_event";
|
export const LIST_UPDATED_EVENT = "list_updated_event";
|
||||||
|
|
||||||
|
interface IStickyRoom {
|
||||||
|
room: Room;
|
||||||
|
position: number;
|
||||||
|
tag: TagID;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a list ordering algorithm. This class will take care of tag
|
* Represents a list ordering algorithm. This class will take care of tag
|
||||||
* management (which rooms go in which tags) and ask the implementation to
|
* management (which rooms go in which tags) and ask the implementation to
|
||||||
|
@ -37,7 +46,9 @@ export const LIST_UPDATED_EVENT = "list_updated_event";
|
||||||
*/
|
*/
|
||||||
export abstract class Algorithm extends EventEmitter {
|
export abstract class Algorithm extends EventEmitter {
|
||||||
private _cachedRooms: ITagMap = {};
|
private _cachedRooms: ITagMap = {};
|
||||||
|
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
|
||||||
private filteredRooms: ITagMap = {};
|
private filteredRooms: ITagMap = {};
|
||||||
|
private _stickyRoom: IStickyRoom = null;
|
||||||
|
|
||||||
protected sortAlgorithms: ITagSortingMap;
|
protected sortAlgorithms: ITagSortingMap;
|
||||||
protected rooms: Room[] = [];
|
protected rooms: Room[] = [];
|
||||||
|
@ -51,6 +62,15 @@ export abstract class Algorithm extends EventEmitter {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get stickyRoom(): Room {
|
||||||
|
return this._stickyRoom ? this._stickyRoom.room : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public set stickyRoom(val: Room) {
|
||||||
|
// setters can't be async, so we call a private function to do the work
|
||||||
|
this.updateStickyRoom(val);
|
||||||
|
}
|
||||||
|
|
||||||
protected get hasFilters(): boolean {
|
protected get hasFilters(): boolean {
|
||||||
return this.allowedByFilter.size > 0;
|
return this.allowedByFilter.size > 0;
|
||||||
}
|
}
|
||||||
|
@ -58,9 +78,14 @@ export abstract class Algorithm extends EventEmitter {
|
||||||
protected set cachedRooms(val: ITagMap) {
|
protected set cachedRooms(val: ITagMap) {
|
||||||
this._cachedRooms = val;
|
this._cachedRooms = val;
|
||||||
this.recalculateFilteredRooms();
|
this.recalculateFilteredRooms();
|
||||||
|
this.recalculateStickyRoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get cachedRooms(): ITagMap {
|
protected get cachedRooms(): ITagMap {
|
||||||
|
// 🐉 Here be dragons.
|
||||||
|
// Note: this is used by the underlying algorithm classes, so don't make it return
|
||||||
|
// the sticky room cache. If it ends up returning the sticky room cache, we end up
|
||||||
|
// corrupting our caches and confusing them.
|
||||||
return this._cachedRooms;
|
return this._cachedRooms;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -94,28 +119,100 @@ export abstract class Algorithm extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateStickyRoom(val: Room) {
|
||||||
|
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
|
||||||
|
// otherwise we risk duplicating rooms.
|
||||||
|
|
||||||
|
// It's possible to have no selected room. In that case, clear the sticky room
|
||||||
|
if (!val) {
|
||||||
|
if (this._stickyRoom) {
|
||||||
|
// Lie to the algorithm and re-add the room to the algorithm
|
||||||
|
await this.handleRoomUpdate(this._stickyRoom.room, RoomUpdateCause.NewRoom);
|
||||||
|
}
|
||||||
|
this._stickyRoom = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When we do have a room though, we expect to be able to find it
|
||||||
|
const tag = this.roomIdsToTags[val.roomId][0];
|
||||||
|
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
|
||||||
|
let position = this.cachedRooms[tag].indexOf(val);
|
||||||
|
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
|
||||||
|
|
||||||
|
// 🐉 Here be dragons.
|
||||||
|
// Before we can go through with lying to the underlying algorithm about a room
|
||||||
|
// we need to ensure that when we do we're ready for the innevitable sticky room
|
||||||
|
// update we'll receive. To prepare for that, we first remove the sticky room and
|
||||||
|
// recalculate the state ourselves so that when the underlying algorithm calls for
|
||||||
|
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
|
||||||
|
// a new update for ourselves.
|
||||||
|
const lastStickyRoom = this._stickyRoom;
|
||||||
|
console.log(`Last sticky room:`, lastStickyRoom);
|
||||||
|
this._stickyRoom = null;
|
||||||
|
this.recalculateStickyRoom();
|
||||||
|
|
||||||
|
// When we do have the room, re-add the old room (if needed) to the algorithm
|
||||||
|
// and remove the sticky room from the algorithm. This is so the underlying
|
||||||
|
// algorithm doesn't try and confuse itself with the sticky room concept.
|
||||||
|
if (lastStickyRoom) {
|
||||||
|
// Lie to the algorithm and re-add the room to the algorithm
|
||||||
|
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
|
||||||
|
}
|
||||||
|
// Lie to the algorithm and remove the room from it's field of view
|
||||||
|
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
|
||||||
|
|
||||||
|
// Now that we're done lying to the algorithm, we need to update our position
|
||||||
|
// marker only if the user is moving further down the same list. If they're switching
|
||||||
|
// lists, or moving upwards, the position marker will splice in just fine but if
|
||||||
|
// they went downwards in the same list we'll be off by 1 due to the shifting rooms.
|
||||||
|
if (lastStickyRoom && lastStickyRoom.tag === tag && lastStickyRoom.position <= position) {
|
||||||
|
position++;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._stickyRoom = {
|
||||||
|
room: val,
|
||||||
|
position: position,
|
||||||
|
tag: tag,
|
||||||
|
};
|
||||||
|
this.recalculateStickyRoom();
|
||||||
|
|
||||||
|
// Finally, trigger an update
|
||||||
|
this.emit(LIST_UPDATED_EVENT);
|
||||||
|
}
|
||||||
|
|
||||||
protected recalculateFilteredRooms() {
|
protected recalculateFilteredRooms() {
|
||||||
if (!this.hasFilters) {
|
if (!this.hasFilters) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn("Recalculating filtered room list");
|
console.warn("Recalculating filtered room list");
|
||||||
const allowedByFilters = new Set<Room>();
|
|
||||||
const filters = Array.from(this.allowedByFilter.keys());
|
const filters = Array.from(this.allowedByFilter.keys());
|
||||||
|
const orderedFilters = new ArrayUtil(filters)
|
||||||
|
.groupBy(f => f.relativePriority)
|
||||||
|
.orderBy(getEnumValues(FilterPriority))
|
||||||
|
.value;
|
||||||
const newMap: ITagMap = {};
|
const newMap: ITagMap = {};
|
||||||
for (const tagId of Object.keys(this.cachedRooms)) {
|
for (const tagId of Object.keys(this.cachedRooms)) {
|
||||||
// Cheaply clone the rooms so we can more easily do operations on the list.
|
// Cheaply clone the rooms so we can more easily do operations on the list.
|
||||||
// We optimize our lookups by trying to reduce sample size as much as possible
|
// We optimize our lookups by trying to reduce sample size as much as possible
|
||||||
// to the rooms we know will be deduped by the Set.
|
// to the rooms we know will be deduped by the Set.
|
||||||
const rooms = this.cachedRooms[tagId];
|
const rooms = this.cachedRooms[tagId];
|
||||||
const remainingRooms = rooms.map(r => r).filter(r => !allowedByFilters.has(r));
|
let remainingRooms = rooms.map(r => r);
|
||||||
const allowedRoomsInThisTag = [];
|
let allowedRoomsInThisTag = [];
|
||||||
for (const filter of filters) {
|
let lastFilterPriority = orderedFilters[0].relativePriority;
|
||||||
|
for (const filter of orderedFilters) {
|
||||||
|
if (filter.relativePriority !== lastFilterPriority) {
|
||||||
|
// Every time the filter changes priority, we want more specific filtering.
|
||||||
|
// To accomplish that, reset the variables to make it look like the process
|
||||||
|
// has started over, but using the filtered rooms as the seed.
|
||||||
|
remainingRooms = allowedRoomsInThisTag;
|
||||||
|
allowedRoomsInThisTag = [];
|
||||||
|
lastFilterPriority = filter.relativePriority;
|
||||||
|
}
|
||||||
const filteredRooms = remainingRooms.filter(r => filter.isVisible(r));
|
const filteredRooms = remainingRooms.filter(r => filter.isVisible(r));
|
||||||
for (const room of filteredRooms) {
|
for (const room of filteredRooms) {
|
||||||
const idx = remainingRooms.indexOf(room);
|
const idx = remainingRooms.indexOf(room);
|
||||||
if (idx >= 0) remainingRooms.splice(idx, 1);
|
if (idx >= 0) remainingRooms.splice(idx, 1);
|
||||||
allowedByFilters.add(room);
|
|
||||||
allowedRoomsInThisTag.push(room);
|
allowedRoomsInThisTag.push(room);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -123,7 +220,8 @@ export abstract class Algorithm extends EventEmitter {
|
||||||
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
|
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.allowedRoomsByFilters = allowedByFilters;
|
const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
|
||||||
|
this.allowedRoomsByFilters = new Set(allowedRooms);
|
||||||
this.filteredRooms = newMap;
|
this.filteredRooms = newMap;
|
||||||
this.emit(LIST_UPDATED_EVENT);
|
this.emit(LIST_UPDATED_EVENT);
|
||||||
}
|
}
|
||||||
|
@ -154,6 +252,59 @@ export abstract class Algorithm extends EventEmitter {
|
||||||
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
|
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recalculate the sticky room position. If this is being called in relation to
|
||||||
|
* a specific tag being updated, it should be given to this function to optimize
|
||||||
|
* the call.
|
||||||
|
* @param updatedTag The tag that was updated, if possible.
|
||||||
|
*/
|
||||||
|
protected recalculateStickyRoom(updatedTag: TagID = null): void {
|
||||||
|
// 🐉 Here be dragons.
|
||||||
|
// This function does far too much for what it should, and is called by many places.
|
||||||
|
// Not only is this responsible for ensuring the sticky room is held in place at all
|
||||||
|
// times, it is also responsible for ensuring our clone of the cachedRooms is up to
|
||||||
|
// date. If either of these desyncs, we see weird behaviour like duplicated rooms,
|
||||||
|
// outdated lists, and other nonsensical issues that aren't necessarily obvious.
|
||||||
|
|
||||||
|
if (!this._stickyRoom) {
|
||||||
|
// If there's no sticky room, just do nothing useful.
|
||||||
|
if (!!this._cachedStickyRooms) {
|
||||||
|
// Clear the cache if we won't be needing it
|
||||||
|
this._cachedStickyRooms = null;
|
||||||
|
this.emit(LIST_UPDATED_EVENT);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this._cachedStickyRooms || !updatedTag) {
|
||||||
|
console.log(`Generating clone of cached rooms for sticky room handling`);
|
||||||
|
const stickiedTagMap: ITagMap = {};
|
||||||
|
for (const tagId of Object.keys(this.cachedRooms)) {
|
||||||
|
stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone
|
||||||
|
}
|
||||||
|
this._cachedStickyRooms = stickiedTagMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedTag) {
|
||||||
|
// Update the tag indicated by the caller, if possible. This is mostly to ensure
|
||||||
|
// our cache is up to date.
|
||||||
|
console.log(`Replacing cached sticky rooms for ${updatedTag}`);
|
||||||
|
this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now try to insert the sticky room, if we need to.
|
||||||
|
// We need to if there's no updated tag (we regenned the whole cache) or if the tag
|
||||||
|
// we might have updated from the cache is also our sticky room.
|
||||||
|
const sticky = this._stickyRoom;
|
||||||
|
if (!updatedTag || updatedTag === sticky.tag) {
|
||||||
|
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
|
||||||
|
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, trigger an update
|
||||||
|
this.emit(LIST_UPDATED_EVENT);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Asks the Algorithm to regenerate all lists, using the tags given
|
* Asks the Algorithm to regenerate all lists, using the tags given
|
||||||
* as reference for which lists to generate and which way to generate
|
* as reference for which lists to generate and which way to generate
|
||||||
|
@ -174,7 +325,7 @@ export abstract class Algorithm extends EventEmitter {
|
||||||
*/
|
*/
|
||||||
public getOrderedRooms(): ITagMap {
|
public getOrderedRooms(): ITagMap {
|
||||||
if (!this.hasFilters) {
|
if (!this.hasFilters) {
|
||||||
return this.cachedRooms;
|
return this._cachedStickyRooms || this.cachedRooms;
|
||||||
}
|
}
|
||||||
return this.filteredRooms;
|
return this.filteredRooms;
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
||||||
|
|
||||||
import { Algorithm } from "./Algorithm";
|
import { Algorithm } from "./Algorithm";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { DefaultTagID, RoomUpdateCause, TagID } from "../../models";
|
import { RoomUpdateCause, TagID } from "../../models";
|
||||||
import { ITagMap, SortAlgorithm } from "../models";
|
import { ITagMap, SortAlgorithm } from "../models";
|
||||||
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
||||||
import * as Unread from '../../../../Unread';
|
import * as Unread from '../../../../Unread';
|
||||||
|
@ -82,15 +82,14 @@ export class ImportanceAlgorithm extends Algorithm {
|
||||||
// HOW THIS WORKS
|
// HOW THIS WORKS
|
||||||
// --------------
|
// --------------
|
||||||
//
|
//
|
||||||
// This block of comments assumes you've read the README one level higher.
|
// This block of comments assumes you've read the README two levels higher.
|
||||||
// You should do that if you haven't already.
|
// You should do that if you haven't already.
|
||||||
//
|
//
|
||||||
// Tags are fed into the algorithmic functions from the Algorithm superclass,
|
// Tags are fed into the algorithmic functions from the Algorithm superclass,
|
||||||
// which cause subsequent updates to the room list itself. Categories within
|
// which cause subsequent updates to the room list itself. Categories within
|
||||||
// those tags are tracked as index numbers within the array (zero = top), with
|
// those tags are tracked as index numbers within the array (zero = top), with
|
||||||
// each sticky room being tracked separately. Internally, the category index
|
// each sticky room being tracked separately. Internally, the category index
|
||||||
// can be found from `this.indices[tag][category]` and the sticky room information
|
// can be found from `this.indices[tag][category]`.
|
||||||
// from `this.stickyRoom`.
|
|
||||||
//
|
//
|
||||||
// The room list store is always provided with the `this.cachedRooms` results, which are
|
// The room list store is always provided with the `this.cachedRooms` results, which are
|
||||||
// updated as needed and not recalculated often. For example, when a room needs to
|
// updated as needed and not recalculated often. For example, when a room needs to
|
||||||
|
@ -102,17 +101,6 @@ export class ImportanceAlgorithm extends Algorithm {
|
||||||
[tag: TagID]: ICategoryIndex;
|
[tag: TagID]: ICategoryIndex;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
// TODO: Use this (see docs above)
|
|
||||||
private stickyRoom: {
|
|
||||||
roomId: string;
|
|
||||||
tag: TagID;
|
|
||||||
fromTop: number;
|
|
||||||
} = {
|
|
||||||
roomId: null,
|
|
||||||
tag: null,
|
|
||||||
fromTop: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
console.log("Constructed an ImportanceAlgorithm");
|
console.log("Constructed an ImportanceAlgorithm");
|
||||||
|
@ -189,12 +177,25 @@ export class ImportanceAlgorithm extends Algorithm {
|
||||||
}
|
}
|
||||||
|
|
||||||
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
|
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
|
||||||
|
if (cause === RoomUpdateCause.PossibleTagChange) {
|
||||||
|
// TODO: Be smarter and splice rather than regen the planet.
|
||||||
|
// TODO: No-op if no change.
|
||||||
|
await this.setKnownRooms(this.rooms);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (cause === RoomUpdateCause.NewRoom) {
|
if (cause === RoomUpdateCause.NewRoom) {
|
||||||
// TODO: Be smarter and insert rather than regen the planet.
|
// TODO: Be smarter and insert rather than regen the planet.
|
||||||
await this.setKnownRooms([room, ...this.rooms]);
|
await this.setKnownRooms([room, ...this.rooms]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cause === RoomUpdateCause.RoomRemoved) {
|
||||||
|
// TODO: Be smarter and splice rather than regen the planet.
|
||||||
|
await this.setKnownRooms(this.rooms.filter(r => r !== room));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let tags = this.roomIdsToTags[room.roomId];
|
let tags = this.roomIdsToTags[room.roomId];
|
||||||
if (!tags) {
|
if (!tags) {
|
||||||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||||
|
@ -251,6 +252,8 @@ export class ImportanceAlgorithm extends Algorithm {
|
||||||
taggedRooms.splice(startIdx, 0, ...sorted);
|
taggedRooms.splice(startIdx, 0, ...sorted);
|
||||||
|
|
||||||
// Finally, flag that we've done something
|
// Finally, flag that we've done something
|
||||||
|
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
|
||||||
|
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
|
|
|
@ -46,11 +46,17 @@ export class NaturalAlgorithm extends Algorithm {
|
||||||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
let changed = false;
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
// TODO: Optimize this loop to avoid useless operations
|
// TODO: Optimize this loop to avoid useless operations
|
||||||
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
|
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
|
||||||
this.cachedRooms[tag] = await sortRoomsWithAlgorithm(this.cachedRooms[tag], tag, this.sortAlgorithms[tag]);
|
this.cachedRooms[tag] = await sortRoomsWithAlgorithm(this.cachedRooms[tag], tag, this.sortAlgorithms[tag]);
|
||||||
|
|
||||||
|
// Flag that we've done something
|
||||||
|
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
|
||||||
|
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
return true; // assume we changed something
|
return changed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,18 +15,18 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition";
|
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
|
||||||
import { Group } from "matrix-js-sdk/src/models/group";
|
import { Group } from "matrix-js-sdk/src/models/group";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
import GroupStore from "../../GroupStore";
|
import GroupStore from "../../GroupStore";
|
||||||
import { arrayHasDiff } from "../../../utils/arrays";
|
import { arrayHasDiff } from "../../../utils/arrays";
|
||||||
import { IDisposable } from "../../../utils/IDisposable";
|
import { IDestroyable } from "../../../utils/IDestroyable";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A filter condition for the room list which reveals rooms which
|
* A filter condition for the room list which reveals rooms which
|
||||||
* are a member of a given community.
|
* are a member of a given community.
|
||||||
*/
|
*/
|
||||||
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDisposable {
|
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
|
||||||
private roomIds: string[] = [];
|
private roomIds: string[] = [];
|
||||||
|
|
||||||
constructor(private community: Group) {
|
constructor(private community: Group) {
|
||||||
|
@ -37,6 +37,11 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
|
||||||
this.onStoreUpdate(); // trigger a false update to seed the store
|
this.onStoreUpdate(); // trigger a false update to seed the store
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get relativePriority(): FilterPriority {
|
||||||
|
// Lowest priority so we can coarsely find rooms.
|
||||||
|
return FilterPriority.Lowest;
|
||||||
|
}
|
||||||
|
|
||||||
public isVisible(room: Room): boolean {
|
public isVisible(room: Room): boolean {
|
||||||
return this.roomIds.includes(room.roomId);
|
return this.roomIds.includes(room.roomId);
|
||||||
}
|
}
|
||||||
|
@ -52,7 +57,7 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public dispose(): void {
|
public destroy(): void {
|
||||||
GroupStore.off("update", this.onStoreUpdate);
|
GroupStore.off("update", this.onStoreUpdate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,6 +19,12 @@ import { EventEmitter } from "events";
|
||||||
|
|
||||||
export const FILTER_CHANGED = "filter_changed";
|
export const FILTER_CHANGED = "filter_changed";
|
||||||
|
|
||||||
|
export enum FilterPriority {
|
||||||
|
Lowest,
|
||||||
|
// in the middle would be Low, Normal, and High if we had a need
|
||||||
|
Highest,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A filter condition for the room list, determining if a room
|
* A filter condition for the room list, determining if a room
|
||||||
* should be shown or not.
|
* should be shown or not.
|
||||||
|
@ -32,6 +38,12 @@ export const FILTER_CHANGED = "filter_changed";
|
||||||
* as a change in the user's input), this emits FILTER_CHANGED.
|
* as a change in the user's input), this emits FILTER_CHANGED.
|
||||||
*/
|
*/
|
||||||
export interface IFilterCondition extends EventEmitter {
|
export interface IFilterCondition extends EventEmitter {
|
||||||
|
/**
|
||||||
|
* The relative priority that this filter should be applied with.
|
||||||
|
* Lower priorities get applied first.
|
||||||
|
*/
|
||||||
|
relativePriority: FilterPriority;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a given room should be visible under this
|
* Determines if a given room should be visible under this
|
||||||
* condition.
|
* condition.
|
||||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition";
|
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
|
||||||
import { EventEmitter } from "events";
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -29,6 +29,11 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get relativePriority(): FilterPriority {
|
||||||
|
// We want this one to be at the highest priority so it can search within other filters.
|
||||||
|
return FilterPriority.Highest;
|
||||||
|
}
|
||||||
|
|
||||||
public get search(): string {
|
public get search(): string {
|
||||||
return this._search;
|
return this._search;
|
||||||
}
|
}
|
||||||
|
|
|
@ -38,6 +38,8 @@ export type TagID = string | DefaultTagID;
|
||||||
|
|
||||||
export enum RoomUpdateCause {
|
export enum RoomUpdateCause {
|
||||||
Timeline = "TIMELINE",
|
Timeline = "TIMELINE",
|
||||||
RoomRead = "ROOM_READ", // TODO: Use this.
|
PossibleTagChange = "POSSIBLE_TAG_CHANGE",
|
||||||
|
ReadReceipt = "READ_RECEIPT",
|
||||||
NewRoom = "NEW_ROOM",
|
NewRoom = "NEW_ROOM",
|
||||||
|
RoomRemoved = "ROOM_REMOVED",
|
||||||
}
|
}
|
||||||
|
|
|
@ -62,7 +62,7 @@ function setCustomThemeVars(customTheme) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCustomTheme(themeName) {
|
export function getCustomTheme(themeName) {
|
||||||
// set css variables
|
// set css variables
|
||||||
const customThemes = SettingsStore.getValue("custom_themes");
|
const customThemes = SettingsStore.getValue("custom_themes");
|
||||||
if (!customThemes) {
|
if (!customThemes) {
|
||||||
|
|
|
@ -66,5 +66,5 @@ export const showToast = (deviceId: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hideToast = (deviceId: string) => {
|
export const hideToast = (deviceId: string) => {
|
||||||
ToastStore.sharedInstance().dismissToast(deviceId);
|
ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
Copyright 2016 OpenMarket Ltd
|
Copyright 2016 OpenMarket Ltd
|
||||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
you may not use this file except in compliance with the License.
|
you may not use this file except in compliance with the License.
|
||||||
|
@ -21,8 +21,8 @@ import { _t } from '../languageHandler';
|
||||||
* formats numbers to fit into ~3 characters, suitable for badge counts
|
* formats numbers to fit into ~3 characters, suitable for badge counts
|
||||||
* e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B
|
* e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B
|
||||||
*/
|
*/
|
||||||
export function formatCount(count) {
|
export function formatCount(count: number): string {
|
||||||
if (count < 1000) return count;
|
if (count < 1000) return count.toString();
|
||||||
if (count < 10000) return (count / 1000).toFixed(1) + "K";
|
if (count < 10000) return (count / 1000).toFixed(1) + "K";
|
||||||
if (count < 100000) return (count / 1000).toFixed(0) + "K";
|
if (count < 100000) return (count / 1000).toFixed(0) + "K";
|
||||||
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
|
if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
|
||||||
|
@ -34,7 +34,7 @@ export function formatCount(count) {
|
||||||
* Format a count showing the whole number but making it a bit more readable.
|
* Format a count showing the whole number but making it a bit more readable.
|
||||||
* e.g: 1000 => 1,000
|
* e.g: 1000 => 1,000
|
||||||
*/
|
*/
|
||||||
export function formatCountLong(count) {
|
export function formatCountLong(count: number): string {
|
||||||
const formatter = new Intl.NumberFormat();
|
const formatter = new Intl.NumberFormat();
|
||||||
return formatter.format(count)
|
return formatter.format(count)
|
||||||
}
|
}
|
||||||
|
@ -43,7 +43,7 @@ export function formatCountLong(count) {
|
||||||
* format a size in bytes into a human readable form
|
* format a size in bytes into a human readable form
|
||||||
* e.g: 1024 -> 1.00 KB
|
* e.g: 1024 -> 1.00 KB
|
||||||
*/
|
*/
|
||||||
export function formatBytes(bytes, decimals = 2) {
|
export function formatBytes(bytes: number, decimals = 2): string {
|
||||||
if (bytes === 0) return '0 Bytes';
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
|
@ -62,7 +62,7 @@ export function formatBytes(bytes, decimals = 2) {
|
||||||
*
|
*
|
||||||
* @return {string}
|
* @return {string}
|
||||||
*/
|
*/
|
||||||
export function formatCryptoKey(key) {
|
export function formatCryptoKey(key: string): string {
|
||||||
return key.match(/.{1,4}/g).join(" ");
|
return key.match(/.{1,4}/g).join(" ");
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
|
@ -72,7 +72,7 @@ export function formatCryptoKey(key) {
|
||||||
*
|
*
|
||||||
* @return {number}
|
* @return {number}
|
||||||
*/
|
*/
|
||||||
export function hashCode(str) {
|
export function hashCode(str: string): number {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
let i;
|
let i;
|
||||||
let chr;
|
let chr;
|
||||||
|
@ -87,7 +87,7 @@ export function hashCode(str) {
|
||||||
return Math.abs(hash);
|
return Math.abs(hash);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserNameColorClass(userId) {
|
export function getUserNameColorClass(userId: string): string {
|
||||||
const colorNumber = (hashCode(userId) % 8) + 1;
|
const colorNumber = (hashCode(userId) % 8) + 1;
|
||||||
return `mx_Username_color${colorNumber}`;
|
return `mx_Username_color${colorNumber}`;
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ export function getUserNameColorClass(userId) {
|
||||||
* @returns {string} a string constructed by joining `items` with a comma
|
* @returns {string} a string constructed by joining `items` with a comma
|
||||||
* between each item, but with the last item appended as " and [lastItem]".
|
* between each item, but with the last item appended as " and [lastItem]".
|
||||||
*/
|
*/
|
||||||
export function formatCommaSeparatedList(items, itemLimit) {
|
export function formatCommaSeparatedList(items: string[], itemLimit?: number): string {
|
||||||
const remaining = itemLimit === undefined ? 0 : Math.max(
|
const remaining = itemLimit === undefined ? 0 : Math.max(
|
||||||
items.length - itemLimit, 0,
|
items.length - itemLimit, 0,
|
||||||
);
|
);
|
||||||
|
@ -119,3 +119,14 @@ export function formatCommaSeparatedList(items, itemLimit) {
|
||||||
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
|
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a number into a 'minimal' badge count (9, 98, 99+).
|
||||||
|
* @param count The number to convert
|
||||||
|
* @returns The badge count, stringified.
|
||||||
|
*/
|
||||||
|
export function formatMinimalBadgeCount(count: number): string {
|
||||||
|
// we specifically go from "98" to "99+"
|
||||||
|
if (count < 99) return count.toString();
|
||||||
|
return "99+";
|
||||||
|
}
|
|
@ -14,6 +14,6 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface IDisposable {
|
export interface IDestroyable {
|
||||||
dispose(): void;
|
destroy(): void;
|
||||||
}
|
}
|
|
@ -421,6 +421,7 @@ export default class WidgetUtils {
|
||||||
if (WidgetType.JITSI.matches(appType)) {
|
if (WidgetType.JITSI.matches(appType)) {
|
||||||
capWhitelist.push(Capability.AlwaysOnScreen);
|
capWhitelist.push(Capability.AlwaysOnScreen);
|
||||||
}
|
}
|
||||||
|
capWhitelist.push(Capability.ReceiveTerminate);
|
||||||
|
|
||||||
return capWhitelist;
|
return capWhitelist;
|
||||||
}
|
}
|
||||||
|
|
|
@ -45,3 +45,63 @@ export function arrayDiff<T>(a: T[], b: T[]): { added: T[], removed: T[] } {
|
||||||
removed: a.filter(i => !b.includes(i)),
|
removed: a.filter(i => !b.includes(i)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper functions to perform LINQ-like queries on arrays.
|
||||||
|
*/
|
||||||
|
export class ArrayUtil<T> {
|
||||||
|
/**
|
||||||
|
* Create a new array helper.
|
||||||
|
* @param a The array to help. Can be modified in-place.
|
||||||
|
*/
|
||||||
|
constructor(private a: T[]) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The value of this array, after all appropriate alterations.
|
||||||
|
*/
|
||||||
|
public get value(): T[] {
|
||||||
|
return this.a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups an array by keys.
|
||||||
|
* @param fn The key-finding function.
|
||||||
|
* @returns This.
|
||||||
|
*/
|
||||||
|
public groupBy<K>(fn: (a: T) => K): GroupedArray<K, T> {
|
||||||
|
const obj = this.a.reduce((rv: Map<K, T[]>, val: T) => {
|
||||||
|
const k = fn(val);
|
||||||
|
if (!rv.has(k)) rv.set(k, []);
|
||||||
|
rv.get(k).push(val);
|
||||||
|
return rv;
|
||||||
|
}, new Map<K, T[]>());
|
||||||
|
return new GroupedArray(obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper functions to perform LINQ-like queries on groups (maps).
|
||||||
|
*/
|
||||||
|
export class GroupedArray<K, T> {
|
||||||
|
/**
|
||||||
|
* Creates a new group helper.
|
||||||
|
* @param val The group to help. Can be modified in-place.
|
||||||
|
*/
|
||||||
|
constructor(private val: Map<K, T[]>) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders the grouping into an array using the provided key order.
|
||||||
|
* @param keyOrder The key order.
|
||||||
|
* @returns An array helper of the result.
|
||||||
|
*/
|
||||||
|
public orderBy(keyOrder: K[]): ArrayUtil<T> {
|
||||||
|
const a: T[] = [];
|
||||||
|
for (const k of keyOrder) {
|
||||||
|
if (!this.val.has(k)) continue;
|
||||||
|
a.push(...this.val.get(k));
|
||||||
|
}
|
||||||
|
return new ArrayUtil(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
27
src/utils/enums.ts
Normal file
27
src/utils/enums.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the values for an enum.
|
||||||
|
* @param e The enum.
|
||||||
|
* @returns The enum values.
|
||||||
|
*/
|
||||||
|
export function getEnumValues<T>(e: any): T[] {
|
||||||
|
const keys = Object.keys(e);
|
||||||
|
return keys
|
||||||
|
.filter(k => ['string', 'number'].includes(typeof(e[k])))
|
||||||
|
.map(k => e[k]);
|
||||||
|
}
|
|
@ -18,11 +18,13 @@ limitations under the License.
|
||||||
// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts
|
// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts
|
||||||
|
|
||||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||||
|
import { EventEmitter } from "events";
|
||||||
|
|
||||||
export enum Capability {
|
export enum Capability {
|
||||||
Screenshot = "m.capability.screenshot",
|
Screenshot = "m.capability.screenshot",
|
||||||
Sticker = "m.sticker",
|
Sticker = "m.sticker",
|
||||||
AlwaysOnScreen = "m.always_on_screen",
|
AlwaysOnScreen = "m.always_on_screen",
|
||||||
|
ReceiveTerminate = "im.vector.receive_terminate",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum KnownWidgetActions {
|
export enum KnownWidgetActions {
|
||||||
|
@ -34,6 +36,7 @@ export enum KnownWidgetActions {
|
||||||
ReceiveOpenIDCredentials = "openid_credentials",
|
ReceiveOpenIDCredentials = "openid_credentials",
|
||||||
SetAlwaysOnScreen = "set_always_on_screen",
|
SetAlwaysOnScreen = "set_always_on_screen",
|
||||||
ClientReady = "im.vector.ready",
|
ClientReady = "im.vector.ready",
|
||||||
|
Terminate = "im.vector.terminate",
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WidgetAction = KnownWidgetActions | string;
|
export type WidgetAction = KnownWidgetActions | string;
|
||||||
|
@ -62,8 +65,13 @@ export interface FromWidgetRequest extends WidgetRequest {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles Riot <--> Widget interactions for embedded/standalone widgets.
|
* Handles Riot <--> Widget interactions for embedded/standalone widgets.
|
||||||
|
*
|
||||||
|
* Emitted events:
|
||||||
|
* - terminate(wait): client requested the widget to terminate.
|
||||||
|
* Call the argument 'wait(promise)' to postpone the finalization until
|
||||||
|
* the given promise resolves.
|
||||||
*/
|
*/
|
||||||
export class WidgetApi {
|
export class WidgetApi extends EventEmitter {
|
||||||
private origin: string;
|
private origin: string;
|
||||||
private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {};
|
private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {};
|
||||||
private readyPromise: Promise<any>;
|
private readyPromise: Promise<any>;
|
||||||
|
@ -75,6 +83,8 @@ export class WidgetApi {
|
||||||
public expectingExplicitReady = false;
|
public expectingExplicitReady = false;
|
||||||
|
|
||||||
constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
|
constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
|
||||||
|
super();
|
||||||
|
|
||||||
this.origin = new URL(currentUrl).origin;
|
this.origin = new URL(currentUrl).origin;
|
||||||
|
|
||||||
this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
|
this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
|
||||||
|
@ -98,6 +108,17 @@ export class WidgetApi {
|
||||||
|
|
||||||
// Automatically acknowledge so we can move on
|
// Automatically acknowledge so we can move on
|
||||||
this.replyToRequest(<ToWidgetRequest>payload, {});
|
this.replyToRequest(<ToWidgetRequest>payload, {});
|
||||||
|
} else if (payload.action === KnownWidgetActions.Terminate) {
|
||||||
|
// Finalization needs to be async, so postpone with a promise
|
||||||
|
let finalizePromise = Promise.resolve();
|
||||||
|
const wait = (promise) => {
|
||||||
|
finalizePromise = finalizePromise.then(value => promise);
|
||||||
|
};
|
||||||
|
this.emit('terminate', wait);
|
||||||
|
Promise.resolve(finalizePromise).then(() => {
|
||||||
|
// Acknowledge that we're shut down now
|
||||||
|
this.replyToRequest(<ToWidgetRequest>payload, {});
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
|
console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue