Merge branch 'develop' into travis/room-list/notification-state

This commit is contained in:
Travis Ralston 2020-07-02 13:26:16 -06:00
commit b54635863f
27 changed files with 812 additions and 285 deletions

View file

@ -588,27 +588,16 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
// A context menu that largely fits the | [icon] [label] | format. // A context menu that largely fits the | [icon] [label] | format.
.mx_IconizedContextMenu { .mx_IconizedContextMenu {
// Put 20px of padding around the whole menu. We do this instead of a min-width: 146px;
// simple `padding: 20px` rule so the horizontal rules added by the
// optionLists is rendered correctly (full width).
> * {
padding-left: 20px;
padding-right: 20px;
&:first-child {
padding-top: 20px;
}
&:last-child {
padding-bottom: 16px;
}
}
.mx_IconizedContextMenu_optionList { .mx_IconizedContextMenu_optionList {
& > * {
padding-left: 20px;
padding-right: 20px;
}
// the notFirst class is for cases where the optionList might be under a header of sorts. // the notFirst class is for cases where the optionList might be under a header of sorts.
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst { &:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
margin-top: 12px;
// This is a bit of a hack when we could just use a simple border-top property, // This is a bit of a hack when we could just use a simple border-top property,
// however we have a (kinda) good reason for doing it this way: we need opacity. // however we have a (kinda) good reason for doing it this way: we need opacity.
// To get the right color, we need an opacity modifier which means we have to work // To get the right color, we need an opacity modifier which means we have to work
@ -631,16 +620,20 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
} }
} }
ul { // round the top corners of the top button for the hover effect to be bounded
list-style: none; &:first-child .mx_AccessibleButton:first-child {
margin: 0; border-radius: 4px 4px 0 0; // radius matches .mx_ContextualMenu
padding: 0; }
li { // round the bottom corners of the bottom button for the hover effect to be bounded
margin: 0; &:last-child .mx_AccessibleButton:last-child {
padding: 12px 0 0; border-radius: 0 0 4px 4px; // radius matches .mx_ContextualMenu
}
.mx_AccessibleButton { .mx_AccessibleButton {
// pad the inside of the button so that the hover background is padded too
padding-top: 12px;
padding-bottom: 12px;
text-decoration: none; text-decoration: none;
color: $primary-fg-color; color: $primary-fg-color;
font-size: $font-15px; font-size: $font-15px;
@ -650,13 +643,17 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
display: flex; display: flex;
align-items: center; align-items: center;
&:hover {
background-color: $menu-selected-color;
}
img, .mx_IconizedContextMenu_icon { // icons img, .mx_IconizedContextMenu_icon { // icons
width: 16px; width: 16px;
min-width: 16px; min-width: 16px;
max-width: 16px; max-width: 16px;
} }
span:last-child { // labels span.mx_IconizedContextMenu_label { // labels
padding-left: 14px; padding-left: 14px;
width: 100%; width: 100%;
flex: 1; flex: 1;
@ -668,35 +665,10 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
} }
} }
} }
}
}
&.mx_IconizedContextMenu_compact { &.mx_IconizedContextMenu_compact {
> * { .mx_IconizedContextMenu_optionList > * {
padding-left: 11px; padding: 8px 16px 8px 11px;
padding-right: 16px;
&:first-child {
padding-top: 13px;
}
&:last-child {
padding-bottom: 13px;
}
}
.mx_IconizedContextMenu_optionList {
&:nth-child(n + 2), .mx_IconizedContextMenu_optionList_notFirst {
margin-top: 10px;
li:first-child {
padding-top: 10px;
}
}
li:first-child {
padding-top: 0;
}
} }
} }
} }

View file

@ -49,6 +49,7 @@
@import "./views/auth/_ServerTypeSelector.scss"; @import "./views/auth/_ServerTypeSelector.scss";
@import "./views/auth/_Welcome.scss"; @import "./views/auth/_Welcome.scss";
@import "./views/avatars/_BaseAvatar.scss"; @import "./views/avatars/_BaseAvatar.scss";
@import "./views/avatars/_DecoratedRoomAvatar.scss";
@import "./views/avatars/_MemberStatusMessageAvatar.scss"; @import "./views/avatars/_MemberStatusMessageAvatar.scss";
@import "./views/context_menus/_MessageContextMenu.scss"; @import "./views/context_menus/_MessageContextMenu.scss";
@import "./views/context_menus/_RoomTileContextMenu.scss"; @import "./views/context_menus/_RoomTileContextMenu.scss";

View file

@ -70,7 +70,8 @@ $tagPanelWidth: 70px; // only applies in this file, used for calculations
.mx_LeftPanel2_breadcrumbsContainer { .mx_LeftPanel2_breadcrumbsContainer {
width: 100%; width: 100%;
overflow: hidden; overflow-y: hidden;
overflow-x: scroll;
margin-top: 8px; margin-top: 8px;
} }
} }

View file

