diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js index baf9b6e3..5938ada5 100644 --- a/src/domain/session/room/timeline/TimelineViewModel.js +++ b/src/domain/session/room/timeline/TimelineViewModel.js @@ -46,6 +46,7 @@ export class TimelineViewModel extends ViewModel { this._requestedStartTile = null; this._requestedEndTile = null; this._requestScheduled = false; + this._showJumpDown = false; } /** if this.tiles is empty, call this with undefined for both startTile and endTile */ @@ -75,10 +76,12 @@ export class TimelineViewModel extends ViewModel { tile.notifyVisible(); } loadTop = startIndex < 10; + this._setShowJumpDown(endIndex < (this._tiles.length - 1)); // console.log("got tiles", startIndex, endIndex, loadTop); } else { // tiles collection is empty, load more at top loadTop = true; + this._setShowJumpDown(false); // console.log("no tiles, load more at top"); } @@ -100,4 +103,15 @@ export class TimelineViewModel extends ViewModel { get tiles() { return this._tiles; } + + _setShowJumpDown(show) { + if (this._showJumpDown !== show) { + this._showJumpDown = show; + this.emitChange("showJumpDown"); + } + } + + get showJumpDown() { + return this._showJumpDown; + } } diff --git a/src/platform/web/ui/css/themes/element/icons/chevron-down.svg b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg new file mode 100644 index 00000000..d2068199 --- /dev/null +++ b/src/platform/web/ui/css/themes/element/icons/chevron-down.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + diff --git a/src/platform/web/ui/css/themes/element/timeline.css b/src/platform/web/ui/css/themes/element/timeline.css index 1caf09d5..d68d7ff5 100644 --- a/src/platform/web/ui/css/themes/element/timeline.css +++ b/src/platform/web/ui/css/themes/element/timeline.css @@ -15,6 +15,20 @@ See the License for the specific language governing permissions and limitations under the License. */ +.Timeline_jumpDown { + width: 40px; + height: 40px; + bottom: 16px; + right: 32px; + border-radius: 100%; + border: 1px solid #8d99a5; + background-image: url(icons/chevron-down.svg); + background-position: center; + background-color: white; + background-repeat: no-repeat; + cursor: pointer; +} + .Timeline_message { display: grid; grid-template: diff --git a/src/platform/web/ui/css/timeline.css b/src/platform/web/ui/css/timeline.css index dd34ba05..c4d1459b 100644 --- a/src/platform/web/ui/css/timeline.css +++ b/src/platform/web/ui/css/timeline.css @@ -14,8 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ +.Timeline { + display: flex; + flex-direction: column; + position: relative; +} -.RoomView_body > .Timeline { +.Timeline_jumpDown { + position: absolute; +} + +.Timeline_scroller { overflow-y: scroll; overscroll-behavior-y: contain; overflow-anchor: none; @@ -23,9 +32,11 @@ limitations under the License. margin: 0; /* need to read the offsetTop of tiles relative to this element in TimelineView */ position: relative; + min-height: 0; + flex: 1 0 0; } -.RoomView_body > .Timeline > ul { +.Timeline_scroller > ul { list-style: none; /* use small horizontal padding so first/last children margin isn't collapsed at the edge and a scrollbar shows up when setting margin-top to bottom-align diff --git a/src/platform/web/ui/session/room/TimelineView.ts b/src/platform/web/ui/session/room/TimelineView.ts index 61fbca08..7446dbcb 100644 --- a/src/platform/web/ui/session/room/TimelineView.ts +++ b/src/platform/web/ui/session/room/TimelineView.ts @@ -78,8 +78,19 @@ export class TimelineView extends TemplateView { this.restoreScrollPosition(); }); this.tilesView = new TilesListView(vm.tiles, () => this.restoreScrollPosition()); - const root = t.div({className: "Timeline bottom-aligned-scroll", onScroll: () => this.onScroll()}, [ - t.view(this.tilesView) + const root = t.div({className: "Timeline"}, [ + t.div({ + className: "Timeline_scroller bottom-aligned-scroll", + onScroll: () => this.onScroll() + }, t.view(this.tilesView)), + t.button({ + className: { + "Timeline_jumpDown": true, + hidden: vm => !vm.showJumpDown + }, + title: "Jump down", + onClick: () => this.jumpDown() + }) ]); if (typeof ResizeObserver === "function") { @@ -92,6 +103,16 @@ export class TimelineView extends TemplateView { return root; } + private get scroller() { + return this.root().firstElementChild as HTMLElement; + } + + private jumpDown() { + const {scroller} = this; + this.stickToBottom = true; + scroller.scrollTop = scroller.scrollHeight; + } + public unmount() { super.unmount(); if (this.resizeObserver) { @@ -101,10 +122,10 @@ export class TimelineView extends TemplateView { } private restoreScrollPosition() { - const timeline = this.root() as HTMLElement; + const {scroller} = this; const tiles = this.tilesView!.root() as HTMLElement; - const missingTilesHeight = timeline.clientHeight - tiles.clientHeight; + const missingTilesHeight = scroller.clientHeight - tiles.clientHeight; if (missingTilesHeight > 0) { tiles.style.setProperty("margin-top", `${missingTilesHeight}px`); // we don't have enough tiles to fill the viewport, so set all as visible @@ -113,23 +134,20 @@ export class TimelineView extends TemplateView { } else { tiles.style.removeProperty("margin-top"); if (this.stickToBottom) { - timeline.scrollTop = timeline.scrollHeight; + scroller.scrollTop = scroller.scrollHeight; } else if (this.anchoredNode) { const newAnchoredBottom = bottom(this.anchoredNode!); if (newAnchoredBottom !== this.anchoredBottom) { const bottomDiff = newAnchoredBottom - this.anchoredBottom; - console.log(`restore: scroll by ${bottomDiff} as height changed`); // scrollBy tends to create less scroll jumps than reassigning scrollTop as it does // not depend on reading scrollTop, which might be out of date as some platforms // run scrolling off the main thread. - if (typeof timeline.scrollBy === "function") { - timeline.scrollBy(0, bottomDiff); + if (typeof scroller.scrollBy === "function") { + scroller.scrollBy(0, bottomDiff); } else { - timeline.scrollTop = timeline.scrollTop + bottomDiff; + scroller.scrollTop = scroller.scrollTop + bottomDiff; } this.anchoredBottom = newAnchoredBottom; - } else { - // console.log("restore: bottom didn't change, must be below viewport"); } } // TODO: should we be updating the visible range here as well as the range might have changed even though @@ -138,8 +156,8 @@ export class TimelineView extends TemplateView { } private onScroll(): void { - const timeline = this.root() as HTMLElement; - const {scrollHeight, scrollTop, clientHeight} = timeline; + const {scroller} = this; + const {scrollHeight, scrollTop, clientHeight} = scroller; const tiles = this.tilesView!.root() as HTMLElement; let bottomNodeIndex;