add comment reply

This commit is contained in:
hjlarry 2025-09-17 12:50:42 +08:00
parent e776accaf3
commit cba9fc3020
4 changed files with 289 additions and 20 deletions

View File

@ -1,13 +1,20 @@
'use client'
import { useParams } from 'next/navigation'
import type { FC } from 'react'
import { memo, useMemo } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useReactFlow, useViewport } from 'reactflow'
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine } from '@remixicon/react'
import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiSendPlane2Fill } from '@remixicon/react'
import Textarea from 'react-textarea-autosize'
import Avatar from '@/app/components/base/avatar'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
import { useFormatTimeFromNow } from '@/app/components/workflow/hooks'
import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
import type { UserProfile, WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment'
import { fetchMentionableUsers } from '@/service/workflow-comment'
import { useAppContext } from '@/context/app-context'
type CommentThreadProps = {
comment: WorkflowCommentDetail
@ -19,6 +26,7 @@ type CommentThreadProps = {
onNext?: () => void
canGoPrev?: boolean
canGoNext?: boolean
onReply?: (content: string, mentionedUserIds?: string[]) => Promise<void> | void
}
const ThreadMessage: FC<{
@ -31,13 +39,13 @@ const ThreadMessage: FC<{
const { formatTimeFromNow } = useFormatTimeFromNow()
return (
<div className={cn('flex gap-3', isReply && 'pl-9')}>
<div className={cn('flex gap-3', isReply && 'pt-1')}>
<div className='shrink-0'>
<Avatar
name={authorName}
avatar={avatarUrl || null}
size={32}
className='h-8 w-8'
size={isReply ? 28 : 32}
className={cn('rounded-full', isReply ? 'h-7 w-7' : 'h-8 w-8')}
/>
</div>
<div className='min-w-0 flex-1 pb-4 text-text-primary last:pb-0'>
@ -74,9 +82,160 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
onNext,
canGoPrev,
canGoNext,
onReply,
}) => {
const params = useParams()
const appId = params?.appId as string | undefined
const { flowToScreenPosition } = useReactFlow()
const viewport = useViewport()
const { userProfile } = useAppContext()
const [replyContent, setReplyContent] = useState('')
const [mentionUsers, setMentionUsers] = useState<UserProfile[]>([])
const [showMentionDropdown, setShowMentionDropdown] = useState(false)
const [mentionQuery, setMentionQuery] = useState('')
const [mentionPosition, setMentionPosition] = useState(0)
const [selectedMentionIndex, setSelectedMentionIndex] = useState(0)
const [mentionedUserIds, setMentionedUserIds] = useState<string[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (!onReply || !appId) {
setMentionUsers([])
return
}
const loadMentionUsers = async () => {
try {
setMentionUsers(await fetchMentionableUsers(appId))
}
catch (error) {
console.error('Failed to load mention users', error)
}
}
loadMentionUsers()
}, [appId, onReply])
useEffect(() => {
setReplyContent('')
setMentionedUserIds([])
setShowMentionDropdown(false)
}, [comment.id])
const handleReplySubmit = useCallback(async () => {
const trimmed = replyContent.trim()
if (!onReply || !trimmed || loading)
return
try {
await onReply(trimmed, mentionedUserIds)
setReplyContent('')
setMentionedUserIds([])
setShowMentionDropdown(false)
}
catch (error) {
console.error('Failed to send reply', error)
}
}, [replyContent, onReply, loading, mentionedUserIds])
const filteredMentionUsers = useMemo(() => {
if (!mentionQuery) return mentionUsers
return mentionUsers.filter(user =>
user.name.toLowerCase().includes(mentionQuery.toLowerCase())
|| user.email?.toLowerCase().includes(mentionQuery.toLowerCase()),
)
}, [mentionUsers, mentionQuery])
const dropdownPosition = useMemo(() => {
if (!showMentionDropdown || !textareaRef.current)
return { x: 0, y: 0 }
const rect = textareaRef.current.getBoundingClientRect()
return { x: rect.left, y: rect.bottom + 4 }
}, [showMentionDropdown])
const handleContentChange = useCallback((value: string) => {
setReplyContent(value)
setTimeout(() => {
const cursorPosition = textareaRef.current?.selectionStart || 0
const textBeforeCursor = value.slice(0, cursorPosition)
const mentionMatch = textBeforeCursor.match(/@(\w*)$/)
if (mentionMatch) {
setMentionQuery(mentionMatch[1])
setMentionPosition(cursorPosition - mentionMatch[0].length)
setShowMentionDropdown(true)
setSelectedMentionIndex(0)
}
else {
setShowMentionDropdown(false)
}
}, 0)
}, [])
const handleMentionButtonClick = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!onReply || loading) return
if (!textareaRef.current) return
const cursorPosition = textareaRef.current.selectionStart || 0
const newContent = `${replyContent.slice(0, cursorPosition)}@${replyContent.slice(cursorPosition)}`
setReplyContent(newContent)
setTimeout(() => {
const textarea = textareaRef.current
if (!textarea) return
const newCursorPos = cursorPosition + 1
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
setMentionQuery('')
setMentionPosition(cursorPosition)
setShowMentionDropdown(true)
setSelectedMentionIndex(0)
}, 0)
}, [replyContent])
const insertMention = useCallback((user: UserProfile) => {
const textarea = textareaRef.current
if (!textarea) return
const beforeMention = replyContent.slice(0, mentionPosition)
const afterMention = replyContent.slice(textarea.selectionStart || 0)
const newContent = `${beforeMention}@${user.name} ${afterMention}`
setReplyContent(newContent)
setShowMentionDropdown(false)
setMentionedUserIds(prev => prev.includes(user.id) ? prev : [...prev, user.id])
setTimeout(() => {
const newCursorPos = mentionPosition + user.name.length + 2
textarea.setSelectionRange(newCursorPos, newCursorPos)
textarea.focus()
}, 0)
}, [mentionPosition, replyContent])
const handleReplyKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (showMentionDropdown) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedMentionIndex(prev => prev < filteredMentionUsers.length - 1 ? prev + 1 : 0)
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedMentionIndex(prev => prev > 0 ? prev - 1 : filteredMentionUsers.length - 1)
return
}
if (e.key === 'Enter') {
e.preventDefault()
const targetUser = filteredMentionUsers[selectedMentionIndex]
if (targetUser)
insertMention(targetUser)
return
}
if (e.key === 'Escape') {
e.preventDefault()
setShowMentionDropdown(false)
return
}
}
if (!onReply) return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleReplySubmit()
}
}, [filteredMentionUsers, handleReplySubmit, insertMention, selectedMentionIndex, showMentionDropdown])
const screenPosition = useMemo(() => {
return flowToScreenPosition({
@ -153,7 +312,7 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
content={comment.content}
/>
{comment.replies?.length > 0 && (
<div className='mt-2 flex flex-col gap-2'>
<div className='mt-3 space-y-3 border-t border-components-panel-border pt-3'>
{comment.replies.map(renderReply)}
</div>
)}
@ -163,7 +322,82 @@ export const CommentThread: FC<CommentThreadProps> = memo(({
Loading
</div>
)}
{onReply && (
<div className='border-t border-components-panel-border px-4 py-3'>
<div className='flex items-start gap-3'>
<Avatar
avatar={userProfile?.avatar_url || null}
name={userProfile?.name || 'You'}
size={32}
className='h-8 w-8'
/>
<div className='flex-1 rounded-xl border border-components-chat-input-border bg-components-panel-bg-blur px-3 py-2 shadow-sm'>
<div className='flex items-center gap-2'>
<Textarea
ref={textareaRef}
minRows={1}
maxRows={1}
value={replyContent}
placeholder='Add a reply'
onChange={e => handleContentChange(e.target.value)}
onKeyDown={handleReplyKeyDown}
className='system-sm-regular h-6 w-full resize-none bg-transparent text-text-primary caret-primary-500 outline-none'
/>
<button
type='button'
disabled={!onReply || loading}
className={cn('disabled:bg-components-button-secondary-bg/60 z-20 flex h-8 w-8 items-center justify-center rounded-lg bg-components-button-secondary-bg hover:bg-state-base-hover disabled:cursor-not-allowed disabled:text-text-disabled')}
onClick={handleMentionButtonClick}
aria-label='Mention user'
>
<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 16 16' fill='none'>
<path d='M13.3334 8.00004C13.3334 5.05452 10.9456 2.66671 8.00004 2.66671C5.05452 2.66671 2.66671 5.05452 2.66671 8.00004C2.66671 10.9456 5.05452 13.3334 8.00004 13.3334C9.09457 13.3334 10.1121 13.0036 10.9588 12.4381L11.6984 13.5476C10.6402 14.2546 9.36824 14.6667 8.00004 14.6667C4.31814 14.6667 1.33337 11.6819 1.33337 8.00004C1.33337 4.31814 4.31814 1.33337 8.00004 1.33337C11.6819 1.33337 14.6667 4.31814 14.6667 8.00004V9.00004C14.6667 10.2887 13.622 11.3334 12.3334 11.3334C11.5306 11.3334 10.8224 10.9279 10.4026 10.3106C9.79617 10.941 8.94391 11.3334 8.00004 11.3334C6.15909 11.3334 4.66671 9.84097 4.66671 8.00004C4.66671 6.15909 6.15909 4.66671 8.00004 4.66671C8.75057 4.66671 9.44317 4.91477 10.0004 5.33337H11.3334V9.00004C11.3334 9.55231 11.7811 10 12.3334 10C12.8856 10 13.3334 9.55231 13.3334 9.00004V8.00004ZM8.00004 6.00004C6.89544 6.00004 6.00004 6.89544 6.00004 8.00004C6.00004 9.10464 6.89544 10 8.00004 10C9.10464 10 10 9.10464 10 8.00004C10 6.89544 9.10464 6.00004 8.00004 6.00004Z' fill='#676F83' />
</svg>
</button>
<Button
variant='primary'
disabled={loading || !onReply || !replyContent.trim()}
onClick={handleReplySubmit}
className='z-20 ml-2 h-8 w-8 px-0'
>
<RiSendPlane2Fill className='h-4 w-4' />
</Button>
</div>
</div>
</div>
</div>
)}
</div>
{showMentionDropdown && filteredMentionUsers.length > 0 && typeof document !== 'undefined' && createPortal(
<div
className='fixed z-[9999] max-h-40 w-56 overflow-y-auto rounded-lg border border-components-panel-border bg-white shadow-lg'
style={{ left: dropdownPosition.x, top: dropdownPosition.y }}
data-mention-dropdown
>
{filteredMentionUsers.map((user, index) => (
<div
key={user.id}
className={cn(
'flex cursor-pointer items-center gap-2 p-2 hover:bg-state-base-hover',
index === selectedMentionIndex && 'bg-state-base-hover',
)}
onClick={() => insertMention(user)}
>
<Avatar
avatar={user.avatar_url || null}
name={user.name}
size={24}
className='shrink-0'
/>
<div className='min-w-0 flex-1'>
<div className='truncate text-sm font-medium text-text-primary'>{user.name}</div>
<div className='truncate text-xs text-text-tertiary'>{user.email}</div>
</div>
</div>
))}
</div>,
document.body,
)}
</div>
)
})

View File

@ -4,7 +4,7 @@ import { useReactFlow } from 'reactflow'
import { useStore } from '../store'
import { ControlMode } from '../types'
import type { WorkflowCommentDetail, WorkflowCommentList } from '@/service/workflow-comment'
import { createWorkflowComment, deleteWorkflowComment, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment } from '@/service/workflow-comment'
import { createWorkflowComment, createWorkflowCommentReply, deleteWorkflowComment, fetchWorkflowComment, fetchWorkflowComments, resolveWorkflowComment } from '@/service/workflow-comment'
export const useWorkflowComment = () => {
const params = useParams()
@ -129,21 +129,27 @@ export const useWorkflowComment = () => {
}
}, [appId, reactflow, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache, setControlMode, setPendingComment])
const refreshActiveComment = useCallback(async (commentId: string) => {
if (!appId) return
const detailResponse = await fetchWorkflowComment(appId, commentId)
const detail = (detailResponse as any)?.data ?? detailResponse
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[commentId]: detail,
}
setCommentDetailCache(commentDetailCacheRef.current)
setActiveComment(detail)
}, [appId, setActiveComment, setCommentDetailCache])
const handleCommentResolve = useCallback(async (commentId: string) => {
if (!appId) return
setActiveCommentLoading(true)
try {
await resolveWorkflowComment(appId, commentId)
const detailResponse = await fetchWorkflowComment(appId, commentId)
const detail = (detailResponse as any)?.data ?? detailResponse
commentDetailCacheRef.current = {
...commentDetailCacheRef.current,
[commentId]: detail,
}
setCommentDetailCache(commentDetailCacheRef.current)
setActiveComment(detail)
await refreshActiveComment(commentId)
await loadComments()
}
catch (error) {
@ -152,7 +158,7 @@ export const useWorkflowComment = () => {
finally {
setActiveCommentLoading(false)
}
}, [appId, loadComments, setActiveComment, setActiveCommentLoading, setCommentDetailCache])
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
const handleCommentDelete = useCallback(async (commentId: string) => {
if (!appId) return
@ -192,6 +198,25 @@ export const useWorkflowComment = () => {
}
}, [appId, comments, handleCommentIconClick, loadComments, setActiveComment, setActiveCommentId, setActiveCommentLoading, setCommentDetailCache])
const handleCommentReply = useCallback(async (commentId: string, content: string, mentionedUserIds: string[] = []) => {
if (!appId) return
const trimmed = content.trim()
if (!trimmed) return
setActiveCommentLoading(true)
try {
await createWorkflowCommentReply(appId, commentId, { content: trimmed, mentioned_user_ids: mentionedUserIds })
await refreshActiveComment(commentId)
await loadComments()
}
catch (error) {
console.error('Failed to create reply:', error)
}
finally {
setActiveCommentLoading(false)
}
}, [appId, loadComments, refreshActiveComment, setActiveCommentLoading])
const handleCommentNavigate = useCallback((direction: 'prev' | 'next') => {
const currentId = activeCommentIdRef.current
if (!currentId) return
@ -235,6 +260,7 @@ export const useWorkflowComment = () => {
handleCommentResolve,
handleCommentDelete,
handleCommentNavigate,
handleCommentReply,
handleCreateComment,
loadComments,
}

View File

@ -174,6 +174,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
handleCommentResolve,
handleCommentDelete,
handleCommentNavigate,
handleCommentReply,
} = useWorkflowComment()
const mousePosition = useStore(s => s.mousePosition)
@ -384,6 +385,7 @@ export const Workflow: FC<WorkflowProps> = memo(({
onDelete={() => setPendingDeleteCommentId(comment.id)}
onPrev={canGoPrev ? () => handleCommentNavigate('prev') : undefined}
onNext={canGoNext ? () => handleCommentNavigate('next') : undefined}
onReply={(content, ids) => handleCommentReply(comment.id, content, ids ?? [])}
canGoPrev={canGoPrev}
canGoNext={canGoNext}
/>

View File

@ -67,6 +67,13 @@ export type WorkflowCommentUpdateRes = {
updated_at: string
}
export type WorkflowCommentResolveRes = {
id: string
resolved: boolean
resolved_by: string
resolved_at: number
}
export type WorkflowCommentReply = {
id: string
comment_id: string
@ -123,8 +130,8 @@ export const deleteWorkflowComment = async (appId: string, commentId: string): P
return del<CommonResponse>(`apps/${appId}/workflow/comments/${commentId}`)
}
export const resolveWorkflowComment = async (appId: string, commentId: string): Promise<WorkflowComment> => {
return post<WorkflowComment>(`apps/${appId}/workflow/comments/${commentId}/resolve`)
export const resolveWorkflowComment = async (appId: string, commentId: string): Promise<WorkflowCommentResolveRes> => {
return post<WorkflowCommentResolveRes>(`apps/${appId}/workflow/comments/${commentId}/resolve`)
}
export const createWorkflowCommentReply = async (appId: string, commentId: string, params: CreateReplyParams): Promise<WorkflowCommentReply> => {