1
0
mirror of https://github.com/flarum/core.git synced 2025-08-13 11:54:32 +02:00

Compare commits

...

69 Commits

Author SHA1 Message Date
Franz Liedke
fc5eddb99d Default parameter value 2020-08-02 09:25:36 +02:00
Franz Liedke
bad8115a4a Remove obsolete properties in post stream state 2020-08-02 08:01:55 +02:00
Franz Liedke
1fc76acf06 Stop injecting post stream state into scrubber 2020-08-02 07:56:58 +02:00
Franz Liedke
527d93120a Shrink the diff, e.g. by moving methods around 2020-08-01 02:59:55 +02:00
Franz Liedke
53582ab999 WIP: Re-work stream, scrubber and state
Mostly, this tries to move common logic up to the DiscussionPage as the
lowest common ancestor of these components sharing certain state.

It's still messy, though. :-/
2020-08-01 02:47:36 +02:00
Franz Liedke
6f6a09d7c4 Start decoupling scrolling from state 2020-07-31 23:23:32 +02:00
Franz Liedke
e8394e4a1d Unify pausing 2020-07-31 16:42:42 +02:00
Franz Liedke
e455e6c431 Restore old implementation of goToLast()
It's not clear whether this was intentionally omitted.
2020-07-31 16:13:16 +02:00
Franz Liedke
a044c642f6 Add default parameter value
This is actually relied on already by not passing the parameter in other
methods.
2020-07-31 16:12:17 +02:00
Franz Liedke
01384139ef Encapsulate a bit more logic in the state 2020-07-31 15:42:01 +02:00
Franz Liedke
57f5ad4893 Move method to previous position 2020-07-31 13:27:26 +02:00
Franz Liedke
8b69b24272 Fix docblocks 2020-07-31 13:21:45 +02:00
Franz Liedke
09c722e522 Remove unused prop 2020-07-31 12:14:00 +02:00
Franz Liedke
3ce94757fc Rename props 2020-07-31 12:13:25 +02:00
Franz Liedke
aae6f24356 Fix docblock 2020-07-31 12:06:33 +02:00
Franz Liedke
1a2f9527fd Revert formatting changes 2020-07-31 11:32:54 +02:00
Alexander Skvortsov
8c362bf7c7 Don't save index twice in post stream post-load 2020-07-31 11:18:12 +02:00
Alexander Skvortsov
f99f79e3c0 De-propify visible 2020-07-31 11:18:12 +02:00
Alexander Skvortsov
bbd8136695 A bit more cleanup 2020-07-31 11:17:46 +02:00
Alexander Skvortsov
1d8662088f A bit more cleanup and bugfixes 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
a850f4a6fb Restore old scrubber index calculation system 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
af55a13c61 A bit more cleanup, UI bugfixes 2020-07-31 11:17:45 +02:00
Alexander Skvortsov
92b62e7ab6 Minor cleanup of PostStreamState methods 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
5ef4de75d1 Fix date not showing up properly 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
88e6be9d0e When scrolling to first post, scroll all the way to top, simplify scrollToItem promise structure 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
228c7b883d move index calculation back out of show 2020-07-31 11:17:44 +02:00
Alexander Skvortsov
cdcf64852e Restore scrubber behavior 2020-07-31 11:17:43 +02:00
Alexander Skvortsov
d20650fb42 Use date of the post in index 2020-07-31 11:17:43 +02:00
Alexander Skvortsov
875a1f70c1 Fix date, index calculation on reload 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
ef206495cd Try calculating index before redraw to avoid calling redraw immediately after scroll 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
2360745237 Fix jumping around on page reload 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
cc10eaadd2 Get rid of separate system for scrollToLast 2020-07-31 11:17:42 +02:00
Alexander Skvortsov
c98c0b027f Fix missing method call 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
73507f403a Don't use anchorScroll on goToNumber, instead scrolling directly to item 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
d3fb5ee77c Handle scroll to end as a special case of scroll to index to ensure that we get completely to the bottom and flash the bottom element 2020-07-31 11:17:41 +02:00
Alexander Skvortsov
479e5a8cf6 in goToNumber, only redraw when the response has been returned. 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
4bce030115 Use same logic as in updateScrubber to calculate current post number 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
9f2540dbe3 Properly bind loadNext button to the state 2020-07-31 11:17:40 +02:00
Alexander Skvortsov
aa15db6f44 Ensure consistent index in scrubber, rework current post index calculation logic. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
0c63be527b Pass in a selector string to anchorScroll instead of a DOM element. Because the DOM element gets destroyed on redraw, it's offset height is interpreted as 0 which throws off our position in the stream. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
9db2f78939 Incorporate math floor in sanitizeIndex, use that for scrubber index display. 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
9572863648 Add anchorScroll with redraw after loadPromise loads in scrollToItem 2020-07-31 11:17:39 +02:00
Alexander Skvortsov
ac1eef7578 Remove unused anchorScroll import 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
514165c3af Anchor scroll after loading posts 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
e84960dcd1 Cleanup 2020-07-31 11:17:38 +02:00
Alexander Skvortsov
f8d1c7a317 Add redraws after posts have been loaded from the API 2020-07-31 11:17:37 +02:00
Alexander Skvortsov
ba82969a58 Remove unnecessary redraw 2020-07-31 11:17:37 +02:00
Alexander Skvortsov
b2917c8716 Add more console logs 2020-07-31 11:17:12 +02:00
Alexander Skvortsov
c150c097c1 Add some more debugging flags 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
beab8ce39c Update scrubber after scrollToItem 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
1360723c3f Separate updateScrubber into separate method from onscroll 2020-07-31 11:17:11 +02:00
Alexander Skvortsov
5cdfeaf9a5 Move scrollPromise log into scrollToItem 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
6e1d385268 Code cleanup, added a bunch of debug console logging 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
193f3b040d Slightly improve scrubber label accuracy on click 2020-07-31 11:17:10 +02:00
Alexander Skvortsov
74cb4f9007 Get rid of post stream events. Initial load is still buggy 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
eb24e628fa Get rid of js-PostStream 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
c03feceb9f Fix goToLast 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
51008bc65d Use scrollToIndex to contain scrollToLast 2020-07-31 11:17:09 +02:00
Alexander Skvortsov
9a357f5d19 Add scrubber height change transition css, don't apply when dragging 2020-07-31 11:17:08 +02:00
Alexander Skvortsov
9c63c54868 Simplify paused logic 2020-07-31 11:17:08 +02:00
Alexander Skvortsov
5427b35c6d Large simplifications of PostStreamScrubber 2020-07-31 11:17:08 +02:00
Franz Liedke
2ec49db6df Pass discussion as prop to stream components
- Law of demeter (no need to access discussion through the state)
- Less public API surface of the state object
2020-07-31 11:17:07 +02:00
Franz Liedke
062dc8f57f Don't call protected method outside of state
In addition, this again avoids writing a state property from
outside the state class.

