mirror of https://github.com/vector-im/riot-web
Merge branch 'develop' into poljar/seshat-search-pagination
commit
eaca8310d3
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)
|
||||
===================================================================================================
|
||||
[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
|
||||
[\#4703](https://github.com/matrix-org/matrix-react-sdk/pull/4703)
|
||||
* Fix checkbox bleed
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "matrix-react-sdk",
|
||||
"version": "2.7.0",
|
||||
"version": "2.7.1",
|
||||
"description": "SDK for matrix.org using React",
|
||||
"author": "matrix.org",
|
||||
"repository": {
|
||||
|
@ -93,6 +93,7 @@
|
|||
"react-beautiful-dnd": "^4.0.1",
|
||||
"react-dom": "^16.9.0",
|
||||
"react-focus-lock": "^2.2.1",
|
||||
"react-resizable": "^1.10.1",
|
||||
"resize-observer-polyfill": "^1.5.0",
|
||||
"sanitize-html": "^1.18.4",
|
||||
"text-encoding-utf-8": "^1.0.1",
|
||||
|
|
|
@ -19,7 +19,7 @@ limitations under the License.
|
|||
@import "./_font-sizes.scss";
|
||||
|
||||
:root {
|
||||
font-size: 15px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
html {
|
||||
|
|
|
@ -12,12 +12,14 @@
|
|||
@import "./structures/_HeaderButtons.scss";
|
||||
@import "./structures/_HomePage.scss";
|
||||
@import "./structures/_LeftPanel.scss";
|
||||
@import "./structures/_LeftPanel2.scss";
|
||||
@import "./structures/_MainSplit.scss";
|
||||
@import "./structures/_MatrixChat.scss";
|
||||
@import "./structures/_MyGroups.scss";
|
||||
@import "./structures/_NotificationPanel.scss";
|
||||
@import "./structures/_RightPanel.scss";
|
||||
@import "./structures/_RoomDirectory.scss";
|
||||
@import "./structures/_RoomSearch.scss";
|
||||
@import "./structures/_RoomStatusBar.scss";
|
||||
@import "./structures/_RoomSubList.scss";
|
||||
@import "./structures/_RoomView.scss";
|
||||
|
@ -28,6 +30,7 @@
|
|||
@import "./structures/_ToastContainer.scss";
|
||||
@import "./structures/_TopLeftMenuButton.scss";
|
||||
@import "./structures/_UploadBar.scss";
|
||||
@import "./structures/_UserMenuButton.scss";
|
||||
@import "./structures/_ViewSource.scss";
|
||||
@import "./structures/auth/_CompleteSecurity.scss";
|
||||
@import "./structures/auth/_Login.scss";
|
||||
|
@ -169,6 +172,7 @@
|
|||
@import "./views/rooms/_MemberList.scss";
|
||||
@import "./views/rooms/_MessageComposer.scss";
|
||||
@import "./views/rooms/_MessageComposerFormatBar.scss";
|
||||
@import "./views/rooms/_NotificationBadge.scss";
|
||||
@import "./views/rooms/_PinnedEventTile.scss";
|
||||
@import "./views/rooms/_PinnedEventsPanel.scss";
|
||||
@import "./views/rooms/_PresenceLabel.scss";
|
||||
|
@ -177,9 +181,12 @@
|
|||
@import "./views/rooms/_RoomDropTarget.scss";
|
||||
@import "./views/rooms/_RoomHeader.scss";
|
||||
@import "./views/rooms/_RoomList.scss";
|
||||
@import "./views/rooms/_RoomList2.scss";
|
||||
@import "./views/rooms/_RoomPreviewBar.scss";
|
||||
@import "./views/rooms/_RoomRecoveryReminder.scss";
|
||||
@import "./views/rooms/_RoomSublist2.scss";
|
||||
@import "./views/rooms/_RoomTile.scss";
|
||||
@import "./views/rooms/_RoomTile2.scss";
|
||||
@import "./views/rooms/_RoomUpgradeWarningBar.scss";
|
||||
@import "./views/rooms/_SearchBar.scss";
|
||||
@import "./views/rooms/_SendMessageComposer.scss";
|
||||
|
|
|
@ -14,59 +14,59 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
$font-1px: 0.067rem;
|
||||
$font-1-5px: 0.100rem;
|
||||
$font-2px: 0.133rem;
|
||||
$font-3px: 0.200rem;
|
||||
$font-4px: 0.267rem;
|
||||
$font-5px: 0.333rem;
|
||||
$font-6px: 0.400rem;
|
||||
$font-7px: 0.467rem;
|
||||
$font-8px: 0.533rem;
|
||||
$font-9px: 0.600rem;
|
||||
$font-10px: 0.667rem;
|
||||
$font-10-4px: 0.693rem;
|
||||
$font-11px: 0.733rem;
|
||||
$font-12px: 0.800rem;
|
||||
$font-13px: 0.867rem;
|
||||
$font-14px: 0.933rem;
|
||||
$font-15px: 1.000rem;
|
||||
$font-16px: 1.067rem;
|
||||
$font-17px: 1.133rem;
|
||||
$font-18px: 1.200rem;
|
||||
$font-19px: 1.267rem;
|
||||
$font-20px: 1.3333333rem;
|
||||
$font-21px: 1.400rem;
|
||||
$font-22px: 1.467rem;
|
||||
$font-23px: 1.533rem;
|
||||
$font-24px: 1.600rem;
|
||||
$font-25px: 1.667rem;
|
||||
$font-26px: 1.733rem;
|
||||
$font-27px: 1.800rem;
|
||||
$font-28px: 1.867rem;
|
||||
$font-29px: 1.933rem;
|
||||
$font-30px: 2.000rem;
|
||||
$font-31px: 2.067rem;
|
||||
$font-32px: 2.133rem;
|
||||
$font-33px: 2.200rem;
|
||||
$font-34px: 2.267rem;
|
||||
$font-35px: 2.333rem;
|
||||
$font-36px: 2.400rem;
|
||||
$font-37px: 2.467rem;
|
||||
$font-38px: 2.533rem;
|
||||
$font-39px: 2.600rem;
|
||||
$font-40px: 2.667rem;
|
||||
$font-41px: 2.733rem;
|
||||
$font-42px: 2.800rem;
|
||||
$font-43px: 2.867rem;
|
||||
$font-44px: 2.933rem;
|
||||
$font-45px: 3.000rem;
|
||||
$font-46px: 3.067rem;
|
||||
$font-47px: 3.133rem;
|
||||
$font-48px: 3.200rem;
|
||||
$font-49px: 3.267rem;
|
||||
$font-50px: 3.333rem;
|
||||
$font-51px: 3.400rem;
|
||||
$font-52px: 3.467rem;
|
||||
$font-88px: 5.887rem;
|
||||
$font-400px: 26.667rem;
|
||||
$font-1px: 0.1rem;
|
||||
$font-1-5px: 0.15rem;
|
||||
$font-2px: 0.2rem;
|
||||
$font-3px: 0.3rem;
|
||||
$font-4px: 0.4rem;
|
||||
$font-5px: 0.5rem;
|
||||
$font-6px: 0.6rem;
|
||||
$font-7px: 0.7rem;
|
||||
$font-8px: 0.8rem;
|
||||
$font-9px: 0.9rem;
|
||||
$font-10px: 1.0rem;
|
||||
$font-10-4px: 1.04rem;
|
||||
$font-11px: 1.1rem;
|
||||
$font-12px: 1.2rem;
|
||||
$font-13px: 1.3rem;
|
||||
$font-14px: 1.4rem;
|
||||
$font-15px: 1.5rem;
|
||||
$font-16px: 1.6rem;
|
||||
$font-17px: 1.7rem;
|
||||
$font-18px: 1.8rem;
|
||||
$font-19px: 1.9rem;
|
||||
$font-20px: 2.0rem;
|
||||
$font-21px: 2.1rem;
|
||||
$font-22px: 2.2rem;
|
||||
$font-23px: 2.3rem;
|
||||
$font-24px: 2.4rem;
|
||||
$font-25px: 2.5rem;
|
||||
$font-26px: 2.6rem;
|
||||
$font-27px: 2.7rem;
|
||||
$font-28px: 2.8rem;
|
||||
$font-29px: 2.9rem;
|
||||
$font-30px: 3.0rem;
|
||||
$font-31px: 3.1rem;
|
||||
$font-32px: 3.2rem;
|
||||
$font-33px: 3.3rem;
|
||||
$font-34px: 3.4rem;
|
||||
$font-35px: 3.5rem;
|
||||
$font-36px: 3.6rem;
|
||||
$font-37px: 3.7rem;
|
||||
$font-38px: 3.8rem;
|
||||
$font-39px: 3.9rem;
|
||||
$font-40px: 4.0rem;
|
||||
$font-41px: 4.1rem;
|
||||
$font-42px: 4.2rem;
|
||||
$font-43px: 4.3rem;
|
||||
$font-44px: 4.4rem;
|
||||
$font-45px: 4.5rem;
|
||||
$font-46px: 4.6rem;
|
||||
$font-47px: 4.7rem;
|
||||
$font-48px: 4.8rem;
|
||||
$font-49px: 4.9rem;
|
||||
$font-50px: 5.0rem;
|
||||
$font-51px: 5.1rem;
|
||||
$font-52px: 5.2rem;
|
||||
$font-88px: 8.8rem;
|
||||
$font-400px: 40rem;
|
||||
|
|
|
@ -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/... */
|
||||
.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;
|
||||
|
||||
flex: 1 1 0;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
||||
.mx_GroupLayout {
|
||||
|
||||
.mx_EventTile {
|
||||
> .mx_SenderProfile {
|
||||
line-height: $font-17px;
|
||||
|
@ -53,14 +52,14 @@ $left-gutter: 65px;
|
|||
/* Compact layout overrides */
|
||||
|
||||
.mx_MatrixChat_useCompactLayout {
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.mx_EventTile {
|
||||
padding-top: 4px;
|
||||
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&.mx_EventTile_info {
|
||||
// same as the padding for non-compact .mx_EventTile.mx_EventTile_info
|
||||
padding-top: 0px;
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
.mx_RoomList.mx_RoomList2 {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.mx_RoomList {
|
||||
/* take up remaining space below TopLeftMenu */
|
||||
flex: 1;
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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-right: 5px;
|
||||
}
|
||||
|
||||
.mx_SettingsTab_customFontSizeField {
|
||||
margin-left: calc($font-16px + 10px);
|
||||
}
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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-badge-fg-color: $accent-fg-color;
|
||||
$roomtile-selected-color: #212121;
|
||||
|
|
|
@ -27,7 +27,7 @@ import RoomViewStore from './stores/RoomViewStore';
|
|||
*/
|
||||
class ActiveRoomObserver {
|
||||
constructor() {
|
||||
this._listeners = {};
|
||||
this._listeners = {}; // key=roomId, value=function(isActive:boolean)
|
||||
|
||||
this._activeRoomId = RoomViewStore.getRoomId();
|
||||
// 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));
|
||||
}
|
||||
|
||||
get activeRoomId(): string {
|
||||
return this._activeRoomId;
|
||||
}
|
||||
|
||||
addListener(roomId, listener) {
|
||||
if (!this._listeners[roomId]) this._listeners[roomId] = [];
|
||||
this._listeners[roomId].push(listener);
|
||||
|
@ -51,23 +55,23 @@ class ActiveRoomObserver {
|
|||
}
|
||||
}
|
||||
|
||||
_emit(roomId) {
|
||||
_emit(roomId, isActive: boolean) {
|
||||
if (!this._listeners[roomId]) return;
|
||||
|
||||
for (const l of this._listeners[roomId]) {
|
||||
l.call();
|
||||
l.call(null, isActive);
|
||||
}
|
||||
}
|
||||
|
||||
_onRoomViewStoreUpdate() {
|
||||
// emit for the old room ID
|
||||
if (this._activeRoomId) this._emit(this._activeRoomId);
|
||||
if (this._activeRoomId) this._emit(this._activeRoomId, false);
|
||||
|
||||
// update our cache
|
||||
this._activeRoomId = RoomViewStore.getRoomId();
|
||||
|
||||
// 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
|
||||
import "blueimp-canvas-to-blob";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
|
||||
const MAX_WIDTH = 800;
|
||||
const MAX_HEIGHT = 600;
|
||||
|
@ -529,7 +530,7 @@ export default class ContentMessages {
|
|||
dis.dispatch({action: 'upload_started'});
|
||||
|
||||
// Focus the composer view
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
|
||||
function onProgress(ev) {
|
||||
upload.total = ev.total;
|
||||
|
|
|
@ -481,6 +481,29 @@ async function combinedPagination(searchResult) {
|
|||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
* @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,
|
||||
component: (
|
||||
<PillCompletion title={shortname} aria-label={unicode}>
|
||||
<span style={{maxWidth: '1em'}}>{ unicode }</span>
|
||||
<span>{ unicode }</span>
|
||||
</PillCompletion>
|
||||
),
|
||||
range,
|
||||
|
|
|
@ -22,9 +22,10 @@ import { _t } from "../../languageHandler";
|
|||
import SdkConfig from "../../SdkConfig";
|
||||
import * as sdk from "../../index";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
|
||||
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 HomePage = () => {
|
||||
|
|
|
@ -26,7 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler';
|
|||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
import Analytics from "../../Analytics";
|
||||
import RoomList2 from "../views/rooms/RoomList2";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
|
||||
const LeftPanel = createReactClass({
|
||||
|
@ -198,7 +198,7 @@ const LeftPanel = createReactClass({
|
|||
|
||||
onSearchCleared: function(source) {
|
||||
if (source === "keyboard") {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
this.setState({searchExpanded: false});
|
||||
},
|
||||
|
@ -252,7 +252,7 @@ const LeftPanel = createReactClass({
|
|||
if (!this.props.collapsed) {
|
||||
exploreButton = (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
@ -274,28 +274,15 @@ const LeftPanel = createReactClass({
|
|||
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
||||
}
|
||||
|
||||
let roomList = null;
|
||||
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}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
ref={this.collectRoomList}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ConferenceHandler={VectorConferenceHandler} />;
|
||||
}
|
||||
const roomList = <RoomList
|
||||
onKeyDown={this._onKeyDown}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
ref={this.collectRoomList}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ConferenceHandler={VectorConferenceHandler} />;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
|
|
|
@ -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,
|
||||
hideToast as hideServerLimitToast
|
||||
} 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)
|
||||
// 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
|
||||
// paste end up in the newly focused element,
|
||||
// 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
|
||||
|
||||
if (ctrlCmdOnly) {
|
||||
dis.dispatch({
|
||||
action: 'toggle_top_left_menu',
|
||||
});
|
||||
dis.fire(Action.ToggleUserMenu);
|
||||
handled = true;
|
||||
}
|
||||
break;
|
||||
|
@ -508,7 +508,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
|||
|
||||
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
|
||||
// synchronous dispatch so we focus before key generates input
|
||||
dis.dispatch({action: 'focus_composer'}, true);
|
||||
dis.fire(Action.FocusComposer, true);
|
||||
ev.stopPropagation();
|
||||
// we should *not* preventDefault() here as
|
||||
// that would prevent typing in the now-focussed composer
|
||||
|
@ -667,6 +667,20 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
|||
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 (
|
||||
<MatrixClientContext.Provider value={this._matrixClient}>
|
||||
<div
|
||||
|
@ -680,11 +694,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
|||
<ToastContainer />
|
||||
<DragDropContext onDragEnd={this._onDragEnd}>
|
||||
<div ref={this._resizeContainer} className={bodyClasses}>
|
||||
<LeftPanel
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapseLhs || false}
|
||||
disabled={this.props.leftDisabled}
|
||||
/>
|
||||
{ leftPanel }
|
||||
<ResizeHandle />
|
||||
{ pageElement }
|
||||
</div>
|
||||
|
|
|
@ -72,6 +72,7 @@ import {
|
|||
hideToast as hideAnalyticsToast
|
||||
} from "../../toasts/AnalyticsToast";
|
||||
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
|
||||
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
|
||||
|
||||
/** constants for MatrixChat.state.view */
|
||||
export enum Views {
|
||||
|
@ -347,7 +348,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
Analytics.trackPageChange(durationMs);
|
||||
}
|
||||
if (this.focusComposer) {
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
this.focusComposer = false;
|
||||
}
|
||||
}
|
||||
|
@ -604,9 +605,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.viewIndexedRoom(payload.roomIndex);
|
||||
break;
|
||||
case Action.ViewUserSettings: {
|
||||
const tabPayload = payload as OpenToTabPayload;
|
||||
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true);
|
||||
Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
|
||||
{initialTabId: tabPayload.initialTabId},
|
||||
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true
|
||||
);
|
||||
|
||||
// View the welcome or home page if we need something to look at
|
||||
this.viewSomethingBehindModal();
|
||||
|
@ -620,7 +624,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
|
||||
break;
|
||||
}
|
||||
case 'view_room_directory': {
|
||||
case Action.ViewRoomDirectory: {
|
||||
const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
|
||||
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
|
||||
'mx_RoomDirectory_dialogWrapper', false, true);
|
||||
|
@ -1363,7 +1367,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
showNotificationsToast();
|
||||
}
|
||||
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
this.setState({
|
||||
ready: true,
|
||||
});
|
||||
|
@ -1607,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
action: 'require_registration',
|
||||
});
|
||||
} else if (screen === 'directory') {
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
} else if (screen === 'groups') {
|
||||
dis.dispatch({
|
||||
action: 'view_my_groups',
|
||||
|
|
|
@ -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 dis from '../../dispatcher/dispatcher';
|
||||
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const STATUS_BAR_HIDDEN = 0;
|
||||
const STATUS_BAR_EXPANDED = 1;
|
||||
|
@ -127,12 +128,12 @@ export default createReactClass({
|
|||
|
||||
_onResendAllClick: function() {
|
||||
Resend.resendUnsentEvents(this.props.room);
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
_onCancelAllClick: function() {
|
||||
Resend.cancelUnsentEvents(this.props.room);
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {
|
||||
|
|
|
@ -55,6 +55,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile";
|
|||
import RoomContext from "../../contexts/RoomContext";
|
||||
import MatrixClientContext from "../../contexts/MatrixClientContext";
|
||||
import { shieldStatusForRoom } from '../../utils/ShieldUtils';
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const DEBUG = false;
|
||||
let debuglog = function() {};
|
||||
|
@ -1161,7 +1162,7 @@ export default createReactClass({
|
|||
ev.dataTransfer.files, this.state.room.roomId, this.context,
|
||||
);
|
||||
this.setState({ draggingFile: false });
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
onDragLeaveOrEnd: function(ev) {
|
||||
|
@ -1367,7 +1368,7 @@ export default createReactClass({
|
|||
event: null,
|
||||
});
|
||||
}
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
onLeaveClick: function() {
|
||||
|
@ -1456,9 +1457,7 @@ export default createReactClass({
|
|||
// using /leave rather than /join. In the short term though, we
|
||||
// just ignore them.
|
||||
// https://github.com/vector-im/vector-web/issues/1134
|
||||
dis.dispatch({
|
||||
action: 'view_room_directory',
|
||||
});
|
||||
dis.fire(Action.ViewRoomDirectory);
|
||||
},
|
||||
|
||||
onSearchClick: function() {
|
||||
|
@ -1478,7 +1477,7 @@ export default createReactClass({
|
|||
// jump down to the bottom of this room, where new events are arriving
|
||||
jumpToLiveTimeline: function() {
|
||||
this._messagePanel.jumpToLiveTimeline();
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
},
|
||||
|
||||
// jump up to wherever our read marker is
|
||||
|
|
|
@ -27,25 +27,20 @@ import { ReactNode } from "react";
|
|||
* Represents a tab for the TabbedView.
|
||||
*/
|
||||
export class Tab {
|
||||
public label: string;
|
||||
public icon: string;
|
||||
public body: React.ReactNode;
|
||||
|
||||
/**
|
||||
* Creates a new tab.
|
||||
* @param {string} tabLabel The untranslated tab label.
|
||||
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask.
|
||||
* @param {React.ReactNode} tabJsx The JSX for the tab container.
|
||||
* @param {string} id The tab's ID.
|
||||
* @param {string} label The untranslated tab label.
|
||||
* @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) {
|
||||
this.label = tabLabel;
|
||||
this.icon = tabIconClass;
|
||||
this.body = tabJsx;
|
||||
constructor(public id: string, public label: string, public icon: string, public body: React.ReactNode) {
|
||||
}
|
||||
}
|
||||
|
||||
interface IProps {
|
||||
tabs: Tab[];
|
||||
initialTabId?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
|
@ -53,16 +48,17 @@ interface 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) {
|
||||
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 = {
|
||||
activeTabIndex: 0,
|
||||
activeTabIndex,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ import * as Avatar from '../../Avatar';
|
|||
import { _t } from '../../languageHandler';
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
|
||||
import {Action} from "../../dispatcher/actions";
|
||||
|
||||
const AVATAR_SIZE = 28;
|
||||
|
||||
|
@ -75,7 +76,7 @@ export default class TopLeftMenuButton extends React.Component {
|
|||
|
||||
onAction = (payload) => {
|
||||
// For accessibility
|
||||
if (payload.action === "toggle_top_left_menu") {
|
||||
if (payload.action === Action.ToggleUserMenu) {
|
||||
if (this._buttonRef) this._buttonRef.click();
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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 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 {
|
||||
static propTypes = {
|
||||
roomId: PropTypes.string.isRequired,
|
||||
|
@ -56,21 +63,25 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
const tabs = [];
|
||||
|
||||
tabs.push(new Tab(
|
||||
ROOM_GENERAL_TAB,
|
||||
_td("General"),
|
||||
"mx_RoomSettingsDialog_settingsIcon",
|
||||
<GeneralRoomSettingsTab roomId={this.props.roomId} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
ROOM_SECURITY_TAB,
|
||||
_td("Security & Privacy"),
|
||||
"mx_RoomSettingsDialog_securityIcon",
|
||||
<SecurityRoomSettingsTab roomId={this.props.roomId} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
ROOM_ROLES_TAB,
|
||||
_td("Roles & Permissions"),
|
||||
"mx_RoomSettingsDialog_rolesIcon",
|
||||
<RolesRoomSettingsTab roomId={this.props.roomId} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
ROOM_NOTIFICATIONS_TAB,
|
||||
_td("Notifications"),
|
||||
"mx_RoomSettingsDialog_notificationsIcon",
|
||||
<NotificationSettingsTab roomId={this.props.roomId} />,
|
||||
|
@ -78,6 +89,7 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
|
||||
if (SettingsStore.isFeatureEnabled("feature_bridge_state")) {
|
||||
tabs.push(new Tab(
|
||||
ROOM_BRIDGES_TAB,
|
||||
_td("Bridges"),
|
||||
"mx_RoomSettingsDialog_bridgesIcon",
|
||||
<BridgeSettingsTab roomId={this.props.roomId} />,
|
||||
|
@ -85,6 +97,7 @@ export default class RoomSettingsDialog extends React.Component {
|
|||
}
|
||||
|
||||
tabs.push(new Tab(
|
||||
ROOM_ADVANCED_TAB,
|
||||
_td("Advanced"),
|
||||
"mx_RoomSettingsDialog_warningIcon",
|
||||
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,
|
||||
|
|
|
@ -33,9 +33,21 @@ import * as sdk from "../../../index";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
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 {
|
||||
static propTypes = {
|
||||
onFinished: PropTypes.func.isRequired,
|
||||
initialTabId: PropTypes.string,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
|
@ -63,42 +75,50 @@ export default class UserSettingsDialog extends React.Component {
|
|||
const tabs = [];
|
||||
|
||||
tabs.push(new Tab(
|
||||
USER_GENERAL_TAB,
|
||||
_td("General"),
|
||||
"mx_UserSettingsDialog_settingsIcon",
|
||||
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_APPEARANCE_TAB,
|
||||
_td("Appearance"),
|
||||
"mx_UserSettingsDialog_appearanceIcon",
|
||||
<AppearanceUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_FLAIR_TAB,
|
||||
_td("Flair"),
|
||||
"mx_UserSettingsDialog_flairIcon",
|
||||
<FlairUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_NOTIFICATIONS_TAB,
|
||||
_td("Notifications"),
|
||||
"mx_UserSettingsDialog_bellIcon",
|
||||
<NotificationUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_PREFERENCES_TAB,
|
||||
_td("Preferences"),
|
||||
"mx_UserSettingsDialog_preferencesIcon",
|
||||
<PreferencesUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_VOICE_TAB,
|
||||
_td("Voice & Video"),
|
||||
"mx_UserSettingsDialog_voiceIcon",
|
||||
<VoiceUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
USER_SECURITY_TAB,
|
||||
_td("Security & Privacy"),
|
||||
"mx_UserSettingsDialog_securityIcon",
|
||||
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) {
|
||||
tabs.push(new Tab(
|
||||
USER_LABS_TAB,
|
||||
_td("Labs"),
|
||||
"mx_UserSettingsDialog_labsIcon",
|
||||
<LabsUserSettingsTab />,
|
||||
|
@ -106,12 +126,14 @@ export default class UserSettingsDialog extends React.Component {
|
|||
}
|
||||
if (this.state.mjolnirEnabled) {
|
||||
tabs.push(new Tab(
|
||||
USER_MJOLNIR_TAB,
|
||||
_td("Ignored users"),
|
||||
"mx_UserSettingsDialog_mjolnirIcon",
|
||||
<MjolnirUserSettingsTab />,
|
||||
));
|
||||
}
|
||||
tabs.push(new Tab(
|
||||
USER_HELP_TAB,
|
||||
_td("Help & About"),
|
||||
"mx_UserSettingsDialog_helpIcon",
|
||||
<HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
|
@ -127,7 +149,7 @@ export default class UserSettingsDialog extends React.Component {
|
|||
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
|
||||
onFinished={this.props.onFinished} title={_t("Settings")}>
|
||||
<div className='ms_SettingsDialog_content'>
|
||||
<TabbedView tabs={this._getTabs()} />
|
||||
<TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
|
||||
</div>
|
||||
</BaseDialog>
|
||||
);
|
||||
|
|
|
@ -39,6 +39,8 @@ import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
|||
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
|
||||
import PersistedElement from "./PersistedElement";
|
||||
import {WidgetType} from "../../../widgets/WidgetType";
|
||||
import {Capability} from "../../../widgets/WidgetApi";
|
||||
import {sleep} from "../../../utils/promise";
|
||||
|
||||
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
|
||||
const ENABLE_REACT_PERF = false;
|
||||
|
@ -341,23 +343,37 @@ export default class AppTile extends React.Component {
|
|||
/**
|
||||
* Ends all widget interaction, such as cancelling calls and disabling webcams.
|
||||
* @private
|
||||
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
|
||||
*/
|
||||
_endWidgetActions() {
|
||||
// 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
|
||||
// stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351
|
||||
if (this._appFrame.current) {
|
||||
// In practice we could just do `+= ''` to trick the browser
|
||||
// into thinking the URL changed, however I can foresee this
|
||||
// being optimized out by a browser. Instead, we'll just point
|
||||
// the iframe at a page that is reasonably safe to use in the
|
||||
// event the iframe doesn't wink away.
|
||||
// This is relative to where the Riot instance is located.
|
||||
this._appFrame.current.src = 'about:blank';
|
||||
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();
|
||||
}
|
||||
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
return terminationPromise.finally(() => {
|
||||
// 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
|
||||
// stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351
|
||||
if (this._appFrame.current) {
|
||||
// In practice we could just do `+= ''` to trick the browser
|
||||
// into thinking the URL changed, however I can foresee this
|
||||
// being optimized out by a browser. Instead, we'll just point
|
||||
// the iframe at a page that is reasonably safe to use in the
|
||||
// event the iframe doesn't wink away.
|
||||
// This is relative to where the Riot instance is located.
|
||||
this._appFrame.current.src = 'about:blank';
|
||||
}
|
||||
|
||||
// Delete the widget from the persisted store for good measure.
|
||||
PersistedElement.destroyElement(this._persistKey);
|
||||
});
|
||||
}
|
||||
|
||||
/* 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._endWidgetActions();
|
||||
|
||||
WidgetUtils.setRoomWidget(
|
||||
this.props.room.roomId,
|
||||
this.props.app.id,
|
||||
).catch((e) => {
|
||||
this._endWidgetActions().then(() => {
|
||||
return WidgetUtils.setRoomWidget(
|
||||
this.props.room.roomId,
|
||||
this.props.app.id,
|
||||
);
|
||||
}).catch((e) => {
|
||||
console.error('Failed to delete widget', e);
|
||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||
|
||||
|
@ -669,6 +685,17 @@ export default class AppTile extends React.Component {
|
|||
}
|
||||
|
||||
_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.
|
||||
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
|
||||
Object.assign(document.createElement('a'),
|
||||
|
|
|
@ -26,6 +26,7 @@ import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import escapeHtml from "escape-html";
|
||||
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
|
||||
// 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,
|
||||
}, this.loadNextEvent);
|
||||
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
|
|
@ -18,11 +18,12 @@ import React from 'react';
|
|||
import * as sdk from '../../../index';
|
||||
import PropTypes from 'prop-types';
|
||||
import { _t } from '../../../languageHandler';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
const RoomDirectoryButton = function(props) {
|
||||
const ActionButton = sdk.getComponent('elements.ActionButton');
|
||||
return (
|
||||
<ActionButton action="view_room_directory"
|
||||
<ActionButton action={Action.ViewRoomDirectory}
|
||||
mouseOverAction={props.callout ? "callout_room_directory" : null}
|
||||
label={_t("Room directory")}
|
||||
iconPath={require("../../../../res/img/icons-directory.svg")}
|
||||
|
|
|
@ -94,7 +94,7 @@ export default class Tooltip extends React.Component<IProps> {
|
|||
return style;
|
||||
}
|
||||
|
||||
private renderTooltip() {
|
||||
private renderTooltip = () => {
|
||||
// Add the parent's position to the tooltips, so it's correctly
|
||||
// positioned, also taking into account any window zoom
|
||||
// NOTE: The additional 6 pixels for the left position, is to take account of the
|
||||
|
|
|
@ -27,8 +27,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
|
|||
if (!data) {
|
||||
throw new Error(`Emoji ${emoji} doesn't exist in emojibase`);
|
||||
}
|
||||
// Prefer our unicode value for quick reactions as we sometimes use variation selectors.
|
||||
return Object.assign({}, data, { unicode: emoji });
|
||||
return data;
|
||||
});
|
||||
|
||||
class QuickReactions extends React.Component {
|
||||
|
|
|
@ -359,6 +359,8 @@ export default class BasicMessageEditor extends React.Component {
|
|||
}
|
||||
|
||||
_onSelectionChange = () => {
|
||||
const {isEmpty} = this.props.model;
|
||||
|
||||
this._refreshLastCaretIfNeeded();
|
||||
const selection = document.getSelection();
|
||||
if (this._hasTextSelected && selection.isCollapsed) {
|
||||
|
@ -366,7 +368,7 @@ export default class BasicMessageEditor extends React.Component {
|
|||
if (this._formatBarRef) {
|
||||
this._formatBarRef.hide();
|
||||
}
|
||||
} else if (!selection.isCollapsed) {
|
||||
} else if (!selection.isCollapsed && !isEmpty) {
|
||||
this._hasTextSelected = true;
|
||||
if (this._formatBarRef) {
|
||||
const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
|
||||
|
|
|
@ -31,6 +31,7 @@ import {EventStatus} from 'matrix-js-sdk';
|
|||
import BasicMessageComposer from "./BasicMessageComposer";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
function _isReply(mxEvent) {
|
||||
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});
|
||||
} else {
|
||||
dis.dispatch({action: 'edit_event', event: null});
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
|
@ -165,7 +166,7 @@ export default class EditMessageComposer extends React.Component {
|
|||
|
||||
_cancelEdit = () => {
|
||||
dis.dispatch({action: "edit_event", event: null});
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
}
|
||||
|
||||
_isContentModified(newContent) {
|
||||
|
@ -195,7 +196,7 @@ export default class EditMessageComposer extends React.Component {
|
|||
|
||||
// close the event editing and focus composer
|
||||
dis.dispatch({action: "edit_event", event: null});
|
||||
dis.dispatch({action: 'focus_composer'});
|
||||
dis.fire(Action.FocusComposer);
|
||||
};
|
||||
|
||||
_cancelPreviousPendingEdit() {
|
||||
|
|
|
@ -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 Velociraptor from "../../../Velociraptor";
|
||||
import * as sdk from "../../../index";
|
||||
import {toRem} from "../../../utils/units";
|
||||
import {toPx} from "../../../utils/units";
|
||||
|
||||
let bounce = false;
|
||||
try {
|
||||
|
@ -149,7 +149,7 @@ export default createReactClass({
|
|||
// start at the old height and in the old h pos
|
||||
|
||||
startStyles.push({ top: startTopOffset+"px",
|
||||
left: toRem(oldInfo.left) });
|
||||
left: toPx(oldInfo.left) });
|
||||
|
||||
const reorderTransitionOpts = {
|
||||
duration: 100,
|
||||
|
@ -182,7 +182,7 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
const style = {
|
||||
left: toRem(this.props.leftOffset),
|
||||
left: toPx(this.props.leftOffset),
|
||||
top: '0px',
|
||||
visibility: this.props.hidden ? 'hidden' : 'visible',
|
||||
};
|
||||
|
|
|
@ -18,18 +18,17 @@ limitations under the License.
|
|||
|
||||
import * as React from "react";
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import { Layout } from '../../../resizer/distributors/roomsublist2';
|
||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||
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 { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { Dispatcher } from "flux";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import RoomSublist2 from "./RoomSublist2";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { IFilterCondition } from "../../../stores/room-list/filters/IFilterCondition";
|
||||
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -50,6 +49,7 @@ interface IProps {
|
|||
|
||||
interface IState {
|
||||
sublists: ITagMap;
|
||||
layouts: Map<TagID, ListLayout>;
|
||||
}
|
||||
|
||||
const TAG_ORDER: TagID[] = [
|
||||
|
@ -96,7 +96,7 @@ const TAG_AESTHETICS: {
|
|||
defaultHidden: false,
|
||||
},
|
||||
[DefaultTagID.DM]: {
|
||||
sectionLabel: _td("Direct Messages"),
|
||||
sectionLabel: _td("People"),
|
||||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
addRoomLabel: _td("Start chat"),
|
||||
|
@ -127,19 +127,15 @@ const TAG_AESTHETICS: {
|
|||
};
|
||||
|
||||
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();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {sublists: {}};
|
||||
this.loadSublistSizes();
|
||||
this.prepareLayouts();
|
||||
this.state = {
|
||||
sublists: {},
|
||||
layouts: new Map<TagID, ListLayout>(),
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||
|
@ -158,49 +154,16 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => {
|
||||
console.log("new lists", store.orderedLists);
|
||||
this.setState({sublists: store.orderedLists});
|
||||
});
|
||||
}
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store: RoomListStore2) => {
|
||||
const newLists = store.orderedLists;
|
||||
console.log("new lists", newLists);
|
||||
|
||||
private loadSublistSizes() {
|
||||
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();
|
||||
const layoutMap = new Map<TagID, ListLayout>();
|
||||
for (const tagId of Object.keys(newLists)) {
|
||||
layoutMap.set(tagId, new ListLayout(tagId));
|
||||
}
|
||||
}, 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,
|
||||
this.setState({sublists: newLists, layouts: layoutMap});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -226,16 +189,20 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
||||
|
||||
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
||||
components.push(<RoomSublist2
|
||||
key={`sublist-${orderedTagId}`}
|
||||
forRooms={true}
|
||||
rooms={orderedRooms}
|
||||
startAsHidden={aesthetics.defaultHidden}
|
||||
label={_t(aesthetics.sectionLabel)}
|
||||
onAddRoom={onAddRoomFn}
|
||||
addRoomLabel={aesthetics.addRoomLabel}
|
||||
isInvite={aesthetics.isInvite}
|
||||
/>);
|
||||
components.push(
|
||||
<RoomSublist2
|
||||
key={`sublist-${orderedTagId}`}
|
||||
forRooms={true}
|
||||
rooms={orderedRooms}
|
||||
startAsHidden={aesthetics.defaultHidden}
|
||||
label={_t(aesthetics.sectionLabel)}
|
||||
onAddRoom={onAddRoomFn}
|
||||
addRoomLabel={aesthetics.addRoomLabel}
|
||||
isInvite={aesthetics.isInvite}
|
||||
layout={this.state.layouts.get(orderedTagId)}
|
||||
showMessagePreviews={orderedTagId === DefaultTagID.DM}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return components;
|
||||
|
@ -250,7 +217,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
|
|||
onFocus={this.props.onFocus}
|
||||
onBlur={this.props.onBlur}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
className="mx_RoomList"
|
||||
className="mx_RoomList2"
|
||||
role="tree"
|
||||
aria-label={_t("Rooms")}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
|
|
|
@ -20,14 +20,13 @@ import * as React from "react";
|
|||
import { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from 'classnames';
|
||||
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
|
||||
import * as FormattingUtils from '../../../utils/FormattingUtils';
|
||||
import RoomTile2 from "./RoomTile2";
|
||||
import { ResizableBox, ResizeCallbackData } from "react-resizable";
|
||||
import { ListLayout } from "../../../stores/room-list/ListLayout";
|
||||
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -42,12 +41,13 @@ interface IProps {
|
|||
rooms?: Room[];
|
||||
startAsHidden: boolean;
|
||||
label: string;
|
||||
showMessagePreviews: boolean;
|
||||
onAddRoom?: () => void;
|
||||
addRoomLabel: string;
|
||||
isInvite: boolean;
|
||||
layout: ListLayout;
|
||||
|
||||
// TODO: Collapsed state
|
||||
// TODO: Height
|
||||
// TODO: Group invites
|
||||
// TODO: Calls
|
||||
// TODO: forceExpand?
|
||||
|
@ -56,17 +56,19 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
notificationState: ListNotificationState;
|
||||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef();
|
||||
|
||||
public setHeight(size: number) {
|
||||
// TODO: Do a thing (maybe - height changes are different in FTUE)
|
||||
}
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
private hasTiles(): boolean {
|
||||
return this.numTiles > 0;
|
||||
this.state = {
|
||||
notificationState: new ListNotificationState(this.props.isInvite),
|
||||
};
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
|
@ -74,17 +76,39 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
return (this.props.rooms || []).length;
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
this.state.notificationState.setRooms(this.props.rooms);
|
||||
}
|
||||
|
||||
private onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
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[] {
|
||||
const tiles: React.ReactElement[] = [];
|
||||
|
||||
if (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 {
|
||||
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: 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 (
|
||||
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
|
@ -118,68 +126,37 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
// 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;
|
||||
if (!!this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSubList_addRoom"
|
||||
title={this.props.addRoomLabel || _t("Add room")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>;
|
||||
|
||||
// TODO: Aux button
|
||||
// let addRoomButton = null;
|
||||
// if (!!this.props.onAddRoom) {
|
||||
// addRoomButton = (
|
||||
// <AccessibleTooltipButton
|
||||
// tabIndex={tabIndex}
|
||||
// onClick={this.onAddRoom}
|
||||
// className="mx_RoomSublist2_addButton"
|
||||
// title={this.props.addRoomLabel || _t("Add room")}
|
||||
// />
|
||||
// );
|
||||
// }
|
||||
|
||||
// TODO: a11y (see old component)
|
||||
return (
|
||||
<div className={"mx_RoomSubList_labelContainer"}>
|
||||
<div className={"mx_RoomSublist2_headerContainer"}>
|
||||
<AccessibleButton
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className={"mx_RoomSubList_label"}
|
||||
className={"mx_RoomSublist2_headerText"}
|
||||
role="treeitem"
|
||||
aria-level="1"
|
||||
>
|
||||
{chevron}
|
||||
<span>{this.props.label}</span>
|
||||
</AccessibleButton>
|
||||
{badge}
|
||||
{addRoomButton}
|
||||
<div className="mx_RoomSublist2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
|
@ -195,19 +172,65 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
|
|||
|
||||
const classes = classNames({
|
||||
// TODO: Proper collapse support
|
||||
'mx_RoomSubList': true,
|
||||
'mx_RoomSubList_hidden': false, // len && isCollapsed
|
||||
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
|
||||
'mx_RoomSublist2': true,
|
||||
'mx_RoomSublist2_collapsed': false, // len && isCollapsed
|
||||
});
|
||||
|
||||
let content = null;
|
||||
if (tiles.length > 0) {
|
||||
// TODO: Lazy list rendering
|
||||
// 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 = (
|
||||
<IndicatorScrollbar className='mx_RoomSubList_scroll'>
|
||||
{tiles}
|
||||
</IndicatorScrollbar>
|
||||
<ResizableBox
|
||||
width={-1}
|
||||
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 AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||
import Tooltip from "../../views/elements/Tooltip";
|
||||
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 * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
||||
import ActiveRoomObserver from "../../../ActiveRoomObserver";
|
||||
import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
|
||||
|
||||
/*******************************************************************
|
||||
* CAUTION *
|
||||
|
@ -41,30 +36,19 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
|
|||
* 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 {
|
||||
room: Room;
|
||||
showMessagePreview: boolean;
|
||||
|
||||
// TODO: Allow falsifying counts (for invites and stuff)
|
||||
// TODO: Transparency? Was this ever used?
|
||||
// TODO: Incoming call boxes?
|
||||
}
|
||||
|
||||
interface INotificationState {
|
||||
symbol: string;
|
||||
color: NotificationColor;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
hover: boolean;
|
||||
notificationState: INotificationState;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
|
@ -86,86 +70,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
|
||||
this.state = {
|
||||
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);
|
||||
this.props.room.on("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.props.room.on("Room.redaction", this.handleRoomEventUpdate);
|
||||
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
|
||||
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
if (this.props.room) {
|
||||
this.props.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
|
||||
this.props.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
|
||||
this.props.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
|
||||
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
|
||||
}
|
||||
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 = () => {
|
||||
|
@ -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 {
|
||||
// TODO: Collapsed state
|
||||
// TODO: Invites
|
||||
// TODO: a11y proper
|
||||
// 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({
|
||||
'mx_RoomTile': true,
|
||||
// 'mx_RoomTile_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,
|
||||
'mx_RoomTile2': true,
|
||||
'mx_RoomTile2_selected': this.state.selected,
|
||||
});
|
||||
|
||||
const avatarClasses = classNames({
|
||||
'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>;
|
||||
}
|
||||
const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
|
||||
|
||||
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||
let name = this.props.room.name;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
const nameClasses = classNames({
|
||||
'mx_RoomTile_name': true,
|
||||
'mx_RoomTile_invite': this.roomIsInvite,
|
||||
'mx_RoomTile_badgeShown': hasBadge,
|
||||
});
|
||||
|
||||
// TODO: Support collapsed state properly
|
||||
let tooltip = null;
|
||||
if (false) { // isCollapsed
|
||||
if (this.state.hover) {
|
||||
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} />
|
||||
}
|
||||
// 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({
|
||||
"mx_RoomTile2_name": true,
|
||||
"mx_RoomTile2_nameWithPreview": !!messagePreview,
|
||||
"mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
|
||||
});
|
||||
|
||||
const avatarSize = 32;
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTile}>
|
||||
|
@ -254,20 +153,18 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
|
|||
onClick={this.onTileClick}
|
||||
role="treeitem"
|
||||
>
|
||||
<div className={avatarClasses}>
|
||||
<div className="mx_RoomTile_avatar_container">
|
||||
<RoomAvatar room={this.props.room} width={24} height={24}/>
|
||||
</div>
|
||||
<div className="mx_RoomTile2_avatarContainer">
|
||||
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize}/>
|
||||
</div>
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div className="mx_RoomTile_labelContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
<div className="mx_RoomTile2_nameContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
{messagePreview}
|
||||
</div>
|
||||
<div className="mx_RoomTile2_badgeContainer">
|
||||
{badge}
|
||||
</div>
|
||||
{tooltip}
|
||||
</AccessibleButton>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
|
|
|
@ -44,6 +44,7 @@ import {Key} from "../../../Keyboard";
|
|||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||
import RateLimitedFunc from '../../../ratelimitedfunc';
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
|
||||
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
|
||||
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
|
||||
|
@ -364,7 +365,7 @@ export default class SendMessageComposer extends React.Component {
|
|||
onAction = (payload) => {
|
||||
switch (payload.action) {
|
||||
case 'reply_to_event':
|
||||
case 'focus_composer':
|
||||
case Action.FocusComposer:
|
||||
this._editorRef && this._editorRef.focus();
|
||||
break;
|
||||
case 'insert_mention':
|
||||
|
|
|
@ -62,7 +62,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
super(props);
|
||||
|
||||
this.state = {
|
||||
fontSize: SettingsStore.getValue("fontSize", null).toString(),
|
||||
fontSize: (SettingsStore.getValue("baseFontSize", null) + FontWatcher.SIZE_DIFF).toString(),
|
||||
...this.calculateThemeState(),
|
||||
customThemeUrl: "",
|
||||
customThemeMessage: {isError: false, text: ""},
|
||||
|
@ -132,13 +132,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
|
||||
private onFontSizeChanged = (size: number): void => {
|
||||
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> => {
|
||||
const parsedSize = parseFloat(value);
|
||||
const min = FontWatcher.MIN_SIZE;
|
||||
const max = FontWatcher.MAX_SIZE;
|
||||
const min = FontWatcher.MIN_SIZE + FontWatcher.SIZE_DIFF;
|
||||
const max = FontWatcher.MAX_SIZE + FontWatcher.SIZE_DIFF;
|
||||
|
||||
if (isNaN(parsedSize)) {
|
||||
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})};
|
||||
}
|
||||
|
||||
|
@ -275,7 +281,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
values={[13, 15, 16, 18, 20]}
|
||||
value={parseInt(this.state.fontSize, 10)}
|
||||
onSelectionChange={this.onFontSizeChanged}
|
||||
displayFunc={value => ""}
|
||||
displayFunc={_ => ""}
|
||||
disabled={this.state.useCustomFontSize}
|
||||
/>
|
||||
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
|
||||
|
@ -284,9 +290,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
name="useCustomFontSize"
|
||||
level={SettingLevel.ACCOUNT}
|
||||
onChange={(checked) => this.setState({useCustomFontSize: checked})}
|
||||
useCheckbox={true}
|
||||
/>
|
||||
<Field
|
||||
type="text"
|
||||
type="number"
|
||||
label={_t("Font size")}
|
||||
autoComplete="off"
|
||||
placeholder={this.state.fontSize.toString()}
|
||||
|
@ -295,6 +302,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
|
|||
onValidate={this.onValidateFontSize}
|
||||
onChange={(value) => this.setState({fontSize: value.target.value})}
|
||||
disabled={!this.state.useCustomFontSize}
|
||||
className="mx_SettingsTab_customFontSizeField"
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
@ -36,9 +36,15 @@ export enum Action {
|
|||
|
||||
/**
|
||||
* Open the user settings. No additional payload information required.
|
||||
* Optionally can include an OpenToTabPayload.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
@ -53,4 +59,14 @@ export enum Action {
|
|||
* Provide status information for an ongoing update check. Should be used with a CheckUpdatesPayload.
|
||||
*/
|
||||
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",
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
||||
// @ts-ignore - import * as EMOJIBASE actually breaks this
|
||||
import EMOJIBASE from 'emojibase-data/en/compact.json';
|
||||
|
||||
export interface IEmoji {
|
||||
|
@ -63,6 +62,8 @@ export const DATA_BY_CATEGORY = {
|
|||
"flags": [],
|
||||
};
|
||||
|
||||
const ZERO_WIDTH_JOINER = "\u200D";
|
||||
|
||||
// Store various mappings from unicode/emoticon/shortcode to the Emoji objects
|
||||
EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
|
||||
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
|
||||
|
@ -70,7 +71,8 @@ EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
|
|||
DATA_BY_CATEGORY[categoryId].push(emoji);
|
||||
}
|
||||
// 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
|
||||
// The 'unicode' field that we use in emojibase has either
|
||||
|
|
|
@ -1090,6 +1090,7 @@
|
|||
"Low priority": "Low priority",
|
||||
"Historical": "Historical",
|
||||
"System Alerts": "System Alerts",
|
||||
"People": "People",
|
||||
"This room": "This room",
|
||||
"Joining room …": "Joining room …",
|
||||
"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>",
|
||||
"Not now": "Not now",
|
||||
"Don't ask me again": "Don't ask me again",
|
||||
"Jump to first unread room.": "Jump to first unread room.",
|
||||
"Jump to first invite.": "Jump to first invite.",
|
||||
"Add room": "Add room",
|
||||
"Show %(n)s more": "Show %(n)s more",
|
||||
"Options": "Options",
|
||||
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
||||
"%(count)s unread messages including mentions.|one": "1 unread mention.",
|
||||
|
@ -1959,6 +1958,7 @@
|
|||
"Explore": "Explore",
|
||||
"Filter": "Filter",
|
||||
"Filter rooms…": "Filter rooms…",
|
||||
"Explore rooms": "Explore rooms",
|
||||
"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.",
|
||||
"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… (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>.",
|
||||
"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>.",
|
||||
"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.",
|
||||
|
@ -2016,6 +2015,9 @@
|
|||
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
|
||||
"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>?",
|
||||
"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 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",
|
||||
|
@ -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|zero": "Uploading %(filename)s",
|
||||
"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",
|
||||
"Verify this login": "Verify this login",
|
||||
"Session verified": "Session verified",
|
||||
|
|
|
@ -290,6 +290,33 @@ export default class EventIndex extends EventEmitter {
|
|||
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.
|
||||
*
|
||||
|
@ -300,8 +327,7 @@ export default class EventIndex extends EventEmitter {
|
|||
|
||||
if (!this.isValidEvent(ev)) return;
|
||||
|
||||
const jsonEvent = ev.toJSON();
|
||||
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
||||
const e = this.eventToJson(ev);
|
||||
|
||||
const profile = {
|
||||
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
|
||||
// consume.
|
||||
const events = filteredEvents.map((ev) => {
|
||||
const jsonEvent = ev.toJSON();
|
||||
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
|
||||
const e = this.eventToJson(ev);
|
||||
|
||||
let profile = {};
|
||||
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) {
|
||||
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
|
||||
if (navigator.storage && navigator.storage.persisted) {
|
||||
|
|
|
@ -170,10 +170,10 @@ export const SETTINGS = {
|
|||
displayName: _td("Show info about bridges in room settings"),
|
||||
default: false,
|
||||
},
|
||||
"fontSize": {
|
||||
"baseFontSize": {
|
||||
displayName: _td("Font size"),
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
default: 15,
|
||||
default: 10,
|
||||
controller: new FontSizeController(),
|
||||
},
|
||||
"useCustomFontSize": {
|
||||
|
|
|
@ -20,8 +20,10 @@ import IWatcher from "./Watcher";
|
|||
import { toPx } from '../../utils/units';
|
||||
|
||||
export class FontWatcher implements IWatcher {
|
||||
public static readonly MIN_SIZE = 13;
|
||||
public static readonly MAX_SIZE = 20;
|
||||
public static readonly MIN_SIZE = 8;
|
||||
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;
|
||||
|
||||
|
@ -30,7 +32,7 @@ export class FontWatcher implements IWatcher {
|
|||
}
|
||||
|
||||
public start() {
|
||||
this.setRootFontSize(SettingsStore.getValue("fontSize"));
|
||||
this.setRootFontSize(SettingsStore.getValue("baseFontSize"));
|
||||
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);
|
||||
|
||||
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);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
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.
|
||||
The sticky room will remain in position on the room list regardless of other factors going on as typically
|
||||
clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms
|
||||
above the selected room at all times, where N is the number of rooms above the selected rooms when it was
|
||||
selected.
|
||||
When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
|
||||
From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
|
||||
manages which room is sticky. This is to ensure that all algorithms handle it the same.
|
||||
|
||||
For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one
|
||||
room above their selection at all times. If they receive another notification, and the tag ordering is
|
||||
specified as Recent, they'll see the new notification go to the top position, and the one that was previously
|
||||
there fall behind the sticky room.
|
||||
The sticky flag is simply to say it will not move higher or lower down the list while it is active. For
|
||||
example, if using the importance algorithm, the room would naturally become idle once viewed and thus
|
||||
would normally fly down the list out of sight. The sticky room concept instead holds it in place, never
|
||||
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
|
||||
tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another
|
||||
room, the previous sticky room gets recalculated to determine which category it needs to be in as the user
|
||||
could have been scrolled up while new messages were received.
|
||||
Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky
|
||||
room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and
|
||||
selects the middle room, they will see exactly one room above their selection at all times. If they
|
||||
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
|
||||
kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user
|
||||
selecting the third room (leaving 2 above it), and then having the rooms above it read on another device.
|
||||
This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain
|
||||
2 rooms above the sticky room.
|
||||
Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
|
||||
and thus the user can see a shift in what kinds of rooms move around their selection. An example would
|
||||
be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
|
||||
the rooms above it read on another device. This would result in 1 red room and 1 other kind of 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
|
||||
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 { IFilterCondition } from "./filters/IFilterCondition";
|
||||
import { TagWatcher } from "./TagWatcher";
|
||||
import RoomViewStore from "../RoomViewStore";
|
||||
|
||||
interface IState {
|
||||
tagsEnabled?: boolean;
|
||||
|
@ -62,6 +63,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
|
|||
|
||||
this.checkEnabled();
|
||||
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
|
||||
RoomViewStore.addListener(this.onRVSUpdate);
|
||||
}
|
||||
|
||||
public get orderedLists(): ITagMap {
|
||||
|
@ -93,6 +95,23 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
|
|||
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) {
|
||||
if (payload.action === 'MatrixActions.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");
|
||||
await this.readAndCacheSettingsFromStore();
|
||||
await this.regenerateAllLists();
|
||||
this.onRVSUpdate(); // fake an RVS update to adjust sticky room, if needed
|
||||
}
|
||||
|
||||
// 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
|
||||
// a room update (we probably read the room on a different device).
|
||||
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
|
||||
// TODO: Update room now that it's been read
|
||||
console.log(payload);
|
||||
console.log(`[RoomListDebug] Got own read receipt in ${payload.event.roomId}`);
|
||||
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;
|
||||
}
|
||||
} else if (payload.action === 'MatrixActions.Room.tags') {
|
||||
// TODO: Update room from tags
|
||||
console.log(payload);
|
||||
const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||
console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
|
||||
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
|
||||
} else if (payload.action === 'MatrixActions.Room.timeline') {
|
||||
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 :(
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
||||
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
|
||||
// TODO: Update DMs
|
||||
console.log(payload);
|
||||
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||
console.log(`[RoomListDebug] Received updated DM map`);
|
||||
const dmMap = eventPayload.event.getContent();
|
||||
for (const userId of Object.keys(dmMap)) {
|
||||
const roomIds = dmMap[userId];
|
||||
for (const roomId of roomIds) {
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
if (!room) {
|
||||
console.warn(`${roomId} was found in DMs but the room is not in the store`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
|
||||
// the user to have hundreds of rooms to update in one event. As such, we just hammer
|
||||
// away at updates until the problem is solved. If we were expecting more than a couple
|
||||
// of rooms to be updated at once, we would consider batching the rooms up.
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
|
||||
}
|
||||
}
|
||||
} else if (payload.action === 'MatrixActions.Room.myMembership') {
|
||||
// TODO: Improve new room check
|
||||
const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||
if (!membershipPayload.oldMembership && membershipPayload.membership === "join") {
|
||||
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;
|
||||
}
|
||||
|
||||
// TODO: Update room from membership change
|
||||
console.log(payload);
|
||||
} else if (payload.action === 'MatrixActions.Room') {
|
||||
// TODO: Improve new room check
|
||||
// const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||
// console.log(`[RoomListDebug] Handling new room ${roomPayload.room.roomId}`);
|
||||
// await this.algorithm.handleRoomUpdate(roomPayload.room, RoomUpdateCause.NewRoom);
|
||||
} else if (payload.action === 'view_room') {
|
||||
// TODO: Update sticky room
|
||||
console.log(payload);
|
||||
// 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 { ITagMap, ITagSortingMap } from "../models";
|
||||
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 { UPDATE_EVENT } from "../../../AsyncStore";
|
||||
import { ArrayUtil } from "../../../../utils/arrays";
|
||||
import { getEnumValues } from "../../../../utils/enums";
|
||||
|
||||
// TODO: Add locking support to avoid concurrent writes?
|
||||
|
||||
|
@ -30,6 +33,12 @@ import { EventEmitter } from "events";
|
|||
*/
|
||||
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
|
||||
* 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 {
|
||||
private _cachedRooms: ITagMap = {};
|
||||
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
|
||||
private filteredRooms: ITagMap = {};
|
||||
private _stickyRoom: IStickyRoom = null;
|
||||
|
||||
protected sortAlgorithms: ITagSortingMap;
|
||||
protected rooms: Room[] = [];
|
||||
|
@ -51,6 +62,15 @@ export abstract class Algorithm extends EventEmitter {
|
|||
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 {
|
||||
return this.allowedByFilter.size > 0;
|
||||
}
|
||||
|
@ -58,9 +78,14 @@ export abstract class Algorithm extends EventEmitter {
|
|||
protected set cachedRooms(val: ITagMap) {
|
||||
this._cachedRooms = val;
|
||||
this.recalculateFilteredRooms();
|
||||
this.recalculateStickyRoom();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -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() {
|
||||
if (!this.hasFilters) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn("Recalculating filtered room list");
|
||||
const allowedByFilters = new Set<Room>();
|
||||
const filters = Array.from(this.allowedByFilter.keys());
|
||||
const orderedFilters = new ArrayUtil(filters)
|
||||
.groupBy(f => f.relativePriority)
|
||||
.orderBy(getEnumValues(FilterPriority))
|
||||
.value;
|
||||
const newMap: ITagMap = {};
|
||||
for (const tagId of Object.keys(this.cachedRooms)) {
|
||||
// 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
|
||||
// to the rooms we know will be deduped by the Set.
|
||||
const rooms = this.cachedRooms[tagId];
|
||||
const remainingRooms = rooms.map(r => r).filter(r => !allowedByFilters.has(r));
|
||||
const allowedRoomsInThisTag = [];
|
||||
for (const filter of filters) {
|
||||
let remainingRooms = rooms.map(r => r);
|
||||
let allowedRoomsInThisTag = [];
|
||||
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));
|
||||
for (const room of filteredRooms) {
|
||||
const idx = remainingRooms.indexOf(room);
|
||||
if (idx >= 0) remainingRooms.splice(idx, 1);
|
||||
allowedByFilters.add(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}`);
|
||||
}
|
||||
|
||||
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.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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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 {
|
||||
if (!this.hasFilters) {
|
||||
return this.cachedRooms;
|
||||
return this._cachedStickyRooms || this.cachedRooms;
|
||||
}
|
||||
return this.filteredRooms;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@ limitations under the License.
|
|||
|
||||
import { Algorithm } from "./Algorithm";
|
||||
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 { sortRoomsWithAlgorithm } from "../tag-sorting";
|
||||
import * as Unread from '../../../../Unread';
|
||||
|
@ -82,15 +82,14 @@ export class ImportanceAlgorithm extends Algorithm {
|
|||
// 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.
|
||||
//
|
||||
// Tags are fed into the algorithmic functions from the Algorithm superclass,
|
||||
// which cause subsequent updates to the room list itself. Categories within
|
||||
// those tags are tracked as index numbers within the array (zero = top), with
|
||||
// each sticky room being tracked separately. Internally, the category index
|
||||
// can be found from `this.indices[tag][category]` and the sticky room information
|
||||
// from `this.stickyRoom`.
|
||||
// can be found from `this.indices[tag][category]`.
|
||||
//
|
||||
// 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
|
||||
|
@ -102,17 +101,6 @@ export class ImportanceAlgorithm extends Algorithm {
|
|||
[tag: TagID]: ICategoryIndex;
|
||||
} = {};
|
||||
|
||||
// TODO: Use this (see docs above)
|
||||
private stickyRoom: {
|
||||
roomId: string;
|
||||
tag: TagID;
|
||||
fromTop: number;
|
||||
} = {
|
||||
roomId: null,
|
||||
tag: null,
|
||||
fromTop: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
console.log("Constructed an ImportanceAlgorithm");
|
||||
|
@ -189,12 +177,25 @@ export class ImportanceAlgorithm extends Algorithm {
|
|||
}
|
||||
|
||||
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) {
|
||||
// TODO: Be smarter and insert rather than regen the planet.
|
||||
await this.setKnownRooms([room, ...this.rooms]);
|
||||
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];
|
||||
if (!tags) {
|
||||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||
|
@ -251,6 +252,8 @@ export class ImportanceAlgorithm extends Algorithm {
|
|||
taggedRooms.splice(startIdx, 0, ...sorted);
|
||||
|
||||
// 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;
|
||||
}
|
||||
return changed;
|
||||
|
|
|
@ -46,11 +46,17 @@ export class NaturalAlgorithm extends Algorithm {
|
|||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||
return false;
|
||||
}
|
||||
let changed = false;
|
||||
for (const tag of tags) {
|
||||
// TODO: Optimize this loop to avoid useless operations
|
||||
// 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]);
|
||||
|
||||
// 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 { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition";
|
||||
import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
|
||||
import { Group } from "matrix-js-sdk/src/models/group";
|
||||
import { EventEmitter } from "events";
|
||||
import GroupStore from "../../GroupStore";
|
||||
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
|
||||
* 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[] = [];
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
public get relativePriority(): FilterPriority {
|
||||
// Lowest priority so we can coarsely find rooms.
|
||||
return FilterPriority.Lowest;
|
||||
}
|
||||
|
||||
public isVisible(room: Room): boolean {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,6 +19,12 @@ import { EventEmitter } from "events";
|
|||
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* condition.
|
||||
|
|
|
@ -15,7 +15,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
|
@ -29,6 +29,11 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
|
|||
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 {
|
||||
return this._search;
|
||||
}
|
||||
|
|
|
@ -38,6 +38,8 @@ export type TagID = string | DefaultTagID;
|
|||
|
||||
export enum RoomUpdateCause {
|
||||
Timeline = "TIMELINE",
|
||||
RoomRead = "ROOM_READ", // TODO: Use this.
|
||||
PossibleTagChange = "POSSIBLE_TAG_CHANGE",
|
||||
ReadReceipt = "READ_RECEIPT",
|
||||
NewRoom = "NEW_ROOM",
|
||||
RoomRemoved = "ROOM_REMOVED",
|
||||
}
|
||||
|
|
|
@ -62,7 +62,7 @@ function setCustomThemeVars(customTheme) {
|
|||
}
|
||||
}
|
||||
|
||||
function getCustomTheme(themeName) {
|
||||
export function getCustomTheme(themeName) {
|
||||
// set css variables
|
||||
const customThemes = SettingsStore.getValue("custom_themes");
|
||||
if (!customThemes) {
|
||||
|
|
|
@ -66,5 +66,5 @@ export const showToast = (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 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");
|
||||
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
|
||||
* e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B
|
||||
*/
|
||||
export function formatCount(count) {
|
||||
if (count < 1000) return count;
|
||||
export function formatCount(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return (count / 1000).toFixed(1) + "K";
|
||||
if (count < 100000) return (count / 1000).toFixed(0) + "K";
|
||||
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.
|
||||
* e.g: 1000 => 1,000
|
||||
*/
|
||||
export function formatCountLong(count) {
|
||||
export function formatCountLong(count: number): string {
|
||||
const formatter = new Intl.NumberFormat();
|
||||
return formatter.format(count)
|
||||
}
|
||||
|
@ -43,7 +43,7 @@ export function formatCountLong(count) {
|
|||
* format a size in bytes into a human readable form
|
||||
* 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';
|
||||
|
||||
const k = 1024;
|
||||
|
@ -62,7 +62,7 @@ export function formatBytes(bytes, decimals = 2) {
|
|||
*
|
||||
* @return {string}
|
||||
*/
|
||||
export function formatCryptoKey(key) {
|
||||
export function formatCryptoKey(key: string): string {
|
||||
return key.match(/.{1,4}/g).join(" ");
|
||||
}
|
||||
/**
|
||||
|
@ -72,7 +72,7 @@ export function formatCryptoKey(key) {
|
|||
*
|
||||
* @return {number}
|
||||
*/
|
||||
export function hashCode(str) {
|
||||
export function hashCode(str: string): number {
|
||||
let hash = 0;
|
||||
let i;
|
||||
let chr;
|
||||
|
@ -87,7 +87,7 @@ export function hashCode(str) {
|
|||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
export function getUserNameColorClass(userId) {
|
||||
export function getUserNameColorClass(userId: string): string {
|
||||
const colorNumber = (hashCode(userId) % 8) + 1;
|
||||
return `mx_Username_color${colorNumber}`;
|
||||
}
|
||||
|
@ -103,7 +103,7 @@ export function getUserNameColorClass(userId) {
|
|||
* @returns {string} a string constructed by joining `items` with a comma
|
||||
* 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(
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface IDisposable {
|
||||
dispose(): void;
|
||||
export interface IDestroyable {
|
||||
destroy(): void;
|
||||
}
|
|
@ -421,6 +421,7 @@ export default class WidgetUtils {
|
|||
if (WidgetType.JITSI.matches(appType)) {
|
||||
capWhitelist.push(Capability.AlwaysOnScreen);
|
||||
}
|
||||
capWhitelist.push(Capability.ReceiveTerminate);
|
||||
|
||||
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)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import { randomString } from "matrix-js-sdk/src/randomstring";
|
||||
import { EventEmitter } from "events";
|
||||
|
||||
export enum Capability {
|
||||
Screenshot = "m.capability.screenshot",
|
||||
Sticker = "m.sticker",
|
||||
AlwaysOnScreen = "m.always_on_screen",
|
||||
ReceiveTerminate = "im.vector.receive_terminate",
|
||||
}
|
||||
|
||||
export enum KnownWidgetActions {
|
||||
|
@ -34,6 +36,7 @@ export enum KnownWidgetActions {
|
|||
ReceiveOpenIDCredentials = "openid_credentials",
|
||||
SetAlwaysOnScreen = "set_always_on_screen",
|
||||
ClientReady = "im.vector.ready",
|
||||
Terminate = "im.vector.terminate",
|
||||
}
|
||||
|
||||
export type WidgetAction = KnownWidgetActions | string;
|
||||
|
@ -62,8 +65,13 @@ export interface FromWidgetRequest extends WidgetRequest {
|
|||
|
||||
/**
|
||||
* 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 inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {};
|
||||
private readyPromise: Promise<any>;
|
||||
|
@ -75,6 +83,8 @@ export class WidgetApi {
|
|||
public expectingExplicitReady = false;
|
||||
|
||||
constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
|
||||
super();
|
||||
|
||||
this.origin = new URL(currentUrl).origin;
|
||||
|
||||
this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
|
||||
|
@ -98,6 +108,17 @@ export class WidgetApi {
|
|||
|
||||
// Automatically acknowledge so we can move on
|
||||
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 {
|
||||
console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue