diff --git a/src/components/forms/inplace-input.scss b/src/components/forms/inplace-input.scss index 33bc254b3..65b3094e1 100644 --- a/src/components/forms/inplace-input.scss +++ b/src/components/forms/inplace-input.scss @@ -36,7 +36,7 @@ } .inplace-textarea { - transition: all 1s ease; + transition: all .2s ease; border: 2px dashed $ui-blue-25percent; border-radius: 8px; background-color: $ui-light-gray; @@ -49,7 +49,7 @@ resize: none; &:focus { - transition: all 1s ease; + transition: all .2s ease; outline: none; border: 2px solid $ui-blue; box-shadow: 0 0 0 4px $ui-blue-25percent; diff --git a/src/redux/preview.js b/src/redux/preview.js index 2652f1379..a151fe41c 100644 --- a/src/redux/preview.js +++ b/src/redux/preview.js @@ -87,6 +87,19 @@ module.exports.previewReducer = (state, action) => { comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this? }); case 'SET_COMMENT_DELETED': + 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 comment; + }) + }) + }); + } + return Object.assign({}, state, { comments: state.comments.map(comment => { if (comment.id === action.commentId) { @@ -95,6 +108,21 @@ module.exports.previewReducer = (state, action) => { return comment; }) }); + case 'ADD_NEW_COMMENT': + if (action.topLevelCommentId) { + return Object.assign({}, state, { + replies: Object.assign({}, state.replies, { + // Replies to comments go at the end of the thread + [action.topLevelCommentId]: state.replies[action.topLevelCommentId].concat(action.comment) + }) + }); + } + + // Reply to the top level project, put the reply at the beginning + return Object.assign({}, state, { + comments: [action.comment, ...state.comments], + replies: Object.assign({}, state.replies, {[action.comment.id]: []}) + }); case 'SET_REPLIES': return Object.assign({}, state, { replies: merge({}, state.replies, action.replies) @@ -200,9 +228,16 @@ module.exports.setStudioFetchStatus = (studioId, status) => ({ status: status }); -module.exports.setCommentDeleted = commentId => ({ +module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({ type: 'SET_COMMENT_DELETED', - commentId: commentId + commentId: commentId, + topLevelCommentId: topLevelCommentId +}); + +module.exports.addNewComment = (comment, topLevelCommentId) => ({ + type: 'ADD_NEW_COMMENT', + comment: comment, + topLevelCommentId: topLevelCommentId }); module.exports.getProjectInfo = (id, token) => (dispatch => { diff --git a/src/views/preview/comment/comment.jsx b/src/views/preview/comment/comment.jsx index 19ca6b131..d5e4bede0 100644 --- a/src/views/preview/comment/comment.jsx +++ b/src/views/preview/comment/comment.jsx @@ -1,76 +1,122 @@ const React = require('react'); const PropTypes = require('prop-types'); +const bindAll = require('lodash.bindall'); 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 ComposeComment = require('./compose-comment.jsx'); require('./comment.scss'); -const Comment = ({ - author, - deletable, - deleted, - content, - datetimeCreated, - onDelete, - id -}) => ( -
- - - - - - {author.username} -
- {deletable ? ( - - Delete {/* TODO internationalize */} - - ) : null} - - Report {/* TODO internationalize */} - -
-
+class Comment extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleDelete', + 'handlePostReply', + 'handleToggleReplying' + ]); + this.state = { + replying: false + }; + } + + handlePostReply (comment) { + this.setState({replying: false}); + this.props.onAddComment(comment); + } + + handleToggleReplying () { + this.setState({replying: !this.state.replying}); + } + + handleDelete () { + this.props.onDelete(this.props.id); + } + + render () { + const { + author, + deletable, + deleted, + canReply, + content, + datetimeCreated, + id, + projectId + } = this.props; + + return (
- {/* TODO: at the moment, comment content does not properly display - * emojis/easter eggs - * @user links in replies - * links to scratch.mit.edu pages - */} - {content} - - - - - + + + + + {author.username} +
+ {deletable ? ( + + Delete {/* TODO internationalize */} + + ) : null} + + Report {/* TODO internationalize */} + +
+
+
- reply - + {/* TODO: at the moment, comment content does not properly display + * emojis/easter eggs + * @user links in replies + * links to scratch.mit.edu pages + */} + {content} + + + + + {canReply ? ( + + reply + + ) : null} + +
+ {this.state.replying ? ( + + + + ) : null}
-
-
-); + ); + } +} Comment.propTypes = { author: PropTypes.shape({ @@ -78,12 +124,15 @@ Comment.propTypes = { image: PropTypes.string, username: PropTypes.string }), + canReply: PropTypes.bool, content: PropTypes.string, datetimeCreated: PropTypes.string, deletable: PropTypes.bool, deleted: PropTypes.bool, id: PropTypes.number, - onDelete: PropTypes.func + onAddComment: PropTypes.func, + onDelete: PropTypes.func, + projectId: PropTypes.number }; module.exports = Comment; diff --git a/src/views/preview/comment/comment.scss b/src/views/preview/comment/comment.scss index 6ebccbe75..48d741a48 100644 --- a/src/views/preview/comment/comment.scss +++ b/src/views/preview/comment/comment.scss @@ -1,8 +1,26 @@ @import "../../../colors"; .compose-comment { + margin-left: .5rem; width: 100%; + .compose-error-row { + width: 100%; + justify-content: flex-start; + + .compose-error-tip { + margin-bottom: .5rem; + border: 1px solid $active-gray; + border-radius: 5px; + background-color: $ui-orange; + padding: .25rem; + width: 100%; + text-align: left; + color: $type-white; + font-size: .85rem; + } + } + .textarea-row { width: 100%; @@ -161,6 +179,9 @@ .comment-reply { display: inline-flex; + cursor: pointer; + color: $ui-blue; + font-weight: bold; &:after { margin-left: .25rem; @@ -197,6 +218,16 @@ } } +.comments-root-reply { + margin-bottom: 1.5rem; +} + +.comment-reply-row { + margin-top: 1.5rem; + margin-left: .5rem; + width: 100%; +} + .expand-thread { margin-bottom: 24px; width: 100%; diff --git a/src/views/preview/comment/compose-comment.jsx b/src/views/preview/comment/compose-comment.jsx index d14537965..0db753c58 100644 --- a/src/views/preview/comment/compose-comment.jsx +++ b/src/views/preview/comment/compose-comment.jsx @@ -1,26 +1,203 @@ const React = require('react'); +const PropTypes = require('prop-types'); +const bindAll = require('lodash.bindall'); +const classNames = require('classnames'); +const keyMirror = require('keymirror'); const FlexRow = require('../../../components/flex-row/flex-row.jsx'); +const Avatar = require('../../../components/avatar/avatar.jsx'); const InplaceInput = require('../../../components/forms/inplace-input.jsx'); const Button = require('../../../components/forms/button.jsx'); +const connect = require('react-redux').connect; + +const api = require('../../../lib/api'); + require('./comment.scss'); const onUpdate = update => update; -const ComposeComment = () => ( - - - - - - 500 characters left - - -); +const MAX_COMMENT_LENGTH = 500; -module.exports = ComposeComment; +const ComposeStatus = keyMirror({ + EDITING: null, + SUBMITTING: null, + REJECTED: null +}); + +/* TODO translations */ +const CommentErrorMessages = { + isEmpty: "You can't post an empty comment", + isFlood: "Woah, seems like you're commenting really quickly. Please wait longer between posts.", + isBad: 'Hmm...the bad word detector thinks there is a problem with your comment. ' + + 'Please change it and remember to be respectful.', + hasChatSite: 'Uh oh! This comment contains a link to a website with unmoderated chat.' + + 'For safety reasons, please do not link to these sites!', + isSpam: "Hmm, seems like you've posted the same comment a bunch of times. Please don't spam.", + isMuted: "Hmm, the filterbot is pretty sure your recent comments weren't ok for Scratch, " + + 'so your account has been muted for the rest of the day. :/', + isUnconstructive: 'Hmm, the filterbot thinks your comment may be mean or disrespectful. ' + + 'Remember, most projects on Scratch are made by people who are just learning how to program.', + isDisallowed: 'Hmm, it looks like comments have been turned off for this page. :/', + // TODO implement the special modal for ip mute bans that includes links to appeals + // this is just a stub of the actual message + isIPMuted: 'Sorry, the Scratch Team had to prevent your network from sharing comments or ' + + 'projects because it was used to break our community guidelines too many times.' + + 'You can still share comments and projects from another network.', + isTooLong: "That's too long! Please find a way to shorten your text.", + error: 'Oops! Something went wrong' +}; + +class ComposeComment extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handlePost', + 'handleCancel', + 'handleInput' + ]); + this.state = { + message: '', + status: ComposeStatus.EDITING, + error: null + }; + } + handleInput (event) { + this.setState({ + message: event.target.value, + status: ComposeStatus.EDITING, + error: null + }); + } + handlePost () { + this.setState({status: ComposeStatus.SUBMITTING}); + api({ + uri: `/proxy/comments/project/${this.props.projectId}`, + authentication: this.props.user.token, + withCredentials: true, + method: 'POST', + useCsrf: true, + json: { + content: this.state.message, + parent_id: this.props.parentId || '', + comentee_id: this.props.comenteeId || '' + } + }, (err, body, res) => { + if (err || res.statusCode !== 200) { + body = {rejected: 'error'}; + } + + if (body.rejected && this.state.status === ComposeStatus.SUBMITTING) { + // Note: does not reset the message state + this.setState({ + status: ComposeStatus.REJECTED, + // If there is a special error message for the rejected reason, + // use it. Otherwise, use the generic 'error' ("Oops!...") + error: CommentErrorMessages[body.rejected] ? + body.rejected : 'error' + }); + return; + } + + // Clear the text field and reset status on successful submission + this.setState({ + message: '', + status: ComposeStatus.EDITING, + error: null + }); + + // Add the username, which isn't included right now from scratch-api + if (body.author) body.author.username = this.props.user.username; + + this.props.onAddComment(body); + }); + } + handleCancel () { + this.setState({ + message: '', + status: ComposeStatus.EDITING, + error: null + }); + if (this.props.onCancel) this.props.onCancel(); + } + render () { + return ( +
+ + + + + {this.state.error ? ( + +
+ {CommentErrorMessages[this.state.error] || 'Unknown error'} +
+
+ ) : null} + = 0 ? 'compose-valid' : 'compose-invalid')} + handleUpdate={onUpdate} + name="compose-comment" + type="textarea" + value={this.state.message} + onInput={this.handleInput} + /> + + + + = 0 ? + 'compose-valid' : 'compose-invalid')} + > + {/* TODO internationalize */} + {MAX_COMMENT_LENGTH - this.state.message.length} characters left + + +
+
+ ); + } +} + +ComposeComment.propTypes = { + comenteeId: PropTypes.number, + onAddComment: PropTypes.func, + onCancel: PropTypes.func, + parentId: PropTypes.number, + projectId: PropTypes.number, + user: PropTypes.shape({ + id: PropTypes.number, + username: PropTypes.string, + token: PropTypes.string, + thumbnailUrl: PropTypes.string + }) +}; + +const mapStateToProps = state => ({ + user: state.session.session.user +}); + +const ConnectedComposeComment = connect( + mapStateToProps +)(ComposeComment); + +module.exports = ConnectedComposeComment; diff --git a/src/views/preview/comment/top-level-comment.jsx b/src/views/preview/comment/top-level-comment.jsx index f3eb129ad..6cc67da91 100644 --- a/src/views/preview/comment/top-level-comment.jsx +++ b/src/views/preview/comment/top-level-comment.jsx @@ -13,7 +13,8 @@ class TopLevelComment extends React.Component { super(props); bindAll(this, [ 'handleExpandThread', - 'handleDelete' + 'handleAddComment', + 'handleDeleteReply' ]); this.state = { expanded: false @@ -26,26 +27,36 @@ class TopLevelComment extends React.Component { }); } - handleDelete () { - this.props.onDelete(this.props.id); + handleDeleteReply (commentId) { + // Only apply topLevelCommentId for deleting replies + // The top level comment itself just gets passed onDelete directly + this.props.onDelete(commentId, this.props.id); + } + + handleAddComment (comment) { + this.props.onAddComment(comment, this.props.id); } render () { const { author, + canReply, content, datetimeCreated, deletable, deleted, id, - replies + onDelete, + replies, + projectId } = this.props; return ( {replies.length > 0 && ( ))} @@ -87,11 +101,13 @@ TopLevelComment.propTypes = { image: PropTypes.string, username: PropTypes.string }), + canReply: PropTypes.bool, content: PropTypes.string, datetimeCreated: PropTypes.string, deletable: PropTypes.bool, deleted: PropTypes.bool, id: PropTypes.number, + onAddComment: PropTypes.func, onDelete: PropTypes.func, parentId: PropTypes.number, projectId: PropTypes.string, diff --git a/src/views/preview/presentation.jsx b/src/views/preview/presentation.jsx index ee3dbab5a..6824de137 100644 --- a/src/views/preview/presentation.jsx +++ b/src/views/preview/presentation.jsx @@ -21,6 +21,7 @@ const StudioList = require('./studio-list.jsx'); const Subactions = require('./subactions.jsx'); const InplaceInput = require('../../components/forms/inplace-input.jsx'); const TopLevelComment = require('./comment/top-level-comment.jsx'); +const ComposeComment = require('./comment/compose-comment.jsx'); const ExtensionChip = require('./extension-chip.jsx'); const projectShape = require('./projectshape.jsx').projectShape; @@ -64,6 +65,7 @@ const PreviewPresentation = ({ projectStudios, studios, userOwnsProject, + onAddComment, onDeleteComment, onFavoriteClicked, onLoadMore, @@ -317,10 +319,21 @@ const PreviewPresentation = ({

Comments

{/* TODO: Add toggle comments component and logic*/}
+ + + {isLoggedIn && + + } + + {comments.map(comment => ( ))} @@ -374,6 +388,7 @@ PreviewPresentation.propTypes = { isShared: PropTypes.bool, loveCount: PropTypes.number, loved: PropTypes.bool, + onAddComment: PropTypes.func, onAddToStudioClicked: PropTypes.func, onAddToStudioClosed: PropTypes.func, onDeleteComment: PropTypes.func, diff --git a/src/views/preview/preview.jsx b/src/views/preview/preview.jsx index e7aed39e9..76ed08b89 100644 --- a/src/views/preview/preview.jsx +++ b/src/views/preview/preview.jsx @@ -33,6 +33,7 @@ class Preview extends React.Component { super(props); bindAll(this, [ 'addEventListeners', + 'handleAddComment', 'handleDeleteComment', 'handleToggleStudio', 'handleFavoriteToggle', @@ -166,8 +167,11 @@ class Preview extends React.Component { }); }); } - handleDeleteComment (id) { - this.props.handleDeleteComment(this.state.projectId, id, this.props.user.token); + handleAddComment (comment, topLevelCommentId) { + this.props.handleAddComment(comment, topLevelCommentId); + } + handleDeleteComment (id, topLevelCommentId) { + this.props.handleDeleteComment(this.state.projectId, id, topLevelCommentId, this.props.user.token); } handleReportClick () { this.setState({reportOpen: true}); @@ -341,6 +345,7 @@ class Preview extends React.Component { reportOpen={this.state.reportOpen} studios={this.props.studios} userOwnsProject={this.props.userOwnsProject} + onAddComment={this.handleAddComment} onAddToStudioClicked={this.handleAddToStudioClick} onAddToStudioClosed={this.handleAddToStudioClose} onDeleteComment={this.handleDeleteComment} @@ -399,6 +404,7 @@ Preview.propTypes = { getProjectStudios: PropTypes.func.isRequired, getRemixes: PropTypes.func.isRequired, getTopLevelComments: PropTypes.func.isRequired, + handleAddComment: PropTypes.func, handleDeleteComment: PropTypes.func, handleLogIn: PropTypes.func, handleLogOut: PropTypes.func, @@ -526,8 +532,11 @@ const mapStateToProps = state => { }; const mapDispatchToProps = dispatch => ({ - handleDeleteComment: (projectId, commentId, token) => { - dispatch(previewActions.deleteComment(projectId, commentId, token)); + handleAddComment: (comment, topLevelCommentId) => { + dispatch(previewActions.addNewComment(comment, topLevelCommentId)); + }, + handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => { + dispatch(previewActions.deleteComment(projectId, commentId, topLevelCommentId, token)); }, handleOpenRegistration: event => { event.preventDefault();