diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js
index ca48764f1..3a9e282ff 100644
--- a/client/coral-admin/src/actions/auth.js
+++ b/client/coral-admin/src/actions/auth.js
@@ -3,6 +3,7 @@ import * as actions from '../constants/auth';
import coralApi from 'coral-framework/helpers/request';
import * as Storage from 'coral-framework/helpers/storage';
import {handleAuthToken} from 'coral-framework/actions/auth';
+import {resetWebsocket} from 'coral-framework/services/client';
//==============================================================================
// SIGN IN
@@ -36,6 +37,7 @@ export const handleLogin = (email, password, recaptchaResponse) => (dispatch) =>
}
dispatch(handleAuthToken(token));
+ resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
@@ -105,6 +107,7 @@ export const checkLogin = () => (dispatch) => {
return dispatch(checkLoginFailure('not logged in'));
}
+ resetWebsocket();
dispatch(checkLoginSuccess(user));
})
.catch((error) => {
diff --git a/client/coral-admin/src/components/ToastContainer.css b/client/coral-admin/src/components/ToastContainer.css
index 319872ceb..2fcbd7f7d 100644
--- a/client/coral-admin/src/components/ToastContainer.css
+++ b/client/coral-admin/src/components/ToastContainer.css
@@ -133,7 +133,7 @@
animation-fill-mode: both; }
.toastify {
- z-index: 999;
+ z-index: 99999;
position: fixed;
padding: 4px;
width: 350px;
@@ -197,7 +197,7 @@
border-radius: 2px;
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.1), 0 3px 20px 0 rgba(0, 0, 0, 0.05); }
.toastify-content--info {
- background: #2488cb; }
+ background: #404040; }
.toastify-content--success {
background: #008577; }
.toastify-content--warning {
@@ -207,6 +207,7 @@
.toastify__body {
color: white;
+ overflow-x: scroll;
font-size: 15px;
font-weight: 400;
}
diff --git a/client/coral-admin/src/components/ui/Header.js b/client/coral-admin/src/components/ui/Header.js
index f2a0b06e3..ca1cc1f30 100644
--- a/client/coral-admin/src/components/ui/Header.js
+++ b/client/coral-admin/src/components/ui/Header.js
@@ -84,12 +84,12 @@ const CoralHeader = ({
{t('configure.sign_out')}
-
- Talk {`v${process.env.VERSION}`}
-
+
+ {`v${process.env.VERSION}`}
+
diff --git a/client/coral-admin/src/graphql/index.js b/client/coral-admin/src/graphql/index.js
index 59a6d7c8f..915fe2989 100644
--- a/client/coral-admin/src/graphql/index.js
+++ b/client/coral-admin/src/graphql/index.js
@@ -1,6 +1,4 @@
import {add} from 'coral-framework/services/graphqlRegistry';
-import update from 'immutability-helper';
-const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
const extension = {
mutations: {
@@ -10,58 +8,6 @@ const extension = {
RejectUsername: () => ({
refetchQueries: ['CoralAdmin_Community'],
}),
- SetCommentStatus: ({variables: {commentId, status}}) => ({
- updateQueries: {
- CoralAdmin_Moderation: (prev) => {
- const comment = queues.reduce((comment, queue) => {
- return comment ? comment : prev[queue].nodes.find((c) => c.id === commentId);
- }, null);
-
- let acceptedNodes = prev.accepted.nodes;
- let acceptedCount = prev.acceptedCount;
- let rejectedNodes = prev.rejected.nodes;
- let rejectedCount = prev.rejectedCount;
-
- if (status !== comment.status) {
- if (status === 'ACCEPTED') {
- comment.status = 'ACCEPTED';
- acceptedCount++;
- acceptedNodes = [comment, ...acceptedNodes];
- }
- else if (status === 'REJECTED') {
- comment.status = 'REJECTED';
- rejectedCount++;
- rejectedNodes = [comment, ...rejectedNodes];
- }
- }
-
- const premodNodes = prev.premod.nodes.filter((c) => c.id !== commentId);
- const flaggedNodes = prev.flagged.nodes.filter((c) => c.id !== commentId);
- const premodCount = premodNodes.length < prev.premod.nodes.length ? prev.premodCount - 1 : prev.premodCount;
- const flaggedCount = flaggedNodes.length < prev.flagged.nodes.length ? prev.flaggedCount - 1 : prev.flaggedCount;
-
- if (status === 'REJECTED') {
- acceptedNodes = prev.accepted.nodes.filter((c) => c.id !== commentId);
- acceptedCount = acceptedNodes.length < prev.accepted.nodes.length ? prev.acceptedCount - 1 : prev.acceptedCount;
- }
- else if (status === 'ACCEPTED') {
- rejectedNodes = prev.rejected.nodes.filter((c) => c.id !== commentId);
- rejectedCount = rejectedNodes.length < prev.rejected.nodes.length ? prev.rejectedCount - 1 : prev.rejectedCount;
- }
-
- return update(prev, {
- premodCount: {$set: Math.max(0, premodCount)},
- flaggedCount: {$set: Math.max(0, flaggedCount)},
- acceptedCount: {$set: Math.max(0, acceptedCount)},
- rejectedCount: {$set: Math.max(0, rejectedCount)},
- premod: {nodes: {$set: premodNodes}},
- flagged: {nodes: {$set: flaggedNodes}},
- accepted: {nodes: {$set: acceptedNodes}},
- rejected: {nodes: {$set: rejectedNodes}},
- });
- }
- }
- }),
},
};
diff --git a/client/coral-admin/src/graphql/utils.js b/client/coral-admin/src/graphql/utils.js
new file mode 100644
index 000000000..331fd876a
--- /dev/null
+++ b/client/coral-admin/src/graphql/utils.js
@@ -0,0 +1,137 @@
+import update from 'immutability-helper';
+import * as notification from 'coral-admin/src/services/notification';
+
+const queues = ['all', 'premod', 'flagged', 'accepted', 'rejected'];
+const limit = 10;
+
+const ascending = (a, b) => {
+ const dateA = new Date(a.created_at);
+ const dateB = new Date(b.created_at);
+ if (dateA < dateB) { return -1; }
+ if (dateA > dateB) { return 1; }
+ return 0;
+};
+
+const descending = (a, b) => -ascending(a, b);
+
+function queueHasComment(root, queue, id) {
+ return root[queue].nodes.find((c) => c.id === id);
+}
+
+function removeCommentFromQueue(root, queue, id) {
+ if (!queueHasComment(root, queue, id)) {
+ return root;
+ }
+ return update(root, {
+ [`${queue}Count`]: {$set: root[`${queue}Count`] - 1},
+ [queue]: {
+ nodes: {$apply: (nodes) => nodes.filter((c) => c.id !== id)},
+ },
+ });
+}
+
+function shouldCommentBeAdded(root, queue, comment, sort) {
+ if (root[`${queue}Count`] < limit) {
+
+ // Adding all comments until first limit has reached.
+ return true;
+ }
+ const cursor = new Date(root[queue].endCursor);
+ return sort === 'CHRONOLOGICAL'
+ ? new Date(comment.created_at) <= cursor
+ : new Date(comment.created_at) >= cursor;
+}
+
+function addCommentToQueue(root, queue, comment, sort) {
+ if (queueHasComment(root, queue, comment.id)) {
+ return root;
+ }
+
+ const sortAlgo = sort === 'CHRONOLOGICAL' ? ascending : descending;
+ const changes = {
+ [`${queue}Count`]: {$set: root[`${queue}Count`] + 1},
+ };
+
+ if (shouldCommentBeAdded(root, queue, comment, sort)) {
+ const nodes = root[queue].nodes.concat(comment).sort(sortAlgo);
+ changes[queue] = {
+ nodes: {$set: nodes},
+ startCursor: {$set: nodes[0].created_at},
+ endCursor: {$set: nodes[nodes.length - 1].created_at},
+ };
+ }
+
+ return update(root, changes);
+}
+
+function getCommentQueues(comment) {
+ const queues = ['all'];
+ if (comment.status === 'ACCEPTED') {
+ queues.push('accepted');
+ }
+ else if (comment.status === 'REJECTED') {
+ queues.push('rejected');
+ }
+ else if (comment.status === 'PREMOD') {
+ queues.push('premod');
+ }
+ if (
+ ['NONE', 'PREMOD'].indexOf(comment.status) >= 0
+ && comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')
+ ) {
+ queues.push('flagged');
+ }
+ return queues;
+}
+
+/**
+ * Assimilate comment changes into current store.
+ * @param {Object} root current state of the store
+ * @param {Object} comment comment that was changed
+ * @param {string} sort current sort order of the queues
+ * @param {Object} [notify] show know notifications if set
+ * @param {string} notify.activeQueue current active queue
+ * @param {string} notify.text notification text to show
+ * @param {bool} notify.anyQueue if true show the notification when the comment is shown
+ * in the current active queue besides the 'all' queue.
+ * @return {Object} next state of the store
+ */
+export function handleCommentChange(root, comment, sort, notify) {
+ let next = root;
+ const nextQueues = getCommentQueues(comment);
+
+ let notificationShown = false;
+ const showNotificationOnce = () => {
+ if (notificationShown) {
+ return;
+ }
+ notification.info(notify.text);
+ notificationShown = true;
+ };
+
+ queues.forEach((queue) => {
+ if (nextQueues.indexOf(queue) >= 0) {
+ if (!queueHasComment(next, queue, comment.id)) {
+ next = addCommentToQueue(next, queue, comment, sort);
+ if (notify && notify.activeQueue === queue && shouldCommentBeAdded(next, queue, comment, sort)) {
+ showNotificationOnce(comment);
+ }
+ }
+ } else if(queueHasComment(next, queue, comment.id)){
+ next = removeCommentFromQueue(next, queue, comment.id);
+ if (notify && notify.activeQueue === queue) {
+ showNotificationOnce(comment);
+ }
+ }
+
+ if (
+ notify
+ && (queue === 'all' || notify.anyQueue)
+ && queueHasComment(next, queue, comment.id)
+ && notify.activeQueue === queue
+ ) {
+ showNotificationOnce(comment);
+ }
+ });
+ return next;
+}
diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js
index 8cd7685f0..2f2e75275 100644
--- a/client/coral-admin/src/index.js
+++ b/client/coral-admin/src/index.js
@@ -2,7 +2,7 @@ import React from 'react';
import {render} from 'react-dom';
import {ApolloProvider} from 'react-apollo';
-import {client} from './services/client';
+import {getClient} from './services/client';
import store from './services/store';
import App from './components/App';
@@ -15,7 +15,7 @@ loadPluginsTranslations();
injectPluginsReducers();
render(
-
+
, document.querySelector('#root')
diff --git a/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js b/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js
index bfca4ceb1..56507e13c 100644
--- a/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js
+++ b/client/coral-admin/src/routes/Moderation/components/BanUserDialog.js
@@ -26,7 +26,7 @@ const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, c
{t('bandialog.ban_user')}
-
{t('bandialog.are_you_sure', user.name)}
+ {t('bandialog.are_you_sure', user.username)}
{showRejectedNote && t('bandialog.note')}
diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js
index 8a23cc5ba..3d946f9fc 100644
--- a/client/coral-admin/src/routes/Moderation/components/Comment.js
+++ b/client/coral-admin/src/routes/Moderation/components/Comment.js
@@ -12,194 +12,224 @@ import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import ActionsMenu from 'coral-admin/src/components/ActionsMenu';
import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
+import cn from 'classnames';
+import {murmur3} from 'murmurhash-js';
+import {CSSTransitionGroup} from 'react-transition-group';
const linkify = new Linkify();
import t, {timeago} from 'coral-framework/services/i18n';
-const Comment = ({
- actions = [],
- comment,
- viewUserDetail,
- suspectWords,
- bannedWords,
- minimal,
- selected,
- toggleSelect,
- ...props
-}) => {
- const links = linkify.getMatches(comment.body);
- const linkText = links ? links.map((link) => link.raw) : [];
- const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
- const flagActions =
- comment.actions &&
- comment.actions.filter((a) => a.__typename === 'FlagAction');
- let commentType = '';
- if (comment.status === 'PREMOD') {
- commentType = 'premod';
- } else if (flagActions && flagActions.length) {
- commentType = 'flagged';
- }
+class Comment extends React.Component {
- // since words are checked against word boundaries on the backend,
- // should be the behavior on the front end as well.
- // currently the highlighter plugin does not support out of the box.
- const searchWords = [...suspectWords, ...bannedWords]
- .filter((w) => {
- return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
- })
- .concat(linkText);
+ render() {
+ const {
+ actions = [],
+ comment,
+ viewUserDetail,
+ suspectWords,
+ bannedWords,
+ minimal,
+ selected,
+ toggleSelect,
+ className,
+ ...props
+ } = this.props;
- let selectionStateCSS;
- if (minimal) {
- selectionStateCSS = selected ? styles.minimalSelection : '';
- } else {
- selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
- }
+ const links = linkify.getMatches(comment.body);
+ const linkText = links ? links.map((link) => link.raw) : [];
+ const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
+ const flagActions =
+ comment.actions &&
+ comment.actions.filter((a) => a.__typename === 'FlagAction');
+ let commentType = '';
+ if (comment.status === 'PREMOD') {
+ commentType = 'premod';
+ } else if (flagActions && flagActions.length) {
+ commentType = 'flagged';
+ }
- return (
-
-
-
-
- {
- !minimal && (
-
viewUserDetail(comment.user.id)}>
- {comment.user.name}
-
- )
- }
- {
- minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
-
toggleSelect(e.target.value, e.target.checked)} />
- )
- }
-
- {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
-
- {props.currentUserId !== comment.user.id &&
-
- props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
- Suspend User
- props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
- Ban User
-
-
- }
-
-
- {comment.user.status === 'banned'
- ?
-
- {t('comment.banned_user')}
+ // since words are checked against word boundaries on the backend,
+ // should be the behavior on the front end as well.
+ // currently the highlighter plugin does not support out of the box.
+ const searchWords = [...suspectWords, ...bannedWords]
+ .filter((w) => {
+ return new RegExp(`(^|\\s)${w}(\\s|$)`).test(comment.body);
+ })
+ .concat(linkText);
+
+ let selectionStateCSS;
+ if (minimal) {
+ selectionStateCSS = selected ? styles.minimalSelection : '';
+ } else {
+ selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
+ }
+
+ return (
+
+
+
+
+ {
+ !minimal && (
+ viewUserDetail(comment.user.id)}>
+ {comment.user.username}
+
+ )
+ }
+ {
+ minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
+ toggleSelect(e.target.value, e.target.checked)} />
+ )
+ }
+
+ {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
- : null}
-
-
-
- Story: {comment.asset.title}
- {!props.currentAsset &&
- {t('modqueue.moderate')}}
-
-
-
-
- {' '}
-
- {t('comment.view_context')}
-
-
-
-
- {links
- ?
- Contains Link
+ {
+ (comment.editing && comment.editing.edited)
+ ? ({t('comment.edited')})
+ : null
+ }
+ {props.currentUserId !== comment.user.id &&
+
+ props.showSuspendUserDialog(comment.user.id, comment.user.username, comment.id, comment.status)}>
+ Suspend User
+ props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
+ Ban User
+
+
+ }
+
+
+ {comment.user.status === 'banned'
+ ?
+
+ {t('comment.banned_user')}
: null}
-
- {actions.map((action, i) => {
- const active =
- (action === 'REJECT' && comment.status === 'REJECTED') ||
- (action === 'APPROVE' && comment.status === 'ACCEPTED');
- return (
-
- (comment.status === 'ACCEPTED'
- ? null
- : props.acceptComment({commentId: comment.id}))}
- rejectComment={() =>
- (comment.status === 'REJECTED'
- ? null
- : props.rejectComment({commentId: comment.id}))}
- />
- );
- })}
-
-
+
+
+ Story: {comment.asset.title}
+ {!props.currentAsset &&
+ {t('modqueue.moderate')}}
+
+
+
+
+
+ {' '}
+
+ {t('comment.view_context')}
+
+
+
+
+ {links
+ ?
+ Contains Link
+
+ : null}
+
+ {actions.map((action, i) => {
+ const active =
+ (action === 'REJECT' && comment.status === 'REJECTED') ||
+ (action === 'APPROVE' && comment.status === 'ACCEPTED');
+ return (
+
+ (comment.status === 'ACCEPTED'
+ ? null
+ : props.acceptComment({commentId: comment.id}))}
+ rejectComment={() =>
+ (comment.status === 'REJECTED'
+ ? null
+ : props.rejectComment({commentId: comment.id}))}
+ />
+ );
+ })}
+
+
+
+
+
-
-
- {flagActions && flagActions.length
- ?
- : null}
-
- );
-};
+
+ {flagActions && flagActions.length
+ ?
+ : null}
+
+ );
+ }
+}
Comment.propTypes = {
minimal: PropTypes.bool,
viewUserDetail: PropTypes.func.isRequired,
acceptComment: PropTypes.func.isRequired,
rejectComment: PropTypes.func.isRequired,
+ className: PropTypes.string,
suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired,
bannedWords: PropTypes.arrayOf(PropTypes.string).isRequired,
currentAsset: PropTypes.object,
diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js
index 63c9e13ab..1c1ce1009 100644
--- a/client/coral-admin/src/routes/Moderation/components/Moderation.js
+++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js
@@ -7,11 +7,9 @@ import SuspendUserDialog from './SuspendUserDialog';
import ModerationQueue from './ModerationQueue';
import ModerationMenu from './ModerationMenu';
import ModerationHeader from './ModerationHeader';
-import NotFoundAsset from './NotFoundAsset';
import ModerationKeysModal from '../../../components/ModerationKeysModal';
import UserDetail from '../containers/UserDetail';
import StorySearch from '../containers/StorySearch';
-import {Spinner} from 'coral-ui';
export default class Moderation extends Component {
constructor() {
@@ -106,24 +104,11 @@ export default class Moderation extends Component {
}
render () {
- const {root, moderation, settings, viewUserDetail, hideUserDetail, ...props} = this.props;
- const providedAssetId = this.props.params.id;
- const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
+
+ const {root, moderation, settings, viewUserDetail, hideUserDetail, activeTab, ...props} = this.props;
+ const assetId = this.props.params.id;
const {asset} = root;
- if (providedAssetId) {
- if (asset === null) {
-
- // Not found.
- return ;
- }
- if (asset === undefined || asset.id !== providedAssetId) {
-
- // Still loading.
- return ;
- }
- }
-
const comments = root[activeTab];
let activeTabCount;
switch(activeTab) {
@@ -177,7 +162,7 @@ export default class Moderation extends Component {
acceptComment={props.acceptComment}
rejectComment={props.rejectComment}
loadMore={props.loadMore}
- assetId={providedAssetId}
+ assetId={assetId}
sort={this.props.moderation.sortOrder}
commentCount={activeTabCount}
currentUserId={this.props.auth.user.id}
diff --git a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js
index d656c7a82..2adf17b23 100644
--- a/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js
+++ b/client/coral-admin/src/routes/Moderation/components/ModerationQueue.js
@@ -6,6 +6,7 @@ import EmptyCard from '../../../components/EmptyCard';
import {actionsMap} from '../helpers/moderationQueueActionsMap';
import LoadMore from './LoadMore';
import t from 'coral-framework/services/i18n';
+import {CSSTransitionGroup} from 'react-transition-group';
class ModerationQueue extends React.Component {
isLoadingMore = false;
@@ -34,6 +35,10 @@ class ModerationQueue extends React.Component {
}
}
+ constructor(props) {
+ super(props);
+ }
+
componentDidUpdate (prev) {
const {comments, commentCount} = this.props;
@@ -52,20 +57,34 @@ class ModerationQueue extends React.Component {
commentCount,
singleView,
viewUserDetail,
+ activeTab,
...props
} = this.props;
return (
-
+
{
- comments.length
- ? comments.map((comment, i) => {
+ comments.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return ;
})
- : {t('modqueue.empty_queue')}
}
-
+
+ {comments.length === 0 &&
+
+ {t('modqueue.empty_queue')}
+
+ }
+
{
+ const user = comment.status_history[comment.status_history.length - 1].assigned_by;
+ const sort = this.props.moderation.sortOrder;
+ const notify = this.props.auth.user.id === user.id
+ ? {}
+ : {
+ activeQueue: this.activeTab,
+ text: t('modqueue.notify_accepted', user.username, prepareNotificationText(comment.body)),
+ anyQueue: false,
+ };
+ return handleCommentChange(prev, comment, sort, notify);
+ },
+ });
+
+ const sub2 = this.props.data.subscribeToMore({
+ document: COMMENT_REJECTED_SUBSCRIPTION,
+ variables,
+ updateQuery: (prev, {subscriptionData: {data: {commentRejected: comment}}}) => {
+ const user = comment.status_history[comment.status_history.length - 1].assigned_by;
+ const sort = this.props.moderation.sortOrder;
+ const notify = this.props.auth.user.id === user.id
+ ? {}
+ : {
+ activeQueue: this.activeTab,
+ text: t('modqueue.notify_rejected', user.username, prepareNotificationText(comment.body)),
+ anyQueue: false,
+ };
+ return handleCommentChange(prev, comment, sort, notify);
+ },
+ });
+
+ const sub3 = this.props.data.subscribeToMore({
+ document: COMMENT_EDITED_SUBSCRIPTION,
+ variables,
+ updateQuery: (prev, {subscriptionData: {data: {commentEdited: comment}}}) => {
+ const sort = this.props.moderation.sortOrder;
+ const notify = {
+ activeQueue: this.activeTab,
+ text: t('modqueue.notify_edited', comment.user.username, prepareNotificationText(comment.body)),
+ anyQueue: false,
+ };
+ return handleCommentChange(prev, comment, sort, notify);
+ },
+ });
+
+ const sub4 = this.props.data.subscribeToMore({
+ document: COMMENT_FLAGGED_SUBSCRIPTION,
+ variables,
+ updateQuery: (prev, {subscriptionData: {data: {commentFlagged: comment}}}) => {
+ const user = comment.actions[comment.actions.length - 1].user;
+ const sort = this.props.moderation.sortOrder;
+ const notify = {
+ activeQueue: this.activeTab,
+ text: t('modqueue.notify_flagged', user.username, prepareNotificationText(comment.body)),
+ anyQueue: true,
+ };
+ return handleCommentChange(prev, comment, sort, notify);
+ },
+ });
+
+ this.subscriptions.push(sub1, sub2, sub3, sub4);
+ }
+
+ unsubscribe() {
+ this.subscriptions.forEach((unsubscribe) => unsubscribe());
+ this.subscriptions = [];
+ }
+
+ resubscribe(variables) {
+ this.unsubscribe();
+ this.subscribeToUpdates(variables);
+ }
+
componentWillMount() {
this.props.clearState();
this.props.fetchSettings();
+ this.subscribeToUpdates();
+ }
+
+ componentWillUnmount() {
+ this.unsubscribe();
+ }
+
+ componentWillReceiveProps(nextProps) {
+
+ // Resubscribe when we change between assets.
+ if(this.props.data.variables.asset_id !== nextProps.data.variables.asset_id) {
+ this.resubscribe(nextProps.data.variables);
+ }
}
suspendUser = async (args) => {
@@ -108,6 +209,9 @@ class ModerationContainer extends Component {
return update(prev, {
[tab]: {
nodes: {$push: comments.nodes},
+ hasNextPage: {$set: comments.hasNextPage},
+ startCursor: {$set: comments.startCursor},
+ endCursor: {$set: comments.endCursor},
},
});
}
@@ -115,14 +219,27 @@ class ModerationContainer extends Component {
};
render () {
- const {root, data} = this.props;
+ const {root, root: {asset}, data, params: {id: assetId}} = this.props;
if (data.error) {
return Error
;
}
- if (!('premodCount' in root)) {
- return
;
+ if (assetId) {
+ if (asset === null) {
+
+ // Not found.
+ return ;
+ }
+ if (asset === undefined || asset.id !== assetId) {
+
+ // Still loading.
+ return ;
+ }
+ } else if(asset !== undefined || !('premodCount' in root)) {
+
+ // loading.
+ return ;
}
return ;
}
}
+const COMMENT_EDITED_SUBSCRIPTION = gql`
+ subscription CommentEdited($asset_id: ID){
+ commentEdited(asset_id: $asset_id){
+ ...${getDefinitionName(Comment.fragments.comment)}
+ }
+ }
+ ${Comment.fragments.comment}
+`;
+
+const COMMENT_FLAGGED_SUBSCRIPTION = gql`
+ subscription CommentFlagged($asset_id: ID){
+ commentFlagged(asset_id: $asset_id){
+ ...${getDefinitionName(Comment.fragments.comment)}
+ }
+ }
+ ${Comment.fragments.comment}
+`;
+
+const COMMENT_ACCEPTED_SUBSCRIPTION = gql`
+ subscription CommentAccepted($asset_id: ID){
+ commentAccepted(asset_id: $asset_id){
+ ...${getDefinitionName(Comment.fragments.comment)}
+ status_history {
+ type
+ created_at
+ assigned_by {
+ id
+ username
+ }
+ }
+ }
+ }
+ ${Comment.fragments.comment}
+`;
+
+const COMMENT_REJECTED_SUBSCRIPTION = gql`
+ subscription CommentRejected($asset_id: ID){
+ commentRejected(asset_id: $asset_id){
+ ...${getDefinitionName(Comment.fragments.comment)}
+ status_history {
+ type
+ created_at
+ assigned_by {
+ id
+ username
+ }
+ }
+ }
+ }
+ ${Comment.fragments.comment}
+`;
+
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort, action_type: $action_type}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
- action_summaries {
- count
- ... on FlagActionSummary {
- reason
- }
- }
}
+ hasNextPage
+ startCursor
+ endCursor
}
}
${Comment.fragments.comment}
diff --git a/client/coral-admin/src/services/client.js b/client/coral-admin/src/services/client.js
index df97066bc..934de2c9e 100644
--- a/client/coral-admin/src/services/client.js
+++ b/client/coral-admin/src/services/client.js
@@ -1,20 +1,6 @@
-import ApolloClient, {addTypename} from 'apollo-client';
-import {networkInterface} from 'coral-framework/services/transport';
+import {getClient as getFrameworkClient} from 'coral-framework/services/client';
import fragmentMatcher from './fragmentMatcher';
-export const client = new ApolloClient({
- fragmentMatcher,
- connectToDevTools: true,
- addTypename: true,
- queryTransformer: addTypename,
- dataIdFromObject: (result) => {
- if (result.id && result.__typename) { // eslint-disable-line no-underscore-dangle
- return `${result.__typename}_${result.id}`; // eslint-disable-line no-underscore-dangle
- }
- return null;
- },
- networkInterface
-});
-
-export default client;
-
+export function getClient() {
+ return getFrameworkClient({fragmentMatcher});
+}
diff --git a/client/coral-admin/src/services/store.js b/client/coral-admin/src/services/store.js
index 9815a0f70..06cb3c758 100644
--- a/client/coral-admin/src/services/store.js
+++ b/client/coral-admin/src/services/store.js
@@ -1,10 +1,10 @@
import {createStore, combineReducers, applyMiddleware, compose} from 'redux';
import thunk from 'redux-thunk';
import mainReducer from '../reducers';
-import {client} from './client';
+import {getClient} from './client';
const middlewares = [
- applyMiddleware(client.middleware()),
+ applyMiddleware(getClient().middleware()),
applyMiddleware(thunk)
];
@@ -16,7 +16,7 @@ if (window.devToolsExtension) {
const coralReducers = {
...mainReducer,
- apollo: client.reducer()
+ apollo: getClient().reducer()
};
const store = createStore(
diff --git a/client/coral-embed-stream/src/components/Comment.js b/client/coral-embed-stream/src/components/Comment.js
index 53ac52edf..754f44558 100644
--- a/client/coral-embed-stream/src/components/Comment.js
+++ b/client/coral-embed-stream/src/components/Comment.js
@@ -26,6 +26,7 @@ import Slot from 'coral-framework/components/Slot';
import IgnoredCommentTombstone from './IgnoredCommentTombstone';
import {EditableCommentContent} from './EditableCommentContent';
import {getActionSummary, iPerformedThisAction} from 'coral-framework/utils';
+import t from 'coral-framework/services/i18n';
const isStaff = (tags) => !tags.every((t) => t.tag.name !== 'STAFF');
const hasTag = (tags, lookupTag) => !!tags.filter((t) => t.tag.name === lookupTag).length;
@@ -427,7 +428,7 @@ export default class Comment extends React.Component {
{
(comment.editing && comment.editing.edited)
- ? (Edited)
+ ? ({t('comment.edited')})
: null
}
diff --git a/client/coral-framework/services/client.js b/client/coral-framework/services/client.js
index 26d8e391f..307646db0 100644
--- a/client/coral-framework/services/client.js
+++ b/client/coral-framework/services/client.js
@@ -26,7 +26,7 @@ export function resetWebsocket() {
});
}
-export function getClient() {
+export function getClient(options = {}) {
if (client) {
return client;
}
@@ -56,6 +56,7 @@ export function getClient() {
);
client = new ApolloClient({
+ ...options,
connectToDevTools: true,
addTypename: true,
queryTransformer: addTypename,
diff --git a/graph/mutators/action.js b/graph/mutators/action.js
index 96bf091cf..19db33183 100644
--- a/graph/mutators/action.js
+++ b/graph/mutators/action.js
@@ -13,7 +13,16 @@ const {CREATE_ACTION, DELETE_ACTION} = require('../../perms/constants');
* @param {String} action_type type of the action
* @return {Promise} resolves to the action created
*/
-const createAction = async ({user = {}}, {item_id, item_type, action_type, group_id, metadata = {}}) => {
+const createAction = async ({user = {}, pubsub, loaders: {Comments}}, {item_id, item_type, action_type, group_id, metadata = {}}) => {
+
+ let comment;
+ if (pubsub && item_type === 'COMMENTS') {
+ comment = await Comments.get.load(item_id);
+ if (!comment) {
+ throw new Error('Comment not found');
+ }
+ }
+
let action = await ActionsService.insertUserAction({
item_id,
item_type,
@@ -29,6 +38,10 @@ const createAction = async ({user = {}}, {item_id, item_type, action_type, group
await UsersService.setStatus(item_id, 'PENDING');
}
+ if (pubsub && comment) {
+ pubsub.publish('commentFlagged', comment);
+ }
+
return action;
};
diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js
index 247eb264e..df6ed71ce 100644
--- a/graph/mutators/comment.js
+++ b/graph/mutators/comment.js
@@ -326,7 +326,7 @@ const createPublicComment = async (context, commentInput) => {
* @param {String} id identifier of the comment (uuid)
* @param {String} status the new status of the comment
*/
-const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
+const setStatus = async ({user, loaders: {Comments}, pubsub}, {id, status}) => {
let comment = await CommentsService.pushStatus(id, status, user ? user.id : null);
// If the loaders are present, clear the caches for these values because we
@@ -346,6 +346,19 @@ const setStatus = async ({user, loaders: {Comments}}, {id, status}) => {
// adjust the affected user's karma in the next tick.
process.nextTick(adjustKarma(Comments, id, status));
+ if (pubsub) {
+
+ if (status === 'ACCEPTED') {
+
+ // Publish the comment status change via the subscription.
+ pubsub.publish('commentAccepted', comment);
+ } else if (status === 'REJECTED') {
+
+ // Publish the comment status change via the subscription.
+ pubsub.publish('commentRejected', comment);
+ }
+ }
+
return comment;
};
@@ -371,7 +384,6 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
// Publish the edited comment via the subscription.
context.pubsub.publish('commentEdited', comment);
}
-
return comment;
};
diff --git a/graph/resolvers/comment_status_history.js b/graph/resolvers/comment_status_history.js
new file mode 100644
index 000000000..2e1676d90
--- /dev/null
+++ b/graph/resolvers/comment_status_history.js
@@ -0,0 +1,11 @@
+const {SEARCH_OTHER_USERS} = require('../../perms/constants');
+
+const CommentStatusHistory = {
+ assigned_by({assigned_by}, _, {user, loaders: {Users}}) {
+ if (user && user.can(SEARCH_OTHER_USERS) && assigned_by != null) {
+ return Users.getByID.load(assigned_by);
+ }
+ }
+};
+
+module.exports = CommentStatusHistory;
diff --git a/graph/resolvers/index.js b/graph/resolvers/index.js
index f2707bcc8..850a2f15a 100644
--- a/graph/resolvers/index.js
+++ b/graph/resolvers/index.js
@@ -6,6 +6,7 @@ const Action = require('./action');
const AssetActionSummary = require('./asset_action_summary');
const Asset = require('./asset');
const Comment = require('./comment');
+const CommentStatusHistory = require('./comment_status_history');
const Date = require('./date');
const FlagActionSummary = require('./flag_action_summary');
const FlagAction = require('./flag_action');
@@ -31,6 +32,7 @@ let resolvers = {
AssetActionSummary,
Asset,
Comment,
+ CommentStatusHistory,
Date,
FlagActionSummary,
FlagAction,
diff --git a/graph/resolvers/subscription.js b/graph/resolvers/subscription.js
index a593c1eb6..1f26d5ff3 100644
--- a/graph/resolvers/subscription.js
+++ b/graph/resolvers/subscription.js
@@ -4,7 +4,16 @@ const Subscription = {
},
commentEdited(comment) {
return comment;
- }
+ },
+ commentAccepted(comment) {
+ return comment;
+ },
+ commentRejected(comment) {
+ return comment;
+ },
+ commentFlagged(comment) {
+ return comment;
+ },
};
module.exports = Subscription;
diff --git a/graph/subscriptions.js b/graph/subscriptions.js
index 7d97d3521..2ba0c2c1f 100644
--- a/graph/subscriptions.js
+++ b/graph/subscriptions.js
@@ -10,6 +10,14 @@ const plugins = require('../services/plugins');
const {deserializeUser} = require('../services/subscriptions');
+const {
+ SUBSCRIBE_COMMENT_ACCEPTED,
+ SUBSCRIBE_COMMENT_REJECTED,
+ SUBSCRIBE_COMMENT_FLAGGED,
+ SUBSCRIBE_ALL_COMMENT_EDITED,
+ SUBSCRIBE_ALL_COMMENT_ADDED,
+} = require('../perms/constants');
+
/**
* Plugin support requires that we merge in existing setupFunctions with our new
* plugin based ones. This allows plugins to extend existing setupFunctions as well
@@ -22,12 +30,52 @@ const setupFunctions = plugins.get('server', 'setupFunctions').reduce((acc, {plu
}, {
commentAdded: (options, args) => ({
commentAdded: {
- filter: (comment) => comment.asset_id === args.asset_id
+ filter: (comment, context) => {
+ if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_ADDED))) {
+ return false;
+ }
+ return !args.asset_id || comment.asset_id === args.asset_id;
+ }
},
}),
commentEdited: (options, args) => ({
commentEdited: {
- filter: (comment) => comment.asset_id === args.asset_id
+ filter: (comment, context) => {
+ if (!args.asset_id && (!context.user || !context.user.can(SUBSCRIBE_ALL_COMMENT_EDITED))) {
+ return false;
+ }
+ return !args.asset_id || comment.asset_id === args.asset_id;
+ }
+ },
+ }),
+ commentFlagged: (options, args) => ({
+ commentFlagged: {
+ filter: (comment, context) => {
+ if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_FLAGGED)) {
+ return false;
+ }
+ return !args.asset_id || comment.asset_id === args.asset_id;
+ }
+ },
+ }),
+ commentAccepted: (options, args) => ({
+ commentAccepted: {
+ filter: (comment, context) => {
+ if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_ACCEPTED)) {
+ return false;
+ }
+ return !args.asset_id || comment.asset_id === args.asset_id;
+ }
+ },
+ }),
+ commentRejected: (options, args) => ({
+ commentRejected: {
+ filter: (comment, context) => {
+ if (!context.user || !context.user.can(SUBSCRIBE_COMMENT_REJECTED)) {
+ return false;
+ }
+ return !args.asset_id || comment.asset_id === args.asset_id;
+ }
},
}),
});
diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql
index c946cca94..09b50cb0e 100644
--- a/graph/typeDefs.graphql
+++ b/graph/typeDefs.graphql
@@ -255,6 +255,12 @@ type EditInfo {
editableUntil: Date
}
+type CommentStatusHistory {
+ type: COMMENT_STATUS!
+ created_at: Date!
+ assigned_by: User
+}
+
# Comment is the base representation of user interaction in Talk.
type Comment {
@@ -294,6 +300,9 @@ type Comment {
# The current status of a comment.
status: COMMENT_STATUS!
+ # The status history of the comment. Requires the `ADMIN` or `MODERATOR` role.
+ status_history: [CommentStatusHistory!]
+
# The time when the comment was created
created_at: Date!
@@ -945,8 +954,26 @@ type RootMutation {
################################################################################
type Subscription {
- commentAdded(asset_id: ID!): Comment
- commentEdited(asset_id: ID!): Comment
+
+ # Get an update whenever a comment was added.
+ # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role.
+ commentAdded(asset_id: ID): Comment
+
+ # Get an update whenever a comment was edited.
+ # `asset_id` is required except for users with the `ADMIN` or `MODERATOR` role.
+ commentEdited(asset_id: ID): Comment
+
+ # Get an update whenever a comment was flagged.
+ # Requires the `ADMIN` or `MODERATOR` role.
+ commentFlagged(asset_id: ID): Comment
+
+ # Get an update whenever a comment has been accepted.
+ # Requires the `ADMIN` or `MODERATOR` role.
+ commentAccepted(asset_id: ID): Comment
+
+ # Get an update whenever a comment has been rejected.
+ # Requires the `ADMIN` or `MODERATOR` role.
+ commentRejected(asset_id: ID): Comment
}
################################################################################
diff --git a/locales/en.yml b/locales/en.yml
index de95ddb91..4c2ae7c76 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -13,6 +13,7 @@ en:
anon: "Anonymous"
ban_user: "Ban User"
comment: "Post a comment"
+ edited: Edited
flagged: "flagged"
view_context: "View context"
comment_box:
@@ -241,6 +242,10 @@ en:
actions: Actions
all: all
all_streams: "All Streams"
+ notify_edited: '{0} edited comment "{1}"'
+ notify_accepted: '{0} accepted comment "{1}"'
+ notify_rejected: '{0} rejected comment "{1}"'
+ notify_flagged: '{0} flagged comment "{1}"'
approve: "Approve"
approved: "Approved"
ban_user: "Ban"
diff --git a/locales/es.yml b/locales/es.yml
index 6897f6f8b..d174ed0ee 100644
--- a/locales/es.yml
+++ b/locales/es.yml
@@ -13,6 +13,7 @@ es:
anon: AnĂ³nimo
ban_user: "Usuario Suspendido"
comment: "Publicar un comentario"
+ edited: Editado
flagged: reportado
view_context: "Ver contexto"
comment_box:
diff --git a/package.json b/package.json
index d55df87e6..729358ffc 100644
--- a/package.json
+++ b/package.json
@@ -99,6 +99,7 @@
"mongoose": "^4.9.8",
"morgan": "^1.8.1",
"ms": "^2.0.0",
+ "murmurhash-js": "^1.0.0",
"natural": "^0.5.0",
"node-emoji": "^1.5.1",
"node-fetch": "^1.6.3",
@@ -201,7 +202,6 @@
"regenerator": "^0.8.46",
"selenium-standalone": "^5.11.2",
"style-loader": "^0.16.0",
- "subscriptions-transport-ws": "^0.5.5-alpha.0",
"supertest": "^2.0.1",
"timeago.js": "^2.0.3",
"webpack": "^2.3.1"
diff --git a/perms/constants.js b/perms/constants.js
index 2b5b907ac..8be745f36 100644
--- a/perms/constants.js
+++ b/perms/constants.js
@@ -21,5 +21,13 @@ module.exports = {
SEARCH_ACTIONS: 'SEARCH_ACTIONS',
SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS: 'SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS',
SEARCH_OTHERS_COMMENTS: 'SEARCH_OTHERS_COMMENTS',
- SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS'
+ SEARCH_COMMENT_METRICS: 'SEARCH_COMMENT_METRICS',
+ SEARCH_COMMENT_STATUS_HISTORY: 'SEARCH_COMMENT_STATUS_HISTORY',
+
+ // subscriptions
+ SUBSCRIBE_COMMENT_ACCEPTED: 'SUBSCRIBE_COMMENT_ACCEPTED',
+ SUBSCRIBE_COMMENT_REJECTED: 'SUBSCRIBE_COMMENT_REJECTED',
+ SUBSCRIBE_COMMENT_FLAGGED: 'SUBSCRIBE_COMMENT_FLAGGED',
+ SUBSCRIBE_ALL_COMMENT_ADDED: 'SUBSCRIBE_ALL_COMMENT_ADDED',
+ SUBSCRIBE_ALL_COMMENT_EDITED: 'SUBSCRIBE_ALL_COMMENT_EDITED',
};
diff --git a/perms/index.js b/perms/index.js
index f0e14c5fc..be6e7a2cd 100644
--- a/perms/index.js
+++ b/perms/index.js
@@ -2,11 +2,13 @@ const constants = require('./constants');
const root = require('./rootReducer');
const queries = require('./queryReducer');
const mutations = require('./mutationReducer');
+const subscriptions = require('./subscriptionReducer');
const reducers = [
root,
queries,
- mutations
+ mutations,
+ subscriptions,
];
// this will make 'reducer' a key in this array. hm.
diff --git a/perms/queryReducer.js b/perms/queryReducer.js
index 0e5054788..2bee02110 100644
--- a/perms/queryReducer.js
+++ b/perms/queryReducer.js
@@ -15,6 +15,8 @@ module.exports = (user, perm) => {
return check(user, ['ADMIN', 'MODERATOR']);
case types.SEARCH_COMMENT_METRICS:
return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SEARCH_COMMENT_STATUS_HISTORY:
+ return check(user, ['ADMIN', 'MODERATOR']);
default:
break;
}
diff --git a/perms/subscriptionReducer.js b/perms/subscriptionReducer.js
new file mode 100644
index 000000000..0b06902cf
--- /dev/null
+++ b/perms/subscriptionReducer.js
@@ -0,0 +1,19 @@
+const {check} = require('./utils');
+const types = require('./constants');
+
+module.exports = (user, perm) => {
+ switch (perm) {
+ case types.SUBSCRIBE_COMMENT_FLAGGED:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SUBSCRIBE_COMMENT_ACCEPTED:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SUBSCRIBE_COMMENT_REJECTED:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SUBSCRIBE_ALL_COMMENT_EDITED:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ case types.SUBSCRIBE_ALL_COMMENT_ADDED:
+ return check(user, ['ADMIN', 'MODERATOR']);
+ default:
+ break;
+ }
+};
diff --git a/plugin-api/beta/server/getReactionConfig.js b/plugin-api/beta/server/getReactionConfig.js
index 78bc45dfd..243b8915b 100644
--- a/plugin-api/beta/server/getReactionConfig.js
+++ b/plugin-api/beta/server/getReactionConfig.js
@@ -132,8 +132,11 @@ function getReactionConfig(reaction) {
return Action.create({item_id, item_type: 'COMMENTS', action_type: REACTION})
.then((action) => {
- // The comment is needed to allow better filtering e.g. by asset_id.
- pubsub.publish(`${reaction}ActionCreated`, {action, comment});
+ if (pubsub) {
+
+ // The comment is needed to allow better filtering e.g. by asset_id.
+ pubsub.publish(`${reaction}ActionCreated`, {action, comment});
+ }
return Promise.resolve(action);
})
.catch((err) => {
@@ -155,8 +158,11 @@ function getReactionConfig(reaction) {
}
return Comments.get.load(action.item_id).then((comment) => {
- // The comment is needed to allow better filtering e.g. by asset_id.
- pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
+ if (pubsub) {
+
+ // The comment is needed to allow better filtering e.g. by asset_id.
+ pubsub.publish(`${reaction}ActionDeleted`, {action, comment});
+ }
return Promise.resolve(action);
});
});
diff --git a/services/comments.js b/services/comments.js
index 3f18d55e3..af7593a8e 100644
--- a/services/comments.js
+++ b/services/comments.js
@@ -215,6 +215,10 @@ module.exports = class CommentsService {
}
},
$set: {status}
+ }, {
+
+ // return modified comment.
+ new: true,
});
}
diff --git a/yarn.lock b/yarn.lock
index 4f99143d0..df7efdaf8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5405,6 +5405,10 @@ muri@1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/muri/-/muri-1.2.1.tgz#ec7ea5ce6ca6a523eb1ab35bacda5fa816c9aa3c"
+murmurhash-js@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51"
+
mute-stream@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.4.tgz#a9219960a6d5d5d046597aee51252c6655f7177e"