Merge branch 'develop' into poljar/seshat-search-pagination

This commit is contained in:
Damir Jelić 2020-06-10 13:34:09 +02:00
commit eaca8310d3
82 changed files with 3355 additions and 1313 deletions

View file

@ -1,7 +1,20 @@
Changes in [2.7.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.1) (2020-06-05)
===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.0...v2.7.1)
* Upgrade to JS SDK 6.2.1
* Fix exceptions from Tooltip
[\#4716](https://github.com/matrix-org/matrix-react-sdk/pull/4716)
* Fix not being able to dismiss new login toasts
[\#4715](https://github.com/matrix-org/matrix-react-sdk/pull/4715)
* Fix compact layout regression
[\#4714](https://github.com/matrix-org/matrix-react-sdk/pull/4714)
Changes in [2.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.0) (2020-06-04) Changes in [2.7.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v2.7.0) (2020-06-04)
=================================================================================================== ===================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.0-rc.2...v2.7.0) [Full Changelog](https://github.com/matrix-org/matrix-react-sdk/compare/v2.7.0-rc.2...v2.7.0)
* Upgrade to JS SDK 6.2.0
* Prevent (double) 4S bootstrap from RestoreKeyBackupDialog * Prevent (double) 4S bootstrap from RestoreKeyBackupDialog
[\#4703](https://github.com/matrix-org/matrix-react-sdk/pull/4703) [\#4703](https://github.com/matrix-org/matrix-react-sdk/pull/4703)
* Fix checkbox bleed * Fix checkbox bleed

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "2.7.0", "version": "2.7.1",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -93,6 +93,7 @@
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^16.9.0", "react-dom": "^16.9.0",
"react-focus-lock": "^2.2.1", "react-focus-lock": "^2.2.1",
"react-resizable": "^1.10.1",
"resize-observer-polyfill": "^1.5.0", "resize-observer-polyfill": "^1.5.0",
"sanitize-html": "^1.18.4", "sanitize-html": "^1.18.4",
"text-encoding-utf-8": "^1.0.1", "text-encoding-utf-8": "^1.0.1",

View file

@ -19,7 +19,7 @@ limitations under the License.
@import "./_font-sizes.scss"; @import "./_font-sizes.scss";
:root { :root {
font-size: 15px; font-size: 10px;
} }
html { html {

View file

@ -12,12 +12,14 @@
@import "./structures/_HeaderButtons.scss"; @import "./structures/_HeaderButtons.scss";
@import "./structures/_HomePage.scss"; @import "./structures/_HomePage.scss";
@import "./structures/_LeftPanel.scss"; @import "./structures/_LeftPanel.scss";
@import "./structures/_LeftPanel2.scss";
@import "./structures/_MainSplit.scss"; @import "./structures/_MainSplit.scss";
@import "./structures/_MatrixChat.scss"; @import "./structures/_MatrixChat.scss";
@import "./structures/_MyGroups.scss"; @import "./structures/_MyGroups.scss";
@import "./structures/_NotificationPanel.scss"; @import "./structures/_NotificationPanel.scss";
@import "./structures/_RightPanel.scss"; @import "./structures/_RightPanel.scss";
@import "./structures/_RoomDirectory.scss"; @import "./structures/_RoomDirectory.scss";
@import "./structures/_RoomSearch.scss";
@import "./structures/_RoomStatusBar.scss"; @import "./structures/_RoomStatusBar.scss";
@import "./structures/_RoomSubList.scss"; @import "./structures/_RoomSubList.scss";
@import "./structures/_RoomView.scss"; @import "./structures/_RoomView.scss";
@ -28,6 +30,7 @@
@import "./structures/_ToastContainer.scss"; @import "./structures/_ToastContainer.scss";
@import "./structures/_TopLeftMenuButton.scss"; @import "./structures/_TopLeftMenuButton.scss";
@import "./structures/_UploadBar.scss"; @import "./structures/_UploadBar.scss";
@import "./structures/_UserMenuButton.scss";
@import "./structures/_ViewSource.scss"; @import "./structures/_ViewSource.scss";
@import "./structures/auth/_CompleteSecurity.scss"; @import "./structures/auth/_CompleteSecurity.scss";
@import "./structures/auth/_Login.scss"; @import "./structures/auth/_Login.scss";
@ -169,6 +172,7 @@
@import "./views/rooms/_MemberList.scss"; @import "./views/rooms/_MemberList.scss";
@import "./views/rooms/_MessageComposer.scss"; @import "./views/rooms/_MessageComposer.scss";
@import "./views/rooms/_MessageComposerFormatBar.scss"; @import "./views/rooms/_MessageComposerFormatBar.scss";
@import "./views/rooms/_NotificationBadge.scss";
@import "./views/rooms/_PinnedEventTile.scss"; @import "./views/rooms/_PinnedEventTile.scss";
@import "./views/rooms/_PinnedEventsPanel.scss"; @import "./views/rooms/_PinnedEventsPanel.scss";
@import "./views/rooms/_PresenceLabel.scss"; @import "./views/rooms/_PresenceLabel.scss";
@ -177,9 +181,12 @@
@import "./views/rooms/_RoomDropTarget.scss"; @import "./views/rooms/_RoomDropTarget.scss";
@import "./views/rooms/_RoomHeader.scss"; @import "./views/rooms/_RoomHeader.scss";
@import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomList.scss";
@import "./views/rooms/_RoomList2.scss";
@import "./views/rooms/_RoomPreviewBar.scss"; @import "./views/rooms/_RoomPreviewBar.scss";
@import "./views/rooms/_RoomRecoveryReminder.scss"; @import "./views/rooms/_RoomRecoveryReminder.scss";
@import "./views/rooms/_RoomSublist2.scss";
@import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomTile.scss";
@import "./views/rooms/_RoomTile2.scss";
@import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss";
@import "./views/rooms/_SearchBar.scss"; @import "./views/rooms/_SearchBar.scss";
@import "./views/rooms/_SendMessageComposer.scss"; @import "./views/rooms/_SendMessageComposer.scss";

View file

@ -14,59 +14,59 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
$font-1px: 0.067rem; $font-1px: 0.1rem;
$font-1-5px: 0.100rem; $font-1-5px: 0.15rem;
$font-2px: 0.133rem; $font-2px: 0.2rem;
$font-3px: 0.200rem; $font-3px: 0.3rem;
$font-4px: 0.267rem; $font-4px: 0.4rem;
$font-5px: 0.333rem; $font-5px: 0.5rem;
$font-6px: 0.400rem; $font-6px: 0.6rem;
$font-7px: 0.467rem; $font-7px: 0.7rem;
$font-8px: 0.533rem; $font-8px: 0.8rem;
$font-9px: 0.600rem; $font-9px: 0.9rem;
$font-10px: 0.667rem; $font-10px: 1.0rem;
$font-10-4px: 0.693rem; $font-10-4px: 1.04rem;
$font-11px: 0.733rem; $font-11px: 1.1rem;
$font-12px: 0.800rem; $font-12px: 1.2rem;
$font-13px: 0.867rem; $font-13px: 1.3rem;
$font-14px: 0.933rem; $font-14px: 1.4rem;
$font-15px: 1.000rem; $font-15px: 1.5rem;
$font-16px: 1.067rem; $font-16px: 1.6rem;
$font-17px: 1.133rem; $font-17px: 1.7rem;
$font-18px: 1.200rem; $font-18px: 1.8rem;
$font-19px: 1.267rem; $font-19px: 1.9rem;
$font-20px: 1.3333333rem; $font-20px: 2.0rem;
$font-21px: 1.400rem; $font-21px: 2.1rem;
$font-22px: 1.467rem; $font-22px: 2.2rem;
$font-23px: 1.533rem; $font-23px: 2.3rem;
$font-24px: 1.600rem; $font-24px: 2.4rem;
$font-25px: 1.667rem; $font-25px: 2.5rem;
$font-26px: 1.733rem; $font-26px: 2.6rem;
$font-27px: 1.800rem; $font-27px: 2.7rem;
$font-28px: 1.867rem; $font-28px: 2.8rem;
$font-29px: 1.933rem; $font-29px: 2.9rem;
$font-30px: 2.000rem; $font-30px: 3.0rem;
$font-31px: 2.067rem; $font-31px: 3.1rem;
$font-32px: 2.133rem; $font-32px: 3.2rem;
$font-33px: 2.200rem; $font-33px: 3.3rem;
$font-34px: 2.267rem; $font-34px: 3.4rem;
$font-35px: 2.333rem; $font-35px: 3.5rem;
$font-36px: 2.400rem; $font-36px: 3.6rem;
$font-37px: 2.467rem; $font-37px: 3.7rem;
$font-38px: 2.533rem; $font-38px: 3.8rem;
$font-39px: 2.600rem; $font-39px: 3.9rem;
$font-40px: 2.667rem; $font-40px: 4.0rem;
$font-41px: 2.733rem; $font-41px: 4.1rem;
$font-42px: 2.800rem; $font-42px: 4.2rem;
$font-43px: 2.867rem; $font-43px: 4.3rem;
$font-44px: 2.933rem; $font-44px: 4.4rem;
$font-45px: 3.000rem; $font-45px: 4.5rem;
$font-46px: 3.067rem; $font-46px: 4.6rem;
$font-47px: 3.133rem; $font-47px: 4.7rem;
$font-48px: 3.200rem; $font-48px: 4.8rem;
$font-49px: 3.267rem; $font-49px: 4.9rem;
$font-50px: 3.333rem; $font-50px: 5.0rem;
$font-51px: 3.400rem; $font-51px: 5.1rem;
$font-52px: 3.467rem; $font-52px: 5.2rem;
$font-88px: 5.887rem; $font-88px: 8.8rem;
$font-400px: 26.667rem; $font-400px: 40rem;

View file

@ -0,0 +1,136 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO: Rename to mx_LeftPanel during replacement of old component
// TODO: Put these variables in the right place, or namespace them.
$tagPanelWidth: 70px;
$roomListMinimizedWidth: 50px;
.mx_LeftPanel2 {
background-color: $header-panel-bg-color;
min-width: 260px;
max-width: 50%;
// Create a row-based flexbox for the TagPanel and the room list
display: flex;
.mx_LeftPanel2_tagPanelContainer {
flex-grow: 0;
flex-shrink: 0;
flex-basis: $tagPanelWidth;
height: 100%;
// Create another flexbox so the TagPanel fills the container
display: flex;
// TagPanel handles its own CSS
}
// Note: The 'room list' in this context is actually everything that isn't the tag
// panel, such as the menu options, breadcrumbs, filtering, etc
.mx_LeftPanel2_roomListContainer {
width: calc(100% - $tagPanelWidth);
// Create another flexbox (this time a column) for the room list components
display: flex;
flex-direction: column;
.mx_LeftPanel2_userHeader {
padding: 14px 12px 20px; // 14px top, 12px sides, 20px bottom
// Create another flexbox column for the rows to stack within
display: flex;
flex-direction: column;
// There's 2 rows when breadcrumbs are present: the top bit and the breadcrumbs
.mx_LeftPanel2_headerRow {
// Create yet another flexbox, this time within the row, to ensure items stay
// aligned correctly. This is also a row-based flexbox.
display: flex;
align-items: center;
}
.mx_LeftPanel2_userAvatarContainer {
position: relative; // to make default avatars work
margin-right: 8px;
}
.mx_LeftPanel2_userName {
font-weight: 600;
font-size: $font-15px;
line-height: $font-20px;
flex: 1;
}
.mx_LeftPanel2_headerButtons {
// No special styles: the rest of the layout happens to make it work.
}
.mx_LeftPanel2_breadcrumbsContainer {
// TODO: Improve CSS for breadcrumbs (currently shoved into the view rather than placed)
width: 100%;
overflow: hidden;
}
}
.mx_LeftPanel2_filterContainer {
margin-left: 12px;
margin-right: 12px;
// Create a flexbox to organize the inputs
display: flex;
align-items: center;
.mx_RoomSearch_expanded + .mx_LeftPanel2_exploreButton {
// Cheaty way to return the occupied space to the filter input
margin: 0;
width: 0;
// Don't forget to hide the masked ::before icon
visibility: hidden;
}
.mx_LeftPanel2_exploreButton {
width: 28px;
height: 28px;
border-radius: 20px;
background-color: #fff; // TODO: Variable and theme
position: relative;
margin-left: 8px;
&::before {
content: '';
position: absolute;
top: 6px;
left: 6px;
width: 16px;
height: 16px;
mask-image: url('$(res)/img/feather-customised/compass.svg');
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $primary-fg-color;
}
}
}
.mx_LeftPanel2_actualRoomListContainer {
flex-grow: 1; // fill the available space
overflow-y: auto;
}
}
}

View file

@ -66,7 +66,7 @@ limitations under the License.
} }
/* not the left panel, and not the resize handle, so the roomview/groupview/... */ /* not the left panel, and not the resize handle, so the roomview/groupview/... */
.mx_MatrixChat > :not(.mx_LeftPanel_container):not(.mx_ResizeHandle) { .mx_MatrixChat > :not(.mx_LeftPanel_container):not(.mx_LeftPanel2):not(.mx_ResizeHandle) {
background-color: $primary-bg-color; background-color: $primary-bg-color;
flex: 1 1 0; flex: 1 1 0;

View file

@ -0,0 +1,70 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// Note: this component expects to be contained within a flexbox
.mx_RoomSearch {
flex: 1;
border-radius: 20px;
background-color: #fff; // TODO: Variable & theme
height: 26px;
padding: 2px;
// Create a flexbox for the icons (easier to manage)
display: flex;
align-items: center;
.mx_RoomSearch_icon {
width: 16px;
height: 16px;
mask: url('$(res)/img/feather-customised/search-input.svg');
mask-repeat: no-repeat;
background: $primary-fg-color;
margin-left: 7px;
}
.mx_RoomSearch_input {
border: none !important; // !important to override default app-wide styles
flex: 1 !important; // !important to override default app-wide styles
color: $primary-fg-color !important; // !important to override default app-wide styles
padding: 0;
height: 100%;
width: 100%;
font-size: $font-12px;
line-height: $font-16px;
&:not(.mx_RoomSearch_inputExpanded)::placeholder {
color: $primary-fg-color !important; // !important to override default app-wide styles
}
}
&.mx_RoomSearch_expanded {
.mx_RoomSearch_clearButton {
width: 16px;
height: 16px;
mask-image: url('$(res)/img/feather-customised/x.svg');
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $primary-fg-color;
margin-right: 8px;
}
}
.mx_RoomSearch_clearButton {
width: 0;
height: 0;
}
}

View file

@ -0,0 +1,162 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_UserMenuButton {
// No special styles on the button itself
}
.mx_UserMenuButton_contextMenu {
width: 231px;
// Put 20px of padding around the whole menu. We do this instead of a
// simple `padding: 20px` rule so the horizontal rules added by the
// optionLists is rendered correctly (full width).
> * {
padding-left: 20px;
padding-right: 20px;
&:first-child {
padding-top: 20px;
}
&:last-child {
padding-bottom: 20px;
}
}
.mx_UserMenuButton_contextMenu_header {
// Create a flexbox to organize the header a bit easier
display: flex;
align-items: center;
&:nth-child(n + 1) {
// The first header will have appropriate padding, subsequent ones need a margin.
margin-top: 10px;
}
.mx_UserMenuButton_contextMenu_name {
// Create another flexbox of columns to handle large user IDs
display: flex;
flex-direction: column;
// fit the container
flex: 1;
width: calc(100% - 40px); // 40px = 32px theme button + 8px margin to theme button
* {
// Automatically grow all subelements to fit the container
flex: 1;
width: 100%;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_UserMenuButton_contextMenu_displayName {
font-weight: bold;
font-size: $font-15px;
line-height: $font-20px;
}
.mx_UserMenuButton_contextMenu_userId {
font-size: $font-15px;
line-height: $font-24px;
}
}
.mx_UserMenuButton_contextMenu_themeButton {
min-width: 32px;
max-width: 32px;
width: 32px;
height: 32px;
margin-left: 8px;
border-radius: 32px;
background-color: $theme-button-bg-color;
cursor: pointer;
// to make alignment easier, create flexbox for the image
display: flex;
align-items: center;
justify-content: center;
}
}
.mx_UserMenuButton_contextMenu_optionList {
margin-top: 20px;
// This is a bit of a hack when we could just use a simple border-top property,
// however we have a (kinda) good reason for doing it this way: we need opacity.
// To get the right color, we need an opacity modifier which means we have to work
// around the problem. PostCSS doesn't support the opacity() function, and if we
// use something like postcss-functions we quickly run into an issue where the
// function we would define gets passed a CSS variable for custom themes, which
// can't be converted easily even when considering https://stackoverflow.com/a/41265350/7037379
//
// Therefore, we just hack in a line and border the thing ourselves
&::before {
border-top: 1px solid $primary-fg-color;
opacity: 0.1;
content: '';
// Counteract the padding problems (width: 100% ignores the 40px padding,
// unless we position it absolutely then it does the right thing).
width: 100%;
position: absolute;
left: 0;
}
ul {
list-style: none;
margin: 0;
padding: 0;
li {
margin: 0;
padding: 20px 0 0;
.mx_AccessibleButton {
text-decoration: none;
color: $primary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
// Create a flexbox to more easily define the list items
display: flex;
align-items: center;
img { // icons
width: 16px;
min-width: 16px;
max-width: 16px;
}
span { // labels
padding-left: 14px;
width: 100%;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
}
}
}

View file

@ -18,7 +18,6 @@ limitations under the License.
$left-gutter: 65px; $left-gutter: 65px;
.mx_GroupLayout { .mx_GroupLayout {
.mx_EventTile { .mx_EventTile {
> .mx_SenderProfile { > .mx_SenderProfile {
line-height: $font-17px; line-height: $font-17px;
@ -53,14 +52,14 @@ $left-gutter: 65px;
/* Compact layout overrides */ /* Compact layout overrides */
.mx_MatrixChat_useCompactLayout { .mx_MatrixChat_useCompactLayout {
.mx_EventTile_line, .mx_EventTile_reply {
padding-top: 0px;
padding-bottom: 0px;
}
.mx_EventTile { .mx_EventTile {
padding-top: 4px; padding-top: 4px;
.mx_EventTile_line, .mx_EventTile_reply {
padding-top: 0;
padding-bottom: 0;
}
&.mx_EventTile_info { &.mx_EventTile_info {
// same as the padding for non-compact .mx_EventTile.mx_EventTile_info // same as the padding for non-compact .mx_EventTile.mx_EventTile_info
padding-top: 0px; padding-top: 0px;

View file

@ -0,0 +1,72 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
.mx_NotificationBadge {
&:not(.mx_NotificationBadge_visible) {
display: none;
}
// Badges are structured a bit weirdly to work around issues with non-monospace
// font styles. The badge pill is actually a background div and the count floats
// within that. For example:
//
// ( 99+ ) <-- Rounded pill is a _bg class.
// ^- The count is an element floating within that.
&.mx_NotificationBadge_visible {
background-color: $roomtile2-badge-color;
margin-right: 14px;
// Create a flexbox to order the count a bit easier
display: flex;
align-items: center;
justify-content: center;
&.mx_NotificationBadge_highlighted {
// TODO: Use a more specific variable
background-color: $warning-color;
}
// These are the 3 background types
&.mx_NotificationBadge_dot {
width: 6px;
height: 6px;
border-radius: 6px;
margin-right: 18px;
}
&.mx_NotificationBadge_2char {
width: 16px;
height: 16px;
border-radius: 16px;
}
&.mx_NotificationBadge_3char {
width: 26px;
height: 16px;
border-radius: 16px;
}
// The following is the floating badge
.mx_NotificationBadge_count {
font-size: $font-10px;
line-height: $font-14px;
color: #fff; // TODO: Variable
}
}
}

View file

@ -15,6 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
.mx_RoomList.mx_RoomList2 {
overflow-y: auto;
}
.mx_RoomList { .mx_RoomList {
/* take up remaining space below TopLeftMenu */ /* take up remaining space below TopLeftMenu */
flex: 1; flex: 1;

View file

@ -0,0 +1,25 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO: Rename to mx_RoomList during replacement of old component
.mx_RoomList2 {
// Create a column-based flexbox for the sublists. That's pretty much all we have to
// worry about in this stylesheet.
display: flex;
flex-direction: column;
flex-wrap: wrap;
}

View file

@ -0,0 +1,80 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO: Rename to mx_RoomSublist during replacement of old component
// TODO: Just use the 3 selectors we need from this instead of importing it.
// We're going to end up with heavy modifications anyways.
@import "../../../../node_modules/react-resizable/css/styles.css";
.mx_RoomSublist2 {
// The sublist is a column of rows, essentially
display: flex;
flex-direction: column;
margin-left: 8px;
margin-top: 12px;
margin-bottom: 12px;
.mx_RoomSublist2_headerContainer {
// Create a flexbox to make ordering easy
display: flex;
align-items: center;
.mx_RoomSublist2_badgeContainer {
opacity: 0.8;
padding-right: 7px;
// Create another flexbox row because it's super easy to position the badge at
// the end this way.
display: flex;
align-items: center;
justify-content: flex-end;
}
.mx_RoomSublist2_headerText {
text-transform: uppercase;
opacity: 0.5;
line-height: $font-16px;
font-size: $font-12px;
padding-bottom: 8px;
width: 100%;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
.mx_RoomSublist2_resizeBox {
// Create another flexbox column for the tiles
display: flex;
flex-direction: column;
overflow: hidden;
.mx_RoomSublist2_showMoreButton {
height: 44px; // 1 room tile high
cursor: pointer;
// We create a flexbox to cheat at alignment
display: flex;
align-items: center;
}
}
}

View file

@ -0,0 +1,77 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
// TODO: Rename to mx_RoomTile during replacement of old component
// Note: the room tile expects to be in a flexbox column container
.mx_RoomTile2 {
width: calc(100% - 11px); // 8px for padding (4px on either side), 3px for margin
margin-bottom: 4px;
margin-right: 3px;
padding: 4px;
// The tile is also a flexbox row itself
display: flex;
flex-wrap: wrap;
&.mx_RoomTile2_selected {
background-color: $roomtile2-selected-bg-color;
border-radius: 32px;
}
.mx_RoomTile2_avatarContainer {
margin-right: 8px;
}
.mx_RoomTile2_nameContainer {
// Create a new column layout flexbox for the name parts
display: flex;
flex-direction: column;
justify-content: center;
.mx_RoomTile2_name,
.mx_RoomTile2_messagePreview {
margin: 0 2px;
}
// TODO: Ellipsis on the name and preview
.mx_RoomTile2_name {
font-size: $font-14px;
line-height: $font-19px;
}
.mx_RoomTile2_name.mx_RoomTile2_nameHasUnreadEvents {
font-weight: 600;
}
.mx_RoomTile2_messagePreview {
font-size: $font-13px;
line-height: $font-18px;
color: $roomtile2-preview-color;
}
}
.mx_RoomTile2_badgeContainer {
flex-grow: 1;
// Create another flexbox row because it's super easy to position the badge at
// the end this way.
display: flex;
align-items: center;
justify-content: flex-end;
}
}

View file

@ -43,3 +43,7 @@ limitations under the License.
padding-left: 20px; padding-left: 20px;
padding-right: 5px; padding-right: 5px;
} }
.mx_SettingsTab_customFontSizeField {
margin-left: calc($font-16px + 10px);
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-archive"><polyline points="21 8 21 21 3 21 3 8"></polyline><rect x="1" y="3" width="22" height="5"></rect><line x1="10" y1="12" x2="14" y2="12"></line></svg>

After

Width:  |  Height:  |  Size: 361 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-compass"><circle cx="12" cy="12" r="10"></circle><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"></polygon></svg>

After

Width:  |  Height:  |  Size: 342 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-more-horizontal"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg>

After

Width:  |  Height:  |  Size: 343 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-sun"><circle cx="12" cy="12" r="5"></circle><line x1="12" y1="1" x2="12" y2="3"></line><line x1="12" y1="21" x2="12" y2="23"></line><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line><line x1="1" y1="12" x2="3" y2="12"></line><line x1="21" y1="12" x2="23" y2="12"></line><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line></svg>

After

Width:  |  Height:  |  Size: 650 B

View file

@ -172,6 +172,13 @@ $header-divider-color: #91A1C0;
// ******************** // ********************
// TODO: Update variables for new room list
// TODO: Dark theme
$roomtile2-preview-color: #9e9e9e;
$roomtile2-badge-color: #61708b;
$roomtile2-selected-bg-color: #FFF;
$theme-button-bg-color: #e3e8f0;
$roomtile-name-color: #61708b; $roomtile-name-color: #61708b;
$roomtile-badge-fg-color: $accent-fg-color; $roomtile-badge-fg-color: $accent-fg-color;
$roomtile-selected-color: #212121; $roomtile-selected-color: #212121;

View file

@ -27,7 +27,7 @@ import RoomViewStore from './stores/RoomViewStore';
*/ */
class ActiveRoomObserver { class ActiveRoomObserver {
constructor() { constructor() {
this._listeners = {}; this._listeners = {}; // key=roomId, value=function(isActive:boolean)
this._activeRoomId = RoomViewStore.getRoomId(); this._activeRoomId = RoomViewStore.getRoomId();
// TODO: We could self-destruct when the last listener goes away, or at least // TODO: We could self-destruct when the last listener goes away, or at least
@ -35,6 +35,10 @@ class ActiveRoomObserver {
this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this)); this._roomStoreToken = RoomViewStore.addListener(this._onRoomViewStoreUpdate.bind(this));
} }
get activeRoomId(): string {
return this._activeRoomId;
}
addListener(roomId, listener) { addListener(roomId, listener) {
if (!this._listeners[roomId]) this._listeners[roomId] = []; if (!this._listeners[roomId]) this._listeners[roomId] = [];
this._listeners[roomId].push(listener); this._listeners[roomId].push(listener);
@ -51,23 +55,23 @@ class ActiveRoomObserver {
} }
} }
_emit(roomId) { _emit(roomId, isActive: boolean) {
if (!this._listeners[roomId]) return; if (!this._listeners[roomId]) return;
for (const l of this._listeners[roomId]) { for (const l of this._listeners[roomId]) {
l.call(); l.call(null, isActive);
} }
} }
_onRoomViewStoreUpdate() { _onRoomViewStoreUpdate() {
// emit for the old room ID // emit for the old room ID
if (this._activeRoomId) this._emit(this._activeRoomId); if (this._activeRoomId) this._emit(this._activeRoomId, false);
// update our cache // update our cache
this._activeRoomId = RoomViewStore.getRoomId(); this._activeRoomId = RoomViewStore.getRoomId();
// and emit for the new one // and emit for the new one
if (this._activeRoomId) this._emit(this._activeRoomId); if (this._activeRoomId) this._emit(this._activeRoomId, true);
} }
} }

View file

@ -31,6 +31,7 @@ import Spinner from "./components/views/elements/Spinner";
// Polyfill for Canvas.toBlob API using Canvas.toDataURL // Polyfill for Canvas.toBlob API using Canvas.toDataURL
import "blueimp-canvas-to-blob"; import "blueimp-canvas-to-blob";
import { Action } from "./dispatcher/actions";
const MAX_WIDTH = 800; const MAX_WIDTH = 800;
const MAX_HEIGHT = 600; const MAX_HEIGHT = 600;
@ -529,7 +530,7 @@ export default class ContentMessages {
dis.dispatch({action: 'upload_started'}); dis.dispatch({action: 'upload_started'});
// Focus the composer view // Focus the composer view
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
function onProgress(ev) { function onProgress(ev) {
upload.total = ev.total; upload.total = ev.total;

View file

@ -481,6 +481,29 @@ async function combinedPagination(searchResult) {
searchResult.pendingRequest = null; searchResult.pendingRequest = null;
// Restore our encryption info so we can properly re-verify the events.
for (let i = 0; i < result.results.length; i++) {
const timeline = result.results[i].context.getTimeline();
for (let j = 0; j < timeline.length; j++) {
const ev = timeline[j];
if (ev.event.curve25519Key) {
ev.makeEncrypted(
"m.room.encrypted",
{ algorithm: ev.event.algorithm },
ev.event.curve25519Key,
ev.event.ed25519Key,
);
ev._forwardingCurve25519KeyChain = ev.event.forwardingCurve25519KeyChain;
delete ev.event.curve25519Key;
delete ev.event.ed25519Key;
delete ev.event.algorithm;
delete ev.event.forwardingCurve25519KeyChain;
}
}
}
return result; return result;
} }

View file

@ -96,6 +96,17 @@ export default class WidgetMessaging {
}); });
} }
/**
* Tells the widget that it should terminate now.
* @returns {Promise<*>} Resolves when widget has acknowledged the message.
*/
terminate() {
return this.messageToWidget({
api: OUTBOUND_API_NAME,
action: KnownWidgetActions.Terminate,
});
}
/** /**
* Request a screenshot from a widget * Request a screenshot from a widget
* @return {Promise} To be resolved with screenshot data when it has been generated * @return {Promise} To be resolved with screenshot data when it has been generated

View file

@ -122,7 +122,7 @@ export default class EmojiProvider extends AutocompleteProvider {
completion: unicode, completion: unicode,
component: ( component: (
<PillCompletion title={shortname} aria-label={unicode}> <PillCompletion title={shortname} aria-label={unicode}>
<span style={{maxWidth: '1em'}}>{ unicode }</span> <span>{ unicode }</span>
</PillCompletion> </PillCompletion>
), ),
range, range,

View file

@ -22,9 +22,10 @@ import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig"; import SdkConfig from "../../SdkConfig";
import * as sdk from "../../index"; import * as sdk from "../../index";
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import { Action } from "../../dispatcher/actions";
const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'}); const onClickSendDm = () => dis.dispatch({action: 'view_create_chat'});
const onClickExplore = () => dis.dispatch({action: 'view_room_directory'}); const onClickExplore = () => dis.fire(Action.ViewRoomDirectory);
const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'}); const onClickNewRoom = () => dis.dispatch({action: 'view_create_room'});
const HomePage = () => { const HomePage = () => {

View file

@ -26,7 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler';
import SettingsStore from '../../settings/SettingsStore'; import SettingsStore from '../../settings/SettingsStore';
import {_t} from "../../languageHandler"; import {_t} from "../../languageHandler";
import Analytics from "../../Analytics"; import Analytics from "../../Analytics";
import RoomList2 from "../views/rooms/RoomList2"; import {Action} from "../../dispatcher/actions";
const LeftPanel = createReactClass({ const LeftPanel = createReactClass({
@ -198,7 +198,7 @@ const LeftPanel = createReactClass({
onSearchCleared: function(source) { onSearchCleared: function(source) {
if (source === "keyboard") { if (source === "keyboard") {
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
} }
this.setState({searchExpanded: false}); this.setState({searchExpanded: false});
}, },
@ -252,7 +252,7 @@ const LeftPanel = createReactClass({
if (!this.props.collapsed) { if (!this.props.collapsed) {
exploreButton = ( exploreButton = (
<div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}> <div className={classNames("mx_LeftPanel_explore", {"mx_LeftPanel_explore_hidden": this.state.searchExpanded})}>
<AccessibleButton onClick={() => dis.dispatch({action: 'view_room_directory'})}>{_t("Explore")}</AccessibleButton> <AccessibleButton onClick={() => dis.fire(Action.ViewRoomDirectory)}>{_t("Explore")}</AccessibleButton>
</div> </div>
); );
} }
@ -274,19 +274,7 @@ const LeftPanel = createReactClass({
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />); breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
} }
let roomList = null; const roomList = <RoomList
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
roomList = <RoomList2
onKeyDown={this._onKeyDown}
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter}
ref={this.collectRoomList}
onFocus={this._onFocus}
onBlur={this._onBlur}
/>;
} else {
roomList = <RoomList
onKeyDown={this._onKeyDown} onKeyDown={this._onKeyDown}
onFocus={this._onFocus} onFocus={this._onFocus}
onBlur={this._onBlur} onBlur={this._onBlur}
@ -295,7 +283,6 @@ const LeftPanel = createReactClass({
collapsed={this.props.collapsed} collapsed={this.props.collapsed}
searchFilter={this.state.searchFilter} searchFilter={this.state.searchFilter}
ConferenceHandler={VectorConferenceHandler} />; ConferenceHandler={VectorConferenceHandler} />;
}
return ( return (
<div className={containerClasses}> <div className={containerClasses}>

View file

@ -0,0 +1,166 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import TagPanel from "./TagPanel";
import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import SearchBox from "./SearchBox";
import RoomList2 from "../views/rooms/RoomList2";
import { Action } from "../../dispatcher/actions";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseAvatar from '../views/avatars/BaseAvatar';
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import UserMenuButton from "./UserMenuButton";
import RoomSearch from "./RoomSearch";
import AccessibleButton from "../views/elements/AccessibleButton";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
// TODO: Support collapsed state
}
interface IState {
searchFilter: string; // TODO: Move search into room list?
}
export default class LeftPanel2 extends React.Component<IProps, IState> {
// TODO: Properly support TagPanel
// TODO: Properly support searching/filtering
// TODO: Properly support breadcrumbs
// TODO: a11y
// TODO: actually make this useful in general (match design proposals)
// TODO: Fadable support (is this still needed?)
constructor(props: IProps) {
super(props);
this.state = {
searchFilter: "",
};
}
private onSearch = (term: string): void => {
this.setState({searchFilter: term});
};
private onExplore = () => {
dis.fire(Action.ViewRoomDirectory);
};
private renderHeader(): React.ReactNode {
// TODO: Update when profile info changes
// TODO: Presence
// TODO: Breadcrumbs toggle
// TODO: Menu button
const avatarSize = 32;
// TODO: Don't do this profile lookup in render()
const client = MatrixClientPeg.get();
let displayName = client.getUserId();
let avatarUrl: string = null;
const myUser = client.getUser(client.getUserId());
if (myUser) {
displayName = myUser.rawDisplayName;
avatarUrl = myUser.avatarUrl;
}
return (
<div className="mx_LeftPanel2_userHeader">
<div className="mx_LeftPanel2_headerRow">
<span className="mx_LeftPanel2_userAvatarContainer">
<BaseAvatar
idName={MatrixClientPeg.get().getUserId()}
name={displayName}
url={avatarUrl}
width={avatarSize}
height={avatarSize}
resizeMethod="crop"
className="mx_LeftPanel2_userAvatar"
/>
</span>
<span className="mx_LeftPanel2_userName">{displayName}</span>
<span className="mx_LeftPanel2_headerButtons">
<UserMenuButton />
</span>
</div>
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
<RoomBreadcrumbs />
</div>
</div>
);
}
private renderSearchExplore(): React.ReactNode {
// TODO: Collapsed support
return (
<div className="mx_LeftPanel2_filterContainer">
<RoomSearch onQueryUpdate={this.onSearch} />
<AccessibleButton
tabIndex={-1}
className='mx_LeftPanel2_exploreButton'
onClick={this.onExplore}
alt={_t("Explore rooms")}
/>
</div>
);
}
public render(): React.ReactNode {
const tagPanel = (
<div className="mx_LeftPanel2_tagPanelContainer">
<TagPanel/>
</div>
);
// TODO: Improve props for RoomList2
const roomList = <RoomList2
onKeyDown={() => {/*TODO*/}}
resizeNotifier={null}
collapsed={false}
searchFilter={this.state.searchFilter}
onFocus={() => {/*TODO*/}}
onBlur={() => {/*TODO*/}}
/>;
// TODO: Breadcrumbs
// TODO: Conference handling / calls
const containerClasses = classNames({
"mx_LeftPanel2": true,
});
return (
<div className={containerClasses}>
{tagPanel}
<aside className="mx_LeftPanel2_roomListContainer">
{this.renderHeader()}
{this.renderSearchExplore()}
<div className="mx_LeftPanel2_actualRoomListContainer">
{roomList}
</div>
</aside>
</div>
);
}
}

View file

@ -51,6 +51,8 @@ import {
showToast as showServerLimitToast, showToast as showServerLimitToast,
hideToast as hideServerLimitToast hideToast as hideServerLimitToast
} from "../../toasts/ServerLimitToast"; } from "../../toasts/ServerLimitToast";
import { Action } from "../../dispatcher/actions";
import LeftPanel2 from "./LeftPanel2";
// We need to fetch each pinned message individually (if we don't already have it) // We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -358,7 +360,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
// refocusing during a paste event will make the // refocusing during a paste event will make the
// paste end up in the newly focused element, // paste end up in the newly focused element,
// so dispatch synchronously before paste happens // so dispatch synchronously before paste happens
dis.dispatch({action: 'focus_composer'}, true); dis.fire(Action.FocusComposer, true);
} }
}; };
@ -450,9 +452,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
// composer, so CTRL+` it is // composer, so CTRL+` it is
if (ctrlCmdOnly) { if (ctrlCmdOnly) {
dis.dispatch({ dis.fire(Action.ToggleUserMenu);
action: 'toggle_top_left_menu',
});
handled = true; handled = true;
} }
break; break;
@ -508,7 +508,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) { if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input // synchronous dispatch so we focus before key generates input
dis.dispatch({action: 'focus_composer'}, true); dis.fire(Action.FocusComposer, true);
ev.stopPropagation(); ev.stopPropagation();
// we should *not* preventDefault() here as // we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer // that would prevent typing in the now-focussed composer
@ -667,6 +667,20 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
bodyClasses += ' mx_MatrixChat_useCompactLayout'; bodyClasses += ' mx_MatrixChat_useCompactLayout';
} }
let leftPanel = (
<LeftPanel
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
);
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
// TODO: Supply props like collapsed and disabled to LeftPanel2
leftPanel = (
<LeftPanel2 />
);
}
return ( return (
<MatrixClientContext.Provider value={this._matrixClient}> <MatrixClientContext.Provider value={this._matrixClient}>
<div <div
@ -680,11 +694,7 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
<ToastContainer /> <ToastContainer />
<DragDropContext onDragEnd={this._onDragEnd}> <DragDropContext onDragEnd={this._onDragEnd}>
<div ref={this._resizeContainer} className={bodyClasses}> <div ref={this._resizeContainer} className={bodyClasses}>
<LeftPanel { leftPanel }
resizeNotifier={this.props.resizeNotifier}
collapsed={this.props.collapseLhs || false}
disabled={this.props.leftDisabled}
/>
<ResizeHandle /> <ResizeHandle />
{ pageElement } { pageElement }
</div> </div>

View file

@ -72,6 +72,7 @@ import {
hideToast as hideAnalyticsToast hideToast as hideAnalyticsToast
} from "../../toasts/AnalyticsToast"; } from "../../toasts/AnalyticsToast";
import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast"; import {showToast as showNotificationsToast} from "../../toasts/DesktopNotificationsToast";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
/** constants for MatrixChat.state.view */ /** constants for MatrixChat.state.view */
export enum Views { export enum Views {
@ -347,7 +348,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Analytics.trackPageChange(durationMs); Analytics.trackPageChange(durationMs);
} }
if (this.focusComposer) { if (this.focusComposer) {
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
this.focusComposer = false; this.focusComposer = false;
} }
} }
@ -604,9 +605,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
this.viewIndexedRoom(payload.roomIndex); this.viewIndexedRoom(payload.roomIndex);
break; break;
case Action.ViewUserSettings: { case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload;
const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog"); const UserSettingsDialog = sdk.getComponent("dialogs.UserSettingsDialog");
Modal.createTrackedDialog('User settings', '', UserSettingsDialog, {}, Modal.createTrackedDialog('User settings', '', UserSettingsDialog,
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true); {initialTabId: tabPayload.initialTabId},
/*className=*/null, /*isPriority=*/false, /*isStatic=*/true
);
// View the welcome or home page if we need something to look at // View the welcome or home page if we need something to look at
this.viewSomethingBehindModal(); this.viewSomethingBehindModal();
@ -620,7 +624,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
Modal.createTrackedDialog('Create Community', '', CreateGroupDialog); Modal.createTrackedDialog('Create Community', '', CreateGroupDialog);
break; break;
} }
case 'view_room_directory': { case Action.ViewRoomDirectory: {
const RoomDirectory = sdk.getComponent("structures.RoomDirectory"); const RoomDirectory = sdk.getComponent("structures.RoomDirectory");
Modal.createTrackedDialog('Room directory', '', RoomDirectory, {}, Modal.createTrackedDialog('Room directory', '', RoomDirectory, {},
'mx_RoomDirectory_dialogWrapper', false, true); 'mx_RoomDirectory_dialogWrapper', false, true);
@ -1363,7 +1367,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
showNotificationsToast(); showNotificationsToast();
} }
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
this.setState({ this.setState({
ready: true, ready: true,
}); });
@ -1607,9 +1611,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
action: 'require_registration', action: 'require_registration',
}); });
} else if (screen === 'directory') { } else if (screen === 'directory') {
dis.dispatch({ dis.fire(Action.ViewRoomDirectory);
action: 'view_room_directory',
});
} else if (screen === 'groups') { } else if (screen === 'groups') {
dis.dispatch({ dis.dispatch({
action: 'view_my_groups', action: 'view_my_groups',

View file

@ -0,0 +1,144 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import { createRef } from "react";
import classNames from "classnames";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import { ActionPayload } from "../../dispatcher/payloads";
import { throttle } from 'lodash';
import { Key } from "../../Keyboard";
import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
onQueryUpdate: (newQuery: string) => void;
}
interface IState {
query: string;
focused: boolean;
}
export default class RoomSearch extends React.PureComponent<IProps, IState> {
private dispatcherRef: string;
private inputRef: React.RefObject<HTMLInputElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
query: "",
focused: false,
};
this.dispatcherRef = defaultDispatcher.register(this.onAction);
}
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'view_room' && payload.clear_search) {
this.clearInput();
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
this.inputRef.current.focus();
}
};
private clearInput = () => {
if (!this.inputRef.current) return;
this.inputRef.current.value = "";
this.onChange();
};
private onChange = () => {
if (!this.inputRef.current) return;
this.setState({query: this.inputRef.current.value});
this.onSearchUpdated();
};
// it wants this at the top of the file, but we know better
// tslint:disable-next-line
private onSearchUpdated = throttle(
() => {
// We can't use the state variable because it can lag behind the input.
// The lag is most obvious when deleting/clearing text with the keyboard.
this.props.onQueryUpdate(this.inputRef.current.value);
}, 200, {trailing: true, leading: true},
);
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
this.setState({focused: true});
ev.target.select();
};
private onBlur = () => {
this.setState({focused: false});
};
private onKeyDown = (ev: React.KeyboardEvent) => {
if (ev.key === Key.ESCAPE) {
this.clearInput();
defaultDispatcher.fire(Action.FocusComposer);
}
};
public render(): React.ReactNode {
const classes = classNames({
'mx_RoomSearch': true,
'mx_RoomSearch_expanded': this.state.query || this.state.focused,
});
const inputClasses = classNames({
'mx_RoomSearch_input': true,
'mx_RoomSearch_inputExpanded': this.state.query || this.state.focused,
});
return (
<div className={classes}>
<div className='mx_RoomSearch_icon'/>
<input
type="text"
ref={this.inputRef}
className={inputClasses}
value={this.state.query}
onFocus={this.onFocus}
onBlur={this.onBlur}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
placeholder={_t("Search")}
autoComplete="off"
/>
<AccessibleButton
tabIndex={-1}
className='mx_RoomSearch_clearButton'
onClick={this.clearInput}
/>
</div>
);
}
}

