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
|
// on mount; combined with `isMobile` to gate the "More" tile so the
|
||||||
// affordance only appears where it can do something useful.
|
// affordance only appears where it can do something useful.
|
||||||
const [nativeShareSupported, setNativeShareSupported] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
setNativeShareSupported(
|
setNativeShareSupported(
|
||||||
@@ -145,6 +154,63 @@ export function SharePostSheet({
|
|||||||
};
|
};
|
||||||
}, [open, authorHandle, post.id]);
|
}, [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
|
// Canonical share payload — quote, reference, link. Built lazily because
|
||||||
// shortUrl arrives async; while loading we render a spinner instead.
|
// shortUrl arrives async; while loading we render a spinner instead.
|
||||||
const shareText = useMemo(() => {
|
const shareText = useMemo(() => {
|
||||||
@@ -167,44 +233,81 @@ export function SharePostSheet({
|
|||||||
onClose();
|
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;
|
if (!nativeShareSupported) return;
|
||||||
const payload: ShareData = {
|
const payload: ShareData = {
|
||||||
title: reference,
|
title: reference,
|
||||||
text: shareText,
|
text: shareText,
|
||||||
};
|
};
|
||||||
// Bonus: attach the first image as a File so the recipient gets
|
let attachedImage = false;
|
||||||
// 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 (
|
if (
|
||||||
|
shareableFile &&
|
||||||
typeof navigator.canShare === "function" &&
|
typeof navigator.canShare === "function" &&
|
||||||
navigator.canShare({ files: [file] })
|
navigator.canShare({ files: [shareableFile] })
|
||||||
) {
|
) {
|
||||||
payload.files = [file];
|
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 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// CORS-blocked or network error — proceed without files.
|
// 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
|
||||||
try {
|
// diagnostics.
|
||||||
await navigator.share(payload);
|
navigator
|
||||||
onClose();
|
.share(payload)
|
||||||
} catch {
|
.then(() => onClose())
|
||||||
// User cancelled the share sheet; leave the drawer open so they
|
.catch((err) => {
|
||||||
// can pick another target.
|
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
|
// Channel tiles — kept in a single declarative list so the JSX is just
|
||||||
|
|||||||
Reference in New Issue
Block a user