feat: share post to other apps
This commit is contained in:
@@ -3,13 +3,19 @@
|
||||
/**
|
||||
* PostActionsFooter — the icon-button row at the bottom of a post card.
|
||||
*
|
||||
* Left cluster: Like · Comment · Reshare
|
||||
* Right cluster: Save to Collection (folder-plus)
|
||||
* Left cluster (non-owner): Like · Comment · Reshare
|
||||
* Left cluster (owner): Like · Comment · Send
|
||||
* Right cluster: Save to Collection (folder-plus)
|
||||
*
|
||||
* Like / Comment / Reshare are placeholders for now — they fire a
|
||||
* "coming soon" toast on click. Save to Collection mounts the existing
|
||||
* CollectionsDrawer in `existingPostId` mode so the post is attached
|
||||
* directly without creating a duplicate.
|
||||
* Reshare and Send are *mutually exclusive* — once the post is on your
|
||||
* account there's nothing left to reshare onto it, so the Reshare slot
|
||||
* is replaced by Send (external share via SharePostSheet). Ownership is
|
||||
* decided by `user.id === post.user_id`, which the BFF already includes
|
||||
* on every post fetch — no extra round-trip needed.
|
||||
*
|
||||
* Like / Comment / Reshare are placeholders today (toast on click).
|
||||
* Save to Collection mounts the existing CollectionsDrawer in
|
||||
* `existingPostId` mode so the post attaches without a duplicate.
|
||||
*
|
||||
* Auth gating is handled per-action via `useAuthGate` — anonymous users
|
||||
* see the in-place auth modal before the underlying action runs.
|
||||
@@ -21,29 +27,59 @@ import {
|
||||
HiOutlineChatBubbleOvalLeft,
|
||||
HiOutlineFolderPlus,
|
||||
HiOutlineHeart,
|
||||
HiOutlinePaperAirplane,
|
||||
} from "react-icons/hi2";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { posts as postsApi, SocialError, type Post } from "@/lib/social";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
import { useAuthGate } from "@/components/auth/auth-gate";
|
||||
import { useMediaQuery } from "@/components/selection-toolbar/shared";
|
||||
import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer";
|
||||
import { SharePostSheet } from "./share-post-sheet";
|
||||
|
||||
// All four icons come from the Heroicons v2 outline family (react-icons
|
||||
// All icons come from the Heroicons v2 outline family (react-icons
|
||||
// `hi2` namespace), so they share one design language — uniform stroke
|
||||
// weight, matching corner radius, matching optical sizing. No more
|
||||
// Lucide cross-icon visual drift.
|
||||
// weight, matching corner radius, matching optical sizing.
|
||||
const ICON_CLASS = "size-[1.375rem]";
|
||||
|
||||
export function PostActionsFooter({ postID }: { postID: string }) {
|
||||
export function PostActionsFooter({ post }: { post: Post }) {
|
||||
const { user } = useAuth();
|
||||
const { requireAuth } = useAuthGate();
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
const [collectionsOpen, setCollectionsOpen] = useState(false);
|
||||
const [shareOpen, setShareOpen] = useState(false);
|
||||
// Optimistic local mirror of post.viewer_has_reshared so the swap
|
||||
// happens the instant the user taps Reshare. Reconciles to the
|
||||
// server's value via rollback if the API call fails.
|
||||
const [reshared, setReshared] = useState<boolean>(
|
||||
!!post.viewer_has_reshared,
|
||||
);
|
||||
|
||||
const isOwner = !!user && user.id === post.user_id;
|
||||
// Owners and resharers both see Send — the post is "on their account"
|
||||
// either way, so the action they need is external share, not reshare.
|
||||
const showSend = isOwner || reshared;
|
||||
|
||||
async function withAuth(fn: () => void | Promise<void>) {
|
||||
if (!(await requireAuth())) return;
|
||||
await fn();
|
||||
}
|
||||
|
||||
async function onReshare() {
|
||||
// Optimistic flip first so the UI feels instant; revert on error.
|
||||
setReshared(true);
|
||||
try {
|
||||
await postsApi.reshare(post.id);
|
||||
toast.success("Reshared to your account", { duration: 2000 });
|
||||
} catch (err) {
|
||||
setReshared(false);
|
||||
const msg =
|
||||
err instanceof SocialError ? err.message : "Could not reshare";
|
||||
toast.error(msg, { duration: 3000 });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between px-2">
|
||||
@@ -66,15 +102,23 @@ export function PostActionsFooter({ postID }: { postID: string }) {
|
||||
}
|
||||
icon={<HiOutlineChatBubbleOvalLeft className={ICON_CLASS} />}
|
||||
/>
|
||||
<ActionButton
|
||||
label="Reshare"
|
||||
onClick={() =>
|
||||
withAuth(() => {
|
||||
toast("Resharing is coming soon", { duration: 2000 });
|
||||
})
|
||||
}
|
||||
icon={<HiOutlineArrowPath className={ICON_CLASS} />}
|
||||
/>
|
||||
{/* Reshare for posts not yet on your account, Send for posts
|
||||
that are — authored OR previously reshared. Mutually
|
||||
exclusive: the row stays three icons regardless of viewer
|
||||
state. */}
|
||||
{showSend ? (
|
||||
<ActionButton
|
||||
label="Send"
|
||||
onClick={() => setShareOpen(true)}
|
||||
icon={<HiOutlinePaperAirplane className={ICON_CLASS} />}
|
||||
/>
|
||||
) : (
|
||||
<ActionButton
|
||||
label="Reshare"
|
||||
onClick={() => withAuth(onReshare)}
|
||||
icon={<HiOutlineArrowPath className={ICON_CLASS} />}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ActionButton
|
||||
label="Save to collection"
|
||||
@@ -87,8 +131,17 @@ export function PostActionsFooter({ postID }: { postID: string }) {
|
||||
open={collectionsOpen}
|
||||
onClose={() => setCollectionsOpen(false)}
|
||||
isMobile={isMobile}
|
||||
existingPostId={postID}
|
||||
existingPostId={post.id}
|
||||
/>
|
||||
|
||||
{showSend ? (
|
||||
<SharePostSheet
|
||||
open={shareOpen}
|
||||
onClose={() => setShareOpen(false)}
|
||||
post={post}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function PostCard({
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="mt-3 pt-2 border-t border-border">
|
||||
<PostActionsFooter postID={post.id} />
|
||||
<PostActionsFooter post={post} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ export function PostScrollCard({
|
||||
{/* Action footer — like / comment / reshare on the left, add to
|
||||
collection on the right. */}
|
||||
<div className="pt-1 pb-2 border-t border-border">
|
||||
<PostActionsFooter postID={post.id} />
|
||||
<PostActionsFooter post={post} />
|
||||
</div>
|
||||
|
||||
{onUpdated ? (
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* SharePostSheet — share a quote you've posted to external apps.
|
||||
*
|
||||
* Modal on desktop (Drawer direction="right"), drawer on mobile
|
||||
* (direction="bottom"). Same UI primitive CollectionsDrawer uses.
|
||||
*
|
||||
* What we share:
|
||||
*
|
||||
* "<full caption>"
|
||||
*
|
||||
* — <reference, e.g. Bhagavad-gītā 2.13>
|
||||
*
|
||||
* <shortened URL pointing to the POST on your profile feed>
|
||||
*
|
||||
* The link target is `/u/<handle>/posts#post-<id>` — the Instagram-style
|
||||
* scroll view anchored to this specific post — rather than the raw
|
||||
* source URL. Recipients land on the card (image, your caption, your
|
||||
* byline) and can click through to the original passage from there.
|
||||
* This matches how every major social product handles "share a post":
|
||||
* the share URL is the post, not the underlying resource.
|
||||
*
|
||||
* URL is run through createShortLink (same /share/<code> short-code
|
||||
* service the selection-toolbar uses) so the payload stays compact and
|
||||
* the click-through is trackable. The shortener's ValidatePath tolerates
|
||||
* fragments, and the destination pages' existing hash-anchor effect
|
||||
* scrolls to the post on mount — no other code needs to know.
|
||||
*
|
||||
* Sharing channels:
|
||||
*
|
||||
* • WhatsApp / Telegram / X / Facebook — official web share intents.
|
||||
* We open them in a new window; the user finishes in that app.
|
||||
* • Instagram — has no web share intent. We copy the payload + toast
|
||||
* "paste in Instagram" so the user can switch and paste.
|
||||
* • Email — mailto: with prefilled subject + body.
|
||||
* • Copy — drops the full payload on the clipboard.
|
||||
* • More — only when navigator.share is supported AND we're on mobile.
|
||||
* Tries to attach the first image as a File via navigator.canShare;
|
||||
* falls back to text-only if R2 CORS blocks the blob fetch.
|
||||
*
|
||||
* Images travel only via the native-share path. None of the open web
|
||||
* share intents accept an image URL directly — they pull OpenGraph
|
||||
* metadata from the linked page instead, which we don't currently
|
||||
* generate per-post. That's the "bonus" the brief allows for.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
FaWhatsapp,
|
||||
FaTelegram,
|
||||
FaXTwitter,
|
||||
FaFacebook,
|
||||
FaInstagram,
|
||||
FaEnvelope,
|
||||
} from "react-icons/fa6";
|
||||
import { Copy, Loader2, Share2 } from "lucide-react";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Post } from "@/lib/social";
|
||||
import { createShortLink } from "@/lib/share";
|
||||
import { displaySourceLabel } from "@/lib/source-reference";
|
||||
import { useAuth } from "@/components/auth/auth-provider";
|
||||
|
||||
export function SharePostSheet({
|
||||
open,
|
||||
onClose,
|
||||
post,
|
||||
isMobile,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
post: Post;
|
||||
isMobile: boolean;
|
||||
}) {
|
||||
const reference = displaySourceLabel(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
|
||||
// non-null here; we still defensively fall back via `?? ""` rather than
|
||||
// crashing if the auth context blips during a re-render.
|
||||
const authorHandle = post.author_handle ?? user?.handle ?? "";
|
||||
|
||||
const [shortUrl, setShortUrl] = useState<string | null>(null);
|
||||
const [shortening, setShortening] = useState(false);
|
||||
// navigator.share only exists on a subset of browsers (mainly mobile
|
||||
// Safari/Chrome and some desktop Safari builds). Capability-check once
|
||||
// on mount; combined with `isMobile` to gate the "More" tile so the
|
||||
// affordance only appears where it can do something useful.
|
||||
const [nativeShareSupported, setNativeShareSupported] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setNativeShareSupported(
|
||||
typeof navigator !== "undefined" &&
|
||||
typeof navigator.share === "function",
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Shorten the post's anchor URL on the author's feed when the sheet
|
||||
// opens. The path takes the form `/u/<handle>/posts#post-<id>`; the
|
||||
// BFF's ValidatePath tolerates fragments, and the destination page
|
||||
// already has a hash-anchor effect that scrolls to the post once the
|
||||
// feed loads, so the recipient lands on the right card automatically.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (!authorHandle) return;
|
||||
let cancelled = false;
|
||||
setShortening(true);
|
||||
(async () => {
|
||||
try {
|
||||
const origin = window.location.origin;
|
||||
const longPath = `/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`;
|
||||
const code = await createShortLink(longPath);
|
||||
if (cancelled) return;
|
||||
if (!code) {
|
||||
// Shorten failed — use the long URL so the share payload at
|
||||
// least carries SOMETHING clickable.
|
||||
setShortUrl(new URL(longPath, origin).toString());
|
||||
return;
|
||||
}
|
||||
setShortUrl(new URL(`/share/${code}`, origin).toString());
|
||||
} catch {
|
||||
// Should be unreachable — createShortLink swallows its own
|
||||
// failures. Fall back to a non-shortened long URL.
|
||||
if (!cancelled) {
|
||||
setShortUrl(
|
||||
new URL(
|
||||
`/u/${encodeURIComponent(authorHandle)}/posts#post-${post.id}`,
|
||||
window.location.origin,
|
||||
).toString(),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setShortening(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, authorHandle, post.id]);
|
||||
|
||||
// Canonical share payload — quote, reference, link. Built lazily because
|
||||
// shortUrl arrives async; while loading we render a spinner instead.
|
||||
const shareText = useMemo(() => {
|
||||
if (!shortUrl) return "";
|
||||
return `“${post.caption}”\n\n— ${reference}\n\n${shortUrl}`;
|
||||
}, [post.caption, reference, shortUrl]);
|
||||
|
||||
function openIntent(url: string) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function copyText(text: string, successMsg = "Copied") {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(successMsg);
|
||||
} catch {
|
||||
toast.error("Could not copy");
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
async function shareNative() {
|
||||
if (!nativeShareSupported) return;
|
||||
const payload: ShareData = {
|
||||
title: reference,
|
||||
text: shareText,
|
||||
};
|
||||
// Bonus: attach the first image as a File so the recipient gets
|
||||
// the visual. Requires (a) R2 to allow CORS GET on the public URL
|
||||
// and (b) navigator.canShare to accept the File. If either fails
|
||||
// we silently degrade to text-only — the quote + URL still go
|
||||
// through, which is the contract.
|
||||
if (firstImage) {
|
||||
try {
|
||||
const res = await fetch(firstImage, { mode: "cors" });
|
||||
if (res.ok) {
|
||||
const blob = await res.blob();
|
||||
const ext = (blob.type.split("/")[1] || "jpg").toLowerCase();
|
||||
const file = new File([blob], `share.${ext}`, {
|
||||
type: blob.type || "image/jpeg",
|
||||
});
|
||||
if (
|
||||
typeof navigator.canShare === "function" &&
|
||||
navigator.canShare({ files: [file] })
|
||||
) {
|
||||
payload.files = [file];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CORS-blocked or network error — proceed without files.
|
||||
}
|
||||
}
|
||||
try {
|
||||
await navigator.share(payload);
|
||||
onClose();
|
||||
} catch {
|
||||
// User cancelled the share sheet; leave the drawer open so they
|
||||
// can pick another target.
|
||||
}
|
||||
}
|
||||
|
||||
// Channel tiles — kept in a single declarative list so the JSX is just
|
||||
// a map. Each tile's onClick closes the sheet to give the user a clean
|
||||
// "done" signal.
|
||||
const targets: ShareTarget[] = [
|
||||
{
|
||||
key: "whatsapp",
|
||||
label: "WhatsApp",
|
||||
brandClass: "text-[#25D366]",
|
||||
icon: <FaWhatsapp className="size-6" />,
|
||||
onClick: () =>
|
||||
openIntent(`https://wa.me/?text=${encodeURIComponent(shareText)}`),
|
||||
},
|
||||
{
|
||||
key: "telegram",
|
||||
label: "Telegram",
|
||||
brandClass: "text-[#229ED9]",
|
||||
icon: <FaTelegram className="size-6" />,
|
||||
onClick: () => {
|
||||
// Telegram's share URL splits text + url so the link is
|
||||
// attached as a separate entity (better preview rendering).
|
||||
const params = new URLSearchParams({
|
||||
url: shortUrl ?? "",
|
||||
text: `“${post.caption}” — ${reference}`,
|
||||
});
|
||||
openIntent(`https://t.me/share/url?${params.toString()}`);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "x",
|
||||
label: "X",
|
||||
brandClass: "text-foreground",
|
||||
icon: <FaXTwitter className="size-6" />,
|
||||
onClick: () =>
|
||||
openIntent(
|
||||
`https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "facebook",
|
||||
label: "Facebook",
|
||||
brandClass: "text-[#1877F2]",
|
||||
icon: <FaFacebook className="size-6" />,
|
||||
// Facebook's web sharer ignores prefilled text for non-FB-app
|
||||
// shares, so we send only the URL — FB scrapes OG tags from the
|
||||
// linked page. The user can paste the rest before posting.
|
||||
onClick: () =>
|
||||
openIntent(
|
||||
`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shortUrl ?? "")}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "instagram",
|
||||
label: "Instagram",
|
||||
brandClass: "text-[#E1306C]",
|
||||
icon: <FaInstagram className="size-6" />,
|
||||
// Instagram has no public web share intent. Copy the payload and
|
||||
// tell the user where it'll land.
|
||||
onClick: () =>
|
||||
copyText(shareText, "Copied — open Instagram and paste"),
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "Email",
|
||||
brandClass: "text-muted-foreground",
|
||||
icon: <FaEnvelope className="size-6" />,
|
||||
onClick: () => {
|
||||
// mailto: drives the user's default mail client. Some clients
|
||||
// crop very long bodies; the user can edit before sending.
|
||||
const href =
|
||||
`mailto:?subject=${encodeURIComponent(reference)}` +
|
||||
`&body=${encodeURIComponent(shareText)}`;
|
||||
window.location.href = href;
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
label: "Copy",
|
||||
brandClass: "text-muted-foreground",
|
||||
icon: <Copy className="size-6" />,
|
||||
onClick: () => copyText(shareText),
|
||||
},
|
||||
];
|
||||
|
||||
const direction = isMobile ? "bottom" : "right";
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
direction={direction}
|
||||
>
|
||||
<DrawerContent direction={direction} className="bg-card">
|
||||
<DrawerHeader className="text-left border-b border-border pb-4">
|
||||
<DrawerTitle>Share post</DrawerTitle>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
“{post.caption.slice(0, 140)}
|
||||
{post.caption.length > 140 ? "…" : ""}”
|
||||
</p>
|
||||
<p className="text-[10px] uppercase tracking-[0.2em] text-muted-foreground/80 mt-1">
|
||||
{reference}
|
||||
</p>
|
||||
</DrawerHeader>
|
||||
|
||||
<div className="p-4 sm:p-6 overflow-y-auto">
|
||||
{shortening ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 gap-3 sm:grid-cols-5">
|
||||
{targets.map((t) => (
|
||||
<ShareTile key={t.key} target={t} />
|
||||
))}
|
||||
{isMobile && nativeShareSupported ? (
|
||||
<ShareTile
|
||||
target={{
|
||||
key: "native",
|
||||
label: "More",
|
||||
brandClass: "text-foreground",
|
||||
icon: <Share2 className="size-6" />,
|
||||
onClick: shareNative,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Pieces ────────────────────────────────────────────────────────────
|
||||
|
||||
type ShareTarget = {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Tailwind text color class applied to the icon — usually the brand's
|
||||
* hex, falling back to text-foreground / text-muted-foreground. */
|
||||
brandClass: string;
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
function ShareTile({ target }: { target: ShareTarget }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={target.onClick}
|
||||
className={cn(
|
||||
"flex flex-col items-center gap-1.5 rounded-lg p-2 sm:p-3",
|
||||
"hover:bg-muted transition-colors",
|
||||
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center justify-center size-12 rounded-full",
|
||||
"bg-muted",
|
||||
target.brandClass,
|
||||
)}
|
||||
>
|
||||
{target.icon}
|
||||
</span>
|
||||
<span className="text-[11px] text-foreground text-center leading-tight">
|
||||
{target.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user