diff --git a/js/src/common/models/Discussion.tsx b/js/src/common/models/Discussion.tsx index 248fe736c..c5a5b9550 100644 --- a/js/src/common/models/Discussion.tsx +++ b/js/src/common/models/Discussion.tsx @@ -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; diff --git a/js/src/forum/Forum.ts b/js/src/forum/Forum.ts index cfe31640f..7c899c15f 100644 --- a/js/src/forum/Forum.ts +++ b/js/src/forum/Forum.ts @@ -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'; diff --git a/js/src/forum/components/DiscussionList.tsx b/js/src/forum/components/DiscussionList.tsx new file mode 100644 index 000000000..cea06379a --- /dev/null +++ b/js/src/forum/components/DiscussionList.tsx @@ -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 extends Component { + /** + * 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
{Placeholder.component({ text })}
; + } + + return ( +
+
    + {this.discussions.map(discussion => { + return ( +
  • + {DiscussionListItem.component({ discussion, params })} +
  • + ); + })} +
+
{loading}
+
+ ); + } + + /** + * 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 { + 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); + } +} diff --git a/js/src/forum/components/DiscussionListItem.tsx b/js/src/forum/components/DiscussionListItem.tsx new file mode 100644 index 000000000..cc358b140 --- /dev/null +++ b/js/src/forum/components/DiscussionListItem.tsx @@ -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 extends Component { + /** + * 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 ( +
+ {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', + }) + : ''} + + + {icon('fas fa-check')} + + +
+ $(vnode.dom).tooltip({ placement: 'right' })} + > + {avatar(user, { title: '' })} + + +
    {listItems(discussion.badges().toArray())}
+ + +

{highlight(discussion.title(), this.highlightRegExp)}

+
    {listItems(this.infoItems().toArray())}
+
+ + + {abbreviateNumber(discussion[showUnread ? 'unreadCount' : 'replyCount']())} + +
+
+ ); + } + + 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; + } +} diff --git a/js/src/forum/components/TerminalPost.tsx b/js/src/forum/components/TerminalPost.tsx new file mode 100644 index 000000000..aeb0f9b5b --- /dev/null +++ b/js/src/forum/components/TerminalPost.tsx @@ -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 extends Component { + 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 ( + + {lastPost ? icon('fas fa-reply') : ''}{' '} + {app.translator.trans(`core.forum.discussion_list.${lastPost ? 'replied' : 'started'}_text`, { + user, + ago: humanTime(time), + })} + + ); + } +} diff --git a/js/src/forum/utils/DiscussionControls.tsx b/js/src/forum/utils/DiscussionControls.tsx new file mode 100644 index 000000000..372840be4 --- /dev/null +++ b/js/src/forum/utils/DiscussionControls.tsx @@ -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 { + 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, + }) + ); + }, +};