diff --git a/client/coral-admin/src/translations.json b/client/coral-admin/src/translations.json
index 88a20e445..013c159a8 100644
--- a/client/coral-admin/src/translations.json
+++ b/client/coral-admin/src/translations.json
@@ -47,7 +47,7 @@
"comment-settings": "Comment Settings",
"embed-comment-stream": "Embed Comment Stream",
"banned-word-header": "Write the bannned words list",
- "banned-word-text": "Comments which contain these words or phrases, not seperated by commas and not case sensitive, will be automatically removed from the comment stream.",
+ "banned-word-text": "Comments which contain these words or phrases, not separated by commas and not case sensitive, will be automatically removed from the comment stream.",
"wordlist": "Banned words list",
"save-changes": "Save Changes",
"copy-and-paste": "Copy and paste code below into your CMS to embed your comment box in your articles",
diff --git a/client/coral-embed-stream/src/CommentStream.js b/client/coral-embed-stream/src/CommentStream.js
index bb59ffa8e..7ec042ea1 100644
--- a/client/coral-embed-stream/src/CommentStream.js
+++ b/client/coral-embed-stream/src/CommentStream.js
@@ -16,7 +16,7 @@ import PubDate from '../../coral-plugin-pubdate/PubDate';
import Count from '../../coral-plugin-comment-count/CommentCount';
import AuthorName from '../../coral-plugin-author-name/AuthorName';
import {ReplyBox, ReplyButton} from '../../coral-plugin-replies';
-import FlagButton from '../../coral-plugin-flags/FlagButton';
+import FlagComment from '../../coral-plugin-flags/FlagComment';
import LikeButton from '../../coral-plugin-likes/LikeButton';
import PermalinkButton from '../../coral-plugin-permalinks/PermalinkButton';
import SignInContainer from '../../coral-sign-in/containers/SignInContainer';
@@ -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}
@@ -139,7 +139,17 @@ class CommentStream extends Component {
const comment = comments[commentId];
return
);
}
diff --git a/client/coral-plugin-flags/FlagBio.js b/client/coral-plugin-flags/FlagBio.js
new file mode 100644
index 000000000..90cc11a7a
--- /dev/null
+++ b/client/coral-plugin-flags/FlagBio.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import FlagButton from './FlagButton';
+import {I18n} from '../coral-framework';
+import translations from './translations.json';
+
+const FlagBio = (props) => ;
+
+const getPopupMenu = [
+ () => {
+ return {
+ header: lang.t('step-2-header'),
+ itemType: 'user',
+ field: 'bio',
+ options: [
+ {val: 'This bio is offensive', text: lang.t('bio-offensive')},
+ {val: 'I don\'t like this bio', text: lang.t('no-like-bio')},
+ {val: 'This looks like an ad/marketing', text: lang.t('marketing')},
+ {val: 'other', text: lang.t('other')}
+ ],
+ button: lang.t('continue'),
+ sets: 'detail'
+ };
+ },
+ () => {
+ return {
+ header: lang.t('step-3-header'),
+ text: lang.t('thank-you'),
+ button: lang.t('done'),
+ };
+ }
+];
+
+export default FlagBio;
+
+const lang = new I18n(translations);
diff --git a/client/coral-plugin-flags/FlagButton.js b/client/coral-plugin-flags/FlagButton.js
index 92e0769fe..d0b9cb6b4 100644
--- a/client/coral-plugin-flags/FlagButton.js
+++ b/client/coral-plugin-flags/FlagButton.js
@@ -1,52 +1,178 @@
-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
+ }
+
+ // When the "report" button is clicked expand the menu
+ 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;
+
+ //Proceed to the next step or close the menu if we've reached the end
+ if (step + 1 >= this.props.getPopupMenu.length) {
+ this.setState({showMenu: false});
+ } else {
+ this.setState({step: step + 1});
}
- if (!flagged) {
- postAction(id, 'flag', currentUser.id, 'comments')
+
+ // If itemType and detail are both set, post the action
+ if (itemType && detail && !posted) {
+ // Set the text from the "other" field if it exists.
+ const updatedDetail = otherText || detail;
+ let item_id;
+ switch(itemType) {
+ case 'comments':
+ item_id = id;
+ break;
+ case 'user':
+ item_id = author_id;
+ break;
+ }
+ 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
-
+ onPopupOptionClick = (sets) => (e) => {
+
+ // If the "other" option is clicked, show the other textbox
+ if(sets === 'detail' && e.target.value === 'other') {
+ this.setState({showOther: true});
+ }
+
+ // If flagging a user, indicate that this is referencing the username rather than the bio
+ if(sets === 'itemType' && e.target.value === 'user') {
+ this.setState({field: 'username'});
+ }
+
+ // Set itemType and field if they are defined in the popupMenu
+ const currentMenu = this.props.getPopupMenu[this.state.step]();
+ if (currentMenu.itemType) {
+ this.setState({itemType: currentMenu.itemType});
+ }
+ if (currentMenu.field) {
+ this.setState({field: currentMenu.field});
+ }
+
+ this.setState({[sets]: e.target.value});
+ }
+
+ onOtherTextChange = (e) => {
+ this.setState({otherText: e.target.value});
+ }
+
+ handleClickOutside () {
+ this.setState({showMenu: false});
+ }
+
+ render () {
+ const {flag, getPopupMenu} = this.props;
+ const flagged = flag && flag.current_user;
+ const popupMenu = getPopupMenu[this.state.step](this.state.itemType);
+
+ return
+
+ {
+ flagged
+ ? {lang.t('reported')}
+ : {lang.t('report')}
+ }
+ flag
+
{
- flagged
- ?
{lang.t('flagged')}
- :
{lang.t('flag')}
+ this.state.showMenu &&
+
+
+ {popupMenu.header}
+ {
+ popupMenu.text &&
+ {popupMenu.text}
+ }
+ {
+ popupMenu.options &&
+ }
+
+ {this.state.step + 1} of {getPopupMenu.length}
+
+ {
+ popupMenu.button &&
+ {popupMenu.button}
+
+ }
+
+
}
-
flag
-
-
;
-};
+ ;
+ }
+}
-export default FlagButton;
+export default onClickOutside(FlagButton);
const styles = {
flaggedIcon: {
diff --git a/client/coral-plugin-flags/FlagComment.js b/client/coral-plugin-flags/FlagComment.js
new file mode 100644
index 000000000..2d5b33039
--- /dev/null
+++ b/client/coral-plugin-flags/FlagComment.js
@@ -0,0 +1,52 @@
+import React from 'react';
+import FlagButton from './FlagButton';
+import {I18n} from '../coral-framework';
+import translations from './translations.json';
+
+const FlagComment = (props) => ;
+
+const getPopupMenu = [
+ () => {
+ return {
+ header: lang.t('step-1-header'),
+ options: [
+ {val: 'user', text: lang.t('flag-username')},
+ {val: 'comments', text: lang.t('flag-comment')}
+ ],
+ button: lang.t('continue'),
+ sets: 'itemType'
+ };
+ },
+ (itemType) => {
+ const options = itemType === 'comments' ?
+ [
+ {val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
+ {val: 'This comment is offensive', text: lang.t('comment-offensive')},
+ {val: 'This comment reveals personally identifiable infomration', text: lang.t('personal-info')},
+ {val: 'other', text: lang.t('other')}
+ ]
+ : [
+ {val: 'This username is offensive', text: lang.t('username-offensive')},
+ {val: 'I don\'t like this username', text: lang.t('no-like-username')},
+ {val: 'This looks like an ad/marketing', text: lang.t('marketing')},
+ {val: 'other', text: lang.t('other')}
+ ];
+ return {
+ header: lang.t('step-2-header'),
+ options,
+ button: lang.t('continue'),
+ sets: 'detail'
+ };
+ },
+ () => {
+ return {
+ header: lang.t('step-3-header'),
+ text: lang.t('thank-you'),
+ button: lang.t('done'),
+ };
+ }
+];
+
+export default FlagComment;
+
+const lang = new I18n(translations);
diff --git a/client/coral-plugin-flags/translations.json b/client/coral-plugin-flags/translations.json
index 633b85282..0c2ce594d 100644
--- a/client/coral-plugin-flags/translations.json
+++ b/client/coral-plugin-flags/translations.json
@@ -1,14 +1,50 @@
{
"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",
+ "bio-offensive": "This bio is offensive",
+ "no-like-bio": "I don't like this bio",
+ "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": "Reportar un problema",
+ "step-2-header": "Ayudanos a entender",
+ "step-3-header": "Gracias por tu participación",
+ "flag-username": "Marcar el nombre de usuario",
+ "flag-comment": "Marcar el comentario",
+ "continue": "Continuar",
+ "done": "hecho",
+ "no-agree-comment": "No estoy de acuerdo con este comentario",
+ "comment-offensive": "Este comentario es ofensivo",
+ "personal-info": "Este comentario muestra información personal",
+ "username-offensive": "Este nombre de usuario es ofensivo",
+ "no-like-username": "No me gusta ese nombre de usuario",
+ "bio-offensive": "Esta bio es ofensiva",
+ "no-like-bio": "No me gusta esta bio",
+ "marketing": "Esto parece una publicidad/marketing",
+ "thank-you": "Nos interesa tu protección y comentarios. Un moderador va a mirar tu marca.",
+ "flag-reason": "Razón por la que marcar",
+ "other": "Otro"
}
}
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..ac6328179 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: 'user',
+ 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');
+ });
+ });
+ });
+});