218 lines
6.7 KiB
TypeScript
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'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>
|
|
);
|
|
}
|