From 0354bf9b6d432354c4c11f376a1c82b4a9705e0e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 8 Jun 2020 17:11:58 -0600 Subject: [PATCH 1/7] 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. --- res/css/_components.scss | 1 + res/css/structures/_LeftPanel2.scss | 2 +- res/css/views/rooms/_RoomBreadcrumbs2.scss | 53 ++++++ src/components/structures/LeftPanel2.tsx | 34 +++- .../views/rooms/RoomBreadcrumbs2.tsx | 90 ++++++++++ src/i18n/strings/en_EN.json | 1 + src/settings/SettingsStore.js | 2 + src/stores/AsyncStoreWithClient.ts | 53 ++++++ src/stores/BreadcrumbsStore.ts | 154 ++++++++++++++++++ 9 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 res/css/views/rooms/_RoomBreadcrumbs2.scss create mode 100644 src/components/views/rooms/RoomBreadcrumbs2.tsx create mode 100644 src/stores/AsyncStoreWithClient.ts create mode 100644 src/stores/BreadcrumbsStore.ts diff --git a/res/css/_components.scss b/res/css/_components.scss index 62bec5ad62..8958aee2fc 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -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"; diff --git a/res/css/structures/_LeftPanel2.scss b/res/css/structures/_LeftPanel2.scss index 822a5ac399..502ed18a87 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -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; } } diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss new file mode 100644 index 0000000000..aa0b0ecb08 --- /dev/null +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -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; + } +} diff --git a/src/components/structures/LeftPanel2.tsx b/src/components/structures/LeftPanel2.tsx index c66c0a6799..b42da0be09 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -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 { @@ -60,7 +63,14 @@ export default class LeftPanel2 extends React.Component { 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 { } } + 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 { displayName = myUser.rawDisplayName; avatarUrl = myUser.avatarUrl; } + + let breadcrumbs; + if (this.state.showBreadcrumbs) { + breadcrumbs = ( +
+ +
+ ); + } + return (
@@ -116,9 +143,7 @@ export default class LeftPanel2 extends React.Component { {displayName}
-
- -
+ {breadcrumbs}
); } @@ -152,7 +177,6 @@ export default class LeftPanel2 extends React.Component { onBlur={() => {/*TODO*/}} />; - // TODO: Breadcrumbs // TODO: Conference handling / calls const containerClasses = classNames({ diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx new file mode 100644 index 0000000000..195757ccf0 --- /dev/null +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -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 { + 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 ( + this.viewRoom(r, i)} + aria-label={_t("Room %(name)s", {name: r.name})} + > + + + ) + }); + + if (tiles.length === 0) { + tiles.push( +
+ {_t("No recently visited rooms")} +
+ ); + } + + return
{tiles}
; + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cf6dc2431a..75caf5b593 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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", diff --git a/src/settings/SettingsStore.js b/src/settings/SettingsStore.js index 4b18a27c6c..dcdde46631 100644 --- a/src/settings/SettingsStore.js +++ b/src/settings/SettingsStore.js @@ -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 = () => { diff --git a/src/stores/AsyncStoreWithClient.ts b/src/stores/AsyncStoreWithClient.ts new file mode 100644 index 0000000000..ce7fd45eec --- /dev/null +++ b/src/stores/AsyncStoreWithClient.ts @@ -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 extends AsyncStore { + 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; + } + } + } +} diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts new file mode 100644 index 0000000000..783b38e62f --- /dev/null +++ b/src/stores/BreadcrumbsStore.ts @@ -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 { + 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); + } + } + +} From 04566e12b202c50d10045fda65bdaf0e288bfecc Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 8 Jun 2020 17:14:40 -0600 Subject: [PATCH 2/7] Fix indentation in styles --- res/css/views/rooms/_RoomBreadcrumbs2.scss | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss index aa0b0ecb08..2db0fdca08 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs2.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -15,16 +15,16 @@ 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); - } + 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 { From eff97e6c205ecdcce0bc40714fe51ca6f4e6d960 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 8 Jun 2020 18:18:34 -0600 Subject: [PATCH 3/7] Fix the tests --- src/stores/BreadcrumbsStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 783b38e62f..5944091d00 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -113,7 +113,7 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } private async appendRoom(room: Room) { - const rooms = this.state.rooms.slice(); // cheap clone + 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. From 5083811deb8a2a1fc53acd331c667a0926bc5bce Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 8 Jun 2020 18:26:43 -0600 Subject: [PATCH 4/7] Appease the tests --- src/stores/BreadcrumbsStore.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index 5944091d00..f0f2dad91b 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -68,7 +68,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { // 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)); + // The tests might not result in a valid room object. + const room = this.matrixClient.getRoom(payload.room_id); + if (room) await this.appendRoom(room); } } } From 708c65cd965f9da3fd8ada635a71a116d710b292 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 8 Jun 2020 19:08:18 -0600 Subject: [PATCH 5/7] Disable new breadcrumb store when old room list is in use --- src/stores/BreadcrumbsStore.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/stores/BreadcrumbsStore.ts b/src/stores/BreadcrumbsStore.ts index f0f2dad91b..332fa7fe2e 100644 --- a/src/stores/BreadcrumbsStore.ts +++ b/src/stores/BreadcrumbsStore.ts @@ -20,6 +20,7 @@ import { ActionPayload } from "../dispatcher/payloads"; import { AsyncStoreWithClient } from "./AsyncStoreWithClient"; import defaultDispatcher from "../dispatcher/dispatcher"; import { arrayHasDiff } from "../utils/arrays"; +import { RoomListStoreTempProxy } from "./room-list/RoomListStoreTempProxy"; const MAX_ROOMS = 20; // arbitrary const AUTOJOIN_WAIT_THRESHOLD_MS = 90000; // 90s, the time we wait for an autojoined room to show up @@ -56,6 +57,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { protected async onAction(payload: ActionPayload) { if (!this.matrixClient) return; + // TODO: Remove when new room list is made the default + if (!RoomListStoreTempProxy.isUsingNewStore()) return; + if (payload.action === 'setting_updated') { if (payload.settingName === 'breadcrumb_rooms') { await this.updateRooms(); @@ -76,6 +80,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } protected async onReady() { + // TODO: Remove when new room list is made the default + if (!RoomListStoreTempProxy.isUsingNewStore()) return; + await this.updateRooms(); await this.updateState({enabled: SettingsStore.getValue("breadcrumbs", null)}); @@ -84,6 +91,9 @@ export class BreadcrumbsStore extends AsyncStoreWithClient { } protected async onNotReady() { + // TODO: Remove when new room list is made the default + if (!RoomListStoreTempProxy.isUsingNewStore()) return; + this.matrixClient.removeListener("Room.myMembership", this.onMyMembership); this.matrixClient.removeListener("Room", this.onRoom); } From 1467191a5d8d3fd9aefa5639aa77aebdd81d0dd9 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 9 Jun 2020 15:06:54 -0600 Subject: [PATCH 6/7] Update the CSS transition for breadcrumbs The actual transition length might need adjusting, but this is fairly close to what was requested. --- package.json | 2 + res/css/views/rooms/_RoomBreadcrumbs2.scss | 34 ++++++------- .../views/rooms/RoomBreadcrumbs2.tsx | 49 ++++++++++++++++--- tsconfig.json | 3 +- yarn.lock | 29 ++++++++++- 5 files changed, 89 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 93d59a4fa6..966119d1eb 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "react-dom": "^16.9.0", "react-focus-lock": "^2.2.1", "react-resizable": "^1.10.1", + "react-transition-group": "^4.4.1", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", "text-encoding-utf-8": "^1.0.1", @@ -126,6 +127,7 @@ "@types/qrcode": "^1.3.4", "@types/react": "^16.9", "@types/react-dom": "^16.9.8", + "@types/react-transition-group": "^4.4.0", "@types/zxcvbn": "^4.4.0", "babel-eslint": "^10.0.3", "babel-jest": "^24.9.0", diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss index 2db0fdca08..68cf7d7500 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs2.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -14,34 +14,32 @@ 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 { + width: 100%; + // 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; - } + // These classes come from the CSSTransition component. There's many more classes we + // could care about, but this is all we worried about for now. The animation works by + // first triggering the enter state with the newest breadcrumb off screen (-40px) then + // sliding it into view. + &.mx_RoomBreadcrumbs2-enter { + margin-left: -40px; // 32px for the avatar, 8px for the margin + } + &.mx_RoomBreadcrumbs2-enter-active { + margin-left: 0; + + // Timing function is as-requested by design. + // NOTE: The transition time MUST match the value passed to CSSTransition! + transition: margin-left 300ms cubic-bezier(0.66, 0.02, 0.36, 1); } .mx_RoomBreadcrumbs2_placeholder { diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx index 195757ccf0..197170018a 100644 --- a/src/components/views/rooms/RoomBreadcrumbs2.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -23,6 +23,7 @@ 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, TransitionGroup } from "react-transition-group"; /******************************************************************* * CAUTION * @@ -36,6 +37,14 @@ interface IProps { } interface IState { + // Both of these control the animation for the breadcrumbs. For details on the + // actual animation, see the CSS. + // + // doAnimation is to lie to the CSSTransition component (see onBreadcrumbsUpdate + // for info). skipFirst is used to try and reduce jerky animation - also see the + // breadcrumb update function for info on that. + doAnimation: boolean; + skipFirst: boolean; } export default class RoomBreadcrumbs2 extends React.PureComponent { @@ -44,6 +53,11 @@ export default class RoomBreadcrumbs2 extends React.PureComponent { if (!this.isMounted) return; - this.forceUpdate(); // we have no state, so this is the best we can do + + // We need to trick the CSSTransition component into updating, which means we need to + // tell it to not animate, then to animate a moment later. This causes two updates + // which means two renders. The skipFirst change is so that our don't-animate state + // doesn't show the breadcrumb we're about to reveal as it causes a visual jump/jerk. + // The second update, on the next available tick, causes the "enter" animation to start + // again and this time we want to show the newest breadcrumb because it'll be hidden + // off screen for the animation. + this.setState({doAnimation: false, skipFirst: true}); + setTimeout(() => this.setState({doAnimation: true, skipFirst: false}), 0); }; private viewRoom = (room: Room, index: number) => { @@ -77,14 +100,26 @@ export default class RoomBreadcrumbs2 extends React.PureComponent - {_t("No recently visited rooms")} + if (tiles.length > 0) { + // NOTE: The CSSTransition timeout MUST match the timeout in our CSS! + return ( + +
+ {tiles.slice(this.state.skipFirst ? 1 : 0)} +
+
+ ); + } else { + return ( +
+
+ {_t("No recently visited rooms")} +
); } - - return
{tiles}
; } } diff --git a/tsconfig.json b/tsconfig.json index 8a01ca335e..db040d1f31 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,7 +15,8 @@ "types": [ "node", "react", - "flux" + "flux", + "react-transition-group" ] }, "include": [ diff --git a/yarn.lock b/yarn.lock index 333c5ccf20..32f8d3093e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -968,7 +968,7 @@ core-js-pure "^3.0.0" regenerator-runtime "^0.13.4" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7": version "7.10.2" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.2.tgz#d103f21f2602497d38348a32e008637d506db839" integrity sha512-6sF3uQw2ivImfVIl62RZ7MXhO2tap69WeWK57vAaimT6AZbE4FbqjdEJIN1UqoD6wI6B+1n9UiagafH1sxjOtg== @@ -1352,6 +1352,13 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.0.tgz#882839db465df1320e4753e6e9f70ca7e9b4d46d" + integrity sha512-/QfLHGpu+2fQOqQaXh8MG9q03bFENooTb/it4jr5kKaZlDQfWvjqWZg48AwzPVMBHlRuTRAY7hRHCEOXz5kV6w== + dependencies: + "@types/react" "*" + "@types/react@*", "@types/react@^16.9": version "16.9.35" resolved "https://registry.yarnpkg.com/@types/react/-/react-16.9.35.tgz#a0830d172e8aadd9bd41709ba2281a3124bbd368" @@ -2835,7 +2842,7 @@ cssstyle@^1.0.0: dependencies: cssom "0.3.x" -csstype@^2.2.0: +csstype@^2.2.0, csstype@^2.6.7: version "2.6.10" resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.10.tgz#e63af50e66d7c266edb6b32909cfd0aabe03928b" integrity sha512-D34BqZU4cIlMCY93rZHbrq9pjTAQJ3U8S8rfBqjwHxkGPThWFjzZDQpgMJY0QViLxth6ZKYiwFBo14RdN44U/w== @@ -3054,6 +3061,14 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-helpers@^5.0.1: + version "5.1.4" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.1.4.tgz#4609680ab5c79a45f2531441f1949b79d6587f4b" + integrity sha512-TjMyeVUvNEnOnhzs6uAn9Ya47GmMo3qq7m+Lr/3ON0Rs5kHvb8I+SQYjLUSYn7qhEm0QjW0yrBkvz9yOrwwz1A== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^2.6.7" + dom-serializer@0, dom-serializer@^0.2.1: version "0.2.2" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.2.2.tgz#1afb81f533717175d478655debc5e332d9f9bb51" @@ -7136,6 +7151,16 @@ react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: react-is "^16.8.6" scheduler "^0.19.1" +react-transition-group@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.1.tgz#63868f9325a38ea5ee9535d828327f85773345c9" + integrity sha512-Djqr7OQ2aPUiYurhPalTrVy9ddmFCCzwhqQmtN+J3+3DzLO209Fdr70QrN8Z3DsglWql6iY1lDWAfpFiBtuKGw== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" From b84af372b9800225f06d45077da87a2d13182e20 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 9 Jun 2020 15:35:07 -0600 Subject: [PATCH 7/7] Bump animation time for breadcrumbs up to 640ms This matches the design, unlike the 300ms which was too fast. --- res/css/views/rooms/_RoomBreadcrumbs2.scss | 2 +- src/components/views/rooms/RoomBreadcrumbs2.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/res/css/views/rooms/_RoomBreadcrumbs2.scss b/res/css/views/rooms/_RoomBreadcrumbs2.scss index 68cf7d7500..ac5a9fc34e 100644 --- a/res/css/views/rooms/_RoomBreadcrumbs2.scss +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -39,7 +39,7 @@ limitations under the License. // Timing function is as-requested by design. // NOTE: The transition time MUST match the value passed to CSSTransition! - transition: margin-left 300ms cubic-bezier(0.66, 0.02, 0.36, 1); + transition: margin-left 640ms cubic-bezier(0.66, 0.02, 0.36, 1); } .mx_RoomBreadcrumbs2_placeholder { diff --git a/src/components/views/rooms/RoomBreadcrumbs2.tsx b/src/components/views/rooms/RoomBreadcrumbs2.tsx index 197170018a..1b912b39d6 100644 --- a/src/components/views/rooms/RoomBreadcrumbs2.tsx +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -104,7 +104,7 @@ export default class RoomBreadcrumbs2 extends React.PureComponent