Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 56 additions & 45 deletions src/app/detail/ui/ActivityWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { CreateAttachmentRequest } from '@/types/dto/attachments.dto'
import { CreateComment } from '@/types/dto/comment.dto'
import { fetcher } from '@/utils/fetcher'
import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity'
import { generateRandomString } from '@/utils/generateRandomString'
import { checkOptimisticStableId, getOptimisticData, getTempLog } from '@/utils/optimisticCommentUtils'
import { LogResponse } from '@api/activity-logs/schemas/LogResponseSchema'
Expand All @@ -22,7 +23,6 @@
import { useSelector } from 'react-redux'
import { TransitionGroup } from 'react-transition-group'
import useSWR, { useSWRConfig } from 'swr'
import { z } from 'zod'

interface OptimisticUpdate {
tempId: string
Expand Down Expand Up @@ -77,7 +77,7 @@
}
setLastUpdated(task?.lastActivityLogUpdated)
}
}, [task?.lastActivityLogUpdated])

Check warning on line 80 in src/app/detail/ui/ActivityWrapper.tsx

View workflow job for this annotation

GitHub Actions / Run linters and tests

React Hook useEffect has missing dependencies: 'cacheKey', 'debounceMutate', 'lastUpdated', and 'task'. Either include them or remove the dependency array

const currentUserId = tokenPayload.internalUserId ?? tokenPayload.clientId

Expand Down Expand Up @@ -127,7 +127,25 @@
}

