diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index ca48764f1..3a9e282ff 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -3,6 +3,7 @@ import * as actions from '../constants/auth'; import coralApi from 'coral-framework/helpers/request'; import * as Storage from 'coral-framework/helpers/storage'; import {handleAuthToken} from 'coral-framework/actions/auth'; +import {resetWebsocket} from 'coral-framework/services/client'; //============================================================================== // SIGN IN @@ -36,6 +37,7 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) => } dispatch(handleAuthToken(token)); + resetWebsocket(); dispatch(checkLoginSuccess(user)); }) .catch((error) => { @@ -105,6 +107,7 @@ export const checkLogin = () => (dispatch) => { return dispatch(checkLoginFailure('not logged in')); } + resetWebsocket(); dispatch(checkLoginSuccess(user)); }) .catch((error) => { diff --git a/client/coral-admin/src/components/ToastContainer.css b/client/coral-admin/src/components/ToastContainer.css index 319872ceb..2fcbd7f7d 100644 --- a/client/coral-admin/src/components/ToastContainer.css +++ b/client/coral-admin/src/components/ToastContainer.css @@ -133,7 +133,7 @@ animation-fill-mode: both; } .toastify { - z-index: 999; + z-index: 99999; position: fixed; padding: 4px; width: 350px; @@ -197,7 +197,7 @@ border-radius: 2px; box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1), 0 3px 20px 0 rgba(0, 0, 0, 0.05); } .toastify-content--info { - background: #2488cb; } + background: #404040; } .toastify-content--success { background: #008577; } .toastify-content--warning { @@ -207,6 +207,7 @@ .toastify__body { color: white; + overflow-x: scroll; font-size: 15px; font-weight: 400; } diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js index f2a0b06e3..ca1cc1f30 100644 --- a/client/coral-admin/src/components/ui/Header.js +++ b/client/coral-admin/src/components/ui/Header.js @@ -84,12 +84,12 @@ const CoralHeader = ({ {t('configure.sign_out')} - - Talk {`v${process.env.VERSION}`} - +
  • + {`v${process.env.VERSION}`} +
  • diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js index 59a6d7c8f..915fe2989 100644 --- a/client/coral-admin/src/graphql/index.js +++ b/client/coral-admin/src/graphql/index.js @@ -1,6 +1,4 @@ import {add} from 'coral-framework/services/graphqlRegistry'; -import update from 'immutability-helper'; -const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']; const extension = { mutations: { @@ -10,58 +8,6 @@ const extension = { RejectUsername: () => ({ refetchQueries: ['CoralAdmin_Community'], }), - SetCommentStatus: ({variables: {commentId, status}}) => ({ - updateQueries: { - CoralAdmin_Moderation: (prev) => { - const comment = queues.reduce((comment, queue) => { - return comment ? comment : prev[queue].nodes.find((c) => c.id === commentId); - }, null); - - let acceptedNodes = prev.accepted.nodes; - let acceptedCount = prev.acceptedCount; - let rejectedNodes = prev.rejected.nodes; - let rejectedCount = prev.rejectedCount; - - if (status !== comment.status) { - if (status === 'ACCEPTED') { - comment.status = 'ACCEPTED'; - acceptedCount++; - acceptedNodes = [comment, ...acceptedNodes]; - } - else if (status === 'REJECTED') { - comment.status = 'REJECTED'; - rejectedCount++; - rejectedNodes = [comment, ...rejectedNodes]; - } - } - - const premodNodes = prev.premod.nodes.filter((c) => c.id !== commentId); - const flaggedNodes = prev.flagged.nodes.filter((c) => c.id !== commentId); - const premodCount = premodNodes.length < prev.premod.nodes.length ? prev.premodCount - 1 : prev.premodCount; - const flaggedCount = flaggedNodes.length < prev.flagged.nodes.length ? prev.flaggedCount - 1 : prev.flaggedCount; - - if (status === 'REJECTED') { - acceptedNodes = prev.accepted.nodes.filter((c) => c.id !== commentId); - acceptedCount = acceptedNodes.length < prev.accepted.nodes.length ? prev.acceptedCount - 1 : prev.acceptedCount; - } - else if (status === 'ACCEPTED') { - rejectedNodes = prev.rejected.nodes.filter((c) => c.id !== commentId); - rejectedCount = rejectedNodes.length < prev.rejected.nodes.length ? prev.rejectedCount - 1 : prev.rejectedCount; - } - - return update(prev, { - premodCount: {$set: Math.max(0, premodCount)}, - flaggedCount: {$set: Math.max(0, flaggedCount)}, - acceptedCount: {$set: Math.max(0, acceptedCount)}, - rejectedCount: {$set: Math.max(0, rejectedCount)}, - premod: {nodes: {$set: premodNodes}}, - flagged: {nodes: {$set: flaggedNodes}}, - accepted: {nodes: {$set: acceptedNodes}}, - rejected: {nodes: {$set: rejectedNodes}}, - }); - } - } - }), }, }; diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js new file mode 100644 index 000000000..331fd876a --- /dev/null +++ b/client/coral-admin/src/graphql/utils.js @@ -0,0 +1,137 @@ +import update from 'immutability-helper'; +import * as notification from 'coral-admin/src/services/notification'; + +const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']; +const limit = 10; + +const ascending = (a, b) => { + const dateA = new Date(a.created_at); + const dateB = new Date(b.created_at); + if (dateA < dateB) { return -1; } + if (dateA > dateB) { return 1; } + return 0; +}; + +const descending = (a, b) => -ascending(a, b); + +function queueHasComment(root, queue, id) { + return root[queue].nodes.find((c) => c.id === id); +} + +function removeCommentFromQueue(root, queue, id) { + if (!queueHasComment(root, queue, id)) { + return root; + } + return update(root, { + [`${queue}Count`]: {$set: root[`${queue}Count`] - 1}, + [queue]: { + nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)}, + }, + }); +} + +function shouldCommentBeAdded(root, queue, comment, sort) { + if (root[`${queue}Count`] < limit) { + + // Adding all comments until first limit has reached. + return true; + } + const cursor = new Date(root[queue].endCursor); + return sort === 'CHRONOLOGICAL' + ? new Date(comment.created_at) <= cursor + : new Date(comment.created_at) >= cursor; +} + +function addCommentToQueue(root, queue, comment, sort) { + if (queueHasComment(root, queue, comment.id)) { + return root; + } + + const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending; + const changes = { + [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, + }; + + if (shouldCommentBeAdded(root, queue, comment, sort)) { + const nodes = root[queue].nodes.concat(comment).sort(sortAlgo); + changes[queue] = { + nodes: {$set: nodes}, + startCursor: {$set: nodes[0].created_at}, + endCursor: {$set: nodes[nodes.length - 1].created_at}, + }; + } + + return update(root, changes); +} + +function getCommentQueues(comment) { + const queues = ['all']; + if (comment.status === 'ACCEPTED') { + queues.push('accepted'); + } + else if (comment.status === 'REJECTED') { + queues.push('rejected'); + } + else if (comment.status === 'PREMOD') { + queues.push('premod'); + } + if ( + ['NONE', 'PREMOD'].indexOf(comment.status) >= 0 + && comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction') + ) { + queues.push('flagged'); + } + return queues; +} + +/** + * Assimilate comment changes into current store. + * @param {Object} root current state of the store + * @param {Object} comment comment that was changed + * @param {string} sort current sort order of the queues + * @param {Object} [notify] show know notifications if set + * @param {string} notify.activeQueue current active queue + * @param {string} notify.text notification text to show + * @param {bool} notify.anyQueue if true show the notification when the comment is shown + * in the current active queue besides the 'all' queue. + * @return {Object} next state of the store + */ +export function handleCommentChange(root, comment, sort, notify) { + let next = root; + const nextQueues = getCommentQueues(comment); + + let notificationShown = false; + const showNotificationOnce = () => { + if (notificationShown) { + return; + } + notification.info(notify.text); + notificationShown = true; + }; + + queues.forEach((queue) => { + if (nextQueues.indexOf(queue) >= 0) { + if (!queueHasComment(next, queue, comment.id)) { + next = addCommentToQueue(next, queue, comment, sort); + if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) { + showNotificationOnce(comment); + } + } + } else if(queueHasComment(next, queue, comment.id)){ + next = removeCommentFromQueue(next, queue, comment.id); + if (notify && notify.activeQueue === queue) { + showNotificationOnce(comment); + } + } + + if ( + notify + && (queue === 'all' || notify.anyQueue) + && queueHasComment(next, queue, comment.id) + && notify.activeQueue === queue + ) { + showNotificationOnce(comment); + } + }); + return next; +} diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js index 8cd7685f0..2f2e75275 100644 --- a/client/coral-admin/src/index.js +++ b/client/coral-admin/src/index.js @@ -2,7 +2,7 @@ import React from 'react'; import {render} from 'react-dom'; import {ApolloProvider} from 'react-apollo'; -import {client} from './services/client'; +import {getClient} from './services/client'; import store from './services/store'; import App from './components/App'; @@ -15,7 +15,7 @@ loadPluginsTranslations(); injectPluginsReducers(); render( - + , document.querySelector('#root') diff --git a/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js b/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js index bfca4ceb1..56507e13c 100644 --- a/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js +++ b/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js @@ -26,7 +26,7 @@ const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, c

    {t('bandialog.ban_user')}

    -

    {t('bandialog.are_you_sure', user.name)}

    +

    {t('bandialog.are_you_sure', user.username)}

    {showRejectedNote && t('bandialog.note')}
    diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 8a23cc5ba..3d946f9fc 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -12,194 +12,224 @@ import {getActionSummary} from 'coral-framework/utils'; import ActionButton from 'coral-admin/src/components/ActionButton'; import ActionsMenu from 'coral-admin/src/components/ActionsMenu'; import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem'; +import cn from 'classnames'; +import {murmur3} from 'murmurhash-js'; +import {CSSTransitionGroup} from 'react-transition-group'; const linkify = new Linkify(); import t, {timeago} from 'coral-framework/services/i18n'; -const Comment = ({ - actions = [], - comment, - viewUserDetail, - suspectWords, - bannedWords, - minimal, - selected, - toggleSelect, - ...props -}) => { - const links = linkify.getMatches(comment.body); - const linkText = links ? links.map((link) => link.raw) : []; - const flagActionSummaries = getActionSummary('FlagActionSummary', comment); - const flagActions = - comment.actions && - comment.actions.filter((a) => a.__typename === 'FlagAction'); - let commentType = ''; - if (comment.status === 'PREMOD') { - commentType = 'premod'; - } else if (flagActions && flagActions.length) { - commentType = 'flagged'; - } +class Comment extends React.Component { - // since words are checked against word boundaries on the backend, - // should be the behavior on the front end as well. - // currently the highlighter plugin does not support out of the box. - const searchWords = [...suspectWords, ...bannedWords] - .filter((w) => { - return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); - }) - .concat(linkText); + render() { + const { + actions = [], + comment, + viewUserDetail, + suspectWords, + bannedWords, + minimal, + selected, + toggleSelect, + className, + ...props + } = this.props; - let selectionStateCSS; - if (minimal) { - selectionStateCSS = selected ? styles.minimalSelection : ''; - } else { - selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; - } + const links = linkify.getMatches(comment.body); + const linkText = links ? links.map((link) => link.raw) : []; + const flagActionSummaries = getActionSummary('FlagActionSummary', comment); + const flagActions = + comment.actions && + comment.actions.filter((a) => a.__typename === 'FlagAction'); + let commentType = ''; + if (comment.status === 'PREMOD') { + commentType = 'premod'; + } else if (flagActions && flagActions.length) { + commentType = 'flagged'; + } - return ( -
  • -
    -
    -
    - { - !minimal && ( - viewUserDetail(comment.user.id)}> - {comment.user.name} - - ) - } - { - minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && ( - toggleSelect(e.target.value, e.target.checked)} /> - ) - } - - {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)} - - {props.currentUserId !== comment.user.id && - - props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}> - Suspend User - props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}> - Ban User - - - } - -
    - {comment.user.status === 'banned' - ? - - {t('comment.banned_user')} + // since words are checked against word boundaries on the backend, + // should be the behavior on the front end as well. + // currently the highlighter plugin does not support out of the box. + const searchWords = [...suspectWords, ...bannedWords] + .filter((w) => { + return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body); + }) + .concat(linkText); + + let selectionStateCSS; + if (minimal) { + selectionStateCSS = selected ? styles.minimalSelection : ''; + } else { + selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; + } + + return ( +
  • +
    +
    +
    + { + !minimal && ( + viewUserDetail(comment.user.id)}> + {comment.user.username} + + ) + } + { + minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && ( + toggleSelect(e.target.value, e.target.checked)} /> + ) + } + + {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)} - : null} - -
    -
    - Story: {comment.asset.title} - {!props.currentAsset && - {t('modqueue.moderate')}} -
    -
    -

    - - {' '} - - {t('comment.view_context')} - -

    - -
    - {links - ? - Contains Link + { + (comment.editing && comment.editing.edited) + ?  ({t('comment.edited')}) + : null + } + {props.currentUserId !== comment.user.id && + + props.showSuspendUserDialog(comment.user.id, comment.user.username, comment.id, comment.status)}> + Suspend User + props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}> + Ban User + + + } + +
    + {comment.user.status === 'banned' + ? + + {t('comment.banned_user')} : null} -
    - {actions.map((action, i) => { - const active = - (action === 'REJECT' && comment.status === 'REJECTED') || - (action === 'APPROVE' && comment.status === 'ACCEPTED'); - return ( - - (comment.status === 'ACCEPTED' - ? null - : props.acceptComment({commentId: comment.id}))} - rejectComment={() => - (comment.status === 'REJECTED' - ? null - : props.rejectComment({commentId: comment.id}))} - /> - ); - })} -
    - +
    +
    + Story: {comment.asset.title} + {!props.currentAsset && + {t('modqueue.moderate')}} +
    + +
    +

    + + {' '} + + {t('comment.view_context')} + +

    + +
    + {links + ? + Contains Link + + : null} +
    + {actions.map((action, i) => { + const active = + (action === 'REJECT' && comment.status === 'REJECTED') || + (action === 'APPROVE' && comment.status === 'ACCEPTED'); + return ( + + (comment.status === 'ACCEPTED' + ? null + : props.acceptComment({commentId: comment.id}))} + rejectComment={() => + (comment.status === 'REJECTED' + ? null + : props.rejectComment({commentId: comment.id}))} + /> + ); + })} +
    + +
    +
    +
    -
    - - {flagActions && flagActions.length - ? - : null} -
  • - ); -}; + + {flagActions && flagActions.length + ? + : null} + + ); + } +} Comment.propTypes = { minimal: PropTypes.bool, viewUserDetail: PropTypes.func.isRequired, acceptComment: PropTypes.func.isRequired, rejectComment: PropTypes.func.isRequired, + className: PropTypes.string, suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired, bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired, currentAsset: PropTypes.object, diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 63c9e13ab..1c1ce1009 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -7,11 +7,9 @@ import SuspendUserDialog from './SuspendUserDialog'; import ModerationQueue from './ModerationQueue'; import ModerationMenu from './ModerationMenu'; import ModerationHeader from './ModerationHeader'; -import NotFoundAsset from './NotFoundAsset'; import ModerationKeysModal from '../../../components/ModerationKeysModal'; import UserDetail from '../containers/UserDetail'; import StorySearch from '../containers/StorySearch'; -import {Spinner} from 'coral-ui'; export default class Moderation extends Component { constructor() { @@ -106,24 +104,11 @@ export default class Moderation extends Component { } render () { - const {root, moderation, settings, viewUserDetail, hideUserDetail, ...props} = this.props; - const providedAssetId = this.props.params.id; - const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path; + + const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props; + const assetId = this.props.params.id; const {asset} = root; - if (providedAssetId) { - if (asset === null) { - - // Not found. - return ; - } - if (asset === undefined || asset.id !== providedAssetId) { - - // Still loading. - return ; - } - } - const comments = root[activeTab]; let activeTabCount; switch(activeTab) { @@ -177,7 +162,7 @@ export default class Moderation extends Component { acceptComment={props.acceptComment} rejectComment={props.rejectComment} loadMore={props.loadMore} - assetId={providedAssetId} + assetId={assetId} sort={this.props.moderation.sortOrder} commentCount={activeTabCount} currentUserId={this.props.auth.user.id} diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index d656c7a82..2adf17b23 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -6,6 +6,7 @@ import EmptyCard from '../../../components/EmptyCard'; import {actionsMap} from '../helpers/moderationQueueActionsMap'; import LoadMore from './LoadMore'; import t from 'coral-framework/services/i18n'; +import {CSSTransitionGroup} from 'react-transition-group'; class ModerationQueue extends React.Component { isLoadingMore = false; @@ -34,6 +35,10 @@ class ModerationQueue extends React.Component { } } + constructor(props) { + super(props); + } + componentDidUpdate (prev) { const {comments, commentCount} = this.props; @@ -52,20 +57,34 @@ class ModerationQueue extends React.Component { commentCount, singleView, viewUserDetail, + activeTab, ...props } = this.props; return (
    -
      + { - comments.length - ? comments.map((comment, i) => { + comments.map((comment, i) => { const status = comment.action_summaries ? 'FLAGGED' : comment.status; return ; }) - : {t('modqueue.empty_queue')} } -
    + + {comments.length === 0 && +
    + {t('modqueue.empty_queue')} +
    + } + { + const user = comment.status_history[comment.status_history.length - 1].assigned_by; + const sort = this.props.moderation.sortOrder; + const notify = this.props.auth.user.id === user.id + ? {} + : { + activeQueue: this.activeTab, + text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)), + anyQueue: false, + }; + return handleCommentChange(prev, comment, sort, notify); + }, + }); + + const sub2 = this.props.data.subscribeToMore({ + document: COMMENT_REJECTED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => { + const user = comment.status_history[comment.status_history.length - 1].assigned_by; + const sort = this.props.moderation.sortOrder; + const notify = this.props.auth.user.id === user.id + ? {} + : { + activeQueue: this.activeTab, + text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)), + anyQueue: false, + }; + return handleCommentChange(prev, comment, sort, notify); + }, + }); + + const sub3 = this.props.data.subscribeToMore({ + document: COMMENT_EDITED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { + const sort = this.props.moderation.sortOrder; + const notify = { + activeQueue: this.activeTab, + text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)), + anyQueue: false, + }; + return handleCommentChange(prev, comment, sort, notify); + }, + }); + + const sub4 = this.props.data.subscribeToMore({ + document: COMMENT_FLAGGED_SUBSCRIPTION, + variables, + updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { + const user = comment.actions[comment.actions.length - 1].user; + const sort = this.props.moderation.sortOrder; + const notify = { + activeQueue: this.activeTab, + text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)), + anyQueue: true, + }; + return handleCommentChange(prev, comment, sort, notify); + }, + }); + + this.subscriptions.push(sub1, sub2, sub3, sub4); + } + + unsubscribe() { + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions = []; + } + + resubscribe(variables) { + this.unsubscribe(); + this.subscribeToUpdates(variables); + } + componentWillMount() { this.props.clearState(); this.props.fetchSettings(); + this.subscribeToUpdates(); + } + + componentWillUnmount() { + this.unsubscribe(); + } + + componentWillReceiveProps(nextProps) { + + // Resubscribe when we change between assets. + if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) { + this.resubscribe(nextProps.data.variables); + } } suspendUser = async (args) => { @@ -108,6 +209,9 @@ class ModerationContainer extends Component { return update(prev, { [tab]: { nodes: {$push: comments.nodes}, + hasNextPage: {$set: comments.hasNextPage}, + startCursor: {$set: comments.startCursor}, + endCursor: {$set: comments.endCursor}, }, }); } @@ -115,14 +219,27 @@ class ModerationContainer extends Component { }; render () { - const {root, data} = this.props; + const {root, root: {asset}, data, params: {id: assetId}} = this.props; if (data.error) { return
    Error
    ; } - if (!('premodCount' in root)) { - return
    ; + if (assetId) { + if (asset === null) { + + // Not found. + return ; + } + if (asset === undefined || asset.id !== assetId) { + + // Still loading. + return ; + } + } else if(asset !== undefined || !('premodCount' in root)) { + + // loading. + return ; } return ; } } +const COMMENT_EDITED_SUBSCRIPTION = gql` + subscription CommentEdited($asset_id: ID){ + commentEdited(asset_id: $asset_id){ + ...${getDefinitionName(Comment.fragments.comment)} + } + } + ${Comment.fragments.comment} +`; + +const COMMENT_FLAGGED_SUBSCRIPTION = gql` + subscription CommentFlagged($asset_id: ID){ + commentFlagged(asset_id: $asset_id){ + ...${getDefinitionName(Comment.fragments.comment)} + } + } + ${Comment.fragments.comment} +`; + +const COMMENT_ACCEPTED_SUBSCRIPTION = gql` + subscription CommentAccepted($asset_id: ID){ + commentAccepted(asset_id: $asset_id){ + ...${getDefinitionName(Comment.fragments.comment)} + status_history { + type + created_at + assigned_by { + id + username + } + } + } + } + ${Comment.fragments.comment} +`; + +const COMMENT_REJECTED_SUBSCRIPTION = gql` + subscription CommentRejected($asset_id: ID){ + commentRejected(asset_id: $asset_id){ + ...${getDefinitionName(Comment.fragments.comment)} + status_history { + type + created_at + assigned_by { + id + username + } + } + } + } + ${Comment.fragments.comment} +`; + const LOAD_MORE_QUERY = gql` query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) { comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) { nodes { ...${getDefinitionName(Comment.fragments.comment)} - action_summaries { - count - ... on FlagActionSummary { - reason - } - } } + hasNextPage + startCursor + endCursor } } ${Comment.fragments.comment} diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js index df97066bc..934de2c9e 100644 --- a/client/coral-admin/src/services/client.js +++ b/client/coral-admin/src/services/client.js @@ -1,20 +1,6 @@ -import ApolloClient, {addTypename} from 'apollo-client'; -import {networkInterface} from 'coral-framework/services/transport'; +import {getClient as getFrameworkClient} from 'coral-framework/services/client'; import fragmentMatcher from './fragmentMatcher'; -export const client = new ApolloClient({ - fragmentMatcher, - connectToDevTools: true, - addTypename: true, - queryTransformer: addTypename, - dataIdFromObject: (result) => { - if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle - return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle - } - return null; - }, - networkInterface -}); - -export default client; - +export function getClient() { + return getFrameworkClient({fragmentMatcher}); +} diff --git a/client/coral-admin/src/services/store.js b/client/coral-admin/src/services/store.js index 9815a0f70..06cb3c758 100644 --- a/client/coral-admin/src/services/store.js +++ b/client/coral-admin/src/services/store.js @@ -1,10 +1,10 @@ import {createStore, combineReducers, applyMiddleware, compose} from 'redux'; import thunk from 'redux-thunk'; import mainReducer from '../reducers'; -import {client} from './client'; +import {getClient} from './client'; const middlewares = [ - applyMiddleware(client.middleware()), + applyMiddleware(getClient().middleware()), applyMiddleware(thunk) ]; @@ -16,7 +16,7 @@ if (window.devToolsExtension) { const coralReducers = { ...mainReducer, - apollo: client.reducer() + apollo: getClient().reducer() }; const store = createStore( diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index 53ac52edf..754f44558 100644 --- a/client/coral-embed-stream/src/components/Comment.js +++ b/client/coral-embed-stream/src/components/Comment.js @@ -26,6 +26,7 @@ import Slot from 'coral-framework/components/Slot'; import IgnoredCommentTombstone from './IgnoredCommentTombstone'; import {EditableCommentContent} from './EditableCommentContent'; import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils'; +import t from 'coral-framework/services/i18n'; const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF'); const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length; @@ -427,7 +428,7 @@ export default class Comment extends React.Component { { (comment.editing && comment.editing.edited) - ?  (Edited) + ?  ({t('comment.edited')}) : null } diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js index 26d8e391f..307646db0 100644 --- a/client/coral-framework/services/client.js +++ b/client/coral-framework/services/client.js @@ -26,7 +26,7 @@ export function resetWebsocket() { }); } -export function getClient() { +export function getClient(options = {}) { if (client) { return client; } @@ -56,6 +56,7 @@ export function getClient() { ); client = new ApolloClient({ + ...options, connectToDevTools: true, addTypename: true, queryTransformer: addTypename, diff --git a/graph/mutators/action.js b/graph/mutators/action.js index 96bf091cf..19db33183 100644 --- a/graph/mutators/action.js +++ b/graph/mutators/action.js @@ -13,7 +13,16 @@ const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants'); * @param {String} action_type type of the action * @return {Promise} resolves to the action created */ -const createAction = async ({user = {}}, {item_id, item_type, action_type, group_id, metadata = {}}) => { +const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id, item_type, action_type, group_id, metadata = {}}) => { + + let comment; + if (pubsub && item_type === 'COMMENTS') { + comment = await Comments.get.load(item_id); + if (!comment) { + throw new Error('Comment not found'); + } + } + let action = await ActionsService.insertUserAction({ item_id, item_type, @@ -29,6 +38,10 @@ const createAction = async ({user = {}}, {item_id, item_type, action_type, group await UsersService.setStatus(item_id, 'PENDING'); } + if (pubsub && comment) { + pubsub.publish('commentFlagged', comment); + } + return action; }; diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 247eb264e..df6ed71ce 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -326,7 +326,7 @@ const createPublicComment = async (context, commentInput) => { * @param {String} id identifier of the comment (uuid) * @param {String} status the new status of the comment */ -const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { +const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => { let comment = await CommentsService.pushStatus(id, status, user ? user.id : null); // If the loaders are present, clear the caches for these values because we @@ -346,6 +346,19 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => { // adjust the affected user's karma in the next tick. process.nextTick(adjustKarma(Comments, id, status)); + if (pubsub) { + + if (status === 'ACCEPTED') { + + // Publish the comment status change via the subscription. + pubsub.publish('commentAccepted', comment); + } else if (status === 'REJECTED') { + + // Publish the comment status change via the subscription. + pubsub.publish('commentRejected', comment); + } + } + return comment; }; @@ -371,7 +384,6 @@ const edit = async (context, {id, asset_id, edit: {body}}) => { // Publish the edited comment via the subscription. context.pubsub.publish('commentEdited', comment); } - return comment; }; diff --git a/graph/resolvers/comment_status_history.js b/graph/resolvers/comment_status_history.js new file mode 100644 index 000000000..2e1676d90 --- /dev/null +++ b/graph/resolvers/comment_status_history.js @@ -0,0 +1,11 @@ +const {SEARCH_OTHER_USERS} = require('../../perms/constants'); + +const CommentStatusHistory = { + assigned_by({assigned_by}, _, {user, loaders: {Users}}) { + if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) { + return Users.getByID.load(assigned_by); + } + } +}; + +module.exports = CommentStatusHistory; diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js index f2707bcc8..850a2f15a 100644 --- a/graph/resolvers/index.js +++ b/graph/resolvers/index.js @@ -6,6 +6,7 @@ const Action = require('./action'); const AssetActionSummary = require('./asset_action_summary'); const Asset = require('./asset'); const Comment = require('./comment'); +const CommentStatusHistory = require('./comment_status_history'); const Date = require('./date'); const FlagActionSummary = require('./flag_action_summary'); const FlagAction = require('./flag_action'); @@ -31,6 +32,7 @@ let resolvers = { AssetActionSummary, Asset, Comment, + CommentStatusHistory, Date, FlagActionSummary, FlagAction, diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index a593c1eb6..1f26d5ff3 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -4,7 +4,16 @@ const Subscription = { }, commentEdited(comment) { return comment; - } + }, + commentAccepted(comment) { + return comment; + }, + commentRejected(comment) { + return comment; + }, + commentFlagged(comment) { + return comment; + }, }; module.exports = Subscription; diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 7d97d3521..2ba0c2c1f 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -10,6 +10,14 @@ const plugins = require('../services/plugins'); const {deserializeUser} = require('../services/subscriptions'); +const { + SUBSCRIBE_COMMENT_ACCEPTED, + SUBSCRIBE_COMMENT_REJECTED, + SUBSCRIBE_COMMENT_FLAGGED, + SUBSCRIBE_ALL_COMMENT_EDITED, + SUBSCRIBE_ALL_COMMENT_ADDED, +} = require('../perms/constants'); + /** * Plugin support requires that we merge in existing setupFunctions with our new * plugin based ones. This allows plugins to extend existing setupFunctions as well @@ -22,12 +30,52 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu }, { commentAdded: (options, args) => ({ commentAdded: { - filter: (comment) => comment.asset_id === args.asset_id + filter: (comment, context) => { + if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } }, }), commentEdited: (options, args) => ({ commentEdited: { - filter: (comment) => comment.asset_id === args.asset_id + filter: (comment, context) => { + if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_EDITED))) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } + }, + }), + commentFlagged: (options, args) => ({ + commentFlagged: { + filter: (comment, context) => { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGGED)) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } + }, + }), + commentAccepted: (options, args) => ({ + commentAccepted: { + filter: (comment, context) => { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_ACCEPTED)) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } + }, + }), + commentRejected: (options, args) => ({ + commentRejected: { + filter: (comment, context) => { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_REJECTED)) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } }, }), }); diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index c946cca94..09b50cb0e 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -255,6 +255,12 @@ type EditInfo { editableUntil: Date } +type CommentStatusHistory { + type: COMMENT_STATUS! + created_at: Date! + assigned_by: User +} + # Comment is the base representation of user interaction in Talk. type Comment { @@ -294,6 +300,9 @@ type Comment { # The current status of a comment. status: COMMENT_STATUS! + # The status history of the comment. Requires the `ADMIN` or `MODERATOR` role. + status_history: [CommentStatusHistory!] + # The time when the comment was created created_at: Date! @@ -945,8 +954,26 @@ type RootMutation { ################################################################################ type Subscription { - commentAdded(asset_id: ID!): Comment - commentEdited(asset_id: ID!): Comment + + # Get an update whenever a comment was added. + # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role. + commentAdded(asset_id: ID): Comment + + # Get an update whenever a comment was edited. + # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role. + commentEdited(asset_id: ID): Comment + + # Get an update whenever a comment was flagged. + # Requires the `ADMIN` or `MODERATOR` role. + commentFlagged(asset_id: ID): Comment + + # Get an update whenever a comment has been accepted. + # Requires the `ADMIN` or `MODERATOR` role. + commentAccepted(asset_id: ID): Comment + + # Get an update whenever a comment has been rejected. + # Requires the `ADMIN` or `MODERATOR` role. + commentRejected(asset_id: ID): Comment } ################################################################################ diff --git a/locales/en.yml b/locales/en.yml index de95ddb91..4c2ae7c76 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -13,6 +13,7 @@ en: anon: "Anonymous" ban_user: "Ban User" comment: "Post a comment" + edited: Edited flagged: "flagged" view_context: "View context" comment_box: @@ -241,6 +242,10 @@ en: actions: Actions all: all all_streams: "All Streams" + notify_edited: '{0} edited comment "{1}"' + notify_accepted: '{0} accepted comment "{1}"' + notify_rejected: '{0} rejected comment "{1}"' + notify_flagged: '{0} flagged comment "{1}"' approve: "Approve" approved: "Approved" ban_user: "Ban" diff --git a/locales/es.yml b/locales/es.yml index 6897f6f8b..d174ed0ee 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -13,6 +13,7 @@ es: anon: AnĂ³nimo ban_user: "Usuario Suspendido" comment: "Publicar un comentario" + edited: Editado flagged: reportado view_context: "Ver contexto" comment_box: diff --git a/package.json b/package.json index d55df87e6..729358ffc 100644 --- a/package.json +++ b/package.json @@ -99,6 +99,7 @@ "mongoose": "^4.9.8", "morgan": "^1.8.1", "ms": "^2.0.0", + "murmurhash-js": "^1.0.0", "natural": "^0.5.0", "node-emoji": "^1.5.1", "node-fetch": "^1.6.3", @@ -201,7 +202,6 @@ "regenerator": "^0.8.46", "selenium-standalone": "^5.11.2", "style-loader": "^0.16.0", - "subscriptions-transport-ws": "^0.5.5-alpha.0", "supertest": "^2.0.1", "timeago.js": "^2.0.3", "webpack": "^2.3.1" diff --git a/perms/constants.js b/perms/constants.js index 2b5b907ac..8be745f36 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -21,5 +21,13 @@ module.exports = { SEARCH_ACTIONS: 'SEARCH_ACTIONS', SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS', SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS', - SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS' + SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS', + SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY', + + // subscriptions + SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED', + SUBSCRIBE_COMMENT_REJECTED: 'SUBSCRIBE_COMMENT_REJECTED', + SUBSCRIBE_COMMENT_FLAGGED: 'SUBSCRIBE_COMMENT_FLAGGED', + SUBSCRIBE_ALL_COMMENT_ADDED: 'SUBSCRIBE_ALL_COMMENT_ADDED', + SUBSCRIBE_ALL_COMMENT_EDITED: 'SUBSCRIBE_ALL_COMMENT_EDITED', }; diff --git a/perms/index.js b/perms/index.js index f0e14c5fc..be6e7a2cd 100644 --- a/perms/index.js +++ b/perms/index.js @@ -2,11 +2,13 @@ const constants = require('./constants'); const root = require('./rootReducer'); const queries = require('./queryReducer'); const mutations = require('./mutationReducer'); +const subscriptions = require('./subscriptionReducer'); const reducers = [ root, queries, - mutations + mutations, + subscriptions, ]; // this will make 'reducer' a key in this array. hm. diff --git a/perms/queryReducer.js b/perms/queryReducer.js index 0e5054788..2bee02110 100644 --- a/perms/queryReducer.js +++ b/perms/queryReducer.js @@ -15,6 +15,8 @@ module.exports = (user, perm) => { return check(user, ['ADMIN', 'MODERATOR']); case types.SEARCH_COMMENT_METRICS: return check(user, ['ADMIN', 'MODERATOR']); + case types.SEARCH_COMMENT_STATUS_HISTORY: + return check(user, ['ADMIN', 'MODERATOR']); default: break; } diff --git a/perms/subscriptionReducer.js b/perms/subscriptionReducer.js new file mode 100644 index 000000000..0b06902cf --- /dev/null +++ b/perms/subscriptionReducer.js @@ -0,0 +1,19 @@ +const {check} = require('./utils'); +const types = require('./constants'); + +module.exports = (user, perm) => { + switch (perm) { + case types.SUBSCRIBE_COMMENT_FLAGGED: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_COMMENT_ACCEPTED: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_COMMENT_REJECTED: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_COMMENT_EDITED: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_COMMENT_ADDED: + return check(user, ['ADMIN', 'MODERATOR']); + default: + break; + } +}; diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js index 78bc45dfd..243b8915b 100644 --- a/plugin-api/beta/server/getReactionConfig.js +++ b/plugin-api/beta/server/getReactionConfig.js @@ -132,8 +132,11 @@ function getReactionConfig(reaction) { return Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION}) .then((action) => { - // The comment is needed to allow better filtering e.g. by asset_id. - pubsub.publish(`${reaction}ActionCreated`, {action, comment}); + if (pubsub) { + + // The comment is needed to allow better filtering e.g. by asset_id. + pubsub.publish(`${reaction}ActionCreated`, {action, comment}); + } return Promise.resolve(action); }) .catch((err) => { @@ -155,8 +158,11 @@ function getReactionConfig(reaction) { } return Comments.get.load(action.item_id).then((comment) => { - // The comment is needed to allow better filtering e.g. by asset_id. - pubsub.publish(`${reaction}ActionDeleted`, {action, comment}); + if (pubsub) { + + // The comment is needed to allow better filtering e.g. by asset_id. + pubsub.publish(`${reaction}ActionDeleted`, {action, comment}); + } return Promise.resolve(action); }); }); diff --git a/services/comments.js b/services/comments.js index 3f18d55e3..af7593a8e 100644 --- a/services/comments.js +++ b/services/comments.js @@ -215,6 +215,10 @@ module.exports = class CommentsService { } }, $set: {status} + }, { + + // return modified comment. + new: true, }); } diff --git a/yarn.lock b/yarn.lock index 4f99143d0..df7efdaf8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5405,6 +5405,10 @@ muri@1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/muri/-/muri-1.2.1.tgz#ec7ea5ce6ca6a523eb1ab35bacda5fa816c9aa3c" +murmurhash-js@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51" + mute-stream@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.4.tgz#a9219960a6d5d5d046597aee51252c6655f7177e"