diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js index bb59ffa8e..445245e1d 100644 --- a/client/coral-embed-stream/src/CommentStream.js +++ b/client/coral-embed-stream/src/CommentStream.js @@ -90,8 +90,8 @@ class CommentStream extends Component { const rootItemId = this.props.items.assets && Object.keys(this.props.items.assets)[0]; const rootItem = this.props.items.assets && this.props.items.assets[rootItemId]; const {actions, users, comments} = this.props.items; + const {status, moderation, closedMessage} = this.props.config; const {loggedIn, user, showSignInDialog, signInOffset} = this.props.auth; - const {status, closedMessage} = this.props.config; const {activeTab} = this.state; const banned = (this.props.userData.status === 'banned'); @@ -123,7 +123,7 @@ class CommentStream extends Component { appendItemArray={this.props.appendItemArray} updateItem={this.props.updateItem} id={rootItemId} - premod={this.props.config.moderation} + premod={moderation} reply={false} currentUser={this.props.auth.user} banned={banned} @@ -165,6 +165,7 @@ class CommentStream extends Component { { comment.children && @@ -220,7 +221,8 @@ class CommentStream extends Component { ; }) @@ -295,14 +297,14 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = (dispatch) => ({ - addItem: (item, itemType) => dispatch(addItem(item, itemType)), + addItem: (item, item_id) => dispatch(addItem(item, item_id)), updateItem: (id, property, value, itemType) => dispatch(updateItem(id, property, value, itemType)), postItem: (data, type, id) => dispatch(postItem(data, type, id)), getStream: (rootId) => dispatch(getStream(rootId)), addNotification: (type, text) => dispatch(addNotification(type, text)), clearNotification: () => dispatch(clearNotification()), + postAction: (item, itemType, action) => dispatch(postAction(item, itemType, action)), showSignInDialog: (offset) => dispatch(showSignInDialog(offset)), - postAction: (item, action, user, itemType) => dispatch(postAction(item, action, user, itemType)), deleteAction: (item, action, user, itemType) => dispatch(deleteAction(item, action, user, itemType)), appendItemArray: (item, property, value, addToFront, itemType) => dispatch(appendItemArray(item, property, value, addToFront, itemType)), handleSignInDialog: () => dispatch(authActions.showSignInDialog()), diff --git a/client/coral-embed-stream/style/default.css b/client/coral-embed-stream/style/default.css index 766ad763a..13c7c205e 100644 --- a/client/coral-embed-stream/style/default.css +++ b/client/coral-embed-stream/style/default.css @@ -197,6 +197,50 @@ hr { margin: 8px; } +/* Flag Styles */ + +.coral-plugin-flags-popup span { + min-width: 100px; + bottom: 36px; + right: 75px; + position: absolute; +} + +.coral-plugin-flags-popup-form { + margin-bottom: 10px; +} + +.coral-plugin-flags-popup-header { + font-weight: bolder; + font-size: 16px; + margin-bottom: 10px; +} + +.coral-plugin-flags-popup-radio { + margin:5px; +} + +.coral-plugin-flags-popup-radio-label { + margin:5px; + font-size: 14px; +} + +.coral-plugin-flags-popup-counter { + float: left; + margin-top: 21px; + color: #999; +} + +.coral-plugin-flags-popup-button { + float: right; + margin-top: 10px; +} + +.coral-plugin-flags-other-text { + margin-left: 20px; + width: 75%; +} + /* Close comments */ .close-comments-intro-wrapper { diff --git a/client/coral-framework/actions/items.js b/client/coral-framework/actions/items.js index c1ee14924..8f0e789af 100644 --- a/client/coral-framework/actions/items.js +++ b/client/coral-framework/actions/items.js @@ -212,13 +212,8 @@ export function postItem (item, type, id) { * */ -export function postAction (item_id, action_type, user_id, item_type) { +export function postAction (item_id, item_type, action) { return () => { - const action = { - action_type, - user_id - }; - return coralApi(`/${item_type}/${item_id}/actions`, {method: 'POST', body: action}); }; } diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js index 92e0769fe..e36d96ab4 100644 --- a/client/coral-plugin-flags/FlagButton.js +++ b/client/coral-plugin-flags/FlagButton.js @@ -1,52 +1,197 @@ -import React from 'react'; +import React, {Component} from 'react'; import {I18n} from '../coral-framework'; import translations from './translations.json'; +import {PopupMenu, Button} from 'coral-ui'; +import onClickOutside from 'react-onclickoutside'; const name = 'coral-plugin-flags'; -const FlagButton = ({flag, id, postAction, deleteAction, addItem, showSignInDialog, updateItem, addNotification, currentUser, banned}) => { - const flagged = flag && flag.current_user; - const onFlagClick = () => { - if (!currentUser) { - const offset = document.getElementById(`c_${id}`).getBoundingClientRect().top - 75; - showSignInDialog(offset); +class FlagButton extends Component { + + state = { + showMenu: false, + showOther: false, + itemType: '', + detail: '', + otherText: '', + step: 0, + posted: false + } + + onReportClick = () => { + if (!this.props.currentUser) { + const offset = document.getElementById(`c_${this.props.id}`).getBoundingClientRect().top - 75; + this.props.showSignInDialog(offset); return; } - if (banned) { - return; + this.setState({showMenu: !this.state.showMenu}); + } + + onPopupContinue = () => { + const {postAction, addItem, updateItem, flag, id, author_id} = this.props; + const {itemType, field, detail, step, otherText, posted} = this.state; + + if (step + 1 >= this.getPopupMenu.length) { + this.setState({showMenu: false}); + } else { + this.setState({step: step + 1}); } - if (!flagged) { - postAction(id, 'flag', currentUser.id, 'comments') + + if (itemType && detail && !posted) { + const updatedDetail = otherText || detail; + const item_id = itemType === 'comments' ? id : author_id; + const action = { + action_type: 'flag', + field, + detail: updatedDetail + }; + postAction(item_id, itemType, action) .then((action) => { let id = `${action.action_type}_${action.item_id}`; addItem({id, current_user: action, count: flag ? flag.count + 1 : 1}, 'actions'); - updateItem(action.item_id, action.action_type, id, 'comments'); + updateItem(action.item_id, action.action_type, id, action.item_type); + this.setState({posted: true}); }); - addNotification('success', lang.t('flag-notif')); - } else { - deleteAction(flagged.id) - .then(() => { - updateItem(id, 'flag', '', 'comments'); - }); - addNotification('success', lang.t('flag-notif-remove')); } - }; + } - return
- { - flagged - ? {lang.t('flagged')} - : {lang.t('flag')} + this.state.showMenu && +
+ +
{popupMenu.header}
+ { + popupMenu.text && +
{popupMenu.text}
+ } + { + popupMenu.options &&
+ { + popupMenu.options.map((option) => +
+ +
+
+ ) + } + { + this.state.showOther &&
+ +
+
+ } +
+ } +
+ {this.state.step + 1} of {this.getPopupMenu.length} +
+ { + popupMenu.button && + } +
+
} - flag - -
; -}; + ; + } +} -export default FlagButton; +export default onClickOutside(FlagButton); const styles = { flaggedIcon: { diff --git a/client/coral-plugin-flags/translations.json b/client/coral-plugin-flags/translations.json index 633b85282..a4bc99ce1 100644 --- a/client/coral-plugin-flags/translations.json +++ b/client/coral-plugin-flags/translations.json @@ -1,14 +1,46 @@ { "en": { - "flag": "Flag", - "flagged": "Flagged", - "flag-notif": "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.", - "flag-notif-remove": "Your flag has been removed." + "report": "Report", + "reported": "Reported", + "report-notif": "Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.", + "report-notif-remove": "Your report has been removed.", + "step-1-header": "Report an issue", + "step-2-header": "Help us understand", + "step-3-header": "Thank you for your input", + "flag-username": "Flag username", + "flag-comment": "Flag comment", + "continue": "Continue", + "done": "Done", + "no-agree-comment": "I don't agree with this comment", + "comment-offensive": "This comment is offensive", + "personal-info": "This comment reveals personally identifiable information", + "username-offensive": "This username is offensive", + "no-like-username": "I don't like this username", + "marketing": "This looks like an ad/marketing", + "thank-you": "We value your safety and feedback. A moderator will review your flag.", + "flag-reason": "Reason for flag", + "other": "Other" }, "es": { - "flag": "Marcar", - "flagged": "Marcado", - "flag-notif": "Gracias por marcar este comentario. Nuestro equipo de moderación ha sido notificado y muy pronto lo va a revisar.", - "flag-notif-remove": "Tu marca ha sido eliminada." + "report": "Informe", + "reported": "Informado", + "report-notif": "Gracias por marcar este comentario. Nuestro equipo de moderación ha sido notificado y muy pronto lo va a revisar.", + "report-notif-remove": "Tu marca ha sido eliminada.", + "step-1-header": "¡traduceme!", + "step-2-header": "¡traduceme!", + "step-3-header": "¡traduceme!", + "flag-username": "¡traduceme!", + "flag-comment": "¡traduceme!", + "continue": "¡traduceme!", + "done": "¡traduceme!", + "no-agree-comment": "¡traduceme!", + "comment-offensive": "¡traduceme!", + "personal-info": "¡traduceme!", + "username-offensive": "¡traduceme!", + "no-like-username": "¡traduceme!", + "marketing": "¡traduceme!", + "thank-you": "¡traduceme!", + "flag-reason": "¡traduceme!", + "other": "¡traduceme!" } } diff --git a/client/coral-plugin-likes/LikeButton.js b/client/coral-plugin-likes/LikeButton.js index 73a945c7e..9e7d06f33 100644 --- a/client/coral-plugin-likes/LikeButton.js +++ b/client/coral-plugin-likes/LikeButton.js @@ -16,7 +16,10 @@ const LikeButton = ({like, id, postAction, deleteAction, addItem, showSignInDial return; } if (!liked) { - postAction(id, 'like', currentUser.id, 'comments') + const action = { + action_type: 'like' + }; + postAction(id, 'comments', action) .then((action) => { let id = `${action.action_type}_${action.item_id}`; addItem({id, current_user: action, count: like ? like.count + 1 : 1}, 'actions'); diff --git a/client/coral-ui/components/Button.css b/client/coral-ui/components/Button.css index e36f55886..6cf13c739 100644 --- a/client/coral-ui/components/Button.css +++ b/client/coral-ui/components/Button.css @@ -21,7 +21,7 @@ cursor: pointer; text-decoration: none; text-align: center; - line-height: 36px; + line-height: 28px; vertical-align: middle; margin: 2px; } diff --git a/client/coral-ui/components/PopupMenu.css b/client/coral-ui/components/PopupMenu.css new file mode 100644 index 000000000..fe57ba8b2 --- /dev/null +++ b/client/coral-ui/components/PopupMenu.css @@ -0,0 +1,31 @@ +.popupMenu { + display: inline-block; + width: inherit; + border: solid 1px #999; + box-shadow: 3px 3px 5px 0 rgba(0, 0, 0, 0.3); + box-sizing: border-box; + background: white; + border-radius: 3px; + padding: 20px 10px; + z-index: 3; +} + +.popupMenu:before{ + content: ''; + border: 10px solid transparent; + border-top-color: white; + position: absolute; + right: 3em; + bottom: -20px; + z-index: 2; +} + +.popupMenu:after{ + content: ''; + border: 10px solid transparent; + border-top-color: #999; + position: absolute; + right: 3em; + bottom: -21px; + z-index: 1; +} diff --git a/client/coral-ui/components/PopupMenu.js b/client/coral-ui/components/PopupMenu.js new file mode 100644 index 000000000..dfc81c3a1 --- /dev/null +++ b/client/coral-ui/components/PopupMenu.js @@ -0,0 +1,6 @@ +import React from 'react'; +import styles from './PopupMenu.css'; + +export default ({children}) => ( + {children} +); diff --git a/client/coral-ui/index.js b/client/coral-ui/index.js index afcce7354..71b0d1d7e 100644 --- a/client/coral-ui/index.js +++ b/client/coral-ui/index.js @@ -7,4 +7,5 @@ export {default as TabContent} from './components/TabContent'; export {default as Button} from './components/Button'; export {default as Spinner} from './components/Spinner'; export {default as Tooltip} from './components/Tooltip'; +export {default as PopupMenu} from './components/PopupMenu'; export {default as Checkbox} from './components/Checkbox'; diff --git a/models/action.js b/models/action.js index b5d2ab8b6..ed18e48c5 100644 --- a/models/action.js +++ b/models/action.js @@ -12,7 +12,9 @@ const ActionSchema = new Schema({ action_type: String, item_type: String, item_id: String, - user_id: String + user_id: String, + field: String, // Used when an action references a particular field of an object. (e.g. a flag on a username or bio) + detail: String, // Describes the reason for an action (e.g. 'Username is offensive') }, { timestamps: { createdAt: 'created_at', @@ -35,13 +37,7 @@ ActionSchema.statics.findById = function(id) { * @param {String} action the new action to the comment * @return {Promise} */ -ActionSchema.statics.insertUserAction = ({item_id, item_type, user_id, action_type}) => { - const action = { - item_id, - item_type, - user_id, - action_type - }; +ActionSchema.statics.insertUserAction = (action) => { // Create/Update the action. return Action.findOneAndUpdate(action, action, { diff --git a/models/comment.js b/models/comment.js index 53e8bbfb0..9cfee2ee7 100644 --- a/models/comment.js +++ b/models/comment.js @@ -287,11 +287,13 @@ CommentSchema.statics.pushStatus = (id, status, assigned_by = null) => Comment.u * @param {String} action the new action to the comment * @return {Promise} */ -CommentSchema.statics.addAction = (item_id, user_id, action_type) => Action.insertUserAction({ +CommentSchema.statics.addAction = (item_id, user_id, action_type, field, detail) => Action.insertUserAction({ item_id, - item_type: 'comment', + item_type: 'comments', user_id, - action_type + action_type, + field, + detail }); /** diff --git a/models/user.js b/models/user.js index b28ad3251..2d5feff3d 100644 --- a/models/user.js +++ b/models/user.js @@ -3,6 +3,7 @@ const uuid = require('uuid'); const _ = require('lodash'); const bcrypt = require('bcrypt'); const jwt = require('jsonwebtoken'); +const Action = require('./action'); const Comment = require('./comment'); @@ -596,3 +597,19 @@ UserService.addBio = (id, bio) => ( new: true }) ); + +/** + * Add an action to the user. + * @param {String} item_id identifier of the user (uuid) + * @param {String} user_id user id of the action (uuid) + * @param {String} action the new action to the user + * @return {Promise} + */ +UserService.addAction = (item_id, user_id, action_type, field, detail) => Action.insertUserAction({ + item_id, + item_type: 'comment', + user_id, + action_type, + field, + detail +}); diff --git a/routes/api/comments/index.js b/routes/api/comments/index.js index 5806c534b..168b89fc3 100644 --- a/routes/api/comments/index.js +++ b/routes/api/comments/index.js @@ -161,11 +161,13 @@ router.put('/:comment_id/status', authorization.needed('admin'), (req, res, next router.post('/:comment_id/actions', (req, res, next) => { const { - action_type + action_type, + field, + detail } = req.body; Comment - .addAction(req.params.comment_id, req.user.id, action_type) + .addAction(req.params.comment_id, req.user.id, action_type, field, detail) .then((action) => { res.status(201).json(action); }) diff --git a/routes/api/user/index.js b/routes/api/user/index.js index ca84af361..5b0c48de8 100644 --- a/routes/api/user/index.js +++ b/routes/api/user/index.js @@ -158,4 +158,21 @@ router.put('/:user_id/bio', (req, res, next) => { }); }); +router.post('/:user_id/actions', authorization.needed(), (req, res, next) => { + const { + action_type, + field, + detail + } = req.body; + + User + .addAction(req.params.user_id, req.user.id, action_type, field, detail) + .then((action) => { + res.status(201).json(action); + }) + .catch((err) => { + next(err); + }); +}); + module.exports = router; diff --git a/tests/client/coral-framework/store/itemActions.spec.js b/tests/client/coral-framework/store/itemActions.spec.js index 6a9b2aeb2..fb7044b7b 100644 --- a/tests/client/coral-framework/store/itemActions.spec.js +++ b/tests/client/coral-framework/store/itemActions.spec.js @@ -155,7 +155,11 @@ describe('itemActions', () => { describe('postAction', () => { it ('should post an action', () => { fetchMock.post('*', {id: '456'}); - return actions.postAction('abc', 'flag', '123', 'comments')(store.dispatch) + const action = { + action_type: 'flag', + detail: 'Comment smells funny' + }; + return actions.postAction('abc', 'comments', action)(store.dispatch) .then(response => { expect(fetchMock.calls().matched[0][0]).to.equal('/api/v1/comments/abc/actions'); expect(response).to.deep.equal({id:'456'}); diff --git a/tests/routes/api/comments/index.js b/tests/routes/api/comments/index.js index 37713e899..b3e4e2675 100644 --- a/tests/routes/api/comments/index.js +++ b/tests/routes/api/comments/index.js @@ -395,11 +395,12 @@ describe('/api/v1/comments/:comment_id/actions', () => { return chai.request(app) .post('/api/v1/comments/abc/actions') .set(passport.inject({id: '456', roles: ['admin']})) - .send({'user_id': '456', 'action_type': 'flag'}) + .send({'action_type': 'flag', 'detail': 'Comment is too awesome.'}) .then((res) => { expect(res).to.have.status(201); expect(res).to.have.body; expect(res.body).to.have.property('action_type', 'flag'); + expect(res.body).to.have.property('detail', 'Comment is too awesome.'); expect(res.body).to.have.property('item_id', 'abc'); }); }); diff --git a/tests/routes/api/user/index.js b/tests/routes/api/user/index.js new file mode 100644 index 000000000..78030049b --- /dev/null +++ b/tests/routes/api/user/index.js @@ -0,0 +1,44 @@ +const passport = require('../../../passport'); + +const app = require('../../../../app'); +const chai = require('chai'); +const expect = chai.expect; + +// Setup chai. +chai.should(); +chai.use(require('chai-http')); + +const User = require('../../../../models/user'); + +describe('/api/v1/user/:user_id/actions', () => { + + const users = [{ + displayName: 'Ana', + email: 'ana@gmail.com', + password: '123' + }, { + displayName: 'Maria', + email: 'maria@gmail.com', + password: '123' + }]; + + beforeEach(() => { + return User.createLocalUsers(users); + }); + + describe('#post', () => { + it('it should update actions', () => { + return chai.request(app) + .post('/api/v1/user/abc/actions') + .set(passport.inject({id: '456', roles: ['admin']})) + .send({'action_type': 'flag', 'detail': 'Bio is too awesome.'}) + .then((res) => { + expect(res).to.have.status(201); + expect(res).to.have.body; + expect(res.body).to.have.property('action_type', 'flag'); + expect(res.body).to.have.property('detail', 'Bio is too awesome.'); + expect(res.body).to.have.property('item_id', 'abc'); + }); + }); + }); +});