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.
.mx_IconizedContextMenu {
// Put 20px of padding around the whole menu. We do this instead of a
// simple `padding: 20px` rule so the horizontal rules added by the
// optionLists is rendered correctly (full width).
> * {
padding-left: 20px;
padding-right: 20px;
&:first-child {
padding-top: 20px;
}
&:last-child {
padding-bottom: 16px;
}
}
min-width: 146px;
.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.
&: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,
// 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
@ -631,72 +620,55 @@ input[type=text]:focus, input[type=password]:focus, textarea:focus {
}
}
ul {
list-style: none;
margin: 0;
padding: 0;
// round the top corners of the top button for the hover effect to be bounded
&:first-child .mx_AccessibleButton:first-child {
border-radius: 4px 4px 0 0; // radius matches .mx_ContextualMenu
}
li {
margin: 0;
padding: 12px 0 0;
// round the bottom corners of the bottom button for the hover effect to be bounded
&:last-child .mx_AccessibleButton:last-child {
border-radius: 0 0 4px 4px; // radius matches .mx_ContextualMenu
}
.mx_AccessibleButton {
text-decoration: none;
color: $primary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
.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;
color: $primary-fg-color;
font-size: $font-15px;
line-height: $font-24px;
// Create a flexbox to more easily define the list items
display: flex;
align-items: center;
// Create a flexbox to more easily define the list items
display: flex;
align-items: center;
img, .mx_IconizedContextMenu_icon { // icons
width: 16px;
min-width: 16px;
max-width: 16px;
}
&:hover {
background-color: $menu-selected-color;
}
span:last-child { // labels
padding-left: 14px;
width: 100%;
flex: 1;
img, .mx_IconizedContextMenu_icon { // icons
width: 16px;
min-width: 16px;
max-width: 16px;
}
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
span.mx_IconizedContextMenu_label { // labels
padding-left: 14px;
width: 100%;
flex: 1;
// Ellipsize any text overflow
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
&.mx_IconizedContextMenu_compact {
> * {
padding-left: 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;
}
.mx_IconizedContextMenu_optionList > * {
padding: 8px 16px 8px 11px;
}
}
}

View file

@ -49,6 +49,7 @@
@import "./views/auth/_ServerTypeSelector.scss";
@import "./views/auth/_Welcome.scss";
@import "./views/avatars/_BaseAvatar.scss";
@import "./views/avatars/_DecoratedRoomAvatar.scss";
@import "./views/avatars/_MemberStatusMessageAvatar.scss";
@import "./views/context_menus/_MessageContextMenu.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 {
width: 100%;
overflow: hidden;
overflow-y: hidden;
overflow-x: scroll;
margin-top: 8px;
}
}

View file

@ -86,6 +86,8 @@ limitations under the License.
.mx_UserMenu_contextMenu_redRow {
.mx_AccessibleButton {
padding-top: 16px;
padding-bottom: 16px;
color: $warning-color !important; // !important to override styles from context menu
}
@ -95,6 +97,8 @@ limitations under the License.
}
.mx_UserMenu_contextMenu_header {
padding: 20px;
// Create a flexbox to organize the header a bit easier
display: flex;
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
display: flex;
flex-wrap: wrap;
&.mx_RoomTile2_selected, &:hover, &.mx_RoomTile2_hasMenuOpen {
background-color: $roomtile2-selected-bg-color;
border-radius: 32px;
}
.mx_RoomTile2_avatarContainer {
.mx_DecoratedRoomAvatar {
margin-right: 8px;
position: relative;
.mx_RoomTileIcon {
position: absolute;
bottom: 0;
right: 0;
}
}
.mx_RoomTile2_nameContainer {
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
display: flex;
@ -81,31 +74,39 @@ limitations under the License.
}
}
.mx_RoomTile2_badgeContainer {
width: 18px;
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;
.mx_RoomTile2_menuButton {
margin-left: 4px; // spacing between buttons
}
// The menu button is hidden by default
// TODO: [Notifications] Use mx_RoomTile2_notificationsButton, similar to the following approach:
// https://github.com/matrix-org/matrix-react-sdk/blob/2180a56074f3698fc0241c309a72ba6cad802d1c/res/css/views/rooms/_RoomSublist2.scss#L48-L76
// You'll need to do the same down below on the &:hover selector for the tile.
// See https://github.com/vector-im/riot-web/issues/13961.
// ... also remove this 5 line TODO comment.
.mx_RoomTile2_badgeContainer {
height: 16px;
// don't set width so that it takes no space when there is no badge to show
margin: auto 0; // vertically align
.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_notificationsButton {
width: 0;
height: 0;
visibility: hidden;
width: 20px;
min-width: 20px; // yay flex
height: 20px;
margin: auto 0;
position: relative;
display: none;
&::before {
top: 2px;
left: 2px;
content: '';
width: 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 {
top: 8px;
left: -1px; // this is off-center to align it with the badges
mask-image: url('$(res)/img/feather-customised/more-horizontal.svg');
}
@ -129,13 +133,12 @@ limitations under the License.
.mx_RoomTile2_badgeContainer {
width: 0;
height: 0;
visibility: hidden;
display: none;
}
.mx_RoomTile2_notificationsButton,
.mx_RoomTile2_menuButton {
width: 18px;
height: 32px;
visibility: visible;
display: block;
}
}
}
@ -145,19 +148,29 @@ limitations under the License.
align-items: center;
position: relative;
.mx_RoomTile2_avatarContainer {
.mx_DecoratedRoomAvatar {
margin-right: 0;
}
.mx_RoomTile2_badgeContainer {
position: absolute;
top: 0;
right: 0;
height: 18px;
}
}
}
// We use these both in context menus and the room tiles
.mx_RoomTile2_iconBell::before {
mask-image: url('$(res)/img/feather-customised/bell.svg');
}
.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_redRow {
.mx_AccessibleButton {
@ -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 {
position: relative;
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();
e.preventDefault();
e.stopPropagation();
const x = e.clientX;
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) => {
let descending = false; // are we currently descending or ascending through the DOM tree?
@ -324,7 +331,7 @@ export class ContextMenu extends React.Component {
}
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}>
{ chevron }
{ props.children }
@ -340,10 +347,18 @@ export class ContextMenu extends React.Component {
}
// 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');
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 }
</AccessibleButton>
);

View file

@ -30,6 +30,7 @@ import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import ResizeNotifier from "../../utils/ResizeNotifier";
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: 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);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.tagPanelWatcherRef = SettingsStore.watchSetting("TagPanel.enableTagPanel", null, () => {
this.setState({showTagPanel: SettingsStore.getValue("TagPanel.enableTagPanel")});
});
@ -81,6 +83,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
public componentWillUnmount() {
SettingsStore.unwatchSetting(this.tagPanelWatcherRef);
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onBreadcrumbsUpdate);
this.props.resizeNotifier.off("middlePanelResizedNoisy", this.onResize);
}
@ -151,7 +154,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
let breadcrumbs;
if (this.state.showBreadcrumbs) {
breadcrumbs = (
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer mx_AutoHideScrollbar">
{this.props.isMinimized ? null : <RoomBreadcrumbs2 />}
</div>
);
@ -205,6 +208,11 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
"mx_LeftPanel2_minimized": this.props.isMinimized,
});
const roomListClasses = classNames(
"mx_LeftPanel2_actualRoomListContainer",
"mx_AutoHideScrollbar",
);
return (
<div className={containerClasses}>
{tagPanel}
@ -212,7 +220,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
{this.renderHeader()}
{this.renderSearchExplore()}
<div
className="mx_LeftPanel2_actualRoomListContainer"
className={roomListClasses}
onScroll={this.onScroll}
ref={this.listContainerRef}
>{roomList}</div>

View file

@ -23,7 +23,6 @@ import * as Matrix from "matrix-js-sdk";
import { InvalidStoreError } from "matrix-js-sdk/src/errors";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
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
import 'focus-visible';
// what-input helps improve keyboard accessibility

View file

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

View file

@ -42,8 +42,10 @@ interface IProps {
isMinimized: boolean;
}
type PartialDOMRect = Pick<DOMRect, "width" | "left" | "top" | "height">;
interface IState {
menuDisplayed: boolean;
contextMenuPosition: PartialDOMRect;
isDarkTheme: boolean;
}
@ -56,7 +58,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
super(props);
this.state = {
menuDisplayed: false,
contextMenuPosition: null,
isDarkTheme: this.isUserOnDarkTheme(),
};
@ -106,13 +108,25 @@ export default class UserMenu extends React.Component<IProps, IState> {
private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault();
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.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 = () => {
@ -129,7 +143,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
const payload: OpenToTabPayload = {action: Action.ViewUserSettings, initialTabId: tabId};
defaultDispatcher.dispatch(payload);
this.setState({menuDisplayed: false}); // also close the menu
this.setState({contextMenuPosition: null}); // also close the menu
};
private onShowArchived = (ev: ButtonEvent) => {
@ -145,7 +159,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation();
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) => {
@ -153,7 +167,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
ev.stopPropagation();
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) => {
@ -164,7 +178,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
};
private renderContextMenu = (): React.ReactNode => {
if (!this.state.menuDisplayed) return null;
if (!this.state.contextMenuPosition) return null;
let hostingLink;
const signupLink = getHostingLink("user-context-menu");
@ -191,21 +205,19 @@ export default class UserMenu extends React.Component<IProps, IState> {
let homeButton = null;
if (this.hasHomePage) {
homeButton = (
<li>
<AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
<span>{_t("Home")}</span>
</AccessibleButton>
</li>
<AccessibleButton onClick={this.onHomeClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconHome" />
<span>{_t("Home")}</span>
</AccessibleButton>
);
}
const elementRect = this.buttonRef.current.getBoundingClientRect();
return (
<ContextMenu
chevronFace="none"
left={elementRect.width + elementRect.left}
top={elementRect.top + elementRect.height}
// -20 to overlap the context menu by just over the width of the `...` icon and make it look connected
left={this.state.contextMenuPosition.width + this.state.contextMenuPosition.left - 20}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
>
<div className="mx_IconizedContextMenu mx_UserMenu_contextMenu">
@ -232,49 +244,33 @@ export default class UserMenu extends React.Component<IProps, IState> {
</div>
{hostingLink}
<div className="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst">
<ul>
{homeButton}
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
<span>{_t("Notification settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
<span>{_t("Security & privacy")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
<span>{_t("All settings")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
<span>{_t("Archived rooms")}</span>
</AccessibleButton>
</li>
<li>
<AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
<span>{_t("Feedback")}</span>
</AccessibleButton>
</li>
</ul>
{homeButton}
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconBell" />
<span className="mx_IconizedContextMenu_label">{_t("Notification settings")}</span>
</AccessibleButton>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, USER_SECURITY_TAB)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconLock" />
<span className="mx_IconizedContextMenu_label">{_t("Security & privacy")}</span>
</AccessibleButton>
<AccessibleButton onClick={(e) => this.onSettingsOpen(e, null)}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSettings" />
<span className="mx_IconizedContextMenu_label">{_t("All settings")}</span>
</AccessibleButton>
<AccessibleButton onClick={this.onShowArchived}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconArchive" />
<span className="mx_IconizedContextMenu_label">{_t("Archived rooms")}</span>
</AccessibleButton>
<AccessibleButton onClick={this.onProvideFeedback}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconMessage" />
<span className="mx_IconizedContextMenu_label">{_t("Feedback")}</span>
</AccessibleButton>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li className="mx_UserMenu_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
<span>{_t("Sign out")}</span>
</AccessibleButton>
</li>
</ul>
<div className="mx_IconizedContextMenu_optionList mx_UserMenu_contextMenu_redRow">
<AccessibleButton onClick={this.onSignOutClick}>
<span className="mx_IconizedContextMenu_icon mx_UserMenu_iconSignOut" />
<span className="mx_IconizedContextMenu_label">{_t("Sign out")}</span>
</AccessibleButton>
</div>
</div>
</ContextMenu>
@ -307,7 +303,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
onClick={this.onOpenMenuClick}
inputRef={this.buttonRef}
label={_t("Account settings")}
isExpanded={this.state.menuDisplayed}
isExpanded={!!this.state.contextMenuPosition}
onContextMenu={this.onContextMenu}
>
<div className="mx_UserMenu_row">
<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 { formatMinimalBadgeCount } from "../../../utils/FormattingUtils";
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 { NotificationColor } from "../../../stores/notifications/NotificationColor";
@ -36,11 +40,18 @@ interface IProps {
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 {
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;
constructor(props: IProps) {
@ -83,23 +94,25 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
};
public render(): React.ReactElement {
const {notification, forceCount, roomId, onClick, ...props} = this.props;
// 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
// 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.
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
const hasNotif = this.props.notification.color >= NotificationColor.Red;
const hasCount = this.props.notification.color >= NotificationColor.Grey;
const hasAnySymbol = this.props.notification.symbol || this.props.notification.count > 0;
const hasNotif = notification.color >= NotificationColor.Red;
const hasCount = notification.color >= NotificationColor.Grey;
const hasAnySymbol = notification.symbol || notification.count > 0;
let isEmptyBadge = !hasAnySymbol || !hasCount;
if (this.props.forceCount) {
if (forceCount) {
isEmptyBadge = false;
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 = "";
const classes = classNames({
@ -111,6 +124,14 @@ export default class NotificationBadge extends React.PureComponent<IProps, IStat
'mx_NotificationBadge_3char': symbol.length > 2,
});
if (onClick) {
return (
<AccessibleButton {...props} className={classes} onClick={onClick}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</AccessibleButton>
);
}
return (
<div className={classes}>
<span className="mx_NotificationBadge_count">{symbol}</span>

View file

@ -17,13 +17,15 @@ limitations under the License.
import React from "react";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import AccessibleButton from "../elements/AccessibleButton";
import RoomAvatar from "../avatars/RoomAvatar";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler";
import { Room } from "matrix-js-sdk/src/models/room";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import Analytics from "../../../Analytics";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
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: 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: Tooltips: https://github.com/vector-im/riot-web/issues/14040
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 (
<AccessibleButton
className="mx_RoomBreadcrumbs2_crumb"
@ -100,7 +104,13 @@ export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState
onClick={() => this.viewRoom(r, i)}
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>
);
});

View file

@ -25,10 +25,15 @@ 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 defaultDispatcher from "../../../dispatcher/dispatcher";
import RoomSublist2 from "./RoomSublist2";
import { ActionPayload } from "../../../dispatcher/payloads";
import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition";
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: 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[] {
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`);
const onAddRoomFn = aesthetics.onAddRoom ? () => aesthetics.onAddRoom(dis) : null;
const extraTiles = orderedTagId === DefaultTagID.Invite ? this.renderCommunityInvites() : null;
components.push(
<RoomSublist2
key={`sublist-${orderedTagId}`}
@ -208,6 +248,7 @@ export default class RoomList2 extends React.Component<IProps, IState> {
isInvite={aesthetics.isInvite}
layout={this.state.layouts.get(orderedTagId)}
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 { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/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 { ListNotificationState } from "../../../stores/notifications/ListNotificationState";
// 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
@ -63,33 +64,35 @@ interface IProps {
isMinimized: boolean;
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
}
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState {
notificationState: ListNotificationState;
menuDisplayed: boolean;
contextMenuPosition: PartialDOMRect;
isResizing: boolean;
}
export default class RoomSublist2 extends React.Component<IProps, IState> {
private headerButton = createRef();
private menuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
constructor(props: IProps) {
super(props);
this.state = {
notificationState: new ListNotificationState(this.props.isInvite, this.props.tagId),
menuDisplayed: false,
contextMenuPosition: null,
isResizing: false,
};
this.state.notificationState.setRooms(this.props.rooms);
}
private get numTiles(): number {
// TODO: Account for group invites: https://github.com/vector-im/riot-web/issues/14179
return (this.props.rooms || []).length;
return (this.props.rooms || []).length + (this.props.extraBadTilesThatShouldntExist || []).length;
}
private get numVisibleTiles(): number {
@ -139,11 +142,24 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private onOpenMenuClick = (ev: InputEvent) => {
ev.preventDefault();
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 = () => {
this.setState({menuDisplayed: false});
this.setState({contextMenuPosition: null});
};
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
};
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>) => {
let target = ev.target as HTMLDivElement;
if (!target.classList.contains('mx_RoomSublist2_headerText')) {
@ -188,6 +228,10 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const tiles: React.ReactElement[] = [];
if (this.props.extraBadTilesThatShouldntExist) {
tiles.push(...this.props.extraBadTilesThatShouldntExist);
}
if (this.props.rooms) {
const visibleRooms = this.props.rooms.slice(0, this.numVisibleTiles);
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;
}
@ -213,15 +265,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
}
let contextMenu = null;
if (this.state.menuDisplayed) {
const elementRect = this.menuButtonRef.current.getBoundingClientRect();
if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
contextMenu = (
<ContextMenu
chevronFace="none"
left={elementRect.left}
top={elementRect.top + elementRect.height}
left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
>
<div className="mx_RoomSublist2_contextMenu">
@ -272,9 +323,8 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<ContextMenuButton
className="mx_RoomSublist2_menuButton"
onClick={this.onOpenMenuClick}
inputRef={this.menuButtonRef}
label={_t("List options")}
isExpanded={this.state.menuDisplayed}
isExpanded={!!this.state.contextMenuPosition}
/>
{contextMenu}
</React.Fragment>
@ -283,12 +333,19 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
private renderHeader(): React.ReactElement {
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) => {
// TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180
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;
if (!!this.props.onAddRoom) {
@ -328,12 +385,14 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
<div className={classes}>
<div className='mx_RoomSublist2_stickable'>
<AccessibleButton
onFocus={onFocus}
inputRef={ref}
tabIndex={tabIndex}
className={"mx_RoomSublist2_headerText"}
role="treeitem"
aria-level={1}
onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
>
<span className={collapseClasses} />
<span>{this.props.label}</span>
@ -358,7 +417,7 @@ export default class RoomSublist2 extends React.Component<IProps, IState> {
const classes = classNames({
'mx_RoomSublist2': true,
'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed,
'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition,
'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 { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver";
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 { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
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 { INotificationState } from "../../../stores/notifications/INotificationState";
import NotificationBadge from "./NotificationBadge";
@ -56,17 +59,51 @@ interface IProps {
// TODO: Incoming call boxes: https://github.com/vector-im/riot-web/issues/14177
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface IState {
hover: boolean;
notificationState: INotificationState;
selected: boolean;
generalMenuDisplayed: boolean;
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
}
export default class RoomTile2 extends React.Component<IProps, IState> {
private roomTileRef: React.RefObject<HTMLDivElement> = createRef();
private generalMenuButtonRef: React.RefObject<HTMLButtonElement> = createRef();
const contextMenuBelow = (elementRect: PartialDOMRect) => {
// align the context menu's icons with the icon which opened the context menu
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
constructor(props: IProps) {
@ -76,7 +113,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
hover: false,
notificationState: new TagSpecificNotificationState(this.props.room, this.props.tag),
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
generalMenuDisplayed: false,
notificationsMenuPosition: null,
generalMenuPosition: null,
};
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) => {
ev.preventDefault();
ev.stopPropagation();
dis.dispatch({
action: 'view_room',
// 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});
};
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) => {
ev.preventDefault();
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.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) => {
@ -138,7 +199,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
action: 'leave_room',
room_id: this.props.room.roomId,
});
this.setState({generalMenuDisplayed: false}); // hide the menu
this.setState({generalMenuPosition: null}); // hide the menu
};
private onOpenRoomSettings = (ev: ButtonEvent) => {
@ -149,9 +210,98 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
action: 'open_room_settings',
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 {
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;
if (this.state.generalMenuDisplayed) {
// The context menu appears within the list, so use the room tile as a reference point
const elementRect = this.roomTileRef.current.getBoundingClientRect();
if (this.state.generalMenuPosition) {
contextMenu = (
<ContextMenu
chevronFace="none"
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}}
>
<ContextMenu {...contextMenuBelow(this.state.generalMenuPosition)} onFinished={this.onCloseGeneralMenu}>
<div className="mx_IconizedContextMenu mx_IconizedContextMenu_compact mx_RoomTile2_contextMenu">
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
<span>{_t("Favourite")}</span>
</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}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span>{_t("Settings")}</span>
</AccessibleButton>
</li>
</ul>
<AccessibleButton onClick={(e) => this.onTagRoom(e, DefaultTagID.Favourite)}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconStar" />
<span className="mx_IconizedContextMenu_label">{_t("Favourite")}</span>
</AccessibleButton>
<AccessibleButton onClick={this.onOpenRoomSettings}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSettings" />
<span className="mx_IconizedContextMenu_label">{_t("Settings")}</span>
</AccessibleButton>
</div>
<div className="mx_IconizedContextMenu_optionList">
<ul>
<li className="mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span>{_t("Leave Room")}</span>
</AccessibleButton>
</li>
</ul>
<div className="mx_IconizedContextMenu_optionList mx_RoomTile2_contextMenu_redRow">
<AccessibleButton onClick={this.onLeaveRoomClick}>
<span className="mx_IconizedContextMenu_icon mx_RoomTile2_iconSignOut" />
<span className="mx_IconizedContextMenu_label">{_t("Leave Room")}</span>
</AccessibleButton>
</div>
</div>
</ContextMenu>
@ -217,9 +341,8 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
<ContextMenuButton
className="mx_RoomTile2_menuButton"
onClick={this.onGeneralMenuOpenClick}
inputRef={this.generalMenuButtonRef}
label={_t("Room options")}
isExpanded={this.state.generalMenuDisplayed}
isExpanded={!!this.state.generalMenuPosition}
/>
{contextMenu}
</React.Fragment>
@ -233,17 +356,25 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
const classes = classNames({
'mx_RoomTile2': true,
'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,
});
const badge = (
<NotificationBadge
const roomAvatar = <DecoratedRoomAvatar
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}
forceCount={false}
roomId={this.props.room.roomId}
/>
);
/>;
}
// TODO: the original RoomTile uses state for the room name. Do we need to?
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;
const avatarSize = 32;
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
@ -295,15 +425,14 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
onMouseLeave={this.onTileMouseLeave}
onClick={this.onTileClick}
role="treeitem"
onContextMenu={this.onContextMenu}
>
<div className="mx_RoomTile2_avatarContainer">
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} />
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
</div>
{roomAvatar}
{nameContainer}
<div className="mx_RoomTile2_badgeContainer">
{badge}
</div>
{this.renderNotificationsMenu()}
{this.renderGeneralMenu()}
</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.",
"Unread mentions.": "Unread mentions.",
"Unread messages.": "Unread messages.",
"Use default": "Use default",
"All messages": "All messages",
"Mentions & Keywords": "Mentions & Keywords",
"Notification options": "Notification options",
"Favourite": "Favourite",
"Low Priority": "Low Priority",
"Leave Room": "Leave Room",
"Room options": "Room options",
"Add a topic": "Add a topic",
@ -1897,10 +1900,10 @@
"Failed to forget room %(errCode)s": "Failed to forget room %(errCode)s",
"Notification settings": "Notification settings",
"All messages (noisy)": "All messages (noisy)",
"All messages": "All messages",
"Mentions only": "Mentions only",
"Leave": "Leave",
"Forget": "Forget",
"Low Priority": "Low Priority",
"Direct Chat": "Direct Chat",
"Clear status": "Clear status",
"Update status": "Update status",

View file

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

View file

@ -17,7 +17,7 @@ limitations under the License.
import { MatrixClient } from "matrix-js-sdk/src/client";
import SettingsStore from "../../settings/SettingsStore";
import { OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import TagOrderStore from "../TagOrderStore";
import { AsyncStore } from "../AsyncStore";
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 tryUpdate = async (updatedRoom: Room) => {
// 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() === '') {
// TODO: Remove debug: https://github.com/vector-im/riot-web/issues/14035
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 {

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
// 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
return this.matches(room.name);
}
public matches(val: string): boolean {
// 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).
// We also doubly convert to lowercase to work around oddities of the library.
const noSecretsFilter = removeHiddenChars(lcFilter).toLowerCase();
const noSecretsName = removeHiddenChars(room.name.toLowerCase()).toLowerCase();
const noSecretsFilter = removeHiddenChars(this.search.toLowerCase()).toLowerCase();
const noSecretsName = removeHiddenChars(val.toLowerCase()).toLowerCase();
return noSecretsName.includes(noSecretsFilter);
}
}