mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-23 11:35:04 +01:00
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
@ -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);
|
||||||
|
@ -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.
|
||||||
|
@ -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";
|
||||||
|
@ -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
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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() {
|
||||||
|
@ -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);
|
||||||
|
@ -78,7 +78,7 @@ html {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.TimelinePanel ul {
|
.TimelinePanel .Timeline, .TimelinePanel .TimelineLoadingView {
|
||||||
flex: 1 0 0;
|
flex: 1 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 {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)))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user