Skip to content

Commit

Permalink
comments: Add proposal author updates
Browse files Browse the repository at this point in the history
- Allow proposal authors to give periodic updates on the status of 
their proposal.
- Once a proposal vote has finished, all existing comment threads 
are locked.
- The author is the only user that will have the ability to start
a new comment thread once the voting period has finished.
- Anyone can reply to any comments in the thread and can cast 
upvotes/downvotes for any comments in the thread.
- The comment thread will remain open until either the author starts a
new update thread or an admin marks the proposal as closed/completed.
- Show each update as its own comment section from newest to oldest.
- Normalize the comments redux state to improve performance.
  • Loading branch information
amass01 authored Sep 20, 2021
1 parent 29b5e4c commit 0c1f08e
Show file tree
Hide file tree
Showing 43 changed files with 1,351 additions and 665 deletions.
91 changes: 71 additions & 20 deletions src/actions/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ import {
ARCHIVED,
PROPOSAL_METADATA_FILENAME,
VOTE_METADATA_FILENAME,
PROPOSAL_STATE_UNVETTED
} from "../constants";
PROPOSAL_STATE_UNVETTED,
PROPOSAL_UPDATE_HINT,
PROPOSAL_MAIN_THREAD_KEY
} from "src/constants";
import {
parseReceivedProposalsMap,
parseRawProposal,
parseRawProposalsBatch
} from "src/helpers";
parseRawProposalsBatch,
shortRecordToken
} from "../helpers";

export const onResetNewUser = act.RESET_NEW_USER;

Expand Down Expand Up @@ -1021,34 +1024,57 @@ export const onSubmitEditedInvoice = (
});
});

export const onCommentVote = (currentUserID, token, commentid, vote, state) =>
export const onCommentVote = (
currentUserID,
token,
commentid,
vote,
state,
sectionId
) =>
withCsrf((dispatch, _, csrf) => {
if (!currentUserID) {
return;
}
dispatch(act.RECEIVE_LIKE_COMMENT({ token, commentid, vote }));
dispatch(act.REQUEST_LIKE_COMMENT({ commentid, token, vote, sectionId }));
return Promise.resolve(api.makeCommentVote(state, token, vote, commentid))
.then((comment) => api.signCommentVote(currentUserID, comment))
.then((comment) => api.commentVote(csrf, comment))
.then(() => {
dispatch(act.RECEIVE_LIKE_COMMENT_SUCCESS({ token, commentid, vote }));
dispatch(
act.RECEIVE_LIKE_COMMENT_SUCCESS({
token,
commentid,
vote,
sectionId
})
);
})
.catch((error) => {
dispatch(act.RECEIVE_LIKE_COMMENT(null, error));
});
});

