From cafa6c7b5d3bec8e78cda8cfeb49432a7fafdc1d Mon Sep 17 00:00:00 2001 From: Toby Zerner Date: Fri, 29 May 2015 18:17:50 +0930 Subject: [PATCH] New and improved post stream. --- js/forum/src/components/discussion-page.js | 59 +-- js/forum/src/components/post-loading.js | 11 + .../{stream-scrubber.js => post-scrubber.js} | 225 +++------ js/forum/src/components/post-stream.js | 463 ++++++++++++++++++ js/forum/src/components/reply-composer.js | 2 +- js/forum/src/components/stream-content.js | 360 -------------- js/forum/src/components/stream-item.js | 112 ----- js/forum/src/components/user-page.js | 2 - .../src/initializers/discussion-controls.js | 4 +- js/forum/src/utils/post-stream.js | 170 ------- js/lib/model.js | 2 +- js/lib/models/discussion.js | 1 + js/lib/utils/anchor-scroll.js | 7 + less/forum/discussion.less | 86 +--- less/lib/variables.less | 4 +- 15 files changed, 609 insertions(+), 899 deletions(-) create mode 100644 js/forum/src/components/post-loading.js rename js/forum/src/components/{stream-scrubber.js => post-scrubber.js} (61%) create mode 100644 js/forum/src/components/post-stream.js delete mode 100644 js/forum/src/components/stream-content.js delete mode 100644 js/forum/src/components/stream-item.js delete mode 100644 js/forum/src/utils/post-stream.js create mode 100644 js/lib/utils/anchor-scroll.js diff --git a/js/forum/src/components/discussion-page.js b/js/forum/src/components/discussion-page.js index f92ffe7b6..8ba62ccda 100644 --- a/js/forum/src/components/discussion-page.js +++ b/js/forum/src/components/discussion-page.js @@ -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' }) ); diff --git a/js/forum/src/components/post-loading.js b/js/forum/src/components/post-loading.js new file mode 100644 index 000000000..2a9d1d868 --- /dev/null +++ b/js/forum/src/components/post-loading.js @@ -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')) + ); + } +} diff --git a/js/forum/src/components/stream-scrubber.js b/js/forum/src/components/post-scrubber.js similarity index 61% rename from js/forum/src/components/stream-scrubber.js rename to js/forum/src/components/post-scrubber.js index 727a599a4..3ec1ae9ef 100644 --- a/js/forum/src/components/stream-scrubber.js +++ b/js/forum/src/components/post-scrubber.js @@ -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'); } } diff --git a/js/forum/src/components/post-stream.js b/js/forum/src/components/post-stream.js new file mode 100644 index 000000000..02536f2eb --- /dev/null +++ b/js/forum/src/components/post-stream.js @@ -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; diff --git a/js/forum/src/components/reply-composer.js b/js/forum/src/components/reply-composer.js index 55039bc57..4e19ad502 100644 --- a/js/forum/src/components/reply-composer.js +++ b/js/forum/src/components/reply-composer.js @@ -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(), diff --git a/js/forum/src/components/stream-content.js b/js/forum/src/components/stream-content.js deleted file mode 100644 index 767e8a657..000000000 --- a/js/forum/src/components/stream-content.js +++ /dev/null @@ -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; - } -} diff --git a/js/forum/src/components/stream-item.js b/js/forum/src/components/stream-item.js deleted file mode 100644 index 7a6512e27..000000000 --- a/js/forum/src/components/stream-item.js +++ /dev/null @@ -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(); - } -} diff --git a/js/forum/src/components/user-page.js b/js/forum/src/components/user-page.js index e3be133a4..ce7aa54eb 100644 --- a/js/forum/src/components/user-page.js +++ b/js/forum/src/components/user-page.js @@ -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'; diff --git a/js/forum/src/initializers/discussion-controls.js b/js/forum/src/initializers/discussion-controls.js index cd52f19ed..755bbc44d 100644 --- a/js/forum/src/initializers/discussion-controls.js +++ b/js/forum/src/initializers/discussion-controls.js @@ -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(); }); diff --git a/js/forum/src/utils/post-stream.js b/js/forum/src/utils/post-stream.js deleted file mode 100644 index 309adc0d8..000000000 --- a/js/forum/src/utils/post-stream.js +++ /dev/null @@ -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) - } -} diff --git a/js/lib/model.js b/js/lib/model.js index ecfbb5bc3..88217b9df 100644 --- a/js/lib/model.js +++ b/js/lib/model.js @@ -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 { diff --git a/js/lib/models/discussion.js b/js/lib/models/discussion.js index cfa11e4e9..f0d533f45 100644 --- a/js/lib/models/discussion.js +++ b/js/lib/models/discussion.js @@ -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'); diff --git a/js/lib/utils/anchor-scroll.js b/js/lib/utils/anchor-scroll.js new file mode 100644 index 000000000..f224170dd --- /dev/null +++ b/js/lib/utils/anchor-scroll.js @@ -0,0 +1,7 @@ +export default function anchorScroll(element, callback) { + var scrollAnchor = $(element).offset().top - $(window).scrollTop(); + + callback(); + + $(window).scrollTop($(element).offset().top - scrollAnchor); +} diff --git a/less/forum/discussion.less b/less/forum/discussion.less index 7ceec871a..4d92ecca6 100644 --- a/less/forum/discussion.less +++ b/less/forum/discussion.less @@ -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; diff --git a/less/lib/variables.less b/less/lib/variables.less index 25b06b218..eb9ea4c05 100644 --- a/less/lib/variables.less +++ b/less/lib/variables.less @@ -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); }