diff --git a/app.js b/app.js index 4e74062d5..9da2041fc 100644 --- a/app.js +++ b/app.js @@ -2,6 +2,7 @@ const express = require('express'); const bodyParser = require('body-parser'); const morgan = require('morgan'); const path = require('path'); +const merge = require('lodash/merge'); const helmet = require('helmet'); const compression = require('compression'); const cookieParser = require('cookie-parser'); @@ -10,6 +11,7 @@ const { BASE_PATH, MOUNT_PATH, STATIC_URL, + HELMET_CONFIGURATION, } = require('./url'); const routes = require('./routes'); const debug = require('debug')('talk:app'); @@ -31,9 +33,9 @@ app.set('trust proxy', 1); // Enable a suite of security good practices through helmet. We disable // frameguard to allow crossdomain injection of the embed. -app.use(helmet({ +app.use(helmet(merge(HELMET_CONFIGURATION, { frameguard: false, -})); +}))); // Compress the responses if appropriate. app.use(compression()); diff --git a/client/coral-admin/src/components/CommentLabels.css b/client/coral-admin/src/components/CommentLabels.css new file mode 100644 index 000000000..b7a5699fe --- /dev/null +++ b/client/coral-admin/src/components/CommentLabels.css @@ -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; +} diff --git a/client/coral-admin/src/components/CommentLabels.js b/client/coral-admin/src/components/CommentLabels.js new file mode 100644 index 000000000..7af7e9a7e --- /dev/null +++ b/client/coral-admin/src/components/CommentLabels.js @@ -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 ( +
+
+ {hasParent && } + {status === 'PREMOD' && } + {isUserFlagged(actions) && User} + {hasSuspectedWords(actions) && Suspect} + {hasHistoryFlag(actions) && History} +
+ +
+ ); +}; + +CommentLabels.propTypes = { + comment: PropTypes.shape({ + className: PropTypes.string, + status: PropTypes.string, + actions: PropTypes.array, + hasParent: PropTypes.bool, + }), +}; + +export default CommentLabels; diff --git a/client/coral-admin/src/components/CommentType.css b/client/coral-admin/src/components/CommentType.css deleted file mode 100644 index 969d9511d..000000000 --- a/client/coral-admin/src/components/CommentType.css +++ /dev/null @@ -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; - } -} diff --git a/client/coral-admin/src/components/CommentType.js b/client/coral-admin/src/components/CommentType.js deleted file mode 100644 index 4d1ff7e67..000000000 --- a/client/coral-admin/src/components/CommentType.js +++ /dev/null @@ -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 ( - - {typeData.text} - - ); -}; - -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; diff --git a/client/coral-admin/src/routes/Moderation/components/CommentCount.css b/client/coral-admin/src/components/CountBadge.css similarity index 93% rename from client/coral-admin/src/routes/Moderation/components/CommentCount.css rename to client/coral-admin/src/components/CountBadge.css index 998133d07..343692ecb 100644 --- a/client/coral-admin/src/routes/Moderation/components/CommentCount.css +++ b/client/coral-admin/src/components/CountBadge.css @@ -5,7 +5,7 @@ vertical-align: middle; padding: 1px 5px; border-radius: 2px; - margin-left: 2px; + margin-left: 5px; line-height: 18px; box-sizing: border-box; height: 18px; diff --git a/client/coral-admin/src/routes/Moderation/components/CommentCount.js b/client/coral-admin/src/components/CountBadge.js similarity index 81% rename from client/coral-admin/src/routes/Moderation/components/CommentCount.js rename to client/coral-admin/src/components/CountBadge.js index 53d611ed6..ff47cf9a6 100644 --- a/client/coral-admin/src/routes/Moderation/components/CommentCount.js +++ b/client/coral-admin/src/components/CountBadge.js @@ -1,10 +1,10 @@ import React from 'react'; import PropTypes from 'prop-types'; -import styles from './CommentCount.css'; +import styles from './CountBadge.css'; import t from 'coral-framework/services/i18n'; -const CommentCount = ({count}) => { +const CountBadge = ({count}) => { let number = count; // shorten large counts to abbreviations @@ -21,8 +21,8 @@ const CommentCount = ({count}) => { ); }; -CommentCount.propTypes = { +CountBadge.propTypes = { count: PropTypes.number.isRequired }; -export default CommentCount; +export default CountBadge; diff --git a/client/coral-admin/src/components/ReplyBadge.js b/client/coral-admin/src/components/ReplyBadge.js deleted file mode 100644 index 5c8bb88ba..000000000 --- a/client/coral-admin/src/components/ReplyBadge.js +++ /dev/null @@ -1,11 +0,0 @@ -import React from 'react'; -import {Badge} from 'coral-ui'; -import t from 'coral-framework/services/i18n'; - -const ReplyBadge = () => ( - - {t('modqueue.reply')} - -); - -export default ReplyBadge; diff --git a/client/coral-admin/src/components/UserDetailComment.css b/client/coral-admin/src/components/UserDetailComment.css index 2dfd61cf3..d1e2b007a 100644 --- a/client/coral-admin/src/components/UserDetailComment.css +++ b/client/coral-admin/src/components/UserDetailComment.css @@ -55,7 +55,7 @@ position: relative; } -.badgeBar { +.labels { position: absolute; right: 0px; } diff --git a/client/coral-admin/src/components/UserDetailComment.js b/client/coral-admin/src/components/UserDetailComment.js index fa196dc41..2aec28ab9 100644 --- a/client/coral-admin/src/components/UserDetailComment.js +++ b/client/coral-admin/src/components/UserDetailComment.js @@ -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 (
  • - {comment.hasParent && } - +
    +
    diff --git a/client/coral-admin/src/containers/CommentLabels.js b/client/coral-admin/src/containers/CommentLabels.js new file mode 100644 index 000000000..af6a0d963 --- /dev/null +++ b/client/coral-admin/src/containers/CommentLabels.js @@ -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); diff --git a/client/coral-admin/src/containers/UserDetailComment.js b/client/coral-admin/src/containers/UserDetailComment.js index 9f0e3a793..9f0199bce 100644 --- a/client/coral-admin/src/containers/UserDetailComment.js +++ b/client/coral-admin/src/containers/UserDetailComment.js @@ -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); diff --git a/client/coral-admin/src/routes/Community/components/Community.js b/client/coral-admin/src/routes/Community/components/Community.js index e59f9b6ca..bf300a166 100644 --- a/client/coral-admin/src/routes/Community/components/Community.js +++ b/client/coral-admin/src/routes/Community/components/Community.js @@ -4,6 +4,7 @@ import CommunityMenu from './CommunityMenu'; import People from './People'; import FlaggedAccounts from '../containers/FlaggedAccounts'; import RejectUsernameDialog from './RejectUsernameDialog'; +import PropTypes from 'prop-types'; export default class Community extends Component { @@ -76,7 +77,10 @@ export default class Community extends Component { return (
    - + - -
    - { tab } -
    + +
    {tab}
    ); } } +Community.propTypes = { + community: PropTypes.object, + fetchAccounts: PropTypes.func, + hideRejectUsernameDialog: PropTypes.func, + updateSorting: PropTypes.func, + newPage: PropTypes.func, + route: PropTypes.object, + rejectUsername: PropTypes.func, + data: PropTypes.object, + root: PropTypes.object +}; diff --git a/client/coral-admin/src/routes/Community/components/CommunityMenu.js b/client/coral-admin/src/routes/Community/components/CommunityMenu.js index 1e226313d..3e13617fe 100644 --- a/client/coral-admin/src/routes/Community/components/CommunityMenu.js +++ b/client/coral-admin/src/routes/Community/components/CommunityMenu.js @@ -1,18 +1,21 @@ import React from 'react'; - import styles from './CommunityMenu.css'; import t from 'coral-framework/services/i18n'; import {Link} from 'react-router'; +import PropTypes from 'prop-types'; +import CountBadge from '../../../components/CountBadge'; -const CommunityMenu = () => { +const CommunityMenu = ({flaggedUsernamesCount = 0}) => { const flaggedPath = '/admin/community/flagged'; const peoplePath = '/admin/community/people'; + return (
    {t('community.flaggedaccounts')} + {t('community.people')} @@ -23,4 +26,8 @@ const CommunityMenu = () => { ); }; +CommunityMenu.propTypes = { + flaggedUsernamesCount: PropTypes.number, +}; + export default CommunityMenu; diff --git a/client/coral-admin/src/routes/Community/containers/Community.js b/client/coral-admin/src/routes/Community/containers/Community.js index a41c257d6..445b7f413 100644 --- a/client/coral-admin/src/routes/Community/containers/Community.js +++ b/client/coral-admin/src/routes/Community/containers/Community.js @@ -1,9 +1,16 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {compose} from 'react-apollo'; +import {compose, gql} from 'react-apollo'; +import withQuery from 'coral-framework/hocs/withQuery'; +import PropTypes from 'prop-types'; + +import FlaggedAccounts from '../containers/FlaggedAccounts'; +import FlaggedUser from '../containers/FlaggedUser'; import {withSetUserStatus, withRejectUsername} from 'coral-framework/graphql/mutations'; +import {getDefinitionName} from 'coral-framework/utils'; + import { fetchAccounts, updateSorting, @@ -20,15 +27,57 @@ class CommunityContainer extends Component { } render() { - return ( - - ); + return ; } } const mapStateToProps = (state) => ({ community: state.community, }); +CommunityContainer.propTypes = { + community: PropTypes.object, + fetchAccounts: PropTypes.func, + hideRejectUsernameDialog: PropTypes.func, + updateSorting: PropTypes.func, + newPage: PropTypes.func, + route: PropTypes.object, + rejectUsername: PropTypes.func, + data: PropTypes.object, + root: PropTypes.object +}; + +const withData = withQuery(gql` + query TalkAdmin_FlaggedUsernamesCount { + flaggedUsernamesCount: userCount(query: { + action_type: FLAG, + statuses: [PENDING] + }) + ...${getDefinitionName(FlaggedAccounts.fragments.root)} + ...${getDefinitionName(FlaggedUser.fragments.root)} + me { + ...${getDefinitionName(FlaggedUser.fragments.me)} + __typename + } + } + ${FlaggedAccounts.fragments.root} + ${FlaggedUser.fragments.root} + ${FlaggedUser.fragments.me} + `, { + options: { + fetchPolicy: 'network-only', + }, +}); + const mapDispatchToProps = (dispatch) => bindActionCreators({ fetchAccounts, @@ -41,4 +90,5 @@ export default compose( connect(mapStateToProps, mapDispatchToProps), withSetUserStatus, withRejectUsername, + withData, )(CommunityContainer); diff --git a/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js b/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js index 00c6fdf33..b7f918d8c 100644 --- a/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js +++ b/client/coral-admin/src/routes/Community/containers/FlaggedAccounts.js @@ -2,8 +2,9 @@ import React, {Component} from 'react'; import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {compose, gql} from 'react-apollo'; -import withQuery from 'coral-framework/hocs/withQuery'; +import {withFragments} from 'plugin-api/beta/client/hocs'; import {Spinner} from 'coral-ui'; +import PropTypes from 'prop-types'; import {withSetUserStatus} from 'coral-framework/graphql/mutations'; import {showBanUserDialog} from 'actions/banUserDialog'; @@ -24,7 +25,10 @@ class FlaggedAccountsContainer extends Component { } approveUser = ({userId}) => { - return this.props.setUserStatus({userId, status: 'APPROVED'}); + return this.props.setUserStatus({ + userId, + status: 'APPROVED' + }); } loadMore = () => { @@ -74,6 +78,16 @@ class FlaggedAccountsContainer extends Component { } } +FlaggedAccountsContainer.propTypes = { + showBanUserDialog: PropTypes.func, + showSuspendUserDialog: PropTypes.func, + showRejectUsernameDialog: PropTypes.func, + viewUserDetail: PropTypes.func, + setUserStatus: PropTypes.func, + data: PropTypes.object, + root: PropTypes.object +}; + const LOAD_MORE_QUERY = gql` query TalkAdmin_LoadMoreFlaggedAccounts($limit: Int, $cursor: Cursor) { users(query:{action_type: FLAG, statuses: [PENDING], limit: $limit, cursor: $cursor}){ @@ -88,31 +102,6 @@ const LOAD_MORE_QUERY = gql` ${FlaggedUser.fragments.user} `; -export const withFlaggedAccountsyQuery = withQuery(gql` - query TalkAdmin_FlaggedAccounts { - ...${getDefinitionName(FlaggedUser.fragments.root)} - users(query:{action_type: FLAG, statuses: [PENDING], limit: 10}){ - hasNextPage - endCursor - nodes { - __typename - ...${getDefinitionName(FlaggedUser.fragments.user)} - } - } - me { - __typename - ...${getDefinitionName(FlaggedUser.fragments.me)} - } - } - ${FlaggedUser.fragments.root} - ${FlaggedUser.fragments.user} - ${FlaggedUser.fragments.me} -`, { - options: { - fetchPolicy: 'network-only', - }, -}); - const mapDispatchToProps = (dispatch) => bindActionCreators({ showBanUserDialog, @@ -123,6 +112,23 @@ const mapDispatchToProps = (dispatch) => export default compose( connect(null, mapDispatchToProps), - withFlaggedAccountsyQuery, withSetUserStatus, + withFragments({ + root: gql` + fragment TalkAdminCommunity_FlaggedAccounts_root on RootQuery { + users(query:{action_type: FLAG, statuses: [PENDING], limit: 10}){ + hasNextPage + endCursor + nodes { + __typename + ...${getDefinitionName(FlaggedUser.fragments.user)} + } + } + me { + id + } + } + ${FlaggedUser.fragments.user} + `, + }), )(FlaggedAccountsContainer); diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index a3edb4196..b210d8f23 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -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 { }
    - {comment.hasParent && } - + - {queue.name} + {queue.name} )}
    diff --git a/client/coral-admin/src/routes/Moderation/containers/Comment.js b/client/coral-admin/src/routes/Moderation/containers/Comment.js index cc127ef4b..0664de669 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Comment.js +++ b/client/coral-admin/src/routes/Moderation/containers/Comment.js @@ -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); diff --git a/client/coral-admin/src/routes/Moderation/containers/Moderation.js b/client/coral-admin/src/routes/Moderation/containers/Moderation.js index 2ff6b6732..baf6b8dfd 100644 --- a/client/coral-admin/src/routes/Moderation/containers/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/containers/Moderation.js @@ -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 ; } - 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' }; diff --git a/client/coral-admin/src/routes/Moderation/queueConfig.js b/client/coral-admin/src/routes/Moderation/queueConfig.js index b90979efe..f17c38a09 100644 --- a/client/coral-admin/src/routes/Moderation/queueConfig.js +++ b/client/coral-admin/src/routes/Moderation/queueConfig.js @@ -28,7 +28,6 @@ export default { name: t('modqueue.rejected'), }, all: { - statuses: ['NONE', 'PREMOD', 'ACCEPTED', 'REJECTED'], icon: 'question_answer', name: t('modqueue.all'), }, diff --git a/client/coral-admin/src/utils/comment.js b/client/coral-admin/src/utils/comment.js deleted file mode 100644 index 26530f6c9..000000000 --- a/client/coral-admin/src/utils/comment.js +++ /dev/null @@ -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; -} diff --git a/client/coral-embed-stream/src/components/Stream.js b/client/coral-embed-stream/src/components/Stream.js index af2ec698b..1ec780e63 100644 --- a/client/coral-embed-stream/src/components/Stream.js +++ b/client/coral-embed-stream/src/components/Stream.js @@ -160,7 +160,7 @@ class Stream extends React.Component { loading={loading} appendTabs={ - All Comments {totalCommentCount} + {t('stream.all_comments')} {totalCommentCount} } appendTabPanes={ diff --git a/client/coral-embed-stream/src/graphql/index.js b/client/coral-embed-stream/src/graphql/index.js index dc81b56a4..20ed8a1d6 100644 --- a/client/coral-embed-stream/src/graphql/index.js +++ b/client/coral-embed-stream/src/graphql/index.js @@ -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: { diff --git a/client/coral-ui/components/Badge.js b/client/coral-ui/components/Badge.js deleted file mode 100644 index c1ba8100b..000000000 --- a/client/coral-ui/components/Badge.js +++ /dev/null @@ -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}) => ( - - {icon && } - {children} - -); - -export default Badge; diff --git a/client/coral-ui/components/FlagLabel.css b/client/coral-ui/components/FlagLabel.css new file mode 100644 index 000000000..070e117d1 --- /dev/null +++ b/client/coral-ui/components/FlagLabel.css @@ -0,0 +1,4 @@ +.flag { + background: #d03235; +} + diff --git a/client/coral-ui/components/FlagLabel.js b/client/coral-ui/components/FlagLabel.js new file mode 100644 index 000000000..8a7864c8b --- /dev/null +++ b/client/coral-ui/components/FlagLabel.js @@ -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 ( + + ); +}; + +FlagLabel.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + iconName: PropTypes.string, +}; + +export default FlagLabel; diff --git a/client/coral-ui/components/Badge.css b/client/coral-ui/components/Label.css similarity index 82% rename from client/coral-ui/components/Badge.css rename to client/coral-ui/components/Label.css index db68ebd58..5e7f656ee 100644 --- a/client/coral-ui/components/Badge.css +++ b/client/coral-ui/components/Label.css @@ -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; -} \ No newline at end of file +} + + diff --git a/client/coral-ui/components/Label.js b/client/coral-ui/components/Label.js new file mode 100644 index 000000000..a73b6183d --- /dev/null +++ b/client/coral-ui/components/Label.js @@ -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 ( + + {children} + + ); +}; + +Label.propTypes = { + className: PropTypes.string, + children: PropTypes.node.isRequired, + iconName: PropTypes.string, +}; + +export default Label; diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index 8f261bcca..93fe5af64 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -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'; diff --git a/client/talk-plugin-commentbox/CommentBox.js b/client/talk-plugin-commentbox/CommentBox.js index e5717e08a..b210f4e79 100644 --- a/client/talk-plugin-commentbox/CommentBox.js +++ b/client/talk-plugin-commentbox/CommentBox.js @@ -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}); diff --git a/config.js b/config.js index 3d8b6213c..c42a1e4d2 100644 --- a/config.js +++ b/config.js @@ -79,6 +79,14 @@ const CONFIG = { INSTALL_LOCK: process.env.TALK_INSTALL_LOCK === 'TRUE', + //------------------------------------------------------------------------------ + // Middleware Configuration + //------------------------------------------------------------------------------ + + // HELMET_CONFIGURATION provides the entrypoint to override options for the + // helmet middleware used. + HELMET_CONFIGURATION: JSON.parse(process.env.TALK_HELMET_CONFIGURATION || '{}'), + //------------------------------------------------------------------------------ // External database url's //------------------------------------------------------------------------------ diff --git a/docs/_docs/00-01-faq.md b/docs/_docs/00-01-faq.md index e114fce72..77b7b86ed 100644 --- a/docs/_docs/00-01-faq.md +++ b/docs/_docs/00-01-faq.md @@ -3,6 +3,21 @@ title: Frequently Asked Questions permalink: /docs/faq/ --- +{% include toc %} + +### My site doesn't use HSTS headers, how do I stop Talk from sending them too? + +You can specify the configuration option `TALK_HELMET_CONFIGURATION` and set it +to: + +``` +TALK_HELMET_CONFIGURATION={"hsts": false} +``` + +Which will disable the HSTS module. See the +[helmet](https://github.com/helmetjs/helmet) repository for more information on +how to configure other security middleware used by default. + ### How are new stories/assets added to Talk? Is there an API? There are three ways that new assets can make their way into Talk: diff --git a/docs/_docs/02-01-configuration.md b/docs/_docs/02-01-configuration.md index b3e637e27..2c7b9bc8a 100644 --- a/docs/_docs/02-01-configuration.md +++ b/docs/_docs/02-01-configuration.md @@ -99,6 +99,12 @@ These are only used during the webpack build. and you would then specify the CDN/Storage url. (Default `process.env.TALK_ROOT_URL`) - `TALK_DISABLE_STATIC_SERVER` (_optional_) - When `TRUE`, it will not mount the static asset serving routes on the router. (Default `FALSE`) +- `TALK_HELMET_CONFIGURATION` (_optional_) - A JSON string representing the + configuration passed to the + [helmet](https://github.com/helmetjs/helmet) middleware. It can be used to + disable features like [HSTS](https://helmetjs.github.io/docs/hsts/) and others + by simply providing the configuration as detailed on the + [helmet README](https://github.com/helmetjs/helmet). (Default `{}`) ### Word Filter diff --git a/graph/loaders/comments.js b/graph/loaders/comments.js index 386d33fd1..b6a347b8e 100644 --- a/graph/loaders/comments.js +++ b/graph/loaders/comments.js @@ -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) { diff --git a/graph/loaders/users.js b/graph/loaders/users.js index b943c60ed..dc58fffad 100644 --- a/graph/loaders/users.js +++ b/graph/loaders/users.js @@ -107,6 +107,40 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor, }; }; +/** + * Retrieves the count of users based on the passed in query. + * @param {Object} context graph context + * @param {Object} query query to execute against the users collection + * to compute the counts + * @return {Promise} resolves to the counts of the users from the + * query + */ +const getCountByQuery = async ({loaders: {Actions}}, {action_type, statuses}) => { + let query = UserModel.find(); + + if (action_type) { + const userIds = await Actions.getByTypes({action_type, item_type: 'USERS'}); + + query = query.find({ + id: { + $in: userIds + } + }); + } + + if (statuses) { + query = query.where({ + status: { + $in: statuses + } + }); + } + + return UserModel + .find(query) + .count(); +}; + /** * Creates a set of loaders based on a GraphQL context. * @param {Object} context the context of the GraphQL request @@ -115,6 +149,7 @@ const getUsersByQuery = async ({user, loaders: {Actions}}, {ids, limit, cursor, module.exports = (context) => ({ Users: { getByQuery: (query) => getUsersByQuery(context, query), - getByID: new DataLoader((ids) => genUserByIDs(context, ids)) + getByID: new DataLoader((ids) => genUserByIDs(context, ids)), + getCountByQuery: (query) => getCountByQuery(context, query) } }); diff --git a/graph/mutators/comment.js b/graph/mutators/comment.js index 75cad99b7..a1315d847 100644 --- a/graph/mutators/comment.js +++ b/graph/mutators/comment.js @@ -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. diff --git a/graph/resolvers/comment.js b/graph/resolvers/comment.js index cd1e93141..8e063ad3c 100644 --- a/graph/resolvers/comment.js +++ b/graph/resolvers/comment.js @@ -26,6 +26,7 @@ const Comment = { query.asset_id = asset_id; query.parent_id = id; + query.statuses = ['NONE', 'ACCEPTED']; return Comments.getByQuery(query); }, diff --git a/graph/resolvers/root_mutation.js b/graph/resolvers/root_mutation.js index df67b8e8a..17235a59e 100644 --- a/graph/resolvers/root_mutation.js +++ b/graph/resolvers/root_mutation.js @@ -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}}), }), diff --git a/graph/resolvers/root_query.js b/graph/resolvers/root_query.js index 13e41a312..e4701c901 100644 --- a/graph/resolvers/root_query.js +++ b/graph/resolvers/root_query.js @@ -50,6 +50,14 @@ const RootQuery = { return Comments.getCountByQuery(query); }, + async userCount(_, {query}, {user, loaders: {Users}}) { + if (user == null || !user.can(SEARCH_OTHER_USERS)) { + return null; + } + + return Users.getCountByQuery(query); + }, + assetMetrics(_, query, {user, loaders: {Metrics: {Assets}}}) { if (user == null || !user.can(SEARCH_ASSETS)) { return null; diff --git a/graph/typeDefs.graphql b/graph/typeDefs.graphql index f36874639..70167bb67 100644 --- a/graph/typeDefs.graphql +++ b/graph/typeDefs.graphql @@ -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 @@ -341,6 +343,18 @@ input CommentCountQuery { tags: [String!] } +# UserCountQuery allows the ability to query user counts by specific +# methods. +input UserCountQuery { + + # comments returned will only be ones which have at least one action of this + # type. + action_type: ACTION_TYPE + + # Current status of a user. + statuses: [USER_STATUS] +} + type EditInfo { edited: Boolean! editableUntil: Date @@ -688,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 @@ -838,6 +852,10 @@ type RootQuery { # expensive as it is not batched. Requires the `ADMIN` role. commentCount(query: CommentCountQuery!): Int + # Return the count of users satisfied by the query. Note that this edge is + # expensive as it is not batched. This field is restricted. + userCount(query: UserCountQuery!): Int + # The currently logged in user based on the request. Requires any logged in # role. me: User @@ -875,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!] } diff --git a/locales/en.yml b/locales/en.yml index 9e1bf1056..1c4805394 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -313,7 +313,6 @@ en: report_notif_remove: "Your report has been removed." reported: Reported settings: - all_comments: "All Comments" from_settings_page: "From the Profile Page you can see your comment history." my_comment_history: "My comment History" profile: Profile @@ -322,6 +321,7 @@ en: to_access: "to access Profile" user_no_comment: "You've never left a comment. Join the conversation!" stream: + all_comments: "All Comments" temporarily_suspended: "In accordance with {0}'s community guidelines, your account has been temporarily suspended. Please rejoin the conversation {1}." comment_not_found: "Comment was not found" step_1_header: "Report an issue" diff --git a/locales/es.yml b/locales/es.yml index 83edac80b..8324ddd73 100644 --- a/locales/es.yml +++ b/locales/es.yml @@ -305,7 +305,6 @@ es: report_notif_remove: "Tu reporte ha sido eliminado." reported: "Reportado" settings: - all_comments: "Todos los comentarios" from_settings_page: "Desde la página de configuración puedes ver tu historial de comentarios." my_comment_history: "Mi historial de comentarios" profile: Perfil @@ -348,6 +347,7 @@ es: step_2_header: "Ayúdanos a comprender" step_3_header: "Gracias por tu participación" stream: + all_comments: "Todos los comentarios" temporarily_suspended: "De acuerdo con la guía de la comunidad de {0}, su cuenta ha sido temporalmente suspendida. Por favor unirse a la conversación {1}." comment_not_found: "Comentario no encontrado" streams: diff --git a/locales/fr.yml b/locales/fr.yml index 5ece19c67..69240b157 100644 --- a/locales/fr.yml +++ b/locales/fr.yml @@ -263,7 +263,6 @@ fr: reported: Signalé set_best: "Sélectionner comme le meilleur" settings: - all_comments: "Tous les commentaires" from_settings_page: "Dans la page Profil, vous pouvez voir l'historique de vos commentaires." my_comment_history: "Mon historique de commentaires" profile: Profil @@ -272,6 +271,7 @@ fr: to_access: "Accéder au profil" user_no_comment: "Vous n'avez jamais laissé de commentaire. Rejoignez la conversation!" stream: + all_comments: "Tous les commentaires" temporarily_suspended: "Conformément à la charte d'utilisation des commentaires de {0}, votre compte a été temporairement suspendu. Merci de revenir dans la conversation {1}." step_1_header: "Signaler un problème" step_2_header: "Aidez-nous à comprendre" diff --git a/locales/pt_BR.yml b/locales/pt_BR.yml index e2e93c531..783105f81 100644 --- a/locales/pt_BR.yml +++ b/locales/pt_BR.yml @@ -303,7 +303,6 @@ pt_BR: report_notif_remove: "Seu marcado foi removido." reported: Reportado settings: - all_comments: "Todos os comentários" from_settings_page: "Na página de Perfil, você pode ver seu histórico de comentários." my_comment_history: "Histórico de comentários" profile: Perfil @@ -312,8 +311,8 @@ pt_BR: to_access: "para acessar o perfil" user_no_comment: "Você nunca deixou um comentário. Participe da conversa!" stream: - temporarily_suspended: " - De acordo com as diretrizes da comunidade de {0}, sua conta foi temporariamente suspensa. Por favor, volte para a conversa {1}." + all_comments: "Todos os comentários" + temporarily_suspended: "De acordo com as diretrizes da comunidade de {0}, sua conta foi temporariamente suspensa. Por favor, volte para a conversa {1}." step_1_header: "Relatar um problema" step_2_header: "Ajude-nos a entender" step_3_header: "Obrigdo por sua contribuição" diff --git a/plugins.default.json b/plugins.default.json index 950cec229..6ce126983 100644 --- a/plugins.default.json +++ b/plugins.default.json @@ -20,6 +20,7 @@ "talk-plugin-sort-most-replied", "talk-plugin-author-menu", "talk-plugin-member-since", - "talk-plugin-ignore-user" + "talk-plugin-ignore-user", + "talk-plugin-moderation-actions" ] } diff --git a/plugins/talk-plugin-toxic-comments/client/components/CheckToxicityHook.js b/plugins/talk-plugin-toxic-comments/client/components/CheckToxicityHook.js index 22c378435..e4bd68097 100644 --- a/plugins/talk-plugin-toxic-comments/client/components/CheckToxicityHook.js +++ b/plugins/talk-plugin-toxic-comments/client/components/CheckToxicityHook.js @@ -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, +}; diff --git a/plugins/talk-plugin-toxic-comments/client/components/ToxicLabel.js b/plugins/talk-plugin-toxic-comments/client/components/ToxicLabel.js new file mode 100644 index 000000000..3c17703dd --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/components/ToxicLabel.js @@ -0,0 +1,8 @@ +import React from 'react'; +import {FlagLabel} from 'plugin-api/beta/client/components/ui'; + +const ToxicLabel = () => ( + Toxic +); + +export default ToxicLabel; diff --git a/plugins/talk-plugin-toxic-comments/client/containers/CheckToxicityHook.js b/plugins/talk-plugin-toxic-comments/client/containers/CheckToxicityHook.js new file mode 100644 index 000000000..f2b010b0d --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/containers/CheckToxicityHook.js @@ -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); diff --git a/plugins/talk-plugin-toxic-comments/client/containers/ToxicLabel.js b/plugins/talk-plugin-toxic-comments/client/containers/ToxicLabel.js new file mode 100644 index 000000000..0d2e9e14b --- /dev/null +++ b/plugins/talk-plugin-toxic-comments/client/containers/ToxicLabel.js @@ -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); diff --git a/plugins/talk-plugin-toxic-comments/client/index.js b/plugins/talk-plugin-toxic-comments/client/index.js index c364a2a00..8a16b8dea 100644 --- a/plugins/talk-plugin-toxic-comments/client/index.js +++ b/plugins/talk-plugin-toxic-comments/client/index.js @@ -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], }, }; diff --git a/plugins/talk-plugin-toxic-comments/client/translations.yml b/plugins/talk-plugin-toxic-comments/client/translations.yml index ebcc31903..787095046 100644 --- a/plugins/talk-plugin-toxic-comments/client/translations.yml +++ b/plugins/talk-plugin-toxic-comments/client/translations.yml @@ -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: