feat: share sheet update
This commit is contained in:
@@ -33,13 +33,11 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { posts as postsApi, SocialError, type Post } from "@/lib/social";
|
import { posts as postsApi, SocialError, type Post } from "@/lib/social";
|
||||||
import { createShortLink } from "@/lib/share";
|
|
||||||
import { structuredReferenceForPost } from "@/lib/source-reference";
|
|
||||||
import { useAuth } from "@/components/auth/auth-provider";
|
import { useAuth } from "@/components/auth/auth-provider";
|
||||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
||||||
import { SharePostSheet } from "./share-post-sheet";
|
import { useSharePost } from "./share-post-provider";
|
||||||
|
|
||||||
// All icons come from the Heroicons v2 outline family (react-icons
|
// All icons come from the Heroicons v2 outline family (react-icons
|
||||||
// `hi2` namespace), so they share one design language — uniform stroke
|
// `hi2` namespace), so they share one design language — uniform stroke
|
||||||
@@ -49,11 +47,12 @@ const ICON_CLASS = "size-[1.375rem]";
|
|||||||
export function PostActionsFooter({ post }: { post: Post }) {
|
export function PostActionsFooter({ post }: { post: Post }) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { requireAuth } = useAuthGate();
|
const { requireAuth } = useAuthGate();
|
||||||
|
const { openShareSheet } = useSharePost();
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||||
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
||||||
const [shareOpen, setShareOpen] = useState(false);
|
// Tracks the platform-aware share routing (short URL + image fetch
|
||||||
// Tracks the short-URL fetch on the direct-mobile-share path so the
|
// on mobile native-share; sheet mount on desktop) so the Send icon
|
||||||
// Send icon can render a spinner while the user waits.
|
// can render a spinner while the user waits.
|
||||||
const [sharePending, setSharePending] = useState(false);
|
const [sharePending, setSharePending] = useState(false);
|
||||||
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
||||||
// happens the instant the user taps Reshare. Reconciles to the
|
// happens the instant the user taps Reshare. Reconciles to the
|
||||||
@@ -66,11 +65,6 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
// Owners and resharers both see Send — the post is "on their account"
|
// Owners and resharers both see Send — the post is "on their account"
|
||||||
// either way, so the action they need is external share, not reshare.
|
// either way, so the action they need is external share, not reshare.
|
||||||
const showSend = isOwner || reshared;
|
const showSend = isOwner || reshared;
|
||||||
// Capability-check the Web Share API once per render — supported on
|
|
||||||
// mobile Safari/Chrome and some desktop Safari builds. We only want
|
|
||||||
// the native-share short-circuit on phones.
|
|
||||||
const nativeShareSupported =
|
|
||||||
typeof navigator !== "undefined" && typeof navigator.share === "function";
|
|
||||||
|
|
||||||
async function withAuth(fn: () => void | Promise<void>) {
|
async function withAuth(fn: () => void | Promise<void>) {
|
||||||
if (!(await requireAuth())) return;
|
if (!(await requireAuth())) return;
|
||||||
@@ -91,64 +85,16 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Direct-mobile share path:
|
// Send button: hand off to the provider, which decides between the
|
||||||
// 1. User taps Send (consumes iOS Safari's user activation).
|
// mobile native-share path (await short URL + image proxy in
|
||||||
// 2. We fetch the short URL — short enough to fit comfortably
|
// parallel, then navigator.share) and the desktop/no-native-share
|
||||||
// inside the same activation window. Loading spinner replaces
|
// path (mount the custom SharePostSheet). Provider's openShareSheet
|
||||||
// the icon while we wait.
|
// is always-resolves, so a single try/finally toggles the spinner.
|
||||||
// 3. navigator.share fires within the same handler. iOS sees the
|
|
||||||
// activation token, opens the system share sheet.
|
|
||||||
// 4. If activation has expired by the time we call share (slow
|
|
||||||
// network) or anything else throws non-AbortError, fall back
|
|
||||||
// to opening the custom in-app sheet so the user isn't stuck.
|
|
||||||
//
|
|
||||||
// Desktop / no native share API → straight to the custom sheet,
|
|
||||||
// which handles its own short-URL fetch + image pre-fetch.
|
|
||||||
async function onSendClick() {
|
async function onSendClick() {
|
||||||
if (!isMobile || !nativeShareSupported) {
|
|
||||||
setShareOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (sharePending) return;
|
if (sharePending) return;
|
||||||
|
|
||||||
const handle = post.author_handle ?? user?.handle ?? "";
|
|
||||||
if (!handle) {
|
|
||||||
// Without a handle we can't build the post URL; fall back to
|
|
||||||
// the custom sheet which has its own resolution path.
|
|
||||||
setShareOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSharePending(true);
|
setSharePending(true);
|
||||||
try {
|
try {
|
||||||
const longPath = `/u/${encodeURIComponent(handle)}/posts#post-${post.id}`;
|
await openShareSheet(post);
|
||||||
const code = await createShortLink(longPath);
|
|
||||||
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;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await navigator.share({ title, text });
|
|
||||||
} catch (err) {
|
|
||||||
// User-cancel = AbortError = leave them alone. Any other
|
|
||||||
// failure (most often iOS NotAllowedError when the awaited
|
|
||||||
// fetch overran the user-activation window) → fall back to
|
|
||||||
// the in-app sheet so the user can still complete the share.
|
|
||||||
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
||||||
console.warn("[share] navigator.share failed, opening custom sheet", err);
|
|
||||||
setShareOpen(true);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setSharePending(false);
|
setSharePending(false);
|
||||||
}
|
}
|
||||||
@@ -192,7 +138,7 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<HiOutlinePaperAirplane className={ICON_CLASS} />
|
<HiOutlinePaperAirplane className={ICON_CLASS+"rotate-45"} />
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -217,15 +163,9 @@ export function PostActionsFooter({ post }: { post: Post }) {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
existingPostId={post.id}
|
existingPostId={post.id}
|
||||||
/>
|
/>
|
||||||
|
{/* SharePostSheet is mounted globally by SharePostProvider, so
|
||||||
{showSend ? (
|
no per-card instance lives here. The provider's
|
||||||
<SharePostSheet
|
openShareSheet swaps the active post under the hood. */}
|
||||||
open={shareOpen}
|
|
||||||
onClose={() => setShareOpen(false)}
|
|
||||||
post={post}
|
|
||||||
isMobile={isMobile}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SharePostProvider — mounts a single global SharePostSheet that any
|
* SharePostProvider — single source of truth for "share a post" routing.
|
||||||
* component in the tree can summon via `useSharePost().openShareSheet(post)`.
|
|
||||||
*
|
*
|
||||||
* The sheet itself is a heavy modal/drawer that loads URLs, pre-fetches
|
* Any component in the tree calls `useSharePost().openShareSheet(post)`
|
||||||
* images for native share, etc. — we don't want every caller to mount
|
* and the provider decides what actually happens:
|
||||||
* its own copy. Mounting once at the root and triggering via context
|
|
||||||
* keeps state isolated and makes "Share" a one-liner anywhere downstream:
|
|
||||||
*
|
*
|
||||||
* const { openShareSheet } = useSharePost();
|
* • Mobile + navigator.share supported → short-URL fetch happens
|
||||||
* openShareSheet(newlyCreatedPost);
|
* 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.
|
||||||
*
|
*
|
||||||
* Used today by compose-post.tsx (toast after a successful post create
|
* Callers used to get away with a synchronous `openShareSheet(post)`
|
||||||
* offers a "Share" action that opens the sheet for the new post).
|
* that just mounted the sheet. The function is now async because the
|
||||||
* Future surfaces like profile cards can call into the same hook.
|
* 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 {
|
import {
|
||||||
@@ -24,13 +38,44 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type { Post } from "@/lib/social";
|
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 { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||||
import { SharePostSheet } from "./share-post-sheet";
|
import { SharePostSheet } from "./share-post-sheet";
|
||||||
|
|
||||||
type SharePostContextValue = {
|
type SharePostContextValue = {
|
||||||
openShareSheet: (post: Post) => void;
|
/** 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);
|
const SharePostContext = createContext<SharePostContextValue | null>(null);
|
||||||
|
|
||||||
export function useSharePost(): SharePostContextValue {
|
export function useSharePost(): SharePostContextValue {
|
||||||
@@ -48,10 +93,103 @@ export function SharePostProvider({
|
|||||||
}) {
|
}) {
|
||||||
const [activePost, setActivePost] = useState<Post | null>(null);
|
const [activePost, setActivePost] = useState<Post | null>(null);
|
||||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
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((post: Post) => {
|
const openShareSheet = useCallback<SharePostContextValue["openShareSheet"]>(
|
||||||
setActivePost(post);
|
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 (
|
return (
|
||||||
<SharePostContext.Provider value={{ openShareSheet }}>
|
<SharePostContext.Provider value={{ openShareSheet }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user