Files
2026-05-31 10:24:32 +05:30

208 lines
7.6 KiB
TypeScript

"use client";
/**
* SharePostProvider — single source of truth for "share a post" routing.
*
* Any component in the tree calls `useSharePost().openShareSheet(post)`
* and the provider decides what actually happens:
*
* • 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.
*
* 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 {
createContext,
useCallback,
useContext,
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 = {
/** 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<void>;
};
/**
* 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/<id> 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 <img> tag. Returns null on any failure so the caller falls
* back to a text-only share.
*/
async function fetchShareImageFile(postId: string): Promise<File | null> {
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<SharePostContextValue | null>(null);
export function useSharePost(): SharePostContextValue {
const v = useContext(SharePostContext);
if (!v) {
throw new Error("useSharePost must be used inside <SharePostProvider>");
}
return v;
}
export function SharePostProvider({
children,
}: {
children: React.ReactNode;
}) {
const [activePost, setActivePost] = useState<Post | null>(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<SharePostContextValue["openShareSheet"]>(
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 (
<SharePostContext.Provider value={{ openShareSheet }}>
{children}
{activePost ? (
<SharePostSheet
open={true}
onClose={() => setActivePost(null)}
post={activePost}
isMobile={isMobile}
/>
) : null}
</SharePostContext.Provider>
);
}