Reimplement breadcrumbs for new room list

This all-new component handles breadcrumbs a bit more smoothly for the app by always listening to changes even if the component isn't present. This allows the breadcrumbs to remain up to date for when the user re-enables breadcrumbs.

The new behaviour is that we turn breadcrumbs on once the user has a room, and we don't turn it back off for them.

This also introduces a new animation which is more stable and not laggy, though instead of sliding the breadcrumbs pop. This might be undesirable - to be reviewed.
This commit is contained in:
Travis Ralston 2020-06-08 17:11:58 -06:00
parent 17d98e8956
commit 0354bf9b6d
9 changed files with 384 additions and 6 deletions

View File

@ -175,6 +175,7 @@
@import "./views/rooms/_PresenceLabel.scss";
@import "./views/rooms/_ReplyPreview.scss";
@import "./views/rooms/_RoomBreadcrumbs.scss";
@import "./views/rooms/_RoomBreadcrumbs2.scss";
@import "./views/rooms/_RoomDropTarget.scss";
@import "./views/rooms/_RoomHeader.scss";
@import "./views/rooms/_RoomList.scss";

View File

@ -76,9 +76,9 @@ $roomListMinimizedWidth: 50px;
}
.mx_LeftPanel2_breadcrumbsContainer {
// TODO: Improve CSS for breadcrumbs (currently shoved into the view rather than placed)
width: 100%;
overflow: hidden;
margin-top: 8px;
}
}

View File

