diff --git a/components/social/share-post-sheet.tsx b/components/social/share-post-sheet.tsx index 6b390aec..ececacb9 100644 --- a/components/social/share-post-sheet.tsx +++ b/components/social/share-post-sheet.tsx @@ -94,6 +94,15 @@ export function SharePostSheet({ // 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( @@ -145,6 +154,63 @@ export function SharePostSheet({ }; }, [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. + // + // Failures (CORS-blocked fetch, 4xx, 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); + (async () => { + try { + const res = await fetch(firstImage, { mode: "cors" }); + if (cancelled) return; + if (!res.ok) { + setImageFetchError(`HTTP ${res.status}`); + console.warn( + `[share] image pre-fetch returned ${res.status} for`, + firstImage, + ); + 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("cors"); + // Almost always a CORS preflight failure when the public R2 + // domain is a separate hostname. Log so it's visible in + // DevTools when the user reports "image didn't attach". + console.warn( + "[share] image pre-fetch failed (likely CORS) for", + firstImage, + err, + ); + } + })(); + return () => { + cancelled = true; + }; + }, [open, firstImage]); + // Canonical share payload — quote, reference, link. Built lazily because // shortUrl arrives async; while loading we render a spinner instead. const shareText = useMemo(() => { @@ -167,44 +233,81 @@ export function SharePostSheet({ onClose(); } - async function shareNative() { + 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, }; - // 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. + 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 === "cors") { + toast( + "Couldn't load image (CORS) — sharing text only. Check R2 CORS policy.", + { duration: 4000 }, + ); + } 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 }, + ); } - try { - await navigator.share(payload); - onClose(); - } catch { - // User cancelled the share sheet; leave the drawer open so they - // can pick another target. - } + + // 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