mirror of
https://github.com/flarum/core.git
synced 2025-08-15 12:54:47 +02:00
Compare commits
69 Commits
dk/harden-
...
post_strea
Author | SHA1 | Date | |
---|---|---|---|
|
fc5eddb99d | ||
|
bad8115a4a | ||
|
1fc76acf06 | ||
|
527d93120a | ||
|
53582ab999 | ||
|
6f6a09d7c4 | ||
|
e8394e4a1d | ||
|
e455e6c431 | ||
|
a044c642f6 | ||
|
01384139ef | ||
|
57f5ad4893 | ||
|
8b69b24272 | ||
|
09c722e522 | ||
|
3ce94757fc | ||
|
aae6f24356 | ||
|
1a2f9527fd | ||
|
8c362bf7c7 | ||
|
f99f79e3c0 | ||
|
bbd8136695 | ||
|
1d8662088f | ||
|
a850f4a6fb | ||
|
af55a13c61 | ||
|
92b62e7ab6 | ||
|
5ef4de75d1 | ||
|
88e6be9d0e | ||
|
228c7b883d | ||
|
cdcf64852e | ||
|
d20650fb42 | ||
|
875a1f70c1 | ||
|
ef206495cd | ||
|
2360745237 | ||
|
cc10eaadd2 | ||
|
c98c0b027f | ||
|
73507f403a | ||
|
d3fb5ee77c | ||
|
479e5a8cf6 | ||
|
4bce030115 | ||
|
9f2540dbe3 | ||
|
aa15db6f44 | ||
|
0c63be527b | ||
|
9db2f78939 | ||
|
9572863648 | ||
|
ac1eef7578 | ||
|
514165c3af | ||
|
e84960dcd1 | ||
|
f8d1c7a317 | ||
|
ba82969a58 | ||
|
b2917c8716 | ||
|
c150c097c1 | ||
|
beab8ce39c | ||
|
1360723c3f | ||
|
5cdfeaf9a5 | ||
|
6e1d385268 | ||
|
193f3b040d | ||
|
74cb4f9007 | ||
|
eb24e628fa | ||
|
c03feceb9f | ||
|
51008bc65d | ||
|
9a357f5d19 | ||
|
9c63c54868 | ||
|
5427b35c6d | ||
|
2ec49db6df | ||
|
062dc8f57f | ||
|
8a9e50d192 | ||
|
6c087da65f | ||
|
6bcecd623b | ||
|
614bb0d71e | ||
|
cff9b327a9 | ||
|
7af8e35a6e |
@@ -8,6 +8,8 @@ import SplitDropdown from '../../common/components/SplitDropdown';
|
|||||||
import listItems from '../../common/helpers/listItems';
|
import listItems from '../../common/helpers/listItems';
|
||||||
import DiscussionControls from '../utils/DiscussionControls';
|
import DiscussionControls from '../utils/DiscussionControls';
|
||||||
import DiscussionList from './DiscussionList';
|
import DiscussionList from './DiscussionList';
|
||||||
|
import PostStreamState from '../states/PostStreamState';
|
||||||
|
import ScrollListener from '../../common/utils/ScrollListener';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `DiscussionPage` component displays a whole discussion page, including
|
* 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.
|
* 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
|
// 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
|
// 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">
|
<nav className="DiscussionPage-nav">
|
||||||
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
<ul>{listItems(this.sidebarItems().toArray())}</ul>
|
||||||
</nav>
|
</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>,
|
</div>,
|
||||||
]
|
]
|
||||||
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
|
||||||
@@ -116,21 +126,24 @@ export default class DiscussionPage extends Page {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
config(...args) {
|
config(isInitialized, context) {
|
||||||
super.config(...args);
|
super.config(isInitialized, context);
|
||||||
|
|
||||||
if (this.discussion) {
|
if (this.discussion) {
|
||||||
app.setTitle(this.discussion.title());
|
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() {
|
load() {
|
||||||
this.near = m.route.param('near') || 0;
|
|
||||||
this.discussion = null;
|
|
||||||
|
|
||||||
const preloadedDiscussion = app.preloadedApiDocument();
|
const preloadedDiscussion = app.preloadedApiDocument();
|
||||||
if (preloadedDiscussion) {
|
if (preloadedDiscussion) {
|
||||||
// We must wrap this in a setTimeout because if we are mounting this
|
// 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
|
// 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
|
// posts we want to display. Tell the stream to scroll down and highlight
|
||||||
// the specific post that was routed to.
|
// the specific post that was routed to.
|
||||||
this.stream = new PostStream({ discussion, includedPosts });
|
this.stream = new PostStreamState(discussion, includedPosts);
|
||||||
this.stream.on('positionChanged', this.positionChanged.bind(this));
|
|
||||||
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true);
|
||||||
|
|
||||||
app.current.set('discussion', discussion);
|
app.current.set('discussion', discussion);
|
||||||
app.current.set('stream', this.stream);
|
app.current.set('stream', this.stream);
|
||||||
|
|
||||||
|
this.scrollListener.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -268,8 +282,12 @@ export default class DiscussionPage extends Page {
|
|||||||
items.add(
|
items.add(
|
||||||
'scrubber',
|
'scrubber',
|
||||||
PostStreamScrubber.component({
|
PostStreamScrubber.component({
|
||||||
stream: this.stream,
|
discussion: this.discussion,
|
||||||
className: 'App-titleControl',
|
className: 'App-titleControl',
|
||||||
|
onNavigate: this.stream.goToIndex.bind(this.stream),
|
||||||
|
count: this.stream.count(),
|
||||||
|
paused: this.stream.paused,
|
||||||
|
...this.scrubberProps(),
|
||||||
}),
|
}),
|
||||||
-100
|
-100
|
||||||
);
|
);
|
||||||
@@ -277,6 +295,84 @@ export default class DiscussionPage extends Page {
|
|||||||
return items;
|
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
|
* 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.
|
* 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();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,8 +1,5 @@
|
|||||||
import Component from '../../common/Component';
|
import Component from '../../common/Component';
|
||||||
import ScrollListener from '../../common/utils/ScrollListener';
|
|
||||||
import PostLoading from './LoadingPost';
|
import PostLoading from './LoadingPost';
|
||||||
import anchorScroll from '../../common/utils/anchorScroll';
|
|
||||||
import evented from '../../common/utils/evented';
|
|
||||||
import ReplyPlaceholder from './ReplyPlaceholder';
|
import ReplyPlaceholder from './ReplyPlaceholder';
|
||||||
import Button from '../../common/components/Button';
|
import Button from '../../common/components/Button';
|
||||||
|
|
||||||
@@ -13,9 +10,10 @@ import Button from '../../common/components/Button';
|
|||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
* - `discussion`
|
* - `discussion`
|
||||||
* - `includedPosts`
|
* - `stream`
|
||||||
|
* - `targetPost`
|
||||||
*/
|
*/
|
||||||
class PostStream extends Component {
|
export default class PostStream extends Component {
|
||||||
init() {
|
init() {
|
||||||
/**
|
/**
|
||||||
* The discussion to display the post stream for.
|
* The discussion to display the post stream for.
|
||||||
@@ -25,171 +23,11 @@ class PostStream extends Component {
|
|||||||
this.discussion = this.props.discussion;
|
this.discussion = this.props.discussion;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether or not the infinite-scrolling auto-load functionality is
|
* The shared state of the post stream.
|
||||||
* disabled.
|
|
||||||
*
|
*
|
||||||
* @type {Boolean}
|
* @type {PostStreamState}
|
||||||
*/
|
*/
|
||||||
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() {
|
view() {
|
||||||
@@ -200,15 +38,13 @@ class PostStream extends Component {
|
|||||||
|
|
||||||
let lastTime;
|
let lastTime;
|
||||||
|
|
||||||
this.visibleEnd = this.sanitizeIndex(this.visibleEnd);
|
const viewingEnd = this.stream.viewingEnd();
|
||||||
this.viewingEnd = this.visibleEnd === this.count();
|
const posts = this.stream.posts();
|
||||||
|
|
||||||
const posts = this.posts();
|
|
||||||
const postIds = this.discussion.postIds();
|
const postIds = this.discussion.postIds();
|
||||||
|
|
||||||
const items = posts.map((post, i) => {
|
const items = posts.map((post, i) => {
|
||||||
let content;
|
let content;
|
||||||
const attrs = { 'data-index': this.visibleStart + i };
|
const attrs = { 'data-index': this.stream.visibleStart + i };
|
||||||
|
|
||||||
if (post) {
|
if (post) {
|
||||||
const time = post.createdAt();
|
const time = post.createdAt();
|
||||||
@@ -238,7 +74,7 @@ class PostStream extends Component {
|
|||||||
|
|
||||||
lastTime = time;
|
lastTime = time;
|
||||||
} else {
|
} else {
|
||||||
attrs.key = 'post' + postIds[this.visibleStart + i];
|
attrs.key = 'post' + postIds[this.stream.visibleStart + i];
|
||||||
|
|
||||||
content = PostLoading.component();
|
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(
|
items.push(
|
||||||
<div className="PostStream-loadMore" key="loadMore">
|
<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')}
|
{app.translator.trans('core.forum.post_stream.load_more_button')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -262,7 +98,7 @@ class PostStream extends Component {
|
|||||||
|
|
||||||
// If we're viewing the end of the discussion, the user can reply, and
|
// 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.
|
// 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(
|
items.push(
|
||||||
<div className="PostStream-item" key="reply">
|
<div className="PostStream-item" key="reply">
|
||||||
{ReplyPlaceholder.component({ discussion: this.discussion })}
|
{ReplyPlaceholder.component({ discussion: this.discussion })}
|
||||||
@@ -274,237 +110,25 @@ class PostStream extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
config(isInitialized, context) {
|
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:
|
const oldTarget = this.prevTarget;
|
||||||
// https://github.com/lhorie/mithril.js/issues/637
|
const newTarget = this.props.targetPost;
|
||||||
setTimeout(() => this.scrollListener.start());
|
|
||||||
|
|
||||||
context.onunload = () => {
|
if (oldTarget) {
|
||||||
this.scrollListener.stop();
|
if ('number' in oldTarget && oldTarget.number === newTarget.number) return;
|
||||||
clearTimeout(this.calculatePositionTimeout);
|
if ('index' in oldTarget && oldTarget.index === newTarget.index) return;
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
if ('number' in newTarget) {
|
||||||
* When the window is scrolled, check if either extreme of the post stream is
|
this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll);
|
||||||
* in the viewport, and if so, trigger loading the next/previous page.
|
} else if ('index' in newTarget) {
|
||||||
*
|
const backwards = newTarget.index === this.stream.count() - 1;
|
||||||
* @param {Integer} top
|
this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards);
|
||||||
*/
|
|
||||||
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 (this.visibleEnd < this.count()) {
|
this.prevTarget = newTarget;
|
||||||
const $item = this.$('.PostStream-item[data-index=' + (this.visibleEnd - 1) + ']');
|
|
||||||
|
|
||||||
if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
|
|
||||||
this.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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -521,42 +145,46 @@ class PostStream extends Component {
|
|||||||
* Scroll down to a certain post by number and 'flash' it.
|
* Scroll down to a certain post by number and 'flash' it.
|
||||||
*
|
*
|
||||||
* @param {Integer} number
|
* @param {Integer} number
|
||||||
* @param {Boolean} noAnimation
|
* @param {Boolean} animate
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToNumber(number, noAnimation) {
|
scrollToNumber(number, animate) {
|
||||||
const $item = this.$(`.PostStream-item[data-number=${number}]`);
|
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.
|
* Scroll down to a certain post by index.
|
||||||
*
|
*
|
||||||
* @param {Integer} index
|
* @param {Integer} index
|
||||||
* @param {Boolean} noAnimation
|
* @param {Boolean} animate
|
||||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||||
* at the given index, instead of the top of it.
|
* at the given index, instead of the top of it.
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToIndex(index, noAnimation, bottom) {
|
scrollToIndex(index, animate, bottom) {
|
||||||
const $item = this.$(`.PostStream-item[data-index=${index}]`);
|
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.
|
* Scroll down to the given post.
|
||||||
*
|
*
|
||||||
* @param {jQuery} $item
|
* @param {jQuery} $item
|
||||||
* @param {Boolean} noAnimation
|
* @param {Boolean} animate
|
||||||
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
* @param {Boolean} force Whether or not to force scrolling to the item, even
|
||||||
* if it is already in the viewport.
|
* if it is already in the viewport.
|
||||||
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
* @param {Boolean} bottom Whether or not to scroll to the bottom of the post
|
||||||
* at the given index, instead of the top of it.
|
* at the given index, instead of the top of it.
|
||||||
* @return {jQuery.Deferred}
|
* @return {jQuery.Deferred}
|
||||||
*/
|
*/
|
||||||
scrollToItem($item, noAnimation, force, bottom) {
|
scrollToItem($item, animate, force, bottom) {
|
||||||
const $container = $('html, body').stop(true);
|
const $container = $('html, body').stop(true);
|
||||||
|
|
||||||
if ($item.length) {
|
if ($item.length) {
|
||||||
@@ -571,7 +199,7 @@ class PostStream extends Component {
|
|||||||
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
|
||||||
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
|
const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop;
|
||||||
|
|
||||||
if (noAnimation) {
|
if (!animate) {
|
||||||
$container.scrollTop(top);
|
$container.scrollTop(top);
|
||||||
} else if (top !== scrollTop) {
|
} else if (top !== scrollTop) {
|
||||||
$container.animate({ scrollTop: top }, 'fast');
|
$container.animate({ scrollTop: top }, 'fast');
|
||||||
@@ -590,24 +218,4 @@ class PostStream extends Component {
|
|||||||
flashItem($item) {
|
flashItem($item) {
|
||||||
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
|
$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;
|
|
||||||
|
@@ -10,41 +10,22 @@ import formatNumber from '../../common/utils/formatNumber';
|
|||||||
*
|
*
|
||||||
* ### Props
|
* ### Props
|
||||||
*
|
*
|
||||||
* - `stream`
|
* - `discussion`
|
||||||
* - `className`
|
* - `className`
|
||||||
|
* - `onNavigate`
|
||||||
|
* - `count`
|
||||||
|
* - `paused`
|
||||||
|
* - `index`
|
||||||
|
* - `visible`
|
||||||
|
* - `description`
|
||||||
*/
|
*/
|
||||||
export default class PostStreamScrubber extends Component {
|
export default class PostStreamScrubber extends Component {
|
||||||
init() {
|
init() {
|
||||||
this.handlers = {};
|
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
|
// Define a handler to update the state of the scrollbar to reflect the
|
||||||
// current scroll position of the page.
|
// 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
|
// Create a subtree retainer that will always cache the subtree after the
|
||||||
// initial draw. We render parts of the scrubber using this because we
|
// initial draw. We render parts of the scrubber using this because we
|
||||||
@@ -55,12 +36,12 @@ export default class PostStreamScrubber extends Component {
|
|||||||
|
|
||||||
view() {
|
view() {
|
||||||
const retain = this.subtree.retain();
|
const retain = this.subtree.retain();
|
||||||
const count = this.count();
|
const { count, index, visible } = this.props;
|
||||||
const unreadCount = this.props.stream.discussion.unreadCount();
|
const unreadCount = this.props.discussion.unreadCount();
|
||||||
const unreadPercent = count ? Math.min(count - this.index, unreadCount) / count : 0;
|
const unreadPercent = count ? Math.min(count - this.props.index, unreadCount) / count : 0;
|
||||||
|
|
||||||
const viewing = app.translator.transChoice('core.forum.post_scrubber.viewing_text', count, {
|
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>,
|
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-bar" />
|
||||||
<div className="Scrubber-info">
|
<div className="Scrubber-info">
|
||||||
<strong>{viewing}</strong>
|
<strong>{viewing}</strong>
|
||||||
<span className="Scrubber-description">{retain || this.description}</span>
|
<span className="Scrubber-description">{retain || this.props.description}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="Scrubber-after" />
|
<div className="Scrubber-after" />
|
||||||
@@ -121,35 +102,14 @@ export default class PostStreamScrubber extends Component {
|
|||||||
* Go to the first post in the discussion.
|
* Go to the first post in the discussion.
|
||||||
*/
|
*/
|
||||||
goToFirst() {
|
goToFirst() {
|
||||||
this.props.stream.goToFirst();
|
this.navigateTo(0);
|
||||||
this.index = 0;
|
|
||||||
this.renderScrollbar(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Go to the last post in the discussion.
|
* Go to the last post in the discussion.
|
||||||
*/
|
*/
|
||||||
goToLast() {
|
goToLast() {
|
||||||
this.props.stream.goToLast();
|
this.navigateTo(this.props.count - 1);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -159,87 +119,7 @@ export default class PostStreamScrubber extends Component {
|
|||||||
* @return {Boolean}
|
* @return {Boolean}
|
||||||
*/
|
*/
|
||||||
disabled() {
|
disabled() {
|
||||||
return this.visible >= this.count();
|
return this.props.visible >= this.props.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) {
|
config(isInitialized, context) {
|
||||||
@@ -272,6 +152,7 @@ export default class PostStreamScrubber extends Component {
|
|||||||
this.dragging = false;
|
this.dragging = false;
|
||||||
this.mouseStart = 0;
|
this.mouseStart = 0;
|
||||||
this.indexStart = 0;
|
this.indexStart = 0;
|
||||||
|
this.dragIndex = null;
|
||||||
|
|
||||||
this.$('.Scrubber-handle')
|
this.$('.Scrubber-handle')
|
||||||
.css('cursor', 'move')
|
.css('cursor', 'move')
|
||||||
@@ -292,8 +173,6 @@ export default class PostStreamScrubber extends Component {
|
|||||||
ondestroy() {
|
ondestroy() {
|
||||||
this.scrollListener.stop();
|
this.scrollListener.stop();
|
||||||
|
|
||||||
this.props.stream.off('unpaused', this.handlers.streamWasUnpaused);
|
|
||||||
|
|
||||||
$(window).off('resize', this.handlers.onresize);
|
$(window).off('resize', this.handlers.onresize);
|
||||||
|
|
||||||
$(document).off('mousemove touchmove', this.handlers.onmousemove).off('mouseup touchend', this.handlers.onmouseup);
|
$(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
|
* @param {Boolean} animate
|
||||||
*/
|
*/
|
||||||
renderScrollbar(animate) {
|
renderScrollbar(options = {}) {
|
||||||
|
const { count, visible, description, paused } = this.props;
|
||||||
const percentPerPost = this.percentPerPost();
|
const percentPerPost = this.percentPerPost();
|
||||||
const index = this.index;
|
|
||||||
const count = this.count();
|
const index = this.dragIndex || this.props.index;
|
||||||
const visible = this.visible || 1;
|
|
||||||
|
|
||||||
const $scrubber = this.$();
|
const $scrubber = this.$();
|
||||||
$scrubber.find('.Scrubber-index').text(formatNumber(Math.min(Math.ceil(index + visible), count)));
|
$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());
|
$scrubber.toggleClass('disabled', this.disabled());
|
||||||
|
|
||||||
const heights = {};
|
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.handle = Math.min(100 - heights.before, percentPerPost.visible * visible);
|
||||||
heights.after = 100 - heights.before - heights.handle;
|
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) {
|
for (const part in heights) {
|
||||||
const $part = $scrubber.find(`.Scrubber-${part}`);
|
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
|
// jQuery likes to put overflow:hidden, but because the scrollbar handle
|
||||||
// has a negative margin-left, we need to override.
|
// has a negative margin-left, we need to override.
|
||||||
if (func === 'animate') $part.css('overflow', 'visible');
|
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.
|
* scrubber.
|
||||||
*/
|
*/
|
||||||
percentPerPost() {
|
percentPerPost() {
|
||||||
const count = this.count() || 1;
|
const count = this.props.count || 1;
|
||||||
const visible = this.visible || 1;
|
const visible = this.props.visible || 1;
|
||||||
|
|
||||||
// To stop the handle of the scrollbar from getting too small when there
|
// 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
|
// are many posts, we define a minimum percentage height for the handle
|
||||||
@@ -381,11 +272,13 @@ export default class PostStreamScrubber extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onmousedown(e) {
|
onmousedown(e) {
|
||||||
|
e.redraw = false;
|
||||||
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
|
||||||
this.indexStart = this.index;
|
this.indexStart = this.props.index;
|
||||||
this.dragging = true;
|
this.dragging = true;
|
||||||
this.props.stream.paused = true;
|
this.dragIndex = null;
|
||||||
$('body').css('cursor', 'move');
|
$('body').css('cursor', 'move');
|
||||||
|
this.$().toggleClass('dragging', this.dragging);
|
||||||
}
|
}
|
||||||
|
|
||||||
onmousemove(e) {
|
onmousemove(e) {
|
||||||
@@ -398,13 +291,14 @@ export default class PostStreamScrubber extends Component {
|
|||||||
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
const deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
|
||||||
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
|
const deltaPercent = (deltaPixels / this.$('.Scrubber-scrollbar').outerHeight()) * 100;
|
||||||
const deltaIndex = deltaPercent / this.percentPerPost().index || 0;
|
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();
|
this.renderScrollbar();
|
||||||
}
|
}
|
||||||
|
|
||||||
onmouseup() {
|
onmouseup() {
|
||||||
|
this.$().toggleClass('dragging', this.dragging);
|
||||||
if (!this.dragging) return;
|
if (!this.dragging) return;
|
||||||
|
|
||||||
this.mouseStart = 0;
|
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-
|
// If the index we've landed on is in a gap, then tell the stream-
|
||||||
// content that we want to load those posts.
|
// content that we want to load those posts.
|
||||||
const intIndex = Math.floor(this.index);
|
this.navigateTo(this.dragIndex);
|
||||||
this.props.stream.goToIndex(intIndex);
|
|
||||||
this.renderScrollbar(true);
|
this.dragIndex = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onclick(e) {
|
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-
|
// 3. Now we can convert the percentage into an index, and tell the stream-
|
||||||
// content component to jump to that index.
|
// content component to jump to that index.
|
||||||
let offsetIndex = offsetPercent / this.percentPerPost().index;
|
let offsetIndex = offsetPercent / this.percentPerPost().index;
|
||||||
offsetIndex = Math.max(0, Math.min(this.count() - 1, offsetIndex));
|
offsetIndex = Math.max(0, Math.min(this.props.count - 1, offsetIndex));
|
||||||
this.props.stream.goToIndex(Math.floor(offsetIndex));
|
|
||||||
this.index = offsetIndex;
|
this.navigateTo(offsetIndex);
|
||||||
this.renderScrollbar(true);
|
|
||||||
|
|
||||||
this.$().removeClass('open');
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
329
js/src/forum/states/PostStreamState.js
Normal file
329
js/src/forum/states/PostStreamState.js
Normal 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;
|
Reference in New Issue
Block a user