diff --git a/framework/core/js/src/forum/components/DiscussionPage.js b/framework/core/js/src/forum/components/DiscussionPage.js index 1d1a6678d..a3cfb0074 100644 --- a/framework/core/js/src/forum/components/DiscussionPage.js +++ b/framework/core/js/src/forum/components/DiscussionPage.js @@ -8,6 +8,7 @@ import SplitDropdown from '../../common/components/SplitDropdown'; import listItems from '../../common/helpers/listItems'; import DiscussionControls from '../utils/DiscussionControls'; import DiscussionList from './DiscussionList'; +import PostStreamState from '../states/PostStreamState'; /** * The `DiscussionPage` component displays a whole discussion page, including @@ -27,11 +28,11 @@ export default class DiscussionPage extends Page { /** * The number of the first post that is currently visible in the viewport. * - * @type {Integer} + * @type {number} */ - this.near = null; + this.near = m.route.param('near') || 0; - this.refresh(); + this.load(); // If the discussion list has been loaded, then we'll enable the pane (and // hide it by default). Also, if we've just come from another discussion @@ -107,7 +108,14 @@ export default class DiscussionPage extends Page { -
{this.stream.render()}
+
+ {PostStream.component({ + discussion, + stream: this.stream, + targetPost: this.stream.targetPost, + onPositionChange: this.positionChanged.bind(this), + })} +
, ] : LoadingIndicator.component({ className: 'LoadingIndicator--block' })} @@ -125,12 +133,9 @@ export default class DiscussionPage extends Page { } /** - * Clear and reload the discussion. + * Load the discussion from the API or use the preloaded one. */ - refresh() { - this.near = m.route.param('near') || 0; - this.discussion = null; - + load() { const preloadedDiscussion = app.preloadedApiDocument(); if (preloadedDiscussion) { // We must wrap this in a setTimeout because if we are mounting this @@ -197,8 +202,7 @@ export default class DiscussionPage extends Page { // Set up the post stream for this discussion, along with the first page of // posts we want to display. Tell the stream to scroll down and highlight // the specific post that was routed to. - this.stream = new PostStream({ discussion, includedPosts }); - this.stream.on('positionChanged', this.positionChanged.bind(this)); + this.stream = new PostStreamState(discussion, includedPosts); this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true); app.current.set('discussion', discussion); diff --git a/framework/core/js/src/forum/components/PostStream.js b/framework/core/js/src/forum/components/PostStream.js index 983fb8eff..ec8c61fd4 100644 --- a/framework/core/js/src/forum/components/PostStream.js +++ b/framework/core/js/src/forum/components/PostStream.js @@ -1,8 +1,6 @@ import Component from '../../common/Component'; import ScrollListener from '../../common/utils/ScrollListener'; import PostLoading from './LoadingPost'; -import anchorScroll from '../../common/utils/anchorScroll'; -import evented from '../../common/utils/evented'; import ReplyPlaceholder from './ReplyPlaceholder'; import Button from '../../common/components/Button'; @@ -13,183 +11,16 @@ import Button from '../../common/components/Button'; * ### Props * * - `discussion` - * - `includedPosts` + * - `stream` + * - `targetPost` + * - `onPositionChange` */ -class PostStream extends Component { +export default class PostStream extends Component { init() { - /** - * The discussion to display the post stream for. - * - * @type {Discussion} - */ this.discussion = this.props.discussion; - - /** - * Whether or not the infinite-scrolling auto-load functionality is - * disabled. - * - * @type {Boolean} - */ - this.paused = false; + this.stream = this.props.stream; this.scrollListener = new ScrollListener(this.onscroll.bind(this)); - this.loadPageTimeouts = {}; - this.pagesLoading = 0; - - this.show(this.props.includedPosts); - } - - /** - * Load and scroll to a post with a certain number. - * - * @param {Integer|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) { - // 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') { - return this.goToLast().then(() => { - $('html,body') - .stop(true) - .animate( - { - scrollTop: $(document).height() - $(window).height(), - }, - 'fast', - () => { - this.flashItem(this.$('.PostStream-item:last-child')); - } - ); - }); - } - - this.paused = true; - - const promise = this.loadNearNumber(number); - - m.redraw(true); - - return promise.then(() => { - m.redraw(true); - - this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this)); - }); - } - - /** - * Load and scroll to a certain index within the discussion. - * - * @param {Integer} index - * @param {Boolean} backwards Whether or not to load backwards from the given - * index. - * @param {Boolean} noAnimation - * @return {Promise} - */ - goToIndex(index, backwards, noAnimation) { - this.paused = true; - - const promise = this.loadNearIndex(index); - - m.redraw(true); - - return promise.then(() => { - anchorScroll(this.$('.PostStream-item:' + (backwards ? 'last' : 'first')), () => m.redraw(true)); - - this.scrollToIndex(index, noAnimation, backwards).done(this.unpause.bind(this)); - }); - } - - /** - * 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); - } - - /** - * 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 m.deferred().resolve().promise; - - this.visibleEnd = this.count(); - - return this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw()); - } - - /** - * Get the total number of posts in the discussion. - * - * @return {Integer} - */ - count() { - return this.discussion.postIds().length; - } - - /** - * Make sure that the given index is not outside of the possible range of - * indexes in the discussion. - * - * @param {Integer} index - * @protected - */ - sanitizeIndex(index) { - return Math.max(0, Math.min(this.count(), index)); - } - - /** - * 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.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 {Integer} [start] - * @param {Integer} [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; - }); } view() { @@ -200,15 +31,13 @@ class PostStream extends Component { let lastTime; - this.visibleEnd = this.sanitizeIndex(this.visibleEnd); - this.viewingEnd = this.visibleEnd === this.count(); - - const posts = this.posts(); + const viewingEnd = this.stream.viewingEnd(); + const posts = this.stream.posts(); const postIds = this.discussion.postIds(); const items = posts.map((post, i) => { let content; - const attrs = { 'data-index': this.visibleStart + i }; + const attrs = { 'data-index': this.stream.visibleStart + i }; if (post) { const time = post.createdAt(); @@ -238,7 +67,7 @@ class PostStream extends Component { lastTime = time; } else { - attrs.key = 'post' + postIds[this.visibleStart + i]; + attrs.key = 'post' + postIds[this.stream.visibleStart + i]; content = PostLoading.component(); } @@ -250,10 +79,10 @@ class PostStream extends Component { ); }); - if (!this.viewingEnd && posts[this.visibleEnd - this.visibleStart - 1]) { + if (!viewingEnd && posts[this.stream.visibleEnd - this.stream.visibleStart - 1]) { items.push(
-
@@ -262,7 +91,7 @@ class PostStream extends Component { // If we're viewing the end of the discussion, the user can reply, and // is not already doing so, then show a 'write a reply' placeholder. - if (this.viewingEnd && (!app.session.user || this.discussion.canReply())) { + if (viewingEnd && (!app.session.user || this.discussion.canReply())) { items.push(
{ReplyPlaceholder.component({ discussion: this.discussion })} @@ -274,6 +103,8 @@ class PostStream extends Component { } config(isInitialized, context) { + this.triggerScroll(); + if (isInitialized) return; // This is wrapped in setTimeout due to the following Mithril issue: @@ -286,201 +117,135 @@ class PostStream extends Component { }; } + /** + * Start scrolling, if appropriate, to a newly-targeted post. + */ + triggerScroll() { + if (!this.props.targetPost) return; + + const oldTarget = this.prevTarget; + const newTarget = this.props.targetPost; + + if (oldTarget) { + if ('number' in oldTarget && oldTarget.number === newTarget.number) return; + if ('index' in oldTarget && oldTarget.index === newTarget.index) return; + } + + if ('number' in newTarget) { + this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll); + } else if ('index' in newTarget) { + const backwards = newTarget.index === this.stream.count() - 1; + this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards); + } + + this.prevTarget = newTarget; + } + /** * When the window is scrolled, check if either extreme of the post stream is * in the viewport, and if so, trigger loading the next/previous page. * * @param {Integer} top */ - onscroll(top) { - if (this.paused) return; - + onscroll(top = window.pageYOffset) { + if (this.stream.paused) return; const marginTop = this.getMarginTop(); const viewportHeight = $(window).height() - marginTop; const viewportTop = top + marginTop; const loadAheadDistance = 300; - if (this.visibleStart > 0) { - const $item = this.$('.PostStream-item[data-index=' + this.visibleStart + ']'); + if (this.stream.visibleStart > 0) { + const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']'); if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) { - this.loadPrevious(); + this.stream.loadPrevious(); } } - if (this.visibleEnd < this.count()) { - const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']'); + if (this.stream.visibleEnd < this.stream.count()) { + const $item = this.$('.PostStream-item[data-index=' + (this.stream.visibleEnd - 1) + ']'); if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) { - this.loadNext(); + this.stream.loadNext(); } } // Throttle calculation of our position (start/end numbers of posts in the // viewport) to 100ms. clearTimeout(this.calculatePositionTimeout); - this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100); + this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100); + + this.updateScrubber(top); } - /** - * Load the next page of posts. - */ - loadNext() { - const start = this.visibleEnd; - const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount)); + updateScrubber(top = window.pageYOffset) { + const marginTop = this.getMarginTop(); + const viewportHeight = $(window).height() - marginTop; + const viewportTop = top + marginTop; - // 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; + // Before looping through all of the posts, we reset the scrollbar + // properties to a 'default' state. These values reflect what would be + // seen if the browser were scrolled right up to the top of the page, + // and the viewport had a height of 0. + const $items = this.$('.PostStream-item[data-index]'); + let index = $items.first().data('index') || 0; + let visible = 0; + let period = ''; - if (this.loadPageTimeouts[twoPagesAway]) { - clearTimeout(this.loadPageTimeouts[twoPagesAway]); - this.loadPageTimeouts[twoPagesAway] = null; - this.pagesLoading--; + // Now loop through each of the items in the discussion. An 'item' is + // either a single post or a 'gap' of one or more posts that haven't + // been loaded yet. + $items.each(function () { + const $this = $(this); + const top = $this.offset().top; + const height = $this.outerHeight(true); + + // If this item is above the top of the viewport, skip to the next + // one. If it's below the bottom of the viewport, break out of the + // loop. + if (top + height < viewportTop) { + return true; } - } - - 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--; + if (top > viewportTop + viewportHeight) { + return false; } - } - this.loadPage(start, end, true); - } + // Work out how many pixels of this item are visible inside the viewport. + // Then add the proportion of this item's total height to the index. + const visibleTop = Math.max(0, viewportTop - top); + const visibleBottom = Math.min(height, viewportTop + viewportHeight - top); + const visiblePost = visibleBottom - visibleTop; - /** - * Load a page of posts into the stream and redraw. - * - * @param {Integer} start - * @param {Integer} end - * @param {Boolean} backwards - */ - loadPage(start, end, backwards) { - const redraw = () => { - if (start < this.visibleStart || end > this.visibleEnd) return; + if (top <= viewportTop) { + index = parseFloat($this.data('index')) + visibleTop / height; + } - const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart; - anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true)); + if (visiblePost > 0) { + visible += visiblePost / height; + } - this.unpause(); - }; - redraw(); + // If this item has a time associated with it, then set the + // scrollbar's current period to a formatted version of this time. + const time = $this.data('time'); + if (time) period = time; + }); - this.loadPageTimeouts[start] = setTimeout( - () => { - this.loadRange(start, end).then(() => { - redraw(); - 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 {Integer} start - * @param {Integer} 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); - } - }); - - return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise; - } - - /** - * 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 {Integer} number - * @return {Promise} - */ - loadNearNumber(number) { - if (this.posts().some((post) => post && Number(post.number()) === Number(number))) { - return m.deferred().resolve().promise; - } - - 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 {Integer} index - * @return {Promise} - */ - loadNearIndex(index) { - if (index >= this.visibleStart && index <= this.visibleEnd) { - return m.deferred().resolve().promise; - } - - 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)); + this.stream.index = index + 1; + this.stream.visible = visible; + if (period) this.stream.description = dayjs(period).format('MMMM YYYY'); } /** * Work out which posts (by number) are currently visible in the viewport, and * fire an event with the information. */ - calculatePosition() { + calculatePosition(top = window.pageYOffset) { const marginTop = this.getMarginTop(); const $window = $(window); const viewportHeight = $window.height() - marginTop; const scrollTop = $window.scrollTop() + marginTop; + const viewportTop = top + marginTop; + let startNumber; let endNumber; @@ -488,12 +253,15 @@ class PostStream extends Component { const $item = $(this); const top = $item.offset().top; const height = $item.outerHeight(true); + const visibleTop = Math.max(0, viewportTop - top); + + const threeQuartersVisible = visibleTop / height < 0.75; + const coversQuarterOfViewport = (height - visibleTop) / viewportHeight > 0.25; + if (startNumber === undefined && (threeQuartersVisible || coversQuarterOfViewport)) { + startNumber = $item.data('number'); + } if (top + height > scrollTop) { - if (!startNumber) { - startNumber = endNumber = $item.data('number'); - } - if (top + height < scrollTop + viewportHeight) { if ($item.data('number')) { endNumber = $item.data('number'); @@ -503,7 +271,7 @@ class PostStream extends Component { }); if (startNumber) { - this.trigger('positionChanged', startNumber || 1, endNumber); + this.props.onPositionChange(startNumber || 1, endNumber, startNumber); } } @@ -521,42 +289,46 @@ class PostStream extends Component { * Scroll down to a certain post by number and 'flash' it. * * @param {Integer} number - * @param {Boolean} noAnimation + * @param {Boolean} animate * @return {jQuery.Deferred} */ - scrollToNumber(number, noAnimation) { + scrollToNumber(number, animate) { const $item = this.$(`.PostStream-item[data-number=${number}]`); - return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item)); + return this.scrollToItem($item, animate).then(this.flashItem.bind(this, $item)); } /** * Scroll down to a certain post by index. * * @param {Integer} index - * @param {Boolean} noAnimation + * @param {Boolean} animate * @param {Boolean} bottom Whether or not to scroll to the bottom of the post * at the given index, instead of the top of it. * @return {jQuery.Deferred} */ - scrollToIndex(index, noAnimation, bottom) { + scrollToIndex(index, animate, bottom) { const $item = this.$(`.PostStream-item[data-index=${index}]`); - return this.scrollToItem($item, noAnimation, true, bottom); + return this.scrollToItem($item, animate, true, bottom).then(() => { + if (index == this.stream.count() - 1) { + this.flashItem(this.$('.PostStream-item:last-child')); + } + }); } /** * Scroll down to the given post. * * @param {jQuery} $item - * @param {Boolean} noAnimation + * @param {Boolean} animate * @param {Boolean} force Whether or not to force scrolling to the item, even * if it is already in the viewport. * @param {Boolean} bottom Whether or not to scroll to the bottom of the post * at the given index, instead of the top of it. * @return {jQuery.Deferred} */ - scrollToItem($item, noAnimation, force, bottom) { + scrollToItem($item, animate, force, bottom) { const $container = $('html, body').stop(true); if ($item.length) { @@ -571,7 +343,7 @@ class PostStream extends Component { if (force || itemTop < scrollTop || itemBottom > scrollBottom) { const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop; - if (noAnimation) { + if (!animate) { $container.scrollTop(top); } else if (top !== scrollTop) { $container.animate({ scrollTop: top }, 'fast'); @@ -579,7 +351,15 @@ class PostStream extends Component { } } - return $container.promise(); + return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => { + this.updateScrubber(); + const index = $item.data('index'); + m.redraw(true); + const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop(); + $(window).scrollTop(scroll); + this.calculatePosition(); + this.stream.paused = false; + }); } /** @@ -590,24 +370,4 @@ class PostStream extends Component { flashItem($item) { $item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash')); } - - /** - * Resume the stream's ability to auto-load posts on scroll. - */ - unpause() { - this.paused = false; - this.scrollListener.update(); - this.trigger('unpaused'); - } } - -/** - * The number of posts to load per page. - * - * @type {Integer} - */ -PostStream.loadCount = 20; - -Object.assign(PostStream.prototype, evented); - -export default PostStream; diff --git a/framework/core/js/src/forum/components/PostStreamScrubber.js b/framework/core/js/src/forum/components/PostStreamScrubber.js index 4f133c596..5371c8c04 100644 --- a/framework/core/js/src/forum/components/PostStreamScrubber.js +++ b/framework/core/js/src/forum/components/PostStreamScrubber.js @@ -1,8 +1,7 @@ import Component from '../../common/Component'; import icon from '../../common/helpers/icon'; -import ScrollListener from '../../common/utils/ScrollListener'; -import SubtreeRetainer from '../../common/utils/SubtreeRetainer'; import formatNumber from '../../common/utils/formatNumber'; +import ScrollListener from '../../common/utils/ScrollListener'; /** * The `PostStreamScrubber` component displays a scrubber which can be used to @@ -15,55 +14,24 @@ import formatNumber from '../../common/utils/formatNumber'; */ export default class PostStreamScrubber extends Component { init() { + this.stream = this.props.stream; this.handlers = {}; - /** - * The index of the post that is currently at the top of the viewport. - * - * @type {Number} - */ - this.index = 0; - - /** - * 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 post stream begins loading posts at a certain index, we want our - // scrubber scrollbar to jump to that position. - this.props.stream.on('unpaused', (this.handlers.streamWasUnpaused = this.streamWasUnpaused.bind(this))); - - // Define a handler to update the state of the scrollbar to reflect the - // current scroll position of the page. - this.scrollListener = new ScrollListener(this.onscroll.bind(this)); - - // Create a subtree retainer that will always cache the subtree after the - // initial draw. We render parts of the scrubber using this because we - // modify their DOM directly, and do not want Mithril messing around with - // our changes. - this.subtree = new SubtreeRetainer(() => true); + this.scrollListener = new ScrollListener(this.updateScrubberValues.bind(this, { fromScroll: true, forceHeightChange: true })); } view() { - const retain = this.subtree.retain(); - const count = this.count(); - const unreadCount = this.props.stream.discussion.unreadCount(); - const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0; + const count = this.stream.count(); + // Index is left blank for performance reasons, it is filled in in updateScubberValues const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, { - index: {retain || formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}, + index: , count: {formatNumber(count)}, }); + const unreadCount = this.stream.discussion.unreadCount(); + const unreadPercent = count ? Math.min(count - this.stream.index, unreadCount) / count : 0; + function styleUnread(element, isInitialized, context) { const $element = $(element); const newStyle = { @@ -79,9 +47,11 @@ export default class PostStreamScrubber extends Component { context.oldStyle = newStyle; } + const classNames = ['PostStreamScrubber', 'Dropdown']; + if (this.props.className) classNames.push(this.props.className); return ( -
+
@@ -98,7 +68,7 @@ export default class PostStreamScrubber extends Component {
{viewing} - {retain || this.description} + {this.stream.description}
@@ -117,138 +87,12 @@ export default class PostStreamScrubber extends Component { ); } - /** - * Go to the first post in the discussion. - */ - goToFirst() { - this.props.stream.goToFirst(); - this.index = 0; - this.renderScrollbar(true); - } - - /** - * Go to the last post in the discussion. - */ - goToLast() { - this.props.stream.goToLast(); - this.index = this.count(); - this.renderScrollbar(true); - } - - /** - * Get the number of posts in the discussion. - * - * @return {Integer} - */ - count() { - return this.props.stream.count(); - } - - /** - * When the stream is unpaused, update the scrubber to reflect its position. - */ - streamWasUnpaused() { - this.update(window.pageYOffset); - this.renderScrollbar(true); - } - - /** - * 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(); - } - - /** - * When the page is scrolled, update the scrollbar to reflect the visible - * posts. - * - * @param {Integer} top - */ - onscroll(top) { - const stream = this.props.stream; - - if (stream.paused || !stream.$()) return; - - this.update(top); - this.renderScrollbar(); - } - - /** - * Update the index/visible/description properties according to the window's - * current scroll position. - * - * @param {Integer} scrollTop - */ - update(scrollTop) { - const stream = this.props.stream; - - const marginTop = stream.getMarginTop(); - const viewportTop = scrollTop + marginTop; - const viewportHeight = $(window).height() - marginTop; - - // Before looping through all of the posts, we reset the scrollbar - // properties to a 'default' state. These values reflect what would be - // seen if the browser were scrolled right up to the top of the page, - // and the viewport had a height of 0. - const $items = stream.$('> .PostStream-item[data-index]'); - let index = $items.first().data('index') || 0; - let visible = 0; - let period = ''; - - // Now loop through each of the items in the discussion. An 'item' is - // either a single post or a 'gap' of one or more posts that haven't - // been loaded yet. - $items.each(function () { - const $this = $(this); - const top = $this.offset().top; - const height = $this.outerHeight(true); - - // If this item is above the top of the viewport, skip to the next - // one. If it's below the bottom of the viewport, break out of the - // loop. - if (top + height < viewportTop) { - return true; - } - if (top > viewportTop + viewportHeight) { - return false; - } - - // Work out how many pixels of this item are visible inside the viewport. - // Then add the proportion of this item's total height to the index. - const visibleTop = Math.max(0, viewportTop - top); - const visibleBottom = Math.min(height, viewportTop + viewportHeight - top); - const visiblePost = visibleBottom - visibleTop; - - if (top <= viewportTop) { - index = parseFloat($this.data('index')) + visibleTop / height; - } - - if (visiblePost > 0) { - visible += visiblePost / height; - } - - // If this item has a time associated with it, then set the - // scrollbar's current period to a formatted version of this time. - const time = $this.data('time'); - if (time) period = time; - }); - - this.index = index; - this.visible = visible; - this.description = period ? dayjs(period).format('MMMM YYYY') : ''; - } - config(isInitialized, context) { + this.stream.loadPromise.then(() => this.updateScrubberValues({ animate: true, forceHeightChange: true })); if (isInitialized) return; context.onunload = this.ondestroy.bind(this); - this.scrollListener.start(); - // Whenever the window is resized, adjust the height of the scrollbar // so that it fills the height of the sidebar. $(window) @@ -287,16 +131,8 @@ export default class PostStreamScrubber extends Component { $(document) .on('mousemove touchmove', (this.handlers.onmousemove = this.onmousemove.bind(this))) .on('mouseup touchend', (this.handlers.onmouseup = this.onmouseup.bind(this))); - } - ondestroy() { - this.scrollListener.stop(); - - this.props.stream.off('unpaused', this.handlers.streamWasUnpaused); - - $(window).off('resize', this.handlers.onresize); - - $(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup); + setTimeout(() => this.scrollListener.start()); } /** @@ -305,66 +141,69 @@ export default class PostStreamScrubber extends Component { * * @param {Boolean} animate */ - renderScrollbar(animate) { + updateScrubberValues(options = {}) { + const index = this.stream.index; + const count = this.stream.count(); + const visible = this.stream.visible || 1; const percentPerPost = this.percentPerPost(); - const index = this.index; - const count = this.count(); - const visible = this.visible || 1; const $scrubber = this.$(); - $scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count))); - $scrubber.find('.Scrubber-description').text(this.description); - $scrubber.toggleClass('disabled', this.disabled()); + $scrubber.find('.Scrubber-index').text(formatNumber(this.stream.sanitizeIndex(Math.max(1, index)))); + $scrubber.find('.Scrubber-description').text(this.stream.description); + $scrubber.toggleClass('disabled', this.stream.disabled()); const heights = {}; - heights.before = Math.max(0, percentPerPost.index * Math.min(index, count - visible)); + heights.before = Math.max(0, percentPerPost.index * Math.min(index - 1, count - visible)); heights.handle = Math.min(100 - heights.before, percentPerPost.visible * visible); heights.after = 100 - heights.before - heights.handle; - const func = animate ? 'animate' : 'css'; + // If the stream is paused, don't change height on scroll, as the viewport is being scrolled by the JS + // If a height change animation is already in progress, don't adjust height unless overriden + if ((options.fromScroll && this.stream.paused) || (this.adjustingHeight && !options.forceHeightChange)) return; + + const func = options.animate ? 'animate' : 'css'; + this.adjustingHeight = true; + const animationPromises = []; for (const part in heights) { const $part = $scrubber.find(`.Scrubber-${part}`); - $part.stop(true, true)[func]({ height: heights[part] + '%' }, 'fast'); + animationPromises.push( + $part + .stop(true, true) + [func]({ height: heights[part] + '%' }, 'fast') + .promise() + ); // jQuery likes to put overflow:hidden, but because the scrollbar handle // has a negative margin-left, we need to override. if (func === 'animate') $part.css('overflow', 'visible'); } + Promise.all(animationPromises).then(() => (this.adjustingHeight = false)); } /** - * Get the percentage of the height of the scrubber that should be allocated - * to each post. - * - * @return {Object} - * @property {Number} index The percent per post for posts on either side of - * the visible part of the scrubber. - * @property {Number} visible The percent per post for the visible part of the - * scrubber. + * Go to the first post in the discussion. */ - percentPerPost() { - const count = this.count() || 1; - const visible = this.visible || 1; + goToFirst() { + this.stream.goToFirst(); + this.updateScrubberValues({ animate: true, forceHeightChange: true }); + } - // To stop the handle of the scrollbar from getting too small when there - // are many posts, we define a minimum percentage height for the handle - // calculated from a 50 pixel limit. From this, we can calculate the - // minimum percentage per visible post. If this is greater than the actual - // percentage per post, then we need to adjust the 'before' percentage to - // account for it. - const minPercentVisible = (50 / this.$('.Scrubber-scrollbar').outerHeight()) * 100; - const percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible); - const percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible); + /** + * Go to the last post in the discussion. + */ + goToLast() { + this.stream.goToLast(); + this.updateScrubberValues({ animate: true, forceHeightChange: true }); + } - return { - index: percentPerPost, - visible: percentPerVisiblePost, - }; + ondestroy() { + this.scrollListener.stop(); + $(window).off('resize', this.handlers.onresize); + + $(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup); } onresize() { - this.scrollListener.update(); - // Adjust the height of the scrollbar so that it fills the height of // the sidebar and doesn't overlap the footer. const scrubber = this.$(); @@ -381,11 +220,12 @@ export default class PostStreamScrubber extends Component { } onmousedown(e) { + e.redraw = false; this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY; - this.indexStart = this.index; + this.indexStart = this.stream.index; this.dragging = true; - this.props.stream.paused = true; $('body').css('cursor', 'move'); + this.$().toggleClass('dragging', this.dragging); } onmousemove(e) { @@ -398,13 +238,14 @@ export default class PostStreamScrubber extends Component { const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart; const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100; const deltaIndex = deltaPercent / this.percentPerPost().index || 0; - const newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1); + const newIndex = Math.min(this.indexStart + deltaIndex, this.stream.count() - 1); - this.index = Math.max(0, newIndex); - this.renderScrollbar(); + this.stream.index = Math.max(0, newIndex); + this.updateScrubberValues(); } onmouseup() { + this.$().toggleClass('dragging', this.dragging); if (!this.dragging) return; this.mouseStart = 0; @@ -416,9 +257,8 @@ export default class PostStreamScrubber extends Component { // If the index we've landed on is in a gap, then tell the stream- // content that we want to load those posts. - const intIndex = Math.floor(this.index); - this.props.stream.goToIndex(intIndex); - this.renderScrollbar(true); + const intIndex = Math.floor(this.stream.index); + this.stream.goToIndex(intIndex); } onclick(e) { @@ -438,11 +278,40 @@ export default class PostStreamScrubber extends Component { // 3. Now we can convert the percentage into an index, and tell the stream- // content component to jump to that index. let offsetIndex = offsetPercent / this.percentPerPost().index; - offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex)); - this.props.stream.goToIndex(Math.floor(offsetIndex)); - this.index = offsetIndex; - this.renderScrollbar(true); + offsetIndex = Math.max(0, Math.min(this.stream.count() - 1, offsetIndex)); + this.stream.goToIndex(Math.floor(offsetIndex)); + this.updateScrubberValues({ animate: true, forceHeightChange: true }); this.$().removeClass('open'); } + + /** + * Get the percentage of the height of the scrubber that should be allocated + * to each post. + * + * @return {Object} + * @property {Number} index The percent per post for posts on either side of + * the visible part of the scrubber. + * @property {Number} visible The percent per post for the visible part of the + * scrubber. + */ + percentPerPost() { + const count = this.stream.count() || 1; + const visible = this.stream.visible || 1; + + // To stop the handle of the scrollbar from getting too small when there + // are many posts, we define a minimum percentage height for the handle + // calculated from a 50 pixel limit. From this, we can calculate the + // minimum percentage per visible post. If this is greater than the actual + // percentage per post, then we need to adjust the 'before' percentage to + // account for it. + const minPercentVisible = (50 / this.$('.Scrubber-scrollbar').outerHeight()) * 100; + const percentPerVisiblePost = Math.max(100 / count, minPercentVisible / visible); + const percentPerPost = count === visible ? 0 : (100 - percentPerVisiblePost * visible) / (count - visible); + + return { + index: percentPerPost, + visible: percentPerVisiblePost, + }; + } } diff --git a/framework/core/js/src/forum/states/PostStreamState.js b/framework/core/js/src/forum/states/PostStreamState.js new file mode 100644 index 000000000..a4e9bd01f --- /dev/null +++ b/framework/core/js/src/forum/states/PostStreamState.js @@ -0,0 +1,356 @@ +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 = ''; + + 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 m.deferred().resolve().promise; + + 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') { + return this.goToLast(); + } + + this.paused = true; + + this.loadPromise = this.loadNearNumber(number); + + this.targetPost = { number }; + this.noAnimationScroll = 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.targetPost = { index }; + this.noAnimationScroll = 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 m.deferred().resolve().promise; + } + + 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 m.deferred().resolve().promise; + } + + 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(true)); + } + 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); + } + }); + + return loadIds.length ? app.store.find('posts', loadIds) : m.deferred().resolve(loaded).promise; + } + + /** + * 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() { + return this.visibleEnd === this.count(); + } + + /** + * 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;