diff --git a/bin/cli-settings b/bin/cli-settings index 7854942c0..ba4305ce3 100755 --- a/bin/cli-settings +++ b/bin/cli-settings @@ -23,7 +23,7 @@ program .description('initilizes the talk settings') .action(() => { const defaults = { - moderation: 'PRE', + moderation: 'POST', wordlist: { banned: [], suspect: [] diff --git a/client/coral-admin/src/actions/auth.js b/client/coral-admin/src/actions/auth.js index c3f9b1be1..8bc0d4e4c 100644 --- a/client/coral-admin/src/actions/auth.js +++ b/client/coral-admin/src/actions/auth.js @@ -14,7 +14,10 @@ export const checkLogin = () => dispatch => { const isAdmin = !!result.user.roles.filter(i => i === 'ADMIN').length; dispatch(checkLoginSuccess(result.user, isAdmin)); }) - .catch(error => dispatch(checkLoginFailure(error))); + .catch(error => { + console.error(error); + dispatch(checkLoginFailure(`${error.message}`)); + }); }; // LogOut Actions diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js index fe4895ed3..df68b72c4 100644 --- a/client/coral-admin/src/actions/comments.js +++ b/client/coral-admin/src/actions/comments.js @@ -15,17 +15,18 @@ export const fetchModerationQueueComments = () => { return Promise.all([ coralApi('/queue/comments/pending'), + coralApi('/queue/users/pending'), coralApi('/queue/comments/rejected'), coralApi('/queue/comments/flagged') ]) - .then(([pending, rejected, flagged]) => { + .then(([pendingComments, pendingUsers, rejected, flagged]) => { /* Combine seperate calls into a single object */ flagged.comments.forEach(comment => comment.flagged = true); return { - comments: [...pending.comments, ...rejected.comments, ...flagged.comments], - users: [...pending.users, ...rejected.users, ...flagged.users], - actions: [...pending.actions, ...rejected.actions, ...flagged.actions] + comments: [...pendingComments.comments, ...rejected.comments, ...flagged.comments], + users: [...pendingComments.users, ...pendingUsers.users, ...rejected.users, ...flagged.users], + actions: [...pendingComments.actions, ...pendingUsers.actions, ...rejected.actions, ...flagged.actions] }; }) .then(addUsersCommentsActions.bind(this, dispatch)); @@ -41,6 +42,15 @@ export const fetchPendingQueue = () => { }; }; +export const fetchPendingUsersQueue = () => { + return dispatch => { + dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); + + return coralApi('/queue/users/pending') + .then(addUsersCommentsActions.bind(this, dispatch)); + }; +}; + export const fetchRejectedQueue = () => { return dispatch => { dispatch({type: commentTypes.COMMENTS_MODERATION_QUEUE_FETCH_REQUEST}); diff --git a/client/coral-admin/src/actions/users.js b/client/coral-admin/src/actions/users.js index 30d9cd34b..44f0325d9 100644 --- a/client/coral-admin/src/actions/users.js +++ b/client/coral-admin/src/actions/users.js @@ -1,5 +1,5 @@ import coralApi from '../../../coral-framework/helpers/response'; -import * as actions from '../constants/user'; +import * as userTypes from '../constants/users'; /** * Action disptacher related to users @@ -7,9 +7,17 @@ import * as actions from '../constants/user'; // change status of a user export const userStatusUpdate = (status, userId, commentId) => { return (dispatch) => { - dispatch({type: actions.UPDATE_STATUS_REQUEST}); + dispatch({type: userTypes.UPDATE_STATUS_REQUEST}); return coralApi(`/users/${userId}/status`, {method: 'POST', body: {status: status, comment_id: commentId}}) - .then(res => dispatch({type: actions.UPDATE_STATUS_SUCCESS, res})) - .catch(error => dispatch({type: actions.UPDATE_STATUS_FAILURE, error})); + .then(res => dispatch({type: userTypes.UPDATE_STATUS_SUCCESS, res})) + .catch(error => dispatch({type: userTypes.UPDATE_STATUS_FAILURE, error})); + }; +}; + +// change status of a user +export const sendNotificationEmail = (userId, subject, body) => { + return (dispatch) => { + return coralApi(`/users/${userId}/email`, {method: 'POST', body: {subject, body}}) + .catch(error => dispatch({type: userTypes.USER_EMAIL_FAILURE, error})); }; }; diff --git a/client/coral-admin/src/components/ActionButton.js b/client/coral-admin/src/components/ActionButton.js new file mode 100644 index 000000000..61de1c669 --- /dev/null +++ b/client/coral-admin/src/components/ActionButton.js @@ -0,0 +1,48 @@ +import React from 'react'; +import styles from './ModerationList.css'; +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations.json'; +import {FabButton, Button, Icon} from 'coral-ui'; + +const ActionButton = ({option, type, comment = {}, user, menuOptionsMap, onClickAction, onClickShowBanDialog}) => +{ + const banned = user.status === 'BANNED'; + + if (option === 'flag' && (type === 'USERS' || comment.status || comment.flagged === true)) { + return null; + } + if (option === 'ban') { + return ( +
+ +
+ ); + } + const menuOption = menuOptionsMap[option]; + const action = { + item_type: type, + item_id: type === 'COMMENTS' ? comment.id : user.id + }; + return ( + onClickAction(menuOption.status, type === 'COMMENTS' ? comment : user, action)} + /> + ); +}; + +export default ActionButton; + +const lang = new I18n(translations); diff --git a/client/coral-admin/src/components/BanUserDialog.js b/client/coral-admin/src/components/BanUserDialog.js index c393df21d..0ad149fbe 100644 --- a/client/coral-admin/src/components/BanUserDialog.js +++ b/client/coral-admin/src/components/BanUserDialog.js @@ -35,7 +35,7 @@ const BanUserDialog = ({open, handleClose, onClickBanUser, user = {}}) => ( - diff --git a/client/coral-admin/src/components/Comment.js b/client/coral-admin/src/components/Comment.js index 0eb4fce95..74b0361a2 100644 --- a/client/coral-admin/src/components/Comment.js +++ b/client/coral-admin/src/components/Comment.js @@ -2,18 +2,19 @@ import React from 'react'; import timeago from 'timeago.js'; import Linkify from 'react-linkify'; -import styles from './CommentList.css'; +import styles from './ModerationList.css'; import I18n from 'coral-framework/modules/i18n/i18n'; import translations from '../translations.json'; import Highlighter from 'react-highlight-words'; -import {FabButton, Button, Icon} from 'coral-ui'; +import {Icon} from 'coral-ui'; +import ActionButton from './ActionButton'; const linkify = new Linkify(); // Render a single comment for the list -export default props => { +const Comment = props => { const {comment, author} = props; let authorStatus = author.status; const links = linkify.getMatches(comment.body); @@ -30,7 +31,18 @@ export default props => { {links ? Contains Link : null}
- {props.modActions.map((action, i) => getActionButton(action, i, props))} + {props.modActions.map( + (action, i) => + + )}
{authorStatus === 'banned' ? {lang.t('comment.banned_user')} : null} @@ -49,43 +61,7 @@ export default props => { ); }; -// Get the button of the action performed over a comment if any -const getActionButton = (action, i, props) => { - const {comment, author} = props; - const status = comment.status; - const flagged = comment.flagged; - const banned = (author.status === 'banned'); - - if (action === 'flag' && (status || flagged === true)) { - return null; - } - if (action === 'ban') { - return ( -
- -
- ); - } - return ( - props.onClickAction(props.actionsMap[action].status, comment)} - /> - ); -}; +export default Comment; const linkStyles = { backgroundColor: 'rgb(255, 219, 135)', diff --git a/client/coral-admin/src/components/CommentList.js b/client/coral-admin/src/components/CommentList.js deleted file mode 100644 index 178d3cc02..000000000 --- a/client/coral-admin/src/components/CommentList.js +++ /dev/null @@ -1,166 +0,0 @@ -import React, {PropTypes} from 'react'; -import styles from './CommentList.css'; -import key from 'keymaster'; -import Hammer from 'hammerjs'; -import Comment from 'components/Comment'; - -// Each action has different meaning and configuration -const modActions = { - 'reject': {status: 'REJECTED', icon: 'close', key: 'r'}, - 'approve': {status: 'ACCEPTED', icon: 'done', key: 't'}, - 'flag': {status: 'FLAGGED', icon: 'flag', filter: 'Untouched'}, - 'ban': {status: 'BANNED', icon: 'not interested'} -}; - -// Renders a comment list and allow performing actions -export default class CommentList extends React.Component { - static propTypes = { - isActive: PropTypes.bool, - singleView: PropTypes.bool, - commentIds: PropTypes.arrayOf(PropTypes.string).isRequired, - comments: PropTypes.object.isRequired, - users: PropTypes.object.isRequired, - onClickAction: PropTypes.func, - - // list of actions (flags, etc) associated with the comments - modActions: PropTypes.arrayOf(PropTypes.string).isRequired, - loading: PropTypes.bool, - - suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired - } - - constructor (props) { - super(props); - - this.state = {active: null}; - this.onClickAction = this.onClickAction.bind(this); - this.onClickShowBanDialog = this.onClickShowBanDialog.bind(this); - } - - // remove key handlers before leaving - componentWillUnmount () { - this.unbindKeyHandlers(); - } - - // add key handlers and gestures - componentDidMount () { - this.bindKeyHandlers(); - - // this.bindGestures() // need to check whether we're on a mobile device or this throws an Error - } - - // If entering to singleview and no active, active is the first eleement - componentWillReceiveProps (nextProps) { - if (nextProps.singleView && !this.state.active) { - this.setState({active: nextProps.commentIds[0]}); - } - } - - // Add swipe to approve or reject - bindGestures () { - const {modActions} = this.props; - this._hammer = new Hammer(this.base); - this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL}); - - if (modActions.indexOf('reject') !== -1) { - this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected')); - } - if (modActions.indexOf('approve') !== -1) { - this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved')); - } - } - - // Add key handlers. Each action has one and added j/k for moving around - bindKeyHandlers () { - this.props.modActions.filter(action => modActions[action].key).forEach(action => { - key(modActions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(modActions[action].status)); - }); - key('j', 'commentList', () => this.props.isActive && this.moveKeyHandler('down')); - key('k', 'commentList', () => this.props.isActive && this.moveKeyHandler('up')); - key.setScope('commentList'); - } - - // Perform an action using the keys only if the comment is active - actionKeyHandler (action) { - if (this.props.isActive && this.state.active) { - this.onClickAction(action, this.state.active); - } - } - - // move around with j/k - moveKeyHandler (direction) { - if (!this.props.isActive) { - return; - } - - const {commentIds} = this.props; - const {active} = this.state; - - // check boundaries - if (active === null || !commentIds.length) { - this.setState({active: commentIds[0]}); - } else if (direction === 'up' && active !== commentIds[0]) { - this.setState({active: commentIds[commentIds.indexOf(active) - 1]}); - } else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) { - this.setState({active: commentIds[commentIds.indexOf(active) + 1]}); - } - - // scroll to the position - const index = Math.max(commentIds.indexOf(this.state.active), 0); - this.base.childNodes[index] && this.base.childNodes[index].focus(); - } - - unbindKeyHandlers () { - key.deleteScope('commentList'); - } - - // If we are performing an action over a comment (aka removing from the list) we need to select a new active. - // TODO: In the future this can be improved and look at the actual state to - // resolve since the content of the list could change externally. For now it works as expected - onClickAction (action, id, author_id) { - - // activate the next comment - if (id === this.state.active) { - const {commentIds} = this.props; - if (commentIds[commentIds.length - 1] === this.state.active) { - this.setState({active: commentIds[commentIds.length - 2]}); - } else { - this.setState({active: commentIds[Math.min(commentIds.indexOf(this.state.active) + 1, commentIds.length - 1)]}); - } - } - this.props.onClickAction(action, id, author_id); - } - - onClickShowBanDialog(userId, userName, commentId) { - this.props.onClickShowBanDialog(userId, userName, commentId); - } - - render () { - const {singleView, commentIds, comments, users, hideActive, key, suspectWords} = this.props; - const {active} = this.state; - - return ( - - ); - } -} diff --git a/client/coral-admin/src/components/Modal.css b/client/coral-admin/src/components/Modal.css index 8a3d2ad00..fe089cf41 100644 --- a/client/coral-admin/src/components/Modal.css +++ b/client/coral-admin/src/components/Modal.css @@ -19,7 +19,6 @@ .inner { width: 100vw; - height: 100vh; background-color: #fff; box-shadow: 2px 2px 5px 0px rgba(153,153,153,1); padding: 20px; @@ -37,7 +36,6 @@ @media (--big-viewport) { .inner { width: 600px; - height: 50vh; border-radius: 4px; } } diff --git a/client/coral-admin/src/components/CommentList.css b/client/coral-admin/src/components/ModerationList.css similarity index 96% rename from client/coral-admin/src/components/CommentList.css rename to client/coral-admin/src/components/ModerationList.css index b90da3166..fbcbce7d7 100644 --- a/client/coral-admin/src/components/CommentList.css +++ b/client/coral-admin/src/components/ModerationList.css @@ -69,6 +69,7 @@ justify-content: space-between; .author { + min-width: 230px; display: flex; align-items: center; } @@ -112,6 +113,12 @@ padding-top: 15px; padding-left: 10px; } + + .flagCount{ + font-size: 12px; + color: #d32f2f; + } + } .empty { diff --git a/client/coral-admin/src/components/ModerationList.js b/client/coral-admin/src/components/ModerationList.js new file mode 100644 index 000000000..8ce3ec4a4 --- /dev/null +++ b/client/coral-admin/src/components/ModerationList.js @@ -0,0 +1,227 @@ +import React, {PropTypes} from 'react'; +import styles from './ModerationList.css'; +import key from 'keymaster'; +import Hammer from 'hammerjs'; +import Comment from './Comment'; +import UserAction from './UserAction'; +import SuspendUserModal from './SuspendUserModal'; + +// Each action has different meaning and configuration +const menuOptionsMap = { + 'reject': {status: 'rejected', icon: 'close', key: 'r'}, + 'approve': {status: 'accepted', icon: 'done', key: 't'}, + 'flag': {status: 'flagged', icon: 'flag', filter: 'Untouched'}, + 'ban': {status: 'banned', icon: 'not interested'} +}; + +// Renders a comment list and allow performing actions +export default class ModerationList extends React.Component { + static propTypes = { + isActive: PropTypes.bool, + singleView: PropTypes.bool, + commentIds: PropTypes.arrayOf(PropTypes.string), + actionIds: PropTypes.arrayOf(PropTypes.string), + comments: PropTypes.object, + users: PropTypes.object.isRequired, + actions: PropTypes.object, + userStatusUpdate: PropTypes.func.isRequired, + suspendUser: PropTypes.func.isRequired, + + // list of actions (flags, etc) associated with the comments + modActions: PropTypes.arrayOf(PropTypes.string).isRequired, + loading: PropTypes.bool, + + suspectWords: PropTypes.arrayOf(PropTypes.string).isRequired + } + + state = {active: null, suspendUserModal: null, email: null}; + + // remove key handlers before leaving + componentWillUnmount () { + this.unbindKeyHandlers(); + } + + // add key handlers and gestures + componentDidMount () { + this.bindKeyHandlers(); + + // this.bindGestures() // need to check whether we're on a mobile device or this throws an Error + } + + // If entering to singleview and no active, active is the first eleement + componentWillReceiveProps (nextProps) { + if (nextProps.singleView && !this.state.active) { + this.setState({active: nextProps.commentIds[0]}); + } + } + + // Add swipe to approve or reject + bindGestures () { + const {modActions} = this.props; + this._hammer = new Hammer(this.base); + this._hammer.get('swipe').set({direction: Hammer.DIRECTION_HORIZONTAL}); + + if (modActions.indexOf('reject') !== -1) { + this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected')); + } + if (modActions.indexOf('approve') !== -1) { + this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved')); + } + } + + // Add key handlers. Each action has one and added j/k for moving around + bindKeyHandlers () { + const {modActions, isActive} = this.props; + modActions.filter(action => menuOptionsMap[action].key).forEach(action => { + key(menuOptionsMap[action].key, 'moderationList', () => isActive && this.actionKeyHandler(menuOptionsMap[action].status)); + }); + key('j', 'moderationList', () => isActive && this.moveKeyHandler('down')); + key('k', 'moderationList', () => isActive && this.moveKeyHandler('up')); + key.setScope('moderationList'); + } + + // Perform an action using the keys only if the comment is active + actionKeyHandler (action) { + if (this.props.isActive && this.state.active) { + this.onClickAction(action, this.state.active); + } + } + + // move around with j/k + moveKeyHandler (direction) { + if (!this.props.isActive) { + return; + } + + const {commentIds} = this.props; + const {active} = this.state; + + // check boundaries + if (active === null || !commentIds.length) { + this.setState({active: commentIds[0]}); + } else if (direction === 'up' && active !== commentIds[0]) { + this.setState({active: commentIds[commentIds.indexOf(active) - 1]}); + } else if (direction === 'down' && active !== commentIds[commentIds.length - 1]) { + this.setState({active: commentIds[commentIds.indexOf(active) + 1]}); + } + + // scroll to the position + const index = Math.max(commentIds.indexOf(this.state.active), 0); + this.base.childNodes[index] && this.base.childNodes[index].focus(); + } + + unbindKeyHandlers () { + key.deleteScope('moderationList'); + } + + // If we are performing an action over a comment (aka removing from the list) we need to select a new active. + // TODO: In the future this can be improved and look at the actual state to + // resolve since the content of the list could change externally. For now it works as expected + onClickAction = (menuOption, id, action) => { + + // activate the next comment + if (id === this.state.active) { + const moderationIds = this.getModerationIds(); + if (moderationIds[moderationIds.length - 1] === this.state.active) { + this.setState({active: moderationIds[moderationIds.length - 2]}); + } else { + this.setState({active: moderationIds[Math.min(moderationIds.indexOf(this.state.active) + 1, moderationIds.length - 1)]}); + } + } + + // Update the status right away if this is a comment + if (action.item_type === 'COMMENTS') { + this.props.updateCommentStatus(menuOption, id); + } else if (action.item_type === 'USERS') { + + // If a user bio or name is rejected, bring up a dialog before suspending them. + if (menuOption === 'rejected') { + this.setState({suspendUserModal: action}); + } else if (menuOption === 'accepted') { + this.props.userStatusUpdate('ACTIVE', action.item_id); + } + } + } + + onClickShowBanDialog = (userId, userName, commentId) => { + this.props.onClickShowBanDialog(userId, userName, commentId); + } + + mapModItems = (itemId, index) => { + + const {comments = {}, users, actions = {}, modActions, suspectWords, hideActive} = this.props; + const {active} = this.state; + + // Because ids are unique, the id will either appear as an action or as a comment. + + const item = comments[itemId] || actions[itemId]; + let modItem; + + if (item.body) { + + // If the item is a comment... + const author = users[item.author_id]; + modItem = ; + } else { + + // If the item is an action... + const user = users[item.item_id]; + modItem = user && ; + } + return modItem; + } + + getModerationIds = () => { + const {commentIds = [], actionIds = [], comments, actions} = this.props; + if (comments && actions) { + return [ ...commentIds, ...actionIds ].sort((a, b) => { + const itemA = comments[a] || actions[a]; + const itemB = comments[b] || actions[b]; + return itemB.updated_at - itemA.updated_at; + }); + } else { + return comments ? commentIds : actionIds; + } + } + + render () { + const {singleView, key, suspendUser} = this.props; + + // Combine moderations and actions into a single stream and sort by most recently updated. + const moderationIds = this.getModerationIds(); + + return ( +
    + {moderationIds.map(this.mapModItems)} + this.setState({suspendUserModal:null})} + suspendUser={suspendUser} /> +
