diff --git a/web/app/components/workflow/comment/thread.tsx b/web/app/components/workflow/comment/thread.tsx index 857a2d2116..436bea26a0 100644 --- a/web/app/components/workflow/comment/thread.tsx +++ b/web/app/components/workflow/comment/thread.tsx @@ -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 } const ThreadMessage: FC<{ @@ -31,13 +39,13 @@ const ThreadMessage: FC<{ const { formatTimeFromNow } = useFormatTimeFromNow() return ( -
+
@@ -74,9 +82,160 @@ export const CommentThread: FC = 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([]) + const [showMentionDropdown, setShowMentionDropdown] = useState(false) + const [mentionQuery, setMentionQuery] = useState('') + const [mentionPosition, setMentionPosition] = useState(0) + const [selectedMentionIndex, setSelectedMentionIndex] = useState(0) + const [mentionedUserIds, setMentionedUserIds] = useState([]) + const textareaRef = useRef(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) => { + 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 = memo(({ content={comment.content} /> {comment.replies?.length > 0 && ( -
+
{comment.replies.map(renderReply)}
)} @@ -163,7 +322,82 @@ export const CommentThread: FC = memo(({ Loading…
)} + {onReply && ( +
+
+ +
+
+