export const onCensorComment = (userid, token, commentid, state, reason) => {
export const onCensorComment = (
userid,
token,
commentid,
state,
sectionId,
reason
) => {
return withCsrf((dispatch, _, csrf) => {
dispatch(act.REQUEST_CENSOR_COMMENT({ commentid, token, state }));
dispatch(
act.REQUEST_CENSOR_COMMENT({ commentid, token, state, sectionId })
);
return Promise.resolve(
api.makeCensoredComment(state, token, reason, commentid)
)
.then((comment) => api.signCensorComment(userid, comment))
.then((comment) => api.censorComment(csrf, comment))
.then(({ comment: { receipt, commentid, token } }) => {
if (receipt) {
dispatch(act.RECEIVE_CENSOR_COMMENT({ commentid, token }, null));
dispatch(act.RECEIVE_CENSOR_COMMENT({ commentid, token, sectionId }));
}
})
.catch((error) => {
Expand All @@ -1063,16 +1089,30 @@ export const onSubmitComment = (
token,
commentText,
parentid,
state
state,
extraData,
extraDataHint,
sectionId
) =>
withCsrf((dispatch, getState, csrf) => {
const comment = api.makeComment(token, commentText, parentid, state);
const comment = api.makeComment(
token,
commentText,
parentid,
state,
extraData,
extraDataHint
);
dispatch(act.REQUEST_NEW_COMMENT(comment));
return Promise.resolve(api.signComment(currentUserID, comment))
.then((comment) => {
// make sure this is not a duplicate comment by comparing to the existent
// comments signatures
const comments = sel.commentsByToken(getState())[token];
// make sure this is not a duplicate comment by comparing to the
// existent comments signatures.
const shortToken = shortRecordToken(token);
const { comments: commentsMap } =
sel.commentsInfoByToken(getState())[shortToken] || {};
// Flatten all comments in one array.
const comments = commentsMap && Object.values(commentsMap).flat();
const signatureFound =
comments && comments.find((cm) => cm.signature === comment.signature);
if (signatureFound) {
Expand All @@ -1082,8 +1122,19 @@ export const onSubmitComment = (
})
.then((comment) => api.newComment(csrf, comment))
.then((response) => {
const responsecomment = response.comment;
return dispatch(act.RECEIVE_NEW_COMMENT(responsecomment));
const responsecomment = response.comment || {};
const { commentid } = responsecomment;
const isAuthorUpdate = extraDataHint === PROPOSAL_UPDATE_HINT;
return dispatch(
act.RECEIVE_NEW_COMMENT({
...responsecomment,
sectionId: sectionId
? sectionId
: isAuthorUpdate
? commentid
: PROPOSAL_MAIN_THREAD_KEY
})
);
})
.catch((error) => {
dispatch(act.RECEIVE_NEW_COMMENT(null, error));
Expand Down Expand Up @@ -1694,9 +1745,9 @@ export const onSubmitDccComment = (currentUserID, token, comment, parentid) =>
return Promise.resolve(api.makeDccComment(token, comment, parentid))
.then((comment) => api.signDccComment(currentUserID, comment))
.then((comment) => {
// make sure this is not a duplicate comment by comparing to the existent
// comments signatures
const comments = sel.commentsByToken(getState())[token];
// make sure this is not a duplicate comment by comparing to the
// existent comments signatures.
const comments = sel.commentsInfoByToken(getState())[token].comments;
const signatureFound =
comments && comments.find((cm) => cm.signature === comment.signature);
if (signatureFound) {
Expand Down
13 changes: 11 additions & 2 deletions src/actions/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,11 +143,20 @@ export const onEditProposal =
};

export const onSaveNewComment =
({ comment, token, parentID, state }) =>
({ comment, token, parentID, state, extraData, extraDataHint, sectionId }) =>
(dispatch, getState) => {
const userid = sel.currentUserID(getState());
return dispatch(
onSubmitCommentApi(userid, token, comment, parentID, state)
onSubmitCommentApi(
userid,
token,
comment,
parentID,
state,
extraData,
extraDataHint,
sectionId
)
);
};

Expand Down
10 changes: 6 additions & 4 deletions src/actions/tests/api.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -622,7 +622,8 @@ describe("test api actions (actions/api.js)", () => {
const keys = await pki.generateKeys(FAKE_USER.id);
await pki.loadKeys(FAKE_USER.id, keys);

// this needs a custom assertion for success response as the common one doesn't work for this case
// this needs a custom assertion for success response as the common
// one doesn't work for this case.
setPostSuccessResponse(path);
const store = getMockedStore();
await store.dispatch(api.onSubmitComment.apply(null, params));
Expand Down Expand Up @@ -659,12 +660,13 @@ describe("test api actions (actions/api.js)", () => {
//const down_action = -1;
const params = [FAKE_USER.id, FAKE_PROPOSAL_TOKEN, commentid, up_action];

// this needs a custom assertion for success response as the common one doesn't work for this case
// this needs a custom assertion for success response as the common one
// doesn't work for this case.
setPostSuccessResponse(path);
const store = getMockedStore();
await store.dispatch(api.onCommentVote.apply(null, params));
const dispatchedActions = store.getActions();
expect(dispatchedActions[0].type).toEqual(act.RECEIVE_LIKE_COMMENT);
expect(dispatchedActions[0].type).toEqual(act.REQUEST_LIKE_COMMENT);
expect(dispatchedActions[1].type).toEqual(act.RECEIVE_LIKE_COMMENT_SUCCESS);

const keys = await pki.generateKeys(FAKE_USER.id);
Expand All @@ -678,7 +680,7 @@ describe("test api actions (actions/api.js)", () => {
{
error: false,
payload: { commentid, token: FAKE_PROPOSAL_TOKEN, vote: up_action },
type: act.RECEIVE_LIKE_COMMENT
type: act.REQUEST_LIKE_COMMENT
},
{
error: true,
Expand Down
126 changes: 103 additions & 23 deletions src/components/CommentForm/CommentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,23 @@ import React from "react";
import PropTypes from "prop-types";
import { Formik } from "formik";
import FormikPersist from "src/components/FormikPersist";
import { Button, Message } from "pi-ui";
import {
H4,
Button,
Message,
BoxTextInput,
Tooltip,
Icon,
useMediaQuery,
Text
} from "pi-ui";
import { Row } from "../layout";
import MarkdownEditor from "src/components/MarkdownEditor";
import validationSchema from "./validation";
import { usePolicy } from "src/hooks";
import useModalContext from "src/hooks/utils/useModalContext";
import ModalConfirm from "src/components/ModalConfirm";
import styles from "./CommentForm.module.css";

const forbiddenCommentsMdElements = ["h1", "h2", "h3", "h4", "h5", "h6"];

Expand All @@ -15,17 +28,44 @@ const CommentForm = ({
onCommentSubmitted,
disableSubmit,
persistKey,
className
className,
isAuthorUpdate,
hasAuthorUpdates
}) => {
const {
policyPi: { namesupportedchars, namelengthmax, namelengthmin }
} = usePolicy();
const [handleOpenModal, handleCloseModal] = useModalContext();
const smallTablet = useMediaQuery("(max-width: 685px)");
async function handleSubmit(
values,
{ comment, title },
{ resetForm, setSubmitting, setFieldError }
) {
try {
await onSubmit(values.comment.trim());
setSubmitting(false);
resetForm();
onCommentSubmitted && onCommentSubmitted();
if (title && hasAuthorUpdates) {
handleOpenModal(ModalConfirm, {
title: "New author update",
message:
"Submitting a new update will lock the previous update thread. Are you sure you want to continue?",
successTitle: "Author Update posted",
successMessage: <Text>The update has been successfully posted!</Text>,
onClose: () => {
setSubmitting(false);
handleCloseModal();
},
onSubmit: async () => {
await onSubmit({ comment: comment.trim(), title });
setSubmitting(false);
resetForm();
onCommentSubmitted && onCommentSubmitted();
}
});
} else {
await onSubmit({ comment: comment.trim(), title });
setSubmitting(false);
resetForm();
onCommentSubmitted && onCommentSubmitted();
}
} catch (e) {
setSubmitting(false);
setFieldError("global", e);
Expand All @@ -34,28 +74,68 @@ const CommentForm = ({
return (
<Formik
initialValues={{
title: isAuthorUpdate ? "" : null,
comment: ""
}}
loading={!validationSchema}
validationSchema={validationSchema}
validationSchema={validationSchema({
namesupportedchars,
namelengthmax,
namelengthmin,
isAuthorUpdate
})}
onSubmit={handleSubmit}>
{(formikProps) => {
const {
values,
handleBlur,
handleSubmit,
isSubmitting,
setFieldValue,
errors,
isValid
} = formikProps;
function handleCommentChange(v) {
setFieldValue("comment", v);
}
{({
values,
handleBlur,
handleSubmit,
handleChange,
isSubmitting,
setFieldTouched,
setFieldValue,
errors,
isValid,
touched
}) => {
const handleTitleChangeWithTouched = (e) => {
setFieldTouched("title", true);
handleChange(e);
};

const handleCommentChange = (v) => setFieldValue("comment", v);

return (
<form onSubmit={handleSubmit} className={className}>
{errors && errors.global && (
<Message kind="error">{errors.global.toString()}</Message>
<Message className="margin-bottom-m" kind="error">
{errors.global.toString()}
</Message>
)}
{isAuthorUpdate && (
<>
<Row noMargin align="center" wrap={smallTablet}>
<H4 className="margin-bottom-s">Proposal Update</H4>
<Tooltip
contentClassName={styles.updateTitleTooltip}
className={styles.titleTooltipWrapper}
placement="right"
content="The proposal author is allowed to give periodic updates on the status of their proposal. You can start an update thread by submitting a new comment. Users
will only be able to reply to your most recent update thread.">
<div>
<Icon type="info" size={smallTablet ? "md" : "lg"} />
</div>
</Tooltip>
</Row>
<BoxTextInput
placeholder="Update title"
name="title"
data-testid="update-title"
tabIndex={1}
value={values.title}
onChange={handleTitleChangeWithTouched}
error={touched.title && errors.title}
/>
</>
)}
<MarkdownEditor
allowImgs={false}
Expand All @@ -66,7 +146,7 @@ const CommentForm = ({
onChange={handleCommentChange}
onBlur={handleBlur}
disallowedElements={forbiddenCommentsMdElements}
placeholder={"Write a comment"}
placeholder="Write a comment"
/>
<Row justify="right" topMarginSize="s">
{!!onCancel && (
Expand Down
11 changes: 11 additions & 0 deletions src/components/CommentForm/CommentForm.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.updateTitleTooltip {
font-size: var(--font-size-small);
width: 50rem;
}

.titleTooltipWrapper {
flex-basis: 4rem;
margin-top: -0.5rem;
display: flex;
justify-content: center;
}
Loading

0 comments on commit 0c1f08e

Please sign in to comment.