feat: add inline mentions in comments

This commit is contained in:
gosangam
2026-06-13 13:10:34 +05:30
parent 37305201bd
commit eb0c981d3c
5 changed files with 272 additions and 11 deletions
+5 -1
View File
@@ -145,7 +145,9 @@ function NotificationRow({
? `/u/${selfHandle}/posts#post-${n.post_id}` ? `/u/${selfHandle}/posts#post-${n.post_id}`
: n.kind === "post_mention" && n.post_id && n.actor_handle : n.kind === "post_mention" && n.post_id && n.actor_handle
? `/u/${n.actor_handle}/posts#post-${n.post_id}` ? `/u/${n.actor_handle}/posts#post-${n.post_id}`
: (n.kind === "post_comment" || n.kind === "comment_reply") && : (n.kind === "post_comment" ||
n.kind === "comment_reply" ||
n.kind === "comment_mention") &&
n.post_id && n.post_id &&
n.post_author_handle n.post_author_handle
? `/u/${n.post_author_handle}/posts#post-${n.post_id}` ? `/u/${n.post_author_handle}/posts#post-${n.post_id}`
@@ -190,6 +192,8 @@ function messageFor(n: AppNotification): string {
return "commented on your post."; return "commented on your post.";
case "comment_reply": case "comment_reply":
return "replied to your comment."; return "replied to your comment.";
case "comment_mention":
return "mentioned you in a comment.";
default: default:
return "sent you a notification."; return "sent you a notification.";
} }
@@ -67,6 +67,8 @@ function messageFor(e: IncomingEvent): string {
return `${who} commented on your post`; return `${who} commented on your post`;
case "comment_reply": case "comment_reply":
return `${who} replied to your comment`; return `${who} replied to your comment`;
case "comment_mention":
return `${who} mentioned you in a comment`;
default: default:
return "You have a new notification"; return "You have a new notification";
} }
+22 -1
View File
@@ -11,6 +11,7 @@
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { HiHeart, HiOutlineHeart } from "react-icons/hi2"; import { HiHeart, HiOutlineHeart } from "react-icons/hi2";
import { Pin } from "lucide-react"; import { Pin } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -20,6 +21,26 @@ import { UserAvatar } from "@/components/ui/user-avatar";
import { timeAgo } from "@/lib/time"; import { timeAgo } from "@/lib/time";
import { CommentMenu } from "./comment-menu"; import { CommentMenu } from "./comment-menu";
// Split a comment body into text + clickable @handle links. The capturing
// group keeps the delimiters so they can be turned into profile links.
const MENTION_TOKEN = /(@[A-Za-z0-9_]+)/g;
function renderBody(text: string) {
return text.split(MENTION_TOKEN).map((part, i) => {
if (part.length > 1 && part[0] === "@") {
return (
<Link
key={i}
href={`/u/${part.slice(1)}`}
className="font-medium text-primary hover:underline"
>
{part}
</Link>
);
}
return part;
});
}
export function CommentRow({ export function CommentRow({
comment, comment,
viewerId, viewerId,
@@ -151,7 +172,7 @@ export function CommentRow({
@{comment.reply_to_handle}{" "} @{comment.reply_to_handle}{" "}
</span> </span>
) : null} ) : null}
{comment.body} {renderBody(comment.body)}
</p> </p>
{/* Inline action row */} {/* Inline action row */}
@@ -37,6 +37,7 @@ import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { Drawer, DrawerContent } from "@/components/ui/drawer"; import { Drawer, DrawerContent } from "@/components/ui/drawer";
import { ConfirmModal } from "@/components/ui/confirm-modal"; import { ConfirmModal } from "@/components/ui/confirm-modal";
import { EmojiPicker } from "@/components/ui/emoji-picker"; import { EmojiPicker } from "@/components/ui/emoji-picker";
import { MentionTextarea } from "@/components/social/mention-textarea";
import { CommentRow } from "./comment-row"; import { CommentRow } from "./comment-row";
import { ReportCommentModal } from "./comment-report-modal"; import { ReportCommentModal } from "./comment-report-modal";
@@ -324,20 +325,15 @@ export function PostCommentsDrawer({
) : null} ) : null}
<div className="flex items-end gap-2"> <div className="flex items-end gap-2">
<EmojiPicker onSelect={insertEmoji} /> <EmojiPicker onSelect={insertEmoji} />
<textarea <MentionTextarea
ref={inputRef} ref={inputRef}
value={draft} value={draft}
onChange={(e) => setDraft(e.target.value)} onChange={setDraft}
onKeyDown={(e) => { onSubmit={submit}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
}}
rows={1} rows={1}
maxLength={2000} maxLength={2000}
placeholder={replyTo ? "Write a reply…" : "Add a comment…"} placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-base sm:text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32" className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-base sm:text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/40 max-h-32"
/> />
<button <button
type="button" type="button"
+238
View File
@@ -0,0 +1,238 @@
"use client";
/**
* MentionTextarea — a textarea with inline "@" mention autocomplete.
*
* As the user types an `@token` at the caret, a suggestion list opens above
* the field (prioritised following → followers → others). Picking a user (tap
* or ↑/↓ + Enter/Tab) replaces the token with `@handle `. The list is portaled
* with `pointer-events-auto` so it works inside a modal vaul drawer, and
* dismisses on blur — no document listener that could fight the drawer.
*
* Enter (without Shift) submits via `onSubmit` ONLY when no suggestion list is
* open; while the list is open, Enter/Tab pick the highlighted user. The
* underlying <textarea> ref is forwarded so callers can focus it / splice text
* (e.g. the emoji picker).
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Loader2 } from "lucide-react";
import { posts, type UserSummary } from "@/lib/social";
import { UserAvatar } from "@/components/ui/user-avatar";
const SUGGEST_LIMIT = 8;
// "@" at line start or after whitespace, then up to 30 handle chars, at the
// end of the text before the caret. Avoids matching emails (foo@bar).
const MENTION_RE = /(^|\s)@([A-Za-z0-9_]{0,30})$/;
type Mention = { start: number; query: string };
type PanelPos = { left: number; bottom: number; width: number };
export const MentionTextarea = forwardRef<
HTMLTextAreaElement,
{
value: string;
onChange: (v: string) => void;
/** Called on Enter (no Shift) when the suggestion list is closed. */
onSubmit?: () => void;
placeholder?: string;
className?: string;
maxLength?: number;
rows?: number;
}
>(function MentionTextarea(
{ value, onChange, onSubmit, placeholder, className, maxLength, rows = 1 },
ref,
) {
const innerRef = useRef<HTMLTextAreaElement>(null);
useImperativeHandle(ref, () => innerRef.current as HTMLTextAreaElement, []);
const [mention, setMention] = useState<Mention | null>(null);
const [items, setItems] = useState<UserSummary[]>([]);
const [loading, setLoading] = useState(false);
const [active, setActive] = useState(0);
const [pos, setPos] = useState<PanelPos | null>(null);
const reqRef = useRef(0);
const blurTimer = useRef<number | null>(null);
// Detect the active @token from the text up to the caret.
function detect(el: HTMLTextAreaElement) {
const caret = el.selectionStart ?? 0;
const m = MENTION_RE.exec(el.value.slice(0, caret));
if (!m) {
setMention(null);
return;
}
setMention({ start: caret - m[2].length - 1, query: m[2] });
setActive(0);
const r = el.getBoundingClientRect();
setPos({
left: r.left,
bottom: window.innerHeight - r.top + 6,
width: r.width,
});
}
// Fetch suggestions (debounced) whenever the active token changes.
useEffect(() => {
if (!mention) return;
const token = ++reqRef.current;
const t = window.setTimeout(() => {
setLoading(true);
posts
.mentionSuggest(mention.query, { limit: SUGGEST_LIMIT, offset: 0 })
.then((res) => {
if (reqRef.current === token) setItems(res);
})
.catch(() => {
if (reqRef.current === token) setItems([]);
})
.finally(() => {
if (reqRef.current === token) setLoading(false);
});
}, 150);
return () => window.clearTimeout(t);
}, [mention]);
function select(u: UserSummary) {
const el = innerRef.current;
if (!el || !mention) return;
const caret = el.selectionStart ?? value.length;
const insert = `@${u.handle} `;
const next = value.slice(0, mention.start) + insert + value.slice(caret);
onChange(next);
setMention(null);
const newCaret = mention.start + insert.length;
requestAnimationFrame(() => {
const node = innerRef.current;
if (!node) return;
node.focus();
node.setSelectionRange(newCaret, newCaret);
});
}
const listOpen = !!mention && (loading || items.length > 0);
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (listOpen && items.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => Math.min(a + 1, items.length - 1));
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
return;
}
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
select(items[active] ?? items[0]);
return;
}
if (e.key === "Escape") {
e.preventDefault();
setMention(null);
return;
}
}
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit?.();
}
}
return (
<div className="relative flex-1">
<textarea
ref={innerRef}
value={value}
rows={rows}
maxLength={maxLength}
placeholder={placeholder}
className={className}
autoComplete="off"
onChange={(e) => {
onChange(e.target.value);
detect(e.target);
}}
onKeyUp={(e) => detect(e.currentTarget)}
onClick={(e) => detect(e.currentTarget)}
onKeyDown={onKeyDown}
onBlur={() => {
// Defer so a result tap (mousedown→click) lands before close.
blurTimer.current = window.setTimeout(() => setMention(null), 120);
}}
/>
{listOpen && pos && typeof document !== "undefined"
? createPortal(
<div
role="listbox"
aria-label="Mention suggestions"
style={{
position: "fixed",
left: pos.left,
bottom: pos.bottom,
width: pos.width,
}}
// pointer-events-auto: the comments drawer is a modal vaul sheet
// (body gets pointer-events:none); without this the list would be
// click-through.
className="pointer-events-auto z-[101] max-h-56 overflow-y-auto overscroll-contain rounded-lg border border-border bg-card shadow-xl"
onTouchMove={(e) => e.stopPropagation()}
>
{items.map((u, i) => (
<button
key={u.id ?? u.handle}
type="button"
role="option"
aria-selected={i === active}
// Keep textarea focus + cancel the pending blur-close so the
// click registers before the list would otherwise close.
onMouseDown={(e) => {
e.preventDefault();
if (blurTimer.current)
window.clearTimeout(blurTimer.current);
}}
onMouseEnter={() => setActive(i)}
onClick={() => select(u)}
className={`flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors ${
i === active ? "bg-muted/70" : "hover:bg-muted/60"
}`}
>
<UserAvatar
src={u.avatar_url}
name={u.name}
className="size-8"
textClassName="text-xs"
/>
<span className="min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{u.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
@{u.handle}
</span>
</span>
</button>
))}
{loading ? (
<div className="flex justify-center py-2">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : null}
</div>,
document.body,
)
: null}
</div>
);
});