feat: share post to other apps
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
+17
-14
@@ -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({
|
||||
<AuthGateProvider>
|
||||
<AudioPlayerProvider>
|
||||
<TooltipProvider>
|
||||
<VerifyEmailBanner />
|
||||
<SiteSidebar />
|
||||
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
||||
<SiteHeader />
|
||||
{children}
|
||||
</div>
|
||||
<SiteBottomNav />
|
||||
<TextSelectionToolbar />
|
||||
<Suspense fallback={null}>
|
||||
<TextHighlighter />
|
||||
</Suspense>
|
||||
<FloatingPlayer />
|
||||
<PageBookmarks />
|
||||
<Toaster />
|
||||
<SharePostProvider>
|
||||
<VerifyEmailBanner />
|
||||
<SiteSidebar />
|
||||
<div className="flex-1 flex flex-col md:pl-16 pb-16 md:pb-0">
|
||||
<SiteHeader />
|
||||
{children}
|
||||
</div>
|
||||
<SiteBottomNav />
|
||||
<TextSelectionToolbar />
|
||||
<Suspense fallback={null}>
|
||||
<TextHighlighter />
|
||||
</Suspense>
|
||||
<FloatingPlayer />
|
||||
<PageBookmarks />
|
||||
<Toaster />
|
||||
</SharePostProvider>
|
||||
</TooltipProvider>
|
||||
</AudioPlayerProvider>
|
||||
</AuthGateProvider>
|
||||
|
||||
@@ -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<HTMLInputElement>(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.");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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/<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(() => {
|
||||
@@ -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()}`);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user