Extract PostStream state (#2160)

Co-authored-by: Franz Liedke <franz@develophp.org>
This commit is contained in:
Alexander Skvortsov 2020-08-08 14:45:54 -04:00 committed by GitHub
parent 695e7bb766
commit 81e6964053
4 changed files with 595 additions and 606 deletions

View File

@ -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 {
<nav className="DiscussionPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="DiscussionPage-stream">{this.stream.render()}</div>
<div className="DiscussionPage-stream">
{PostStream.component({
discussion,
stream: this.stream,
targetPost: this.stream.targetPost,
onPositionChange: this.positionChanged.bind(this),
})}
</div>
</div>,
]
: 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);

View File

@ -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(
<div className="PostStream-loadMore" key="loadMore">
<Button className="Button" onclick={this.loadNext.bind(this)}>
<Button className="Button" onclick={this.stream.loadNext.bind(this.stream)}>
{app.translator.trans('core.forum.post_stream.load_more_button')}
</Button>
</div>
@ -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(
<div className="PostStream-item" key="reply">
{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;

View File

@ -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: <span className="Scrubber-index">{retain || formatNumber(Math.min(Math.ceil(this.index + this.visible), count))}</span>,
index: <span className="Scrubber-index"></span>,
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
});
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 (
<div className={'PostStreamScrubber Dropdown ' + (this.disabled() ? 'disabled ' : '') + (this.props.className || '')}>
<div className={classNames.join(' ')}>
<button className="Button Dropdown-toggle" data-toggle="dropdown">
{viewing} {icon('fas fa-sort')}
</button>
@ -98,7 +68,7 @@ export default class PostStreamScrubber extends Component {
<div className="Scrubber-bar" />
<div className="Scrubber-info">
<strong>{viewing}</strong>
<span className="Scrubber-description">{retain || this.description}</span>
<span className="Scrubber-description">{this.stream.description}</span>
</div>
</div>
<div className="Scrubber-after" />
@ -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,
};
}
}

View File

@ -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;