1
0
mirror of https://github.com/flarum/core.git synced 2025-02-25 03:33:42 +01:00

New and improved post stream.

This commit is contained in:
Toby Zerner 2015-05-29 18:17:50 +09:30
parent 2741923714
commit cafa6c7b5d
15 changed files with 609 additions and 899 deletions

View File

@ -1,10 +1,9 @@
import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import PostStream from 'flarum/utils/post-stream';
import DiscussionList from 'flarum/components/discussion-list';
import DiscussionHero from 'flarum/components/discussion-hero';
import StreamContent from 'flarum/components/stream-content';
import StreamScrubber from 'flarum/components/stream-scrubber';
import PostStream from 'flarum/components/post-stream';
import PostScrubber from 'flarum/components/post-scrubber';
import ReplyComposer from 'flarum/components/reply-composer';
import ActionButton from 'flarum/components/action-button';
import LoadingIndicator from 'flarum/components/loading-indicator';
@ -22,24 +21,13 @@ export default class DiscussionPage extends mixin(Component, evented) {
super(props);
this.discussion = m.prop();
// Set up the stream. The stream is an object that represents the posts in
// a discussion, as they're displayed on the screen (i.e. missing posts
// are condensed into "load more" gaps).
this.stream = m.prop();
// Get the discussion. We may already have a copy of it in our store, so
// we'll start off with that. If we do have a copy of the discussion, and
// its posts relationship has been loaded (i.e. we've viewed this
// discussion before), then we can proceed with displaying it immediately.
// If not, we'll make an API request first.
this.refresh();
if (app.cache.discussionList) {
if (!(app.current instanceof DiscussionPage)) {
app.cache.discussionList.subtrees.map(subtree => subtree.invalidate());
} else {
m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter even is triggered so it doesn't hide
m.redraw.strategy('diff'); // otherwise pane redraws (killing retained subtrees) and mouseenter event is triggered so it doesn't hide
}
app.pane.enable();
app.pane.hide();
@ -74,25 +62,6 @@ export default class DiscussionPage extends mixin(Component, evented) {
*/
setupDiscussion(discussion) {
this.discussion(discussion);
var includedPosts = [];
discussion.payload.included && discussion.payload.included.forEach(record => {
if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) {
includedPosts.push(record.id);
}
});
// Set up the post stream for this discussion, and add all of the posts we
// have loaded so far.
this.stream(new PostStream(discussion));
this.stream().addPosts(discussion.posts().filter(value => value && includedPosts.indexOf(value.id()) !== -1));
this.streamContent = new StreamContent({
stream: this.stream(),
className: 'discussion-posts posts',
positionChanged: this.positionChanged.bind(this)
});
// Hold up there skippy! If the slug in the URL doesn't match up, we'll
// redirect so we have the correct one.
// Waiting on https://github.com/lhorie/mithril.js/issues/539
@ -104,11 +73,19 @@ export default class DiscussionPage extends mixin(Component, evented) {
// return;
// }
this.streamContent.goToNumber(this.currentNear, true);
this.discussion(discussion);
app.setTitle(discussion.title());
this.trigger('loaded');
var includedPosts = [];
discussion.payload.included && discussion.payload.included.forEach(record => {
if (record.type === 'posts' && (record.contentType !== 'comment' || record.contentHtml)) {
includedPosts.push(app.store.getById('posts', record.id));
}
});
this.stream = new PostStream({ discussion, includedPosts });
this.stream.on('positionChanged', this.positionChanged.bind(this));
this.stream.goToNumber(m.route.param('near') || 1, true);
}
onload(element, isInitialized, context) {
@ -134,7 +111,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
if (m.route.param('id') == discussion.id()) {
e.preventDefault();
if (m.route.param('near') != this.currentNear) {
this.streamContent.goToNumber(m.route.param('near'));
this.stream.goToNumber(m.route.param('near') || 1);
}
this.currentNear = null;
return;
@ -160,7 +137,7 @@ export default class DiscussionPage extends mixin(Component, evented) {
m('nav.discussion-nav', [
m('ul', listItems(this.sidebarItems().toArray()))
]),
this.streamContent.view()
this.stream.view()
])
] : LoadingIndicator.component({className: 'loading-indicator-block'}))
]);
@ -219,8 +196,8 @@ export default class DiscussionPage extends mixin(Component, evented) {
);
items.add('scrubber',
StreamScrubber.component({
streamContent: this.streamContent,
PostScrubber.component({
stream: this.stream,
wrapperClass: 'title-control'
})
);

View File

@ -0,0 +1,11 @@
import Component from 'flarum/component';
import avatar from 'flarum/helpers/avatar';
export default class PostLoadingComponent extends Component {
view() {
return m('div.post.comment-post.loading-post.fake-post',
m('header.post-header', avatar(), m('div.fake-text')),
m('div.post-body', m('div.fake-text'), m('div.fake-text'), m('div.fake-text'))
);
}
}

View File

@ -7,26 +7,19 @@ import computed from 'flarum/utils/computed';
/**
*/
export default class StreamScrubber extends Component {
export default class PostScrubber extends Component {
/**
*/
constructor(props) {
super(props);
var streamContent = this.props.streamContent;
var stream = this.props.stream;
this.handlers = {};
// When the stream-content component begins loading posts at a certain
// index, we want our scrubber scrollbar to jump to that position.
streamContent.on('loadingIndex', this.handlers.loadingIndex = this.loadingIndex.bind(this));
streamContent.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
/**
Disable the scrubber if the stream's initial content isn't loaded, or
if all of the posts in the discussion are visible in the viewport.
*/
this.disabled = () => !streamContent.loaded() || this.visible() >= this.count();
stream.on('unpaused', this.handlers.unpaused = this.unpaused.bind(this));
/**
The integer index of the last item that is visible in the viewport. This
@ -36,16 +29,11 @@ export default class StreamScrubber extends Component {
return Math.min(count, Math.ceil(Math.max(0, index) + visible));
});
this.count = () => this.props.streamContent.props.stream.count();
this.count = () => this.props.stream.count();
this.index = m.prop(-1);
this.visible = m.prop(1);
this.description = m.prop();
this.unreadCount = () => {
var discussion = this.props.streamContent.props.stream.discussion;
return discussion.lastPostNumber() - discussion.readNumber();
};
// 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));
@ -58,13 +46,21 @@ export default class StreamScrubber extends Component {
this.renderScrollbar(true);
}
/**
Disable the scrubber if the stream's initial content isn't loaded, or
if all of the posts in the discussion are visible in the viewport.
*/
disabled() {
return this.visible() >= this.count();
}
/**
*/
view() {
var retain = this.subtree.retain();
var streamContent = this.props.streamContent;
var unreadCount = this.unreadCount();
var stream = this.props.stream;
var unreadCount = this.props.stream.discussion.unreadCount();
var unreadPercent = unreadCount / this.count();
return m('div.stream-scrubber.dropdown'+(this.disabled() ? '.disabled' : ''), {config: this.onload.bind(this)}, [
@ -74,7 +70,11 @@ export default class StreamScrubber extends Component {
]),
m('div.dropdown-menu', [
m('div.scrubber', [
m('a.scrubber-first[href=javascript:;]', {onclick: streamContent.goToFirst.bind(streamContent)}, [icon('angle-double-up'), ' Original Post']),
m('a.scrubber-first[href=javascript:;]', {onclick: () => {
stream.goToFirst();
this.index(0);
this.renderScrollbar(true);
}}, [icon('angle-double-up'), ' Original Post']),
m('div.scrubber-scrollbar', [
m('div.scrubber-before'),
m('div.scrubber-slider', [
@ -89,7 +89,7 @@ export default class StreamScrubber extends Component {
style: {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'},
config: function(element, isInitialized, context) {
var $element = $(element);
var newStyle = {top: $element.css('top'), height: $element.css('height')};
var newStyle = {top: (100 - unreadPercent * 100)+'%', height: (unreadPercent * 100)+'%'};
if (context.oldStyle) {
$element.stop(true).css(context.oldStyle).animate(newStyle);
}
@ -97,16 +97,20 @@ export default class StreamScrubber extends Component {
}
}, unreadCount+' unread') : ''
]),
m('a.scrubber-last[href=javascript:;]', {onclick: streamContent.goToLast.bind(streamContent)}, [icon('angle-double-down'), ' Now'])
m('a.scrubber-last[href=javascript:;]', {onclick: () => {
stream.goToLast();
this.index(stream.count());
this.renderScrollbar(true);
}}, [icon('angle-double-down'), ' Now'])
])
])
])
}
onscroll(top) {
var streamContent = this.props.streamContent;
var stream = this.props.stream;
if (!streamContent.active() || !streamContent.$()) { return; }
if (stream.paused() || !stream.$()) { return; }
this.update(top);
this.renderScrollbar();
@ -117,10 +121,10 @@ export default class StreamScrubber extends Component {
current scroll position.
*/
update(top) {
var streamContent = this.props.streamContent;
var stream = this.props.stream;
var $window = $(window);
var marginTop = streamContent.getMarginTop();
var marginTop = stream.getMarginTop();
var scrollTop = $window.scrollTop() + marginTop;
var windowHeight = $window.height() - marginTop;
@ -128,8 +132,8 @@ export default class StreamScrubber extends Component {
// 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.
var $items = streamContent.$('.item');
var index = $items.first().data('end') - 1;
var $items = stream.$('> .item');
var index = $items.first().data('index');
var visible = 0;
var period = '';
@ -146,7 +150,7 @@ export default class StreamScrubber extends Component {
// loop.
if (top + height < scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
index = parseFloat($this.data('index')) + 1 - visible;
return;
}
if (top > scrollTop + windowHeight) {
@ -154,14 +158,10 @@ export default class StreamScrubber extends Component {
}
// If the bottom half of this item is visible at the top of the
// viewport, then add the visible proportion to the visible
// counter, and set the scrollbar index to whatever the visible
// proportion represents. For example, if a gap represents indexes
// 0-9, and the bottom 50% of the gap is visible in the viewport,
// then the scrollbar index will be 5.
// viewport
if (top <= scrollTop && top + height > scrollTop) {
visible = (top + height - scrollTop) / height;
index = parseFloat($this.data('end')) + 1 - visible;
index = parseFloat($this.data('index')) + 1 - visible;
}
// If the top half of this item is visible at the bottom of the
@ -206,69 +206,30 @@ export default class StreamScrubber extends Component {
// so that it fills the height of the sidebar.
$(window).on('resize', this.handlers.onresize = this.onresize.bind(this)).resize();
var self = this;
// When any part of the whole scrollbar is clicked, we want to jump to
// that position.
this.$('.scrubber-scrollbar')
.bind('click touchstart', function(e) {
if (!self.props.streamContent.active()) { return; }
.bind('click touchstart', this.onclick.bind(this))
// Calculate the index which we want to jump to based on the
// click position.
// 1. Get the offset of the click from the top of the
// scrollbar, as a percentage of the scrollbar's height.
var $this = $(this);
var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $this.offset().top + $('body').scrollTop();
var offsetPercent = offsetPixels / $this.outerHeight() * 100;
// 2. We want the handle of the scrollbar to end up centered
// on the click position. Thus, we calculate the height of
// the handle in percent and use that to find a new
// offset percentage.
offsetPercent = offsetPercent - parseFloat($this.find('.scrubber-slider')[0].style.height) / 2;
// 3. Now we can convert the percentage into an index, and
// tell the stream-content component to jump to that index.
var offsetIndex = offsetPercent / self.percentPerPost().index;
offsetIndex = Math.max(0, Math.min(self.count() - 1, offsetIndex));
self.props.streamContent.goToIndex(Math.floor(offsetIndex));
self.$().removeClass('open');
});
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
this.$('.scrubber-scrollbar')
.css({
cursor: 'pointer',
'user-select': 'none'
})
.bind('dragstart mousedown touchstart', function(e) {
e.preventDefault();
});
// Now we want to make the scrollbar handle draggable. Let's start by
// preventing default browser events from messing things up.
.css({ cursor: 'pointer', 'user-select': 'none' })
.bind('dragstart mousedown touchstart', e => e.preventDefault());
// When the mouse is pressed on the scrollbar handle, we capture some
// information about its current position. We will store this
// information in an object and pass it on to the document's
// mousemove/mouseup events later.
this.dragging = false;
this.mouseStart = 0;
this.indexStart = 0;
this.handle = null;
this.$('.scrubber-slider')
.css('cursor', 'move')
.bind('mousedown touchstart', function(e) {
self.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
self.indexStart = self.index();
self.handle = $(this);
self.props.streamContent.paused(true);
$('body').css('cursor', 'move');
})
.bind('mousedown touchstart', this.onmousedown.bind(this))
// Exempt the scrollbar handle from the 'jump to' click event.
.click(function(e) {
e.stopPropagation();
});
.click(e => e.stopPropagation());
// When the mouse moves and when it is released, we pass the
// information that we captured when the mouse was first pressed onto
@ -282,8 +243,7 @@ export default class StreamScrubber extends Component {
ondestroy() {
this.scrollListener.stop();
this.props.streamContent.off('loadingIndex', this.handlers.loadingIndex);
this.props.streamContent.off('unpaused', this.handlers.unpaused);
this.props.stream.off('unpaused', this.handlers.unpaused);
$(window)
.off('resize', this.handlers.onresize);
@ -305,7 +265,6 @@ export default class StreamScrubber extends Component {
var $scrubber = this.$();
$scrubber.find('.index').text(this.visibleIndex());
// $scrubber.find('.count').text(count);
$scrubber.find('.description').text(this.description());
$scrubber.toggleClass('disabled', this.disabled());
@ -350,16 +309,7 @@ export default class StreamScrubber extends Component {
};
}
/*
When the stream-content component begins loading posts at a certain
index, we want our scrubber scrollbar to jump to that position.
*/
loadingIndex(index) {
this.index(index);
this.renderScrollbar(true);
}
onresize(event) {
onresize() {
this.scrollListener.update(true);
// Adjust the height of the scrollbar so that it fills the height of
@ -368,81 +318,68 @@ export default class StreamScrubber extends Component {
scrollbar.css('max-height', $(window).height() - scrollbar.offset().top + $(window).scrollTop() - parseInt($('.global-page').css('padding-bottom')));
}
onmousemove(event) {
if (! this.handle) { return; }
onmousedown() {
this.mouseStart = e.clientY || e.originalEvent.touches[0].clientY;
this.indexStart = this.index();
this.dragging = true;
this.props.stream.paused(true);
$('body').css('cursor', 'move');
}
onmousemove() {
if (! this.dragging) { return; }
// Work out how much the mouse has moved by - first in pixels, then
// convert it to a percentage of the scrollbar's height, and then
// finally convert it into an index. Add this delta index onto
// the index at which the drag was started, and then scroll there.
var deltaPixels = (event.clientY || event.originalEvent.touches[0].clientY) - this.mouseStart;
var deltaPixels = (e.clientY || e.originalEvent.touches[0].clientY) - this.mouseStart;
var deltaPercent = deltaPixels / this.$('.scrubber-scrollbar').outerHeight() * 100;
var deltaIndex = deltaPercent / this.percentPerPost().index;
var newIndex = Math.min(this.indexStart + deltaIndex, this.count() - 1);
this.index(Math.max(0, newIndex));
this.renderScrollbar();
if (! this.$().is('.open')) {
this.scrollToIndex(newIndex);
}
}
onmouseup(event) {
if (!this.handle) { return; }
onmouseup() {
if (!this.dragging) { return; }
this.mouseStart = 0;
this.indexStart = 0;
this.handle = null;
this.dragging = false;
$('body').css('cursor', '');
if (this.$().is('.open')) {
this.scrollToIndex(this.index());
this.$().removeClass('open');
}
this.$().removeClass('open');
// If the index we've landed on is in a gap, then tell the stream-
// content that we want to load those posts.
var intIndex = Math.floor(this.index());
if (!this.props.streamContent.props.stream.findNearestToIndex(intIndex).post) {
this.props.streamContent.goToIndex(intIndex);
} else {
this.props.streamContent.paused(false);
}
this.props.stream.goToIndex(intIndex);
this.renderScrollbar(true);
}
/**
Instantly scroll to a certain index in the discussion. The index doesn't
have to be an integer; any fraction of a post will be scrolled to.
*/
scrollToIndex(index) {
var streamContent = this.props.streamContent;
onclick(e) {
// Calculate the index which we want to jump to based on the click position.
index = Math.min(index, this.count() - 1);
// 1. Get the offset of the click from the top of the scrollbar, as a
// percentage of the scrollbar's height.
var $scrollbar = this.$('.scrubber-scrollbar');
var offsetPixels = (e.clientY || e.originalEvent.touches[0].clientY) - $scrollbar.offset().top + $('body').scrollTop();
var offsetPercent = offsetPixels / $scrollbar.outerHeight() * 100;
// Find the item for this index, whether it's a post corresponding to
// the index, or a gap which the index is within.
var indexFloor = Math.max(0, Math.floor(index));
var $nearestItem = streamContent.findNearestToIndex(indexFloor);
// 2. We want the handle of the scrollbar to end up centered on the click
// position. Thus, we calculate the height of the handle in percent and
// use that to find a new offset percentage.
offsetPercent = offsetPercent - parseFloat($scrollbar.find('.scrubber-slider')[0].style.height) / 2;
// Calculate the position of this item so that we can scroll to it. If
// the item is a gap, then we will mark it as 'active' to indicate to
// the user that it will expand if they release their mouse.
// Otherwise, we will add a proportion of the item's height onto the
// scroll position.
var pos = $nearestItem.offset().top - streamContent.getMarginTop();
if ($nearestItem.is('.gap')) {
$nearestItem.addClass('active');
} else {
if (index >= 0) {
pos += $nearestItem.outerHeight(true) * (index - indexFloor);
} else {
pos += $nearestItem.offset().top * index;
}
}
// 3. Now we can convert the percentage into an index, and tell the stream-
// content component to jump to that index.
var 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);
// Remove the 'active' class from other gaps.
streamContent.$().find('.gap').not($nearestItem).removeClass('active');
$('html, body').scrollTop(pos);
this.$().removeClass('open');
}
}

View File

@ -0,0 +1,463 @@
import Component from 'flarum/component';
import ScrollListener from 'flarum/utils/scroll-listener';
import PostLoading from 'flarum/components/post-loading';
import anchorScroll from 'flarum/utils/anchor-scroll';
import mixin from 'flarum/utils/mixin';
import evented from 'flarum/utils/evented';
class PostStream extends mixin(Component, evented) {
constructor(props) {
super(props);
this.discussion = this.props.discussion;
this.setup(this.props.includedPosts);
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.paused = m.prop(false);
this.loadPageTimeouts = {};
this.pagesLoading = 0;
}
/**
Load and scroll to a post with a certain number.
*/
goToNumber(number, noAnimation) {
this.paused(true);
var 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.
*/
goToIndex(index, backwards, noAnimation) {
this.paused(true);
var promise = this.loadNearIndex(index);
m.redraw(true);
return promise.then(() => {
anchorScroll(this.$('.item:'+(backwards ? 'last' : 'first')), () => m.redraw(true));
this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this));
});
}
/**
Load and scroll up to the first post in the discussion.
*/
goToFirst() {
return this.goToIndex(0);
}
/**
Load and scroll down to the last post in the discussion.
*/
goToLast() {
return this.goToIndex(this.count() - 1);
}
/**
Update the stream to reflect any posts that have been added/removed from the
discussion.
*/
sync() {
var addedPosts = this.discussion.addedPosts();
if (addedPosts) addedPosts.forEach(this.pushPost.bind(this));
this.discussion.pushData({links: {addedPosts: null}});
var removedPosts = this.discussion.removedPosts();
if (removedPosts) removedPosts.forEach(this.removePost.bind(this));
this.discussion.pushData({removedPosts: null});
}
/**
Add a post to the end of the stream. Nothing will be done if the end of the
stream is not visible.
*/
pushPost(post) {
if (this.visibleEnd == this.count() - 1) {
this.posts.push(post);
this.visibleEnd++;
}
}
/**
Search for and remove a specific post from the stream. Nothing will be done
if the post is not visible.
*/
removePost(id) {
this.posts.some((item, i) => {
if (item && item.id() === id) {
this.posts.splice(i, 1);
this.visibleEnd--;
return true;
}
});
}
/**
Get the total number of posts in the discussion.
*/
count() {
return this.discussion.postIds().length;
}
/**
Make sure that the given index is not outside of the possible range of
indexes in the discussion.
*/
sanitizeIndex(index) {
return Math.max(0, Math.min(this.count(), index));
}
/**
Set up the stream with the given array of posts.
*/
setup(posts) {
this.posts = posts;
this.visibleStart = this.discussion.postIds().indexOf(posts[0].id());
this.visibleEnd = this.visibleStart + posts.length;
}
/**
Clear the stream and fill it with placeholder posts.
*/
clear(start, end) {
this.visibleStart = start || 0;
this.visibleEnd = end || this.constructor.loadCount;
this.posts = [];
for (var i = this.visibleStart; i < this.visibleEnd; i++) {
this.posts.push(null);
}
}
/**
Construct a vDOM containing an element for each post that is visible in the
stream. Posts that have not been loaded will be rendered as placeholders.
*/
view() {
function fadeIn(element, isInitialized, context) {
if (!context.fadedIn) $(element).hide().fadeIn();
context.fadedIn = true;
}
return m('div.discussion-posts.posts', {config: this.onload.bind(this)},
this.posts.map((post, i) => {
var content;
var attributes = {};
attributes['data-index'] = attributes.key = this.visibleStart + i;
if (post) {
var PostComponent = app.postComponentRegistry[post.contentType()];
content = PostComponent ? PostComponent.component({post}) : '';
attributes.config = fadeIn;
attributes['data-time'] = post.time().toISOString();
attributes['data-number'] = post.number();
} else {
content = PostLoading.component();
}
return m('div.item', attributes, content);
})
);
}
/**
Store a reference to the component's DOM and begin listening for the
window's scroll event.
*/
onload(element, isInitialized, context) {
this.element(element);
if (isInitialized) { return; }
context.onunload = this.ondestroy.bind(this);
// This is wrapped in setTimeout due to the following Mithril issue:
// https://github.com/lhorie/mithril.js/issues/637
setTimeout(() => this.scrollListener.start());
}
/**
Stop listening for the window's scroll event, and cancel outstanding
timeouts.
*/
ondestroy() {
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.
*/
onscroll(top) {
if (this.paused()) return;
var marginTop = this.getMarginTop();
var viewportHeight = $(window).height() - marginTop;
var viewportTop = top + marginTop;
var loadAheadDistance = viewportHeight;
if (this.visibleStart > 0) {
var $item = this.$('.item[data-index='+this.visibleStart+']');
if ($item.offset().top > viewportTop - loadAheadDistance) {
this.loadPrevious();
}
}
if (this.visibleEnd < this.count()) {
var $item = this.$('.item[data-index='+(this.visibleEnd - 1)+']');
if ($item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) {
this.loadNext();
}
}
clearTimeout(this.calculatePositionTimeout);
this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 500);
}
/**
Load the next page of posts.
*/
loadNext() {
var start = this.visibleEnd;
var end = this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount);
for (var i = start; i < end; i++) {
this.posts.push(null);
}
// If the posts which are two pages back from the page we're currently
// loading still haven't loaded, we can assume that the user is scrolling
// pretty fast. Thus, we will unload them.
var twoPagesAway = start - this.constructor.loadCount * 2;
if (twoPagesAway >= 0 && !this.posts[twoPagesAway - this.visibleStart]) {
this.posts.splice(0, twoPagesAway + this.constructor.loadCount - this.visibleStart);
this.visibleStart = twoPagesAway + this.constructor.loadCount;
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
}
this.loadPage(start, end);
}
/**
Load the previous page of posts.
*/
loadPrevious() {
var end = this.visibleStart;
var start = this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount);
for (var i = start; i < end; i++) {
this.posts.unshift(null);
}
// If the posts which are two pages back from the page we're currently
// loading still haven't loaded, we can assume that the user is scrolling
// pretty fast. Thus, we will unload them.
var twoPagesAway = start + this.constructor.loadCount * 2;
if (twoPagesAway <= this.count() && !this.posts[twoPagesAway - this.visibleStart]) {
this.posts.splice(twoPagesAway - this.visibleStart);
this.visibleEnd = twoPagesAway;
clearTimeout(this.loadPageTimeouts[twoPagesAway]);
}
this.loadPage(start, end, true);
}
/**
Load a page of posts into the stream and redraw.
*/
loadPage(start, end, backwards) {
var redraw = () => {
if (start < this.visibleStart || end > this.visibleEnd) return;
var anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart;
anchorScroll(this.$('.item[data-index='+anchorIndex+']'), () => m.redraw(true));
this.unpause();
};
redraw();
this.pagesLoading++;
this.loadPageTimeouts[start] = setTimeout(() => {
this.loadRange(start, end).then(() => {
redraw();
this.pagesLoading--;
});
}, this.pagesLoading ? 1000 : 0);
}
/**
Load and inject the specified range of posts into the stream, without
clearing it.
*/
loadRange(start, end) {
return app.store.find('posts', this.discussion.postIds().slice(start, end)).then(posts => {
if (start < this.visibleStart || end > this.visibleEnd) return;
this.posts.splice.apply(this.posts, [start - this.visibleStart, end - start].concat(posts));
});
}
/**
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.
*/
loadNearNumber(number) {
if (this.posts.some(post => post.number() == number)) {
return m.deferred().resolve().promise;
}
this.clear();
return app.store.find('posts', {
discussions: this.discussion.id(),
near: number
}).then(this.setup.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.
*/
loadNearIndex(index) {
if (index >= this.visibleStart && index <= this.visibleEnd) {
return m.deferred().resolve().promise;
}
var start = this.sanitizeIndex(index - this.constructor.loadCount / 2);
var end = start + this.constructor.loadCount;
this.clear(start, end);
var ids = this.discussion.postIds().slice(start, end);
return app.store.find('posts', ids).then(this.setup.bind(this));
}
/**
Work out which posts (by number) are currently visible in the viewport, and
fire an event with the information.
*/
calculatePosition() {
var marginTop = this.getMarginTop();
var $window = $(window);
var viewportHeight = $window.height() - marginTop;
var scrollTop = $window.scrollTop() + marginTop;
var startNumber;
var endNumber;
this.$('.item').each(function() {
var $item = $(this);
var top = $item.offset().top;
var height = $item.outerHeight(true);
if (top + height > scrollTop) {
if (!startNumber) {
startNumber = $item.data('number');
}
if (top + height < scrollTop + viewportHeight) {
endNumber = $item.data('number');
} else {
return false;
}
}
});
if (startNumber) {
this.trigger('positionChanged', startNumber || 1, endNumber);
}
}
/**
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.
*/
getMarginTop() {
return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'));
}
/**
Scroll down to a certain post by number and 'flash' it.
*/
scrollToNumber(number, noAnimation) {
var $item = this.$('.item[data-number='+number+']');
return this.scrollToItem($item, noAnimation).done(this.flashItem.bind(this, $item));
}
/**
Scroll down to a certain post by index.
*/
scrollToIndex(index, noAnimation) {
var $item = this.$('.item[data-index='+index+']');
return this.scrollToItem($item, noAnimation, true);
}
/**
Scroll down to the given post.
*/
scrollToItem($item, noAnimation, force) {
var $container = $('html, body').stop(true);
if ($item.length) {
var itemTop = $item.offset().top - this.getMarginTop();
var itemBottom = itemTop + $item.height();
var scrollTop = $(document).scrollTop();
var scrollBottom = scrollTop + $(window).height();
// If the item is already in the viewport, we may not need to scroll.
if (force || itemTop < scrollTop || itemBottom > scrollBottom) {
var scrollTop = $item.is(':first-child') ? 0 : itemTop;
if (noAnimation) {
$container.scrollTop(scrollTop);
} else if (scrollTop !== $(document).scrollTop()) {
$container.animate({scrollTop: scrollTop}, 'fast');
}
}
}
return $container.promise();
}
/**
'Flash' the given post, drawing the user's attention to it.
*/
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(true);
this.trigger('unpaused');
}
}
PostStream.loadCount = 20;
export default PostStream;

View File

@ -67,7 +67,7 @@ export default class ReplyComposer extends ComposerBody {
// If we're currently viewing the discussion which this reply was made
// in, then we can add the post to the end of the post stream.
if (app.current && app.current.discussion && app.current.discussion().id() === discussion.id()) {
app.current.stream().addPostToEnd(post);
app.current.stream.pushPost(post);
m.route(app.route('discussion.near', {
id: discussion.id(),
slug: discussion.slug(),

View File

@ -1,360 +0,0 @@
import Component from 'flarum/component';
import StreamItem from 'flarum/components/stream-item';
import LoadingIndicator from 'flarum/components/loading-indicator';
import ScrollListener from 'flarum/utils/scroll-listener';
import mixin from 'flarum/utils/mixin';
import evented from 'flarum/utils/evented';
/**
*/
export default class StreamContent extends mixin(Component, evented) {
/**
*/
constructor(props) {
super(props);
this.loaded = () => this.props.stream.loadedCount();
this.paused = m.prop(false);
this.active = () => this.loaded() && !this.paused();
this.scrollListener = new ScrollListener(this.onscroll.bind(this));
this.on('loadingIndex', this.loadingIndex.bind(this));
this.on('loadedIndex', this.loadedIndex.bind(this));
this.on('loadingNumber', this.loadingNumber.bind(this));
this.on('loadedNumber', this.loadedNumber.bind(this));
}
/**
*/
view() {
var stream = this.props.stream;
return m('div', {className: 'stream '+(this.props.className || ''), config: this.onload.bind(this)},
stream ? stream.content.map(item => StreamItem.component({
key: item.start+'-'+item.end,
item: item,
loadRange: stream.loadRange.bind(stream),
ondelete: this.ondelete.bind(this)
}))
: LoadingIndicator.component());
}
/**
*/
onload(element, isInitialized, context) {
this.element(element);
if (isInitialized) { return; }
context.onunload = this.ondestroy.bind(this);
this.scrollListener.start();
}
ondelete(post) {
this.props.stream.removePost(post.id());
}
/**
*/
ondestroy() {
this.scrollListener.stop();
clearTimeout(this.positionChangedTimeout);
}
/**
*/
onscroll(top) {
if (!this.active()) { return; }
var $items = this.$('.item');
var marginTop = this.getMarginTop();
var $window = $(window);
var viewportHeight = $window.height() - marginTop;
var scrollTop = top + marginTop;
var loadAheadDistance = 300;
var startNumber;
var endNumber;
// Loop through each of the items in the stream. An 'item' is either a
// single post or a 'gap' of one or more posts that haven't been loaded
// yet.
$items.each(function() {
var $this = $(this);
var top = $this.offset().top;
var height = $this.outerHeight();
// If this item is above the top of the viewport (plus a bit of leeway
// for loading-ahead gaps), skip to the next one. If it's below the
// bottom of the viewport, break out of the loop.
if (top + height < scrollTop - loadAheadDistance) { return; }
if (top > scrollTop + viewportHeight + loadAheadDistance) { return false; }
// If this item is a gap, then we may proceed to check if it's a
// *terminal* gap and trigger its loading mechanism.
if ($this.hasClass('gap')) {
var first = $this.is(':first-child');
var last = $this.is(':last-child');
var item = $this[0].instance.props.item;
if ((first || last) && !item.loading) {
item.direction = first ? 'up' : 'down';
$this[0].instance.load();
}
} else {
if (top + height < scrollTop + viewportHeight) {
endNumber = $this.data('number');
}
// Check if this item is in the viewport, minus the distance we allow
// for load-ahead gaps. If we haven't yet stored a post's number, then
// this item must be the FIRST item in the viewport. Therefore, we'll
// grab its post number so we can update the controller's state later.
if (top + height > scrollTop && !startNumber) {
startNumber = $this.data('number');
}
}
});
// Finally, we want to update the controller's state with regards to the
// current viewing position of the discussion. However, we don't want to
// do this on every single scroll event as it will slow things down. So,
// let's do it at a minimum of 250ms by clearing and setting a timeout.
clearTimeout(this.positionChangedTimeout);
this.positionChangedTimeout = setTimeout(() => this.props.positionChanged(startNumber || 1, endNumber), 500);
}
/**
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.
*/
getMarginTop() {
return this.$() && $('.global-header').outerHeight() + parseInt(this.$().css('margin-top'));
}
/**
Scroll down to a certain post by number (or the gap which we think the
post is in) and highlight it.
*/
scrollToNumber(number, noAnimation) {
// Clear the highlight class from all posts, and attempt to find and
// highlight a post with the specified number. However, we don't apply
// the highlight to the first post in the stream because it's pretty
// obvious that it's the top one.
var $item = this.$('.item').removeClass('highlight').filter('[data-number='+number+']');
if (!$item.is(':first-child')) {
$item.addClass('highlight');
}
// If we didn't have any luck, then a post with this number either
// doesn't exist, or it hasn't been loaded yet. We'll find the item
// that's closest to the post with this number and scroll to that
// instead.
if (!$item.length) {
$item = this.findNearestToNumber(number);
}
return this.scrollToItem($item, noAnimation);
}
/**
Scroll down to a certain post by index (or the gap the post is in.)
*/
scrollToIndex(index, noAnimation) {
var $item = this.findNearestToIndex(index);
return this.scrollToItem($item, noAnimation);
}
/**
*/
scrollToItem($item, noAnimation) {
var $container = $('html, body').stop(true);
if ($item.length) {
var itemTop = $item.offset().top - this.getMarginTop();
var itemBottom = itemTop + $item.height();
var scrollTop = $(document).scrollTop();
var scrollBottom = scrollTop + $(window).height();
// If the item is already in the viewport, just flash it, we don't need to
// scroll anywhere.
if (itemTop > scrollTop && itemBottom < scrollBottom) {
this.flashItem($item);
} else {
var scrollTop = $item.is(':first-child') ? 0 : itemTop;
if (noAnimation) {
$container.scrollTop(scrollTop);
} else if (scrollTop !== $(document).scrollTop()) {
$container.animate({scrollTop: scrollTop}, 'fast', this.flashItem.bind(this, $item));
} else {
this.flashItem($item);
}
}
}
return $container.promise();
}
flashItem($item) {
$item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash'));
}
/**
Find the DOM element of the item that is nearest to a post with a certain
number. This will either be another post (if the requested post doesn't
exist,) or a gap presumed to contain the requested post.
*/
findNearestToNumber(number) {
var $nearestItem = $();
this.$('.item').each(function() {
var $this = $(this);
if ($this.data('number') > number) {
return false;
}
$nearestItem = $this;
});
return $nearestItem;
}
/**
*/
findNearestToIndex(index) {
var $nearestItem = this.$('.item[data-start='+index+'][data-end='+index+']');
if (!$nearestItem.length) {
this.$('.item').each(function() {
$nearestItem = $(this);
if ($nearestItem.data('end') >= index) {
return false;
}
});
}
return $nearestItem;
}
/**
*/
loadingIndex(index, noAnimation) {
// The post at this index is being loaded. We want to scroll to where we
// think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.paused(true);
this.scrollToIndex(index, noAnimation);
}
/**
*/
loadedIndex(index, noAnimation) {
m.redraw(true);
// The post at this index has been loaded. After we scroll to this post,
// we want to resume scroll events.
this.scrollToIndex(index, noAnimation).done(this.unpause.bind(this));
}
/**
*/
loadingNumber(number, noAnimation) {
// The post with this number is being loaded. We want to scroll to where
// we think it will appear. We may be scrolling to the edge of the page,
// but we don't want to trigger any terminal post gaps to load by doing
// that. So, we'll disable the window's scroll handler for now.
this.paused(true);
if (this.$()) {
this.scrollToNumber(number, noAnimation);
}
}
/**
*/
loadedNumber(number, noAnimation) {
m.redraw(true);
// The post with this number has been loaded. After we scroll to this
// post, we want to resume scroll events.
this.scrollToNumber(number, noAnimation).done(this.unpause.bind(this));
}
/**
*/
unpause() {
this.paused(false);
this.scrollListener.update(true);
this.trigger('unpaused');
}
/**
*/
goToNumber(number, noAnimation) {
number = Math.max(number, 1);
// Let's start by telling our listeners that we're going to load
// posts near this number. Elsewhere we will listen and
// consequently scroll down to the appropriate position.
this.trigger('loadingNumber', number, noAnimation);
// Now we have to actually make sure the posts around this new start
// position are loaded. We will tell our listeners when they are.
// Again, a listener will scroll down to the appropriate post.
var promise = this.props.stream.loadNearNumber(number);
m.redraw();
return promise.then(() => this.trigger('loadedNumber', number, noAnimation));
}
/**
*/
goToIndex(index, backwards, noAnimation) {
// Let's start by telling our listeners that we're going to load
// posts at this index. Elsewhere we will listen and consequently
// scroll down to the appropriate position.
this.trigger('loadingIndex', index, noAnimation);
// Now we have to actually make sure the posts around this index
// are loaded. We will tell our listeners when they are. Again, a
// listener will scroll down to the appropriate post.
var promise = this.props.stream.loadNearIndex(index, backwards);
m.redraw();
return promise.then(() => this.trigger('loadedIndex', index, noAnimation));
}
/**
*/
goToFirst() {
return this.goToIndex(0);
}
/**
*/
goToLast() {
var promise = this.goToIndex(this.props.stream.count() - 1, true);
// If the post stream is loading some new posts, then after it's
// done we'll want to immediately scroll down to the bottom of the
// page.
var items = this.props.stream.content;
if (!items[items.length - 1].post) {
promise.then(() => $('html, body').stop(true).scrollTop($('body').height()));
}
return promise;
}
}

View File

@ -1,112 +0,0 @@
import Component from 'flarum/component';
import classList from 'flarum/utils/class-list';
import LoadingIndicator from 'flarum/components/loading-indicator';
export default class StreamItem extends Component {
/**
*/
constructor(props) {
super(props);
this.element = m.prop();
}
/**
*/
view() {
var component = this;
var item = this.props.item;
var gap = !item.post;
var direction = item.direction;
var loading = item.loading;
var count = item.end - item.start + 1;
var classes = { item: true, gap, loading, direction };
var attributes = {
className: classList(classes),
config: this.element,
'data-start': item.start,
'data-end': item.end
};
if (!gap) {
attributes['data-time'] = item.post.time().toISOString();
attributes['data-number'] = item.post.number();
} else {
attributes['config'] = (element) => {
this.element(element);
element.instance = this;
};
attributes['onclick'] = this.load.bind(this);
attributes['onmouseenter'] = function(e) {
if (!item.loading) {
var $this = $(this);
var up = e.clientY > $this.offset().top - $(document).scrollTop() + $this.outerHeight(true) / 2;
$this.removeClass('up down').addClass(item.direction = up ? 'up' : 'down');
}
m.redraw.strategy('none');
};
}
var content;
if (gap) {
content = m('span', loading ? LoadingIndicator.component() : count+' more post'+(count !== 1 ? 's' : ''));
} else {
var PostComponent = app.postComponentRegistry[item.post.contentType()];
if (PostComponent) {
content = PostComponent.component({post: item.post, ondelete: this.props.ondelete});
}
}
return m('div', attributes, content);
}
/**
*/
load() {
var item = this.props.item;
// If this item is not a gap, or if we're already loading its posts,
// then we don't need to do anything.
if (item.post || item.loading) {
return false;
}
// If new posts are being loaded in an upwards direction, then when
// they are rendered, the rest of the posts will be pushed down the
// page. If loaded in a downwards direction from the end of a
// discussion, the terminal gap will disappear and the page will
// scroll up a bit before the new posts are rendered. In order to
// maintain the current scroll position relative to the content
// before/after the gap, we need to find item directly after the gap
// and use it as an anchor.
var siblingFunc = item.direction === 'up' ? 'nextAll' : 'prevAll';
var anchor = this.$()[siblingFunc]('.item:first');
// Tell the controller that we want to load the range of posts that this
// gap represents. We also specify which direction we want to load the
// posts from.
this.props.loadRange(item.start, item.end, item.direction === 'up').then(function() {
// Immediately after the posts have been loaded (but before they
// have been rendered,) we want to grab the distance from the top of
// the viewport to the top of the anchor element.
if (anchor.length) {
var scrollOffset = anchor.offset().top - $(document).scrollTop();
}
m.redraw(true);
// After they have been rendered, we scroll back to a position
// so that the distance from the top of the viewport to the top
// of the anchor element is the same as before. If there is no
// anchor (i.e. this gap is terminal,) then we'll scroll to the
// bottom of the document.
$('body').scrollTop(anchor.length ? anchor.offset().top - scrollOffset : $('body').height());
});
m.redraw();
}
}

View File

@ -2,8 +2,6 @@ import Component from 'flarum/component';
import ItemList from 'flarum/utils/item-list';
import IndexPage from 'flarum/components/index-page';
import DiscussionList from 'flarum/components/discussion-list';
import StreamContent from 'flarum/components/stream-content';
import StreamScrubber from 'flarum/components/stream-scrubber';
import UserCard from 'flarum/components/user-card';
import ReplyComposer from 'flarum/components/reply-composer';
import ActionButton from 'flarum/components/action-button';

View File

@ -10,7 +10,7 @@ export default function(app) {
Discussion.prototype.replyAction = function(goToLast, forceRefresh) {
if (app.session.user() && this.canReply()) {
if (goToLast && app.current.discussion && app.current.discussion().id() === this.id()) {
app.current.streamContent.goToLast();
app.current.stream.goToLast();
}
var component = app.composer.component;
if (!(component instanceof ReplyComposer) || component.props.discussion !== this || component.props.user !== app.session.user() || forceRefresh) {
@ -47,7 +47,7 @@ export default function(app) {
if (title && title !== currentTitle) {
this.save({title}).then(discussion => {
if (app.current instanceof DiscussionPage) {
app.current.stream().sync();
app.current.stream.sync();
}
m.redraw();
});

View File

@ -1,170 +0,0 @@
export default class PostStream {
constructor(discussion) {
this.discussion = discussion
this.ids = this.discussion.data().links.posts.linkage.map((link) => link.id)
var item = this.makeItem(0, this.ids.length - 1)
item.loading = true
this.content = [item]
this.postLoadCount = 20
}
count() {
return this.ids.length;
}
loadedCount() {
return this.content.filter((item) => item.post).length;
}
loadRange(start, end, backwards) {
// Find the appropriate gap objects in the post stream. When we find
// one, we will turn on its loading flag.
this.content.forEach(function(item) {
if (!item.post && ((item.start >= start && item.start <= end) || (item.end >= start && item.end <= end))) {
item.loading = true
item.direction = backwards ? 'up' : 'down'
}
});
// Get a list of post numbers that we'll want to retrieve. If there are
// more post IDs than the number of posts we want to load, then take a
// slice of the array in the appropriate direction.
var ids = this.ids.slice(start, end + 1);
var limit = this.postLoadCount
ids = backwards ? ids.slice(-limit) : ids.slice(0, limit)
return this.loadPosts(ids)
}
loadPosts(ids) {
if (!ids.length) {
return m.deferred().resolve().promise;
}
return app.store.find('posts', ids).then(this.addPosts.bind(this));
}
loadNearNumber(number) {
// Find the item in the post stream which is nearest to this number. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToNumber(number)
if (item) {
if (item.post && item.post.number() === number) {
return m.deferred().resolve([item.post]).promise;
} else if (!item.post) {
item.direction = 'down'
item.loading = true;
}
}
var stream = this
return app.store.find('posts', {
discussions: this.discussion.id(),
near: number,
count: this.postLoadCount
}).then(this.addPosts.bind(this))
}
loadNearIndex(index, backwards) {
// Find the item in the post stream which is nearest to this index. If
// it turns out the be the actual post we're trying to load, then we can
// return a resolved promise (i.e. we don't need to make an API
// request.) Or, if it's a gap, we'll switch on its loading flag.
var item = this.findNearestToIndex(index)
if (item) {
if (item.post) {
return m.deferred().resolve([item.post]).promise;
}
return this.loadRange(Math.max(item.start, index - this.postLoadCount / 2), item.end, backwards);
}
}
addPosts(posts) {
posts.forEach(this.addPost.bind(this))
}
addPost(post) {
var index = this.ids.indexOf(post.id())
var content = this.content
var makeItem = this.makeItem
// Here we loop through each item in the post stream, and find the gap
// in which this post should be situated. When we find it, we can replace
// it with the post, and new gaps either side if appropriate.
content.some(function(item, i) {
if (item.start <= index && item.end >= index) {
var newItems = []
if (item.start < index) {
newItems.push(makeItem(item.start, index - 1))
}
newItems.push(makeItem(index, index, post))
if (item.end > index) {
newItems.push(makeItem(index + 1, item.end))
}
var args = [i, 1].concat(newItems);
[].splice.apply(content, args)
return true
}
})
}
// @todo rename to pushPost
addPostToEnd(post) {
if (this.ids.indexOf(post.id()) === -1) {
var index = this.ids.length;
this.ids.push(post.id());
this.content.push(this.makeItem(index, index, post));
}
}
removePost(id) {
this.ids.splice(this.ids.indexOf(id), 1);
this.content.some((item, i) => {
if (item.post && item.post.id() === id) {
this.content.splice(i, 1);
return true;
}
});
}
sync() {
var discussion = this.discussion;
var addedPosts = discussion.addedPosts();
addedPosts && addedPosts.forEach(this.addPostToEnd.bind(this));
discussion.pushData({links: {addedPosts: null}});
var removedPosts = discussion.removedPosts();
removedPosts && removedPosts.forEach(this.removePost.bind(this));
discussion.pushData({removedPosts: null});
}
makeItem(start, end, post) {
var item = {start, end}
if (post) {
item.post = post
}
return item
}
findNearestTo(index, property) {
var nearestItem
this.content.some(function(item) {
if (property(item) > index) { return true }
nearestItem = item
})
return nearestItem
}
findNearestToNumber(number) {
return this.findNearestTo(number, (item) => item.post && item.post.number())
}
findNearestToIndex(index) {
return this.findNearestTo(index, (item) => item.start)
}
}

View File

@ -41,7 +41,7 @@ export default class Model {
for (var i in data) {
if (i === 'links') {
oldData[i] = oldData[i] || {};
for (var j in newData[i]) {
for (var j in currentData[i]) {
oldData[i][j] = currentData[i][j];
}
} else {

View File

@ -37,6 +37,7 @@ Discussion.prototype.commentsCount = Model.prop('commentsCount');
Discussion.prototype.repliesCount = computed('commentsCount', commentsCount => commentsCount - 1);
Discussion.prototype.posts = Model.many('posts');
Discussion.prototype.postIds = function() { return this.data().links.posts.linkage.map((link) => link.id); };
Discussion.prototype.relevantPosts = Model.many('relevantPosts');
Discussion.prototype.addedPosts = Model.many('addedPosts');
Discussion.prototype.removedPosts = Model.prop('removedPosts');

View File

@ -0,0 +1,7 @@
export default function anchorScroll(element, callback) {
var scrollAnchor = $(element).offset().top - $(window).scrollTop();
callback();
$(window).scrollTop($(element).offset().top - scrollAnchor);
}

View File

@ -73,67 +73,25 @@
margin-bottom: 40px;
}
}
.gap {
padding: 30px 0;
text-align: center;
color: #aaa;
cursor: pointer;
border: 2px dashed @fl-body-bg;
background: #f2f2f2;
text-transform: uppercase;
font-size: 12px;
font-weight: bold;
overflow: hidden;
position: relative;
.transition(padding 0.2s);
&:hover, &.loading, &.active {
padding: 50px 0;
&.up:before, &.down:after {
opacity: 1;
}
}
&.loading {
.transition(none);
}
&:before, &:after {
font-family: 'FontAwesome';
display: block;
opacity: 0;
transition: opacity 0.2s;
height: 15px;
color: #aaa;
}
&.up:before {
content: '\f077';
margin-top: -25px;
margin-bottom: 10px;
}
&.down:after {
content: '\f078';
margin-bottom: -25px;
margin-top: 10px;
}
&:only-child {
background: none;
border: 0;
color: @fl-primary-color;
&:before, &:after {
display: none;
}
}
& .loading-indicator {
color: #aaa;
}
@keyframes blink {
0% {opacity: 0.5}
50% {opacity: 1}
100% {opacity: 0.5}
}
.loading-post {
animation: blink 1s linear;
animation-iteration-count: infinite;
}
.fake-text {
background: @fl-body-secondary-color;
height: 12px;
width: 100%;
margin-bottom: 20px;
border-radius: @border-radius-base;
@media @phone {
.gap {
margin-left: -15px;
margin-right: -15px;
border-left: 0;
border-right: 0;
.post-header & {
height: 16px;
width: 150px;
}
}
@ -175,18 +133,18 @@
background: @fl-primary-color;
}
}
@-webkit-keyframes pulsate {
@keyframes pulsate {
0% {transform: scale(1)}
50% {transform: scale(1.02)}
100% {transform: scale(1)}
}
.item.pulsate {
-webkit-animation: pulsate 1s ease-in-out;
-webkit-animation-iteration-count: infinite;
animation: pulsate 1s ease-in-out;
animation-iteration-count: infinite;
}
.item.flash {
-webkit-animation: pulsate 0.2s ease-in-out;
-webkit-animation-iteration-count: 1;
animation: pulsate 0.2s ease-in-out;
animation-iteration-count: 1;
}
.post-header {
margin-bottom: 10px;

View File

@ -13,11 +13,11 @@
.define-body-variables(@fl-dark-mode);
.define-body-variables(false) {
@fl-body-primary-color: @fl-primary-color;
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 95%);
@fl-body-secondary-color: hsl(@fl-secondary-hue, min(50%, @fl-secondary-sat), 93%);
@fl-body-bg: #fff;
@fl-body-color: #444;
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 68%);
@fl-body-muted-color: hsl(@fl-secondary-hue, min(25%, @fl-secondary-sat), 66%);
@fl-body-muted-more-color: #bbb;
@fl-shadow-color: rgba(0, 0, 0, 0.35);
}