Merge pull request #20 from vector-im/bwindels/auto-fill-gaps

Fill gaps when scrolling up & on timelines < viewport
This commit is contained in:
Bruno Windels 2020-08-17 14:39:51 +00:00 committed by GitHub
commit 1261ac05d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 141 additions and 61 deletions

View File

@ -70,6 +70,10 @@ export class ViewModel extends EventEmitter {
return result; return result;
} }
updateOptions(options) {
this._options = Object.assign(this._options, options);
}
emitChange(changedProps) { emitChange(changedProps) {
if (this._options.emitChange) { if (this._options.emitChange) {
this._options.emitChange(changedProps); this._options.emitChange(changedProps);

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.

View File

@ -201,6 +201,10 @@ export class TilesCollection extends BaseObservableList {
get length() { get length() {
return this._tiles.length; return this._tiles.length;
} }
getFirst() {
return this._tiles[0];
}
} }
import {ObservableArray} from "../../../../observable/list/ObservableArray.js"; import {ObservableArray} from "../../../../observable/list/ObservableArray.js";

View File

@ -44,8 +44,13 @@ export class TimelineViewModel {
// doesn't fill gaps, only loads stored entries/tiles // doesn't fill gaps, only loads stored entries/tiles
loadAtTop() { loadAtTop() {
const firstTile = this._tiles.getFirst();
if (firstTile.shape === "gap") {
return firstTile.fill();
} else {
return this._timeline.loadAtTop(50); return this._timeline.loadAtTop(50);
} }
}
unloadAtTop(tileAmount) { unloadAtTop(tileAmount) {
// get lowerSortKey for tile at index tileAmount - 1 // get lowerSortKey for tile at index tileAmount - 1

View File

@ -29,16 +29,16 @@ export class GapTile extends SimpleTile {
// prevent doing this twice // prevent doing this twice
if (!this._loading) { if (!this._loading) {
this._loading = true; this._loading = true;
this.emitUpdate("isLoading"); this.emitChange("isLoading");
try { try {
await this._timeline.fillGap(this._entry, 10); await this._timeline.fillGap(this._entry, 10);
} catch (err) { } catch (err) {
console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`); console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`);
this._error = err; this._error = err;
this.emitUpdate("error"); this.emitChange("error");
} finally { } finally {
this._loading = false; this._loading = false;
this.emitUpdate("isLoading"); this.emitChange("isLoading");
} }
} }
} }

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {MessageTile} from "./MessageTile.js"; import {MessageTile} from "./MessageTile.js";
import {readPath, Type} from "../../../../../utils/validate.js";
const MAX_HEIGHT = 300; const MAX_HEIGHT = 300;
const MAX_WIDTH = 400; const MAX_WIDTH = 400;
@ -26,20 +27,22 @@ export class ImageTile extends MessageTile {
} }
get thumbnailUrl() { get thumbnailUrl() {
const mxcUrl = this._getContent().url; try {
if (mxcUrl) { const mxcUrl = readPath(this._getContent(), ["url"], Type.String);
return this._room.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale"); return this._room.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
} } catch (err) {
return null; return null;
} }
}
get url() { get url() {
const mxcUrl = this._getContent().url; try {
if (mxcUrl) { const mxcUrl = readPath(this._getContent(), ["url"], Type.String);
return this._room.mxcUrl(mxcUrl); return this._room.mxcUrl(mxcUrl);
} } catch (err) {
return null; return null;
} }
}
_scaleFactor() { _scaleFactor() {
const {info} = this._getContent(); const {info} = this._getContent();

View File

@ -62,7 +62,7 @@ export class MessageTile extends SimpleTile {
const isContinuation = prev && prev instanceof MessageTile && prev.sender === this.sender; const isContinuation = prev && prev instanceof MessageTile && prev.sender === this.sender;
if (isContinuation !== this._isContinuation) { if (isContinuation !== this._isContinuation) {
this._isContinuation = isContinuation; this._isContinuation = isContinuation;
this.emitUpdate("isContinuation"); this.emitChange("isContinuation");
} }
} }
} }

View File

@ -15,11 +15,12 @@ limitations under the License.
*/ */
import {UpdateAction} from "../UpdateAction.js"; import {UpdateAction} from "../UpdateAction.js";
import {ViewModel} from "../../../../ViewModel.js";
export class SimpleTile { export class SimpleTile extends ViewModel {
constructor({entry}) { constructor({entry}) {
super();
this._entry = entry; this._entry = entry;
this._emitUpdate = null;
} }
// view model props for all subclasses // view model props for all subclasses
// hmmm, could also do instanceof ... ? // hmmm, could also do instanceof ... ?
@ -38,12 +39,6 @@ export class SimpleTile {
return false; return false;
} }
emitUpdate(paramName) {
if (this._emitUpdate) {
this._emitUpdate(this, paramName);
}
}
get internalId() { get internalId() {
return this._entry.asEventKey().toString(); return this._entry.asEventKey().toString();
} }
@ -53,7 +48,7 @@ export class SimpleTile {
} }
// TilesCollection contract below // TilesCollection contract below
setUpdateEmit(emitUpdate) { setUpdateEmit(emitUpdate) {
this._emitUpdate = emitUpdate; this.updateOptions({emitChange: paramName => emitUpdate(this, paramName)});
} }
get upperEntry() { get upperEntry() {

View File

@ -60,8 +60,8 @@ export class TimelineReader {
let fragmentEntry = new FragmentBoundaryEntry(fragment, direction.isBackward, this._fragmentIdComparer); let fragmentEntry = new FragmentBoundaryEntry(fragment, direction.isBackward, this._fragmentIdComparer);
// append or prepend fragmentEntry, reuse func from GapWriter? // append or prepend fragmentEntry, reuse func from GapWriter?
directionalAppend(entries, fragmentEntry, direction); directionalAppend(entries, fragmentEntry, direction);
// don't count it in amount perhaps? or do? // only continue loading if the fragment boundary can't be backfilled
if (fragmentEntry.hasLinkedFragment) { if (!fragmentEntry.token && fragmentEntry.hasLinkedFragment) {
const nextFragment = await fragmentStore.get(this._roomId, fragmentEntry.linkedFragmentId); const nextFragment = await fragmentStore.get(this._roomId, fragmentEntry.linkedFragmentId);
this._fragmentIdComparer.add(nextFragment); this._fragmentIdComparer.add(nextFragment);
const nextFragmentEntry = new FragmentBoundaryEntry(nextFragment, direction.isForward, this._fragmentIdComparer); const nextFragmentEntry = new FragmentBoundaryEntry(nextFragment, direction.isForward, this._fragmentIdComparer);

View File

@ -78,7 +78,7 @@ html {
height: 100%; height: 100%;
} }
.TimelinePanel ul { .TimelinePanel .Timeline, .TimelinePanel .TimelineLoadingView {
flex: 1 0 0; flex: 1 0 0;
} }

View File

@ -73,3 +73,13 @@ limitations under the License.
flex: 1; flex: 1;
box-sizing: border-box; box-sizing: border-box;
} }
.TimelineLoadingView {
display: flex;
align-items: center;
justify-content: center;
}
.TimelineLoadingView div {
margin-left: 10px;
}

View File

@ -67,3 +67,18 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
} }
.GapView {
visibility: hidden;
display: flex;
padding: 10px 20px;
}
.GapView.isLoading {
visibility: visible;
}
.GapView > div {
flex: 1;
margin-left: 10px;
}

View File

@ -1,5 +1,6 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud> Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -16,16 +17,11 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView.js"; import {TemplateView} from "../../general/TemplateView.js";
import {TimelineList} from "./TimelineList.js"; import {TimelineList} from "./TimelineList.js";
import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js"; import {MessageComposer} from "./MessageComposer.js";
export class RoomView extends TemplateView { export class RoomView extends TemplateView {
constructor(viewModel) {
super(viewModel);
this._timelineList = null;
}
render(t, vm) { render(t, vm) {
this._timelineList = new TimelineList();
return t.div({className: "RoomView"}, [ return t.div({className: "RoomView"}, [
t.div({className: "TimelinePanel"}, [ t.div({className: "TimelinePanel"}, [
t.div({className: "RoomHeader"}, [ t.div({className: "RoomHeader"}, [
@ -36,16 +32,13 @@ export class RoomView extends TemplateView {
]), ]),
]), ]),
t.div({className: "RoomView_error"}, vm => vm.error), t.div({className: "RoomView_error"}, vm => vm.error),
t.view(this._timelineList), t.mapView(vm => vm.timelineViewModel, timelineViewModel => {
return timelineViewModel ?
new TimelineList(timelineViewModel) :
new TimelineLoadingView(vm); // vm is just needed for i18n
}),
t.view(new MessageComposer(this.value.composerViewModel)), t.view(new MessageComposer(this.value.composerViewModel)),
]) ])
]); ]);
} }
update(value, prop) {
super.update(value, prop);
if (prop === "timelineViewModel") {
this._timelineList.update({viewModel: this.value.timelineViewModel});
}
}
} }

