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/_components.scss b/res/css/_components.scss index fb804ad09d..31f319e76f 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -179,6 +179,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 d9a2b1dd5c..65d23fc23a 100644 --- a/res/css/structures/_LeftPanel2.scss +++ b/res/css/structures/_LeftPanel2.scss @@ -81,9 +81,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..ac5a9fc34e --- /dev/null +++ b/res/css/views/rooms/_RoomBreadcrumbs2.scss @@ -0,0 +1,51 @@ +/* +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_RoomBreadcrumbs2 { + width: 100%; + + // Create a flexbox for the crumbs + display: flex; + flex-direction: row; + align-items: flex-start; + + .mx_RoomBreadcrumbs2_crumb { + margin-right: 8px; + width: 32px; + } + + // 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 640ms cubic-bezier(0.66, 0.02, 0.36, 1); + } + + .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 f417dc99b1..302d71afa8 100644 --- a/src/components/structures/LeftPanel2.tsx +++ b/src/components/structures/LeftPanel2.tsx @@ -24,10 +24,12 @@ import RoomList2 from "../views/rooms/RoomList2"; import { Action } from "../../dispatcher/actions"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import BaseAvatar from '../views/avatars/BaseAvatar'; -import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs"; import UserMenuButton from "./UserMenuButton"; import RoomSearch from "./RoomSearch"; import AccessibleButton from "../views/elements/AccessibleButton"; +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 { searchFilter: string; // TODO: Move search into room list? + showBreadcrumbs: boolean; } export default class LeftPanel2 extends React.Component { @@ -58,7 +61,14 @@ export default class LeftPanel2 extends React.Component { this.state = { 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 => { @@ -69,6 +79,13 @@ export default class LeftPanel2 extends React.Component { dis.fire(Action.ViewRoomDirectory); }; + 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 @@ -84,6 +101,16 @@ export default class LeftPanel2 extends React.Component { displayName = myUser.rawDisplayName; avatarUrl = myUser.avatarUrl; } + + let breadcrumbs; + if (this.state.showBreadcrumbs) { + breadcrumbs = ( +
+ +
+ ); + } + return (
@@ -103,9 +130,7 @@ export default class LeftPanel2 extends React.Component {
-
- -
+ {breadcrumbs}
); } @@ -143,7 +168,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..1b912b39d6 --- /dev/null +++ b/src/components/views/rooms/RoomBreadcrumbs2.tsx @@ -0,0 +1,125 @@ +/* +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"; +import { CSSTransition, TransitionGroup } from "react-transition-group"; + +/******************************************************************* + * 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 { + // 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 { + private isMounted = true; + + constructor(props: IProps) { + super(props); + + this.state = { + doAnimation: true, // technically we want animation on mount, but it won't be perfect + skipFirst: false, // render the thing, as boring as it is + }; + + 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; + + // 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) => { + 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) { + // 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")} +
+
+ ); + } + } +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 8a1d112e5d..21943ef3f6 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..332fa7fe2e --- /dev/null +++ b/src/stores/BreadcrumbsStore.ts @@ -0,0 +1,166 @@ +/* +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"; +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 + +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; + + // 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(); + } 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 { + // The tests might not result in a valid room object. + const room = this.matrixClient.getRoom(payload.room_id); + if (room) await this.appendRoom(room); + } + } + } + + 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)}); + + this.matrixClient.on("Room.myMembership", this.onMyMembership); + this.matrixClient.on("Room", this.onRoom); + } + + 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); + } + + 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); + } + } + +} 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"