feat: share post to other apps

This commit is contained in:
gosangam
2026-05-31 08:20:06 +05:30
parent 336f478f8b
commit 88f1b08675
+134 -31
View File
@@ -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<File | null>(null);
const [imageFetchError, setImageFetchError] = useState<string | null>(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