diff --git a/client/coral-admin/src/components/CommentBodyHighlighter.js b/client/coral-admin/src/components/CommentBodyHighlighter.js new file mode 100644 index 000000000..3b3ee3318 --- /dev/null +++ b/client/coral-admin/src/components/CommentBodyHighlighter.js @@ -0,0 +1,27 @@ +import React from 'react'; +import Highlighter from 'react-highlight-words'; +import Linkify from 'react-linkify'; +const linkify = new Linkify(); + +export default ({suspectWords, bannedWords, body, ...rest}) => { + + const links = linkify.getMatches(body); + const linkText = links ? links.map((link) => link.raw) : []; + + // 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|$)`, 'i').test(body); + }) + .concat(linkText); + + return ( + + ); +}; diff --git a/client/coral-admin/src/components/IfHasLink.js b/client/coral-admin/src/components/IfHasLink.js new file mode 100644 index 000000000..cd37ea951 --- /dev/null +++ b/client/coral-admin/src/components/IfHasLink.js @@ -0,0 +1,13 @@ +import React from 'react'; +import Linkify from 'react-linkify'; +const linkify = new Linkify(); + +export default ({text, children}) => { + const hasLinks = !!linkify.getMatches(text); + + if (!hasLinks) { + return null; + } + + return React.Children.only(children); +}; diff --git a/client/coral-admin/src/components/ModerationList.css b/client/coral-admin/src/components/ModerationList.css index 2b5989191..89e43e02a 100644 --- a/client/coral-admin/src/components/ModerationList.css +++ b/client/coral-admin/src/components/ModerationList.css @@ -192,7 +192,6 @@ .minimal { width: 45px; min-width: 0; - float: left; } .approve__active { diff --git a/client/coral-admin/src/containers/UserDetail.js b/client/coral-admin/src/containers/UserDetail.js new file mode 100644 index 000000000..6b476b861 --- /dev/null +++ b/client/coral-admin/src/containers/UserDetail.js @@ -0,0 +1,119 @@ +import React, {PropTypes} from 'react'; +import {compose, gql} from 'react-apollo'; +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import UserDetail from '../components/UserDetail'; +import withQuery from 'coral-framework/hocs/withQuery'; +import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils'; +import { + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail +} from 'coral-admin/src/actions/moderation'; +import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; +import Comment from './Comment'; + +const commentConnectionFragment = gql` + fragment CoralAdmin_Moderation_CommentConnection on CommentConnection { + nodes { + ...${getDefinitionName(Comment.fragments.comment)} + } + hasNextPage + startCursor + endCursor + } + ${Comment.fragments.comment} +`; + +const slots = [ + 'userProfile', +]; + +class UserDetailContainer extends React.Component { + static propTypes = { + id: PropTypes.string.isRequired, + hideUserDetail: PropTypes.func.isRequired + } + + // status can be 'ACCEPTED' or 'REJECTED' + bulkSetCommentStatus = (status) => { + const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => { + return this.props.setCommentStatus({commentId, status}); + }); + + Promise.all(changes).then(() => { + this.props.data.refetch(); // some comments may have moved out of this tab + this.props.clearUserDetailSelections(); // un-select everything + }); + } + + bulkReject = () => { + this.bulkSetCommentStatus('REJECTED'); + } + + bulkAccept = () => { + this.bulkSetCommentStatus('ACCEPTED'); + } + + render () { + if (!('user' in this.props.root)) { + return null; + } + + return ; + } +} + +export const withUserDetailQuery = withQuery(gql` + query CoralAdmin_UserDetail($author_id: ID!, $statuses: [COMMENT_STATUS!]) { + user(id: $author_id) { + id + username + created_at + profiles { + id + provider + } + ${getSlotFragmentSpreads(slots, 'user')} + } + totalComments: commentCount(query: {author_id: $author_id}) + rejectedComments: commentCount(query: {author_id: $author_id, statuses: [REJECTED]}) + comments: comments(query: { + author_id: $author_id, + statuses: $statuses + }) { + ...CoralAdmin_Moderation_CommentConnection + } + ${getSlotFragmentSpreads(slots, 'root')} + } + ${commentConnectionFragment} +`, { + options: ({id, moderation: {userDetailStatuses: statuses}}) => { + return { + variables: {author_id: id, statuses} + }; + } +}); + +const mapStateToProps = (state) => ({ + moderation: state.moderation.toJS() +}); + +const mapDispatchToProps = (dispatch) => ({ + ...bindActionCreators({ + changeUserDetailStatuses, + clearUserDetailSelections, + toggleSelectCommentInUserDetail + }, dispatch) +}); + +export default compose( + connect(mapStateToProps, mapDispatchToProps), + withUserDetailQuery, + withSetCommentStatus, +)(UserDetailContainer); diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.css b/client/coral-admin/src/routes/Moderation/components/Comment.css deleted file mode 100644 index e69de29bb..000000000 diff --git a/client/coral-admin/src/routes/Moderation/components/Comment.js b/client/coral-admin/src/routes/Moderation/components/Comment.js index bca774c04..07e168ab7 100644 --- a/client/coral-admin/src/routes/Moderation/components/Comment.js +++ b/client/coral-admin/src/routes/Moderation/components/Comment.js @@ -1,22 +1,20 @@ import React, {PropTypes} from 'react'; import {Link} from 'react-router'; -import Linkify from 'react-linkify'; import {Icon} from 'coral-ui'; import FlagBox from './FlagBox'; import styles from './styles.css'; import CommentType from './CommentType'; -import Highlighter from 'react-highlight-words'; +import CommentAnimatedEdit from './CommentAnimatedEdit'; import Slot from 'coral-framework/components/Slot'; 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 CommentBodyHighlighter from 'coral-admin/src/components/CommentBodyHighlighter'; +import IfHasLink from 'coral-admin/src/components/IfHasLink'; import cn from 'classnames'; -import {murmur3} from 'murmurhash-js'; -import {CSSTransitionGroup} from 'react-transition-group'; - -const linkify = new Linkify(); +import {getCommentType} from 'coral-admin/src/utils/comment'; import t, {timeago} from 'coral-framework/services/i18n'; @@ -29,41 +27,16 @@ class Comment extends React.Component { viewUserDetail, suspectWords, bannedWords, - minimal, selected, - toggleSelect, className, ...props } = this.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'; - } + const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction'); + const commentType = getCommentType(comment); - // 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|$)`, 'i').test(comment.body); - }) - .concat(linkText); - - let selectionStateCSS; - if (minimal) { - selectionStateCSS = selected ? styles.minimalSelection : ''; - } else { - selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; - } + let selectionStateCSS = selected ? 'mdl-shadow--16dp' : 'mdl-shadow--2dp'; const showSuspenUserDialog = () => props.showSuspendUserDialog({ userId: comment.user.id, @@ -81,31 +54,21 @@ class Comment extends React.Component { return (
  • { - !minimal && ( + ( viewUserDetail(comment.user.id)}> {comment.user.username} ) } - { - minimal && typeof selected === 'boolean' && typeof toggleSelect === 'function' && ( - toggleSelect(e.target.value, e.target.checked)} /> - ) - } - {timeago(comment.created_at || Date.now() - props.index * 60 * 1000)} + {timeago(comment.created_at)} { (comment.editing && comment.editing.edited) @@ -125,45 +88,27 @@ class Comment extends React.Component { } - +
    - {comment.user.status === 'banned' - ? - - {t('comment.banned_user')} - - : null} - +
    Story: {comment.asset.title} {!props.currentAsset && {t('modqueue.moderate')}}
    - -
    + + { + return ( + + {React.cloneElement(React.Children.only(children), {key: murmur3(body)})} + + ); +}; diff --git a/client/coral-admin/src/routes/Moderation/components/CommentType.css b/client/coral-admin/src/routes/Moderation/components/CommentType.css index 3144a5bd7..beafc2a7c 100644 --- a/client/coral-admin/src/routes/Moderation/components/CommentType.css +++ b/client/coral-admin/src/routes/Moderation/components/CommentType.css @@ -1,22 +1,19 @@ .commentType { - position: absolute; - right: 15px; - top: 11px; + display: inline-block; color: white; background: grey; height: 32px; box-sizing: border-box; line-height: 29px; - padding: 2px 8px 2px 26px; + padding: 2px 8px; border-radius: 2px; font-size: 12px; - i { + > i { font-size: 14px; - position: absolute; - left: 6px; - top: 8px; + vertical-align: text-top; margin: 0; + margin-right: 4px; } &.premod { diff --git a/client/coral-admin/src/routes/Moderation/components/CommentType.js b/client/coral-admin/src/routes/Moderation/components/CommentType.js index 346b5f1e6..075ec5a78 100644 --- a/client/coral-admin/src/routes/Moderation/components/CommentType.js +++ b/client/coral-admin/src/routes/Moderation/components/CommentType.js @@ -1,12 +1,13 @@ import React, {PropTypes} from 'react'; 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} ); diff --git a/client/coral-admin/src/routes/Moderation/components/Moderation.js b/client/coral-admin/src/routes/Moderation/components/Moderation.js index 693be075e..75f613cc9 100644 --- a/client/coral-admin/src/routes/Moderation/components/Moderation.js +++ b/client/coral-admin/src/routes/Moderation/components/Moderation.js @@ -176,13 +176,7 @@ export default class Moderation extends Component { {moderation.userDetailId && ( + /> )} p.provider === 'local'); @@ -116,8 +111,8 @@ export default class UserDetail extends React.Component { selectedIds.length === 0 ? (
      -
    • All
    • -
    • Rejected
    • +
    • All
    • +
    • Rejected
    ) : ( @@ -141,27 +136,23 @@ export default class UserDetail extends React.Component {
    { - nodes.map((comment, i) => { + nodes.map((comment) => { const status = comment.action_summaries ? 'FLAGGED' : comment.status; const selected = selectedIds.indexOf(comment.id) !== -1; return {}} actions={actionsMap[status]} - showBanUserDialog={showBanUserDialog} - showSuspendUserDialog={showSuspendUserDialog} acceptComment={this.acceptThenReload} rejectComment={this.rejectThenReload} selected={selected} toggleSelect={toggleSelect} - currentAsset={null} - currentUserId={this.props.id} - minimal={true} />; + viewUserDetail={viewUserDetail} + />; }) }
    diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetailComment.css b/client/coral-admin/src/routes/Moderation/components/UserDetailComment.css new file mode 100644 index 000000000..0e10f00c0 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/UserDetailComment.css @@ -0,0 +1,128 @@ +.root { + display: block; + margin: 0; + border-bottom: 1px solid #e0e0e0; + width: 100%; + transition: all 200ms; + padding: 10px 0px; + min-height: 0; +} + +.rootSelected { + background-color: #ecf4ff; +} + +.container { + padding: 0px 14px; +} + +.story { + font-size: 14px; + margin: 10px 0; + font-weight: 500; + line-height: 1.2; + max-width: 500px; +} + +.story > a { + display: inline-block; + color: #063b9a; + text-decoration: none; + font-weight: 500; + letter-spacing: .5px; + margin-left: 10px; + font-size: 13px; + margin-left: 5px; + padding-bottom: 0px; + border-bottom: solid 1px; + line-height: 16px; +} + +.bodyContainer { + display: flex; + justify-content: space-between; + font-size: 14px; + line-height: 1.5; + font-weight: 300; + margin-bottom: 8px; +} + +.header { + position: relative; +} + +.commentType { + position: absolute; + right: 0px; +} + +.created { + padding: 5px; + color: #262626; + font-size: 14px; + line-height: 1px; + font-weight: 300; +} + +.body { + margin-top: 0px; + flex: 1; + color: black; + max-width: 500px; + word-wrap: break-word; + font-weight: 300; + font-size: 16px; + max-width: 360px; +} + +.sideActions { +} + +.editedMarker { + font-style: italic; + color: #666; + font-size: 12px; + line-height: 1px; + font-weight: 300; +} + +.actions { + display: flex; + justify-content: flex-end; +} + +.bulkSelectInput { + cursor: pointer; +} + +.external { + font-size: .7em; + text-decoration: none; + color: #063b9a; + cursor: pointer; + font-weight: normal; + margin-left: 10px; + white-space: nowrap; + + &:hover { + text-decoration: underline; + opacity: .9; + } + + > i { + font-size: 12px; + top: 2px; + position: relative; + } +} + +.hasLinks { + color: #f00; + text-align: right; + display: flex; + align-items: center; + + > i { + margin-right: 5px; + } +} diff --git a/client/coral-admin/src/routes/Moderation/components/UserDetailComment.js b/client/coral-admin/src/routes/Moderation/components/UserDetailComment.js new file mode 100644 index 000000000..1ec181214 --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/components/UserDetailComment.js @@ -0,0 +1,151 @@ +import React, {PropTypes} from 'react'; +import {Link} from 'react-router'; + +import {Icon} from 'coral-ui'; +import FlagBox from './FlagBox'; +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 t, {timeago} from 'coral-framework/services/i18n'; + +class UserDetailComment extends React.Component { + + render() { + const { + actions = [], + comment, + viewUserDetail, + suspectWords, + bannedWords, + selected, + toggleSelect, + className, + user, + ...props + } = this.props; + + const flagActionSummaries = getActionSummary('FlagActionSummary', comment); + const flagActions = comment.actions && comment.actions.filter((a) => a.__typename === 'FlagAction'); + const commentType = getCommentType(comment); + + return ( +
  • +
    +
    + toggleSelect(e.target.value, e.target.checked)} /> + + {timeago(comment.created_at)} + + { + (comment.editing && comment.editing.edited) + ?  ({t('comment.edited')}) + : null + } + +
    +
    + Story: {comment.asset.title} + {{t('modqueue.moderate')}} +
    + +
    +

    + + {' '} + + {t('comment.view_context')} + +

    +
    + + + Contains Link + + +
    + {actions.map((action, i) => { + const active = + (action === 'REJECT' && comment.status === 'REJECTED') || + (action === 'APPROVE' && comment.status === 'ACCEPTED'); + return ( + + (comment.status === 'ACCEPTED' + ? null + : props.acceptComment({commentId: comment.id}))} + rejectComment={() => + (comment.status === 'REJECTED' + ? null + : props.rejectComment({commentId: comment.id}))} + /> + ); + })} +
    +
    +
    + +
    + {flagActions && flagActions.length + ? + : null} +
  • + ); + } +} + +UserDetailComment.propTypes = { + user: PropTypes.object.isRequired, + 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, + toggleSelect: PropTypes.func, + comment: PropTypes.shape({ + body: PropTypes.string.isRequired, + action_summaries: PropTypes.array, + actions: PropTypes.array, + created_at: PropTypes.string.isRequired, + asset: PropTypes.shape({ + title: PropTypes.string, + url: PropTypes.string, + id: PropTypes.string + }) + }) +}; + +export default UserDetailComment; diff --git a/client/coral-admin/src/routes/Moderation/components/styles.css b/client/coral-admin/src/routes/Moderation/components/styles.css index 1228c61a4..67b7a6d34 100644 --- a/client/coral-admin/src/routes/Moderation/components/styles.css +++ b/client/coral-admin/src/routes/Moderation/components/styles.css @@ -213,11 +213,12 @@ span { .author { font-weight: 300; - min-width: 230px; + width: 100%; display: flex; align-items: center; color: #262626; font-size: 16px; + position: relative; } } @@ -457,18 +458,6 @@ span { } } -.minimal { - margin: 0; -} - -.minimalSelection { - background-color: #ecf4ff; -} - -.bulkSelectInput { - cursor: pointer; -} - .emptyCardContainer { margin-top: 16px; } @@ -528,3 +517,8 @@ span { position: relative; top: .3em; } + +.commentType { + position: absolute; + right: 0px; +} diff --git a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js index 6b476b861..16f0916d3 100644 --- a/client/coral-admin/src/routes/Moderation/containers/UserDetail.js +++ b/client/coral-admin/src/routes/Moderation/containers/UserDetail.js @@ -5,24 +5,25 @@ import {bindActionCreators} from 'redux'; import UserDetail from '../components/UserDetail'; import withQuery from 'coral-framework/hocs/withQuery'; import {getDefinitionName, getSlotFragmentSpreads} from 'coral-framework/utils'; +import {viewUserDetail, hideUserDetail} from 'actions/moderation'; import { changeUserDetailStatuses, clearUserDetailSelections, - toggleSelectCommentInUserDetail + toggleSelectCommentInUserDetail, } from 'coral-admin/src/actions/moderation'; import {withSetCommentStatus} from 'coral-framework/graphql/mutations'; -import Comment from './Comment'; +import UserDetailComment from './UserDetailComment'; const commentConnectionFragment = gql` fragment CoralAdmin_Moderation_CommentConnection on CommentConnection { nodes { - ...${getDefinitionName(Comment.fragments.comment)} + ...${getDefinitionName(UserDetailComment.fragments.comment)} } hasNextPage startCursor endCursor } - ${Comment.fragments.comment} + ${UserDetailComment.fragments.comment} `; const slots = [ @@ -32,12 +33,11 @@ const slots = [ class UserDetailContainer extends React.Component { static propTypes = { id: PropTypes.string.isRequired, - hideUserDetail: PropTypes.func.isRequired } // status can be 'ACCEPTED' or 'REJECTED' bulkSetCommentStatus = (status) => { - const changes = this.props.moderation.userDetailSelectedIds.map((commentId) => { + const changes = this.props.selectedIds.map((commentId) => { return this.props.setCommentStatus({commentId, status}); }); @@ -55,6 +55,14 @@ class UserDetailContainer extends React.Component { this.bulkSetCommentStatus('ACCEPTED'); } + acceptComment = ({commentId}) => { + return this.props.setCommentStatus({commentId, status: 'ACCEPTED'}); + } + + rejectComment = ({commentId}) => { + return this.props.setCommentStatus({commentId, status: 'REJECTED'}); + } + render () { if (!('user' in this.props.root)) { return null; @@ -65,6 +73,8 @@ class UserDetailContainer extends React.Component { bulkAccept={this.bulkAccept} changeStatus={this.props.changeUserDetailStatuses} toggleSelect={this.props.toggleSelectCommentInUserDetail} + acceptComment={this.acceptComment} + rejectComment={this.rejectComment} {...this.props} />; } } @@ -93,7 +103,7 @@ export const withUserDetailQuery = withQuery(gql` } ${commentConnectionFragment} `, { - options: ({id, moderation: {userDetailStatuses: statuses}}) => { + options: ({id, statuses}) => { return { variables: {author_id: id, statuses} }; @@ -101,14 +111,20 @@ export const withUserDetailQuery = withQuery(gql` }); const mapStateToProps = (state) => ({ - moderation: state.moderation.toJS() + selectedIds: state.moderation.toJS().userDetailSelectedIds, + statuses: state.moderation.toJS().userDetailStatuses, + activeTab: state.moderation.toJS().userDetailActiveTab, + bannedWords: state.settings.toJS().wordlist.banned, + suspectWords: state.settings.toJS().wordlist.suspect, }); const mapDispatchToProps = (dispatch) => ({ ...bindActionCreators({ changeUserDetailStatuses, clearUserDetailSelections, - toggleSelectCommentInUserDetail + toggleSelectCommentInUserDetail, + viewUserDetail, + hideUserDetail, }, dispatch) }); diff --git a/client/coral-admin/src/routes/Moderation/containers/UserDetailComment.js b/client/coral-admin/src/routes/Moderation/containers/UserDetailComment.js new file mode 100644 index 000000000..a70a9394d --- /dev/null +++ b/client/coral-admin/src/routes/Moderation/containers/UserDetailComment.js @@ -0,0 +1,39 @@ +import {gql} from 'react-apollo'; +import UserDetailComment from '../components/UserDetailComment'; +import withFragments from 'coral-framework/hocs/withFragments'; + +export default withFragments({ + comment: gql` + fragment CoralAdmin_UserDetail_comment on Comment { + id + body + created_at + status + asset { + id + title + url + } + action_summaries { + count + ... on FlagActionSummary { + reason + } + } + actions { + ... on FlagAction { + id + reason + message + user { + id + username + } + } + } + editing { + edited + } + } + ` +})(UserDetailComment); diff --git a/client/coral-admin/src/utils/comment.js b/client/coral-admin/src/utils/comment.js new file mode 100644 index 000000000..26530f6c9 --- /dev/null +++ b/client/coral-admin/src/utils/comment.js @@ -0,0 +1,9 @@ +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; +}