diff --git a/src/components/modal/comments/delete-comment.jsx b/src/components/modal/comments/delete-comment.jsx new file mode 100644 index 000000000..0d2264854 --- /dev/null +++ b/src/components/modal/comments/delete-comment.jsx @@ -0,0 +1,81 @@ +const PropTypes = require('prop-types'); +const React = require('react'); +const FormattedMessage = require('react-intl').FormattedMessage; +const injectIntl = require('react-intl').injectIntl; +const intlShape = require('react-intl').intlShape; +const Modal = require('../base/modal.jsx'); + +const Button = require('../../forms/button.jsx'); +const FlexRow = require('../../flex-row/flex-row.jsx'); + +require('../../forms/button.scss'); +require('./modal.scss'); + +const DeleteModal = ({ + intl, + onDelete, + onReport, + onRequestClose, + ...modalProps +}) => ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + + +DeleteModal.propTypes = { + intl: intlShape, + onDelete: PropTypes.func, + onReport: PropTypes.func, + onRequestClose: PropTypes.func +}; + +module.exports = injectIntl(DeleteModal); diff --git a/src/components/modal/comments/modal.scss b/src/components/modal/comments/modal.scss new file mode 100644 index 000000000..40a526fe3 --- /dev/null +++ b/src/components/modal/comments/modal.scss @@ -0,0 +1,44 @@ +@import "../../../colors"; +@import "../../../frameless"; + +$medium-and-small: "screen and (max-width : #{$tablet}-1)"; + +.mod-report * { + box-sizing: border-box; +} + +.mod-report { + margin: 100px auto; + outline: none; + padding: 0; + width: 36.25rem; /* 580px; */ + user-select: none; +} + +.report-modal-header { + border-radius: 1rem 1rem 0 0; + box-shadow: inset 0 -1px 0 0 $ui-coral-dark; + background-color: $ui-coral; + padding-top: .75rem; + width: 100%; + height: 3rem; + box-sizing: border-box; +} + +.report-content-label { + text-align: center; + color: $type-white; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 1rem; + font-weight: bold; +} + +.report-modal-content { + margin: 1rem auto; + width: 80%; + font-size: .875rem; + + .instructions { + line-height: 1.5rem; + } +} diff --git a/src/components/modal/comments/report-comment.jsx b/src/components/modal/comments/report-comment.jsx new file mode 100644 index 000000000..197484dfe --- /dev/null +++ b/src/components/modal/comments/report-comment.jsx @@ -0,0 +1,84 @@ +const PropTypes = require('prop-types'); +const React = require('react'); +const FormattedMessage = require('react-intl').FormattedMessage; +const injectIntl = require('react-intl').injectIntl; +const intlShape = require('react-intl').intlShape; +const Modal = require('../base/modal.jsx'); + +const Button = require('../../forms/button.jsx'); +const FlexRow = require('../../flex-row/flex-row.jsx'); + +require('../../forms/button.scss'); +require('./modal.scss'); + +const ReportModal = ({ + intl, + isConfirmed, + onReport, + onRequestClose, + ...modalProps +}) => ( + + + + + + + + + + + + {isConfirmed ? ( + + ) : ( + + )} + + + + + + + + + + + {isConfirmed ? null : ( + + + + + + )} + + + + +); + + +ReportModal.propTypes = { + intl: intlShape, + isConfirmed: PropTypes.bool, + isOwnSpace: PropTypes.bool, + onReport: PropTypes.func, + onRequestClose: PropTypes.func, + type: PropTypes.string +}; + +module.exports = injectIntl(ReportModal); diff --git a/src/l10n.json b/src/l10n.json index 13e327d37..422f8017d 100644 --- a/src/l10n.json +++ b/src/l10n.json @@ -202,5 +202,14 @@ "report.tooShortError": "That's too short. Please describe in detail what's inappropriate or disrespectful about the project.", "report.send": "Send", "report.sending": "Sending...", - "report.textMissing": "Please tell us why you are reporting this project" + "report.textMissing": "Please tell us why you are reporting this project", + + "comments.report": "Report", + "comments.delete": "Delete", + "comments.reportModal.title": "Report Comment", + "comments.reportModal.reported": "The comment has been reported, and the Scratch Team has been notified.", + "comments.reportModal.prompt": "Are you sure you want to report this comment?", + "comments.deleteModal.title": "Delete Comment", + "comments.deleteModal.body": "Delete this comment? If the comment is mean or disrespectful, please click Report instead to let the Scratch Team know about it.", + "comments.reply": "reply" } diff --git a/src/redux/preview.js b/src/redux/preview.js index a151fe41c..fb1adc1a8 100644 --- a/src/redux/preview.js +++ b/src/redux/preview.js @@ -86,13 +86,13 @@ module.exports.previewReducer = (state, action) => { return Object.assign({}, state, { comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this? }); - case 'SET_COMMENT_DELETED': + case 'UPDATE_COMMENT': if (action.topLevelCommentId) { return Object.assign({}, state, { replies: Object.assign({}, state.replies, { [action.topLevelCommentId]: state.replies[action.topLevelCommentId].map(comment => { if (comment.id === action.commentId) { - return Object.assign({}, comment, {deleted: true}); + return Object.assign({}, comment, action.comment); } return comment; }) @@ -103,7 +103,7 @@ module.exports.previewReducer = (state, action) => { return Object.assign({}, state, { comments: state.comments.map(comment => { if (comment.id === action.commentId) { - return Object.assign({}, comment, {deleted: true}); + return Object.assign({}, comment, action.comment); } return comment; }) @@ -229,9 +229,21 @@ module.exports.setStudioFetchStatus = (studioId, status) => ({ }); module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({ - type: 'SET_COMMENT_DELETED', + type: 'UPDATE_COMMENT', commentId: commentId, - topLevelCommentId: topLevelCommentId + topLevelCommentId: topLevelCommentId, + comment: { + deleted: true + } +}); + +module.exports.setCommentReported = (commentId, topLevelCommentId) => ({ + type: 'UPDATE_COMMENT', + commentId: commentId, + topLevelCommentId: topLevelCommentId, + comment: { + reported: true + } }); module.exports.addNewComment = (comment, topLevelCommentId) => ({ @@ -611,7 +623,7 @@ module.exports.updateProject = (id, jsonData, username, token) => (dispatch => { }); }); -module.exports.deleteComment = (projectId, commentId, token) => (dispatch => { +module.exports.deleteComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { /* TODO fetching/fetched/error states updates for comment deleting */ api({ uri: `/proxy/comments/project/${projectId}`, @@ -627,7 +639,24 @@ module.exports.deleteComment = (projectId, commentId, token) => (dispatch => { log.error(err || res.body); return; } - dispatch(module.exports.setCommentDeleted(commentId)); + dispatch(module.exports.setCommentDeleted(commentId, topLevelCommentId)); + }); +}); + +module.exports.reportComment = (projectId, commentId, topLevelCommentId, token) => (dispatch => { + api({ + uri: `/proxy/project/${projectId}/comment/${commentId}/report`, + authentication: token, + withCredentials: true, + method: 'POST', + useCsrf: true + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + log.error(err || res.body); + return; + } + // TODO use the reportId in the response for unreporting functionality + dispatch(module.exports.setCommentReported(commentId, topLevelCommentId)); }); }); diff --git a/src/views/preview/comment/comment.jsx b/src/views/preview/comment/comment.jsx index d5e4bede0..067e8aabd 100644 --- a/src/views/preview/comment/comment.jsx +++ b/src/views/preview/comment/comment.jsx @@ -6,7 +6,10 @@ const classNames = require('classnames'); const FlexRow = require('../../../components/flex-row/flex-row.jsx'); const Avatar = require('../../../components/avatar/avatar.jsx'); const FormattedRelative = require('react-intl').FormattedRelative; +const FormattedMessage = require('react-intl').FormattedMessage; const ComposeComment = require('./compose-comment.jsx'); +const DeleteCommentModal = require('../../../components/modal/comments/delete-comment.jsx'); +const ReportCommentModal = require('../../../components/modal/comments/report-comment.jsx'); require('./comment.scss'); @@ -15,10 +18,18 @@ class Comment extends React.Component { super(props); bindAll(this, [ 'handleDelete', + 'handleCancelDelete', + 'handleConfirmDelete', + 'handleReport', + 'handleConfirmReport', + 'handleCancelReport', 'handlePostReply', 'handleToggleReplying' ]); this.state = { + deleting: false, + reporting: false, + reportConfirmed: false, replying: false }; } @@ -33,9 +44,39 @@ class Comment extends React.Component { } handleDelete () { + this.setState({deleting: true}); + } + + handleConfirmDelete () { + this.setState({deleting: false}); this.props.onDelete(this.props.id); } + handleCancelDelete () { + this.setState({deleting: false}); + } + + handleReport () { + this.setState({reporting: true}); + } + + handleConfirmReport () { + this.setState({ + reporting: false, + reportConfirmed: true, + deleting: false // To close delete modal if reported from delete modal + }); + + this.props.onReport(this.props.id); + } + + handleCancelReport () { + this.setState({ + reporting: false, + reportConfirmed: false + }); + } + render () { const { author, @@ -45,7 +86,8 @@ class Comment extends React.Component { content, datetimeCreated, id, - projectId + projectId, + reported } = this.props; return ( @@ -68,18 +110,22 @@ class Comment extends React.Component { className="comment-delete" onClick={this.handleDelete} > - Delete {/* TODO internationalize */} + ) : null} - - Report {/* TODO internationalize */} + + {/* TODO: at the moment, comment content does not properly display @@ -97,11 +143,12 @@ class Comment extends React.Component { className="comment-reply" onClick={this.handleToggleReplying} > - reply + ) : null} + {this.state.replying ? ( ) : null} + {this.state.deleting ? ( + + ) : null} + {(this.state.reporting || this.state.reportConfirmed) ? ( + + ) : null} ); } @@ -132,7 +197,9 @@ Comment.propTypes = { id: PropTypes.number, onAddComment: PropTypes.func, onDelete: PropTypes.func, - projectId: PropTypes.number + onReport: PropTypes.func, + projectId: PropTypes.string, + reported: PropTypes.bool }; module.exports = Comment; diff --git a/src/views/preview/comment/comment.scss b/src/views/preview/comment/comment.scss index 48d741a48..6214a2212 100644 --- a/src/views/preview/comment/comment.scss +++ b/src/views/preview/comment/comment.scss @@ -151,8 +151,8 @@ } &.comment-bubble-deleted { - $deleted-outline: #ff6680; - $deleted-background: rgb(236, 206, 223); + $deleted-outline: $active-gray; + $deleted-background: rgb(215, 222, 234); border-color: $deleted-outline; background-color: $deleted-background; @@ -162,6 +162,19 @@ background: $deleted-background; } } + + &.comment-bubble-reported { + $reported-outline: #ff6680; + $reported-background: rgb(236, 206, 223); + + border-color: $reported-outline; + background-color: $reported-background; + + &:before { + border-color: $reported-outline transparent $reported-outline $reported-outline; + background: $reported-background; + } + } } .comment-content { diff --git a/src/views/preview/comment/compose-comment.jsx b/src/views/preview/comment/compose-comment.jsx index 0db753c58..73f07733c 100644 --- a/src/views/preview/comment/compose-comment.jsx +++ b/src/views/preview/comment/compose-comment.jsx @@ -183,7 +183,7 @@ ComposeComment.propTypes = { onAddComment: PropTypes.func, onCancel: PropTypes.func, parentId: PropTypes.number, - projectId: PropTypes.number, + projectId: PropTypes.string, user: PropTypes.shape({ id: PropTypes.number, username: PropTypes.string, diff --git a/src/views/preview/comment/top-level-comment.jsx b/src/views/preview/comment/top-level-comment.jsx index 6cc67da91..62be1a13d 100644 --- a/src/views/preview/comment/top-level-comment.jsx +++ b/src/views/preview/comment/top-level-comment.jsx @@ -14,7 +14,8 @@ class TopLevelComment extends React.Component { bindAll(this, [ 'handleExpandThread', 'handleAddComment', - 'handleDeleteReply' + 'handleDeleteReply', + 'handleReportReply' ]); this.state = { expanded: false @@ -33,6 +34,12 @@ class TopLevelComment extends React.Component { this.props.onDelete(commentId, this.props.id); } + handleReportReply (commentId) { + // Only apply topLevelCommentId for reporting replies + // The top level comment itself just gets passed onReport directly + this.props.onReport(commentId, this.props.id); + } + handleAddComment (comment) { this.props.onAddComment(comment, this.props.id); } @@ -47,7 +54,9 @@ class TopLevelComment extends React.Component { deleted, id, onDelete, + onReport, replies, + reported, projectId } = this.props; @@ -56,7 +65,18 @@ class TopLevelComment extends React.Component { {replies.length > 0 && ))} @@ -109,9 +131,11 @@ TopLevelComment.propTypes = { id: PropTypes.number, onAddComment: PropTypes.func, onDelete: PropTypes.func, + onReport: PropTypes.func, parentId: PropTypes.number, projectId: PropTypes.string, - replies: PropTypes.arrayOf(PropTypes.object) + replies: PropTypes.arrayOf(PropTypes.object), + reported: PropTypes.bool }; module.exports = TopLevelComment; diff --git a/src/views/preview/presentation.jsx b/src/views/preview/presentation.jsx index 30993a680..43019d79b 100644 --- a/src/views/preview/presentation.jsx +++ b/src/views/preview/presentation.jsx @@ -74,6 +74,7 @@ const PreviewPresentation = ({ onLoveClicked, onReportClicked, onReportClose, + onReportComment, onReportSubmit, onAddToStudioClicked, onAddToStudioClosed, @@ -367,8 +368,10 @@ const PreviewPresentation = ({ parentId={comment.parent_id} projectId={projectId} replies={replies && replies[comment.id] ? replies[comment.id] : []} + reported={comment.reported} onAddComment={onAddComment} onDelete={onDeleteComment} + onReport={onReportComment} /> ))} {comments.length < projectInfo.stats.comments && @@ -421,6 +424,7 @@ PreviewPresentation.propTypes = { onLoveClicked: PropTypes.func, onReportClicked: PropTypes.func.isRequired, onReportClose: PropTypes.func.isRequired, + onReportComment: PropTypes.func.isRequired, onReportSubmit: PropTypes.func.isRequired, onSeeInside: PropTypes.func, onToggleComments: PropTypes.func, diff --git a/src/views/preview/preview.jsx b/src/views/preview/preview.jsx index def3de3bd..a7db2a3f0 100644 --- a/src/views/preview/preview.jsx +++ b/src/views/preview/preview.jsx @@ -42,6 +42,7 @@ class Preview extends React.Component { 'handlePopState', 'handleReportClick', 'handleReportClose', + 'handleReportComment', 'handleReportSubmit', 'handleAddToStudioClick', 'handleAddToStudioClose', @@ -182,6 +183,9 @@ class Preview extends React.Component { handleDeleteComment (id, topLevelCommentId) { this.props.handleDeleteComment(this.state.projectId, id, topLevelCommentId, this.props.user.token); } + handleReportComment (id, topLevelCommentId) { + this.props.handleReportComment(this.state.projectId, id, topLevelCommentId, this.props.user.token); + } handleReportClick () { this.setState({reportOpen: true}); } @@ -363,6 +367,7 @@ class Preview extends React.Component { onLoveClicked={this.handleLoveToggle} onReportClicked={this.handleReportClick} onReportClose={this.handleReportClose} + onReportComment={this.handleReportComment} onReportSubmit={this.handleReportSubmit} onSeeInside={this.handleSeeInside} onToggleComments={this.handleToggleComments} @@ -419,6 +424,7 @@ Preview.propTypes = { handleLogIn: PropTypes.func, handleLogOut: PropTypes.func, handleOpenRegistration: PropTypes.func, + handleReportComment: PropTypes.func, handleToggleLoginOpen: PropTypes.func, isEditable: PropTypes.bool, isLoggedIn: PropTypes.bool, @@ -548,6 +554,9 @@ const mapDispatchToProps = dispatch => ({ handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => { dispatch(previewActions.deleteComment(projectId, commentId, topLevelCommentId, token)); }, + handleReportComment: (projectId, commentId, topLevelCommentId, token) => { + dispatch(previewActions.reportComment(projectId, commentId, topLevelCommentId, token)); + }, handleOpenRegistration: event => { event.preventDefault(); dispatch(navigationActions.setRegistrationOpen(true));