|
|
|
@@ -1,6 +1,6 @@
|
|
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useState, useEffect, useCallback, useMemo, Fragment } from "react";
|
|
|
|
|
import { useState, useEffect, useCallback, useMemo, useRef, Fragment } from "react";
|
|
|
|
|
import Link from "next/link";
|
|
|
|
|
import { MessageCircle, Reply, Trash2 } from "lucide-react";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
@@ -22,6 +22,14 @@ import {
|
|
|
|
|
import { CommentReactions } from "@/components/social/comment-reactions";
|
|
|
|
|
import { ReportButton } from "@/components/social/report-button";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { MENTION_REGEX } from "@/lib/mentions";
|
|
|
|
|
|
|
|
|
|
type MentionCandidate = { id: string; name: string; username: string | null; avatarUrl: string | null };
|
|
|
|
|
|
|
|
|
|
// Matches an in-progress "@partial" token ending at the cursor — e.g. typing
|
|
|
|
|
// "hey @al" mid-comment matches "@al", but "hey @al there" (cursor after
|
|
|
|
|
// "there") doesn't, since the mention token no longer ends at the cursor.
|
|
|
|
|
const MENTION_TRIGGER_REGEX = /@([a-z0-9_-]{0,20})$/i;
|
|
|
|
|
|
|
|
|
|
type Comment = {
|
|
|
|
|
id: string;
|
|
|
|
@@ -44,7 +52,6 @@ type CommentsResponse = {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MAX_VISUAL_INDENT = 4;
|
|
|
|
|
const MENTION_REGEX = /@([a-z0-9_-]{3,30})/gi;
|
|
|
|
|
|
|
|
|
|
function renderContentWithMentions(content: string) {
|
|
|
|
|
const parts = content.split(MENTION_REGEX);
|
|
|
|
@@ -92,6 +99,16 @@ function CommentForm({
|
|
|
|
|
const [content, setContent] = useState("");
|
|
|
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
|
|
|
|
|
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
|
const mentionDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
|
// Where the "@" of the mention currently being typed sits in `content` —
|
|
|
|
|
// needed to splice in the chosen username without disturbing the rest of
|
|
|
|
|
// the text, since the cursor may have moved by the time a fetch resolves.
|
|
|
|
|
const mentionStartRef = useRef<number | null>(null);
|
|
|
|
|
const [mentionCandidates, setMentionCandidates] = useState<MentionCandidate[]>([]);
|
|
|
|
|
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
|
|
|
|
|
const mentionOpen = mentionCandidates.length > 0;
|
|
|
|
|
|
|
|
|
|
async function submit() {
|
|
|
|
|
if (!content.trim()) return;
|
|
|
|
|
setSubmitting(true);
|
|
|
|
@@ -103,21 +120,122 @@ function CommentForm({
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) { toast.error(t("commentFailed")); return; }
|
|
|
|
|
setContent("");
|
|
|
|
|
closeMentions();
|
|
|
|
|
onSubmit();
|
|
|
|
|
} finally {
|
|
|
|
|
setSubmitting(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function closeMentions() {
|
|
|
|
|
setMentionCandidates([]);
|
|
|
|
|
setMentionActiveIndex(0);
|
|
|
|
|
mentionStartRef.current = null;
|
|
|
|
|
if (mentionDebounceRef.current) clearTimeout(mentionDebounceRef.current);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleContentChange(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
|
|
|
|
const value = e.target.value;
|
|
|
|
|
const cursor = e.target.selectionStart;
|
|
|
|
|
setContent(value);
|
|
|
|
|
|
|
|
|
|
const beforeCursor = value.slice(0, cursor);
|
|
|
|
|
const match = MENTION_TRIGGER_REGEX.exec(beforeCursor);
|
|
|
|
|
if (!match) {
|
|
|
|
|
closeMentions();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const partial = match[1] ?? "";
|
|
|
|
|
mentionStartRef.current = cursor - match[0].length;
|
|
|
|
|
if (mentionDebounceRef.current) clearTimeout(mentionDebounceRef.current);
|
|
|
|
|
if (partial.length < 2) {
|
|
|
|
|
setMentionCandidates([]);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
mentionDebounceRef.current = setTimeout(async () => {
|
|
|
|
|
try {
|
|
|
|
|
const res = await fetch(`/api/v1/users/search?q=${encodeURIComponent(partial)}`);
|
|
|
|
|
if (!res.ok) return;
|
|
|
|
|
const data = (await res.json()) as { users: MentionCandidate[] };
|
|
|
|
|
setMentionCandidates(data.users);
|
|
|
|
|
setMentionActiveIndex(0);
|
|
|
|
|
} catch {
|
|
|
|
|
// Silently drop — a failed autocomplete fetch shouldn't interrupt typing.
|
|
|
|
|
}
|
|
|
|
|
}, 250);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectMention(candidate: MentionCandidate) {
|
|
|
|
|
const start = mentionStartRef.current;
|
|
|
|
|
const textarea = textareaRef.current;
|
|
|
|
|
if (start === null || !candidate.username || !textarea) { closeMentions(); return; }
|
|
|
|
|
|
|
|
|
|
const cursor = textarea.selectionStart;
|
|
|
|
|
const next = `${content.slice(0, start)}@${candidate.username} ${content.slice(cursor)}`;
|
|
|
|
|
setContent(next);
|
|
|
|
|
closeMentions();
|
|
|
|
|
|
|
|
|
|
const caretPos = start + candidate.username.length + 2;
|
|
|
|
|
requestAnimationFrame(() => {
|
|
|
|
|
textarea.focus();
|
|
|
|
|
textarea.setSelectionRange(caretPos, caretPos);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
|
|
|
if (!mentionOpen) return;
|
|
|
|
|
if (e.key === "ArrowDown") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setMentionActiveIndex((i) => (i + 1) % mentionCandidates.length);
|
|
|
|
|
} else if (e.key === "ArrowUp") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setMentionActiveIndex((i) => (i - 1 + mentionCandidates.length) % mentionCandidates.length);
|
|
|
|
|
} else if (e.key === "Enter" || e.key === "Tab") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
selectMention(mentionCandidates[mentionActiveIndex]!);
|
|
|
|
|
} else if (e.key === "Escape") {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
closeMentions();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Textarea
|
|
|
|
|
value={content}
|
|
|
|
|
onChange={(e) => setContent(e.target.value)}
|
|
|
|
|
placeholder={placeholder ?? t("commentPlaceholder")}
|
|
|
|
|
rows={2}
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
/>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<Textarea
|
|
|
|
|
ref={textareaRef}
|
|
|
|
|
value={content}
|
|
|
|
|
onChange={handleContentChange}
|
|
|
|
|
onKeyDown={handleKeyDown}
|
|
|
|
|
onBlur={() => setTimeout(closeMentions, 150)}
|
|
|
|
|
placeholder={placeholder ?? t("commentPlaceholder")}
|
|
|
|
|
rows={2}
|
|
|
|
|
disabled={submitting}
|
|
|
|
|
/>
|
|
|
|
|
{mentionOpen && (
|
|
|
|
|
<div className="absolute z-20 top-full left-0 mt-1 w-64 max-h-56 overflow-y-auto rounded-lg border bg-popover shadow-lg py-1">
|
|
|
|
|
{mentionCandidates.map((candidate, i) => (
|
|
|
|
|
<button
|
|
|
|
|
key={candidate.id}
|
|
|
|
|
type="button"
|
|
|
|
|
onMouseDown={(e) => { e.preventDefault(); selectMention(candidate); }}
|
|
|
|
|
className={cn(
|
|
|
|
|
"w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm",
|
|
|
|
|
i === mentionActiveIndex ? "bg-accent" : "hover:bg-accent/50"
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<Avatar className="h-6 w-6 shrink-0">
|
|
|
|
|
{candidate.avatarUrl && <AvatarImage src={candidate.avatarUrl} alt={candidate.name} />}
|
|
|
|
|
<AvatarFallback className="text-xs">{candidate.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
|
|
|
|
</Avatar>
|
|
|
|
|
<span className="truncate">{candidate.name}</span>
|
|
|
|
|
<span className="text-xs text-muted-foreground truncate">@{candidate.username}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button size="sm" onClick={submit} disabled={!content.trim() || submitting}>
|
|
|
|
|
{submitting ? t("postingButton") : t("postButton")}
|
|
|
|
|