Merge pull request #4862 from matrix-org/joriks/room-list-breadcrumbs

Implement breadcrumb notifications and scrolling
This commit is contained in:
Travis Ralston 2020-07-02 13:24:12 -06:00 committed by GitHub
commit e98f6ca656
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 149 additions and 34 deletions

View file

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

View file

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

View file

@ -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

@ -29,15 +29,8 @@ limitations under the License.
border-radius: 32px; border-radius: 32px;
} }
.mx_RoomTile2_avatarContainer { .mx_DecoratedRoomAvatar {
margin-right: 8px; margin-right: 8px;
position: relative;
.mx_RoomTileIcon {
position: absolute;
bottom: 0;
right: 0;
}
} }
.mx_RoomTile2_nameContainer { .mx_RoomTile2_nameContainer {
@ -155,16 +148,9 @@ limitations under the License.
align-items: center; align-items: center;
position: relative; position: relative;
.mx_RoomTile2_avatarContainer { .mx_DecoratedRoomAvatar {
margin-right: 0; margin-right: 0;
} }
.mx_RoomTile2_badgeContainer {
position: absolute;
top: 0;
right: 0;
height: 18px;
}
} }
} }

View file

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

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

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

View file

@ -22,7 +22,6 @@ import { Room } from "matrix-js-sdk/src/models/room";
import classNames from "classnames"; import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
import RoomAvatar from "../../views/avatars/RoomAvatar";
import dis from '../../../dispatcher/dispatcher'; import dis from '../../../dispatcher/dispatcher';
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import ActiveRoomObserver from "../../../ActiveRoomObserver"; import ActiveRoomObserver from "../../../ActiveRoomObserver";
@ -35,6 +34,7 @@ import { _t } from "../../../languageHandler";
import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu"; import { ContextMenu, ContextMenuButton, MenuItemRadio } from "../../structures/ContextMenu";
import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import RoomTileIcon from "./RoomTileIcon"; import RoomTileIcon from "./RoomTileIcon";
import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs"; import { getRoomNotifsState, ALL_MESSAGES, ALL_MESSAGES_LOUD, MENTIONS_ONLY, MUTE } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
@ -361,13 +361,21 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
'mx_RoomTile2_minimized': this.props.isMinimized, 'mx_RoomTile2_minimized': this.props.isMinimized,
}); });
const badge = ( const roomAvatar = <DecoratedRoomAvatar
<NotificationBadge room={this.props.room}
avatarSize={32}
tag={this.props.tag}
displayBadge={this.props.isMinimized}
/>;
let badge: React.ReactNode;
if (!this.props.isMinimized) {
badge = <NotificationBadge
notification={this.state.notificationState} notification={this.state.notificationState}
forceCount={false} forceCount={false}
roomId={this.props.room.roomId} roomId={this.props.room.roomId}
/> />;
); }
// TODO: the original RoomTile uses state for the room name. Do we need to? // TODO: the original RoomTile uses state for the room name. Do we need to?
let name = this.props.room.name; let name = this.props.room.name;
@ -405,7 +413,6 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
); );
if (this.props.isMinimized) nameContainer = null; if (this.props.isMinimized) nameContainer = null;
const avatarSize = 32;
return ( return (
<React.Fragment> <React.Fragment>
<RovingTabIndexWrapper> <RovingTabIndexWrapper>
@ -421,10 +428,7 @@ export default class RoomTile2 extends React.Component<IProps, IState> {
role="treeitem" role="treeitem"
onContextMenu={this.onContextMenu} onContextMenu={this.onContextMenu}
> >
<div className="mx_RoomTile2_avatarContainer"> {roomAvatar}
<RoomAvatar room={this.props.room} width={avatarSize} height={avatarSize} />
<RoomTileIcon room={this.props.room} tag={this.props.tag} />
</div>
{nameContainer} {nameContainer}
<div className="mx_RoomTile2_badgeContainer"> <div className="mx_RoomTile2_badgeContainer">
{badge} {badge}

View file

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

View file

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

View file

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