+ ); + } +} diff --git a/client/coral-admin/src/components/SuspendUserModal.css b/client/coral-admin/src/components/SuspendUserModal.css new file mode 100644 index 000000000..2dbdfc0f3 --- /dev/null +++ b/client/coral-admin/src/components/SuspendUserModal.css @@ -0,0 +1,31 @@ + +.modalButtons { + display: flex; + justify-content: flex-end; + margin-top: 50px; +} + +.emailInput { + border-radius: 3px; + width: 100%; + padding: 10px; + font-size: 14px; +} + +.writeContainer { + margin-top: 20px; +} + +.emailContainer { + display: flex; +} + +.emailMessage { + font-weight: 800; +} + +.title { + font-size: 20px; + font-weight: 800; + margin-bottom: 20px; +} diff --git a/client/coral-admin/src/components/SuspendUserModal.js b/client/coral-admin/src/components/SuspendUserModal.js new file mode 100644 index 000000000..5ad8c1616 --- /dev/null +++ b/client/coral-admin/src/components/SuspendUserModal.js @@ -0,0 +1,106 @@ +import I18n from 'coral-framework/modules/i18n/i18n'; +import translations from '../translations.json'; +import React, {Component, PropTypes} from 'react'; +import Modal from 'components/Modal'; +import styles from './SuspendUserModal.css'; +import {Button} from 'coral-ui'; + +const stages = [ + { + title: 'suspenduser.title_0', + description: 'suspenduser.description_0', + options: { + 'j': 'suspenduser.no_cancel', + 'k': 'suspenduser.yes_suspend' + } + }, + { + title: 'suspenduser.title_1', + description: 'suspenduser.description_1', + options: { + 'j': 'bandialog.cancel', + 'k': 'suspenduser.send' + } + } +]; + +class SuspendUserModal extends Component { + + state = {email: '', stage: 0} + + static propTypes = { + stage: PropTypes.number, + actionType: PropTypes.string, + onClose: PropTypes.func.isRequired, + suspendUser: PropTypes.func.isRequired + } + + componentDidMount() { + const about = this.props.actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username'); + this.setState({email: lang.t('suspenduser.email', about)}); + } + + /* + * When an admin clicks to suspend a user a dialog is shown, this function + * handles the possible actions for that dialog. + */ + onActionClick = (stage, menuOption) => () => { + const {suspendUser, action} = this.props; + const {stage, email} = this.state; + const cancel = this.props.onClose; + const next = () => this.setState({stage: stage + 1}); + const suspend = () => suspendUser(action.item_id, lang.t('suspenduser.email_subject'), email) + .then(this.props.onClose); + const suspendModalActions = [ + [ cancel, next ], + [ cancel, suspend ] + ]; + return suspendModalActions[stage][menuOption](); + } + + onEmailChange = (e) => this.setState({email: e.target.value}) + + render () { + const {action, onClose} = this.props; + + if (!action) { + return null; + } + + const {stage} = this.state; + const actionType = action.actionType; + const about = actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username'); + return +
{lang.t(stages[stage].title, about)}
+
+
+ {lang.t(stages[stage].description, about)} +
+ { + stage === 1 && +
+
{lang.t('suspenduser.write_message')}
+
+