I am not 100% sure whether this extra sanitization is necessary,
but it seems to be the only place where it is not applied when
changing the value of `visibleEnd` (and not safeguarded otherwise),
so I erred on the safe side.
2020-07-31 11:17:07 +02:00
Franz Liedke
8a9e50d192 Encapsulate viewingEnd() in state
...instead of calculating this derived value outside the state class.
2020-07-31 11:17:07 +02:00
Franz Liedke
6c087da65f Remove obsolete event handler
The event is not triggered anymore.
This is now handled through the `positionHandler` prop.
2020-07-31 11:17:06 +02:00
Franz Liedke
6bcecd623b Revert inlining method, rename the method instead 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
614bb0d71e Moved refresh method of discussionpage into init, as its not used externally (and using it would be bad practice), fixing up PostStream 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
cff9b327a9 Remove event from PostState, pass handler in via props, 2020-07-31 11:17:06 +02:00
Alexander Skvortsov
7af8e35a6e Extract PostStream state 2020-07-31 11:17:03 +02:00
4 changed files with 610 additions and 604 deletions

View File

@@ -8,6 +8,8 @@ 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';
import ScrollListener from '../../common/utils/ScrollListener';
/**
* The `DiscussionPage` component displays a whole discussion page, including
@@ -27,11 +29,13 @@ 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.scrollListener = new ScrollListener(this.onscroll.bind(this));
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 +111,13 @@ 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,
})}
</div>
</div>,
]
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
@@ -116,21 +126,24 @@ export default class DiscussionPage extends Page {
);
}
config(...args) {
super.config(...args);
config(isInitialized, context) {
super.config(isInitialized, context);
if (this.discussion) {
app.setTitle(this.discussion.title());
}
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
* 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,12 +210,13 @@ 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);
app.current.set('stream', this.stream);
this.scrollListener.start();
}
/**
@@ -268,8 +282,12 @@ export default class DiscussionPage extends Page {
items.add(
'scrubber',
PostStreamScrubber.component({
stream: this.stream,
discussion: this.discussion,
className: 'App-titleControl',
onNavigate: this.stream.goToIndex.bind(this.stream),
count: this.stream.count(),
paused: this.stream.paused,
...this.scrubberProps(),
}),
-100
);
@@ -277,6 +295,84 @@ export default class DiscussionPage extends Page {
return items;
}
/**
* 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 {number} top
*/
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.stream.visibleStart > 0) {
const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']');
if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
this.stream.loadPrevious();
}
}
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.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, top), 100);
// Update numbers for the scrubber if necessary
m.redraw();
}
/**
* Work out which posts (by number) are currently visible in the viewport, and
* fire an event with the information.
*/
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;
this.$('.PostStream-item').each(function () {
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 (top + height < scrollTop + viewportHeight) {
if ($item.data('number')) {
endNumber = $item.data('number');
}
} else return false;
}
});
if (startNumber) {
this.positionChanged(startNumber || 1, endNumber);
}
}
/**
* When the posts that are visible in the post stream change (i.e. the user
* scrolls up or down), then we update the URL and mark the posts as read.
@@ -303,4 +399,73 @@ export default class DiscussionPage extends Page {
m.redraw();
}
}
scrubberProps(top = window.pageYOffset) {
const marginTop = this.getMarginTop();
const viewportHeight = $(window).height() - marginTop;
const viewportTop = top + 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 = this.$('.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;
});
return {
index: index + 1,
visible: visible || 1,
description: period && dayjs(period).format('MMMM YYYY'),
};
}
/**
* Get the distance from the top of the viewport to the point at which we
* would consider a post to be the first one visible.
*
* @return {Integer}
*/
getMarginTop() {
return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10);
}
}

