diff --git a/package.json b/package.json index 7006c13f10..630297718a 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "react-beautiful-dnd": "^4.0.1", "react-dom": "^16.9.0", "react-focus-lock": "^2.2.1", + "react-resizable": "^1.10.1", "resize-observer-polyfill": "^1.5.0", "sanitize-html": "^1.18.4", "text-encoding-utf-8": "^1.0.1", diff --git a/res/css/_components.scss b/res/css/_components.scss index 5a7630a51f..b047519d99 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -179,6 +179,7 @@ @import "./views/rooms/_RoomList.scss"; @import "./views/rooms/_RoomPreviewBar.scss"; @import "./views/rooms/_RoomRecoveryReminder.scss"; +@import "./views/rooms/_RoomSublist2.scss"; @import "./views/rooms/_RoomTile.scss"; @import "./views/rooms/_RoomUpgradeWarningBar.scss"; @import "./views/rooms/_SearchBar.scss"; diff --git a/res/css/views/rooms/_RoomList.scss b/res/css/views/rooms/_RoomList.scss index 50a9e7ee1f..7abf86cb0e 100644 --- a/res/css/views/rooms/_RoomList.scss +++ b/res/css/views/rooms/_RoomList.scss @@ -15,6 +15,14 @@ See the License for the specific language governing permissions and limitations under the License. */ +.mx_RoomList2_resizer { + cursor: ns-resize; +} + +.mx_RoomList.mx_RoomList2 { + overflow-y: auto; +} + .mx_RoomList { /* take up remaining space below TopLeftMenu */ flex: 1; diff --git a/res/css/views/rooms/_RoomSublist2.scss b/res/css/views/rooms/_RoomSublist2.scss new file mode 100644 index 0000000000..abc3133fc1 --- /dev/null +++ b/res/css/views/rooms/_RoomSublist2.scss @@ -0,0 +1,22 @@ +/* +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 "../../../../node_modules/react-resizable/css/styles.css"; + +.mx_RoomList2 .mx_RoomSubList_labelContainer { + z-index: 12; + background-color: purple; +} diff --git a/src/components/views/rooms/RoomList2.tsx b/src/components/views/rooms/RoomList2.tsx index e732e70edf..15aa880109 100644 --- a/src/components/views/rooms/RoomList2.tsx +++ b/src/components/views/rooms/RoomList2.tsx @@ -18,18 +18,17 @@ limitations under the License. import * as React from "react"; import { _t, _td } from "../../../languageHandler"; -import { Layout } from '../../../resizer/distributors/roomsublist2'; import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex"; import { ResizeNotifier } from "../../../utils/ResizeNotifier"; -import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore2"; +import RoomListStore, { LISTS_UPDATE_EVENT, RoomListStore2 } from "../../../stores/room-list/RoomListStore2"; 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 RoomSublist2 from "./RoomSublist2"; import { ActionPayload } from "../../../dispatcher/payloads"; -import { IFilterCondition } from "../../../stores/room-list/filters/IFilterCondition"; import { NameFilterCondition } from "../../../stores/room-list/filters/NameFilterCondition"; +import { ListLayout } from "../../../stores/room-list/ListLayout"; /******************************************************************* * CAUTION * @@ -50,6 +49,7 @@ interface IProps { interface IState { sublists: ITagMap; + layouts: Map; } const TAG_ORDER: TagID[] = [ @@ -127,19 +127,15 @@ const TAG_AESTHETICS: { }; export default class RoomList2 extends React.Component { - private sublistRefs: { [tagId: string]: React.RefObject } = {}; - private sublistSizes: { [tagId: string]: number } = {}; - private sublistCollapseStates: { [tagId: string]: boolean } = {}; - private unfilteredLayout: Layout; - private filteredLayout: Layout; private searchFilter: NameFilterCondition = new NameFilterCondition(); constructor(props: IProps) { super(props); - this.state = {sublists: {}}; - this.loadSublistSizes(); - this.prepareLayouts(); + this.state = { + sublists: {}, + layouts: new Map(), + }; } public componentDidUpdate(prevProps: Readonly): void { @@ -158,49 +154,16 @@ export default class RoomList2 extends React.Component { } public componentDidMount(): void { - RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store) => { - console.log("new lists", store.orderedLists); - this.setState({sublists: store.orderedLists}); - }); - } + RoomListStore.instance.on(LISTS_UPDATE_EVENT, (store: RoomListStore2) => { + const newLists = store.orderedLists; + console.log("new lists", newLists); - private loadSublistSizes() { - const sizesJson = window.localStorage.getItem("mx_roomlist_sizes"); - if (sizesJson) this.sublistSizes = JSON.parse(sizesJson); - - const collapsedJson = window.localStorage.getItem("mx_roomlist_collapsed"); - if (collapsedJson) this.sublistCollapseStates = JSON.parse(collapsedJson); - } - - private saveSublistSizes() { - window.localStorage.setItem("mx_roomlist_sizes", JSON.stringify(this.sublistSizes)); - window.localStorage.setItem("mx_roomlist_collapsed", JSON.stringify(this.sublistCollapseStates)); - } - - private prepareLayouts() { - // TODO: Change layout engine for FTUE support - this.unfilteredLayout = new Layout((tagId: string, height: number) => { - const sublist = this.sublistRefs[tagId]; - if (sublist) sublist.current.setHeight(height); - - // TODO: Check overflow (see old impl) - - // Don't store a height for collapsed sublists - if (!this.sublistCollapseStates[tagId]) { - this.sublistSizes[tagId] = height; - this.saveSublistSizes(); + const layoutMap = new Map(); + for (const tagId of Object.keys(newLists)) { + layoutMap.set(tagId, new ListLayout(tagId)); } - }, this.sublistSizes, this.sublistCollapseStates, { - allowWhitespace: false, - handleHeight: 1, - }); - this.filteredLayout = new Layout((tagId: string, height: number) => { - const sublist = this.sublistRefs[tagId]; - if (sublist) sublist.current.setHeight(height); - }, null, null, { - allowWhitespace: false, - handleHeight: 0, + this.setState({sublists: newLists, layouts: layoutMap}); }); } @@ -226,16 +189,19 @@ 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; - components.push(); + components.push( + + ); } return components; @@ -250,7 +216,7 @@ export default class RoomList2 extends React.Component { onFocus={this.props.onFocus} onBlur={this.props.onBlur} onKeyDown={onKeyDownHandler} - className="mx_RoomList" + className="mx_RoomList mx_RoomList2" role="tree" aria-label={_t("Rooms")} // Firefox sometimes makes this element focusable due to diff --git a/src/components/views/rooms/RoomSublist2.tsx b/src/components/views/rooms/RoomSublist2.tsx index e2f489b959..cc56f92769 100644 --- a/src/components/views/rooms/RoomSublist2.tsx +++ b/src/components/views/rooms/RoomSublist2.tsx @@ -20,7 +20,6 @@ import * as React from "react"; import { createRef } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import classNames from 'classnames'; -import IndicatorScrollbar from "../../structures/IndicatorScrollbar"; import * as RoomNotifs from '../../../RoomNotifs'; import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex"; import { _t } from "../../../languageHandler"; @@ -28,6 +27,8 @@ import AccessibleButton from "../../views/elements/AccessibleButton"; import AccessibleTooltipButton from "../../views/elements/AccessibleTooltipButton"; import * as FormattingUtils from '../../../utils/FormattingUtils'; import RoomTile2 from "./RoomTile2"; +import { ResizableBox, ResizeCallbackData } from "react-resizable"; +import { ListLayout } from "../../../stores/room-list/ListLayout"; /******************************************************************* * CAUTION * @@ -45,9 +46,9 @@ interface IProps { onAddRoom?: () => void; addRoomLabel: string; isInvite: boolean; + layout: ListLayout; // TODO: Collapsed state - // TODO: Height // TODO: Group invites // TODO: Calls // TODO: forceExpand? @@ -61,10 +62,6 @@ interface IState { export default class RoomSublist2 extends React.Component { private headerButton = createRef(); - public setHeight(size: number) { - // TODO: Do a thing (maybe - height changes are different in FTUE) - } - private hasTiles(): boolean { return this.numTiles > 0; } @@ -79,6 +76,18 @@ export default class RoomSublist2 extends React.Component { if (this.props.onAddRoom) this.props.onAddRoom(); }; + private onResize = (e: React.MouseEvent, data: ResizeCallbackData) => { + const direction = e.movementY < 0 ? -1 : +1; + const tileDiff = this.props.layout.pixelsToTiles(Math.abs(e.movementY)) * direction; + this.props.layout.visibleTiles += tileDiff; + this.forceUpdate(); // because the layout doesn't trigger a re-render + }; + + private onShowAllClick = () => { + this.props.layout.visibleTiles = this.numTiles; + this.forceUpdate(); // because the layout doesn't trigger a re-render + }; + private renderTiles(): React.ReactElement[] { const tiles: React.ReactElement[] = []; @@ -204,10 +213,61 @@ export default class RoomSublist2 extends React.Component { if (tiles.length > 0) { // TODO: Lazy list rendering // TODO: Whatever scrolling magic needs to happen here + const layout = this.props.layout; // to shorten calls + const minTilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.minVisibleTiles)); + const maxTilesPx = layout.tilesToPixels(tiles.length); + const tilesPx = layout.tilesToPixels(Math.min(tiles.length, layout.visibleTiles)); + let handles = ['s']; + if (layout.visibleTiles >= tiles.length && tiles.length <= layout.minVisibleTiles) { + handles = []; // no handles, we're at a minimum + } + + let nVisible = Math.floor(layout.visibleTiles); + if (localStorage.getItem("mx_rl_mathfn")) { + nVisible = Math[localStorage.getItem("mx_rl_mathfn")](layout.visibleTiles); + } + console.log({nVisible}) + const visibleTiles = tiles.slice(0, nVisible); + + // If we're hiding rooms, show a 'show more' button to the user. This button + // replaces the last visible tile, so will always show 2+ rooms. We do this + // because if it said "show 1 more room" we had might as well show that room + // instead. We also replace the last item so we don't have to adjust our math + // on pixel heights, etc. It's much easier to pretend the button is a tile. + if (tiles.length > nVisible) { + // we have a cutoff condition - add the button to show all + + // we +1 to account for the room we're about to hide with our 'show more' button + const numMissing = (tiles.length - visibleTiles.length) + 1; + + // TODO: Copy TBD + // TODO: CSS TBD + // TODO: Show N more instead of infinity more? + // TODO: Safely use the same height of a tile, not hardcoded hacks + visibleTiles.splice(visibleTiles.length - 1, 1, ( +
+ {_t("Show %(n)s more rooms", {n: numMissing})} +
+ )); + } content = ( - - {tiles} - + + {visibleTiles} + ) } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index eaa34278e7..884192b22a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1136,6 +1136,7 @@ "Jump to first unread room.": "Jump to first unread room.", "Jump to first invite.": "Jump to first invite.", "Add room": "Add room", + "Show %(n)s more rooms": "Show %(n)s more rooms", "Options": "Options", "%(count)s unread messages including mentions.|other": "%(count)s unread messages including mentions.", "%(count)s unread messages including mentions.|one": "1 unread mention.", diff --git a/src/stores/room-list/ListLayout.ts b/src/stores/room-list/ListLayout.ts new file mode 100644 index 0000000000..fd57a03ca1 --- /dev/null +++ b/src/stores/room-list/ListLayout.ts @@ -0,0 +1,69 @@ +/* +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. +*/ + +const TILE_HEIGHT_PX = 34; + +interface ISerializedListLayout { + numTiles: number; +} + +export class ListLayout { + private _n = 0; + + constructor(public readonly tagId) { + const serialized = localStorage.getItem(this.key); + if (serialized) { + // We don't use the setters as they cause writes. + const parsed = JSON.parse(serialized); + this._n = parsed.numTiles; + } + } + + public get tileHeight(): number { + return TILE_HEIGHT_PX; + } + + private get key(): string { + return `mx_sublist_layout_${this.tagId}_boxed`; + } + + public get visibleTiles(): number { + return Math.max(this._n, this.minVisibleTiles); + } + + public set visibleTiles(v: number) { + this._n = v; + localStorage.setItem(this.key, JSON.stringify(this.serialize())); + } + + public get minVisibleTiles(): number { + return 3; + } + + public tilesToPixels(n: number): number { + return n * this.tileHeight; + } + + public pixelsToTiles(px: number): number { + return px / this.tileHeight; + } + + private serialize(): ISerializedListLayout { + return { + numTiles: this.visibleTiles, + }; + } +} diff --git a/yarn.lock b/yarn.lock index adc880ffa5..41737716cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2458,7 +2458,7 @@ class-utils@^0.3.5: isobject "^3.0.0" static-extend "^0.1.1" -classnames@^2.1.2: +classnames@^2.1.2, classnames@^2.2.5: version "2.2.6" resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce" integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q== @@ -6858,7 +6858,7 @@ prop-types-exact@^1.2.0: object.assign "^4.1.0" reflect.ownkeys "^0.2.0" -prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: +prop-types@15.x, prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2: version "15.7.2" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== @@ -7062,6 +7062,14 @@ react-dom@^16.9.0: prop-types "^15.6.2" scheduler "^0.19.1" +react-draggable@^4.0.3: + version "4.4.2" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.4.2.tgz#f3cefecee25f467f865144cda0d066e5f05f94a0" + integrity sha512-zLQs4R4bnBCGnCVTZiD8hPsHtkiJxgMpGDlRESM+EHQo8ysXhKJ2GKdJ8UxxLJdRVceX1j19jy+hQS2wHislPQ== + dependencies: + classnames "^2.2.5" + prop-types "^15.6.0" + react-focus-lock@^2.2.1: version "2.3.1" resolved "https://registry.yarnpkg.com/react-focus-lock/-/react-focus-lock-2.3.1.tgz#9d5d85899773609c7eefa4fc54fff6a0f5f2fc47" @@ -7106,6 +7114,14 @@ react-redux@^5.0.6: react-is "^16.6.0" react-lifecycles-compat "^3.0.0" +react-resizable@^1.10.1: + version "1.10.1" + resolved "https://registry.yarnpkg.com/react-resizable/-/react-resizable-1.10.1.tgz#f0c2cf1d83b3470b87676ce6d6b02bbe3f4d8cd4" + integrity sha512-Jd/bKOKx6+19NwC4/aMLRu/J9/krfxlDnElP41Oc+oLiUWs/zwV1S9yBfBZRnqAwQb6vQ/HRSk3bsSWGSgVbpw== + dependencies: + prop-types "15.x" + react-draggable "^4.0.3" + react-test-renderer@^16.0.0-0, react-test-renderer@^16.9.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-test-renderer/-/react-test-renderer-16.13.1.tgz#de25ea358d9012606de51e012d9742e7f0deabc1"