diff --git a/surfsense_web/app/preview/chat-comments/page.tsx b/surfsense_web/app/preview/chat-comments/page.tsx index 74c0f2dc1..0c4194c61 100644 --- a/surfsense_web/app/preview/chat-comments/page.tsx +++ b/surfsense_web/app/preview/chat-comments/page.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { CommentComposer } from "@/components/chat-comments/comment-composer/comment-composer"; import { MemberMentionPicker } from "@/components/chat-comments/member-mention-picker/member-mention-picker"; import type { MemberOption } from "@/components/chat-comments/member-mention-picker/types"; @@ -40,10 +41,11 @@ const fakeMembersData: MemberOption[] = [ export default function ChatCommentsPreviewPage() { const [highlightedIndex, setHighlightedIndex] = useState(0); const [selectedMember, setSelectedMember] = useState(null); + const [submittedContent, setSubmittedContent] = useState(null); return (
-
+

Chat Comments UI Preview

@@ -51,76 +53,104 @@ export default function ChatCommentsPreviewPage() {

-
-
-

After typing @

-

Shows all members

-
- setSelectedMember(member)} - onHighlightChange={setHighlightedIndex} - /> + {/* Comment Composer Section */} +
+

Comment Composer

+

+ Type @ to trigger mention picker. Use Tab/Shift+Tab/Arrow keys to navigate, Enter to select. +

+ +
+ setSubmittedContent(content)} + onCancel={() => setSubmittedContent(null)} + autoFocus + /> +
+ + {submittedContent && ( +
+ Submitted content: + + {submittedContent} +
- {selectedMember && ( -
- Selected: - - @[{selectedMember.id.slice(0, 8)}...] - - - {" → @"} - {selectedMember.displayName || selectedMember.email} - + )} +
+ + {/* Member Mention Picker Section */} +
+

Member Mention Picker (Standalone)

+ +
+
+

After typing @

+

Shows all members

+
+ setSelectedMember(member)} + onHighlightChange={setHighlightedIndex} + />
- )} -
- -
-

After typing @ali

-

Filtered to matching members

-
- {}} - onHighlightChange={() => {}} - /> + {selectedMember && ( +
+ Selected: + + @[{selectedMember.id.slice(0, 8)}...] + +
+ )}
-
-
-

Loading State

-

While fetching members

-
- {}} - onHighlightChange={() => {}} - /> +
+

After typing @ali

+

Filtered to matching members

+
+ {}} + onHighlightChange={() => {}} + /> +
-
-
-

No Results

-

After typing @xyz (no match)

-
- {}} - onHighlightChange={() => {}} - /> +
+

Loading State

+

While fetching members

+
+ {}} + onHighlightChange={() => {}} + /> +
-
-
+ +
+

No Results

+

After typing @xyz (no match)

