forked from mystiq/hydrogen-web
Merge pull request #20 from vector-im/bwindels/auto-fill-gaps
Fill gaps when scrolling up & on timelines < viewport
This commit is contained in:
commit
1261ac05d1
16 changed files with 141 additions and 61 deletions
|
@ -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);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -78,7 +78,7 @@ html {
|
|||
height: 100%;
|
||||
}
|
||||
|
||||
.TimelinePanel ul {
|
||||
.TimelinePanel .Timeline, .TimelinePanel .TimelineLoadingView {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
27
src/ui/web/session/room/TimelineLoadingView.js
Normal file
27
src/ui/web/session/room/TimelineLoadingView.js
Normal 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…`)
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -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)))
|
||||
]);
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue