feat: mention
This commit is contained in:
@@ -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'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user