mirror of
https://github.com/element-hq/element-web
synced 2024-11-24 18:25:49 +03:00
Merge branch 'develop' of github.com:matrix-org/matrix-react-sdk into t3chguy/peeking-races
This commit is contained in:
commit
37bd59bf90
74 changed files with 3752 additions and 369 deletions
|
@ -151,6 +151,7 @@ General Style
|
|||
Don't set things to undefined. Reserve that value to mean "not yet set to anything."
|
||||
Boolean objects are verboten.
|
||||
- Use JSDoc
|
||||
- Use switch-case statements where there are 5 or more branches running against the same variable.
|
||||
|
||||
ECMAScript
|
||||
----------
|
||||
|
|
|
@ -55,6 +55,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.3",
|
||||
"await-lock": "^2.0.1",
|
||||
"blueimp-canvas-to-blob": "^3.5.0",
|
||||
"browser-encrypt-attachment": "^0.3.0",
|
||||
"browser-request": "^0.3.3",
|
||||
|
|
|
@ -115,6 +115,7 @@
|
|||
@import "./views/elements/_RichText.scss";
|
||||
@import "./views/elements/_RoleButton.scss";
|
||||
@import "./views/elements/_RoomAliasField.scss";
|
||||
@import "./views/elements/_Slider.scss";
|
||||
@import "./views/elements/_Spinner.scss";
|
||||
@import "./views/elements/_SyntaxHighlight.scss";
|
||||
@import "./views/elements/_TextWithTooltip.scss";
|
||||
|
@ -162,6 +163,8 @@
|
|||
@import "./views/rooms/_EditMessageComposer.scss";
|
||||
@import "./views/rooms/_EntityTile.scss";
|
||||
@import "./views/rooms/_EventTile.scss";
|
||||
@import "./views/rooms/_GroupLayout.scss";
|
||||
@import "./views/rooms/_IRCLayout.scss";
|
||||
@import "./views/rooms/_InviteOnlyIcon.scss";
|
||||
@import "./views/rooms/_JumpToBottomButton.scss";
|
||||
@import "./views/rooms/_LinkPreviewWidget.scss";
|
||||
|
@ -204,6 +207,7 @@
|
|||
@import "./views/settings/tabs/room/_GeneralRoomSettingsTab.scss";
|
||||
@import "./views/settings/tabs/room/_RolesRoomSettingsTab.scss";
|
||||
@import "./views/settings/tabs/room/_SecurityRoomSettingsTab.scss";
|
||||
@import "./views/settings/tabs/user/_AppearanceUserSettingsTab.scss";
|
||||
@import "./views/settings/tabs/user/_GeneralUserSettingsTab.scss";
|
||||
@import "./views/settings/tabs/user/_HelpUserSettingsTab.scss";
|
||||
@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.scss";
|
||||
|
|
|
@ -69,7 +69,7 @@ limitations under the License.
|
|||
height: 100%;
|
||||
}
|
||||
.mx_TagPanel .mx_TagPanel_tagTileContainer > div {
|
||||
height: $font-40px;
|
||||
height: 40px;
|
||||
padding: 10px 0 9px 0;
|
||||
}
|
||||
|
||||
|
@ -116,7 +116,7 @@ limitations under the License.
|
|||
position: absolute;
|
||||
left: -15px;
|
||||
border-radius: 0 3px 3px 0;
|
||||
top: -8px; // (16px / 2)
|
||||
top: -8px; // (16px from height / 2)
|
||||
}
|
||||
|
||||
.mx_TagPanel .mx_TagTile.mx_AccessibleButton:focus {
|
||||
|
|
|
@ -43,7 +43,7 @@ limitations under the License.
|
|||
margin: 0 7px;
|
||||
mask: url('$(res)/img/feather-customised/dropdown-arrow.svg');
|
||||
mask-repeat: no-repeat;
|
||||
width: 10px;
|
||||
width: $font-22px;
|
||||
height: 6px;
|
||||
background-color: $roomsublist-label-fg-color;
|
||||
}
|
||||
|
|
|
@ -21,6 +21,10 @@ limitations under the License.
|
|||
mask-image: url('$(res)/img/feather-customised/settings.svg');
|
||||
}
|
||||
|
||||
.mx_UserSettingsDialog_appearanceIcon::before {
|
||||
mask-image: url('$(res)/img/feather-customised/brush.svg');
|
||||
}
|
||||
|
||||
.mx_UserSettingsDialog_voiceIcon::before {
|
||||
mask-image: url('$(res)/img/feather-customised/phone.svg');
|
||||
}
|
||||
|
|
99
res/css/views/elements/_Slider.scss
Normal file
99
res/css/views/elements/_Slider.scss
Normal file
|
@ -0,0 +1,99 @@
|
|||
/*
|
||||
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_Slider {
|
||||
position: relative;
|
||||
margin: 0px;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.mx_Slider_dotContainer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.mx_Slider_bar {
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
position: absolute;
|
||||
height: 1em;
|
||||
width: 100%;
|
||||
padding: 0 0.5em; // half the width of a dot.
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mx_Slider_bar > hr {
|
||||
width: 100%;
|
||||
height: 0.4em;
|
||||
background-color: $slider-background-color;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mx_Slider_selection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% - 1em); // 2 * half the width of a dot
|
||||
height: 1em;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mx_Slider_selectionDot {
|
||||
position: absolute;
|
||||
width: 1.1em;
|
||||
height: 1.1em;
|
||||
background-color: $slider-selection-color;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px lightgrey;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mx_Slider_selection > hr {
|
||||
margin: 0;
|
||||
border: 0.2em solid $slider-selection-color;
|
||||
}
|
||||
|
||||
.mx_Slider_dot {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
border-radius: 50%;
|
||||
background-color: $slider-background-color;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.mx_Slider_dotActive {
|
||||
background-color: $slider-selection-color;
|
||||
}
|
||||
|
||||
.mx_Slider_dotValue {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
color: $slider-background-color;
|
||||
}
|
||||
|
||||
// The following is a hack to center the labels without adding
|
||||
// any width to the slider's dots.
|
||||
.mx_Slider_labelContainer {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.mx_Slider_label {
|
||||
position: relative;
|
||||
width: fit-content;
|
||||
left: -50%;
|
||||
}
|
|
@ -96,6 +96,10 @@ $AppsDrawerBodyHeight: 273px;
|
|||
height: $AppsDrawerBodyHeight;
|
||||
}
|
||||
|
||||
.mx_AppTile_persistedWrapper > div {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.mx_AppTile_mini .mx_AppTile_persistedWrapper {
|
||||
height: 114px;
|
||||
}
|
||||
|
|
|
@ -37,7 +37,6 @@ limitations under the License.
|
|||
}
|
||||
|
||||
.mx_EventTile_avatar {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 8px;
|
||||
cursor: pointer;
|
||||
|
@ -68,11 +67,9 @@ limitations under the License.
|
|||
display: inline-block; /* anti-zalgo, with overflow hidden */
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
padding-left: 65px; /* left gutter */
|
||||
padding-bottom: 0px;
|
||||
padding-top: 0px;
|
||||
margin: 0px;
|
||||
line-height: $font-17px;
|
||||
/* the next three lines, along with overflow hidden, truncate long display names */
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
@ -104,9 +101,7 @@ limitations under the License.
|
|||
visibility: hidden;
|
||||
white-space: nowrap;
|
||||
left: 0px;
|
||||
width: 46px; /* 8 + 30 (avatar) + 8 */
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
|
@ -117,10 +112,7 @@ limitations under the License.
|
|||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
position: relative;
|
||||
padding-left: 65px; /* left gutter */
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
border-radius: 4px;
|
||||
line-height: $font-22px;
|
||||
}
|
||||
|
||||
.mx_RoomView_timeline_rr_enabled,
|
||||
|
@ -151,10 +143,6 @@ limitations under the License.
|
|||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.mx_EventTile_info .mx_EventTile_line {
|
||||
padding-left: 83px;
|
||||
}
|
||||
|
||||
/* HACK to override line-height which is already marked important elsewhere */
|
||||
.mx_EventTile_bigEmoji.mx_EventTile_bigEmoji {
|
||||
font-size: 48px !important;
|
||||
|
@ -171,10 +159,15 @@ limitations under the License.
|
|||
}
|
||||
|
||||
// Explicit relationships so that it doesn't apply to nested EventTile components (e.g in Replies)
|
||||
// The first set is to handle the 'group layout' (default) and the second for the IRC layout
|
||||
.mx_EventTile_last > div > a > .mx_MessageTimestamp,
|
||||
.mx_EventTile:hover > div > a > .mx_MessageTimestamp,
|
||||
.mx_EventTile.mx_EventTile_actionBarFocused > div > a > .mx_MessageTimestamp,
|
||||
.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp {
|
||||
.mx_EventTile.focus-visible:focus-within > div > a > .mx_MessageTimestamp,
|
||||
.mx_IRCLayout .mx_EventTile_last > a > .mx_MessageTimestamp,
|
||||
.mx_IRCLayout .mx_EventTile:hover > a > .mx_MessageTimestamp,
|
||||
.mx_IRCLayout .mx_EventTile.mx_EventTile_actionBarFocused > a > .mx_MessageTimestamp,
|
||||
.mx_IRCLayout .mx_EventTile.focus-visible:focus-within > a > .mx_MessageTimestamp {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
|
@ -560,84 +553,6 @@ limitations under the License.
|
|||
|
||||
/* end of overrides */
|
||||
|
||||
.mx_MatrixChat_useCompactLayout {
|
||||
.mx_EventTile {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.mx_EventTile.mx_EventTile_info {
|
||||
// same as the padding for non-compact .mx_EventTile.mx_EventTile_info
|
||||
padding-top: 0px;
|
||||
font-size: $font-13px;
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
line-height: $font-20px;
|
||||
}
|
||||
.mx_EventTile_avatar {
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile .mx_SenderProfile {
|
||||
font-size: $font-13px;
|
||||
}
|
||||
|
||||
.mx_EventTile.mx_EventTile_emote {
|
||||
// add a bit more space for emotes so that avatars don't collide
|
||||
padding-top: 8px;
|
||||
.mx_EventTile_avatar {
|
||||
top: 2px;
|
||||
}
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile.mx_EventTile_emote.mx_EventTile_continuation {
|
||||
padding-top: 0;
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.mx_EventTile_avatar {
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.mx_EventTile_e2eIcon {
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.mx_EventTile_readAvatars {
|
||||
top: 27px;
|
||||
}
|
||||
|
||||
.mx_EventTile_continuation .mx_EventTile_readAvatars,
|
||||
.mx_EventTile_emote .mx_EventTile_readAvatars {
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.mx_EventTile_info .mx_EventTile_readAvatars {
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.mx_RoomView_MessageList h2 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.mx_EventTile_content .markdown-body {
|
||||
p, ul, ol, dl, blockquote, pre, table {
|
||||
margin-bottom: 4px; // 1/4 of the non-compact margin-bottom
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile_tileError {
|
||||
color: red;
|
||||
text-align: center;
|
||||
|
|
131
res/css/views/rooms/_GroupLayout.scss
Normal file
131
res/css/views/rooms/_GroupLayout.scss
Normal file
|
@ -0,0 +1,131 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
$left-gutter: 65px;
|
||||
|
||||
.mx_GroupLayout {
|
||||
|
||||
.mx_EventTile {
|
||||
> .mx_SenderProfile {
|
||||
line-height: $font-17px;
|
||||
padding-left: $left-gutter;
|
||||
}
|
||||
|
||||
> .mx_EventTile_line {
|
||||
padding-left: $left-gutter;
|
||||
}
|
||||
|
||||
> .mx_EventTile_avatar {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.mx_MessageTimestamp {
|
||||
position: absolute;
|
||||
width: 46px; /* 8 + 30 (avatar) + 8 */
|
||||
}
|
||||
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 3px;
|
||||
padding-bottom: 3px;
|
||||
line-height: $font-22px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile_info .mx_EventTile_line {
|
||||
padding-left: calc($left-gutter + 18px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact layout overrides */
|
||||
|
||||
.mx_MatrixChat_useCompactLayout {
|
||||
.mx_EventTile {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.mx_EventTile.mx_EventTile_info {
|
||||
// same as the padding for non-compact .mx_EventTile.mx_EventTile_info
|
||||
padding-top: 0px;
|
||||
font-size: $font-13px;
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
line-height: $font-20px;
|
||||
}
|
||||
.mx_EventTile_avatar {
|
||||
top: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile .mx_SenderProfile {
|
||||
font-size: $font-13px;
|
||||
}
|
||||
|
||||
.mx_EventTile.mx_EventTile_emote {
|
||||
// add a bit more space for emotes so that avatars don't collide
|
||||
padding-top: 8px;
|
||||
.mx_EventTile_avatar {
|
||||
top: 2px;
|
||||
}
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile.mx_EventTile_emote.mx_EventTile_continuation {
|
||||
padding-top: 0;
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding-top: 0px;
|
||||
padding-bottom: 0px;
|
||||
}
|
||||
|
||||
.mx_EventTile_avatar {
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
.mx_EventTile_e2eIcon {
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.mx_EventTile_readAvatars {
|
||||
top: 27px;
|
||||
}
|
||||
|
||||
.mx_EventTile_continuation .mx_EventTile_readAvatars,
|
||||
.mx_EventTile_emote .mx_EventTile_readAvatars {
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.mx_EventTile_info .mx_EventTile_readAvatars {
|
||||
top: 4px;
|
||||
}
|
||||
|
||||
.mx_RoomView_MessageList h2 {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.mx_EventTile_content .markdown-body {
|
||||
p, ul, ol, dl, blockquote, pre, table {
|
||||
margin-bottom: 4px; // 1/4 of the non-compact margin-bottom
|
||||
}
|
||||
}
|
||||
}
|
214
res/css/views/rooms/_IRCLayout.scss
Normal file
214
res/css/views/rooms/_IRCLayout.scss
Normal file
|
@ -0,0 +1,214 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
$icon-width: 14px;
|
||||
$timestamp-width: 45px;
|
||||
$right-padding: 5px;
|
||||
$irc-line-height: $font-18px;
|
||||
|
||||
.mx_IRCLayout {
|
||||
--name-width: 70px;
|
||||
|
||||
line-height: $irc-line-height !important;
|
||||
|
||||
.mx_EventTile {
|
||||
|
||||
// timestamps are links which shouldn't be underlined
|
||||
> a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
padding-top: 0;
|
||||
|
||||
> * {
|
||||
margin-right: $right-padding;
|
||||
}
|
||||
|
||||
> .mx_EventTile_msgOption {
|
||||
order: 4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
> .mx_SenderProfile {
|
||||
order: 2;
|
||||
flex-shrink: 0;
|
||||
width: var(--name-width);
|
||||
text-overflow: ellipsis;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mx_EventTile_line, .mx_EventTile_reply {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
order: 3;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
> .mx_EventTile_avatar {
|
||||
order: 1;
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
flex-shrink: 0;
|
||||
height: $irc-line-height;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
// Need to use important to override the js provided height and width values.
|
||||
> .mx_BaseAvatar, .mx_BaseAvatar > * {
|
||||
height: $font-14px !important;
|
||||
width: $font-14px !important;
|
||||
font-size: $font-10px !important;
|
||||
line-height: $font-15px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MessageTimestamp {
|
||||
font-size: $font-10px;
|
||||
width: $timestamp-width;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mx_EventTile_e2eIcon {
|
||||
position: relative;
|
||||
right: unset;
|
||||
left: unset;
|
||||
top: -2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.mx_EventTile_line {
|
||||
.mx_EventTile_e2eIcon,
|
||||
.mx_TextualEvent,
|
||||
.mx_MTextBody,
|
||||
.mx_ReplyThread_wrapper_empty {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EvenTile_line .mx_MessageActionBar,
|
||||
.mx_EvenTile_line .mx_ReplyThread_wrapper {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mx_EventTile_reply {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.mx_EditMessageComposer_buttons {
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile_emote {
|
||||
> .mx_EventTile_avatar {
|
||||
margin-left: calc(var(--name-width) + $icon-width + $right-padding);
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.mx_EventListSummary {
|
||||
> .mx_EventTile_line {
|
||||
padding-left: calc(var(--name-width) + $icon-width + $timestamp-width + 3 * $right-padding); // 15 px of padding
|
||||
}
|
||||
|
||||
.mx_EventListSummary_avatars {
|
||||
padding: 0;
|
||||
margin: 0 9px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_EventTile.mx_EventTile_info {
|
||||
.mx_EventTile_avatar {
|
||||
left: calc(var(--name-width) + 10px + $icon-width);
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.mx_EventTile_line {
|
||||
left: calc(var(--name-width) + 10px + $icon-width);
|
||||
}
|
||||
|
||||
.mx_TextualEvent {
|
||||
line-height: $irc-line-height;
|
||||
}
|
||||
}
|
||||
|
||||
// Suppress highlight thing from the normal Layout.
|
||||
.mx_EventTile:hover.mx_EventTile_verified .mx_EventTile_line,
|
||||
.mx_EventTile:hover.mx_EventTile_unverified .mx_EventTile_line,
|
||||
.mx_EventTile:hover.mx_EventTile_unknown .mx_EventTile_line {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.mx_SenderProfile_hover {
|
||||
background-color: $primary-bg-color;
|
||||
overflow: hidden;
|
||||
|
||||
> span {
|
||||
display: flex;
|
||||
|
||||
> .mx_SenderProfile_name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mx_SenderProfile:hover {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.mx_SenderProfile_hover:hover {
|
||||
overflow: visible;
|
||||
width: max(auto, 100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mx_ReplyThread {
|
||||
margin: 0;
|
||||
.mx_SenderProfile {
|
||||
width: unset;
|
||||
max-width: var(--name-width);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_ProfileResizer {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
width: 15px;
|
||||
left: calc(80px + var(--name-width));
|
||||
cursor: col-resize;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
// Need to use important to override the js provided height and width values.
|
||||
.mx_Flair > img {
|
||||
height: $font-14px !important;
|
||||
width: $font-14px !important;
|
||||
}
|
||||
}
|
|
@ -20,7 +20,7 @@ limitations under the License.
|
|||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
height: $font-34px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0 8px 0 10px;
|
||||
position: relative;
|
||||
|
@ -81,6 +81,7 @@ limitations under the License.
|
|||
|
||||
.mx_RoomTile_avatar_container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mx_RoomTile_avatar {
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
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_AppearanceUserSettingsTab_fontSlider,
|
||||
.mx_AppearanceUserSettingsTab_themeSection .mx_Field,
|
||||
.mx_AppearanceUserSettingsTab_fontScaling .mx_Field {
|
||||
@mixin mx_Settings_fullWidthField;
|
||||
}
|
||||
|
||||
.mx_AppearanceUserSettingsTab_fontSlider {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 15px;
|
||||
background: $font-slider-bg-color;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mx_AppearanceUserSettingsTab_fontSlider_smallText {
|
||||
font-size: 15px;
|
||||
padding-right: 20px;
|
||||
padding-left: 5px;
|
||||
}
|
||||
|
||||
.mx_AppearanceUserSettingsTab_fontSlider_largeText {
|
||||
font-size: 18px;
|
||||
padding-left: 20px;
|
||||
padding-right: 5px;
|
||||
}
|
|
@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_GeneralUserSettingsTab_changePassword .mx_Field,
|
||||
.mx_GeneralUserSettingsTab_themeSection .mx_Field {
|
||||
.mx_GeneralUserSettingsTab_changePassword .mx_Field {
|
||||
@mixin mx_Settings_fullWidthField;
|
||||
}
|
||||
|
||||
|
|
5
res/img/feather-customised/brush.svg
Normal file
5
res/img/feather-customised/brush.svg
Normal file
|
@ -0,0 +1,5 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 16.5C12 18.9853 9.98528 21 7.5 21C6.21514 21 3 21 3 21C3 21 3 17.7004 3 16.5C3 14.0147 5.01472 12 7.5 12C9.98528 12 12 14.0147 12 16.5Z" stroke="#2E2F32" stroke-linejoin="round"/>
|
||||
<path d="M8.25 12L17.1955 3.69345C18.0632 2.88776 19.4127 2.91274 20.25 3.75V3.75C21.0873 4.58726 21.1122 5.93682 20.3065 6.80449L12 15.75" stroke="#2E2F32"/>
|
||||
<path d="M11.25 9C11.25 9 12.3929 9.45 13.5 10.5C14.6071 11.55 15 12.75 15 12.75" stroke="#2E2F32"/>
|
||||
</svg>
|
After Width: | Height: | Size: 556 B |
|
@ -180,6 +180,9 @@ $breadcrumb-placeholder-bg-color: #272c35;
|
|||
|
||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||
|
||||
// FontSlider colors
|
||||
$font-slider-bg-color: $room-highlight-color;
|
||||
|
||||
// ***** Mixins! *****
|
||||
|
||||
@define-mixin mx_DialogButton {
|
||||
|
|
|
@ -262,6 +262,10 @@ $togglesw-off-color: #c1c9d6;
|
|||
$togglesw-on-color: $accent-color;
|
||||
$togglesw-ball-color: #fff;
|
||||
|
||||
// Slider
|
||||
$slider-selection-color: $accent-color;
|
||||
$slider-background-color: #c1c9d6;
|
||||
|
||||
$progressbar-color: #000;
|
||||
|
||||
$room-warning-bg-color: $yellow-background;
|
||||
|
@ -302,6 +306,9 @@ $breadcrumb-placeholder-bg-color: #e8eef5;
|
|||
|
||||
$user-tile-hover-bg-color: $header-panel-bg-color;
|
||||
|
||||
// FontSlider colors
|
||||
$font-slider-bg-color: rgba($input-darker-bg-color, 0.2);
|
||||
|
||||
// ***** Mixins! *****
|
||||
|
||||
@define-mixin mx_DialogButton {
|
||||
|
|
51
src/FontWatcher.js
Normal file
51
src/FontWatcher.js
Normal file
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
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 dis from './dispatcher/dispatcher';
|
||||
import SettingsStore, {SettingLevel} from './settings/SettingsStore';
|
||||
|
||||
export class FontWatcher {
|
||||
static MIN_SIZE = 13;
|
||||
static MAX_SIZE = 20;
|
||||
|
||||
constructor() {
|
||||
this._dispatcherRef = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this._setRootFontSize(SettingsStore.getValue("fontSize"));
|
||||
this._dispatcherRef = dis.register(this._onAction);
|
||||
}
|
||||
|
||||
stop() {
|
||||
dis.unregister(this._dispatcherRef);
|
||||
}
|
||||
|
||||
_onAction = (payload) => {
|
||||
if (payload.action === 'update-font-size') {
|
||||
this._setRootFontSize(payload.size);
|
||||
}
|
||||
};
|
||||
|
||||
_setRootFontSize = (size) => {
|
||||
const fontSize = Math.max(Math.min(FontWatcher.MAX_SIZE, size), FontWatcher.MIN_SIZE);
|
||||
|
||||
if (fontSize != size) {
|
||||
SettingsStore.setValue("fontSize", null, SettingLevel.Device, fontSize);
|
||||
}
|
||||
document.querySelector(":root").style.fontSize = fontSize + "px";
|
||||
};
|
||||
}
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
*/
|
||||
|
||||
import { asyncAction } from './actionCreators';
|
||||
import RoomListStore, { TAG_DM } from '../stores/RoomListStore';
|
||||
import { TAG_DM } from '../stores/RoomListStore';
|
||||
import Modal from '../Modal';
|
||||
import * as Rooms from '../Rooms';
|
||||
import { _t } from '../languageHandler';
|
||||
|
@ -24,6 +24,7 @@ import * as sdk from '../index';
|
|||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { AsyncActionPayload } from "../dispatcher/payloads";
|
||||
import { RoomListStoreTempProxy } from "../stores/room-list/RoomListStoreTempProxy";
|
||||
|
||||
export default class RoomListActions {
|
||||
/**
|
||||
|
@ -51,7 +52,7 @@ export default class RoomListActions {
|
|||
|
||||
// Is the tag ordered manually?
|
||||
if (newTag && !newTag.match(/^(m\.lowpriority|im\.vector\.fake\.(invite|recent|direct|archived))$/)) {
|
||||
const lists = RoomListStore.getRoomLists();
|
||||
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||
const newList = [...lists[newTag]];
|
||||
|
||||
newList.sort((a, b) => a.tags[newTag].order - b.tags[newTag].order);
|
||||
|
|
|
@ -26,6 +26,7 @@ import * as VectorConferenceHandler from '../../VectorConferenceHandler';
|
|||
import SettingsStore from '../../settings/SettingsStore';
|
||||
import {_t} from "../../languageHandler";
|
||||
import Analytics from "../../Analytics";
|
||||
import RoomList2 from "../views/rooms/RoomList2";
|
||||
|
||||
|
||||
const LeftPanel = createReactClass({
|
||||
|
@ -273,6 +274,29 @@ const LeftPanel = createReactClass({
|
|||
breadcrumbs = (<RoomBreadcrumbs collapsed={this.props.collapsed} />);
|
||||
}
|
||||
|
||||
let roomList = null;
|
||||
if (SettingsStore.isFeatureEnabled("feature_new_room_list")) {
|
||||
roomList = <RoomList2
|
||||
onKeyDown={this._onKeyDown}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ref={this.collectRoomList}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
/>;
|
||||
} else {
|
||||
roomList = <RoomList
|
||||
onKeyDown={this._onKeyDown}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
ref={this.collectRoomList}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ConferenceHandler={VectorConferenceHandler} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{ tagPanelContainer }
|
||||
|
@ -284,15 +308,7 @@ const LeftPanel = createReactClass({
|
|||
{ exploreButton }
|
||||
{ searchBox }
|
||||
</div>
|
||||
<RoomList
|
||||
onKeyDown={this._onKeyDown}
|
||||
onFocus={this._onFocus}
|
||||
onBlur={this._onBlur}
|
||||
ref={this.collectRoomList}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
collapsed={this.props.collapsed}
|
||||
searchFilter={this.state.searchFilter}
|
||||
ConferenceHandler={VectorConferenceHandler} />
|
||||
{roomList}
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -31,7 +31,6 @@ import dis from '../../dispatcher/dispatcher';
|
|||
import sessionStore from '../../stores/SessionStore';
|
||||
import {MatrixClientPeg, MatrixClientCreds} from '../../MatrixClientPeg';
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore from "../../stores/RoomListStore";
|
||||
|
||||
import TagOrderActions from '../../actions/TagOrderActions';
|
||||
import RoomListActions from '../../actions/RoomListActions';
|
||||
|
@ -42,6 +41,8 @@ import * as KeyboardShortcuts from "../../accessibility/KeyboardShortcuts";
|
|||
import HomePage from "./HomePage";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PlatformPeg from "../../PlatformPeg";
|
||||
import { RoomListStoreTempProxy } from "../../stores/room-list/RoomListStoreTempProxy";
|
||||
import { DefaultTagID } from "../../stores/room-list/models";
|
||||
// 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.
|
||||
// NB. this is just for server notices rather than pinned messages in general.
|
||||
|
@ -297,18 +298,18 @@ class LoggedInView extends React.PureComponent<IProps, IState> {
|
|||
};
|
||||
|
||||
onRoomStateEvents = (ev, state) => {
|
||||
const roomLists = RoomListStore.getRoomLists();
|
||||
if (roomLists['m.server_notice'] && roomLists['m.server_notice'].some(r => r.roomId === ev.getRoomId())) {
|
||||
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
if (roomLists[DefaultTagID.ServerNotice] && roomLists[DefaultTagID.ServerNotice].some(r => r.roomId === ev.getRoomId())) {
|
||||
this._updateServerNoticeEvents();
|
||||
}
|
||||
};
|
||||
|
||||
_updateServerNoticeEvents = async () => {
|
||||
const roomLists = RoomListStore.getRoomLists();
|
||||
if (!roomLists['m.server_notice']) return [];
|
||||
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
if (!roomLists[DefaultTagID.ServerNotice]) return [];
|
||||
|
||||
const pinnedEvents = [];
|
||||
for (const room of roomLists['m.server_notice']) {
|
||||
for (const room of roomLists[DefaultTagID.ServerNotice]) {
|
||||
const pinStateEvent = room.currentState.getStateEvents("m.room.pinned_events", "");
|
||||
|
||||
if (!pinStateEvent || !pinStateEvent.getContent().pinned) continue;
|
||||
|
|
|
@ -60,6 +60,7 @@ import AutoDiscoveryUtils, { ValidatedServerConfig } from "../../utils/AutoDisco
|
|||
import DMRoomMap from '../../utils/DMRoomMap';
|
||||
import { countRoomsWithNotif } from '../../RoomNotifs';
|
||||
import { ThemeWatcher } from "../../theme";
|
||||
import { FontWatcher } from '../../FontWatcher';
|
||||
import { storeRoomAliasInCache } from '../../RoomAliasCache';
|
||||
import { defer, IDeferred } from "../../utils/promise";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
|
@ -216,6 +217,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
private readonly loggedInView: React.RefObject<LoggedInViewType>;
|
||||
private readonly dispatcherRef: any;
|
||||
private readonly themeWatcher: ThemeWatcher;
|
||||
private readonly fontWatcher: FontWatcher;
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
@ -283,8 +285,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
this.accountPasswordTimer = null;
|
||||
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
|
||||
this.themeWatcher = new ThemeWatcher();
|
||||
this.fontWatcher = new FontWatcher();
|
||||
this.themeWatcher.start();
|
||||
this.fontWatcher.start();
|
||||
|
||||
this.focusComposer = false;
|
||||
|
||||
|
@ -367,6 +372,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
|||
Lifecycle.stopMatrixClient();
|
||||
dis.unregister(this.dispatcherRef);
|
||||
this.themeWatcher.stop();
|
||||
this.fontWatcher.stop();
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
this.state.resizeNotifier.removeListener("middlePanelResized", this.dispatchTimelineResize);
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ import SettingsStore from '../../settings/SettingsStore';
|
|||
import {_t} from "../../languageHandler";
|
||||
import {haveTileForEvent} from "../views/rooms/EventTile";
|
||||
import {textForEvent} from "../../TextForEvent";
|
||||
import IRCTimelineProfileResizer from "../views/elements/IRCTimelineProfileResizer";
|
||||
|
||||
const CONTINUATION_MAX_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
||||
const continuedTypes = ['m.sticker', 'm.room.message'];
|
||||
|
@ -109,14 +110,16 @@ export default class MessagePanel extends React.Component {
|
|||
showReactions: PropTypes.bool,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// Force props to be loaded for useIRCLayout
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
// previous positions the read marker has been in, so we can
|
||||
// display 'ghost' read markers that are animating away
|
||||
ghostReadMarkers: [],
|
||||
showTypingNotifications: SettingsStore.getValue("showTypingNotifications"),
|
||||
useIRCLayout: this.useIRCLayout(SettingsStore.getValue("feature_irc_ui")),
|
||||
};
|
||||
|
||||
// opaque readreceipt info for each userId; used by ReadReceiptMarker
|
||||
|
@ -169,6 +172,8 @@ export default class MessagePanel extends React.Component {
|
|||
|
||||
this._showTypingNotificationsWatcherRef =
|
||||
SettingsStore.watchSetting("showTypingNotifications", null, this.onShowTypingNotificationsChange);
|
||||
|
||||
this._layoutWatcherRef = SettingsStore.watchSetting("feature_irc_ui", null, this.onLayoutChange);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -178,6 +183,7 @@ export default class MessagePanel extends React.Component {
|
|||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
SettingsStore.unwatchSetting(this._showTypingNotificationsWatcherRef);
|
||||
SettingsStore.unwatchSetting(this._layoutWatcherRef);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
|
@ -196,6 +202,17 @@ export default class MessagePanel extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
onLayoutChange = () => {
|
||||
this.setState({
|
||||
useIRCLayout: this.useIRCLayout(SettingsStore.getValue("feature_irc_ui")),
|
||||
});
|
||||
}
|
||||
|
||||
useIRCLayout(ircLayoutSelected) {
|
||||
// if room is null we are not in a normal room list
|
||||
return ircLayoutSelected && this.props.room;
|
||||
}
|
||||
|
||||
/* get the DOM node representing the given event */
|
||||
getNodeForEventId(eventId) {
|
||||
if (!this.eventNodes) {
|
||||
|
@ -597,6 +614,7 @@ export default class MessagePanel extends React.Component {
|
|||
isSelectedEvent={highlight}
|
||||
getRelationsForEvent={this.props.getRelationsForEvent}
|
||||
showReactions={this.props.showReactions}
|
||||
useIRCLayout={this.state.useIRCLayout}
|
||||
/>
|
||||
</TileErrorBoundary>
|
||||
</li>,
|
||||
|
@ -779,6 +797,8 @@ export default class MessagePanel extends React.Component {
|
|||
this.props.className,
|
||||
{
|
||||
"mx_MessagePanel_alwaysShowTimestamps": this.props.alwaysShowTimestamps,
|
||||
"mx_IRCLayout": this.state.useIRCLayout,
|
||||
"mx_GroupLayout": !this.state.useIRCLayout,
|
||||
},
|
||||
);
|
||||
|
||||
|
@ -792,6 +812,15 @@ export default class MessagePanel extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
let ircResizer = null;
|
||||
if (this.state.useIRCLayout) {
|
||||
ircResizer = <IRCTimelineProfileResizer
|
||||
minWidth={20}
|
||||
maxWidth={600}
|
||||
roomId={this.props.room ? this.props.roomroomId : null}
|
||||
/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<ScrollPanel
|
||||
|
@ -804,6 +833,7 @@ export default class MessagePanel extends React.Component {
|
|||
style={style}
|
||||
stickyBottom={this.props.stickyBottom}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
fixedChildren={ircResizer}
|
||||
>
|
||||
{ topSpinner }
|
||||
{ this._getEventTiles() }
|
||||
|
|
|
@ -32,7 +32,7 @@ import RoomTile from "../views/rooms/RoomTile";
|
|||
import LazyRenderList from "../views/elements/LazyRenderList";
|
||||
import {_t} from "../../languageHandler";
|
||||
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
|
||||
import toRem from "../../utils/rem";
|
||||
import {toPx} from "../../utils/units";
|
||||
|
||||
// turn this on for drop & drag console debugging galore
|
||||
const debug = false;
|
||||
|
@ -420,7 +420,7 @@ export default class RoomSubList extends React.PureComponent {
|
|||
|
||||
setHeight = (height) => {
|
||||
if (this._subList.current) {
|
||||
this._subList.current.style.height = toRem(height);
|
||||
this._subList.current.style.height = toPx(height);
|
||||
}
|
||||
this._updateLazyRenderHeight(height);
|
||||
};
|
||||
|
|
|
@ -144,6 +144,11 @@ export default createReactClass({
|
|||
/* resizeNotifier: ResizeNotifier to know when middle column has changed size
|
||||
*/
|
||||
resizeNotifier: PropTypes.object,
|
||||
|
||||
/* fixedChildren: allows for children to be passed which are rendered outside
|
||||
* of the wrapper
|
||||
*/
|
||||
fixedChildren: PropTypes.node,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
@ -881,6 +886,7 @@ export default createReactClass({
|
|||
return (<AutoHideScrollbar wrappedRef={this._collectScroll}
|
||||
onScroll={this.onScroll}
|
||||
className={`mx_ScrollPanel ${this.props.className}`} style={this.props.style}>
|
||||
{ this.props.fixedChildren }
|
||||
<div className="mx_RoomView_messageListWrapper">
|
||||
<ol ref={this._itemlist} className="mx_RoomView_MessageList" aria-live="polite" role="list">
|
||||
{ this.props.children }
|
||||
|
|
|
@ -24,7 +24,7 @@ import * as AvatarLogic from '../../../Avatar';
|
|||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import AccessibleButton from '../elements/AccessibleButton';
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import toRem from "../../../utils/rem";
|
||||
import {toPx} from "../../../utils/units";
|
||||
|
||||
export default createReactClass({
|
||||
displayName: 'BaseAvatar',
|
||||
|
@ -166,9 +166,9 @@ export default createReactClass({
|
|||
const textNode = (
|
||||
<span className="mx_BaseAvatar_initial" aria-hidden="true"
|
||||
style={{
|
||||
fontSize: toRem(width * 0.65),
|
||||
width: toRem(width),
|
||||
lineHeight: toRem(height),
|
||||
fontSize: toPx(width * 0.65),
|
||||
width: toPx(width),
|
||||
lineHeight: toPx(height),
|
||||
}}
|
||||
>
|
||||
{ initialLetter }
|
||||
|
@ -179,8 +179,8 @@ export default createReactClass({
|
|||
alt="" title={title} onError={this.onError}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height)
|
||||
width: toPx(width),
|
||||
height: toPx(height)
|
||||
}} />
|
||||
);
|
||||
if (onClick != null) {
|
||||
|
@ -210,8 +210,8 @@ export default createReactClass({
|
|||
onClick={onClick}
|
||||
onError={this.onError}
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt=""
|
||||
inputRef={inputRef}
|
||||
|
@ -224,8 +224,8 @@ export default createReactClass({
|
|||
src={imageUrl}
|
||||
onError={this.onError}
|
||||
style={{
|
||||
width: toRem(width),
|
||||
height: toRem(height),
|
||||
width: toPx(width),
|
||||
height: toPx(height),
|
||||
}}
|
||||
title={title} alt=""
|
||||
ref={inputRef}
|
||||
|
|
|
@ -34,9 +34,10 @@ import {humanizeTime} from "../../../utils/humanize";
|
|||
import createRoom, {canEncryptToAllUsers} from "../../../createRoom";
|
||||
import {inviteMultipleToRoom} from "../../../RoomInvite";
|
||||
import SettingsStore from '../../../settings/SettingsStore';
|
||||
import RoomListStore, {TAG_DM} from "../../../stores/RoomListStore";
|
||||
import {Key} from "../../../Keyboard";
|
||||
import {Action} from "../../../dispatcher/actions";
|
||||
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
|
||||
export const KIND_DM = "dm";
|
||||
export const KIND_INVITE = "invite";
|
||||
|
@ -344,10 +345,10 @@ export default class InviteDialog extends React.PureComponent {
|
|||
_buildRecents(excludedTargetIds: Set<string>): {userId: string, user: RoomMember, lastActive: number} {
|
||||
const rooms = DMRoomMap.shared().getUniqueRoomsWithIndividuals(); // map of userId => js-sdk Room
|
||||
|
||||
// Also pull in all the rooms tagged as TAG_DM so we don't miss anything. Sometimes the
|
||||
// Also pull in all the rooms tagged as DefaultTagID.DM so we don't miss anything. Sometimes the
|
||||
// room list doesn't tag the room for the DMRoomMap, but does for the room list.
|
||||
const taggedRooms = RoomListStore.getRoomLists();
|
||||
const dmTaggedRooms = taggedRooms[TAG_DM];
|
||||
const taggedRooms = RoomListStoreTempProxy.getRoomLists();
|
||||
const dmTaggedRooms = taggedRooms[DefaultTagID.DM];
|
||||
const myUserId = MatrixClientPeg.get().getUserId();
|
||||
for (const dmRoom of dmTaggedRooms) {
|
||||
const otherMembers = dmRoom.getJoinedMembers().filter(u => u.userId !== myUserId);
|
||||
|
|
|
@ -22,6 +22,7 @@ import {_t, _td} from "../../../languageHandler";
|
|||
import GeneralUserSettingsTab from "../settings/tabs/user/GeneralUserSettingsTab";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import LabsUserSettingsTab from "../settings/tabs/user/LabsUserSettingsTab";
|
||||
import AppearanceUserSettingsTab from "../settings/tabs/user/AppearanceUserSettingsTab";
|
||||
import SecurityUserSettingsTab from "../settings/tabs/user/SecurityUserSettingsTab";
|
||||
import NotificationUserSettingsTab from "../settings/tabs/user/NotificationUserSettingsTab";
|
||||
import PreferencesUserSettingsTab from "../settings/tabs/user/PreferencesUserSettingsTab";
|
||||
|
@ -66,6 +67,11 @@ export default class UserSettingsDialog extends React.Component {
|
|||
"mx_UserSettingsDialog_settingsIcon",
|
||||
<GeneralUserSettingsTab closeSettingsFn={this.props.onFinished} />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
_td("Appearance"),
|
||||
"mx_UserSettingsDialog_appearanceIcon",
|
||||
<AppearanceUserSettingsTab />,
|
||||
));
|
||||
tabs.push(new Tab(
|
||||
_td("Flair"),
|
||||
"mx_UserSettingsDialog_flairIcon",
|
||||
|
|
|
@ -201,7 +201,7 @@ export default class RestoreKeyBackupDialog extends React.PureComponent {
|
|||
// `accessSecretStorage` may prompt for storage access as needed.
|
||||
const recoverInfo = await accessSecretStorage(async () => {
|
||||
return MatrixClientPeg.get().restoreKeyBackupWithSecretStorage(
|
||||
this.state.backupInfo,
|
||||
this.state.backupInfo, undefined, undefined,
|
||||
{ progressCallback: this._progressCallback },
|
||||
);
|
||||
});
|
||||
|
|
84
src/components/views/elements/Draggable.tsx
Normal file
84
src/components/views/elements/Draggable.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
interface IProps {
|
||||
className: string,
|
||||
dragFunc: (currentLocation: ILocationState, event: MouseEvent) => ILocationState,
|
||||
onMouseUp: (event: MouseEvent) => void,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
onMouseMove: (event: MouseEvent) => void,
|
||||
onMouseUp: (event: MouseEvent) => void,
|
||||
location: ILocationState,
|
||||
}
|
||||
|
||||
export interface ILocationState {
|
||||
currentX: number,
|
||||
currentY: number,
|
||||
}
|
||||
|
||||
export default class Draggable extends React.Component<IProps, IState> {
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
onMouseMove: this.onMouseMove.bind(this),
|
||||
onMouseUp: this.onMouseUp.bind(this),
|
||||
location: {
|
||||
currentX: 0,
|
||||
currentY: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private onMouseDown = (event: MouseEvent): void => {
|
||||
this.setState({
|
||||
location: {
|
||||
currentX: event.clientX,
|
||||
currentY: event.clientY,
|
||||
},
|
||||
});
|
||||
|
||||
document.addEventListener("mousemove", this.state.onMouseMove);
|
||||
document.addEventListener("mouseup", this.state.onMouseUp);
|
||||
console.log("Mouse down")
|
||||
}
|
||||
|
||||
private onMouseUp = (event: MouseEvent): void => {
|
||||
document.removeEventListener("mousemove", this.state.onMouseMove);
|
||||
document.removeEventListener("mouseup", this.state.onMouseUp);
|
||||
this.props.onMouseUp(event);
|
||||
console.log("Mouse up")
|
||||
}
|
||||
|
||||
private onMouseMove(event: MouseEvent): void {
|
||||
console.log("Mouse Move")
|
||||
const newLocation = this.props.dragFunc(this.state.location, event);
|
||||
|
||||
this.setState({
|
||||
location: newLocation,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className={this.props.className} onMouseDown={this.onMouseDown.bind(this)} />
|
||||
}
|
||||
|
||||
}
|
88
src/components/views/elements/IRCTimelineProfileResizer.tsx
Normal file
88
src/components/views/elements/IRCTimelineProfileResizer.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
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 SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
|
||||
import Draggable, {ILocationState} from './Draggable';
|
||||
|
||||
interface IProps {
|
||||
// Current room
|
||||
roomId: string,
|
||||
minWidth: number,
|
||||
maxWidth: number,
|
||||
};
|
||||
|
||||
interface IState {
|
||||
width: number,
|
||||
IRCLayoutRoot: HTMLElement,
|
||||
};
|
||||
|
||||
export default class IRCTimelineProfileResizer extends React.Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
width: SettingsStore.getValue("ircDisplayNameWidth", this.props.roomId),
|
||||
IRCLayoutRoot: null,
|
||||
}
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({
|
||||
IRCLayoutRoot: document.querySelector(".mx_IRCLayout") as HTMLElement,
|
||||
}, () => this.updateCSSWidth(this.state.width))
|
||||
}
|
||||
|
||||
private dragFunc = (location: ILocationState, event: React.MouseEvent<Element, MouseEvent>): ILocationState => {
|
||||
const offset = event.clientX - location.currentX;
|
||||
const newWidth = this.state.width + offset;
|
||||
|
||||
console.log({offset})
|
||||
// If we're trying to go smaller than min width, don't.
|
||||
if (newWidth < this.props.minWidth) {
|
||||
return location;
|
||||
}
|
||||
|
||||
if (newWidth > this.props.maxWidth) {
|
||||
return location;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
width: newWidth,
|
||||
});
|
||||
|
||||
this.updateCSSWidth.bind(this)(newWidth);
|
||||
|
||||
return {
|
||||
currentX: event.clientX,
|
||||
currentY: location.currentY,
|
||||
}
|
||||
}
|
||||
|
||||
private updateCSSWidth(newWidth: number) {
|
||||
this.state.IRCLayoutRoot.style.setProperty("--name-width", newWidth + "px");
|
||||
}
|
||||
|
||||
private onMoueUp(event: MouseEvent) {
|
||||
if (this.props.roomId) {
|
||||
SettingsStore.setValue("ircDisplayNameWidth", this.props.roomId, SettingLevel.ROOM_DEVICE, this.state.width);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return <Draggable className="mx_ProfileResizer" dragFunc={this.dragFunc.bind(this)} onMouseUp={this.onMoueUp.bind(this)}/>
|
||||
}
|
||||
};
|
|
@ -156,16 +156,70 @@ export default class PersistedElement extends React.Component {
|
|||
child.style.display = visible ? 'block' : 'none';
|
||||
}
|
||||
|
||||
/*
|
||||
* Clip element bounding rectangle to that of the parent elements.
|
||||
* This is not a full visibility check, but prevents the persisted
|
||||
* element from overflowing parent containers when inside a scrolled
|
||||
* area.
|
||||
*/
|
||||
_getClippedBoundingClientRect(element) {
|
||||
let parentElement = element.parentElement;
|
||||
let rect = element.getBoundingClientRect();
|
||||
|
||||
rect = new DOMRect(rect.left, rect.top, rect.width, rect.height);
|
||||
|
||||
while (parentElement) {
|
||||
const parentRect = parentElement.getBoundingClientRect();
|
||||
|
||||
if (parentRect.left > rect.left) {
|
||||
rect.width = rect.width - (parentRect.left - rect.left);
|
||||
rect.x = parentRect.x;
|
||||
}
|
||||
|
||||
if (parentRect.top > rect.top) {
|
||||
rect.height = rect.height - (parentRect.top - rect.top);
|
||||
rect.y = parentRect.y;
|
||||
}
|
||||
|
||||
if (parentRect.right < rect.right) {
|
||||
rect.width = rect.width - (rect.right - parentRect.right);
|
||||
}
|
||||
|
||||
if (parentRect.bottom < rect.bottom) {
|
||||
rect.height = rect.height - (rect.bottom - parentRect.bottom);
|
||||
}
|
||||
|
||||
parentElement = parentElement.parentElement;
|
||||
}
|
||||
|
||||
if (rect.width < 0) rect.width = 0;
|
||||
if (rect.height < 0) rect.height = 0;
|
||||
|
||||
return rect;
|
||||
}
|
||||
|
||||
updateChildPosition(child, parent) {
|
||||
if (!child || !parent) return;
|
||||
|
||||
const parentRect = parent.getBoundingClientRect();
|
||||
const clipRect = this._getClippedBoundingClientRect(parent);
|
||||
|
||||
Object.assign(child.parentElement.style, {
|
||||
position: 'absolute',
|
||||
top: clipRect.top + 'px',
|
||||
left: clipRect.left + 'px',
|
||||
width: clipRect.width + 'px',
|
||||
height: clipRect.height + 'px',
|
||||
overflow: "hidden",
|
||||
});
|
||||
|
||||
Object.assign(child.style, {
|
||||
position: 'absolute',
|
||||
top: parentRect.top + 'px',
|
||||
left: parentRect.left + 'px',
|
||||
top: (parentRect.top - clipRect.top) + 'px',
|
||||
left: (parentRect.left - clipRect.left) + 'px',
|
||||
width: parentRect.width + 'px',
|
||||
height: parentRect.height + 'px',
|
||||
overflow: "hidden",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -37,6 +37,8 @@ export default class ReplyThread extends React.Component {
|
|||
// called when the ReplyThread contents has changed, including EventTiles thereof
|
||||
onHeightChanged: PropTypes.func.isRequired,
|
||||
permalinkCreator: PropTypes.instanceOf(RoomPermalinkCreator).isRequired,
|
||||
// Specifies which layout to use.
|
||||
useIRCLayout: PropTypes.bool,
|
||||
};
|
||||
|
||||
static contextType = MatrixClientContext;
|
||||
|
@ -176,12 +178,17 @@ export default class ReplyThread extends React.Component {
|
|||
};
|
||||
}
|
||||
|
||||
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref) {
|
||||
static makeThread(parentEv, onHeightChanged, permalinkCreator, ref, useIRCLayout) {
|
||||
if (!ReplyThread.getParentEventId(parentEv)) {
|
||||
return <div />;
|
||||
return <div className="mx_ReplyThread_wrapper_empty" />;
|
||||
}
|
||||
return <ReplyThread parentEv={parentEv} onHeightChanged={onHeightChanged}
|
||||
ref={ref} permalinkCreator={permalinkCreator} />;
|
||||
return <ReplyThread
|
||||
parentEv={parentEv}
|
||||
onHeightChanged={onHeightChanged}
|
||||
ref={ref}
|
||||
permalinkCreator={permalinkCreator}
|
||||
useIRCLayout={useIRCLayout}
|
||||
/>;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
@ -331,11 +338,13 @@ export default class ReplyThread extends React.Component {
|
|||
onHeightChanged={this.props.onHeightChanged}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
isRedacted={ev.isRedacted()}
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")} />
|
||||
isTwelveHour={SettingsStore.getValue("showTwelveHourTimestamps")}
|
||||
useIRCLayout={this.props.useIRCLayout}
|
||||
/>
|
||||
</blockquote>;
|
||||
});
|
||||
|
||||
return <div>
|
||||
return <div className="mx_ReplyThread_wrapper">
|
||||
<div>{ header }</div>
|
||||
<div>{ evTiles }</div>
|
||||
</div>;
|
||||
|
|
146
src/components/views/elements/Slider.tsx
Normal file
146
src/components/views/elements/Slider.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
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';
|
||||
|
||||
interface IProps {
|
||||
// A callback for the selected value
|
||||
onSelectionChange: (value: number) => void;
|
||||
|
||||
// The current value of the slider
|
||||
value: number;
|
||||
|
||||
// The range and values of the slider
|
||||
// Currently only supports an ascending, constant interval range
|
||||
values: number[];
|
||||
|
||||
// A function for formatting the the values
|
||||
displayFunc: (value: number) => string;
|
||||
|
||||
// Whether the slider is disabled
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default class Slider extends React.Component<IProps> {
|
||||
// offset is a terrible inverse approximation.
|
||||
// if the values represents some function f(x) = y where x is the
|
||||
// index of the array and y = values[x] then offset(f, y) = x
|
||||
// s.t f(x) = y.
|
||||
// it assumes a monotonic function and interpolates linearly between
|
||||
// y values.
|
||||
// Offset is used for finding the location of a value on a
|
||||
// non linear slider.
|
||||
private offset(values: number[], value: number): number {
|
||||
// the index of the first number greater than value.
|
||||
let closest = values.reduce((prev, curr) => {
|
||||
return (value > curr ? prev + 1 : prev);
|
||||
}, 0);
|
||||
|
||||
// Off the left
|
||||
if (closest === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Off the right
|
||||
if (closest === values.length) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
// Now
|
||||
const closestLessValue = values[closest - 1];
|
||||
const closestGreaterValue = values[closest];
|
||||
|
||||
const intervalWidth = 1 / (values.length - 1);
|
||||
|
||||
const linearInterpolation = (value - closestLessValue) / (closestGreaterValue - closestLessValue)
|
||||
|
||||
return 100 * (closest - 1 + linearInterpolation) * intervalWidth
|
||||
|
||||
}
|
||||
|
||||
render(): React.ReactNode {
|
||||
const dots = this.props.values.map(v =>
|
||||
<Dot active={v <= this.props.value}
|
||||
label={this.props.displayFunc(v)}
|
||||
onClick={this.props.disabled ? () => {} : () => this.props.onSelectionChange(v)}
|
||||
key={v}
|
||||
disabled={this.props.disabled}
|
||||
/>);
|
||||
|
||||
let selection = null;
|
||||
|
||||
if (!this.props.disabled) {
|
||||
const offset = this.offset(this.props.values, this.props.value);
|
||||
selection = <div className="mx_Slider_selection">
|
||||
<div className="mx_Slider_selectionDot" style={{left: "calc(-0.55em + " + offset + "%)"}} />
|
||||
<hr style={{width: offset + "%"}} />
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="mx_Slider">
|
||||
<div>
|
||||
<div className="mx_Slider_bar">
|
||||
<hr onClick={this.props.disabled ? () => {} : this.onClick.bind(this)}/>
|
||||
{ selection }
|
||||
</div>
|
||||
<div className="mx_Slider_dotContainer">
|
||||
{dots}
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
onClick(event: React.MouseEvent) {
|
||||
const width = (event.target as HTMLElement).clientWidth;
|
||||
// nativeEvent is safe to use because https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/offsetX
|
||||
// is supported by all modern browsers
|
||||
const relativeClick = (event.nativeEvent.offsetX / width);
|
||||
const nearestValue = this.props.values[Math.round(relativeClick * (this.props.values.length - 1))];
|
||||
this.props.onSelectionChange(nearestValue);
|
||||
}
|
||||
}
|
||||
|
||||
interface IDotProps {
|
||||
// Callback for behavior onclick
|
||||
onClick: () => void,
|
||||
|
||||
// Whether the dot should appear active
|
||||
active: boolean,
|
||||
|
||||
// The label on the dot
|
||||
label: string,
|
||||
|
||||
// Whether the slider is disabled
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
class Dot extends React.PureComponent<IDotProps> {
|
||||
render(): React.ReactNode {
|
||||
let className = "mx_Slider_dot"
|
||||
if (!this.props.disabled && this.props.active) {
|
||||
className += " mx_Slider_dotActive";
|
||||
}
|
||||
|
||||
return <span onClick={this.props.onClick} className="mx_Slider_dotValue">
|
||||
<div className={className} />
|
||||
<div className="mx_Slider_labelContainer">
|
||||
<div className="mx_Slider_label">
|
||||
{this.props.label}
|
||||
</div>
|
||||
</div>
|
||||
</span>;
|
||||
}
|
||||
}
|
|
@ -131,7 +131,9 @@ export default createReactClass({
|
|||
|
||||
return (
|
||||
<div className="mx_SenderProfile" dir="auto" onClick={this.props.onClick}>
|
||||
{ content }
|
||||
<div className="mx_SenderProfile_hover">
|
||||
{ content }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
@ -141,6 +141,15 @@ export default createReactClass({
|
|||
return counters;
|
||||
},
|
||||
|
||||
_onScroll: function(rect) {
|
||||
if (this.props.onResize) {
|
||||
this.props.onResize();
|
||||
}
|
||||
|
||||
/* Force refresh of PersistedElements which may be partially hidden */
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
},
|
||||
|
||||
render: function() {
|
||||
const CallView = sdk.getComponent("voip.CallView");
|
||||
const TintableSvg = sdk.getComponent("elements.TintableSvg");
|
||||
|
@ -265,7 +274,7 @@ export default createReactClass({
|
|||
}
|
||||
|
||||
return (
|
||||
<AutoHideScrollbar className={classes} style={style} >
|
||||
<AutoHideScrollbar className={classes} style={style} onScroll={this._onScroll}>
|
||||
{ stateViews }
|
||||
{ appsDrawer }
|
||||
{ fileDropTarget }
|
||||
|
|
|
@ -34,7 +34,7 @@ import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
|
|||
import * as ObjectUtils from "../../../ObjectUtils";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import {E2E_STATE} from "./E2EIcon";
|
||||
import toRem from "../../../utils/rem";
|
||||
import {toRem} from "../../../utils/units";
|
||||
|
||||
const eventTileTypes = {
|
||||
'm.room.message': 'messages.MessageEvent',
|
||||
|
@ -206,6 +206,9 @@ export default createReactClass({
|
|||
|
||||
// whether to show reactions for this event
|
||||
showReactions: PropTypes.bool,
|
||||
|
||||
// whether to use the irc layout
|
||||
useIRCLayout: PropTypes.bool,
|
||||
},
|
||||
|
||||
getDefaultProps: function() {
|
||||
|
@ -695,6 +698,9 @@ export default createReactClass({
|
|||
// joins/parts/etc
|
||||
avatarSize = 14;
|
||||
needsSenderProfile = false;
|
||||
} else if (this.props.useIRCLayout) {
|
||||
avatarSize = 14;
|
||||
needsSenderProfile = true;
|
||||
} else if (this.props.continuation && this.props.tileShape !== "file_grid") {
|
||||
// no avatar or sender profile for continuation messages
|
||||
avatarSize = 0;
|
||||
|
@ -786,6 +792,17 @@ export default createReactClass({
|
|||
/>;
|
||||
}
|
||||
|
||||
const linkedTimestamp = <a
|
||||
href={permalink}
|
||||
onClick={this.onPermalinkClicked}
|
||||
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
|
||||
>
|
||||
{ timestamp }
|
||||
</a>;
|
||||
|
||||
const groupTimestamp = !this.props.useIRCLayout ? linkedTimestamp : null;
|
||||
const ircTimestamp = this.props.useIRCLayout ? linkedTimestamp : null;
|
||||
|
||||
switch (this.props.tileShape) {
|
||||
case 'notif': {
|
||||
const room = this.context.getRoom(this.props.mxEvent.getRoomId());
|
||||
|
@ -853,12 +870,11 @@ export default createReactClass({
|
|||
}
|
||||
return (
|
||||
<div className={classes}>
|
||||
{ ircTimestamp }
|
||||
{ avatar }
|
||||
{ sender }
|
||||
<div className="mx_EventTile_reply">
|
||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||
{ timestamp }
|
||||
</a>
|
||||
{ groupTimestamp }
|
||||
{ !isBubbleMessage && this._renderE2EPadlock() }
|
||||
{ thread }
|
||||
<EventTileType ref={this._tile}
|
||||
|
@ -877,22 +893,19 @@ export default createReactClass({
|
|||
this.props.onHeightChanged,
|
||||
this.props.permalinkCreator,
|
||||
this._replyThread,
|
||||
this.props.useIRCLayout,
|
||||
);
|
||||
|
||||
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
|
||||
return (
|
||||
<div className={classes} tabIndex={-1}>
|
||||
{ ircTimestamp }
|
||||
<div className="mx_EventTile_msgOption">
|
||||
{ readAvatars }
|
||||
</div>
|
||||
{ sender }
|
||||
<div className="mx_EventTile_line">
|
||||
<a
|
||||
href={permalink}
|
||||
onClick={this.onPermalinkClicked}
|
||||
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
|
||||
>
|
||||
{ timestamp }
|
||||
</a>
|
||||
{ groupTimestamp }
|
||||
{ !isBubbleMessage && this._renderE2EPadlock() }
|
||||
{ thread }
|
||||
<EventTileType ref={this._tile}
|
||||
|
|
|
@ -381,7 +381,7 @@ export default class MessageComposer extends React.Component {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx_MessageComposer">
|
||||
<div className="mx_MessageComposer mx_GroupLayout">
|
||||
<div className="mx_MessageComposer_wrapper">
|
||||
<div className="mx_MessageComposer_row">
|
||||
{ controls }
|
||||
|
|
|
@ -23,7 +23,7 @@ import { _t } from '../../../languageHandler';
|
|||
import {formatDate} from '../../../DateUtils';
|
||||
import Velociraptor from "../../../Velociraptor";
|
||||
import * as sdk from "../../../index";
|
||||
import toRem from "../../../utils/rem";
|
||||
import {toRem} from "../../../utils/units";
|
||||
|
||||
let bounce = false;
|
||||
try {
|
||||
|
|
|
@ -29,7 +29,6 @@ import rate_limited_func from "../../../ratelimitedfunc";
|
|||
import * as Rooms from '../../../Rooms';
|
||||
import DMRoomMap from '../../../utils/DMRoomMap';
|
||||
import TagOrderStore from '../../../stores/TagOrderStore';
|
||||
import RoomListStore, {TAG_DM} from '../../../stores/RoomListStore';
|
||||
import CustomRoomTagStore from '../../../stores/CustomRoomTagStore';
|
||||
import GroupStore from '../../../stores/GroupStore';
|
||||
import RoomSubList from '../../structures/RoomSubList';
|
||||
|
@ -41,6 +40,8 @@ import * as Receipt from "../../../utils/Receipt";
|
|||
import {Resizer} from '../../../resizer';
|
||||
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
||||
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
|
||||
import {RoomListStoreTempProxy} from "../../../stores/room-list/RoomListStoreTempProxy";
|
||||
import {DefaultTagID} from "../../../stores/room-list/models";
|
||||
import * as Unread from "../../../Unread";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
|
||||
|
@ -161,7 +162,7 @@ export default createReactClass({
|
|||
this.updateVisibleRooms();
|
||||
});
|
||||
|
||||
this._roomListStoreToken = RoomListStore.addListener(() => {
|
||||
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
|
||||
this._delayedRefreshRoomList();
|
||||
});
|
||||
|
||||
|
@ -521,7 +522,7 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
getTagNameForRoomId: function(roomId) {
|
||||
const lists = RoomListStore.getRoomLists();
|
||||
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||
for (const tagName of Object.keys(lists)) {
|
||||
for (const room of lists[tagName]) {
|
||||
// Should be impossible, but guard anyways.
|
||||
|
@ -541,7 +542,7 @@ export default createReactClass({
|
|||
},
|
||||
|
||||
getRoomLists: function() {
|
||||
const lists = RoomListStore.getRoomLists();
|
||||
const lists = RoomListStoreTempProxy.getRoomLists();
|
||||
|
||||
const filteredLists = {};
|
||||
|
||||
|
@ -773,10 +774,10 @@ export default createReactClass({
|
|||
incomingCall: incomingCallIfTaggedAs('m.favourite'),
|
||||
},
|
||||
{
|
||||
list: this.state.lists[TAG_DM],
|
||||
list: this.state.lists[DefaultTagID.DM],
|
||||
label: _t('Direct Messages'),
|
||||
tagName: TAG_DM,
|
||||
incomingCall: incomingCallIfTaggedAs(TAG_DM),
|
||||
tagName: DefaultTagID.DM,
|
||||
incomingCall: incomingCallIfTaggedAs(DefaultTagID.DM),
|
||||
onAddRoom: () => {dis.dispatch({action: 'view_create_chat'});},
|
||||
addRoomLabel: _t("Start chat"),
|
||||
},
|
||||
|
@ -785,6 +786,7 @@ export default createReactClass({
|
|||
label: _t('Rooms'),
|
||||
incomingCall: incomingCallIfTaggedAs('im.vector.fake.recent'),
|
||||
onAddRoom: () => {dis.dispatch({action: 'view_create_room'});},
|
||||
addRoomLabel: _t("Create room"),
|
||||
},
|
||||
];
|
||||
const tagSubLists = Object.keys(this.state.lists)
|
||||
|
|
246
src/components/views/rooms/RoomList2.tsx
Normal file
246
src/components/views/rooms/RoomList2.tsx
Normal file
|
@ -0,0 +1,246 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 Vector Creations Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import { Layout } from '../../../resizer/distributors/roomsublist2';
|
||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2";
|
||||
import { ITagMap } from "../../../stores/room-list/algorithms/models";
|
||||
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
|
||||
import { Dispatcher } from "flux";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import RoomSublist2 from "./RoomSublist2";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
|
||||
/*******************************************************************
|
||||
* 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 {
|
||||
onKeyDown: (ev: React.KeyboardEvent) => void;
|
||||
onFocus: (ev: React.FocusEvent) => void;
|
||||
onBlur: (ev: React.FocusEvent) => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
collapsed: boolean;
|
||||
searchFilter: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
sublists: ITagMap;
|
||||
}
|
||||
|
||||
const TAG_ORDER: TagID[] = [
|
||||
// -- Community Invites Placeholder --
|
||||
|
||||
DefaultTagID.Invite,
|
||||
DefaultTagID.Favourite,
|
||||
DefaultTagID.DM,
|
||||
DefaultTagID.Untagged,
|
||||
|
||||
// -- Custom Tags Placeholder --
|
||||
|
||||
DefaultTagID.LowPriority,
|
||||
DefaultTagID.ServerNotice,
|
||||
DefaultTagID.Archived,
|
||||
];
|
||||
const COMMUNITY_TAGS_BEFORE_TAG = DefaultTagID.Invite;
|
||||
const CUSTOM_TAGS_BEFORE_TAG = DefaultTagID.LowPriority;
|
||||
const ALWAYS_VISIBLE_TAGS: TagID[] = [
|
||||
DefaultTagID.DM,
|
||||
DefaultTagID.Untagged,
|
||||
];
|
||||
|
||||
interface ITagAesthetics {
|
||||
sectionLabel: string;
|
||||
addRoomLabel?: string;
|
||||
onAddRoom?: (dispatcher: Dispatcher<ActionPayload>) => void;
|
||||
isInvite: boolean;
|
||||
defaultHidden: boolean;
|
||||
}
|
||||
|
||||
const TAG_AESTHETICS: {
|
||||
// @ts-ignore - TS wants this to be a string but we know better
|
||||
[tagId: TagID]: ITagAesthetics;
|
||||
} = {
|
||||
[DefaultTagID.Invite]: {
|
||||
sectionLabel: _td("Invites"),
|
||||
isInvite: true,
|
||||
defaultHidden: false,
|
||||
},
|
||||
[DefaultTagID.Favourite]: {
|
||||
sectionLabel: _td("Favourites"),
|
||||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
},
|
||||
[DefaultTagID.DM]: {
|
||||
sectionLabel: _td("Direct Messages"),
|
||||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
addRoomLabel: _td("Start chat"),
|
||||
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_chat'}),
|
||||
},
|
||||
[DefaultTagID.Untagged]: {
|
||||
sectionLabel: _td("Rooms"),
|
||||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
addRoomLabel: _td("Create room"),
|
||||
onAddRoom: (dispatcher: Dispatcher<ActionPayload>) => dispatcher.dispatch({action: 'view_create_room'}),
|
||||
},
|
||||
[DefaultTagID.LowPriority]: {
|
||||
sectionLabel: _td("Low priority"),
|
||||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
},
|
||||
[DefaultTagID.ServerNotice]: {
|
||||
sectionLabel: _td("System Alerts"),
|
||||
isInvite: false,
|
||||
defaultHidden: false,
|
||||
},
|
||||
[DefaultTagID.Archived]: {
|
||||
sectionLabel: _td("Historical"),
|
||||
isInvite: false,
|
||||
defaultHidden: true,
|
||||
},
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {sublists: {}};
|
||||
this.loadSublistSizes();
|
||||
this.prepareLayouts();
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => {
|
||||
console.log("new lists", store.orderedLists);
|
||||
this.setState({sublists: store.orderedLists});
|
||||
});
|
||||
}
|
||||
|
||||
private loadSublistSizes() {
|
||||
const sizesJson = window.localStorage.getItem("mx_roomlist_sizes");
|
||||
if (sizesJson) this.sublistSizes = JSON.parse(sizesJson);
|
||||
|
||||
const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed");
|
||||
if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson);
|
||||
}
|
||||
|
||||
private saveSublistSizes() {
|
||||
window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes));
|
||||
window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates));
|
||||
}
|
||||
|
||||
private prepareLayouts() {
|
||||
// TODO: Change layout engine for FTUE support
|
||||
this.unfilteredLayout = new Layout((tagId: string, height: number) => {
|
||||
const sublist = this.sublistRefs[tagId];
|
||||
if (sublist) sublist.current.setHeight(height);
|
||||
|
||||
// TODO: Check overflow (see old impl)
|
||||
|
||||
// Don't store a height for collapsed sublists
|
||||
if (!this.sublistCollapseStates[tagId]) {
|
||||
this.sublistSizes[tagId] = height;
|
||||
this.saveSublistSizes();
|
||||
}
|
||||
}, 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,
|
||||
});
|
||||
}
|
||||
|
||||
private renderSublists(): React.ReactElement[] {
|
||||
const components: React.ReactElement[] = [];
|
||||
|
||||
for (const orderedTagId of TAG_ORDER) {
|
||||
if (COMMUNITY_TAGS_BEFORE_TAG === orderedTagId) {
|
||||
// Populate community invites if we have the chance
|
||||
// TODO
|
||||
}
|
||||
if (CUSTOM_TAGS_BEFORE_TAG === orderedTagId) {
|
||||
// Populate custom tags if needed
|
||||
// TODO
|
||||
}
|
||||
|
||||
const orderedRooms = this.state.sublists[orderedTagId] || [];
|
||||
if (orderedRooms.length === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
|
||||
continue; // skip tag - not needed
|
||||
}
|
||||
|
||||
const aesthetics: ITagAesthetics = TAG_AESTHETICS[orderedTagId];
|
||||
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
|
||||
|
||||
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
|
||||
components.push(<RoomSublist2
|
||||
key={`sublist-${orderedTagId}`}
|
||||
forRooms={true}
|
||||
rooms={orderedRooms}
|
||||
startAsHidden={aesthetics.defaultHidden}
|
||||
label={_t(aesthetics.sectionLabel)}
|
||||
onAddRoom={onAddRoomFn}
|
||||
addRoomLabel={aesthetics.addRoomLabel}
|
||||
isInvite={aesthetics.isInvite}
|
||||
/>);
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
public render() {
|
||||
const sublists = this.renderSublists();
|
||||
return (
|
||||
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}>
|
||||
{({onKeyDownHandler}) => (
|
||||
<div
|
||||
onFocus={this.props.onFocus}
|
||||
onBlur={this.props.onBlur}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
className="mx_RoomList"
|
||||
role="tree"
|
||||
aria-label={_t("Rooms")}
|
||||
// Firefox sometimes makes this element focusable due to
|
||||
// overflow:scroll;, so force it out of tab order.
|
||||
tabIndex={-1}
|
||||
>{sublists}</div>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
}
|
||||
}
|
226
src/components/views/rooms/RoomSublist2.tsx
Normal file
226
src/components/views/rooms/RoomSublist2.tsx
Normal file
|
@ -0,0 +1,226 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017, 2018 Vector Creations Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from 'classnames';
|
||||
import IndicatorScrollbar from "../../structures/IndicatorScrollbar";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton";
|
||||
import * as FormattingUtils from '../../../utils/FormattingUtils';
|
||||
import RoomTile2 from "./RoomTile2";
|
||||
|
||||
/*******************************************************************
|
||||
* 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 {
|
||||
forRooms: boolean;
|
||||
rooms?: Room[];
|
||||
startAsHidden: boolean;
|
||||
label: string;
|
||||
onAddRoom?: () => void;
|
||||
addRoomLabel: string;
|
||||
isInvite: boolean;
|
||||
|
||||
// TODO: Collapsed state
|
||||
// TODO: Height
|
||||
// TODO: Group invites
|
||||
// TODO: Calls
|
||||
// TODO: forceExpand?
|
||||
// TODO: Header clicking
|
||||
// TODO: Spinner support for historical
|
||||
}
|
||||
|
||||
interface IState {
|
||||
}
|
||||
|
||||
export default class RoomSublist2 extends React.Component<IProps, IState> {
|
||||
private headerButton = createRef();
|
||||
|
||||
public setHeight(size: number) {
|
||||
// TODO: Do a thing (maybe - height changes are different in FTUE)
|
||||
}
|
||||
|
||||
private hasTiles(): boolean {
|
||||
return this.numTiles > 0;
|
||||
}
|
||||
|
||||
private get numTiles(): number {
|
||||
// TODO: Account for group invites
|
||||
return (this.props.rooms || []).length;
|
||||
}
|
||||
|
||||
private onAddRoom = (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.props.onAddRoom) this.props.onAddRoom();
|
||||
};
|
||||
|
||||
private renderTiles(): React.ReactElement[] {
|
||||
const tiles: React.ReactElement[] = [];
|
||||
|
||||
if (this.props.rooms) {
|
||||
for (const room of this.props.rooms) {
|
||||
tiles.push(<RoomTile2 room={room} key={`room-${room.roomId}`}/>);
|
||||
}
|
||||
}
|
||||
|
||||
return tiles;
|
||||
}
|
||||
|
||||
private renderHeader(): React.ReactElement {
|
||||
const notifications = !this.props.isInvite
|
||||
? RoomNotifs.aggregateNotificationCount(this.props.rooms)
|
||||
: {count: 0, highlight: true};
|
||||
const notifCount = notifications.count;
|
||||
const notifHighlight = notifications.highlight;
|
||||
|
||||
// TODO: Title on collapsed
|
||||
// TODO: Incoming call box
|
||||
|
||||
let chevron = null;
|
||||
if (this.hasTiles()) {
|
||||
const chevronClasses = classNames({
|
||||
'mx_RoomSubList_chevron': true,
|
||||
'mx_RoomSubList_chevronRight': false, // isCollapsed
|
||||
'mx_RoomSubList_chevronDown': true, // !isCollapsed
|
||||
});
|
||||
chevron = (<div className={chevronClasses}/>);
|
||||
}
|
||||
|
||||
return (
|
||||
<RovingTabIndexWrapper inputRef={this.headerButton}>
|
||||
{({onFocus, isActive, ref}) => {
|
||||
// TODO: Use onFocus
|
||||
const tabIndex = isActive ? 0 : -1;
|
||||
|
||||
// TODO: Collapsed state
|
||||
let badge;
|
||||
if (true) { // !isCollapsed
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomSubList_badge': true,
|
||||
'mx_RoomSubList_badgeHighlight': notifHighlight,
|
||||
});
|
||||
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||
if (notifCount > 0) {
|
||||
badge = (
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
className={badgeClasses}
|
||||
aria-label={_t("Jump to first unread room.")}
|
||||
>
|
||||
<div>
|
||||
{FormattingUtils.formatCount(notifCount)}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
} else if (this.props.isInvite && this.hasTiles()) {
|
||||
// Render the `!` badge for invites
|
||||
badge = (
|
||||
<AccessibleButton
|
||||
tabIndex={tabIndex}
|
||||
className={badgeClasses}
|
||||
aria-label={_t("Jump to first invite.")}
|
||||
>
|
||||
<div>
|
||||
{FormattingUtils.formatCount(this.numTiles)}
|
||||
</div>
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let addRoomButton = null;
|
||||
if (!!this.props.onAddRoom) {
|
||||
addRoomButton = (
|
||||
<AccessibleTooltipButton
|
||||
tabIndex={tabIndex}
|
||||
onClick={this.onAddRoom}
|
||||
className="mx_RoomSubList_addRoom"
|
||||
title={this.props.addRoomLabel || _t("Add room")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: a11y (see old component)
|
||||
return (
|
||||
<div className={"mx_RoomSubList_labelContainer"}>
|
||||
<AccessibleButton
|
||||
inputRef={ref}
|
||||
tabIndex={tabIndex}
|
||||
className={"mx_RoomSubList_label"}
|
||||
role="treeitem"
|
||||
aria-level="1"
|
||||
>
|
||||
{chevron}
|
||||
<span>{this.props.label}</span>
|
||||
</AccessibleButton>
|
||||
{badge}
|
||||
{addRoomButton}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</RovingTabIndexWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// TODO: Proper rendering
|
||||
// TODO: Error boundary
|
||||
|
||||
const tiles = this.renderTiles();
|
||||
|
||||
const classes = classNames({
|
||||
// TODO: Proper collapse support
|
||||
'mx_RoomSubList': true,
|
||||
'mx_RoomSubList_hidden': false, // len && isCollapsed
|
||||
'mx_RoomSubList_nonEmpty': this.hasTiles(), // len && !isCollapsed
|
||||
});
|
||||
|
||||
let content = null;
|
||||
if (tiles.length > 0) {
|
||||
// TODO: Lazy list rendering
|
||||
// TODO: Whatever scrolling magic needs to happen here
|
||||
content = (
|
||||
<IndicatorScrollbar className='mx_RoomSubList_scroll'>
|
||||
{tiles}
|
||||
</IndicatorScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: onKeyDown support
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
role="group"
|
||||
aria-label={this.props.label}
|
||||
>
|
||||
{this.renderHeader()}
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
219
src/components/views/rooms/RoomTile2.tsx
Normal file
219
src/components/views/rooms/RoomTile2.tsx
Normal file
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
Copyright 2017 New Vector Ltd
|
||||
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
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, { createRef } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import classNames from "classnames";
|
||||
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../views/elements/AccessibleButton";
|
||||
import RoomAvatar from "../../views/avatars/RoomAvatar";
|
||||
import Tooltip from "../../views/elements/Tooltip";
|
||||
import dis from '../../../dispatcher/dispatcher';
|
||||
import { Key } from "../../../Keyboard";
|
||||
import * as RoomNotifs from '../../../RoomNotifs';
|
||||
import { EffectiveMembership, getEffectiveMembership } from "../../../stores/room-list/membership";
|
||||
import * as Unread from '../../../Unread';
|
||||
import * as FormattingUtils from "../../../utils/FormattingUtils";
|
||||
|
||||
/*******************************************************************
|
||||
* 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 {
|
||||
room: Room;
|
||||
|
||||
// TODO: Allow falsifying counts (for invites and stuff)
|
||||
// TODO: Transparency? Was this ever used?
|
||||
// TODO: Incoming call boxes?
|
||||
}
|
||||
|
||||
interface IBadgeState {
|
||||
showBadge: boolean; // if numUnread > 0 && !showBadge -> bold room
|
||||
numUnread: number; // used only if showBadge or showBadgeHighlight is true
|
||||
hasUnread: number; // used to make the room bold
|
||||
showBadgeHighlight: boolean; // make the badge red
|
||||
isInvite: boolean; // show a `!` instead of a number
|
||||
}
|
||||
|
||||
interface IState extends IBadgeState {
|
||||
hover: boolean;
|
||||
}
|
||||
|
||||
export default class RoomTile2 extends React.Component<IProps, IState> {
|
||||
private roomTile = createRef();
|
||||
|
||||
// TODO: Custom status
|
||||
// TODO: Lock icon
|
||||
// TODO: Presence indicator
|
||||
// TODO: e2e shields
|
||||
// TODO: Handle changes to room aesthetics (name, join rules, etc)
|
||||
// TODO: scrollIntoView?
|
||||
// TODO: hover, badge, etc
|
||||
// TODO: isSelected for hover effects
|
||||
// TODO: Context menu
|
||||
// TODO: a11y
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
hover: false,
|
||||
|
||||
...this.getBadgeState(),
|
||||
};
|
||||
}
|
||||
|
||||
public componentWillUnmount() {
|
||||
// TODO: Listen for changes to the badge count and update as needed
|
||||
}
|
||||
|
||||
private updateBadgeCount() {
|
||||
this.setState({...this.getBadgeState()});
|
||||
}
|
||||
|
||||
private getBadgeState(): IBadgeState {
|
||||
// TODO: Make this code path faster
|
||||
const highlightCount = RoomNotifs.getUnreadNotificationCount(this.props.room, 'highlight');
|
||||
const numUnread = RoomNotifs.getUnreadNotificationCount(this.props.room);
|
||||
const showBadge = Unread.doesRoomHaveUnreadMessages(this.props.room);
|
||||
const myMembership = getEffectiveMembership(this.props.room.getMyMembership());
|
||||
const isInvite = myMembership === EffectiveMembership.Invite;
|
||||
const notifState = RoomNotifs.getRoomNotifsState(this.props.room.roomId);
|
||||
const shouldShowNotifBadge = RoomNotifs.shouldShowNotifBadge(notifState);
|
||||
const shouldShowHighlightBadge = RoomNotifs.shouldShowMentionBadge(notifState);
|
||||
|
||||
return {
|
||||
showBadge: (showBadge && shouldShowNotifBadge) || isInvite,
|
||||
numUnread,
|
||||
hasUnread: showBadge,
|
||||
showBadgeHighlight: (highlightCount > 0 && shouldShowHighlightBadge) || isInvite,
|
||||
isInvite,
|
||||
};
|
||||
}
|
||||
|
||||
private onTileMouseEnter = () => {
|
||||
this.setState({hover: true});
|
||||
};
|
||||
|
||||
private onTileMouseLeave = () => {
|
||||
this.setState({hover: false});
|
||||
};
|
||||
|
||||
private onTileClick = (ev: React.KeyboardEvent) => {
|
||||
dis.dispatch({
|
||||
action: 'view_room',
|
||||
// TODO: Support show_room_tile in new room list
|
||||
show_room_tile: true, // make sure the room is visible in the list
|
||||
room_id: this.props.room.roomId,
|
||||
clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)),
|
||||
});
|
||||
};
|
||||
|
||||
public render(): React.ReactElement {
|
||||
// TODO: Collapsed state
|
||||
// TODO: Invites
|
||||
// TODO: a11y proper
|
||||
// TODO: Render more than bare minimum
|
||||
|
||||
const classes = classNames({
|
||||
'mx_RoomTile': true,
|
||||
// 'mx_RoomTile_selected': this.state.selected,
|
||||
'mx_RoomTile_unread': this.state.numUnread > 0 || this.state.hasUnread,
|
||||
'mx_RoomTile_unreadNotify': this.state.showBadge,
|
||||
'mx_RoomTile_highlight': this.state.showBadgeHighlight,
|
||||
'mx_RoomTile_invited': this.state.isInvite,
|
||||
// 'mx_RoomTile_menuDisplayed': isMenuDisplayed,
|
||||
'mx_RoomTile_noBadges': !this.state.showBadge,
|
||||
// 'mx_RoomTile_transparent': this.props.transparent,
|
||||
// 'mx_RoomTile_hasSubtext': subtext && !this.props.collapsed,
|
||||
});
|
||||
|
||||
const avatarClasses = classNames({
|
||||
'mx_RoomTile_avatar': true,
|
||||
});
|
||||
|
||||
|
||||
let badge;
|
||||
if (this.state.showBadge) {
|
||||
const badgeClasses = classNames({
|
||||
'mx_RoomTile_badge': true,
|
||||
'mx_RoomTile_badgeButton': false, // this.state.badgeHover || isMenuDisplayed
|
||||
});
|
||||
const formattedCount = this.state.isInvite ? `!` : FormattingUtils.formatCount(this.state.numUnread);
|
||||
badge = <div className={badgeClasses}>{formattedCount}</div>;
|
||||
}
|
||||
|
||||
// TODO: the original RoomTile uses state for the room name. Do we need to?
|
||||
let name = this.props.room.name;
|
||||
if (typeof name !== 'string') name = '';
|
||||
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
|
||||
|
||||
const nameClasses = classNames({
|
||||
'mx_RoomTile_name': true,
|
||||
'mx_RoomTile_invite': this.state.isInvite,
|
||||
'mx_RoomTile_badgeShown': this.state.showBadge,
|
||||
});
|
||||
|
||||
// TODO: Support collapsed state properly
|
||||
let tooltip = null;
|
||||
if (false) { // isCollapsed
|
||||
if (this.state.hover) {
|
||||
tooltip = <Tooltip className="mx_RoomTile_tooltip" label={this.props.room.name} dir="auto"/>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<RovingTabIndexWrapper inputRef={this.roomTile}>
|
||||
{({onFocus, isActive, ref}) =>
|
||||
<AccessibleButton
|
||||
onFocus={onFocus}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
inputRef={ref}
|
||||
className={classes}
|
||||
onMouseEnter={this.onTileMouseEnter}
|
||||
onMouseLeave={this.onTileMouseLeave}
|
||||
onClick={this.onTileClick}
|
||||
role="treeitem"
|
||||
>
|
||||
<div className={avatarClasses}>
|
||||
<div className="mx_RoomTile_avatar_container">
|
||||
<RoomAvatar room={this.props.room} width={24} height={24}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_RoomTile_nameContainer">
|
||||
<div className="mx_RoomTile_labelContainer">
|
||||
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
{badge}
|
||||
</div>
|
||||
{tooltip}
|
||||
</AccessibleButton>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,281 @@
|
|||
/*
|
||||
Copyright 2019 New Vector Ltd
|
||||
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
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 {_t} from "../../../../../languageHandler";
|
||||
import SettingsStore, {SettingLevel} from "../../../../../settings/SettingsStore";
|
||||
import * as sdk from "../../../../../index";
|
||||
import {enumerateThemes, ThemeWatcher} from "../../../../../theme";
|
||||
import Field from "../../../elements/Field";
|
||||
import Slider from "../../../elements/Slider";
|
||||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import dis from "../../../../../dispatcher/dispatcher";
|
||||
import { FontWatcher } from "../../../../../FontWatcher";
|
||||
|
||||
export default class AppearanceUserSettingsTab extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.state = {
|
||||
fontSize: SettingsStore.getValue("fontSize", null),
|
||||
...this._calculateThemeState(),
|
||||
customThemeUrl: "",
|
||||
customThemeMessage: {isError: false, text: ""},
|
||||
useCustomFontSize: SettingsStore.getValue("useCustomFontSize"),
|
||||
};
|
||||
}
|
||||
|
||||
_calculateThemeState() {
|
||||
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
|
||||
// show the right values for things.
|
||||
|
||||
const themeChoice = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "theme");
|
||||
const systemThemeExplicit = SettingsStore.getValueAt(
|
||||
SettingLevel.DEVICE, "use_system_theme", null, false, true);
|
||||
const themeExplicit = SettingsStore.getValueAt(
|
||||
SettingLevel.DEVICE, "theme", null, false, true);
|
||||
|
||||
// If the user has enabled system theme matching, use that.
|
||||
if (systemThemeExplicit) {
|
||||
return {
|
||||
theme: themeChoice,
|
||||
useSystemTheme: true,
|
||||
};
|
||||
}
|
||||
|
||||
// If the user has set a theme explicitly, use that (no system theme matching)
|
||||
if (themeExplicit) {
|
||||
return {
|
||||
theme: themeChoice,
|
||||
useSystemTheme: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise assume the defaults for the settings
|
||||
return {
|
||||
theme: themeChoice,
|
||||
useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"),
|
||||
};
|
||||
}
|
||||
|
||||
_onThemeChange = (e) => {
|
||||
const newTheme = e.target.value;
|
||||
if (this.state.theme === newTheme) return;
|
||||
|
||||
// doing getValue in the .catch will still return the value we failed to set,
|
||||
// so remember what the value was before we tried to set it so we can revert
|
||||
const oldTheme = SettingsStore.getValue('theme');
|
||||
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme).catch(() => {
|
||||
dis.dispatch({action: 'recheck_theme'});
|
||||
this.setState({theme: oldTheme});
|
||||
});
|
||||
this.setState({theme: newTheme});
|
||||
// The settings watcher doesn't fire until the echo comes back from the
|
||||
// server, so to make the theme change immediately we need to manually
|
||||
// do the dispatch now
|
||||
// XXX: The local echoed value appears to be unreliable, in particular
|
||||
// when settings custom themes(!) so adding forceTheme to override
|
||||
// the value from settings.
|
||||
dis.dispatch({action: 'recheck_theme', forceTheme: newTheme});
|
||||
};
|
||||
|
||||
_onUseSystemThemeChanged = (checked) => {
|
||||
this.setState({useSystemTheme: checked});
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
|
||||
dis.dispatch({action: 'recheck_theme'});
|
||||
};
|
||||
|
||||
_onFontSizeChanged = (size) => {
|
||||
this.setState({fontSize: size});
|
||||
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, size);
|
||||
};
|
||||
|
||||
_onValidateFontSize = ({value}) => {
|
||||
console.log({value});
|
||||
|
||||
const parsedSize = parseFloat(value);
|
||||
const min = FontWatcher.MIN_SIZE;
|
||||
const max = FontWatcher.MAX_SIZE;
|
||||
|
||||
if (isNaN(parsedSize)) {
|
||||
return {valid: false, feedback: _t("Size must be a number")};
|
||||
}
|
||||
|
||||
if (!(min <= parsedSize && parsedSize <= max)) {
|
||||
return {
|
||||
valid: false,
|
||||
feedback: _t('Custom font size can only be between %(min)s pt and %(max)s pt', {min, max}),
|
||||
};
|
||||
}
|
||||
|
||||
SettingsStore.setValue("fontSize", null, SettingLevel.DEVICE, value);
|
||||
return {valid: true, feedback: _t('Use between %(min)s pt and %(max)s pt', {min, max})};
|
||||
}
|
||||
|
||||
_onAddCustomTheme = async () => {
|
||||
let currentThemes = SettingsStore.getValue("custom_themes");
|
||||
if (!currentThemes) currentThemes = [];
|
||||
currentThemes = currentThemes.map(c => c); // cheap clone
|
||||
|
||||
if (this._themeTimer) {
|
||||
clearTimeout(this._themeTimer);
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(this.state.customThemeUrl);
|
||||
const themeInfo = await r.json();
|
||||
if (!themeInfo || typeof(themeInfo['name']) !== 'string' || typeof(themeInfo['colors']) !== 'object') {
|
||||
this.setState({customThemeMessage: {text: _t("Invalid theme schema."), isError: true}});
|
||||
return;
|
||||
}
|
||||
currentThemes.push(themeInfo);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.setState({customThemeMessage: {text: _t("Error downloading theme information."), isError: true}});
|
||||
return; // Don't continue on error
|
||||
}
|
||||
|
||||
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
|
||||
this.setState({customThemeUrl: "", customThemeMessage: {text: _t("Theme added!"), isError: false}});
|
||||
|
||||
this._themeTimer = setTimeout(() => {
|
||||
this.setState({customThemeMessage: {text: "", isError: false}});
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
_onCustomThemeChange = (e) => {
|
||||
this.setState({customThemeUrl: e.target.value});
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="mx_SettingsTab">
|
||||
<div className="mx_SettingsTab_heading">{_t("Appearance")}</div>
|
||||
{this._renderThemeSection()}
|
||||
{SettingsStore.isFeatureEnabled("feature_font_scaling") ? this._renderFontSection() : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_renderThemeSection() {
|
||||
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
|
||||
const LabelledToggleSwitch = sdk.getComponent("views.elements.LabelledToggleSwitch");
|
||||
|
||||
const themeWatcher = new ThemeWatcher();
|
||||
let systemThemeSection;
|
||||
if (themeWatcher.isSystemThemeSupported()) {
|
||||
systemThemeSection = <div>
|
||||
<LabelledToggleSwitch
|
||||
value={this.state.useSystemTheme}
|
||||
label={SettingsStore.getDisplayName("use_system_theme")}
|
||||
onChange={this._onUseSystemThemeChanged}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let customThemeForm;
|
||||
if (SettingsStore.isFeatureEnabled("feature_custom_themes")) {
|
||||
let messageElement = null;
|
||||
if (this.state.customThemeMessage.text) {
|
||||
if (this.state.customThemeMessage.isError) {
|
||||
messageElement = <div className='text-error'>{this.state.customThemeMessage.text}</div>;
|
||||
} else {
|
||||
messageElement = <div className='text-success'>{this.state.customThemeMessage.text}</div>;
|
||||
}
|
||||
}
|
||||
customThemeForm = (
|
||||
<div className='mx_SettingsTab_section'>
|
||||
<form onSubmit={this._onAddCustomTheme}>
|
||||
<Field
|
||||
label={_t("Custom theme URL")}
|
||||
type='text'
|
||||
id='mx_GeneralUserSettingsTab_customThemeInput'
|
||||
autoComplete="off"
|
||||
onChange={this._onCustomThemeChange}
|
||||
value={this.state.customThemeUrl}
|
||||
/>
|
||||
<AccessibleButton
|
||||
onClick={this._onAddCustomTheme}
|
||||
type="submit" kind="primary_sm"
|
||||
disabled={!this.state.customThemeUrl.trim()}
|
||||
>{_t("Add theme")}</AccessibleButton>
|
||||
{messageElement}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const themes = Object.entries(enumerateThemes())
|
||||
.map(p => ({id: p[0], name: p[1]})); // convert pairs to objects for code readability
|
||||
const builtInThemes = themes.filter(p => !p.id.startsWith("custom-"));
|
||||
const customThemes = themes.filter(p => !builtInThemes.includes(p))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const orderedThemes = [...builtInThemes, ...customThemes];
|
||||
return (
|
||||
<div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_themeSection">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
|
||||
{systemThemeSection}
|
||||
<Field
|
||||
id="theme" label={_t("Theme")} element="select"
|
||||
value={this.state.theme} onChange={this._onThemeChange}
|
||||
disabled={this.state.useSystemTheme}
|
||||
>
|
||||
{orderedThemes.map(theme => {
|
||||
return <option key={theme.id} value={theme.id}>{theme.name}</option>;
|
||||
})}
|
||||
</Field>
|
||||
{customThemeForm}
|
||||
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_renderFontSection() {
|
||||
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
|
||||
return <div className="mx_SettingsTab_section mx_AppearanceUserSettingsTab_fontScaling">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Font size")}</span>
|
||||
<div className="mx_AppearanceUserSettingsTab_fontSlider">
|
||||
<div className="mx_AppearanceUserSettingsTab_fontSlider_smallText">Aa</div>
|
||||
<Slider
|
||||
values={[13, 15, 16, 18, 20]}
|
||||
value={this.state.fontSize}
|
||||
onSelectionChange={this._onFontSizeChanged}
|
||||
displayFunc={value => {}}
|
||||
disabled={this.state.useCustomFontSize}
|
||||
/>
|
||||
<div className="mx_AppearanceUserSettingsTab_fontSlider_largeText">Aa</div>
|
||||
</div>
|
||||
<SettingsFlag
|
||||
name="useCustomFontSize"
|
||||
level={SettingLevel.ACCOUNT}
|
||||
onChange={(checked)=> this.setState({useCustomFontSize: checked})}
|
||||
/>
|
||||
<Field
|
||||
type="text"
|
||||
label={_t("Font size")}
|
||||
autoComplete="off"
|
||||
placeholder={this.state.fontSize.toString()}
|
||||
value={this.state.fontSize.toString()}
|
||||
id="font_size_field"
|
||||
onValidate={this._onValidateFontSize}
|
||||
onChange={(value) => this.setState({fontSize: value.target.value})}
|
||||
disabled={!this.state.useCustomFontSize}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
|
@ -19,7 +19,6 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import {_t} from "../../../../../languageHandler";
|
||||
import ProfileSettings from "../../ProfileSettings";
|
||||
import Field from "../../../elements/Field";
|
||||
import * as languageHandler from "../../../../../languageHandler";
|
||||
import {SettingLevel} from "../../../../../settings/SettingsStore";
|
||||
import SettingsStore from "../../../../../settings/SettingsStore";
|
||||
|
@ -27,7 +26,6 @@ import LanguageDropdown from "../../../elements/LanguageDropdown";
|
|||
import AccessibleButton from "../../../elements/AccessibleButton";
|
||||
import DeactivateAccountDialog from "../../../dialogs/DeactivateAccountDialog";
|
||||
import PropTypes from "prop-types";
|
||||
import {enumerateThemes, ThemeWatcher} from "../../../../../theme";
|
||||
import PlatformPeg from "../../../../../PlatformPeg";
|
||||
import {MatrixClientPeg} from "../../../../../MatrixClientPeg";
|
||||
import * as sdk from "../../../../..";
|
||||
|
@ -62,9 +60,6 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
emails: [],
|
||||
msisdns: [],
|
||||
loading3pids: true, // whether or not the emails and msisdns have been loaded
|
||||
...this._calculateThemeState(),
|
||||
customThemeUrl: "",
|
||||
customThemeMessage: {isError: false, text: ""},
|
||||
};
|
||||
|
||||
this.dispatcherRef = dis.register(this._onAction);
|
||||
|
@ -93,39 +88,6 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
_calculateThemeState() {
|
||||
// We have to mirror the logic from ThemeWatcher.getEffectiveTheme so we
|
||||
// show the right values for things.
|
||||
|
||||
const themeChoice = SettingsStore.getValueAt(SettingLevel.ACCOUNT, "theme");
|
||||
const systemThemeExplicit = SettingsStore.getValueAt(
|
||||
SettingLevel.DEVICE, "use_system_theme", null, false, true);
|
||||
const themeExplicit = SettingsStore.getValueAt(
|
||||
SettingLevel.DEVICE, "theme", null, false, true);
|
||||
|
||||
// If the user has enabled system theme matching, use that.
|
||||
if (systemThemeExplicit) {
|
||||
return {
|
||||
theme: themeChoice,
|
||||
useSystemTheme: true,
|
||||
};
|
||||
}
|
||||
|
||||
// If the user has set a theme explicitly, use that (no system theme matching)
|
||||
if (themeExplicit) {
|
||||
return {
|
||||
theme: themeChoice,
|
||||
useSystemTheme: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise assume the defaults for the settings
|
||||
return {
|
||||
theme: themeChoice,
|
||||
useSystemTheme: SettingsStore.getValueAt(SettingLevel.DEVICE, "use_system_theme"),
|
||||
};
|
||||
}
|
||||
|
||||
_onAction = (payload) => {
|
||||
if (payload.action === 'id_server_changed') {
|
||||
this.setState({haveIdServer: Boolean(MatrixClientPeg.get().getIdentityServerUrl())});
|
||||
|
@ -219,33 +181,6 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
PlatformPeg.get().reload();
|
||||
};
|
||||
|
||||
_onThemeChange = (e) => {
|
||||
const newTheme = e.target.value;
|
||||
if (this.state.theme === newTheme) return;
|
||||
|
||||
// doing getValue in the .catch will still return the value we failed to set,
|
||||
// so remember what the value was before we tried to set it so we can revert
|
||||
const oldTheme = SettingsStore.getValue('theme');
|
||||
SettingsStore.setValue("theme", null, SettingLevel.ACCOUNT, newTheme).catch(() => {
|
||||
dis.dispatch({action: 'recheck_theme'});
|
||||
this.setState({theme: oldTheme});
|
||||
});
|
||||
this.setState({theme: newTheme});
|
||||
// The settings watcher doesn't fire until the echo comes back from the
|
||||
// server, so to make the theme change immediately we need to manually
|
||||
// do the dispatch now
|
||||
// XXX: The local echoed value appears to be unreliable, in particular
|
||||
// when settings custom themes(!) so adding forceTheme to override
|
||||
// the value from settings.
|
||||
dis.dispatch({action: 'recheck_theme', forceTheme: newTheme});
|
||||
};
|
||||
|
||||
_onUseSystemThemeChanged = (checked) => {
|
||||
this.setState({useSystemTheme: checked});
|
||||
SettingsStore.setValue("use_system_theme", null, SettingLevel.DEVICE, checked);
|
||||
dis.dispatch({action: 'recheck_theme'});
|
||||
};
|
||||
|
||||
_onPasswordChangeError = (err) => {
|
||||
// TODO: Figure out a design that doesn't involve replacing the current dialog
|
||||
let errMsg = err.error || "";
|
||||
|
@ -282,41 +217,6 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
});
|
||||
};
|
||||
|
||||
_onAddCustomTheme = async () => {
|
||||
let currentThemes = SettingsStore.getValue("custom_themes");
|
||||
if (!currentThemes) currentThemes = [];
|
||||
currentThemes = currentThemes.map(c => c); // cheap clone
|
||||
|
||||
if (this._themeTimer) {
|
||||
clearTimeout(this._themeTimer);
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch(this.state.customThemeUrl);
|
||||
const themeInfo = await r.json();
|
||||
if (!themeInfo || typeof(themeInfo['name']) !== 'string' || typeof(themeInfo['colors']) !== 'object') {
|
||||
this.setState({customThemeMessage: {text: _t("Invalid theme schema."), isError: true}});
|
||||
return;
|
||||
}
|
||||
currentThemes.push(themeInfo);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.setState({customThemeMessage: {text: _t("Error downloading theme information."), isError: true}});
|
||||
return; // Don't continue on error
|
||||
}
|
||||
|
||||
await SettingsStore.setValue("custom_themes", null, SettingLevel.ACCOUNT, currentThemes);
|
||||
this.setState({customThemeUrl: "", customThemeMessage: {text: _t("Theme added!"), isError: false}});
|
||||
|
||||
this._themeTimer = setTimeout(() => {
|
||||
this.setState({customThemeMessage: {text: "", isError: false}});
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
_onCustomThemeChange = (e) => {
|
||||
this.setState({customThemeUrl: e.target.value});
|
||||
};
|
||||
|
||||
_renderProfileSection() {
|
||||
return (
|
||||
<div className="mx_SettingsTab_section">
|
||||
|
@ -401,77 +301,6 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
);
|
||||
}
|
||||
|
||||
_renderThemeSection() {
|
||||
const SettingsFlag = sdk.getComponent("views.elements.SettingsFlag");
|
||||
const LabelledToggleSwitch = sdk.getComponent("views.elements.LabelledToggleSwitch");
|
||||
|
||||
const themeWatcher = new ThemeWatcher();
|
||||
let systemThemeSection;
|
||||
if (themeWatcher.isSystemThemeSupported()) {
|
||||
systemThemeSection = <div>
|
||||
<LabelledToggleSwitch
|
||||
value={this.state.useSystemTheme}
|
||||
label={SettingsStore.getDisplayName("use_system_theme")}
|
||||
onChange={this._onUseSystemThemeChanged}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let customThemeForm;
|
||||
if (SettingsStore.isFeatureEnabled("feature_custom_themes")) {
|
||||
let messageElement = null;
|
||||
if (this.state.customThemeMessage.text) {
|
||||
if (this.state.customThemeMessage.isError) {
|
||||
messageElement = <div className='text-error'>{this.state.customThemeMessage.text}</div>;
|
||||
} else {
|
||||
messageElement = <div className='text-success'>{this.state.customThemeMessage.text}</div>;
|
||||
}
|
||||
}
|
||||
customThemeForm = (
|
||||
<div className='mx_SettingsTab_section'>
|
||||
<form onSubmit={this._onAddCustomTheme}>
|
||||
<Field
|
||||
label={_t("Custom theme URL")}
|
||||
type='text'
|
||||
autoComplete="off"
|
||||
onChange={this._onCustomThemeChange}
|
||||
value={this.state.customThemeUrl}
|
||||
/>
|
||||
<AccessibleButton
|
||||
onClick={this._onAddCustomTheme}
|
||||
type="submit" kind="primary_sm"
|
||||
disabled={!this.state.customThemeUrl.trim()}
|
||||
>{_t("Add theme")}</AccessibleButton>
|
||||
{messageElement}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const themes = Object.entries(enumerateThemes())
|
||||
.map(p => ({id: p[0], name: p[1]})); // convert pairs to objects for code readability
|
||||
const builtInThemes = themes.filter(p => !p.id.startsWith("custom-"));
|
||||
const customThemes = themes.filter(p => !builtInThemes.includes(p))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const orderedThemes = [...builtInThemes, ...customThemes];
|
||||
return (
|
||||
<div className="mx_SettingsTab_section mx_GeneralUserSettingsTab_themeSection">
|
||||
<span className="mx_SettingsTab_subheading">{_t("Theme")}</span>
|
||||
{systemThemeSection}
|
||||
<Field label={_t("Theme")} element="select"
|
||||
value={this.state.theme} onChange={this._onThemeChange}
|
||||
disabled={this.state.useSystemTheme}
|
||||
>
|
||||
{orderedThemes.map(theme => {
|
||||
return <option key={theme.id} value={theme.id}>{theme.name}</option>;
|
||||
})}
|
||||
</Field>
|
||||
{customThemeForm}
|
||||
<SettingsFlag name="useCompactLayout" level={SettingLevel.ACCOUNT} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_renderDiscoverySection() {
|
||||
const SetIdServer = sdk.getComponent("views.settings.SetIdServer");
|
||||
|
||||
|
@ -560,7 +389,6 @@ export default class GeneralUserSettingsTab extends React.Component {
|
|||
{this._renderProfileSection()}
|
||||
{this._renderAccountSection()}
|
||||
{this._renderLanguageSection()}
|
||||
{this._renderThemeSection()}
|
||||
<div className="mx_SettingsTab_heading">{discoWarning} {_t("Discovery")}</div>
|
||||
{this._renderDiscoverySection()}
|
||||
{this._renderIntegrationManagerSection() /* Has its own title */}
|
||||
|
|
|
@ -400,15 +400,20 @@
|
|||
"Sorry, your homeserver is too old to participate in this room.": "Sorry, your homeserver is too old to participate in this room.",
|
||||
"Please contact your homeserver administrator.": "Please contact your homeserver administrator.",
|
||||
"Failed to join room": "Failed to join room",
|
||||
"Font scaling": "Font scaling",
|
||||
"Message Pinning": "Message Pinning",
|
||||
"Custom user status messages": "Custom user status messages",
|
||||
"Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)",
|
||||
"Render simple counters in room header": "Render simple counters in room header",
|
||||
"Multiple integration managers": "Multiple integration managers",
|
||||
"Try out new ways to ignore people (experimental)": "Try out new ways to ignore people (experimental)",
|
||||
"Use the improved room list (in development - refresh to apply changes)": "Use the improved room list (in development - refresh to apply changes)",
|
||||
"Support adding custom themes": "Support adding custom themes",
|
||||
"Use IRC layout": "Use IRC layout",
|
||||
"Enable cross-signing to verify per-user instead of per-session": "Enable cross-signing to verify per-user instead of per-session",
|
||||
"Show info about bridges in room settings": "Show info about bridges in room settings",
|
||||
"Font size": "Font size",
|
||||
"Custom font size": "Custom font size",
|
||||
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
|
||||
"Use compact timeline layout": "Use compact timeline layout",
|
||||
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
|
||||
|
@ -453,6 +458,7 @@
|
|||
"Keep recovery passphrase in memory for this session": "Keep recovery passphrase in memory for this session",
|
||||
"How fast should messages be downloaded.": "How fast should messages be downloaded.",
|
||||
"Manually verify all remote sessions": "Manually verify all remote sessions",
|
||||
"IRC display name width": "IRC display name width",
|
||||
"Collecting app version information": "Collecting app version information",
|
||||
"Collecting logs": "Collecting logs",
|
||||
"Uploading report": "Uploading report",
|
||||
|
@ -746,22 +752,26 @@
|
|||
"Use an Integration Manager to manage bots, widgets, and sticker packs.": "Use an Integration Manager to manage bots, widgets, and sticker packs.",
|
||||
"Manage integrations": "Manage integrations",
|
||||
"Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.": "Integration Managers receive configuration data, and can modify widgets, send room invites, and set power levels on your behalf.",
|
||||
"Size must be a number": "Size must be a number",
|
||||
"Custom font size can only be between %(min)s pt and %(max)s pt": "Custom font size can only be between %(min)s pt and %(max)s pt",
|
||||
"Use between %(min)s pt and %(max)s pt": "Use between %(min)s pt and %(max)s pt",
|
||||
"Invalid theme schema.": "Invalid theme schema.",
|
||||
"Error downloading theme information.": "Error downloading theme information.",
|
||||
"Theme added!": "Theme added!",
|
||||
"Appearance": "Appearance",
|
||||
"Custom theme URL": "Custom theme URL",
|
||||
"Add theme": "Add theme",
|
||||
"Theme": "Theme",
|
||||
"Flair": "Flair",
|
||||
"Failed to change password. Is your password correct?": "Failed to change password. Is your password correct?",
|
||||
"Success": "Success",
|
||||
"Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them": "Your password was successfully changed. You will not receive push notifications on other sessions until you log back in to them",
|
||||
"Invalid theme schema.": "Invalid theme schema.",
|
||||
"Error downloading theme information.": "Error downloading theme information.",
|
||||
"Theme added!": "Theme added!",
|
||||
"Profile": "Profile",
|
||||
"Email addresses": "Email addresses",
|
||||
"Phone numbers": "Phone numbers",
|
||||
"Set a new account password...": "Set a new account password...",
|
||||
"Account": "Account",
|
||||
"Language and region": "Language and region",
|
||||
"Custom theme URL": "Custom theme URL",
|
||||
"Add theme": "Add theme",
|
||||
"Theme": "Theme",
|
||||
"Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.": "Agree to the identity server (%(serverName)s) Terms of Service to allow yourself to be discoverable by email address or phone number.",
|
||||
"Account management": "Account management",
|
||||
"Deactivating your account is a permanent action - be careful!": "Deactivating your account is a permanent action - be careful!",
|
||||
|
@ -1113,6 +1123,7 @@
|
|||
"Direct Messages": "Direct Messages",
|
||||
"Start chat": "Start chat",
|
||||
"Rooms": "Rooms",
|
||||
"Create room": "Create room",
|
||||
"Low priority": "Low priority",
|
||||
"Historical": "Historical",
|
||||
"System Alerts": "System Alerts",
|
||||
|
@ -1159,6 +1170,9 @@
|
|||
"Securely back up your keys to avoid losing them. <a>Learn more.</a>": "Securely back up your keys to avoid losing them. <a>Learn more.</a>",
|
||||
"Not now": "Not now",
|
||||
"Don't ask me again": "Don't ask me again",
|
||||
"Jump to first unread room.": "Jump to first unread room.",
|
||||
"Jump to first invite.": "Jump to first invite.",
|
||||
"Add room": "Add room",
|
||||
"Options": "Options",
|
||||
"%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.",
|
||||
"%(count)s unread messages including mentions.|one": "1 unread mention.",
|
||||
|
@ -2050,9 +2064,6 @@
|
|||
"Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.",
|
||||
"Active call": "Active call",
|
||||
"There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?": "There's no one else here! Would you like to <inviteText>invite others</inviteText> or <nowarnText>stop warning about the empty room</nowarnText>?",
|
||||
"Jump to first unread room.": "Jump to first unread room.",
|
||||
"Jump to first invite.": "Jump to first invite.",
|
||||
"Add room": "Add room",
|
||||
"You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?",
|
||||
"You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?",
|
||||
"Search failed": "Search failed",
|
||||
|
|
|
@ -489,14 +489,20 @@ export default class EventIndex extends EventEmitter {
|
|||
return object;
|
||||
});
|
||||
|
||||
// Create a new checkpoint so we can continue crawling the room for
|
||||
// messages.
|
||||
const newCheckpoint = {
|
||||
roomId: checkpoint.roomId,
|
||||
token: res.end,
|
||||
fullCrawl: checkpoint.fullCrawl,
|
||||
direction: checkpoint.direction,
|
||||
};
|
||||
let newCheckpoint;
|
||||
|
||||
// The token can be null for some reason. Don't create a checkpoint
|
||||
// in that case since adding it to the db will fail.
|
||||
if (res.end) {
|
||||
// Create a new checkpoint so we can continue crawling the room
|
||||
// for messages.
|
||||
newCheckpoint = {
|
||||
roomId: checkpoint.roomId,
|
||||
token: res.end,
|
||||
fullCrawl: checkpoint.fullCrawl,
|
||||
direction: checkpoint.direction,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
for (let i = 0; i < redactionEvents.length; i++) {
|
||||
|
@ -506,6 +512,15 @@ export default class EventIndex extends EventEmitter {
|
|||
|
||||
const eventsAlreadyAdded = await indexManager.addHistoricEvents(
|
||||
events, newCheckpoint, checkpoint);
|
||||
|
||||
// We didn't get a valid new checkpoint from the server, nothing
|
||||
// to do here anymore.
|
||||
if (!newCheckpoint) {
|
||||
console.log("EventIndex: The server didn't return a valid ",
|
||||
"new checkpoint, not continuing the crawl.", checkpoint);
|
||||
continue;
|
||||
}
|
||||
|
||||
// If all events were already indexed we assume that we catched
|
||||
// up with our index and don't need to crawl the room further.
|
||||
// Let us delete the checkpoint in that case, otherwise push
|
||||
|
|
|
@ -133,7 +133,6 @@ export default async function sendBugReport(bugReportEndpoint: string, opts: IOp
|
|||
body.append("cross_signing_supported_by_hs",
|
||||
String(await client.doesServerSupportUnstableFeature("org.matrix.e2e_cross_signing")));
|
||||
body.append("cross_signing_ready", String(await client.isCrossSigningReady()));
|
||||
body.append("ssss_key_needs_upgrade", String(await client.secretStorageKeyNeedsUpgrade()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ import ThemeController from './controllers/ThemeController';
|
|||
import PushToMatrixClientController from './controllers/PushToMatrixClientController';
|
||||
import ReloadOnChangeController from "./controllers/ReloadOnChangeController";
|
||||
import {RIGHT_PANEL_PHASES} from "../stores/RightPanelStorePhases";
|
||||
import FontSizeController from './controllers/FontSizeController';
|
||||
|
||||
// These are just a bunch of helper arrays to avoid copy/pasting a bunch of times
|
||||
const LEVELS_ROOM_SETTINGS = ['device', 'room-device', 'room-account', 'account', 'config'];
|
||||
|
@ -94,6 +95,12 @@ export const SETTINGS = {
|
|||
// // not use this for new settings.
|
||||
// invertedSettingName: "my-negative-setting",
|
||||
// },
|
||||
"feature_font_scaling": {
|
||||
isFeature: true,
|
||||
displayName: _td("Font scaling"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
"feature_pinning": {
|
||||
isFeature: true,
|
||||
displayName: _td("Message Pinning"),
|
||||
|
@ -131,12 +138,24 @@ export const SETTINGS = {
|
|||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
"feature_new_room_list": {
|
||||
isFeature: true,
|
||||
displayName: _td("Use the improved room list (in development - refresh to apply changes)"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
"feature_custom_themes": {
|
||||
isFeature: true,
|
||||
displayName: _td("Support adding custom themes"),
|
||||
supportedLevels: LEVELS_FEATURE,
|
||||
default: false,
|
||||
},
|
||||
"feature_irc_ui": {
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
displayName: _td('Use IRC layout'),
|
||||
default: false,
|
||||
isFeature: true,
|
||||
},
|
||||
"mjolnirRooms": {
|
||||
supportedLevels: ['account'],
|
||||
default: [],
|
||||
|
@ -158,6 +177,17 @@ export const SETTINGS = {
|
|||
displayName: _td("Show info about bridges in room settings"),
|
||||
default: false,
|
||||
},
|
||||
"fontSize": {
|
||||
displayName: _td("Font size"),
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
default: 16,
|
||||
controller: new FontSizeController(),
|
||||
},
|
||||
"useCustomFontSize": {
|
||||
displayName: _td("Custom font size"),
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
default: false,
|
||||
},
|
||||
"MessageComposerInput.suggestEmoji": {
|
||||
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
|
||||
displayName: _td('Enable Emoji suggestions while typing'),
|
||||
|
@ -519,4 +549,11 @@ export const SETTINGS = {
|
|||
MatrixClient.prototype.setCryptoTrustCrossSignedDevices, true,
|
||||
),
|
||||
},
|
||||
"ircDisplayNameWidth": {
|
||||
// We specifically want to have room-device > device so that users may set a device default
|
||||
// with a per-room override.
|
||||
supportedLevels: ['room-device', 'device'],
|
||||
displayName: _td("IRC display name width"),
|
||||
default: 80,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -370,6 +370,21 @@ export default class SettingsStore {
|
|||
return SettingsStore._getFinalValue(setting, level, roomId, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default value of a setting.
|
||||
* @param {string} settingName The name of the setting to read the value of.
|
||||
* @param {String} roomId The room ID to read the setting value in, may be null.
|
||||
* @return {*} The default value
|
||||
*/
|
||||
static getDefaultValue(settingName) {
|
||||
// Verify that the setting is actually a setting
|
||||
if (!SETTINGS[settingName]) {
|
||||
throw new Error("Setting '" + settingName + "' does not appear to be a setting.");
|
||||
}
|
||||
|
||||
return SETTINGS[settingName].default;
|
||||
}
|
||||
|
||||
static _getFinalValue(setting, level, roomId, calculatedValue, calculatedAtLevel) {
|
||||
let resultingValue = calculatedValue;
|
||||
|
||||
|
|
32
src/settings/controllers/FontSizeController.js
Normal file
32
src/settings/controllers/FontSizeController.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
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 SettingController from "./SettingController";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
|
||||
export default class FontSizeController extends SettingController {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
onChange(level, roomId, newValue) {
|
||||
// Dispatch font size change so that everything open responds to the change.
|
||||
dis.dispatch({
|
||||
action: "update-font-size",
|
||||
size: newValue,
|
||||
});
|
||||
}
|
||||
}
|
107
src/stores/AsyncStore.ts
Normal file
107
src/stores/AsyncStore.ts
Normal file
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
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 { EventEmitter } from 'events';
|
||||
import AwaitLock from 'await-lock';
|
||||
import { Dispatcher } from "flux";
|
||||
import { ActionPayload } from "../dispatcher/payloads";
|
||||
|
||||
/**
|
||||
* The event/channel to listen for in an AsyncStore.
|
||||
*/
|
||||
export const UPDATE_EVENT = "update";
|
||||
|
||||
/**
|
||||
* Represents a minimal store which works similar to Flux stores. Instead
|
||||
* of everything needing to happen in a dispatch cycle, everything can
|
||||
* happen async to that cycle.
|
||||
*
|
||||
* The store operates by using Object.assign() to mutate state - it sends the
|
||||
* state objects (current and new) through the function onto a new empty
|
||||
* object. Because of this, it is recommended to break out your state to be as
|
||||
* safe as possible. The state mutations are also locked, preventing concurrent
|
||||
* writes.
|
||||
*
|
||||
* All updates to the store happen on the UPDATE_EVENT event channel with the
|
||||
* one argument being the instance of the store.
|
||||
*
|
||||
* To update the state, use updateState() and preferably await the result to
|
||||
* help prevent lock conflicts.
|
||||
*/
|
||||
export abstract class AsyncStore<T extends Object> extends EventEmitter {
|
||||
private storeState: T = <T>{};
|
||||
private lock = new AwaitLock();
|
||||
private readonly dispatcherRef: string;
|
||||
|
||||
/**
|
||||
* Creates a new AsyncStore using the given dispatcher.
|
||||
* @param {Dispatcher<ActionPayload>} dispatcher The dispatcher to rely upon.
|
||||
*/
|
||||
protected constructor(private dispatcher: Dispatcher<ActionPayload>) {
|
||||
super();
|
||||
|
||||
this.dispatcherRef = dispatcher.register(this.onDispatch.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of the store. Cannot be mutated.
|
||||
*/
|
||||
protected get state(): T {
|
||||
return Object.freeze(this.storeState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the store's listening functions, such as the listener to the dispatcher.
|
||||
*/
|
||||
protected stop() {
|
||||
if (this.dispatcherRef) this.dispatcher.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the state of the store.
|
||||
* @param {T|*} newState The state to update in the store using Object.assign()
|
||||
*/
|
||||
protected async updateState(newState: T | Object) {
|
||||
await this.lock.acquireAsync();
|
||||
try {
|
||||
this.storeState = Object.assign(<T>{}, this.storeState, newState);
|
||||
this.emit(UPDATE_EVENT, this);
|
||||
} finally {
|
||||
await this.lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the store's to the provided state or an empty object.
|
||||
* @param {T|*} newState The new state of the store.
|
||||
* @param {boolean} quiet If true, the function will not raise an UPDATE_EVENT.
|
||||
*/
|
||||
protected async reset(newState: T | Object = null, quiet = false) {
|
||||
await this.lock.acquireAsync();
|
||||
try {
|
||||
this.storeState = <T>(newState || {});
|
||||
if (!quiet) this.emit(UPDATE_EVENT, this);
|
||||
} finally {
|
||||
await this.lock.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the dispatcher broadcasts a dispatch event.
|
||||
* @param {ActionPayload} payload The event being dispatched.
|
||||
*/
|
||||
protected abstract onDispatch(payload: ActionPayload);
|
||||
}
|
|
@ -15,10 +15,10 @@ limitations under the License.
|
|||
*/
|
||||
import dis from '../dispatcher/dispatcher';
|
||||
import * as RoomNotifs from '../RoomNotifs';
|
||||
import RoomListStore from './RoomListStore';
|
||||
import EventEmitter from 'events';
|
||||
import { throttle } from "lodash";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import {RoomListStoreTempProxy} from "./room-list/RoomListStoreTempProxy";
|
||||
|
||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
||||
|
||||
|
@ -60,7 +60,7 @@ class CustomRoomTagStore extends EventEmitter {
|
|||
trailing: true,
|
||||
},
|
||||
);
|
||||
this._roomListStoreToken = RoomListStore.addListener(() => {
|
||||
this._roomListStoreToken = RoomListStoreTempProxy.addListener(() => {
|
||||
this._setState({tags: this._getUpdatedTags()});
|
||||
});
|
||||
dis.register(payload => this._onDispatch(payload));
|
||||
|
@ -85,7 +85,7 @@ class CustomRoomTagStore extends EventEmitter {
|
|||
}
|
||||
|
||||
getSortedTags() {
|
||||
const roomLists = RoomListStore.getRoomLists();
|
||||
const roomLists = RoomListStoreTempProxy.getRoomLists();
|
||||
|
||||
const tagNames = Object.keys(this._state.tags).sort();
|
||||
const prefixes = tagNames.map((name, i) => {
|
||||
|
@ -140,7 +140,7 @@ class CustomRoomTagStore extends EventEmitter {
|
|||
return;
|
||||
}
|
||||
|
||||
const newTagNames = Object.keys(RoomListStore.getRoomLists())
|
||||
const newTagNames = Object.keys(RoomListStoreTempProxy.getRoomLists())
|
||||
.filter((tagName) => {
|
||||
return !tagName.match(STANDARD_TAGS_REGEX);
|
||||
}).sort();
|
||||
|
|
|
@ -92,11 +92,19 @@ class RoomListStore extends Store {
|
|||
constructor() {
|
||||
super(dis);
|
||||
|
||||
this._checkDisabled();
|
||||
this._init();
|
||||
this._getManualComparator = this._getManualComparator.bind(this);
|
||||
this._recentsComparator = this._recentsComparator.bind(this);
|
||||
}
|
||||
|
||||
_checkDisabled() {
|
||||
this.disabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
|
||||
if (this.disabled) {
|
||||
console.warn("👋 legacy room list store has been disabled");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the sorting algorithm used by the RoomListStore.
|
||||
* @param {string} algorithm The new algorithm to use. Should be one of the ALGO_* constants.
|
||||
|
@ -113,6 +121,8 @@ class RoomListStore extends Store {
|
|||
}
|
||||
|
||||
_init() {
|
||||
if (this.disabled) return;
|
||||
|
||||
// Initialise state
|
||||
const defaultLists = {
|
||||
"m.server_notice": [/* { room: js-sdk room, category: string } */],
|
||||
|
@ -140,6 +150,8 @@ class RoomListStore extends Store {
|
|||
}
|
||||
|
||||
_setState(newState) {
|
||||
if (this.disabled) return;
|
||||
|
||||
// If we're changing the lists, transparently change the presentation lists (which
|
||||
// is given to requesting components). This dramatically simplifies our code elsewhere
|
||||
// while also ensuring we don't need to update all the calling components to support
|
||||
|
@ -156,6 +168,8 @@ class RoomListStore extends Store {
|
|||
}
|
||||
|
||||
__onDispatch(payload) {
|
||||
if (this.disabled) return;
|
||||
|
||||
const logicallyReady = this._matrixClient && this._state.ready;
|
||||
switch (payload.action) {
|
||||
case 'setting_updated': {
|
||||
|
@ -182,6 +196,9 @@ class RoomListStore extends Store {
|
|||
break;
|
||||
}
|
||||
|
||||
this._checkDisabled();
|
||||
if (this.disabled) return;
|
||||
|
||||
// Always ensure that we set any state needed for settings here. It is possible that
|
||||
// setting updates trigger on startup before we are ready to sync, so we want to make
|
||||
// sure that the right state is in place before we actually react to those changes.
|
||||
|
|
125
src/stores/room-list/README.md
Normal file
125
src/stores/room-list/README.md
Normal file
|
@ -0,0 +1,125 @@
|
|||
# Room list sorting
|
||||
|
||||
It's so complicated it needs its own README.
|
||||
|
||||
## Algorithms involved
|
||||
|
||||
There's two main kinds of algorithms involved in the room list store: list ordering and tag sorting.
|
||||
Throughout the code an intentional decision has been made to call them the List Algorithm and Sorting
|
||||
Algorithm respectively. The list algorithm determines the behaviour of the room list whereas the sorting
|
||||
algorithm determines how rooms get ordered within tags affected by the list algorithm.
|
||||
|
||||
Behaviour of the room list takes the shape of determining what features the room list supports, as well
|
||||
as determining where and when to apply the sorting algorithm in a tag. The importance algorithm, which
|
||||
is described later in this doc, is an example of an algorithm which makes heavy behavioural changes
|
||||
to the room list.
|
||||
|
||||
Tag sorting is effectively the comparator supplied to the list algorithm. This gives the list algorithm
|
||||
the power to decide when and how to apply the tag sorting, if at all.
|
||||
|
||||
### Tag sorting algorithm: Alphabetical
|
||||
|
||||
When used, rooms in a given tag will be sorted alphabetically, where the alphabet's order is a problem
|
||||
for the browser. All we do is a simple string comparison and expect the browser to return something
|
||||
useful.
|
||||
|
||||
### Tag sorting algorithm: Manual
|
||||
|
||||
Manual sorting makes use of the `order` property present on all tags for a room, per the
|
||||
[Matrix specification](https://matrix.org/docs/spec/client_server/r0.6.0#room-tagging). Smaller values
|
||||
of `order` cause rooms to appear closer to the top of the list.
|
||||
|
||||
### Tag sorting algorithm: Recent
|
||||
|
||||
Rooms get ordered by the timestamp of the most recent useful message. Usefulness is yet another algorithm
|
||||
in the room list system which determines whether an event type is capable of bubbling up in the room list.
|
||||
Normally events like room messages, stickers, and room security changes will be considered useful enough
|
||||
to cause a shift in time.
|
||||
|
||||
Note that this is reliant on the event timestamps of the most recent message. Because Matrix is eventually
|
||||
consistent this means that from time to time a room might plummet or skyrocket across the tag due to the
|
||||
timestamp contained within the event (generated server-side by the sender's server).
|
||||
|
||||
### List ordering algorithm: Natural
|
||||
|
||||
This is the easiest of the algorithms to understand because it does essentially nothing. It imposes no
|
||||
behavioural changes over the tag sorting algorithm and is by far the simplest way to order a room list.
|
||||
Historically, it's been the only option in Riot and extremely common in most chat applications due to
|
||||
its relative deterministic behaviour.
|
||||
|
||||
### List ordering algorithm: Importance
|
||||
|
||||
On the other end of the spectrum, this is the most complicated algorithm which exists. There's major
|
||||
behavioural changes, and the tag sorting algorithm gets selectively applied depending on circumstances.
|
||||
|
||||
Each tag which is not manually ordered gets split into 4 sections or "categories". Manually ordered tags
|
||||
simply get the manual sorting algorithm applied to them with no further involvement from the importance
|
||||
algorithm. There are 4 categories: Red, Grey, Bold, and Idle. Each has their own definition based off
|
||||
relative (perceived) importance to the user:
|
||||
|
||||
* **Red**: The room has unread mentions waiting for the user.
|
||||
* **Grey**: The room has unread notifications waiting for the user. Notifications are simply unread
|
||||
messages which cause a push notification or badge count. Typically, this is the default as rooms get
|
||||
set to 'All Messages'.
|
||||
* **Bold**: The room has unread messages waiting for the user. Essentially this is a grey room without
|
||||
a badge/notification count (or 'Mentions Only'/'Muted').
|
||||
* **Idle**: No useful (see definition of useful above) activity has occurred in the room since the user
|
||||
last read it.
|
||||
|
||||
Conveniently, each tag gets ordered by those categories as presented: red rooms appear above grey, grey
|
||||
above bold, etc.
|
||||
|
||||
Once the algorithm has determined which rooms belong in which categories, the tag sorting algorithm
|
||||
gets applied to each category in a sub-sub-list fashion. This should result in the red rooms (for example)
|
||||
being sorted alphabetically amongst each other as well as the grey rooms sorted amongst each other, but
|
||||
collectively the tag will be sorted into categories with red being at the top.
|
||||
|
||||
<!-- TODO: Implement sticky rooms as described below -->
|
||||
|
||||
The algorithm also has a concept of a 'sticky' room which is the room the user is currently viewing.
|
||||
The sticky room will remain in position on the room list regardless of other factors going on as typically
|
||||
clicking on a room will cause it to change categories into 'idle'. This is done by preserving N rooms
|
||||
above the selected room at all times, where N is the number of rooms above the selected rooms when it was
|
||||
selected.
|
||||
|
||||
For example, if the user has 3 red rooms and selects the middle room, they will always see exactly one
|
||||
room above their selection at all times. If they receive another notification, and the tag ordering is
|
||||
specified as Recent, they'll see the new notification go to the top position, and the one that was previously
|
||||
there fall behind the sticky room.
|
||||
|
||||
The sticky room's category is technically 'idle' while being viewed and is explicitly pulled out of the
|
||||
tag sorting algorithm's input as it must maintain its position in the list. When the user moves to another
|
||||
room, the previous sticky room gets recalculated to determine which category it needs to be in as the user
|
||||
could have been scrolled up while new messages were received.
|
||||
|
||||
Further, the sticky room is not aware of category boundaries and thus the user can see a shift in what
|
||||
kinds of rooms move around their selection. An example would be the user having 4 red rooms, the user
|
||||
selecting the third room (leaving 2 above it), and then having the rooms above it read on another device.
|
||||
This would result in 1 red room and 1 other kind of room above the sticky room as it will try to maintain
|
||||
2 rooms above the sticky room.
|
||||
|
||||
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
|
||||
the N required rooms above the sticky room. In this case, the sticky room will simply decrease N as needed.
|
||||
The N value will never increase while selection remains unchanged: adding a bunch of rooms after having
|
||||
put the sticky room in a position where it's had to decrease N will not increase N.
|
||||
|
||||
## Responsibilities of the store
|
||||
|
||||
The store is responsible for the ordering, upkeep, and tracking of all rooms. The room list component simply gets
|
||||
an object containing the tags it needs to worry about and the rooms within. The room list component will
|
||||
decide which tags need rendering (as it commonly filters out empty tags in most cases), and will deal with
|
||||
all kinds of filtering.
|
||||
|
||||
## Class breakdowns
|
||||
|
||||
The `RoomListStore` is the major coordinator of various `Algorithm` implementations, which take care
|
||||
of the various `ListAlgorithm` and `SortingAlgorithm` options. The `Algorithm` superclass is also
|
||||
responsible for figuring out which tags get which rooms, as Matrix specifies them as a reverse map:
|
||||
tags get defined on rooms and are not defined as a collection of rooms (unlike how they are presented
|
||||
to the user). Various list-specific utilities are also included, though they are expected to move
|
||||
somewhere more general when needed. For example, the `membership` utilities could easily be moved
|
||||
elsewhere as needed.
|
||||
|
||||
The various bits throughout the room list store should also have jsdoc of some kind to help describe
|
||||
what they do and how they work.
|
253
src/stores/room-list/RoomListStore2.ts
Normal file
253
src/stores/room-list/RoomListStore2.ts
Normal file
|
@ -0,0 +1,253 @@
|
|||
/*
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
|
||||
import { Algorithm } from "./algorithms/list-ordering/Algorithm";
|
||||
import TagOrderStore from "../TagOrderStore";
|
||||
import { AsyncStore } from "../AsyncStore";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
|
||||
import { getListAlgorithmInstance } from "./algorithms/list-ordering";
|
||||
import { ActionPayload } from "../../dispatcher/payloads";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
|
||||
interface IState {
|
||||
tagsEnabled?: boolean;
|
||||
|
||||
preferredSort?: SortAlgorithm;
|
||||
preferredAlgorithm?: ListAlgorithm;
|
||||
}
|
||||
|
||||
/**
|
||||
* The event/channel which is called when the room lists have been changed. Raised
|
||||
* with one argument: the instance of the store.
|
||||
*/
|
||||
export const LISTS_UPDATE_EVENT = "lists_update";
|
||||
|
||||
class _RoomListStore extends AsyncStore<ActionPayload> {
|
||||
private matrixClient: MatrixClient;
|
||||
private initialListsGenerated = false;
|
||||
private enabled = false;
|
||||
private algorithm: Algorithm;
|
||||
|
||||
private readonly watchedSettings = [
|
||||
'RoomList.orderAlphabetically',
|
||||
'RoomList.orderByImportance',
|
||||
'feature_custom_tags',
|
||||
];
|
||||
|
||||
constructor() {
|
||||
super(defaultDispatcher);
|
||||
|
||||
this.checkEnabled();
|
||||
for (const settingName of this.watchedSettings) SettingsStore.monitorSetting(settingName, null);
|
||||
}
|
||||
|
||||
public get orderedLists(): ITagMap {
|
||||
if (!this.algorithm) return {}; // No tags yet.
|
||||
return this.algorithm.getOrderedRooms();
|
||||
}
|
||||
|
||||
// TODO: Remove enabled flag when the old RoomListStore goes away
|
||||
private checkEnabled() {
|
||||
this.enabled = SettingsStore.isFeatureEnabled("feature_new_room_list");
|
||||
if (this.enabled) {
|
||||
console.log("⚡ new room list store engaged");
|
||||
}
|
||||
}
|
||||
|
||||
private async readAndCacheSettingsFromStore() {
|
||||
const tagsEnabled = SettingsStore.isFeatureEnabled("feature_custom_tags");
|
||||
const orderByImportance = SettingsStore.getValue("RoomList.orderByImportance");
|
||||
const orderAlphabetically = SettingsStore.getValue("RoomList.orderAlphabetically");
|
||||
await this.updateState({
|
||||
tagsEnabled,
|
||||
preferredSort: orderAlphabetically ? SortAlgorithm.Alphabetic : SortAlgorithm.Recent,
|
||||
preferredAlgorithm: orderByImportance ? ListAlgorithm.Importance : ListAlgorithm.Natural,
|
||||
});
|
||||
this.setAlgorithmClass();
|
||||
}
|
||||
|
||||
protected async onDispatch(payload: ActionPayload) {
|
||||
if (payload.action === 'MatrixActions.sync') {
|
||||
// Filter out anything that isn't the first PREPARED sync.
|
||||
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Remove this once the RoomListStore becomes default
|
||||
this.checkEnabled();
|
||||
if (!this.enabled) return;
|
||||
|
||||
this.matrixClient = payload.matrixClient;
|
||||
|
||||
// Update any settings here, as some may have happened before we were logically ready.
|
||||
console.log("Regenerating room lists: Startup");
|
||||
await this.readAndCacheSettingsFromStore();
|
||||
await this.regenerateAllLists();
|
||||
}
|
||||
|
||||
// TODO: Remove this once the RoomListStore becomes default
|
||||
if (!this.enabled) return;
|
||||
|
||||
if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
|
||||
// Reset state without causing updates as the client will have been destroyed
|
||||
// and downstream code will throw NPE errors.
|
||||
this.reset(null, true);
|
||||
this.matrixClient = null;
|
||||
this.initialListsGenerated = false; // we'll want to regenerate them
|
||||
}
|
||||
|
||||
// Everything below here requires a MatrixClient or some sort of logical readiness.
|
||||
const logicallyReady = this.matrixClient && this.initialListsGenerated;
|
||||
if (!logicallyReady) return;
|
||||
|
||||
if (payload.action === 'setting_updated') {
|
||||
if (this.watchedSettings.includes(payload.settingName)) {
|
||||
console.log("Regenerating room lists: Settings changed");
|
||||
await this.readAndCacheSettingsFromStore();
|
||||
|
||||
await this.regenerateAllLists(); // regenerate the lists now
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.algorithm) {
|
||||
// This shouldn't happen because `initialListsGenerated` implies we have an algorithm.
|
||||
throw new Error("Room list store has no algorithm to process dispatcher update with");
|
||||
}
|
||||
|
||||
if (payload.action === 'MatrixActions.Room.receipt') {
|
||||
// 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).
|
||||
// noinspection JSObjectNullOrUndefined - this.matrixClient can't be null by this point in the lifecycle
|
||||
const myUserId = this.matrixClient.getUserId();
|
||||
for (const eventId of Object.keys(payload.event.getContent())) {
|
||||
const receiptUsers = Object.keys(payload.event.getContent()[eventId]['m.read'] || {});
|
||||
if (receiptUsers.includes(myUserId)) {
|
||||
// TODO: Update room now that it's been read
|
||||
console.log(payload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (payload.action === 'MatrixActions.Room.tags') {
|
||||
// TODO: Update room from tags
|
||||
console.log(payload);
|
||||
} else if (payload.action === 'MatrixActions.Room.timeline') {
|
||||
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||
|
||||
// Ignore non-live events (backfill)
|
||||
if (!eventPayload.isLiveEvent || !payload.isLiveUnfilteredRoomTimelineEvent) return;
|
||||
|
||||
const roomId = eventPayload.event.getRoomId();
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${roomId}`);
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
||||
} else if (payload.action === 'MatrixActions.Event.decrypted') {
|
||||
const eventPayload = (<any>payload); // TODO: Type out the dispatcher types
|
||||
const roomId = eventPayload.event.getRoomId();
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
if (!room) {
|
||||
console.warn(`Event ${eventPayload.event.getId()} was decrypted in an unknown room ${roomId}`);
|
||||
return;
|
||||
}
|
||||
console.log(`[RoomListDebug] Decrypted timeline event ${eventPayload.event.getId()} in ${roomId}`);
|
||||
// TODO: Check that e2e rooms are calculated correctly on initial load.
|
||||
// It seems like when viewing the room the timeline is decrypted, rather than at startup. This could
|
||||
// cause inaccuracies with the list ordering. We may have to decrypt the last N messages of every room :(
|
||||
await this.handleRoomUpdate(room, RoomUpdateCause.Timeline);
|
||||
} else if (payload.action === 'MatrixActions.accountData' && payload.event_type === 'm.direct') {
|
||||
// TODO: Update DMs
|
||||
console.log(payload);
|
||||
} else if (payload.action === 'MatrixActions.Room.myMembership') {
|
||||
// TODO: Update room from membership change
|
||||
console.log(payload);
|
||||
} else if (payload.action === 'MatrixActions.Room') {
|
||||
// TODO: Update room from creation/join
|
||||
console.log(payload);
|
||||
} else if (payload.action === 'view_room') {
|
||||
// TODO: Update sticky room
|
||||
console.log(payload);
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<any> {
|
||||
const shouldUpdate = await this.algorithm.handleRoomUpdate(room, cause);
|
||||
if (shouldUpdate) {
|
||||
console.log(`[DEBUG] Room "${room.name}" (${room.roomId}) triggered by ${cause} requires list update`);
|
||||
this.emit(LISTS_UPDATE_EVENT, this);
|
||||
}
|
||||
}
|
||||
|
||||
private getSortAlgorithmFor(tagId: TagID): SortAlgorithm {
|
||||
switch (tagId) {
|
||||
case DefaultTagID.Invite:
|
||||
case DefaultTagID.Untagged:
|
||||
case DefaultTagID.Archived:
|
||||
case DefaultTagID.LowPriority:
|
||||
case DefaultTagID.DM:
|
||||
return this.state.preferredSort;
|
||||
case DefaultTagID.Favourite:
|
||||
default:
|
||||
return SortAlgorithm.Manual;
|
||||
}
|
||||
}
|
||||
|
||||
protected async updateState(newState: IState) {
|
||||
if (!this.enabled) return;
|
||||
|
||||
await super.updateState(newState);
|
||||
}
|
||||
|
||||
private setAlgorithmClass() {
|
||||
this.algorithm = getListAlgorithmInstance(this.state.preferredAlgorithm);
|
||||
}
|
||||
|
||||
private async regenerateAllLists() {
|
||||
console.warn("Regenerating all room lists");
|
||||
const tags: ITagSortingMap = {};
|
||||
for (const tagId of OrderedDefaultTagIDs) {
|
||||
tags[tagId] = this.getSortAlgorithmFor(tagId);
|
||||
}
|
||||
|
||||
if (this.state.tagsEnabled) {
|
||||
// TODO: Find a more reliable way to get tags (this doesn't work)
|
||||
const roomTags = TagOrderStore.getOrderedTags() || [];
|
||||
console.log("rtags", roomTags);
|
||||
}
|
||||
|
||||
await this.algorithm.populateTags(tags);
|
||||
await this.algorithm.setKnownRooms(this.matrixClient.getRooms());
|
||||
|
||||
this.initialListsGenerated = true;
|
||||
|
||||
this.emit(LISTS_UPDATE_EVENT, this);
|
||||
}
|
||||
}
|
||||
|
||||
export default class RoomListStore {
|
||||
private static internalInstance: _RoomListStore;
|
||||
|
||||
public static get instance(): _RoomListStore {
|
||||
if (!RoomListStore.internalInstance) {
|
||||
RoomListStore.internalInstance = new _RoomListStore();
|
||||
}
|
||||
|
||||
return RoomListStore.internalInstance;
|
||||
}
|
||||
}
|
49
src/stores/room-list/RoomListStoreTempProxy.ts
Normal file
49
src/stores/room-list/RoomListStoreTempProxy.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
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 SettingsStore from "../../settings/SettingsStore";
|
||||
import RoomListStore from "./RoomListStore2";
|
||||
import OldRoomListStore from "../RoomListStore";
|
||||
import { UPDATE_EVENT } from "../AsyncStore";
|
||||
import { ITagMap } from "./algorithms/models";
|
||||
|
||||
/**
|
||||
* Temporary RoomListStore proxy. Should be replaced with RoomListStore2 when
|
||||
* it is available to everyone.
|
||||
*
|
||||
* TODO: Remove this when RoomListStore gets fully replaced.
|
||||
*/
|
||||
export class RoomListStoreTempProxy {
|
||||
public static isUsingNewStore(): boolean {
|
||||
return SettingsStore.isFeatureEnabled("feature_new_room_list");
|
||||
}
|
||||
|
||||
public static addListener(handler: () => void) {
|
||||
if (RoomListStoreTempProxy.isUsingNewStore()) {
|
||||
return RoomListStore.instance.on(UPDATE_EVENT, handler);
|
||||
} else {
|
||||
return OldRoomListStore.addListener(handler);
|
||||
}
|
||||
}
|
||||
|
||||
public static getRoomLists(): ITagMap {
|
||||
if (RoomListStoreTempProxy.isUsingNewStore()) {
|
||||
return RoomListStore.instance.orderedLists;
|
||||
} else {
|
||||
return OldRoomListStore.getRoomLists();
|
||||
}
|
||||
}
|
||||
}
|
177
src/stores/room-list/algorithms/list-ordering/Algorithm.ts
Normal file
177
src/stores/room-list/algorithms/list-ordering/Algorithm.ts
Normal file
|
@ -0,0 +1,177 @@
|
|||
/*
|
||||
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 { DefaultTagID, RoomUpdateCause, TagID } from "../../models";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { isNullOrUndefined } from "matrix-js-sdk/src/utils";
|
||||
import { EffectiveMembership, splitRoomsByMembership } from "../../membership";
|
||||
import { ITagMap, ITagSortingMap } from "../models";
|
||||
import DMRoomMap from "../../../../utils/DMRoomMap";
|
||||
|
||||
// TODO: Add locking support to avoid concurrent writes?
|
||||
// TODO: EventEmitter support? Might not be needed.
|
||||
|
||||
/**
|
||||
* Represents a list ordering algorithm. This class will take care of tag
|
||||
* management (which rooms go in which tags) and ask the implementation to
|
||||
* deal with ordering mechanics.
|
||||
*/
|
||||
export abstract class Algorithm {
|
||||
protected cached: ITagMap = {};
|
||||
protected sortAlgorithms: ITagSortingMap;
|
||||
protected rooms: Room[] = [];
|
||||
protected roomIdsToTags: {
|
||||
[roomId: string]: TagID[];
|
||||
} = {};
|
||||
|
||||
protected constructor() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the Algorithm to regenerate all lists, using the tags given
|
||||
* as reference for which lists to generate and which way to generate
|
||||
* them.
|
||||
* @param {ITagSortingMap} tagSortingMap The tags to generate.
|
||||
* @returns {Promise<*>} A promise which resolves when complete.
|
||||
*/
|
||||
public async populateTags(tagSortingMap: ITagSortingMap): Promise<any> {
|
||||
if (!tagSortingMap) throw new Error(`Map cannot be null or empty`);
|
||||
this.sortAlgorithms = tagSortingMap;
|
||||
return this.setKnownRooms(this.rooms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an ordered set of rooms for the all known tags.
|
||||
* @returns {ITagMap} The cached list of rooms, ordered,
|
||||
* for each tag. May be empty, but never null/undefined.
|
||||
*/
|
||||
public getOrderedRooms(): ITagMap {
|
||||
return this.cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seeds the Algorithm with a set of rooms. The algorithm will discard all
|
||||
* previously known information and instead use these rooms instead.
|
||||
* @param {Room[]} rooms The rooms to force the algorithm to use.
|
||||
* @returns {Promise<*>} A promise which resolves when complete.
|
||||
*/
|
||||
public async setKnownRooms(rooms: Room[]): Promise<any> {
|
||||
if (isNullOrUndefined(rooms)) throw new Error(`Array of rooms cannot be null`);
|
||||
if (!this.sortAlgorithms) throw new Error(`Cannot set known rooms without a tag sorting map`);
|
||||
|
||||
this.rooms = rooms;
|
||||
|
||||
const newTags: ITagMap = {};
|
||||
for (const tagId in this.sortAlgorithms) {
|
||||
// noinspection JSUnfilteredForInLoop
|
||||
newTags[tagId] = [];
|
||||
}
|
||||
|
||||
// If we can avoid doing work, do so.
|
||||
if (!rooms.length) {
|
||||
await this.generateFreshTags(newTags); // just in case it wants to do something
|
||||
this.cached = newTags;
|
||||
return;
|
||||
}
|
||||
|
||||
// Split out the easy rooms first (leave and invite)
|
||||
const memberships = splitRoomsByMembership(rooms);
|
||||
for (const room of memberships[EffectiveMembership.Invite]) {
|
||||
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is an Invite`);
|
||||
newTags[DefaultTagID.Invite].push(room);
|
||||
}
|
||||
for (const room of memberships[EffectiveMembership.Leave]) {
|
||||
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Historical`);
|
||||
newTags[DefaultTagID.Archived].push(room);
|
||||
}
|
||||
|
||||
// Now process all the joined rooms. This is a bit more complicated
|
||||
for (const room of memberships[EffectiveMembership.Join]) {
|
||||
let tags = Object.keys(room.tags || {});
|
||||
|
||||
if (tags.length === 0) {
|
||||
// Check to see if it's a DM if it isn't anything else
|
||||
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
|
||||
tags = [DefaultTagID.DM];
|
||||
}
|
||||
}
|
||||
|
||||
let inTag = false;
|
||||
if (tags.length > 0) {
|
||||
for (const tag of tags) {
|
||||
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged as ${tag}`);
|
||||
if (!isNullOrUndefined(newTags[tag])) {
|
||||
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is tagged with VALID tag ${tag}`);
|
||||
newTags[tag].push(room);
|
||||
inTag = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!inTag) {
|
||||
// TODO: Determine if DM and push there instead
|
||||
newTags[DefaultTagID.Untagged].push(room);
|
||||
console.log(`[DEBUG] "${room.name}" (${room.roomId}) is Untagged`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.generateFreshTags(newTags);
|
||||
|
||||
this.cached = newTags;
|
||||
this.updateTagsFromCache();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the roomsToTags map
|
||||
*/
|
||||
protected updateTagsFromCache() {
|
||||
const newMap = {};
|
||||
|
||||
const tags = Object.keys(this.cached);
|
||||
for (const tagId of tags) {
|
||||
const rooms = this.cached[tagId];
|
||||
for (const room of rooms) {
|
||||
if (!newMap[room.roomId]) newMap[room.roomId] = [];
|
||||
newMap[room.roomId].push(tagId);
|
||||
}
|
||||
}
|
||||
|
||||
this.roomIdsToTags = newMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the Algorithm believes a complete regeneration of the existing
|
||||
* lists is needed.
|
||||
* @param {ITagMap} updatedTagMap The tag map which needs populating. Each tag
|
||||
* will already have the rooms which belong to it - they just need ordering. Must
|
||||
* be mutated in place.
|
||||
* @returns {Promise<*>} A promise which resolves when complete.
|
||||
*/
|
||||
protected abstract generateFreshTags(updatedTagMap: ITagMap): Promise<any>;
|
||||
|
||||
/**
|
||||
* Asks the Algorithm to update its knowledge of a room. For example, when
|
||||
* a user tags a room, joins/creates a room, or leaves a room the Algorithm
|
||||
* should be told that the room's info might have changed. The Algorithm
|
||||
* may no-op this request if no changes are required.
|
||||
* @param {Room} room The room which might have affected sorting.
|
||||
* @param {RoomUpdateCause} cause The reason for the update being triggered.
|
||||
* @returns {Promise<boolean>} A promise which resolve to true or false
|
||||
* depending on whether or not getOrderedRooms() should be called after
|
||||
* processing.
|
||||
*/
|
||||
public abstract handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean>;
|
||||
}
|
|
@ -0,0 +1,298 @@
|
|||
/*
|
||||
Copyright 2018, 2019 New Vector Ltd
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Algorithm } from "./Algorithm";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RoomUpdateCause, TagID } from "../../models";
|
||||
import { ITagMap, SortAlgorithm } from "../models";
|
||||
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
||||
import * as Unread from '../../../../Unread';
|
||||
|
||||
/**
|
||||
* The determined category of a room.
|
||||
*/
|
||||
export enum Category {
|
||||
/**
|
||||
* The room has unread mentions within.
|
||||
*/
|
||||
Red = "RED",
|
||||
/**
|
||||
* The room has unread notifications within. Note that these are not unread
|
||||
* mentions - they are simply messages which the user has asked to cause a
|
||||
* badge count update or push notification.
|
||||
*/
|
||||
Grey = "GREY",
|
||||
/**
|
||||
* The room has unread messages within (grey without the badge).
|
||||
*/
|
||||
Bold = "BOLD",
|
||||
/**
|
||||
* The room has no relevant unread messages within.
|
||||
*/
|
||||
Idle = "IDLE",
|
||||
}
|
||||
|
||||
interface ICategorizedRoomMap {
|
||||
// @ts-ignore - TS wants this to be a string, but we know better
|
||||
[category: Category]: Room[];
|
||||
}
|
||||
|
||||
interface ICategoryIndex {
|
||||
// @ts-ignore - TS wants this to be a string, but we know better
|
||||
[category: Category]: number; // integer
|
||||
}
|
||||
|
||||
// Caution: changing this means you'll need to update a bunch of assumptions and
|
||||
// comments! Check the usage of Category carefully to figure out what needs changing
|
||||
// if you're going to change this array's order.
|
||||
const CATEGORY_ORDER = [Category.Red, Category.Grey, Category.Bold, Category.Idle];
|
||||
|
||||
/**
|
||||
* An implementation of the "importance" algorithm for room list sorting. Where
|
||||
* the tag sorting algorithm does not interfere, rooms will be ordered into
|
||||
* categories of varying importance to the user. Alphabetical sorting does not
|
||||
* interfere with this algorithm, however manual ordering does.
|
||||
*
|
||||
* The importance of a room is defined by the kind of notifications, if any, are
|
||||
* present on the room. These are classified internally as Red, Grey, Bold, and
|
||||
* Idle. Red rooms have mentions, grey have unread messages, bold is a less noisy
|
||||
* version of grey, and idle means all activity has been seen by the user.
|
||||
*
|
||||
* The algorithm works by monitoring all room changes, including new messages in
|
||||
* tracked rooms, to determine if it needs a new category or different placement
|
||||
* within the same category. For more information, see the comments contained
|
||||
* within the class.
|
||||
*/
|
||||
export class ImportanceAlgorithm extends Algorithm {
|
||||
|
||||
// HOW THIS WORKS
|
||||
// --------------
|
||||
//
|
||||
// This block of comments assumes you've read the README one level higher.
|
||||
// You should do that if you haven't already.
|
||||
//
|
||||
// Tags are fed into the algorithmic functions from the Algorithm superclass,
|
||||
// which cause subsequent updates to the room list itself. Categories within
|
||||
// those tags are tracked as index numbers within the array (zero = top), with
|
||||
// each sticky room being tracked separately. Internally, the category index
|
||||
// can be found from `this.indices[tag][category]` and the sticky room information
|
||||
// from `this.stickyRoom`.
|
||||
//
|
||||
// The room list store is always provided with the `this.cached` results, which are
|
||||
// updated as needed and not recalculated often. For example, when a room needs to
|
||||
// move within a tag, the array in `this.cached` will be spliced instead of iterated.
|
||||
// The `indices` help track the positions of each category to make splicing easier.
|
||||
|
||||
private indices: {
|
||||
// @ts-ignore - TS wants this to be a string but we know better than it
|
||||
[tag: TagID]: ICategoryIndex;
|
||||
} = {};
|
||||
|
||||
// TODO: Use this (see docs above)
|
||||
private stickyRoom: {
|
||||
roomId: string;
|
||||
tag: TagID;
|
||||
fromTop: number;
|
||||
} = {
|
||||
roomId: null,
|
||||
tag: null,
|
||||
fromTop: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
console.log("Constructed an ImportanceAlgorithm");
|
||||
}
|
||||
|
||||
// noinspection JSMethodCanBeStatic
|
||||
private categorizeRooms(rooms: Room[]): ICategorizedRoomMap {
|
||||
const map: ICategorizedRoomMap = {
|
||||
[Category.Red]: [],
|
||||
[Category.Grey]: [],
|
||||
[Category.Bold]: [],
|
||||
[Category.Idle]: [],
|
||||
};
|
||||
for (const room of rooms) {
|
||||
const category = this.getRoomCategory(room);
|
||||
map[category].push(room);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// noinspection JSMethodCanBeStatic
|
||||
private getRoomCategory(room: Room): Category {
|
||||
// Function implementation borrowed from old RoomListStore
|
||||
|
||||
const mentions = room.getUnreadNotificationCount('highlight') > 0;
|
||||
if (mentions) {
|
||||
return Category.Red;
|
||||
}
|
||||
|
||||
let unread = room.getUnreadNotificationCount() > 0;
|
||||
if (unread) {
|
||||
return Category.Grey;
|
||||
}
|
||||
|
||||
unread = Unread.doesRoomHaveUnreadMessages(room);
|
||||
if (unread) {
|
||||
return Category.Bold;
|
||||
}
|
||||
|
||||
return Category.Idle;
|
||||
}
|
||||
|
||||
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
|
||||
for (const tagId of Object.keys(updatedTagMap)) {
|
||||
const unorderedRooms = updatedTagMap[tagId];
|
||||
|
||||
const sortBy = this.sortAlgorithms[tagId];
|
||||
if (!sortBy) throw new Error(`${tagId} does not have a sorting algorithm`);
|
||||
|
||||
if (sortBy === SortAlgorithm.Manual) {
|
||||
// Manual tags essentially ignore the importance algorithm, so don't do anything
|
||||
// special about them.
|
||||
updatedTagMap[tagId] = await sortRoomsWithAlgorithm(unorderedRooms, tagId, sortBy);
|
||||
} else {
|
||||
// Every other sorting type affects the categories, not the whole tag.
|
||||
const categorized = this.categorizeRooms(unorderedRooms);
|
||||
for (const category of Object.keys(categorized)) {
|
||||
const roomsToOrder = categorized[category];
|
||||
categorized[category] = await sortRoomsWithAlgorithm(roomsToOrder, tagId, sortBy);
|
||||
}
|
||||
|
||||
const newlyOrganized: Room[] = [];
|
||||
const newIndices: ICategoryIndex = {};
|
||||
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
newIndices[category] = newlyOrganized.length;
|
||||
newlyOrganized.push(...categorized[category]);
|
||||
}
|
||||
|
||||
this.indices[tagId] = newIndices;
|
||||
updatedTagMap[tagId] = newlyOrganized;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async handleRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<boolean> {
|
||||
const tags = this.roomIdsToTags[room.roomId];
|
||||
if (!tags) {
|
||||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||
return false;
|
||||
}
|
||||
const category = this.getRoomCategory(room);
|
||||
let changed = false;
|
||||
for (const tag of tags) {
|
||||
if (this.sortAlgorithms[tag] === SortAlgorithm.Manual) {
|
||||
continue; // Nothing to do here.
|
||||
}
|
||||
|
||||
const taggedRooms = this.cached[tag];
|
||||
const indices = this.indices[tag];
|
||||
let roomIdx = taggedRooms.indexOf(room);
|
||||
if (roomIdx === -1) {
|
||||
console.warn(`Degrading performance to find missing room in "${tag}": ${room.roomId}`);
|
||||
roomIdx = taggedRooms.findIndex(r => r.roomId === room.roomId);
|
||||
}
|
||||
if (roomIdx === -1) {
|
||||
throw new Error(`Room ${room.roomId} has no index in ${tag}`);
|
||||
}
|
||||
|
||||
// Try to avoid doing array operations if we don't have to: only move rooms within
|
||||
// the categories if we're jumping categories
|
||||
const oldCategory = this.getCategoryFromIndices(roomIdx, indices);
|
||||
if (oldCategory !== category) {
|
||||
// Move the room and update the indices
|
||||
this.moveRoomIndexes(1, oldCategory, category, indices);
|
||||
taggedRooms.splice(roomIdx, 1); // splice out the old index (fixed position)
|
||||
taggedRooms.splice(indices[category], 0, room); // splice in the new room (pre-adjusted)
|
||||
// Note: if moveRoomIndexes() is called after the splice then the insert operation
|
||||
// will happen in the wrong place. Because we would have already adjusted the index
|
||||
// for the category, we don't need to determine how the room is moving in the list.
|
||||
// If we instead tried to insert before updating the indices, we'd have to determine
|
||||
// whether the room was moving later (towards IDLE) or earlier (towards RED) from its
|
||||
// current position, as it'll affect the category's start index after we remove the
|
||||
// room from the array.
|
||||
}
|
||||
|
||||
// The room received an update, so take out the slice and sort it. This should be relatively
|
||||
// quick because the room is inserted at the top of the category, and most popular sorting
|
||||
// algorithms will deal with trying to keep the active room at the top/start of the category.
|
||||
// For the few algorithms that will have to move the thing quite far (alphabetic with a Z room
|
||||
// for example), the list should already be sorted well enough that it can rip through the
|
||||
// array and slot the changed room in quickly.
|
||||
const nextCategoryStartIdx = category === CATEGORY_ORDER[CATEGORY_ORDER.length - 1]
|
||||
? Number.MAX_SAFE_INTEGER
|
||||
: indices[CATEGORY_ORDER[CATEGORY_ORDER.indexOf(category) + 1]];
|
||||
const startIdx = indices[category];
|
||||
const numSort = nextCategoryStartIdx - startIdx; // splice() returns up to the max, so MAX_SAFE_INT is fine
|
||||
const unsortedSlice = taggedRooms.splice(startIdx, numSort);
|
||||
const sorted = await sortRoomsWithAlgorithm(unsortedSlice, tag, this.sortAlgorithms[tag]);
|
||||
taggedRooms.splice(startIdx, 0, ...sorted);
|
||||
|
||||
// Finally, flag that we've done something
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
private getCategoryFromIndices(index: number, indices: ICategoryIndex): Category {
|
||||
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
|
||||
const category = CATEGORY_ORDER[i];
|
||||
const isLast = i === (CATEGORY_ORDER.length - 1);
|
||||
const startIdx = indices[category];
|
||||
const endIdx = isLast ? Number.MAX_SAFE_INTEGER : indices[CATEGORY_ORDER[i + 1]];
|
||||
if (index >= startIdx && index < endIdx) {
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
// "Should never happen" disclaimer goes here
|
||||
throw new Error("Programming error: somehow you've ended up with an index that isn't in a category");
|
||||
}
|
||||
|
||||
private moveRoomIndexes(nRooms: number, fromCategory: Category, toCategory: Category, indices: ICategoryIndex) {
|
||||
// We have to update the index of the category *after* the from/toCategory variables
|
||||
// in order to update the indices correctly. Because the room is moving from/to those
|
||||
// categories, the next category's index will change - not the category we're modifying.
|
||||
// We also need to update subsequent categories as they'll all shift by nRooms, so we
|
||||
// loop over the order to achieve that.
|
||||
|
||||
for (let i = CATEGORY_ORDER.indexOf(fromCategory) + 1; i < CATEGORY_ORDER.length; i++) {
|
||||
const nextCategory = CATEGORY_ORDER[i];
|
||||
indices[nextCategory] -= nRooms;
|
||||
}
|
||||
|
||||
for (let i = CATEGORY_ORDER.indexOf(toCategory) + 1; i < CATEGORY_ORDER.length; i++) {
|
||||
const nextCategory = CATEGORY_ORDER[i];
|
||||
indices[nextCategory] += nRooms;
|
||||
}
|
||||
|
||||
// Do a quick check to see if we've completely broken the index
|
||||
for (let i = 1; i <= CATEGORY_ORDER.length; i++) {
|
||||
const lastCat = CATEGORY_ORDER[i - 1];
|
||||
const thisCat = CATEGORY_ORDER[i];
|
||||
|
||||
if (indices[lastCat] > indices[thisCat]) {
|
||||
// "should never happen" disclaimer goes here
|
||||
console.warn(`!! Room list index corruption: ${lastCat} (i:${indices[lastCat]}) is greater than ${thisCat} (i:${indices[thisCat]}) - category indices are likely desynced from reality`);
|
||||
|
||||
// TODO: Regenerate index when this happens
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
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 { Algorithm } from "./Algorithm";
|
||||
import { ITagMap } from "../models";
|
||||
import { sortRoomsWithAlgorithm } from "../tag-sorting";
|
||||
|
||||
/**
|
||||
* Uses the natural tag sorting algorithm order to determine tag ordering. No
|
||||
* additional behavioural changes are present.
|
||||
*/
|
||||
export class NaturalAlgorithm extends Algorithm {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
console.log("Constructed a NaturalAlgorithm");
|
||||
}
|
||||
|
||||
protected async generateFreshTags(updatedTagMap: ITagMap): Promise<any> {
|
||||
for (const tagId of Object.keys(updatedTagMap)) {
|
||||
const unorderedRooms = updatedTagMap[tagId];
|
||||
|
||||
const sortBy = this.sortAlgorithms[tagId];
|
||||
if (!sortBy) throw new Error(`${tagId} does not have a sorting algorithm`);
|
||||
|
||||
updatedTagMap[tagId] = await sortRoomsWithAlgorithm(unorderedRooms, tagId, sortBy);
|
||||
}
|
||||
}
|
||||
|
||||
public async handleRoomUpdate(room, cause): Promise<boolean> {
|
||||
const tags = this.roomIdsToTags[room.roomId];
|
||||
if (!tags) {
|
||||
console.warn(`No tags known for "${room.name}" (${room.roomId})`);
|
||||
return false;
|
||||
}
|
||||
for (const tag of tags) {
|
||||
// TODO: Optimize this loop to avoid useless operations
|
||||
// For example, we can skip updates to alphabetic (sometimes) and manually ordered tags
|
||||
this.cached[tag] = await sortRoomsWithAlgorithm(this.cached[tag], tag, this.sortAlgorithms[tag]);
|
||||
}
|
||||
return true; // assume we changed something
|
||||
}
|
||||
}
|
38
src/stores/room-list/algorithms/list-ordering/index.ts
Normal file
38
src/stores/room-list/algorithms/list-ordering/index.ts
Normal file
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
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 { Algorithm } from "./Algorithm";
|
||||
import { ImportanceAlgorithm } from "./ImportanceAlgorithm";
|
||||
import { ListAlgorithm } from "../models";
|
||||
import { NaturalAlgorithm } from "./NaturalAlgorithm";
|
||||
|
||||
const ALGORITHM_FACTORIES: { [algorithm in ListAlgorithm]: () => Algorithm } = {
|
||||
[ListAlgorithm.Natural]: () => new NaturalAlgorithm(),
|
||||
[ListAlgorithm.Importance]: () => new ImportanceAlgorithm(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an instance of the defined algorithm
|
||||
* @param {ListAlgorithm} algorithm The algorithm to get an instance of.
|
||||
* @returns {Algorithm} The algorithm instance.
|
||||
*/
|
||||
export function getListAlgorithmInstance(algorithm: ListAlgorithm): Algorithm {
|
||||
if (!ALGORITHM_FACTORIES[algorithm]) {
|
||||
throw new Error(`${algorithm} is not a known algorithm`);
|
||||
}
|
||||
|
||||
return ALGORITHM_FACTORIES[algorithm]();
|
||||
}
|
42
src/stores/room-list/algorithms/models.ts
Normal file
42
src/stores/room-list/algorithms/models.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
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";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
export enum SortAlgorithm {
|
||||
Manual = "MANUAL",
|
||||
Alphabetic = "ALPHABETIC",
|
||||
Recent = "RECENT",
|
||||
}
|
||||
|
||||
export enum ListAlgorithm {
|
||||
// Orders Red > Grey > Bold > Idle
|
||||
Importance = "IMPORTANCE",
|
||||
|
||||
// Orders however the SortAlgorithm decides
|
||||
Natural = "NATURAL",
|
||||
}
|
||||
|
||||
export interface ITagSortingMap {
|
||||
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
|
||||
[tagId: TagID]: SortAlgorithm;
|
||||
}
|
||||
|
||||
export interface ITagMap {
|
||||
// @ts-ignore - TypeScript really wants this to be [tagId: string] but we know better.
|
||||
[tagId: TagID]: Room[];
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { TagID } from "../../models";
|
||||
import { IAlgorithm } from "./IAlgorithm";
|
||||
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||
import * as Unread from "../../../../Unread";
|
||||
|
||||
/**
|
||||
* Sorts rooms according to the browser's determination of alphabetic.
|
||||
*/
|
||||
export class AlphabeticAlgorithm implements IAlgorithm {
|
||||
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
|
||||
return rooms.sort((a, b) => {
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
}
|
31
src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
Normal file
31
src/stores/room-list/algorithms/tag-sorting/IAlgorithm.ts
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { TagID } from "../../models";
|
||||
|
||||
/**
|
||||
* Represents a tag sorting algorithm.
|
||||
*/
|
||||
export interface IAlgorithm {
|
||||
/**
|
||||
* Sorts the given rooms according to the sorting rules of the algorithm.
|
||||
* @param {Room[]} rooms The rooms to sort.
|
||||
* @param {TagID} tagId The tag ID in which the rooms are being sorted.
|
||||
* @returns {Promise<Room[]>} Resolves to the sorted rooms.
|
||||
*/
|
||||
sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]>;
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { TagID } from "../../models";
|
||||
import { IAlgorithm } from "./IAlgorithm";
|
||||
|
||||
/**
|
||||
* Sorts rooms according to the tag's `order` property on the room.
|
||||
*/
|
||||
export class ManualAlgorithm implements IAlgorithm {
|
||||
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
|
||||
const getOrderProp = (r: Room) => r.tags[tagId].order || 0;
|
||||
return rooms.sort((a, b) => {
|
||||
return getOrderProp(a) - getOrderProp(b);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
/*
|
||||
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 { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { TagID } from "../../models";
|
||||
import { IAlgorithm } from "./IAlgorithm";
|
||||
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
|
||||
import * as Unread from "../../../../Unread";
|
||||
|
||||
/**
|
||||
* Sorts rooms according to the last event's timestamp in each room that seems
|
||||
* useful to the user.
|
||||
*/
|
||||
export class RecentAlgorithm implements IAlgorithm {
|
||||
public async sortRooms(rooms: Room[], tagId: TagID): Promise<Room[]> {
|
||||
// We cache the timestamp lookup to avoid iterating forever on the timeline
|
||||
// of events. This cache only survives a single sort though.
|
||||
// We wouldn't need this if `.sort()` didn't constantly try and compare all
|
||||
// of the rooms to each other.
|
||||
|
||||
// TODO: We could probably improve the sorting algorithm here by finding changes.
|
||||
// For example, if we spent a little bit of time to determine which elements have
|
||||
// actually changed (probably needs to be done higher up?) then we could do an
|
||||
// insertion sort or similar on the limited set of changes.
|
||||
|
||||
const tsCache: { [roomId: string]: number } = {};
|
||||
const getLastTs = (r: Room) => {
|
||||
if (tsCache[r.roomId]) {
|
||||
return tsCache[r.roomId];
|
||||
}
|
||||
|
||||
const ts = (() => {
|
||||
// Apparently we can have rooms without timelines, at least under testing
|
||||
// environments. Just return MAX_INT when this happens.
|
||||
if (!r || !r.timeline) {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
for (let i = r.timeline.length - 1; i >= 0; --i) {
|
||||
const ev = r.timeline[i];
|
||||
if (!ev.getTs()) continue; // skip events that don't have timestamps (tests only?)
|
||||
|
||||
// TODO: Don't assume we're using the same client as the peg
|
||||
if (ev.getSender() === MatrixClientPeg.get().getUserId()
|
||||
|| Unread.eventTriggersUnreadCount(ev)) {
|
||||
return ev.getTs();
|
||||
}
|
||||
}
|
||||
|
||||
// we might only have events that don't trigger the unread indicator,
|
||||
// in which case use the oldest event even if normally it wouldn't count.
|
||||
// This is better than just assuming the last event was forever ago.
|
||||
if (r.timeline.length && r.timeline[0].getTs()) {
|
||||
return r.timeline[0].getTs();
|
||||
} else {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
})();
|
||||
|
||||
tsCache[r.roomId] = ts;
|
||||
return ts;
|
||||
};
|
||||
|
||||
return rooms.sort((a, b) => {
|
||||
return getLastTs(a) - getLastTs(b);
|
||||
});
|
||||
}
|
||||
}
|
53
src/stores/room-list/algorithms/tag-sorting/index.ts
Normal file
53
src/stores/room-list/algorithms/tag-sorting/index.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
/*
|
||||
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 { SortAlgorithm } from "../models";
|
||||
import { ManualAlgorithm } from "./ManualAlgorithm";
|
||||
import { IAlgorithm } from "./IAlgorithm";
|
||||
import { TagID } from "../../models";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { RecentAlgorithm } from "./RecentAlgorithm";
|
||||
import { AlphabeticAlgorithm } from "./AlphabeticAlgorithm";
|
||||
|
||||
const ALGORITHM_INSTANCES: { [algorithm in SortAlgorithm]: IAlgorithm } = {
|
||||
[SortAlgorithm.Recent]: new RecentAlgorithm(),
|
||||
[SortAlgorithm.Alphabetic]: new AlphabeticAlgorithm(),
|
||||
[SortAlgorithm.Manual]: new ManualAlgorithm(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets an instance of the defined algorithm
|
||||
* @param {SortAlgorithm} algorithm The algorithm to get an instance of.
|
||||
* @returns {IAlgorithm} The algorithm instance.
|
||||
*/
|
||||
export function getSortingAlgorithmInstance(algorithm: SortAlgorithm): IAlgorithm {
|
||||
if (!ALGORITHM_INSTANCES[algorithm]) {
|
||||
throw new Error(`${algorithm} is not a known algorithm`);
|
||||
}
|
||||
|
||||
return ALGORITHM_INSTANCES[algorithm];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts rooms in a given tag according to the algorithm given.
|
||||
* @param {Room[]} rooms The rooms to sort.
|
||||
* @param {TagID} tagId The tag in which the sorting is occurring.
|
||||
* @param {SortAlgorithm} algorithm The algorithm to use for sorting.
|
||||
* @returns {Promise<Room[]>} Resolves to the sorted rooms.
|
||||
*/
|
||||
export function sortRoomsWithAlgorithm(rooms: Room[], tagId: TagID, algorithm: SortAlgorithm): Promise<Room[]> {
|
||||
return getSortingAlgorithmInstance(algorithm).sortRooms(rooms, tagId);
|
||||
}
|
72
src/stores/room-list/membership.ts
Normal file
72
src/stores/room-list/membership.ts
Normal file
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
|
||||
/**
|
||||
* Approximation of a membership status for a given room.
|
||||
*/
|
||||
export enum EffectiveMembership {
|
||||
/**
|
||||
* The user is effectively joined to the room. For example, actually joined
|
||||
* or knocking on the room (when that becomes possible).
|
||||
*/
|
||||
Join = "JOIN",
|
||||
|
||||
/**
|
||||
* The user is effectively invited to the room. Currently this is a direct map
|
||||
* to the invite membership as no other membership states are effectively
|
||||
* invites.
|
||||
*/
|
||||
Invite = "INVITE",
|
||||
|
||||
/**
|
||||
* The user is effectively no longer in the room. For example, kicked,
|
||||
* banned, or voluntarily left.
|
||||
*/
|
||||
Leave = "LEAVE",
|
||||
}
|
||||
|
||||
export interface MembershipSplit {
|
||||
// @ts-ignore - TS wants this to be a string key, but we know better.
|
||||
[state: EffectiveMembership]: Room[];
|
||||
}
|
||||
|
||||
export function splitRoomsByMembership(rooms: Room[]): MembershipSplit {
|
||||
const split: MembershipSplit = {
|
||||
[EffectiveMembership.Invite]: [],
|
||||
[EffectiveMembership.Join]: [],
|
||||
[EffectiveMembership.Leave]: [],
|
||||
};
|
||||
|
||||
for (const room of rooms) {
|
||||
split[getEffectiveMembership(room.getMyMembership())].push(room);
|
||||
}
|
||||
|
||||
return split;
|
||||
}
|
||||
|
||||
export function getEffectiveMembership(membership: string): EffectiveMembership {
|
||||
if (membership === 'invite') {
|
||||
return EffectiveMembership.Invite;
|
||||
} else if (membership === 'join') {
|
||||
// TODO: Do the same for knock? Update docs as needed in the enum.
|
||||
return EffectiveMembership.Join;
|
||||
} else {
|
||||
// Probably a leave, kick, or ban
|
||||
return EffectiveMembership.Leave;
|
||||
}
|
||||
}
|
42
src/stores/room-list/models.ts
Normal file
42
src/stores/room-list/models.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
export enum DefaultTagID {
|
||||
Invite = "im.vector.fake.invite",
|
||||
Untagged = "im.vector.fake.recent", // legacy: used to just be 'recent rooms' but now it's all untagged rooms
|
||||
Archived = "im.vector.fake.archived",
|
||||
LowPriority = "m.lowpriority",
|
||||
Favourite = "m.favourite",
|
||||
DM = "im.vector.fake.direct",
|
||||
ServerNotice = "m.server_notice",
|
||||
}
|
||||
|
||||
export const OrderedDefaultTagIDs = [
|
||||
DefaultTagID.Invite,
|
||||
DefaultTagID.Favourite,
|
||||
DefaultTagID.DM,
|
||||
DefaultTagID.Untagged,
|
||||
DefaultTagID.LowPriority,
|
||||
DefaultTagID.ServerNotice,
|
||||
DefaultTagID.Archived,
|
||||
];
|
||||
|
||||
export type TagID = string | DefaultTagID;
|
||||
|
||||
export enum RoomUpdateCause {
|
||||
Timeline = "TIMELINE",
|
||||
RoomRead = "ROOM_READ", // TODO: Use this.
|
||||
}
|
|
@ -81,7 +81,7 @@ export class ThemeWatcher {
|
|||
}
|
||||
|
||||
getEffectiveTheme() {
|
||||
// Dev note: Much of this logic is replicated in the GeneralUserSettingsTab
|
||||
// Dev note: Much of this logic is replicated in the AppearanceUserSettingsTab
|
||||
|
||||
// XXX: checking the isLight flag here makes checking it in the ThemeController
|
||||
// itself completely redundant since we just override the result here and we're
|
||||
|
|
|
@ -14,7 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
/* Simple utils for formatting style values
|
||||
*/
|
||||
|
||||
// converts a pixel value to rem.
|
||||
export default function(pixelVal) {
|
||||
return pixelVal / 15 + "rem";
|
||||
export function toRem(pixelValue: number): string {
|
||||
return pixelValue / 15 + "rem";
|
||||
}
|
||||
|
||||
export function toPx(pixelValue: number): string {
|
||||
return pixelValue + "px";
|
||||
}
|
|
@ -206,7 +206,7 @@ describe("<TextualBody />", () => {
|
|||
'Hey <span>' +
|
||||
'<a class="mx_Pill mx_UserPill" title="@user:server">' +
|
||||
'<img class="mx_BaseAvatar mx_BaseAvatar_image" src="mxc://avatar.url/image.png" ' +
|
||||
'style="width: 1.0666666666666667rem; height: 1.0666666666666667rem;" ' +
|
||||
'style="width: 16px; height: 16px;" ' +
|
||||
'title="@member:domain.bla" alt="" aria-hidden="true">Member</a>' +
|
||||
'</span></span>');
|
||||
});
|
||||
|
|
|
@ -14,7 +14,7 @@ import DMRoomMap from '../../../../src/utils/DMRoomMap.js';
|
|||
import GroupStore from '../../../../src/stores/GroupStore.js';
|
||||
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import {TAG_DM} from "../../../../src/stores/RoomListStore";
|
||||
import {DefaultTagID} from "../../../../src/stores/room-list/models";
|
||||
|
||||
function generateRoomId() {
|
||||
return '!' + Math.random().toString().slice(2, 10) + ':domain';
|
||||
|
@ -153,7 +153,7 @@ describe('RoomList', () => {
|
|||
// Set up the room that will be moved such that it has the correct state for a room in
|
||||
// the section for oldTag
|
||||
if (['m.favourite', 'm.lowpriority'].includes(oldTag)) movingRoom.tags = {[oldTag]: {}};
|
||||
if (oldTag === TAG_DM) {
|
||||
if (oldTag === DefaultTagID.DM) {
|
||||
// Mock inverse m.direct
|
||||
DMRoomMap.shared().roomToUser = {
|
||||
[movingRoom.roomId]: '@someotheruser:domain',
|
||||
|
@ -180,7 +180,7 @@ describe('RoomList', () => {
|
|||
// TODO: Re-enable dragging tests when we support dragging again.
|
||||
describe.skip('does correct optimistic update when dragging from', () => {
|
||||
it('rooms to people', () => {
|
||||
expectCorrectMove(undefined, TAG_DM);
|
||||
expectCorrectMove(undefined, DefaultTagID.DM);
|
||||
});
|
||||
|
||||
it('rooms to favourites', () => {
|
||||
|
@ -195,15 +195,15 @@ describe('RoomList', () => {
|
|||
// Whe running the app live, it updates when some other event occurs (likely the
|
||||
// m.direct arriving) that these tests do not fire.
|
||||
xit('people to rooms', () => {
|
||||
expectCorrectMove(TAG_DM, undefined);
|
||||
expectCorrectMove(DefaultTagID.DM, undefined);
|
||||
});
|
||||
|
||||
it('people to favourites', () => {
|
||||
expectCorrectMove(TAG_DM, 'm.favourite');
|
||||
expectCorrectMove(DefaultTagID.DM, 'm.favourite');
|
||||
});
|
||||
|
||||
it('people to lowpriority', () => {
|
||||
expectCorrectMove(TAG_DM, 'm.lowpriority');
|
||||
expectCorrectMove(DefaultTagID.DM, 'm.lowpriority');
|
||||
});
|
||||
|
||||
it('low priority to rooms', () => {
|
||||
|
@ -211,7 +211,7 @@ describe('RoomList', () => {
|
|||
});
|
||||
|
||||
it('low priority to people', () => {
|
||||
expectCorrectMove('m.lowpriority', TAG_DM);
|
||||
expectCorrectMove('m.lowpriority', DefaultTagID.DM);
|
||||
});
|
||||
|
||||
it('low priority to low priority', () => {
|
||||
|
@ -223,7 +223,7 @@ describe('RoomList', () => {
|
|||
});
|
||||
|
||||
it('favourites to people', () => {
|
||||
expectCorrectMove('m.favourite', TAG_DM);
|
||||
expectCorrectMove('m.favourite', DefaultTagID.DM);
|
||||
});
|
||||
|
||||
it('favourites to low priority', () => {
|
||||
|
|
|
@ -1847,6 +1847,11 @@ autoprefixer@^9.0.0:
|
|||
postcss "^7.0.27"
|
||||
postcss-value-parser "^4.0.3"
|
||||
|
||||
await-lock@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/await-lock/-/await-lock-2.0.1.tgz#b3f65fdf66e08f7538260f79b46c15bcfc18cadd"
|
||||
integrity sha512-ntLi9fzlMT/vWjC1wwVI11/cSRJ3nTS35qVekNc9WnaoMOP2eWH0RvIqwLQkDjX4a4YynsKEv+Ere2VONp9wxg==
|
||||
|
||||
aws-sign2@~0.7.0:
|
||||
version "0.7.0"
|
||||
resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8"
|
||||
|
|
Loading…
Reference in a new issue