"use client"; /** * PostActionsFooter — the icon-button row at the bottom of a post card. * * Left cluster (non-owner): Like · Comment · Reshare * Left cluster (owner): Like · Comment · Send * Right cluster: Save to Collection (folder-plus) * * Reshare and Send are *mutually exclusive* — once the post is on your * account there's nothing left to reshare onto it, so the Reshare slot * is replaced by Send (external share via SharePostSheet). Ownership is * decided by `user.id === post.user_id`, which the BFF already includes * on every post fetch — no extra round-trip needed. * * Like / Comment / Reshare are placeholders today (toast on click). * Save to Collection mounts the existing CollectionsDrawer in * `existingPostId` mode so the post attaches without a duplicate. * * Auth gating is handled per-action via `useAuthGate` — anonymous users * see the in-place auth modal before the underlying action runs. */ import { useRef, useState } from "react"; import { Loader2, Pin } from "lucide-react"; import { HiHeart, HiOutlineArrowPath, HiOutlineChatBubbleOvalLeft, HiOutlineFolderPlus, HiOutlineHeart, HiOutlinePaperAirplane, } from "react-icons/hi2"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { posts as postsApi, SocialError, type Comment, type Post, } from "@/lib/social"; import { useLongPress } from "@/lib/use-long-press"; import { ShareToChatDialog } from "@/components/chat/share-to-chat-dialog"; 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 "./post-likes-drawer"; import { PostCommentsDrawer } from "@/components/social/comment/post-comments-drawer"; import { useSharePost } from "../share-post-provider"; // All icons come from the Heroicons v2 outline family (react-icons // `hi2` namespace), so they share one design language — uniform stroke // weight, matching corner radius, matching optical sizing. const ICON_CLASS = "size-[1.375rem]"; export function PostActionsFooter({ post }: { post: Post }) { const { user } = useAuth(); const { requireAuth } = useAuthGate(); const { openShareSheet } = useSharePost(); const isMobile = useMediaQuery("(max-width: 767px)"); const [collectionsOpen, setCollectionsOpen] = useState(false); // Tracks the platform-aware share routing (short URL + image fetch // on mobile native-share; sheet mount on desktop) so the Send icon // can render a spinner while the user waits. const [sharePending, setSharePending] = useState(false); // Opens the chat picker for this post. The post travels as a reference, // not as text — the BFF renders the card and re-checks who may see it. const [sharingToChat, setSharingToChat] = useState(false); // Optimistic local mirror of post.viewer_has_reshared so the swap // happens the instant the user taps Reshare. Reconciles to the // server's value via rollback if the API call fails. 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); // 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 [commentCount, setCommentCount] = useState( post.comments_count ?? 0, ); const isOwner = !!user && user.id === post.user_id; // Owners and resharers both see Send — the post is "on their account" // either way, so the action they need is external share, not reshare. const showSend = isOwner || reshared; async function withAuth(fn: () => void | Promise) { if (!(await requireAuth())) return; 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); try { await postsApi.reshare(post.id); toast.success("Reshared to your account", { duration: 2000 }); } catch (err) { setReshared(false); const msg = err instanceof SocialError ? err.message : "Could not reshare"; toast.error(msg, { duration: 3000 }); } } // Send button: hand off to the provider, which decides between the // mobile native-share path (await short URL + image proxy in // parallel, then navigator.share) and the desktop/no-native-share // path (mount the custom SharePostSheet). Provider's openShareSheet // is always-resolves, so a single try/finally toggles the spinner. // Tap → send to a chat. Hold → the platform's own share sheet, for // everything outside the app. The link text is identical either way; only // the destination differs. function onSendClick() { // No URL to resolve: sharing in-app sends the post id, so this is // instant where the native sheet has to await the shortener. setSharingToChat(true); } async function onSendHold() { if (sharePending) return; setSharePending(true); try { await openShareSheet(post); } finally { setSharePending(false); } } const sendGestures = useLongPress({ onClick: onSendClick, onLongPress: onSendHold, disabled: sharePending, }); return ( <>
withAuth(onLikeToggle)} icon={ liked ? ( ) : ( ) } /> {likeCount > 0 ? ( ) : null} setCommentsOpen(true)} icon={} /> {commentCount > 0 ? ( ) : null} {/* Reshare for posts not yet on your account, Send for posts that are — authored OR previously reshared. Mutually exclusive: the row stays three icons regardless of viewer state. */} {showSend ? ( ) : ( // -rotate-45 CCW takes the default east-pointing // airplane and tilts it toward the upper-right, which // reads more as "send / off it goes" than the // horizontal default. ) } /> ) : ( withAuth(onReshare)} icon={} /> )}
withAuth(() => setCollectionsOpen(true))} icon={} />
{/* Pinned comment — stays visible under the action row even with the comments drawer closed. Tapping it opens the thread. */} {pinned ? ( ) : null} setCollectionsOpen(false)} isMobile={isMobile} existingPostId={post.id} /> setLikesOpen(false)} /> setCommentsOpen(false)} onPinnedChange={setPinned} onCountChange={(delta) => setCommentCount((c) => Math.max(0, c + delta)) } /> {/* SharePostSheet is mounted globally by SharePostProvider, so no per-card instance lives here. The provider's openShareSheet swaps the active post under the hood. */} {sharingToChat && ( setSharingToChat(false)} /> )} ); } function ActionButton({ label, icon, disabled = false, ...buttonProps }: { label: string; icon: React.ReactNode; /** Greys out the button and blocks clicks. Used by the Send button * while the short URL is being resolved on the mobile native-share * path so users can't fire navigator.share before the URL is * ready. */ disabled?: boolean; /** onClick, plus the pointer handlers the Send button needs to tell a * tap from a hold. */ } & React.ComponentProps<"button">) { return ( ); }