@ -0,0 +1,53 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
@keyframes breadcrumb-popin {
0% {
// Ideally we'd use `width` instead of `opacity`, but we only
// have 16 nanoseconds to render the frame, and width is expensive.
opacity: 0;
transform: scale(0);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.mx_RoomBreadcrumbs2 {
// Create a flexbox for the crumbs
display: flex;
flex-direction: row;
align-items: flex-start;
width: 100%;
.mx_RoomBreadcrumbs2_crumb {
margin-right: 8px;
width: 32px;
// React loves to add elements, so only target the one we want to animate
&:first-child {
animation: breadcrumb-popin 0.3s;
}
}
.mx_RoomBreadcrumbs2_placeholder {
font-weight: 600;
font-size: $font-14px;
line-height: 32px; // specifically to match the height this is not scaled
height: 32px;
}
}

View File

@ -26,7 +26,9 @@ import TopLeftMenuButton from "./TopLeftMenuButton";
import { Action } from "../../dispatcher/actions";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import BaseAvatar from '../views/avatars/BaseAvatar';
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import RoomBreadcrumbs2 from "../views/rooms/RoomBreadcrumbs2";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
/*******************************************************************
* CAUTION *
@ -43,6 +45,7 @@ interface IProps {
interface IState {
searchExpanded: boolean;
searchFilter: string; // TODO: Move search into room list?
showBreadcrumbs: boolean;
}
export default class LeftPanel2 extends React.Component<IProps, IState> {
@ -60,7 +63,14 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
this.state = {
searchExpanded: false,
searchFilter: "",
showBreadcrumbs: BreadcrumbsStore.instance.visible,
};
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
public componentWillUnmount() {
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
private onSearch = (term: string): void => {
@ -85,6 +95,13 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
}
}
private onBreadcrumbsUpdate = () => {
const newVal = BreadcrumbsStore.instance.visible;
if (newVal !== this.state.showBreadcrumbs) {
this.setState({showBreadcrumbs: newVal});
}
};
private renderHeader(): React.ReactNode {
// TODO: Update when profile info changes
// TODO: Presence
@ -100,6 +117,16 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
displayName = myUser.rawDisplayName;
avatarUrl = myUser.avatarUrl;
}
let breadcrumbs;
if (this.state.showBreadcrumbs) {
breadcrumbs = (
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
<RoomBreadcrumbs2 />
</div>
);
}
return (
<div className="mx_LeftPanel2_userHeader">
<div className="mx_LeftPanel2_headerRow">
@ -116,9 +143,7 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
</span>
<span className="mx_LeftPanel2_userName">{displayName}</span>
</div>
<div className="mx_LeftPanel2_headerRow mx_LeftPanel2_breadcrumbsContainer">
<RoomBreadcrumbs />
</div>
{breadcrumbs}
</div>
);
}
@ -152,7 +177,6 @@ export default class LeftPanel2 extends React.Component<IProps, IState> {
onBlur={() => {/*TODO*/}}
/>;
// TODO: Breadcrumbs
// TODO: Conference handling / calls
const containerClasses = classNames({

View File

@ -0,0 +1,90 @@
/*
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 { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import AccessibleButton from "../elements/AccessibleButton";
import RoomAvatar from "../avatars/RoomAvatar";
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";
/*******************************************************************
* CAUTION *
*******************************************************************
* This is a work in progress implementation and isn't complete or *
* even useful as a component. Please avoid using it until this *
* warning disappears. *
*******************************************************************/
interface IProps {
}
interface IState {
}
export default class RoomBreadcrumbs2 extends React.PureComponent<IProps, IState> {
private isMounted = true;
constructor(props: IProps) {
super(props);
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
public componentWillUnmount() {
this.isMounted = false;
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
private onBreadcrumbsUpdate = () => {
if (!this.isMounted) return;
this.forceUpdate(); // we have no state, so this is the best we can do
};
private viewRoom = (room: Room, index: number) => {
Analytics.trackEvent("Breadcrumbs", "click_node", index);
defaultDispatcher.dispatch({action: "view_room", room_id: room.roomId});
};
public render(): React.ReactElement {
// TODO: Decorate crumbs with icons
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => {
return (
<AccessibleButton
className="mx_RoomBreadcrumbs2_crumb"
key={r.roomId}
onClick={() => this.viewRoom(r, i)}
aria-label={_t("Room %(name)s", {name: r.name})}
>
<RoomAvatar room={r} width={32} height={32}/>
</AccessibleButton>
)
});
if (tiles.length === 0) {
tiles.push(
<div className="mx_RoomBreadcrumbs2_placeholder">
{_t("No recently visited rooms")}
</div>
);
}
return <div className='mx_RoomBreadcrumbs2'>{tiles}</div>;
}
}

View File

@ -1069,6 +1069,7 @@
"Replying": "Replying",
"Room %(name)s": "Room %(name)s",
"Recent rooms": "Recent rooms",
"No recently visited rooms": "No recently visited rooms",
"No rooms to show": "No rooms to show",
"Unnamed room": "Unnamed room",
"World readable": "World readable",

View File

@ -181,6 +181,8 @@ export default class SettingsStore {
* @param {String} roomId The room ID to monitor for changes in. Use null for all rooms.
*/
static monitorSetting(settingName, roomId) {
roomId = roomId || null; // the thing wants null specifically to work, so appease it.
if (!this._monitors[settingName]) this._monitors[settingName] = {};
const registerWatcher = () => {

View File

@ -0,0 +1,53 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient } from "matrix-js-sdk/src/client";
import { AsyncStore } from "./AsyncStore";
import { ActionPayload } from "../dispatcher/payloads";
export abstract class AsyncStoreWithClient<T extends Object> extends AsyncStore<T> {
protected matrixClient: MatrixClient;
protected abstract async onAction(payload: ActionPayload);
protected async onReady() {
// Default implementation is to do nothing.
}
protected async onNotReady() {
// Default implementation is to do nothing.
}
protected async onDispatch(payload: ActionPayload) {
await this.onAction(payload);
if (payload.action === 'MatrixActions.sync') {
// Filter out anything that isn't the first PREPARED sync.
if (!(payload.prevState === 'PREPARED' && payload.state !== 'PREPARED')) {
return;
}
this.matrixClient = payload.matrixClient;
await this.onReady();
} else if (payload.action === 'on_client_not_viable' || payload.action === 'on_logged_out') {
if (this.matrixClient) {
await this.onNotReady();
this.matrixClient = null;
}
}
}
}

View File

@ -0,0 +1,154 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import SettingsStore, { SettingLevel } from "../settings/SettingsStore";
import { Room } from "matrix-js-sdk/src/models/room";
import { ActionPayload } from "../dispatcher/payloads";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { arrayHasDiff } from "../utils/arrays";
const MAX_ROOMS = 20; // arbitrary
const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up
interface IState {
enabled?: boolean;
rooms?: Room[];
}
export class BreadcrumbsStore extends AsyncStoreWithClient<IState> {
private static internalInstance = new BreadcrumbsStore();
private waitingRooms: { roomId: string, addedTs: number }[] = [];
private constructor() {
super(defaultDispatcher);
SettingsStore.monitorSetting("breadcrumb_rooms", null);
SettingsStore.monitorSetting("breadcrumbs", null);
}
public static get instance(): BreadcrumbsStore {
return BreadcrumbsStore.internalInstance;
}
public get rooms(): Room[] {
return this.state.rooms || [];
}
public get visible(): boolean {
return this.state.enabled;
}
protected async onAction(payload: ActionPayload) {
if (!this.matrixClient) return;
if (payload.action === 'setting_updated') {
if (payload.settingName === 'breadcrumb_rooms') {
await this.updateRooms();
} else if (payload.settingName === 'breadcrumbs') {
await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)});
}
} else if (payload.action === 'view_room') {
if (payload.auto_join && !this.matrixClient.getRoom(payload.room_id)) {
// Queue the room instead of pushing it immediately. We're probably just
// waiting for a room join to complete.
this.waitingRooms.push({roomId: payload.room_id, addedTs: Date.now()});
} else {
await this.appendRoom(this.matrixClient.getRoom(payload.room_id));
}
}
}
protected async onReady() {
await this.updateRooms();
await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)});
this.matrixClient.on("Room.myMembership", this.onMyMembership);
this.matrixClient.on("Room", this.onRoom);
}
protected async onNotReady() {
this.matrixClient.removeListener("Room.myMembership", this.onMyMembership);
this.matrixClient.removeListener("Room", this.onRoom);
}
private onMyMembership = async (room: Room) => {
// We turn on breadcrumbs by default once the user has at least 1 room to show.
if (!this.state.enabled) {
await SettingsStore.setValue("breadcrumbs", null, SettingLevel.ACCOUNT, true);
}
};
private onRoom = async (room: Room) => {
const waitingRoom = this.waitingRooms.find(r => r.roomId === room.roomId);
if (!waitingRoom) return;
this.waitingRooms.splice(this.waitingRooms.indexOf(waitingRoom), 1);
if ((Date.now() - waitingRoom.addedTs) > AUTOJOIN_WAIT_THRESHOLD_MS) return; // Too long ago.
await this.appendRoom(room);
};
private async updateRooms() {
let roomIds = SettingsStore.getValue("breadcrumb_rooms");
if (!roomIds || roomIds.length === 0) roomIds = [];
const rooms = roomIds.map(r => this.matrixClient.getRoom(r)).filter(r => !!r);
const currentRooms = this.state.rooms || [];
if (!arrayHasDiff(rooms, currentRooms)) return; // no change (probably echo)
await this.updateState({rooms});
}
private async appendRoom(room: Room) {
const rooms = this.state.rooms.slice(); // cheap clone
// If the room is upgraded, use that room instead. We'll also splice out
// any children of the room.
const history = this.matrixClient.getRoomUpgradeHistory(room.roomId);
if (history.length > 1) {
room = history[history.length - 1]; // Last room is most recent in history
// Take out any room that isn't the most recent room
for (let i = 0; i < history.length - 1; i++) {
const idx = rooms.findIndex(r => r.roomId === history[i].roomId);
if (idx !== -1) rooms.splice(idx, 1);
}
}
// Remove the existing room, if it is present
const existingIdx = rooms.findIndex(r => r.roomId === room.roomId);
if (existingIdx !== -1) {
rooms.splice(existingIdx, 1);
}
// Splice the room to the start of the list
rooms.splice(0, 0, room);
if (rooms.length > MAX_ROOMS) {
// This looks weird, but it's saying to start at the MAX_ROOMS point in the
// list and delete everything after it.
rooms.splice(MAX_ROOMS, rooms.length - MAX_ROOMS);
}
// Update the breadcrumbs
await this.updateState({rooms});
const roomIds = rooms.map(r => r.roomId);
if (roomIds.length > 0) {
await SettingsStore.setValue("breadcrumb_rooms", null, SettingLevel.ACCOUNT, roomIds);
}
}
}