"use client"; /** * SharePostProvider — single source of truth for "share a post" routing. * * Any component in the tree calls `useSharePost().openShareSheet(post)` * and the provider decides what actually happens: * * • Mobile + navigator.share supported → short-URL fetch happens * inside the user gesture, then navigator.share fires * immediately so iOS Safari's activation token is preserved. * The OS share sheet appears with the canonical "Source:" payload. * • Desktop, or no navigator.share → mount the custom SharePostSheet * drawer/modal (handles its own URL shortening + image pre-fetch * for the More-button file-attach path). * • Native share fails non-AbortError (e.g. activation already * expired) → graceful fallback to the custom sheet so the user * never gets stranded. * * Callers used to get away with a synchronous `openShareSheet(post)` * that just mounted the sheet. The function is now async because the * native path awaits the URL shortener inside the gesture. Callers * that show a loading spinner (the Send icon in PostActionsFooter) * await the returned promise; callers that don't (the post-create * success toast) can fire-and-forget. * * Used today by: * • PostActionsFooter — owner + resharer "Send" buttons. * • compose-post.tsx success toast — "Share" action after a post is * created. * • Any future surface (profile cards, etc.) — same one-line API. */ import { createContext, useCallback, useContext, useState, } from "react"; import type { Post } from "@/lib/social"; import { buildPostShareText } from "@/lib/post-share-text"; import { useAuth } from "@/components/auth/auth-provider"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { SharePostSheet } from "./share-post-sheet"; type SharePostContextValue = { /** Open the right share surface for the current platform. Returns * a promise so callers can render loading state during the short-URL * fetch on the mobile native-share path. Always resolves; never * rejects. */ openShareSheet: (post: Post) => Promise; }; /** * Fetch the post's first image via our same-origin proxy and wrap it * in a File so navigator.share can attach it. The proxy at * /api/share-image/ sits in front of R2 and serves the bytes * without CORS, which sidesteps the cached-without-Vary issue R2 * custom domains hit when a browser previously loaded the same image * via an tag. Returns null on any failure so the caller falls * back to a text-only share. */ async function fetchShareImageFile(postId: string): Promise { try { const res = await fetch(`/api/share-image/${encodeURIComponent(postId)}`); if (!res.ok) return null; const blob = await res.blob(); const ext = (blob.type.split("/")[1] || "jpg").toLowerCase(); return new File([blob], `share.${ext}`, { type: blob.type || "image/jpeg", lastModified: Date.now(), }); } catch { return null; } } const SharePostContext = createContext(null); export function useSharePost(): SharePostContextValue { const v = useContext(SharePostContext); if (!v) { throw new Error("useSharePost must be used inside "); } return v; } export function SharePostProvider({ children, }: { children: React.ReactNode; }) { const [activePost, setActivePost] = useState(null); const isMobile = useMediaQuery("(max-width: 767px)"); const { user } = useAuth(); // Capability check once — the result doesn't change at runtime. const nativeShareSupported = typeof navigator !== "undefined" && typeof navigator.share === "function"; const openShareSheet = useCallback( async (post) => { // Path A — desktop or no native share API: drop straight into // the custom sheet. It handles its own URL shortening + image // pre-fetch. if (!isMobile || !nativeShareSupported) { setActivePost(post); return; } // Resolve the author handle. For owner posts the BFF Create // response doesn't include author_handle, so fall back to the // signed-in user's handle (the post creator IS the auth user // in the toast-after-post case). For reshared posts the handle // arrives populated. const handle = post.author_handle ?? user?.handle ?? ""; if (!handle) { // Can't build a post URL without a handle — bail to the // custom sheet which has its own fallback path. setActivePost(post); return; } // Path B — mobile native share: // 1. Fetch short URL AND image-file proxy in parallel — the // wait is bounded by the slower of the two. Both target // same-origin Next routes so they're typically <300ms. // 2. Fire navigator.share synchronously after, with image // attached as a File when the platform's canShare accepts // it. // 3. On any non-AbortError, fall back to the custom sheet. try { const hasImage = !!post.images?.[0]; const [share, imageFile] = await Promise.all([ buildPostShareText(post, handle), hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null), ]); if (!share) { setActivePost(post); return; } const payload: ShareData = { title: share.title, text: share.text }; // Only attach files when the platform actually accepts them. // canShare returns false on Chrome desktop / Firefox even // though navigator.share itself exists, and we want to fail // gracefully there. if ( imageFile && typeof navigator.canShare === "function" && navigator.canShare({ files: [imageFile] }) ) { payload.files = [imageFile]; } try { await navigator.share(payload); } catch (err) { if (err instanceof DOMException && err.name === "AbortError") { return; } // Most likely NotAllowedError — iOS Safari refused the call // because user activation lapsed during the parallel fetch // on a slow network. The custom sheet has no activation // requirement, so falling back gets the user unstuck. console.warn( "[share] navigator.share failed, opening custom sheet", err, ); setActivePost(post); } } catch { // Short URL fetch itself blew up — same fallback. setActivePost(post); } }, [isMobile, nativeShareSupported, user?.handle], ); return ( {children} {activePost ? ( setActivePost(null)} post={activePost} isMobile={isMobile} /> ) : null} ); }