diff --git a/components/social/post-actions-footer.tsx b/components/social/post-actions-footer.tsx index c552cc59..e752ff9b 100644 --- a/components/social/post-actions-footer.tsx +++ b/components/social/post-actions-footer.tsx @@ -33,13 +33,11 @@ import { 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"; +import { useSharePost } from "./share-post-provider"; // All icons come from the Heroicons v2 outline family (react-icons // `hi2` namespace), so they share one design language — uniform stroke @@ -49,11 +47,12 @@ const ICON_CLASS = "size-[1.375rem]"; export function PostActionsFooter({ post }: { post: Post }) { const { user } = useAuth(); const { requireAuth } = useAuthGate(); + const { openShareSheet } = useSharePost(); 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. + // Tracks the platform-aware share routing (short URL + image fetch + // on mobile native-share; sheet mount on desktop) 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 @@ -66,11 +65,6 @@ export function PostActionsFooter({ post }: { post: Post }) { // 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; @@ -91,64 +85,16 @@ export function PostActionsFooter({ post }: { post: Post }) { } } - // 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. + // Send button: hand off to the provider, which decides between the + // mobile native-share path (await short URL + image proxy in + // parallel, then navigator.share) and the desktop/no-native-share + // path (mount the custom SharePostSheet). Provider's openShareSheet + // is always-resolves, so a single try/finally toggles the spinner. 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); - } + await openShareSheet(post); } finally { setSharePending(false); } @@ -192,7 +138,7 @@ export function PostActionsFooter({ post }: { post: Post }) { aria-hidden /> ) : ( - + ) } /> @@ -217,15 +163,9 @@ export function PostActionsFooter({ post }: { post: Post }) { isMobile={isMobile} existingPostId={post.id} /> - - {showSend ? ( - setShareOpen(false)} - post={post} - isMobile={isMobile} - /> - ) : null} + {/* SharePostSheet is mounted globally by SharePostProvider, so + no per-card instance lives here. The provider's + openShareSheet swaps the active post under the hood. */} ); } diff --git a/components/social/share-post-provider.tsx b/components/social/share-post-provider.tsx index 12c0519a..3656970c 100644 --- a/components/social/share-post-provider.tsx +++ b/components/social/share-post-provider.tsx @@ -1,20 +1,34 @@ "use client"; /** - * SharePostProvider — mounts a single global SharePostSheet that any - * component in the tree can summon via `useSharePost().openShareSheet(post)`. + * SharePostProvider — single source of truth for "share a post" routing. * - * The sheet itself is a heavy modal/drawer that loads URLs, pre-fetches - * images for native share, etc. — we don't want every caller to mount - * its own copy. Mounting once at the root and triggering via context - * keeps state isolated and makes "Share" a one-liner anywhere downstream: + * Any component in the tree calls `useSharePost().openShareSheet(post)` + * and the provider decides what actually happens: * - * const { openShareSheet } = useSharePost(); - * openShareSheet(newlyCreatedPost); + * • 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. * - * Used today by compose-post.tsx (toast after a successful post create - * offers a "Share" action that opens the sheet for the new post). - * Future surfaces like profile cards can call into the same hook. + * 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 { @@ -24,13 +38,44 @@ import { useState, } from "react"; import type { Post } from "@/lib/social"; +import { createShortLink } from "@/lib/share"; +import { structuredReferenceForPost } from "@/lib/source-reference"; +import { useAuth } from "@/components/auth/auth-provider"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { SharePostSheet } from "./share-post-sheet"; type SharePostContextValue = { - openShareSheet: (post: Post) => void; + /** 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 { @@ -48,10 +93,103 @@ export function SharePostProvider({ }) { 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((post: Post) => { - setActivePost(post); - }, []); + 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 longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`; + const hasImage = !!post.images?.[0]; + const [code, imageFile] = await Promise.all([ + createShortLink(longPath), + hasImage ? fetchShareImageFile(post.id) : Promise.resolve(null), + ]); + 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; + + const payload: ShareData = { title, 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 (