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;
}
updateOptions(options) {
this._options = Object.assign(this._options, options);
}
emitChange(changedProps) {
if (this._options.emitChange) {
this._options.emitChange(changedProps);

View file

@ -1,5 +1,6 @@
/*
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");
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() {
return this._tiles.length;
}
getFirst() {
return this._tiles[0];
}
}
import {ObservableArray} from "../../../../observable/list/ObservableArray.js";

View file

@ -44,7 +44,12 @@ export class TimelineViewModel {
// doesn't fill gaps, only loads stored entries/tiles
loadAtTop() {
return this._timeline.loadAtTop(50);
const firstTile = this._tiles.getFirst();
if (firstTile.shape === "gap") {
return firstTile.fill();
} else {
return this._timeline.loadAtTop(50);
}
}
unloadAtTop(tileAmount) {

View file

@ -29,16 +29,16 @@ export class GapTile extends SimpleTile {
// prevent doing this twice
if (!this._loading) {
this._loading = true;
this.emitUpdate("isLoading");
this.emitChange("isLoading");
try {
await this._timeline.fillGap(this._entry, 10);
} catch (err) {
console.error(`timeline.fillGap(): ${err.message}:\n${err.stack}`);
this._error = err;
this.emitUpdate("error");
this.emitChange("error");
} finally {
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 {readPath, Type} from "../../../../../utils/validate.js";
const MAX_HEIGHT = 300;
const MAX_WIDTH = 400;
@ -26,19 +27,21 @@ export class ImageTile extends MessageTile {
}
get thumbnailUrl() {
const mxcUrl = this._getContent().url;
if (mxcUrl) {
try {
const mxcUrl = readPath(this._getContent(), ["url"], Type.String);
return this._room.mxcUrlThumbnail(mxcUrl, this.thumbnailWidth, this.thumbnailHeight, "scale");
} catch (err) {
return null;
}
return null;
}
get url() {
const mxcUrl = this._getContent().url;
if (mxcUrl) {
try {
const mxcUrl = readPath(this._getContent(), ["url"], Type.String);
return this._room.mxcUrl(mxcUrl);
} catch (err) {
return null;
}
return null;
}
_scaleFactor() {

View file

@ -62,7 +62,7 @@ export class MessageTile extends SimpleTile {
const isContinuation = prev && prev instanceof MessageTile && prev.sender === this.sender;
if (isContinuation !== this._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 {ViewModel} from "../../../../ViewModel.js";
export class SimpleTile {
export class SimpleTile extends ViewModel {
constructor({entry}) {
super();
this._entry = entry;
this._emitUpdate = null;
}
// view model props for all subclasses
// hmmm, could also do instanceof ... ?
@ -38,12 +39,6 @@ export class SimpleTile {
return false;
}
emitUpdate(paramName) {
if (this._emitUpdate) {
this._emitUpdate(this, paramName);
}
}
get internalId() {
return this._entry.asEventKey().toString();
}
@ -53,7 +48,7 @@ export class SimpleTile {
}
// TilesCollection contract below
setUpdateEmit(emitUpdate) {
this._emitUpdate = emitUpdate;
this.updateOptions({emitChange: paramName => emitUpdate(this, paramName)});
}
get upperEntry() {

View file

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

View file

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

View file

@ -73,3 +73,13 @@ limitations under the License.
flex: 1;
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;
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 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.
@ -16,16 +17,11 @@ limitations under the License.
import {TemplateView} from "../../general/TemplateView.js";
import {TimelineList} from "./TimelineList.js";
import {TimelineLoadingView} from "./TimelineLoadingView.js";
import {MessageComposer} from "./MessageComposer.js";
export class RoomView extends TemplateView {
constructor(viewModel) {
super(viewModel);
this._timelineList = null;
}
render(t, vm) {
this._timelineList = new TimelineList();
return t.div({className: "RoomView"}, [
t.div({className: "TimelinePanel"}, [
t.div({className: "RoomHeader"}, [
@ -36,16 +32,13 @@ export class RoomView extends TemplateView {
]),
]),
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)),
])
]);
}
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";
export class TimelineList extends ListView {
constructor(options = {}) {
options.className = "Timeline";
constructor(viewModel) {
const options = {
className: "Timeline",
list: viewModel.tiles,
}
super(options, entry => {
switch (entry.shape) {
case "gap": return new GapView(entry);
@ -34,28 +37,42 @@ export class TimelineList extends ListView {
this._atBottom = false;
this._onScroll = this._onScroll.bind(this);
this._topLoadingPromise = null;
this._viewModel = null;
this._viewModel = viewModel;
}
async _onScroll() {
const root = this.root();
if (root.scrollTop === 0 && !this._topLoadingPromise && this._viewModel) {
const beforeFromBottom = this._distanceFromBottom();
this._topLoadingPromise = this._viewModel.loadAtTop();
await this._topLoadingPromise;
const fromBottom = this._distanceFromBottom();
const amountGrown = fromBottom - beforeFromBottom;
root.scrollTop = root.scrollTop + amountGrown;
async _loadAtTopWhile(predicate) {
try {
while (predicate()) {
// fill, not enough content to fill timeline
this._topLoadingPromise = this._viewModel.loadAtTop();
await this._topLoadingPromise;
}
}
catch (err) {
//ignore error, as it is handled in the VM
}
finally {
this._topLoadingPromise = null;
}
}
update(attributes) {
if(attributes.viewModel) {
this._viewModel = attributes.viewModel;
attributes.list = attributes.viewModel.tiles;
async _onScroll() {
const PAGINATE_OFFSET = 100;
const root = this.root();
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() {
@ -72,7 +89,15 @@ export class TimelineList extends ListView {
loadList() {
super.loadList();
const root = this.root();
root.scrollTop = root.scrollHeight;
const {scrollHeight, clientHeight} = root;
if (scrollHeight > clientHeight) {
root.scrollTop = root.scrollHeight;
}
// load while viewport is not filled
this._loadAtTopWhile(() => {
const {scrollHeight, clientHeight} = root;
return scrollHeight <= clientHeight;
});
}
onBeforeListChanged() {
@ -86,8 +111,8 @@ export class TimelineList extends ListView {
}
onListChanged() {
const root = this.root();
if (this._atBottom) {
const root = this.root();
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 {spinner} from "../../../common.js";
export class GapView extends TemplateView {
render(t, vm) {
@ -22,12 +23,9 @@ export class GapView extends TemplateView {
GapView: true,
isLoading: vm => vm.isLoading
};
const label = (vm.isUp ? "🠝" : "🠟") + " fill gap"; //no binding
return t.li({className}, [
t.button({
onClick: () => vm.fill(),
disabled: vm => vm.isLoading
}, label),
spinner(t),
t.div(vm.i18n`Loading more messages …`),
t.if(vm => vm.error, t.createTemplate(t => t.strong(vm => vm.error)))
]);
}