diff --git a/packages/ui/src/app/GlobalModals.tsx b/packages/ui/src/app/GlobalModals.tsx index 4169a326d7..bd36fd7f4c 100644 --- a/packages/ui/src/app/GlobalModals.tsx +++ b/packages/ui/src/app/GlobalModals.tsx @@ -49,6 +49,7 @@ import { CreatePostModal, CreatePostModalCall } from '@/forum/modals/PostActionM import { DeletePostModal, DeletePostModalCall } from '@/forum/modals/PostActionModal/DeletePostModal' import { EditPostModal, EditPostModalCall } from '@/forum/modals/PostActionModal/EditPostModal' import { PostHistoryModal, PostHistoryModalCall } from '@/forum/modals/PostHistoryModal' +import { PostReplyModal, PostReplyModalCall } from '@/forum/modals/PostReplyModal' import { MemberModalCall, MemberProfile } from '@/memberships/components/MemberProfile' import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' import { BuyMembershipModal, BuyMembershipModalCall } from '@/memberships/modals/BuyMembershipModal' @@ -117,6 +118,7 @@ export type ModalNames = | ModalName | ModalName | ModalName + | ModalName const modals: Record = { Member: , @@ -164,6 +166,7 @@ const modals: Record = { ClaimVestingModal: , UpdateMembershipModal: , ReportContentModal: , + PostReplyModal: , } const GUEST_ACCESSIBLE_MODALS: ModalNames[] = [ diff --git a/packages/ui/src/app/pages/Forum/ForumThread.tsx b/packages/ui/src/app/pages/Forum/ForumThread.tsx index fb6db13e9f..95199ee6ca 100644 --- a/packages/ui/src/app/pages/Forum/ForumThread.tsx +++ b/packages/ui/src/app/pages/Forum/ForumThread.tsx @@ -1,6 +1,6 @@ import { ForumPostMetadata } from '@joystream/metadata-protobuf' -import React, { useEffect, useRef, useState } from 'react' -import { generatePath, useHistory, useParams } from 'react-router-dom' +import React, { useEffect, useRef } from 'react' +import { useHistory, useParams } from 'react-router-dom' import styled from 'styled-components' import { useApi } from '@/api/hooks/useApi' @@ -25,7 +25,6 @@ import { ThreadTitle } from '@/forum/components/Thread/ThreadTitle' import { WatchlistButton } from '@/forum/components/Thread/WatchlistButton' import { ForumRoutes } from '@/forum/constant' import { useForumThread } from '@/forum/hooks/useForumThread' -import { ForumPost } from '@/forum/types' import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' import { ForumPageLayout } from './components/ForumPageLayout' @@ -44,11 +43,6 @@ export const ForumThread = () => { const sideNeighborRef = useRef(null) const newPostRef = useRef(null) const history = useHistory() - const [replyTo, setReplyTo] = useState() - - useEffect(() => { - replyTo && newPostRef.current?.scrollIntoView({ behavior: 'smooth', inline: 'end' }) - }, [replyTo]) const isThreadActive = !!(thread && thread.status.__typename === 'ThreadStatusActive') @@ -59,7 +53,7 @@ export const ForumThread = () => { createType('ForumUserId', Number.parseInt(active.id)), categoryId, threadId, - metadataToBytes(ForumPostMetadata, { text: postText, repliesTo: replyTo ? Number(replyTo.id) : undefined }), + metadataToBytes(ForumPostMetadata, { text: postText, repliesTo: undefined }), isEditable ) } @@ -114,16 +108,8 @@ export const ForumThread = () => { const displayMain = () => ( - - {thread && isThreadActive && ( - setReplyTo(undefined)} - getTransaction={getTransaction} - replyToLink={`${generatePath(ForumRoutes.thread, { id: thread.id })}?post=${replyTo?.id}`} - /> - )} + + {thread && isThreadActive && } ) diff --git a/packages/ui/src/bounty/components/BountyDiscussion/BountyDiscussion.tsx b/packages/ui/src/bounty/components/BountyDiscussion/BountyDiscussion.tsx index fd3010b03f..68f6dd529e 100644 --- a/packages/ui/src/bounty/components/BountyDiscussion/BountyDiscussion.tsx +++ b/packages/ui/src/bounty/components/BountyDiscussion/BountyDiscussion.tsx @@ -1,13 +1,10 @@ -import React, { useCallback } from 'react' +import React from 'react' import { useTranslation } from 'react-i18next' -import { generatePath, useHistory } from 'react-router-dom' import styled from 'styled-components' import { TextMedium } from '@/common/components/typography' import { PostList } from '@/forum/components/PostList/PostList' -import { ForumRoutes } from '@/forum/constant' import { useForumThread } from '@/forum/hooks/useForumThread' -import { ForumPost } from '@/forum/types' export interface BountyDiscussionProps { discussionThreadId: string @@ -15,31 +12,15 @@ export interface BountyDiscussionProps { export const BountyDiscussion = React.memo(({ discussionThreadId }: BountyDiscussionProps) => { const { t } = useTranslation('bounty') - const { push } = useHistory() const { isLoading, thread } = useForumThread(discussionThreadId) const isThreadActive = !!(thread && thread.status.__typename === 'ThreadStatusActive') - const replyToPost = useCallback( - (post: ForumPost) => { - if (thread) { - return push(`${generatePath(ForumRoutes.thread, { id: thread.id })}?post=${post.id}`) - } - }, - [thread] - ) - return ( {t('discussionThread.title')} - + ) }) diff --git a/packages/ui/src/common/components/Modal/ModalTransactionFooter.tsx b/packages/ui/src/common/components/Modal/ModalTransactionFooter.tsx index 71fa3abfdf..607dc4ba32 100644 --- a/packages/ui/src/common/components/Modal/ModalTransactionFooter.tsx +++ b/packages/ui/src/common/components/Modal/ModalTransactionFooter.tsx @@ -20,10 +20,12 @@ interface Props { next: ButtonState className?: string extraButtons?: ReactNode + extraLeftButtons?: ReactNode } export const ModalTransactionFooter: FC = ({ extraButtons, + extraLeftButtons, transactionFee, prev, next, @@ -34,6 +36,7 @@ export const ModalTransactionFooter: FC = ({ return ( + {extraLeftButtons} {prev && !prev.disabled && ( diff --git a/packages/ui/src/forum/components/PostList/PostList.tsx b/packages/ui/src/forum/components/PostList/PostList.tsx index c12110b38c..258eae5441 100644 --- a/packages/ui/src/forum/components/PostList/PostList.tsx +++ b/packages/ui/src/forum/components/PostList/PostList.tsx @@ -12,7 +12,6 @@ import { AnyKeys } from '@/common/types' import { getUrl } from '@/common/utils/getUrl' import { ForumRoutes } from '@/forum/constant' import { useForumThreadPosts } from '@/forum/hooks/useForumThreadPosts' -import { ForumPost } from '@/forum/types' import { ForumPostStyles, PostListItem } from './PostListItem' @@ -20,11 +19,10 @@ interface PostListProps { threadId: string isThreadActive?: boolean isLoading?: boolean - replyToPost: (post: ForumPost) => void isDiscussion?: boolean } -export const PostList = ({ threadId, isThreadActive, isLoading, replyToPost, isDiscussion }: PostListProps) => { +export const PostList = ({ threadId, isThreadActive, isLoading, isDiscussion }: PostListProps) => { const history = useHistory() const { pathname } = useLocation() const query = useRouteQuery() @@ -61,7 +59,6 @@ export const PostList = ({ threadId, isThreadActive, isLoading, replyToPost, isD isSelected={post.id === navigation.post} isThreadActive={isThreadActive} type="forum" - replyToPost={() => replyToPost({ ...post, repliesTo: undefined })} link={getUrl({ route: ForumRoutes.thread, params: { id: threadId }, query: { post: post.id } })} repliesToLink={`${generatePath(ForumRoutes.thread, { id: threadId })}?post=${post.repliesTo?.id}`} isDiscussion={isDiscussion} diff --git a/packages/ui/src/forum/components/PostList/PostListItem.stories.tsx b/packages/ui/src/forum/components/PostList/PostListItem.stories.tsx index 65306fde99..1281a754c5 100644 --- a/packages/ui/src/forum/components/PostList/PostListItem.stories.tsx +++ b/packages/ui/src/forum/components/PostList/PostListItem.stories.tsx @@ -10,6 +10,7 @@ import { PostListItem } from '@/forum/components/PostList/PostListItem' import { ForumPost } from '@/forum/types' import { MembershipContext } from '@/memberships/providers/membership/context' import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvider' +import { forumPostMock } from '@/mocks/data/commonMocks' import { getMember } from '@/mocks/helpers' export default { @@ -60,7 +61,6 @@ const Template: Story = ({ post, text, edited = -1, likes = -1, replyText post={{ ...post, lastEditedAt, text, reaction, repliesTo }} isThreadActive={isThreadActive} type="forum" - replyToPost={() => true} link="#" repliesToLink="" /> @@ -80,30 +80,7 @@ consequat sunt nostrud.`, replyText: `Amet minim mollit non deserunt ullamco est sit aliqua dolor do amet sint. Velit officia consequat duis enim velit mollit. Exercitation veniam consequat sunt nostrud amet.`, - post: { - id: '0', - createdAt: new Date().toISOString(), - createdAtBlock: { - number: 1000, - network: 'OLYMPIA', - timestamp: '2012-01-26T13:51:50.417-07:00', - }, - author: { - id: '0', - name: 'Alice member', - rootAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', - controllerAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', - handle: 'alice', - isVerified: false, - isFoundingMember: false, - isCouncilMember: false, - roles: [], - boundAccounts: [], - inviteCount: 0, - createdAt: '', - }, - status: 'PostStatusActive', - }, + post: forumPostMock, isThreadActive: true, } @@ -118,27 +95,7 @@ consequat sunt nostrud.`, Velit officia consequat duis enim velit mollit. Exercitation veniam consequat sunt nostrud amet.`, post: { - id: '0', - createdAt: new Date().toISOString(), - createdAtBlock: { - number: 1000, - network: 'OLYMPIA', - timestamp: '2012-01-26T13:51:50.417-07:00', - }, - author: { - id: '0', - name: 'Alice member', - rootAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', - controllerAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', - handle: 'alice', - isVerified: false, - isFoundingMember: false, - isCouncilMember: false, - roles: [], - boundAccounts: [], - inviteCount: 0, - createdAt: '', - }, + ...forumPostMock, moderator: { id: '0', name: 'Alice member', diff --git a/packages/ui/src/forum/components/PostList/PostListItem.tsx b/packages/ui/src/forum/components/PostList/PostListItem.tsx index f333e99357..1b9b628b4a 100644 --- a/packages/ui/src/forum/components/PostList/PostListItem.tsx +++ b/packages/ui/src/forum/components/PostList/PostListItem.tsx @@ -18,6 +18,7 @@ import { useLocation } from '@/common/hooks/useLocation' import { useModal } from '@/common/hooks/useModal' import { relativeIfRecent } from '@/common/model/relativeIfRecent' import { PostHistoryModalCall } from '@/forum/modals/PostHistoryModal' +import { PostReplyModalCall } from '@/forum/modals/PostReplyModal' import { ForumPost } from '@/forum/types' import { MemberInfo } from '@/memberships/components' import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' @@ -37,7 +38,6 @@ interface PostListItemProps { isThreadActive?: boolean insertRef?: (ref: RefObject) => void type: PostListItemType - replyToPost: () => void link?: string isDiscussion?: boolean repliesToLink: string @@ -52,7 +52,6 @@ export const PostListItem = ({ insertRef, type, link, - replyToPost, isDiscussion, repliesToLink, }: PostListItemProps) => { @@ -96,7 +95,13 @@ export const PostListItem = ({ const onReply = (): void => { if (!active) showModal({ modal: 'SwitchMember' }) - return replyToPost() + showModal({ + modal: 'PostReplyModal', + data: { + replyTo: post, + module: type === 'forum' ? type : 'proposalsDiscussion', + }, + }) } return ( diff --git a/packages/ui/src/forum/components/Thread/NewThreadPost.tsx b/packages/ui/src/forum/components/Thread/NewThreadPost.tsx index a1c6fcc837..589775ccc3 100644 --- a/packages/ui/src/forum/components/Thread/NewThreadPost.tsx +++ b/packages/ui/src/forum/components/Thread/NewThreadPost.tsx @@ -1,20 +1,15 @@ import { SubmittableExtrinsic } from '@polkadot/api/types' import { ISubmittableResult } from '@polkadot/types/types' import React, { Ref, RefObject, useCallback, useRef, useState } from 'react' -import { Link } from 'react-router-dom' -import { ButtonPrimary, ButtonsGroup } from '@/common/components/buttons' +import { ButtonsGroup } from '@/common/components/buttons' import { TransactionButton } from '@/common/components/buttons/TransactionButton' import { BaseCKEditor } from '@/common/components/CKEditor' import { Checkbox, InputComponent } from '@/common/components/forms' -import { ArrowReplyIcon, CrossIcon } from '@/common/components/icons' -import { MarkdownPreview } from '@/common/components/MarkdownPreview' import { RowGapBlock } from '@/common/components/page/PageContent' -import { Badge, TextBig } from '@/common/components/typography' +import { TextBig } from '@/common/components/typography' import { useModal } from '@/common/hooks/useModal' -import { Reply, ReplyBadge } from '@/forum/components/PostList/PostListItem' import { CreatePostModalCall } from '@/forum/modals/PostActionModal/CreatePostModal' -import { ForumPost } from '@/forum/types' import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' type GetTransaction = ( @@ -24,13 +19,10 @@ type GetTransaction = ( export interface NewPostProps { getTransaction: GetTransaction - replyTo?: ForumPost - removeReply: () => void - replyToLink: string } export const NewThreadPost = React.forwardRef( - ({ getTransaction, replyTo, removeReply, replyToLink }: NewPostProps, ref: React.ForwardedRef) => { + ({ getTransaction }: NewPostProps, ref: React.ForwardedRef) => { const [postText, setText] = useState('') const [isEditable, setEditable] = useState(true) const { active } = useMyMemberships() @@ -49,24 +41,6 @@ export const NewThreadPost = React.forwardRef( return ( - {replyTo && ( - - -
- {' '} - - Replies to {replyTo.author.handle} - -
-
- - - -
-
- -
- )} ({ modal: 'CreatePost', - data: { module: 'proposalsDiscussion', postText, replyTo, transaction, isEditable, onSuccess }, + data: { module: 'proposalsDiscussion', postText, transaction, isEditable, onSuccess }, }) }} disabled={postText === ''} > - {replyTo ? 'Post a reply' : 'Create post'} + Create post Keep editable diff --git a/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/CreatePostModal.tsx b/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/CreatePostModal.tsx index b72e75ff93..9767fe264f 100644 --- a/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/CreatePostModal.tsx +++ b/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/CreatePostModal.tsx @@ -1,9 +1,7 @@ import React, { useEffect, useMemo } from 'react' -import { useTranslation } from 'react-i18next' import { useBalance } from '@/accounts/hooks/useBalance' import { useTransactionFee } from '@/accounts/hooks/useTransactionFee' -import { InsufficientFundsModal } from '@/accounts/modals/InsufficientFundsModal' import { useApi } from '@/api/hooks/useApi' import { TextMedium, TokenValue } from '@/common/components/typography' import { BN_ZERO } from '@/common/constants' @@ -13,14 +11,14 @@ import { SignTransactionModal } from '@/common/modals/SignTransactionModal/SignT import { defaultTransactionModalMachine } from '@/common/model/machines/defaultTransactionModalMachine' import { getFeeSpendableBalance } from '@/common/providers/transactionFees/provider' import { PreviewPostButton } from '@/forum/components/PreviewPostButton' +import { PostInsufficientFundsModal } from '@/forum/modals/PostActionModal/components/PostInsufficientFundsModal' import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' import { CreatePostModalCall } from '.' export const CreatePostModal = () => { - const { t } = useTranslation('accounts') - const { modalData, hideModal } = useModal() - const { module = 'forum', postText, replyTo, transaction, isEditable, onSuccess } = modalData + const { modalData } = useModal() + const { module = 'forum', postText, transaction, isEditable, onSuccess } = modalData const [state, send] = useMachine( defaultTransactionModalMachine('There was a problem posting your message.', 'Your post has been submitted.'), @@ -67,7 +65,7 @@ export const CreatePostModal = () => { if (state.matches('transaction') && transaction && active && postDeposit) { return ( { ] : undefined } - extraButtons={} + extraButtons={} > You intend to post in a thread. {isEditable && ( @@ -94,25 +92,7 @@ export const CreatePostModal = () => { } if (state.matches('requirementsFailed') && feeInfo && requiredAmount && active) { - return ( - - - {t('modals.insufficientFunds.feeInfo1')} - {feeInfo.transactionFee.gtn(0) && ( - <> - - {t('modals.insufficientFunds.feeInfo2')} - - )} - {postDeposit?.gtn(0) && ( - <> - {feeInfo.transactionFee.gtn(0) && <> and} available to deposit to - make the post editable - - )} - - - ) + return } return null diff --git a/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/index.ts b/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/index.ts index ae9f0736bc..b70a1971ae 100644 --- a/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/index.ts +++ b/packages/ui/src/forum/modals/PostActionModal/CreatePostModal/index.ts @@ -2,7 +2,6 @@ import { SubmittableExtrinsic } from '@polkadot/api/types' import { ISubmittableResult } from '@polkadot/types/types' import { ModalWithDataCall } from '@/common/providers/modal/types' -import { ForumPost } from '@/forum/types' export * from './CreatePostModal' export type CreatePostModalCall = ModalWithDataCall< @@ -10,7 +9,6 @@ export type CreatePostModalCall = ModalWithDataCall< { postText: string module?: 'forum' | 'proposalsDiscussion' - replyTo?: ForumPost isEditable: boolean transaction: SubmittableExtrinsic<'rxjs', ISubmittableResult> onSuccess: () => void diff --git a/packages/ui/src/forum/modals/PostActionModal/components/PostInsufficientFundsModal.tsx b/packages/ui/src/forum/modals/PostActionModal/components/PostInsufficientFundsModal.tsx new file mode 100644 index 0000000000..602a2876ac --- /dev/null +++ b/packages/ui/src/forum/modals/PostActionModal/components/PostInsufficientFundsModal.tsx @@ -0,0 +1,43 @@ +import BN from 'bn.js' +import React from 'react' +import { useTranslation } from 'react-i18next' + +import { InsufficientFundsModal } from '@/accounts/modals/InsufficientFundsModal' +import { TextMedium, TokenValue } from '@/common/components/typography' +import { useModal } from '@/common/hooks/useModal' +import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' + +interface PostInsufficientFundsModalProps { + requiredAmount: BN + feeInfo: { transactionFee: BN; canAfford: boolean } + postDeposit?: BN +} + +export const PostInsufficientFundsModal = ({ + feeInfo, + requiredAmount, + postDeposit, +}: PostInsufficientFundsModalProps) => { + const { t } = useTranslation() + const { hideModal } = useModal() + const { active } = useMyMemberships() + return ( + + + {t('modals.insufficientFunds.feeInfo1')} + {feeInfo.transactionFee.gtn(0) && ( + <> + + {t('modals.insufficientFunds.feeInfo2')} + + )} + {postDeposit?.gtn(0) && ( + <> + {feeInfo.transactionFee.gtn(0) && <> and} available to deposit to make + the post editable + + )} + + + ) +} diff --git a/packages/ui/src/forum/modals/PostReplyModal/PostReplyModal.tsx b/packages/ui/src/forum/modals/PostReplyModal/PostReplyModal.tsx new file mode 100644 index 0000000000..0ae656835b --- /dev/null +++ b/packages/ui/src/forum/modals/PostReplyModal/PostReplyModal.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useMemo } from 'react' +import styled from 'styled-components' + +import { useBalance } from '@/accounts/hooks/useBalance' +import { useTransactionFee } from '@/accounts/hooks/useTransactionFee' +import { useApi } from '@/api/hooks/useApi' +import { CKEditor } from '@/common/components/CKEditor' +import { Checkbox, InputComponent } from '@/common/components/forms' +import { MarkdownPreview } from '@/common/components/MarkdownPreview' +import { Modal, ModalBody, ModalHeader, ModalTransactionFooter } from '@/common/components/Modal' +import { TextMedium, TokenValue } from '@/common/components/typography' +import { BN_ZERO } from '@/common/constants' +import { useMachine } from '@/common/hooks/useMachine' +import { useModal } from '@/common/hooks/useModal' +import { SignTransactionModal } from '@/common/modals/SignTransactionModal/SignTransactionModal' +import { getFeeSpendableBalance } from '@/common/providers/transactionFees/provider' +import { PreviewPostButton } from '@/forum/components/PreviewPostButton' +import { PostInsufficientFundsModal } from '@/forum/modals/PostActionModal/components/PostInsufficientFundsModal' +import { transactionFactory } from '@/forum/modals/PostReplyModal/helpers' +import { PostReplyModalCall } from '@/forum/modals/PostReplyModal/index' +import { postReplyMachine, PostReplyStateName } from '@/forum/modals/PostReplyModal/machine' +import { MemberInfo } from '@/memberships/components' +import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' +import { Member } from '@/memberships/types' + +export const PostReplyModal = () => { + const { modalData, hideModal } = useModal() + const [state, send] = useMachine(postReplyMachine) + const { active } = useMyMemberships() + const { api } = useApi() + const balance = useBalance(active?.controllerAccount) + + const { replyTo, module = 'forum' } = modalData + const postDeposit = api?.consts[module].postDeposit.toBn() + const transaction = useMemo(() => { + if (api && active) { + return transactionFactory(api, module, state.context.postText, state.context.isEditable, replyTo, active.id) + } + }, [api?.isConnected]) + const { feeInfo } = useTransactionFee(active?.controllerAccount, () => transaction) + const requiredAmount = useMemo( + () => feeInfo && api && feeInfo.transactionFee.add(postDeposit ?? BN_ZERO), + [feeInfo, postDeposit] + ) + + useEffect(() => { + if (!(feeInfo && requiredAmount && active && balance)) { + return + } + + if (state.matches(PostReplyStateName.requirementsVerification)) { + if (state.context.isEditable ? getFeeSpendableBalance(balance).gte(requiredAmount) : feeInfo.canAfford) { + send('NEXT') + } else { + send('FAIL') + } + } + }, [state.value, JSON.stringify(feeInfo), postDeposit, balance]) + + if (state.matches(PostReplyStateName.prepare)) + return ( + + + + + + + + send({ type: 'SET_TEXT', payload: event.getData() })} + /> + + + + send({ type: 'SET_EDITABLE', payload })} + isChecked={state.context.isEditable} + > + Keep editable + + } + extraButtons={ + + } + next={{ onClick: () => send('NEXT'), disabled: !state.context.postText, label: 'Post a Reply' }} + /> + + ) + + if (state.matches(PostReplyStateName.transaction) && transaction && active && postDeposit) { + return ( + } + > + You intend to post in a thread. + {state.context.isEditable && ( + + will be deposited to make the post editable. + + )} + + ) + } + + if (state.matches(PostReplyStateName.requirementsFailed) && feeInfo && requiredAmount && active) { + return + } + + return null +} + +const MainContainer = styled.div` + display: flex; + flex-direction: column; + row-gap: 20px; + :last-child { + flex: 1; + margin-top: 10px; + } +` diff --git a/packages/ui/src/forum/modals/PostReplyModal/helpers.ts b/packages/ui/src/forum/modals/PostReplyModal/helpers.ts new file mode 100644 index 0000000000..f54a377851 --- /dev/null +++ b/packages/ui/src/forum/modals/PostReplyModal/helpers.ts @@ -0,0 +1,35 @@ +import { ForumPostMetadata } from '@joystream/metadata-protobuf' + +import { createType } from '@/common/model/createType' +import { metadataToBytes } from '@/common/model/JoystreamNode' +import { ForumPost } from '@/forum/types' +import { ProxyApi } from '@/proxyApi' + +export const transactionFactory = ( + api: ProxyApi, + module: 'forum' | 'proposalsDiscussion', + text: string, + isEditable: boolean, + replyTo: ForumPost, + memberId: string +) => { + if (module === 'forum') { + return api.tx.forum.addPost( + createType('ForumUserId', Number.parseInt(memberId)), + replyTo.categoryId, + replyTo.threadId, + metadataToBytes(ForumPostMetadata, { + text, + repliesTo: Number(replyTo.id), + }), + isEditable + ) + } + + return api.tx.proposalsDiscussion.addPost( + createType('MemberId', Number.parseInt(memberId)), + replyTo.threadId, + metadataToBytes(ForumPostMetadata, { text, repliesTo: Number(replyTo.id) }), + isEditable + ) +} diff --git a/packages/ui/src/forum/modals/PostReplyModal/index.ts b/packages/ui/src/forum/modals/PostReplyModal/index.ts new file mode 100644 index 0000000000..ff33b76165 --- /dev/null +++ b/packages/ui/src/forum/modals/PostReplyModal/index.ts @@ -0,0 +1,12 @@ +import { ModalWithDataCall } from '@/common/providers/modal/types' +import { ForumPost } from '@/forum/types' + +export * from './PostReplyModal' + +export type PostReplyModalCall = ModalWithDataCall< + 'PostReplyModal', + { + replyTo: ForumPost + module?: 'forum' | 'proposalsDiscussion' + } +> diff --git a/packages/ui/src/forum/modals/PostReplyModal/machine.ts b/packages/ui/src/forum/modals/PostReplyModal/machine.ts new file mode 100644 index 0000000000..4ac3093a8b --- /dev/null +++ b/packages/ui/src/forum/modals/PostReplyModal/machine.ts @@ -0,0 +1,102 @@ +import { EventRecord } from '@polkadot/types/interfaces/system' +import { assign, createMachine } from 'xstate' + +import { transactionModalFinalStatusesFactory } from '@/common/modals/utils' +import { + isTransactionCanceled, + isTransactionError, + isTransactionSuccess, + transactionMachine, +} from '@/common/model/machines' + +interface PrepareContext { + postText: string + isEditable: boolean +} + +interface TransactionContext extends PrepareContext { + transactionEvents?: EventRecord[] +} + +export enum PostReplyStateName { + requirementsVerification = 'requirementsVerification', + requirementsFailed = 'requirementsFailed', + prepare = 'prepare', + transaction = 'transaction', + success = 'success', + error = 'error', +} + +type PostReplyState = + | { value: PostReplyStateName.requirementsVerification; context: PrepareContext } + | { value: PostReplyStateName.requirementsFailed; context: PrepareContext } + | { value: PostReplyStateName.prepare; context: PrepareContext } + | { value: PostReplyStateName.transaction; context: TransactionContext } + | { value: PostReplyStateName.success; context: Required } + | { value: PostReplyStateName.error; context: Required } + +export type PostReplyEvent = + | { type: 'FAIL' } + | { type: 'NEXT' } + | { type: 'SET_TEXT'; payload: string } + | { type: 'SET_EDITABLE'; payload: boolean } + +export const postReplyMachine = createMachine({ + initial: 'prepare', + context: { + isEditable: false, + postText: '', + }, + states: { + requirementsVerification: { + on: { + NEXT: 'prepare', + FAIL: 'requirementsFailed', + }, + }, + requirementsFailed: { + type: 'final', + }, + prepare: { + on: { + SET_TEXT: { + actions: assign({ + postText: (_, event) => event.payload, + }), + }, + SET_EDITABLE: { + actions: assign({ + isEditable: (_, event) => event.payload, + }), + }, + NEXT: 'transaction', + }, + }, + transaction: { + invoke: { + id: 'transaction', + src: transactionMachine, + onDone: [ + { + target: 'success', + cond: isTransactionSuccess, + }, + { + target: 'error', + cond: isTransactionError, + actions: assign({ transactionEvents: (_, event) => event.data.events }), + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + }, + ...transactionModalFinalStatusesFactory({ + metaMessages: { + error: 'There was a problem with replying to the post.', + }, + }), + }, +}) diff --git a/packages/ui/src/forum/modals/PreviewPostModal/PreviewPostModal.tsx b/packages/ui/src/forum/modals/PreviewPostModal/PreviewPostModal.tsx index edfa43d9b0..2515c88d78 100644 --- a/packages/ui/src/forum/modals/PreviewPostModal/PreviewPostModal.tsx +++ b/packages/ui/src/forum/modals/PreviewPostModal/PreviewPostModal.tsx @@ -13,6 +13,7 @@ import { capitalizeFirstLetter } from '@/common/helpers' import { PostListItem } from '@/forum/components/PostList/PostListItem' import { ForumPost } from '@/forum/types' import { Member } from '@/memberships/types' +import { forumPostMock } from '@/mocks/data/commonMocks' export interface PreviewPostModalProps { onClose: () => void @@ -25,6 +26,7 @@ export interface PreviewPostModalProps { export const PreviewPostModal = ({ onClose, author, replyTo, text, type = 'thread' }: PreviewPostModalProps) => { const post: ForumPost = useMemo( () => ({ + ...forumPostMock, id: '', createdAt: new Date(Date.now()).toString(), author, @@ -40,14 +42,7 @@ export const PreviewPostModal = ({ onClose, author, replyTo, text, type = 'threa - true} - type="forum" - isPreview - repliesToLink="-1" - /> + diff --git a/packages/ui/src/forum/queries/__generated__/forum.generated.tsx b/packages/ui/src/forum/queries/__generated__/forum.generated.tsx index 905aba82f4..4718cace15 100644 --- a/packages/ui/src/forum/queries/__generated__/forum.generated.tsx +++ b/packages/ui/src/forum/queries/__generated__/forum.generated.tsx @@ -278,6 +278,7 @@ export type ForumPostFieldsFragment = { updatedAt?: any | null text: string authorId: string + threadId: string repliesTo?: { __typename: 'ForumPost' id: string @@ -285,6 +286,7 @@ export type ForumPostFieldsFragment = { updatedAt?: any | null text: string authorId: string + threadId: string author: { __typename: 'Membership' id: string @@ -373,6 +375,7 @@ export type ForumPostFieldsFragment = { | { __typename: 'PostStatusModerated' } | { __typename: 'PostStatusRemoved' } edits: Array<{ __typename: 'PostTextUpdatedEvent'; createdAt: any }> + thread: { __typename: 'ForumThread'; categoryId: string } } | null author: { __typename: 'Membership' @@ -462,6 +465,7 @@ export type ForumPostFieldsFragment = { | { __typename: 'PostStatusModerated' } | { __typename: 'PostStatusRemoved' } edits: Array<{ __typename: 'PostTextUpdatedEvent'; createdAt: any }> + thread: { __typename: 'ForumThread'; categoryId: string } } export type ForumPostWithoutReplyFieldsFragment = { @@ -471,6 +475,7 @@ export type ForumPostWithoutReplyFieldsFragment = { updatedAt?: any | null text: string authorId: string + threadId: string author: { __typename: 'Membership' id: string @@ -559,6 +564,7 @@ export type ForumPostWithoutReplyFieldsFragment = { | { __typename: 'PostStatusModerated' } | { __typename: 'PostStatusRemoved' } edits: Array<{ __typename: 'PostTextUpdatedEvent'; createdAt: any }> + thread: { __typename: 'ForumThread'; categoryId: string } } export type ForumThreadDetailedFieldsFragment = { @@ -1103,6 +1109,7 @@ export type GetForumPostsQuery = { updatedAt?: any | null text: string authorId: string + threadId: string repliesTo?: { __typename: 'ForumPost' id: string @@ -1110,6 +1117,7 @@ export type GetForumPostsQuery = { updatedAt?: any | null text: string authorId: string + threadId: string author: { __typename: 'Membership' id: string @@ -1198,6 +1206,7 @@ export type GetForumPostsQuery = { | { __typename: 'PostStatusModerated' } | { __typename: 'PostStatusRemoved' } edits: Array<{ __typename: 'PostTextUpdatedEvent'; createdAt: any }> + thread: { __typename: 'ForumThread'; categoryId: string } } | null author: { __typename: 'Membership' @@ -1287,6 +1296,7 @@ export type GetForumPostsQuery = { | { __typename: 'PostStatusModerated' } | { __typename: 'PostStatusRemoved' } edits: Array<{ __typename: 'PostTextUpdatedEvent'; createdAt: any }> + thread: { __typename: 'ForumThread'; categoryId: string } }> } @@ -1619,6 +1629,10 @@ export const ForumPostWithoutReplyFieldsFragmentDoc = gql` edits { createdAt } + threadId + thread { + categoryId + } } ${MemberFieldsFragmentDoc} ` diff --git a/packages/ui/src/forum/queries/forum.graphql b/packages/ui/src/forum/queries/forum.graphql index e3b1976cc7..f4b17ebe7f 100644 --- a/packages/ui/src/forum/queries/forum.graphql +++ b/packages/ui/src/forum/queries/forum.graphql @@ -162,6 +162,10 @@ fragment ForumPostWithoutReplyFields on ForumPost { edits { createdAt } + threadId + thread { + categoryId + } } fragment ForumThreadDetailedFields on ForumThread { diff --git a/packages/ui/src/forum/types/ForumPost.ts b/packages/ui/src/forum/types/ForumPost.ts index 607697f316..f0bb7cd823 100644 --- a/packages/ui/src/forum/types/ForumPost.ts +++ b/packages/ui/src/forum/types/ForumPost.ts @@ -16,6 +16,8 @@ export interface ForumPost { reaction?: PostReaction[] moderator?: Member status: PostStatusTypename + threadId: string + categoryId: string } export const asForumPost = (fields: ForumPostFieldsFragment): ForumPost => ({ @@ -37,6 +39,8 @@ export const asForumPost = (fields: ForumPostFieldsFragment): ForumPost => ({ ? asMember(fields.postmoderatedeventpost[0].actor.membership) : undefined, status: fields.status.__typename, + categoryId: fields.thread.categoryId, + threadId: fields.threadId, }) export interface PostEdit { diff --git a/packages/ui/src/mocks/data/commonMocks.ts b/packages/ui/src/mocks/data/commonMocks.ts new file mode 100644 index 0000000000..4b8e4161f3 --- /dev/null +++ b/packages/ui/src/mocks/data/commonMocks.ts @@ -0,0 +1,29 @@ +import { ForumPost } from '@/forum/types' + +export const forumPostMock: ForumPost = { + id: '0', + createdAt: new Date().toISOString(), + createdAtBlock: { + number: 1000, + network: 'OLYMPIA', + timestamp: '2012-01-26T13:51:50.417-07:00', + }, + author: { + id: '0', + name: 'Alice member', + rootAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + controllerAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + handle: 'alice', + isVerified: false, + isFoundingMember: false, + isCouncilMember: false, + roles: [], + boundAccounts: [], + inviteCount: 0, + createdAt: '', + }, + text: 'Forum post common mock', + status: 'PostStatusActive', + threadId: '1', + categoryId: '1', +} diff --git a/packages/ui/src/proposals/components/ProposalDiscussions.tsx b/packages/ui/src/proposals/components/ProposalDiscussions.tsx index 1bd669b24e..790917be51 100644 --- a/packages/ui/src/proposals/components/ProposalDiscussions.tsx +++ b/packages/ui/src/proposals/components/ProposalDiscussions.tsx @@ -1,5 +1,5 @@ import { ForumPostMetadata } from '@joystream/metadata-protobuf' -import React, { RefObject, useEffect, useMemo, useRef, useState } from 'react' +import React, { RefObject, useMemo, useRef } from 'react' import { generatePath } from 'react-router-dom' import styled, { css } from 'styled-components' @@ -14,7 +14,6 @@ import { AnyKeys } from '@/common/types' import { getUrl } from '@/common/utils/getUrl' import { ForumPostStyles, PostListItem } from '@/forum/components/PostList/PostListItem' import { NewThreadPost } from '@/forum/components/Thread/NewThreadPost' -import { ForumPost } from '@/forum/types' import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' import { ProposalsRoutes } from '@/proposals/constants/routes' import { ProposalDiscussionThread } from '@/proposals/types' @@ -35,16 +34,11 @@ export const ProposalDiscussions = ({ thread, proposalId }: Props) => { (thread.mode === 'closed' && active && (thread.whitelistIds?.includes(active.id) || active.isCouncilMember)) const isInWhitelist = thread.mode === 'closed' && members.find((member) => thread.whitelistIds?.includes(member.id)) const hasCouncilMembership = thread.mode === 'closed' && members.find((member) => member.isCouncilMember) - const [replyTo, setReplyTo] = useState() const newPostRef = useRef(null) const postsRefs: AnyKeys = {} const getInsertRef = (postId: string) => (ref: RefObject) => (postsRefs[postId] = ref) - useEffect(() => { - replyTo && newPostRef.current?.scrollIntoView({ behavior: 'smooth', inline: 'end' }) - }, [replyTo]) - const discussionPosts = useMemo( () => thread.discussionPosts.filter((post) => post.status !== 'PostStatusRemoved'), [thread] @@ -55,7 +49,7 @@ export const ProposalDiscussions = ({ thread, proposalId }: Props) => { return api.tx.proposalsDiscussion.addPost( createType('MemberId', Number.parseInt(active.id)), thread.id, - metadataToBytes(ForumPostMetadata, { text: postText, repliesTo: replyTo ? Number(replyTo.id) : undefined }), + metadataToBytes(ForumPostMetadata, { text: postText, repliesTo: undefined }), isEditable ) } @@ -63,15 +57,7 @@ export const ProposalDiscussions = ({ thread, proposalId }: Props) => { const getPostForm = () => { if (isAbleToPost) { - return ( - setReplyTo(undefined)} - replyToLink={`${generatePath(ProposalsRoutes.preview, { id: proposalId })}?post=${replyTo?.id}`} - /> - ) + return } if (hasCouncilMembership) { @@ -109,7 +95,6 @@ export const ProposalDiscussions = ({ thread, proposalId }: Props) => { isSelected={post.id === initialPost} isThreadActive={true} post={post} - replyToPost={() => setReplyTo(post)} type="proposal" isDiscussion link={getUrl({ route: ProposalsRoutes.preview, params: { id: proposalId }, query: { post: post.id } })} diff --git a/packages/ui/src/proposals/queries/__generated__/proposals.generated.tsx b/packages/ui/src/proposals/queries/__generated__/proposals.generated.tsx index b8bd6fd3e5..f2319c5511 100644 --- a/packages/ui/src/proposals/queries/__generated__/proposals.generated.tsx +++ b/packages/ui/src/proposals/queries/__generated__/proposals.generated.tsx @@ -1,8 +1,9 @@ import * as Types from '../../../common/api/queries/__generated__/baseTypes.generated' +import * as Apollo from '@apollo/client' import { gql } from '@apollo/client' import { MemberFieldsFragmentDoc } from '../../../memberships/queries/__generated__/members.generated' -import * as Apollo from '@apollo/client' + const defaultOptions = {} as const export type WorkerProposalDetailsFragment = { __typename: 'Worker' @@ -578,6 +579,7 @@ export type ProposalWithDetailsFieldsFragment = { | { __typename: 'ProposalDiscussionPostStatusActive' } | { __typename: 'ProposalDiscussionPostStatusLocked' } | { __typename: 'ProposalDiscussionPostStatusRemoved' } + discussionThread: { __typename: 'ProposalDiscussionThread'; id: string } } | null createdInEvent: { __typename: 'ProposalDiscussionPostCreatedEvent' @@ -622,6 +624,7 @@ export type ProposalWithDetailsFieldsFragment = { | { __typename: 'ProposalDiscussionPostStatusActive' } | { __typename: 'ProposalDiscussionPostStatusLocked' } | { __typename: 'ProposalDiscussionPostStatusRemoved' } + discussionThread: { __typename: 'ProposalDiscussionThread'; id: string } }> mode: | { @@ -735,6 +738,7 @@ export type DiscussionPostFieldsFragment = { | { __typename: 'ProposalDiscussionPostStatusActive' } | { __typename: 'ProposalDiscussionPostStatusLocked' } | { __typename: 'ProposalDiscussionPostStatusRemoved' } + discussionThread: { __typename: 'ProposalDiscussionThread'; id: string } } | null createdInEvent: { __typename: 'ProposalDiscussionPostCreatedEvent' @@ -779,6 +783,7 @@ export type DiscussionPostFieldsFragment = { | { __typename: 'ProposalDiscussionPostStatusActive' } | { __typename: 'ProposalDiscussionPostStatusLocked' } | { __typename: 'ProposalDiscussionPostStatusRemoved' } + discussionThread: { __typename: 'ProposalDiscussionThread'; id: string } } export type DiscussionPostWithoutReplyFieldsFragment = { @@ -830,6 +835,7 @@ export type DiscussionPostWithoutReplyFieldsFragment = { | { __typename: 'ProposalDiscussionPostStatusActive' } | { __typename: 'ProposalDiscussionPostStatusLocked' } | { __typename: 'ProposalDiscussionPostStatusRemoved' } + discussionThread: { __typename: 'ProposalDiscussionThread'; id: string } } export type ProposalPostParentsFragment = { __typename: 'ProposalDiscussionPost'; discussionThreadId: string } @@ -1396,6 +1402,7 @@ export type GetProposalQuery = { | { __typename: 'ProposalDiscussionPostStatusActive' } | { __typename: 'ProposalDiscussionPostStatusLocked' } | { __typename: 'ProposalDiscussionPostStatusRemoved' } + discussionThread: { __typename: 'ProposalDiscussionThread'; id: string } } | null createdInEvent: { __typename: 'ProposalDiscussionPostCreatedEvent' @@ -1440,6 +1447,7 @@ export type GetProposalQuery = { | { __typename: 'ProposalDiscussionPostStatusActive' } | { __typename: 'ProposalDiscussionPostStatusLocked' } | { __typename: 'ProposalDiscussionPostStatusRemoved' } + discussionThread: { __typename: 'ProposalDiscussionThread'; id: string } }> mode: | { @@ -1864,6 +1872,9 @@ export const DiscussionPostWithoutReplyFieldsFragmentDoc = gql` status { __typename } + discussionThread { + id + } } ${MemberFieldsFragmentDoc} ` diff --git a/packages/ui/src/proposals/queries/proposals.graphql b/packages/ui/src/proposals/queries/proposals.graphql index 9884bfa948..28b7d05f90 100644 --- a/packages/ui/src/proposals/queries/proposals.graphql +++ b/packages/ui/src/proposals/queries/proposals.graphql @@ -223,6 +223,9 @@ fragment DiscussionPostWithoutReplyFields on ProposalDiscussionPost { status { __typename } + discussionThread { + id + } } fragment ProposalPostParents on ProposalDiscussionPost { diff --git a/packages/ui/src/proposals/types/ProposalWithDetails.ts b/packages/ui/src/proposals/types/ProposalWithDetails.ts index 6b3c75e965..4ca5c998f5 100644 --- a/packages/ui/src/proposals/types/ProposalWithDetails.ts +++ b/packages/ui/src/proposals/types/ProposalWithDetails.ts @@ -92,4 +92,6 @@ const asForumComment = (fields: DiscussionPostFieldsFragment): ForumPost => ({ text: fields.text, ...(fields.repliesTo ? { repliesTo: asForumComment(fields.repliesTo) } : {}), status: asDiscussionPostStatus(fields.status.__typename), + categoryId: '1', + threadId: fields.discussionThread.id, }) diff --git a/packages/ui/test/forum/components/NewThreadPost.test.tsx b/packages/ui/test/forum/components/NewThreadPost.test.tsx index c6b105d273..2fba64ff0b 100644 --- a/packages/ui/test/forum/components/NewThreadPost.test.tsx +++ b/packages/ui/test/forum/components/NewThreadPost.test.tsx @@ -36,6 +36,7 @@ describe('UI: Add new post', () => { } }, } + const api = stubApi() stubTransaction(api, 'api.tx.forum.addPost') stubTransaction(api, 'api.tx.proposalsDiscussion.addPost') @@ -58,8 +59,6 @@ describe('UI: Add new post', () => { const props: NewPostProps = { getTransaction: (text, isEditable) => api.api.tx.forum.addPost(1, 1, 1, text, isEditable), - removeReply: () => true, - replyToLink: '', } it('No selected membership', async () => { @@ -90,33 +89,9 @@ describe('UI: Add new post', () => { expect(useModal.modalData.isEditable).toEqual(true) expect(useModal.modalData.replyTo).toEqual(replyTo) }) - - it('With reply', async () => { - useMyMemberships.setActive(getMember('alice')) - replyTo = { - id: '1', - author: getMember('bob'), - text: 'Some text', - status: 'PostStatusActive', - createdAt: new Date().toISOString(), - } - renderEditor({ ...props, replyTo }) - const editor = await screen.findByRole('textbox') - act(() => { - fireEvent.change(editor, { target: { value: 'I disagree' } }) - }) - await waitFor(async () => expect(await getButton('Post a reply')).not.toBeDisabled()) - await act(async () => { - fireEvent.click(await getButton('Post a reply')) - }) - expect(useModal.modal).toEqual('CreatePost') - expect(useModal.modalData.postText).toEqual('I disagree') - expect(useModal.modalData.isEditable).toEqual(true) - expect(useModal.modalData.replyTo).toEqual(replyTo) - }) }) - const renderEditor = (props: NewPostProps) => + const renderEditor = (props: NewPostProps) => { render( @@ -130,4 +105,5 @@ describe('UI: Add new post', () => { ) + } }) diff --git a/packages/ui/test/forum/components/PostEditor.test.tsx b/packages/ui/test/forum/components/PostEditor.test.tsx index 5ca5da6039..90cda83f5c 100644 --- a/packages/ui/test/forum/components/PostEditor.test.tsx +++ b/packages/ui/test/forum/components/PostEditor.test.tsx @@ -8,6 +8,7 @@ import { isModalWithData } from '@/common/providers/modal/provider' import { UseModal } from '@/common/providers/modal/types' import { PostEditor } from '@/forum/components/PostList/PostEditor' import { ForumPost } from '@/forum/types' +import { forumPostMock } from '@/mocks/data/commonMocks' import { getButton } from '../../_helpers/getButton' import { mockCKEditor } from '../../_mocks/components/CKEditor' @@ -20,6 +21,7 @@ jest.mock('@/common/components/CKEditor', () => ({ })) const post: ForumPost = { + ...forumPostMock, id: '1:1', createdAt: new Date().toISOString(), author: getMember('alice'), diff --git a/packages/ui/test/forum/components/PostListItem.test.tsx b/packages/ui/test/forum/components/PostListItem.test.tsx index 5294d8de2b..eb330fdb63 100644 --- a/packages/ui/test/forum/components/PostListItem.test.tsx +++ b/packages/ui/test/forum/components/PostListItem.test.tsx @@ -108,7 +108,7 @@ describe('UI: Post list item', () => { - true} isThreadActive={isThreadActive} /> + diff --git a/packages/ui/test/forum/modals/DeletePostModal.test.tsx b/packages/ui/test/forum/modals/DeletePostModal.test.tsx index 5b365b9f5c..9d1343c786 100644 --- a/packages/ui/test/forum/modals/DeletePostModal.test.tsx +++ b/packages/ui/test/forum/modals/DeletePostModal.test.tsx @@ -13,6 +13,7 @@ import { postsToDeleteMap } from '@/forum/model/postsToDeleteMap' import { MembershipContext } from '@/memberships/providers/membership/context' import { MyMemberships } from '@/memberships/providers/membership/provider' import { seedMember } from '@/mocks/data' +import { forumPostMock } from '@/mocks/data/commonMocks' import rawMembers from '@/mocks/data/raw/members.json' import { seedForumCategory, seedForumPost, seedForumThread } from '@/mocks/data/seedForum' @@ -48,6 +49,7 @@ describe('UI: DeletePostModal', () => { const modalData: ModalCallData = { post: { + ...forumPostMock, id: '0', author: getMember('alice'), createdAt: '2021-07-02T04:22:13.523Z',