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 {
-
,
]
: 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 (
-
@@ -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;