import Component from '../../common/Component'; import ScrollListener from '../../common/utils/ScrollListener'; import PostLoading from './LoadingPost'; import ReplyPlaceholder from './ReplyPlaceholder'; import Button from '../../common/components/Button'; /** * The `PostStream` component displays an infinitely-scrollable wall of posts in * a discussion. Posts that have not loaded will be displayed as placeholders. * * ### Attrs * * - `discussion` * - `stream` * - `targetPost` * - `onPositionChange` */ export default class PostStream extends Component { oninit(vnode) { super.oninit(vnode); this.discussion = this.attrs.discussion; this.stream = this.attrs.stream; this.scrollListener = new ScrollListener(this.onscroll.bind(this)); } view() { let lastTime; const viewingEnd = this.stream.viewingEnd(); const posts = this.stream.posts(); const postIds = this.discussion.postIds(); const items = posts.map((post, i) => { let content; const attrs = { 'data-index': this.stream.visibleStart + i }; if (post) { const time = post.createdAt(); const PostComponent = app.postComponents[post.contentType()]; content = PostComponent ? PostComponent.component({ post }) : ''; attrs.key = 'post' + post.id(); attrs['data-time'] = time.toISOString(); attrs['data-number'] = post.number(); attrs['data-id'] = post.id(); attrs['data-type'] = post.contentType(); // If the post before this one was more than 4 days ago, we will // display a 'time gap' indicating how long it has been in between // the posts. const dt = time - lastTime; if (dt > 1000 * 60 * 60 * 24 * 4) { content = [
{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: dayjs().add(dt, 'ms').fromNow(true) })}
, content, ]; } lastTime = time; } else { attrs.key = 'post' + postIds[this.stream.visibleStart + i]; content = PostLoading.component(); } return (
{content}
); }); if (!viewingEnd && posts[this.stream.visibleEnd - this.stream.visibleStart - 1]) { items.push(
); } // If we're viewing the end of the discussion, the user can reply, and // is not already doing so, then show a 'write a reply' placeholder. if (viewingEnd && (!app.session.user || this.discussion.canReply())) { items.push(
{ReplyPlaceholder.component({ discussion: this.discussion })}
); } return
{items}
; } onupdate() { this.triggerScroll(); } oncreate(vnode) { super.oncreate(vnode); this.triggerScroll(); // This is wrapped in setTimeout due to the following Mithril issue: // https://github.com/lhorie/mithril.js/issues/637 setTimeout(() => this.scrollListener.start()); } onremove() { this.scrollListener.stop(); clearTimeout(this.calculatePositionTimeout); } /** * Start scrolling, if appropriate, to a newly-targeted post. */ triggerScroll() { if (!this.attrs.targetPost) return; const oldTarget = this.prevTarget; const newTarget = this.attrs.targetPost; if (oldTarget) { if ('number' in oldTarget && oldTarget.number === newTarget.number) return; if ('index' in oldTarget && oldTarget.index === newTarget.index) return; } if ('number' in newTarget) { this.scrollToNumber(newTarget.number, this.stream.noAnimationScroll); } else if ('index' in newTarget) { const backwards = newTarget.index === this.stream.count() - 1; this.scrollToIndex(newTarget.index, this.stream.noAnimationScroll, backwards); } this.prevTarget = newTarget; } /** * When the window is scrolled, check if either extreme of the post stream is * in the viewport, and if so, trigger loading the next/previous page. * * @param {Integer} top */ onscroll(top = window.pageYOffset) { if (this.stream.paused) return; const marginTop = this.getMarginTop(); const viewportHeight = $(window).height() - marginTop; const viewportTop = top + marginTop; const loadAheadDistance = 300; if (this.stream.visibleStart > 0) { const $item = this.$('.PostStream-item[data-index=' + this.stream.visibleStart + ']'); if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) { this.stream.loadPrevious(); } } if (this.stream.visibleEnd < this.stream.count()) { const $item = this.$('.PostStream-item[data-index=' + (this.stream.visibleEnd - 1) + ']'); if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) { this.stream.loadNext(); } } // Throttle calculation of our position (start/end numbers of posts in the // viewport) to 100ms. clearTimeout(this.calculatePositionTimeout); this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this, top), 100); this.updateScrubber(top); } updateScrubber(top = window.pageYOffset) { const marginTop = this.getMarginTop(); const viewportHeight = $(window).height() - marginTop; const viewportTop = top + marginTop; // Before looping through all of the posts, we reset the scrollbar // properties to a 'default' state. These values reflect what would be // seen if the browser were scrolled right up to the top of the page, // and the viewport had a height of 0. const $items = this.$('.PostStream-item[data-index]'); let index = $items.first().data('index') || 0; let visible = 0; let period = ''; // Now loop through each of the items in the discussion. An 'item' is // either a single post or a 'gap' of one or more posts that haven't // been loaded yet. $items.each(function () { const $this = $(this); const top = $this.offset().top; const height = $this.outerHeight(true); // If this item is above the top of the viewport, skip to the next // one. If it's below the bottom of the viewport, break out of the // loop. if (top + height < viewportTop) { return true; } if (top > viewportTop + viewportHeight) { return false; } // Work out how many pixels of this item are visible inside the viewport. // Then add the proportion of this item's total height to the index. const visibleTop = Math.max(0, viewportTop - top); const visibleBottom = Math.min(height, viewportTop + viewportHeight - top); const visiblePost = visibleBottom - visibleTop; if (top <= viewportTop) { index = parseFloat($this.data('index')) + visibleTop / height; } if (visiblePost > 0) { visible += visiblePost / height; } // If this item has a time associated with it, then set the // scrollbar's current period to a formatted version of this time. const time = $this.data('time'); if (time) period = time; }); this.stream.index = index + 1; this.stream.visible = visible; if (period) this.stream.description = dayjs(period).format('MMMM YYYY'); } /** * Work out which posts (by number) are currently visible in the viewport, and * fire an event with the information. */ calculatePosition(top = window.pageYOffset) { const marginTop = this.getMarginTop(); const $window = $(window); const viewportHeight = $window.height() - marginTop; const scrollTop = $window.scrollTop() + marginTop; const viewportTop = top + marginTop; let startNumber; let endNumber; this.$('.PostStream-item').each(function () { const $item = $(this); const top = $item.offset().top; const height = $item.outerHeight(true); const visibleTop = Math.max(0, viewportTop - top); const threeQuartersVisible = visibleTop / height < 0.75; const coversQuarterOfViewport = (height - visibleTop) / viewportHeight > 0.25; if (startNumber === undefined && (threeQuartersVisible || coversQuarterOfViewport)) { startNumber = $item.data('number'); } if (top + height > scrollTop) { if (top + height < scrollTop + viewportHeight) { if ($item.data('number')) { endNumber = $item.data('number'); } } else return false; } }); if (startNumber) { this.attrs.onPositionChange(startNumber || 1, endNumber, startNumber); } } /** * Get the distance from the top of the viewport to the point at which we * would consider a post to be the first one visible. * * @return {Integer} */ getMarginTop() { return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10); } /** * Scroll down to a certain post by number and 'flash' it. * * @param {Integer} number * @param {Boolean} animate * @return {jQuery.Deferred} */ scrollToNumber(number, animate) { const $item = this.$(`.PostStream-item[data-number=${number}]`); return this.scrollToItem($item, animate).then(this.flashItem.bind(this, $item)); } /** * Scroll down to a certain post by index. * * @param {Integer} index * @param {Boolean} animate * @param {Boolean} bottom Whether or not to scroll to the bottom of the post * at the given index, instead of the top of it. * @return {jQuery.Deferred} */ scrollToIndex(index, animate, bottom) { const $item = this.$(`.PostStream-item[data-index=${index}]`); return this.scrollToItem($item, animate, true, bottom).then(() => { if (index == this.stream.count() - 1) { this.flashItem(this.$('.PostStream-item:last-child')); } }); } /** * Scroll down to the given post. * * @param {jQuery} $item * @param {Boolean} animate * @param {Boolean} force Whether or not to force scrolling to the item, even * if it is already in the viewport. * @param {Boolean} bottom Whether or not to scroll to the bottom of the post * at the given index, instead of the top of it. * @return {jQuery.Deferred} */ scrollToItem($item, animate, force, bottom) { const $container = $('html, body').stop(true); if ($item.length) { const itemTop = $item.offset().top - this.getMarginTop(); const itemBottom = $item.offset().top + $item.height(); const scrollTop = $(document).scrollTop(); const scrollBottom = scrollTop + $(window).height(); // If the item is already in the viewport, we may not need to scroll. // If we're scrolling to the bottom of an item, then we'll make sure the // bottom will line up with the top of the composer. if (force || itemTop < scrollTop || itemBottom > scrollBottom) { const top = bottom ? itemBottom - $(window).height() + app.composer.computedHeight() : $item.is(':first-child') ? 0 : itemTop; if (!animate) { $container.scrollTop(top); } else if (top !== scrollTop) { $container.animate({ scrollTop: top }, 'fast'); } } } return Promise.all([$container.promise(), this.stream.loadPromise]).then(() => { this.updateScrubber(); const index = $item.data('index'); m.redraw.sync(); const scroll = index == 0 ? 0 : $(`.PostStream-item[data-index=${$item.data('index')}]`).offset().top - this.getMarginTop(); $(window).scrollTop(scroll); this.calculatePosition(); this.stream.paused = false; }); } /** * 'Flash' the given post, drawing the user's attention to it. * * @param {jQuery} $item */ flashItem($item) { $item.addClass('flash').on('animationend webkitAnimationEnd', (e) => { if (e.animationName === 'fadeIn') { $item.removeClass('flash'); } }); } }