Files
spsays-training/components/social/share-post-sheet.tsx
T
2026-06-05 14:13:13 +05:30

527 lines
18 KiB
TypeScript

"use client";
/**
* SharePostSheet — share a quote you've posted to external apps.
*
* Modal on desktop (Drawer direction="right"), drawer on mobile
* (direction="bottom"). Same UI primitive CollectionsDrawer uses.
*
* What we share:
*
* "<full caption>"
*
* — <reference, e.g. Bhagavad-gītā 2.13>
*
* <shortened URL pointing to the POST on your profile feed>
*
* The link target is `/u/<handle>/posts#post-<id>` — the Instagram-style
* scroll view anchored to this specific post — rather than the raw
* source URL. Recipients land on the card (image, your caption, your
* byline) and can click through to the original passage from there.
* This matches how every major social product handles "share a post":
* the share URL is the post, not the underlying resource.
*
* URL is run through createShortLink (same /share/<code> short-code
* service the selection-toolbar uses) so the payload stays compact and
* the click-through is trackable. The shortener's ValidatePath tolerates
* fragments, and the destination pages' existing hash-anchor effect
* scrolls to the post on mount — no other code needs to know.
*
* Sharing channels:
*
* • WhatsApp / Telegram / X / Facebook — official web share intents.
* We open them in a new window; the user finishes in that app.
* • Instagram — has no web share intent. We copy the payload + toast
* "paste in Instagram" so the user can switch and paste.
* • Email — mailto: with prefilled subject + body.
* • Copy — drops the full payload on the clipboard.
* • More — only when navigator.share is supported AND we're on mobile.
* Tries to attach the first image as a File via navigator.canShare;
* falls back to text-only if R2 CORS blocks the blob fetch.
*
* Images travel only via the native-share path. None of the open web
* share intents accept an image URL directly — they pull OpenGraph
* metadata from the linked page instead, which we don't currently
* generate per-post. That's the "bonus" the brief allows for.
*/
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import {
FaWhatsapp,
FaTelegram,
FaXTwitter,
FaFacebook,
FaInstagram,
FaEnvelope,
} from "react-icons/fa6";
import { Copy, Loader2, Share2 } from "lucide-react";
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social";
import { createShortLink } from "@/lib/share";
import {
displaySourceLabel,
structuredReferenceForPost,
} from "@/lib/source-reference";
import { useAuth } from "@/components/auth/auth-provider";
export function SharePostSheet({
open,
onClose,
post,
isMobile,
}: {
open: boolean;
onClose: () => void;
post: Post;
isMobile: boolean;
}) {
// `reference` is the single-line citation used for UI chrome — the
// drawer header, email subject, etc. where multi-line doesn't render.
const reference = displaySourceLabel(post.source_url, post.source_title);
// Structured `{ title, detail }` for the multi-line "Source:" block
// in the share payload. Same helper that the direct-mobile native
// share path in post-actions-footer.tsx uses, so both surfaces
// produce byte-identical citation lines.
const structuredRef = useMemo(
() => structuredReferenceForPost(post.source_url, post.source_title),
[post.source_url, post.source_title],
);
const firstImage = post.images?.[0]?.url ?? null;
const { user } = useAuth();
// The Send button is only rendered for owners, so user is guaranteed
// non-null here; we still defensively fall back via `?? ""` rather than
// crashing if the auth context blips during a re-render.
const authorHandle = post.author_handle ?? user?.handle ?? "";
const [shortUrl, setShortUrl] = useState<string | null>(null);
const [shortening, setShortening] = useState(false);
// navigator.share only exists on a subset of browsers (mainly mobile
// Safari/Chrome and some desktop Safari builds). Capability-check once
// 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(
typeof navigator !== "undefined" &&
typeof navigator.share === "function",
);
}, []);
// Shorten the post's anchor URL on the author's feed when the sheet
// opens. The path takes the form `/u/<handle>/posts#post-<id>`; the
// BFF's ValidatePath tolerates fragments, and the destination page
// already has a hash-anchor effect that scrolls to the post once the
// feed loads, so the recipient lands on the right card automatically.
useEffect(() => {
if (!open) return;
if (!authorHandle) return;
let cancelled = false;
setShortening(true);
(async () => {
try {
const origin = window.location.origin;
const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`;
const code = await createShortLink(longPath);
if (cancelled) return;
if (!code) {
// Shorten failed — use the long URL so the share payload at
// least carries SOMETHING clickable.
setShortUrl(new URL(longPath, origin).toString());
return;
}
setShortUrl(new URL(`/share/${code}`, origin).toString());
} catch {
// Should be unreachable — createShortLink swallows its own
// failures. Fall back to a non-shortened long URL.
if (!cancelled) {
setShortUrl(
new URL(
`/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`,
window.location.origin,
).toString(),
);
}
} finally {
if (!cancelled) setShortening(false);
}
})();
return () => {
cancelled = true;
};
}, [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.
//
// We hit our same-origin proxy (/api/share-image/<id>) rather than
// the raw R2 URL. Reason: even with R2's CORS policy set correctly,
// browsers that have a pre-CORS response cached for the image (from
// earlier <img> loads without a `Vary: Origin` header) will serve
// that stale no-ACAO copy and the fetch fails CORS regardless of
// how the bucket is configured. The proxy is same-origin so the
// browser never sends an Origin header to R2 in the first place.
//
// Failures (4xx from the proxy, 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);
const proxyUrl = `/api/share-image/${encodeURIComponent(post.id)}`;
(async () => {
try {
const res = await fetch(proxyUrl);
if (cancelled) return;
if (!res.ok) {
setImageFetchError(`HTTP ${res.status}`);
console.warn(
`[share] image proxy returned ${res.status} for post`,
post.id,
);
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("network");
console.warn(
"[share] image proxy fetch failed for post",
post.id,
err,
);
}
})();
return () => {
cancelled = true;
};
}, [open, firstImage, post.id]);
// Canonical share payload — quote, "Source:" delimiter, structured
// citation, link. Built lazily because shortUrl arrives async; while
// loading we render a spinner instead. The structure mirrors
// formatCopyText from selection-toolbar/shared.tsx exactly so the
// Copy and Share flows produce identical text:
//
// "caption"
//
// Source:
// Book Name
// Chapter, Verse
// short URL
const shareText = useMemo(() => {
if (!shortUrl) return "";
const lines: string[] = [`“${post.caption}”`, "", "Source:"];
lines.push(structuredRef.title);
if (structuredRef.detail) lines.push(structuredRef.detail);
lines.push(shortUrl);
return lines.join("\n");
}, [post.caption, structuredRef, shortUrl]);
function openIntent(url: string) {
window.open(url, "_blank", "noopener,noreferrer");
onClose();
}
async function copyText(text: string, successMsg = "Copied") {
try {
await navigator.clipboard.writeText(text);
toast.success(successMsg);
} catch {
toast.error("Could not copy");
}
onClose();
}
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,
};
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 === "network") {
toast(
"Couldn't load image for sharing — sharing text only",
{ duration: 3000 },
);
} 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 },
);
}
// 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
// a map. Each tile's onClick closes the sheet to give the user a clean
// "done" signal.
const targets: ShareTarget[] = [
{
key: "whatsapp",
label: "WhatsApp",
brandClass: "text-[#25D366]",
icon: <FaWhatsapp className="size-6" />,
onClick: () =>
openIntent(`https://wa.me/?text=${encodeURIComponent(shareText)}`),
},
{
key: "telegram",
label: "Telegram",
brandClass: "text-[#229ED9]",
icon: <FaTelegram className="size-6" />,
onClick: () => {
// Telegram's share URL splits text + url so the link is
// attached as a separate entity (better preview rendering).
// Keep the same multi-line citation — including the "Source:"
// delimiter — as the canonical share text. Telegram preserves
// newlines in the message body so the structure carries over.
const lines = [
`“${post.caption}”`,
"",
"Source:",
structuredRef.title,
];
if (structuredRef.detail) lines.push(structuredRef.detail);
const params = new URLSearchParams({
url: shortUrl ?? "",
text: lines.join("\n"),
});
openIntent(`https://t.me/share/url?${params.toString()}`);
},
},
{
key: "x",
label: "X",
brandClass: "text-foreground",
icon: <FaXTwitter className="size-6" />,
onClick: () =>
openIntent(
`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`,
),
},
{
key: "facebook",
label: "Facebook",
brandClass: "text-[#1877F2]",
icon: <FaFacebook className="size-6" />,
// Facebook's web sharer ignores prefilled text for non-FB-app
// shares, so we send only the URL — FB scrapes OG tags from the
// linked page. The user can paste the rest before posting.
onClick: () =>
openIntent(
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shortUrl ?? "")}`,
),
},
{
key: "instagram",
label: "Instagram",
brandClass: "text-[#E1306C]",
icon: <FaInstagram className="size-6" />,
// Instagram has no public web share intent. Copy the payload and
// tell the user where it'll land.
onClick: () =>
copyText(shareText, "Copied — open Instagram and paste"),
},
{
key: "email",
label: "Email",
brandClass: "text-muted-foreground",
icon: <FaEnvelope className="size-6" />,
onClick: () => {
// mailto: drives the user's default mail client. Some clients
// crop very long bodies; the user can edit before sending.
const href =
`mailto:?subject=${encodeURIComponent(reference)}` +
`&body=${encodeURIComponent(shareText)}`;
window.location.href = href;
onClose();
},
},
{
key: "copy",
label: "Copy",
brandClass: "text-muted-foreground",
icon: <Copy className="size-6" />,
onClick: () => copyText(shareText),
},
];
const direction = isMobile ? "bottom" : "right";
return (
<Drawer
open={open}
onOpenChange={(o) => !o && onClose()}
direction={direction}
>
<DrawerContent direction={direction} className="bg-card select-none">
<DrawerHeader className="text-left border-b border-border pb-4">
<DrawerTitle>Share post</DrawerTitle>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
“{post.caption.slice(0, 140)}
{post.caption.length > 140 ? "…" : ""}”
</p>
<p className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground/80 mt-1">
{reference}
</p>
</DrawerHeader>
<div className="p-4 sm:p-6 overflow-y-auto">
{shortening ? (
<div className="flex items-center justify-center py-10 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
) : (
<div className="grid grid-cols-4 gap-3 sm:grid-cols-5">
{targets.map((t) => (
<ShareTile key={t.key} target={t} />
))}
{isMobile && nativeShareSupported ? (
<ShareTile
target={{
key: "native",
label: "More",
brandClass: "text-foreground",
icon: <Share2 className="size-6" />,
onClick: shareNative,
}}
/>
) : null}
</div>
)}
</div>
</DrawerContent>
</Drawer>
);
}
// ─── Pieces ────────────────────────────────────────────────────────────
type ShareTarget = {
key: string;
label: string;
/** Tailwind text color class applied to the icon — usually the brand's
* hex, falling back to text-foreground / text-muted-foreground. */
brandClass: string;
icon: React.ReactNode;
onClick: () => void;
};
function ShareTile({ target }: { target: ShareTarget }) {
return (
<button
type="button"
onClick={target.onClick}
className={cn(
"flex flex-col items-center gap-1.5 rounded-lg p-2 sm:p-3",
"hover:bg-muted transition-colors",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<span
className={cn(
"flex items-center justify-center size-12 rounded-full",
"bg-muted",
target.brandClass,
)}
>
{target.icon}
</span>
<span className="text-[11px] text-foreground text-center leading-tight">
{target.label}
</span>
</button>
);
}