'use client' import type { FC, ReactNode } from 'react' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useReactFlow, useViewport } from 'reactflow' import { useTranslation } from 'react-i18next' import { RiArrowDownSLine, RiArrowUpSLine, RiCheckboxCircleFill, RiCheckboxCircleLine, RiCloseLine, RiDeleteBinLine, RiMoreFill } from '@remixicon/react' import Avatar from '@/app/components/base/avatar' import Divider from '@/app/components/base/divider' import cn from '@/utils/classnames' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import type { WorkflowCommentDetail, WorkflowCommentDetailReply } from '@/service/workflow-comment' import { useAppContext } from '@/context/app-context' import { MentionInput } from './mention-input' import { getUserColor } from '@/app/components/workflow/collaboration/utils/user-color' type CommentThreadProps = { comment: WorkflowCommentDetail loading?: boolean onClose: () => void onDelete?: () => void onResolve?: () => void onPrev?: () => void onNext?: () => void canGoPrev?: boolean canGoNext?: boolean onReply?: (content: string, mentionedUserIds?: string[]) => Promise | void onReplyEdit?: (replyId: string, content: string, mentionedUserIds?: string[]) => Promise | void onReplyDelete?: (replyId: string) => void } const ThreadMessage: FC<{ authorId: string authorName: string avatarUrl?: string | null createdAt: number content: string mentionedNames?: string[] }> = ({ authorId, authorName, avatarUrl, createdAt, content, mentionedNames }) => { const { formatTimeFromNow } = useFormatTimeFromNow() const { userProfile } = useAppContext() const currentUserId = userProfile?.id const isCurrentUser = authorId === currentUserId const userColor = isCurrentUser ? undefined : getUserColor(authorId) const highlightedContent = useMemo(() => { if (!content) return '' const normalizedNames = Array.from(new Set((mentionedNames || []) .map(name => name.trim()) .filter(Boolean))) if (normalizedNames.length === 0) return content const segments: ReactNode[] = [] let hasMention = false let cursor = 0 while (cursor < content.length) { let nextMatchStart = -1 let matchedName = '' for (const name of normalizedNames) { const searchStart = content.indexOf(`@${name}`, cursor) if (searchStart === -1) continue const previousChar = searchStart > 0 ? content[searchStart - 1] : '' if (searchStart > 0 && !/\s/.test(previousChar)) continue if ( nextMatchStart === -1 || searchStart < nextMatchStart || (searchStart === nextMatchStart && name.length > matchedName.length) ) { nextMatchStart = searchStart matchedName = name } } if (nextMatchStart === -1) break if (nextMatchStart > cursor) segments.push({content.slice(cursor, nextMatchStart)}) const mentionEnd = nextMatchStart + matchedName.length + 1 segments.push( {content.slice(nextMatchStart, mentionEnd)} , ) hasMention = true cursor = mentionEnd } if (!hasMention) return content if (cursor < content.length) segments.push({content.slice(cursor)}) return segments }, [content, mentionedNames]) return (
{authorName} {formatTimeFromNow(createdAt * 1000)}
{highlightedContent}
) } export const CommentThread: FC = memo(({ comment, loading = false, onClose, onDelete, onResolve, onPrev, onNext, canGoPrev, canGoNext, onReply, onReplyEdit, onReplyDelete, }) => { const { flowToScreenPosition } = useReactFlow() const viewport = useViewport() const { userProfile } = useAppContext() const { t } = useTranslation() const [replyContent, setReplyContent] = useState('') const [activeReplyMenuId, setActiveReplyMenuId] = useState(null) const [editingReply, setEditingReply] = useState<{ id: string; content: string }>({ id: '', content: '' }) useEffect(() => { setReplyContent('') }, [comment.id]) const handleReplySubmit = useCallback(async (content: string, mentionedUserIds: string[]) => { if (!onReply || loading) return try { await onReply(content, mentionedUserIds) setReplyContent('') } catch (error) { console.error('Failed to send reply', error) } }, [onReply, loading]) const screenPosition = useMemo(() => { return flowToScreenPosition({ x: comment.position_x, y: comment.position_y, }) }, [comment.position_x, comment.position_y, viewport.x, viewport.y, viewport.zoom, flowToScreenPosition]) const handleStartEdit = useCallback((reply: WorkflowCommentDetailReply) => { setEditingReply({ id: reply.id, content: reply.content }) setActiveReplyMenuId(null) }, []) const handleCancelEdit = useCallback(() => { setEditingReply({ id: '', content: '' }) }, []) const handleEditSubmit = useCallback(async (content: string, mentionedUserIds: string[]) => { if (!onReplyEdit || !editingReply) return const trimmed = content.trim() if (!trimmed) return await onReplyEdit(editingReply.id, trimmed, mentionedUserIds) setEditingReply({ id: '', content: '' }) }, [editingReply, onReplyEdit]) const replies = comment.replies || [] const messageListRef = useRef(null) const previousReplyCountRef = useRef(replies.length) const previousCommentIdRef = useRef(comment.id) useEffect(() => { const container = messageListRef.current if (!container) return const isNewComment = comment.id !== previousCommentIdRef.current const hasNewReply = replies.length > previousReplyCountRef.current if (isNewComment || hasNewReply) container.scrollTop = container.scrollHeight previousCommentIdRef.current = comment.id previousReplyCountRef.current = replies.length }, [comment.id, replies.length]) const mentionsByTarget = useMemo(() => { const map = new Map() for (const mention of comment.mentions || []) { const name = mention.mentioned_user_account?.name?.trim() if (!name) continue const key = mention.reply_id ?? 'root' const existing = map.get(key) if (existing) { if (!existing.includes(name)) existing.push(name) } else { map.set(key, [name]) } } return map }, [comment.mentions]) return (
{t('workflow.comments.panelTitle')}
{replies.length > 0 && (
{replies.map((reply) => { const isReplyEditing = editingReply?.id === reply.id const isOwnReply = reply.created_by_account?.id === userProfile?.id return (
{isOwnReply && !isReplyEditing && (
{activeReplyMenuId === reply.id && (
)}
)} {isReplyEditing ? (
setEditingReply(prev => prev ? { ...prev, content: newContent } : prev)} onSubmit={handleEditSubmit} onCancel={handleCancelEdit} placeholder={t('workflow.comments.placeholder.editReply')} disabled={loading} loading={loading} isEditing={true} className="system-sm-regular" autoFocus />
) : ( )}
) })}
)}
{loading && (
{t('workflow.comments.loading')}
)} {onReply && (
)}
) }) CommentThread.displayName = 'CommentThread'