feat: mention

This commit is contained in:
gosangam
2026-06-10 16:29:44 +05:30
parent 482a845f37
commit 909ae67320
10 changed files with 628 additions and 66 deletions
@@ -61,6 +61,8 @@ function messageFor(e: IncomingEvent): string {
return `${who} accepted your follow request`;
case "post_like":
return `${who} liked your post`;
case "post_mention":
return `${who} mentioned you in a post`;
default:
return "You have a new notification";
}
+47 -1
View File
@@ -24,7 +24,8 @@ import {
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { posts, SocialError } from "@/lib/social";
import { posts, SocialError, type UserSummary } from "@/lib/social";
import { MentionAutocomplete } from "@/components/social/mention-autocomplete";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
import { compressImage } from "@/lib/compress-image";
@@ -79,6 +80,8 @@ export function ComposePost({
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Users tagged on this post (separate from the locked caption).
const [mentions, setMentions] = useState<UserSummary[]>([]);
async function onFiles(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
@@ -149,6 +152,9 @@ export function ComposePost({
image_urls: images
.filter((img) => !img.uploading && img.url)
.map((img) => img.url),
mention_user_ids: mentions
.map((m) => m.id)
.filter((id): id is string => !!id),
});
onPosted?.();
onClose();
@@ -249,6 +255,46 @@ export function ComposePost({
) : null}
</div>
<div>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
Mention people
</p>
{mentions.length > 0 ? (
<div className="mt-2 flex flex-wrap gap-2">
{mentions.map((u) => (
<span
key={u.id ?? u.handle}
className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 text-sm text-foreground"
>
@{u.handle}
<button
type="button"
onClick={() =>
setMentions((prev) => prev.filter((m) => m.id !== u.id))
}
aria-label={`Remove @${u.handle}`}
className="text-muted-foreground hover:text-foreground"
>
<X className="size-3.5" />
</button>
</span>
))}
</div>
) : null}
<div className="mt-2">
<MentionAutocomplete
excludeIds={mentions
.map((m) => m.id)
.filter((id): id is string => !!id)}
onSelect={(u) =>
setMentions((prev) =>
prev.some((m) => m.id === u.id) ? prev : [...prev, u],
)
}
/>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<button
+206
View File
@@ -0,0 +1,206 @@
"use client";
/**
* MentionAutocomplete — a search input with a suggestions popover for tagging
* users. Suggestions come from the backend prioritised (following → followers
* → others), 20 per page, and the popover infinite-scrolls (≈5 rows visible).
* Already-selected users are filtered out. Calls onSelect with the chosen
* user; the caller manages the selected set.
*/
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import { Loader2, Search } from "lucide-react";
import { posts, type UserSummary } from "@/lib/social";
const PAGE = 20;
export function MentionAutocomplete({
excludeIds = [],
onSelect,
placeholder = "Search people to mention…",
autoFocus = false,
}: {
excludeIds?: string[];
onSelect: (user: UserSummary) => void;
placeholder?: string;
autoFocus?: boolean;
}) {
const [q, setQ] = useState("");
const [open, setOpen] = useState(false);
const [items, setItems] = useState<UserSummary[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const reqRef = useRef(0);
const busyRef = useRef(false);
const rootRef = useRef<HTMLDivElement>(null);
// (Re)load page 0 on open / query change (debounced — timer callback, so
// setState isn't synchronous-in-effect).
useEffect(() => {
if (!open) return;
const t = window.setTimeout(() => {
const token = ++reqRef.current;
busyRef.current = true;
setLoading(true);
posts
.mentionSuggest(q, { limit: PAGE, offset: 0 })
.then((res) => {
if (reqRef.current !== token) return;
setItems(res);
setHasMore(res.length === PAGE);
})
.catch(() => {
if (reqRef.current === token) {
setItems([]);
setHasMore(false);
}
})
.finally(() => {
if (reqRef.current === token) {
setLoading(false);
busyRef.current = false;
}
});
}, 200);
return () => window.clearTimeout(t);
}, [q, open]);
function loadMore() {
if (busyRef.current || !hasMore) return;
const token = reqRef.current;
busyRef.current = true;
setLoading(true);
posts
.mentionSuggest(q, { limit: PAGE, offset: items.length })
.then((res) => {
if (reqRef.current !== token) return;
setItems((prev) => [...prev, ...res]);
setHasMore(res.length === PAGE);
})
.catch(() => {})
.finally(() => {
if (reqRef.current === token) {
setLoading(false);
busyRef.current = false;
}
});
}
// Close on outside click / Escape.
useEffect(() => {
if (!open) return;
function onDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
setOpen(false);
}
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
document.addEventListener("pointerdown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointerdown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const excluded = new Set(excludeIds);
const visible = items.filter((u) => !u.id || !excluded.has(u.id));
return (
<div ref={rootRef} className="relative">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<input
type="text"
value={q}
autoFocus={autoFocus}
onFocus={() => setOpen(true)}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
}}
placeholder={placeholder}
autoComplete="off"
className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
{open ? (
<div
onScroll={(e) => {
const el = e.currentTarget;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 80) {
loadMore();
}
}}
className="absolute z-50 mt-1 max-h-60 w-full overflow-y-auto overscroll-contain rounded-lg border border-border bg-card shadow-lg"
>
{visible.length === 0 && !loading ? (
<p className="px-3 py-3 text-sm text-muted-foreground">
{q.trim() ? "No people found." : "No suggestions."}
</p>
) : (
visible.map((u) => (
<button
key={u.id ?? u.handle}
type="button"
onMouseDown={(e) => e.preventDefault()} // keep input focused
onClick={() => {
onSelect(u);
setQ("");
}}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-muted/60"
>
<RowAvatar src={u.avatar_url} name={u.name} />
<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>
) : null}
</div>
);
}
function RowAvatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={32}
height={32}
className="size-8 shrink-0 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-primary/15 text-xs font-semibold text-primary ring-1 ring-foreground/10">
{initials}
</span>
);
}
+209
View File
@@ -0,0 +1,209 @@
"use client";
/**
* MentionsDrawer — the users tagged on a post. Right-side drawer on desktop,
* bottom sheet on mobile. Read-only for everyone except the post's author,
* who gets an autocomplete to add people and an X to remove them.
*
* `onCountChange(delta)` lets the card update its mentions-icon badge as the
* author adds/removes, without a refetch.
*/
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { X } from "lucide-react";
import { toast } from "sonner";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { posts, type UserSummary } from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { MentionAutocomplete } from "@/components/social/mention-autocomplete";
import { cn } from "@/lib/utils";
export function MentionsDrawer({
open,
postId,
authorId,
onClose,
onCountChange,
}: {
open: boolean;
postId: string;
authorId: string;
onClose: () => void;
onCountChange?: (delta: number) => void;
}) {
const isMobile = useMediaQuery("(max-width: 767px)");
const direction = isMobile ? "bottom" : "right";
const { user } = useAuth();
const isAuthor = !!user && user.id === authorId;
const [list, setList] = useState<UserSummary[]>([]);
const [status, setStatus] = useState<"loading" | "idle" | "error">("loading");
const reqRef = useRef(0);
useEffect(() => {
if (!open) return;
// Deferred to a timer so the setState calls aren't synchronous in the
// effect body.
const t = window.setTimeout(() => {
const token = ++reqRef.current;
setStatus("loading");
posts
.mentions(postId)
.then((res) => {
if (reqRef.current === token) {
setList(res);
setStatus("idle");
}
})
.catch(() => {
if (reqRef.current === token) setStatus("error");
});
}, 0);
return () => window.clearTimeout(t);
}, [open, postId]);
async function add(u: UserSummary) {
if (!u.id || list.some((m) => m.id === u.id)) return;
setList((prev) => [...prev, u]); // optimistic
onCountChange?.(1);
try {
await posts.addMention(postId, u.id);
} catch {
setList((prev) => prev.filter((m) => m.id !== u.id));
onCountChange?.(-1);
toast.error("Couldn't add that person");
}
}
async function remove(u: UserSummary) {
if (!u.id) return;
setList((prev) => prev.filter((m) => m.id !== u.id)); // optimistic
onCountChange?.(-1);
try {
await posts.removeMention(postId, u.id);
} catch {
setList((prev) => [...prev, u]);
onCountChange?.(1);
toast.error("Couldn't remove that person");
}
}
return (
<Drawer
open={open}
direction={direction}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DrawerContent
direction={direction}
className={cn(
"bg-card flex flex-col",
isMobile ? "h-[80vh]" : "h-full w-[88vw] max-w-sm",
)}
>
<DrawerHeader className="border-b border-border text-left">
<DrawerTitle className="text-base">Mentions</DrawerTitle>
{isAuthor ? (
<div className="mt-2">
<MentionAutocomplete
excludeIds={list.map((m) => m.id).filter((id): id is string => !!id)}
onSelect={add}
placeholder="Add someone…"
/>
</div>
) : null}
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto">
{status === "loading" ? (
<Message>Loading…</Message>
) : status === "error" ? (
<Message>Couldn&apos;t load mentions.</Message>
) : list.length === 0 ? (
<Message>
{isAuthor ? "No one tagged yet — add someone above." : "No one is tagged."}
</Message>
) : (
list.map((u) => (
<div
key={u.id ?? u.handle}
className="flex h-16 items-center gap-3 px-4 hover:bg-muted/40"
>
<Link
href={`/u/${u.handle}`}
onClick={onClose}
className="flex min-w-0 flex-1 items-center gap-3"
>
<RowAvatar src={u.avatar_url} name={u.name} />
<span className="min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">
{u.name}
</span>
<span className="block truncate text-sm text-muted-foreground">
@{u.handle}
</span>
</span>
</Link>
{isAuthor ? (
<button
type="button"
onClick={() => remove(u)}
aria-label={`Remove @${u.handle}`}
className="shrink-0 rounded-full p-1.5 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-4" />
</button>
) : null}
</div>
))
)}
</div>
</DrawerContent>
</Drawer>
);
}
function RowAvatar({ src, name }: { src: string | null; name: string }) {
if (src) {
return (
<Image
src={src}
alt={name}
width={40}
height={40}
className="size-10 shrink-0 rounded-full object-cover ring-1 ring-foreground/10"
unoptimized
/>
);
}
const initials =
name
.split(/\s+/)
.map((p) => p[0])
.slice(0, 2)
.join("")
.toUpperCase() || "?";
return (
<span className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary/15 text-sm font-semibold text-primary ring-1 ring-foreground/10">
{initials}
</span>
);
}
function Message({ children }: { children: React.ReactNode }) {
return (
<p className="px-4 py-10 text-center text-sm text-muted-foreground">
{children}
</p>
);
}
+43 -16
View File
@@ -29,15 +29,17 @@
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { ImageIcon, Repeat2 } from "lucide-react";
import { AtSign, ImageIcon, Repeat2 } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Post, PostImage } from "@/lib/social";
import { displaySourceLabel } from "@/lib/source-reference";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { useAuth } from "@/components/auth/auth-provider";
import { postSourceHref, quoteCardBgStyle } from "./post-card-shared";
import { PostActionsFooter } from "./post-actions-footer";
import { PostCardMenu } from "./post-card-menu";
import { UpdateBackgroundModal } from "./update-background-modal";
import { MentionsDrawer } from "./mentions-drawer";
export function PostScrollCard({
post,
@@ -52,23 +54,40 @@ export function PostScrollCard({
const hasImages = images.length > 0;
const sourceHref = postSourceHref(post);
const isMobile = useMediaQuery("(max-width: 767px)");
const { user } = useAuth();
const isAuthor = !!user && user.id === post.user_id;
const [bgModal, setBgModal] = useState(false);
const [expanded, setExpanded] = useState(false);
const [mentionsOpen, setMentionsOpen] = useState(false);
// Live mention count — seeded from the hydrated post, adjusted when the
// author edits tags in the drawer, so the icon appears/disappears in place.
const [mentionCount, setMentionCount] = useState(post.mentions_count ?? 0);
// Update Background stays as a media-overlay button — it's context-
// specific to the visible card surface and reads more naturally
// positioned over the image itself. Delete has moved into the
// header's three-dot menu (PostCardMenu) where Block / Report also
// live, so the overlay is now Update-Background-only.
const ownerCluster = onUpdated ? (
<div className="absolute top-3 right-3 z-10">
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
</div>
) : null;
// The author always gets the mentions entry point (to add/remove); everyone
// else sees it only when the post actually tags someone.
const showMentions = mentionCount > 0 || isAuthor;
// Top-right media overlay: a Mentions icon and the owner-only
// Update-background button. Delete lives in the header menu.
const topRight =
showMentions || onUpdated ? (
<div className="absolute top-3 right-3 z-10 flex items-center gap-2">
{showMentions ? (
<OwnerButton
label="See who's mentioned"
onClick={() => setMentionsOpen(true)}
icon={<AtSign className="size-4" />}
/>
) : null}
{onUpdated ? (
<OwnerButton
label="Update background"
onClick={() => setBgModal(true)}
icon={<ImageIcon className="size-4" />}
/>
) : null}
</div>
) : null;
return (
<article
@@ -125,7 +144,7 @@ export function PostScrollCard({
) : (
<QuotePanel post={post} />
)}
{ownerCluster}
{topRight}
</div>
{/* Caption + citation — reference sits directly under the media
@@ -162,6 +181,14 @@ export function PostScrollCard({
onUpdated={onUpdated}
/>
) : null}
<MentionsDrawer
open={mentionsOpen}
postId={post.id}
authorId={post.user_id}
onClose={() => setMentionsOpen(false)}
onCountChange={(d) => setMentionCount((c) => Math.max(0, c + d))}
/>
</article>
);
}
-23
View File
@@ -488,17 +488,6 @@ export function TextSelectionToolbar() {
icon: <FolderPlus className="size-5" />,
onClick: onSaveToCollection,
},
{
key: "mention",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
onClick: () => {
setShowMore(false);
if (isMobile) restoreSelection();
else interactingRef.current = false;
},
},
];
const publicMoreItems: MoreItem[] = [
@@ -532,18 +521,6 @@ export function TextSelectionToolbar() {
await onBookmarkOrCollect();
},
},
{
key: "mention-public",
label: "Mention",
description: "Tag someone with this quote",
icon: <AtSign className="size-5" />,
locked: true,
onClick: async () => {
setShowMore(false);
if (!(await requireAuth())) return;
// TODO: Implement Mention
},
},
];
const moreItems = isAuthed ? authedMoreItems : publicMoreItems;