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 {
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;

View file

@ -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 => {

View file

@ -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
}) => (
<div
className="flex-row comment"
id={`comments-${id}`}
>
<a href={`/users/${author.username}`}>
<Avatar src={author.image} />
</a>
<FlexRow className="comment-body column">
<FlexRow className="comment-top-row">
<a
className="username"
href={`/users/${author.username}`}
>{author.username}</a>
<div className="action-list">
{deletable ? (
<span
className="comment-delete"
onClick={onDelete}
>
Delete {/* TODO internationalize */}
</span>
) : null}
<span className="comment-report">
Report {/* TODO internationalize */}
</span>
</div>
</FlexRow>
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 (
<div
className={classNames({
'comment-bubble': true,
'comment-bubble-deleted': deleted
})}
className="flex-row comment"
id={`comments-${id}`}
>
{/* TODO: at the moment, comment content does not properly display
* emojis/easter eggs
* @user links in replies
* links to scratch.mit.edu pages
*/}
<span className="comment-content">{content}</span>
<FlexRow className="comment-bottom-row">
<span className="comment-time">
<FormattedRelative value={new Date(datetimeCreated)} />
</span>
<a
className="comment-reply"
href={`#comments-${id}`}
<a href={`/users/${author.username}`}>
<Avatar src={author.image} />
</a>
<FlexRow className="comment-body column">
<FlexRow className="comment-top-row">
<a
className="username"
href={`/users/${author.username}`}
>{author.username}</a>
<div className="action-list">
{deletable ? (
<span
className="comment-delete"
onClick={this.handleDelete}
>
Delete {/* TODO internationalize */}
</span>
) : null}
<span className="comment-report">
Report {/* TODO internationalize */}
</span>
</div>
</FlexRow>
<div
className={classNames({
'comment-bubble': true,
'comment-bubble-deleted': deleted
})}
>
reply
</a>
{/* TODO: at the moment, comment content does not properly display
* emojis/easter eggs
* @user links in replies
* links to scratch.mit.edu pages
*/}
<span className="comment-content">{content}</span>
<FlexRow className="comment-bottom-row">
<span className="comment-time">
<FormattedRelative value={new Date(datetimeCreated)} />
</span>
{canReply ? (
<span
className="comment-reply"
onClick={this.handleToggleReplying}
>
reply
</span>
) : null}
</FlexRow>
</div>
{this.state.replying ? (
<FlexRow className="comment-reply-row">
<ComposeComment
parentId={id}
projectId={projectId}
onAddComment={this.handlePostReply}
onCancel={this.handleToggleReplying}
/>
</FlexRow>
) : null}
</FlexRow>
</div>
</FlexRow>
</div>
);
);
}
}
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;

View file

@ -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%;

View file

@ -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 = () => (
<FlexRow className="compose-comment column">
<InplaceInput
handleUpdate={onUpdate}
name="compose-comment"
type="textarea"
/>
<FlexRow className="compose-bottom-row">
<Button className="compose-post">Post</Button>
<Button className="compose-cancel">Cancel</Button>
<span className="compose-limit">500 characters left</span>
</FlexRow>
</FlexRow>
);
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 (
<div
className="flex-row comment"
>
<a href={`/users/${this.props.user.username}`}>
<Avatar src={this.props.user.thumbnailUrl} />
</a>
<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
className={classNames('compose-input',
MAX_COMMENT_LENGTH - this.state.message.length >= 0 ? 'compose-valid' : 'compose-invalid')}
handleUpdate={onUpdate}
name="compose-comment"
type="textarea"
value={this.state.message}
onInput={this.handleInput}
/>
<FlexRow className="compose-bottom-row">
<Button
className="compose-post"
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>
</div>
);
}
}
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);
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 (
<FlexRow className="comment-container">
<Comment
onDelete={this.handleDelete}
{...{author, content, datetimeCreated, deletable, deleted, id}}
projectId={projectId}
onAddComment={this.handleAddComment}
{...{author, content, datetimeCreated, deletable, deleted, canReply, id, onDelete}}
/>
{replies.length > 0 &&
<FlexRow
@ -59,13 +70,16 @@ class TopLevelComment extends React.Component {
{(this.state.expanded ? replies : replies.slice(0, 3)).map(reply => (
<Comment
author={reply.author}
canReply={canReply}
content={reply.content}
datetimeCreated={reply.datetime_created}
deletable={deletable}
deleted={reply.deleted}
id={reply.id}
key={reply.id}
onDelete={this.handleDelete}
projectId={projectId}
onAddComment={this.handleAddComment}
onDelete={this.handleDeleteReply}
/>
))}
</FlexRow>
@ -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,

View file

@ -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 = ({
<h4>Comments</h4>
{/* TODO: Add toggle comments component and logic*/}
</FlexRow>
<FlexRow className="comments-root-reply">
{isLoggedIn &&
<ComposeComment
projectId={projectId}
onAddComment={onAddComment}
/>
}
</FlexRow>
<FlexRow className="comments-list">
{comments.map(comment => (
<TopLevelComment
author={comment.author}
canReply={isLoggedIn}
content={comment.content}
datetimeCreated={comment.datetime_created}
deletable={userOwnsProject}
@ -330,6 +343,7 @@ const PreviewPresentation = ({
parentId={comment.parent_id}
projectId={projectId}
replies={replies && replies[comment.id] ? replies[comment.id] : []}
onAddComment={onAddComment}
onDelete={onDeleteComment}
/>
))}
@ -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,

View file

@ -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();