forum: add DiscussionPage with hero, loading post, post preview, post stream, reply placeholder

No post stream scrubber yet. Composer hasn't been added either, so many calls return errors because of app.composer not being set.
This commit is contained in:
David Sevilla Martin 2020-02-07 09:30:30 -05:00
parent 4368dfcc6c
commit 7485559cbf
No known key found for this signature in database
GPG Key ID: F764F1417E16B15F
7 changed files with 1041 additions and 2 deletions

View File

@ -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;

View File

@ -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<T extends DiscussionProp = DiscussionProp> extends Component<T> {
view() {
return (
<header className="Hero DiscussionHero">
<div className="container">
<ul className="DiscussionHero-items">{listItems(this.items().toArray())}</ul>
</div>
</header>
);
}
/**
* 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', <ul className="DiscussionHero-badges badges">{listItems(badges)}</ul>, 10);
}
items.add('title', <h2 className="DiscussionHero-title">{discussion.title()}</h2>);
return items;
}
}

View File

@ -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 (
<div className="DiscussionPage">
{app.cache.discussionList ? (
<div className="DiscussionPage-list" oncreate={this.oncreatePane.bind(this)} onbeforeupdate={() => false}>
{!$('.App-navigation').is(':visible') ? app.cache.discussionList.render() : ''}
</div>
) : (
''
)}
<div className="DiscussionPage-discussion">
{discussion
? [
DiscussionHero.component({ discussion }),
<div className="container">
<nav className="DiscussionPage-nav">
<ul>{listItems(this.sidebarItems().toArray())}</ul>
</nav>
<div className="DiscussionPage-stream">{this.stream.render()}</div>
</div>,
]
: LoadingIndicator.component({ className: 'LoadingIndicator--block' })}
</div>
</div>
);
}
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();
}
}
}

View File

@ -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 (
<div className="Post CommentPost LoadingPost">
<header className="Post-header">
{avatar(null, { className: 'PostUser-avatar' })}
<div className="fakeText" />
</header>
<div className="Post-body">
<div className="fakeText" />
<div className="fakeText" />
<div className="fakeText" />
</div>
</div>
);
}
}

View File

@ -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<T extends PostProp = PostProp> extends Component<T> {
view() {
const post = this.props.post;
const user = post.user();
const excerpt = highlight(post.contentPlain(), this.props.highlight, 300);
return (
<LinkButton className="PostPreview" href={app.route.post(post)} onclick={this.props.onclick}>
<span className="PostPreview-content">
{avatar(user)}
{username(user)} <span className="PostPreview-excerpt">{excerpt}</span>
</span>
</LinkButton>
);
}
}

View File

@ -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<T extends PostStreamProps = PostStreamProps> extends Component<T>, 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<T extends PostStreamProps = PostStreamProps> extends Component<T> {
/**
* 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<void> {
// 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<void> {
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<void> {
return this.goToIndex(0);
}
/**
* Load and scroll down to the last post in the discussion.
*/
goToLast(): Promise<void> {
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<void> {
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<Post>('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 = [
<div className="PostStream-timeGap">
<span>
{app.translator.trans('core.forum.post_stream.time_lapsed_text', { period: dayjs(time).from(dayjs(lastTime, true)) })}
</span>
</div>,
content,
];
}
lastTime = time;
} else {
attrs.key = 'post' + postIds[this.visibleStart + i];
content = PostLoading.component();
}
return (
<div className="PostStream-item" {...attrs}>
{content}
</div>
);
});
if (!this.viewingEnd && posts[this.visibleEnd - this.visibleStart - 1]) {
items.push(
<div className="PostStream-loadMore" key="loadMore">
<Button className="Button" onclick={this.loadNext.bind(this)}>
{app.translator.trans('core.forum.post_stream.load_more_button')}
</Button>
</div>
);
}
// 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(
<div className="PostStream-item" key="reply">
{ReplyPlaceholder.component({ discussion: this.discussion })}
</div>
);
}
return <div className="PostStream">{items}</div>;
}
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<void> {
const loadIds = [];
const loaded = [];
this.discussion
.postIds()
.slice(start, end)
.forEach(id => {
const post = app.store.getById<Post>('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<void> {
if (this.posts().some(post => post && Number(post.number()) === Number(number))) {
return Promise.resolve();
}
this.reset();
return app.store
.find<Post>('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<void> {
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<void> {
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<void> {
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<void>(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;

View File

@ -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<T extends DiscussionProp = DiscussionProp> extends Component<T> {
view() {
// TODO: add method & remove `false &&`
if (false && app.composingReplyTo(this.props.discussion)) {
return (
<article className="Post CommentPost editing">
<header className="Post-header">
<div className="PostUser">
<h3>
{avatar(app.session.user, { className: 'PostUser-avatar' })}
{username(app.session.user)}
</h3>
</div>
</header>
<div className="Post-body" oncreate={this.oncreatePreview.bind(this)} />
</article>
);
}
const reply = () => DiscussionControls.replyAction.call(this.props.discussion, true);
return (
<article className="Post ReplyPlaceholder" onclick={reply}>
<header className="Post-header">
{avatar(app.session.user, { className: 'PostUser-avatar' })} {app.translator.trans('core.forum.post_stream.reply_placeholder')}
</header>
</article>
);
}
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);
}
}