feat: add inline mentions in comments
This commit is contained in:
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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