"use client"; /** * SharePostSheet — share a quote you've posted to external apps. * * Modal on desktop (Drawer direction="right"), drawer on mobile * (direction="bottom"). Same UI primitive CollectionsDrawer uses. * * What we share: * * "" * * — * * * * The link target is `/u//posts#post-` — the Instagram-style * scroll view anchored to this specific post — rather than the raw * source URL. Recipients land on the card (image, your caption, your * byline) and can click through to the original passage from there. * This matches how every major social product handles "share a post": * the share URL is the post, not the underlying resource. * * URL is run through createShortLink (same /share/ short-code * service the selection-toolbar uses) so the payload stays compact and * the click-through is trackable. The shortener's ValidatePath tolerates * fragments, and the destination pages' existing hash-anchor effect * scrolls to the post on mount — no other code needs to know. * * Sharing channels: * * • WhatsApp / Telegram / X / Facebook — official web share intents. * We open them in a new window; the user finishes in that app. * • Instagram — has no web share intent. We copy the payload + toast * "paste in Instagram" so the user can switch and paste. * • Email — mailto: with prefilled subject + body. * • Copy — drops the full payload on the clipboard. * • More — only when navigator.share is supported AND we're on mobile. * Tries to attach the first image as a File via navigator.canShare; * falls back to text-only if R2 CORS blocks the blob fetch. * * Images travel only via the native-share path. None of the open web * share intents accept an image URL directly — they pull OpenGraph * metadata from the linked page instead, which we don't currently * generate per-post. That's the "bonus" the brief allows for. */ import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { FaWhatsapp, FaTelegram, FaXTwitter, FaFacebook, FaInstagram, FaEnvelope, } from "react-icons/fa6"; import { Copy, Loader2, Share2 } from "lucide-react"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; import { cn } from "@/lib/utils"; import type { Post } from "@/lib/social"; import { createShortLink } from "@/lib/share"; import { displaySourceLabel } from "@/lib/source-reference"; import { useAuth } from "@/components/auth/auth-provider"; export function SharePostSheet({ open, onClose, post, isMobile, }: { open: boolean; onClose: () => void; post: Post; isMobile: boolean; }) { const reference = displaySourceLabel(post.source_url, post.source_title); const firstImage = post.images?.[0]?.url ?? null; const { user } = useAuth(); // The Send button is only rendered for owners, so user is guaranteed // non-null here; we still defensively fall back via `?? ""` rather than // crashing if the auth context blips during a re-render. const authorHandle = post.author_handle ?? user?.handle ?? ""; const [shortUrl, setShortUrl] = useState(null); const [shortening, setShortening] = useState(false); // navigator.share only exists on a subset of browsers (mainly mobile // Safari/Chrome and some desktop Safari builds). Capability-check once // on mount; combined with `isMobile` to gate the "More" tile so the // affordance only appears where it can do something useful. const [nativeShareSupported, setNativeShareSupported] = useState(false); useEffect(() => { setNativeShareSupported( typeof navigator !== "undefined" && typeof navigator.share === "function", ); }, []); // Shorten the post's anchor URL on the author's feed when the sheet // opens. The path takes the form `/u//posts#post-`; the // BFF's ValidatePath tolerates fragments, and the destination page // already has a hash-anchor effect that scrolls to the post once the // feed loads, so the recipient lands on the right card automatically. useEffect(() => { if (!open) return; if (!authorHandle) return; let cancelled = false; setShortening(true); (async () => { try { const origin = window.location.origin; const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`; const code = await createShortLink(longPath); if (cancelled) return; if (!code) { // Shorten failed — use the long URL so the share payload at // least carries SOMETHING clickable. setShortUrl(new URL(longPath, origin).toString()); return; } setShortUrl(new URL(`/share/${code}`, origin).toString()); } catch { // Should be unreachable — createShortLink swallows its own // failures. Fall back to a non-shortened long URL. if (!cancelled) { setShortUrl( new URL( `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`, window.location.origin, ).toString(), ); } } finally { if (!cancelled) setShortening(false); } })(); return () => { cancelled = true; }; }, [open, authorHandle, post.id]); // Canonical share payload — quote, reference, link. Built lazily because // shortUrl arrives async; while loading we render a spinner instead. const shareText = useMemo(() => { if (!shortUrl) return ""; return `“${post.caption}”\n\n— ${reference}\n\n${shortUrl}`; }, [post.caption, reference, shortUrl]); function openIntent(url: string) { window.open(url, "_blank", "noopener,noreferrer"); onClose(); } async function copyText(text: string, successMsg = "Copied") { try { await navigator.clipboard.writeText(text); toast.success(successMsg); } catch { toast.error("Could not copy"); } onClose(); } async function shareNative() { if (!nativeShareSupported) return; const payload: ShareData = { title: reference, text: shareText, }; // Bonus: attach the first image as a File so the recipient gets // the visual. Requires (a) R2 to allow CORS GET on the public URL // and (b) navigator.canShare to accept the File. If either fails // we silently degrade to text-only — the quote + URL still go // through, which is the contract. if (firstImage) { try { const res = await fetch(firstImage, { mode: "cors" }); if (res.ok) { const blob = await res.blob(); const ext = (blob.type.split("/")[1] || "jpg").toLowerCase(); const file = new File([blob], `share.${ext}`, { type: blob.type || "image/jpeg", }); if ( typeof navigator.canShare === "function" && navigator.canShare({ files: [file] }) ) { payload.files = [file]; } } } catch { // CORS-blocked or network error — proceed without files. } } try { await navigator.share(payload); onClose(); } catch { // User cancelled the share sheet; leave the drawer open so they // can pick another target. } } // Channel tiles — kept in a single declarative list so the JSX is just // a map. Each tile's onClick closes the sheet to give the user a clean // "done" signal. const targets: ShareTarget[] = [ { key: "whatsapp", label: "WhatsApp", brandClass: "text-[#25D366]", icon: , onClick: () => openIntent(`https://wa.me/?text=${encodeURIComponent(shareText)}`), }, { key: "telegram", label: "Telegram", brandClass: "text-[#229ED9]", icon: , onClick: () => { // Telegram's share URL splits text + url so the link is // attached as a separate entity (better preview rendering). const params = new URLSearchParams({ url: shortUrl ?? "", text: `“${post.caption}” — ${reference}`, }); openIntent(`https://t.me/share/url?${params.toString()}`); }, }, { key: "x", label: "X", brandClass: "text-foreground", icon: , onClick: () => openIntent( `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`, ), }, { key: "facebook", label: "Facebook", brandClass: "text-[#1877F2]", icon: , // Facebook's web sharer ignores prefilled text for non-FB-app // shares, so we send only the URL — FB scrapes OG tags from the // linked page. The user can paste the rest before posting. onClick: () => openIntent( `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shortUrl ?? "")}`, ), }, { key: "instagram", label: "Instagram", brandClass: "text-[#E1306C]", icon: , // Instagram has no public web share intent. Copy the payload and // tell the user where it'll land. onClick: () => copyText(shareText, "Copied — open Instagram and paste"), }, { key: "email", label: "Email", brandClass: "text-muted-foreground", icon: , onClick: () => { // mailto: drives the user's default mail client. Some clients // crop very long bodies; the user can edit before sending. const href = `mailto:?subject=${encodeURIComponent(reference)}` + `&body=${encodeURIComponent(shareText)}`; window.location.href = href; onClose(); }, }, { key: "copy", label: "Copy", brandClass: "text-muted-foreground", icon: , onClick: () => copyText(shareText), }, ]; const direction = isMobile ? "bottom" : "right"; return ( !o && onClose()} direction={direction} > Share post

“{post.caption.slice(0, 140)} {post.caption.length > 140 ? "…" : ""}”

{reference}

{shortening ? (
) : (
{targets.map((t) => ( ))} {isMobile && nativeShareSupported ? ( , onClick: shareNative, }} /> ) : null}
)}
); } // ─── Pieces ──────────────────────────────────────────────────────────── type ShareTarget = { key: string; label: string; /** Tailwind text color class applied to the icon — usually the brand's * hex, falling back to text-foreground / text-muted-foreground. */ brandClass: string; icon: React.ReactNode; onClick: () => void; }; function ShareTile({ target }: { target: ShareTarget }) { return ( ); }