feat: @mention autocomplete in comment composer
Typing "@" + 2+ characters now opens a dropdown of matching users (reusing the existing people-search endpoint), with arrow-key/Enter/ Tab selection and click-to-select. Consolidated the previously duplicated MENTION_REGEX (client display vs. server extraction) into a single export from lib/mentions.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user