Files
2026-06-10 20:02:23 +05:30

218 lines
6.7 KiB
TypeScript

"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}
// Let the browser handle the on-screen keyboard. vaul's default
// input-repositioning resizes/translates the sheet and gets stuck on
// iOS (stays enlarged after blur, drags dismiss it). The add-field sits
// at the top of the scrollable body, so it stays visible without it.
repositionInputs={false}
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>
</DrawerHeader>
<div className="flex-1 min-h-0 overflow-y-auto">
{/* Author add-field lives inside the scroll area so its inline
results scroll with the sheet instead of overflowing a fixed
header. */}
{isAuthor ? (
<div className="p-4 pb-2">
<MentionAutocomplete
excludeIds={list.map((m) => m.id).filter((id): id is string => !!id)}
onSelect={add}
placeholder="Add someone…"
/>
</div>
) : null}
{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>
);
}