2026-01-15 20:35:21 +02:00
|
|
|
"use client";
|
|
|
|
|
|
2026-03-10 18:24:28 +05:30
|
|
|
import { ArrowUp, Send, X } from "lucide-react";
|
2026-01-15 20:35:21 +02:00
|
|
|
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";
|
|
|
|
|
|
2026-01-16 11:36:17 +02:00
|
|
|
function convertDisplayToData(displayContent: string, mentions: InsertedMention[]): string {
|
2026-01-15 20:35:21 +02:00
|
|
|
let result = displayContent;
|
|
|
|
|
|
2026-01-16 11:36:17 +02:00
|
|
|
const sortedMentions = [...mentions].sort((a, b) => b.displayName.length - a.displayName.length);
|
2026-01-15 20:35:21 +02:00
|
|
|
|
|
|
|
|
for (const mention of sortedMentions) {
|
2026-01-16 11:36:17 +02:00
|
|
|
const displayPattern = new RegExp(
|
|
|
|
|
`@${escapeRegExp(mention.displayName)}(?=\\s|$|[.,!?;:])`,
|
|
|
|
|
"g"
|
|
|
|
|
);
|
2026-01-15 20:35:21 +02:00
|
|
|
const dataFormat = `@[${mention.id}]`;
|
|
|
|
|
result = result.replace(displayPattern, dataFormat);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function escapeRegExp(string: string): string {
|
2026-01-16 11:36:17 +02:00
|
|
|
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
2026-01-15 20:35:21 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function findMentionTrigger(
|
|
|
|
|
text: string,
|
|
|
|
|
cursorPos: number,
|
|
|
|
|
insertedMentions: InsertedMention[]
|
|
|
|
|
): { isActive: boolean; query: string; startIndex: number } {
|
|
|
|
|
const textBeforeCursor = text.slice(0, cursorPos);
|
2026-01-16 11:36:17 +02:00
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
const mentionMatch = textBeforeCursor.match(/(?:^|[\s])@([^\s]*)$/);
|
2026-01-16 11:36:17 +02:00
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
if (!mentionMatch) {
|
|
|
|
|
return { isActive: false, query: "", startIndex: 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);
|
2026-01-16 11:36:17 +02:00
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
for (const mention of insertedMentions) {
|
|
|
|
|
const mentionPattern = `@${mention.displayName}`;
|
2026-01-16 11:36:17 +02:00
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
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,
|
2026-01-20 19:49:34 +05:30
|
|
|
placeholder = "Comment or @mention",
|
2026-01-15 20:35:21 +02:00
|
|
|
submitLabel = "Send",
|
|
|
|
|
isSubmitting = false,
|
|
|
|
|
onSubmit,
|
|
|
|
|
onCancel,
|
|
|
|
|
autoFocus = false,
|
2026-01-16 20:27:00 +02:00
|
|
|
initialValue = "",
|
2026-03-10 18:24:28 +05:30
|
|
|
compact = false,
|
2026-01-15 20:35:21 +02:00
|
|
|
}: CommentComposerProps) {
|
2026-01-16 20:27:00 +02:00
|
|
|
const [displayContent, setDisplayContent] = useState(initialValue);
|
2026-01-15 20:35:21 +02:00
|
|
|
const [insertedMentions, setInsertedMentions] = useState<InsertedMention[]>([]);
|
2026-01-16 20:27:00 +02:00
|
|
|
const [mentionsInitialized, setMentionsInitialized] = useState(false);
|
2026-01-15 20:35:21 +02:00
|
|
|
const [mentionState, setMentionState] = useState<MentionState>({
|
|
|
|
|
isActive: false,
|
|
|
|
|
query: "",
|
|
|
|
|
startIndex: 0,
|
|
|
|
|
});
|
|
|
|
|
const [highlightedIndex, setHighlightedIndex] = useState(0);
|
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(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<HTMLTextAreaElement>) => {
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
const cursorPos = e.target.selectionStart;
|
|
|
|
|
setDisplayContent(value);
|
|
|
|
|
|
2026-01-20 19:49:34 +05:30
|
|
|
// Auto-resize textarea on content change
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
const textarea = e.target;
|
|
|
|
|
textarea.style.height = "auto";
|
|
|
|
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
const triggerResult = findMentionTrigger(value, cursorPos, insertedMentions);
|
2026-01-16 11:36:17 +02:00
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
if (triggerResult.isActive) {
|
|
|
|
|
setMentionState(triggerResult);
|
|
|
|
|
setHighlightedIndex(0);
|
|
|
|
|
} else if (mentionState.isActive) {
|
|
|
|
|
closeMentionPicker();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
|
|
|
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();
|
2026-01-16 11:36:17 +02:00
|
|
|
setHighlightedIndex((prev) => (prev < filteredMembers.length - 1 ? prev + 1 : 0));
|
2026-01-15 20:35:21 +02:00
|
|
|
} else if (e.key === "Tab") {
|
|
|
|
|
e.preventDefault();
|
2026-01-16 11:36:17 +02:00
|
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
|
2026-01-15 20:35:21 +02:00
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case "ArrowUp":
|
|
|
|
|
e.preventDefault();
|
2026-01-16 11:36:17 +02:00
|
|
|
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filteredMembers.length - 1));
|
2026-01-15 20:35:21 +02:00
|
|
|
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([]);
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-16 20:27:00 +02:00
|
|
|
// Pre-populate insertedMentions from initialValue when members are loaded
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (mentionsInitialized || !initialValue || members.length === 0) return;
|
2026-01-19 14:37:45 +02:00
|
|
|
|
2026-01-16 20:27:00 +02:00
|
|
|
const mentionPattern = /@([^\s@]+(?:\s+[^\s@]+)*?)(?=\s|$|[.,!?;:]|@)/g;
|
|
|
|
|
const foundMentions: InsertedMention[] = [];
|
2026-01-20 19:49:34 +05:30
|
|
|
const matches = initialValue.matchAll(mentionPattern);
|
2026-01-19 14:37:45 +02:00
|
|
|
|
2026-01-20 19:49:34 +05:30
|
|
|
for (const match of matches) {
|
2026-01-16 20:27:00 +02:00
|
|
|
const displayName = match[1];
|
|
|
|
|
const member = members.find(
|
|
|
|
|
(m) => m.displayName === displayName || m.email.split("@")[0] === displayName
|
|
|
|
|
);
|
|
|
|
|
if (member) {
|
|
|
|
|
const exists = foundMentions.some((m) => m.id === member.id);
|
|
|
|
|
if (!exists) {
|
|
|
|
|
foundMentions.push({ id: member.id, displayName });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-19 14:37:45 +02:00
|
|
|
|
2026-01-16 20:27:00 +02:00
|
|
|
if (foundMentions.length > 0) {
|
|
|
|
|
setInsertedMentions(foundMentions);
|
|
|
|
|
}
|
|
|
|
|
setMentionsInitialized(true);
|
|
|
|
|
}, [initialValue, members, mentionsInitialized]);
|
|
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (autoFocus && textareaRef.current) {
|
|
|
|
|
textareaRef.current.focus();
|
|
|
|
|
}
|
|
|
|
|
}, [autoFocus]);
|
|
|
|
|
|
|
|
|
|
const canSubmit = displayContent.trim().length > 0 && !isSubmitting;
|
|
|
|
|
|
2026-01-20 19:49:34 +05:30
|
|
|
// Auto-resize textarea
|
|
|
|
|
const adjustTextareaHeight = useCallback(() => {
|
|
|
|
|
const textarea = textareaRef.current;
|
|
|
|
|
if (textarea) {
|
|
|
|
|
textarea.style.height = "auto";
|
|
|
|
|
textarea.style.height = `${textarea.scrollHeight}px`;
|
|
|
|
|
}
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
adjustTextareaHeight();
|
|
|
|
|
}, [adjustTextareaHeight]);
|
|
|
|
|
|
2026-01-15 20:35:21 +02:00
|
|
|
return (
|
2026-03-10 18:24:28 +05:30
|
|
|
<div className={cn("flex", compact ? "flex-row items-center gap-2" : "flex-col gap-2")}>
|
|
|
|
|
<div className={cn(compact && "flex-1 min-w-0")}>
|
|
|
|
|
<Popover
|
|
|
|
|
open={mentionState.isActive}
|
|
|
|
|
onOpenChange={(open) => !open && closeMentionPicker()}
|
|
|
|
|
modal={false}
|
2026-01-15 20:35:21 +02:00
|
|
|
>
|
2026-03-10 18:24:28 +05:30
|
|
|
<PopoverAnchor asChild>
|
|
|
|
|
<Textarea
|
|
|
|
|
ref={textareaRef}
|
|
|
|
|
value={displayContent}
|
|
|
|
|
onChange={handleInputChange}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
placeholder={placeholder}
|
|
|
|
|
className="min-h-[40px] max-h-[200px] w-full resize-none overflow-y-auto scrollbar-thin border-none shadow-none focus-visible:ring-0 bg-transparent dark:bg-transparent"
|
|
|
|
|
rows={1}
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
/>
|
|
|
|
|
</PopoverAnchor>
|
|
|
|
|
<PopoverContent
|
|
|
|
|
side="top"
|
|
|
|
|
align="start"
|
|
|
|
|
sideOffset={4}
|
|
|
|
|
collisionPadding={8}
|
|
|
|
|
className="w-72 p-0"
|
|
|
|
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
|
|
|
|
>
|
|
|
|
|
<MemberMentionPicker
|
|
|
|
|
members={members}
|
|
|
|
|
query={mentionState.query}
|
|
|
|
|
highlightedIndex={highlightedIndex}
|
|
|
|
|
isLoading={membersLoading}
|
|
|
|
|
onSelect={insertMention}
|
|
|
|
|
onHighlightChange={setHighlightedIndex}
|
|
|
|
|
/>
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
</Popover>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={cn("flex items-center gap-2", !compact && "justify-end")}>
|
2026-01-15 20:35:21 +02:00
|
|
|
{onCancel && (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={onCancel}
|
|
|
|
|
disabled={isSubmitting}
|
|
|
|
|
>
|
|
|
|
|
<X className="mr-1 size-4" />
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
2026-03-10 18:24:28 +05:30
|
|
|
size={compact ? "icon" : "sm"}
|
2026-01-15 20:35:21 +02:00
|
|
|
onClick={handleSubmit}
|
|
|
|
|
disabled={!canSubmit}
|
2026-03-10 18:24:53 +05:30
|
|
|
className={cn(!canSubmit && "opacity-50", compact && "size-8 shrink-0 rounded-full")}
|
2026-01-15 20:35:21 +02:00
|
|
|
>
|
2026-03-10 18:24:28 +05:30
|
|
|
{compact ? (
|
|
|
|
|
<ArrowUp className="size-4" />
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<Send className="mr-1 size-4" />
|
|
|
|
|
{submitLabel}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-01-15 20:35:21 +02:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|