Merge pull request #194 from grant-project/working-comments

Functioning comments with EIP-712 signatures
This commit is contained in:
William O'Beirne 2018-11-27 14:43:00 -05:00 committed by GitHub
commit 062bf2aa7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 306 additions and 154 deletions

View File

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

View File

@ -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 doesnt exist"}, 400
# Make sure comment content matches
typed_data = ast.literal_eval(raw_typed_data)
if comment != typed_data['message']['comment']:
return {"message": "Comment doesnt 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(

View File

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

View File

@ -92,7 +92,7 @@ def create_user(
title=title
)
result = user_schema.dump(user)
return result
return result, 201
@blueprint.route("/auth", methods=["POST"])

View File

@ -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'}

View File

@ -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 ###

View File

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

View File

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

View File

@ -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!',

View File

@ -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"])

View File

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

View File

@ -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,

View File

@ -13,6 +13,10 @@
line-height: @info-height;
margin-bottom: 1rem;
a {
color: inherit;
}
&-thumb {
display: block;
margin-right: 0.5rem;

View File

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

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

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

View File

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