// Handle comment deletion
const handleDeleteComment = async (commentId: string, logId: string, replyId?: string, softDelete?: boolean) => {
const resolveCommentIdToDelete = async (commentId: string): Promise<string | undefined> => {
if (!isPendingCommentId(commentId)) return commentId

const attempts = Array.from({ length: 6 })
for (const _attempt of attempts) {
const matchedUpdate = optimisticUpdates.find((update) => update.tempId === commentId)
if (matchedUpdate?.serverId) return matchedUpdate.serverId
await new Promise((resolve) => setTimeout(resolve, 500))
}

return undefined
}

const handleDeleteComment = async (
commentId: string | undefined,
logId: string,
replyId?: string,
softDelete?: boolean,
) => {
let optimisticData
if (replyId) {
optimisticData = activities
Expand Down Expand Up @@ -173,24 +191,14 @@
cacheKey,
async () => {
shouldRefetchRef.current = false
let commentIdToDelete = commentId
if (commentIdToDelete.includes('temp-comment')) {
const maxAttempts = 6
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const matchedUpdate = optimisticUpdates.find((update) => update.tempId === commentIdToDelete)
if (matchedUpdate?.serverId) {
commentIdToDelete = matchedUpdate.serverId
break
}
await new Promise((resolve) => setTimeout(resolve, 500))
}
if (commentIdToDelete.includes('temp-comment')) {
console.warn('Comment is still pending server sync. Try again later.')
return activities
}
} //Due to optimistic updates on comment creation applied in our ui, some deleted comments might have tempId which are yet to be replaced by the server id. Although the usecase frequency for this is very very minimal, we are waiting for serverId to replace tempId if the deleted comment has tempId by polling method.
const commentIdToDelete = getCommentActivityId({ id: commentId })
const resolvedCommentId = commentIdToDelete ? await resolveCommentIdToDelete(commentIdToDelete) : undefined
if (!resolvedCommentId) {
console.warn('Comment is missing a server id. Try again later.')
return activities
}

await deleteComment(token, commentIdToDelete)
await deleteComment(token, resolvedCommentId)
return await fetcher(cacheKey)
},
{
Expand All @@ -217,32 +225,35 @@
) : (
<Stack direction="column" alignItems="left" rowGap={2}>
<TransitionGroup>
{activities?.data?.map((item: LogResponse, index: number) => (
<Collapse key={checkOptimisticStableId(item, optimisticUpdates)}>
<Box
key={index}
sx={{
height: 'auto',
}}
>
{item.type === ActivityType.COMMENT_ADDED ? (
<Comments
token={token}
comment={item}
createComment={handleCreateComment}
deleteComment={(commentId, replyId, softDelete) =>
handleDeleteComment(commentId, item.id, replyId, softDelete)
}
task_id={task_id}
stableId={z.string().parse(item.details.id) ?? item.id}
optimisticUpdates={optimisticUpdates}
/>
) : Object.keys(item).length === 0 ? null : (
<ActivityLog log={item} />
)}
</Box>
</Collapse>
))}
{activities?.data?.map((item: LogResponse, index: number) => {
const commentActivityId = getCommentActivityId(item.details)
return (
<Collapse key={checkOptimisticStableId(item, optimisticUpdates)}>
<Box
key={index}
sx={{
height: 'auto',
}}
>
{item.type === ActivityType.COMMENT_ADDED ? (
<Comments
token={token}
comment={item}
createComment={handleCreateComment}
deleteComment={(commentId, replyId, softDelete) =>
handleDeleteComment(commentId, item.id, replyId, softDelete)
}
task_id={task_id}
stableId={commentActivityId ?? item.id}
optimisticUpdates={optimisticUpdates}
/>
) : Object.keys(item).length === 0 ? null : (
<ActivityLog log={item} />
)}
</Box>
</Collapse>
)
})}
</TransitionGroup>
<CommentInput createComment={handleCreateComment} task_id={task_id} token={token} />
</Stack>
Expand Down
31 changes: 19 additions & 12 deletions src/components/cards/CommentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { getAssigneeName } from '@/utils/assignee'
import { deleteEditorAttachmentsHandler, getAttachmentPayload, getCustomFilePath } from '@/utils/attachmentUtils'
import { createUploadFn } from '@/utils/createUploadFn'
import { fetcher } from '@/utils/fetcher'
import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity'
import { getTimeDifference } from '@/utils/getTimeDifference'
import { isTapwriteContentEmpty } from '@/utils/isTapwriteContentEmpty'
import { checkOptimisticStableId, OptimisticUpdate } from '@/utils/optimisticCommentUtils'
Expand Down Expand Up @@ -85,6 +86,7 @@ export const CommentCard = ({
const [deletedReplies, setDeletedReplies] = useState<string[]>([])

const { postAttachment } = usePostAttachment()
const commentId = getCommentActivityId(comment.details)

const windowWidth = useWindowWidth()
const isMobile = () => {
Expand All @@ -111,11 +113,11 @@ export const CommentCard = ({
return () => clearInterval(intervalId)
}, [comment.createdAt])

const commentIdRef = useRef(comment.details.id)
const commentIdRef = useRef(commentId)

useEffect(() => {
commentIdRef.current = comment.details.id
}, [comment.details.id]) //done because tapwrite only takes uploadFn once on mount where commentId will be temp from optimistic update. So we need an actual commentId for uploadFn to work.
commentIdRef.current = commentId
}, [commentId]) //done because tapwrite only takes uploadFn once on mount where commentId will be temp from optimistic update. So we need an actual commentId for uploadFn to work.

const uploadFn = createUploadFn({
token,
Expand All @@ -139,7 +141,7 @@ export const CommentCard = ({
setIsReadOnly(true)
return
}
const commentId = z.string().parse(comment.details.id)
const commentId = z.string().parse(commentIdRef.current)
const updateCommentPayload: UpdateComment = {
content: editedContent,
// mentions : add mentions in the future
Expand Down Expand Up @@ -173,7 +175,7 @@ export const CommentCard = ({

const replyCount = (comment.details as CommentResponse).replyCount

const cacheKey = `/api/comments/?token=${token}&parentId=${comment.details.id}`
const cacheKey = commentId ? `/api/comments/?token=${token}&parentId=${commentId}` : null
const { trigger } = useSWRMutation(cacheKey, fetcher, {
optimisticData: optimisticUpdates.filter((update) => update.tempId),
})
Expand All @@ -182,7 +184,9 @@ export const CommentCard = ({
const updatedComment = await trigger()

setReplies(updatedComment?.comments || comment.details.replies || [])
store.dispatch(setExpandedComments([...expandedComments, z.string().parse(comment.details.id ?? '')]))
if (commentId) {
store.dispatch(setExpandedComments([...expandedComments, commentId]))
}
} catch (error) {
console.error('Failed to fetch replies:', error)
}
Expand All @@ -191,15 +195,15 @@ export const CommentCard = ({
useEffect(() => {
const replies = (comment.details.replies as ReplyResponse[]) || []

if (expandedComments.length && expandedComments.includes(z.string().parse(comment.details.id))) {
if (expandedComments.length && commentId && expandedComments.includes(commentId)) {
const lastReply = replies[replies.length - 1]
if (deletedReplies.length > 0) {
const pendingReplyToBeRemoved = deletedReplies[0]
setReplies((prev) => prev.filter((reply) => reply.id !== pendingReplyToBeRemoved))
setDeletedReplies((prev) => prev.slice(1))
return
} //handle optimistic updates on reply deletion when view all button is active.
if (lastReply && lastReply.id.includes('temp-comment')) {
if (lastReply && isPendingCommentId(getCommentActivityId(lastReply))) {
setReplies((prev) => [...prev, lastReply])
return
} //handle optimistic updates on reply creation when view all button is active.
Expand Down Expand Up @@ -359,7 +363,7 @@ export const CommentCard = ({
{((Array.isArray((comment as LogResponse).details?.replies) &&
((comment as LogResponse).details.replies as ReplyResponse[]).length > 0) ||
showReply) && <CustomDivider />}
{replyCount > 3 && !expandedComments.includes(z.string().parse(comment.details.id)) && (
{replyCount > 3 && commentId && !expandedComments.includes(commentId) && (
<CollapsibleReplyCard
lastAssignees={firstInitiators}
fetchCommentsWithFullReplies={fetchCommentsWithFullReplies}
Expand All @@ -385,9 +389,10 @@ export const CommentCard = ({
)
})}
</TransitionGroup>
{(Array.isArray((comment as LogResponse).details?.replies) &&
{commentId &&
((Array.isArray((comment as LogResponse).details?.replies) &&
((comment as LogResponse).details.replies as LogResponse[]).length > 0) ||
showReply ? (
showReply) ? (
<ReplyInput
token={token}
comment={comment}
Expand All @@ -407,7 +412,9 @@ export const CommentCard = ({
<ConfirmDeleteUI
handleCancel={() => setShowConfirmDeleteModal(false)}
handleDelete={() => {
deleteComment((comment as LogResponse).details.id as string, undefined, replies.length > 0)
if (commentId) {
deleteComment(commentId, undefined, replies.length > 0)
}
setShowConfirmDeleteModal(false)
}}
bodyTag="comment"
Expand Down
26 changes: 14 additions & 12 deletions src/components/inputs/ReplyInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,13 @@ import { Dispatch, SetStateAction, useCallback, useEffect, useRef, useState } fr
import { useSelector } from 'react-redux'
import { Tapwrite } from 'tapwrite'
import { createUploadFn } from '@/utils/createUploadFn'
import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity'
import { AttachmentTypes } from '@/types/interfaces'

interface ReplyInputProps {
token: string
task_id: string
comment: any
comment: { details?: { id?: unknown } }
createComment: (postCommentPayload: CreateComment) => void
focusReplyInput: boolean
setFocusReplyInput: Dispatch<SetStateAction<boolean>>
Expand All @@ -45,6 +46,7 @@ export const ReplyInput = ({
const currentUserId = tokenPayload?.internalUserId ?? tokenPayload?.clientId
const currentUserDetails = assignee.find((el) => el.id === currentUserId)
const [pendingReplies, setPendingReplies] = useState<{ content: string; taskId: string }[]>([])
const commentId = getCommentActivityId(comment.details)

const handleReplySubmission = useCallback(() => {
let content = detail
Expand All @@ -57,19 +59,19 @@ export const ReplyInput = ({
setDetail('')
setPendingReplies((prev) => [...prev, { content, taskId: task_id }])
}
}, [comment, detail, task_id])
}, [detail, task_id])

useEffect(() => {
if (pendingReplies.length > 0 && !comment.details.id.includes('temp-comment')) {
const { content, taskId } = pendingReplies[0]
createComment({
content,
taskId,
parentId: comment.details.id,
})
setPendingReplies((prev) => prev.slice(1)) //handling the reply submission 1 by 1
}
}, [comment.details.id, pendingReplies])
if (pendingReplies.length === 0 || !commentId || isPendingCommentId(commentId)) return

const { content, taskId } = pendingReplies[0]
createComment({
content,
taskId,
parentId: commentId,
})
setPendingReplies((prev) => prev.slice(1)) //handling the reply submission 1 by 1
}, [commentId, pendingReplies])

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
Expand Down
26 changes: 26 additions & 0 deletions src/utils/commentActivity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getCommentActivityId, isPendingCommentId } from '@/utils/commentActivity'

describe('commentActivity utils', () => {
describe('getCommentActivityId', () => {
it('returns a string id when activity details contain one', () => {
expect(getCommentActivityId({ id: 'comment-id' })).toBe('comment-id')
})

it('returns undefined for missing, empty, or non-string ids', () => {
expect(getCommentActivityId(undefined)).toBeUndefined()
expect(getCommentActivityId({})).toBeUndefined()
expect(getCommentActivityId({ id: '' })).toBeUndefined()
expect(getCommentActivityId({ id: 123 })).toBeUndefined()
})
})

describe('isPendingCommentId', () => {
it('detects optimistic comment ids', () => {
expect(isPendingCommentId('temp-comment-123')).toBe(true)
})

it('treats missing ids as not pending without throwing', () => {
expect(isPendingCommentId(undefined)).toBe(false)
})
})
})
4 changes: 4 additions & 0 deletions src/utils/commentActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const getCommentActivityId = (details: { id?: unknown } | undefined): string | undefined =>
typeof details?.id === 'string' && details.id.length > 0 ? details.id : undefined

export const isPendingCommentId = (commentId: string | undefined): boolean => commentId?.includes('temp-comment') ?? false
Loading