forum: add DiscussionList component with DiscussionListItem & TerminalPost

This commit is contained in:
David Sevilla Martin 2020-02-07 09:32:38 -05:00
parent 7485559cbf
commit 21d19df9bd
No known key found for this signature in database
GPG Key ID: F764F1417E16B15F
6 changed files with 681 additions and 1 deletions

View File

@ -20,7 +20,7 @@ export default class Discussion extends Model {
lastPostNumber = Model.attribute('lastPostNumber') as () => number;
commentCount = Model.attribute('commentCount') as () => number;
replyCount = computed('commentCount', commentCount => Math.max(0, commentCount - 1)) as () => string;
replyCount = computed('commentCount', commentCount => Math.max(0, commentCount - 1)) as () => number;
posts = Model.hasMany('posts') as () => Post[];
mostRelevantPost = Model.hasOne('mostRelevantPost') as () => Post;

View File

@ -6,10 +6,13 @@ import HeaderSecondary from './components/HeaderSecondary';
import Page from './components/Page';
import IndexPage from './components/IndexPage';
import DiscussionList from './components/DiscussionList';
import DiscussionPage from './components/DiscussionPage';
import PostsUserPage from './components/PostsUserPage';
import SettingsPage from './components/SettingsPage';
import CommentPost from './components/CommentPost';
import User from '../common/models/User';
import Post from '../common/models/Post';
import Discussion from '../common/models/Discussion';

View File

@ -0,0 +1,194 @@
import Component, { ComponentProps } from '../../common/Component';
import DiscussionListItem from './DiscussionListItem';
import Button from '../../common/components/Button';
import LoadingIndicator from '../../common/components/LoadingIndicator';
import Placeholder from '../../common/components/Placeholder';
import Discussion from '../../common/models/Discussion';
export interface DiscussionListProps extends ComponentProps {
/**
* A map of parameters used to construct a refined parameter object
* to send along in the API request to get discussion results.
*/
params: any;
}
/**
* The `DiscussionList` component displays a list of discussions.
*/
export default class DiscussionList<T extends DiscussionListProps = DiscussionListProps> extends Component<T> {
/**
* Whether or not discussion results are loading.
*/
loading = true;
/**
* Whether or not there are more results that can be loaded.
*/
moreResults = false;
/**
* The discussions in the discussion list.
*/
discussions: Discussion[] = [];
oninit(vnode) {
super.oninit(vnode);
this.refresh();
}
view() {
const params = this.props.params;
let loading;
if (this.loading) {
loading = LoadingIndicator.component();
} else if (this.moreResults) {
loading = Button.component({
children: app.translator.trans('core.forum.discussion_list.load_more_button'),
className: 'Button',
onclick: this.loadMore.bind(this),
});
}
if (this.discussions.length === 0 && !this.loading) {
const text = app.translator.trans('core.forum.discussion_list.empty_text');
return <div className="DiscussionList">{Placeholder.component({ text })}</div>;
}
return (
<div className={'DiscussionList' + (this.props.params.q ? ' DiscussionList--searchResults' : '')}>
<ul className="DiscussionList-discussions">
{this.discussions.map(discussion => {
return (
<li key={discussion.id()} data-id={discussion.id()}>
{DiscussionListItem.component({ discussion, params })}
</li>
);
})}
</ul>
<div className="DiscussionList-loadMore">{loading}</div>
</div>
);
}
/**
* Get the parameters that should be passed in the API request to get
* discussion results.
*
* @api
*/
requestParams(): any {
const params = { include: ['user', 'lastPostedUser'], filter: {} };
params.sort = this.sortMap()[this.props.params.sort];
if (this.props.params.q) {
params.filter.q = this.props.params.q;
params.include.push('mostRelevantPost', 'mostRelevantPost.user');
}
return params;
}
/**
* Get a map of sort keys (which appear in the URL, and are used for
* translation) to the API sort value that they represent.
*/
sortMap() {
const map: any = {};
if (this.props.params.q) {
map.relevance = '';
}
map.latest = '-lastPostedAt';
map.top = '-commentCount';
map.newest = '-createdAt';
map.oldest = 'createdAt';
return map;
}
/**
* Clear and reload the discussion list.
*/
public refresh(clear = true) {
if (clear) {
this.loading = true;
this.discussions = [];
}
return this.loadResults().then(
results => {
this.discussions = [];
this.parseResults(results);
},
() => {
this.loading = false;
m.redraw();
}
);
}
/**
* Load a new page of discussion results.
*
* @param offset The index to start the page at.
*/
loadResults(offset?: number): Promise<Discussion[]> {
const preloadedDiscussions = app.preloadedApiDocument();
if (preloadedDiscussions) {
return Promise.resolve(preloadedDiscussions);
}
const params = this.requestParams();
params.page = { offset };
params.include = params.include.join(',');
return app.store.find('discussions', params);
}
/**
* Load the next page of discussion results.
*/
public loadMore() {
this.loading = true;
this.loadResults(this.discussions.length).then(this.parseResults.bind(this));
}
/**
* Parse results and append them to the discussion list.
*/
parseResults(results: Discussion[]): Discussion[] {
[].push.apply(this.discussions, results);
this.loading = false;
this.moreResults = !!results.payload.links.next;
m.redraw();
return results;
}
/**
* Remove a discussion from the list if it is present.
*/
public removeDiscussion(discussion: Discussion) {
const index = this.discussions.indexOf(discussion);
if (index !== -1) {
this.discussions.splice(index, 1);
}
}
/**
* Add a discussion to the top of the list.
*/
public addDiscussion(discussion: Discussion) {
this.discussions.unshift(discussion);
}
}

View File

@ -0,0 +1,207 @@
import Component from '../../common/Component';
import avatar from '../../common/helpers/avatar';
import listItems from '../../common/helpers/listItems';
import highlight from '../../common/helpers/highlight';
import icon from '../../common/helpers/icon';
import humanTime from '../../common/utils/humanTime';
import ItemList from '../../common/utils/ItemList';
import Dropdown from '../../common/components/Dropdown';
import SubtreeRetainer from '../../common/utils/SubtreeRetainer';
import LinkButton from '../../common/components/LinkButton';
import abbreviateNumber from '../../common/utils/abbreviateNumber';
import TerminalPost from './TerminalPost';
import DiscussionControls from '../utils/DiscussionControls';
import { DiscussionProp } from '../../common/concerns/ComponentProps';
export interface DiscussionListItemProps extends DiscussionProp {
params: any;
}
/**
* The `DiscussionListItem` component shows a single discussion in the
* discussion list.
*/
export default class DiscussionListItem<T extends DiscussionListItemProps = DiscussionListItemProps> extends Component<T> {
/**
* Set up a subtree retainer so that the discussion will not be redrawn
* unless new data comes in.
*/
subtree: SubtreeRetainer;
highlightRegExp?: RegExp;
oninit(vnode) {
super.oninit(vnode);
this.subtree = new SubtreeRetainer(
() => this.props.discussion.freshness,
() => {
const time = app.session.user && app.session.user.markedAllAsReadAt();
return time && time.getTime();
},
() => this.active()
);
}
attrs() {
return {
className: classNames('DiscussionListItem', this.active() && 'active', this.props.discussion.isHidden() && 'DiscussionListItem--hidden'),
};
}
view() {
const discussion = this.props.discussion;
const user = discussion.user();
const isUnread = discussion.isUnread();
const isRead = discussion.isRead();
const showUnread = !this.showRepliesCount() && isUnread;
let jumpTo = 0;
const controls = DiscussionControls.controls(discussion, this).toArray();
const attrs = this.attrs();
if (this.props.params.q) {
const post = discussion.mostRelevantPost();
if (post) {
jumpTo = post.number();
}
const phrase = this.props.params.q;
this.highlightRegExp = new RegExp(phrase + '|' + phrase.trim().replace(/\s+/g, '|'), 'gi');
} else {
jumpTo = Math.min(discussion.lastPostNumber(), (discussion.lastReadPostNumber() || 0) + 1);
}
return (
<div {...attrs}>
{controls.length
? Dropdown.component({
icon: 'fas fa-ellipsis-v',
children: controls,
className: 'DiscussionListItem-controls',
buttonClassName: 'Button Button--icon Button--flat Slidable-underneath Slidable-underneath--right',
})
: ''}
<a
className={'Slidable-underneath Slidable-underneath--left Slidable-underneath--elastic' + (isUnread ? '' : ' disabled')}
onclick={this.markAsRead.bind(this)}
>
{icon('fas fa-check')}
</a>
<div className={classNames('DiscussionListItem-content', 'Slidable-content', isUnread && 'unread', isRead && 'read')}>
<LinkButton
href={user ? app.route.user(user) : '#'}
className="DiscussionListItem-author"
title={app.translator.transText('core.forum.discussion_list.started_text', {
user: user,
ago: humanTime(discussion.createdAt()),
})}
oncreate={vnode => $(vnode.dom).tooltip({ placement: 'right' })}
>
{avatar(user, { title: '' })}
</LinkButton>
<ul className="DiscussionListItem-badges badges">{listItems(discussion.badges().toArray())}</ul>
<LinkButton href={app.route.discussion(discussion, jumpTo)} className="DiscussionListItem-main">
<h3 className="DiscussionListItem-title">{highlight(discussion.title(), this.highlightRegExp)}</h3>
<ul className="DiscussionListItem-info">{listItems(this.infoItems().toArray())}</ul>
</LinkButton>
<span
className="DiscussionListItem-count"
onclick={this.markAsRead.bind(this)}
title={showUnread ? app.translator.trans('core.forum.discussion_list.mark_as_read_tooltip') : ''}
>
{abbreviateNumber(discussion[showUnread ? 'unreadCount' : 'replyCount']())}
</span>
</div>
</div>
);
}
oncreate(vnode) {
super.oncreate(vnode);
// If we're on a touch device, set up the discussion row to be slidable.
// This allows the user to drag the row to either side of the screen to
// reveal controls.
if ('ontouchstart' in window) {
const slidableInstance = slidable(this.$().addClass('Slidable'));
this.$('.DiscussionListItem-controls').on('hidden.bs.dropdown', () => slidableInstance.reset());
}
}
onbeforeupdate(vnode) {
super.onbeforeupdate(vnode);
return this.subtree.update();
}
/**
* Determine whether or not the discussion is currently being viewed.
*/
active(): boolean {
const idParam = m.route.param('id');
return idParam && idParam.split('-')[0] === this.props.discussion.id();
}
/**
* Determine whether or not information about who started the discussion
* should be displayed instead of information about the most recent reply to
* the discussion.
*/
showFirstPost(): boolean {
return ['newest', 'oldest'].indexOf(this.props.params.sort) !== -1;
}
/**
* Determine whether or not the number of replies should be shown instead of
* the number of unread posts.
*/
showRepliesCount(): boolean {
return this.props.params.sort === 'replies';
}
/**
* Mark the discussion as read.
*/
markAsRead() {
const discussion = this.props.discussion;
if (discussion.isUnread()) {
discussion.save({ lastReadPostNumber: discussion.lastPostNumber() });
m.redraw();
}
}
/**
* Build an item list of info for a discussion listing. By default this is
* just the first/last post indicator.
*/
infoItems(): ItemList {
const items = new ItemList();
if (this.props.params.q) {
const post = this.props.discussion.mostRelevantPost() || this.props.discussion.firstPost();
if (post && post.contentType() === 'comment') {
const excerpt = highlight(post.contentPlain(), this.highlightRegExp, 175);
items.add('excerpt', excerpt, -100);
}
} else {
items.add(
'terminalPost',
TerminalPost.component({
discussion: this.props.discussion,
lastPost: !this.showFirstPost(),
})
);
}
return items;
}
}

View File

@ -0,0 +1,32 @@
import Component from '../../common/Component';
import humanTime from '../../common/helpers/humanTime';
import icon from '../../common/helpers/icon';
import Post from '../../common/models/Post';
import { DiscussionProp } from '../../common/concerns/ComponentProps';
export interface TerminalPostProps extends DiscussionProp {
lastPost: Post;
}
/**
* Displays information about a the first or last post in a discussion.
*/
export default class TerminalPost<T extends TerminalPostProps = TerminalPostProps> extends Component<T> {
view() {
const discussion = this.props.discussion;
const lastPost = this.props.lastPost && discussion.replyCount();
const user = discussion[lastPost ? 'lastPostedUser' : 'user']();
const time = discussion[lastPost ? 'lastPostedAt' : 'createdAt']();
return (
<span>
{lastPost ? icon('fas fa-reply') : ''}{' '}
{app.translator.trans(`core.forum.discussion_list.${lastPost ? 'replied' : 'started'}_text`, {
user,
ago: humanTime(time),
})}
</span>
);
}
}

View File

@ -0,0 +1,244 @@
import DiscussionPage from '../components/DiscussionPage';
// import ReplyComposer from '../components/ReplyComposer';
import LogInModal from '../components/LogInModal';
import Button from '../../common/components/Button';
import Separator from '../../common/components/Separator';
// import RenameDiscussionModal from '../components/RenameDiscussionModal';
import ItemList from '../../common/utils/ItemList';
import extractText from '../../common/utils/extractText';
import Discussion from '../../common/models/Discussion';
/**
* The `DiscussionControls` utility constructs a list of buttons for a
* discussion which perform actions on it.
*/
export default {
/**
* Get a list of controls for a discussion.
*
* @param discussion
* @param context The parent component under which the controls menu will
* be displayed
* @public
*/
controls(discussion: Discussion, context): ItemList {
const items = new ItemList();
['user', 'moderation', 'destructive'].forEach(section => {
const controls = this[section](discussion, context).toArray();
if (controls.length) {
controls.forEach(item => items.add(item.itemName, item));
items.add(section + 'Separator', Separator.component());
}
});
return items;
},
/**
* Get controls for a discussion pertaining to the current user (e.g. reply,
* follow).
*
* @param discussion
* @param context The parent component under which the controls menu will
* be displayed.
* @protected
*/
user(discussion: Discussion, context: any): ItemList {
const items = new ItemList();
// Only add a reply control if this is the discussion's controls dropdown
// for the discussion page itself. We don't want it to show up for
// discussions in the discussion list, etc.
if (context instanceof DiscussionPage) {
items.add(
'reply',
!app.session.user || discussion.canReply()
? Button.component({
icon: 'fas fa-reply',
children: app.translator.trans(
app.session.user
? 'core.forum.discussion_controls.reply_button'
: 'core.forum.discussion_controls.log_in_to_reply_button'
),
onclick: this.replyAction.bind(discussion, true, false),
})
: Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.discussion_controls.cannot_reply_button'),
className: 'disabled',
title: app.translator.trans('core.forum.discussion_controls.cannot_reply_text'),
})
);
}
return items;
},
/**
* Get controls for a discussion pertaining to moderation (e.g. rename, lock).
*
* @param discussion
* @param context The parent component under which the controls menu will
* be displayed.
* @protected
*/
moderation(discussion): ItemList {
const items = new ItemList();
if (discussion.canRename()) {
items.add(
'rename',
Button.component({
icon: 'fas fa-pencil-alt',
children: app.translator.trans('core.forum.discussion_controls.rename_button'),
onclick: this.renameAction.bind(discussion),
})
);
}
return items;
},
/**
* Get controls for a discussion which are destructive (e.g. delete).
*
* @param discussion
* @param context The parent component under which the controls menu will
* be displayed.
* @protected
*/
destructive(discussion: Discussion): ItemList {
const items = new ItemList();
if (!discussion.isHidden()) {
if (discussion.canHide()) {
items.add(
'hide',
Button.component({
icon: 'far fa-trash-alt',
children: app.translator.trans('core.forum.discussion_controls.delete_button'),
onclick: this.hideAction.bind(discussion),
})
);
}
} else {
if (discussion.canHide()) {
items.add(
'restore',
Button.component({
icon: 'fas fa-reply',
children: app.translator.trans('core.forum.discussion_controls.restore_button'),
onclick: this.restoreAction.bind(discussion),
})
);
}
if (discussion.canDelete()) {
items.add(
'delete',
Button.component({
icon: 'fas fa-times',
children: app.translator.trans('core.forum.discussion_controls.delete_forever_button'),
onclick: this.deleteAction.bind(discussion),
})
);
}
}
return items;
},
/**
* Open the reply composer for the discussion. A promise will be returned,
* which resolves when the composer opens successfully. If the user is not
* logged in, they will be prompted. If they don't have permission to
* reply, the promise will be rejected.
*
* @param goToLast Whether or not to scroll down to the last post if
* the discussion is being viewed.
* @param forceRefresh Whether or not to force a reload of the
* composer component, even if it is already open for this discussion.
*/
replyAction(this: Discussion, goToLast: boolean, forceRefresh: boolean): Promise<any> {
return new Promise((resolve, reject) => {
if (app.session.user) {
if (this.canReply()) {
let component = app.composer.component;
if (!app.composingReplyTo(this) || forceRefresh) {
component = new ReplyComposer({
user: app.session.user,
discussion: this,
});
app.composer.load(component);
}
app.composer.show();
if (goToLast && app.viewingDiscussion(this) && !app.composer.isFullScreen()) {
app.current.stream.goToNumber('reply');
}
return resolve(component);
} else {
return reject();
}
}
app.modal.show(new LogInModal());
reject();
});
},
/**
* Hide a discussion.
*/
hideAction(this: Discussion) {
this.pushAttributes({ hiddenAt: new Date(), hiddenUser: app.session.user });
return this.save({ isHidden: true });
},
/**
* Restore a discussion.
*/
restoreAction(this: Discussion) {
this.pushAttributes({ hiddenAt: null, hiddenUser: null });
return this.save({ isHidden: false });
},
/**
* Delete the discussion after confirming with the user.
*/
deleteAction(this: Discussion) {
if (confirm(extractText(app.translator.trans('core.forum.discussion_controls.delete_confirmation')))) {
// If we're currently viewing the discussion that was deleted, go back
// to the previous page.
if (app.viewingDiscussion(this)) {
app.history.back();
}
return this.delete().then(() => {
// If there is a discussion list in the cache, remove this discussion.
if (app.cache.discussionList) {
app.cache.discussionList.removeDiscussion(this);
m.redraw();
}
});
}
},
/**
* Rename the discussion.
*/
renameAction(this: Discussion) {
return app.modal.show(
new RenameDiscussionModal({
currentTitle: this.title(),
discussion: this,
})
);
},
};