+
+ {}} + onHighlightChange={() => {}} + /> +
+
+
+
); diff --git a/surfsense_web/components/chat-comments/comment-composer/comment-composer.tsx b/surfsense_web/components/chat-comments/comment-composer/comment-composer.tsx new file mode 100644 index 000000000..58e046117 --- /dev/null +++ b/surfsense_web/components/chat-comments/comment-composer/comment-composer.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { Send, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { MemberMentionPicker } from "../member-mention-picker/member-mention-picker"; +import type { MemberOption } from "../member-mention-picker/types"; +import type { CommentComposerProps, InsertedMention, MentionState } from "./types"; + +function convertDisplayToData( + displayContent: string, + mentions: InsertedMention[] +): string { + let result = displayContent; + + const sortedMentions = [...mentions].sort( + (a, b) => b.displayName.length - a.displayName.length + ); + + for (const mention of sortedMentions) { + const displayPattern = new RegExp(`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`, 'g'); + const dataFormat = `@[${mention.id}]`; + result = result.replace(displayPattern, dataFormat); + } + + return result; +} + +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function findMentionTrigger( + text: string, + cursorPos: number, + insertedMentions: InsertedMention[] +): { isActive: boolean; query: string; startIndex: number } { + const textBeforeCursor = text.slice(0, cursorPos); + + const mentionMatch = textBeforeCursor.match(/(?:^|[\s])@([^\s]*)$/); + + if (!mentionMatch) { + return { isActive: false, query: "", startIndex: 0 }; + } + + const fullMatch = mentionMatch[0]; + const query = mentionMatch[1]; + const atIndex = cursorPos - query.length - 1; + + if (atIndex > 0) { + const charBefore = text[atIndex - 1]; + if (charBefore && !/[\s]/.test(charBefore)) { + return { isActive: false, query: "", startIndex: 0 }; + } + } + + const textFromAt = text.slice(atIndex); + + for (const mention of insertedMentions) { + const mentionPattern = `@${mention.displayName}`; + + if (textFromAt.startsWith(mentionPattern)) { + const charAfterMention = text[atIndex + mentionPattern.length]; + if (!charAfterMention || /[\s.,!?;:]/.test(charAfterMention)) { + if (cursorPos <= atIndex + mentionPattern.length) { + return { isActive: false, query: "", startIndex: 0 }; + } + } + } + } + + if (query.length > 50) { + return { isActive: false, query: "", startIndex: 0 }; + } + + return { isActive: true, query, startIndex: atIndex }; +} + +export function CommentComposer({ + members, + membersLoading = false, + placeholder = "Write a comment...", + submitLabel = "Send", + isSubmitting = false, + onSubmit, + onCancel, + autoFocus = false, +}: CommentComposerProps) { + const [displayContent, setDisplayContent] = useState(""); + const [insertedMentions, setInsertedMentions] = useState([]); + const [mentionState, setMentionState] = useState({ + isActive: false, + query: "", + startIndex: 0, + }); + const [highlightedIndex, setHighlightedIndex] = useState(0); + const textareaRef = useRef(null); + + const filteredMembers = mentionState.query + ? members.filter( + (member) => + member.displayName?.toLowerCase().includes(mentionState.query.toLowerCase()) || + member.email.toLowerCase().includes(mentionState.query.toLowerCase()) + ) + : members; + + const closeMentionPicker = useCallback(() => { + setMentionState({ isActive: false, query: "", startIndex: 0 }); + setHighlightedIndex(0); + }, []); + + const insertMention = useCallback( + (member: MemberOption) => { + const displayName = member.displayName || member.email.split("@")[0]; + const before = displayContent.slice(0, mentionState.startIndex); + const cursorPos = textareaRef.current?.selectionStart ?? displayContent.length; + const after = displayContent.slice(cursorPos); + const mentionText = `@${displayName} `; + const newContent = before + mentionText + after; + + setDisplayContent(newContent); + setInsertedMentions((prev) => { + const exists = prev.some((m) => m.id === member.id && m.displayName === displayName); + if (exists) return prev; + return [...prev, { id: member.id, displayName }]; + }); + closeMentionPicker(); + + requestAnimationFrame(() => { + if (textareaRef.current) { + const cursorPos = before.length + mentionText.length; + textareaRef.current.focus(); + textareaRef.current.setSelectionRange(cursorPos, cursorPos); + } + }); + }, + [displayContent, mentionState.startIndex, closeMentionPicker] + ); + + const handleInputChange = (e: React.ChangeEvent) => { + const value = e.target.value; + const cursorPos = e.target.selectionStart; + setDisplayContent(value); + + const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions); + + if (triggerResult.isActive) { + setMentionState(triggerResult); + setHighlightedIndex(0); + } else if (mentionState.isActive) { + closeMentionPicker(); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!mentionState.isActive) { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSubmit(); + } + return; + } + + switch (e.key) { + case "ArrowDown": + case "Tab": + if (!e.shiftKey) { + e.preventDefault(); + setHighlightedIndex((prev) => + prev < filteredMembers.length - 1 ? prev + 1 : 0 + ); + } else if (e.key === "Tab") { + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredMembers.length - 1 + ); + } + break; + case "ArrowUp": + e.preventDefault(); + setHighlightedIndex((prev) => + prev > 0 ? prev - 1 : filteredMembers.length - 1 + ); + break; + case "Enter": + e.preventDefault(); + if (filteredMembers[highlightedIndex]) { + insertMention(filteredMembers[highlightedIndex]); + } + break; + case "Escape": + e.preventDefault(); + closeMentionPicker(); + break; + } + }; + + const handleSubmit = () => { + const trimmed = displayContent.trim(); + if (!trimmed || isSubmitting) return; + + const dataContent = convertDisplayToData(trimmed, insertedMentions); + onSubmit(dataContent); + setDisplayContent(""); + setInsertedMentions([]); + }; + + useEffect(() => { + if (autoFocus && textareaRef.current) { + textareaRef.current.focus(); + } + }, [autoFocus]); + + const canSubmit = displayContent.trim().length > 0 && !isSubmitting; + + return ( +
+ !open && closeMentionPicker()}> + +