-
+
{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 (
+
+ );
+ }
+ 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 = {