feat: share post to other apps

This commit is contained in:
gosangam
2026-05-31 08:44:29 +05:30
parent 88f1b08675
commit 5506afa758
5 changed files with 276 additions and 36 deletions
+78
View File
@@ -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 <img> 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/<id>
* 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<PostShape>(`/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 });
}
}
+3
View File
@@ -15,6 +15,7 @@ import { AuthProvider } from "@/components/auth/auth-provider";
import { AuthGateProvider } from "@/components/auth/auth-gate"; import { AuthGateProvider } from "@/components/auth/auth-gate";
import { VerifyEmailBanner } from "@/components/auth/verify-email-banner"; import { VerifyEmailBanner } from "@/components/auth/verify-email-banner";
import { PageBookmarks } from "@/components/social/page-bookmarks"; import { PageBookmarks } from "@/components/social/page-bookmarks";
import { SharePostProvider } from "@/components/social/share-post-provider";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import "./globals.css"; import "./globals.css";
@@ -77,6 +78,7 @@ export default function RootLayout({
<AuthGateProvider> <AuthGateProvider>
<AudioPlayerProvider> <AudioPlayerProvider>
<TooltipProvider> <TooltipProvider>
<SharePostProvider>
<VerifyEmailBanner /> <VerifyEmailBanner />
<SiteSidebar /> <SiteSidebar />
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0"> <div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
@@ -91,6 +93,7 @@ export default function RootLayout({
<FloatingPlayer /> <FloatingPlayer />
<PageBookmarks /> <PageBookmarks />
<Toaster /> <Toaster />
</SharePostProvider>
</TooltipProvider> </TooltipProvider>
</AudioPlayerProvider> </AudioPlayerProvider>
</AuthGateProvider> </AuthGateProvider>
+17 -1
View File
@@ -17,6 +17,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Image from "next/image"; import Image from "next/image";
import { X, ImagePlus, Trash2, Send } from "lucide-react"; import { X, ImagePlus, Trash2, Send } from "lucide-react";
import { toast } from "sonner";
import { import {
Drawer, Drawer,
DrawerContent, DrawerContent,
@@ -26,6 +27,7 @@ import {
import { posts, SocialError } from "@/lib/social"; import { posts, SocialError } from "@/lib/social";
import { buildHighlightUrl } from "@/lib/text-highlight"; import { buildHighlightUrl } from "@/lib/text-highlight";
import { getSourceReference } from "@/lib/source-reference"; import { getSourceReference } from "@/lib/source-reference";
import { useSharePost } from "@/components/social/share-post-provider";
const MAX_IMAGES = 5; const MAX_IMAGES = 5;
const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif"; const PRESS_IMAGE_TYPES = "image/png,image/jpeg,image/webp,image/gif";
@@ -44,6 +46,7 @@ export function ComposePost({
onPosted?: () => void; onPosted?: () => void;
}) { }) {
const router = useRouter(); const router = useRouter();
const { openShareSheet } = useSharePost();
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const [images, setImages] = useState<{ url: string; uploading: boolean }[]>( const [images, setImages] = useState<{ url: string; uploading: boolean }[]>(
[], [],
@@ -108,7 +111,7 @@ export function ComposePost({
: typeof document !== "undefined" : typeof document !== "undefined"
? document.title ? document.title
: ""; : "";
await posts.create({ const newPost = await posts.create({
caption: selectedText, caption: selectedText,
source_url: sourceURL, source_url: sourceURL,
source_title: sourceTitle, source_title: sourceTitle,
@@ -119,6 +122,19 @@ export function ComposePost({
onPosted?.(); onPosted?.();
onClose(); onClose();
router.refresh(); 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) { } catch (err) {
if (err instanceof SocialError && err.status === 401) { if (err instanceof SocialError && err.status === 401) {
setError("Please log in to post."); setError("Please log in to post.");
+69
View File
@@ -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<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 openShareSheet = useCallback((post: Post) => {
setActivePost(post);
}, []);
return (
<SharePostContext.Provider value={{ openShareSheet }}>
{children}
{activePost ? (
<SharePostSheet
open={true}
onClose={() => setActivePost(null)}
post={activePost}
isMobile={isMobile}
/>
) : null}
</SharePostContext.Provider>
);
}
+95 -21
View File
@@ -65,7 +65,10 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Post } from "@/lib/social"; import type { Post } from "@/lib/social";
import { createShortLink } from "@/lib/share"; 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"; import { useAuth } from "@/components/auth/auth-provider";
export function SharePostSheet({ export function SharePostSheet({
@@ -79,7 +82,48 @@ export function SharePostSheet({
post: Post; post: Post;
isMobile: boolean; 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); 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 firstImage = post.images?.[0]?.url ?? null;
const { user } = useAuth(); const { user } = useAuth();
// The Send button is only rendered for owners, so user is guaranteed // 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 // fetch INSIDE the click handler burns the activation window and
// iOS rejects the share with NotAllowedError. // iOS rejects the share with NotAllowedError.
// //
// Failures (CORS-blocked fetch, 4xx, network error) are remembered // We hit our same-origin proxy (/api/share-image/<id>) rather than
// in imageFetchError so the click handler can toast a useful // 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 // explanation. We don't toast at fetch time because the user hasn't
// committed to sharing yet — that would feel like noise. // committed to sharing yet — that would feel like noise.
useEffect(() => { useEffect(() => {
@@ -174,15 +226,16 @@ export function SharePostSheet({
let cancelled = false; let cancelled = false;
setShareableFile(null); setShareableFile(null);
setImageFetchError(null); setImageFetchError(null);
const proxyUrl = `/api/share-image/${encodeURIComponent(post.id)}`;
(async () => { (async () => {
try { try {
const res = await fetch(firstImage, { mode: "cors" }); const res = await fetch(proxyUrl);
if (cancelled) return; if (cancelled) return;
if (!res.ok) { if (!res.ok) {
setImageFetchError(`HTTP ${res.status}`); setImageFetchError(`HTTP ${res.status}`);
console.warn( console.warn(
`[share] image pre-fetch returned ${res.status} for`, `[share] image proxy returned ${res.status} for post`,
firstImage, post.id,
); );
return; return;
} }
@@ -195,13 +248,10 @@ export function SharePostSheet({
setShareableFile(file); setShareableFile(file);
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
setImageFetchError("cors"); setImageFetchError("network");
// 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( console.warn(
"[share] image pre-fetch failed (likely CORS) for", "[share] image proxy fetch failed for post",
firstImage, post.id,
err, err,
); );
} }
@@ -209,14 +259,28 @@ export function SharePostSheet({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [open, firstImage]); }, [open, firstImage, post.id]);
// Canonical share payload — quote, reference, link. Built lazily because // Canonical share payload — quote, "Source:" delimiter, structured
// shortUrl arrives async; while loading we render a spinner instead. // 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(() => { const shareText = useMemo(() => {
if (!shortUrl) return ""; if (!shortUrl) return "";
return `“${post.caption}”\n\n— ${reference}\n\n${shortUrl}`; const lines: string[] = [`“${post.caption}”`, "", "Source:"];
}, [post.caption, reference, shortUrl]); 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) { function openIntent(url: string) {
window.open(url, "_blank", "noopener,noreferrer"); window.open(url, "_blank", "noopener,noreferrer");
@@ -258,10 +322,10 @@ export function SharePostSheet({
// (or failed). Surface the reason so the user knows why the // (or failed). Surface the reason so the user knows why the
// share is text-only — at the moment they actually share, not // share is text-only — at the moment they actually share, not
// earlier when the sheet first opened. // earlier when the sheet first opened.
if (imageFetchError === "cors") { if (imageFetchError === "network") {
toast( toast(
"Couldn't load image (CORS) — sharing text only. Check R2 CORS policy.", "Couldn't load image for sharing — sharing text only",
{ duration: 4000 }, { duration: 3000 },
); );
} else if (imageFetchError) { } else if (imageFetchError) {
toast(`Image not available (${imageFetchError}) — sharing text only`, { toast(`Image not available (${imageFetchError}) — sharing text only`, {
@@ -330,9 +394,19 @@ export function SharePostSheet({
onClick: () => { onClick: () => {
// Telegram's share URL splits text + url so the link is // Telegram's share URL splits text + url so the link is
// attached as a separate entity (better preview rendering). // 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({ const params = new URLSearchParams({
url: shortUrl ?? "", url: shortUrl ?? "",
text: `“${post.caption}” — ${reference}`, text: lines.join("\n"),
}); });
openIntent(`https://t.me/share/url?${params.toString()}`); openIntent(`https://t.me/share/url?${params.toString()}`);
}, },