Merge pull request #194 from grant-project/working-comments
Functioning comments with EIP-712 signatures
This commit is contained in:
commit
062bf2aa7e
|
@ -11,12 +11,17 @@ class Comment(db.Model):
|
|||
date_created = db.Column(db.DateTime)
|
||||
content = db.Column(db.Text, nullable=False)
|
||||
|
||||
parent_comment_id = db.Column(db.Integer, db.ForeignKey("comment.id"), nullable=True)
|
||||
proposal_id = db.Column(db.Integer, db.ForeignKey("proposal.id"), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
|
||||
|
||||
def __init__(self, proposal_id, user_id, content):
|
||||
author = db.relationship("User", back_populates="comments")
|
||||
replies = db.relationship("Comment")
|
||||
|
||||
def __init__(self, proposal_id, user_id, parent_comment_id, content):
|
||||
self.proposal_id = proposal_id
|
||||
self.user_id = user_id
|
||||
self.parent_comment_id = parent_comment_id
|
||||
self.content = content
|
||||
self.date_created = datetime.datetime.now()
|
||||
|
||||
|
@ -26,13 +31,18 @@ class CommentSchema(ma.Schema):
|
|||
model = Comment
|
||||
# Fields to expose
|
||||
fields = (
|
||||
"user_id",
|
||||
"content",
|
||||
"id",
|
||||
"proposal_id",
|
||||
"author",
|
||||
"content",
|
||||
"parent_comment_id",
|
||||
"date_created",
|
||||
"replies"
|
||||
)
|
||||
|
||||
date_created = ma.Method("get_date_created")
|
||||
author = ma.Nested("UserSchema", exclude=["email_address"])
|
||||
replies = ma.Nested("CommentSchema", many=True)
|
||||
|
||||
def get_date_created(self, obj):
|
||||
return dt_to_unix(obj.date_created)
|
||||
|
|
|
@ -1,15 +1,16 @@
|
|||
from dateutil.parser import parse
|
||||
from functools import wraps
|
||||
import ast
|
||||
|
||||
from flask import Blueprint, g
|
||||
from flask_yoloapi import endpoint, parameter
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from grant.comment.models import Comment, comment_schema
|
||||
from grant.comment.models import Comment, comment_schema, comments_schema
|
||||
from grant.milestone.models import Milestone
|
||||
from grant.user.models import User, SocialMedia, Avatar
|
||||
from grant.email.send import send_email
|
||||
from grant.utils.auth import requires_sm, requires_team_member_auth
|
||||
from grant.utils.auth import requires_sm, requires_team_member_auth, verify_signed_auth, BadSignatureException
|
||||
from grant.utils.exceptions import ValidationException
|
||||
from grant.utils.misc import is_email
|
||||
from grant.web3.proposal import read_proposal
|
||||
|
@ -50,37 +51,69 @@ def get_proposal(proposal_id):
|
|||
@endpoint.api()
|
||||
def get_proposal_comments(proposal_id):
|
||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
if proposal:
|
||||
dumped_proposal = proposal_schema.dump(proposal)
|
||||
return {
|
||||
"proposalId": proposal_id,
|
||||
"totalComments": len(dumped_proposal["comments"]),
|
||||
"comments": dumped_proposal["comments"]
|
||||
}
|
||||
else:
|
||||
if not proposal:
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
# Only pull top comments, replies will be attached to them
|
||||
comments = Comment.query.filter_by(proposal_id=proposal_id, parent_comment_id=None)
|
||||
num_comments = Comment.query.filter_by(proposal_id=proposal_id).count()
|
||||
return {
|
||||
"proposalId": proposal_id,
|
||||
"totalComments": num_comments,
|
||||
"comments": comments_schema.dump(comments)
|
||||
}
|
||||
|
||||
|
||||
@blueprint.route("/<proposal_id>/comments", methods=["POST"])
|
||||
@requires_sm
|
||||
@endpoint.api(
|
||||
parameter('content', type=str, required=True)
|
||||
parameter('comment', type=str, required=True),
|
||||
parameter('parentCommentId', type=int, required=False),
|
||||
parameter('signedMessage', type=str, required=True),
|
||||
parameter('rawTypedData', type=str, required=True)
|
||||
)
|
||||
def post_proposal_comments(proposal_id, user_id, content):
|
||||
def post_proposal_comments(proposal_id, comment, parent_comment_id, signed_message, raw_typed_data):
|
||||
# Make sure proposal exists
|
||||
proposal = Proposal.query.filter_by(id=proposal_id).first()
|
||||
if proposal:
|
||||
comment = Comment(
|
||||
proposal_id=proposal_id,
|
||||
user_id=g.current_user.id,
|
||||
content=content
|
||||
)
|
||||
db.session.add(comment)
|
||||
db.session.commit()
|
||||
dumped_comment = comment_schema.dump(comment)
|
||||
return dumped_comment, 201
|
||||
else:
|
||||
if not proposal:
|
||||
return {"message": "No proposal matching id"}, 404
|
||||
|
||||
# Make sure the parent comment exists
|
||||
if parent_comment_id:
|
||||
parent = Comment.query.filter_by(id=parent_comment_id).first()
|
||||
if not parent:
|
||||
return {"message": "Parent comment doesn’t exist"}, 400
|
||||
|
||||
# Make sure comment content matches
|
||||
typed_data = ast.literal_eval(raw_typed_data)
|
||||
if comment != typed_data['message']['comment']:
|
||||
return {"message": "Comment doesn’t match signature data"}, 404
|
||||
|
||||
# Verify the signature
|
||||
try:
|
||||
sig_address = verify_signed_auth(signed_message, raw_typed_data)
|
||||
if sig_address.lower() != g.current_user.account_address.lower():
|
||||
return {
|
||||
"message": "Message signature address ({sig_address}) doesn't match current account address ({account_address})".format(
|
||||
sig_address=sig_address,
|
||||
account_address=g.current_user.account_address
|
||||
)
|
||||
}, 400
|
||||
except BadSignatureException:
|
||||
return {"message": "Invalid message signature"}, 400
|
||||
|
||||
# Make the comment
|
||||
comment = Comment(
|
||||
proposal_id=proposal_id,
|
||||
user_id=g.current_user.id,
|
||||
parent_comment_id=parent_comment_id,
|
||||
content=comment
|
||||
)
|
||||
db.session.add(comment)
|
||||
db.session.commit()
|
||||
dumped_comment = comment_schema.dump(comment)
|
||||
return dumped_comment, 201
|
||||
|
||||
|
||||
@blueprint.route("/", methods=["GET"])
|
||||
@endpoint.api(
|
||||
|
|
|
@ -74,7 +74,7 @@ class User(db.Model):
|
|||
db.session.add(ev)
|
||||
db.session.commit()
|
||||
|
||||
if send_email:
|
||||
if _send_email:
|
||||
send_email(user.email_address, 'signup', {
|
||||
'display_name': user.display_name,
|
||||
'confirm_url': make_url(f'/email/verify?code={ev.code}')
|
||||
|
@ -104,7 +104,6 @@ class UserSchema(ma.Schema):
|
|||
"avatar",
|
||||
"display_name",
|
||||
"userid"
|
||||
|
||||
)
|
||||
|
||||
social_medias = ma.Nested("SocialMediaSchema", many=True)
|
||||
|
@ -114,11 +113,9 @@ class UserSchema(ma.Schema):
|
|||
def get_userid(self, obj):
|
||||
return obj.id
|
||||
|
||||
|
||||
user_schema = UserSchema()
|
||||
users_schema = UserSchema(many=True)
|
||||
|
||||
|
||||
class SocialMediaSchema(ma.Schema):
|
||||
class Meta:
|
||||
model = SocialMedia
|
||||
|
|
|
@ -92,7 +92,7 @@ def create_user(
|
|||
title=title
|
||||
)
|
||||
result = user_schema.dump(user)
|
||||
return result
|
||||
return result, 201
|
||||
|
||||
|
||||
@blueprint.route("/auth", methods=["POST"])
|
||||
|
|
|
@ -11,7 +11,6 @@ import sentry_sdk
|
|||
from grant.settings import SECRET_KEY, AUTH_URL
|
||||
from ..proposal.models import Proposal
|
||||
from ..user.models import User
|
||||
from ..proposal.models import Proposal
|
||||
|
||||
TWO_WEEKS = 1209600
|
||||
|
||||
|
@ -41,6 +40,9 @@ class BadSignatureException(Exception):
|
|||
|
||||
def verify_signed_auth(signature, typed_data):
|
||||
loaded_typed_data = ast.literal_eval(typed_data)
|
||||
if loaded_typed_data['domain']['name'] != 'Grant.io':
|
||||
raise BadSignatureException("Signature is not for Grant.io")
|
||||
|
||||
url = AUTH_URL + "/message/recover"
|
||||
payload = json.dumps({"sig": signature, "data": loaded_typed_data})
|
||||
headers = {'content-type': 'application/json'}
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: 3699cb98fc2a
|
||||
Revises: e1e8573b7298
|
||||
Create Date: 2018-11-08 12:33:14.995080
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '3699cb98fc2a'
|
||||
down_revision = 'e1e8573b7298'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('comment', sa.Column('parent_comment_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'comment', 'comment', ['parent_comment_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'comment', type_='foreignkey')
|
||||
op.drop_column('comment', 'parent_comment_id')
|
||||
# ### end Alembic commands ###
|
|
@ -62,4 +62,4 @@ flask-web3==0.1.1
|
|||
web3==4.8.1
|
||||
|
||||
#sentry
|
||||
sentry-sdk[flask]==0.5.5
|
||||
sentry-sdk[flask]==0.5.5
|
||||
|
|
|
@ -20,7 +20,17 @@ class BaseTestConfig(TestCase):
|
|||
def tearDown(self):
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
def assertStatus(self, response, status_code, message=None):
|
||||
"""
|
||||
Overrides TestCase's default to print out response JSON.
|
||||
"""
|
||||
|
||||
message = message or 'HTTP Status %s expected but got %s. Response json: %s' \
|
||||
% (status_code, response.status_code, response.json)
|
||||
self.assertEqual(response.status_code, status_code, message)
|
||||
|
||||
assert_status = assertStatus
|
||||
|
||||
class BaseUserConfig(BaseTestConfig):
|
||||
headers = {
|
||||
|
|
|
@ -4,48 +4,49 @@ import random
|
|||
from grant.proposal.models import CATEGORIES
|
||||
|
||||
message = {
|
||||
"sig": "0x4355c47d63924e8a72e509b65029052eb6c299d53a04e167c5775fd466751c9d07299936d304c153f6443dfa05f40ff007d72911b6f72307f996231605b915621c",
|
||||
"sig": "0x7b3a85e9f158c2ae2a9ffba986a7dcb9108cf8ea9691080f80eadb506719f14925c89777aade3fabc5f9730ea389abdf7ffb0da16babdf1a1ea710b1e998cb891c",
|
||||
"data": {
|
||||
"types": {
|
||||
"EIP712Domain": [
|
||||
{"name": "name", "type": "string"},
|
||||
{"name": "version", "type": "string"},
|
||||
{"name": "chainId", "type": "uint256"},
|
||||
{"name": "verifyingContract", "type": "address"}
|
||||
],
|
||||
"Person": [
|
||||
{"name": "name", "type": "string"},
|
||||
{"name": "wallet", "type": "address"}
|
||||
],
|
||||
"Mail": [
|
||||
{"name": "from", "type": "Person"},
|
||||
{"name": "to", "type": "Person"},
|
||||
{"name": "contents", "type": "string"}
|
||||
]
|
||||
},
|
||||
"primaryType": "Mail",
|
||||
"domain": {
|
||||
"name": "Ether Mail",
|
||||
"version": "1",
|
||||
"chainId": 1,
|
||||
"verifyingContract": "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
|
||||
"name": "Grant.io",
|
||||
"version": 1,
|
||||
"chainId": 1543277948575
|
||||
},
|
||||
"message": {
|
||||
"from": {
|
||||
"name": "Cow",
|
||||
"wallet": "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826"
|
||||
"types": {
|
||||
"authorization": [
|
||||
{
|
||||
"name": "Message Proof",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"name": "Time",
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"EIP712Domain": [
|
||||
{
|
||||
"name": "name",
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"name": "Bob",
|
||||
"wallet": "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB"
|
||||
{
|
||||
"name": "version",
|
||||
"type": "string"
|
||||
},
|
||||
"contents": "Hello, Bob!"
|
||||
}
|
||||
}
|
||||
{
|
||||
"name": "chainId",
|
||||
"type": "uint256"
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"message": "I am proving the identity of 0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa on Grant.io",
|
||||
"time": "Tue, 27 Nov 2018 19:02:04 GMT"
|
||||
},
|
||||
"primaryType": "authorization"
|
||||
}
|
||||
}
|
||||
|
||||
test_user = {
|
||||
"accountAddress": '0xcd2a3d9f938e13cd947ec05abc7fe734df8dd826',
|
||||
"accountAddress": '0x6bEeA1Cef016c23e292381b6FcaeC092960e41aa',
|
||||
"displayName": 'Groot',
|
||||
"emailAddress": 'iam@groot.com',
|
||||
"title": 'I am Groot!',
|
||||
|
|
|
@ -18,11 +18,12 @@ class TestAPI(BaseUserConfig):
|
|||
db.session.delete(self.user)
|
||||
db.session.commit()
|
||||
|
||||
self.app.post(
|
||||
response = self.app.post(
|
||||
"/api/v1/users/",
|
||||
data=json.dumps(test_user),
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assertStatus(response, 201)
|
||||
|
||||
# User
|
||||
user_db = User.get_by_identifier(account_address=test_user["accountAddress"])
|
||||
|
@ -34,6 +35,7 @@ class TestAPI(BaseUserConfig):
|
|||
users_get_resp = self.app.get(
|
||||
"/api/v1/users/"
|
||||
)
|
||||
self.assert200(users_get_resp)
|
||||
users_json = users_get_resp.json
|
||||
self.assertEqual(users_json[0]["displayName"], self.user.display_name)
|
||||
|
||||
|
@ -83,7 +85,7 @@ class TestAPI(BaseUserConfig):
|
|||
headers=self.headers,
|
||||
content_type='application/json'
|
||||
)
|
||||
self.assert200(user_update_resp)
|
||||
self.assert200(user_update_resp, user_update_resp.json)
|
||||
|
||||
user_json = user_update_resp.json
|
||||
self.assertFalse(user_json["avatar"])
|
||||
|
|
|
@ -161,3 +161,14 @@ export function postProposalContribution(
|
|||
amount,
|
||||
});
|
||||
}
|
||||
|
||||
export function postProposalComment(payload: {
|
||||
proposalId: number;
|
||||
parentCommentId?: number;
|
||||
comment: string;
|
||||
signedMessage: string;
|
||||
rawTypedData: string;
|
||||
}): Promise<{ data: any }> {
|
||||
const { proposalId, ...args } = payload;
|
||||
return axios.post(`/api/v1/proposals/${proposalId}/comments`, args);
|
||||
}
|
||||
|
|
|
@ -2,22 +2,24 @@ import React from 'react';
|
|||
import { connect } from 'react-redux';
|
||||
import moment from 'moment';
|
||||
import { Button } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import Markdown from 'components/Markdown';
|
||||
import Identicon from 'components/Identicon';
|
||||
import UserAvatar from 'components/UserAvatar';
|
||||
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
|
||||
import { postProposalComment } from 'modules/proposals/actions';
|
||||
import { Comment as IComment, Proposal } from 'types';
|
||||
import { getIsSignedIn } from 'modules/auth/selectors';
|
||||
import { Comment as IComment } from 'types';
|
||||
import { AppState } from 'store/reducers';
|
||||
import './style.less';
|
||||
|
||||
interface OwnProps {
|
||||
comment: IComment;
|
||||
proposalId: Proposal['proposalId'];
|
||||
}
|
||||
|
||||
interface StateProps {
|
||||
isPostCommentPending: AppState['proposal']['isPostCommentPending'];
|
||||
postCommentError: AppState['proposal']['postCommentError'];
|
||||
isSignedIn: ReturnType<typeof getIsSignedIn>;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
|
@ -46,29 +48,37 @@ class Comment extends React.Component<Props> {
|
|||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { comment, proposalId } = this.props;
|
||||
const { comment, isSignedIn, isPostCommentPending } = this.props;
|
||||
const { isReplying, reply } = this.state;
|
||||
const authorPath = `/profile/${comment.author.accountAddress}`;
|
||||
return (
|
||||
<div className="Comment">
|
||||
<div className="Comment-info">
|
||||
<div className="Comment-info-thumb">
|
||||
<Identicon address={comment.author.accountAddress} />
|
||||
<Link to={authorPath}>
|
||||
<div className="Comment-info-thumb">
|
||||
<UserAvatar user={comment.author} />
|
||||
</div>
|
||||
</Link>
|
||||
<Link to={authorPath}>
|
||||
<div className="Comment-info-name">{comment.author.displayName}</div>
|
||||
</Link>
|
||||
<div className="Comment-info-time">
|
||||
{moment.unix(comment.dateCreated).fromNow()}
|
||||
</div>
|
||||
{/* <div className="Comment-info-thumb" src={comment.author.avatar['120x120']} /> */}
|
||||
<div className="Comment-info-name">{comment.author.displayName}</div>
|
||||
<div className="Comment-info-time">{moment(comment.dateCreated).fromNow()}</div>
|
||||
</div>
|
||||
|
||||
<div className="Comment-body">
|
||||
<Markdown source={comment.content} type={MARKDOWN_TYPE.REDUCED} />
|
||||
</div>
|
||||
|
||||
<div className="Comment-controls">
|
||||
<a className="Comment-controls-button" onClick={this.toggleReply}>
|
||||
{isReplying ? 'Cancel' : 'Reply'}
|
||||
</a>
|
||||
{/*<a className="Comment-controls-button">Report</a>*/}
|
||||
</div>
|
||||
{isSignedIn && (
|
||||
<div className="Comment-controls">
|
||||
<a className="Comment-controls-button" onClick={this.toggleReply}>
|
||||
{isReplying ? 'Cancel' : 'Reply'}
|
||||
</a>
|
||||
{/*<a className="Comment-controls-button">Report</a>*/}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(comment.replies.length || isReplying) && (
|
||||
<div className="Comment-replies">
|
||||
|
@ -79,17 +89,17 @@ class Comment extends React.Component<Props> {
|
|||
type={MARKDOWN_TYPE.REDUCED}
|
||||
/>
|
||||
<div style={{ marginTop: '0.5rem' }} />
|
||||
<Button onClick={this.reply} disabled={!reply.length}>
|
||||
<Button
|
||||
onClick={this.reply}
|
||||
disabled={!reply.length}
|
||||
loading={isPostCommentPending}
|
||||
>
|
||||
Submit reply
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{comment.replies.map(subComment => (
|
||||
<ConnectedComment
|
||||
key={subComment.commentId}
|
||||
comment={subComment}
|
||||
proposalId={proposalId}
|
||||
/>
|
||||
<ConnectedComment key={subComment.id} comment={subComment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
@ -106,9 +116,9 @@ class Comment extends React.Component<Props> {
|
|||
};
|
||||
|
||||
private reply = () => {
|
||||
const { comment, proposalId } = this.props;
|
||||
const { comment } = this.props;
|
||||
const { reply } = this.state;
|
||||
this.props.postProposalComment(proposalId, reply, comment.commentId);
|
||||
this.props.postProposalComment(comment.proposalId, reply, comment.id);
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -116,6 +126,7 @@ const ConnectedComment = connect<StateProps, DispatchProps, OwnProps, AppState>(
|
|||
(state: AppState) => ({
|
||||
isPostCommentPending: state.proposal.isPostCommentPending,
|
||||
postCommentError: state.proposal.postCommentError,
|
||||
isSignedIn: getIsSignedIn(state),
|
||||
}),
|
||||
{
|
||||
postProposalComment,
|
||||
|
|
|
@ -13,6 +13,10 @@
|
|||
line-height: @info-height;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&-thumb {
|
||||
display: block;
|
||||
margin-right: 0.5rem;
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
import React from 'react';
|
||||
import { Proposal, ProposalComments } from 'types';
|
||||
import { ProposalComments } from 'types';
|
||||
import Comment from 'components/Comment';
|
||||
|
||||
interface Props {
|
||||
comments: ProposalComments['comments'];
|
||||
proposalId: Proposal['proposalId'];
|
||||
}
|
||||
|
||||
const Comments = ({ comments, proposalId }: Props) => (
|
||||
const Comments = ({ comments }: Props) => (
|
||||
<React.Fragment>
|
||||
{comments.map(c => (
|
||||
<Comment key={c.commentId} comment={c} proposalId={proposalId} />
|
||||
<Comment key={c.id} comment={c} />
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
|
|
|
@ -39,31 +39,30 @@ interface Props {
|
|||
}
|
||||
|
||||
interface State {
|
||||
randomKey: string;
|
||||
mdeState: ReactMdeTypes.MdeState | null;
|
||||
}
|
||||
|
||||
export default class MarkdownEditor extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
mdeState: null,
|
||||
};
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
const mdeState = props.initialMarkdown ? { markdown: props.initialMarkdown } : null;
|
||||
this.state = { mdeState };
|
||||
this.state = {
|
||||
mdeState,
|
||||
randomKey: Math.random().toString(),
|
||||
};
|
||||
}
|
||||
|
||||
handleChange = (mdeState: ReactMdeTypes.MdeState) => {
|
||||
this.setState({ mdeState });
|
||||
this.props.onChange(mdeState.markdown || '');
|
||||
};
|
||||
|
||||
generatePreview = (md: string) => {
|
||||
return Promise.resolve(convert(md, this.props.type));
|
||||
};
|
||||
reset() {
|
||||
this.setState({
|
||||
randomKey: Math.random().toString(),
|
||||
mdeState: null,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const type = this.props.type || MARKDOWN_TYPE.FULL;
|
||||
const { mdeState, randomKey } = this.state;
|
||||
return (
|
||||
<div
|
||||
className={classnames({
|
||||
|
@ -72,8 +71,9 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
|
|||
})}
|
||||
>
|
||||
<ReactMde
|
||||
key={randomKey}
|
||||
onChange={this.handleChange}
|
||||
editorState={this.state.mdeState as ReactMdeTypes.MdeState}
|
||||
editorState={mdeState as ReactMdeTypes.MdeState}
|
||||
generateMarkdownPreview={this.generatePreview}
|
||||
commands={commands[type]}
|
||||
layout="tabbed"
|
||||
|
@ -81,6 +81,15 @@ export default class MarkdownEditor extends React.PureComponent<Props, State> {
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private handleChange = (mdeState: ReactMdeTypes.MdeState) => {
|
||||
this.setState({ mdeState });
|
||||
this.props.onChange(mdeState.markdown || '');
|
||||
};
|
||||
|
||||
private generatePreview = (md: string) => {
|
||||
return Promise.resolve(convert(md, this.props.type));
|
||||
};
|
||||
}
|
||||
|
||||
export { MARKDOWN_TYPE } from 'utils/markdown';
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { Spin, Button } from 'antd';
|
||||
import { Spin, Button, message } from 'antd';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { ProposalWithCrowdFund } from 'types';
|
||||
import { fetchProposalComments, postProposalComment } from 'modules/proposals/actions';
|
||||
|
@ -9,6 +9,7 @@ import {
|
|||
getIsFetchingComments,
|
||||
getCommentsError,
|
||||
} from 'modules/proposals/selectors';
|
||||
import { getIsSignedIn } from 'modules/auth/selectors';
|
||||
import Comments from 'components/Comments';
|
||||
import Placeholder from 'components/Placeholder';
|
||||
import MarkdownEditor, { MARKDOWN_TYPE } from 'components/MarkdownEditor';
|
||||
|
@ -22,6 +23,9 @@ interface StateProps {
|
|||
comments: ReturnType<typeof getProposalComments>;
|
||||
isFetchingComments: ReturnType<typeof getIsFetchingComments>;
|
||||
commentsError: ReturnType<typeof getCommentsError>;
|
||||
isPostCommentPending: AppState['proposal']['isPostCommentPending'];
|
||||
postCommentError: AppState['proposal']['postCommentError'];
|
||||
isSignedIn: ReturnType<typeof getIsSignedIn>;
|
||||
}
|
||||
|
||||
interface DispatchProps {
|
||||
|
@ -40,6 +44,8 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
comment: '',
|
||||
};
|
||||
|
||||
private editor: MarkdownEditor | null = null;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.proposalId) {
|
||||
this.props.fetchProposalComments(this.props.proposalId);
|
||||
|
@ -52,8 +58,27 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
// TODO: Come up with better check on if our comment post was a success
|
||||
const { isPostCommentPending, postCommentError } = this.props;
|
||||
if (!isPostCommentPending && !postCommentError && prevProps.isPostCommentPending) {
|
||||
this.setState({ comment: '' });
|
||||
this.editor!.reset();
|
||||
}
|
||||
|
||||
if (postCommentError && postCommentError !== prevProps.postCommentError) {
|
||||
message.error('Failed to submit comment');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { proposalId, comments, isFetchingComments, commentsError } = this.props;
|
||||
const {
|
||||
comments,
|
||||
isFetchingComments,
|
||||
commentsError,
|
||||
isPostCommentPending,
|
||||
isSignedIn,
|
||||
} = this.props;
|
||||
const { comment } = this.state;
|
||||
let content = null;
|
||||
|
||||
|
@ -68,7 +93,7 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
);
|
||||
} else if (comments) {
|
||||
if (comments.length) {
|
||||
content = <Comments comments={comments} proposalId={proposalId} />;
|
||||
content = <Comments comments={comments} />;
|
||||
} else {
|
||||
content = (
|
||||
<Placeholder
|
||||
|
@ -81,16 +106,23 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="ProposalComments-post">
|
||||
<MarkdownEditor
|
||||
onChange={this.handleCommentChange}
|
||||
type={MARKDOWN_TYPE.REDUCED}
|
||||
/>
|
||||
<div style={{ marginTop: '0.5rem' }} />
|
||||
<Button onClick={this.postComment} disabled={!comment.length}>
|
||||
Submit comment
|
||||
</Button>
|
||||
</div>
|
||||
{isSignedIn && (
|
||||
<div className="ProposalComments-post">
|
||||
<MarkdownEditor
|
||||
ref={el => (this.editor = el)}
|
||||
onChange={this.handleCommentChange}
|
||||
type={MARKDOWN_TYPE.REDUCED}
|
||||
/>
|
||||
<div style={{ marginTop: '0.5rem' }} />
|
||||
<Button
|
||||
onClick={this.postComment}
|
||||
disabled={!comment.length}
|
||||
loading={isPostCommentPending}
|
||||
>
|
||||
Submit comment
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
|
@ -105,11 +137,14 @@ class ProposalComments extends React.Component<Props, State> {
|
|||
};
|
||||
}
|
||||
|
||||
export default connect(
|
||||
(state: AppState, ownProps: OwnProps) => ({
|
||||
export default connect<StateProps, DispatchProps, OwnProps, AppState>(
|
||||
(state, ownProps) => ({
|
||||
comments: getProposalComments(state, ownProps.proposalId),
|
||||
isFetchingComments: getIsFetchingComments(state),
|
||||
commentsError: getCommentsError(state),
|
||||
isPostCommentPending: state.proposal.isPostCommentPending,
|
||||
postCommentError: state.proposal.postCommentError,
|
||||
isSignedIn: getIsSignedIn(state),
|
||||
}),
|
||||
{
|
||||
fetchProposalComments,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { AppState as S } from 'store/reducers';
|
||||
|
||||
export const getIsSignedIn = (s: S) => !!s.auth.user;
|
||||
export const getAuthSignature = (s: S) => s.auth.authSignature;
|
||||
export const getAuthSignatureAddress = (s: S) => s.auth.authSignatureAddress;
|
||||
|
|
|
@ -5,9 +5,10 @@ import {
|
|||
getProposalComments,
|
||||
getProposalUpdates,
|
||||
postProposalContribution as apiPostProposalContribution,
|
||||
postProposalComment as apiPostProposalComment,
|
||||
} from 'api/api';
|
||||
import { Dispatch } from 'redux';
|
||||
import { ProposalWithCrowdFund, Comment } from 'types';
|
||||
import { ProposalWithCrowdFund, Comment, AuthSignatureData } from 'types';
|
||||
import { signData } from 'modules/web3/actions';
|
||||
|
||||
export type TFetchProposals = typeof fetchProposals;
|
||||
|
@ -58,46 +59,41 @@ export function fetchProposalUpdates(proposalId: ProposalWithCrowdFund['proposal
|
|||
export function postProposalComment(
|
||||
proposalId: ProposalWithCrowdFund['proposalId'],
|
||||
comment: string,
|
||||
parentCommentId?: Comment['commentId'],
|
||||
parentCommentId?: Comment['id'],
|
||||
) {
|
||||
return async (dispatch: Dispatch<any>) => {
|
||||
dispatch({ type: types.POST_PROPOSAL_COMMENT_PENDING });
|
||||
|
||||
try {
|
||||
const signedComment = await dispatch(
|
||||
const sigData: AuthSignatureData = (await dispatch(
|
||||
signData(
|
||||
{ comment },
|
||||
{
|
||||
comment: {
|
||||
name: 'Comment',
|
||||
type: 'string',
|
||||
},
|
||||
comment: [
|
||||
{
|
||||
name: 'Comment',
|
||||
type: 'string',
|
||||
},
|
||||
],
|
||||
},
|
||||
'comment',
|
||||
),
|
||||
);
|
||||
)) as any;
|
||||
|
||||
const res = await apiPostProposalComment({
|
||||
proposalId,
|
||||
parentCommentId,
|
||||
comment,
|
||||
signedMessage: sigData.signedMessage,
|
||||
rawTypedData: JSON.stringify(sigData.rawTypedData),
|
||||
});
|
||||
|
||||
// TODO: API up the comment & signed comment, handle response / failures
|
||||
// TODO: Remove console log
|
||||
console.log(signedComment);
|
||||
dispatch({
|
||||
type: types.POST_PROPOSAL_COMMENT_FULFILLED,
|
||||
payload: {
|
||||
proposalId,
|
||||
parentCommentId,
|
||||
comment: {
|
||||
commentId: Math.random(),
|
||||
content: comment,
|
||||
dateCreated: Date.now(),
|
||||
replies: [],
|
||||
author: {
|
||||
accountAddress: '0x0',
|
||||
userid: 'test',
|
||||
username: 'test',
|
||||
title: 'test',
|
||||
avatar: { '120x120': 'test' },
|
||||
},
|
||||
},
|
||||
comment: res.data,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
@ -92,7 +92,7 @@ function addUpdates(state: ProposalState, payload: ProposalUpdates) {
|
|||
interface PostCommentPayload {
|
||||
proposalId: ProposalWithCrowdFund['proposalId'];
|
||||
comment: Comment;
|
||||
parentCommentId?: Comment['commentId'];
|
||||
parentCommentId?: Comment['id'];
|
||||
}
|
||||
function addPostedComment(state: ProposalState, payload: PostCommentPayload) {
|
||||
const { proposalId, comment, parentCommentId } = payload;
|
||||
|
|
|
@ -10,11 +10,11 @@ export async function sleep(ms: number) {
|
|||
}
|
||||
|
||||
export function findComment(
|
||||
commentId: Comment['commentId'],
|
||||
commentId: Comment['id'],
|
||||
comments: Comment[],
|
||||
): Comment | null {
|
||||
for (const comment of comments) {
|
||||
if (comment.commentId === commentId) {
|
||||
if (comment.id === commentId) {
|
||||
return comment;
|
||||
} else if (comment.replies.length) {
|
||||
const foundComment = findComment(commentId, comment.replies);
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { User, UserProposal } from 'types';
|
||||
import { UserProposal, User } from 'types';
|
||||
|
||||
export interface Comment {
|
||||
commentId: number | string;
|
||||
id: number;
|
||||
proposalId: number;
|
||||
content: string;
|
||||
dateCreated: number;
|
||||
author: User;
|
||||
|
|
Loading…
Reference in New Issue