diff --git a/CHANGELOG.md b/CHANGELOG.md index c004b1d..05fe340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.16.0 — 2026-07-13 21:59 + +### Added +- **@mention autocomplete in comments**: type "@" and a name to search and insert a mention, instead of typing the exact username blind. + ## 0.15.0 — 2026-07-13 21:54 ### Added diff --git a/apps/web/components/social/comments-section.tsx b/apps/web/components/social/comments-section.tsx index 4db1ea1..cd4ee3e 100644 --- a/apps/web/components/social/comments-section.tsx +++ b/apps/web/components/social/comments-section.tsx @@ -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(null); + const mentionDebounceRef = useRef | 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(null); + const [mentionCandidates, setMentionCandidates] = useState([]); + 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) { + 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) { + 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 (
-