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
-}) => (
-
-);
+ );
+ }
+}
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();