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:
Arnaud
2026-07-13 22:00:10 +02:00
parent 6efabeab8a
commit a144052e46
6 changed files with 143 additions and 13 deletions
+5
View File
@@ -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
+127 -9
View File
@@ -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")}
+8 -1
View File
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
export const APP_VERSION = "0.15.0";
export const APP_VERSION = "0.16.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
{
version: "0.16.0",
date: "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.",
],
},
{
version: "0.15.0",
date: "2026-07-13 21:54",
+1 -1
View File
@@ -1,4 +1,4 @@
const MENTION_REGEX = /@([a-z0-9_-]{3,30})/gi;
export const MENTION_REGEX = /@([a-z0-9_-]{3,30})/gi;
export function extractMentionedUsernames(content: string): string[] {
const matches = content.matchAll(MENTION_REGEX);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"scripts": {
"dev": "next dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "epicure",
"version": "0.15.0",
"version": "0.16.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",