Merge pull request #971 from coralproject/new-flags

Flags: Implement Labels, bugfixes and improvements
This commit is contained in:
Wyatt Johnson
2017-09-19 17:17:50 -06:00
committed by GitHub
33 changed files with 320 additions and 173 deletions
@@ -0,0 +1,27 @@
.root {
display: flex;
justify-content: flex-end;
}
.coreLabels {
> *:not(:last-child) {
margin-right: 3px;
}
}
.slot {
&:not(:empty) {
padding-left: 3px;
}
> *:not(:last-child) {
margin-right: 3px;
}
}
.replyLabel {
background-color: #3D73D5;
}
.premodLabel {
background-color: #063B9A;
}
@@ -0,0 +1,45 @@
import React from 'react';
import PropTypes from 'prop-types';
import Label from 'coral-ui/components/Label';
import Slot from 'coral-framework/components/Slot';
import FlagLabel from 'coral-ui/components/FlagLabel';
import cn from 'classnames';
import styles from './CommentLabels.css';
function isUserFlagged(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.user);
}
function hasSuspectedWords(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'Matched suspect word filter');
}
function hasHistoryFlag(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'TRUST');
}
const CommentLabels = ({comment, comment: {className, status, actions, hasParent}}) => {
return (
<div className={cn(className, styles.root)}>
<div className={styles.coreLabels}>
{hasParent && <Label iconName="reply" className={styles.replyLabel}>reply</Label>}
{status === 'PREMOD' && <Label iconName="query_builder" className={styles.premodLabel}>Pre-Mod</Label>}
{isUserFlagged(actions) && <FlagLabel iconName="person">User</FlagLabel>}
{hasSuspectedWords(actions) && <FlagLabel iconName="sms_failed">Suspect</FlagLabel>}
{hasHistoryFlag(actions) && <FlagLabel iconName="sentiment_very_dissatisfied">History</FlagLabel>}
</div>
<Slot className={styles.slot} fill="adminCommentLabels" queryData={{comment}} />
</div>
);
};
CommentLabels.propTypes = {
comment: PropTypes.shape({
className: PropTypes.string,
status: PropTypes.string,
actions: PropTypes.array,
hasParent: PropTypes.bool,
}),
};
export default CommentLabels;
@@ -1,30 +0,0 @@
.commentType {
display: inline-block;
color: white;
background: grey;
box-sizing: border-box;
padding: 2px 5px;
font-size: 12px;
height: 24px;
letter-spacing: 0.4px;
line-height: 22px;
> i {
font-size: 14px;
vertical-align: text-top;
margin: 0;
margin-right: 4px;
}
&.premod {
background: #063B9A;
}
&.flagged {
background: #d03235;
}
&.no-type {
display: none;
}
}
@@ -1,32 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './CommentType.css';
import {Icon} from 'coral-ui';
import cn from 'classnames';
const CommentType = (props) => {
const typeData = getTypeData(props.type);
return (
<span className={cn(styles.commentType, styles[typeData.className], props.className)}>
<Icon name={typeData.icon}/>{typeData.text}
</span>
);
};
const getTypeData = (type) => {
switch (type) {
case 'premod':
return {icon: 'query_builder', text: 'Pre-Mod', className: 'premod'};
case 'flagged':
return {icon: 'flag', text: 'Flagged', className: 'flagged'};
default:
return {icon: 'flag', className: 'no-type'};
}
};
CommentType.propTypes = {
type: PropTypes.string.isRequired
};
export default CommentType;
@@ -1,11 +0,0 @@
import React from 'react';
import {Badge} from 'coral-ui';
import t from 'coral-framework/services/i18n';
const ReplyBadge = () => (
<Badge icon="reply">
{t('modqueue.reply')}
</Badge>
);
export default ReplyBadge;
@@ -55,7 +55,7 @@
position: relative;
}
.badgeBar {
.labels {
position: absolute;
right: 0px;
}
@@ -4,16 +4,14 @@ import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import FlagBox from './FlagBox';
import ReplyBadge from './ReplyBadge';
import styles from './UserDetailComment.css';
import CommentType from './CommentType';
import {getActionSummary} from 'coral-framework/utils';
import ActionButton from 'coral-admin/src/components/ActionButton';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import {getCommentType} from 'coral-admin/src/utils/comment';
import CommentAnimatedEdit from './CommentAnimatedEdit';
import CommentLabels from '../containers/CommentLabels';
import t, {timeago} from 'coral-framework/services/i18n';
@@ -35,7 +33,6 @@ class UserDetailComment extends React.Component {
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
const commentType = getCommentType(comment);
return (
<li
@@ -59,9 +56,8 @@ class UserDetailComment extends React.Component {
: null
}
<div className={styles.badgeBar}>
{comment.hasParent && <ReplyBadge/>}
<CommentType type={commentType}/>
<div className={styles.labels}>
<CommentLabels comment={comment} />
</div>
</div>
<div className={styles.story}>
@@ -0,0 +1,27 @@
import {gql} from 'react-apollo';
import CommentLabels from '../components/CommentLabels';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
const slots = [
'adminCommentLabels',
];
export default withFragments({
comment: gql`
fragment CoralAdmin_CommentLabels_comment on Comment {
hasParent
status
actions {
__typename
... on FlagAction {
reason
}
user {
id
}
}
${getSlotFragmentSpreads(slots, 'comment')}
}
`
})(CommentLabels);
@@ -1,6 +1,8 @@
import {gql} from 'react-apollo';
import UserDetailComment from '../components/UserDetailComment';
import withFragments from 'coral-framework/hocs/withFragments';
import {getDefinitionName} from 'coral-framework/utils';
import CommentLabels from './CommentLabels';
export default withFragments({
comment: gql`
@@ -35,6 +37,8 @@ export default withFragments({
editing {
edited
}
...${getDefinitionName(CommentLabels.fragments.comment)}
}
${CommentLabels.fragments.comment}
`
})(UserDetailComment);
@@ -3,10 +3,9 @@ import PropTypes from 'prop-types';
import {Link} from 'react-router';
import {Icon} from 'coral-ui';
import ReplyBadge from 'coral-admin/src/components/ReplyBadge';
import FlagBox from 'coral-admin/src/components/FlagBox';
import styles from './styles.css';
import CommentType from 'coral-admin/src/components/CommentType';
import CommentLabels from 'coral-admin/src/components/CommentLabels';
import CommentAnimatedEdit from 'coral-admin/src/components/CommentAnimatedEdit';
import Slot from 'coral-framework/components/Slot';
import {getActionSummary} from 'coral-framework/utils';
@@ -16,7 +15,6 @@ import ActionsMenuItem from 'coral-admin/src/components/ActionsMenuItem';
import CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter';
import IfHasLink from 'coral-admin/src/components/IfHasLink';
import cn from 'classnames';
import {getCommentType} from 'coral-admin/src/utils/comment';
import t, {timeago} from 'coral-framework/services/i18n';
@@ -66,7 +64,6 @@ class Comment extends React.Component {
const flagActionSummaries = getActionSummary('FlagActionSummary', comment);
const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction');
const commentType = getCommentType(comment);
const selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp';
@@ -109,8 +106,9 @@ class Comment extends React.Component {
</ActionsMenu>
}
<div className={styles.adminCommentInfoBar}>
{comment.hasParent && <ReplyBadge/>}
<CommentType type={commentType} className={styles.commentType}/>
<CommentLabels
comment={comment}
/>
<Slot
fill="adminCommentInfoBar"
data={data}
@@ -1,7 +1,8 @@
import {gql} from 'react-apollo';
import Comment from '../components/Comment';
import CommentLabels from '../../../containers/CommentLabels';
import withFragments from 'coral-framework/hocs/withFragments';
import {getSlotFragmentSpreads} from 'coral-framework/utils';
import {getSlotFragmentSpreads, getDefinitionName} from 'coral-framework/utils';
const slots = [
'adminCommentInfoBar',
@@ -58,6 +59,8 @@ export default withFragments({
}
hasParent
${getSlotFragmentSpreads(slots, 'comment')}
...${getDefinitionName(CommentLabels.fragments.comment)}
}
${CommentLabels.fragments.comment}
`
})(Comment);
@@ -170,7 +170,8 @@ class ModerationContainer extends Component {
cursor: this.props.root[tab].endCursor,
sortOrder: this.props.data.variables.sortOrder,
asset_id: this.props.data.variables.asset_id,
statuses: this.props.queueConfig[tab].statuses,
statuses: this.props.queueConfig[tab].statuses || null,
tags: this.props.queueConfig[tab].tags || null,
action_type: this.props.queueConfig[tab].action_type,
};
return this.props.data.fetchMore({
@@ -214,7 +215,7 @@ class ModerationContainer extends Component {
return <Spinner />;
}
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
const premodEnabled = assetId ? isPremod(asset.settings.moderation) :
isPremod(settings.moderation);
const currentQueueConfig = Object.assign({}, this.props.queueConfig);
@@ -293,8 +294,8 @@ const COMMENT_REJECTED_SUBSCRIPTION = gql`
`;
const LOAD_MORE_QUERY = gql`
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type}) {
query CoralAdmin_Moderation_LoadMore($limit: Int = 10, $cursor: Cursor, $sortOrder: SORT_ORDER, $asset_id: ID, $tags:[String!], $statuses:[COMMENT_STATUS!], $action_type: ACTION_TYPE) {
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sortOrder: $sortOrder, action_type: $action_type, tags: $tags}) {
nodes {
...${getDefinitionName(Comment.fragments.comment)}
}
@@ -319,10 +320,10 @@ const commentConnectionFragment = gql`
`;
const withModQueueQuery = withQuery(({queueConfig}) => gql`
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!) {
query CoralAdmin_Moderation($asset_id: ID, $sortOrder: SORT_ORDER, $allAssets: Boolean!, $nullStatuses: [COMMENT_STATUS!]) {
${Object.keys(queueConfig).map((queue) => `
${queue}: comments(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
@@ -333,7 +334,7 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
`)}
${Object.keys(queueConfig).map((queue) => `
${queue}Count: commentCount(query: {
${queueConfig[queue].statuses ? `statuses: [${queueConfig[queue].statuses.join(', ')}],` : ''}
statuses: ${queueConfig[queue].statuses ? `[${queueConfig[queue].statuses.join(', ')}],` : '$nullStatuses'}
${queueConfig[queue].tags ? `tags: ["${queueConfig[queue].tags.join('", "')}"],` : ''}
${queueConfig[queue].action_type ? `action_type: ${queueConfig[queue].action_type}` : ''}
asset_id: $asset_id,
@@ -361,6 +362,7 @@ const withModQueueQuery = withQuery(({queueConfig}) => gql`
asset_id: id,
sortOrder: props.moderation.sortOrder,
allAssets: id === null,
nullStatuses: null,
},
fetchPolicy: 'network-only'
};
@@ -28,7 +28,6 @@ export default {
name: t('modqueue.rejected'),
},
all: {
statuses: ['NONE', 'PREMOD', 'ACCEPTED', 'REJECTED'],
icon: 'question_answer',
name: t('modqueue.all'),
},
-9
View File
@@ -1,9 +0,0 @@
export function getCommentType(comment) {
let commentType = '';
if (comment.status === 'PREMOD') {
commentType = 'premod';
} else if (comment.actions && comment.actions.some((a) => a.__typename === 'FlagAction')) {
commentType = 'flagged';
}
return commentType;
}
@@ -64,6 +64,13 @@ export default {
hasNextPage
}
}
actions {
__typename
... on FlagAction {
reason
message
}
}
}
fragment CoralEmbedStream_CreateCommentResponse_Comment on Comment {
@@ -77,14 +84,6 @@ export default {
title
url
}
actions {
__typename
id
... on FlagAction {
reason
message
}
}
tags {
tag {
name
@@ -123,6 +122,8 @@ export default {
optimisticResponse: {
createComment: {
__typename: 'CreateCommentResponse',
errors: null,
actions: [],
comment: {
__typename: 'Comment',
user: {
-13
View File
@@ -1,13 +0,0 @@
import React from 'react';
import styles from './Badge.css';
import Icon from './Icon';
import cn from 'classnames';
const Badge = ({className, children, icon, props}) => (
<span className={cn(styles.badge, className)} {...props}>
{icon && <Icon name={icon} className={styles.icon} />}
{children}
</span>
);
export default Badge;
+4
View File
@@ -0,0 +1,4 @@
.flag {
background: #d03235;
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './FlagLabel.css';
import Label from './Label';
import cn from 'classnames';
const FlagLabel = ({iconName, children, className}) => {
return (
<Label iconName={iconName} className={cn(className, styles.flag)}>
{children}
</Label>
);
};
FlagLabel.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
iconName: PropTypes.string,
};
export default FlagLabel;
@@ -1,4 +1,4 @@
.badge {
.root {
display: inline-block;
color: white;
background: grey;
@@ -8,8 +8,8 @@
height: 24px;
letter-spacing: 0.4px;
line-height: 22px;
background-color: #3D73D5;
margin-right: 4px;
min-width: 80px;
text-align: center;
}
.icon {
@@ -17,4 +17,6 @@
vertical-align: text-top;
margin: 0;
margin-right: 4px;
}
}
+21
View File
@@ -0,0 +1,21 @@
import React from 'react';
import PropTypes from 'prop-types';
import styles from './Label.css';
import {Icon} from 'coral-ui';
import cn from 'classnames';
const Label = ({iconName, children, className}) => {
return (
<span className={cn(className, styles.root)}>
<Icon name={iconName} className={styles.icon} /> {children}
</span>
);
};
Label.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
iconName: PropTypes.string,
};
export default Label;
+2 -1
View File
@@ -26,4 +26,5 @@ export {default as Option} from './components/Option';
export {default as SnackBar} from './components/SnackBar';
export {default as TextArea} from './components/TextArea';
export {default as Drawer} from './components/Drawer';
export {default as Badge} from './components/Badge';
export {default as Label} from './components/Label';
export {default as FlagLabel} from './components/FlagLabel';
+13 -3
View File
@@ -11,12 +11,20 @@ import {CommentForm} from './CommentForm';
export const name = 'talk-plugin-commentbox';
const notifyReasons = ['LINKS', 'TRUST'];
function shouldNotify(actions = []) {
return actions.some(({__typename, reason}) => __typename === 'FlagAction' && notifyReasons.includes(reason));
}
// Given a newly posted comment's status, show a notification to the user
// if needed
export const notifyForNewCommentStatus = (notify, comment) => {
export const notifyForNewCommentStatus = (notify, comment, actions) => {
if (comment.status === 'REJECTED') {
notify('error', t('comment_box.comment_post_banned_word'));
} else if (comment.status === 'PREMOD' || comment.status === 'SYSTEM_WITHHELD') {
} else if (
comment.status === 'PREMOD' ||
comment.status === 'SYSTEM_WITHHELD' && shouldNotify(actions)) {
notify('success', t('comment_box.comment_post_notif_premod'));
}
};
@@ -70,11 +78,12 @@ class CommentBox extends React.Component {
.then(({data}) => {
this.setState({loadingState: 'success', body: ''});
const postedComment = data.createComment.comment;
const actions = data.createComment.actions;
// Execute postSubmit Hooks
this.state.hooks.postSubmit.forEach((hook) => hook(data));
notifyForNewCommentStatus(notify, postedComment);
notifyForNewCommentStatus(notify, postedComment, actions);
if (commentPostedHandler) {
commentPostedHandler();
@@ -187,6 +196,7 @@ CommentBox.propTypes = {
isReply: PropTypes.bool.isRequired,
canPost: PropTypes.bool,
notify: PropTypes.func.isRequired,
commentBox: PropTypes.object,
};
const mapStateToProps = ({commentBox}) => ({commentBox});
+22 -16
View File
@@ -91,14 +91,23 @@ const getParentCountsByAssetID = (context, asset_ids) => {
const getCommentCountByQuery = (context, {ids, statuses, asset_id, parent_id, author_id, tags, action_type}) => {
let query = CommentModel.find();
if (ids) {
query = query.where({id: {$in: ids}});
// If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
// special priviledges.
if (
(!statuses || statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(context.user == null || !context.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
) {
return null;
}
if (statuses) {
query = query.where({status: {$in: statuses}});
}
if (ids) {
query = query.where({id: {$in: ids}});
}
if (asset_id != null) {
query = query.where({asset_id});
}
@@ -279,20 +288,17 @@ const executeWithSort = async (ctx, query, {cursor, sortOrder, sortBy, limit}) =
const getCommentsByQuery = async (ctx, {ids, statuses, asset_id, parent_id, author_id, limit, cursor, sortOrder, sortBy, excludeIgnored, tags, action_type}) => {
let comments = CommentModel.find();
// Only administrators can search for comments with statuses that are not
// `null`, or `'ACCEPTED'`.
if (ctx.user != null && ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS) && statuses && statuses.length > 0) {
comments = comments.where({
status: {
$in: statuses
}
});
} else {
comments = comments.where({
status: {
$in: ['NONE', 'ACCEPTED']
}
});
// If user queries for statuses other than NONE and/or ACCEPTED statuses, it needs
// special priviledges.
if (
(!statuses || statuses.some((status) => !['NONE', 'ACCEPTED'].includes(status))) &&
(ctx.user == null || !ctx.user.can(SEARCH_NON_NULL_OR_ACCEPTED_COMMENTS))
) {
return null;
}
if (statuses) {
comments = comments.where({status: {$in: statuses}});
}
if (ctx.user != null && ctx.user.can(SEARCH_OTHERS_COMMENTS) && action_type) {
+17 -12
View File
@@ -393,13 +393,12 @@ const moderationPhases = [
];
/**
* This resolves a given comment's status to take into account moderator actions
* are applied.
* This resolves a given comment's status and actions.
* @param {Object} context graphql context
* @param {String} body body of the comment
* @param {String} [asset_id] asset for the comment
* @param {Object} [wordlist={}] the results of the wordlist scan
* @return {Promise} resolves to the comment's status
* @return {Promise} resolves to the comment's status and actions
*/
const resolveCommentModeration = async (context, comment) => {
@@ -418,6 +417,8 @@ const resolveCommentModeration = async (context, comment) => {
// Combine the asset and the settings to get the asset settings.
const assetSettings = await AssetsService.rectifySettings(asset, settings);
let actions = comment.actions || [];
// Loop over all the moderation phases and see if we've resolved the status.
for (const phase of moderationPhases) {
const result = await phase(context, comment, {
@@ -429,13 +430,14 @@ const resolveCommentModeration = async (context, comment) => {
if (result) {
// Merge the comment and the result together.
comment = merge(comment, result);
if (result.actions) {
actions.push(...result.actions);
}
// If this result contained a status, then we've finished resolving
// phases!
if (result.status) {
return comment.actions;
return {status: result.status, actions};
}
}
}
@@ -454,17 +456,20 @@ const createPublicComment = async (context, comment) => {
// We then take the wordlist and the comment into consideration when
// considering what status to assign the new comment, and resolve the new
// status to set the comment to.
let actions = await resolveCommentModeration(context, comment);
let {actions, status} = await resolveCommentModeration(context, comment);
// Assign status to comment.
comment.status = status;
// Then we actually create the comment with the new status.
comment = await createComment(context, comment);
const result = await createComment(context, comment);
// Create all the actions that were determined during the moderation check
// phase.
await createActions(comment.id, actions);
await createActions(result.id, actions);
// Finally, we return the comment.
return comment;
return result;
};
// createActions will for each of the provided actions, create the given action
@@ -515,10 +520,10 @@ const edit = async (context, {id, asset_id, edit: {body}}) => {
let comment = {id, asset_id, body};
// Determine the new status of the comment.
const actions = await resolveCommentModeration(context, comment);
const {actions, status} = await resolveCommentModeration(context, comment);
// Execute the edit.
comment = await CommentsService.edit({id, author_id: context.user.id, body, status: comment.status});
comment = await CommentsService.edit({id, author_id: context.user.id, body, status});
// Create all the actions that were determined during the moderation check
// phase.
+1
View File
@@ -26,6 +26,7 @@ const Comment = {
query.asset_id = asset_id;
query.parent_id = id;
query.statuses = ['NONE', 'ACCEPTED'];
return Comments.getByQuery(query);
},
+8 -3
View File
@@ -1,7 +1,12 @@
const RootMutation = {
createComment: async (_, {input}, {mutators: {Comment}}) => ({
comment: await Comment.create(input),
}),
createComment: async (_, {input}, {mutators: {Comment}, loaders: {Actions}}) => {
const comment = await Comment.create(input);
// Retrieve actions that was assigned to comment.
const actions = await Actions.getByID.load(comment.id);
return {comment, actions};
},
editComment: async (_, {id, asset_id, edit: {body}}, {mutators: {Comment}}) => ({
comment: await Comment.edit({id, asset_id, edit: {body}}),
}),
+11 -6
View File
@@ -261,11 +261,12 @@ enum ACTION_TYPE {
# CommentsQuery allows the ability to query comments by a specific methods.
input CommentsQuery {
# Author of the comments
# Author of the commente
author_id: ID
# Current status of a comment. Requires the `ADMIN` role.
statuses: [COMMENT_STATUS!]
# Current status of a comment.
# This field is restricted.
statuses: [COMMENT_STATUS!] = [NONE, ACCEPTED]
# Asset that a comment is on.
asset_id: ID
@@ -317,8 +318,9 @@ input RepliesQuery {
# methods.
input CommentCountQuery {
# Current status of a comment. Requires the `ADMIN` role.
statuses: [COMMENT_STATUS!]
# Current status of a comment.
# This field is restricted.
statuses: [COMMENT_STATUS!] = [NONE, ACCEPTED]
# Asset that a comment is on.
asset_id: ID
@@ -700,7 +702,7 @@ type Asset {
# The comments that are attached to the asset. When `deep` is true, the
# comments returned will be at all depths.
comments(query: CommentsQuery = {}, deep: Boolean = false): CommentConnection!
comments(query: CommentsQuery = {}, deep: Boolean = false): CommentConnection
# A Comment from the Asset by comment's ID
comment(id: ID!): Comment
@@ -891,6 +893,9 @@ type CreateCommentResponse implements Response {
# The comment that was created.
comment: Comment
# Actions that was assigned during creation of the comment.
actions: [Action]
# An array of errors relating to the mutation that occurred.
errors: [UserError!]
}
@@ -1,4 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import {t} from 'plugin-api/beta/client/services';
/**
* CheckToxicityHook adds hooks to the `commentBox`
@@ -20,7 +22,11 @@ export default class CheckToxicityHook extends React.Component {
}
});
this.toxicityPostHook = this.props.registerHook('postSubmit', () => {
this.toxicityPostHook = this.props.registerHook('postSubmit', (result) => {
const actions = result.createComment.actions;
if (actions && actions.some(({__typename, reason}) => __typename === 'FlagAction' && reason === 'TOXIC_COMMENT')) {
this.props.notify('error', t('talk-plugin-toxic-comments.still_toxic'));
}
// Reset `checked` after comment was successfully posted.
this.checked = false;
@@ -36,3 +42,9 @@ export default class CheckToxicityHook extends React.Component {
return null;
}
}
CheckToxicityHook.propTypes = {
notify: PropTypes.func.isRequired,
registerHook: PropTypes.func.isRequired,
unregisterHook: PropTypes.func.isRequired,
};
@@ -0,0 +1,8 @@
import React from 'react';
import {FlagLabel} from 'plugin-api/beta/client/components/ui';
const ToxicLabel = () => (
<FlagLabel iconName="add_box">Toxic</FlagLabel>
);
export default ToxicLabel;
@@ -0,0 +1,9 @@
import {bindActionCreators} from 'redux';
import {connect} from 'plugin-api/beta/client/hocs';
import {notify} from 'plugin-api/beta/client/actions/notification';
import CheckToxicityHook from '../components/CheckToxicityHook';
const mapDispatchToProps = (dispatch) =>
bindActionCreators({notify}, dispatch);
export default connect(null, mapDispatchToProps)(CheckToxicityHook);
@@ -0,0 +1,24 @@
import {compose, gql} from 'react-apollo';
import {withFragments, excludeIf} from 'plugin-api/beta/client/hocs';
import ToxicLabel from '../components/ToxicLabel';
function isToxic(actions) {
return actions.some((action) => action.__typename === 'FlagAction' && action.reason === 'TOXIC_COMMENT');
}
const enhance = compose(
withFragments({
comment: gql`
fragment TalkToxicComments_Comment on Comment {
actions {
__typename
... on FlagAction {
reason
}
}
}`,
}),
excludeIf(({comment: {actions}}) => !isToxic(actions)),
);
export default enhance(ToxicLabel);
@@ -1,9 +1,11 @@
import translations from './translations.yml';
import CheckToxicityHook from './components/CheckToxicityHook';
import CheckToxicityHook from './containers/CheckToxicityHook';
import ToxicLabel from './containers/ToxicLabel';
export default {
translations,
slots: {
commentInputDetailArea: [CheckToxicityHook],
adminCommentLabels: [ToxicLabel],
},
};
@@ -3,4 +3,8 @@ en:
COMMENT_IS_TOXIC: |
Are you sure? The language in this comment might violate our community guidelines.
You can edit the comment or submit it for moderator review.
talk-plugin-toxic-comments:
still_toxic: |
This edited comment might still violate our community guidelines.
Our moderation team will review your comment shortly.
es: