static/src/javascripts/projects/common/modules/discussion/comments.js (511 lines of code) (raw):

import $ from 'lib/$'; import bean from 'bean'; import bonzo from 'bonzo'; import qwery from 'qwery'; import config from 'lib/config'; import { fetchJson } from 'lib/fetch-json'; import { mediator } from 'lib/mediator'; import { reportError } from 'lib/report-error'; import { constructQuery } from 'lib/url'; import { Component } from 'common/modules/component'; import { reportComment, pickComment, unPickComment, } from 'common/modules/discussion/api'; import { CommentBox } from 'common/modules/discussion/comment-box'; import { WholeDiscussion } from 'common/modules/discussion/whole-discussion'; import { init as initRelativeDates } from 'common/modules/ui/relativedates'; import userPrefs from 'common/modules/user-prefs'; import { inlineSvg } from 'common/views/svgs'; const PREF_RELATIVE_TIMESTAMPS = 'discussion.enableRelativeTimestamps'; const shouldMakeTimestampsRelative = () => userPrefs.get(PREF_RELATIVE_TIMESTAMPS) !== null ? userPrefs.get(PREF_RELATIVE_TIMESTAMPS) : true; class Comments extends Component { constructor(options) { super(); this.componentClass = 'd-comments'; this.comments = null; this.topLevelComments = null; this.user = null; this.classes = { comments: 'd-thread--top-level', topLevelComment: 'd-comment--top-level', reply: 'd-comment--response', showReplies: 'd-show-more-replies', showRepliesButton: 'd-show-more-replies__button', newComments: 'js-new-comments', comment: 'd-comment', commentReply: 'd-comment__action--reply', commentPick: 'd-comment__action--pick', commentStaff: 'd-comment--staff', commentBody: 'd-comment__body', commentTimestampJs: 'js-timestamp', commentReport: 'js-report-comment', }; this.defaultOptions = { discussionId: null, showRepliesCount: 3, commentId: null, order: 'newest', threading: 'collapsed', }; this.setOptions(options); } getMoreReplies(event) { event.preventDefault(); const target = (event.target); const currentTarget = (event.currentTarget); const li = currentTarget.closest(this.getClass('showReplies')); if (li) { li.innerHTML = 'Loading…'; } const source = bonzo(target).data('source-comment'); const commentId = currentTarget.getAttribute('data-comment-id'); if (commentId) { const endpoint = `/discussion/comment/${commentId}.json?displayThreaded=true`; fetchJson(endpoint, { mode: 'cors', }) .then(resp => { const comment = bonzo.create(resp.html); const replies = qwery( this.getClass('reply'), comment ).slice(this.options && this.options.showRepliesCount); bonzo(qwery('.d-thread--responses', source)).append( replies ); bonzo(li).addClass('u-h'); this.emit('untruncate-thread'); if (shouldMakeTimestampsRelative()) { this.relativeDates(); } }) .catch(ex => { reportError(ex, { feature: 'comments-more-replies', }); }); } } addMoreRepliesButtons(comms) { const comments = comms || this.topLevelComments; comments.forEach(elem => { const replies = parseInt( elem.getAttribute('data-comment-replies'), 10 ); const renderedReplies = qwery(this.getClass('reply'), elem); if (renderedReplies.length < replies) { const numHiddenReplies = replies - renderedReplies.length; const plusIcon = inlineSvg('plus', ['icon']); const repliesStr = numHiddenReplies === 1 ? 'reply' : 'replies'; const btnMarkup = ` <button class="u-button-reset button button--show-more button--small button--tone-news d-show-more-replies__button"> ${plusIcon}Show ${numHiddenReplies} more ${repliesStr} </button>`; const $btn = $.create(btnMarkup) .attr({ 'data-link-name': 'Show more replies', 'data-is-ajax': '', 'data-comment-id': elem.getAttribute('data-comment-id'), }) .data('source-comment', elem); $.create( `<li class="${this.getClass('showReplies', true)}"></li>` ) .append($btn) .appendTo($('.d-thread--responses', elem)); } }); } fetchComments(options = {}) { const { discussionId } = this.options || {}; const url = `/discussion/${ options.comment ? `comment-context/${options.comment}` : discussionId }.json`; let orderBy = options.order || (this.options && this.options.order); let promise; const ajaxParams = { mode: 'cors' }; if (orderBy === 'recommendations') { orderBy = 'mostRecommended'; } const queryParams = { orderBy, pageSize: options.pagesize || (this.options && this.options.pagesize), displayThreaded: !!( this.options && this.options.threading !== 'unthreaded' ), commentsClosed: options.commentsClosed, }; if (options.page) { queryParams.page = options.page; } if ( !options.comment && this.options && this.options.threading === 'collapsed' ) { queryParams.maxResponses = 3; } if (this.isAllPageSizeActive()) { promise = new WholeDiscussion({ discussionId: this.options && this.options.discussionId, orderBy: queryParams.orderBy, displayThreaded: queryParams.displayThreaded, maxResponses: queryParams.maxResponses, commentsClosed: queryParams.commentsClosed, }) .loadAllComments() .catch(() => { this.wholeDiscussionErrors = true; queryParams.pageSize = 100; return fetchJson( `${url}?${constructQuery(queryParams)}`, ajaxParams ); }); } else { // It is possible that the user has chosen to view all comments, // but the WholeDiscussion module has failed. Fall back to 100 comments. if (queryParams.pageSize === 'All') { queryParams.pageSize = 100; } promise = fetchJson( `${url}?${constructQuery(queryParams)}`, ajaxParams ); } return promise.then(resp => this.renderComments(resp)); } handlePickClick(e) { e.preventDefault(); const target = (e.target); const commentId = target.getAttribute('data-comment-id'); const $thisButton = $(target); const highlighted = $thisButton[0].getAttribute( 'data-comment-highlighted' ); const action = highlighted === 'true' ? this.unPickComment : this.pickComment; if (commentId) { action.call(this, commentId, $thisButton).catch(resp => { const responseText = resp.response.length > 0 ? JSON.parse(resp.response).message : resp.statusText; $thisButton.text(responseText); }); } } pickComment(commentId, $thisButton) { const comment = qwery(`#comment-${commentId}`, this.elem); return pickComment(commentId).then(() => { $(this.getClass('commentPick'), comment).removeClass('u-h'); $thisButton.text('Unpick'); comment.setAttribute('data-comment-highlighted', true); }); } ready() { this.topLevelComments = qwery( this.getClass('topLevelComment'), this.elem ); this.comments = qwery(this.getClass('comment'), this.elem); this.on('click', this.getClass('showRepliesButton'), (event) => this.getMoreReplies(event) ); this.on('click', this.getClass('commentReport'), (event) => this.reportComment(event) ); if (shouldMakeTimestampsRelative()) { window.setInterval(() => this.relativeDates(), 60000); this.relativeDates(); } this.emit('ready'); this.on('click', '.js-report-comment-close', () => { const commentForm = document.querySelector( '.js-report-comment-form' ); if (commentForm) { commentForm.setAttribute('hidden', ''); } }); } showHiddenComments(e) { if (e) { e.preventDefault(); } this.emit('first-load'); $('.js-discussion-main-comments').css('display', 'block'); if (shouldMakeTimestampsRelative()) { this.relativeDates(); } } unPickComment(commentId, $thisButton) { const comment = qwery(`#comment-${commentId}`); return unPickComment(commentId).then(() => { $(this.getClass('commentPick'), comment).addClass('u-h'); $thisButton.text('Pick'); comment.setAttribute('data-comment-highlighted', false); }); } isReadOnly() { return !!( this.elem && this.elem instanceof HTMLElement && this.elem.getAttribute('data-read-only') === 'true' ); } addComment(comment, parent) { const commentElem = bonzo.create(this.postedCommentEl)[0]; const $commentElem = bonzo(commentElem); const replyButton = commentElem && commentElem.getElementsByClassName( this.getClass('commentReply', true) )[0]; const map = { username: 'd-comment__author', timestamp: 'js-timestamp', body: 'd-comment__body', report: 'd-comment__action--report', avatar: 'd-comment__avatar', }; const vals = { username: this.user && this.user.displayName, timestamp: 'Just now', body: `<p>${comment.body.replace(/\n+/g, '</p><p>')}</p>`, report: { href: `http://discussion.theguardian.com/components/report-abuse/${ comment.id }`, }, avatar: { src: this.user && this.user.avatar, }, }; $commentElem.addClass('d-comment--new'); Object.keys(map).forEach(key => { const selector = map[key]; const val = vals[key]; const elem = qwery(`.${selector}`, commentElem)[0]; if (typeof val === 'string') { elem.innerHTML = val; } else if (typeof val === 'object') { Object.keys(val).forEach(attr => { elem.setAttribute(attr, val[attr]); }); } }); commentElem.id = `comment-${comment.id}`; if (this.user && !this.user.isStaff) { $commentElem.addClass(this.getClass('commentStaff', true)); } if (replyButton) { replyButton.setAttribute('data-comment-id', comment.id); } if (!parent) { $(this.getClass('newComments'), this.elem).prepend(commentElem); } else { $commentElem.removeClass(this.getClass('topLevelComment', true)); $commentElem.addClass(this.getClass('reply', true)); bonzo(parent).append($commentElem); } window.location.replace(`#comment-${comment.id}`); } replyToComment(e) { e.preventDefault(); const replyLink = (e.currentTarget); const replyToId = replyLink.getAttribute('data-comment-id'); if (!replyToId) { return; } const replyTo = document.getElementById(`reply-to-${replyToId}`); // There is already a comment box for this on the page if (replyTo) { replyTo.focus(); return; } $('.d-comment-box--response').remove(); const replyToComment = qwery(`#comment-${replyToId}`)[0]; const replyToAuthor = replyToComment.getAttribute( 'data-comment-author' ); const replyToAuthorId = replyToComment.getAttribute( 'data-comment-author-id' ); const $replyToComment = bonzo(replyToComment); const replyToBody = qwery( this.getClass('commentBody'), replyToComment )[0].innerHTML; const replyToTimestamp = qwery( this.getClass('commentTimestampJs'), replyToComment )[0].innerHTML; const commentBox = new CommentBox({ discussionId: this.options && this.options.discussionId, premod: this.user && this.user.privateFields && this.user.privateFields.isPremoderated, state: 'response', replyTo: { commentId: replyToId, author: replyToAuthor, authorId: replyToAuthorId, body: replyToBody, timestamp: replyToTimestamp, }, focus: true, }); // this is a bit toffee, but we don't have .parents() in bonzo const parentCommentEl = $replyToComment.hasClass( this.getClass('topLevelComment', true) ) ? $replyToComment[0] : $replyToComment.parent().parent()[0]; const showRepliesElem = qwery( this.getClass('showReplies'), parentCommentEl ); if ( showRepliesElem.length > 0 && !bonzo(showRepliesElem).hasClass('u-h') ) { showRepliesElem[0].click(); } commentBox.render(parentCommentEl); commentBox.on('post:success', (comment) => { let responses = qwery('.d-thread--responses', parentCommentEl)[0]; if (!responses) { responses = bonzo.create( '<ul class="d-thread d-thread--responses"></ul>' )[0]; bonzo(parentCommentEl).append(responses); } commentBox.destroy(); this.addComment(comment, responses); }); } // eslint-disable-next-line class-methods-use-this reportComment(e) { e.preventDefault(); const currentTarget = (e.currentTarget); const commentId = currentTarget.getAttribute('data-comment-id'); const submitHandler = (form) => { form.removeAttribute('hidden'); bean.on(form, 'submit', (submitEvent) => { submitEvent.preventDefault(); const category = (form.querySelector( '[name="category"]' )); const comment = (form.querySelector( '[name="comment"]' )); const reportCommentSuccess = (formEL) => { formEL.setAttribute('hidden', ''); }; const reportCommentFailure = () => { const commentClose = document.querySelector( '.d-report-comment__close' ); const commentError = document.querySelector( '.js-discussion__report-comment-error' ); if (commentError) { commentError.removeAttribute('hidden'); } if (commentClose) { commentClose.classList.add( 'd-report-comment__close--error' ); } }; if (commentId && category.value !== '0') { const email = (form.querySelector( '[name="email"]' )); reportComment(commentId, { email: email.value, categoryId: category.value, reason: comment.value, }) .then(() => reportCommentSuccess(form)) .catch((e) => reportCommentFailure()); } }); }; if (commentId) { const reportContainer = document.querySelector( `#comment-${commentId} .js-report-comment-container` ); $('.js-report-comment-form') .first() .each(submitHandler) .appendTo(reportContainer); } } addUser(user) { this.user = user; // Determine user staff status if (this.user && this.user.badge) { this.user.isStaff = this.user.badge.some(e => e.name === 'Staff'); } if (!this.isReadOnly()) { if (this.user && this.user.privateFields.canPostComment) { // remove sign-in link $(this.getClass('commentReply')).attr('href', '#'); this.on('click', this.getClass('commentReply'), event => this.replyToComment(event) ); this.on('click', this.getClass('commentPick'), event => this.handlePickClick(event) ); } } mediator.on('user:username:updated', newUsername => { if (this.user) { this.user.displayName = newUsername; } }); } // eslint-disable-next-line class-methods-use-this relativeDates() { if (shouldMakeTimestampsRelative()) { initRelativeDates(); } } isAllPageSizeActive() { return !!( config.get('switches.discussionAllPageSize') && (this.options && this.options.pagesize === 'All') && !this.wholeDiscussionErrors ); } // Similar to above, but tells the loader that the fallback size should be used. shouldShowPageSizeMessage() { return !!( config.get('switches.discussionAllPageSize') && (this.options && this.options.pagesize === 'All') && this.wholeDiscussionErrors ); } renderComments(resp) { const contentEl = bonzo.create(resp.commentsHtml); const comments = qwery(this.getClass('comment'), contentEl); bonzo(this.elem) .empty() .append(contentEl); this.addMoreRepliesButtons(comments); this.postedCommentEl = resp.postedCommentHtml; if (shouldMakeTimestampsRelative()) { this.relativeDates(); } this.emit('rendered', resp.paginationHtml); mediator.emit('modules:comments:renderComments:rendered'); } } export { Comments };