+ ({ 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: (
+
+ ),
+ onSubmit: handleCensorComment,
+ onClose: () => handleCloseModal()
+ });
+ },
+ [
onCensorComment,
userid,
recordTokenFull,
- id,
- proposalState
- );
- handleOpenModal(ModalConfirmWithReason, {
- title: "Censor comment",
- reasonLabel: "Censor reason",
- subject: "censorComment",
- successTitle: "Comment censored",
- successMessage: (
-
- ),
- 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 && (
+ <>
+
+ {!isSingleThread && (
+
+ )}
+ >
)}
- )}
-
- {!!comments && !!comments.length && (
- <>
-
+ )}
+
+
+ {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 (
<>
-
+ {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;