View File

@ -21,8 +21,11 @@ import {ImageView} from "./timeline/ImageView.js";
import {AnnouncementView} from "./timeline/AnnouncementView.js"; import {AnnouncementView} from "./timeline/AnnouncementView.js";
export class TimelineList extends ListView { export class TimelineList extends ListView {
constructor(options = {}) { constructor(viewModel) {
options.className = "Timeline"; const options = {
className: "Timeline",
list: viewModel.tiles,
}
super(options, entry => { super(options, entry => {
switch (entry.shape) { switch (entry.shape) {
case "gap": return new GapView(entry); case "gap": return new GapView(entry);
@ -34,28 +37,42 @@ export class TimelineList extends ListView {
this._atBottom = false; this._atBottom = false;
this._onScroll = this._onScroll.bind(this); this._onScroll = this._onScroll.bind(this);
this._topLoadingPromise = null; this._topLoadingPromise = null;
this._viewModel = null; this._viewModel = viewModel;
} }
async _onScroll() { async _loadAtTopWhile(predicate) {
const root = this.root(); try {
if (root.scrollTop === 0 && !this._topLoadingPromise && this._viewModel) { while (predicate()) {
const beforeFromBottom = this._distanceFromBottom(); // fill, not enough content to fill timeline
this._topLoadingPromise = this._viewModel.loadAtTop(); this._topLoadingPromise = this._viewModel.loadAtTop();
await this._topLoadingPromise; await this._topLoadingPromise;
const fromBottom = this._distanceFromBottom(); }
const amountGrown = fromBottom - beforeFromBottom; }
root.scrollTop = root.scrollTop + amountGrown; catch (err) {
//ignore error, as it is handled in the VM
}
finally {
this._topLoadingPromise = null; this._topLoadingPromise = null;
} }
} }
update(attributes) { async _onScroll() {
if(attributes.viewModel) { const PAGINATE_OFFSET = 100;
this._viewModel = attributes.viewModel; const root = this.root();
attributes.list = attributes.viewModel.tiles; if (root.scrollTop < PAGINATE_OFFSET && !this._topLoadingPromise && this._viewModel) {
// to calculate total amountGrown to check when we stop loading
let beforeContentHeight = root.scrollHeight;
// to adjust scrollTop every time
let lastContentHeight = beforeContentHeight;
// load until pagination offset is reached again
this._loadAtTopWhile(() => {
const contentHeight = root.scrollHeight;
const amountGrown = contentHeight - beforeContentHeight;
root.scrollTop = root.scrollTop + (contentHeight - lastContentHeight);
lastContentHeight = contentHeight;
return amountGrown < PAGINATE_OFFSET;
});
} }
super.update(attributes);
} }
mount() { mount() {
@ -72,8 +89,16 @@ export class TimelineList extends ListView {
loadList() { loadList() {
super.loadList(); super.loadList();
const root = this.root(); const root = this.root();
const {scrollHeight, clientHeight} = root;
if (scrollHeight > clientHeight) {
root.scrollTop = root.scrollHeight; root.scrollTop = root.scrollHeight;
} }
// load while viewport is not filled
this._loadAtTopWhile(() => {
const {scrollHeight, clientHeight} = root;
return scrollHeight <= clientHeight;
});
}
onBeforeListChanged() { onBeforeListChanged() {
const fromBottom = this._distanceFromBottom(); const fromBottom = this._distanceFromBottom();
@ -86,8 +111,8 @@ export class TimelineList extends ListView {
} }
onListChanged() { onListChanged() {
if (this._atBottom) {
const root = this.root(); const root = this.root();
if (this._atBottom) {
root.scrollTop = root.scrollHeight; root.scrollTop = root.scrollHeight;
} }
} }

View File

@ -0,0 +1,27 @@
/*
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 {TemplateView} from "../../general/TemplateView.js";
import {spinner} from "../../common.js";
export class TimelineLoadingView extends TemplateView {
render(t, vm) {
return t.div({className: "TimelineLoadingView"}, [
spinner(t),
t.div(vm.i18n`Loading messages…`)
]);
}
}

View File

@ -15,6 +15,7 @@ limitations under the License.
*/ */
import {TemplateView} from "../../../general/TemplateView.js"; import {TemplateView} from "../../../general/TemplateView.js";
import {spinner} from "../../../common.js";
export class GapView extends TemplateView { export class GapView extends TemplateView {
render(t, vm) { render(t, vm) {
@ -22,12 +23,9 @@ export class GapView extends TemplateView {
GapView: true, GapView: true,
isLoading: vm => vm.isLoading isLoading: vm => vm.isLoading
}; };
const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding
return t.li({className}, [ return t.li({className}, [
t.button({ spinner(t),
onClick: () => vm.fill(), t.div(vm.i18n`Loading more messages …`),
disabled: vm => vm.isLoading
}, label),
t.if(vm => vm.error, t.createTemplate(t => t.strong(vm => vm.error))) t.if(vm => vm.error, t.createTemplate(t => t.strong(vm => vm.error)))
]); ]);
} }