Merge pull request #2150 from paulkaplan/new-comment

Add comments to projects and replies to comments
This commit is contained in:
Paul Kaplan 2018-10-09 10:02:17 -04:00 committed by GitHub
commit e29caceb6b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 421 additions and 89 deletions

View file

@ -36,7 +36,7 @@
} }
.inplace-textarea { .inplace-textarea {
transition: all 1s ease; transition: all .2s ease;
border: 2px dashed $ui-blue-25percent; border: 2px dashed $ui-blue-25percent;
border-radius: 8px; border-radius: 8px;
background-color: $ui-light-gray; background-color: $ui-light-gray;
@ -49,7 +49,7 @@
resize: none; resize: none;
&:focus { &:focus {
transition: all 1s ease; transition: all .2s ease;
outline: none; outline: none;
border: 2px solid $ui-blue; border: 2px solid $ui-blue;
box-shadow: 0 0 0 4px $ui-blue-25percent; box-shadow: 0 0 0 4px $ui-blue-25percent;

View file

@ -87,6 +87,19 @@ module.exports.previewReducer = (state, action) => {
comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this? comments: [...state.comments, ...action.items] // TODO: consider a different way of doing this?
}); });
case 'SET_COMMENT_DELETED': 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, { return Object.assign({}, state, {
comments: state.comments.map(comment => { comments: state.comments.map(comment => {
if (comment.id === action.commentId) { if (comment.id === action.commentId) {
@ -95,6 +108,21 @@ module.exports.previewReducer = (state, action) => {
return comment; 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': case 'SET_REPLIES':
return Object.assign({}, state, { return Object.assign({}, state, {
replies: merge({}, state.replies, action.replies) replies: merge({}, state.replies, action.replies)
@ -200,9 +228,16 @@ module.exports.setStudioFetchStatus = (studioId, status) => ({
status: status status: status
}); });
module.exports.setCommentDeleted = commentId => ({ module.exports.setCommentDeleted = (commentId, topLevelCommentId) => ({
type: 'SET_COMMENT_DELETED', 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 => { module.exports.getProjectInfo = (id, token) => (dispatch => {

View file

@ -1,22 +1,54 @@
const React = require('react'); const React = require('react');
const PropTypes = require('prop-types'); const PropTypes = require('prop-types');
const bindAll = require('lodash.bindall');
const classNames = require('classnames'); const classNames = require('classnames');
const FlexRow = require('../../../components/flex-row/flex-row.jsx'); const FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx'); const Avatar = require('../../../components/avatar/avatar.jsx');
const FormattedRelative = require('react-intl').FormattedRelative; const FormattedRelative = require('react-intl').FormattedRelative;
const ComposeComment = require('./compose-comment.jsx');
require('./comment.scss'); require('./comment.scss');
const Comment = ({ 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, author,
deletable, deletable,
deleted, deleted,
canReply,
content, content,
datetimeCreated, datetimeCreated,
onDelete, id,
id projectId
}) => ( } = this.props;
return (
<div <div
className="flex-row comment" className="flex-row comment"
id={`comments-${id}`} id={`comments-${id}`}
@ -34,7 +66,7 @@ const Comment = ({
{deletable ? ( {deletable ? (
<span <span
className="comment-delete" className="comment-delete"
onClick={onDelete} onClick={this.handleDelete}
> >
Delete {/* TODO internationalize */} Delete {/* TODO internationalize */}
</span> </span>
@ -60,17 +92,31 @@ const Comment = ({
<span className="comment-time"> <span className="comment-time">
<FormattedRelative value={new Date(datetimeCreated)} /> <FormattedRelative value={new Date(datetimeCreated)} />
</span> </span>
<a {canReply ? (
<span
className="comment-reply" className="comment-reply"
href={`#comments-${id}`} onClick={this.handleToggleReplying}
> >
reply reply
</a> </span>
) : null}
</FlexRow> </FlexRow>
</div> </div>
{this.state.replying ? (
<FlexRow className="comment-reply-row">
<ComposeComment
parentId={id}
projectId={projectId}
onAddComment={this.handlePostReply}
onCancel={this.handleToggleReplying}
/>
</FlexRow>
) : null}
</FlexRow> </FlexRow>
</div> </div>
); );
}
}
Comment.propTypes = { Comment.propTypes = {
author: PropTypes.shape({ author: PropTypes.shape({
@ -78,12 +124,15 @@ Comment.propTypes = {
image: PropTypes.string, image: PropTypes.string,
username: PropTypes.string username: PropTypes.string
}), }),
canReply: PropTypes.bool,
content: PropTypes.string, content: PropTypes.string,
datetimeCreated: PropTypes.string, datetimeCreated: PropTypes.string,
deletable: PropTypes.bool, deletable: PropTypes.bool,
deleted: PropTypes.bool, deleted: PropTypes.bool,
id: PropTypes.number, id: PropTypes.number,
onDelete: PropTypes.func onAddComment: PropTypes.func,
onDelete: PropTypes.func,
projectId: PropTypes.number
}; };
module.exports = Comment; module.exports = Comment;

View file

@ -1,8 +1,26 @@
@import "../../../colors"; @import "../../../colors";
.compose-comment { .compose-comment {
margin-left: .5rem;
width: 100%; 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 { .textarea-row {
width: 100%; width: 100%;
@ -161,6 +179,9 @@
.comment-reply { .comment-reply {
display: inline-flex; display: inline-flex;
cursor: pointer;
color: $ui-blue;
font-weight: bold;
&:after { &:after {
margin-left: .25rem; 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 { .expand-thread {
margin-bottom: 24px; margin-bottom: 24px;
width: 100%; width: 100%;

View file

@ -1,26 +1,203 @@
const React = require('react'); 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 FlexRow = require('../../../components/flex-row/flex-row.jsx');
const Avatar = require('../../../components/avatar/avatar.jsx');
const InplaceInput = require('../../../components/forms/inplace-input.jsx'); const InplaceInput = require('../../../components/forms/inplace-input.jsx');
const Button = require('../../../components/forms/button.jsx'); const Button = require('../../../components/forms/button.jsx');
const connect = require('react-redux').connect;
const api = require('../../../lib/api');
require('./comment.scss'); require('./comment.scss');
const onUpdate = update => update; const onUpdate = update => update;
const ComposeComment = () => ( const MAX_COMMENT_LENGTH = 500;
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 (
<div
className="flex-row comment"
>
<a href={`/users/${this.props.user.username}`}>
<Avatar src={this.props.user.thumbnailUrl} />
</a>
<FlexRow className="compose-comment column"> <FlexRow className="compose-comment column">
{this.state.error ? (
<FlexRow className="compose-error-row">
<div className="compose-error-tip">
{CommentErrorMessages[this.state.error] || 'Unknown error'}
</div>
</FlexRow>
) : null}
<InplaceInput <InplaceInput
className={classNames('compose-input',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ? 'compose-valid' : 'compose-invalid')}
handleUpdate={onUpdate} handleUpdate={onUpdate}
name="compose-comment" name="compose-comment"
type="textarea" type="textarea"
value={this.state.message}
onInput={this.handleInput}
/> />
<FlexRow className="compose-bottom-row"> <FlexRow className="compose-bottom-row">
<Button className="compose-post">Post</Button> <Button
<Button className="compose-cancel">Cancel</Button> className="compose-post"
<span className="compose-limit">500 characters left</span> disabled={this.state.status === ComposeStatus.SUBMITTING}
onClick={this.handlePost}
>
{this.state.status === ComposeStatus.SUBMITTING ? (
'Posting...' /* TODO internationalize */
) : (
'Post' /* TODO internationalize */
)}
</Button>
<Button
className="compose-cancel"
onClick={this.handleCancel}
>
Cancel {/* TODO internationalize */}
</Button>
<span
className={classNames('compose-limit',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ?
'compose-valid' : 'compose-invalid')}
>
{/* TODO internationalize */}
{MAX_COMMENT_LENGTH - this.state.message.length} characters left
</span>
</FlexRow> </FlexRow>
</FlexRow> </FlexRow>
); </div>
);
}
}
module.exports = ComposeComment; 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;

View file

@ -13,7 +13,8 @@ class TopLevelComment extends React.Component {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'handleExpandThread', 'handleExpandThread',
'handleDelete' 'handleAddComment',
'handleDeleteReply'
]); ]);
this.state = { this.state = {
expanded: false expanded: false
@ -26,26 +27,36 @@ class TopLevelComment extends React.Component {
}); });
} }
handleDelete () { handleDeleteReply (commentId) {
this.props.onDelete(this.props.id); // 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 () { render () {
const { const {
author, author,
canReply,
content, content,
datetimeCreated, datetimeCreated,
deletable, deletable,
deleted, deleted,
id, id,
replies onDelete,
replies,
projectId
} = this.props; } = this.props;
return ( return (
<FlexRow className="comment-container"> <FlexRow className="comment-container">
<Comment <Comment
onDelete={this.handleDelete} projectId={projectId}
{...{author, content, datetimeCreated, deletable, deleted, id}} onAddComment={this.handleAddComment}
{...{author, content, datetimeCreated, deletable, deleted, canReply, id, onDelete}}
/> />
{replies.length > 0 && {replies.length > 0 &&
<FlexRow <FlexRow
@ -59,13 +70,16 @@ class TopLevelComment extends React.Component {
{(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => ( {(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => (
<Comment <Comment
author={reply.author} author={reply.author}
canReply={canReply}
content={reply.content} content={reply.content}
datetimeCreated={reply.datetime_created} datetimeCreated={reply.datetime_created}
deletable={deletable} deletable={deletable}
deleted={reply.deleted} deleted={reply.deleted}
id={reply.id} id={reply.id}
key={reply.id} key={reply.id}
onDelete={this.handleDelete} projectId={projectId}
onAddComment={this.handleAddComment}
onDelete={this.handleDeleteReply}
/> />
))} ))}
</FlexRow> </FlexRow>
@ -87,11 +101,13 @@ TopLevelComment.propTypes = {
image: PropTypes.string, image: PropTypes.string,
username: PropTypes.string username: PropTypes.string
}), }),
canReply: PropTypes.bool,
content: PropTypes.string, content: PropTypes.string,
datetimeCreated: PropTypes.string, datetimeCreated: PropTypes.string,
deletable: PropTypes.bool, deletable: PropTypes.bool,
deleted: PropTypes.bool, deleted: PropTypes.bool,
id: PropTypes.number, id: PropTypes.number,
onAddComment: PropTypes.func,
onDelete: PropTypes.func, onDelete: PropTypes.func,
parentId: PropTypes.number, parentId: PropTypes.number,
projectId: PropTypes.string, projectId: PropTypes.string,

View file

@ -21,6 +21,7 @@ const StudioList = require('./studio-list.jsx');
const Subactions = require('./subactions.jsx'); const Subactions = require('./subactions.jsx');
const InplaceInput = require('../../components/forms/inplace-input.jsx'); const InplaceInput = require('../../components/forms/inplace-input.jsx');
const TopLevelComment = require('./comment/top-level-comment.jsx'); const TopLevelComment = require('./comment/top-level-comment.jsx');
const ComposeComment = require('./comment/compose-comment.jsx');
const ExtensionChip = require('./extension-chip.jsx'); const ExtensionChip = require('./extension-chip.jsx');
const projectShape = require('./projectshape.jsx').projectShape; const projectShape = require('./projectshape.jsx').projectShape;
@ -64,6 +65,7 @@ const PreviewPresentation = ({
projectStudios, projectStudios,
studios, studios,
userOwnsProject, userOwnsProject,
onAddComment,
onDeleteComment, onDeleteComment,
onFavoriteClicked, onFavoriteClicked,
onLoadMore, onLoadMore,
@ -317,10 +319,21 @@ const PreviewPresentation = ({
<h4>Comments</h4> <h4>Comments</h4>
{/* TODO: Add toggle comments component and logic*/} {/* TODO: Add toggle comments component and logic*/}
</FlexRow> </FlexRow>
<FlexRow className="comments-root-reply">
{isLoggedIn &&
<ComposeComment
projectId={projectId}
onAddComment={onAddComment}
/>
}
</FlexRow>
<FlexRow className="comments-list"> <FlexRow className="comments-list">
{comments.map(comment => ( {comments.map(comment => (
<TopLevelComment <TopLevelComment
author={comment.author} author={comment.author}
canReply={isLoggedIn}
content={comment.content} content={comment.content}
datetimeCreated={comment.datetime_created} datetimeCreated={comment.datetime_created}
deletable={userOwnsProject} deletable={userOwnsProject}
@ -330,6 +343,7 @@ const PreviewPresentation = ({
parentId={comment.parent_id} parentId={comment.parent_id}
projectId={projectId} projectId={projectId}
replies={replies && replies[comment.id] ? replies[comment.id] : []} replies={replies && replies[comment.id] ? replies[comment.id] : []}
onAddComment={onAddComment}
onDelete={onDeleteComment} onDelete={onDeleteComment}
/> />
))} ))}
@ -374,6 +388,7 @@ PreviewPresentation.propTypes = {
isShared: PropTypes.bool, isShared: PropTypes.bool,
loveCount: PropTypes.number, loveCount: PropTypes.number,
loved: PropTypes.bool, loved: PropTypes.bool,
onAddComment: PropTypes.func,
onAddToStudioClicked: PropTypes.func, onAddToStudioClicked: PropTypes.func,
onAddToStudioClosed: PropTypes.func, onAddToStudioClosed: PropTypes.func,
onDeleteComment: PropTypes.func, onDeleteComment: PropTypes.func,

View file

@ -33,6 +33,7 @@ class Preview extends React.Component {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'addEventListeners', 'addEventListeners',
'handleAddComment',
'handleDeleteComment', 'handleDeleteComment',
'handleToggleStudio', 'handleToggleStudio',
'handleFavoriteToggle', 'handleFavoriteToggle',
@ -166,8 +167,11 @@ class Preview extends React.Component {
}); });
}); });
} }
handleDeleteComment (id) { handleAddComment (comment, topLevelCommentId) {
this.props.handleDeleteComment(this.state.projectId, id, this.props.user.token); this.props.handleAddComment(comment, topLevelCommentId);
}
handleDeleteComment (id, topLevelCommentId) {
this.props.handleDeleteComment(this.state.projectId, id, topLevelCommentId, this.props.user.token);
} }
handleReportClick () { handleReportClick () {
this.setState({reportOpen: true}); this.setState({reportOpen: true});
@ -341,6 +345,7 @@ class Preview extends React.Component {
reportOpen={this.state.reportOpen} reportOpen={this.state.reportOpen}
studios={this.props.studios} studios={this.props.studios}
userOwnsProject={this.props.userOwnsProject} userOwnsProject={this.props.userOwnsProject}
onAddComment={this.handleAddComment}
onAddToStudioClicked={this.handleAddToStudioClick} onAddToStudioClicked={this.handleAddToStudioClick}
onAddToStudioClosed={this.handleAddToStudioClose} onAddToStudioClosed={this.handleAddToStudioClose}
onDeleteComment={this.handleDeleteComment} onDeleteComment={this.handleDeleteComment}
@ -399,6 +404,7 @@ Preview.propTypes = {
getProjectStudios: PropTypes.func.isRequired, getProjectStudios: PropTypes.func.isRequired,
getRemixes: PropTypes.func.isRequired, getRemixes: PropTypes.func.isRequired,
getTopLevelComments: PropTypes.func.isRequired, getTopLevelComments: PropTypes.func.isRequired,
handleAddComment: PropTypes.func,
handleDeleteComment: PropTypes.func, handleDeleteComment: PropTypes.func,
handleLogIn: PropTypes.func, handleLogIn: PropTypes.func,
handleLogOut: PropTypes.func, handleLogOut: PropTypes.func,
@ -526,8 +532,11 @@ const mapStateToProps = state => {
}; };
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
handleDeleteComment: (projectId, commentId, token) => { handleAddComment: (comment, topLevelCommentId) => {
dispatch(previewActions.deleteComment(projectId, commentId, token)); dispatch(previewActions.addNewComment(comment, topLevelCommentId));
},
handleDeleteComment: (projectId, commentId, topLevelCommentId, token) => {
dispatch(previewActions.deleteComment(projectId, commentId, topLevelCommentId, token));
}, },
handleOpenRegistration: event => { handleOpenRegistration: event => {
event.preventDefault(); event.preventDefault();