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
+17 -1
View File
@@ -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.");
+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 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()}`);
},