diff --git a/src/actions/api.js b/src/actions/api.js index 87d81d8a9..1cc4f72f1 100644 --- a/src/actions/api.js +++ b/src/actions/api.js @@ -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; @@ -1021,26 +1024,49 @@ 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) ) @@ -1048,7 +1074,7 @@ export const onCensorComment = (userid, token, commentid, state, reason) => { .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) => { @@ -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) { @@ -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)); @@ -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) { diff --git a/src/actions/app.js b/src/actions/app.js index 4337a95ce..c0e12dde5 100644 --- a/src/actions/app.js +++ b/src/actions/app.js @@ -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 + ) ); }; diff --git a/src/actions/tests/api.test.js b/src/actions/tests/api.test.js index 77b687de1..b3f6b0df8 100644 --- a/src/actions/tests/api.test.js +++ b/src/actions/tests/api.test.js @@ -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)); @@ -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); @@ -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, diff --git a/src/components/CommentForm/CommentForm.jsx b/src/components/CommentForm/CommentForm.jsx index 703dd69a1..67f60daab 100644 --- a/src/components/CommentForm/CommentForm.jsx +++ b/src/components/CommentForm/CommentForm.jsx @@ -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"]; @@ -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: The update has been successfully posted!, + 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); @@ -34,28 +74,68 @@ const CommentForm = ({ return ( - {(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 (
{errors && errors.global && ( - {errors.global.toString()} + + {errors.global.toString()} + + )} + {isAuthorUpdate && ( + <> + +

Proposal Update

+ +
+ +
+
+
+ + )} {!!onCancel && ( diff --git a/src/components/CommentForm/CommentForm.module.css b/src/components/CommentForm/CommentForm.module.css new file mode 100644 index 000000000..322c3cb94 --- /dev/null +++ b/src/components/CommentForm/CommentForm.module.css @@ -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; +} diff --git a/src/components/CommentForm/validation.js b/src/components/CommentForm/validation.js index 032a07ddf..a54158fbb 100644 --- a/src/components/CommentForm/validation.js +++ b/src/components/CommentForm/validation.js @@ -1,7 +1,22 @@ import * as Yup from "yup"; +import { yupFieldMatcher } from "src/utils/validation"; -const commentValidationSchema = Yup.object().shape({ - comment: Yup.string().required("Required") -}); +const commentValidationSchema = ({ + namesupportedchars, + namelengthmax, + namelengthmin, + isAuthorUpdate +}) => + Yup.object().shape({ + comment: Yup.string().required("required"), + title: isAuthorUpdate + ? Yup.string() + .nullable() + .required("required") + .min(namelengthmin) + .max(namelengthmax) + .matches(...yupFieldMatcher("Title", namesupportedchars)) + : undefined + }); export default commentValidationSchema; diff --git a/src/components/ModalConfirm.jsx b/src/components/ModalConfirm.jsx index de82dcb68..374657a1c 100644 --- a/src/components/ModalConfirm.jsx +++ b/src/components/ModalConfirm.jsx @@ -74,21 +74,19 @@ const ModalConfirm = ({ handleSubmit, errors, isSubmitting - }) => { - return ( - - {errors && errors.global && ( - {errors.global.toString()} - )} - {message} - - - - - ); - }} + }) => ( +
+ {errors && errors.global && ( + {errors.global.toString()} + )} + {message} + + + +
+ )} )} {success && ( diff --git a/src/constants.js b/src/constants.js index 0f0ec7f83..cd215b369 100644 --- a/src/constants.js +++ b/src/constants.js @@ -53,9 +53,12 @@ export const PROPOSAL_USER_FILTER_DRAFT_PROPOSALS = 2; export const PROPOSAL_METADATA_FILENAME = "proposalmetadata.json"; export const VOTE_METADATA_FILENAME = "votemetadata.json"; +export const PROPOSAL_UPDATE_HINT = "proposalupdate"; export const PROPOSAL_AMOUNT_UNIT = "$"; +export const PROPOSAL_MAIN_THREAD_KEY = "main"; + export const USER_METADATA_PLUGIN = "usermd"; export const PAYWALL_STATUS_WAITING = 0; diff --git a/src/containers/Comments/Comment/Comment.jsx b/src/containers/Comments/Comment/Comment.jsx index dbcc65f07..177bfbcbf 100644 --- a/src/containers/Comments/Comment/Comment.jsx +++ b/src/containers/Comments/Comment/Comment.jsx @@ -71,7 +71,7 @@ const Comment = ({ return (
+ ({ comment }) => onSubmitComment({ comment, token, parentID: commentid, - state: proposalState + state: proposalState, + sectionId }), - [onSubmitComment, token, commentid, proposalState] + [onSubmitComment, token, commentid, proposalState, sectionId] ); const handleCommentSubmitted = useCallback(() => { @@ -177,7 +187,8 @@ const CommentWrapper = ({ loadingLikes || readOnly || (userLoggedIn && - (identityError || paywallMissing || currentUser.username === username)); + (identityError || paywallMissing || currentUser.username === username)) || + notInLatestAuthorUpdateThread; return ( <> @@ -195,7 +206,12 @@ const CommentWrapper = ({ !enableCommentVote || proposalState === PROPOSAL_STATE_UNVETTED } disableLikesClick={isLikeCommentDisabled} - disableReply={readOnly || !!identityError || paywallMissing} + disableReply={ + readOnly || + !!identityError || + paywallMissing || + notInLatestAuthorUpdateThread + } likesUpCount={upvotes} likesDownCount={downvotes} likeOption={getCommentLikeOption(commentid)} diff --git a/src/containers/Comments/Comments.jsx b/src/containers/Comments/Comments.jsx index 68810e79e..56362c61e 100644 --- a/src/containers/Comments/Comments.jsx +++ b/src/containers/Comments/Comments.jsx @@ -5,19 +5,19 @@ import React, { useMemo, useState } from "react"; -import { Card, H2, Text, Message, classNames, P, Select } from "pi-ui"; -import { withRouter } from "react-router-dom"; +import { Card, H2, Text, Message, classNames, Select } from "pi-ui"; import styles from "./Comments.module.css"; -import LoggedInContent from "src/components/LoggedInContent"; -import CommentForm from "src/components/CommentForm/CommentFormLazy"; import ModalConfirmWithReason from "src/components/ModalConfirmWithReason"; -import { useComments, CommentContext } from "./hooks"; +import { CommentContext } from "./hooks"; import CommentsListWrapper from "./CommentsList/CommentsListWrapper"; import CommentLoader from "./Comment/CommentLoader"; import Link from "src/components/Link"; -import Or from "src/components/Or"; -import useQueryString from "src/hooks/utils/useQueryString"; -import useScrollTo from "src/hooks/utils/useScrollTo"; +import { + useQueryString, + useScrollTo, + useComments, + useLocalStorage +} from "src/hooks"; import { getSortOptionsForSelect, createSelectOptionFromSortOption, @@ -25,19 +25,11 @@ import { handleCommentCensoringInfo, NUMBER_OF_LIST_PLACEHOLDERS } from "./helpers"; -import useIdentity from "src/hooks/api/useIdentity"; -import usePaywall from "src/hooks/api/usePaywall"; -import { IdentityMessageError } from "src/components/IdentityErrorIndicators"; -import ModalLogin from "src/components/ModalLogin"; -import useModalContext from "src/hooks/utils/useModalContext"; -import WhatAreYourThoughts from "src/components/WhatAreYourThoughts"; +import { PROPOSAL_MAIN_THREAD_KEY } from "src/constants"; import { commentsReducer, initialState, actions } from "./commentsReducer"; import { getQueryStringValue } from "src/lib/queryString"; -import useLocalStorage from "src/hooks/utils/useLocalStorage"; import { debounce } from "lodash"; -const COMMENTS_LOGIN_MODAL_ID = "commentsLoginModal"; - const FlatModeButton = ({ isActive, onClick }) => (
(
); -const CommentsListAndActions = ({ - sortOption, - setSortOption, - dispatch, - comments, - numOfComments, - state, - threadParentID, - isSingleThread, - recordTokenFull, - onCommentVote, - recordToken, - recordType, - lastVisitTimestamp, - commentsCtx, - onCensorComment, - currentUser, - proposalState, - handleOpenModal, - handleCloseModal, - loading, - recordAuthorID, - recordAuthorUsername, - recordBaseLink, - onSubmitComment, - readOnly, - identityError, - paywallMissing, - handleOpenLoginModal -}) => { - const { userid } = currentUser || {}; - const commentsCount = comments ? comments.length : 0; - const numOfDuplicatedComments = numOfComments - state.comments.length; - const hasDuplicatedComments = - !!state.comments.length && numOfDuplicatedComments > 0; - /** SORT START */ - const handleSetSortOption = useCallback( - (option) => { - setSortOption(option.value); +const CommentsListAndActions = React.memo( + ({ + sortOption, + setSortOption, + dispatch, + comments, + numOfComments, + state, + threadParentID, + isSingleThread, + recordTokenFull, + onCommentVote, + recordToken, + recordType, + lastVisitTimestamp, + commentsCtx, + onCensorComment, + currentUser, + userid, + proposalState, + handleOpenModal, + handleCloseModal, + loading, + recordAuthorID, + recordAuthorUsername, + recordBaseLink, + onSubmitComment, + readOnly, + identityError, + paywallMissing, + handleOpenLoginModal, + latestAuthorUpdateId, + areAuthorUpdatesAllowed, + authorUpdateTitle, + sectionId + }) => { + const { + getCommentLikeOption, + enableCommentVote, + userLoggedIn, + userEmail, + loadingLikes, + getCommentVotes + } = commentsCtx; + const commentsCount = comments ? comments.length : 0; + const numOfDuplicatedComments = numOfComments - state.comments.length; + const hasDuplicatedComments = + !!state.comments.length && numOfDuplicatedComments > 0; + /** SORT START */ + const handleSetSortOption = useCallback( + (option) => { + setSortOption(option.value); + dispatch({ + type: actions.SORT, + sortOption: option.value + }); + }, + [dispatch, setSortOption] + ); + + const selectOptions = useMemo(() => getSortOptionsForSelect(), []); + const selectValue = useMemo( + () => createSelectOptionFromSortOption(sortOption), + [sortOption] + ); + /** SORT END */ + /** FLAT MODE START */ + const [flatModeOnLocalStorage, setflatModeOnLocalStorage] = useLocalStorage( + "flatComments", + false + ); + const [isFlatCommentsMode, setIsFlatCommentsMode] = useState( + flatModeOnLocalStorage + ); + + const handleCommentsModeToggle = () => { + const newFlagValue = !isFlatCommentsMode; + setIsFlatCommentsMode(newFlagValue); + setflatModeOnLocalStorage(newFlagValue); dispatch({ type: actions.SORT, - sortOption: option.value + sortOption }); - }, - [dispatch, setSortOption] - ); - - const selectOptions = useMemo(() => getSortOptionsForSelect(), []); - const selectValue = useMemo( - () => createSelectOptionFromSortOption(sortOption), - [sortOption] - ); - /** SORT END */ - /** FLAT MODE START */ - const [flatModeOnLocalStorage, setflatModeOnLocalStorage] = useLocalStorage( - "flatComments", - false - ); - const [isFlatCommentsMode, setIsFlatCommentsMode] = useState( - flatModeOnLocalStorage - ); - - const handleCommentsModeToggle = () => { - const newFlagValue = !isFlatCommentsMode; - setIsFlatCommentsMode(newFlagValue); - setflatModeOnLocalStorage(newFlagValue); - dispatch({ - type: actions.SORT, - sortOption - }); - }; - const debouncedHandleCommentsModeToggle = debounce( - handleCommentsModeToggle, - 50 - ); - /** FLAT MODE END */ - /** VOTE START */ - const handleCommentVote = useCallback( - (commentID, action) => - recordTokenFull - ? onCommentVote(commentID, action, recordTokenFull) - : null, - [onCommentVote, recordTokenFull] - ); - /** VOTE END */ - /** CENSOR START */ - const handleCensorCommentModal = useCallback( - function handleCensorCommentModal(id) { - const handleCensorComment = handleCommentCensoringInfo( + }; + const debouncedHandleCommentsModeToggle = debounce( + handleCommentsModeToggle, + 50 + ); + /** FLAT MODE END */ + /** VOTE START */ + const handleCommentVote = useCallback( + (commentID, action) => + recordTokenFull + ? onCommentVote(commentID, action, recordTokenFull) + : null, + [onCommentVote, recordTokenFull] + ); + /** VOTE END */ + /** CENSOR START */ + const handleCensorCommentModal = useCallback( + function handleCensorCommentModal(id) { + const handleCensorComment = handleCommentCensoringInfo( + onCensorComment, + userid, + recordTokenFull, + id, + proposalState, + sectionId + ); + handleOpenModal(ModalConfirmWithReason, { + title: "Censor comment", + reasonLabel: "Censor reason", + subject: "censorComment", + successTitle: "Comment censored", + successMessage: ( + The comment has been successfully censored. + ), + onSubmit: handleCensorComment, + onClose: () => handleCloseModal() + }); + }, + [ onCensorComment, userid, recordTokenFull, - id, - proposalState - ); - handleOpenModal(ModalConfirmWithReason, { - title: "Censor comment", - reasonLabel: "Censor reason", - subject: "censorComment", - successTitle: "Comment censored", - successMessage: ( - The comment has been successfully censored. - ), - onSubmit: handleCensorComment, - onClose: () => handleCloseModal() - }); - }, - [ - onCensorComment, - userid, - recordTokenFull, - proposalState, - handleOpenModal, - handleCloseModal - ] - ); - /** CENSOR END */ - /** LOADERS START */ - const commentLoaders = useMemo(() => { - if (!loading) return null; + proposalState, + handleOpenModal, + handleCloseModal, + sectionId + ] + ); + /** CENSOR END */ + /** LOADERS START */ + const commentLoaders = useMemo(() => { + if (!loading) return null; - const numOfContents = - numOfComments < 3 ? numOfComments : NUMBER_OF_LIST_PLACEHOLDERS; - const contents = []; - for (let i = 0; i < numOfContents; i++) { - contents.push(); - } - return contents; - }, [numOfComments, loading]); - /** LOADERS END */ - /** SINGLE THREAD VERIFICATION START */ - const singleThreadCommentCannotBeAccessed = - isSingleThread && - ((comments && !comments.find((c) => c.commentid === +threadParentID)) || - numOfComments === 0); - /** SINGLE THREAD VERIFICATION END */ - return ( - <> -
- {!isSingleThread && ( -
-

- Comments{" "} - {commentsCount} -

- {hasDuplicatedComments && ( - {`(${numOfDuplicatedComments} duplicate comments omitted)`} + const numOfContents = + numOfComments < 3 ? numOfComments : NUMBER_OF_LIST_PLACEHOLDERS; + const contents = []; + for (let i = 0; i < numOfContents; i++) { + contents.push(); + } + return contents; + }, [numOfComments, loading]); + /** LOADERS END */ + /** SINGLE THREAD VERIFICATION START */ + const singleThreadCommentCannotBeAccessed = + isSingleThread && + ((comments && !comments.find((c) => c.commentid === +threadParentID)) || + numOfComments === 0); + /** SINGLE THREAD VERIFICATION END */ + return ( + <> +
+ {!isSingleThread && ( +
+

+ {authorUpdateTitle ? authorUpdateTitle : "Comments"}{" "} + {`(${commentsCount})`} +

+ {hasDuplicatedComments && ( + {`(${numOfDuplicatedComments} duplicate comments omitted)`} + )} +
+ )} +
+ {!!comments && !!comments.length && ( + <> + + Single comment thread. + + View all. + +
+ )} +
+
+ {loading ? ( + commentLoaders + ) : !singleThreadCommentCannotBeAccessed ? ( + + - {!isSingleThread && ( - - )} - + + ) : null} + {singleThreadCommentCannotBeAccessed && ( + + The comment you are trying to access does not exist or it is a + duplicated. Return to the full thread to select a valid comment. + )}
- {isSingleThread && ( -
- Single comment thread. - - View all. - -
- )} -
-
- {loading ? ( - commentLoaders - ) : !singleThreadCommentCannotBeAccessed ? ( - - - - ) : null} - {singleThreadCommentCannotBeAccessed && ( - - The comment you are trying to access does not exist or it is a - duplicated. Return to the full thread to select a valid comment. - - )} -
- - ); -}; + + ); + } +); const Comments = ({ - numOfComments, recordToken, recordTokenFull, recordAuthorID, recordAuthorUsername, threadParentID, readOnly, - readOnlyReason, className, - history, proposalState, - recordBaseLink + recordBaseLink, + areAuthorUpdatesAllowed, + handleOpenModal, + handleCloseModal, + handleOpenLoginModal, + paywallMissing, + identityError, + sectionId }) => { - const [, identityError] = useIdentity(); - const { isPaid, paywallEnabled } = usePaywall(); - const [state, dispatch] = useReducer(commentsReducer, initialState); - + const isSingleThread = !!threadParentID; const { onSubmitComment, onCommentVote, @@ -305,31 +324,18 @@ const Comments = ({ recordType, lastVisitTimestamp, currentUser, - error, - ...commentsCtx - } = useComments(recordTokenFull, proposalState); - - const [handleOpenModal, handleCloseModal] = useModalContext(); + getCommentLikeOption, + enableCommentVote, + userLoggedIn, + userEmail, + loadingLikes, + getCommentVotes, + latestAuthorUpdateId + } = useComments(recordTokenFull, proposalState, sectionId, threadParentID); const { userid } = currentUser || {}; + const numOfComments = comments?.length; - const onRedirectToSignup = () => { - history.push("/user/signup"); - }; - - const paywallMissing = paywallEnabled && !isPaid; - const isSingleThread = !!threadParentID; - - const handleSubmitComment = useCallback( - (comment) => - onSubmitComment({ - comment, - token: recordTokenFull, - parentID: 0, - state: proposalState - }), - [onSubmitComment, proposalState, recordTokenFull] - ); - + const [state, dispatch] = useReducer(commentsReducer, initialState); const hasComments = !!comments; const hasScrollToQuery = !!getQueryStringValue("scrollToComments"); const shouldScrollToComments = @@ -341,6 +347,17 @@ const Comments = ({ commentSortOptions.SORT_BY_TOP ); + const authorUpdateTitle = useCallback( + (updateId) => { + const { extradata } = comments.find( + ({ commentid }) => commentid === updateId + ); + const authorUpdateMetadata = JSON.parse(extradata); + return authorUpdateMetadata.title; + }, + [comments] + ); + useEffect( function handleUpdateComments() { if (!!comments && !!comments.length) { @@ -354,92 +371,63 @@ const Comments = ({ [comments, sortOption] ); - const handleOpenLoginModal = useCallback(() => { - handleOpenModal(ModalLogin, { - id: COMMENTS_LOGIN_MODAL_ID, - onLoggedIn: handleCloseModal - }); - }, [handleOpenModal, handleCloseModal]); + const updateTitle = useMemo(() => { + if (sectionId && sectionId !== PROPOSAL_MAIN_THREAD_KEY && !isSingleThread) + return authorUpdateTitle(sectionId); + }, [sectionId, authorUpdateTitle, isSingleThread]); return ( <> - -
- - }> - - {readOnly && ( - - {readOnlyReason} - - )} - {!isPaid && paywallEnabled && currentUser && ( - -

- You won't be able to submit comments or proposals before - paying the paywall, please visit your{" "} - account page - to correct this problem. -

-
- )} - {!readOnly && !!identityError && } -
- {!isSingleThread && !readOnly && recordTokenFull && ( - - )} -
- {error && ( - - {error.toString()} - - )} -
- -
+ {comments && ( + + + + )} ); }; -export default withRouter(Comments); +export default React.memo(Comments); diff --git a/src/containers/Comments/Comments.module.css b/src/containers/Comments/Comments.module.css index d6be56d87..73be684f7 100644 --- a/src/containers/Comments/Comments.module.css +++ b/src/containers/Comments/Comments.module.css @@ -1,10 +1,12 @@ div.commentAreaContainer { + padding-top: 2rem; + padding-bottom: 2rem; margin-top: var(--spacing-medium); margin-bottom: var(--spacing-medium); } div.commentsWrapper { - padding: 0 2.6rem 2rem 2.6rem; + padding: 0 2.6rem 0 2.6rem; max-width: calc(100vw - 12rem); } @@ -83,7 +85,6 @@ span.flatModeActive { max-width: 100vw; } - .commentsHeaderWrapper, .commentsHeader { padding-left: 1.4rem; padding-right: 1.4rem; diff --git a/src/containers/Comments/CommentsList/CommentsListWrapper.jsx b/src/containers/Comments/CommentsList/CommentsListWrapper.jsx index 349a33568..2aaa7eb23 100644 --- a/src/containers/Comments/CommentsList/CommentsListWrapper.jsx +++ b/src/containers/Comments/CommentsList/CommentsListWrapper.jsx @@ -1,13 +1,12 @@ import React, { useState, useEffect } from "react"; import CommentsList from "./CommentsList"; -const getChildren = (comments, commentId, lastTimeAccessed, currentUserID) => { - return ( - comments.filter((comment) => +comment.parentid === +commentId) || [] - ).map((comment) => - createComputedComment(comment, comments, lastTimeAccessed, currentUserID) - ); -}; +const getChildren = (comments, commentId, lastTimeAccessed, currentUserID) => + comments + .filter((comment) => +comment.parentid === +commentId) + .map((comment) => + createComputedComment(comment, comments, lastTimeAccessed, currentUserID) + ); const createComputedComment = ( comment, diff --git a/src/containers/Comments/Download/hooks.js b/src/containers/Comments/Download/hooks.js index 36752c920..a49c5952e 100644 --- a/src/containers/Comments/Download/hooks.js +++ b/src/containers/Comments/Download/hooks.js @@ -27,7 +27,8 @@ export function useDownloadComments(token) { () => sel.makeGetRecordComments(token), [token] ); - const comments = useSelector(commentsSelector); + const allCommentsBySection = useSelector(commentsSelector); + const comments = Object.values(allCommentsBySection).flat(); const { onFetchCommentsTimestamps } = useTimestamps(); return { comments, onFetchCommentsTimestamps }; @@ -42,7 +43,8 @@ export function useDownloadCommentsTimestamps(recordToken) { () => sel.makeGetRecordComments(recordToken), [recordToken] ); - const comments = useSelector(commentsSelector); + const allCommentsBySection = useSelector(commentsSelector); + const comments = Object.values(allCommentsBySection).flat(); const commentsLength = comments?.length || 0; const multiPage = commentsLength > TIMESTAMPS_PAGE_SIZE; const onFetchCommentsTimestamps = useAction(act.onFetchCommentsTimestamps); diff --git a/src/containers/Comments/helpers.js b/src/containers/Comments/helpers.js index ed9687bd2..d2a5c95ec 100644 --- a/src/containers/Comments/helpers.js +++ b/src/containers/Comments/helpers.js @@ -8,6 +8,41 @@ export const commentSortOptions = { SORT_BY_NEW: "New" }; +/** + * isInCommentTree returns whether the leafID is part of the provided comment + * tree. A leaf is considered to be part of the tree if the leaf is a child of + * the root or the leaf references the root itself. + * @param {Int} rootId root node id. + * @param {Int} leafId leaf node id. + * @param {Map} comments array of comments. + */ +export const isInCommentTree = (rootId, leafId, comments) => { + if (leafId === rootId) { + return true; + } + + // Convert comment array to a map + const commentsMap = comments.reduce((map, comment) => { + map[comment.commentid] = comment; + return map; + }, {}); + + // Start with the provided comment leaf and traverse the comment tree up + // until either the provided root ID is found or we reach the tree head. The + // tree head will have a comment ID of 0. + let current = commentsMap[leafId]; + while (current && current.parentid !== 0) { + // Check if next parent in the tree is the rootID. + if (current.parentid === rootId) { + return true; + } + const newLeafId = current.parentid; + current = commentsMap[newLeafId]; + } + + return false; +}; + /** * Creates a select option from a string sort option. * @param {String} sortOption diff --git a/src/containers/Comments/hooks.js b/src/containers/Comments/hooks.js index f573b718e..088cd8ebb 100644 --- a/src/containers/Comments/hooks.js +++ b/src/containers/Comments/hooks.js @@ -1,142 +1,4 @@ -import { - useEffect, - useCallback, - createContext, - useContext, - useMemo -} from "react"; -import * as sel from "src/selectors"; -import * as act from "src/actions"; -import { useSelector, useAction } from "src/redux"; -import { useConfig } from "src/containers/Config"; -import { useLoaderContext } from "src/containers/Loader"; -import { or } from "src/lib/fp"; -import { PROPOSAL_STATE_VETTED } from "src/constants"; +import { createContext, useContext } from "react"; export const CommentContext = createContext(); export const useComment = () => useContext(CommentContext); - -export function useComments(recordToken, proposalState) { - const { enableCommentVote, recordType, constants } = useConfig(); - - const errorSelector = or( - sel.apiProposalCommentsError, - sel.apiInvoiceCommentsError, - sel.apiDccCommentsError, - sel.apiLikeCommentsError, - sel.apiCommentsLikesError - ); - const error = useSelector(errorSelector); - const commentsSelector = useMemo( - () => sel.makeGetRecordComments(recordToken), - [recordToken] - ); - const commentsLikesSelector = useMemo( - () => sel.makeGetRecordCommentsLikes(recordToken), - [recordToken] - ); - const commentsVotesSelector = useMemo( - () => sel.makeGetRecordCommentsVotes(recordToken), - [recordToken] - ); - const lastVisitTimestampSelector = useMemo( - () => sel.makeGetLastAccessTime(recordToken), - [recordToken] - ); - const comments = useSelector(commentsSelector); - const commentsLikes = useSelector(commentsLikesSelector); - const commentsVotes = useSelector(commentsVotesSelector); - const lastVisitTimestamp = useSelector(lastVisitTimestampSelector); - const loading = useSelector(sel.isApiRequestingComments); - const loadingLikes = useSelector(sel.isApiRequestingCommentsLikes); - const onSubmitComment = useAction( - recordType === constants.RECORD_TYPE_DCC - ? act.onSaveNewDccComment - : act.onSaveNewComment - ); - const onFetchComments = useAction( - recordType === constants.RECORD_TYPE_PROPOSAL - ? act.onFetchProposalComments - : recordType === constants.RECORD_TYPE_DCC - ? act.onFetchDccComments - : act.onFetchInvoiceComments - ); - const onFetchLikes = useAction(act.onFetchLikedComments); - const onCommentVoteAction = useAction(act.onCommentVote); - const onCensorComment = useAction(act.onCensorComment); - - const { currentUser } = useLoaderContext(); - const userid = currentUser && currentUser.userid; - const email = currentUser && currentUser.email; - - const userLoggedIn = !!email; - - // comments are not public on cms. User needs to be logged in - const isProposal = recordType === constants.RECORD_TYPE_PROPOSAL; - const needsToFetchComments = isProposal - ? !!recordToken && !comments - : !!recordToken && !comments && userLoggedIn; - - const needsToFetchCommentsLikes = - !!recordToken && - !commentsLikes && - enableCommentVote && - userLoggedIn && - proposalState === PROPOSAL_STATE_VETTED; - - useEffect( - function handleFetchOfComments() { - if (needsToFetchComments) { - onFetchComments(recordToken); - } - }, - [onFetchComments, needsToFetchComments, recordToken] - ); - - useEffect( - function handleFetchOfLikes() { - if (needsToFetchCommentsLikes) { - onFetchLikes(recordToken, userid); - } - }, - [onFetchLikes, needsToFetchCommentsLikes, recordToken, userid] - ); - - const onCommentVote = useCallback( - (commentID, action, token) => { - onCommentVoteAction(userid, token, commentID, action, proposalState); - }, - [onCommentVoteAction, userid, proposalState] - ); - - const getCommentLikeOption = useCallback( - (commentID) => { - const option = commentsLikes && commentsLikes[commentID]; - return option ? option : 0; - }, - [commentsLikes] - ); - - const getCommentVotes = useCallback( - (commentID) => commentsVotes && commentsVotes[commentID], - [commentsVotes] - ); - - return { - comments, - onCommentVote, - onCensorComment, - getCommentLikeOption, - enableCommentVote, - userLoggedIn, - userEmail: email, - recordType, - currentUser, - lastVisitTimestamp, - loading, - loadingLikes, - onSubmitComment, - error, - getCommentVotes - }; -} diff --git a/src/containers/Proposal/Detail/Detail.jsx b/src/containers/Proposal/Detail/Detail.jsx index ae7fa0c24..28a8199fc 100644 --- a/src/containers/Proposal/Detail/Detail.jsx +++ b/src/containers/Proposal/Detail/Detail.jsx @@ -1,5 +1,5 @@ -import React from "react"; -import { Message } from "pi-ui"; +import React, { useMemo, useCallback } from "react"; +import { Card, Message, P, classNames } from "pi-ui"; import get from "lodash/fp/get"; import { withRouter } from "react-router-dom"; import Proposal from "src/components/Proposal"; @@ -13,7 +13,8 @@ import { getProposalToken, isCensoredProposal, isAbandonedProposal, - getProposalLink + getProposalLink, + isApprovedProposal } from "../helpers"; import { UnvettedActionsProvider, @@ -22,35 +23,218 @@ import { import { useProposalVote } from "../hooks"; import useDocumentTitle from "src/hooks/utils/useDocumentTitle"; import useProposalsStatusChangeUser from "src/hooks/api/useProposalsStatusChangeUser"; +import Link from "src/components/Link"; import { GoBackLink } from "src/components/Router"; import { useConfig } from "src/containers/Config"; -import { PROPOSAL_STATUS_CENSORED } from "src/constants"; +import Or from "src/components/Or"; +import LoggedInContent from "src/components/LoggedInContent"; +import CommentForm from "src/components/CommentForm/CommentFormLazy"; +import { IdentityMessageError } from "src/components/IdentityErrorIndicators"; +import WhatAreYourThoughts from "src/components/WhatAreYourThoughts"; +import { PROPOSAL_STATUS_CENSORED, PROPOSAL_UPDATE_HINT } from "src/constants"; import { shortRecordToken } from "src/helpers"; +import ModalLogin from "src/components/ModalLogin"; +import useModalContext from "src/hooks/utils/useModalContext"; +import { usePaywall, useIdentity } from "src/hooks"; + +const COMMENTS_LOGIN_MODAL_ID = "commentsLoginModal"; const SetPageTitle = ({ title }) => { useDocumentTitle(title); return null; }; -const ProposalDetail = ({ Main, match }) => { +const ProposalDetail = ({ Main, match, history }) => { const tokenFromUrl = shortRecordToken(get("params.token", match)); const threadParentCommentID = get("params.commentid", match); const { proposal: fetchedProposal, loading, threadParentID, - error + error, + isCurrentUserProposalAuthor, + commentSectionIds, + hasAuthorUpdates, + singleThreadRootId, + onSubmitComment, + currentUser, + commentsError, + commentsLoading } = useProposal(tokenFromUrl, threadParentCommentID); + const { userid } = currentUser || {}; + const isSingleThread = !!threadParentID; const { proposals, loading: mdLoading } = useProposalsStatusChangeUser( { [tokenFromUrl]: fetchedProposal }, PROPOSAL_STATUS_CENSORED ); const proposal = proposals[tokenFromUrl]; const proposalToken = getProposalToken(proposal); + const proposalState = proposal?.state; const { voteSummary } = useProposalVote(proposalToken || tokenFromUrl); - const canReceiveComments = + const areCommentsAllowed = !isVotingFinishedProposal(voteSummary) && !isAbandonedProposal(proposal); + // XXX this should be to false when the proposal billing status is set + // to closed or completed. + // Currently this piece of info isn't available and should be returned + // from the BE somehow. + const areAuthorUpdatesAllowed = isApprovedProposal(proposal, voteSummary); + const readOnly = !areCommentsAllowed && !areAuthorUpdatesAllowed; + const readOnlyReason = getCommentBlockedReason(proposal, voteSummary); const { javascriptEnabled } = useConfig(); + const { isPaid, paywallEnabled } = usePaywall(); + const paywallMissing = paywallEnabled && !isPaid; + const [, identityError] = useIdentity(); + + const onRedirectToSignup = useCallback( + () => history.push("/user/signup"), + [history] + ); + + const handleSubmitComment = useCallback( + ({ comment, title }) => { + // If title is provided then we are dealing with an author + // update. + let extraData = "", + extraDataHint = ""; + if (title) { + extraDataHint = PROPOSAL_UPDATE_HINT; + extraData = JSON.stringify({ title }); + } + return onSubmitComment({ + comment, + token: proposalToken, + parentID: 0, + state: proposalState, + extraData, + extraDataHint + }); + }, + [onSubmitComment, proposalState, proposalToken] + ); + + const [handleOpenModal, handleCloseModal] = useModalContext(); + const handleOpenLoginModal = useCallback(() => { + handleOpenModal(ModalLogin, { + id: COMMENTS_LOGIN_MODAL_ID, + onLoggedIn: handleCloseModal + }); + }, [handleOpenModal, handleCloseModal]); + + const CommentsSection = React.memo(({ sectionId }) => ( + + )); + + const proposalComments = useMemo( + () => ( + <> + {!(currentUser && isSingleThread) && ( + + + }> + + {readOnly && ( + + {readOnlyReason} + + )} + {!isPaid && paywallEnabled && currentUser && ( + +

+ You won't be able to submit comments or proposals before + paying the paywall, please visit your{" "} + account{" "} + page to correct this problem. +

+
+ )} + {!readOnly && !!identityError && } + {areAuthorUpdatesAllowed && !isCurrentUserProposalAuthor && ( + + Replies & upvotes/downvotes are allowed only on the latest + author update thread. + + )} +
+ {!isSingleThread && + ((!readOnly && !areAuthorUpdatesAllowed) || + (!readOnly && + areAuthorUpdatesAllowed && + isCurrentUserProposalAuthor)) && + !!proposalToken && ( + + )} +
+
+ )} + {commentsError && ( + + {commentsError.toString()} + + )} + {singleThreadRootId ? ( + + ) : ( + <> + {commentSectionIds?.map((sectionId) => ( + + ))} + + )} + + ), + [ + commentSectionIds, + singleThreadRootId, + areAuthorUpdatesAllowed, + currentUser, + handleOpenLoginModal, + handleSubmitComment, + identityError, + isCurrentUserProposalAuthor, + isPaid, + isSingleThread, + onRedirectToSignup, + paywallEnabled, + paywallMissing, + proposalToken, + readOnly, + readOnlyReason, + tokenFromUrl, + userid, + commentsError, + hasAuthorUpdates + ] + ); return ( <> @@ -70,20 +254,9 @@ const ProposalDetail = ({ Main, match }) => { collapseBodyContent={!!threadParentID} /> )} - {!isCensoredProposal(proposal) && ( - - )} + {!isCensoredProposal(proposal) && + !commentsLoading && + proposalComments} diff --git a/src/containers/Proposal/Detail/Detail.module.css b/src/containers/Proposal/Detail/Detail.module.css index f1501b101..fd8af07ce 100644 --- a/src/containers/Proposal/Detail/Detail.module.css +++ b/src/containers/Proposal/Detail/Detail.module.css @@ -10,3 +10,10 @@ .customTitleAndSubtitleWrapper { justify-content: center; } + +@media screen and (max-width: 560px) { + .commentsHeaderWrapper { + padding-left: 1.4rem; + padding-right: 1.4rem; + } +} diff --git a/src/containers/Proposal/Detail/hooks.js b/src/containers/Proposal/Detail/hooks.js index e13e46291..d29a2f1ad 100644 --- a/src/containers/Proposal/Detail/hooks.js +++ b/src/containers/Proposal/Detail/hooks.js @@ -11,6 +11,8 @@ import { getDetailsFile } from "./helpers"; import { shortRecordToken, parseRawProposal } from "src/helpers"; import { PROPOSAL_STATE_VETTED } from "src/constants"; import useFetchMachine from "src/hooks/utils/useFetchMachine"; +import { useLoaderContext } from "src/containers/Loader"; +import { useComments } from "src/hooks"; import isEmpty from "lodash/fp/isEmpty"; import keys from "lodash/fp/keys"; import difference from "lodash/fp/difference"; @@ -61,6 +63,9 @@ export function useProposal(token, threadParentID) { const rfpLinks = getProposalRfpLinksTokens(proposal); const isRfp = proposal && !!proposal.linkby; const isSubmission = proposal && !!proposal.linkto; + const { currentUser } = useLoaderContext(); + const isCurrentUserProposalAuthor = + currentUser && proposal && currentUser.userid === proposal.userid; const unfetchedProposalTokens = rfpLinks && @@ -161,7 +166,8 @@ export function useProposal(token, threadParentID) { }, done: () => { // verify proposal on proposal changes - // TODO: improve this in the future so we don't need to verify once it should be done + // TODO: improve this in the future so we don't need to verify once + // it should be done. if (!isEqual(state.proposal, proposal)) { return send(VERIFY); } @@ -176,10 +182,30 @@ export function useProposal(token, threadParentID) { proposals ); + const proposalToken = getProposalToken(proposalWithLinks); + const proposalState = proposalWithLinks?.state; + + const { + onSubmitComment, + commentSectionIds, + hasAuthorUpdates, + singleThreadRootId, + error: commentsError, + loading: commentsLoading + } = useComments(proposalToken, proposalState, null, threadParentID); + return { proposal: proposalWithLinks, error: state.error, loading: state.status === "idle" || state.status === "loading", - threadParentID + threadParentID, + isCurrentUserProposalAuthor, + commentSectionIds, + hasAuthorUpdates, + singleThreadRootId, + onSubmitComment, + commentsError, + currentUser, + commentsLoading }; } diff --git a/src/helpers.js b/src/helpers.js index 33598a2ed..fd75b6bf6 100644 --- a/src/helpers.js +++ b/src/helpers.js @@ -8,6 +8,7 @@ import map from "lodash/fp/map"; import splitFp from "lodash/fp/split"; import reduce from "lodash/fp/reduce"; import compose from "lodash/fp/compose"; +import uniq from "lodash/fp/uniq"; import * as pki from "./lib/pki"; import { sha3_256 } from "js-sha3"; import { capitalize } from "./utils/strings"; @@ -583,3 +584,36 @@ export function getAttachmentsFiles(files) { ].includes(f.name) ); } + +/** + * getChildrenComments accepts an array of comments and a subset of comment ids + * as parents and returns the ids of the children comments as an array. + * @param {Array} comments + * @param {Array} parents comment ids + * @param {Array} children comment ids + */ +const getChildrenComments = (comments, parents) => + comments + .filter(({ parentid }) => parents.includes(parentid)) + .map(({ commentid }) => commentid); + +/** + * calculateAuthorUpdateTree accepts an array of comments and an author update + * id. It calculates the author update thread comment tree then returns + * the tree as an sub-array of the original array. + * @param {String} authorUpdateId + * @param {Array} comments + * @returns {Array} array of author update thread comments. + */ +export const calculateAuthorUpdateTree = (authorUpdateId, comments) => { + let authorUpdateTree = [authorUpdateId]; + let children = getChildrenComments(comments, authorUpdateTree); + let allTreeComments = uniq([...authorUpdateTree, ...children]); + while (allTreeComments.length > authorUpdateTree.length) { + authorUpdateTree = allTreeComments; + const parents = [...authorUpdateTree]; + children = getChildrenComments(comments, parents); + allTreeComments = uniq([...authorUpdateTree, ...children]); + } + return allTreeComments; +}; diff --git a/src/hooks/api/useComments.js b/src/hooks/api/useComments.js new file mode 100644 index 000000000..93d1456a9 --- /dev/null +++ b/src/hooks/api/useComments.js @@ -0,0 +1,175 @@ +import { useEffect, useCallback, useMemo } from "react"; +import { useConfig } from "src/containers/Config"; +import { or } from "src/lib/fp"; +import * as sel from "src/selectors"; +import * as act from "src/actions"; +import { useSelector, useAction } from "src/redux"; +import { useLoaderContext } from "src/containers/Loader"; +import { PROPOSAL_STATE_VETTED } from "src/constants"; + +export default function useComments( + recordToken, + proposalState, + sectionId, + threadParentID +) { + const isSingleThread = !!threadParentID; + const { enableCommentVote, recordType, constants } = useConfig(); + + const errorSelector = or( + sel.apiProposalCommentsError, + sel.apiInvoiceCommentsError, + sel.apiDccCommentsError, + sel.apiLikeCommentsError, + sel.apiCommentsLikesError + ); + const error = useSelector(errorSelector); + const allCommentsBySectionSelector = useMemo( + () => sel.makeGetRecordComments(recordToken), + [recordToken] + ); + const sectionCommentsSelector = useMemo( + () => sel.makeGetRecordSectionComments(recordToken, sectionId), + [recordToken, sectionId] + ); + const sectionIdsSelector = useMemo( + () => sel.makeGetRecordCommentSectionIds(recordToken), + [recordToken] + ); + const commentsLikesSelector = useMemo( + () => sel.makeGetRecordCommentsLikes(recordToken), + [recordToken] + ); + const commentsVotesSelector = useMemo( + () => sel.makeGetRecordCommentsVotes(recordToken), + [recordToken] + ); + const lastVisitTimestampSelector = useMemo( + () => sel.makeGetLastAccessTime(recordToken), + [recordToken] + ); + const comments = useSelector(sectionCommentsSelector); + const allCommentsBySection = useSelector(allCommentsBySectionSelector); + const commentSectionIds = useSelector(sectionIdsSelector); + const hasAuthorUpdates = commentSectionIds && commentSectionIds.length > 1; + const commentsLikes = useSelector(commentsLikesSelector); + const commentsVotes = useSelector(commentsVotesSelector); + const lastVisitTimestamp = useSelector(lastVisitTimestampSelector); + const loading = useSelector(sel.isApiRequestingComments); + const loadingLikes = useSelector(sel.isApiRequestingCommentsLikes); + const onSubmitComment = useAction( + recordType === constants.RECORD_TYPE_DCC + ? act.onSaveNewDccComment + : act.onSaveNewComment + ); + const onFetchComments = useAction( + recordType === constants.RECORD_TYPE_PROPOSAL + ? act.onFetchProposalComments + : recordType === constants.RECORD_TYPE_DCC + ? act.onFetchDccComments + : act.onFetchInvoiceComments + ); + const onFetchLikes = useAction(act.onFetchLikedComments); + const onCommentVoteAction = useAction(act.onCommentVote); + const onCensorComment = useAction(act.onCensorComment); + + const { currentUser } = useLoaderContext(); + const userid = currentUser && currentUser.userid; + const email = currentUser && currentUser.email; + + const userLoggedIn = !!email; + + // comments are not public on cms. User needs to be logged in + const isProposal = recordType === constants.RECORD_TYPE_PROPOSAL; + const needsToFetchComments = isProposal + ? !!recordToken && !comments + : !!recordToken && !comments && userLoggedIn; + + const needsToFetchCommentsLikes = + !!recordToken && + !commentsLikes && + enableCommentVote && + userLoggedIn && + proposalState === PROPOSAL_STATE_VETTED; + + useEffect( + function handleFetchOfComments() { + if (needsToFetchComments) { + onFetchComments(recordToken); + } + }, + [onFetchComments, needsToFetchComments, recordToken] + ); + + useEffect( + function handleFetchOfLikes() { + if (needsToFetchCommentsLikes) { + onFetchLikes(recordToken, userid); + } + }, + [onFetchLikes, needsToFetchCommentsLikes, recordToken, userid] + ); + + const onCommentVote = useCallback( + (commentID, action, token) => { + onCommentVoteAction( + userid, + token, + commentID, + action, + proposalState, + sectionId + ); + }, + [onCommentVoteAction, userid, proposalState, sectionId] + ); + + const getCommentLikeOption = useCallback( + (commentID) => { + const option = commentsLikes && commentsLikes[commentID]; + return option ? option : 0; + }, + [commentsLikes] + ); + + const getCommentVotes = useCallback( + (commentID) => commentsVotes && commentsVotes[commentID], + [commentsVotes] + ); + + // If displaying a single thread while having multiple author updates with + // different section, find to which tree the sub-tree we are displaying + // belongs to display only the relevant section. + let singleThreadRootId; + if (isSingleThread && hasAuthorUpdates) { + for (const [sectionId, comments] of Object.entries(allCommentsBySection)) { + if ( + comments.map(({ commentid }) => commentid).includes(+threadParentID) + ) { + singleThreadRootId = sectionId; + } + } + } + + return { + comments, + onCommentVote, + onCensorComment, + getCommentLikeOption, + enableCommentVote, + userLoggedIn, + userEmail: email, + recordType, + currentUser, + lastVisitTimestamp, + loading: loading, + loadingLikes, + onSubmitComment, + error, + getCommentVotes, + commentSectionIds, + hasAuthorUpdates, + latestAuthorUpdateId: hasAuthorUpdates && commentSectionIds[0], + singleThreadRootId + }; +} diff --git a/src/hooks/api/useProposalsBatch.js b/src/hooks/api/useProposalsBatch.js index 8a4f7532e..f6accd8c3 100644 --- a/src/hooks/api/useProposalsBatch.js +++ b/src/hooks/api/useProposalsBatch.js @@ -336,10 +336,11 @@ export default function useProposalsBatch({ ), onFetchProposalsBatch, proposalsTokens: allByStatus, + // loading is true when fetching cycle is running and there are no + // proposals fetched to avoid flickering at starting. loading: state.loading || (!values(proposals).length && statusIndex + 1 < voteStatuses?.length), - // loading return true when fetching cycle is running and there are no proposals fetched to avoid flickering at starting verifying: state.verifying, onRestartMachine, onFetchMoreProposals, diff --git a/src/hooks/index.js b/src/hooks/index.js index 746e7881a..4e1e8614f 100644 --- a/src/hooks/index.js +++ b/src/hooks/index.js @@ -7,6 +7,7 @@ export { default as usePaywall } from "./api/usePaywall"; export { default as usePolicy } from "./api/usePolicy"; export { default as useProposalsBatch } from "./api/useProposalsBatch"; export { default as useTimestamps } from "./api/useTimestamps"; +export { default as useComments } from "./api/useComments"; export { default as useSubContractors } from "./api/useSubContractors"; export { default as useSupervisors } from "./api/useSupervisors"; export { default as useUserDetail } from "./api/useUserDetail"; @@ -29,6 +30,7 @@ export { default as useOnRouteChange } from "./utils/useOnRouteChange"; export { default as useQueryString } from "./utils/useQueryString"; export { default as useQueryStringWithIndexValue } from "./utils/useQueryStringWithIndexValue"; export { default as useScrollFormOnError } from "./utils/useScrollFormOnError"; +export { default as useScrollTo } from "./utils/useScrollTo"; export { default as useScrollToTop } from "./utils/useScrollToTop"; export { default as useSessionStorage } from "./utils/useSessionStorage"; export { default as useThrowError } from "./utils/useThrowError"; diff --git a/src/hooks/utils/useScrollTo.js b/src/hooks/utils/useScrollTo.js index fbda6eb2d..75afba546 100644 --- a/src/hooks/utils/useScrollTo.js +++ b/src/hooks/utils/useScrollTo.js @@ -1,14 +1,22 @@ -import { useLayoutEffect } from "react"; - -const scrollToElement = (element) => - setTimeout(() => { - document.getElementById(element).scrollIntoView(); - }, 100); +import { useLayoutEffect, useCallback, useRef } from "react"; function useScrollTo(element, shouldScroll) { + // This ref is used to hold the setTimeout timer to clean it up + // on unmount. + const timer = useRef(null); + const scrollToElement = useCallback((element) => { + timer.current = setTimeout( + () => document.getElementById(element).scrollIntoView(), + 100 + ); + }, []); + useLayoutEffect(() => { shouldScroll && scrollToElement(element); - }, [element, shouldScroll]); + + // Cleanup timer on unmount + return () => timer.current && clearTimeout(timer.current); + }, [element, scrollToElement, shouldScroll]); } export default useScrollTo; diff --git a/src/lib/api.js b/src/lib/api.js index b1d9e6b5b..d547a3578 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -175,11 +175,20 @@ export const makeInvoice = ( }; }; -export const makeComment = (token, comment, parentid, state) => ({ +export const makeComment = ( + token, + comment, + parentid, + state, + extradata, + extradatahint +) => ({ token, parentid: parentid || TOP_LEVEL_COMMENT_PARENTID, comment, - state + state, + extradata, + extradatahint }); export const makeDccComment = (token, comment, parentid) => ({ @@ -268,7 +277,9 @@ export const signComment = (userid, comment) => comment.state, comment.token, comment.parentid, - comment.comment + comment.comment, + comment.extradata, + comment.extradatahint ].join("") ) .then((signature) => ({ ...comment, publickey, signature })) diff --git a/src/lib/errors.js b/src/lib/errors.js index 01eb6e25c..78a698fef 100644 --- a/src/lib/errors.js +++ b/src/lib/errors.js @@ -293,7 +293,8 @@ function PiPluginError(code, context) { 8: `Proposal start date is invalid, ${context}`, 9: `Proposal end date is invalid, ${context}`, 10: `Proposal amount is invalid, ${context}`, - 11: `Proposal domain is invalid, ${context}` + 11: `Proposal domain is invalid, ${context}`, + 18: "Author update title is missing" }; this.message = errorMap[code] || defaultErrorMessage(code, PluginIdPi); @@ -324,7 +325,8 @@ function CommentsPluginError(code, context) { 7: "Only the comment author is allowed to edit", 8: `The provided parent ID is invalid, ${context}`, 9: `The provided comment vote is invalid, ${context}`, - 10: "You have exceeded the max number of changes on your vote" + 10: "You have exceeded the max number of changes on your vote", + 12: "Backend does not accept the extra data needed for author updates" }; this.message = errorMap[code] || defaultErrorMessage(code, PluginIdComments); diff --git a/src/reducers/models/comments.js b/src/reducers/models/comments.js index cd13c5cb1..173dd0916 100644 --- a/src/reducers/models/comments.js +++ b/src/reducers/models/comments.js @@ -6,11 +6,13 @@ import set from "lodash/fp/set"; import get from "lodash/fp/get"; import find from "lodash/fp/find"; import update from "lodash/fp/update"; -import { shortRecordToken } from "src/helpers"; +import { shortRecordToken, calculateAuthorUpdateTree } from "src/helpers"; import { PROPOSAL_STATUS_PUBLIC, - PROPOSAL_STATUS_UNREVIEWED -} from "../../constants"; + PROPOSAL_STATUS_UNREVIEWED, + PROPOSAL_MAIN_THREAD_KEY, + PROPOSAL_UPDATE_HINT +} from "src/constants"; const DEFAULT_STATE = { comments: { byToken: {}, accessTimeByToken: {}, backup: null }, @@ -24,7 +26,8 @@ const calcScoreByComment = (votes) => { return votes.sort(olderVotesFirst).reduce((accObj, currentVote) => { const id = currentVote.commentid; if (!accObj[id] || accObj[id] !== currentVote.vote) { - // no vote found or old vote is different than new vote. Vote option is the chosen option + // no vote found or old vote is different than new vote. Vote option + // is the chosen option. return { ...accObj, [id]: currentVote.vote @@ -108,16 +111,64 @@ const comments = (state = DEFAULT_STATE, action) => (arrVal, othVal) => arrVal.signature && arrVal.signaure !== othVal.signaure ); + + // Find author update ids. + const authorUpdateIds = filteredComments + .filter( + ({ extradatahint }) => extradatahint === PROPOSAL_UPDATE_HINT + ) + .map(({ commentid }) => commentid); + + const sectionIds = [...authorUpdateIds, PROPOSAL_MAIN_THREAD_KEY]; + + // Calculate comments tree for each author update to display each + // one of them in a separate comments section. + const commentsMap = {}; + let authorUpdateThreads = []; + authorUpdateIds.forEach((updateId) => { + const authorUpdateTree = calculateAuthorUpdateTree( + updateId, + filteredComments + ); + authorUpdateThreads = [ + ...authorUpdateThreads, + ...authorUpdateTree + ]; + commentsMap[updateId] = filteredComments.filter(({ commentid }) => + authorUpdateTree.includes(commentid) + ); + }); + commentsMap[PROPOSAL_MAIN_THREAD_KEY] = filteredComments.filter( + ({ commentid }) => !authorUpdateThreads.includes(commentid) + ); + return compose( - set(["comments", "byToken", shortToken], filteredComments), + set(["comments", "byToken", shortToken], { + sectionIds, + comments: commentsMap + }), set(["comments", "accessTimeByToken", shortToken], accesstime) )(state); }, [act.RECEIVE_NEW_COMMENT]: () => { - const comment = action.payload; - return update( - ["comments", "byToken", shortRecordToken(comment.token)], - (comments = []) => [...comments, comment] + const { sectionId, ...comment } = action.payload; + const shortToken = shortRecordToken(comment.token); + // If comment's section id is not known then we are dealing + // with a new author update and the section id should be + // added to the section ids array. + const { sectionIds } = state.comments.byToken[shortToken]; + const isNewSectionId = !sectionIds.includes(sectionId); + return compose( + update( + ["comments", "byToken", shortToken, "comments", sectionId], + (comments = []) => [comment, ...comments] + ), + isNewSectionId + ? set( + ["comments", "byToken", shortToken, "sectionIds"], + [sectionId, ...sectionIds] + ) + : (state) => state )(state); }, [act.RECEIVE_LIKED_COMMENTS]: () => { @@ -128,12 +179,12 @@ const comments = (state = DEFAULT_STATE, action) => commentsUserVote )(state); }, - [act.RECEIVE_LIKE_COMMENT]: () => { - const { token, vote, commentid } = action.payload; + [act.REQUEST_LIKE_COMMENT]: () => { + const { token, vote, commentid, sectionId } = action.payload; const shortToken = shortRecordToken(token); const oldComment = compose( find((c) => c.commentid === commentid), - get(["comments", "byToken", shortToken]) + get(["comments", "byToken", shortToken, "comments", sectionId]) )(state); const commentsLikes = state.commentsLikes.byToken[shortToken]; const oldCommentsVotes = get([ @@ -186,19 +237,21 @@ const comments = (state = DEFAULT_STATE, action) => )(state); }, [act.RECEIVE_LIKE_COMMENT_SUCCESS]: () => { - const { token, commentid } = action.payload; + const { token, commentid, sectionId } = action.payload; const shortToken = shortRecordToken(token); const { upvotes, downvotes } = state.commentsVotes.byToken[shortToken][commentid]; - return update(["comments", "byToken", shortToken], (comments) => - comments.map((comment) => { - if (comment.commentid !== commentid) return comment; - return { ...comment, upvotes, downvotes }; - }) + return update( + ["comments", "byToken", shortToken, "comments", sectionId], + (comments) => + comments.map((comment) => { + if (comment.commentid !== commentid) return comment; + return { ...comment, upvotes, downvotes }; + }) )(state); }, [act.RECEIVE_CENSOR_COMMENT]: () => { - const { commentid, token } = action.payload; + const { commentid, token, sectionId } = action.payload; const censorTargetComment = (comment) => { if (comment.commentid !== commentid) return comment; return { @@ -209,7 +262,13 @@ const comments = (state = DEFAULT_STATE, action) => }; return compose( update( - ["comments", "byToken", shortRecordToken(token)], + [ + "comments", + "byToken", + shortRecordToken(token), + "comments", + sectionId + ], (comments) => comments.map(censorTargetComment) ) )(state); diff --git a/src/selectors/models/comments.js b/src/selectors/models/comments.js index 218a08309..ed85b4d7e 100644 --- a/src/selectors/models/comments.js +++ b/src/selectors/models/comments.js @@ -2,7 +2,7 @@ import { createSelector } from "reselect"; import get from "lodash/fp/get"; import { shortRecordToken } from "src/helpers"; -export const commentsByToken = get(["comments", "comments", "byToken"]); +export const commentsInfoByToken = get(["comments", "comments", "byToken"]); const commentsVotesByToken = get(["comments", "commentsVotes", "byToken"]); export const accessTimeByToken = get([ @@ -16,24 +16,37 @@ export const commentsLikesByToken = get([ "byToken" ]); -const getCommentsByToken = (token) => (commentsByToken) => { +const getByToken = (token) => (mapByToken) => { const shortToken = token && shortRecordToken(token); - const comment = commentsByToken[shortToken]; - if (comment) return comment; - const commentsTokens = Object.keys(commentsByToken); - // check if the provided token is prefix of original token - const matchedTokenByPrefix = commentsTokens.find((key) => key === shortToken); - return commentsByToken[matchedTokenByPrefix]; + return mapByToken[shortToken]; }; +const getSectionIds = ({ sectionIds } = {}) => sectionIds; + +const getCommentsMap = ({ comments } = {}) => comments; + +const getSectionComments = + (sectionId) => + (commentsMap = {}) => + commentsMap[sectionId]; + +export const makeGetRecordCommentSectionIds = (token) => + createSelector(makeGetRecordCommentsInfo(token), getSectionIds); + +export const makeGetRecordSectionComments = (token, sectionId) => + createSelector(makeGetRecordComments(token), getSectionComments(sectionId)); + export const makeGetRecordComments = (token) => - createSelector(commentsByToken, getCommentsByToken(token)); + createSelector(makeGetRecordCommentsInfo(token), getCommentsMap); + +export const makeGetRecordCommentsInfo = (token) => + createSelector(commentsInfoByToken, getByToken(token)); export const makeGetRecordCommentsVotes = (token) => - createSelector(commentsVotesByToken, getCommentsByToken(token)); + createSelector(commentsVotesByToken, getByToken(token)); export const makeGetRecordCommentsLikes = (token) => - createSelector(commentsLikesByToken, getCommentsByToken(token)); + createSelector(commentsLikesByToken, getByToken(token)); export const makeGetLastAccessTime = (token) => - createSelector(accessTimeByToken, getCommentsByToken(token)); + createSelector(accessTimeByToken, getByToken(token)); diff --git a/teste2e/cypress/e2e/adminAccount.js b/teste2e/cypress/e2e/admin/account.js similarity index 98% rename from teste2e/cypress/e2e/adminAccount.js rename to teste2e/cypress/e2e/admin/account.js index 393d51c53..57b716ad3 100644 --- a/teste2e/cypress/e2e/adminAccount.js +++ b/teste2e/cypress/e2e/admin/account.js @@ -1,5 +1,5 @@ -import { buildUser } from "../support/generate"; -import * as pki from "../pki"; +import { buildUser } from "../../support/generate"; +import * as pki from "../../pki"; describe("Admin account actions", () => { it("Can search users", () => { diff --git a/teste2e/cypress/e2e/adminComments.js b/teste2e/cypress/e2e/admin/comments.js similarity index 90% rename from teste2e/cypress/e2e/adminComments.js rename to teste2e/cypress/e2e/admin/comments.js index 6db3d2c13..ef0a732d3 100644 --- a/teste2e/cypress/e2e/adminComments.js +++ b/teste2e/cypress/e2e/admin/comments.js @@ -1,5 +1,5 @@ -import { buildProposal, buildComment } from "../support/generate"; -import { shortRecordToken } from "../utils"; +import { buildProposal, buildComment } from "../../support/generate"; +import { shortRecordToken } from "../../utils"; describe("User admin comments", () => { it("Can censor comments", () => { diff --git a/teste2e/cypress/e2e/adminProposals.js b/teste2e/cypress/e2e/admin/proposals.js similarity index 97% rename from teste2e/cypress/e2e/adminProposals.js rename to teste2e/cypress/e2e/admin/proposals.js index 2c2ce9bf2..ef303d761 100644 --- a/teste2e/cypress/e2e/adminProposals.js +++ b/teste2e/cypress/e2e/admin/proposals.js @@ -1,5 +1,5 @@ -import { buildProposal } from "../support/generate"; -import { shortRecordToken } from "../utils"; +import { buildProposal } from "../../support/generate"; +import { shortRecordToken } from "../../utils"; describe("Admin proposals actions", () => { it("Can approve proposals", () => { diff --git a/teste2e/cypress/e2e/comments/authorUpdates.js b/teste2e/cypress/e2e/comments/authorUpdates.js new file mode 100644 index 000000000..79fcf2f6b --- /dev/null +++ b/teste2e/cypress/e2e/comments/authorUpdates.js @@ -0,0 +1,76 @@ +import { + buildProposal, + buildComment, + buildAuthorUpdate +} from "../../support/generate"; +import { shortRecordToken, PROPOSAL_VOTING_APPROVED } from "../../utils"; + +describe("Proposal author updates", () => { + it("Should allow proposal author to submit update on approved proposals, and normal users should be able to reply on the latest author update only", () => { + // paid admin user with proposal credits + cy.server(); + // create proposal + const admin = { + email: "adminuser@example.com", + username: "adminuser", + password: "password" + }; + const user1 = { + email: "user1@example.com", + username: "user1", + password: "password" + }; + const proposal = buildProposal(); + cy.login(admin); + cy.identity(); + cy.createProposal(proposal).then( + ({ + body: { + record: { + censorshiprecord: { token } + } + } + }) => { + cy.visit(`record/${shortRecordToken(token)}`); + // Manually approve proposal + cy.findByText(/approve/i).click(); + cy.route("POST", "/api/records/v1/setstatus").as("setstatus"); + cy.findByText(/confirm/i).click(); + cy.wait("@setstatus"); + // Mock vote summary reply to set proposal vote status to + // approved to test author updates. + cy.middleware("ticketvote.summaries", { + token, + status: PROPOSAL_VOTING_APPROVED + }); + cy.visit(`record/${shortRecordToken(token)}`); + cy.wait("@ticketvote.summaries"); + const { title, text } = buildAuthorUpdate(); + cy.findByTestId(/update-title/i).type(title); + cy.findByTestId(/text-area/i).type(text); + cy.route("POST", "/api/comments/v1/new").as("newComment"); + cy.findByText(/add comment/i).click(); + cy.wait("@newComment").its("status").should("eq", 200); + // Ensure new comments section title is the author update title. + cy.findByText(title).should("be.visible"); + + // Normal users shouldn't be able to post normal comments at this level + // and only reply on latest author update thread. + cy.logout(admin); + cy.login(user1); + cy.identity(); + cy.visit(`record/${shortRecordToken(token)}`); + cy.findByTestId(/text-area/i).should("not.exist"); + const { text: replyText } = buildComment(); + cy.findByText(/reply/i).click(); + cy.findByTestId(/text-area/i).type(replyText); + cy.route("POST", "/api/comments/v1/new").as("newComment"); + cy.findByText(/add comment/i).click(); + cy.wait("@newComment").its("status").should("eq", 200); + // Ensure new reply is displayed in the author update thread. + cy.wait(1000); + cy.findByText(replyText).should("be.visible"); + } + ); + }); +}); diff --git a/teste2e/cypress/e2e/comments/commentsVotes.js b/teste2e/cypress/e2e/comments/commentVotes.js similarity index 94% rename from teste2e/cypress/e2e/comments/commentsVotes.js rename to teste2e/cypress/e2e/comments/commentVotes.js index 32145e5a9..bb073dca6 100644 --- a/teste2e/cypress/e2e/comments/commentsVotes.js +++ b/teste2e/cypress/e2e/comments/commentVotes.js @@ -28,9 +28,7 @@ describe("Comments Votes", () => { // like action cy.findAllByTestId("score-like") .first() - .then((score) => { - upvotes = score[0].innerText; - }); + .then((score) => (upvotes = score[0].innerText)); cy.findAllByTestId("like-btn").first().click(); cy.wait("@comments.vote"); cy.findAllByTestId("score-like") @@ -43,11 +41,10 @@ describe("Comments Votes", () => { // dislike action cy.findAllByTestId("score-dislike") .first() - .then((score) => { - downvotes = score[0].innerText; - }); + .then((score) => (downvotes = score[0].innerText)); cy.findAllByTestId("dislike-btn").first().click(); cy.wait("@comments.vote"); + // check if downvotes count has increased cy.findAllByTestId("score-dislike") .first() .then((score) => { @@ -55,7 +52,7 @@ describe("Comments Votes", () => { expect(Number(newdown)).to.equal(Number(downvotes) + 1); downvotes = newdown; }); - // checks if like vote count has decreased + // check if upvotes count has decreased cy.findAllByTestId("score-like") .first() .then((score) => { diff --git a/teste2e/cypress/e2e/comments.js b/teste2e/cypress/e2e/comments/comments.js similarity index 93% rename from teste2e/cypress/e2e/comments.js rename to teste2e/cypress/e2e/comments/comments.js index 87608a5b9..b006e74ad 100644 --- a/teste2e/cypress/e2e/comments.js +++ b/teste2e/cypress/e2e/comments/comments.js @@ -1,5 +1,5 @@ -import { buildProposal, buildComment } from "../support/generate"; -import { shortRecordToken } from "../utils"; +import { buildProposal, buildComment } from "../../support/generate"; +import { shortRecordToken } from "../../utils"; describe("User comments", () => { it("Can not comment if hasn't paid the paywall", () => { @@ -77,7 +77,7 @@ describe("User comments", () => { cy.logout(user1); cy.login(user); cy.identity(); - cy.visit(`record/${censorshiprecord.token.substring(0, 7)}`); + cy.visit(`record/${shortRecordToken(censorshiprecord.token)}`); cy.route("POST", "/api/comments/v1/vote").as("likeComment"); cy.findByTestId("like-btn").click(); cy.wait("@likeComment", { timeout: 10000 }) diff --git a/teste2e/cypress/e2e/proposalCreate.js b/teste2e/cypress/e2e/proposal/create.js similarity index 95% rename from teste2e/cypress/e2e/proposalCreate.js rename to teste2e/cypress/e2e/proposal/create.js index 2968b0eeb..7cdb3deda 100644 --- a/teste2e/cypress/e2e/proposalCreate.js +++ b/teste2e/cypress/e2e/proposal/create.js @@ -1,4 +1,4 @@ -import { buildProposal } from "../support/generate"; +import { buildProposal } from "../../support/generate"; describe("Proposal Create", () => { // XXX This test needs changes in the Datepicker and (probably) the Select diff --git a/teste2e/cypress/e2e/records/recordDetails.js b/teste2e/cypress/e2e/proposal/detail.js similarity index 100% rename from teste2e/cypress/e2e/records/recordDetails.js rename to teste2e/cypress/e2e/proposal/detail.js diff --git a/teste2e/cypress/e2e/proposalEdit.js b/teste2e/cypress/e2e/proposal/edit.js similarity index 96% rename from teste2e/cypress/e2e/proposalEdit.js rename to teste2e/cypress/e2e/proposal/edit.js index 5529db46a..0d0f92fe2 100644 --- a/teste2e/cypress/e2e/proposalEdit.js +++ b/teste2e/cypress/e2e/proposal/edit.js @@ -1,5 +1,5 @@ -import { buildProposal } from "../support/generate"; -import { shortRecordToken } from "../utils"; +import { buildProposal } from "../../support/generate"; +import { shortRecordToken } from "../../utils"; describe("Proposal Edit", () => { const user = { diff --git a/teste2e/cypress/e2e/proposalFormErrorCodes.js b/teste2e/cypress/e2e/proposal/formErrorCodes.js similarity index 96% rename from teste2e/cypress/e2e/proposalFormErrorCodes.js rename to teste2e/cypress/e2e/proposal/formErrorCodes.js index 06bc142b5..879125f53 100644 --- a/teste2e/cypress/e2e/proposalFormErrorCodes.js +++ b/teste2e/cypress/e2e/proposal/formErrorCodes.js @@ -1,5 +1,5 @@ -import { buildProposal } from "../support/generate"; -import { APIPluginError } from "../errors"; +import { buildProposal } from "../../support/generate"; +import { APIPluginError } from "../../errors"; describe("Proposal Form Error Codes Mapping", () => { const user = { diff --git a/teste2e/cypress/e2e/records/recordsList.js b/teste2e/cypress/e2e/proposal/list.js similarity index 94% rename from teste2e/cypress/e2e/records/recordsList.js rename to teste2e/cypress/e2e/proposal/list.js index f6c47fca4..e832e1ad3 100644 --- a/teste2e/cypress/e2e/records/recordsList.js +++ b/teste2e/cypress/e2e/proposal/list.js @@ -88,8 +88,9 @@ describe("Records list", () => { cy.scrollTo("bottom"); // prepare to fetch 25 items: 3 started, 20 authorized and 2 unauthorized // scan inventory: page 2 of authorized - cy.wait("@ticketvote.inventory").its("request.body") - .should("deep.eq", {page: 2,status : 2}); + cy.wait("@ticketvote.inventory") + .its("request.body") + .should("deep.eq", { page: 2, status: 2 }); cy.wait("@records.records"); cy.assertListLengthByTestId("record-title", 25); cy.scrollTo("bottom"); @@ -104,8 +105,9 @@ describe("Records list", () => { cy.scrollTo("bottom"); // prepare to fetch 45 items: 3 started, 20 authorized and 22 unauthorized // scan inventory: page 2 of unauthorized - cy.wait("@ticketvote.inventory").its("request.body") - .should("deep.eq", {page: 2,status : 1}); + cy.wait("@ticketvote.inventory") + .its("request.body") + .should("deep.eq", { page: 2, status: 1 }); cy.wait("@records.records"); cy.assertListLengthByTestId("record-title", 45); cy.scrollTo("bottom"); @@ -120,8 +122,9 @@ describe("Records list", () => { cy.scrollTo("bottom"); // prepare to fetch 65 items: 3 started, 20 authorized and 42 unauthorized // scan inventory: page 3 of unauthorized - cy.wait("@ticketvote.inventory").its("request.body") - .should("deep.eq", {page: 3,status : 1}); + cy.wait("@ticketvote.inventory") + .its("request.body") + .should("deep.eq", { page: 3, status: 1 }); cy.wait("@records.records"); cy.assertListLengthByTestId("record-title", 65); cy.scrollTo("bottom"); @@ -191,7 +194,8 @@ describe("Records list", () => { cy.findByTestId("sidebar").should("be.visible"); cy.viewport("iphone-6"); cy.findByTestId("sidebar").should("be.hidden"); - cy.viewport(1000, 500); // sidebar breakpoint + // sidebar breakpoint + cy.viewport(1000, 500); cy.findByTestId("sidebar").should("be.hidden"); cy.viewport(1001, 500); cy.findByTestId("sidebar").should("be.visible"); @@ -226,12 +230,14 @@ describe("Records list", () => { inventory = body.unvetted; }); cy.wait("@records.records"); - cy.assertListLengthByTestId("record-title", RECORDS_PAGE_SIZE) // first records batch - .each(([{ id }], position) => { + // first records batch + cy.assertListLengthByTestId("record-title", RECORDS_PAGE_SIZE).each( + ([{ id }], position) => { const tokens = getTokensByStatusTab(inventory, "Unreviewed"); const expectedToken = shortRecordToken(tokens[position]); expect(id).to.have.string(expectedToken); - }); + } + ); }); it("can render records and inventory pagination correctly", () => { cy.visit("/admin/records"); diff --git a/teste2e/cypress/support/generate.js b/teste2e/cypress/support/generate.js index 013d32480..ad38b090a 100644 --- a/teste2e/cypress/support/generate.js +++ b/teste2e/cypress/support/generate.js @@ -27,6 +27,11 @@ export const buildComment = build("Comment").fields({ text: fake((f) => f.lorem.sentence()) }); +export const buildAuthorUpdate = build("AuthorUpdate").fields({ + title: fake((f) => f.lorem.words()), + text: fake((f) => f.lorem.sentence()) +}); + export const buildRecord = build("Record").fields({ token: fake((f) => f.internet.password(15, false, /[0-9a-z]/)), timestamp: Date.now() / 1000, diff --git a/teste2e/cypress/support/mock/ticketvote.js b/teste2e/cypress/support/mock/ticketvote.js index 0486c1fa1..9d8a14225 100644 --- a/teste2e/cypress/support/mock/ticketvote.js +++ b/teste2e/cypress/support/mock/ticketvote.js @@ -40,5 +40,24 @@ export const middlewares = { } }); } + }), + summaries: ({ token, status }) => + cy.intercept("/api/ticketvote/v1/summaries", (req) => { + req.continue((res) => { + res.body.summaries[token] = { + type: 0, + status, + duration: 0, + startblockheight: 0, + startblockhash: "", + endblockheight: 0, + eligibletickets: 0, + quorumpercentage: 0, + passpercentage: 0, + results: [], + bestblock: 767301 + }; + res.send(res.body); + }); }) }; diff --git a/teste2e/cypress/utils.js b/teste2e/cypress/utils.js index 2044fe327..04dec002a 100644 --- a/teste2e/cypress/utils.js +++ b/teste2e/cypress/utils.js @@ -24,7 +24,7 @@ const PROPOSAL_STATE_VETTED = 2; const PROPOSAL_VOTING_NOT_AUTHORIZED = 1; const PROPOSAL_VOTING_AUTHORIZED = 2; const PROPOSAL_VOTING_ACTIVE = 3; -const PROPOSAL_VOTING_APPROVED = 5; +export const PROPOSAL_VOTING_APPROVED = 5; const PROPOSAL_VOTING_REJECTED = 6; const PROPOSAL_VOTING_INELIGIBLE = 7; const PROPOSAL_STATUS_UNREVIEWED = 1;