diff --git a/js/src/forum/Forum.ts b/js/src/forum/Forum.ts index 0b466db0a..cfe31640f 100644 --- a/js/src/forum/Forum.ts +++ b/js/src/forum/Forum.ts @@ -6,6 +6,7 @@ import HeaderSecondary from './components/HeaderSecondary'; import Page from './components/Page'; import IndexPage from './components/IndexPage'; +import DiscussionPage from './components/DiscussionPage'; import PostsUserPage from './components/PostsUserPage'; import SettingsPage from './components/SettingsPage'; @@ -17,8 +18,8 @@ export default class Forum extends Application { routes = { index: { path: '/all', component: IndexPage }, - discussion: { path: '/d/:id', component: IndexPage }, - 'discussion.near': { path: '/d/:id/:near', component: IndexPage }, + discussion: { path: '/d/:id', component: DiscussionPage }, + 'discussion.near': { path: '/d/:id/:near', component: DiscussionPage }, user: { path: '/u/:username', component: PostsUserPage }, 'user.posts': { path: '/u/:username', component: PostsUserPage }, @@ -35,6 +36,11 @@ export default class Forum extends Application { */ history: History = new History(); + postComponents = { + comment: CommentPost, + // discussionRenamed: DiscussionRenamedPost + }; + previous: Page; current: Page; diff --git a/js/src/forum/components/DiscussionHero.tsx b/js/src/forum/components/DiscussionHero.tsx new file mode 100644 index 000000000..e246d3f6c --- /dev/null +++ b/js/src/forum/components/DiscussionHero.tsx @@ -0,0 +1,38 @@ +import Component from '../../common/Component'; +import ItemList from '../../common/utils/ItemList'; +import listItems from '../../common/helpers/listItems'; +import { DiscussionProp } from '../../common/concerns/ComponentProps'; + +/** + * The `DiscussionHero` component displays the hero on a discussion page. + */ +export default class DiscussionHero extends Component { + view() { + return ( +
+
+
    {listItems(this.items().toArray())}
+
+
+ ); + } + + /** + * Build an item list for the contents of the discussion hero. + * + * @return {ItemList} + */ + items() { + const items = new ItemList(); + const discussion = this.props.discussion; + const badges = discussion.badges().toArray(); + + if (badges.length) { + items.add('badges', , 10); + } + + items.add('title',

{discussion.title()}

); + + return items; + } +} diff --git a/js/src/forum/components/DiscussionPage.tsx b/js/src/forum/components/DiscussionPage.tsx new file mode 100644 index 000000000..c35c9802b --- /dev/null +++ b/js/src/forum/components/DiscussionPage.tsx @@ -0,0 +1,285 @@ +import Page from './Page'; +import ItemList from '../../common/utils/ItemList'; +import DiscussionHero from './DiscussionHero'; +import PostStream from './PostStream'; +// import PostStreamScrubber from './PostStreamScrubber'; +import LoadingIndicator from '../../common/components/LoadingIndicator'; +import SplitDropdown from '../../common/components/SplitDropdown'; +import listItems from '../../common/helpers/listItems'; +import DiscussionControls from '../utils/DiscussionControls'; +import Discussion from '../../common/models/Discussion'; + +/** + * The `DiscussionPage` component displays a whole discussion page, including + * the discussion list pane, the hero, the posts, and the sidebar. + */ +export default class DiscussionPage extends Page { + /** + * The discussion that is being viewed. + */ + discussion?: Discussion; + + /** + * The number of the first post that is currently visible in the viewport. + */ + near?: number; + + stream: PostStream; + + oninit(vnode) { + super.oninit(vnode); + + this.refresh(); + + // If the discussion list has been loaded, then we'll enable the pane (and + // hide it by default). Also, if we've just come from another discussion + // page, then we don't want Mithril to redraw the whole page – if it did, + // then the pane would which would be slow and would cause problems with + // event handlers. + if (app.cache.discussionList) { + // TODO app pane + // app.pane.enable(); + // app.pane.hide(); + } + + app.history.push('discussion'); + + this.bodyClass = 'App--discussion'; + } + + onbeforeremove(vnode) { + super.onbeforeremove(vnode); + + // If we have routed to the same discussion as we were viewing previously, + // cancel the unloading of this controller and instead prompt the post + // stream to jump to the new 'near' param. + if (this.discussion) { + const idParam = m.route.param('id'); + + if (idParam && idParam.split('-')[0] === this.discussion.id()) { + const near = m.route.param('near') || '1'; + + if (near !== String(this.near)) { + // this.stream.goToNumber(near); + } + + this.near = null; + + return false; + } + } + + // If we are indeed navigating away from this discussion, then disable the + // discussion list pane. Also, if we're composing a reply to this + // discussion, minimize the composer – unless it's empty, in which case + // we'll just close it. + // TODO pane & composer + // app.pane.disable(); + + // if (app.composingReplyTo(this.discussion) && !app.composer.component.content()) { + // app.composer.hide(); + // } else { + // app.composer.minimize(); + // } + } + + view() { + const discussion = this.discussion; + + return ( +
+ {app.cache.discussionList ? ( +
false}> + {!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''} +
+ ) : ( + '' + )} + +
+ {discussion + ? [ + DiscussionHero.component({ discussion }), +
+ +
{this.stream.render()}
+
, + ] + : LoadingIndicator.component({ className: 'LoadingIndicator--block' })} +
+
+ ); + } + + oncreate(vnode) { + super.oncreate(vnode); + + if (this.discussion) { + app.setTitle(this.discussion.title()); + } + } + + /** + * Clear and reload the discussion. + */ + refresh() { + this.near = Number(m.route.param('near') || 0); + this.discussion = null; + + const preloadedDiscussion = app.preloadedApiDocument(); + if (preloadedDiscussion) { + // We must wrap this in a setTimeout because if we are mounting this + // component for the first time on page load, then any calls to m.redraw + // will be ineffective and thus any configs (scroll code) will be run + // before stuff is drawn to the page. + setTimeout(this.show.bind(this, preloadedDiscussion), 0); + } else { + const params = this.requestParams(); + + app.store.find('discussions', m.route.param('id').split('-')[0], params).then(this.show.bind(this)); + } + + m.redraw(); + } + + /** + * Get the parameters that should be passed in the API request to get the + * discussion. + */ + requestParams(): any { + return { + page: { near: this.near }, + }; + } + + /** + * Initialize the component to display the given discussion. + */ + show(discussion: Discussion) { + this.discussion = discussion; + + app.history.push('discussion', discussion.title()); + app.setTitleCount(0); + + // When the API responds with a discussion, it will also include a number of + // posts. Some of these posts are included because they are on the first + // page of posts we want to display (determined by the `near` parameter) – + // others may be included because due to other relationships introduced by + // extensions. We need to distinguish the two so we don't end up displaying + // the wrong posts. We do so by filtering out the posts that don't have + // the 'discussion' relationship linked, then sorting and splicing. + let includedPosts = []; + if (discussion.payload && discussion.payload.included) { + const discussionId = discussion.id(); + + includedPosts = discussion.payload.included + .filter( + record => + record.type === 'posts' && + record.relationships && + record.relationships.discussion && + record.relationships.discussion.data.id === discussionId + ) + .map(record => app.store.getById('posts', record.id)) + .sort((a, b) => a.id() - b.id()) + .slice(0, 20); + } + + m.redraw(); + + // Set up the post stream for this discussion, along with the first page of + // posts we want to display. Tell the stream to scroll down and highlight + // the specific post that was routed to. + this.stream = new PostStream({ discussion, includedPosts }); + this.stream.on('positionChanged', this.positionChanged.bind(this)); + this.stream.goToNumber(m.route.param('near') || (includedPosts[0] && includedPosts[0].number()), true); + } + + /** + * Configure the discussion list pane. + */ + oncreatePane(vnode) { + const $list = $(vnode.dom); + + // When the mouse enters and leaves the discussions pane, we want to show + // and hide the pane respectively. We also create a 10px 'hot edge' on the + // left of the screen to activate the pane. + const pane = app.pane; + $list.hover(pane.show.bind(pane), pane.onmouseleave.bind(pane)); + + const hotEdge = e => { + if (e.pageX < 10) pane.show(); + }; + $(document).on('mousemove', hotEdge); + vnode.dom.onunload = () => $(document).off('mousemove', hotEdge); + + // If the discussion we are viewing is listed in the discussion list, then + // we will make sure it is visible in the viewport – if it is not we will + // scroll the list down to it. + const $discussion = $list.find('.DiscussionListItem.active'); + if ($discussion.length) { + const listTop = $list.offset().top; + const listBottom = listTop + $list.outerHeight(); + const discussionTop = $discussion.offset().top; + const discussionBottom = discussionTop + $discussion.outerHeight(); + + if (discussionTop < listTop || discussionBottom > listBottom) { + $list.scrollTop($list.scrollTop() - listTop + discussionTop); + } + } + } + + /** + * Build an item list for the contents of the sidebar. + */ + sidebarItems(): ItemList { + const items = new ItemList(); + + items.add( + 'controls', + SplitDropdown.component({ + children: DiscussionControls.controls(this.discussion, this).toArray(), + icon: 'fas fa-ellipsis-v', + className: 'App-primaryControl', + buttonClassName: 'Button--primary', + }) + ); + + // items.add( + // 'scrubber', + // PostStreamScrubber.component({ + // stream: this.stream, + // className: 'App-titleControl', + // }), + // -100 + // ); + + return items; + } + + /** + * When the posts that are visible in the post stream change (i.e. the user + * scrolls up or down), then we update the URL and mark the posts as read. + */ + positionChanged(startNumber: number, endNumber: number) { + const discussion = this.discussion; + + // Construct a URL to this discussion with the updated position, then + // replace it into the window's history and our own history stack. + const url = app.route.discussion(discussion, (this.near = startNumber)); + + m.route(url, true); + window.history.replaceState(null, document.title, url); + + app.history.push('discussion', discussion.title()); + + // If the user hasn't read past here before, then we'll update their read + // state and redraw. + if (app.session.user && endNumber > (discussion.lastReadPostNumber() || 0)) { + discussion.save({ lastReadPostNumber: endNumber }); + m.redraw(); + } + } +} diff --git a/js/src/forum/components/LoadingPost.tsx b/js/src/forum/components/LoadingPost.tsx new file mode 100644 index 000000000..76633120a --- /dev/null +++ b/js/src/forum/components/LoadingPost.tsx @@ -0,0 +1,25 @@ +import Component from '../../common/Component'; +import avatar from '../../common/helpers/avatar'; + +/** + * The `LoadingPost` component shows a placeholder that looks like a post, + * indicating that the post is loading. + */ +export default class LoadingPost extends Component { + view() { + return ( +
+
+ {avatar(null, { className: 'PostUser-avatar' })} +
+
+ +
+
+
+
+
+
+ ); + } +} diff --git a/js/src/forum/components/PostPreview.tsx b/js/src/forum/components/PostPreview.tsx new file mode 100644 index 000000000..da9d9e0ac --- /dev/null +++ b/js/src/forum/components/PostPreview.tsx @@ -0,0 +1,27 @@ +import Component from '../../common/Component'; +import avatar from '../../common/helpers/avatar'; +import username from '../../common/helpers/username'; +import highlight from '../../common/helpers/highlight'; +import LinkButton from '../../common/components/LinkButton'; +import { PostProp } from '../../common/concerns/ComponentProps'; + +/** + * The `PostPreview` component shows a link to a post containing the avatar and + * username of the author, and a short excerpt of the post's content. + */ +export default class PostPreview extends Component { + view() { + const post = this.props.post; + const user = post.user(); + const excerpt = highlight(post.contentPlain(), this.props.highlight, 300); + + return ( + + + {avatar(user)} + {username(user)} {excerpt} + + + ); + } +} diff --git a/js/src/forum/components/PostStream.tsx b/js/src/forum/components/PostStream.tsx new file mode 100644 index 000000000..e59bc18b2 --- /dev/null +++ b/js/src/forum/components/PostStream.tsx @@ -0,0 +1,591 @@ +import Component from '../../common/Component'; +import ScrollListener from '../../common/utils/ScrollListener'; +import PostLoading from './LoadingPost'; +import anchorScroll from '../../common/utils/anchorScroll'; +import ReplyPlaceholder from './ReplyPlaceholder'; +import Button from '../../common/components/Button'; +import Discussion from '../../common/models/Discussion'; +import Post from '../../common/models/Post'; +import Evented from '../../common/utils/evented'; +import { DiscussionProp } from '../../common/concerns/ComponentProps'; + +export interface PostStreamProps extends DiscussionProp { + includedPosts: Post[]; +} + +interface PostStream extends Component, Evented {} + +/** + * The `PostStream` component displays an infinitely-scrollable wall of posts in + * a discussion. Posts that have not loaded will be displayed as placeholders. + */ +class PostStream extends Component { + /** + * The number of posts to load per page. + */ + static loadCount = 20; + + /** + * The discussion to display the post stream for. + */ + discussion: Discussion; + + /** + * Whether or not the infinite-scrolling auto-load functionality is + * disabled. + */ + paused = false; + + scrollListener = new ScrollListener(this.onscroll.bind(this)); + loadPageTimeouts = {}; + pagesLoading = 0; + + calculatePositionTimeout: number; + visibleStart: number; + visibleEnd: number; + viewingEnd: boolean; + + constructor(...args) { + super(...args); + + this.discussion = this.props.discussion; + } + + oninit(vnode) { + super.oninit(vnode); + + this.discussion = this.props.discussion; + + this.show(this.props.includedPosts); + } + + /** + * Load and scroll to a post with a certain number. + * + * @param number The post number to go to. If 'reply', go to + * the last post and scroll the reply preview into view. + * @param noAnimation + */ + goToNumber(number: number | 'reply', noAnimation?: boolean): Promise { + // If we want to go to the reply preview, then we will go to the end of the + // discussion and then scroll to the very bottom of the page. + if (number === 'reply') { + return this.goToLast().then(() => { + $('html,body').animate( + { + scrollTop: $(document).height() - $(window).height(), + }, + 'fast', + () => { + this.flashItem(this.$('.PostStream-item:last-child')); + } + ); + }); + } + + this.paused = true; + + const promise = this.loadNearNumber(number); + + m.redraw(); + + return promise.then(() => { + m.redraw(); + + this.scrollToNumber(number, noAnimation).then(this.unpause.bind(this)); + }); + } + + /** + * Load and scroll to a certain index within the discussion. + * + * @param index + * @param backwards Whether or not to load backwards from the given + * index. + * @param noAnimation + */ + goToIndex(index: number, backwards?: boolean, noAnimation?: boolean): Promise { + this.paused = true; + + return this.loadNearIndex(index).then(() => { + m.redraw.sync(); + + anchorScroll(this.$('.PostStream-item:' + (backwards ? 'last' : 'first')), () => m.redraw()); + + return this.scrollToIndex(index, noAnimation, backwards).then(this.unpause.bind(this)); + }); + } + + /** + * Load and scroll up to the first post in the discussion. + */ + goToFirst(): Promise { + return this.goToIndex(0); + } + + /** + * Load and scroll down to the last post in the discussion. + */ + goToLast(): Promise { + return this.goToIndex(this.count() - 1, true); + } + + /** + * Update the stream so that it loads and includes the latest posts in the + * discussion, if the end is being viewed. + */ + update(): Promise { + if (!this.viewingEnd) return Promise.resolve(); + + this.visibleEnd = this.count(); + + return this.loadRange(this.visibleStart, this.visibleEnd).then(() => m.redraw()); + } + + /** + * Get the total number of posts in the discussion. + */ + count(): number { + return this.discussion.postIds().length; + } + + /** + * Make sure that the given index is not outside of the possible range of + * indexes in the discussion. + */ + protected sanitizeIndex(index: number) { + return Math.max(0, Math.min(this.count(), index)); + } + + /** + * Set up the stream with the given array of posts. + */ + show(posts: Post[]) { + this.visibleStart = posts.length ? this.discussion.postIds().indexOf(posts[0].id()) : 0; + this.visibleEnd = this.visibleStart + posts.length; + } + + /** + * Reset the stream so that a specific range of posts is displayed. If a range + * is not specified, the first page of posts will be displayed. + */ + reset(start?: number, end?: number) { + this.visibleStart = start || 0; + this.visibleEnd = this.sanitizeIndex(end || this.constructor.loadCount); + } + + /** + * Get the visible page of posts. + */ + posts(): Post[] { + return this.discussion + .postIds() + .slice(this.visibleStart, this.visibleEnd) + .map(id => { + const post = app.store.getById('posts', id); + + return post && post.discussion() && typeof post.canEdit() !== 'undefined' ? post : null; + }); + } + + view() { + function fadeIn(vnode) { + if (!vnode.attrs.fadedIn) + $(vnode.dom) + .hide() + .fadeIn(); + vnode.attrs.fadedIn = true; + } + + let lastTime; + + this.visibleEnd = this.sanitizeIndex(this.visibleEnd); + this.viewingEnd = this.visibleEnd === this.count(); + + const posts = this.posts(); + const postIds = this.discussion.postIds(); + + const items = posts.map((post, i) => { + let content; + const attrs = { 'data-index': this.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.oncreate = fadeIn; + 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 hours ago, we will + // display a 'time gap' indicating how long it has been in between + // the posts. + const dt = time.valueOf() - lastTime; + + if (dt > 1000 * 60 * 60 * 24 * 4) { + content = [ +
+ + {app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: dayjs(time).from(dayjs(lastTime, true)) })} + +
, + content, + ]; + } + + lastTime = time; + } else { + attrs.key = 'post' + postIds[this.visibleStart + i]; + + content = PostLoading.component(); + } + + return ( +
+ {content} +
+ ); + }); + + if (!this.viewingEnd && posts[this.visibleEnd - this.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 (this.viewingEnd && (!app.session.user || this.discussion.canReply())) { + items.push( +
+ {ReplyPlaceholder.component({ discussion: this.discussion })} +
+ ); + } + + return
{items}
; + } + + oncreate(vnode) { + super.oncreate(vnode); + + // // This is wrapped in setTimeout due to the following Mithril issue: + // // https://github.com/lhorie/mithril.js/issues/637 + // setTimeout(() => this.scrollListener.start()); + this.scrollListener.start(); + } + + onremove(vnode) { + super.onremove(vnode); + + 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: number) { + if (this.paused) return; + + const marginTop = this.getMarginTop(); + const viewportHeight = $(window).height() - marginTop; + const viewportTop = top + marginTop; + const loadAheadDistance = 300; + + if (this.visibleStart > 0) { + const $item = this.$(`.PostStream-item[data-index="${this.visibleStart}"]`); + + if ($item.length && $item.offset().top > viewportTop - loadAheadDistance) { + this.loadPrevious(); + } + } + + if (this.visibleEnd < this.count()) { + const $item = this.$(`.PostStream-item[data-index=${this.visibleEnd - 1}]`); + + if ($item.length && $item.offset().top + $item.outerHeight(true) < viewportTop + viewportHeight + loadAheadDistance) { + this.loadNext(); + } + } + + // Throttle calculation of our position (start/end numbers of posts in the + // viewport) to 100ms. + clearTimeout(this.calculatePositionTimeout); + this.calculatePositionTimeout = setTimeout(this.calculatePosition.bind(this), 100); + } + + /** + * Load the next page of posts. + */ + loadNext() { + const start = this.visibleEnd; + const end = (this.visibleEnd = this.sanitizeIndex(this.visibleEnd + this.constructor.loadCount)); + + // Unload the posts which are two pages back from the page we're currently + // loading. + const twoPagesAway = start - this.constructor.loadCount * 2; + if (twoPagesAway > this.visibleStart && twoPagesAway >= 0) { + this.visibleStart = twoPagesAway + this.constructor.loadCount + 1; + + if (this.loadPageTimeouts[twoPagesAway]) { + clearTimeout(this.loadPageTimeouts[twoPagesAway]); + this.loadPageTimeouts[twoPagesAway] = null; + this.pagesLoading--; + } + } + + this.loadPage(start, end); + } + + /** + * Load the previous page of posts. + */ + loadPrevious() { + const end = this.visibleStart; + const start = (this.visibleStart = this.sanitizeIndex(this.visibleStart - this.constructor.loadCount)); + + // Unload the posts which are two pages back from the page we're currently + // loading. + const twoPagesAway = start + this.constructor.loadCount * 2; + if (twoPagesAway < this.visibleEnd && twoPagesAway <= this.count()) { + this.visibleEnd = twoPagesAway; + + if (this.loadPageTimeouts[twoPagesAway]) { + clearTimeout(this.loadPageTimeouts[twoPagesAway]); + this.loadPageTimeouts[twoPagesAway] = null; + this.pagesLoading--; + } + } + + this.loadPage(start, end, true); + } + + /** + * Load a page of posts into the stream and redraw. + */ + loadPage(start: number, end: number, backwards?: boolean) { + const redraw = () => { + if (start < this.visibleStart || end > this.visibleEnd) return; + + const anchorIndex = backwards ? this.visibleEnd - 1 : this.visibleStart; + anchorScroll(`.PostStream-item[data-index="${anchorIndex}"]`, () => m.redraw(true)); + + this.unpause(); + }; + redraw(); + + this.loadPageTimeouts[start] = setTimeout( + () => { + this.loadRange(start, end).then(() => { + redraw(); + this.pagesLoading--; + }); + this.loadPageTimeouts[start] = null; + }, + this.pagesLoading ? 1000 : 0 + ); + + this.pagesLoading++; + } + + /** + * Load and inject the specified range of posts into the stream, without + * clearing it. + */ + loadRange(start: number, end?: number): Promise { + const loadIds = []; + const loaded = []; + + this.discussion + .postIds() + .slice(start, end) + .forEach(id => { + const post = app.store.getById('posts', id); + + if (post && post.discussion() && typeof post.canEdit() !== 'undefined') { + loaded.push(post); + } else { + loadIds.push(id); + } + }); + + return loadIds.length ? app.store.find('posts', loadIds) : Promise.resolve(loaded); + } + + /** + * 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: number): Promise { + if (this.posts().some(post => post && Number(post.number()) === Number(number))) { + return Promise.resolve(); + } + + this.reset(); + + return app.store + .find('posts', { + filter: { discussion: this.discussion.id() }, + page: { near: number }, + }) + .then(this.show.bind(this)); + } + + /** + * Clear the stream and load posts near a certain index. A page of posts + * surrounding the given index will be loaded. Returns a promise. If the given + * index is already loaded, the promise will be resolved immediately. + */ + loadNearIndex(index: number): Promise { + if (index >= this.visibleStart && index <= this.visibleEnd) { + return Promise.resolve(); + } + + const start = this.sanitizeIndex(index - this.constructor.loadCount / 2); + const end = start + this.constructor.loadCount; + + this.reset(start, end); + + return this.loadRange(start, end).then(this.show.bind(this)); + } + + /** + * Work out which posts (by number) are currently visible in the viewport, and + * fire an event with the information. + */ + calculatePosition() { + const marginTop = this.getMarginTop(); + const $window = $(window); + const viewportHeight = $window.height() - marginTop; + const scrollTop = $window.scrollTop() + marginTop; + let startNumber; + let endNumber; + + this.$('.PostStream-item').each(function() { + const $item = $(this); + const top = $item.offset().top; + const height = $item.outerHeight(true); + + if (top + height > scrollTop) { + if (!startNumber) { + startNumber = endNumber = $item.data('number'); + } + + if (top + height < scrollTop + viewportHeight) { + if ($item.data('number')) { + endNumber = $item.data('number'); + } + } else return false; + } + }); + + if (startNumber) { + this.trigger('positionChanged', startNumber || 1, endNumber); + } + } + + /** + * 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(): number { + return this.$() && $('#header').outerHeight() + parseInt(this.$().css('margin-top'), 10); + } + + /** + * Scroll down to a certain post by number and 'flash' it. + */ + scrollToNumber(number: number, noAnimation?: boolean): Promise { + const $item = this.$(`.PostStream-item[data-number="${number}"]`); + + return this.scrollToItem($item, noAnimation).then(() => this.flashItem($item)); + } + + /** + * Scroll down to a certain post by index. + * + * @param index + * @param noAnimation + * @param bottom Whether or not to scroll to the bottom of the post + * at the given index, instead of the top of it. + */ + scrollToIndex(index: number, noAnimation?: boolean, bottom?: boolean): Promise { + const $item = this.$(`.PostStream-item[data-index="${index}"]`); + + return this.scrollToItem($item, noAnimation, true, bottom); + } + + /** + * Scroll down to the given post. + * + * @param $item + * @param noAnimation + * @param force Whether or not to force scrolling to the item, even + * if it is already in the viewport. + * @param bottom Whether or not to scroll to the bottom of the post + * at the given index, instead of the top of it. + */ + scrollToItem($item, noAnimation?: boolean, force?: boolean, bottom?: boolean): Promise { + const $container = $('html, body'); + + 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; + + return new Promise(resolve => { + if (noAnimation) { + $container.scrollTop(top); + resolve(); + } else if (top !== scrollTop) { + $container.animate({ scrollTop: top }, 'fast', 'linear', () => resolve()); + } else { + resolve(); + } + }); + } + } + + return Promise.resolve(); + } + + /** + * 'Flash' the given post, drawing the user's attention to it. + * + * @param {jQuery} $item + */ + flashItem($item) { + $item.addClass('flash').one('animationend webkitAnimationEnd', () => $item.removeClass('flash')); + } + + /** + * Resume the stream's ability to auto-load posts on scroll. + */ + unpause() { + this.paused = false; + this.scrollListener.update(); + this.trigger('unpaused'); + } +} + +Object.assign(PostStream.prototype, Evented.prototype); + +export default PostStream; diff --git a/js/src/forum/components/ReplyPlaceholder.tsx b/js/src/forum/components/ReplyPlaceholder.tsx new file mode 100644 index 000000000..f3dba53eb --- /dev/null +++ b/js/src/forum/components/ReplyPlaceholder.tsx @@ -0,0 +1,67 @@ +import Component from '../../common/Component'; +import avatar from '../../common/helpers/avatar'; +import username from '../../common/helpers/username'; +import DiscussionControls from '../utils/DiscussionControls'; +import { DiscussionProp } from '../../common/concerns/ComponentProps'; + +/** + * The `ReplyPlaceholder` component displays a placeholder for a reply, which, + * when clicked, opens the reply composer. + */ +export default class ReplyPlaceholder extends Component { + view() { + // TODO: add method & remove `false &&` + if (false && app.composingReplyTo(this.props.discussion)) { + return ( +
+
+
+

+ {avatar(app.session.user, { className: 'PostUser-avatar' })} + {username(app.session.user)} +

+
+
+
+
+ ); + } + + const reply = () => DiscussionControls.replyAction.call(this.props.discussion, true); + + return ( +
+
+ {avatar(app.session.user, { className: 'PostUser-avatar' })} {app.translator.trans('core.forum.post_stream.reply_placeholder')} +
+
+ ); + } + + oncreatePreview(vnode) { + // Every 50ms, if the composer content has changed, then update the post's + // body with a preview. + let preview; + const updateInterval = setInterval(() => { + // Since we're polling, the composer may have been closed in the meantime, + // so we bail in that case. + if (!app.composer.component) return; + + const content = app.composer.component.content(); + + if (preview === content) return; + + preview = content; + + const anchorToBottom = $(window).scrollTop() + $(window).height() >= $(document).height(); + + s9e.TextFormatter.preview(preview || '', vnode.dom); + + if (anchorToBottom) { + $(window).scrollTop($(document).height()); + } + }, 50); + + vnode.attrs.onunload = () => clearInterval(updateInterval); + } +}