From 3658a804b0013380eef3aace54e2dcf31cf4d00d Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 15 Jun 2017 01:08:25 +0700 Subject: [PATCH 01/20] Implement live updates for mod actions --- .../src/components/ToastContainer.css | 4 +- client/coral-admin/src/graphql/index.js | 54 --- client/coral-admin/src/graphql/utils.js | 62 ++++ client/coral-admin/src/index.js | 4 +- .../routes/Moderation/components/Comment.js | 336 +++++++++--------- .../Moderation/components/Moderation.js | 3 +- .../Moderation/components/ModerationQueue.js | 22 +- .../routes/Moderation/components/styles.css | 18 + .../Moderation/containers/Moderation.js | 88 +++++ client/coral-admin/src/services/client.js | 22 +- client/coral-admin/src/services/store.js | 6 +- client/coral-framework/services/client.js | 3 +- graph/mutators/comment.js | 5 +- graph/resolvers/root_mutation.js | 8 +- graph/resolvers/subscription.js | 5 +- graph/subscriptions.js | 14 + graph/typeDefs.graphql | 8 + package.json | 1 - perms/constants.js | 5 +- perms/index.js | 4 +- perms/subscriptionReducer.js | 11 + services/comments.js | 4 + 22 files changed, 432 insertions(+), 255 deletions(-) create mode 100644 client/coral-admin/src/graphql/utils.js create mode 100644 perms/subscriptionReducer.js diff --git a/client/coral-admin/src/components/ToastContainer.css b/client/coral-admin/src/components/ToastContainer.css index 319872ceb..57ddf3134 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 { 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..7c8bd8875 --- /dev/null +++ b/client/coral-admin/src/graphql/utils.js @@ -0,0 +1,62 @@ +import update from 'immutability-helper'; + +export function findCommentInModQueues(root, id, queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']) { + return queues.reduce((comment, queue) => { + return comment ? comment : root[queue].nodes.find((c) => c.id === id); + }, null); +} + +export function handleCommentStatusChange(root, {id, status}, previousStatus) { + const comment = findCommentInModQueues(root, id); + if (!previousStatus && comment) { + previousStatus = comment.status; + } + + if (status === previousStatus) { + return root; + } + + let acceptedNodes = root.accepted.nodes; + let acceptedCount = root.acceptedCount; + let rejectedNodes = root.rejected.nodes; + let rejectedCount = root.rejectedCount; + + if (status === 'ACCEPTED') { + acceptedCount++; + if (comment) { + acceptedNodes = [{...comment, status}, ...acceptedNodes]; + } + } + else if (status === 'REJECTED') { + rejectedCount++; + if (comment) { + rejectedNodes = [{...comment, status}, ...rejectedNodes]; + } + } + + const premodNodes = root.premod.nodes.filter((c) => c.id !== id); + const flaggedNodes = root.flagged.nodes.filter((c) => c.id !== id); + const premodCount = premodNodes.length < root.premod.nodes.length ? root.premodCount - 1 : root.premodCount; + const flaggedCount = flaggedNodes.length < root.flagged.nodes.length ? root.flaggedCount - 1 : root.flaggedCount; + + if (status === 'REJECTED') { + acceptedNodes = root.accepted.nodes.filter((c) => c.id !== id); + acceptedCount = acceptedNodes.length < root.accepted.nodes.length ? root.acceptedCount - 1 : root.acceptedCount; + } + else if (status === 'ACCEPTED') { + rejectedNodes = root.rejected.nodes.filter((c) => c.id !== id); + rejectedCount = rejectedNodes.length < root.rejected.nodes.length ? root.rejectedCount - 1 : root.rejectedCount; + } + + return update(root, { + 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/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/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 9d1019b5e..814b87c82 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -12,194 +12,202 @@ 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'; 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.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)} - : null} - -
    -
    - Story: {comment.asset.title} - {!props.currentAsset && - {t('modqueue.moderate')}} -
    -
    -

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

    - -
    - {links - ? - Contains Link + {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')} : 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 c788da993..1c1aa7690 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -92,9 +92,8 @@ export default class Moderation extends Component { } render () { - const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, ...props} = this.props; + const {root, moderation, settings, assets, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props; const providedAssetId = this.props.params.id; - const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path; let asset; diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 490c2a985..399fc48ed 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 { @@ -43,12 +44,27 @@ class ModerationQueue extends React.Component { commentCount, singleView, viewUserDetail, + activeTab, ...props } = this.props; return (
    -
      + { comments.length ? comments.map((comment, i) => { @@ -56,7 +72,7 @@ class ModerationQueue extends React.Component { return {t('modqueue.empty_queue')} } -
    + length) ? `${s.substring(0, length)}...` : s; +} + class ModerationContainer extends Component { + unsubscribe = null; + + get activeTab() { return this.props.route.path === ':id' ? 'premod' : this.props.route.path; } + + subscribeToUpdates() { + this.unsubscribe = this.props.data.subscribeToMore({ + document: STATUS_CHANGED_SUBSCRIPTION, + variables: { + asset_id: this.props.data.variables.asset_id, + }, + updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: {user, comment, previous}}}}) => { + const activeTab = this.activeTab; + + // Status changed was caused by a different user. + if (user && user.id !== this.props.auth.user.id) { + if (findCommentInModQueues(prev, comment.id) && ( + activeTab === 'all' && findCommentInModQueues(prev, comment.id, ['all']) + || activeTab === 'premod' && previous.status === 'PREMOD' + || activeTab === 'flagged' && findCommentInModQueues(prev, comment.id, ['flagged']) + || comment.status === 'ACCEPTED' && activeTab === 'accepted' + || comment.status !== 'ACCEPTED' && previous.status === 'ACCEPTED' && activeTab === 'accepted' + || comment.status === 'REJECTED' && activeTab === 'rejected' + || comment.status !== 'REJECTED' && previous.status === 'REJECTED' && activeTab === 'rejected' + ) + ) { + const text = `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, 50)}"`; + notification.info(text); + } + } + return handleCommentStatusChange(prev, comment, previous.status, user); + }, + }); + } + + unsubscribe() { + if (!this.unsubscribe) { + return; + } + this.unsubscribe(); + this.unsubscribe = null; + } + + resubscribe() { + this.unsubscribe(); + this.subscribeToUpdates(); + } + componentWillMount() { this.props.fetchSettings(); + this.subscribeToUpdates(); + } + + componentWillUnmount() { + this.unsubscribe(); } componentWillReceiveProps(nextProps) { @@ -40,6 +97,11 @@ class ModerationContainer extends Component { if(!isEqual(nextProps.root.assets, this.props.root.assets)) { updateAssets(nextProps.root.assets); } + + // Resubscribe when we change between assets. + if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) { + this.resubscribe(); + } } suspendUser = async (args) => { @@ -113,6 +175,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}, }, }); } @@ -137,10 +202,30 @@ class ModerationContainer extends Component { acceptComment={this.acceptComment} rejectComment={this.rejectComment} suspendUser={this.suspendUser} + activeTab={this.activeTab} />; } } +const STATUS_CHANGED_SUBSCRIPTION = gql` + subscription CommentStatusChanged($asset_id: ID){ + commentStatusChanged(asset_id: $asset_id){ + user { + id + username + } + comment { + id + status + body + } + previous { + status + } + } + } +`; + 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}) { @@ -153,6 +238,9 @@ const LOAD_MORE_QUERY = gql` } } } + 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-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/comment.js b/graph/mutators/comment.js index 247eb264e..e11eaf2b9 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 @@ -370,6 +370,9 @@ const edit = async (context, {id, asset_id, edit: {body}}) => { // Publish the edited comment via the subscription. context.pubsub.publish('commentEdited', comment); + + // Publish the comment status change via the subscription. + context.pubsub.publish('commentStatusChanged', comment); } return comment; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index f192405cc..087ec94bc 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -31,7 +31,13 @@ const RootMutation = { stopIgnoringUser(_, {id}, {mutators: {User}}) { return wrapResponse(null)(User.stopIgnoringUser({id})); }, - setCommentStatus(_, {id, status}, {mutators: {Comment}}) { + setCommentStatus: async (_, {id, status}, {loaders: {Comments}, mutators: {Comment}, user, pubsub}) => { + const previous = await Comments.get.load(id); + const comment = await Comment.setStatus({id, status}); + + // Publish the comment status change via the subscription. + pubsub.publish('commentStatusChanged', {user, comment, previous}); + return wrapResponse(null)(Comment.setStatus({id, status})); }, addTag(_, {tag}, {mutators: {Tag}}) { diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index a593c1eb6..8a989cc81 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -4,7 +4,10 @@ const Subscription = { }, commentEdited(comment) { return comment; - } + }, + commentStatusChanged(data) { + return data; + }, }; module.exports = Subscription; diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 7d97d3521..2192dbdcf 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -10,6 +10,10 @@ const plugins = require('../services/plugins'); const {deserializeUser} = require('../services/subscriptions'); +const { + SUBSCRIBE_COMMENT_STATUS, +} = 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 @@ -30,6 +34,16 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu filter: (comment) => comment.asset_id === args.asset_id }, }), + commentStatusChanged: (options, args) => ({ + commentStatusChanged: { + filter: ({comment}, context) => { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_STATUS)) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } + }, + }), }); /** diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 117b7e077..c1dfa0b7d 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -936,9 +936,17 @@ type RootMutation { ## Subscriptions ################################################################################ +# Response to ignoreUser mutation +type CommentStatusChangedUpdate { + user: User + comment: Comment + previous: Comment +} + type Subscription { commentAdded(asset_id: ID!): Comment commentEdited(asset_id: ID!): Comment + commentStatusChanged(asset_id: ID): CommentStatusChangedUpdate } ################################################################################ diff --git a/package.json b/package.json index f6a969de9..92bcd2be4 100644 --- a/package.json +++ b/package.json @@ -201,7 +201,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..1578946cf 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -21,5 +21,8 @@ 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', + + // subscriptions + SUBSCRIBE_COMMENT_STATUS: 'SUBSCRIBE_COMMENT_STATUS', }; 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/subscriptionReducer.js b/perms/subscriptionReducer.js new file mode 100644 index 000000000..4ce817ea5 --- /dev/null +++ b/perms/subscriptionReducer.js @@ -0,0 +1,11 @@ +const {check} = require('./utils'); +const types = require('./constants'); + +module.exports = (user, perm) => { + switch (perm) { + case types.SUBSCRIBE_COMMENT_STATUS: + return check(user, ['ADMIN', 'MODERATOR']); + default: + break; + } +}; 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, }); } From 5ecdfe94cc99ceee424b03bbc6fed7d32c148ae3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 15 Jun 2017 18:28:08 +0700 Subject: [PATCH 02/20] Refactor state updater --- client/coral-admin/src/graphql/utils.js | 149 ++++++++++++------ .../Moderation/containers/Moderation.js | 53 ++++--- 2 files changed, 128 insertions(+), 74 deletions(-) diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index 7c8bd8875..d42029df4 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -1,62 +1,111 @@ import update from 'immutability-helper'; +import * as notification from 'coral-admin/src/services/notification'; -export function findCommentInModQueues(root, id, queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']) { - return queues.reduce((comment, queue) => { - return comment ? comment : root[queue].nodes.find((c) => c.id === id); - }, null); +const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected']; + +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 truncate(s, length = 10) { + return (s.length > length) ? `${s.substring(0, length)}...` : s; } -export function handleCommentStatusChange(root, {id, status}, previousStatus) { - const comment = findCommentInModQueues(root, id); - if (!previousStatus && comment) { - previousStatus = comment.status; - } +function queueHasComment(root, queue, id) { + return root[queue].nodes.find((c) => c.id === id); +} - if (status === previousStatus) { +function removeCommentFromQueue(root, queue, id) { + if (!queueHasComment(root, queue, id)) { return root; } - let acceptedNodes = root.accepted.nodes; - let acceptedCount = root.acceptedCount; - let rejectedNodes = root.rejected.nodes; - let rejectedCount = root.rejectedCount; - - if (status === 'ACCEPTED') { - acceptedCount++; - if (comment) { - acceptedNodes = [{...comment, status}, ...acceptedNodes]; - } - } - else if (status === 'REJECTED') { - rejectedCount++; - if (comment) { - rejectedNodes = [{...comment, status}, ...rejectedNodes]; - } - } - - const premodNodes = root.premod.nodes.filter((c) => c.id !== id); - const flaggedNodes = root.flagged.nodes.filter((c) => c.id !== id); - const premodCount = premodNodes.length < root.premod.nodes.length ? root.premodCount - 1 : root.premodCount; - const flaggedCount = flaggedNodes.length < root.flagged.nodes.length ? root.flaggedCount - 1 : root.flaggedCount; - - if (status === 'REJECTED') { - acceptedNodes = root.accepted.nodes.filter((c) => c.id !== id); - acceptedCount = acceptedNodes.length < root.accepted.nodes.length ? root.acceptedCount - 1 : root.acceptedCount; - } - else if (status === 'ACCEPTED') { - rejectedNodes = root.rejected.nodes.filter((c) => c.id !== id); - rejectedCount = rejectedNodes.length < root.rejected.nodes.length ? root.rejectedCount - 1 : root.rejectedCount; - } - return update(root, { - 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}}, + [`${queue}Count`]: {$set: root[`${queue}Count`] - 1}, + [queue]: { + nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)}, + }, }); } +function isCommentInCursor(root, queue, comment, sort) { + 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 (isCommentInCursor(root, queue, comment, sort)) { + changes[queue] = { + nodes: {$apply: (nodes) => nodes.concat(comment).sort(sortAlgo)}, + }; + } + + 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; +} + +function showNotification(queue, comment, user) { + const text = `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, 50)}"`; + notification.info(text); +} + +export function handleCommentStatusChange(root, comment, {sort, notify, user, activeQueue}) { + let next = root; + const nextQueues = getCommentQueues(comment); + + queues.forEach((queue) => { + if (nextQueues.indexOf(queue) >= 0 && !queueHasComment(next, queue, comment.id)) { + next = addCommentToQueue(next, queue, comment, sort); + if (notify && activeQueue === queue && isCommentInCursor(next, queue, comment, sort)) { + showNotification(queue, comment, user); + } + } else if(queueHasComment(next, queue, comment.id)){ + next = removeCommentFromQueue(next, queue, comment.id); + if (notify && activeQueue === queue) { + showNotification(queue, comment, user); + } + } + + // TODO: All notification + // TODO: Flagged notification + // TODO: Edited notification + }); + return next; +} + diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 6b7a9bf1b..25db5f2fc 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -10,7 +10,7 @@ import t, {timeago} from 'coral-framework/services/i18n'; import update from 'immutability-helper'; import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations'; -import {handleCommentStatusChange, findCommentInModQueues} from '../../../graphql/utils'; +import {handleCommentStatusChange} from '../../../graphql/utils'; import {fetchSettings} from 'actions/settings'; import {updateAssets} from 'actions/assets'; @@ -31,10 +31,6 @@ import {Spinner} from 'coral-ui'; import Moderation from '../components/Moderation'; import Comment from './Comment'; -function truncate(s, length = 10) { - return (s.length > length) ? `${s.substring(0, length)}...` : s; -} - class ModerationContainer extends Component { unsubscribe = null; @@ -47,25 +43,18 @@ class ModerationContainer extends Component { asset_id: this.props.data.variables.asset_id, }, updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: {user, comment, previous}}}}) => { - const activeTab = this.activeTab; - - // Status changed was caused by a different user. - if (user && user.id !== this.props.auth.user.id) { - if (findCommentInModQueues(prev, comment.id) && ( - activeTab === 'all' && findCommentInModQueues(prev, comment.id, ['all']) - || activeTab === 'premod' && previous.status === 'PREMOD' - || activeTab === 'flagged' && findCommentInModQueues(prev, comment.id, ['flagged']) - || comment.status === 'ACCEPTED' && activeTab === 'accepted' - || comment.status !== 'ACCEPTED' && previous.status === 'ACCEPTED' && activeTab === 'accepted' - || comment.status === 'REJECTED' && activeTab === 'rejected' - || comment.status !== 'REJECTED' && previous.status === 'REJECTED' && activeTab === 'rejected' - ) - ) { - const text = `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, 50)}"`; - notification.info(text); - } - } - return handleCommentStatusChange(prev, comment, previous.status, user); + const extraParams = this.props.auth.user.id === user.id + ? {} + : { + notify: true, + user, + activeQueue: this.activeTab, + previous, + }; + return handleCommentStatusChange(prev, comment, { + sort: this.props.moderation.sortOrder, + ...extraParams, + }); }, }); } @@ -218,6 +207,22 @@ const STATUS_CHANGED_SUBSCRIPTION = gql` id status body + created_at + action_summaries { + count + ... on FlagActionSummary { + reason + } + } + actions { + ... on FlagAction { + reason + message + user { + username + } + } + } } previous { status From 303b3ea0f50fd11bd1e095ef31a647b53825f023 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 15 Jun 2017 22:46:38 +0700 Subject: [PATCH 03/20] Support live comment edit update --- client/coral-admin/src/graphql/utils.js | 29 +++- .../routes/Moderation/components/Comment.js | 130 ++++++++++-------- .../Moderation/components/ModerationQueue.js | 14 +- .../routes/Moderation/components/styles.css | 33 +++++ .../routes/Moderation/containers/Comment.js | 4 + .../Moderation/containers/Moderation.js | 50 +++++-- graph/mutators/comment.js | 4 - graph/resolvers/root_mutation.js | 12 +- graph/subscriptions.js | 8 +- graph/typeDefs.graphql | 3 +- package.json | 1 + perms/constants.js | 2 + perms/subscriptionReducer.js | 4 + plugin-api/beta/server/getReactionConfig.js | 14 +- yarn.lock | 4 + 15 files changed, 221 insertions(+), 91 deletions(-) diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index d42029df4..62126f1af 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -90,10 +90,12 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac const nextQueues = getCommentQueues(comment); queues.forEach((queue) => { - if (nextQueues.indexOf(queue) >= 0 && !queueHasComment(next, queue, comment.id)) { - next = addCommentToQueue(next, queue, comment, sort); - if (notify && activeQueue === queue && isCommentInCursor(next, queue, comment, sort)) { - showNotification(queue, comment, user); + if (nextQueues.indexOf(queue) >= 0) { + if (!queueHasComment(next, queue, comment.id)) { + next = addCommentToQueue(next, queue, comment, sort); + if (notify && activeQueue === queue && isCommentInCursor(next, queue, comment, sort)) { + showNotification(queue, comment, user); + } } } else if(queueHasComment(next, queue, comment.id)){ next = removeCommentFromQueue(next, queue, comment.id); @@ -102,10 +104,27 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac } } - // TODO: All notification + if ( + queue === 'all' + && queueHasComment(next, queue, comment.id) + && notify + && activeQueue === queue + ) { + showNotification(queue, comment, user); + } + // TODO: Flagged notification // TODO: Edited notification }); return next; } +export function handleCommentEdit(root, comment, {sort, activeQueue}) { + if ( + queueHasComment(root, activeQueue, comment.id) + ) { + const text = `${comment.user.username} edited comment to "${truncate(comment.body, 50)}"`; + notification.info(text); + } + return handleCommentStatusChange(root, comment, {sort, activeQueue}); +} diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 814b87c82..18f3aa057 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -13,6 +13,8 @@ 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(); @@ -91,6 +93,11 @@ class Comment extends React.Component { {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)} + { + (comment.editing && comment.editing.edited) + ?  (Edited) + : null + } {props.currentUserId !== comment.user.id && {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}))} - /> - ); - })} -
    + +
    +

    + + {' '} + + {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}))} + /> + ); + })} +
    + +
    -
    +
    0) { + this.loadMore(); + } + } + componentDidUpdate (prev) { const {comments, commentCount} = this.props; @@ -66,8 +74,7 @@ class ModerationQueue extends React.Component { transitionLeaveTimeout={1000} > { - 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')}

    } + { + updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: {user, comment}}}}) => { const extraParams = this.props.auth.user.id === user.id ? {} : { notify: true, user, activeQueue: this.activeTab, - previous, }; return handleCommentStatusChange(prev, comment, { sort: this.props.moderation.sortOrder, @@ -57,14 +56,25 @@ class ModerationContainer extends Component { }); }, }); + + const sub2 = this.props.data.subscribeToMore({ + document: COMMENTS_EDITED_SUBSCRIPTION, + variables: { + asset_id: this.props.data.variables.asset_id, + }, + updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { + return handleCommentEdit(prev, commentEdited, { + activeQueue: this.activeTab, + }); + }, + }); + + this.subscriptions.push(sub1, sub2); } unsubscribe() { - if (!this.unsubscribe) { - return; - } - this.unsubscribe(); - this.unsubscribe = null; + this.subscriptions.forEach((unsubscribe) => unsubscribe()); + this.subscriptions = []; } resubscribe() { @@ -196,6 +206,23 @@ class ModerationContainer extends Component { } } +const COMMENTS_EDITED_SUBSCRIPTION = gql` + subscription CommentEdited($asset_id: ID){ + commentEdited(asset_id: $asset_id){ + id + body + status + editing { + edited + } + user { + id + username + } + } + } +`; + const STATUS_CHANGED_SUBSCRIPTION = gql` subscription CommentStatusChanged($asset_id: ID){ commentStatusChanged(asset_id: $asset_id){ @@ -224,9 +251,6 @@ const STATUS_CHANGED_SUBSCRIPTION = gql` } } } - previous { - status - } } } `; diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index e11eaf2b9..d9dd47a9a 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -370,11 +370,7 @@ const edit = async (context, {id, asset_id, edit: {body}}) => { // Publish the edited comment via the subscription. context.pubsub.publish('commentEdited', comment); - - // Publish the comment status change via the subscription. - context.pubsub.publish('commentStatusChanged', comment); } - return comment; }; diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 087ec94bc..83886f0d1 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -31,14 +31,16 @@ const RootMutation = { stopIgnoringUser(_, {id}, {mutators: {User}}) { return wrapResponse(null)(User.stopIgnoringUser({id})); }, - setCommentStatus: async (_, {id, status}, {loaders: {Comments}, mutators: {Comment}, user, pubsub}) => { - const previous = await Comments.get.load(id); + setCommentStatus: async (_, {id, status}, {mutators: {Comment}, user, pubsub}) => { const comment = await Comment.setStatus({id, status}); - // Publish the comment status change via the subscription. - pubsub.publish('commentStatusChanged', {user, comment, previous}); + if (pubsub) { - return wrapResponse(null)(Comment.setStatus({id, status})); + // Publish the comment status change via the subscription. + pubsub.publish('commentStatusChanged', {user, comment}); + } + + return wrapResponse(null)(comment); }, addTag(_, {tag}, {mutators: {Tag}}) { return wrapResponse(null)(Tag.add(tag)); diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 2192dbdcf..9b3df6b0e 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -12,6 +12,7 @@ const {deserializeUser} = require('../services/subscriptions'); const { SUBSCRIBE_COMMENT_STATUS, + SUBSCRIBE_ALL_COMMENT_EDITS, } = require('../perms/constants'); /** @@ -31,7 +32,12 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu }), 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_EDITS))) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } }, }), commentStatusChanged: (options, args) => ({ diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index c1dfa0b7d..2bd7c0476 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -940,12 +940,11 @@ type RootMutation { type CommentStatusChangedUpdate { user: User comment: Comment - previous: Comment } type Subscription { commentAdded(asset_id: ID!): Comment - commentEdited(asset_id: ID!): Comment + commentEdited(asset_id: ID): Comment commentStatusChanged(asset_id: ID): CommentStatusChangedUpdate } diff --git a/package.json b/package.json index 92bcd2be4..5724e96ca 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,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", diff --git a/perms/constants.js b/perms/constants.js index 1578946cf..0b14932f3 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -25,4 +25,6 @@ module.exports = { // subscriptions SUBSCRIBE_COMMENT_STATUS: 'SUBSCRIBE_COMMENT_STATUS', + SUBSCRIBE_ALL_COMMENT_FLAGS: 'SUBSCRIBE_ALL_COMMENT_FLAGS', + SUBSCRIBE_ALL_COMMENT_EDITS: 'SUBSCRIBE_ALL_COMMENT_EDITS', }; diff --git a/perms/subscriptionReducer.js b/perms/subscriptionReducer.js index 4ce817ea5..e2570b272 100644 --- a/perms/subscriptionReducer.js +++ b/perms/subscriptionReducer.js @@ -5,6 +5,10 @@ module.exports = (user, perm) => { switch (perm) { case types.SUBSCRIBE_COMMENT_STATUS: return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_COMMENT_EDITS: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_COMMENT_FLAGS: + 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/yarn.lock b/yarn.lock index 6013a3e49..1633c27d1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5387,6 +5387,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" From cbcb84f7ac18ab960c6b84d69552eb9e9ab75f5d Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 15 Jun 2017 22:54:29 +0700 Subject: [PATCH 04/20] No need for editableUntil --- client/coral-admin/src/routes/Moderation/containers/Comment.js | 1 - 1 file changed, 1 deletion(-) diff --git a/client/coral-admin/src/routes/Moderation/containers/Comment.js b/client/coral-admin/src/routes/Moderation/containers/Comment.js index ab77ec7bb..26074065d 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Comment.js +++ b/client/coral-admin/src/routes/Moderation/containers/Comment.js @@ -51,7 +51,6 @@ export default withFragments({ } editing { edited - editableUntil } ${pluginFragments.spreads('comment')} } From 21f95c33c2afe27175e77b879c2da6809ebe91e1 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 15 Jun 2017 22:54:36 +0700 Subject: [PATCH 05/20] Use correct comment id in UserDetail --- .../coral-admin/src/routes/Moderation/components/UserDetail.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetail.js b/client/coral-admin/src/routes/Moderation/components/UserDetail.js index 688ac75d0..f416161a1 100644 --- a/client/coral-admin/src/routes/Moderation/components/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/components/UserDetail.js @@ -147,7 +147,7 @@ export default class UserDetail extends React.Component { const status = comment.action_summaries ? 'FLAGGED' : comment.status; const selected = selectedIds.indexOf(comment.id) !== -1; return Date: Thu, 15 Jun 2017 23:27:31 +0700 Subject: [PATCH 06/20] More mod queue fixes --- client/coral-admin/src/graphql/utils.js | 20 +++++++++----- .../Moderation/components/ModerationQueue.js | 4 --- .../Moderation/containers/Moderation.js | 27 ++----------------- graph/resolvers/root_mutation.js | 18 +++++++------ 4 files changed, 26 insertions(+), 43 deletions(-) diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index 62126f1af..9d85c6bb1 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -2,6 +2,7 @@ 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); @@ -25,7 +26,6 @@ function removeCommentFromQueue(root, queue, id) { if (!queueHasComment(root, queue, id)) { return root; } - return update(root, { [`${queue}Count`]: {$set: root[`${queue}Count`] - 1}, [queue]: { @@ -34,7 +34,12 @@ function removeCommentFromQueue(root, queue, id) { }); } -function isCommentInCursor(root, queue, comment, sort) { +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 @@ -51,9 +56,12 @@ function addCommentToQueue(root, queue, comment, sort) { [`${queue}Count`]: {$set: root[`${queue}Count`] + 1}, }; - if (isCommentInCursor(root, queue, comment, sort)) { + if (shouldCommentBeAdded(root, queue, comment, sort)) { + const nodes = root[queue].nodes.concat(comment).sort(sortAlgo); changes[queue] = { - nodes: {$apply: (nodes) => nodes.concat(comment).sort(sortAlgo)}, + nodes: {$set: nodes}, + startCursor: {$set: nodes[0].created_at}, + endCursor: {$set: nodes[nodes.length - 1].created_at}, }; } @@ -93,7 +101,7 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { next = addCommentToQueue(next, queue, comment, sort); - if (notify && activeQueue === queue && isCommentInCursor(next, queue, comment, sort)) { + if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) { showNotification(queue, comment, user); } } @@ -114,7 +122,6 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac } // TODO: Flagged notification - // TODO: Edited notification }); return next; } @@ -122,6 +129,7 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac export function handleCommentEdit(root, comment, {sort, activeQueue}) { if ( queueHasComment(root, activeQueue, comment.id) + || comment.status === 'PREMOD' && root[`${activeQueue}Count`] < limit ) { const text = `${comment.user.username} edited comment to "${truncate(comment.body, 50)}"`; notification.info(text); diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index 2e039c635..a0a11ab45 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -28,10 +28,6 @@ class ModerationQueue extends React.Component { constructor(props) { super(props); - - if (props.comments.length === 0 && props.commentCount > 0) { - this.loadMore(); - } } componentDidUpdate (prev) { diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 706f7a5ee..d693863e0 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -231,28 +231,11 @@ const STATUS_CHANGED_SUBSCRIPTION = gql` username } comment { - id - status - body - created_at - action_summaries { - count - ... on FlagActionSummary { - reason - } - } - actions { - ... on FlagAction { - reason - message - user { - username - } - } - } + ...${getDefinitionName(Comment.fragments.comment)} } } } + ${Comment.fragments.comment} `; const LOAD_MORE_QUERY = gql` @@ -260,12 +243,6 @@ const LOAD_MORE_QUERY = gql` 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 diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index 83886f0d1..ac2fd729d 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -31,16 +31,18 @@ const RootMutation = { stopIgnoringUser(_, {id}, {mutators: {User}}) { return wrapResponse(null)(User.stopIgnoringUser({id})); }, - setCommentStatus: async (_, {id, status}, {mutators: {Comment}, user, pubsub}) => { - const comment = await Comment.setStatus({id, status}); + setCommentStatus(_, {id, status}, {mutators: {Comment}, user, pubsub}) { + const response = Comment.setStatus({id, status}) + .then((comment) => { + if (pubsub) { - if (pubsub) { + // Publish the comment status change via the subscription. + pubsub.publish('commentStatusChanged', {user, comment}); + } + return Promise.resolve(comment); + }); - // Publish the comment status change via the subscription. - pubsub.publish('commentStatusChanged', {user, comment}); - } - - return wrapResponse(null)(comment); + return wrapResponse(null)(response); }, addTag(_, {tag}, {mutators: {Tag}}) { return wrapResponse(null)(Tag.add(tag)); From 5a2ce0f6a7c68fa53527c7ab7c809d09ab87c7e7 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Thu, 15 Jun 2017 23:31:30 +0700 Subject: [PATCH 07/20] No div in p --- .../src/routes/Moderation/components/ModerationQueue.js | 6 +++++- .../coral-admin/src/routes/Moderation/components/styles.css | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js index a0a11ab45..9ccde344d 100644 --- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js +++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js @@ -93,7 +93,11 @@ class ModerationQueue extends React.Component { }) } - {comments.length === 0 &&

    {t('modqueue.empty_queue')}

    } + {comments.length === 0 && +
    + {t('modqueue.empty_queue')} +
    + } Date: Fri, 16 Jun 2017 00:43:48 +0700 Subject: [PATCH 08/20] Fix some queries --- .../routes/Moderation/components/BanUserDialog.js | 2 +- .../src/routes/Moderation/components/Comment.js | 4 ++-- .../src/routes/Moderation/containers/Comment.js | 2 +- .../src/routes/Moderation/containers/Moderation.js | 12 ++---------- 4 files changed, 6 insertions(+), 14 deletions(-) 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 18f3aa057..2a5f44f28 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -76,7 +76,7 @@ class Comment extends React.Component { { !minimal && ( viewUserDetail(comment.user.id)}> - {comment.user.name} + {comment.user.username} ) } @@ -102,7 +102,7 @@ class Comment extends React.Component { props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}> + onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.username, comment.id, comment.status)}> Suspend User Date: Fri, 16 Jun 2017 00:50:44 +0700 Subject: [PATCH 09/20] Only show notification once --- client/coral-admin/src/graphql/utils.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index 9d85c6bb1..1a3262529 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -97,18 +97,27 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac let next = root; const nextQueues = getCommentQueues(comment); + let notificationShown = false; + const showNotificationOnce = (...args) => { + if (notificationShown) { + return; + } + showNotification(...args); + notificationShown = true; + }; + queues.forEach((queue) => { if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { next = addCommentToQueue(next, queue, comment, sort); if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) { - showNotification(queue, comment, user); + showNotificationOnce(queue, comment, user); } } } else if(queueHasComment(next, queue, comment.id)){ next = removeCommentFromQueue(next, queue, comment.id); if (notify && activeQueue === queue) { - showNotification(queue, comment, user); + showNotificationOnce(queue, comment, user); } } @@ -118,7 +127,7 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac && notify && activeQueue === queue ) { - showNotification(queue, comment, user); + showNotificationOnce(queue, comment, user); } // TODO: Flagged notification From ecf63ffb55b29a764b7e066259e4845f9642bda7 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 16 Jun 2017 01:49:35 +0700 Subject: [PATCH 10/20] More accurate edit notification --- client/coral-admin/src/graphql/utils.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index 1a3262529..407a4e53b 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -138,7 +138,7 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac export function handleCommentEdit(root, comment, {sort, activeQueue}) { if ( queueHasComment(root, activeQueue, comment.id) - || comment.status === 'PREMOD' && root[`${activeQueue}Count`] < limit + || comment.status.toLowerCase() === activeQueue && shouldCommentBeAdded(root, activeQueue, comment, sort) ) { const text = `${comment.user.username} edited comment to "${truncate(comment.body, 50)}"`; notification.info(text); From 5f450adbaf28eb2d454af67409bfe6552014a3b3 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 16 Jun 2017 19:18:21 +0700 Subject: [PATCH 11/20] Simplify subscription --- client/coral-admin/src/graphql/utils.js | 11 +++++---- .../Moderation/containers/Moderation.js | 19 ++++++++------- graph/mutators/comment.js | 6 +++++ graph/resolvers/comment_status_history.js | 11 +++++++++ graph/resolvers/index.js | 2 ++ graph/resolvers/root_mutation.js | 14 ++--------- graph/resolvers/subscription.js | 4 ++-- graph/typeDefs.graphql | 23 +++++++++++++------ perms/constants.js | 1 + perms/queryReducer.js | 2 ++ 10 files changed, 59 insertions(+), 34 deletions(-) create mode 100644 graph/resolvers/comment_status_history.js diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index 407a4e53b..0b7384622 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -93,7 +93,7 @@ function showNotification(queue, comment, user) { notification.info(text); } -export function handleCommentStatusChange(root, comment, {sort, notify, user, activeQueue}) { +export function handleCommentStatusChange(root, comment, {sort, notify, activeQueue}) { let next = root; const nextQueues = getCommentQueues(comment); @@ -102,7 +102,8 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac if (notificationShown) { return; } - showNotification(...args); + const user = comment.status_history[comment.status_history.length - 1].assigned_by; + showNotification(...[...args, user]); notificationShown = true; }; @@ -111,13 +112,13 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac if (!queueHasComment(next, queue, comment.id)) { next = addCommentToQueue(next, queue, comment, sort); if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) { - showNotificationOnce(queue, comment, user); + showNotificationOnce(queue, comment); } } } else if(queueHasComment(next, queue, comment.id)){ next = removeCommentFromQueue(next, queue, comment.id); if (notify && activeQueue === queue) { - showNotificationOnce(queue, comment, user); + showNotificationOnce(queue, comment); } } @@ -127,7 +128,7 @@ export function handleCommentStatusChange(root, comment, {sort, notify, user, ac && notify && activeQueue === queue ) { - showNotificationOnce(queue, comment, user); + showNotificationOnce(queue, comment); } // TODO: Flagged notification diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 737afdac9..ede004c87 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -42,12 +42,13 @@ class ModerationContainer extends Component { variables: { asset_id: this.props.data.variables.asset_id, }, - updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: {user, comment}}}}) => { + updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: comment}}}) => { + const user = comment.status_history[comment.status_history.length - 1].assigned_by; + const extraParams = this.props.auth.user.id === user.id ? {} : { notify: true, - user, activeQueue: this.activeTab, }; return handleCommentStatusChange(prev, comment, { @@ -218,12 +219,14 @@ const COMMENTS_EDITED_SUBSCRIPTION = gql` const STATUS_CHANGED_SUBSCRIPTION = gql` subscription CommentStatusChanged($asset_id: ID){ commentStatusChanged(asset_id: $asset_id){ - user { - id - username - } - comment { - ...${getDefinitionName(Comment.fragments.comment)} + ...${getDefinitionName(Comment.fragments.comment)} + status_history { + type + created_at + assigned_by { + id + username + } } } } diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index d9dd47a9a..8d01ededf 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -346,6 +346,12 @@ const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => { // adjust the affected user's karma in the next tick. process.nextTick(adjustKarma(Comments, id, status)); + if (pubsub) { + + // Publish the comment status change via the subscription. + pubsub.publish('commentStatusChanged', 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/root_mutation.js b/graph/resolvers/root_mutation.js index ac2fd729d..f192405cc 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -31,18 +31,8 @@ const RootMutation = { stopIgnoringUser(_, {id}, {mutators: {User}}) { return wrapResponse(null)(User.stopIgnoringUser({id})); }, - setCommentStatus(_, {id, status}, {mutators: {Comment}, user, pubsub}) { - const response = Comment.setStatus({id, status}) - .then((comment) => { - if (pubsub) { - - // Publish the comment status change via the subscription. - pubsub.publish('commentStatusChanged', {user, comment}); - } - return Promise.resolve(comment); - }); - - return wrapResponse(null)(response); + setCommentStatus(_, {id, status}, {mutators: {Comment}}) { + return wrapResponse(null)(Comment.setStatus({id, status})); }, addTag(_, {tag}, {mutators: {Tag}}) { return wrapResponse(null)(Tag.add(tag)); diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index 8a989cc81..cc98b19da 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -5,8 +5,8 @@ const Subscription = { commentEdited(comment) { return comment; }, - commentStatusChanged(data) { - return data; + commentStatusChanged(comment) { + return comment; }, }; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 2bd7c0476..d2c927995 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -247,6 +247,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 { @@ -286,6 +292,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! @@ -936,16 +945,16 @@ type RootMutation { ## Subscriptions ################################################################################ -# Response to ignoreUser mutation -type CommentStatusChangedUpdate { - user: User - comment: Comment -} - type Subscription { + + # Get an update whenever a comment was added. commentAdded(asset_id: ID!): Comment + + # Get an update whenever a comment was edited. commentEdited(asset_id: ID): Comment - commentStatusChanged(asset_id: ID): CommentStatusChangedUpdate + + # Get an update whenever the status of a comment changed due to a moderator action. + commentStatusChanged(asset_id: ID): Comment } ################################################################################ diff --git a/perms/constants.js b/perms/constants.js index 0b14932f3..37c5c3814 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -22,6 +22,7 @@ module.exports = { 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_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY', // subscriptions SUBSCRIBE_COMMENT_STATUS: 'SUBSCRIBE_COMMENT_STATUS', 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; } From 2625b372e197e44e510797a832afec48cca0d03c Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 16 Jun 2017 20:06:13 +0700 Subject: [PATCH 12/20] Handle comment flags --- client/coral-admin/src/graphql/utils.js | 45 ++++------------ .../Moderation/containers/Moderation.js | 51 +++++++++++++++---- graph/mutators/action.js | 15 +++++- graph/resolvers/subscription.js | 3 ++ graph/subscriptions.js | 19 ++++++- graph/typeDefs.graphql | 9 +++- perms/constants.js | 3 +- perms/subscriptionReducer.js | 4 +- 8 files changed, 101 insertions(+), 48 deletions(-) diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index 0b7384622..8d4418806 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -14,10 +14,6 @@ const ascending = (a, b) => { const descending = (a, b) => -ascending(a, b); -function truncate(s, length = 10) { - return (s.length > length) ? `${s.substring(0, length)}...` : s; -} - function queueHasComment(root, queue, id) { return root[queue].nodes.find((c) => c.id === id); } @@ -88,22 +84,16 @@ function getCommentQueues(comment) { return queues; } -function showNotification(queue, comment, user) { - const text = `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, 50)}"`; - notification.info(text); -} - -export function handleCommentStatusChange(root, comment, {sort, notify, activeQueue}) { +export function handleCommentStatusChange(root, comment, {sort, notify}) { let next = root; const nextQueues = getCommentQueues(comment); let notificationShown = false; - const showNotificationOnce = (...args) => { + const showNotificationOnce = () => { if (notificationShown) { return; } - const user = comment.status_history[comment.status_history.length - 1].assigned_by; - showNotification(...[...args, user]); + notification.info(notify.text); notificationShown = true; }; @@ -111,38 +101,25 @@ export function handleCommentStatusChange(root, comment, {sort, notify, activeQu if (nextQueues.indexOf(queue) >= 0) { if (!queueHasComment(next, queue, comment.id)) { next = addCommentToQueue(next, queue, comment, sort); - if (notify && activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) { - showNotificationOnce(queue, comment); + 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 && activeQueue === queue) { - showNotificationOnce(queue, comment); + if (notify && notify.activeQueue === queue) { + showNotificationOnce(comment); } } if ( - queue === 'all' + notify + && (queue === 'all' || notify.anyQueue) && queueHasComment(next, queue, comment.id) - && notify - && activeQueue === queue + && notify.activeQueue === queue ) { - showNotificationOnce(queue, comment); + showNotificationOnce(comment); } - - // TODO: Flagged notification }); return next; } - -export function handleCommentEdit(root, comment, {sort, activeQueue}) { - if ( - queueHasComment(root, activeQueue, comment.id) - || comment.status.toLowerCase() === activeQueue && shouldCommentBeAdded(root, activeQueue, comment, sort) - ) { - const text = `${comment.user.username} edited comment to "${truncate(comment.body, 50)}"`; - notification.info(text); - } - return handleCommentStatusChange(root, comment, {sort, activeQueue}); -} diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index ede004c87..e1bd18435 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -8,9 +8,10 @@ import {getDefinitionName} from 'coral-framework/utils'; import * as notification from 'coral-admin/src/services/notification'; import t, {timeago} from 'coral-framework/services/i18n'; import update from 'immutability-helper'; +import truncate from 'lodash/truncate'; import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations'; -import {handleCommentStatusChange, handleCommentEdit} from '../../../graphql/utils'; +import {handleCommentStatusChange} from '../../../graphql/utils'; import {fetchSettings} from 'actions/settings'; import {updateAssets} from 'actions/assets'; @@ -44,16 +45,16 @@ class ModerationContainer extends Component { }, updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: comment}}}) => { const user = comment.status_history[comment.status_history.length - 1].assigned_by; - - const extraParams = this.props.auth.user.id === user.id + const notify = this.props.auth.user.id === user.id ? {} : { - notify: true, activeQueue: this.activeTab, + text: `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, {lenght: 50})}"`, + anyQueue: false, }; return handleCommentStatusChange(prev, comment, { sort: this.props.moderation.sortOrder, - ...extraParams, + notify, }); }, }); @@ -63,14 +64,37 @@ class ModerationContainer extends Component { variables: { asset_id: this.props.data.variables.asset_id, }, - updateQuery: (prev, {subscriptionData: {data: {commentEdited}}}) => { - return handleCommentEdit(prev, commentEdited, { - activeQueue: this.activeTab, + updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { + return handleCommentStatusChange(prev, comment, { + sort: this.props.moderation.sortOrder, + notify: { + activeQueue: this.activeTab, + text: `${comment.user.username} edited comment to "${truncate(comment.body, {lenght: 50})}"`, + anyQueue: false, + }, }); }, }); - this.subscriptions.push(sub1, sub2); + const sub3 = this.props.data.subscribeToMore({ + document: COMMENTS_FLAGGED_SUBSCRIPTION, + variables: { + asset_id: this.props.data.variables.asset_id, + }, + updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { + const user = comment.actions[comment.actions.length - 1].user; + return handleCommentStatusChange(prev, comment, { + sort: this.props.moderation.sortOrder, + notify: { + activeQueue: this.activeTab, + text: `${user.username} flagged comment "${truncate(comment.body, {lenght: 50})}"`, + anyQueue: true, + }, + }); + }, + }); + + this.subscriptions.push(sub1, sub2, sub3); } unsubscribe() { @@ -216,6 +240,15 @@ const COMMENTS_EDITED_SUBSCRIPTION = gql` ${Comment.fragments.comment} `; +const COMMENTS_FLAGGED_SUBSCRIPTION = gql` + subscription CommentFlagged($asset_id: ID){ + commentFlagged(asset_id: $asset_id){ + ...${getDefinitionName(Comment.fragments.comment)} + } + } + ${Comment.fragments.comment} +`; + const STATUS_CHANGED_SUBSCRIPTION = gql` subscription CommentStatusChanged($asset_id: ID){ commentStatusChanged(asset_id: $asset_id){ 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/resolvers/subscription.js b/graph/resolvers/subscription.js index cc98b19da..3c2130ff1 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -8,6 +8,9 @@ const Subscription = { commentStatusChanged(comment) { return comment; }, + commentFlagged(comment) { + return comment; + }, }; module.exports = Subscription; diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 9b3df6b0e..494345319 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -12,7 +12,9 @@ const {deserializeUser} = require('../services/subscriptions'); const { SUBSCRIBE_COMMENT_STATUS, + SUBSCRIBE_COMMENT_FLAGS, SUBSCRIBE_ALL_COMMENT_EDITS, + SUBSCRIBE_ALL_COMMENT_ADDITIONS, } = require('../perms/constants'); /** @@ -27,7 +29,12 @@ 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_ADDITIONS))) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } }, }), commentEdited: (options, args) => ({ @@ -40,6 +47,16 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu } }, }), + commentFlagged: (options, args) => ({ + commentFlagged: { + filter: ({comment}, context) => { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGS)) { + return false; + } + return !args.asset_id || comment.asset_id === args.asset_id; + } + }, + }), commentStatusChanged: (options, args) => ({ commentStatusChanged: { filter: ({comment}, context) => { diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index d2c927995..948d0672f 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -948,12 +948,19 @@ type RootMutation { type Subscription { # Get an update whenever a comment was added. - commentAdded(asset_id: ID!): Comment + # `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 the status of a comment changed due to a moderator action. + # Requires the `ADMIN` or `MODERATOR` role. commentStatusChanged(asset_id: ID): Comment } diff --git a/perms/constants.js b/perms/constants.js index 37c5c3814..8f92c09a3 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -26,6 +26,7 @@ module.exports = { // subscriptions SUBSCRIBE_COMMENT_STATUS: 'SUBSCRIBE_COMMENT_STATUS', - SUBSCRIBE_ALL_COMMENT_FLAGS: 'SUBSCRIBE_ALL_COMMENT_FLAGS', + SUBSCRIBE_COMMENT_FLAGS: 'SUBSCRIBE_COMMENT_FLAGS', + SUBSCRIBE_ALL_COMMENT_ADDITIONS: 'SUBSCRIBE_ALL_COMMENT_ADDITIONS', SUBSCRIBE_ALL_COMMENT_EDITS: 'SUBSCRIBE_ALL_COMMENT_EDITS', }; diff --git a/perms/subscriptionReducer.js b/perms/subscriptionReducer.js index e2570b272..218443ead 100644 --- a/perms/subscriptionReducer.js +++ b/perms/subscriptionReducer.js @@ -3,11 +3,13 @@ const types = require('./constants'); module.exports = (user, perm) => { switch (perm) { + case types.SUBSCRIBE_COMMENT_FLAGS: + return check(user, ['ADMIN', 'MODERATOR']); case types.SUBSCRIBE_COMMENT_STATUS: return check(user, ['ADMIN', 'MODERATOR']); case types.SUBSCRIBE_ALL_COMMENT_EDITS: return check(user, ['ADMIN', 'MODERATOR']); - case types.SUBSCRIBE_ALL_COMMENT_FLAGS: + case types.SUBSCRIBE_ALL_COMMENT_ADDITIONS: return check(user, ['ADMIN', 'MODERATOR']); default: break; From 9573c9a7afd006ff57296148def24e0d8eabb18d Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 16 Jun 2017 20:49:17 +0700 Subject: [PATCH 13/20] Refactor & docs --- client/coral-admin/src/graphql/utils.js | 14 ++++++- .../Moderation/containers/Moderation.js | 38 +++++++++---------- 2 files changed, 30 insertions(+), 22 deletions(-) diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js index 8d4418806..331fd876a 100644 --- a/client/coral-admin/src/graphql/utils.js +++ b/client/coral-admin/src/graphql/utils.js @@ -84,7 +84,19 @@ function getCommentQueues(comment) { return queues; } -export function handleCommentStatusChange(root, comment, {sort, notify}) { +/** + * 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); diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index e1bd18435..1d75b3838 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -11,7 +11,7 @@ import update from 'immutability-helper'; import truncate from 'lodash/truncate'; import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations'; -import {handleCommentStatusChange} from '../../../graphql/utils'; +import {handleCommentChange} from '../../../graphql/utils'; import {fetchSettings} from 'actions/settings'; import {updateAssets} from 'actions/assets'; @@ -45,6 +45,7 @@ class ModerationContainer extends Component { }, updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: 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 ? {} : { @@ -52,10 +53,7 @@ class ModerationContainer extends Component { text: `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, {lenght: 50})}"`, anyQueue: false, }; - return handleCommentStatusChange(prev, comment, { - sort: this.props.moderation.sortOrder, - notify, - }); + return handleCommentChange(prev, comment, sort, notify); }, }); @@ -65,14 +63,13 @@ class ModerationContainer extends Component { asset_id: this.props.data.variables.asset_id, }, updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { - return handleCommentStatusChange(prev, comment, { - sort: this.props.moderation.sortOrder, - notify: { - activeQueue: this.activeTab, - text: `${comment.user.username} edited comment to "${truncate(comment.body, {lenght: 50})}"`, - anyQueue: false, - }, - }); + const sort = this.props.moderation.sortOrder; + const notify = { + activeQueue: this.activeTab, + text: `${comment.user.username} edited comment to "${truncate(comment.body, {lenght: 50})}"`, + anyQueue: false, + }; + return handleCommentChange(prev, comment, sort, notify); }, }); @@ -83,14 +80,13 @@ class ModerationContainer extends Component { }, updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { const user = comment.actions[comment.actions.length - 1].user; - return handleCommentStatusChange(prev, comment, { - sort: this.props.moderation.sortOrder, - notify: { - activeQueue: this.activeTab, - text: `${user.username} flagged comment "${truncate(comment.body, {lenght: 50})}"`, - anyQueue: true, - }, - }); + const sort = this.props.moderation.sortOrder; + const notify = { + activeQueue: this.activeTab, + text: `${user.username} flagged comment "${truncate(comment.body, {lenght: 50})}"`, + anyQueue: true, + }; + return handleCommentChange(prev, comment, sort, notify); }, }); From 74fbadcc0f85223fde5629d6820badeeaddfcc95 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 16 Jun 2017 22:55:22 +0700 Subject: [PATCH 14/20] Refactor subscriptions --- .../Moderation/containers/Moderation.js | 60 +++++++++++++++---- graph/mutators/comment.js | 11 +++- graph/resolvers/subscription.js | 5 +- graph/subscriptions.js | 14 ++++- graph/typeDefs.graphql | 8 ++- 5 files changed, 79 insertions(+), 19 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 1d75b3838..021ac3ceb 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -39,18 +39,18 @@ class ModerationContainer extends Component { subscribeToUpdates() { const sub1 = this.props.data.subscribeToMore({ - document: STATUS_CHANGED_SUBSCRIPTION, + document: COMMENT_ACCEPTED_SUBSCRIPTION, variables: { asset_id: this.props.data.variables.asset_id, }, - updateQuery: (prev, {subscriptionData: {data: {commentStatusChanged: comment}}}) => { + updateQuery: (prev, {subscriptionData: {data: {commentAccepted: 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: `${user.username} ${comment.status.toLowerCase()} comment "${truncate(comment.body, {lenght: 50})}"`, + text: `${user.username} accepted comment "${truncate(comment.body, {lenght: 50})}"`, anyQueue: false, }; return handleCommentChange(prev, comment, sort, notify); @@ -58,7 +58,26 @@ class ModerationContainer extends Component { }); const sub2 = this.props.data.subscribeToMore({ - document: COMMENTS_EDITED_SUBSCRIPTION, + document: COMMENT_REJECTED_SUBSCRIPTION, + variables: { + asset_id: this.props.data.variables.asset_id, + }, + 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: `${user.username} rejected comment "${truncate(comment.body, {lenght: 50})}"`, + anyQueue: false, + }; + return handleCommentChange(prev, comment, sort, notify); + }, + }); + + const sub3 = this.props.data.subscribeToMore({ + document: COMMENT_EDITED_SUBSCRIPTION, variables: { asset_id: this.props.data.variables.asset_id, }, @@ -73,8 +92,8 @@ class ModerationContainer extends Component { }, }); - const sub3 = this.props.data.subscribeToMore({ - document: COMMENTS_FLAGGED_SUBSCRIPTION, + const sub4 = this.props.data.subscribeToMore({ + document: COMMENT_FLAGGED_SUBSCRIPTION, variables: { asset_id: this.props.data.variables.asset_id, }, @@ -90,7 +109,7 @@ class ModerationContainer extends Component { }, }); - this.subscriptions.push(sub1, sub2, sub3); + this.subscriptions.push(sub1, sub2, sub3, sub4); } unsubscribe() { @@ -227,7 +246,7 @@ class ModerationContainer extends Component { } } -const COMMENTS_EDITED_SUBSCRIPTION = gql` +const COMMENT_EDITED_SUBSCRIPTION = gql` subscription CommentEdited($asset_id: ID){ commentEdited(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} @@ -236,7 +255,7 @@ const COMMENTS_EDITED_SUBSCRIPTION = gql` ${Comment.fragments.comment} `; -const COMMENTS_FLAGGED_SUBSCRIPTION = gql` +const COMMENT_FLAGGED_SUBSCRIPTION = gql` subscription CommentFlagged($asset_id: ID){ commentFlagged(asset_id: $asset_id){ ...${getDefinitionName(Comment.fragments.comment)} @@ -245,9 +264,26 @@ const COMMENTS_FLAGGED_SUBSCRIPTION = gql` ${Comment.fragments.comment} `; -const STATUS_CHANGED_SUBSCRIPTION = gql` - subscription CommentStatusChanged($asset_id: ID){ - commentStatusChanged(asset_id: $asset_id){ +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 diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 8d01ededf..df6ed71ce 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -348,8 +348,15 @@ const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => { if (pubsub) { - // Publish the comment status change via the subscription. - pubsub.publish('commentStatusChanged', comment); + 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; diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js index 3c2130ff1..1f26d5ff3 100644 --- a/graph/resolvers/subscription.js +++ b/graph/resolvers/subscription.js @@ -5,7 +5,10 @@ const Subscription = { commentEdited(comment) { return comment; }, - commentStatusChanged(comment) { + commentAccepted(comment) { + return comment; + }, + commentRejected(comment) { return comment; }, commentFlagged(comment) { diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 494345319..47899e1e3 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -57,8 +57,18 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu } }, }), - commentStatusChanged: (options, args) => ({ - commentStatusChanged: { + commentAccepted: (options, args) => ({ + commentAccepted: { + filter: ({comment}, context) => { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_STATUS)) { + 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_STATUS)) { return false; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index 948d0672f..9cd147605 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -959,9 +959,13 @@ type Subscription { # Requires the `ADMIN` or `MODERATOR` role. commentFlagged(asset_id: ID): Comment - # Get an update whenever the status of a comment changed due to a moderator action. + # Get an update whenever a comment has been accepted. # Requires the `ADMIN` or `MODERATOR` role. - commentStatusChanged(asset_id: ID): Comment + 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 } ################################################################################ From 6da00282581351f81b55dbe34a30be4970e58dc6 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Fri, 16 Jun 2017 23:00:40 +0700 Subject: [PATCH 15/20] Reset websocket after login change --- client/coral-admin/src/actions/auth.js | 3 +++ 1 file changed, 3 insertions(+) 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) => { From c14c3fbe2305ecb1e62e74b0cdb1d6a285817026 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Sat, 17 Jun 2017 00:03:33 +0700 Subject: [PATCH 16/20] Streamline perm names --- graph/subscriptions.js | 19 ++++++++++--------- perms/constants.js | 9 +++++---- perms/subscriptionReducer.js | 10 ++++++---- 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 47899e1e3..31b430523 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -11,10 +11,11 @@ const plugins = require('../services/plugins'); const {deserializeUser} = require('../services/subscriptions'); const { - SUBSCRIBE_COMMENT_STATUS, - SUBSCRIBE_COMMENT_FLAGS, - SUBSCRIBE_ALL_COMMENT_EDITS, - SUBSCRIBE_ALL_COMMENT_ADDITIONS, + SUBSCRIBE_COMMENT_ACCEPTED, + SUBSCRIBE_COMMENT_REJECTED, + SUBSCRIBE_COMMENT_FLAGGED, + SUBSCRIBE_ALL_COMMENT_EDITED, + SUBSCRIBE_ALL_COMMENT_ADDED, } = require('../perms/constants'); /** @@ -30,7 +31,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu commentAdded: (options, args) => ({ commentAdded: { filter: (comment, context) => { - if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDITIONS))) { + 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; @@ -40,7 +41,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu commentEdited: (options, args) => ({ commentEdited: { filter: (comment, context) => { - if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_EDITS))) { + 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; @@ -50,7 +51,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu commentFlagged: (options, args) => ({ commentFlagged: { filter: ({comment}, context) => { - if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGS)) { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGGED)) { return false; } return !args.asset_id || comment.asset_id === args.asset_id; @@ -60,7 +61,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu commentAccepted: (options, args) => ({ commentAccepted: { filter: ({comment}, context) => { - if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_STATUS)) { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_ACCEPTED)) { return false; } return !args.asset_id || comment.asset_id === args.asset_id; @@ -70,7 +71,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu commentRejected: (options, args) => ({ commentRejected: { filter: ({comment}, context) => { - if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_STATUS)) { + if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_REJECTED)) { return false; } return !args.asset_id || comment.asset_id === args.asset_id; diff --git a/perms/constants.js b/perms/constants.js index 8f92c09a3..8be745f36 100644 --- a/perms/constants.js +++ b/perms/constants.js @@ -25,8 +25,9 @@ module.exports = { SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY', // subscriptions - SUBSCRIBE_COMMENT_STATUS: 'SUBSCRIBE_COMMENT_STATUS', - SUBSCRIBE_COMMENT_FLAGS: 'SUBSCRIBE_COMMENT_FLAGS', - SUBSCRIBE_ALL_COMMENT_ADDITIONS: 'SUBSCRIBE_ALL_COMMENT_ADDITIONS', - SUBSCRIBE_ALL_COMMENT_EDITS: 'SUBSCRIBE_ALL_COMMENT_EDITS', + 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/subscriptionReducer.js b/perms/subscriptionReducer.js index 218443ead..0b06902cf 100644 --- a/perms/subscriptionReducer.js +++ b/perms/subscriptionReducer.js @@ -3,13 +3,15 @@ const types = require('./constants'); module.exports = (user, perm) => { switch (perm) { - case types.SUBSCRIBE_COMMENT_FLAGS: + case types.SUBSCRIBE_COMMENT_FLAGGED: return check(user, ['ADMIN', 'MODERATOR']); - case types.SUBSCRIBE_COMMENT_STATUS: + case types.SUBSCRIBE_COMMENT_ACCEPTED: return check(user, ['ADMIN', 'MODERATOR']); - case types.SUBSCRIBE_ALL_COMMENT_EDITS: + case types.SUBSCRIBE_COMMENT_REJECTED: return check(user, ['ADMIN', 'MODERATOR']); - case types.SUBSCRIBE_ALL_COMMENT_ADDITIONS: + case types.SUBSCRIBE_ALL_COMMENT_EDITED: + return check(user, ['ADMIN', 'MODERATOR']); + case types.SUBSCRIBE_ALL_COMMENT_ADDED: return check(user, ['ADMIN', 'MODERATOR']); default: break; From 70276c3358289bccc1f78b56cdc6755326c5362c Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 20 Jun 2017 01:01:39 +0700 Subject: [PATCH 17/20] Fix subscription issues --- .../Moderation/containers/Moderation.js | 25 +++++++------------ graph/subscriptions.js | 6 ++--- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index f9aa4c2c5..12a991454 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -38,12 +38,10 @@ class ModerationContainer extends Component { get activeTab() { return this.props.route.path === ':id' ? 'premod' : this.props.route.path; } - subscribeToUpdates() { + subscribeToUpdates(variables = this.props.data.variables) { const sub1 = this.props.data.subscribeToMore({ document: COMMENT_ACCEPTED_SUBSCRIPTION, - variables: { - asset_id: this.props.data.variables.asset_id, - }, + variables, updateQuery: (prev, {subscriptionData: {data: {commentAccepted: comment}}}) => { const user = comment.status_history[comment.status_history.length - 1].assigned_by; const sort = this.props.moderation.sortOrder; @@ -60,9 +58,7 @@ class ModerationContainer extends Component { const sub2 = this.props.data.subscribeToMore({ document: COMMENT_REJECTED_SUBSCRIPTION, - variables: { - asset_id: this.props.data.variables.asset_id, - }, + 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; @@ -79,9 +75,7 @@ class ModerationContainer extends Component { const sub3 = this.props.data.subscribeToMore({ document: COMMENT_EDITED_SUBSCRIPTION, - variables: { - asset_id: this.props.data.variables.asset_id, - }, + variables, updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => { const sort = this.props.moderation.sortOrder; const notify = { @@ -95,9 +89,7 @@ class ModerationContainer extends Component { const sub4 = this.props.data.subscribeToMore({ document: COMMENT_FLAGGED_SUBSCRIPTION, - variables: { - asset_id: this.props.data.variables.asset_id, - }, + variables, updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => { const user = comment.actions[comment.actions.length - 1].user; const sort = this.props.moderation.sortOrder; @@ -118,9 +110,9 @@ class ModerationContainer extends Component { this.subscriptions = []; } - resubscribe() { + resubscribe(variables) { this.unsubscribe(); - this.subscribeToUpdates(); + this.subscribeToUpdates(variables); } componentWillMount() { @@ -134,9 +126,10 @@ class ModerationContainer extends Component { } componentWillReceiveProps(nextProps) { + // Resubscribe when we change between assets. if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) { - this.resubscribe(); + this.resubscribe(nextProps.data.variables); } } diff --git a/graph/subscriptions.js b/graph/subscriptions.js index 31b430523..2ba0c2c1f 100644 --- a/graph/subscriptions.js +++ b/graph/subscriptions.js @@ -50,7 +50,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu }), commentFlagged: (options, args) => ({ commentFlagged: { - filter: ({comment}, context) => { + filter: (comment, context) => { if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGGED)) { return false; } @@ -60,7 +60,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu }), commentAccepted: (options, args) => ({ commentAccepted: { - filter: ({comment}, context) => { + filter: (comment, context) => { if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_ACCEPTED)) { return false; } @@ -70,7 +70,7 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu }), commentRejected: (options, args) => ({ commentRejected: { - filter: ({comment}, context) => { + filter: (comment, context) => { if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_REJECTED)) { return false; } From b8967dcdb4dd91d66fb509c5e2a69ed06f400156 Mon Sep 17 00:00:00 2001 From: Chi Vinh Le Date: Tue, 20 Jun 2017 01:09:37 +0700 Subject: [PATCH 18/20] More accurate loading detection --- .../Moderation/components/Moderation.js | 19 ++---------------- .../Moderation/containers/Moderation.js | 20 ++++++++++++++++--- 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 8c81ae18b..a5f823072 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 { state = { @@ -105,22 +103,9 @@ export default class Moderation extends Component { render () { const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props; - const providedAssetId = this.props.params.id; + 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) { @@ -174,7 +159,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/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 12a991454..86c46ac30 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -8,6 +8,7 @@ import * as notification from 'coral-admin/src/services/notification'; import t, {timeago} from 'coral-framework/services/i18n'; import update from 'immutability-helper'; import truncate from 'lodash/truncate'; +import NotFoundAsset from '../components/NotFoundAsset'; import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations'; import {handleCommentChange} from '../../../graphql/utils'; @@ -214,14 +215,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 Date: Tue, 20 Jun 2017 01:38:38 +0700 Subject: [PATCH 19/20] Translate, pep up notifications --- client/coral-admin/src/components/ToastContainer.css | 1 + .../src/routes/Moderation/components/Comment.js | 2 +- .../src/routes/Moderation/containers/Moderation.js | 12 ++++++++---- client/coral-embed-stream/src/components/Comment.js | 3 ++- locales/en.yml | 5 +++++ locales/es.yml | 1 + 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/client/coral-admin/src/components/ToastContainer.css b/client/coral-admin/src/components/ToastContainer.css index 57ddf3134..2fcbd7f7d 100644 --- a/client/coral-admin/src/components/ToastContainer.css +++ b/client/coral-admin/src/components/ToastContainer.css @@ -207,6 +207,7 @@ .toastify__body { color: white; + overflow-x: scroll; font-size: 15px; font-weight: 400; } diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index 2a5f44f28..41b537f74 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -95,7 +95,7 @@ class Comment extends React.Component { { (comment.editing && comment.editing.edited) - ?  (Edited) + ?  ({t('comment.edited')}) : null } {props.currentUserId !== comment.user.id && diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 86c46ac30..ba026abb3 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -34,6 +34,10 @@ import {Spinner} from 'coral-ui'; import Moderation from '../components/Moderation'; import Comment from './Comment'; +function prepareNotificationText(text) { + return truncate(text, {length: 50}).replace('\n', ' '); +} + class ModerationContainer extends Component { subscriptions = []; @@ -50,7 +54,7 @@ class ModerationContainer extends Component { ? {} : { activeQueue: this.activeTab, - text: `${user.username} accepted comment "${truncate(comment.body, {lenght: 50})}"`, + text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)), anyQueue: false, }; return handleCommentChange(prev, comment, sort, notify); @@ -67,7 +71,7 @@ class ModerationContainer extends Component { ? {} : { activeQueue: this.activeTab, - text: `${user.username} rejected comment "${truncate(comment.body, {lenght: 50})}"`, + text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)), anyQueue: false, }; return handleCommentChange(prev, comment, sort, notify); @@ -81,7 +85,7 @@ class ModerationContainer extends Component { const sort = this.props.moderation.sortOrder; const notify = { activeQueue: this.activeTab, - text: `${comment.user.username} edited comment to "${truncate(comment.body, {lenght: 50})}"`, + text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)), anyQueue: false, }; return handleCommentChange(prev, comment, sort, notify); @@ -96,7 +100,7 @@ class ModerationContainer extends Component { const sort = this.props.moderation.sortOrder; const notify = { activeQueue: this.activeTab, - text: `${user.username} flagged comment "${truncate(comment.body, {lenght: 50})}"`, + text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)), anyQueue: true, }; return handleCommentChange(prev, comment, sort, notify); diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js index a66b8a224..8626154f4 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; @@ -415,7 +416,7 @@ export default class Comment extends React.Component { { (comment.editing && comment.editing.edited) - ?  (Edited) + ?  ({t('comment.edited')}) : null } 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: From 646b5be8c8ca7fe7b908496647b9a1a2cda98588 Mon Sep 17 00:00:00 2001 From: Kim Gardner Date: Tue, 20 Jun 2017 10:48:01 +0100 Subject: [PATCH 20/20] Show version in nav --- client/coral-admin/src/components/ui/Header.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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}`} +