From cc5633c37aea909cc5ce37398a9720d094d1d288 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sat, 13 Jun 2026 01:30:18 +0530 Subject: [PATCH] feat: add comments --- app/(app)/notifications/page.tsx | 14 +- .../notifications/notifications-provider.tsx | 4 + components/social/post-actions-footer.tsx | 45 +- components/social/post-comments-drawer.tsx | 749 ++++++++++++++++++ components/ui/drawer.tsx | 8 +- lib/social.ts | 92 +++ 6 files changed, 900 insertions(+), 12 deletions(-) create mode 100644 components/social/post-comments-drawer.tsx diff --git a/app/(app)/notifications/page.tsx b/app/(app)/notifications/page.tsx index 4ad966ea..f2ce18da 100644 --- a/app/(app)/notifications/page.tsx +++ b/app/(app)/notifications/page.tsx @@ -144,9 +144,13 @@ function NotificationRow({ ? `/u/${selfHandle}/posts#post-${n.post_id}` : n.kind === "post_mention" && n.post_id && n.actor_handle ? `/u/${n.actor_handle}/posts#post-${n.post_id}` - : n.actor_handle - ? `/u/${n.actor_handle}` - : null; + : (n.kind === "post_comment" || n.kind === "comment_reply") && + n.post_id && + n.post_author_handle + ? `/u/${n.post_author_handle}/posts#post-${n.post_id}` + : n.actor_handle + ? `/u/${n.actor_handle}` + : null; return href ? (
  • @@ -181,6 +185,10 @@ function messageFor(n: AppNotification): string { } case "post_mention": return "mentioned you in a post."; + case "post_comment": + return "commented on your post."; + case "comment_reply": + return "replied to your comment."; default: return "sent you a notification."; } diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index 5bf63829..a3ed8e08 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -63,6 +63,10 @@ function messageFor(e: IncomingEvent): string { return `${who} liked your post`; case "post_mention": return `${who} mentioned you in a post`; + case "post_comment": + return `${who} commented on your post`; + case "comment_reply": + return `${who} replied to your comment`; default: return "You have a new notification"; } diff --git a/components/social/post-actions-footer.tsx b/components/social/post-actions-footer.tsx index 5716b751..f68cdc2a 100644 --- a/components/social/post-actions-footer.tsx +++ b/components/social/post-actions-footer.tsx @@ -22,7 +22,7 @@ */ import { useRef, useState } from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, Pin } from "lucide-react"; import { HiHeart, HiOutlineArrowPath, @@ -33,12 +33,18 @@ import { } from "react-icons/hi2"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; -import { posts as postsApi, SocialError, type Post } from "@/lib/social"; +import { + posts as postsApi, + 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 { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer"; import { PostLikesDrawer } from "@/components/social/post-likes-drawer"; +import { PostCommentsDrawer } from "@/components/social/post-comments-drawer"; import { useSharePost } from "./share-post-provider"; // All icons come from the Heroicons v2 outline family (react-icons @@ -68,6 +74,13 @@ export function PostActionsFooter({ post }: { post: Post }) { const [likeCount, setLikeCount] = useState(post.likes_count ?? 0); const [likesOpen, setLikesOpen] = useState(false); const likeBusy = useRef(false); + // Comments drawer + the post's pinned comment (shown below the action row + // even when the drawer is closed). Seeded from the hydrated post; kept in + // sync as the author pins/unpins inside the drawer. + const [commentsOpen, setCommentsOpen] = useState(false); + const [pinned, setPinned] = useState( + post.pinned_comment ?? null, + ); const isOwner = !!user && user.id === post.user_id; // Owners and resharers both see Send — the post is "on their account" @@ -161,11 +174,7 @@ export function PostActionsFooter({ post }: { post: Post }) { ) : null} - withAuth(() => { - toast("Comments are coming soon", { duration: 2000 }); - }) - } + onClick={() => setCommentsOpen(true)} icon={} /> {/* Reshare for posts not yet on your account, Send for posts @@ -209,6 +218,22 @@ export function PostActionsFooter({ post }: { post: Post }) { /> + {/* Pinned comment — stays visible under the action row even with the + comments drawer closed. Tapping it opens the thread. */} + {pinned ? ( + + ) : null} + setCollectionsOpen(false)} @@ -220,6 +245,12 @@ export function PostActionsFooter({ post }: { post: Post }) { postId={post.id} onClose={() => setLikesOpen(false)} /> + setCommentsOpen(false)} + onPinnedChange={setPinned} + /> {/* SharePostSheet is mounted globally by SharePostProvider, so no per-card instance lives here. The provider's openShareSheet swaps the active post under the hood. */} diff --git a/components/social/post-comments-drawer.tsx b/components/social/post-comments-drawer.tsx new file mode 100644 index 00000000..33209e15 --- /dev/null +++ b/components/social/post-comments-drawer.tsx @@ -0,0 +1,749 @@ +"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. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import { HiHeart, HiOutlineHeart } from "react-icons/hi2"; +import { Loader2, Pin, PinOff, Send, Trash2, X } from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { + comments as commentsApi, + SocialError, + type Comment, + type Post, + type PostReportCategory, +} 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"; + +const PAGE = 20; + +export function PostCommentsDrawer({ + open, + post, + onClose, + onPinnedChange, +}: { + 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; +}) { + 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([]); + 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(null); + const [toReport, setToReport] = useState(null); + const inputRef = useRef(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(""); + 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 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(); + } + + 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); + 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 ( + <> + { + if (!o) onClose(); + }} + > + +
    +

    Comments

    + +
    + +
    + {loading ? ( +
    + +
    + ) : items.length === 0 ? ( +

    + No comments yet. Be the first to comment. +

    + ) : ( +
      + {items.map((c) => ( + + 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 ? ( +
    • + +
    • + ) : null} +
    + )} +
    + + {/* Composer */} +
    + {replyTo ? ( +
    + + Replying to @{replyTo.handle} + + +
    + ) : null} +
    +