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;