mirror of
https://github.com/wassname/talk.git
synced 2026-07-02 19:06:38 +08:00
Implement live updates for mod actions
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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}},
|
||||
});
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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}},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
<ApolloProvider client={client} store={store}>
|
||||
<ApolloProvider client={getClient()} store={store}>
|
||||
<App />
|
||||
</ApolloProvider>
|
||||
, document.querySelector('#root')
|
||||
|
||||
@@ -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 (
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={`mdl-card ${selectionStateCSS} ${styles.Comment} ${styles.listItem} ${minimal ? styles.minimal : ''}`}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
{
|
||||
!minimal && (
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
|
||||
<input
|
||||
className={styles.bulkSelectInput}
|
||||
type='checkbox'
|
||||
value={comment.id}
|
||||
checked={selected}
|
||||
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
|
||||
)
|
||||
}
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
|
||||
</span>
|
||||
{props.currentUserId !== comment.user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
|
||||
Suspend User</ActionsMenuItem>
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
|
||||
Ban User
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{comment.user.status === 'banned'
|
||||
? <span className={styles.banned}>
|
||||
<Icon name="error_outline" />
|
||||
{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 (
|
||||
<li
|
||||
tabIndex={props.index}
|
||||
className={cn(className, 'mdl-card', selectionStateCSS, styles.Comment, styles.listItem, {[styles.minimal] : minimal})}
|
||||
>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
{
|
||||
!minimal && (
|
||||
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
|
||||
{comment.user.name}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
{
|
||||
minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && (
|
||||
<input
|
||||
className={styles.bulkSelectInput}
|
||||
type='checkbox'
|
||||
value={comment.id}
|
||||
checked={selected}
|
||||
onChange={(e) => toggleSelect(e.target.value, e.target.checked)} />
|
||||
)
|
||||
}
|
||||
<span className={styles.created}>
|
||||
{timeago(comment.created_at || Date.now() - props.index * 60 * 1000)}
|
||||
</span>
|
||||
: null}
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentInfoBar"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset &&
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={searchWords}
|
||||
textToHighlight={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}#${comment.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name="open_in_new" /> {t('comment.view_context')}
|
||||
</a>
|
||||
</p>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentContent"
|
||||
comment={comment}
|
||||
/>
|
||||
<div className={styles.sideActions}>
|
||||
{links
|
||||
? <span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
{props.currentUserId !== comment.user.id &&
|
||||
<ActionsMenu icon="not_interested">
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.name, comment.id, comment.status)}>
|
||||
Suspend User</ActionsMenuItem>
|
||||
<ActionsMenuItem
|
||||
disabled={comment.user.status === 'BANNED'}
|
||||
onClick={() => props.showBanUserDialog(comment.user, comment.id, comment.status, comment.status !== 'REJECTED')}>
|
||||
Ban User
|
||||
</ActionsMenuItem>
|
||||
</ActionsMenu>
|
||||
}
|
||||
<CommentType type={commentType} />
|
||||
</div>
|
||||
{comment.user.status === 'banned'
|
||||
? <span className={styles.banned}>
|
||||
<Icon name="error_outline" />
|
||||
{t('comment.banned_user')}
|
||||
</span>
|
||||
: null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
(action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={minimal}
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
active={active}
|
||||
acceptComment={() =>
|
||||
(comment.status === 'ACCEPTED'
|
||||
? null
|
||||
: props.acceptComment({commentId: comment.id}))}
|
||||
rejectComment={() =>
|
||||
(comment.status === 'REJECTED'
|
||||
? null
|
||||
: props.rejectComment({commentId: comment.id}))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentInfoBar"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.moderateArticle}>
|
||||
Story: {comment.asset.title}
|
||||
{!props.currentAsset &&
|
||||
<Link to={`/admin/moderate/${comment.asset.id}`}>{t('modqueue.moderate')}</Link>}
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<p className={styles.body}>
|
||||
<Highlighter
|
||||
searchWords={searchWords}
|
||||
textToHighlight={comment.body}
|
||||
/>
|
||||
{' '}
|
||||
<a
|
||||
className={styles.external}
|
||||
href={`${comment.asset.url}#${comment.id}`}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon name="open_in_new" /> {t('comment.view_context')}
|
||||
</a>
|
||||
</p>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminSideActions"
|
||||
fill="adminCommentContent"
|
||||
comment={comment}
|
||||
/>
|
||||
<div className={styles.sideActions}>
|
||||
{links
|
||||
? <span className={styles.hasLinks}>
|
||||
<Icon name="error_outline" /> Contains Link
|
||||
</span>
|
||||
: null}
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{actions.map((action, i) => {
|
||||
const active =
|
||||
(action === 'REJECT' && comment.status === 'REJECTED') ||
|
||||
(action === 'APPROVE' && comment.status === 'ACCEPTED');
|
||||
return (
|
||||
<ActionButton
|
||||
minimal={minimal}
|
||||
key={i}
|
||||
type={action}
|
||||
user={comment.user}
|
||||
status={comment.status}
|
||||
active={active}
|
||||
acceptComment={() =>
|
||||
(comment.status === 'ACCEPTED'
|
||||
? null
|
||||
: props.acceptComment({commentId: comment.id}))}
|
||||
rejectComment={() =>
|
||||
(comment.status === 'REJECTED'
|
||||
? null
|
||||
: props.rejectComment({commentId: comment.id}))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminSideActions"
|
||||
comment={comment}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentDetailArea"
|
||||
comment={comment}
|
||||
/>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
<Slot
|
||||
data={props.data}
|
||||
root={props.root}
|
||||
fill="adminCommentDetailArea"
|
||||
comment={comment}
|
||||
/>
|
||||
{flagActions && flagActions.length
|
||||
? <FlagBox
|
||||
actions={flagActions}
|
||||
actionSummaries={flagActionSummaries}
|
||||
/>
|
||||
: null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
|
||||
<ul style={{paddingLeft: 0}}>
|
||||
<CSSTransitionGroup
|
||||
key={activeTab}
|
||||
component={'ul'}
|
||||
style={{paddingLeft: 0}}
|
||||
transitionName={{
|
||||
enter: styles.commentEnter,
|
||||
enterActive: styles.commentEnterActive,
|
||||
leave: styles.commentLeave,
|
||||
leaveActive: styles.commentLeaveActive,
|
||||
}}
|
||||
transitionEnter={true}
|
||||
transitionLeave={true}
|
||||
transitionEnterTimeout={1000}
|
||||
transitionLeaveTimeout={1000}
|
||||
>
|
||||
{
|
||||
comments.length
|
||||
? comments.map((comment, i) => {
|
||||
@@ -56,7 +72,7 @@ class ModerationQueue extends React.Component {
|
||||
return <Comment
|
||||
data={this.props.data}
|
||||
root={this.props.root}
|
||||
key={i}
|
||||
key={comment.id}
|
||||
index={i}
|
||||
comment={comment}
|
||||
selected={i === selectedIndex}
|
||||
@@ -74,7 +90,7 @@ class ModerationQueue extends React.Component {
|
||||
})
|
||||
: <EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
|
||||
}
|
||||
</ul>
|
||||
</CSSTransitionGroup>
|
||||
<LoadMore
|
||||
loadMore={this.loadMore}
|
||||
showLoadMore={comments.length < commentCount}
|
||||
|
||||
@@ -479,3 +479,21 @@ span {
|
||||
.bulkSelectInput {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commentLeave {
|
||||
opacity: 1.0;
|
||||
transition: opacity 800ms;
|
||||
}
|
||||
|
||||
.commentLeaveActive {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.commentEnter {
|
||||
opacity: 0;
|
||||
transition: opacity 800ms;
|
||||
}
|
||||
|
||||
.commentEnterActive {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
@@ -10,6 +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 {fetchSettings} from 'actions/settings';
|
||||
import {updateAssets} from 'actions/assets';
|
||||
@@ -30,9 +31,65 @@ 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;
|
||||
|
||||
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}
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}) {
|
||||
|
||||
@@ -4,7 +4,10 @@ const Subscription = {
|
||||
},
|
||||
commentEdited(comment) {
|
||||
return comment;
|
||||
}
|
||||
},
|
||||
commentStatusChanged(data) {
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = Subscription;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
################################################################################
|
||||
|
||||
@@ -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"
|
||||
|
||||
+4
-1
@@ -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',
|
||||
};
|
||||
|
||||
+3
-1
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -215,6 +215,10 @@ module.exports = class CommentsService {
|
||||
}
|
||||
},
|
||||
$set: {status}
|
||||
}, {
|
||||
|
||||
// return modified comment.
|
||||
new: true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user