"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 { useState } from "react"; import { Loader2 } from "lucide-react"; import { HiOutlineArrowPath, HiOutlineChatBubbleOvalLeft, HiOutlineFolderPlus, HiOutlineHeart, HiOutlinePaperAirplane, } from "react-icons/hi2"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { posts as postsApi, SocialError, type Post } from "@/lib/social"; import { createShortLink } from "@/lib/share"; import { structuredReferenceForPost } from "@/lib/source-reference"; 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 { SharePostSheet } from "./share-post-sheet"; // 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 isMobile = useMediaQuery("(max-width: 767px)"); const [collectionsOpen, setCollectionsOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false); // Tracks the short-URL fetch on the direct-mobile-share path so the // Send icon can render a spinner while the user waits. const [sharePending, setSharePending] = 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, ); 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; // Capability-check the Web Share API once per render — supported on // mobile Safari/Chrome and some desktop Safari builds. We only want // the native-share short-circuit on phones. const nativeShareSupported = typeof navigator !== "undefined" && typeof navigator.share === "function"; async function withAuth(fn: () => void | Promise) { if (!(await requireAuth())) return; await fn(); } 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 }); } } // Direct-mobile share path: // 1. User taps Send (consumes iOS Safari's user activation). // 2. We fetch the short URL — short enough to fit comfortably // inside the same activation window. Loading spinner replaces // the icon while we wait. // 3. navigator.share fires within the same handler. iOS sees the // activation token, opens the system share sheet. // 4. If activation has expired by the time we call share (slow // network) or anything else throws non-AbortError, fall back // to opening the custom in-app sheet so the user isn't stuck. // // Desktop / no native share API → straight to the custom sheet, // which handles its own short-URL fetch + image pre-fetch. async function onSendClick() { if (!isMobile || !nativeShareSupported) { setShareOpen(true); return; } if (sharePending) return; const handle = post.author_handle ?? user?.handle ?? ""; if (!handle) { // Without a handle we can't build the post URL; fall back to // the custom sheet which has its own resolution path. setShareOpen(true); return; } setSharePending(true); try { const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`; const code = await createShortLink(longPath); const origin = window.location.origin; const url = code ? new URL(`/share/${code}`, origin).toString() : new URL(longPath, origin).toString(); const ref = structuredReferenceForPost( post.source_url, post.source_title, ); const lines = [`“${post.caption}”`, "", "Source:", ref.title]; if (ref.detail) lines.push(ref.detail); lines.push(url); const text = lines.join("\n"); const title = ref.detail ? `${ref.title} — ${ref.detail}` : ref.title; try { await navigator.share({ title, text }); } catch (err) { // User-cancel = AbortError = leave them alone. Any other // failure (most often iOS NotAllowedError when the awaited // fetch overran the user-activation window) → fall back to // the in-app sheet so the user can still complete the share. if (err instanceof DOMException && err.name === "AbortError") return; console.warn("[share] navigator.share failed, opening custom sheet", err); setShareOpen(true); } } finally { setSharePending(false); } } return ( <>
withAuth(() => { toast("Likes are coming soon", { duration: 2000 }); }) } icon={} /> withAuth(() => { toast("Comments are coming soon", { duration: 2000 }); }) } icon={} /> {/* 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 ? ( ) : ( ) } /> ) : ( withAuth(onReshare)} icon={} /> )}
withAuth(() => setCollectionsOpen(true))} icon={} />
setCollectionsOpen(false)} isMobile={isMobile} existingPostId={post.id} /> {showSend ? ( setShareOpen(false)} post={post} isMobile={isMobile} /> ) : null} ); } function ActionButton({ label, onClick, icon, disabled = false, }: { label: string; onClick: () => void; 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; }) { return ( ); }