feat: add inline mentions in comments
This commit is contained in:
@@ -145,7 +145,9 @@ function NotificationRow({
|
||||
? `/u/${selfHandle}/posts#post-${n.post_id}`
|
||||
: n.kind === "post_mention" && n.post_id && n.actor_handle
|
||||
? `/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_author_handle
|
||||
? `/u/${n.post_author_handle}/posts#post-${n.post_id}`
|
||||
@@ -190,6 +192,8 @@ function messageFor(n: AppNotification): string {
|
||||
return "commented on your post.";
|
||||
case "comment_reply":
|
||||
return "replied to your comment.";
|
||||
case "comment_mention":
|
||||
return "mentioned you in a comment.";
|
||||
default:
|
||||
return "sent you a notification.";
|
||||
}
|
||||
|
||||
@@ -67,6 +67,8 @@ function messageFor(e: IncomingEvent): string {
|
||||
return `${who} commented on your post`;
|
||||
case "comment_reply":
|
||||
return `${who} replied to your comment`;
|
||||
case "comment_mention":
|
||||
return `${who} mentioned you in a comment`;
|
||||
default:
|
||||
return "You have a new notification";
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { HiHeart, HiOutlineHeart } from "react-icons/hi2";
|
||||
import { Pin } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -20,6 +21,26 @@ import { UserAvatar } from "@/components/ui/user-avatar";
|
||||
import { timeAgo } from "@/lib/time";
|
||||
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({
|
||||
comment,
|
||||
viewerId,
|
||||
@@ -151,7 +172,7 @@ export function CommentRow({
|
||||
@{comment.reply_to_handle}{" "}
|
||||
</span>
|
||||
) : null}
|
||||
{comment.body}
|
||||
{renderBody(comment.body)}
|
||||
</p>
|
||||
|
||||
{/* Inline action row */}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { Drawer, DrawerContent } from "@/components/ui/drawer";
|
||||
import { ConfirmModal } from "@/components/ui/confirm-modal";
|
||||
import { EmojiPicker } from "@/components/ui/emoji-picker";
|
||||
import { MentionTextarea } from "@/components/social/mention-textarea";
|
||||
import { CommentRow } from "./comment-row";
|
||||
import { ReportCommentModal } from "./comment-report-modal";
|
||||
|
||||
@@ -324,20 +325,15 @@ export function PostCommentsDrawer({
|
||||
) : null}
|
||||
<div className="flex items-end gap-2">
|
||||
<EmojiPicker onSelect={insertEmoji} />
|
||||
<textarea
|
||||
<MentionTextarea
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}}
|
||||
onChange={setDraft}
|
||||
onSubmit={submit}
|
||||
rows={1}
|
||||
maxLength={2000}
|
||||
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
|
||||
type="button"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user