From 647a803b96abb468b4d7d4dd6389be84ad865e82 Mon Sep 17 00:00:00 2001 From: gosangam Date: Tue, 9 Jun 2026 16:34:10 +0530 Subject: [PATCH] feat: add post like --- app/(app)/notifications/page.tsx | 35 ++- .../notifications/notifications-provider.tsx | 2 + components/profile/follow-list-drawer.tsx | 264 +---------------- components/profile/profile-header.tsx | 6 +- components/social/post-actions-footer.tsx | 65 ++++- components/social/post-likes-drawer.tsx | 29 ++ components/social/user-list-drawer.tsx | 274 ++++++++++++++++++ lib/social.ts | 32 ++ 8 files changed, 441 insertions(+), 266 deletions(-) create mode 100644 components/social/post-likes-drawer.tsx create mode 100644 components/social/user-list-drawer.tsx diff --git a/app/(app)/notifications/page.tsx b/app/(app)/notifications/page.tsx index adf4950b..948094e2 100644 --- a/app/(app)/notifications/page.tsx +++ b/app/(app)/notifications/page.tsx @@ -99,7 +99,7 @@ export default function NotificationsPage() { ) : ( )} @@ -107,9 +107,15 @@ export default function NotificationsPage() { ); } -function NotificationRow({ n }: { n: AppNotification }) { +function NotificationRow({ + n, + selfHandle, +}: { + n: AppNotification; + selfHandle: string; +}) { const display = n.actor_name || (n.actor_handle ? `@${n.actor_handle}` : "Someone"); - const message = messageFor(n.kind); + const message = messageFor(n); const unread = !n.read_at; const body = ( @@ -131,10 +137,18 @@ function NotificationRow({ n }: { n: AppNotification }) { ); - // Link to the actor's profile when we know their handle. - return n.actor_handle ? ( + // A like links to your own post; everything else links to the actor's + // profile (when we know their handle). + const href = + n.kind === "post_like" && n.post_id + ? `/u/${selfHandle}/posts#post-${n.post_id}` + : n.actor_handle + ? `/u/${n.actor_handle}` + : null; + + return href ? (
  • - + {body}
  • @@ -152,12 +166,17 @@ function cnRow(unread: boolean) { /** Human-readable tail for each notification kind. Add cases as new * notification kinds are introduced on the backend. */ -function messageFor(kind: string): string { - switch (kind) { +function messageFor(n: AppNotification): string { + switch (n.kind) { case "follow_request": return "requested to follow you."; case "follow_accepted": return "accepted your follow request."; + case "post_like": { + const others = (n.actor_count ?? 1) - 1; + if (others <= 0) return "liked your post."; + return `and ${others} ${others === 1 ? "other" : "others"} liked your post.`; + } default: return "sent you a notification."; } diff --git a/components/notifications/notifications-provider.tsx b/components/notifications/notifications-provider.tsx index 004a9425..475e45b1 100644 --- a/components/notifications/notifications-provider.tsx +++ b/components/notifications/notifications-provider.tsx @@ -59,6 +59,8 @@ function messageFor(e: IncomingEvent): string { return `${who} requested to follow you`; case "follow_accepted": return `${who} accepted your follow request`; + case "post_like": + return `${who} liked your post`; default: return "You have a new notification"; } diff --git a/components/profile/follow-list-drawer.tsx b/components/profile/follow-list-drawer.tsx index ca390067..849a853c 100644 --- a/components/profile/follow-list-drawer.tsx +++ b/components/profile/follow-list-drawer.tsx @@ -1,40 +1,16 @@ "use client"; /** - * FollowListDrawer — the followers / following list for a profile. - * - * Opens as a right-side drawer on desktop and a bottom sheet on mobile. The - * list is virtualized (fixed-height rows) and paginated: it fetches a page at - * a time from the BFF and loads the next page as you scroll near the bottom. - * The search field queries the backend across the profile's followers / - * following (debounced), so it works against the full set, not just the - * loaded page. - * - * State-update notes (React 19 / set-state-in-effect rule): the (re)load runs - * inside a debounce timer callback and pagination runs from the scroll - * handler — never synchronously inside an effect body. + * FollowListDrawer — the followers / following list for a profile. A thin + * wrapper over the generic virtualized UserListDrawer, wired to the + * relationships followers/following endpoints. */ -import { useEffect, useRef, useState } from "react"; -import Image from "next/image"; -import Link from "next/link"; -import { Loader2, Search } from "lucide-react"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { - Drawer, - DrawerContent, - DrawerHeader, - DrawerTitle, -} from "@/components/ui/drawer"; -import { useMediaQuery } from "@/components/selection-toolbar/shared"; -import { relationships, type UserSummary } from "@/lib/social"; -import { cn } from "@/lib/utils"; +import { relationships } from "@/lib/social"; +import { UserListDrawer } from "@/components/social/user-list-drawer"; export type FollowMode = "followers" | "following"; -const PAGE = 30; -const ROW = 64; // px — fixed row height for the virtualizer - export function FollowListDrawer({ open, mode, @@ -46,227 +22,17 @@ export function FollowListDrawer({ handle: string; onClose: () => void; }) { - const isMobile = useMediaQuery("(max-width: 767px)"); - const direction = isMobile ? "bottom" : "right"; - - const [q, setQ] = useState(""); - const [items, setItems] = useState([]); - const [total, setTotal] = useState(0); - const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); - - // reqRef tags the current query "session"; a newer reload bumps it so any - // in-flight reload/append for an older query is ignored on arrival. - const reqRef = useRef(0); - const busyRef = useRef(false); - const scrollRef = useRef(null); - - const fetchPage = mode === "followers" ? relationships.followers : relationships.following; - - // (Re)load page 0 whenever the drawer opens or the mode/handle/query - // changes. Debounced so typing in search doesn't spam the BFF. - useEffect(() => { - if (!open) return; - const t = window.setTimeout(() => { - const token = ++reqRef.current; - busyRef.current = true; - setItems([]); - setStatus("loading"); - fetchPage(handle, { q, limit: PAGE, offset: 0 }) - .then((res) => { - if (reqRef.current !== token) return; - setItems(res.users); - setTotal(res.total); - setStatus("idle"); - }) - .catch(() => { - if (reqRef.current === token) setStatus("error"); - }) - .finally(() => { - if (reqRef.current === token) busyRef.current = false; - }); - }, 250); - return () => window.clearTimeout(t); - }, [open, mode, handle, q, fetchPage]); - - function loadMore() { - if (busyRef.current || items.length >= total) return; - const token = reqRef.current; // same session — don't bump - const offset = items.length; - busyRef.current = true; - setStatus("loading"); - fetchPage(handle, { q, limit: PAGE, offset }) - .then((res) => { - if (reqRef.current !== token) return; - setItems((prev) => [...prev, ...res.users]); - setTotal(res.total); - setStatus("idle"); - }) - .catch(() => { - if (reqRef.current === token) setStatus("error"); - }) - .finally(() => { - if (reqRef.current === token) busyRef.current = false; - }); - } - - // TanStack Virtual returns non-memoizable functions; the React Compiler - // simply skips memoizing this component, which is fine here. - // eslint-disable-next-line react-hooks/incompatible-library - const virtualizer = useVirtualizer({ - count: items.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => ROW, - overscan: 8, - }); - - const empty = status !== "loading" && items.length === 0; - return ( - { - if (!o) onClose(); - }} - > - - - {mode} -
    - - setQ(e.target.value)} - placeholder={`Search ${mode}`} - autoComplete="off" - className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" - /> -
    -
    - -
    { - const el = e.currentTarget; - if (el.scrollHeight - el.scrollTop - el.clientHeight < 240) { - loadMore(); - } - }} - className="flex-1 min-h-0 overflow-y-auto overscroll-contain" - > - {status === "error" ? ( - Couldn't load. Pull down to retry. - ) : empty ? ( - - {q.trim() - ? "No matches." - : mode === "followers" - ? "No followers yet." - : "Not following anyone yet."} - - ) : ( -
    - {virtualizer.getVirtualItems().map((vi) => { - const u = items[vi.index]; - if (!u) return null; - return ( -
    - -
    - ); - })} -
    - )} - - {status === "loading" ? ( -
    - -
    - ) : null} -
    -
    -
    - ); -} - -function Row({ - user, - onNavigate, -}: { - user: UserSummary; - onNavigate: () => void; -}) { - return ( - - -
    -

    - {user.name} -

    -

    @{user.handle}

    -
    - - ); -} - -function RowAvatar({ src, name }: { src: string | null; name: string }) { - if (src) { - return ( - {name} - ); - } - const initials = - name - .split(/\s+/) - .map((p) => p[0]) - .slice(0, 2) - .join("") - .toUpperCase() || "?"; - return ( -
    - {initials} -
    - ); -} - -function Message({ children }: { children: React.ReactNode }) { - return ( -

    - {children} -

    + onClose={onClose} + title={mode === "followers" ? "Followers" : "Following"} + depsKey={`${mode}:${handle}`} + fetchPage={(params) => + mode === "followers" + ? relationships.followers(handle, params) + : relationships.following(handle, params) + } + /> ); } diff --git a/components/profile/profile-header.tsx b/components/profile/profile-header.tsx index 9d536d75..32566970 100644 --- a/components/profile/profile-header.tsx +++ b/components/profile/profile-header.tsx @@ -87,7 +87,9 @@ export function ProfileHeader({ // without a refetch. Re-seeds per profile via the call-site key. const [followers, setFollowers] = useState(followerCount ?? 0); const [following, setFollowing] = useState(followingCount ?? 0); - const [rel, setRel] = useState(relationship ?? null); + const [rel, setRel] = useState( + relationship ?? null, + ); // Which follow list (if any) is open in the drawer. const [followList, setFollowList] = useState(null); @@ -110,7 +112,7 @@ export function ProfileHeader({ />
    -

    +

    {name}

    @{handle}

    diff --git a/components/social/post-actions-footer.tsx b/components/social/post-actions-footer.tsx index 62ade0ce..5716b751 100644 --- a/components/social/post-actions-footer.tsx +++ b/components/social/post-actions-footer.tsx @@ -21,9 +21,10 @@ * see the in-place auth modal before the underlying action runs. */ -import { useState } from "react"; +import { useRef, useState } from "react"; import { Loader2 } from "lucide-react"; import { + HiHeart, HiOutlineArrowPath, HiOutlineChatBubbleOvalLeft, HiOutlineFolderPlus, @@ -37,6 +38,7 @@ 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 { useSharePost } from "./share-post-provider"; // All icons come from the Heroicons v2 outline family (react-icons @@ -60,6 +62,12 @@ export function PostActionsFooter({ post }: { post: Post }) { const [reshared, setReshared] = useState( !!post.viewer_has_reshared, ); + // Like state — seeded from the hydrated post, toggled optimistically and + // reconciled to the server's effective count on success. + const [liked, setLiked] = useState(!!post.viewer_has_liked); + const [likeCount, setLikeCount] = useState(post.likes_count ?? 0); + const [likesOpen, setLikesOpen] = useState(false); + const likeBusy = useRef(false); const isOwner = !!user && user.id === post.user_id; // Owners and resharers both see Send — the post is "on their account" @@ -71,6 +79,32 @@ export function PostActionsFooter({ post }: { post: Post }) { await fn(); } + // Like toggle — optimistic flip + count nudge, reconciled to the server's + // effective total (DB count + pending Redis delta) on success, rolled back + // on error. A ref guard ignores taps while a request is in flight. + async function onLikeToggle() { + if (likeBusy.current) return; + likeBusy.current = true; + const next = !liked; + setLiked(next); + setLikeCount((c) => Math.max(0, c + (next ? 1 : -1))); + try { + const res = next + ? await postsApi.like(post.id) + : await postsApi.unlike(post.id); + setLiked(res.liked); + setLikeCount(res.likes_count); + } catch (err) { + setLiked(!next); + setLikeCount((c) => Math.max(0, c + (next ? -1 : 1))); + const msg = + err instanceof SocialError ? err.message : "Could not update like"; + toast.error(msg, { duration: 3000 }); + } finally { + likeBusy.current = false; + } + } + async function onReshare() { // Optimistic flip first so the UI feels instant; revert on error. setReshared(true); @@ -105,14 +139,26 @@ export function PostActionsFooter({ post }: { post: Post }) {
    - withAuth(() => { - toast("Likes are coming soon", { duration: 2000 }); - }) + label={liked ? "Unlike" : "Like"} + onClick={() => withAuth(onLikeToggle)} + icon={ + liked ? ( + + ) : ( + + ) } - icon={} /> + {likeCount > 0 ? ( + + ) : null} @@ -169,6 +215,11 @@ export function PostActionsFooter({ post }: { post: Post }) { isMobile={isMobile} existingPostId={post.id} /> + setLikesOpen(false)} + /> {/* 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-likes-drawer.tsx b/components/social/post-likes-drawer.tsx new file mode 100644 index 00000000..396c2460 --- /dev/null +++ b/components/social/post-likes-drawer.tsx @@ -0,0 +1,29 @@ +"use client"; + +/** + * PostLikesDrawer — the "liked by" list for a post. A thin wrapper over the + * generic virtualized UserListDrawer, wired to the post-likes endpoint. + */ + +import { posts } from "@/lib/social"; +import { UserListDrawer } from "@/components/social/user-list-drawer"; + +export function PostLikesDrawer({ + open, + postId, + onClose, +}: { + open: boolean; + postId: string; + onClose: () => void; +}) { + return ( + posts.likers(postId, params)} + /> + ); +} diff --git a/components/social/user-list-drawer.tsx b/components/social/user-list-drawer.tsx new file mode 100644 index 00000000..7a81df18 --- /dev/null +++ b/components/social/user-list-drawer.tsx @@ -0,0 +1,274 @@ +"use client"; + +/** + * UserListDrawer — a generic, virtualized, paginated, searchable list of + * users in a drawer (right on desktop, bottom sheet on mobile). Powers both + * the followers/following lists and the post "liked by" list. + * + * The caller supplies `fetchPage(params)` returning `{ users, total }` and a + * `depsKey` that changes when the data source changes (so the list reloads). + * Search is debounced and queries the backend across the full set. The list + * loads the next page as you scroll near the bottom. + * + * State-update notes (set-state-in-effect rule): the (re)load runs inside a + * debounce timer callback and pagination runs from the scroll handler — never + * synchronously inside an effect body. + */ + +import { useEffect, useRef, useState } from "react"; +import Image from "next/image"; +import Link from "next/link"; +import { Loader2, Search } from "lucide-react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer"; +import { useMediaQuery } from "@/components/selection-toolbar/shared"; +import type { FollowList, FollowListParams, UserSummary } from "@/lib/social"; +import { cn } from "@/lib/utils"; + +const PAGE = 30; +const ROW = 64; // px — fixed row height for the virtualizer + +export type FetchUserPage = (params: FollowListParams) => Promise; + +export function UserListDrawer({ + open, + onClose, + title, + depsKey, + fetchPage, +}: { + open: boolean; + onClose: () => void; + title: string; + /** Changes when the data source changes (e.g. mode/handle or post id), to + * trigger a fresh reload. */ + depsKey: string; + fetchPage: FetchUserPage; +}) { + const isMobile = useMediaQuery("(max-width: 767px)"); + const direction = isMobile ? "bottom" : "right"; + + const [q, setQ] = useState(""); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [status, setStatus] = useState<"idle" | "loading" | "error">("idle"); + + // reqRef tags the current query "session"; a newer reload bumps it so any + // in-flight reload/append for an older query is ignored on arrival. + const reqRef = useRef(0); + const busyRef = useRef(false); + const scrollRef = useRef(null); + // Latest fetchPage, read via ref so its (unstable) identity isn't an effect + // dep — the reload keys on `depsKey` instead. + const fetchRef = useRef(fetchPage); + fetchRef.current = fetchPage; + + // (Re)load page 0 when the drawer opens or the source/query changes. + // Debounced so typing in search doesn't spam the backend. + useEffect(() => { + if (!open) return; + const t = window.setTimeout(() => { + const token = ++reqRef.current; + busyRef.current = true; + setItems([]); + setStatus("loading"); + fetchRef + .current({ q, limit: PAGE, offset: 0 }) + .then((res) => { + if (reqRef.current !== token) return; + setItems(res.users); + setTotal(res.total); + setStatus("idle"); + }) + .catch(() => { + if (reqRef.current === token) setStatus("error"); + }) + .finally(() => { + if (reqRef.current === token) busyRef.current = false; + }); + }, 250); + return () => window.clearTimeout(t); + }, [open, depsKey, q]); + + function loadMore() { + if (busyRef.current || items.length >= total) return; + const token = reqRef.current; // same session — don't bump + const offset = items.length; + busyRef.current = true; + setStatus("loading"); + fetchRef + .current({ q, limit: PAGE, offset }) + .then((res) => { + if (reqRef.current !== token) return; + setItems((prev) => [...prev, ...res.users]); + setTotal(res.total); + setStatus("idle"); + }) + .catch(() => { + if (reqRef.current === token) setStatus("error"); + }) + .finally(() => { + if (reqRef.current === token) busyRef.current = false; + }); + } + + // TanStack Virtual returns non-memoizable functions; the React Compiler + // simply skips memoizing this component, which is fine here. + // eslint-disable-next-line react-hooks/incompatible-library + const virtualizer = useVirtualizer({ + count: items.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW, + overscan: 8, + }); + + const empty = status !== "loading" && items.length === 0; + + return ( + { + if (!o) onClose(); + }} + > + + + {title} +
    + + setQ(e.target.value)} + placeholder="Search" + autoComplete="off" + className="w-full rounded-lg border border-border bg-background py-2 pl-9 pr-3 text-sm text-foreground outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" + /> +
    +
    + +
    { + const el = e.currentTarget; + if (el.scrollHeight - el.scrollTop - el.clientHeight < 240) { + loadMore(); + } + }} + className="flex-1 min-h-0 overflow-y-auto overscroll-contain" + > + {status === "error" ? ( + Couldn't load. Please try again. + ) : empty ? ( + {q.trim() ? "No matches." : "Nothing here yet."} + ) : ( +
    + {virtualizer.getVirtualItems().map((vi) => { + const u = items[vi.index]; + if (!u) return null; + return ( +
    + +
    + ); + })} +
    + )} + + {status === "loading" ? ( +
    + +
    + ) : null} +
    +
    +
    + ); +} + +function Row({ + user, + onNavigate, +}: { + user: UserSummary; + onNavigate: () => void; +}) { + return ( + + +
    +

    + {user.name} +

    +

    @{user.handle}

    +
    + + ); +} + +function RowAvatar({ src, name }: { src: string | null; name: string }) { + if (src) { + return ( + {name} + ); + } + const initials = + name + .split(/\s+/) + .map((p) => p[0]) + .slice(0, 2) + .join("") + .toUpperCase() || "?"; + return ( +
    + {initials} +
    + ); +} + +function Message({ children }: { children: React.ReactNode }) { + return ( +

    + {children} +

    + ); +} diff --git a/lib/social.ts b/lib/social.ts index 1f51b1e7..7f18d847 100644 --- a/lib/social.ts +++ b/lib/social.ts @@ -77,6 +77,18 @@ export type Post = { original_author_name?: string | null; original_author_handle?: string | null; original_author_avatar_url?: string | null; + /** Effective like total = denormalized count + pending (un-flushed) + * delta. Always present on hydrated reads (defaults to 0). */ + likes_count?: number; + /** Whether the calling viewer has liked this post. Always false / absent + * for anonymous viewers. */ + viewer_has_liked?: boolean; +}; + +/** Result of a like / unlike toggle: the new viewer state + effective total. */ +export type LikeResult = { + liked: boolean; + likes_count: number; }; export type Bookmark = { @@ -198,6 +210,22 @@ export const posts = { unreshare(id: string) { return call(`/posts/${id}/reshare`, { method: "DELETE" }); }, + /** Like a post (idempotent). Returns the new viewer state + effective + * total (DB count + pending Redis delta). Auth-required. */ + like(id: string) { + return call(`/posts/${id}/like`, { method: "POST" }); + }, + /** Unlike a post (idempotent). Same shape as `like`. */ + unlike(id: string) { + return call(`/posts/${id}/like`, { method: "DELETE" }); + }, + /** One page of users who liked the post (newest first), optional + * name/handle search. `total` is the full matching count. Public. */ + likers(id: string, opts: FollowListParams = {}) { + return call( + `/posts/${id}/likes?${followListQuery(opts)}`, + ); + }, /** Report a post. category from PostReportCategory; reason optional. * Auth-required (401 for anonymous callers). Returns nothing (204). */ report(id: string, category: PostReportCategory, reason: string) { @@ -417,6 +445,10 @@ export type AppNotification = { actor_name?: string | null; actor_handle?: string | null; actor_avatar_url?: string | null; + /** For aggregated `post_like` rows: the liked post + how many likers the + * row represents (renders "X and N-1 others liked your post"). */ + post_id?: string | null; + actor_count?: number; }; export const notifications = {