Merge branch 'master' into bug-killing-spree

Conflicts:
	client/coral-admin/src/routes/Moderation/components/UserDetail.js
This commit is contained in:
Chi Vinh Le
2017-06-21 21:47:01 +07:00
35 changed files with 817 additions and 305 deletions
+3
View File
@@ -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) => {
@@ -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;
}
@@ -84,12 +84,12 @@ const CoralHeader = ({
<MenuItem onClick={handleLogout}>
{t('configure.sign_out')}
</MenuItem>
<MenuItem>
Talk {`v${process.env.VERSION}`}
</MenuItem>
</Menu>
</div>
</li>
<li>
{`v${process.env.VERSION}`}
</li>
</ul>
</div>
</div>
-54
View File
@@ -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}},
});
}
}
}),
},
};
+137
View File
@@ -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;
}
+2 -2
View File
@@ -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')
@@ -26,7 +26,7 @@ const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, c
<h2>{t('bandialog.ban_user')}</h2>
</div>
<div className={styles.separator}>
<h3>{t('bandialog.are_you_sure', user.name)}</h3>
<h3>{t('bandialog.are_you_sure', user.username)}</h3>
<i>{showRejectedNote && t('bandialog.note')}</i>
</div>
<div className={styles.buttons}>
@@ -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 (
<li
tabIndex={props.index}
className={`mdl-card ${selectionStateCSS} ${selected ? styles.selected : ''} ${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, [styles.selected]: selected})}
>
<div className={styles.container}>
<div className={styles.itemHeader}>
<div className={styles.author}>
{
!minimal && (
<span className={styles.username} onClick={() => viewUserDetail(comment.user.id)}>
{comment.user.username}
</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
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
{props.currentUserId !== comment.user.id &&
<ActionsMenu icon="not_interested">
<ActionsMenuItem
disabled={comment.user.status === 'BANNED'}
onClick={() => props.showSuspendUserDialog(comment.user.id, comment.user.username, 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="adminSideActions"
comment={comment}
/>
<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>
<CSSTransitionGroup
component={'div'}
style={{position: 'relative'}}
transitionName={{
enter: styles.bodyEnter,
enterActive: styles.bodyEnterActive,
leave: styles.bodyLeave,
leaveActive: styles.bodyLeaveActive,
}}
transitionEnter={true}
transitionLeave={true}
transitionEnterTimeout={3600}
transitionLeaveTimeout={2800}
>
<div className={styles.itemBody} key={murmur3(comment.body)}>
<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
</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>
</CSSTransitionGroup>
</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,
@@ -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 <NotFoundAsset assetId={providedAssetId} />;
}
if (asset === undefined || asset.id !== providedAssetId) {
// Still loading.
return <Spinner />;
}
}
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}
@@ -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 (
<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) => {
comments.map((comment, i) => {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
return <Comment
data={this.props.data}
root={this.props.root}
key={i}
key={comment.id}
index={i}
comment={comment}
selected={i === selectedIndex}
@@ -81,9 +100,14 @@ class ModerationQueue extends React.Component {
currentUserId={this.props.currentUserId}
/>;
})
: <EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
}
</ul>
</CSSTransitionGroup>
{comments.length === 0 &&
<div className={styles.emptyCardContainer}>
<EmptyCard>{t('modqueue.empty_queue')}</EmptyCard>
</div>
}
<LoadMore
loadMore={this.loadMore}
showLoadMore={comments.length < commentCount}
@@ -163,7 +163,7 @@ export default class UserDetail extends React.Component {
const status = comment.action_summaries ? 'FLAGGED' : comment.status;
const selected = selectedIds.indexOf(comment.id) !== -1;
return <Comment
key={i}
key={comment.id}
index={i}
comment={comment}
selected={false}
@@ -469,6 +469,61 @@ span {
cursor: pointer;
}
.emptyCardContainer {
margin-top: 16px;
}
.commentLeave {
opacity: 1.0;
transition: opacity 800ms;
}
.commentLeaveActive {
opacity: 0;
}
.commentEnter {
opacity: 0;
transition: opacity 800ms;
}
.commentEnterActive {
opacity: 1.0;
}
.bodyLeave {
position: absolute;
width: 100%;
top: 0;
background-color: white;
opacity: 1.0;
transition: background 400ms, opacity 800ms 1600ms;
pointer-events: none;
}
.bodyLeaveActive {
opacity: 0;
background-color: rgba(255,255,0, 0.2);
}
.bodyEnter {
opacity: 0;
pointer-events: none;
}
.bodyEnterActive {
opacity: 1.0;
transition: opacity 800ms 2400ms;
}
.editedMarker {
font-style: italic;
color: #666;
font-size: 12px;
line-height: 1px;
font-weight: 300;
}
.searchTrigger {
position: relative;
top: .3em;
@@ -26,7 +26,7 @@ export default withFragments({
status
user {
id
name: username
username
status
}
asset {
@@ -49,6 +49,9 @@ export default withFragments({
}
}
}
editing {
edited
}
${pluginFragments.spreads('comment')}
}
${pluginFragments.definitions('comment')}
@@ -7,8 +7,11 @@ 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 NotFoundAsset from '../components/NotFoundAsset';
import {withSetUserStatus, withSuspendUser, withSetCommentStatus} from 'coral-framework/graphql/mutations';
import {handleCommentChange} from '../../../graphql/utils';
import {fetchSettings} from 'actions/settings';
import {
@@ -31,10 +34,108 @@ 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 = [];
get activeTab() { return this.props.route.path === ':id' ? 'premod' : this.props.route.path; }
subscribeToUpdates(variables = this.props.data.variables) {
const sub1 = this.props.data.subscribeToMore({
document: COMMENT_ACCEPTED_SUBSCRIPTION,
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;
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 <div>Error</div>;
}
if (!('premodCount' in root)) {
return <div><Spinner/></div>;
if (assetId) {
if (asset === null) {
// Not found.
return <NotFoundAsset assetId={assetId} />;
}
if (asset === undefined || asset.id !== assetId) {
// Still loading.
return <Spinner />;
}
} else if(asset !== undefined || !('premodCount' in root)) {
// loading.
return <Spinner />;
}
return <Moderation
@@ -132,22 +249,72 @@ class ModerationContainer extends Component {
acceptComment={this.acceptComment}
rejectComment={this.rejectComment}
suspendUser={this.suspendUser}
activeTab={this.activeTab}
/>;
}
}
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}
+4 -18
View File
@@ -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});
}
+3 -3
View File
@@ -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,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 {
<PubDate created_at={comment.created_at} className={'talk-stream-comment-published-date'} />
{
(comment.editing && comment.editing.edited)
? <span>&nbsp;<span className={styles.editedMarker}>(Edited)</span></span>
? <span>&nbsp;<span className={styles.editedMarker}>({t('comment.edited')})</span></span>
: null
}
</span>
+2 -1
View File
@@ -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,
+14 -1
View File
@@ -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;
};
+14 -2
View File
@@ -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;
};
+11
View File
@@ -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;
+2
View File
@@ -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,
+10 -1
View File
@@ -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;
+50 -2
View File
@@ -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;
}
},
}),
});
+29 -2
View File
@@ -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
}
################################################################################
+5
View File
@@ -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"
+1
View File
@@ -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:
+1 -1
View File
@@ -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"
+9 -1
View File
@@ -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',
};
+3 -1
View File
@@ -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.
+2
View File
@@ -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;
}
+19
View File
@@ -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;
}
};
+10 -4
View File
@@ -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);
});
});
+4
View File
@@ -215,6 +215,10 @@ module.exports = class CommentsService {
}
},
$set: {status}
}, {
// return modified comment.
new: true,
});
}
+4
View File
@@ -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"