mirror of
https://github.com/flarum/core.git
synced 2025-10-11 23:14:29 +02:00
In the PostStream, `this.visibleEnd` represents the index of the last post + 1, but `loadNearIndex` treated it as if it was the index of the last post. This means that executing `goToIndex` on the post stream's current `this.visiblePost` didn't load new posts, and as a result, the requested scrolling did not occur.
384 lines
10 KiB
JavaScript
384 lines
10 KiB
JavaScript
import anchorScroll from '../../common/utils/anchorScroll';
|
|
|
|
class PostStreamState {
|
|
constructor(discussion, includedPosts = []) {
|
|
/**
|
|
* The discussion to display the post stream for.
|
|
*
|
|
* @type {Discussion}
|
|
*/
|
|
this.discussion = discussion;
|
|
|
|
/**
|
|
* Whether or not the infinite-scrolling auto-load functionality is
|
|
* disabled.
|
|
*
|
|
* @type {Boolean}
|
|
*/
|
|
this.paused = false;
|
|
|
|
this.loadPageTimeouts = {};
|
|
this.pagesLoading = 0;
|
|
|
|
this.index = 0;
|
|
this.number = 1;
|
|
|
|
/**
|
|
* The number of posts that are currently visible in the viewport.
|
|
*
|
|
* @type {Number}
|
|
*/
|
|
this.visible = 1;
|
|
|
|
/**
|
|
* The description to render on the scrubber.
|
|
*
|
|
* @type {String}
|
|
*/
|
|
this.description = '';
|
|
|
|
/**
|
|
* When the page is scrolled, goToIndex is called, or the page is loaded,
|
|
* various listeners result in the scrubber being updated with a new
|
|
* position and values. However, if goToNumber is called, the scrubber
|
|
* will not be updated. Accordingly, we add logic to the scrubber's
|
|
* onupdate to update itself, but only when needed, as indicated by this
|
|
* property.
|
|
*
|
|
* @type {Boolean}
|
|
*/
|
|
this.forceUpdateScrubber = false;
|
|
|
|
this.show(includedPosts);
|
|
}
|
|
|
|
/**
|
|
* Update the stream so that it loads and includes the latest posts in the
|
|
* discussion, if the end is being viewed.
|
|
*
|
|
* @public
|
|
*/
|
|
update() {
|
|
if (!this.viewingEnd()) return Promise.resolve();
|
|
|
|
this.visibleEnd = this.count();
|
|
|
|
return this.loadRange(this.visibleStart, this.visibleEnd);
|
|
}
|
|
|
|
/**
|
|
* Load and scroll up to the first post in the discussion.
|
|
*
|
|
* @return {Promise}
|
|
*/
|
|
goToFirst() {
|
|
return this.goToIndex(0);
|
|
}
|
|
|
|
/**
|
|
* Load and scroll down to the last post in the discussion.
|
|
*
|
|
* @return {Promise}
|
|
*/
|
|
goToLast() {
|
|
return this.goToIndex(this.count() - 1, true);
|
|
}
|
|
|
|
/**
|
|
* Load and scroll to a post with a certain number.
|
|
*
|
|
* @param {number|String} number The post number to go to. If 'reply', go to
|
|
* the last post and scroll the reply preview into view.
|
|
* @param {Boolean} noAnimation
|
|
* @return {Promise}
|
|
*/
|
|
goToNumber(number, noAnimation = false) {
|
|
// If we want to go to the reply preview, then we will go to the end of the
|
|
// discussion and then scroll to the very bottom of the page.
|
|
if (number === 'reply') {
|
|
const resultPromise = this.goToLast();
|
|
this.targetPost.reply = true;
|
|
return resultPromise;
|
|
}
|
|
|
|
this.paused = true;
|
|
|
|
this.loadPromise = this.loadNearNumber(number);
|
|
|
|
this.needsScroll = true;
|
|
this.targetPost = { number };
|
|
this.animateScroll = !noAnimation;
|
|
this.number = number;
|
|
|
|
// In this case, the redraw is only called after the response has been loaded
|
|
// because we need to know the indices of the post range before we can
|
|
// start scrolling to items. Calling redraw early causes issues.
|
|
// Since this is only used for external navigation to the post stream, the delay
|
|
// before the stream is moved is not an issue.
|
|
return this.loadPromise.then(() => m.redraw());
|
|
}
|
|
|
|
/**
|
|
* Load and scroll to a certain index within the discussion.
|
|
*
|
|
* @param {number} index
|
|
* @param {Boolean} noAnimation
|
|
* @return {Promise}
|
|
*/
|
|
goToIndex(index, noAnimation = false) {
|
|
this.paused = true;
|
|
|
|
this.loadPromise = this.loadNearIndex(index);
|
|
|
|
this.needsScroll = true;
|
|
this.targetPost = { index };
|
|
this.animateScroll = !noAnimation;
|
|
this.index = index;
|
|
|
|
m.redraw();
|
|
|
|
return this.loadPromise;
|
|
}
|
|
|
|
/**
|
|
* Clear the stream and load posts near a certain number. Returns a promise.
|
|
* If the post with the given number is already loaded, the promise will be
|
|
* resolved immediately.
|
|
*
|
|
* @param {number} number
|
|
* @return {Promise}
|
|
*/
|
|
loadNearNumber(number) {
|
|
if (this.posts().some((post) => post && Number(post.number()) === Number(number))) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
this.reset();
|
|
|
|
return app.store
|
|
.find('posts', {
|
|
filter: { discussion: this.discussion.id() },
|
|
page: { near: number },
|
|
})
|
|
.then(this.show.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Clear the stream and load posts near a certain index. A page of posts
|
|
* surrounding the given index will be loaded. Returns a promise. If the given
|
|
* index is already loaded, the promise will be resolved immediately.
|
|
*
|
|
* @param {number} index
|
|
* @return {Promise}
|
|
*/
|
|
loadNearIndex(index) {
|
|
if (index >= this.visibleStart && index < this.visibleEnd) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
const start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
|
|
const end = start + this.constructor.loadCount;
|
|
|
|
this.reset(start, end);
|
|
|
|
return this.loadRange(start, end).then(this.show.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Load the next page of posts.
|
|
*/
|
|
loadNext() {
|
|
const start = this.visibleEnd;
|
|
const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount));
|
|
|
|
// Unload the posts which are two pages back from the page we're currently
|
|
// loading.
|
|
const twoPagesAway = start - this.constructor.loadCount * 2;
|
|
if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) {
|
|
this.visibleStart = twoPagesAway + this.constructor.loadCount + 1;
|
|
|
|
if (this.loadPageTimeouts[twoPagesAway]) {
|
|
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
|
this.loadPageTimeouts[twoPagesAway] = null;
|
|
this.pagesLoading--;
|
|
}
|
|
}
|
|
|
|
this.loadPage(start, end);
|
|
}
|
|
|
|
/**
|
|
* Load the previous page of posts.
|
|
*/
|
|
loadPrevious() {
|
|
const end = this.visibleStart;
|
|
const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount));
|
|
|
|
// Unload the posts which are two pages back from the page we're currently
|
|
// loading.
|
|
const twoPagesAway = start + this.constructor.loadCount * 2;
|
|
if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) {
|
|
this.visibleEnd = twoPagesAway;
|
|
|
|
if (this.loadPageTimeouts[twoPagesAway]) {
|
|
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
|
|
this.loadPageTimeouts[twoPagesAway] = null;
|
|
this.pagesLoading--;
|
|
}
|
|
}
|
|
|
|
this.loadPage(start, end, true);
|
|
}
|
|
|
|
/**
|
|
* Load a page of posts into the stream and redraw.
|
|
*
|
|
* @param {number} start
|
|
* @param {number} end
|
|
* @param {Boolean} backwards
|
|
*/
|
|
loadPage(start, end, backwards = false) {
|
|
m.redraw();
|
|
|
|
this.loadPageTimeouts[start] = setTimeout(
|
|
() => {
|
|
this.loadRange(start, end).then(() => {
|
|
if (start >= this.visibleStart && end <= this.visibleEnd) {
|
|
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
|
|
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw.sync());
|
|
}
|
|
this.pagesLoading--;
|
|
});
|
|
this.loadPageTimeouts[start] = null;
|
|
},
|
|
this.pagesLoading ? 1000 : 0
|
|
);
|
|
|
|
this.pagesLoading++;
|
|
}
|
|
|
|
/**
|
|
* Load and inject the specified range of posts into the stream, without
|
|
* clearing it.
|
|
*
|
|
* @param {number} start
|
|
* @param {number} end
|
|
* @return {Promise}
|
|
*/
|
|
loadRange(start, end) {
|
|
const loadIds = [];
|
|
const loaded = [];
|
|
|
|
this.discussion
|
|
.postIds()
|
|
.slice(start, end)
|
|
.forEach((id) => {
|
|
const post = app.store.getById('posts', id);
|
|
|
|
if (post && post.discussion() && typeof post.canEdit() !== 'undefined') {
|
|
loaded.push(post);
|
|
} else {
|
|
loadIds.push(id);
|
|
}
|
|
});
|
|
|
|
if (loadIds.length) {
|
|
return app.store.find('posts', loadIds).then((newPosts) => {
|
|
return loaded.concat(newPosts).sort((a, b) => a.createdAt() - b.createdAt());
|
|
});
|
|
}
|
|
|
|
return Promise.resolve(loaded);
|
|
}
|
|
|
|
/**
|
|
* Set up the stream with the given array of posts.
|
|
*
|
|
* @param {Post[]} posts
|
|
*/
|
|
show(posts) {
|
|
this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0;
|
|
this.visibleEnd = this.sanitizeIndex(this.visibleStart + posts.length);
|
|
}
|
|
|
|
/**
|
|
* Reset the stream so that a specific range of posts is displayed. If a range
|
|
* is not specified, the first page of posts will be displayed.
|
|
*
|
|
* @param {number} [start]
|
|
* @param {number} [end]
|
|
*/
|
|
reset(start, end) {
|
|
this.visibleStart = start || 0;
|
|
this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount);
|
|
}
|
|
|
|
/**
|
|
* Get the visible page of posts.
|
|
*
|
|
* @return {Post[]}
|
|
*/
|
|
posts() {
|
|
return this.discussion
|
|
.postIds()
|
|
.slice(this.visibleStart, this.visibleEnd)
|
|
.map((id) => {
|
|
const post = app.store.getById('posts', id);
|
|
|
|
return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get the total number of posts in the discussion.
|
|
*
|
|
* @return {number}
|
|
*/
|
|
count() {
|
|
return this.discussion.postIds().length;
|
|
}
|
|
|
|
/**
|
|
* Check whether or not the scrubber should be disabled, i.e. if all of the
|
|
* posts are visible in the viewport.
|
|
*
|
|
* @return {Boolean}
|
|
*/
|
|
disabled() {
|
|
return this.visible >= this.count();
|
|
}
|
|
|
|
/**
|
|
* Are we currently viewing the end of the discussion?
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
viewingEnd() {
|
|
// In some cases, such as if we've stickied a post, an event post
|
|
// may have been added / removed. This means that `this.visibleEnd`
|
|
// and`this.count()` will be out of sync by 1 post, but we are still
|
|
// "viewing the end" of the post stream, so we should still reload
|
|
// all posts up until the last one.
|
|
return Math.abs(this.count() - this.visibleEnd) <= 1;
|
|
}
|
|
|
|
/**
|
|
* Make sure that the given index is not outside of the possible range of
|
|
* indexes in the discussion.
|
|
*
|
|
* @param {number} index
|
|
*/
|
|
sanitizeIndex(index) {
|
|
return Math.max(0, Math.min(this.count(), Math.floor(index)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The number of posts to load per page.
|
|
*
|
|
* @type {number}
|
|
*/
|
|
PostStreamState.loadCount = 20;
|
|
|
|
export default PostStreamState;
|