View File

@@ -1,8 +1,5 @@
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,9 +10,10 @@ import Button from '../../common/components/Button';
* ### Props
*
* - `discussion`
* - `includedPosts`
* - `stream`
* - `targetPost`
*/
class PostStream extends Component {
export default class PostStream extends Component {
init() {
/**
* The discussion to display the post stream for.
@@ -25,171 +23,11 @@ class PostStream extends Component {
this.discussion = this.props.discussion;
/**
* Whether or not the infinite-scrolling auto-load functionality is
* disabled.
* The shared state of the post stream.
*
* @type {Boolean}
* @type {PostStreamState}
*/
this.paused = false;
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;
});
this.stream = this.props.stream;
}
view() {
@@ -200,15 +38,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 +74,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 +86,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 +98,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,237 +110,25 @@ class PostStream extends Component {
}
config(isInitialized, context) {
if (isInitialized) return;
// Start scrolling, if appropriate, to a newly-targeted post.
if (!this.props.targetPost) return;
// This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
const oldTarget = this.prevTarget;
const newTarget = this.props.targetPost;
context.onunload = () => {
this.scrollListener.stop();
clearTimeout(this.calculatePositionTimeout);
};
}
/**
* 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;
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 ($item.length && $item.offset().top > viewportTop - loadAheadDistance) {
this.loadPrevious();
}
if (oldTarget) {
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
if ('index' in oldTarget && oldTarget.index === newTarget.index) return;
}
if (this.visibleEnd < this.count()) {
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.loadNext();
}
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);
}
// 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);
}
/**
* 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 {Integer} start
* @param {Integer} end
* @param {Boolean} backwards
*/
loadPage(start, end, backwards) {
const redraw = () => {
if (start < this.visibleStart || end > this.visibleEnd) return;
const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true));
this.unpause();
};
redraw();
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));
}
/**
* Work out which posts (by number) are currently visible in the viewport, and
* fire an event with the information.
*/
calculatePosition() {
const marginTop = this.getMarginTop();
const $window = $(window);
const viewportHeight = $window.height() - marginTop;
const scrollTop = $window.scrollTop() + marginTop;
let startNumber;
let endNumber;
this.$('.PostStream-item').each(function () {
const $item = $(this);
const top = $item.offset().top;
const height = $item.outerHeight(true);
if (top + height > scrollTop) {
if (!startNumber) {
startNumber = endNumber = $item.data('number');
}
if (top + height < scrollTop + viewportHeight) {
if ($item.data('number')) {
endNumber = $item.data('number');
}
} else return false;
}
});
if (startNumber) {
this.trigger('positionChanged', startNumber || 1, endNumber);
}
this.prevTarget = newTarget;
}
/**
@@ -521,42 +145,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 +199,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');
@@ -590,24 +218,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

@@ -10,41 +10,22 @@ import formatNumber from '../../common/utils/formatNumber';
*
* ### Props
*
* - `stream`
* - `discussion`
* - `className`
* - `onNavigate`
* - `count`
* - `paused`
* - `index`
* - `visible`
* - `description`
*/
export default class PostStreamScrubber extends Component {
init() {
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));
this.scrollListener = new ScrollListener(this.renderScrollbar.bind(this, { fromScroll: true, forceHeightChange: true }));
// Create a subtree retainer that will always cache the subtree after the
// initial draw. We render parts of the scrubber using this because we
@@ -55,12 +36,12 @@ export default class PostStreamScrubber extends Component {
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, index, visible } = this.props;
const unreadCount = this.props.discussion.unreadCount();
const unreadPercent = count ? Math.min(count - this.props.index, unreadCount) / count : 0;
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">{retain || formatNumber(Math.min(Math.ceil(index + visible), count))}</span>,
count: <span className="Scrubber-count">{formatNumber(count)}</span>,
});
@@ -98,7 +79,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">{retain || this.props.description}</span>
</div>
</div>
<div className="Scrubber-after" />
@@ -121,35 +102,14 @@ 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);
this.navigateTo(0);
}
/**
* 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);
this.navigateTo(this.props.count - 1);
}
/**
@@ -159,87 +119,7 @@ export default class PostStreamScrubber extends Component {
* @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') : '';
return this.props.visible >= this.props.count;
}
config(isInitialized, context) {
@@ -272,6 +152,7 @@ export default class PostStreamScrubber extends Component {
this.dragging = false;
this.mouseStart = 0;
this.indexStart = 0;
this.dragIndex = null;
this.$('.Scrubber-handle')
.css('cursor', 'move')
@@ -292,8 +173,6 @@ export default class PostStreamScrubber extends Component {
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);
@@ -305,31 +184,43 @@ export default class PostStreamScrubber extends Component {
*
* @param {Boolean} animate
*/
renderScrollbar(animate) {
renderScrollbar(options = {}) {
const { count, visible, description, paused } = this.props;
const percentPerPost = this.percentPerPost();
const index = this.index;
const count = this.count();
const visible = this.visible || 1;
const index = this.dragIndex || this.props.index;
const $scrubber = this.$();
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
$scrubber.find('.Scrubber-description').text(this.description);
$scrubber.find('.Scrubber-description').text(description);
$scrubber.toggleClass('disabled', this.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 overridden
if ((options.fromScroll && 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));
}
/**
@@ -343,8 +234,8 @@ export default class PostStreamScrubber extends Component {
* scrubber.
*/
percentPerPost() {
const count = this.count() || 1;
const visible = this.visible || 1;
const count = this.props.count || 1;
const visible = this.props.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
@@ -381,11 +272,13 @@ 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.props.index;
this.dragging = true;
this.props.stream.paused = true;
this.dragIndex = null;
$('body').css('cursor', 'move');
this.$().toggleClass('dragging', this.dragging);
}
onmousemove(e) {
@@ -398,13 +291,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.props.count - 1);
this.index = Math.max(0, newIndex);
this.dragIndex = Math.max(0, newIndex);
this.renderScrollbar();
}
onmouseup() {
this.$().toggleClass('dragging', this.dragging);
if (!this.dragging) return;
this.mouseStart = 0;
@@ -416,9 +310,9 @@ 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);
this.navigateTo(this.dragIndex);
this.dragIndex = null;
}
onclick(e) {
@@ -438,11 +332,21 @@ 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.props.count - 1, offsetIndex));
this.navigateTo(offsetIndex);
this.$().removeClass('open');
}
/**
* Trigger post stream navigation, but also animate the scrollbar according
* to the expected result.
*
* @param {number} index
*/
navigateTo(index) {
this.props.onNavigate(Math.floor(index));
this.renderScrollbar({ animate: true });
}
}

View File

@@ -0,0 +1,329 @@
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.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.targetPost = { number };
this.noAnimationScroll = noAnimation;
// 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.loadNearNumber(number).then(() => {
this.paused = false;
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;
const promise = this.loadNearIndex(index);
this.targetPost = { index };
this.noAnimationScroll = noAnimation;
this.index = index;
m.redraw();
return promise.then(() => (this.paused = false));
}
/**
* 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;
}
/**
* 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;