From c6592b55f982ba0bb3f0d8cafdf31b210c8b7bbc Mon Sep 17 00:00:00 2001 From: Lezek123 Date: Sun, 22 Dec 2024 16:43:49 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=A7=20Fix=20optimistic=20actions?= =?UTF-8?q?=20handling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/atlas/src/.env | 2 +- .../api/hooks/useCommentSectionComments.ts | 1 + .../VideoOverlays/EndingOverlay.styles.ts | 2 +- .../atlas/src/hooks/useOptimisticActions.ts | 169 +++++++++--------- .../src/hooks/useReactionTransactions.ts | 92 ++++++---- .../networkUtils/networkUtils.provider.tsx | 25 +-- .../transactions/transactions.manager.tsx | 2 +- 7 files changed, 152 insertions(+), 141 deletions(-) diff --git a/packages/atlas/src/.env b/packages/atlas/src/.env index 4ed1ec82cd..5f98838619 100644 --- a/packages/atlas/src/.env +++ b/packages/atlas/src/.env @@ -60,7 +60,7 @@ VITE_NEXT_YPP_FAUCET_URL=wss://3.73.121.180.nip.io/ws-rpc # Local development env URLs VITE_LOCAL_ORION_AUTH_URL=http://localhost:4074/api/v1 VITE_LOCAL_ORION_URL=http://localhost:4350/graphql -VITE_LOCAL_QUERY_NODE_SUBSCRIPTION_URL=ws://localhost:8081/graphql +VITE_LOCAL_QUERY_NODE_SUBSCRIPTION_URL=ws://localhost:4350/graphql VITE_LOCAL_NODE_URL=ws://localhost:9944/ws-rpc VITE_LOCAL_FAUCET_URL=http://localhost:3002/register VITE_LOCAL_YPP_FAUCET_URL=https://52.204.147.11.nip.io/membership diff --git a/packages/atlas/src/api/hooks/useCommentSectionComments.ts b/packages/atlas/src/api/hooks/useCommentSectionComments.ts index da5375529a..7155574aff 100644 --- a/packages/atlas/src/api/hooks/useCommentSectionComments.ts +++ b/packages/atlas/src/api/hooks/useCommentSectionComments.ts @@ -22,6 +22,7 @@ export const useCommentSectionComments = ( const unconfirmedComments = data?.videoCommentsConnection.edges .map((edge) => edge.node) .filter((node) => node.id.includes(UNCONFIRMED)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) const unconfirmedCommentLookup = unconfirmedComments && createLookup(unconfirmedComments) const videoComments = data?.videoCommentsConnection?.edges diff --git a/packages/atlas/src/components/_video/VideoPlayer/VideoOverlays/EndingOverlay.styles.ts b/packages/atlas/src/components/_video/VideoPlayer/VideoOverlays/EndingOverlay.styles.ts index f5bd187812..ccc7b6ec4b 100644 --- a/packages/atlas/src/components/_video/VideoPlayer/VideoOverlays/EndingOverlay.styles.ts +++ b/packages/atlas/src/components/_video/VideoPlayer/VideoOverlays/EndingOverlay.styles.ts @@ -117,7 +117,7 @@ export const StyledChannelLink = styled(ChannelLink)` } } - > a:first-child { + > a:first-of-type { width: ${sizes(6)}; min-height: ${sizes(6)}; min-width: ${sizes(6)}; diff --git a/packages/atlas/src/hooks/useOptimisticActions.ts b/packages/atlas/src/hooks/useOptimisticActions.ts index 032c0c5d34..000a1f4fb3 100644 --- a/packages/atlas/src/hooks/useOptimisticActions.ts +++ b/packages/atlas/src/hooks/useOptimisticActions.ts @@ -3,7 +3,7 @@ import BN from 'bn.js' import { FragmentDefinitionNode, Kind } from 'graphql' import { useCallback } from 'react' -import { CommentStatus, CommentTipTier } from '@/api/queries/__generated__/baseTypes.generated' +import { CommentEdge, CommentStatus, CommentTipTier } from '@/api/queries/__generated__/baseTypes.generated' import { BasicMembershipFieldsFragmentDoc, CommentFieldsFragment, @@ -60,9 +60,8 @@ const fragmentName = (d: DocumentNode): string | undefined => { } export const UNCONFIRMED = 'UNCONFIRMED' -export const UNCOFIRMED_COMMENT = `${UNCONFIRMED}-COMMENT` -export const UNCOFIRMED_REPLY = `${UNCONFIRMED}-REPLY` -export const UNCOFIRMED_REACTION = `${UNCONFIRMED}-REACTION` +export const UNCONFIRMED_COMMENT = `${UNCONFIRMED}-COMMENT` +export const UNCONFIRMED_REACTION = `${UNCONFIRMED}-REACTION` const reactionFragment = gql` fragment CommentReactionFields on CommentReaction { @@ -102,7 +101,7 @@ export const useOptimisticActions = () => { }, }) - const recordId = `${commentId}-${videoId}-${UNCOFIRMED_REACTION}-${Date.now()}` + const recordId = `${commentId}-${videoId}-${UNCONFIRMED_REACTION}-${Date.now()}` client.cache.writeFragment({ id: `CommentReaction:${recordId}`, @@ -129,6 +128,8 @@ export const useOptimisticActions = () => { }, }) } + + return recordId }, [client.cache] ) @@ -188,6 +189,15 @@ export const useOptimisticActions = () => { [client.cache] ) + const evictUnconfirmedReaction = useCallback( + (id: string) => { + if (id.includes(UNCONFIRMED_REACTION)) { + client.cache.evict({ id: `CommentReaction:${id}` }) + } + }, + [client.cache] + ) + const deleteVideoComment = useCallback( ({ commentId }: { commentId: string }) => { client.cache.modify({ @@ -213,78 +223,72 @@ export const useOptimisticActions = () => { [client.cache] ) - const addVideoReplyComment = useCallback( - ({ memberId, videoId, text, parentCommentId }: AddCommentActionParams) => { - const commentId = Date.now() - const recordId = `${commentId}-${videoId}-${UNCOFIRMED_REPLY}` - client.cache.writeFragment({ - id: `Comment:${recordId}`, - fragment: CommentFieldsFragmentDoc, - fragmentName: fragmentName(CommentFieldsFragmentDoc), - data: { - __typename: 'Comment', - id: recordId, - isExcluded: false, - author: client.cache.readFragment({ - fragment: BasicMembershipFieldsFragmentDoc, - fragmentName: fragmentName(BasicMembershipFieldsFragmentDoc), - id: `Membership:${memberId}`, - })!, - createdAt: new Date().toISOString() as unknown as Date, - isEdited: false, - reactionsCountByReactionId: null, - repliesCount: 0, - text: text, - status: CommentStatus.Visible, - parentComment: { - __typename: 'Comment', - id: parentCommentId, - }, - tipAmount: '0', - tipTier: null, - }, - }) - const parentQuery = findParentCacheKey(client.cache as AtlasCacheType, parentCommentId) - if (parentQuery) { + /** + * cached commentsConnection query results need to be updated manually to include unconfirmed comments + * (contrary to `comments` query results which get updated automatically when a new entity is added) + */ + const refreshCommentsCache = useCallback( + (videoId: string, parentCommentId?: string) => { + const unconfirmedCommentIds = Object.keys(client.cache.extract()).filter((k) => k.includes(UNCONFIRMED_COMMENT)) + const queryCacheKey = parentCommentId + ? findParentCacheKey(client.cache as AtlasCacheType, parentCommentId) + : findCommentCacheKey(client.cache as AtlasCacheType, videoId) + + if (queryCacheKey) { + let addedUnconfirmedComments = 0 client.cache.modify({ optimistic: true, id: 'ROOT_QUERY', fields: { - [parentQuery]: (existingCommentsConnection = {}) => { - const newEdge = { - __typename: 'CommentEdge', - cursor: '0', - node: { - __ref: `Comment:${recordId}`, - }, - } + [queryCacheKey]: (existingConnection = {}) => { + const existingEdges: (CommentEdge | { node: { __ref: string } })[] = existingConnection.edges || [] + const unconfirmedEdges = unconfirmedCommentIds + .filter((unconfirmedId) => { + const recordId = unconfirmedId.split(':')[1] + return !existingEdges.find((e) => + '__ref' in e.node ? e.node.__ref === unconfirmedId : e.node.id === recordId + ) + }) + .map((unconfirmedId) => ({ + __typename: 'CommentEdge', + cursor: '0', + node: { + __ref: unconfirmedId, + }, + })) + addedUnconfirmedComments = unconfirmedEdges.length return { - ...(existingCommentsConnection as { - /* */ - }), - edges: [...((existingCommentsConnection as { edges: unknown[] }).edges ?? []), newEdge], + ...existingConnection, + edges: [...existingEdges, ...unconfirmedEdges], } }, }, }) - client.cache.modify({ - optimistic: true, - id: `Comment:${parentCommentId}`, - fields: { - repliesCount: (prev) => prev + 1, - }, - }) + if (parentCommentId) { + client.cache.modify({ + optimistic: true, + id: `Comment:${parentCommentId}`, + fields: { + repliesCount: (prevCount) => prevCount + addedUnconfirmedComments, + }, + }) + } } - return recordId }, [client.cache] ) const addVideoComment = useCallback( - ({ memberId, videoId, text, tip }: Omit) => { + ({ + memberId, + videoId, + parentCommentId, + text, + tip, + }: Omit & { parentCommentId?: string }) => { const commentId = Date.now() - const recordId = `${commentId}-${videoId}-${UNCOFIRMED_COMMENT}` + const recordId = `${commentId}-${videoId}-${UNCONFIRMED_COMMENT}` client.cache.writeFragment({ id: `Comment:${recordId}`, fragment: CommentFieldsFragmentDoc, @@ -301,7 +305,12 @@ export const useOptimisticActions = () => { createdAt: new Date(), isEdited: false, reactionsCountByReactionId: null, - parentComment: null, + parentComment: parentCommentId + ? { + __typename: 'Comment', + id: parentCommentId, + } + : null, repliesCount: 0, text: text, status: CommentStatus.Visible, @@ -310,33 +319,19 @@ export const useOptimisticActions = () => { }, }) - const queryKey = findCommentCacheKey(client.cache as AtlasCacheType, videoId) - if (queryKey) { - client.cache.modify({ - optimistic: true, - id: 'ROOT_QUERY', - fields: { - [queryKey]: (existingCommentsConnection = { edges: [] }) => { - const newEdge = { - __typename: 'CommentEdge', - cursor: '0', // Generate or use an appropriate cursor value - node: { - __ref: `Comment:${recordId}`, - }, - } - return { - ...(existingCommentsConnection as { - /* */ - }), - edges: [...((existingCommentsConnection as { edges: unknown[] }).edges ?? []), newEdge], - } - }, - }, - }) - } + refreshCommentsCache(videoId, parentCommentId) return recordId }, + [client.cache, refreshCommentsCache] + ) + + const evictUnconfirmedComment = useCallback( + (id: string) => { + if (id.includes(UNCONFIRMED_COMMENT)) { + client.cache.evict({ id: `Comment:${id}` }) + } + }, [client.cache] ) @@ -402,10 +397,12 @@ export const useOptimisticActions = () => { addVideoReaction, removeVideoReaction, addVideoComment, - addVideoReplyComment, editVideoComment, deleteVideoComment, increaseVideoCommentReaction, decreaseVideoCommentReaction, + evictUnconfirmedReaction, + evictUnconfirmedComment, + refreshCommentsCache, } } diff --git a/packages/atlas/src/hooks/useReactionTransactions.ts b/packages/atlas/src/hooks/useReactionTransactions.ts index 126bbb833d..ba5c98f1b6 100644 --- a/packages/atlas/src/hooks/useReactionTransactions.ts +++ b/packages/atlas/src/hooks/useReactionTransactions.ts @@ -24,10 +24,12 @@ export const useReactionTransactions = () => { deleteVideoComment, removeVideoReaction, addVideoComment, - addVideoReplyComment, editVideoComment, increaseVideoCommentReaction, decreaseVideoCommentReaction, + evictUnconfirmedComment, + evictUnconfirmedReaction, + refreshCommentsCache, } = useOptimisticActions() const navigate = useNavigate() const { displaySnackbar } = useSnackbar() @@ -47,7 +49,7 @@ export const useReactionTransactions = () => { commentBody: string videoTitle?: string | null commentAuthorHandle?: string - optimisticOpts?: { onTxSign: (newCommentId: string) => void } + optimisticOpts?: { onTxSign: (unconfirmedCommentId: string) => void } tip?: TipDetails }) => { if (!joystream || !memberId) { @@ -55,19 +57,30 @@ export const useReactionTransactions = () => { return } + let unconfirmedCommentId: string | undefined // this should be always populated in onTxSign let newCommentId = '' // this should be always populated in onTxSync - const refetch = async () => { + const cleanup = async () => { + // In all cases: + // Evict unconfirmed comment (if exists) + if (unconfirmedCommentId) { + evictUnconfirmedComment(unconfirmedCommentId) + } if (parentCommentId) { - await Promise.all([ - refetchComment(parentCommentId), // need to refetch parent as its replyCount will change - refetchReplies(parentCommentId), - refetchVideo(videoId), - ]) + // if the comment was a reply - refetch parent comment's replies + // and the parent comment itself (as its replyCount will change) + await Promise.all([refetchReplies(parentCommentId), refetchComment(parentCommentId)]) } else { - // if the comment was top-level, refetch the comments section query (will take care of separating user comments) - await Promise.all([refetchCommentsSection(videoId, memberId), refetchVideo(videoId)]) + // if the comment was top-level - refetch the comments section query + // (will take care of separating user comments) + await refetchCommentsSection(videoId, memberId) } + // In all cases: + // Re-add other unconfirmed comments (if exist) to cached commentsConnection query results + // and update replyCount of parent comment (if needed) + refreshCommentsCache(videoId, parentCommentId) + // Refetch the video + await refetchVideo(videoId) } await handleTransaction({ @@ -85,24 +98,17 @@ export const useReactionTransactions = () => { onTxSign: () => { if (!optimisticOpts) return - if (parentCommentId) { - newCommentId = addVideoReplyComment({ - memberId, - text: commentBody, - videoId, - parentCommentId, - }) - } else { - newCommentId = addVideoComment({ - memberId, - text: commentBody, - videoId, - tip, - }) - } - optimisticOpts.onTxSign(newCommentId) + unconfirmedCommentId = addVideoComment({ + memberId, + text: commentBody, + videoId, + parentCommentId, + tip, + }) + optimisticOpts.onTxSign(unconfirmedCommentId) }, onTxSync: async (_, metaStatus) => { + await cleanup() if ( !metaStatus?.__typename || !(metaStatus?.__typename === 'MetaprotocolTransactionResultCommentCreated' && metaStatus.commentCreated?.id) @@ -111,9 +117,8 @@ export const useReactionTransactions = () => { return } newCommentId = metaStatus.commentCreated?.id - await refetch() }, - onError: refetch, + onError: cleanup, minimized: { errorMessage: parentCommentId ? `Your reply to the comment from "${commentAuthorHandle}" was not posted.` @@ -130,12 +135,13 @@ export const useReactionTransactions = () => { memberId, handleTransaction, proxyCallback, - addVideoReplyComment, addVideoComment, refetchComment, refetchReplies, refetchVideo, refetchCommentsSection, + evictUnconfirmedComment, + refreshCommentsCache, ] ) @@ -168,6 +174,15 @@ export const useReactionTransactions = () => { return } + let unconfirmedReactionId: string | undefined + + const cleanup = async () => { + if (unconfirmedReactionId) { + evictUnconfirmedReaction(unconfirmedReactionId) + } + await refetchReactions(videoId) + } + return handleTransaction({ fee, txFactory: async (updateStatus) => @@ -188,14 +203,24 @@ export const useReactionTransactions = () => { videoId: optimisticOpts.videoId, }) } else { - increaseVideoCommentReaction({ commentId, reactionId, videoId: optimisticOpts.videoId }) + unconfirmedReactionId = increaseVideoCommentReaction({ + commentId, + reactionId, + videoId: optimisticOpts.videoId, + }) + } + }, + onTxSync: async (_, metaStatus) => { + await cleanup() + if (!metaStatus?.__typename || !(metaStatus?.__typename === 'MetaprotocolTransactionResultOK')) { + SentryLogger.error('Comment reaction metaprotocol failure', 'useReactionTransactions') } }, minimized: { errorMessage: `Your reaction to the comment from "${commentAuthorHandle}" has not been posted.`, }, allowMultiple: true, - onError: async () => refetchReactions(videoId), + onError: cleanup, unsignedMessage: 'To add your reaction', }) }, @@ -208,6 +233,7 @@ export const useReactionTransactions = () => { decreaseVideoCommentReaction, increaseVideoCommentReaction, refetchReactions, + evictUnconfirmedReaction, ] ) @@ -249,6 +275,7 @@ export const useReactionTransactions = () => { optimisticOpts.onTxSign() }, onTxSync: async () => refetchEdits(commentId), + onError: async () => refetchEdits(commentId), minimized: { errorMessage: `Your comment to the video "${videoTitle}" has not been edited.`, }, @@ -295,6 +322,9 @@ export const useReactionTransactions = () => { onTxSync: async () => { refetchComment(commentId) }, + onError: async () => { + refetchComment(commentId) + }, snackbarSuccessMessage: { title: 'Comment deleted', description: 'Your comment has been deleted.', diff --git a/packages/atlas/src/providers/networkUtils/networkUtils.provider.tsx b/packages/atlas/src/providers/networkUtils/networkUtils.provider.tsx index ddbfa32f47..8e96a06145 100644 --- a/packages/atlas/src/providers/networkUtils/networkUtils.provider.tsx +++ b/packages/atlas/src/providers/networkUtils/networkUtils.provider.tsx @@ -41,7 +41,6 @@ import { GetFullVideoQuery, GetFullVideoQueryVariables, } from '@/api/queries/__generated__/videos.generated' -import { UNCOFIRMED_COMMENT, UNCOFIRMED_REACTION, UNCOFIRMED_REPLY } from '@/hooks/useOptimisticActions' import { NetworkUtilsContextValue } from '@/providers/networkUtils/networkUtils.type' import { useUser } from '@/providers/user/user.hooks' @@ -52,17 +51,6 @@ export const NetworkUtilsProvider = ({ children }: { children: ReactNode }) => { const client = useApolloClient() const { memberId: activeMemberId } = useUser() - const evictUnconfirmedCache = useCallback( - (keyPart: string) => { - Object.keys(client.cache.extract()).forEach((key) => { - if (key.includes(keyPart)) { - client.cache.evict({ id: key }) - } - }) - }, - [client.cache] - ) - /* Channel */ const refetchChannel = useCallback( @@ -95,7 +83,6 @@ export const NetworkUtilsProvider = ({ children }: { children: ReactNode }) => { const refetchComment = useCallback( (id: string) => { - evictUnconfirmedCache(UNCOFIRMED_COMMENT) return client.query({ query: GetCommentDocument, variables: { @@ -104,7 +91,7 @@ export const NetworkUtilsProvider = ({ children }: { children: ReactNode }) => { fetchPolicy: 'network-only', }) }, - [client, evictUnconfirmedCache] + [client] ) const refetchEdits = useCallback( @@ -122,7 +109,6 @@ export const NetworkUtilsProvider = ({ children }: { children: ReactNode }) => { const refetchReactions = useCallback( (videoId: string, memberId?: string) => { - evictUnconfirmedCache(UNCOFIRMED_REACTION) return client.query({ query: GetUserCommentsReactionsDocument, variables: { @@ -132,12 +118,11 @@ export const NetworkUtilsProvider = ({ children }: { children: ReactNode }) => { fetchPolicy: 'network-only', }) }, - [activeMemberId, client, evictUnconfirmedCache] + [activeMemberId, client] ) const refetchReplies = useCallback( (parentCommentId: string) => { - evictUnconfirmedCache(UNCOFIRMED_REPLY) return client.query({ query: GetCommentRepliesConnectionDocument, variables: { @@ -146,13 +131,11 @@ export const NetworkUtilsProvider = ({ children }: { children: ReactNode }) => { fetchPolicy: 'network-only', }) }, - [client, evictUnconfirmedCache] + [client] ) const refetchCommentsSection = useCallback( (videoId: string, memberId?: string) => { - evictUnconfirmedCache(UNCOFIRMED_COMMENT) - return client.query< GetUserCommentsAndVideoCommentsConnectionQuery, GetUserCommentsAndVideoCommentsConnectionQueryVariables @@ -165,7 +148,7 @@ export const NetworkUtilsProvider = ({ children }: { children: ReactNode }) => { fetchPolicy: 'network-only', }) }, - [activeMemberId, client, evictUnconfirmedCache] + [activeMemberId, client] ) const refetchAllCommentsSections = useCallback(async () => { diff --git a/packages/atlas/src/providers/transactions/transactions.manager.tsx b/packages/atlas/src/providers/transactions/transactions.manager.tsx index 8ee3c91069..0f1d078da3 100644 --- a/packages/atlas/src/providers/transactions/transactions.manager.tsx +++ b/packages/atlas/src/providers/transactions/transactions.manager.tsx @@ -65,7 +65,7 @@ export const TransactionsManager: FC = () => { lastProcessedQnBlockRef.current = lastProcessedBlock const blockActions = useTransactionManagerStore.getState().blockActions - const syncedActions = blockActions.filter((action) => lastProcessedBlock > action.targetBlock) + const syncedActions = blockActions.filter((action) => lastProcessedBlock >= action.targetBlock) if (!syncedActions.length) { return From 41d00c101bd7b490394eb23deeb610794f018aad Mon Sep 17 00:00:00 2001 From: Lezek123 Date: Sun, 22 Dec 2024 19:35:32 +0100 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=94=A7=20Fix=20comment+reply=20duplic?= =?UTF-8?q?ation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/atlas/src/hooks/useOptimisticActions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/atlas/src/hooks/useOptimisticActions.ts b/packages/atlas/src/hooks/useOptimisticActions.ts index 000a1f4fb3..2159d42ebf 100644 --- a/packages/atlas/src/hooks/useOptimisticActions.ts +++ b/packages/atlas/src/hooks/useOptimisticActions.ts @@ -249,6 +249,11 @@ export const useOptimisticActions = () => { '__ref' in e.node ? e.node.__ref === unconfirmedId : e.node.id === recordId ) }) + .filter( + (unconfirmedId) => + unconfirmedId.split('/')[1] === videoId && + unconfirmedId.split('/')[2] === (parentCommentId || 'none') + ) .map((unconfirmedId) => ({ __typename: 'CommentEdge', cursor: '0', @@ -288,7 +293,7 @@ export const useOptimisticActions = () => { tip, }: Omit & { parentCommentId?: string }) => { const commentId = Date.now() - const recordId = `${commentId}-${videoId}-${UNCONFIRMED_COMMENT}` + const recordId = `${commentId}/${videoId}/${parentCommentId || 'none'}/${UNCONFIRMED_COMMENT}` client.cache.writeFragment({ id: `Comment:${recordId}`, fragment: CommentFieldsFragmentDoc,