diff --git a/res/css/_common.scss b/res/css/_common.scss index 560bd894c6..6e70618142 100644 --- a/res/css/_common.scss +++ b/res/css/_common.scss @@ -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; } } } diff --git a/res/css/_components.scss b/res/css/_components.scss index afc40ca0d6..8288cf34f6 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index 40babaa9ca..bdaada0d15 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -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; } } diff --git a/res/css/structures/_UserMenu.scss b/res/css/structures/_UserMenu.scss index bbb1e1cc7b..c958b9eacd 100644 --- a/res/css/structures/_UserMenu.scss +++ b/res/css/structures/_UserMenu.scss @@ -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; diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss new file mode 100644 index 0000000000..984fa0ce9a --- /dev/null +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -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; + } +} diff --git a/res/css/views/rooms/_RoomTile2.scss b/res/css/views/rooms/_RoomTile2.scss index 2845068de3..b11eb47d1c 100644 --- a/res/css/views/rooms/_RoomTile2.scss +++ b/res/css/views/rooms/_RoomTile2.scss @@ -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; diff --git a/res/img/feather-customised/bell-crossed.svg b/res/img/feather-customised/bell-crossed.svg new file mode 100644 index 0000000000..3ca24662b9 --- /dev/null +++ b/res/img/feather-customised/bell-crossed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/feather-customised/bell-mentions.custom.svg b/res/img/feather-customised/bell-mentions.custom.svg new file mode 100644 index 0000000000..fcc02f337f --- /dev/null +++ b/res/img/feather-customised/bell-mentions.custom.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/bell-notification.custom.svg b/res/img/feather-customised/bell-notification.custom.svg new file mode 100644 index 0000000000..7bfd551f97 --- /dev/null +++ b/res/img/feather-customised/bell-notification.custom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/res/img/feather-customised/bell.svg b/res/img/feather-customised/bell.svg new file mode 100644 index 0000000000..b6bc5ec502 --- /dev/null +++ b/res/img/feather-customised/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/structures/ContextMenu.js b/src/components/structures/ContextMenu.js index 98b0867ccc..5ba2662796 100644 --- a/src/components/structures/ContextMenu.js +++ b/src/components/structures/ContextMenu.js @@ -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 ( -
+
{ chevron } { props.children } @@ -340,10 +347,18 @@ export class ContextMenu extends React.Component { } // Semantic component for representing the AccessibleButton which launches a -export const ContextMenuButton = ({ label, isExpanded, children, ...props }) => { +export const ContextMenuButton = ({ label, isExpanded, children, onClick, onContextMenu, ...props }) => { const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return ( - + { children } ); diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index 0f614435e5..83d5b9e138 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -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 { }; 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 { 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 { let breadcrumbs; if (this.state.showBreadcrumbs) { breadcrumbs = ( -
+
{this.props.isMinimized ? null : }
); @@ -205,6 +208,11 @@ export default class LeftPanel2 extends React.Component { "mx_LeftPanel2_minimized": this.props.isMinimized, }); + const roomListClasses = classNames( + "mx_LeftPanel2_actualRoomListContainer", + "mx_AutoHideScrollbar", + ); + return (
{tagPanel} @@ -212,7 +220,7 @@ export default class LeftPanel2 extends React.Component { {this.renderHeader()} {this.renderSearchExplore()}
{roomList}
diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 02f08211b9..315c648e15 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -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 diff --git a/src/components/structures/RoomView.js b/src/components/structures/RoomView.js index 19f1cccebd..519c4c1f8e 100644 --- a/src/components/structures/RoomView.js +++ b/src/components/structures/RoomView.js @@ -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 diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index 8c06a06852..1cfe244845 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -42,8 +42,10 @@ interface IProps { isMinimized: boolean; } +type PartialDOMRect = Pick; + interface IState { - menuDisplayed: boolean; + contextMenuPosition: PartialDOMRect; isDarkTheme: boolean; } @@ -56,7 +58,7 @@ export default class UserMenu extends React.Component { super(props); this.state = { - menuDisplayed: false, + contextMenuPosition: null, isDarkTheme: this.isUserOnDarkTheme(), }; @@ -106,13 +108,25 @@ export default class UserMenu extends React.Component { 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 { 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 { 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 { 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 { }; 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 { let homeButton = null; if (this.hasHomePage) { homeButton = ( -
  • - - - {_t("Home")} - -
  • + + + {_t("Home")} + ); } - const elementRect = this.buttonRef.current.getBoundingClientRect(); return (
    @@ -232,49 +244,33 @@ export default class UserMenu extends React.Component {
    {hostingLink}
    -
      - {homeButton} -
    • - this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> - - {_t("Notification settings")} - -
    • -
    • - this.onSettingsOpen(e, USER_SECURITY_TAB)}> - - {_t("Security & privacy")} - -
    • -
    • - this.onSettingsOpen(e, null)}> - - {_t("All settings")} - -
    • -
    • - - - {_t("Archived rooms")} - -
    • -
    • - - - {_t("Feedback")} - -
    • -
    + {homeButton} + this.onSettingsOpen(e, USER_NOTIFICATIONS_TAB)}> + + {_t("Notification settings")} + + this.onSettingsOpen(e, USER_SECURITY_TAB)}> + + {_t("Security & privacy")} + + this.onSettingsOpen(e, null)}> + + {_t("All settings")} + + + + {_t("Archived rooms")} + + + + {_t("Feedback")} +
    -
    -
      -
    • - - - {_t("Sign out")} - -
    • -
    +
    + + + {_t("Sign out")} +
    @@ -307,7 +303,8 @@ export default class UserMenu extends React.Component { onClick={this.onOpenMenuClick} inputRef={this.buttonRef} label={_t("Account settings")} - isExpanded={this.state.menuDisplayed} + isExpanded={!!this.state.contextMenuPosition} + onContextMenu={this.onContextMenu} >
    diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx new file mode 100644 index 0000000000..1156c80313 --- /dev/null +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -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 { + + 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 = ; + } + + return
    + + + {badge} +
    ; + } +} \ No newline at end of file diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index dfc02176c6..829b05fbfc 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -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 { + /** + * 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 { +export default class NotificationBadge extends React.PureComponent, IState> { private countWatcherRef: string; constructor(props: IProps) { @@ -83,23 +94,25 @@ export default class NotificationBadge extends React.PureComponent= 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 2, }); + if (onClick) { + return ( + + {symbol} + + ); + } + return (
    {symbol} diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx index bd12ced6ee..c0417fc592 100644 --- a/src/components/views/rooms/RoomBreadcrumbs2.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -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 { + const roomTags = RoomListStore.instance.getTagsForRoom(r); + const roomTag = roomTags.includes(DefaultTagID.DM) ? DefaultTagID.DM : roomTags[0]; return ( this.viewRoom(r, i)} aria-label={_t("Room %(name)s", {name: r.name})} > - + ); }); diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index a1298e107b..5d7b8ad2c6 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -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 { }); } + 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 = ( + + ); + const openGroup = () => { + defaultDispatcher.dispatch({ + action: 'view_group', + group_id: g.groupId, + }); + }; + return ( + + ); + }); + } + private renderSublists(): React.ReactElement[] { const components: React.ReactElement[] = []; @@ -195,6 +234,7 @@ export default class RoomList2 extends React.Component { 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( { isInvite={aesthetics.isInvite} layout={this.state.layouts.get(orderedTagId)} isMinimized={this.props.isMinimized} + extraBadTilesThatShouldntExist={extraTiles} /> ); } diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index 47c877f2d0..547aa5e67e 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -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; + interface IState { notificationState: ListNotificationState; - menuDisplayed: boolean; + contextMenuPosition: PartialDOMRect; isResizing: boolean; } export default class RoomSublist2 extends React.Component { - private headerButton = createRef(); - private menuButtonRef: React.RefObject = 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 { 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 { 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) => { let target = ev.target as HTMLDivElement; if (!target.classList.contains('mx_RoomSublist2_headerText')) { @@ -188,6 +228,10 @@ export default class RoomSublist2 extends React.Component { 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 { } } + // 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 { } 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 = (
    @@ -272,9 +323,8 @@ export default class RoomSublist2 extends React.Component { {contextMenu} @@ -283,12 +333,19 @@ export default class RoomSublist2 extends React.Component { private renderHeader(): React.ReactElement { return ( - + {({onFocus, isActive, ref}) => { // TODO: Use onFocus: https://github.com/vector-im/riot-web/issues/14180 const tabIndex = isActive ? 0 : -1; - const badge = ; + const badge = ( + + ); let addRoomButton = null; if (!!this.props.onAddRoom) { @@ -328,12 +385,14 @@ export default class RoomSublist2 extends React.Component {
    {this.props.label} @@ -358,7 +417,7 @@ export default class RoomSublist2 extends React.Component { const classes = classNames({ 'mx_RoomSublist2': true, - 'mx_RoomSublist2_hasMenuOpen': this.state.menuDisplayed, + 'mx_RoomSublist2_hasMenuOpen': !!this.state.contextMenuPosition, 'mx_RoomSublist2_minimized': this.props.isMinimized, }); diff --git a/src/components/views/rooms/RoomTile2.tsx b/src/components/views/rooms/RoomTile2.tsx index c331b7f7b1..2647a24412 100644 --- a/src/components/views/rooms/RoomTile2.tsx +++ b/src/components/views/rooms/RoomTile2.tsx @@ -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; + interface IState { hover: boolean; notificationState: INotificationState; selected: boolean; - generalMenuDisplayed: boolean; + notificationsMenuPosition: PartialDOMRect; + generalMenuPosition: PartialDOMRect; } -export default class RoomTile2 extends React.Component { - private roomTileRef: React.RefObject = createRef(); - private generalMenuButtonRef: React.RefObject = 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 = ({active, onClick, iconClassName, label}) => { + const classes = classNames({ + mx_RoomTile2_contextMenu_activeRow: active, + }); + + let activeIcon; + if (active) { + activeIcon = ; + } + + return ( + + + { label } + { activeIcon } + + ); +}; + +export default class RoomTile2 extends React.Component { // 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 { 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 { }; 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 { 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 { 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 { 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 = ( + +
    +
    + + + + +
    +
    +
    + ); + } + + 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 ( + + + {contextMenu} + + ); + } + 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 { } 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 = ( - -
    + +
    -
      -
    • - this.onTagRoom(e, DefaultTagID.Favourite)}> - - {_t("Favourite")} - -
    • -
    • - this.onTagRoom(e, DefaultTagID.LowPriority)}> - - {_t("Low Priority")} - -
    • -
    • - - - {_t("Settings")} - -
    • -
    + this.onTagRoom(e, DefaultTagID.Favourite)}> + + {_t("Favourite")} + + + + {_t("Settings")} +
    -
    -
      -
    • - - - {_t("Leave Room")} - -
    • -
    +
    + + + {_t("Leave Room")} +
    @@ -217,9 +341,8 @@ export default class RoomTile2 extends React.Component { {contextMenu} @@ -233,17 +356,25 @@ export default class RoomTile2 extends React.Component { 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 = ( - ; + + let badge: React.ReactNode; + if (!this.props.isMinimized) { + badge = - ); + />; + } // 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 { ); if (this.props.isMinimized) nameContainer = null; - const avatarSize = 32; return ( - + {({onFocus, isActive, ref}) => { onMouseLeave={this.onTileMouseLeave} onClick={this.onTileClick} role="treeitem" + onContextMenu={this.onContextMenu} > -
    - - -
    + {roomAvatar} {nameContainer}
    {badge}
    + {this.renderNotificationsMenu()} {this.renderGeneralMenu()}
    } diff --git a/src/components/views/rooms/TemporaryTile.tsx b/src/components/views/rooms/TemporaryTile.tsx new file mode 100644 index 0000000000..676969cade --- /dev/null +++ b/src/components/views/rooms/TemporaryTile.tsx @@ -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 { + 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 = ( + + ); + + 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 = ( +
    +
    + {name} +
    +
    + ); + if (this.props.isMinimized) nameContainer = null; + + const avatarSize = 32; + return ( + + + {({onFocus, isActive, ref}) => + +
    + {this.props.avatar} +
    + {nameContainer} +
    + {badge} +
    +
    + } +
    +
    + ); + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9ecd747be9..b23264a297 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 1c47075cbb..c78f15c3b4 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -51,7 +51,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } public get visible(): boolean { - return this.state.enabled; + return this.state.enabled && this.matrixClient.getVisibleRooms().length >= 20; } protected async onAction(payload: ActionPayload) { diff --git a/src/stores/room-list/RoomListStore2.ts b/src/stores/room-list/RoomListStore2.ts index 6de555f234..e5205f6051 100644 --- a/src/stores/room-list/RoomListStore2.ts +++ b/src/stores/room-list/RoomListStore2.ts @@ -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 { 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 { } } } + + /** + * 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 { diff --git a/src/stores/room-list/algorithms/Algorithm.ts b/src/stores/room-list/algorithms/Algorithm.ts index 8215d2ef57..36abf86975 100644 --- a/src/stores/room-list/algorithms/Algorithm.ts +++ b/src/stores/room-list/algorithms/Algorithm.ts @@ -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 diff --git a/src/stores/room-list/filters/NameFilterCondition.ts b/src/stores/room-list/filters/NameFilterCondition.ts index 8625cd932c..12f147990d 100644 --- a/src/stores/room-list/filters/NameFilterCondition.ts +++ b/src/stores/room-list/filters/NameFilterCondition.ts @@ -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); } }