diff --git a/app/api/share-image/[id]/route.ts b/app/api/share-image/[id]/route.ts
new file mode 100644
index 00000000..18ed85b6
--- /dev/null
+++ b/app/api/share-image/[id]/route.ts
@@ -0,0 +1,78 @@
+/**
+ * Same-origin proxy for a post's first attached image.
+ *
+ * Why this exists:
+ *
+ * navigator.share({ files }) needs a Blob built from a successful
+ * cross-origin fetch. The R2 bucket sits behind a custom domain
+ * (`s3.prabhupadasays.com`), so even with a correct CORS policy on
+ * the bucket, the browser will reuse pre-CORS cached responses from
+ * when the image was first loaded by an
tag without a `Vary:
+ * Origin` header. The cached copy lacks `Access-Control-Allow-Origin`,
+ * so subsequent CORS fetches from the share flow blow up with a CORS
+ * error even though R2 itself is now configured correctly.
+ *
+ * Routing the fetch through this same-origin endpoint sidesteps the
+ * whole CORS layer. The browser's request to /api/share-image/
+ * is same-origin and never carries an Origin header to R2; Next.js
+ * fetches the image server-side (no CORS at all) and pipes the
+ * bytes back to the browser.
+ *
+ * Security: we fetch by post id (not by raw URL) so the route can't
+ * be coerced into SSRF — only images attached to a real post are
+ * reachable, and the post lookup goes through the BFF.
+ */
+
+import { bff } from "@/lib/bff";
+
+export const runtime = "nodejs";
+
+type PostShape = {
+ id: string;
+ images: { url: string }[];
+};
+
+export async function GET(
+ _request: Request,
+ context: { params: Promise<{ id: string }> },
+) {
+ try {
+ const { id } = await context.params;
+ const post = await bff(`/posts/${encodeURIComponent(id)}`, {
+ revalidate: 60,
+ });
+ if (!post || !post.images?.[0]?.url) {
+ return new Response("Image not found", { status: 404 });
+ }
+ const imageUrl = post.images[0].url;
+
+ // Server-side fetch — no CORS at all from this side of the wire.
+ const upstream = await fetch(imageUrl);
+ if (!upstream.ok || !upstream.body) {
+ return new Response("Upstream fetch failed", {
+ status: upstream.status || 502,
+ });
+ }
+
+ const contentType =
+ upstream.headers.get("content-type") || "image/jpeg";
+ const contentLength = upstream.headers.get("content-length");
+
+ return new Response(upstream.body, {
+ status: 200,
+ headers: {
+ "Content-Type": contentType,
+ // Allow shared CDN caching for a day — image content for a
+ // given post id is immutable in practice (replacing the image
+ // mints a new key), so a long TTL is safe. Browser cache
+ // shorter so updates are picked up faster on the share flow.
+ "Cache-Control":
+ "public, max-age=3600, s-maxage=86400, stale-while-revalidate=86400",
+ ...(contentLength ? { "Content-Length": contentLength } : {}),
+ },
+ });
+ } catch (e) {
+ console.error("share-image proxy failed:", e);
+ return new Response("Proxy error", { status: 500 });
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 14484ca3..ec0dcc89 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -15,6 +15,7 @@ import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate";
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { PageBookmarks } from "@/components/social/page-bookmarks";
+import { SharePostProvider } from "@/components/social/share-post-provider";
import { Toaster } from "@/components/ui/toaster";
import "./globals.css";
@@ -77,20 +78,22 @@ export default function RootLayout({
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
+
+
diff --git a/components/selection-toolbar/compose-post.tsx b/components/selection-toolbar/compose-post.tsx
index 6481b18e..88874017 100644
--- a/components/selection-toolbar/compose-post.tsx
+++ b/components/selection-toolbar/compose-post.tsx
@@ -17,6 +17,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { X, ImagePlus, Trash2, Send } from "lucide-react";
+import { toast } from "sonner";
import {
Drawer,
DrawerContent,
@@ -26,6 +27,7 @@ import {
import { posts, SocialError } from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference";
+import { useSharePost } from "@/components/social/share-post-provider";
const MAX_IMAGES = 5;
const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
@@ -44,6 +46,7 @@ export function ComposePost({
onPosted?: () => void;
}) {
const router = useRouter();
+ const { openShareSheet } = useSharePost();
const fileInput = useRef(null);
const [images, setImages] = useState<{ url: string; uploading: boolean }[]>(
[],
@@ -108,7 +111,7 @@ export function ComposePost({
: typeof document !== "undefined"
? document.title
: "";
- await posts.create({
+ const newPost = await posts.create({
caption: selectedText,
source_url: sourceURL,
source_title: sourceTitle,
@@ -119,6 +122,19 @@ export function ComposePost({
onPosted?.();
onClose();
router.refresh();
+ // Success feedback + offer to externally share. The BFF's Create
+ // response doesn't include author info (no JOIN with users), but
+ // SharePostSheet falls back to the authed user's handle when
+ // post.author_handle is missing — perfect for a freshly-created
+ // post since the creator IS the auth user.
+ toast.success("Posted to your feed", {
+ description: "Want to share it to other apps?",
+ action: {
+ label: "Share",
+ onClick: () => openShareSheet(newPost),
+ },
+ duration: 5000,
+ });
} catch (err) {
if (err instanceof SocialError && err.status === 401) {
setError("Please log in to post.");
diff --git a/components/social/share-post-provider.tsx b/components/social/share-post-provider.tsx
new file mode 100644
index 00000000..12c0519a
--- /dev/null
+++ b/components/social/share-post-provider.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+/**
+ * SharePostProvider — mounts a single global SharePostSheet that any
+ * component in the tree can summon via `useSharePost().openShareSheet(post)`.
+ *
+ * The sheet itself is a heavy modal/drawer that loads URLs, pre-fetches
+ * images for native share, etc. — we don't want every caller to mount
+ * 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();
+ * openShareSheet(newlyCreatedPost);
+ *
+ * Used today by compose-post.tsx (toast after a successful post create
+ * offers a "Share" action that opens the sheet for the new post).
+ * Future surfaces like profile cards can call into the same hook.
+ */
+
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+} from "react";
+import type { Post } from "@/lib/social";
+import { useMediaQuery } from "@/components/selection-toolbar/shared";
+import { SharePostSheet } from "./share-post-sheet";
+
+type SharePostContextValue = {
+ openShareSheet: (post: Post) => void;
+};
+
+const SharePostContext = createContext(null);
+
+export function useSharePost(): SharePostContextValue {
+ const v = useContext(SharePostContext);
+ if (!v) {
+ throw new Error("useSharePost must be used inside ");
+ }
+ return v;
+}
+
+export function SharePostProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const [activePost, setActivePost] = useState(null);
+ const isMobile = useMediaQuery("(max-width: 767px)");
+
+ const openShareSheet = useCallback((post: Post) => {
+ setActivePost(post);
+ }, []);
+
+ return (
+
+ {children}
+ {activePost ? (
+ setActivePost(null)}
+ post={activePost}
+ isMobile={isMobile}
+ />
+ ) : null}
+
+ );
+}
diff --git a/components/social/share-post-sheet.tsx b/components/social/share-post-sheet.tsx
index ececacb9..03a2e86c 100644
--- a/components/social/share-post-sheet.tsx
+++ b/components/social/share-post-sheet.tsx
@@ -65,7 +65,10 @@ import {
import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social";
import { createShortLink } from "@/lib/share";
-import { displaySourceLabel } from "@/lib/source-reference";
+import {
+ displaySourceLabel,
+ referenceFromUrl,
+} from "@/lib/source-reference";
import { useAuth } from "@/components/auth/auth-provider";
export function SharePostSheet({
@@ -79,7 +82,48 @@ export function SharePostSheet({
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);
+ // `structuredRef` is the same citation split into its `{ title, detail }`
+ // halves so the share text can lay them on separate lines — matches
+ // the formatCopyText pattern from selection-toolbar/shared.tsx:
+ //
+ // "caption text"
+ //
+ // Source:
+ // Letter to Mukunda
+ // 15 January, 1968 · Calcutta
+ // short URL
+ //
+ // Two routes to the structured shape:
+ //
+ // 1. Book URLs → `referenceFromUrl` parses the path and returns
+ // a clean { title, detail } (e.g. Bhagavad-gītā /
+ // Chapter 2, Verse 13).
+ // 2. Everything else (letters, essays, transcriptions, magazines)
+ // → `source_title` was composed by compose-post.tsx
+ // as "Title — Detail" using ` — ` as the join.
+ // We split on the first ` — ` to recover the
+ // structured fields. Falls back to title-only if
+ // no separator is present (book-index pages,
+ // legacy posts, etc.).
+ const structuredRef = useMemo<{ title: string; detail: string | null }>(
+ () => {
+ const r = referenceFromUrl(post.source_url);
+ if (r) return r;
+ const stored = post.source_title?.trim() || "Source";
+ const idx = stored.indexOf(" — ");
+ if (idx === -1) {
+ return { title: stored, detail: null };
+ }
+ return {
+ title: stored.slice(0, idx).trim(),
+ detail: stored.slice(idx + 3).trim() || null,
+ };
+ },
+ [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
@@ -161,8 +205,16 @@ export function SharePostSheet({
// 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
+ // We hit our same-origin proxy (/api/share-image/) 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
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(() => {
@@ -174,15 +226,16 @@ export function SharePostSheet({
let cancelled = false;
setShareableFile(null);
setImageFetchError(null);
+ const proxyUrl = `/api/share-image/${encodeURIComponent(post.id)}`;
(async () => {
try {
- const res = await fetch(firstImage, { mode: "cors" });
+ const res = await fetch(proxyUrl);
if (cancelled) return;
if (!res.ok) {
setImageFetchError(`HTTP ${res.status}`);
console.warn(
- `[share] image pre-fetch returned ${res.status} for`,
- firstImage,
+ `[share] image proxy returned ${res.status} for post`,
+ post.id,
);
return;
}
@@ -195,13 +248,10 @@ export function SharePostSheet({
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".
+ setImageFetchError("network");
console.warn(
- "[share] image pre-fetch failed (likely CORS) for",
- firstImage,
+ "[share] image proxy fetch failed for post",
+ post.id,
err,
);
}
@@ -209,14 +259,28 @@ export function SharePostSheet({
return () => {
cancelled = true;
};
- }, [open, firstImage]);
+ }, [open, firstImage, post.id]);
- // Canonical share payload — quote, reference, link. Built lazily because
- // shortUrl arrives async; while loading we render a spinner instead.
+ // 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 "";
- return `“${post.caption}”\n\n— ${reference}\n\n${shortUrl}`;
- }, [post.caption, reference, shortUrl]);
+ 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");
@@ -258,10 +322,10 @@ export function SharePostSheet({
// (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") {
+ if (imageFetchError === "network") {
toast(
- "Couldn't load image (CORS) — sharing text only. Check R2 CORS policy.",
- { duration: 4000 },
+ "Couldn't load image for sharing — sharing text only",
+ { duration: 3000 },
);
} else if (imageFetchError) {
toast(`Image not available (${imageFetchError}) — sharing text only`, {
@@ -330,9 +394,19 @@ export function SharePostSheet({
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: `“${post.caption}” — ${reference}`,
+ text: lines.join("\n"),
});
openIntent(`https://t.me/share/url?${params.toString()}`);
},