@ -86,6 +86,8 @@ limitations under the License.
.mx_UserMenu_contextMenu_redRow { .mx_UserMenu_contextMenu_redRow {
.mx_AccessibleButton { .mx_AccessibleButton {
padding-top: 16px;
padding-bottom: 16px;
color: $warning-color !important; // !important to override styles from context menu color: $warning-color !important; // !important to override styles from context menu
} }
@ -95,6 +97,8 @@ limitations under the License.
} }
.mx_UserMenu_contextMenu_header { .mx_UserMenu_contextMenu_header {
padding: 20px;
// Create a flexbox to organize the header a bit easier // Create a flexbox to organize the header a bit easier
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -0,0 +1,33 @@
/*
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_DecoratedRoomAvatar {
position: relative;
.mx_RoomTileIcon {
position: absolute;
bottom: 0;
right: 0;
}
.mx_NotificationBadge {
position: absolute;
top: 0;
right: 0;
height: 18px;
width: 18px;
}
}

View file

@ -23,27 +23,20 @@ limitations under the License.
// The tile is also a flexbox row itself // The tile is also a flexbox row itself
display: flex; display: flex;
flex-wrap: wrap;
&.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen { &.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen {
background-color: $roomtile2-selected-bg-color; background-color: $roomtile2-selected-bg-color;
border-radius: 32px; border-radius: 32px;
} }
.mx_RoomTile2_avatarContainer { .mx_DecoratedRoomAvatar {
margin-right: 8px; margin-right: 8px;
position: relative;
.mx_RoomTileIcon {
position: absolute;
bottom: 0;
right: 0;
}
} }
.mx_RoomTile2_nameContainer { .mx_RoomTile2_nameContainer {
flex-grow: 1; flex-grow: 1;
max-width: calc(100% - 58px); // 32px avatar, 18px badge area, 8px margin on avatar min-width: 0; // allow flex to shrink it
margin-right: 8px; // spacing to buttons/badges
// Create a new column layout flexbox for the name parts // Create a new column layout flexbox for the name parts
display: flex; display: flex;
@ -81,31 +74,39 @@ limitations under the License.
} }
} }
.mx_RoomTile2_badgeContainer { .mx_RoomTile2_menuButton {
width: 18px; margin-left: 4px; // spacing between buttons
height: 32px;
// Create another flexbox row because it's super easy to position the badge at
// the end this way.
display: flex;
align-items: center;
justify-content: center;
} }
// The menu button is hidden by default .mx_RoomTile2_badgeContainer {
// TODO: [Notifications] Use mx_RoomTile2_notificationsButton, similar to the following approach: height: 16px;
// https://github.com/matrix-org/matrix-react-sdk/blob/2180a56074f3698fc0241c309a72ba6cad802d1c/res/css/views/rooms/_RoomSublist2.scss#L48-L76 // don't set width so that it takes no space when there is no badge to show
// You'll need to do the same down below on the &:hover selector for the tile. margin: auto 0; // vertically align
// See https://github.com/vector-im/riot-web/issues/13961.
// ... also remove this 5 line TODO comment. .mx_NotificationBadge {
margin-right: 2px; // centering
}
.mx_NotificationBadge_dot {
// make the smaller dot occupy the same width for centering
margin-left: 5px;
margin-right: 7px;
}
}
// The context menu buttons are hidden by default
.mx_RoomTile2_menuButton, .mx_RoomTile2_menuButton,
.mx_RoomTile2_notificationsButton { .mx_RoomTile2_notificationsButton {
width: 0; width: 20px;
height: 0; min-width: 20px; // yay flex
visibility: hidden; height: 20px;
margin: auto 0;
position: relative; position: relative;
display: none;
&::before { &::before {
top: 2px;
left: 2px;
content: ''; content: '';
width: 16px; width: 16px;
height: 16px; height: 16px;
@ -117,9 +118,12 @@ limitations under the License.
} }
} }
// If the room has an overriden notification setting then we always show the notifications menu button
.mx_RoomTile2_notificationsButton.mx_RoomTile2_notificationsButton_show {
display: block;
}
.mx_RoomTile2_menuButton::before { .mx_RoomTile2_menuButton::before {
top: 8px;
left: -1px; // this is off-center to align it with the badges
mask-image: url('$(res)/img/feather-customised/more-horizontal.svg'); mask-image: url('$(res)/img/feather-customised/more-horizontal.svg');
} }
@ -129,13 +133,12 @@ limitations under the License.
.mx_RoomTile2_badgeContainer { .mx_RoomTile2_badgeContainer {
width: 0; width: 0;
height: 0; height: 0;
visibility: hidden; display: none;
} }
.mx_RoomTile2_notificationsButton,
.mx_RoomTile2_menuButton { .mx_RoomTile2_menuButton {
width: 18px; display: block;
height: 32px;
visibility: visible;
} }
} }
} }
@ -145,17 +148,27 @@ limitations under the License.
align-items: center; align-items: center;
position: relative; position: relative;
.mx_RoomTile2_avatarContainer { .mx_DecoratedRoomAvatar {
margin-right: 0; margin-right: 0;
} }
}
}
.mx_RoomTile2_badgeContainer { // We use these both in context menus and the room tiles
position: absolute; .mx_RoomTile2_iconBell::before {
top: 0; mask-image: url('$(res)/img/feather-customised/bell.svg');
right: 0; }
height: 18px; .mx_RoomTile2_iconBellDot::before {
} mask-image: url('$(res)/img/feather-customised/bell-notification.custom.svg');
} }
.mx_RoomTile2_iconBellCrossed::before {
mask-image: url('$(res)/img/feather-customised/bell-crossed.svg');
}
.mx_RoomTile2_iconBellMentions::before {
mask-image: url('$(res)/img/feather-customised/bell-mentions.custom.svg');
}
.mx_RoomTile2_iconCheck::before {
mask-image: url('$(res)/img/feather-customised/check.svg');
} }
.mx_RoomTile2_contextMenu { .mx_RoomTile2_contextMenu {
@ -169,6 +182,16 @@ limitations under the License.
} }
} }
.mx_RoomTile2_contextMenu_activeRow {
&.mx_AccessibleButton, .mx_AccessibleButton {
color: $accent-color !important; // !important to override styles from context menu
}
.mx_IconizedContextMenu_icon::before {
background-color: $accent-color;
}
}
.mx_IconizedContextMenu_icon { .mx_IconizedContextMenu_icon {
position: relative; position: relative;
width: 16px; width: 16px;

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.31422 2.4647C8.07372 2.6004 7.98877 2.90537 8.12448 3.14587C8.26018 3.38637 8.56515 3.47132 8.80565 3.33562L8.31422 2.4647ZM18.9999 9.00016L18.4999 8.9999V9.00016H18.9999ZM18.4999 13.0002C18.4999 13.2763 18.7238 13.5002 18.9999 13.5002C19.2761 13.5002 19.4999 13.2763 19.4999 13.0002H18.4999ZM17 17.5004C17.2761 17.5004 17.5 17.2765 17.5 17.0004C17.5 16.7242 17.2761 16.5004 17 16.5004V17.5004ZM2 16.5004C1.72386 16.5004 1.5 16.7242 1.5 17.0004C1.5 17.2765 1.72386 17.5004 2 17.5004V16.5004ZM5 9.00036H5.5L5.5 8.99973L5 9.00036ZM6.22429 6.00974C6.35096 5.76436 6.25474 5.46276 6.00937 5.33608C5.764 5.2094 5.46239 5.30562 5.33571 5.551L6.22429 6.00974ZM14.1625 21.2509C14.301 21.012 14.2197 20.7061 13.9808 20.5675C13.742 20.4289 13.436 20.5103 13.2975 20.7491L14.1625 21.2509ZM10.7025 20.7491C10.5639 20.5103 10.2579 20.4289 10.0191 20.5675C9.78021 20.7061 9.6989 21.012 9.83746 21.2509L10.7025 20.7491ZM8.80565 3.33562C10.8187 2.19975 13.2834 2.21831 15.2791 3.38436L15.7836 2.52094C13.4809 1.17549 10.6369 1.15408 8.31422 2.4647L8.80565 3.33562ZM15.2791 3.38436C17.2748 4.55042 18.5011 6.68854 18.4999 8.9999L19.4999 9.00041C19.5013 6.33346 18.0863 3.86639 15.7836 2.52094L15.2791 3.38436ZM18.4999 9.00016V13.0002H19.4999V9.00016H18.4999ZM17 16.5004H2V17.5004H17V16.5004ZM2 17.5004C3.933 17.5004 5.5 15.9334 5.5 14.0004H4.5C4.5 15.3811 3.38071 16.5004 2 16.5004V17.5004ZM5.5 14.0004V9.00036H4.5V14.0004H5.5ZM5.5 8.99973C5.49869 7.95947 5.74707 6.93408 6.22429 6.00974L5.33571 5.551C4.78509 6.61755 4.49849 7.80069 4.5 9.00099L5.5 8.99973ZM13.2975 20.7491C13.0291 21.2117 12.5348 21.4965 12 21.4965V22.4965C12.8913 22.4965 13.7152 22.0219 14.1625 21.2509L13.2975 20.7491ZM12 21.4965C11.4652 21.4965 10.9708 21.2117 10.7025 20.7491L9.83746 21.2509C10.2847 22.0219 11.1086 22.4965 12 22.4965V21.4965Z" fill="#2E2F32"/>
<path d="M1 1L23 23" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.62998 3.57476C7.55241 1.6636 9.6476 0.644608 11.692 1.13263C13.7342 1.62012 15.1666 3.47754 15.1667 5.60305V5.60308V6.01222C15.1667 6.95553 14.4163 7.73965 13.4668 7.73965C12.9349 7.73965 12.4655 7.49363 12.1555 7.11141C11.7768 7.49925 11.2519 7.74098 10.6668 7.74098C9.49647 7.74098 8.56689 6.77368 8.56689 5.60441C8.56689 4.43514 9.49647 3.46784 10.6668 3.46784C11.8348 3.46784 12.7629 4.43111 12.7668 5.59709L12.7668 5.60308V6.01222C12.7668 6.4247 13.0908 6.73965 13.4668 6.73965C13.8428 6.73965 14.1667 6.4247 14.1667 6.01222V5.60311V5.60308C14.1666 3.92595 13.0379 2.48201 11.4598 2.1053C9.8839 1.72911 8.25387 2.51086 7.53057 4.00944C6.80579 5.5111 7.19017 7.3233 8.44894 8.38151C9.70415 9.43672 11.5011 9.46808 12.7905 8.45807C13.0079 8.28778 13.3221 8.32596 13.4924 8.54335C13.6627 8.76074 13.6245 9.07501 13.4071 9.24529C11.745 10.5473 9.42229 10.5062 7.80545 9.14696C6.19216 7.79072 5.70903 5.48285 6.62998 3.57476ZM10.6668 4.46784C10.07 4.46784 9.56689 4.96597 9.56689 5.60441C9.56689 6.24285 10.07 6.74098 10.6668 6.74098C11.2637 6.74098 11.7668 6.24285 11.7668 5.60441C11.7668 4.96597 11.2637 4.46784 10.6668 4.46784ZM5.48951 2.14C5.61741 2.38474 5.5227 2.68682 5.27796 2.81472C3.92878 3.51981 3 4.95881 3 6.62506V10.0347C3 10.6137 2.8091 11.1505 2.48631 11.5805H13.8333C14.1095 11.5805 14.3333 11.8043 14.3333 12.0805C14.3333 12.3566 14.1095 12.5805 13.8333 12.5805H0.5C0.223858 12.5805 0 12.3566 0 12.0805C0 11.8043 0.223858 11.5805 0.5 11.5805C1.31782 11.5805 2 10.8991 2 10.0347V6.62506C2 4.58053 3.14094 2.80322 4.81479 1.92845C5.05953 1.80055 5.36161 1.89527 5.48951 2.14ZM5.76678 14.3741C6.00698 14.2379 6.31214 14.3222 6.44836 14.5624C6.59999 14.8298 6.8752 14.9886 7.16676 14.9886C7.45832 14.9886 7.73354 14.8298 7.88516 14.5624C8.02139 14.3222 8.32654 14.2379 8.56674 14.3741C8.80695 14.5104 8.89124 14.8155 8.75502 15.0557C8.42959 15.6296 7.82596 15.9886 7.16676 15.9886C6.50756 15.9886 5.90393 15.6296 5.5785 15.0557C5.44228 14.8155 5.52657 14.5104 5.76678 14.3741Z" fill="#2E2F32"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View 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="M13.73 21C13.3722 21.6168 12.7131 21.9965 12 21.9965C11.287 21.9965 10.6278 21.6168 10.27 21" stroke="#2E2F32" stroke-linecap="round"/>
<path d="M11.9999 2.00024C8.13388 2.00024 4.99988 5.13425 4.99988 9.00024V14.0002C4.99988 15.6571 3.65673 17.0002 1.99988 17.0002H21.9999C20.343 17.0002 18.9999 15.6571 18.9999 14.0002V12.75" stroke="#2E2F32" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="18.75" cy="5.25" r="4.75" stroke="#2E2F32"/>
</svg>

After

Width:  |  Height:  |  Size: 563 B

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 17.5C22.2761 17.5 22.5 17.2761 22.5 17C22.5 16.7239 22.2761 16.5 22 16.5V17.5ZM2 16.5C1.72386 16.5 1.5 16.7239 1.5 17C1.5 17.2761 1.72386 17.5 2 17.5V16.5ZM5 9H4.5H5ZM19 9H19.5H19ZM14.1625 21.2509C14.3011 21.012 14.2197 20.7061 13.9809 20.5675C13.742 20.4289 13.4361 20.5103 13.2975 20.7491L14.1625 21.2509ZM10.7025 20.7491C10.5639 20.5103 10.258 20.4289 10.0191 20.5675C9.78025 20.7061 9.69894 21.012 9.8375 21.2509L10.7025 20.7491ZM22 16.5H2V17.5H22V16.5ZM2 17.5C3.933 17.5 5.5 15.933 5.5 14H4.5C4.5 15.3807 3.38071 16.5 2 16.5V17.5ZM5.5 14V9H4.5V14H5.5ZM5.5 9C5.5 5.41015 8.41015 2.5 12 2.5V1.5C7.85786 1.5 4.5 4.85786 4.5 9H5.5ZM12 2.5C15.5899 2.5 18.5 5.41015 18.5 9H19.5C19.5 4.85786 16.1421 1.5 12 1.5V2.5ZM18.5 9V14H19.5V9H18.5ZM18.5 14C18.5 15.933 20.067 17.5 22 17.5V16.5C20.6193 16.5 19.5 15.3807 19.5 14H18.5ZM13.2975 20.7491C13.0292 21.2117 12.5348 21.4965 12 21.4965V22.4965C12.8913 22.4965 13.7153 22.0219 14.1625 21.2509L13.2975 20.7491ZM12 21.4965C11.4652 21.4965 10.9708 21.2117 10.7025 20.7491L9.8375 21.2509C10.2847 22.0219 11.1087 22.4965 12 22.4965V21.4965Z" fill="#2E2F32"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -116,6 +116,7 @@ export class ContextMenu extends React.Component {
this.props.onFinished(); this.props.onFinished();
e.preventDefault(); e.preventDefault();
e.stopPropagation();
const x = e.clientX; const x = e.clientX;
const y = e.clientY; const y = e.clientY;
@ -133,6 +134,12 @@ export class ContextMenu extends React.Component {
} }
}; };
onContextMenuPreventBubbling = (e) => {
// stop propagation so that any context menu handlers don't leak out of this context menu
// but do not inhibit the default browser menu
e.stopPropagation();
};
_onMoveFocus = (element, up) => { _onMoveFocus = (element, up) => {
let descending = false; // are we currently descending or ascending through the DOM tree? let descending = false; // are we currently descending or ascending through the DOM tree?
@ -324,7 +331,7 @@ export class ContextMenu extends React.Component {
} }
return ( return (
<div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown}> <div className="mx_ContextualMenu_wrapper" style={{...position, ...wrapperStyle}} onKeyDown={this._onKeyDown} onContextMenu={this.onContextMenuPreventBubbling}>
<div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}> <div className={menuClasses} style={menuStyle} ref={this.collectContextMenuRect} role={this.props.managed ? "menu" : undefined}>
{ chevron } { chevron }
{ props.children } { props.children }
@ -340,10 +347,18 @@ export class ContextMenu extends React.Component {
} }
// Semantic component for representing the AccessibleButton which launches a <ContextMenu /> // Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => { export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => {
const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return ( return (
<AccessibleButton {...props} title={label} aria-label={label} aria-haspopup={true} aria-expanded={isExpanded}> <AccessibleButton
{...props}
onClick={onClick}
onContextMenu={onContextMenu || onClick}
title={label}
aria-label={label}
aria-haspopup={true}
aria-expanded={isExpanded}
>
{ children } { children }
</AccessibleButton> </AccessibleButton>
); );

View file

@ -30,6 +30,7 @@ import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier"; import ResizeNotifier from "../../utils/ResizeNotifier";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import RoomListStore, { RoomListStore2, LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore2";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -69,6 +70,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}; };
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate); BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => { this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")}); this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
}); });
@ -81,6 +83,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
SettingsStore.unwatchSetting(this.tagPanelWatcherRef); SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate); BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize); this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
} }
@ -151,7 +154,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
let breadcrumbs; let breadcrumbs;
if (this.state.showBreadcrumbs) { if (this.state.showBreadcrumbs) {
breadcrumbs = ( breadcrumbs = (
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer"> <div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar">
{this.props.isMinimized ? null : <RoomBreadcrumbs2 />} {this.props.isMinimized ? null : <RoomBreadcrumbs2 />}
</div> </div>
); );
@ -205,6 +208,11 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
"mx_LeftPanel2_minimized": this.props.isMinimized, "mx_LeftPanel2_minimized": this.props.isMinimized,
}); });
const roomListClasses = classNames(
"mx_LeftPanel2_actualRoomListContainer",
"mx_AutoHideScrollbar",
);
return ( return (
<div className={containerClasses}> <div className={containerClasses}>
{tagPanel} {tagPanel}
@ -212,7 +220,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
{this.renderHeader()} {this.renderHeader()}
{this.renderSearchExplore()} {this.renderSearchExplore()}
<div <div
className="mx_LeftPanel2_actualRoomListContainer" className={roomListClasses}
onScroll={this.onScroll} onScroll={this.onScroll}
ref={this.listContainerRef} ref={this.listContainerRef}
>{roomList}</div> >{roomList}</div>

View file

@ -23,7 +23,6 @@ import * as Matrix from "matrix-js-sdk";
import { InvalidStoreError } from "matrix-js-sdk/src/errors"; import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { isCryptoAvailable } from 'matrix-js-sdk/src/crypto';
// focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss // focus-visible is a Polyfill for the :focus-visible CSS pseudo-attribute used by _AccessibleButton.scss
import 'focus-visible'; import 'focus-visible';
// what-input helps improve keyboard accessibility // what-input helps improve keyboard accessibility

View file

@ -1819,6 +1819,7 @@ export default createReactClass({
); );
const showRoomRecoveryReminder = ( const showRoomRecoveryReminder = (
this.context.isCryptoEnabled() &&
SettingsStore.getValue("showRoomRecoveryReminder") && SettingsStore.getValue("showRoomRecoveryReminder") &&
this.context.isRoomEncrypted(this.state.room.roomId) && this.context.isRoomEncrypted(this.state.room.roomId) &&
this.context.getKeyBackupEnabled() === false this.context.getKeyBackupEnabled() === false

View file

@ -42,8 +42,10 @@ interface IProps {
isMinimized: boolean; isMinimized: boolean;
} }
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
interface IState { interface IState {
menuDisplayed: boolean; contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean; isDarkTheme: boolean;
} }
@ -56,7 +58,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
super(props); super(props);
this.state = { this.state = {
menuDisplayed: false, contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(), isDarkTheme: this.isUserOnDarkTheme(),
}; };
@ -106,13 +108,25 @@ export default class UserMenu extends React.Component<IProps, IState> {
private onOpenMenuClick = (ev: InputEvent) => { private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({menuDisplayed: true}); const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
}; };
private onCloseMenu = (ev: InputEvent) => { private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({menuDisplayed: false}); this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
width: 20,
height: 0,
},
});
};
private onCloseMenu = () => {
this.setState({contextMenuPosition: null});
}; };
private onSwitchThemeClick = () => { private onSwitchThemeClick = () => {
@ -129,7 +143,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId}; const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
defaultDispatcher.dispatch(payload); defaultDispatcher.dispatch(payload);
this.setState({menuDisplayed: false}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onShowArchived = (ev: ButtonEvent) => { private onShowArchived = (ev: ButtonEvent) => {
@ -145,7 +159,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation(); ev.stopPropagation();
Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog); Modal.createTrackedDialog('Report bugs & give feedback', '', RedesignFeedbackDialog);
this.setState({menuDisplayed: false}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onSignOutClick = (ev: ButtonEvent) => { private onSignOutClick = (ev: ButtonEvent) => {
@ -153,7 +167,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation(); ev.stopPropagation();
Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog); Modal.createTrackedDialog('Logout from LeftPanel', '', LogoutDialog);
this.setState({menuDisplayed: false}); // also close the menu this.setState({contextMenuPosition: null}); // also close the menu
}; };
private onHomeClick = (ev: ButtonEvent) => { private onHomeClick = (ev: ButtonEvent) => {
@ -164,7 +178,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
}; };
private renderContextMenu = (): React.ReactNode => { private renderContextMenu = (): React.ReactNode => {
if (!this.state.menuDisplayed) return null; if (!this.state.contextMenuPosition) return null;
let hostingLink; let hostingLink;
const signupLink = getHostingLink("user-context-menu"); const signupLink = getHostingLink("user-context-menu");
@ -191,21 +205,19 @@ export default class UserMenu extends React.Component<IProps, IState> {
let homeButton = null; let homeButton = null;
if (this.hasHomePage) { if (this.hasHomePage) {
homeButton = ( homeButton = (
<li>
<AccessibleButton onClick={this.onHomeClick}> <AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" /> <span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
<span>{_t("Home")}</span> <span>{_t("Home")}</span>
</AccessibleButton> </AccessibleButton>
</li>
); );
} }
const elementRect = this.buttonRef.current.getBoundingClientRect();
return ( return (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace="none"
left={elementRect.width + elementRect.left} // -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
top={elementRect.top + elementRect.height} left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}
> >
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu"> <div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
@ -232,49 +244,33 @@ export default class UserMenu extends React.Component<IProps, IState> {
</div> </div>
{hostingLink} {hostingLink}
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst"> <div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul>
{homeButton} {homeButton}
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> <AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" /> <span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
<span>{_t("Notification settings")}</span> <span className="mx_IconizedContextMenu_label">{_t("Notification settings")}</span>
</AccessibleButton> </AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}> <AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" /> <span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
<span>{_t("Security & privacy")}</span> <span className="mx_IconizedContextMenu_label">{_t("Security & privacy")}</span>
</AccessibleButton> </AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}> <AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" /> <span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
<span>{_t("All settings")}</span> <span className="mx_IconizedContextMenu_label">{_t("All settings")}</span>
</AccessibleButton> </AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onShowArchived}> <AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" /> <span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
<span>{_t("Archived rooms")}</span> <span className="mx_IconizedContextMenu_label">{_t("Archived rooms")}</span>
</AccessibleButton> </AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onProvideFeedback}> <AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" /> <span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
<span>{_t("Feedback")}</span> <span className="mx_IconizedContextMenu_label">{_t("Feedback")}</span>
</AccessibleButton> </AccessibleButton>
</li>
</ul>
</div> </div>
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
<ul>
<li className="mx_UserMenu_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}> <AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" /> <span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
<span>{_t("Sign out")}</span> <span className="mx_IconizedContextMenu_label">{_t("Sign out")}</span>
</AccessibleButton> </AccessibleButton>
</li>
</ul>
</div> </div>
</div> </div>
</ContextMenu> </ContextMenu>
@ -307,7 +303,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
onClick={this.onOpenMenuClick} onClick={this.onOpenMenuClick}
inputRef={this.buttonRef} inputRef={this.buttonRef}
label={_t("Account settings")} label={_t("Account settings")}
isExpanded={this.state.menuDisplayed} isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
> >
<div className="mx_UserMenu_row"> <div className="mx_UserMenu_row">
<span className="mx_UserMenu_userAvatarContainer"> <span className="mx_UserMenu_userAvatarContainer">

View file

@ -0,0 +1,63 @@
/*
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 { Room } from "matrix-js-sdk/src/models/room";
import { TagID } from '../../../stores/room-list/models';
import RoomAvatar from "./RoomAvatar";
import RoomTileIcon from "../rooms/RoomTileIcon";
import NotificationBadge, { INotificationState, TagSpecificNotificationState } from '../rooms/NotificationBadge';
interface IProps {
room: Room;
avatarSize: number;
tag: TagID;
displayBadge?: boolean;
forceCount?: boolean;
}
interface IState {
notificationState?: INotificationState;
}
export default class DecoratedRoomAvatar extends React.PureComponent<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
};
}
public render(): React.ReactNode {
let badge: React.ReactNode;
if (this.props.displayBadge) {
badge = <NotificationBadge
notification={this.state.notificationState}
forceCount={this.props.forceCount}
roomId={this.props.room.roomId}
/>;
}
return <div className="mx_DecoratedRoomAvatar">
<RoomAvatar room={this.props.room} width={this.props.avatarSize} height={this.props.avatarSize} />
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
{badge}
</div>;
}
}

View file

@ -18,6 +18,10 @@ import React from "react";
import classNames from "classnames"; import classNames from "classnames";
import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils"; import { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { readReceiptChangeIsFor } from "../../../utils/read-receipts";
import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState"; import { INotificationState, NOTIFICATION_STATE_UPDATE } from "../../../stores/notifications/INotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor"; import { NotificationColor } from "../../../stores/notifications/NotificationColor";
@ -36,11 +40,18 @@ interface IProps {
roomId?: string; roomId?: string;
} }
interface IClickableProps extends IProps, React.InputHTMLAttributes<Element> {
/**
* If specified will return an AccessibleButton instead of a div.
*/
onClick?(ev: React.MouseEvent);
}
interface IState { interface IState {
showCounts: boolean; // whether or not to show counts. Independent of props.forceCount showCounts: boolean; // whether or not to show counts. Independent of props.forceCount
} }
export default class NotificationBadge extends React.PureComponent<IProps, IState> { export default class NotificationBadge extends React.PureComponent<XOR<IProps, IClickableProps>, IState> {
private countWatcherRef: string; private countWatcherRef: string;
constructor(props: IProps) { constructor(props: IProps) {
@ -83,23 +94,25 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
}; };
public render(): React.ReactElement { public render(): React.ReactElement {
const {notification, forceCount, roomId, onClick, ...props} = this.props;
// Don't show a badge if we don't need to // Don't show a badge if we don't need to
if (this.props.notification.color <= NotificationColor.None) return null; if (notification.color <= NotificationColor.None) return null;
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261 // TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/riot-web/issues/14261
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots". // As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
// See git diff for what that boolean state looks like. // See git diff for what that boolean state looks like.
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots). // XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
const hasNotif = this.props.notification.color >= NotificationColor.Red; const hasNotif = notification.color >= NotificationColor.Red;
const hasCount = this.props.notification.color >= NotificationColor.Grey; const hasCount = notification.color >= NotificationColor.Grey;
const hasAnySymbol = this.props.notification.symbol || this.props.notification.count > 0; const hasAnySymbol = notification.symbol || notification.count > 0;
let isEmptyBadge = !hasAnySymbol || !hasCount; let isEmptyBadge = !hasAnySymbol || !hasCount;
if (this.props.forceCount) { if (forceCount) {
isEmptyBadge = false; isEmptyBadge = false;
if (!hasCount) return null; // Can't render a badge if (!hasCount) return null; // Can't render a badge
} }
let symbol = this.props.notification.symbol || formatMinimalBadgeCount(this.props.notification.count); let symbol = notification.symbol || formatMinimalBadgeCount(notification.count);
if (isEmptyBadge) symbol = ""; if (isEmptyBadge) symbol = "";
const classes = classNames({ const classes = classNames({
@ -111,6 +124,14 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
'mx_NotificationBadge_3char': symbol.length > 2, 'mx_NotificationBadge_3char': symbol.length > 2,
}); });
if (onClick) {
return (
<AccessibleButton {...props} className={classes} onClick={onClick}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</AccessibleButton>
);
}
return ( return (
<div className={classes}> <div className={classes}>
<span className="mx_NotificationBadge_count">{symbol}</span> <span className="mx_NotificationBadge_count">{symbol}</span>

View file

@ -17,13 +17,15 @@ limitations under the License.
import React from "react"; import React from "react";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import RoomAvatar from "../avatars/RoomAvatar"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import Analytics from "../../../Analytics"; import Analytics from "../../../Analytics";
import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { CSSTransition } from "react-transition-group"; import { CSSTransition } from "react-transition-group";
import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { DefaultTagID } from "../../../stores/room-list/models";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -93,6 +95,8 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
// TODO: Scrolling: https://github.com/vector-im/riot-web/issues/14040 // TODO: Scrolling: https://github.com/vector-im/riot-web/issues/14040
// TODO: Tooltips: https://github.com/vector-im/riot-web/issues/14040 // TODO: Tooltips: https://github.com/vector-im/riot-web/issues/14040
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => { const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
const roomTags = RoomListStore.instance.getTagsForRoom(r);
const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0];
return ( return (
<AccessibleButton <AccessibleButton
className="mx_RoomBreadcrumbs2_crumb" className="mx_RoomBreadcrumbs2_crumb"
@ -100,7 +104,13 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
onClick={() => this.viewRoom(r, i)} onClick={() => this.viewRoom(r, i)}
aria-label={_t("Room %(name)s", {name: r.name})} aria-label={_t("Room %(name)s", {name: r.name})}
> >
<RoomAvatar room={r} width={32} height={32}/> <DecoratedRoomAvatar
room={r}
avatarSize={32}
tag={roomTag}
displayBadge={true}
forceCount={true}
/>
</AccessibleButton> </AccessibleButton>
); );
}); });

View file

@ -25,10 +25,15 @@ import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { Dispatcher } from "flux"; import { Dispatcher } from "flux";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2"; import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
import { ListLayout } from "../../../stores/room-list/ListLayout"; import { ListLayout } from "../../../stores/room-list/ListLayout";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar";
import TemporaryTile from "./TemporaryTile";
import { NotificationColor, StaticNotificationState } from "./NotificationBadge";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -173,6 +178,40 @@ export default class RoomList2 extends React.Component<IProps, IState> {
}); });
} }
private renderCommunityInvites(): React.ReactElement[] {
// TODO: Put community invites in a more sensible place (not in the room list)
return MatrixClientPeg.get().getGroups().filter(g => {
if (g.myMembership !== 'invite') return false;
return !this.searchFilter || this.searchFilter.matches(g.name);
}).map(g => {
const avatar = (
<GroupAvatar
groupId={g.groupId}
groupName={g.name}
groupAvatarUrl={g.avatarUrl}
width={32} height={32} resizeMethod='crop'
/>
);
const openGroup = () => {
defaultDispatcher.dispatch({
action: 'view_group',
group_id: g.groupId,
});
};
return (
<TemporaryTile
isMinimized={this.props.isMinimized}
isSelected={false}
displayName={g.name}
avatar={avatar}
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
onClick={openGroup}
key={`temporaryGroupTile_${g.groupId}`}
/>
);
});
}
private renderSublists(): React.ReactElement[] { private renderSublists(): React.ReactElement[] {
const components: React.ReactElement[] = []; const components: React.ReactElement[] = [];
@ -195,6 +234,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`); if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null; const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
components.push( components.push(
<RoomSublist2 <RoomSublist2
key={`sublist-${orderedTagId}`} key={`sublist-${orderedTagId}`}
@ -208,6 +248,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
isInvite={aesthetics.isInvite} isInvite={aesthetics.isInvite}
layout={this.state.layouts.get(orderedTagId)} layout={this.state.layouts.get(orderedTagId)}
isMinimized={this.props.isMinimized} isMinimized={this.props.isMinimized}
extraBadTilesThatShouldntExist={extraTiles}
/> />
); );
} }

View file

@ -32,8 +32,9 @@ import StyledRadioButton from "../elements/StyledRadioButton";
import RoomListStore from "../../../stores/room-list/RoomListStore2"; import RoomListStore from "../../../stores/room-list/RoomListStore2";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models"; import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState"; import dis from "../../../dispatcher/dispatcher";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
import { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
// TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Remove banner on launch: https://github.com/vector-im/riot-web/issues/14231
// TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231 // TODO: Rename on launch: https://github.com/vector-im/riot-web/issues/14231
@ -63,33 +64,35 @@ interface IProps {
isMinimized: boolean; isMinimized: boolean;
tagId: TagID; tagId: TagID;
// TODO: Don't use this. It's for community invites, and community invites shouldn't be here.
// You should feel bad if you use this.
extraBadTilesThatShouldntExist?: React.ReactElement[];
// TODO: Account for https://github.com/vector-im/riot-web/issues/14179 // TODO: Account for https://github.com/vector-im/riot-web/issues/14179
} }
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState { interface IState {
notificationState: ListNotificationState; notificationState: ListNotificationState;
menuDisplayed: boolean; contextMenuPosition: PartialDOMRect;
isResizing: boolean; isResizing: boolean;
} }
export default class RoomSublist2 extends React.Component<IProps, IState> { export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef();
private menuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.state = { this.state = {
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId), notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
menuDisplayed: false, contextMenuPosition: null,
isResizing: false, isResizing: false,
}; };
this.state.notificationState.setRooms(this.props.rooms); this.state.notificationState.setRooms(this.props.rooms);
} }
private get numTiles(): number { private get numTiles(): number {
// TODO: Account for group invites: https://github.com/vector-im/riot-web/issues/14179 return (this.props.rooms || []).length + (this.props.extraBadTilesThatShouldntExist || []).length;
return (this.props.rooms || []).length;
} }
private get numVisibleTiles(): number { private get numVisibleTiles(): number {
@ -139,11 +142,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private onOpenMenuClick = (ev: InputEvent) => { private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({menuDisplayed: true}); const target = ev.target as HTMLButtonElement;
this.setState({contextMenuPosition: target.getBoundingClientRect()});
};
private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
height: 0,
},
});
}; };
private onCloseMenu = () => { private onCloseMenu = () => {
this.setState({menuDisplayed: false}); this.setState({contextMenuPosition: null});
}; };
private onUnreadFirstChanged = async () => { private onUnreadFirstChanged = async () => {
@ -161,6 +177,30 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
this.forceUpdate(); // because the layout doesn't trigger a re-render this.forceUpdate(); // because the layout doesn't trigger a re-render
}; };
private onBadgeClick = (ev: React.MouseEvent) => {
ev.preventDefault();
ev.stopPropagation();
let room;
if (this.props.tagId === DefaultTagID.Invite) {
// switch to first room as that'll be the top of the list for the user
room = this.props.rooms && this.props.rooms[0];
} else {
// find the first room with a count of the same colour as the badge count
room = this.props.rooms.find((r: Room) => {
const notifState = this.state.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.color === this.state.notificationState.color;
});
}
if (room) {
dis.dispatch({
action: 'view_room',
room_id: room.roomId,
});
}
};
private onHeaderClick = (ev: React.MouseEvent<HTMLDivElement>) => { private onHeaderClick = (ev: React.MouseEvent<HTMLDivElement>) => {
let target = ev.target as HTMLDivElement; let target = ev.target as HTMLDivElement;
if (!target.classList.contains('mx_RoomSublist2_headerText')) { if (!target.classList.contains('mx_RoomSublist2_headerText')) {
@ -188,6 +228,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tiles: React.ReactElement[] = []; const tiles: React.ReactElement[] = [];
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
if (this.props.rooms) { if (this.props.rooms) {
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles); const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
for (const room of visibleRooms) { for (const room of visibleRooms) {
@ -203,6 +247,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
} }
// We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra
// tiles are used.
if (tiles.length > this.numVisibleTiles) {
return tiles.slice(0, this.numVisibleTiles);
}
return tiles; return tiles;
} }
@ -213,15 +265,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
} }
let contextMenu = null; let contextMenu = null;
if (this.state.menuDisplayed) { if (this.state.contextMenuPosition) {
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic; const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance; const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu
chevronFace="none" chevronFace="none"
left={elementRect.left} left={this.state.contextMenuPosition.left}
top={elementRect.top + elementRect.height} top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu} onFinished={this.onCloseMenu}
> >
<div className="mx_RoomSublist2_contextMenu"> <div className="mx_RoomSublist2_contextMenu">
@ -272,9 +323,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<ContextMenuButton <ContextMenuButton
className="mx_RoomSublist2_menuButton" className="mx_RoomSublist2_menuButton"
onClick={this.onOpenMenuClick} onClick={this.onOpenMenuClick}
inputRef={this.menuButtonRef}
label={_t("List options")} label={_t("List options")}
isExpanded={this.state.menuDisplayed} isExpanded={!!this.state.contextMenuPosition}
/> />
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>
@ -283,12 +333,19 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private renderHeader(): React.ReactElement { private renderHeader(): React.ReactElement {
return ( return (
<RovingTabIndexWrapper inputRef={this.headerButton}> <RovingTabIndexWrapper>
{({onFocus, isActive, ref}) => { {({onFocus, isActive, ref}) => {
// TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180 // TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
const badge = <NotificationBadge forceCount={true} notification={this.state.notificationState}/>; const badge = (
<NotificationBadge
forceCount={true}
notification={this.state.notificationState}
onClick={this.onBadgeClick}
tabIndex={tabIndex}
/>
);
let addRoomButton = null; let addRoomButton = null;
if (!!this.props.onAddRoom) { if (!!this.props.onAddRoom) {
@ -328,12 +385,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<div className={classes}> <div className={classes}>
<div className='mx_RoomSublist2_stickable'> <div className='mx_RoomSublist2_stickable'>
<AccessibleButton <AccessibleButton
onFocus={onFocus}
inputRef={ref} inputRef={ref}
tabIndex={tabIndex} tabIndex={tabIndex}
className={"mx_RoomSublist2_headerText"} className={"mx_RoomSublist2_headerText"}
role="treeitem" role="treeitem"
aria-level={1} aria-level={1}
onClick={this.onHeaderClick} onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
> >
<span className={collapseClasses} /> <span className={collapseClasses} />
<span>{this.props.label}</span> <span>{this.props.label}</span>
@ -358,7 +417,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const classes = classNames({ const classes = classNames({
'mx_RoomSublist2': true, 'mx_RoomSublist2': true,
'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed, 'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist2_minimized': this.props.isMinimized, 'mx_RoomSublist2_minimized': this.props.isMinimized,
}); });

View file

@ -22,15 +22,18 @@ import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton } from "../../structures/ContextMenu"; import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import RoomTileIcon from "./RoomTileIcon"; import RoomTileIcon from "./RoomTileIcon";
import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { setRoomNotifsState } from "../../../RoomNotifs";
import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState"; import { TagSpecificNotificationState } from "../../../stores/notifications/TagSpecificNotificationState";
import { INotificationState } from "../../../stores/notifications/INotificationState"; import { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge"; import NotificationBadge from "./NotificationBadge";
@ -56,17 +59,51 @@ interface IProps {
// TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177 // TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177
} }
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface IState { interface IState {
hover: boolean; hover: boolean;
notificationState: INotificationState; notificationState: INotificationState;
selected: boolean; selected: boolean;
generalMenuDisplayed: boolean; notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
} }
export default class RoomTile2 extends React.Component<IProps, IState> { const contextMenuBelow = (elementRect: PartialDOMRect) => {
private roomTileRef: React.RefObject<HTMLDivElement> = createRef(); // align the context menu's icons with the icon which opened the context menu
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef(); const left = elementRect.left + window.pageXOffset - 9;
const top = elementRect.bottom + window.pageYOffset + 17;
const chevronFace = "none";
return {left, top, chevronFace};
};
interface INotifOptionProps {
active: boolean;
iconClassName: string;
label: string;
onClick(ev: ButtonEvent);
}
const NotifOption: React.FC<INotifOptionProps> = ({active, onClick, iconClassName, label}) => {
const classes = classNames({
mx_RoomTile2_contextMenu_activeRow: active,
});
let activeIcon;
if (active) {
activeIcon = <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconCheck" />;
}
return (
<MenuItemRadio className={classes} onClick={onClick} active={active} label={label}>
<span className={classNames("mx_IconizedContextMenu_icon", iconClassName)} />
<span className="mx_IconizedContextMenu_label">{ label }</span>
{ activeIcon }
</MenuItemRadio>
);
};
export default class RoomTile2 extends React.Component<IProps, IState> {
// TODO: a11y: https://github.com/vector-im/riot-web/issues/14180 // TODO: a11y: https://github.com/vector-im/riot-web/issues/14180
constructor(props: IProps) { constructor(props: IProps) {
@ -76,7 +113,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
hover: false, hover: false,
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag), notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId, selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
generalMenuDisplayed: false, notificationsMenuPosition: null,
generalMenuPosition: null,
}; };
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate); ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
@ -97,6 +135,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
}; };
private onTileClick = (ev: React.KeyboardEvent) => { private onTileClick = (ev: React.KeyboardEvent) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
// TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233 // TODO: Support show_room_tile in new room list: https://github.com/vector-im/riot-web/issues/14233
@ -110,16 +150,37 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
this.setState({selected: isActive}); this.setState({selected: isActive});
}; };
private onNotificationsMenuOpenClick = (ev: InputEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({notificationsMenuPosition: target.getBoundingClientRect()});
};
private onCloseNotificationsMenu = () => {
this.setState({notificationsMenuPosition: null});
};
private onGeneralMenuOpenClick = (ev: InputEvent) => { private onGeneralMenuOpenClick = (ev: InputEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({generalMenuDisplayed: true}); const target = ev.target as HTMLButtonElement;
this.setState({generalMenuPosition: target.getBoundingClientRect()});
}; };
private onCloseGeneralMenu = (ev: InputEvent) => { private onContextMenu = (ev: React.MouseEvent) => {
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
this.setState({generalMenuDisplayed: false}); this.setState({
generalMenuPosition: {
left: ev.clientX,
bottom: ev.clientY,
},
});
};
private onCloseGeneralMenu = () => {
this.setState({generalMenuPosition: null});
}; };
private onTagRoom = (ev: ButtonEvent, tagId: TagID) => { private onTagRoom = (ev: ButtonEvent, tagId: TagID) => {
@ -138,7 +199,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
action: 'leave_room', action: 'leave_room',
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
this.setState({generalMenuDisplayed: false}); // hide the menu this.setState({generalMenuPosition: null}); // hide the menu
}; };
private onOpenRoomSettings = (ev: ButtonEvent) => { private onOpenRoomSettings = (ev: ButtonEvent) => {
@ -149,9 +210,98 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
action: 'open_room_settings', action: 'open_room_settings',
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
}); });
this.setState({generalMenuDisplayed: false}); // hide the menu this.setState({generalMenuPosition: null}); // hide the menu
}; };
private async saveNotifState(ev: ButtonEvent, newState: ALL_MESSAGES_LOUD | ALL_MESSAGES | MENTIONS_ONLY | MUTE) {
ev.preventDefault();
ev.stopPropagation();
if (MatrixClientPeg.get().isGuest()) return;
try {
// TODO add local echo - https://github.com/vector-im/riot-web/issues/14280
await setRoomNotifsState(this.props.room.roomId, newState);
} catch (error) {
// TODO: some form of error notification to the user to inform them that their state change failed.
// https://github.com/vector-im/riot-web/issues/14281
console.error(error);
}
this.setState({notificationsMenuPosition: null}); // Close the context menu
}
private onClickAllNotifs = ev => this.saveNotifState(ev, ALL_MESSAGES);
private onClickAlertMe = ev => this.saveNotifState(ev, ALL_MESSAGES_LOUD);
private onClickMentions = ev => this.saveNotifState(ev, MENTIONS_ONLY);
private onClickMute = ev => this.saveNotifState(ev, MUTE);
private renderNotificationsMenu(): React.ReactElement {
if (this.props.isMinimized || MatrixClientPeg.get().isGuest() || this.props.tag === DefaultTagID.Invite) {
// the menu makes no sense in these cases so do not show one
return null;
}
const state = getRoomNotifsState(this.props.room.roomId);
let contextMenu = null;
if (this.state.notificationsMenuPosition) {
contextMenu = (
<ContextMenu {...contextMenuBelow(this.state.notificationsMenuPosition)} onFinished={this.onCloseNotificationsMenu}>
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList">
<NotifOption
label={_t("Use default")}
active={state === ALL_MESSAGES}
iconClassName="mx_RoomTile2_iconBell"
onClick={this.onClickAllNotifs}
/>
<NotifOption
label={_t("All messages")}
active={state === ALL_MESSAGES_LOUD}
iconClassName="mx_RoomTile2_iconBellDot"
onClick={this.onClickAlertMe}
/>
<NotifOption
label={_t("Mentions & Keywords")}
active={state === MENTIONS_ONLY}
iconClassName="mx_RoomTile2_iconBellMentions"
onClick={this.onClickMentions}
/>
<NotifOption
label={_t("None")}
active={state === MUTE}
iconClassName="mx_RoomTile2_iconBellCrossed"
onClick={this.onClickMute}
/>
</div>
</div>
</ContextMenu>
);
}
const classes = classNames("mx_RoomTile2_notificationsButton", {
// Show bell icon for the default case too.
mx_RoomTile2_iconBell: state === ALL_MESSAGES_LOUD || state === ALL_MESSAGES,
mx_RoomTile2_iconBellDot: state === MENTIONS_ONLY,
mx_RoomTile2_iconBellCrossed: state === MUTE,
// XXX: RoomNotifs assumes ALL_MESSAGES is default, this is wrong,
// but cannot be fixed until FTUE Notifications lands.
mx_RoomTile2_notificationsButton_show: state !== ALL_MESSAGES,
});
return (
<React.Fragment>
<ContextMenuButton
className={classes}
onClick={this.onNotificationsMenuOpenClick}
label={_t("Notification options")}
isExpanded={!!this.state.notificationsMenuPosition}
/>
{contextMenu}
</React.Fragment>
);
}
private renderGeneralMenu(): React.ReactElement { private renderGeneralMenu(): React.ReactElement {
if (this.props.isMinimized) return null; // no menu when minimized if (this.props.isMinimized) return null; // no menu when minimized
@ -161,51 +311,25 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
} }
let contextMenu = null; let contextMenu = null;
if (this.state.generalMenuDisplayed) { if (this.state.generalMenuPosition) {
// The context menu appears within the list, so use the room tile as a reference point
const elementRect = this.roomTileRef.current.getBoundingClientRect();
contextMenu = ( contextMenu = (
<ContextMenu <ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
chevronFace="none" <div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
left={elementRect.left}
top={elementRect.top + elementRect.height + 8}
onFinished={this.onCloseGeneralMenu}
>
<div
className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu"
style={{width: elementRect.width}}
>
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}> <AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
<span>{_t("Favourite")}</span> <span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span>
</AccessibleButton> </AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.LowPriority)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconArrowDown" />
<span>{_t("Low Priority")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onOpenRoomSettings}> <AccessibleButton onClick={this.onOpenRoomSettings}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span>{_t("Settings")}</span> <span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
</AccessibleButton> </AccessibleButton>
</li>
</ul>
</div> </div>
<div className="mx_IconizedContextMenu_optionList"> <div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
<ul>
<li className="mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}> <AccessibleButton onClick={this.onLeaveRoomClick}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" /> <span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span>{_t("Leave Room")}</span> <span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
</AccessibleButton> </AccessibleButton>
</li>
</ul>
</div> </div>
</div> </div>
</ContextMenu> </ContextMenu>
@ -217,9 +341,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<ContextMenuButton <ContextMenuButton
className="mx_RoomTile2_menuButton" className="mx_RoomTile2_menuButton"
onClick={this.onGeneralMenuOpenClick} onClick={this.onGeneralMenuOpenClick}
inputRef={this.generalMenuButtonRef}
label={_t("Room options")} label={_t("Room options")}
isExpanded={this.state.generalMenuDisplayed} isExpanded={!!this.state.generalMenuPosition}
/> />
{contextMenu} {contextMenu}
</React.Fragment> </React.Fragment>
@ -233,17 +356,25 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const classes = classNames({ const classes = classNames({
'mx_RoomTile2': true, 'mx_RoomTile2': true,
'mx_RoomTile2_selected': this.state.selected, 'mx_RoomTile2_selected': this.state.selected,
'mx_RoomTile2_hasMenuOpen': this.state.generalMenuDisplayed, 'mx_RoomTile2_hasMenuOpen': !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
'mx_RoomTile2_minimized': this.props.isMinimized, 'mx_RoomTile2_minimized': this.props.isMinimized,
}); });
const badge = ( const roomAvatar = <DecoratedRoomAvatar
<NotificationBadge room={this.props.room}
avatarSize={32}
tag={this.props.tag}
displayBadge={this.props.isMinimized}
/>;
let badge: React.ReactNode;
if (!this.props.isMinimized) {
badge = <NotificationBadge
notification={this.state.notificationState} notification={this.state.notificationState}
forceCount={false} forceCount={false}
roomId={this.props.room.roomId} roomId={this.props.room.roomId}
/> />;
); }
// TODO: the original RoomTile uses state for the room name. Do we need to? // TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name; let name = this.props.room.name;
@ -281,10 +412,9 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) nameContainer = null; if (this.props.isMinimized) nameContainer = null;
const avatarSize = 32;
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTileRef}> <RovingTabIndexWrapper>
{({onFocus, isActive, ref}) => {({onFocus, isActive, ref}) =>
<AccessibleButton <AccessibleButton
onFocus={onFocus} onFocus={onFocus}
@ -295,15 +425,14 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onMouseLeave={this.onTileMouseLeave} onMouseLeave={this.onTileMouseLeave}
onClick={this.onTileClick} onClick={this.onTileClick}
role="treeitem" role="treeitem"
onContextMenu={this.onContextMenu}
> >
<div className="mx_RoomTile2_avatarContainer"> {roomAvatar}
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} />
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
</div>
{nameContainer} {nameContainer}
<div className="mx_RoomTile2_badgeContainer"> <div className="mx_RoomTile2_badgeContainer">
{badge} {badge}
</div> </div>
{this.renderNotificationsMenu()}
{this.renderGeneralMenu()} {this.renderGeneralMenu()}
</AccessibleButton> </AccessibleButton>
} }

View file

@ -0,0 +1,114 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton from "../../views/elements/AccessibleButton";
import NotificationBadge, { INotificationState, NotificationColor } from "./NotificationBadge";
interface IProps {
isMinimized: boolean;
isSelected: boolean;
displayName: string;
avatar: React.ReactElement;
notificationState: INotificationState;
onClick: () => void;
}
interface IState {
hover: boolean;
}
export default class TemporaryTile extends React.Component<IProps, IState> {
constructor(props: IProps) {
super(props);
this.state = {
hover: false,
};
}
private onTileMouseEnter = () => {
this.setState({hover: true});
};
private onTileMouseLeave = () => {
this.setState({hover: false});
};
public render(): React.ReactElement {
// XXX: We copy classes because it's easier
const classes = classNames({
'mx_RoomTile2': true,
'mx_RoomTile2_selected': this.props.isSelected,
'mx_RoomTile2_minimized': this.props.isMinimized,
});
const badge = (
<NotificationBadge
notification={this.props.notificationState}
forceCount={false}
/>
);
let name = this.props.displayName;
if (typeof name !== 'string') name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const nameClasses = classNames({
"mx_RoomTile2_name": true,
"mx_RoomTile2_nameHasUnreadEvents": this.props.notificationState.color >= NotificationColor.Bold,
});
let nameContainer = (
<div className="mx_RoomTile2_nameContainer">
<div title={name} className={nameClasses} tabIndex={-1} dir="auto">
{name}
</div>
</div>
);
if (this.props.isMinimized) nameContainer = null;
const avatarSize = 32;
return (
<React.Fragment>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onMouseEnter={this.onTileMouseEnter}
onMouseLeave={this.onTileMouseLeave}
onClick={this.props.onClick}
role="treeitem"
>
<div className="mx_RoomTile2_avatarContainer">
{this.props.avatar}
</div>
{nameContainer}
<div className="mx_RoomTile2_badgeContainer">
{badge}
</div>
</AccessibleButton>
}
</RovingTabIndexWrapper>
</React.Fragment>
);
}
}

View file

@ -1218,8 +1218,11 @@
"%(count)s unread messages.|one": "1 unread message.", "%(count)s unread messages.|one": "1 unread message.",
"Unread mentions.": "Unread mentions.", "Unread mentions.": "Unread mentions.",
"Unread messages.": "Unread messages.", "Unread messages.": "Unread messages.",
"Use default": "Use default",
"All messages": "All messages",
"Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options",
"Favourite": "Favourite", "Favourite": "Favourite",
"Low Priority": "Low Priority",
"Leave Room": "Leave Room", "Leave Room": "Leave Room",
"Room options": "Room options", "Room options": "Room options",
"Add a topic": "Add a topic", "Add a topic": "Add a topic",
@ -1897,10 +1900,10 @@
"Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s", "Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s",
"Notification settings": "Notification settings", "Notification settings": "Notification settings",
"All messages (noisy)": "All messages (noisy)", "All messages (noisy)": "All messages (noisy)",
"All messages": "All messages",
"Mentions only": "Mentions only", "Mentions only": "Mentions only",
"Leave": "Leave", "Leave": "Leave",
"Forget": "Forget", "Forget": "Forget",
"Low Priority": "Low Priority",
"Direct Chat": "Direct Chat", "Direct Chat": "Direct Chat",
"Clear status": "Clear status", "Clear status": "Clear status",
"Update status": "Update status", "Update status": "Update status",

View file

@ -51,7 +51,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
} }
public get visible(): boolean { public get visible(): boolean {
return this.state.enabled; return this.state.enabled && this.matrixClient.getVisibleRooms().length >= 20;
} }
protected async onAction(payload: ActionPayload) { protected async onAction(payload: ActionPayload) {

View file

@ -17,7 +17,7 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import { OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import TagOrderStore from "../TagOrderStore"; import TagOrderStore from "../TagOrderStore";
import { AsyncStore } from "../AsyncStore"; import { AsyncStore } from "../AsyncStore";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
@ -186,7 +186,8 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
const room = this.matrixClient.getRoom(roomId); const room = this.matrixClient.getRoom(roomId);
const tryUpdate = async (updatedRoom: Room) => { const tryUpdate = async (updatedRoom: Room) => {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()} in ${updatedRoom.roomId}`); console.log(`[RoomListDebug] Live timeline event ${eventPayload.event.getId()}` +
` in ${updatedRoom.roomId}`);
if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') { if (eventPayload.event.getType() === 'm.room.tombstone' && eventPayload.event.getStateKey() === '') {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035 // TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
console.log(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`); console.log(`[RoomListDebug] Got tombstone event - trying to remove now-dead room`);
@ -427,6 +428,19 @@ export class RoomListStore2 extends AsyncStore<ActionPayload> {
} }
} }
} }
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if
* the store is not aware of any tags.
* @param room The room to get the tags for.
* @returns The tags for the room.
*/
public getTagsForRoom(room: Room): TagID[] {
const algorithmTags = this.algorithm.getTagsForRoom(room);
if (!algorithmTags) return [DefaultTagID.Untagged];
return algorithmTags;
}
} }
export default class RoomListStore { export default class RoomListStore {

View file

@ -524,7 +524,7 @@ export class Algorithm extends EventEmitter {
} }
} }
private getTagsForRoom(room: Room): TagID[] { public getTagsForRoom(room: Room): TagID[] {
// XXX: This duplicates a lot of logic from setKnownRooms above, but has a slightly // XXX: This duplicates a lot of logic from setKnownRooms above, but has a slightly
// different use case and therefore different performance curve // different use case and therefore different performance curve

View file

@ -60,11 +60,15 @@ export class NameFilterCondition extends EventEmitter implements IFilterConditio
if (!room.name) return false; // should realistically not happen: the js-sdk always calculates a name if (!room.name) return false; // should realistically not happen: the js-sdk always calculates a name
return this.matches(room.name);
}
public matches(val: string): boolean {
// Note: we have to match the filter with the removeHiddenChars() room name because the // Note: we have to match the filter with the removeHiddenChars() room name because the
// function strips spaces and other characters (M becomes RN for example, in lowercase). // function strips spaces and other characters (M becomes RN for example, in lowercase).
// We also doubly convert to lowercase to work around oddities of the library. // We also doubly convert to lowercase to work around oddities of the library.
const noSecretsFilter = removeHiddenChars(lcFilter).toLowerCase(); const noSecretsFilter = removeHiddenChars(this.search.toLowerCase()).toLowerCase();
const noSecretsName = removeHiddenChars(room.name.toLowerCase()).toLowerCase(); const noSecretsName = removeHiddenChars(val.toLowerCase()).toLowerCase();
return noSecretsName.includes(noSecretsFilter); return noSecretsName.includes(noSecretsFilter);
} }
} }