View file

@ -26,6 +26,7 @@ import {MatrixClientPeg} from '../../MatrixClientPeg';
import Resend from '../../Resend'; import Resend from '../../Resend';
import dis from '../../dispatcher/dispatcher'; import dis from '../../dispatcher/dispatcher';
import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils'; import {messageForResourceLimitError, messageForSendError} from '../../utils/ErrorUtils';
import {Action} from "../../dispatcher/actions";
const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED = 1;
@ -127,12 +128,12 @@ export default createReactClass({
_onResendAllClick: function() { _onResendAllClick: function() {
Resend.resendUnsentEvents(this.props.room); Resend.resendUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
}, },
_onCancelAllClick: function() { _onCancelAllClick: function() {
Resend.cancelUnsentEvents(this.props.room); Resend.cancelUnsentEvents(this.props.room);
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
}, },
_onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) { _onRoomLocalEchoUpdated: function(event, room, oldEventId, oldStatus) {

View file

@ -55,6 +55,7 @@ import {haveTileForEvent} from "../views/rooms/EventTile";
import RoomContext from "../../contexts/RoomContext"; import RoomContext from "../../contexts/RoomContext";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import { shieldStatusForRoom } from '../../utils/ShieldUtils'; import { shieldStatusForRoom } from '../../utils/ShieldUtils';
import {Action} from "../../dispatcher/actions";
const DEBUG = false; const DEBUG = false;
let debuglog = function() {}; let debuglog = function() {};
@ -1161,7 +1162,7 @@ export default createReactClass({
ev.dataTransfer.files, this.state.room.roomId, this.context, ev.dataTransfer.files, this.state.room.roomId, this.context,
); );
this.setState({ draggingFile: false }); this.setState({ draggingFile: false });
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
}, },
onDragLeaveOrEnd: function(ev) { onDragLeaveOrEnd: function(ev) {
@ -1367,7 +1368,7 @@ export default createReactClass({
event: null, event: null,
}); });
} }
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
}, },
onLeaveClick: function() { onLeaveClick: function() {
@ -1456,9 +1457,7 @@ export default createReactClass({
// using /leave rather than /join. In the short term though, we // using /leave rather than /join. In the short term though, we
// just ignore them. // just ignore them.
// https://github.com/vector-im/vector-web/issues/1134 // https://github.com/vector-im/vector-web/issues/1134
dis.dispatch({ dis.fire(Action.ViewRoomDirectory);
action: 'view_room_directory',
});
}, },
onSearchClick: function() { onSearchClick: function() {
@ -1478,7 +1477,7 @@ export default createReactClass({
// jump down to the bottom of this room, where new events are arriving // jump down to the bottom of this room, where new events are arriving
jumpToLiveTimeline: function() { jumpToLiveTimeline: function() {
this._messagePanel.jumpToLiveTimeline(); this._messagePanel.jumpToLiveTimeline();
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
}, },
// jump up to wherever our read marker is // jump up to wherever our read marker is

View file

@ -27,25 +27,20 @@ import { ReactNode } from "react";
* Represents a tab for the TabbedView. * Represents a tab for the TabbedView.
*/ */
export class Tab { export class Tab {
public label: string;
public icon: string;
public body: React.ReactNode;
/** /**
* Creates a new tab. * Creates a new tab.
* @param {string} tabLabel The untranslated tab label. * @param {string} id The tab's ID.
* @param {string} tabIconClass The class for the tab icon. This should be a simple mask. * @param {string} label The untranslated tab label.
* @param {React.ReactNode} tabJsx The JSX for the tab container. * @param {string} icon The class for the tab icon. This should be a simple mask.
* @param {React.ReactNode} body The JSX for the tab container.
*/ */
constructor(tabLabel: string, tabIconClass: string, tabJsx: React.ReactNode) { constructor(public id: string, public label: string, public icon: string, public body: React.ReactNode) {
this.label = tabLabel;
this.icon = tabIconClass;
this.body = tabJsx;
} }
} }
interface IProps { interface IProps {
tabs: Tab[]; tabs: Tab[];
initialTabId?: string;
} }
interface IState { interface IState {
@ -53,16 +48,17 @@ interface IState {
} }
export default class TabbedView extends React.Component<IProps, IState> { export default class TabbedView extends React.Component<IProps, IState> {
static propTypes = {
// The tabs to show
tabs: PropTypes.arrayOf(PropTypes.instanceOf(Tab)).isRequired,
};
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
let activeTabIndex = 0;
if (props.initialTabId) {
const tabIndex = props.tabs.findIndex(t => t.id === props.initialTabId);
if (tabIndex >= 0) activeTabIndex = tabIndex;
}
this.state = { this.state = {
activeTabIndex: 0, activeTabIndex,
}; };
} }

View file

@ -24,6 +24,7 @@ import * as Avatar from '../../Avatar';
import { _t } from '../../languageHandler'; import { _t } from '../../languageHandler';
import dis from "../../dispatcher/dispatcher"; import dis from "../../dispatcher/dispatcher";
import {ContextMenu, ContextMenuButton} from "./ContextMenu"; import {ContextMenu, ContextMenuButton} from "./ContextMenu";
import {Action} from "../../dispatcher/actions";
const AVATAR_SIZE = 28; const AVATAR_SIZE = 28;
@ -75,7 +76,7 @@ export default class TopLeftMenuButton extends React.Component {
onAction = (payload) => { onAction = (payload) => {
// For accessibility // For accessibility
if (payload.action === "toggle_top_left_menu") { if (payload.action === Action.ToggleUserMenu) {
if (this._buttonRef) this._buttonRef.click(); if (this._buttonRef) this._buttonRef.click();
} }
}; };

View file

@ -0,0 +1,270 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import * as React from "react";
import {User} from "matrix-js-sdk/src/models/user";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { ActionPayload } from "../../dispatcher/payloads";
import { Action } from "../../dispatcher/actions";
import { createRef } from "react";
import { _t } from "../../languageHandler";
import {ContextMenu, ContextMenuButton} from "./ContextMenu";
import {USER_NOTIFICATIONS_TAB, USER_SECURITY_TAB} from "../views/dialogs/UserSettingsDialog";
import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
import RedesignFeedbackDialog from "../views/dialogs/RedesignFeedbackDialog";
import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore, {SettingLevel} from "../../settings/SettingsStore";
import {getCustomTheme} from "../../theme";
import {getHostingLink} from "../../utils/HostingLink";
import AccessibleButton from "../views/elements/AccessibleButton";
interface IProps {
}
interface IState {
user: User;
menuDisplayed: boolean;
isDarkTheme: boolean;
}
export default class UserMenuButton extends React.Component<IProps, IState> {
private dispatcherRef: string;
private themeWatcherRef: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
menuDisplayed: false,
user: MatrixClientPeg.get().getUser(MatrixClientPeg.get().getUserId()),
isDarkTheme: this.isUserOnDarkTheme(),
};
}
private get displayName(): string {
if (MatrixClientPeg.get().isGuest()) {
return _t("Guest");
} else if (this.state.user) {
return this.state.user.displayName;
} else {
return MatrixClientPeg.get().getUserId();
}
}
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.themeWatcherRef = SettingsStore.watchSetting("theme", null, this.onThemeChanged);
}
public componentWillUnmount() {
if (this.themeWatcherRef) SettingsStore.unwatchSetting(this.themeWatcherRef);
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
}
private isUserOnDarkTheme(): boolean {
const theme = SettingsStore.getValue("theme");
if (theme.startsWith("custom-")) {
return getCustomTheme(theme.substring(0, 7)).is_dark;
}
return theme === "dark";
}
private onThemeChanged = () => {
this.setState({isDarkTheme: this.isUserOnDarkTheme()});
};
private onAction = (ev: ActionPayload) => {
if (ev.action !== Action.ToggleUserMenu) return; // not interested
// For accessibility
if (this.buttonRef.current) this.buttonRef.current.click();
};
private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({menuDisplayed: true});
};
private onCloseMenu = () => {
this.setState({menuDisplayed: false});
};
private onSwitchThemeClick = () => {
// Disable system theme matching if the user hits this button
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, false);
const newTheme = this.state.isDarkTheme ? "light" : "dark";
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme);
};
private onSettingsOpen = (ev: React.MouseEvent, tabId: string) => {
ev.preventDefault();
ev.stopPropagation();
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
defaultDispatcher.dispatch(payload);
this.setState({menuDisplayed: false}); // also close the menu
};
private onShowArchived = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
// TODO: Archived room view (deferred)
console.log("TODO: Show archived rooms");
};
private onProvideFeedback = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
this.setState({menuDisplayed: false}); // also close the menu
};
private onSignOutClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
this.setState({menuDisplayed: false}); // also close the menu
};
public render() {
let contextMenu;
if (this.state.menuDisplayed) {
let hostingLink;
const signupLink = getHostingLink("user-context-menu");
if (signupLink) {
hostingLink = (
<div className="mx_UserMenuButton_contextMenu_header">
{_t(
"<a>Upgrade</a> to your own domain", {},
{
a: sub => (
<a
href={signupLink}
target="_blank"
rel="noreferrer noopener"
tabIndex={-1}
>{sub}</a>
),
},
)}
</div>
);
}
const elementRect = this.buttonRef.current.getBoundingClientRect();
contextMenu = (
<ContextMenu
chevronFace="none"
left={elementRect.left}
top={elementRect.top + elementRect.height}
onFinished={this.onCloseMenu}
>
<div className="mx_UserMenuButton_contextMenu">
<div className="mx_UserMenuButton_contextMenu_header">
<div className="mx_UserMenuButton_contextMenu_name">
<span className="mx_UserMenuButton_contextMenu_displayName">
{this.displayName}
</span>
<span className="mx_UserMenuButton_contextMenu_userId">
{MatrixClientPeg.get().getUserId()}
</span>
</div>
<div
className="mx_UserMenuButton_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
>
<img
src={require("../../../res/img/feather-customised/sun.svg")}
alt={_t("Switch theme")}
width={16}
/>
</div>
</div>
{hostingLink}
<div className="mx_UserMenuButton_contextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<img src={require("../../../res/img/feather-customised/notifications.svg")} width={16} />
<span>{_t("Notification settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<img src={require("../../../res/img/feather-customised/lock.svg")} width={16} />
<span>{_t("Security & privacy")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<img src={require("../../../res/img/feather-customised/settings.svg")} width={16} />
<span>{_t("All settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onShowArchived}>
<img src={require("../../../res/img/feather-customised/archive.svg")} width={16} />
<span>{_t("Archived rooms")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onProvideFeedback}>
<img src={require("../../../res/img/feather-customised/message-circle.svg")} width={16} />
<span>{_t("Feedback")}</span>
</AccessibleButton>
</li>
</ul>
</div>
<div className="mx_UserMenuButton_contextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={this.onSignOutClick}>
<img src={require("../../../res/img/feather-customised/sign-out.svg")} width={16} />
<span>{_t("Sign out")}</span>
</AccessibleButton>
</li>
</ul>
</div>
</div>
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuButton
className="mx_UserMenuButton"
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("Account settings")}
isExpanded={this.state.menuDisplayed}
>
<img src={require("../../../res/img/feather-customised/more-horizontal.svg")} alt="..." width={14} />
</ContextMenuButton>
{contextMenu}
</React.Fragment>
)
}
}

View file

@ -30,6 +30,13 @@ import {MatrixClientPeg} from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB";
export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB";
export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB";
export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB";
export const ROOM_BRIDGES_TAB = "ROOM_BRIDGES_TAB";
export const ROOM_ADVANCED_TAB = "ROOM_ADVANCED_TAB";
export default class RoomSettingsDialog extends React.Component { export default class RoomSettingsDialog extends React.Component {
static propTypes = { static propTypes = {
roomId: PropTypes.string.isRequired, roomId: PropTypes.string.isRequired,
@ -56,21 +63,25 @@ export default class RoomSettingsDialog extends React.Component {
const tabs = []; const tabs = [];
tabs.push(new Tab( tabs.push(new Tab(
ROOM_GENERAL_TAB,
_td("General"), _td("General"),
"mx_RoomSettingsDialog_settingsIcon", "mx_RoomSettingsDialog_settingsIcon",
<GeneralRoomSettingsTab roomId={this.props.roomId} />, <GeneralRoomSettingsTab roomId={this.props.roomId} />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
ROOM_SECURITY_TAB,
_td("Security & Privacy"), _td("Security & Privacy"),
"mx_RoomSettingsDialog_securityIcon", "mx_RoomSettingsDialog_securityIcon",
<SecurityRoomSettingsTab roomId={this.props.roomId} />, <SecurityRoomSettingsTab roomId={this.props.roomId} />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
ROOM_ROLES_TAB,
_td("Roles & Permissions"), _td("Roles & Permissions"),
"mx_RoomSettingsDialog_rolesIcon", "mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab roomId={this.props.roomId} />, <RolesRoomSettingsTab roomId={this.props.roomId} />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
ROOM_NOTIFICATIONS_TAB,
_td("Notifications"), _td("Notifications"),
"mx_RoomSettingsDialog_notificationsIcon", "mx_RoomSettingsDialog_notificationsIcon",
<NotificationSettingsTab roomId={this.props.roomId} />, <NotificationSettingsTab roomId={this.props.roomId} />,
@ -78,6 +89,7 @@ export default class RoomSettingsDialog extends React.Component {
if (SettingsStore.isFeatureEnabled("feature_bridge_state")) { if (SettingsStore.isFeatureEnabled("feature_bridge_state")) {
tabs.push(new Tab( tabs.push(new Tab(
ROOM_BRIDGES_TAB,
_td("Bridges"), _td("Bridges"),
"mx_RoomSettingsDialog_bridgesIcon", "mx_RoomSettingsDialog_bridgesIcon",
<BridgeSettingsTab roomId={this.props.roomId} />, <BridgeSettingsTab roomId={this.props.roomId} />,
@ -85,6 +97,7 @@ export default class RoomSettingsDialog extends React.Component {
} }
tabs.push(new Tab( tabs.push(new Tab(
ROOM_ADVANCED_TAB,
_td("Advanced"), _td("Advanced"),
"mx_RoomSettingsDialog_warningIcon", "mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />, <AdvancedRoomSettingsTab roomId={this.props.roomId} closeSettingsFn={this.props.onFinished} />,

View file

@ -33,9 +33,21 @@ import * as sdk from "../../../index";
import SdkConfig from "../../../SdkConfig"; import SdkConfig from "../../../SdkConfig";
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab"; import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
export const USER_GENERAL_TAB = "USER_GENERAL_TAB";
export const USER_APPEARANCE_TAB = "USER_APPEARANCE_TAB";
export const USER_FLAIR_TAB = "USER_FLAIR_TAB";
export const USER_NOTIFICATIONS_TAB = "USER_NOTIFICATIONS_TAB";
export const USER_PREFERENCES_TAB = "USER_PREFERENCES_TAB";
export const USER_VOICE_TAB = "USER_VOICE_TAB";
export const USER_SECURITY_TAB = "USER_SECURITY_TAB";
export const USER_LABS_TAB = "USER_LABS_TAB";
export const USER_MJOLNIR_TAB = "USER_MJOLNIR_TAB";
export const USER_HELP_TAB = "USER_HELP_TAB";
export default class UserSettingsDialog extends React.Component { export default class UserSettingsDialog extends React.Component {
static propTypes = { static propTypes = {
onFinished: PropTypes.func.isRequired, onFinished: PropTypes.func.isRequired,
initialTabId: PropTypes.string,
}; };
constructor() { constructor() {
@ -63,42 +75,50 @@ export default class UserSettingsDialog extends React.Component {
const tabs = []; const tabs = [];
tabs.push(new Tab( tabs.push(new Tab(
USER_GENERAL_TAB,
_td("General"), _td("General"),
"mx_UserSettingsDialog_settingsIcon", "mx_UserSettingsDialog_settingsIcon",
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />, <GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
USER_APPEARANCE_TAB,
_td("Appearance"), _td("Appearance"),
"mx_UserSettingsDialog_appearanceIcon", "mx_UserSettingsDialog_appearanceIcon",
<AppearanceUserSettingsTab />, <AppearanceUserSettingsTab />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
USER_FLAIR_TAB,
_td("Flair"), _td("Flair"),
"mx_UserSettingsDialog_flairIcon", "mx_UserSettingsDialog_flairIcon",
<FlairUserSettingsTab />, <FlairUserSettingsTab />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
USER_NOTIFICATIONS_TAB,
_td("Notifications"), _td("Notifications"),
"mx_UserSettingsDialog_bellIcon", "mx_UserSettingsDialog_bellIcon",
<NotificationUserSettingsTab />, <NotificationUserSettingsTab />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
USER_PREFERENCES_TAB,
_td("Preferences"), _td("Preferences"),
"mx_UserSettingsDialog_preferencesIcon", "mx_UserSettingsDialog_preferencesIcon",
<PreferencesUserSettingsTab />, <PreferencesUserSettingsTab />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
USER_VOICE_TAB,
_td("Voice & Video"), _td("Voice & Video"),
"mx_UserSettingsDialog_voiceIcon", "mx_UserSettingsDialog_voiceIcon",
<VoiceUserSettingsTab />, <VoiceUserSettingsTab />,
)); ));
tabs.push(new Tab( tabs.push(new Tab(
USER_SECURITY_TAB,
_td("Security & Privacy"), _td("Security & Privacy"),
"mx_UserSettingsDialog_securityIcon", "mx_UserSettingsDialog_securityIcon",
<SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />, <SecurityUserSettingsTab closeSettingsFn={this.props.onFinished} />,
)); ));
if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) { if (SdkConfig.get()['showLabsSettings'] || SettingsStore.getLabsFeatures().length > 0) {
tabs.push(new Tab( tabs.push(new Tab(
USER_LABS_TAB,
_td("Labs"), _td("Labs"),
"mx_UserSettingsDialog_labsIcon", "mx_UserSettingsDialog_labsIcon",
<LabsUserSettingsTab />, <LabsUserSettingsTab />,
@ -106,12 +126,14 @@ export default class UserSettingsDialog extends React.Component {
} }
if (this.state.mjolnirEnabled) { if (this.state.mjolnirEnabled) {
tabs.push(new Tab( tabs.push(new Tab(
USER_MJOLNIR_TAB,
_td("Ignored users"), _td("Ignored users"),
"mx_UserSettingsDialog_mjolnirIcon", "mx_UserSettingsDialog_mjolnirIcon",
<MjolnirUserSettingsTab />, <MjolnirUserSettingsTab />,
)); ));
} }
tabs.push(new Tab( tabs.push(new Tab(
USER_HELP_TAB,
_td("Help & About"), _td("Help & About"),
"mx_UserSettingsDialog_helpIcon", "mx_UserSettingsDialog_helpIcon",
<HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />, <HelpUserSettingsTab closeSettingsFn={this.props.onFinished} />,
@ -127,7 +149,7 @@ export default class UserSettingsDialog extends React.Component {
<BaseDialog className='mx_UserSettingsDialog' hasCancel={true} <BaseDialog className='mx_UserSettingsDialog' hasCancel={true}
onFinished={this.props.onFinished} title={_t("Settings")}> onFinished={this.props.onFinished} title={_t("Settings")}>
<div className='ms_SettingsDialog_content'> <div className='ms_SettingsDialog_content'>
<TabbedView tabs={this._getTabs()} /> <TabbedView tabs={this._getTabs()} initialTabId={this.props.initialTabId} />
</div> </div>
</BaseDialog> </BaseDialog>
); );

View file

@ -39,6 +39,8 @@ import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu"; import {aboveLeftOf, ContextMenu, ContextMenuButton} from "../../structures/ContextMenu";
import PersistedElement from "./PersistedElement"; import PersistedElement from "./PersistedElement";
import {WidgetType} from "../../../widgets/WidgetType"; import {WidgetType} from "../../../widgets/WidgetType";
import {Capability} from "../../../widgets/WidgetApi";
import {sleep} from "../../../utils/promise";
const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:']; const ALLOWED_APP_URL_SCHEMES = ['https:', 'http:'];
const ENABLE_REACT_PERF = false; const ENABLE_REACT_PERF = false;
@ -341,8 +343,21 @@ export default class AppTile extends React.Component {
/** /**
* Ends all widget interaction, such as cancelling calls and disabling webcams. * Ends all widget interaction, such as cancelling calls and disabling webcams.
* @private * @private
* @returns {Promise<*>} Resolves when the widget is terminated, or timeout passed.
*/ */
_endWidgetActions() { _endWidgetActions() {
let terminationPromise;
if (this._hasCapability(Capability.ReceiveTerminate)) {
// Wait for widget to terminate within a timeout
const timeout = 2000;
const messaging = ActiveWidgetStore.getWidgetMessaging(this.props.app.id);
terminationPromise = Promise.race([messaging.terminate(), sleep(timeout)]);
} else {
terminationPromise = Promise.resolve();
}
return terminationPromise.finally(() => {
// HACK: This is a really dirty way to ensure that Jitsi cleans up // HACK: This is a really dirty way to ensure that Jitsi cleans up
// its hold on the webcam. Without this, the widget holds a media // its hold on the webcam. Without this, the widget holds a media
// stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351 // stream open, even after death. See https://github.com/vector-im/riot-web/issues/7351
@ -358,6 +373,7 @@ export default class AppTile extends React.Component {
// Delete the widget from the persisted store for good measure. // Delete the widget from the persisted store for good measure.
PersistedElement.destroyElement(this._persistKey); PersistedElement.destroyElement(this._persistKey);
});
} }
/* If user has permission to modify widgets, delete the widget, /* If user has permission to modify widgets, delete the widget,
@ -381,12 +397,12 @@ export default class AppTile extends React.Component {
} }
this.setState({deleting: true}); this.setState({deleting: true});
this._endWidgetActions(); this._endWidgetActions().then(() => {
return WidgetUtils.setRoomWidget(
WidgetUtils.setRoomWidget(
this.props.room.roomId, this.props.room.roomId,
this.props.app.id, this.props.app.id,
).catch((e) => { );
}).catch((e) => {
console.error('Failed to delete widget', e); console.error('Failed to delete widget', e);
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
@ -669,6 +685,17 @@ export default class AppTile extends React.Component {
} }
_onPopoutWidgetClick() { _onPopoutWidgetClick() {
// Ensure Jitsi conferences are closed on pop-out, to not confuse the user to join them
// twice from the same computer, which Jitsi can have problems with (audio echo/gain-loop).
if (WidgetType.JITSI.matches(this.props.app.type) && this.props.show) {
this._endWidgetActions().then(() => {
if (this._appFrame.current) {
// Reload iframe
this._appFrame.current.src = this._getRenderedUrl();
this.setState({});
}
});
}
// Using Object.assign workaround as the following opens in a new window instead of a new tab. // Using Object.assign workaround as the following opens in a new window instead of a new tab.
// window.open(this._getPopoutUrl(), '_blank', 'noopener=yes'); // window.open(this._getPopoutUrl(), '_blank', 'noopener=yes');
Object.assign(document.createElement('a'), Object.assign(document.createElement('a'),

View file

@ -26,6 +26,7 @@ import {makeUserPermalink, RoomPermalinkCreator} from "../../../utils/permalinks
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import escapeHtml from "escape-html"; import escapeHtml from "escape-html";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
// This component does no cycle detection, simply because the only way to make such a cycle would be to // This component does no cycle detection, simply because the only way to make such a cycle would be to
// craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would // craft event_id's, using a homeserver that generates predictable event IDs; even then the impact would
@ -290,7 +291,7 @@ export default class ReplyThread extends React.Component {
events, events,
}, this.loadNextEvent); }, this.loadNextEvent);
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
} }
render() { render() {

View file

@ -18,11 +18,12 @@ import React from 'react';
import * as sdk from '../../../index'; import * as sdk from '../../../index';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import {Action} from "../../../dispatcher/actions";
const RoomDirectoryButton = function(props) { const RoomDirectoryButton = function(props) {
const ActionButton = sdk.getComponent('elements.ActionButton'); const ActionButton = sdk.getComponent('elements.ActionButton');
return ( return (
<ActionButton action="view_room_directory" <ActionButton action={Action.ViewRoomDirectory}
mouseOverAction={props.callout ? "callout_room_directory" : null} mouseOverAction={props.callout ? "callout_room_directory" : null}
label={_t("Room directory")} label={_t("Room directory")}
iconPath={require("../../../../res/img/icons-directory.svg")} iconPath={require("../../../../res/img/icons-directory.svg")}

View file

@ -94,7 +94,7 @@ export default class Tooltip extends React.Component<IProps> {
return style; return style;
} }
private renderTooltip() { private renderTooltip = () => {
// Add the parent's position to the tooltips, so it's correctly // Add the parent's position to the tooltips, so it's correctly
// positioned, also taking into account any window zoom // positioned, also taking into account any window zoom
// NOTE: The additional 6 pixels for the left position, is to take account of the // NOTE: The additional 6 pixels for the left position, is to take account of the

View file

@ -27,8 +27,7 @@ const QUICK_REACTIONS = ["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀
if (!data) { if (!data) {
throw new Error(`Emoji ${emoji} doesn't exist in emojibase`); throw new Error(`Emoji ${emoji} doesn't exist in emojibase`);
} }
// Prefer our unicode value for quick reactions as we sometimes use variation selectors. return data;
return Object.assign({}, data, { unicode: emoji });
}); });
class QuickReactions extends React.Component { class QuickReactions extends React.Component {

View file

@ -359,6 +359,8 @@ export default class BasicMessageEditor extends React.Component {
} }
_onSelectionChange = () => { _onSelectionChange = () => {
const {isEmpty} = this.props.model;
this._refreshLastCaretIfNeeded(); this._refreshLastCaretIfNeeded();
const selection = document.getSelection(); const selection = document.getSelection();
if (this._hasTextSelected && selection.isCollapsed) { if (this._hasTextSelected && selection.isCollapsed) {
@ -366,7 +368,7 @@ export default class BasicMessageEditor extends React.Component {
if (this._formatBarRef) { if (this._formatBarRef) {
this._formatBarRef.hide(); this._formatBarRef.hide();
} }
} else if (!selection.isCollapsed) { } else if (!selection.isCollapsed && !isEmpty) {
this._hasTextSelected = true; this._hasTextSelected = true;
if (this._formatBarRef) { if (this._formatBarRef) {
const selectionRect = selection.getRangeAt(0).getBoundingClientRect(); const selectionRect = selection.getRangeAt(0).getBoundingClientRect();

View file

@ -31,6 +31,7 @@ import {EventStatus} from 'matrix-js-sdk';
import BasicMessageComposer from "./BasicMessageComposer"; import BasicMessageComposer from "./BasicMessageComposer";
import {Key} from "../../../Keyboard"; import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {Action} from "../../../dispatcher/actions";
function _isReply(mxEvent) { function _isReply(mxEvent) {
const relatesTo = mxEvent.getContent()["m.relates_to"]; const relatesTo = mxEvent.getContent()["m.relates_to"];
@ -157,7 +158,7 @@ export default class EditMessageComposer extends React.Component {
dis.dispatch({action: 'edit_event', event: nextEvent}); dis.dispatch({action: 'edit_event', event: nextEvent});
} else { } else {
dis.dispatch({action: 'edit_event', event: null}); dis.dispatch({action: 'edit_event', event: null});
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
} }
event.preventDefault(); event.preventDefault();
} }
@ -165,7 +166,7 @@ export default class EditMessageComposer extends React.Component {
_cancelEdit = () => { _cancelEdit = () => {
dis.dispatch({action: "edit_event", event: null}); dis.dispatch({action: "edit_event", event: null});
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
} }
_isContentModified(newContent) { _isContentModified(newContent) {
@ -195,7 +196,7 @@ export default class EditMessageComposer extends React.Component {
// close the event editing and focus composer // close the event editing and focus composer
dis.dispatch({action: "edit_event", event: null}); dis.dispatch({action: "edit_event", event: null});
dis.dispatch({action: 'focus_composer'}); dis.fire(Action.FocusComposer);
}; };
_cancelPreviousPendingEdit() { _cancelPreviousPendingEdit() {

View file

@ -0,0 +1,280 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
import { Room } from "matrix-js-sdk/src/models/room";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import * as RoomNotifs from '../../../RoomNotifs';
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
import * as Unread from '../../../Unread';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { EventEmitter } from "events";
import { arrayDiff } from "../../../utils/arrays";
import { IDestroyable } from "../../../utils/IDestroyable";
export const NOTIFICATION_STATE_UPDATE = "update";
export enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
Bold, // no badge, show as unread
Grey, // unread notified messages
Red, // unread pings
}
export interface INotificationState extends EventEmitter {
symbol?: string;
count: number;
color: NotificationColor;
}
interface IProps {
notification: INotificationState;
/**
* If true, the badge will conditionally display a badge without count for the user.
*/
allowNoCount: boolean;
}
interface IState {
}
export default class NotificationBadge extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
public componentDidUpdate(prevProps: Readonly<IProps>) {
if (prevProps.notification) {
prevProps.notification.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
this.props.notification.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
}
private onNotificationUpdate = () => {
this.forceUpdate(); // notification state changed - update
};
public render(): React.ReactElement {
// Don't show a badge if we don't need to
if (this.props.notification.color <= NotificationColor.Bold) return null;
const hasNotif = this.props.notification.color >= NotificationColor.Red;
const hasCount = this.props.notification.color >= NotificationColor.Grey;
const isEmptyBadge = this.props.allowNoCount && !localStorage.getItem("mx_rl_rt_badgeCount");
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count);
if (isEmptyBadge) symbol = "";
const classes = classNames({
'mx_NotificationBadge': true,
'mx_NotificationBadge_visible': hasCount,
'mx_NotificationBadge_highlighted': hasNotif,
'mx_NotificationBadge_dot': isEmptyBadge,
'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
'mx_NotificationBadge_3char': symbol.length > 2,
});
return (
<div className={classes}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</div>
);
}
}
export class RoomNotificationState extends EventEmitter implements IDestroyable {
private _symbol: string;
private _count: number;
private _color: NotificationColor;
constructor(private room: Room) {
super();
this.room.on("Room.receipt", this.handleRoomEventUpdate);
this.room.on("Room.timeline", this.handleRoomEventUpdate);
this.room.on("Room.redaction", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
this.updateNotificationState();
}
public get symbol(): string {
return this._symbol;
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.room.getMyMembership()) === EffectiveMembership.Invite;
}
public destroy(): void {
this.room.removeListener("Room.receipt", this.handleRoomEventUpdate);
this.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
}
}
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();
if (roomId !== this.room.roomId) return; // ignore - not for us
this.updateNotificationState();
};
private updateNotificationState() {
const before = {count: this.count, symbol: this.symbol, color: this.color};
if (this.roomIsInvite) {
this._color = NotificationColor.Red;
this._symbol = "!";
this._count = 1; // not used, technically
} else {
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'highlight');
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.room, 'total');
// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
// count. If that count is broken for some reason, assume zero. This avoids us showing
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
// Note: we only set the symbol if we have an actual count. We don't want to show
// zero on badges.
if (redNotifs > 0) {
this._color = NotificationColor.Red;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
} else if (greyNotifs > 0) {
this._color = NotificationColor.Grey;
this._count = trueCount;
this._symbol = null; // symbol calculated by component
} else {
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.room);
if (hasUnread) {
this._color = NotificationColor.Bold;
} else {
this._color = NotificationColor.None;
}
// no symbol or count for this state
this._count = 0;
this._symbol = null;
}
}
// finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color};
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
}
}
export class ListNotificationState extends EventEmitter {
private _count: number;
private _color: NotificationColor;
private rooms: Room[] = [];
private states: { [roomId: string]: RoomNotificationState } = {};
constructor(private byTileCount = false) {
super();
}
public get symbol(): string {
return null; // This notification state doesn't support symbols
}
public get count(): number {
return this._count;
}
public get color(): NotificationColor {
return this._color;
}
public setRooms(rooms: Room[]) {
// If we're only concerned about the tile count, don't bother setting up listeners.
if (this.byTileCount) {
this.rooms = rooms;
this.calculateTotalState();
return;
}
const oldRooms = this.rooms;
const diff = arrayDiff(oldRooms, rooms);
for (const oldRoom of diff.removed) {
const state = this.states[oldRoom.roomId];
delete this.states[oldRoom.roomId];
state.off(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
state.destroy();
}
for (const newRoom of diff.added) {
const state = new RoomNotificationState(newRoom);
state.on(NOTIFICATION_STATE_UPDATE, this.onRoomNotificationStateUpdate);
this.states[newRoom.roomId] = state;
}
this.calculateTotalState();
}
private onRoomNotificationStateUpdate = () => {
this.calculateTotalState();
};
private calculateTotalState() {
const before = {count: this.count, symbol: this.symbol, color: this.color};
if (this.byTileCount) {
this._color = NotificationColor.Red;
this._count = this.rooms.length;
} else {
this._count = 0;
this._color = NotificationColor.None;
for (const state of Object.values(this.states)) {
this._count += state.count;
this._color = Math.max(this.color, state.color);
}
}
// finally, publish an update if needed
const after = {count: this.count, symbol: this.symbol, color: this.color};
if (JSON.stringify(before) !== JSON.stringify(after)) {
this.emit(NOTIFICATION_STATE_UPDATE);
}
}
}

View file

@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
import {formatDate} from '../../../DateUtils'; import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor"; import Velociraptor from "../../../Velociraptor";
import * as sdk from "../../../index"; import * as sdk from "../../../index";
import {toRem} from "../../../utils/units"; import {toPx} from "../../../utils/units";
let bounce = false; let bounce = false;
try { try {
@ -149,7 +149,7 @@ export default createReactClass({
// start at the old height and in the old h pos // start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px", startStyles.push({ top: startTopOffset+"px",
left: toRem(oldInfo.left) }); left: toPx(oldInfo.left) });
const reorderTransitionOpts = { const reorderTransitionOpts = {
duration: 100, duration: 100,
@ -182,7 +182,7 @@ export default createReactClass({
} }
const style = { const style = {
left: toRem(this.props.leftOffset), left: toPx(this.props.leftOffset),
top: '0px', top: '0px',
visibility: this.props.hidden ? 'hidden' : 'visible', visibility: this.props.hidden ? 'hidden' : 'visible',
}; };

View file

@ -18,18 +18,17 @@ limitations under the License.
import * as React from "react"; import * as React from "react";
import { _t, _td } from "../../../languageHandler"; import { _t, _td } from "../../../languageHandler";
import { Layout } from '../../../resizer/distributors/roomsublist2';
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import { ResizeNotifier } from "../../../utils/ResizeNotifier"; import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2"; import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStore2 } from "../../../stores/room-list/RoomListStore2";
import { ITagMap } from "../../../stores/room-list/algorithms/models"; import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { Dispatcher } from "flux"; import { Dispatcher } from "flux";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2"; import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { IFilterCondition } from "../../../stores/room-list/filters/IFilterCondition";
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
import { ListLayout } from "../../../stores/room-list/ListLayout";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -50,6 +49,7 @@ interface IProps {
interface IState { interface IState {
sublists: ITagMap; sublists: ITagMap;
layouts: Map<TagID, ListLayout>;
} }
const TAG_ORDER: TagID[] = [ const TAG_ORDER: TagID[] = [
@ -96,7 +96,7 @@ const TAG_AESTHETICS: {
defaultHidden: false, defaultHidden: false,
}, },
[DefaultTagID.DM]: { [DefaultTagID.DM]: {
sectionLabel: _td("Direct Messages"), sectionLabel: _td("People"),
isInvite: false, isInvite: false,
defaultHidden: false, defaultHidden: false,
addRoomLabel: _td("Start chat"), addRoomLabel: _td("Start chat"),
@ -127,19 +127,15 @@ const TAG_AESTHETICS: {
}; };
export default class RoomList2 extends React.Component<IProps, IState> { export default class RoomList2 extends React.Component<IProps, IState> {
private sublistRefs: { [tagId: string]: React.RefObject<RoomSublist2> } = {};
private sublistSizes: { [tagId: string]: number } = {};
private sublistCollapseStates: { [tagId: string]: boolean } = {};
private unfilteredLayout: Layout;
private filteredLayout: Layout;
private searchFilter: NameFilterCondition = new NameFilterCondition(); private searchFilter: NameFilterCondition = new NameFilterCondition();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.state = {sublists: {}}; this.state = {
this.loadSublistSizes(); sublists: {},
this.prepareLayouts(); layouts: new Map<TagID, ListLayout>(),
};
} }
public componentDidUpdate(prevProps: Readonly<IProps>): void { public componentDidUpdate(prevProps: Readonly<IProps>): void {
@ -158,49 +154,16 @@ export default class RoomList2 extends React.Component<IProps, IState> {
} }
public componentDidMount(): void { public componentDidMount(): void {
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => { RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store: RoomListStore2) => {
console.log("new lists", store.orderedLists); const newLists = store.orderedLists;
this.setState({sublists: store.orderedLists}); console.log("new lists", newLists);
});
const layoutMap = new Map<TagID, ListLayout>();
for (const tagId of Object.keys(newLists)) {
layoutMap.set(tagId, new ListLayout(tagId));
} }
private loadSublistSizes() { this.setState({sublists: newLists, layouts: layoutMap});
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
if (sizesJson) this.sublistSizes = JSON.parse(sizesJson);
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson);
}
private saveSublistSizes() {
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes));
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates));
}
private prepareLayouts() {
// TODO: Change layout engine for FTUE support
this.unfilteredLayout = new Layout((tagId: string, height: number) => {
const sublist = this.sublistRefs[tagId];
if (sublist) sublist.current.setHeight(height);
// TODO: Check overflow (see old impl)
// Don't store a height for collapsed sublists
if (!this.sublistCollapseStates[tagId]) {
this.sublistSizes[tagId] = height;
this.saveSublistSizes();
}
}, this.sublistSizes, this.sublistCollapseStates, {
allowWhitespace: false,
handleHeight: 1,
});
this.filteredLayout = new Layout((tagId: string, height: number) => {
const sublist = this.sublistRefs[tagId];
if (sublist) sublist.current.setHeight(height);
}, null, null, {
allowWhitespace: false,
handleHeight: 0,
}); });
} }
@ -226,7 +189,8 @@ export default class RoomList2 extends React.Component<IProps, IState> {
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
components.push(<RoomSublist2 components.push(
<RoomSublist2
key={`sublist-${orderedTagId}`} key={`sublist-${orderedTagId}`}
forRooms={true} forRooms={true}
rooms={orderedRooms} rooms={orderedRooms}
@ -235,7 +199,10 @@ export default class RoomList2 extends React.Component<IProps, IState> {
onAddRoom={onAddRoomFn} onAddRoom={onAddRoomFn}
addRoomLabel={aesthetics.addRoomLabel} addRoomLabel={aesthetics.addRoomLabel}
isInvite={aesthetics.isInvite} isInvite={aesthetics.isInvite}
/>); layout={this.state.layouts.get(orderedTagId)}
showMessagePreviews={orderedTagId === DefaultTagID.DM}
/>
);
} }
return components; return components;
@ -250,7 +217,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
onFocus={this.props.onFocus} onFocus={this.props.onFocus}
onBlur={this.props.onBlur} onBlur={this.props.onBlur}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
className="mx_RoomList" className="mx_RoomList2"
role="tree" role="tree"
aria-label={_t("Rooms")} aria-label={_t("Rooms")}
// Firefox sometimes makes this element focusable due to // Firefox sometimes makes this element focusable due to

View file

@ -20,14 +20,13 @@ import * as React from "react";
import { createRef } from "react"; import { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import classNames from 'classnames'; import classNames from 'classnames';
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
import * as RoomNotifs from '../../../RoomNotifs';
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleButton from "../../views/elements/AccessibleButton";
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
import * as FormattingUtils from '../../../utils/FormattingUtils';
import RoomTile2 from "./RoomTile2"; import RoomTile2 from "./RoomTile2";
import { ResizableBox, ResizeCallbackData } from "react-resizable";
import { ListLayout } from "../../../stores/room-list/ListLayout";
import NotificationBadge, { ListNotificationState } from "./NotificationBadge";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -42,12 +41,13 @@ interface IProps {
rooms?: Room[]; rooms?: Room[];
startAsHidden: boolean; startAsHidden: boolean;
label: string; label: string;
showMessagePreviews: boolean;
onAddRoom?: () => void; onAddRoom?: () => void;
addRoomLabel: string; addRoomLabel: string;
isInvite: boolean; isInvite: boolean;
layout: ListLayout;
// TODO: Collapsed state // TODO: Collapsed state
// TODO: Height
// TODO: Group invites // TODO: Group invites
// TODO: Calls // TODO: Calls
// TODO: forceExpand? // TODO: forceExpand?
@ -56,17 +56,19 @@ interface IProps {
} }
interface IState { interface IState {
notificationState: ListNotificationState;
} }
export default class RoomSublist2 extends React.Component<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef(); private headerButton = createRef();
public setHeight(size: number) { constructor(props: IProps) {
// TODO: Do a thing (maybe - height changes are different in FTUE) super(props);
}
private hasTiles(): boolean { this.state = {
return this.numTiles > 0; notificationState: new ListNotificationState(this.props.isInvite),
};
this.state.notificationState.setRooms(this.props.rooms);
} }
private get numTiles(): number { private get numTiles(): number {
@ -74,17 +76,39 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
return (this.props.rooms || []).length; return (this.props.rooms || []).length;
} }
public componentDidUpdate() {
this.state.notificationState.setRooms(this.props.rooms);
}
private onAddRoom = (e) => { private onAddRoom = (e) => {
e.stopPropagation(); e.stopPropagation();
if (this.props.onAddRoom) this.props.onAddRoom(); if (this.props.onAddRoom) this.props.onAddRoom();
}; };
private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => {
const direction = e.movementY < 0 ? -1 : +1;
const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction;
this.props.layout.visibleTiles += tileDiff;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onShowAllClick = () => {
this.props.layout.visibleTiles = this.numTiles;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private renderTiles(): React.ReactElement[] { private renderTiles(): React.ReactElement[] {
const tiles: React.ReactElement[] = []; const tiles: React.ReactElement[] = [];
if (this.props.rooms) { if (this.props.rooms) {
for (const room of this.props.rooms) { for (const room of this.props.rooms) {
tiles.push(<RoomTile2 room={room} key={`room-${room.roomId}`}/>); tiles.push(
<RoomTile2
room={room}
key={`room-${room.roomId}`}
showMessagePreview={this.props.showMessagePreviews}
/>
);
} }
} }
@ -92,25 +116,9 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
private renderHeader(): React.ReactElement { private renderHeader(): React.ReactElement {
const notifications = !this.props.isInvite
? RoomNotifs.aggregateNotificationCount(this.props.rooms)
: {count: 0, highlight: true};
const notifCount = notifications.count;
const notifHighlight = notifications.highlight;
// TODO: Title on collapsed // TODO: Title on collapsed
// TODO: Incoming call box // TODO: Incoming call box
let chevron = null;
if (this.hasTiles()) {
const chevronClasses = classNames({
'mx_RoomSubList_chevron': true,
'mx_RoomSubList_chevronRight': false, // isCollapsed
'mx_RoomSubList_chevronDown': true, // !isCollapsed
});
chevron = (<div className={chevronClasses}/>);
}
return ( return (
<RovingTabIndexWrapper inputRef={this.headerButton}> <RovingTabIndexWrapper inputRef={this.headerButton}>
{({onFocus, isActive, ref}) => { {({onFocus, isActive, ref}) => {
@ -118,68 +126,37 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
// TODO: Collapsed state // TODO: Collapsed state
let badge;
if (true) { // !isCollapsed
const badgeClasses = classNames({
'mx_RoomSubList_badge': true,
'mx_RoomSubList_badgeHighlight': notifHighlight,
});
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
if (notifCount > 0) {
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
aria-label={_t("Jump to first unread room.")}
>
<div>
{FormattingUtils.formatCount(notifCount)}
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.hasTiles()) {
// Render the `!` badge for invites
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
aria-label={_t("Jump to first invite.")}
>
<div>
{FormattingUtils.formatCount(this.numTiles)}
</div>
</AccessibleButton>
);
}
}
let addRoomButton = null; const badge = <NotificationBadge allowNoCount={false} notification={this.state.notificationState}/>;
if (!!this.props.onAddRoom) {
addRoomButton = ( // TODO: Aux button
<AccessibleTooltipButton // let addRoomButton = null;
tabIndex={tabIndex} // if (!!this.props.onAddRoom) {
onClick={this.onAddRoom} // addRoomButton = (
className="mx_RoomSubList_addRoom" // <AccessibleTooltipButton
title={this.props.addRoomLabel || _t("Add room")} // tabIndex={tabIndex}
/> // onClick={this.onAddRoom}
); // className="mx_RoomSublist2_addButton"
} // title={this.props.addRoomLabel || _t("Add room")}
// />
// );
// }
// TODO: a11y (see old component) // TODO: a11y (see old component)
return ( return (
<div className={"mx_RoomSubList_labelContainer"}> <div className={"mx_RoomSublist2_headerContainer"}>
<AccessibleButton <AccessibleButton
inputRef={ref} inputRef={ref}
tabIndex={tabIndex} tabIndex={tabIndex}
className={"mx_RoomSubList_label"} className={"mx_RoomSublist2_headerText"}
role="treeitem" role="treeitem"
aria-level="1" aria-level="1"
> >
{chevron}
<span>{this.props.label}</span> <span>{this.props.label}</span>
</AccessibleButton> </AccessibleButton>
<div className="mx_RoomSublist2_badgeContainer">
{badge} {badge}
{addRoomButton} </div>
</div> </div>
); );
}} }}
@ -195,19 +172,65 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const classes = classNames({ const classes = classNames({
// TODO: Proper collapse support // TODO: Proper collapse support
'mx_RoomSubList': true, 'mx_RoomSublist2': true,
'mx_RoomSubList_hidden': false, // len && isCollapsed 'mx_RoomSublist2_collapsed': false, // len && isCollapsed
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
}); });
let content = null; let content = null;
if (tiles.length > 0) { if (tiles.length > 0) {
// TODO: Lazy list rendering // TODO: Lazy list rendering
// TODO: Whatever scrolling magic needs to happen here // TODO: Whatever scrolling magic needs to happen here
const layout = this.props.layout; // to shorten calls
const minTilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.minVisibleTiles));
const maxTilesPx = layout.tilesToPixels(tiles.length);
const tilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.visibleTiles));
let handles = ['s'];
if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) {
handles = []; // no handles, we're at a minimum
}
// TODO: This might need adjustment, however for now it is fine as a round.
const nVisible = Math.round(layout.visibleTiles);
const visibleTiles = tiles.slice(0, nVisible);
// If we're hiding rooms, show a 'show more' button to the user. This button
// replaces the last visible tile, so will always show 2+ rooms. We do this
// because if it said "show 1 more room" we had might as well show that room
// instead. We also replace the last item so we don't have to adjust our math
// on pixel heights, etc. It's much easier to pretend the button is a tile.
if (tiles.length > nVisible) {
// we have a cutoff condition - add the button to show all
// we +1 to account for the room we're about to hide with our 'show more' button
// this results in the button always being 1+, and not needing an i18n `count`.
const numMissing = (tiles.length - visibleTiles.length) + 1;
// TODO: CSS TBD
// TODO: Make this an actual tile
// TODO: This is likely to pop out of the list, consider that.
visibleTiles.splice(visibleTiles.length - 1, 1, (
<div
onClick={this.onShowAllClick}
className='mx_RoomSublist2_showMoreButton'
key='showall'
>
{_t("Show %(n)s more", {n: numMissing})}
</div>
));
}
content = ( content = (
<IndicatorScrollbar className='mx_RoomSubList_scroll'> <ResizableBox
{tiles} width={-1}
</IndicatorScrollbar> height={tilesPx}
axis="y"
minConstraints={[-1, minTilesPx]}
maxConstraints={[-1, maxTilesPx]}
resizeHandles={handles}
onResize={this.onResize}
className="mx_RoomSublist2_resizeBox"
>
{visibleTiles}
</ResizableBox>
) )
} }

View file

@ -23,15 +23,10 @@ import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleButton from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar"; import RoomAvatar from "../../views/avatars/RoomAvatar";
import Tooltip from "../../views/elements/Tooltip";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import * as RoomNotifs from '../../../RoomNotifs'; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership"; import NotificationBadge, { INotificationState, NotificationColor, RoomNotificationState } from "./NotificationBadge";
import * as Unread from '../../../Unread';
import * as FormattingUtils from "../../../utils/FormattingUtils";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
/******************************************************************* /*******************************************************************
* CAUTION * * CAUTION *
@ -41,30 +36,19 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event";
* warning disappears. * * warning disappears. *
*******************************************************************/ *******************************************************************/
enum NotificationColor {
// Inverted (None -> Red) because we do integer comparisons on this
None, // nothing special
Bold, // no badge, show as unread
Grey, // unread notified messages
Red, // unread pings
}
interface IProps { interface IProps {
room: Room; room: Room;
showMessagePreview: boolean;
// TODO: Allow falsifying counts (for invites and stuff) // TODO: Allow falsifying counts (for invites and stuff)
// TODO: Transparency? Was this ever used? // TODO: Transparency? Was this ever used?
// TODO: Incoming call boxes? // TODO: Incoming call boxes?
} }
interface INotificationState {
symbol: string;
color: NotificationColor;
}
interface IState { interface IState {
hover: boolean; hover: boolean;
notificationState: INotificationState; notificationState: INotificationState;
selected: boolean;
} }
export default class RoomTile2 extends React.Component<IProps, IState> { export default class RoomTile2 extends React.Component<IProps, IState> {
@ -86,86 +70,17 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.state = { this.state = {
hover: false, hover: false,
notificationState: this.getNotificationState(), notificationState: new RoomNotificationState(this.props.room),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
}; };
this.props.room.on("Room.receipt", this.handleRoomEventUpdate); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.props.room.on("Room.timeline", this.handleRoomEventUpdate);
this.props.room.on("Room.redaction", this.handleRoomEventUpdate);
MatrixClientPeg.get().on("Event.decrypted", this.handleRoomEventUpdate);
} }
public componentWillUnmount() { public componentWillUnmount() {
if (this.props.room) { if (this.props.room) {
this.props.room.removeListener("Room.receipt", this.handleRoomEventUpdate); ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.props.room.removeListener("Room.timeline", this.handleRoomEventUpdate);
this.props.room.removeListener("Room.redaction", this.handleRoomEventUpdate);
} }
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("Event.decrypted", this.handleRoomEventUpdate);
}
}
// XXX: This is a bit of an awful-looking hack. We should probably be using state for
// this, but instead we're kinda forced to either duplicate the code or thread a variable
// through the code paths. This feels like the least evil option.
private get roomIsInvite(): boolean {
return getEffectiveMembership(this.props.room.getMyMembership()) === EffectiveMembership.Invite;
}
private handleRoomEventUpdate = (event: MatrixEvent) => {
const roomId = event.getRoomId();
// Sanity check: should never happen
if (roomId !== this.props.room.roomId) return;
this.updateNotificationState();
};
private updateNotificationState() {
this.setState({notificationState: this.getNotificationState()});
}
private getNotificationState(): INotificationState {
const state: INotificationState = {
color: NotificationColor.None,
symbol: null,
};
if (this.roomIsInvite) {
state.color = NotificationColor.Red;
state.symbol = "!";
} else {
const redNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
const greyNotifs = RoomNotifs.getUnreadNotificationCount(this.props.room, 'total');
// For a 'true count' we pick the grey notifications first because they include the
// red notifications. If we don't have a grey count for some reason we use the red
// count. If that count is broken for some reason, assume zero. This avoids us showing
// a badge for 'NaN' (which formats as 'NaNB' for NaN Billion).
const trueCount = greyNotifs ? greyNotifs : (redNotifs ? redNotifs : 0);
// Note: we only set the symbol if we have an actual count. We don't want to show
// zero on badges.
if (redNotifs > 0) {
state.color = NotificationColor.Red;
state.symbol = FormattingUtils.formatCount(trueCount);
} else if (greyNotifs > 0) {
state.color = NotificationColor.Grey;
state.symbol = FormattingUtils.formatCount(trueCount);
} else {
// We don't have any notified messages, but we might have unread messages. Let's
// find out.
const hasUnread = Unread.doesRoomHaveUnreadMessages(this.props.room);
if (hasUnread) {
state.color = NotificationColor.Bold;
// no symbol for this state
}
}
}
return state;
} }
private onTileMouseEnter = () => { private onTileMouseEnter = () => {
@ -186,60 +101,44 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
}); });
}; };
private onActiveRoomUpdate = (isActive: boolean) => {
this.setState({selected: isActive});
};
public render(): React.ReactElement { public render(): React.ReactElement {
// TODO: Collapsed state // TODO: Collapsed state
// TODO: Invites // TODO: Invites
// TODO: a11y proper // TODO: a11y proper
// TODO: Render more than bare minimum // TODO: Render more than bare minimum
const hasBadge = this.state.notificationState.color > NotificationColor.Bold;
const isUnread = this.state.notificationState.color > NotificationColor.None;
const classes = classNames({ const classes = classNames({
'mx_RoomTile': true, 'mx_RoomTile2': true,
// 'mx_RoomTile_selected': this.state.selected, 'mx_RoomTile2_selected': this.state.selected,
'mx_RoomTile_unread': isUnread,
'mx_RoomTile_unreadNotify': this.state.notificationState.color >= NotificationColor.Grey,
'mx_RoomTile_highlight': this.state.notificationState.color >= NotificationColor.Red,
'mx_RoomTile_invited': this.roomIsInvite,
// 'mx_RoomTile_menuDisplayed': isMenuDisplayed,
'mx_RoomTile_noBadges': !hasBadge,
// 'mx_RoomTile_transparent': this.props.transparent,
// 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
}); });
const avatarClasses = classNames({ const badge = <NotificationBadge notification={this.state.notificationState} allowNoCount={true} />;
'mx_RoomTile_avatar': true,
});
let badge;
if (hasBadge) {
const badgeClasses = classNames({
'mx_RoomTile_badge': true,
'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed
});
badge = <div className={badgeClasses}>{this.state.notificationState.symbol}</div>;
}
// TODO: the original RoomTile uses state for the room name. Do we need to? // TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name; let name = this.props.room.name;
if (typeof name !== 'string') name = ''; if (typeof name !== 'string') name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
// TODO: Support collapsed state properly
// TODO: Tooltip?
let messagePreview = null;
if (this.props.showMessagePreview) {
// TODO: Actually get the real message preview from state
messagePreview = <div className="mx_RoomTile2_messagePreview">I just ate a pie.</div>;
}
const nameClasses = classNames({ const nameClasses = classNames({
'mx_RoomTile_name': true, "mx_RoomTile2_name": true,
'mx_RoomTile_invite': this.roomIsInvite, "mx_RoomTile2_nameWithPreview": !!messagePreview,
'mx_RoomTile_badgeShown': hasBadge, "mx_RoomTile2_nameHasUnreadEvents": this.state.notificationState.color >= NotificationColor.Bold,
}); });
// TODO: Support collapsed state properly const avatarSize = 32;
let tooltip = null;
if (false) { // isCollapsed
if (this.state.hover) {
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} />
}
}
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTile}> <RovingTabIndexWrapper inputRef={this.roomTile}>
@ -254,20 +153,18 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onClick={this.onTileClick} onClick={this.onTileClick}
role="treeitem" role="treeitem"
> >
<div className={avatarClasses}> <div className="mx_RoomTile2_avatarContainer">
<div className="mx_RoomTile_avatar_container"> <RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize}/>
<RoomAvatar room={this.props.room} width={24} height={24}/>
</div> </div>
</div> <div className="mx_RoomTile2_nameContainer">
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto"> <div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name} {name}
</div> </div>
{messagePreview}
</div> </div>
<div className="mx_RoomTile2_badgeContainer">
{badge} {badge}
</div> </div>
{tooltip}
</AccessibleButton> </AccessibleButton>
} }
</RovingTabIndexWrapper> </RovingTabIndexWrapper>

View file

@ -44,6 +44,7 @@ import {Key} from "../../../Keyboard";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {MatrixClientPeg} from "../../../MatrixClientPeg";
import RateLimitedFunc from '../../../ratelimitedfunc'; import RateLimitedFunc from '../../../ratelimitedfunc';
import {Action} from "../../../dispatcher/actions";
function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) { function addReplyToMessageContent(content, repliedToEvent, permalinkCreator) {
const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent); const replyContent = ReplyThread.makeReplyMixIn(repliedToEvent);
@ -364,7 +365,7 @@ export default class SendMessageComposer extends React.Component {
onAction = (payload) => { onAction = (payload) => {
switch (payload.action) { switch (payload.action) {
case 'reply_to_event': case 'reply_to_event':
case 'focus_composer': case Action.FocusComposer:
this._editorRef && this._editorRef.focus(); this._editorRef && this._editorRef.focus();
break; break;
case 'insert_mention': case 'insert_mention':

View file

@ -62,7 +62,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
super(props); super(props);
this.state = { this.state = {
fontSize: SettingsStore.getValue("fontSize", null).toString(), fontSize: (SettingsStore.getValue("baseFontSize", null) + FontWatcher.SIZE_DIFF).toString(),
...this.calculateThemeState(), ...this.calculateThemeState(),
customThemeUrl: "", customThemeUrl: "",
customThemeMessage: {isError: false, text: ""}, customThemeMessage: {isError: false, text: ""},
@ -132,13 +132,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
private onFontSizeChanged = (size: number): void => { private onFontSizeChanged = (size: number): void => {
this.setState({fontSize: size.toString()}); this.setState({fontSize: size.toString()});
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, size); SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, size - FontWatcher.SIZE_DIFF);
}; };
private onValidateFontSize = async ({value}: Pick<IFieldState, "value">): Promise<IValidationResult> => { private onValidateFontSize = async ({value}: Pick<IFieldState, "value">): Promise<IValidationResult> => {
const parsedSize = parseFloat(value); const parsedSize = parseFloat(value);
const min = FontWatcher.MIN_SIZE; const min = FontWatcher.MIN_SIZE + FontWatcher.SIZE_DIFF;
const max = FontWatcher.MAX_SIZE; const max = FontWatcher.MAX_SIZE + FontWatcher.SIZE_DIFF;
if (isNaN(parsedSize)) { if (isNaN(parsedSize)) {
return {valid: false, feedback: _t("Size must be a number")}; return {valid: false, feedback: _t("Size must be a number")};
@ -151,7 +151,13 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
}; };
} }
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, value); SettingsStore.setValue(
"baseFontSize",
null,
SettingLevel.DEVICE,
parseInt(value, 10) - FontWatcher.SIZE_DIFF
);
return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})}; return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})};
} }
@ -275,7 +281,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
values={[13, 15, 16, 18, 20]} values={[13, 15, 16, 18, 20]}
value={parseInt(this.state.fontSize, 10)} value={parseInt(this.state.fontSize, 10)}
onSelectionChange={this.onFontSizeChanged} onSelectionChange={this.onFontSizeChanged}
displayFunc={value => ""} displayFunc={_ => ""}
disabled={this.state.useCustomFontSize} disabled={this.state.useCustomFontSize}
/> />
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div> <div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
@ -284,9 +290,10 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
name="useCustomFontSize" name="useCustomFontSize"
level={SettingLevel.ACCOUNT} level={SettingLevel.ACCOUNT}
onChange={(checked) => this.setState({useCustomFontSize: checked})} onChange={(checked) => this.setState({useCustomFontSize: checked})}
useCheckbox={true}
/> />
<Field <Field
type="text" type="number"
label={_t("Font size")} label={_t("Font size")}
autoComplete="off" autoComplete="off"
placeholder={this.state.fontSize.toString()} placeholder={this.state.fontSize.toString()}
@ -295,6 +302,7 @@ export default class AppearanceUserSettingsTab extends React.Component<IProps, I
onValidate={this.onValidateFontSize} onValidate={this.onValidateFontSize}
onChange={(value) => this.setState({fontSize: value.target.value})} onChange={(value) => this.setState({fontSize: value.target.value})}
disabled={!this.state.useCustomFontSize} disabled={!this.state.useCustomFontSize}
className="mx_SettingsTab_customFontSizeField"
/> />
</div>; </div>;
} }

View file

@ -36,9 +36,15 @@ export enum Action {
/** /**
* Open the user settings. No additional payload information required. * Open the user settings. No additional payload information required.
* Optionally can include an OpenToTabPayload.
*/ */
ViewUserSettings = "view_user_settings", ViewUserSettings = "view_user_settings",
/**
* Opens the room directory. No additional payload information required.
*/
ViewRoomDirectory = "view_room_directory",
/** /**
* Sets the current tooltip. Should be use with ViewTooltipPayload. * Sets the current tooltip. Should be use with ViewTooltipPayload.
*/ */
@ -53,4 +59,14 @@ export enum Action {
* Provide status information for an ongoing update check. Should be used with a CheckUpdatesPayload. * Provide status information for an ongoing update check. Should be used with a CheckUpdatesPayload.
*/ */
CheckUpdates = "check_updates", CheckUpdates = "check_updates",
/**
* Focuses the user's cursor to the composer. No additional payload information required.
*/
FocusComposer = "focus_composer",
/**
* Opens the user menu (previously known as the top left menu). No additional payload information required.
*/
ToggleUserMenu = "toggle_user_menu",
} }

View file

@ -0,0 +1,27 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { ActionPayload } from "../payloads";
import { Action } from "../actions";
export interface OpenToTabPayload extends ActionPayload {
action: Action.ViewUserSettings | string, // TODO: Add room settings action
/**
* The tab ID to open in the settings view to start, if possible.
*/
initialTabId?: string;
}

View file

@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
// @ts-ignore - import * as EMOJIBASE actually breaks this
import EMOJIBASE from 'emojibase-data/en/compact.json'; import EMOJIBASE from 'emojibase-data/en/compact.json';
export interface IEmoji { export interface IEmoji {
@ -63,6 +62,8 @@ export const DATA_BY_CATEGORY = {
"flags": [], "flags": [],
}; };
const ZERO_WIDTH_JOINER = "\u200D";
// Store various mappings from unicode/emoticon/shortcode to the Emoji objects // Store various mappings from unicode/emoticon/shortcode to the Emoji objects
EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => { EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group]; const categoryId = EMOJIBASE_GROUP_ID_TO_CATEGORY[emoji.group];
@ -70,7 +71,8 @@ EMOJIBASE.forEach((emoji: IEmojiWithFilterString) => {
DATA_BY_CATEGORY[categoryId].push(emoji); DATA_BY_CATEGORY[categoryId].push(emoji);
} }
// This is used as the string to match the query against when filtering emojis // This is used as the string to match the query against when filtering emojis
emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}`.toLowerCase(); emoji.filterString = `${emoji.annotation}\n${emoji.shortcodes.join('\n')}}\n${emoji.emoticon || ''}\n` +
`${emoji.unicode.split(ZERO_WIDTH_JOINER).join("\n")}`.toLowerCase();
// Add mapping from unicode to Emoji object // Add mapping from unicode to Emoji object
// The 'unicode' field that we use in emojibase has either // The 'unicode' field that we use in emojibase has either

View file

@ -1090,6 +1090,7 @@
"Low priority": "Low priority", "Low priority": "Low priority",
"Historical": "Historical", "Historical": "Historical",
"System Alerts": "System Alerts", "System Alerts": "System Alerts",
"People": "People",
"This room": "This room", "This room": "This room",
"Joining room …": "Joining room …", "Joining room …": "Joining room …",
"Loading …": "Loading …", "Loading …": "Loading …",
@ -1133,9 +1134,7 @@
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>", "Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
"Not now": "Not now", "Not now": "Not now",
"Don't ask me again": "Don't ask me again", "Don't ask me again": "Don't ask me again",
"Jump to first unread room.": "Jump to first unread room.", "Show %(n)s more": "Show %(n)s more",
"Jump to first invite.": "Jump to first invite.",
"Add room": "Add room",
"Options": "Options", "Options": "Options",
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
"%(count)s unread messages including mentions.|one": "1 unread mention.", "%(count)s unread messages including mentions.|one": "1 unread mention.",
@ -1959,6 +1958,7 @@
"Explore": "Explore", "Explore": "Explore",
"Filter": "Filter", "Filter": "Filter",
"Filter rooms…": "Filter rooms…", "Filter rooms…": "Filter rooms…",
"Explore rooms": "Explore rooms",
"Failed to reject invitation": "Failed to reject invitation", "Failed to reject invitation": "Failed to reject invitation",
"This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.", "This room is not public. You will not be able to rejoin without an invite.": "This room is not public. You will not be able to rejoin without an invite.",
"Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?", "Are you sure you want to leave the room '%(roomName)s'?": "Are you sure you want to leave the room '%(roomName)s'?",
@ -2004,7 +2004,6 @@
"Find a room…": "Find a room…", "Find a room…": "Find a room…",
"Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)", "Find a room… (e.g. %(exampleRoom)s)": "Find a room… (e.g. %(exampleRoom)s)",
"If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.", "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.": "If you can't find the room you're looking for, ask for an invite or <a>Create a new room</a>.",
"Explore rooms": "Explore rooms",
"You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.", "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.": "You can't send any messages until you review and agree to <consentLink>our terms and conditions</consentLink>.",
"Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has hit its Monthly Active User Limit. Please <a>contact your service administrator</a> to continue using the service.",
"Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.", "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.": "Your message wasn't sent because this homeserver has exceeded a resource limit. Please <a>contact your service administrator</a> to continue using the service.",
@ -2016,6 +2015,9 @@
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
"Active call": "Active call", "Active call": "Active call",
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?", "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
"Jump to first unread room.": "Jump to first unread room.",
"Jump to first invite.": "Jump to first invite.",
"Add room": "Add room",
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
"Search failed": "Search failed", "Search failed": "Search failed",
@ -2040,6 +2042,14 @@
"Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others", "Uploading %(filename)s and %(count)s others|other": "Uploading %(filename)s and %(count)s others",
"Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s", "Uploading %(filename)s and %(count)s others|zero": "Uploading %(filename)s",
"Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other", "Uploading %(filename)s and %(count)s others|one": "Uploading %(filename)s and %(count)s other",
"Switch to light mode": "Switch to light mode",
"Switch to dark mode": "Switch to dark mode",
"Switch theme": "Switch theme",
"Security & privacy": "Security & privacy",
"All settings": "All settings",
"Archived rooms": "Archived rooms",
"Feedback": "Feedback",
"Account settings": "Account settings",
"Could not load user profile": "Could not load user profile", "Could not load user profile": "Could not load user profile",
"Verify this login": "Verify this login", "Verify this login": "Verify this login",
"Session verified": "Session verified", "Session verified": "Session verified",

View file

@ -290,6 +290,33 @@ export default class EventIndex extends EventEmitter {
return validEventType && validMsgType && hasContentValue; return validEventType && validMsgType && hasContentValue;
} }
eventToJson(ev) {
const jsonEvent = ev.toJSON();
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
if (ev.isEncrypted()) {
// Let us store some additional data so we can re-verify the event.
// The js-sdk checks if an event is encrypted using the algorithm,
// the sender key and ed25519 signing key are used to find the
// correct device that sent the event which allows us to check the
// verification state of the event, either directly or using cross
// signing.
e.curve25519Key = ev.getSenderKey();
e.ed25519Key = ev.getClaimedEd25519Key();
e.algorithm = ev.getWireContent().algorithm;
e.forwardingCurve25519KeyChain = ev.getForwardingCurve25519KeyChain();
} else {
// Make sure that unencrypted events don't contain any of that data,
// despite what the server might give to us.
delete e.curve25519Key;
delete e.ed25519Key;
delete e.algorithm;
delete e.forwardingCurve25519KeyChain;
}
return e;
}
/** /**
* Queue up live events to be added to the event index. * Queue up live events to be added to the event index.
* *
@ -300,8 +327,7 @@ export default class EventIndex extends EventEmitter {
if (!this.isValidEvent(ev)) return; if (!this.isValidEvent(ev)) return;
const jsonEvent = ev.toJSON(); const e = this.eventToJson(ev);
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
const profile = { const profile = {
displayname: ev.sender.rawDisplayName, displayname: ev.sender.rawDisplayName,
@ -477,8 +503,7 @@ export default class EventIndex extends EventEmitter {
// Let us convert the events back into a format that EventIndex can // Let us convert the events back into a format that EventIndex can
// consume. // consume.
const events = filteredEvents.map((ev) => { const events = filteredEvents.map((ev) => {
const jsonEvent = ev.toJSON(); const e = this.eventToJson(ev);
const e = ev.isEncrypted() ? jsonEvent.decrypted : jsonEvent;
let profile = {}; let profile = {};
if (e.sender in profiles) profile = profiles[e.sender]; if (e.sender in profiles) profile = profiles[e.sender];

View file

@ -145,6 +145,10 @@ export default async function sendBugReport(bugReportEndpoint: string, opts: IOp
if (enabledLabs.length) { if (enabledLabs.length) {
body.append('enabled_labs', enabledLabs.join(', ')); body.append('enabled_labs', enabledLabs.join(', '));
} }
// if low bandwidth mode is enabled, say so over rageshake, it causes many issues
if (SettingsStore.getValue("lowBandwidth")) {
body.append("lowBandwidth", "enabled");
}
// add storage persistence/quota information // add storage persistence/quota information
if (navigator.storage && navigator.storage.persisted) { if (navigator.storage && navigator.storage.persisted) {

View file

@ -170,10 +170,10 @@ export const SETTINGS = {
displayName: _td("Show info about bridges in room settings"), displayName: _td("Show info about bridges in room settings"),
default: false, default: false,
}, },
"fontSize": { "baseFontSize": {
displayName: _td("Font size"), displayName: _td("Font size"),
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: 15, default: 10,
controller: new FontSizeController(), controller: new FontSizeController(),
}, },
"useCustomFontSize": { "useCustomFontSize": {

View file

@ -20,8 +20,10 @@ import IWatcher from "./Watcher";
import { toPx } from '../../utils/units'; import { toPx } from '../../utils/units';
export class FontWatcher implements IWatcher { export class FontWatcher implements IWatcher {
public static readonly MIN_SIZE = 13; public static readonly MIN_SIZE = 8;
public static readonly MAX_SIZE = 20; public static readonly MAX_SIZE = 15;
// Externally we tell the user the font is size 15. Internally we use 10.
public static readonly SIZE_DIFF = 5;
private dispatcherRef: string; private dispatcherRef: string;
@ -30,7 +32,7 @@ export class FontWatcher implements IWatcher {
} }
public start() { public start() {
this.setRootFontSize(SettingsStore.getValue("fontSize")); this.setRootFontSize(SettingsStore.getValue("baseFontSize"));
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }
@ -48,7 +50,7 @@ export class FontWatcher implements IWatcher {
const fontSize = Math.max(Math.min(FontWatcher.MAX_SIZE, size), FontWatcher.MIN_SIZE); const fontSize = Math.max(Math.min(FontWatcher.MAX_SIZE, size), FontWatcher.MIN_SIZE);
if (fontSize !== size) { if (fontSize !== size) {
SettingsStore.setValue("fontSize", null, SettingLevel.Device, fontSize); SettingsStore.setValue("baseFontSize", null, SettingLevel.DEVICE, fontSize);
} }
(<HTMLElement>document.querySelector(":root")).style.fontSize = toPx(fontSize); (<HTMLElement>document.querySelector(":root")).style.fontSize = toPx(fontSize);
}; };

View file

@ -0,0 +1,71 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { TagID } from "./models";
const TILE_HEIGHT_PX = 44;
interface ISerializedListLayout {
numTiles: number;
}
export class ListLayout {
private _n = 0;
constructor(public readonly tagId: TagID) {
const serialized = localStorage.getItem(this.key);
if (serialized) {
// We don't use the setters as they cause writes.
const parsed = <ISerializedListLayout>JSON.parse(serialized);
this._n = parsed.numTiles;
}
}
public get tileHeight(): number {
return TILE_HEIGHT_PX;
}
private get key(): string {
return `mx_sublist_layout_${this.tagId}_boxed`;
}
public get visibleTiles(): number {
return Math.max(this._n, this.minVisibleTiles);
}
public set visibleTiles(v: number) {
this._n = v;
localStorage.setItem(this.key, JSON.stringify(this.serialize()));
}
public get minVisibleTiles(): number {
return 3;
}
public tilesToPixels(n: number): number {
return n * this.tileHeight;
}
public pixelsToTiles(px: number): number {
return px / this.tileHeight;
}
private serialize(): ISerializedListLayout {
return {
numTiles: this.visibleTiles,
};
}
}

View file

@ -74,29 +74,29 @@ gets applied to each category in a sub-sub-list fashion. This should result in t
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
collectively the tag will be sorted into categories with red being at the top. collectively the tag will be sorted into categories with red being at the top.
<!-- TODO: Implement sticky rooms as described below --> ### Sticky rooms
The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing. When the user visits a room, that room becomes 'sticky' in the list, regardless of ordering algorithm.
The sticky room will remain in position on the room list regardless of other factors going on as typically From a code perspective, the underlying algorithm is not aware of a sticky room and instead the base class
clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms manages which room is sticky. This is to ensure that all algorithms handle it the same.
above the selected room at all times, where N is the number of rooms above the selected rooms when it was
selected.
For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one The sticky flag is simply to say it will not move higher or lower down the list while it is active. For
room above their selection at all times. If they receive another notification, and the tag ordering is example, if using the importance algorithm, the room would naturally become idle once viewed and thus
specified as Recent, they'll see the new notification go to the top position, and the one that was previously would normally fly down the list out of sight. The sticky room concept instead holds it in place, never
there fall behind the sticky room. letting it fly down until the user moves to another room.
The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the Only one room can be sticky at a time. Room updates around the sticky room will still hold the sticky
tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another room in place. The best example of this is the importance algorithm: if the user has 3 red rooms and
room, the previous sticky room gets recalculated to determine which category it needs to be in as the user selects the middle room, they will see exactly one room above their selection at all times. If they
could have been scrolled up while new messages were received. receive another notification which causes the room to move into the topmost position, the room that was
above the sticky room will move underneath to allow for the new room to take the top slot, maintaining
the sticky room's position.
Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what Though only applicable to the importance algorithm, the sticky room is not aware of category boundaries
kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user and thus the user can see a shift in what kinds of rooms move around their selection. An example would
selecting the third room (leaving 2 above it), and then having the rooms above it read on another device. be the user having 4 red rooms, the user selecting the third room (leaving 2 above it), and then having
This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain the rooms above it read on another device. This would result in 1 red room and 1 other kind of room
2 rooms above the sticky room. above the sticky room as it will try to maintain 2 rooms above the sticky room.
An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement An exception for the sticky room placement is when there's suddenly not enough rooms to maintain the placement
exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain exactly. This typically happens if the user selects a room and leaves enough rooms where it cannot maintain

View file

@ -29,6 +29,7 @@ import defaultDispatcher from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts"; import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { IFilterCondition } from "./filters/IFilterCondition"; import { IFilterCondition } from "./filters/IFilterCondition";
import { TagWatcher } from "./TagWatcher"; import { TagWatcher } from "./TagWatcher";
import RoomViewStore from "../RoomViewStore";
interface IState { interface IState {
tagsEnabled?: boolean; tagsEnabled?: boolean;
@ -62,6 +63,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.checkEnabled(); this.checkEnabled();
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null); for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
RoomViewStore.addListener(this.onRVSUpdate);
} }
public get orderedLists(): ITagMap { public get orderedLists(): ITagMap {
@ -93,6 +95,23 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
this.setAlgorithmClass(); this.setAlgorithmClass();
} }
private onRVSUpdate = () => {
if (!this.enabled) return; // TODO: Remove enabled flag when RoomListStore2 takes over
if (!this.matrixClient) return; // We assume there won't be RVS updates without a client
const activeRoomId = RoomViewStore.getRoomId();
if (!activeRoomId && this.algorithm.stickyRoom) {
this.algorithm.stickyRoom = null;
} else if (activeRoomId) {
const activeRoom = this.matrixClient.getRoom(activeRoomId);
if (!activeRoom) throw new Error(`${activeRoomId} is current in RVS but missing from client`);
if (activeRoom !== this.algorithm.stickyRoom) {
console.log(`Changing sticky room to ${activeRoomId}`);
this.algorithm.stickyRoom = activeRoom;
}
}
};
protected async onDispatch(payload: ActionPayload) { protected async onDispatch(payload: ActionPayload) {
if (payload.action === 'MatrixActions.sync') { if (payload.action === 'MatrixActions.sync') {
// Filter out anything that isn't the first PREPARED sync. // Filter out anything that isn't the first PREPARED sync.
@ -110,6 +129,7 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
console.log("Regenerating room lists: Startup"); console.log("Regenerating room lists: Startup");
await this.readAndCacheSettingsFromStore(); await this.readAndCacheSettingsFromStore();
await this.regenerateAllLists(); await this.regenerateAllLists();
this.onRVSUpdate(); // fake an RVS update to adjust sticky room, if needed
} }
// TODO: Remove this once the RoomListStore becomes default // TODO: Remove this once the RoomListStore becomes default
@ -145,13 +165,19 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
// First see if the receipt event is for our own user. If it was, trigger // First see if the receipt event is for our own user. If it was, trigger
// a room update (we probably read the room on a different device). // a room update (we probably read the room on a different device).
if (readReceiptChangeIsFor(payload.event, this.matrixClient)) { if (readReceiptChangeIsFor(payload.event, this.matrixClient)) {
// TODO: Update room now that it's been read console.log(`[RoomListDebug] Got own read receipt in ${payload.event.roomId}`);
console.log(payload); const room = this.matrixClient.getRoom(payload.event.roomId);
if (!room) {
console.warn(`Own read receipt was in unknown room ${payload.event.roomId}`);
return;
}
await this.handleRoomUpdate(room, RoomUpdateCause.ReadReceipt);
return; return;
} }
} else if (payload.action === 'MatrixActions.Room.tags') { } else if (payload.action === 'MatrixActions.Room.tags') {
// TODO: Update room from tags const roomPayload = (<any>payload); // TODO: Type out the dispatcher types
console.log(payload); console.log(`[RoomListDebug] Got tag change in ${roomPayload.room.roomId}`);
await this.handleRoomUpdate(roomPayload.room, RoomUpdateCause.PossibleTagChange);
} else if (payload.action === 'MatrixActions.Room.timeline') { } else if (payload.action === 'MatrixActions.Room.timeline') {
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
@ -189,26 +215,39 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :( // cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline); await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') { } else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
// TODO: Update DMs const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
console.log(payload); console.log(`[RoomListDebug] Received updated DM map`);
} else if (payload.action === 'MatrixActions.Room.myMembership') { const dmMap = eventPayload.event.getContent();
// TODO: Improve new room check for (const userId of Object.keys(dmMap)) {
const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types const roomIds = dmMap[userId];
if (!membershipPayload.oldMembership && membershipPayload.membership === "join") { for (const roomId of roomIds) {
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`); const room = this.matrixClient.getRoom(roomId);
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); if (!room) {
console.warn(`${roomId} was found in DMs but the room is not in the store`);
continue;
} }
// TODO: Update room from membership change // We expect this RoomUpdateCause to no-op if there's no change, and we don't expect
console.log(payload); // the user to have hundreds of rooms to update in one event. As such, we just hammer
} else if (payload.action === 'MatrixActions.Room') { // away at updates until the problem is solved. If we were expecting more than a couple
// TODO: Improve new room check // of rooms to be updated at once, we would consider batching the rooms up.
// const roomPayload = (<any>payload); // TODO: Type out the dispatcher types await this.handleRoomUpdate(room, RoomUpdateCause.PossibleTagChange);
// console.log(`[RoomListDebug] Handling new room ${roomPayload.room.roomId}`); }
// await this.algorithm.handleRoomUpdate(roomPayload.room, RoomUpdateCause.NewRoom); }
} else if (payload.action === 'view_room') { } else if (payload.action === 'MatrixActions.Room.myMembership') {
// TODO: Update sticky room const membershipPayload = (<any>payload); // TODO: Type out the dispatcher types
console.log(payload); if (membershipPayload.oldMembership !== "join" && membershipPayload.membership === "join") {
console.log(`[RoomListDebug] Handling new room ${membershipPayload.room.roomId}`);
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
return;
}
// If it's not a join, it's transitioning into a different list (possibly historical)
if (membershipPayload.oldMembership !== membershipPayload.membership) {
console.log(`[RoomListDebug] Handling membership change in ${membershipPayload.room.roomId}`);
await this.algorithm.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
return;
}
} }
} }

View file

@ -20,8 +20,11 @@ import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
import { EffectiveMembership, splitRoomsByMembership } from "../../membership"; import { EffectiveMembership, splitRoomsByMembership } from "../../membership";
import { ITagMap, ITagSortingMap } from "../models"; import { ITagMap, ITagSortingMap } from "../models";
import DMRoomMap from "../../../../utils/DMRoomMap"; import DMRoomMap from "../../../../utils/DMRoomMap";
import { FILTER_CHANGED, IFilterCondition } from "../../filters/IFilterCondition"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "../../filters/IFilterCondition";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { UPDATE_EVENT } from "../../../AsyncStore";
import { ArrayUtil } from "../../../../utils/arrays";
import { getEnumValues } from "../../../../utils/enums";
// TODO: Add locking support to avoid concurrent writes? // TODO: Add locking support to avoid concurrent writes?
@ -30,6 +33,12 @@ import { EventEmitter } from "events";
*/ */
export const LIST_UPDATED_EVENT = "list_updated_event"; export const LIST_UPDATED_EVENT = "list_updated_event";
interface IStickyRoom {
room: Room;
position: number;
tag: TagID;
}
/** /**
* Represents a list ordering algorithm. This class will take care of tag * Represents a list ordering algorithm. This class will take care of tag
* management (which rooms go in which tags) and ask the implementation to * management (which rooms go in which tags) and ask the implementation to
@ -37,7 +46,9 @@ export const LIST_UPDATED_EVENT = "list_updated_event";
*/ */
export abstract class Algorithm extends EventEmitter { export abstract class Algorithm extends EventEmitter {
private _cachedRooms: ITagMap = {}; private _cachedRooms: ITagMap = {};
private _cachedStickyRooms: ITagMap = {}; // a clone of the _cachedRooms, with the sticky room
private filteredRooms: ITagMap = {}; private filteredRooms: ITagMap = {};
private _stickyRoom: IStickyRoom = null;
protected sortAlgorithms: ITagSortingMap; protected sortAlgorithms: ITagSortingMap;
protected rooms: Room[] = []; protected rooms: Room[] = [];
@ -51,6 +62,15 @@ export abstract class Algorithm extends EventEmitter {
super(); super();
} }
public get stickyRoom(): Room {
return this._stickyRoom ? this._stickyRoom.room : null;
}
public set stickyRoom(val: Room) {
// setters can't be async, so we call a private function to do the work
this.updateStickyRoom(val);
}
protected get hasFilters(): boolean { protected get hasFilters(): boolean {
return this.allowedByFilter.size > 0; return this.allowedByFilter.size > 0;
} }
@ -58,9 +78,14 @@ export abstract class Algorithm extends EventEmitter {
protected set cachedRooms(val: ITagMap) { protected set cachedRooms(val: ITagMap) {
this._cachedRooms = val; this._cachedRooms = val;
this.recalculateFilteredRooms(); this.recalculateFilteredRooms();
this.recalculateStickyRoom();
} }
protected get cachedRooms(): ITagMap { protected get cachedRooms(): ITagMap {
// 🐉 Here be dragons.
// Note: this is used by the underlying algorithm classes, so don't make it return
// the sticky room cache. If it ends up returning the sticky room cache, we end up
// corrupting our caches and confusing them.
return this._cachedRooms; return this._cachedRooms;
} }
@ -94,28 +119,100 @@ export abstract class Algorithm extends EventEmitter {
} }
} }
private async updateStickyRoom(val: Room) {
// Note throughout: We need async so we can wait for handleRoomUpdate() to do its thing,
// otherwise we risk duplicating rooms.
// It's possible to have no selected room. In that case, clear the sticky room
if (!val) {
if (this._stickyRoom) {
// Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(this._stickyRoom.room, RoomUpdateCause.NewRoom);
}
this._stickyRoom = null;
return;
}
// When we do have a room though, we expect to be able to find it
const tag = this.roomIdsToTags[val.roomId][0];
if (!tag) throw new Error(`${val.roomId} does not belong to a tag and cannot be sticky`);
let position = this.cachedRooms[tag].indexOf(val);
if (position < 0) throw new Error(`${val.roomId} does not appear to be known and cannot be sticky`);
// 🐉 Here be dragons.
// Before we can go through with lying to the underlying algorithm about a room
// we need to ensure that when we do we're ready for the innevitable sticky room
// update we'll receive. To prepare for that, we first remove the sticky room and
// recalculate the state ourselves so that when the underlying algorithm calls for
// the same thing it no-ops. After we're done calling the algorithm, we'll issue
// a new update for ourselves.
const lastStickyRoom = this._stickyRoom;
console.log(`Last sticky room:`, lastStickyRoom);
this._stickyRoom = null;
this.recalculateStickyRoom();
// When we do have the room, re-add the old room (if needed) to the algorithm
// and remove the sticky room from the algorithm. This is so the underlying
// algorithm doesn't try and confuse itself with the sticky room concept.
if (lastStickyRoom) {
// Lie to the algorithm and re-add the room to the algorithm
await this.handleRoomUpdate(lastStickyRoom.room, RoomUpdateCause.NewRoom);
}
// Lie to the algorithm and remove the room from it's field of view
await this.handleRoomUpdate(val, RoomUpdateCause.RoomRemoved);
// Now that we're done lying to the algorithm, we need to update our position
// marker only if the user is moving further down the same list. If they're switching
// lists, or moving upwards, the position marker will splice in just fine but if
// they went downwards in the same list we'll be off by 1 due to the shifting rooms.
if (lastStickyRoom && lastStickyRoom.tag === tag && lastStickyRoom.position <= position) {
position++;
}
this._stickyRoom = {
room: val,
position: position,
tag: tag,
};
this.recalculateStickyRoom();
// Finally, trigger an update
this.emit(LIST_UPDATED_EVENT);
}
protected recalculateFilteredRooms() { protected recalculateFilteredRooms() {
if (!this.hasFilters) { if (!this.hasFilters) {
return; return;
} }
console.warn("Recalculating filtered room list"); console.warn("Recalculating filtered room list");
const allowedByFilters = new Set<Room>();
const filters = Array.from(this.allowedByFilter.keys()); const filters = Array.from(this.allowedByFilter.keys());
const orderedFilters = new ArrayUtil(filters)
.groupBy(f => f.relativePriority)
.orderBy(getEnumValues(FilterPriority))
.value;
const newMap: ITagMap = {}; const newMap: ITagMap = {};
for (const tagId of Object.keys(this.cachedRooms)) { for (const tagId of Object.keys(this.cachedRooms)) {
// Cheaply clone the rooms so we can more easily do operations on the list. // Cheaply clone the rooms so we can more easily do operations on the list.
// We optimize our lookups by trying to reduce sample size as much as possible // We optimize our lookups by trying to reduce sample size as much as possible
// to the rooms we know will be deduped by the Set. // to the rooms we know will be deduped by the Set.
const rooms = this.cachedRooms[tagId]; const rooms = this.cachedRooms[tagId];
const remainingRooms = rooms.map(r => r).filter(r => !allowedByFilters.has(r)); let remainingRooms = rooms.map(r => r);
const allowedRoomsInThisTag = []; let allowedRoomsInThisTag = [];
for (const filter of filters) { let lastFilterPriority = orderedFilters[0].relativePriority;
for (const filter of orderedFilters) {
if (filter.relativePriority !== lastFilterPriority) {
// Every time the filter changes priority, we want more specific filtering.
// To accomplish that, reset the variables to make it look like the process
// has started over, but using the filtered rooms as the seed.
remainingRooms = allowedRoomsInThisTag;
allowedRoomsInThisTag = [];
lastFilterPriority = filter.relativePriority;
}
const filteredRooms = remainingRooms.filter(r => filter.isVisible(r)); const filteredRooms = remainingRooms.filter(r => filter.isVisible(r));
for (const room of filteredRooms) { for (const room of filteredRooms) {
const idx = remainingRooms.indexOf(room); const idx = remainingRooms.indexOf(room);
if (idx >= 0) remainingRooms.splice(idx, 1); if (idx >= 0) remainingRooms.splice(idx, 1);
allowedByFilters.add(room);
allowedRoomsInThisTag.push(room); allowedRoomsInThisTag.push(room);
} }
} }
@ -123,7 +220,8 @@ export abstract class Algorithm extends EventEmitter {
console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`); console.log(`[DEBUG] ${newMap[tagId].length}/${rooms.length} rooms filtered into ${tagId}`);
} }
this.allowedRoomsByFilters = allowedByFilters; const allowedRooms = Object.values(newMap).reduce((rv, v) => { rv.push(...v); return rv; }, <Room[]>[]);
this.allowedRoomsByFilters = new Set(allowedRooms);
this.filteredRooms = newMap; this.filteredRooms = newMap;
this.emit(LIST_UPDATED_EVENT); this.emit(LIST_UPDATED_EVENT);
} }
@ -154,6 +252,59 @@ export abstract class Algorithm extends EventEmitter {
console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`); console.log(`[DEBUG] ${filteredRooms.length}/${rooms.length} rooms filtered into ${tagId}`);
} }
/**
* Recalculate the sticky room position. If this is being called in relation to
* a specific tag being updated, it should be given to this function to optimize
* the call.
* @param updatedTag The tag that was updated, if possible.
*/
protected recalculateStickyRoom(updatedTag: TagID = null): void {
// 🐉 Here be dragons.
// This function does far too much for what it should, and is called by many places.
// Not only is this responsible for ensuring the sticky room is held in place at all
// times, it is also responsible for ensuring our clone of the cachedRooms is up to
// date. If either of these desyncs, we see weird behaviour like duplicated rooms,
// outdated lists, and other nonsensical issues that aren't necessarily obvious.
if (!this._stickyRoom) {
// If there's no sticky room, just do nothing useful.
if (!!this._cachedStickyRooms) {
// Clear the cache if we won't be needing it
this._cachedStickyRooms = null;
this.emit(LIST_UPDATED_EVENT);
}
return;
}
if (!this._cachedStickyRooms || !updatedTag) {
console.log(`Generating clone of cached rooms for sticky room handling`);
const stickiedTagMap: ITagMap = {};
for (const tagId of Object.keys(this.cachedRooms)) {
stickiedTagMap[tagId] = this.cachedRooms[tagId].map(r => r); // shallow clone
}
this._cachedStickyRooms = stickiedTagMap;
}
if (updatedTag) {
// Update the tag indicated by the caller, if possible. This is mostly to ensure
// our cache is up to date.
console.log(`Replacing cached sticky rooms for ${updatedTag}`);
this._cachedStickyRooms[updatedTag] = this.cachedRooms[updatedTag].map(r => r); // shallow clone
}
// Now try to insert the sticky room, if we need to.
// We need to if there's no updated tag (we regenned the whole cache) or if the tag
// we might have updated from the cache is also our sticky room.
const sticky = this._stickyRoom;
if (!updatedTag || updatedTag === sticky.tag) {
console.log(`Inserting sticky room ${sticky.room.roomId} at position ${sticky.position} in ${sticky.tag}`);
this._cachedStickyRooms[sticky.tag].splice(sticky.position, 0, sticky.room);
}
// Finally, trigger an update
this.emit(LIST_UPDATED_EVENT);
}
/** /**
* Asks the Algorithm to regenerate all lists, using the tags given * Asks the Algorithm to regenerate all lists, using the tags given
* as reference for which lists to generate and which way to generate * as reference for which lists to generate and which way to generate
@ -174,7 +325,7 @@ export abstract class Algorithm extends EventEmitter {
*/ */
public getOrderedRooms(): ITagMap { public getOrderedRooms(): ITagMap {
if (!this.hasFilters) { if (!this.hasFilters) {
return this.cachedRooms; return this._cachedStickyRooms || this.cachedRooms;
} }
return this.filteredRooms; return this.filteredRooms;
} }

View file

@ -17,7 +17,7 @@ limitations under the License.
import { Algorithm } from "./Algorithm"; import { Algorithm } from "./Algorithm";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { DefaultTagID, RoomUpdateCause, TagID } from "../../models"; import { RoomUpdateCause, TagID } from "../../models";
import { ITagMap, SortAlgorithm } from "../models"; import { ITagMap, SortAlgorithm } from "../models";
import { sortRoomsWithAlgorithm } from "../tag-sorting"; import { sortRoomsWithAlgorithm } from "../tag-sorting";
import * as Unread from '../../../../Unread'; import * as Unread from '../../../../Unread';
@ -82,15 +82,14 @@ export class ImportanceAlgorithm extends Algorithm {
// HOW THIS WORKS // HOW THIS WORKS
// -------------- // --------------
// //
// This block of comments assumes you've read the README one level higher. // This block of comments assumes you've read the README two levels higher.
// You should do that if you haven't already. // You should do that if you haven't already.
// //
// Tags are fed into the algorithmic functions from the Algorithm superclass, // Tags are fed into the algorithmic functions from the Algorithm superclass,
// which cause subsequent updates to the room list itself. Categories within // which cause subsequent updates to the room list itself. Categories within
// those tags are tracked as index numbers within the array (zero = top), with // those tags are tracked as index numbers within the array (zero = top), with
// each sticky room being tracked separately. Internally, the category index // each sticky room being tracked separately. Internally, the category index
// can be found from `this.indices[tag][category]` and the sticky room information // can be found from `this.indices[tag][category]`.
// from `this.stickyRoom`.
// //
// The room list store is always provided with the `this.cachedRooms` results, which are // The room list store is always provided with the `this.cachedRooms` results, which are
// updated as needed and not recalculated often. For example, when a room needs to // updated as needed and not recalculated often. For example, when a room needs to
@ -102,17 +101,6 @@ export class ImportanceAlgorithm extends Algorithm {
[tag: TagID]: ICategoryIndex; [tag: TagID]: ICategoryIndex;
} = {}; } = {};
// TODO: Use this (see docs above)
private stickyRoom: {
roomId: string;
tag: TagID;
fromTop: number;
} = {
roomId: null,
tag: null,
fromTop: 0,
};
constructor() { constructor() {
super(); super();
console.log("Constructed an ImportanceAlgorithm"); console.log("Constructed an ImportanceAlgorithm");
@ -189,12 +177,25 @@ export class ImportanceAlgorithm extends Algorithm {
} }
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> { public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
if (cause === RoomUpdateCause.PossibleTagChange) {
// TODO: Be smarter and splice rather than regen the planet.
// TODO: No-op if no change.
await this.setKnownRooms(this.rooms);
return;
}
if (cause === RoomUpdateCause.NewRoom) { if (cause === RoomUpdateCause.NewRoom) {
// TODO: Be smarter and insert rather than regen the planet. // TODO: Be smarter and insert rather than regen the planet.
await this.setKnownRooms([room, ...this.rooms]); await this.setKnownRooms([room, ...this.rooms]);
return; return;
} }
if (cause === RoomUpdateCause.RoomRemoved) {
// TODO: Be smarter and splice rather than regen the planet.
await this.setKnownRooms(this.rooms.filter(r => r !== room));
return;
}
let tags = this.roomIdsToTags[room.roomId]; let tags = this.roomIdsToTags[room.roomId];
if (!tags) { if (!tags) {
console.warn(`No tags known for "${room.name}" (${room.roomId})`); console.warn(`No tags known for "${room.name}" (${room.roomId})`);
@ -251,6 +252,8 @@ export class ImportanceAlgorithm extends Algorithm {
taggedRooms.splice(startIdx, 0, ...sorted); taggedRooms.splice(startIdx, 0, ...sorted);
// Finally, flag that we've done something // Finally, flag that we've done something
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
changed = true; changed = true;
} }
return changed; return changed;

View file

@ -46,11 +46,17 @@ export class NaturalAlgorithm extends Algorithm {
console.warn(`No tags known for "${room.name}" (${room.roomId})`); console.warn(`No tags known for "${room.name}" (${room.roomId})`);
return false; return false;
} }
let changed = false;
for (const tag of tags) { for (const tag of tags) {
// TODO: Optimize this loop to avoid useless operations // TODO: Optimize this loop to avoid useless operations
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags // For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
this.cachedRooms[tag] = await sortRoomsWithAlgorithm(this.cachedRooms[tag], tag, this.sortAlgorithms[tag]); this.cachedRooms[tag] = await sortRoomsWithAlgorithm(this.cachedRooms[tag], tag, this.sortAlgorithms[tag]);
// Flag that we've done something
this.recalculateFilteredRoomsForTag(tag); // update filter to re-sort the list
this.recalculateStickyRoom(tag); // update sticky room to make sure it appears if needed
changed = true;
} }
return true; // assume we changed something return changed;
} }
} }

View file

@ -15,18 +15,18 @@ limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
import { Group } from "matrix-js-sdk/src/models/group"; import { Group } from "matrix-js-sdk/src/models/group";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import GroupStore from "../../GroupStore"; import GroupStore from "../../GroupStore";
import { arrayHasDiff } from "../../../utils/arrays"; import { arrayHasDiff } from "../../../utils/arrays";
import { IDisposable } from "../../../utils/IDisposable"; import { IDestroyable } from "../../../utils/IDestroyable";
/** /**
* A filter condition for the room list which reveals rooms which * A filter condition for the room list which reveals rooms which
* are a member of a given community. * are a member of a given community.
*/ */
export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDisposable { export class CommunityFilterCondition extends EventEmitter implements IFilterCondition, IDestroyable {
private roomIds: string[] = []; private roomIds: string[] = [];
constructor(private community: Group) { constructor(private community: Group) {
@ -37,6 +37,11 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
this.onStoreUpdate(); // trigger a false update to seed the store this.onStoreUpdate(); // trigger a false update to seed the store
} }
public get relativePriority(): FilterPriority {
// Lowest priority so we can coarsely find rooms.
return FilterPriority.Lowest;
}
public isVisible(room: Room): boolean { public isVisible(room: Room): boolean {
return this.roomIds.includes(room.roomId); return this.roomIds.includes(room.roomId);
} }
@ -52,7 +57,7 @@ export class CommunityFilterCondition extends EventEmitter implements IFilterCon
} }
}; };
public dispose(): void { public destroy(): void {
GroupStore.off("update", this.onStoreUpdate); GroupStore.off("update", this.onStoreUpdate);
} }
} }

View file

@ -19,6 +19,12 @@ import { EventEmitter } from "events";
export const FILTER_CHANGED = "filter_changed"; export const FILTER_CHANGED = "filter_changed";
export enum FilterPriority {
Lowest,
// in the middle would be Low, Normal, and High if we had a need
Highest,
}
/** /**
* A filter condition for the room list, determining if a room * A filter condition for the room list, determining if a room
* should be shown or not. * should be shown or not.
@ -32,6 +38,12 @@ export const FILTER_CHANGED = "filter_changed";
* as a change in the user's input), this emits FILTER_CHANGED. * as a change in the user's input), this emits FILTER_CHANGED.
*/ */
export interface IFilterCondition extends EventEmitter { export interface IFilterCondition extends EventEmitter {
/**
* The relative priority that this filter should be applied with.
* Lower priorities get applied first.
*/
relativePriority: FilterPriority;
/** /**
* Determines if a given room should be visible under this * Determines if a given room should be visible under this
* condition. * condition.

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { FILTER_CHANGED, IFilterCondition } from "./IFilterCondition"; import { FILTER_CHANGED, FilterPriority, IFilterCondition } from "./IFilterCondition";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
/** /**
@ -29,6 +29,11 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
super(); super();
} }
public get relativePriority(): FilterPriority {
// We want this one to be at the highest priority so it can search within other filters.
return FilterPriority.Highest;
}
public get search(): string { public get search(): string {
return this._search; return this._search;
} }

View file

@ -38,6 +38,8 @@ export type TagID = string | DefaultTagID;
export enum RoomUpdateCause { export enum RoomUpdateCause {
Timeline = "TIMELINE", Timeline = "TIMELINE",
RoomRead = "ROOM_READ", // TODO: Use this. PossibleTagChange = "POSSIBLE_TAG_CHANGE",
ReadReceipt = "READ_RECEIPT",
NewRoom = "NEW_ROOM", NewRoom = "NEW_ROOM",
RoomRemoved = "ROOM_REMOVED",
} }

View file

@ -62,7 +62,7 @@ function setCustomThemeVars(customTheme) {
} }
} }
function getCustomTheme(themeName) { export function getCustomTheme(themeName) {
// set css variables // set css variables
const customThemes = SettingsStore.getValue("custom_themes"); const customThemes = SettingsStore.getValue("custom_themes");
if (!customThemes) { if (!customThemes) {

View file

@ -66,5 +66,5 @@ export const showToast = (deviceId: string) => {
}; };
export const hideToast = (deviceId: string) => { export const hideToast = (deviceId: string) => {
ToastStore.sharedInstance().dismissToast(deviceId); ToastStore.sharedInstance().dismissToast(toastKey(deviceId));
}; };

View file

@ -1,6 +1,6 @@
/* /*
Copyright 2016 OpenMarket Ltd Copyright 2016 OpenMarket Ltd
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -21,8 +21,8 @@ import { _t } from '../languageHandler';
* formats numbers to fit into ~3 characters, suitable for badge counts * formats numbers to fit into ~3 characters, suitable for badge counts
* e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B * e.g: 999, 9.9K, 99K, 0.9M, 9.9M, 99M, 0.9B, 9.9B
*/ */
export function formatCount(count) { export function formatCount(count: number): string {
if (count < 1000) return count; if (count < 1000) return count.toString();
if (count < 10000) return (count / 1000).toFixed(1) + "K"; if (count < 10000) return (count / 1000).toFixed(1) + "K";
if (count < 100000) return (count / 1000).toFixed(0) + "K"; if (count < 100000) return (count / 1000).toFixed(0) + "K";
if (count < 10000000) return (count / 1000000).toFixed(1) + "M"; if (count < 10000000) return (count / 1000000).toFixed(1) + "M";
@ -34,7 +34,7 @@ export function formatCount(count) {
* Format a count showing the whole number but making it a bit more readable. * Format a count showing the whole number but making it a bit more readable.
* e.g: 1000 => 1,000 * e.g: 1000 => 1,000
*/ */
export function formatCountLong(count) { export function formatCountLong(count: number): string {
const formatter = new Intl.NumberFormat(); const formatter = new Intl.NumberFormat();
return formatter.format(count) return formatter.format(count)
} }
@ -43,7 +43,7 @@ export function formatCountLong(count) {
* format a size in bytes into a human readable form * format a size in bytes into a human readable form
* e.g: 1024 -> 1.00 KB * e.g: 1024 -> 1.00 KB
*/ */
export function formatBytes(bytes, decimals = 2) { export function formatBytes(bytes: number, decimals = 2): string {
if (bytes === 0) return '0 Bytes'; if (bytes === 0) return '0 Bytes';
const k = 1024; const k = 1024;
@ -62,7 +62,7 @@ export function formatBytes(bytes, decimals = 2) {
* *
* @return {string} * @return {string}
*/ */
export function formatCryptoKey(key) { export function formatCryptoKey(key: string): string {
return key.match(/.{1,4}/g).join(" "); return key.match(/.{1,4}/g).join(" ");
} }
/** /**
@ -72,7 +72,7 @@ export function formatCryptoKey(key) {
* *
* @return {number} * @return {number}
*/ */
export function hashCode(str) { export function hashCode(str: string): number {
let hash = 0; let hash = 0;
let i; let i;
let chr; let chr;
@ -87,7 +87,7 @@ export function hashCode(str) {
return Math.abs(hash); return Math.abs(hash);
} }
export function getUserNameColorClass(userId) { export function getUserNameColorClass(userId: string): string {
const colorNumber = (hashCode(userId) % 8) + 1; const colorNumber = (hashCode(userId) % 8) + 1;
return `mx_Username_color${colorNumber}`; return `mx_Username_color${colorNumber}`;
} }
@ -103,7 +103,7 @@ export function getUserNameColorClass(userId) {
* @returns {string} a string constructed by joining `items` with a comma * @returns {string} a string constructed by joining `items` with a comma
* between each item, but with the last item appended as " and [lastItem]". * between each item, but with the last item appended as " and [lastItem]".
*/ */
export function formatCommaSeparatedList(items, itemLimit) { export function formatCommaSeparatedList(items: string[], itemLimit?: number): string {
const remaining = itemLimit === undefined ? 0 : Math.max( const remaining = itemLimit === undefined ? 0 : Math.max(
items.length - itemLimit, 0, items.length - itemLimit, 0,
); );
@ -119,3 +119,14 @@ export function formatCommaSeparatedList(items, itemLimit) {
return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem }); return _t("%(items)s and %(lastItem)s", { items: items.join(', '), lastItem: lastItem });
} }
} }
/**
* Formats a number into a 'minimal' badge count (9, 98, 99+).
* @param count The number to convert
* @returns The badge count, stringified.
*/
export function formatMinimalBadgeCount(count: number): string {
// we specifically go from "98" to "99+"
if (count < 99) return count.toString();
return "99+";
}

View file

@ -14,6 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export interface IDisposable { export interface IDestroyable {
dispose(): void; destroy(): void;
} }

View file

@ -421,6 +421,7 @@ export default class WidgetUtils {
if (WidgetType.JITSI.matches(appType)) { if (WidgetType.JITSI.matches(appType)) {
capWhitelist.push(Capability.AlwaysOnScreen); capWhitelist.push(Capability.AlwaysOnScreen);
} }
capWhitelist.push(Capability.ReceiveTerminate);
return capWhitelist; return capWhitelist;
} }

View file

@ -45,3 +45,63 @@ export function arrayDiff<T>(a: T[], b: T[]): { added: T[], removed: T[] } {
removed: a.filter(i => !b.includes(i)), removed: a.filter(i => !b.includes(i)),
}; };
} }
/**
* Helper functions to perform LINQ-like queries on arrays.
*/
export class ArrayUtil<T> {
/**
* Create a new array helper.
* @param a The array to help. Can be modified in-place.
*/
constructor(private a: T[]) {
}
/**
* The value of this array, after all appropriate alterations.
*/
public get value(): T[] {
return this.a;
}
/**
* Groups an array by keys.
* @param fn The key-finding function.
* @returns This.
*/
public groupBy<K>(fn: (a: T) => K): GroupedArray<K, T> {
const obj = this.a.reduce((rv: Map<K, T[]>, val: T) => {
const k = fn(val);
if (!rv.has(k)) rv.set(k, []);
rv.get(k).push(val);
return rv;
}, new Map<K, T[]>());
return new GroupedArray(obj);
}
}
/**
* Helper functions to perform LINQ-like queries on groups (maps).
*/
export class GroupedArray<K, T> {
/**
* Creates a new group helper.
* @param val The group to help. Can be modified in-place.
*/
constructor(private val: Map<K, T[]>) {
}
/**
* Orders the grouping into an array using the provided key order.
* @param keyOrder The key order.
* @returns An array helper of the result.
*/
public orderBy(keyOrder: K[]): ArrayUtil<T> {
const a: T[] = [];
for (const k of keyOrder) {
if (!this.val.has(k)) continue;
a.push(...this.val.get(k));
}
return new ArrayUtil(a);
}
}

27
src/utils/enums.ts Normal file
View file

@ -0,0 +1,27 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* Get the values for an enum.
* @param e The enum.
* @returns The enum values.
*/
export function getEnumValues<T>(e: any): T[] {
const keys = Object.keys(e);
return keys
.filter(k => ['string', 'number'].includes(typeof(e[k])))
.map(k => e[k]);
}

View file

@ -18,11 +18,13 @@ limitations under the License.
// https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts // https://github.com/turt2live/matrix-dimension/blob/4f92d560266635e5a3c824606215b84e8c0b19f5/web/app/shared/services/scalar/scalar-widget.api.ts
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import { EventEmitter } from "events";
export enum Capability { export enum Capability {
Screenshot = "m.capability.screenshot", Screenshot = "m.capability.screenshot",
Sticker = "m.sticker", Sticker = "m.sticker",
AlwaysOnScreen = "m.always_on_screen", AlwaysOnScreen = "m.always_on_screen",
ReceiveTerminate = "im.vector.receive_terminate",
} }
export enum KnownWidgetActions { export enum KnownWidgetActions {
@ -34,6 +36,7 @@ export enum KnownWidgetActions {
ReceiveOpenIDCredentials = "openid_credentials", ReceiveOpenIDCredentials = "openid_credentials",
SetAlwaysOnScreen = "set_always_on_screen", SetAlwaysOnScreen = "set_always_on_screen",
ClientReady = "im.vector.ready", ClientReady = "im.vector.ready",
Terminate = "im.vector.terminate",
} }
export type WidgetAction = KnownWidgetActions | string; export type WidgetAction = KnownWidgetActions | string;
@ -62,8 +65,13 @@ export interface FromWidgetRequest extends WidgetRequest {
/** /**
* Handles Riot <--> Widget interactions for embedded/standalone widgets. * Handles Riot <--> Widget interactions for embedded/standalone widgets.
*
* Emitted events:
* - terminate(wait): client requested the widget to terminate.
* Call the argument 'wait(promise)' to postpone the finalization until
* the given promise resolves.
*/ */
export class WidgetApi { export class WidgetApi extends EventEmitter {
private origin: string; private origin: string;
private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {}; private inFlightRequests: { [requestId: string]: (reply: FromWidgetRequest) => void } = {};
private readyPromise: Promise<any>; private readyPromise: Promise<any>;
@ -75,6 +83,8 @@ export class WidgetApi {
public expectingExplicitReady = false; public expectingExplicitReady = false;
constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) { constructor(currentUrl: string, private widgetId: string, private requestedCapabilities: string[]) {
super();
this.origin = new URL(currentUrl).origin; this.origin = new URL(currentUrl).origin;
this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve); this.readyPromise = new Promise<any>(resolve => this.readyPromiseResolve = resolve);
@ -98,6 +108,17 @@ export class WidgetApi {
// Automatically acknowledge so we can move on // Automatically acknowledge so we can move on
this.replyToRequest(<ToWidgetRequest>payload, {}); this.replyToRequest(<ToWidgetRequest>payload, {});
} else if (payload.action === KnownWidgetActions.Terminate) {
// Finalization needs to be async, so postpone with a promise
let finalizePromise = Promise.resolve();
const wait = (promise) => {
finalizePromise = finalizePromise.then(value => promise);
};
this.emit('terminate', wait);
Promise.resolve(finalizePromise).then(() => {
// Acknowledge that we're shut down now
this.replyToRequest(<ToWidgetRequest>payload, {});
});
} else { } else {
console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`); console.warn(`[WidgetAPI] Got unexpected action: ${payload.action}`);
} }

1521
yarn.lock

File diff suppressed because it is too large Load diff