Skip to content

Commit

Permalink
📂 Optimistic actions (Joystream#6369)
Browse files Browse the repository at this point in the history
* Add optimistic video reactions

* Add initial implementation for comment optimistic actions

* Add initial implementation for comment optimistic actions

* Initial changes for comments reactions

* Improve comments layout

* Fix first comment reaction update

* Avoid refetch on comment reactions

* Disallow certain actions if comment is unconfirmed

* Bring back joystream total earnings query
  • Loading branch information
ikprk committed Jun 4, 2024
1 parent a5882ba commit d5f18aa
Show file tree
Hide file tree
Showing 15 changed files with 844 additions and 202 deletions.
18 changes: 17 additions & 1 deletion packages/atlas/src/api/client/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { FieldPolicy, FieldReadFunction } from '@apollo/client/cache/inmemory/po
import { offsetLimitPagination, relayStylePagination } from '@apollo/client/utilities'
import { parseISO } from 'date-fns'

import { QueryCommentReactionsConnectionArgs } from '../../../../atlas-meta-server/src/api/__generated__/sdk'
import {
Query,
QueryChannelsConnectionArgs,
Expand Down Expand Up @@ -119,7 +120,19 @@ const getCommentKeyArgs = (
const parentCommentId = args?.where?.parentComment?.id_eq
const videoId = args?.where?.video?.id_eq ?? ctx.variables?.videoId
const orderBy = args?.orderBy || []
return `${parentCommentId}:${videoId}:${orderBy}`
return `parentId-${parentCommentId}-:id-${videoId}-:${orderBy}`
}

const getCommentReactionsKeyArgs = (
args: Partial<QueryCommentReactionsConnectionArgs> | null,
ctx: {
variables?: Record<string, unknown>
}
) => {
const memberId = args?.where?.member?.id_eq
const videoId = args?.where?.video?.id_eq ?? ctx.variables?.videoId
const orderBy = args?.orderBy || []
return `memberId-${memberId}-:videoId-${videoId}-:${orderBy}`
}

const createDateHandler = () => ({
Expand Down Expand Up @@ -216,6 +229,9 @@ const queryCacheFields: CachePolicyFields<keyof Query> = {
return existing?.slice(offset, offset + limit)
},
},
commentReactions: {
...offsetLimitPagination(getCommentReactionsKeyArgs),
},
commentsConnection: relayStylePagination(getCommentKeyArgs),
channelById: (existing, { toReference, args }) => {
return (
Expand Down
12 changes: 9 additions & 3 deletions packages/atlas/src/api/hooks/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const useComment = (
}
}

export type UserCommentReactions = Record<string, number[]>
export type UserCommentReactions = Record<string, { reactionId: number; reactionServerId: string }[]>
export const useUserCommentsReactions = (videoId?: string | null, memberId?: string | null) => {
const { data } = useGetUserCommentsReactionsQuery({
variables: {
Expand All @@ -42,9 +42,15 @@ export const useUserCommentsReactions = (videoId?: string | null, memberId?: str

return useMemo(
() => ({
userReactions: data?.commentReactions.reduce<Record<string, number[]>>((acc, item) => {
userReactions: data?.commentReactions.reduce<UserCommentReactions>((acc, item) => {
if (item) {
acc[item.comment.id] = [...(acc[item.comment.id] ? acc[item.comment.id] : []), item.reactionId]
acc[item.comment.id] = [
...(acc[item.comment.id] ? acc[item.comment.id] : []),
{
reactionId: item.reactionId,
reactionServerId: item.id,
},
]
}
return acc
}, {}),
Expand Down
38 changes: 38 additions & 0 deletions packages/atlas/src/api/hooks/useCommentSectionComments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { QueryHookOptions } from '@apollo/client'

import { UNCONFIRMED } from '@/hooks/useOptimisticActions'
import { createLookup } from '@/utils/data'

import {
GetUserCommentsAndVideoCommentsConnectionQuery,
GetUserCommentsAndVideoCommentsConnectionQueryVariables,
useGetUserCommentsAndVideoCommentsConnectionQuery,
} from '../queries/__generated__/comments.generated'

export const useCommentSectionComments = (
variables?: GetUserCommentsAndVideoCommentsConnectionQueryVariables,
opts?: QueryHookOptions<
GetUserCommentsAndVideoCommentsConnectionQuery,
GetUserCommentsAndVideoCommentsConnectionQueryVariables
>
) => {
const { data, loading, ...rest } = useGetUserCommentsAndVideoCommentsConnectionQuery({ ...opts, variables })
const userComments = data?.userComments
const userCommentLookup = data?.userComments && createLookup(data?.userComments)
const unconfirmedComments = data?.videoCommentsConnection.edges
.map((edge) => edge.node)
.filter((node) => node.id.includes(UNCONFIRMED))
const unconfirmedCommentLookup = unconfirmedComments && createLookup(unconfirmedComments)

const videoComments = data?.videoCommentsConnection?.edges
.map((edge) => edge.node)
.filter((comment) => userCommentLookup && !userCommentLookup[comment.id] && !unconfirmedCommentLookup?.[comment.id])

return {
userComments,
comments: data ? [...(unconfirmedComments || []), ...(userComments || []), ...(videoComments || [])] : undefined,
loading: loading,
pageInfo: data?.videoCommentsConnection?.pageInfo,
...rest,
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/atlas/src/api/queries/comments.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ query GetUserCommentsAndVideoCommentsConnection(
# CHANGE: ID is now `String`
query GetUserCommentsReactions($memberId: String!, $videoId: String!) {
commentReactions(where: { member: { id_eq: $memberId }, video: { id_eq: $videoId } }, limit: 1000) {
id
reactionId
comment {
# CHANGE: `commentId` no longer exists, use `comment.id`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ export const KebabMenuIconButton = styled(Button)<{ isActive: boolean }>`
}
`

export const CommentWrapper = styled.div<{ shouldShowKebabButton: boolean }>`
export const CommentWrapper = styled.div<{ shouldShowKebabButton: boolean; isUnconfirmed?: boolean }>`
display: grid;
gap: ${sizes(3)};
align-items: start;
opacity: ${(props) => (props.isUnconfirmed ? '0.8' : 'unset')};
/* comment content, kebab button */
grid-template-columns: 1fr auto;
Expand Down
75 changes: 47 additions & 28 deletions packages/atlas/src/components/_comments/Comment/Comment.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import BN from 'bn.js'
import { Dispatch, FC, SetStateAction, memo, useCallback, useRef, useState } from 'react'

import { useComment } from '@/api/hooks/comments'
import { UserCommentReactions, useComment } from '@/api/hooks/comments'
import { CommentStatus } from '@/api/queries/__generated__/baseTypes.generated'
import { CommentFieldsFragment, FullVideoFieldsFragment } from '@/api/queries/__generated__/fragments.generated'
import { DialogModal } from '@/components/_overlays/DialogModal'
Expand All @@ -25,7 +25,7 @@ import { CommentRowProps } from '../CommentRow'
export type CommentProps = {
commentId?: string
video?: FullVideoFieldsFragment | null
userReactions?: number[]
userReactions?: UserCommentReactions[string]
isReplyable?: boolean
setHighlightedCommentId?: Dispatch<SetStateAction<string | null>>
setRepliesOpen?: Dispatch<SetStateAction<boolean>>
Expand Down Expand Up @@ -102,8 +102,10 @@ export const Comment: FC<CommentProps> = memo(
closeModal()
isChannelOwner
? await moderateComment(comment.id, video?.channel.id, comment.author.handle, video?.id)
: await deleteComment(comment.id, video?.title || '', video?.id)
setIsCommentProcessing(false)
: await deleteComment(comment.id, video?.title || '', video?.id, {
onUnconfirmed: () => setIsCommentProcessing(false),
onTxSign: () => setIsCommentProcessing(false),
})
},
},
secondaryButton: {
Expand Down Expand Up @@ -155,27 +157,25 @@ export const Comment: FC<CommentProps> = memo(
}

setEditCommentInputIsProcessing(true)
const success = await updateComment({
await updateComment({
videoId: video.id,
commentBody: editCommentInputText ?? '',
commentId: comment.id,
videoTitle: video.title,
optimisticOpts: {
onTxSign: () => {
setEditCommentInputIsProcessing(false)
setEditCommentInputText('')
setHighlightedCommentId?.(comment?.id ?? null)
setIsEditingComment(false)
},
onUnconfirmed: () => {
setEditCommentInputIsProcessing(false)
setEditCommentInputText('')
setIsEditingComment(false)
},
},
})
setEditCommentInputIsProcessing(false)

if (success) {
setEditCommentInputText('')
setHighlightedCommentId?.(comment?.id ?? null)
setIsEditingComment(false)
}
}
const handleCommentReaction = async (commentId: string, reactionId: CommentReaction) => {
setProcessingReactionsIds((previous) => [...previous, reactionId])
const fee =
reactionFee ||
(await getReactToVideoCommentFee(memberId && comment?.id ? [memberId, comment.id, reactionId] : undefined))
await reactToComment(commentId, video?.id || '', reactionId, comment?.author.handle || '', fee)
setProcessingReactionsIds((previous) => previous.filter((r) => r !== reactionId))
}

const handleOnBoardingPopoverOpen = async (reactionId: number) => {
Expand All @@ -190,19 +190,23 @@ export const Comment: FC<CommentProps> = memo(
}

setReplyCommentInputIsProcessing(true)
const newCommentId = await addComment({
await addComment({
videoId: video.id,
commentBody: replyCommentInputText,
parentCommentId: comment.id,
videoTitle: video.title,
commentAuthorHandle: comment.author.handle,
optimisticOpts: {
onTxSign: (newCommentId) => {
setReplyCommentInputIsProcessing(false)
setReplyCommentInputText('')
setHighlightedCommentId?.(newCommentId || null)
onReplyPosted?.(newCommentId || '')
setRepliesOpen?.(true)
setReplyInputOpen(false)
},
},
})
setReplyCommentInputIsProcessing(false)
setReplyCommentInputText('')
setHighlightedCommentId?.(newCommentId || null)
onReplyPosted?.(newCommentId || '')
setRepliesOpen?.(true)
setReplyInputOpen(false)
}

const handleReplyClick = () => {
Expand Down Expand Up @@ -250,13 +254,28 @@ export const Comment: FC<CommentProps> = memo(
const reactions =
(comment &&
getCommentReactions({
userReactionsIds: userReactions,
userReactionsIds: userReactions?.map((uR) => uR.reactionId),
reactionsCount: comment.reactionsCountByReactionId || [],
processingReactionsIds,
deleted: commentType === 'deleted',
})) ||
undefined

const handleCommentReaction = async (commentId: string, reactionId: CommentReaction) => {
setProcessingReactionsIds((previous) => [...previous, reactionId])
const fee =
reactionFee ||
(await getReactToVideoCommentFee(memberId && comment?.id ? [memberId, comment.id, reactionId] : undefined))
await reactToComment(commentId, video?.id || '', reactionId, comment?.author.handle || '', fee, {
prevReactionServerId:
userReactions?.find((reaction) => reaction.reactionId === reactionId)?.reactionServerId ?? '',
videoId: video?.id ?? '',
onUnconfirmedComment: () => setProcessingReactionsIds((previous) => previous.filter((r) => r !== reactionId)),
onTxSign: () => setProcessingReactionsIds((previous) => previous.filter((r) => r !== reactionId)),
})
setProcessingReactionsIds((previous) => previous.filter((r) => r !== reactionId))
}

if (isEditingComment) {
return (
<CommentInput
Expand Down
62 changes: 35 additions & 27 deletions packages/atlas/src/components/_comments/Comment/InternalComment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { PopoverImperativeHandle } from '@/components/_overlays/Popover'
import { ReactionsOnboardingPopover } from '@/components/_video/ReactionsOnboardingPopover'
import { atlasConfig } from '@/config'
import { absoluteRoutes } from '@/config/routes'
import { UNCONFIRMED } from '@/hooks/useOptimisticActions'
import { useTouchDevice } from '@/hooks/useTouchDevice'
import { CommentReaction } from '@/joystream-lib/types'
import { getMemberAvatar } from '@/providers/assets/assets.helpers'
Expand Down Expand Up @@ -113,6 +114,7 @@ export const InternalComment: FC<InternalCommentProps> = ({
const [tempReactionId, setTempReactionId] = useState<CommentReaction | null>(null)
const isDeleted = type === 'deleted'
const isProcessing = type === 'processing'
const isUnconfirmed = commentId?.includes(UNCONFIRMED)
const shouldShowKebabButton = type === 'options' && !loading && !isDeleted

const popoverRef = useRef<PopoverImperativeHandle>(null)
Expand Down Expand Up @@ -191,7 +193,7 @@ export const InternalComment: FC<InternalCommentProps> = ({
onMouseEnter={() => setCommentHover(true)}
onMouseLeave={() => setCommentHover(false)}
>
<CommentWrapper shouldShowKebabButton={shouldShowKebabButton} ref={domRef}>
<CommentWrapper isUnconfirmed={isUnconfirmed} shouldShowKebabButton={shouldShowKebabButton} ref={domRef}>
<SwitchTransition>
<CSSTransition
timeout={parseInt(cVar('animationTimingFast', true))}
Expand Down Expand Up @@ -266,7 +268,7 @@ export const InternalComment: FC<InternalCommentProps> = ({
onDecline={handleOnboardingPopoverHide}
trigger={
<ReactionsWrapper>
{hasReactionsAndCommentIsNotDeleted && (
{!isUnconfirmed && hasReactionsAndCommentIsNotDeleted && (
<ProtectedActionWrapper
title="You want to react to this comment?"
description="Sign in to let others know what you think"
Expand Down Expand Up @@ -312,18 +314,22 @@ export const InternalComment: FC<InternalCommentProps> = ({
{repliesOpen ? 'Hide' : 'Show'} {repliesCount} {repliesCount === 1 ? 'reply' : 'replies'}
</TextButton>
)}
{onReplyClick && !isDeleted && !isProcessing && (commentHover || isTouchDevice) && (
<ReplyButtonWrapper>
<ProtectedActionWrapper
title="You want to reply to this comment?"
description="Sign in to let others know what you think"
>
<ReplyButton onClick={onReplyClick} variant="tertiary" size="small" _textOnly>
Reply
</ReplyButton>
</ProtectedActionWrapper>
</ReplyButtonWrapper>
)}
{onReplyClick &&
!isUnconfirmed &&
!isDeleted &&
!isProcessing &&
(commentHover || isTouchDevice) && (
<ReplyButtonWrapper>
<ProtectedActionWrapper
title="You want to reply to this comment?"
description="Sign in to let others know what you think"
>
<ReplyButton onClick={onReplyClick} variant="tertiary" size="small" _textOnly>
Reply
</ReplyButton>
</ProtectedActionWrapper>
</ReplyButtonWrapper>
)}
</RepliesWrapper>
</ReactionsWrapper>
}
Expand All @@ -333,19 +339,21 @@ export const InternalComment: FC<InternalCommentProps> = ({
</CommentArticle>
</CSSTransition>
</SwitchTransition>
<ContextMenu
placement="bottom-end"
disabled={loading || !shouldShowKebabButton}
items={contexMenuItems}
trigger={
<KebabMenuIconButton
icon={<SvgActionMore />}
variant="tertiary"
size="small"
isActive={shouldShowKebabButton}
/>
}
/>
{!isUnconfirmed ? (
<ContextMenu
placement="bottom-end"
disabled={loading || !shouldShowKebabButton}
items={contexMenuItems}
trigger={
<KebabMenuIconButton
icon={<SvgActionMore />}
variant="tertiary"
size="small"
isActive={shouldShowKebabButton}
/>
}
/>
) : null}
</CommentWrapper>
</CommentRow>
)
Expand Down
Loading

0 comments on commit d5f18aa

Please sign in to comment.