feat: share post to other apps
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user