feat: share post to other apps
This commit is contained in:
@@ -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