Files
spsays-training/components/social/post-actions-footer.tsx
T

266 lines
9.3 KiB
TypeScript

"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<boolean>(
!!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<void>) {
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 (
<>
<div className="flex items-center justify-between px-2">
<div className="flex items-center gap-1">
<ActionButton
label="Like"
onClick={() =>
withAuth(() => {
toast("Likes are coming soon", { duration: 2000 });
})
}
icon={<HiOutlineHeart className={ICON_CLASS} />}
/>
<ActionButton
label="Comment"
onClick={() =>
withAuth(() => {
toast("Comments are coming soon", { duration: 2000 });
})
}
icon={<HiOutlineChatBubbleOvalLeft className={ICON_CLASS} />}
/>
{/* 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 ? (
<ActionButton
label={sharePending ? "Preparing share link…" : "Send"}
onClick={onSendClick}
disabled={sharePending}
icon={
sharePending ? (
<Loader2
className={cn(ICON_CLASS, "animate-spin")}
aria-hidden
/>
) : (
<HiOutlinePaperAirplane className={ICON_CLASS} />
)
}
/>
) : (
<ActionButton
label="Reshare"
onClick={() => withAuth(onReshare)}
icon={<HiOutlineArrowPath className={ICON_CLASS} />}
/>
)}
</div>
<ActionButton
label="Save to collection"
onClick={() => withAuth(() => setCollectionsOpen(true))}
icon={<HiOutlineFolderPlus className={ICON_CLASS} />}
/>
</div>
<CollectionsDrawer
open={collectionsOpen}
onClose={() => setCollectionsOpen(false)}
isMobile={isMobile}
existingPostId={post.id}
/>
{showSend ? (
<SharePostSheet
open={shareOpen}
onClose={() => 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 (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
className={cn(
"size-10 inline-flex items-center justify-center rounded-full",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors",
"disabled:opacity-60 disabled:cursor-not-allowed disabled:hover:bg-transparent",
)}
>
{icon}
</button>
);
}