Files
spsays-training/components/social/comment/post-comments-drawer.tsx
T

373 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* PostCommentsDrawer — the comments sheet for a post.
*
* Right-side on desktop, bottom on mobile (vaul `direction`), with a FULLY
* TRANSPARENT overlay so the post stays visible behind it while reading the
* thread. Anyone signed in can comment; the composer gates anonymous users
* through the auth modal.
*
* Threading is one level deep: top-level comments each own a single flat
* thread of replies. Replying to a reply still lands in that one thread,
* addressed (visually) to whoever was answered. The post author can pin one
* comment (shown first here and in the card footer) and delete any comment;
* a comment's author can delete their own (a thread root takes its replies
* with it — behind a confirm). Likes show a count only.
*
* This file owns the drawer shell, the comment list + pagination, the
* composer (with emoji picker), and the optimistic mutation wiring. The row
* (CommentRow), its kebab menu (CommentMenu), and the report dialog
* (ReportCommentModal) live in sibling files.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2, Send, X } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import {
comments as commentsApi,
SocialError,
type Comment,
type Post,
} from "@/lib/social";
import { useAuth } from "@/components/auth/auth-provider";
import { useAuthGate } from "@/components/auth/auth-gate";
import { useMediaQuery } from "@/components/selection-toolbar/shared";
import { Drawer, DrawerContent } from "@/components/ui/drawer";
import { ConfirmModal } from "@/components/ui/confirm-modal";
import { EmojiPicker } from "@/components/ui/emoji-picker";
import { MentionTextarea } from "@/components/social/mention-textarea";
import { CommentRow } from "./comment-row";
import { ReportCommentModal } from "./comment-report-modal";
const PAGE = 20;
export function PostCommentsDrawer({
open,
post,
onClose,
onPinnedChange,
onCountChange,
}: {
open: boolean;
post: Post;
onClose: () => void;
/** Notifies the parent (the card footer) when the pinned comment changes,
* so the footer's pinned line stays in sync without a refetch. */
onPinnedChange?: (pinned: Comment | null) => void;
/** Notifies the parent of a net change to the post's comment count (+1 on a
* new comment/reply, −N when a comment/thread is deleted) so the footer
* count stays live. */
onCountChange?: (delta: number) => void;
}) {
const { user } = useAuth();
const { requireAuth } = useAuthGate();
const isMobile = useMediaQuery("(max-width: 767px)");
const direction = isMobile ? "bottom" : "right";
const isPostAuthor = !!user && user.id === post.user_id;
const [items, setItems] = useState<Comment[]>([]);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(false);
const [posting, setPosting] = useState(false);
const [draft, setDraft] = useState("");
// The comment being replied to (null = a new top-level comment).
const [replyTo, setReplyTo] = useState<{ id: string; handle: string } | null>(
null,
);
const [toDelete, setToDelete] = useState<Comment | null>(null);
const [toReport, setToReport] = useState<Comment | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
// Load the first page whenever the drawer opens for a post.
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setItems([]);
setReplyTo(null);
commentsApi
.list(post.id, { limit: PAGE, offset: 0 })
.then((rows) => {
if (cancelled) return;
setItems(rows ?? []);
setHasMore((rows?.length ?? 0) === PAGE);
})
.catch(() => {})
.finally(() => !cancelled && setLoading(false));
return () => {
cancelled = true;
};
}, [open, post.id]);
const loadMore = useCallback(async () => {
try {
const rows = await commentsApi.list(post.id, {
limit: PAGE,
offset: items.length,
});
setItems((prev) => [...prev, ...(rows ?? [])]);
setHasMore((rows?.length ?? 0) === PAGE);
} catch {
/* leave the list as-is on error */
}
}, [post.id, items.length]);
// Replace a comment in place (after a like toggle / reply-count bump).
const patch = useCallback((id: string, fn: (c: Comment) => Comment) => {
setItems((prev) => prev.map((c) => (c.id === id ? fn(c) : c)));
}, []);
async function submit() {
const body = draft.trim();
if (!body || posting) return;
if (!(await requireAuth())) return;
setPosting(true);
try {
const created = await commentsApi.create(post.id, body, replyTo?.id);
setDraft("");
onCountChange?.(1);
if (created.parent_id) {
// A reply — bump the root's count and append into its open thread.
bumpReply(created.parent_id, created);
setReplyTo(null);
} else {
// Top-level — drop it in just below any pinned comment.
setItems((prev) => {
const pinnedFirst = prev[0]?.pinned_at ? 1 : 0;
return [
...prev.slice(0, pinnedFirst),
created,
...prev.slice(pinnedFirst),
];
});
}
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not post comment",
{ duration: 3000 },
);
} finally {
setPosting(false);
}
}
// Append a freshly-created reply into its root thread's open reply list (if
// loaded) and bump the visible reply count.
const repliesRef = useRef<Record<string, (c: Comment) => void>>({});
function bumpReply(rootId: string, reply: Comment) {
patch(rootId, (c) => ({ ...c, reply_count: c.reply_count + 1 }));
repliesRef.current[rootId]?.(reply);
}
function startReply(target: Comment) {
// Reply chip addresses whoever was tapped; the thread is the root.
setReplyTo({ id: target.id, handle: target.author_handle });
inputRef.current?.focus();
}
// Insert an emoji at the caret (or append if the textarea isn't focused),
// then restore the caret just after it so typing can continue.
function insertEmoji(emoji: string) {
const el = inputRef.current;
const start = el?.selectionStart ?? draft.length;
const end = el?.selectionEnd ?? draft.length;
const next = draft.slice(0, start) + emoji + draft.slice(end);
setDraft(next);
requestAnimationFrame(() => {
const node = inputRef.current;
if (!node) return;
node.focus();
const caret = start + emoji.length;
node.setSelectionRange(caret, caret);
});
}
async function pinToggle(c: Comment) {
const pinning = !c.pinned_at;
try {
if (pinning) await commentsApi.pin(c.id);
else await commentsApi.unpin(c.id);
// Reload the first page so pinned-first ordering is authoritative.
const rows = await commentsApi.list(post.id, { limit: PAGE, offset: 0 });
setItems(rows ?? []);
setHasMore((rows?.length ?? 0) === PAGE);
onPinnedChange?.(
pinning ? { ...c, pinned_at: new Date().toISOString() } : null,
);
toast(pinning ? "Comment pinned" : "Comment unpinned", { duration: 1800 });
} catch (err) {
toast.error(
err instanceof SocialError ? err.message : "Could not update pin",
{ duration: 3000 },
);
}
}
async function confirmDelete() {
if (!toDelete) return;
const c = toDelete;
await commentsApi.delete(c.id);
// A thread root takes its replies with it; a reply removes just itself.
onCountChange?.(-(c.parent_id ? 1 : 1 + c.reply_count));
if (c.parent_id) {
// A reply — drop the root's count; the thread list prunes itself.
patch(c.parent_id, (p) => ({
...p,
reply_count: Math.max(0, p.reply_count - 1),
}));
repliesRef.current[c.parent_id]?.({ ...c, body: "__deleted__" });
} else {
setItems((prev) => prev.filter((x) => x.id !== c.id));
if (c.pinned_at) onPinnedChange?.(null);
}
}
return (
<>
<Drawer
open={open}
direction={direction}
repositionInputs={false}
onOpenChange={(o) => {
if (!o) onClose();
}}
>
<DrawerContent
direction={direction}
// Transparent scrim: keep the post visible behind the sheet.
overlayClassName="bg-transparent"
className={cn(
"bg-card flex flex-col shadow-2xl",
isMobile ? "h-[80vh]" : "h-full w-[420px] max-w-[92vw]",
)}
>
<div className="flex items-center justify-between border-b border-border px-4 py-3 shrink-0">
<h2 className="text-base font-semibold text-foreground">Comments</h2>
<button
type="button"
onClick={onClose}
aria-label="Close comments"
className="size-8 inline-flex items-center justify-center rounded-lg text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<X className="size-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto overscroll-contain px-4 py-3">
{loading ? (
<div className="flex justify-center py-10">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : items.length === 0 ? (
<p className="py-10 text-center text-sm text-muted-foreground">
No comments yet. Be the first to comment.
</p>
) : (
<ul className="space-y-4">
{items.map((c) => (
<CommentRow
key={c.id}
comment={c}
viewerId={user?.id}
isPostAuthor={isPostAuthor}
onReply={startReply}
onLikeChange={(likes, liked) =>
patch(c.id, (x) => ({
...x,
likes_count: likes,
viewer_has_liked: liked,
}))
}
onPinToggle={() => pinToggle(c)}
onRequestDelete={setToDelete}
onRequestReport={setToReport}
requireAuth={requireAuth}
registerThread={(fn) => {
if (fn) repliesRef.current[c.id] = fn;
else delete repliesRef.current[c.id];
}}
/>
))}
{hasMore ? (
<li>
<button
type="button"
onClick={loadMore}
className="text-sm font-medium text-primary hover:underline"
>
Load more comments
</button>
</li>
) : null}
</ul>
)}
</div>
{/* Composer */}
<div className="border-t border-border p-3 shrink-0">
{replyTo ? (
<div className="mb-2 flex items-center justify-between rounded-md bg-muted/60 px-3 py-1.5 text-xs text-muted-foreground">
<span>
Replying to{" "}
<span className="font-medium">@{replyTo.handle}</span>
</span>
<button
type="button"
onClick={() => setReplyTo(null)}
aria-label="Cancel reply"
className="rounded p-0.5 hover:text-foreground"
>
<X className="size-3.5" />
</button>
</div>
) : null}
<div className="flex items-end gap-2">
<EmojiPicker onSelect={insertEmoji} />
<MentionTextarea
ref={inputRef}
value={draft}
onChange={setDraft}
onSubmit={submit}
rows={1}
maxLength={2000}
placeholder={replyTo ? "Write a reply…" : "Add a comment…"}
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
type="button"
onClick={submit}
disabled={posting || draft.trim() === ""}
aria-label="Post comment"
className="size-10 shrink-0 inline-flex items-center justify-center rounded-lg bg-primary text-primary-foreground dark:text-white hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{posting ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</button>
</div>
</div>
</DrawerContent>
</Drawer>
<ConfirmModal
open={toDelete !== null}
onClose={() => setToDelete(null)}
title="Delete this comment?"
message={
toDelete && !toDelete.parent_id && toDelete.reply_count > 0
? "Its replies will be deleted too. This can't be undone."
: "This can't be undone."
}
confirmLabel="Delete"
onConfirm={confirmDelete}
/>
<ReportCommentModal comment={toReport} onClose={() => setToReport(null)} />
</>
);
}