"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, referenceFromUrl, } 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; }) { // `reference` is the single-line citation used for UI chrome — the // drawer header, email subject, etc. where multi-line doesn't render. const reference = displaySourceLabel(post.source_url, post.source_title); // `structuredRef` is the same citation split into its `{ title, detail }` // halves so the share text can lay them on separate lines — matches // the formatCopyText pattern from selection-toolbar/shared.tsx: // // "caption text" // // Source: // Letter to Mukunda // 15 January, 1968 · Calcutta // short URL // // Two routes to the structured shape: // // 1. Book URLs → `referenceFromUrl` parses the path and returns // a clean { title, detail } (e.g. Bhagavad-gītā / // Chapter 2, Verse 13). // 2. Everything else (letters, essays, transcriptions, magazines) // → `source_title` was composed by compose-post.tsx // as "Title — Detail" using ` — ` as the join. // We split on the first ` — ` to recover the // structured fields. Falls back to title-only if // no separator is present (book-index pages, // legacy posts, etc.). const structuredRef = useMemo<{ title: string; detail: string | null }>( () => { const r = referenceFromUrl(post.source_url); if (r) return r; const stored = post.source_title?.trim() || "Source"; const idx = stored.indexOf(" — "); if (idx === -1) { return { title: stored, detail: null }; } return { title: stored.slice(0, idx).trim(), detail: stored.slice(idx + 3).trim() || null, }; }, [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); // Pre-fetched File ready to attach to navigator.share. We build this // when the sheet opens — NOT lazily inside the "More" click handler — // because iOS Safari requires navigator.share() to fire within the // transient user activation window (~5 s after the tap). Awaiting a // network fetch in between burns that window and iOS rejects the // share. With the file pre-fetched, the tap → share() call is // synchronous in the same task. const [shareableFile, setShareableFile] = useState(null); const [imageFetchError, setImageFetchError] = useState(null); 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]); // Pre-fetch the first image as a File while the sheet is open. By // the time the user taps "More" the File is already in memory, so // navigator.share fires synchronously in the same tap — preserving // iOS Safari's user-activation token. Without this, awaiting the // fetch INSIDE the click handler burns the activation window and // iOS rejects the share with NotAllowedError. // // We hit our same-origin proxy (/api/share-image/) rather than // the raw R2 URL. Reason: even with R2's CORS policy set correctly, // browsers that have a pre-CORS response cached for the image (from // earlier loads without a `Vary: Origin` header) will serve // that stale no-ACAO copy and the fetch fails CORS regardless of // how the bucket is configured. The proxy is same-origin so the // browser never sends an Origin header to R2 in the first place. // // Failures (4xx from the proxy, network error) are remembered in // imageFetchError so the click handler can toast a useful // explanation. We don't toast at fetch time because the user hasn't // committed to sharing yet — that would feel like noise. useEffect(() => { if (!open || !firstImage) { setShareableFile(null); setImageFetchError(null); return; } let cancelled = false; setShareableFile(null); setImageFetchError(null); const proxyUrl = `/api/share-image/${encodeURIComponent(post.id)}`; (async () => { try { const res = await fetch(proxyUrl); if (cancelled) return; if (!res.ok) { setImageFetchError(`HTTP ${res.status}`); console.warn( `[share] image proxy returned ${res.status} for post`, post.id, ); return; } const blob = await res.blob(); if (cancelled) return; const ext = (blob.type.split("/")[1] || "jpg").toLowerCase(); const file = new File([blob], `share.${ext}`, { type: blob.type || "image/jpeg", }); setShareableFile(file); } catch (err) { if (cancelled) return; setImageFetchError("network"); console.warn( "[share] image proxy fetch failed for post", post.id, err, ); } })(); return () => { cancelled = true; }; }, [open, firstImage, post.id]); // Canonical share payload — quote, "Source:" delimiter, structured // citation, link. Built lazily because shortUrl arrives async; while // loading we render a spinner instead. The structure mirrors // formatCopyText from selection-toolbar/shared.tsx exactly so the // Copy and Share flows produce identical text: // // "caption" // // Source: // Book Name // Chapter, Verse // short URL const shareText = useMemo(() => { if (!shortUrl) return ""; const lines: string[] = [`“${post.caption}”`, "", "Source:"]; lines.push(structuredRef.title); if (structuredRef.detail) lines.push(structuredRef.detail); lines.push(shortUrl); return lines.join("\n"); }, [post.caption, structuredRef, 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(); } function shareNative() { // Crucially: this function must NOT await anything before calling // navigator.share. iOS Safari only honours the call inside the // tap's transient user-activation window; any awaited fetch / // microtask here would burn it and the share would be rejected as // NotAllowedError. The image is pre-fetched in the useEffect // above; here we just pick it up and fire. if (!nativeShareSupported) return; const payload: ShareData = { title: reference, text: shareText, }; let attachedImage = false; if ( shareableFile && typeof navigator.canShare === "function" && navigator.canShare({ files: [shareableFile] }) ) { payload.files = [shareableFile]; attachedImage = true; } else if (firstImage && !shareableFile) { // Image existed on the post but pre-fetch didn't complete // (or failed). Surface the reason so the user knows why the // share is text-only — at the moment they actually share, not // earlier when the sheet first opened. if (imageFetchError === "network") { toast( "Couldn't load image for sharing — sharing text only", { duration: 3000 }, ); } else if (imageFetchError) { toast(`Image not available (${imageFetchError}) — sharing text only`, { duration: 3000, }); } else { toast("Still loading image — sharing text only", { duration: 2500 }); } } else if ( firstImage && shareableFile && typeof navigator.canShare === "function" && !navigator.canShare({ files: [shareableFile] }) ) { // File is ready but the platform refuses it (rare on iOS — more // often desktop Chrome / Firefox). The "More" tile is already // gated on isMobile so this branch mainly catches edge cases. console.warn( "[share] navigator.canShare rejected files on this platform", ); toast( "Your browser can't share images here — sharing text only", { duration: 3000 }, ); } // Fire-and-forget — we don't await so React doesn't get a chance // to render in between the tap and the share call. The .catch // tail handles AbortError (user cancel) vs real failure for // diagnostics. navigator .share(payload) .then(() => onClose()) .catch((err) => { const aborted = err instanceof DOMException && err.name === "AbortError"; if (!aborted) { console.warn("[share] navigator.share threw", err); toast.error( `Share failed${ attachedImage ? " (image may be too large for this app)" : "" }`, { duration: 3000 }, ); } }); } // 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). // Keep the same multi-line citation — including the "Source:" // delimiter — as the canonical share text. Telegram preserves // newlines in the message body so the structure carries over. const lines = [ `“${post.caption}”`, "", "Source:", structuredRef.title, ]; if (structuredRef.detail) lines.push(structuredRef.detail); const params = new URLSearchParams({ url: shortUrl ?? "", text: lines.join("\n"), }); 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 ( ); }