feat: share post to other apps

This commit is contained in:
gosangam
2026-05-31 08:04:46 +05:30
parent 03b8f85e06
commit 336f478f8b
13 changed files with 1290 additions and 82 deletions
+24 -7
View File
@@ -8,18 +8,21 @@
* The action row hosts:
* • "Edit profile" link — only on the owner's own profile (via the
* `editHref` prop set by /u/me layout).
* • ReadingSettings gear — for every signed-in viewer, regardless of
* whose profile they're on. SiteHeader drops its own mobile gear
* for auth users (see comment there) so this is the single source
* of truth for that affordance once you're signed in.
* • Reading-settings gear — for every signed-in viewer. It links to
* /u/me/settings (a focused page that mounts ReadingSettings in
* inline mode) rather than opening a popover here. A popover
* triggered from this centred row tends to spill off the left edge
* on narrow viewports, so the page-navigation pattern matches Edit
* Profile and dodges the problem entirely.
*
* Anonymous visitors don't see this row at all — they read SiteHeader's
* gear instead.
* popover gear instead.
*/
import Image from "next/image";
import Link from "next/link";
import { ReadingSettings } from "@/components/reading-settings";
import { Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import { useAuth } from "@/components/auth/auth-provider";
export function ProfileHeader({
@@ -77,7 +80,21 @@ export function ProfileHeader({
Edit profile
</Link>
) : null}
{showReadingSettings ? <ReadingSettings /> : null}
{showReadingSettings ? (
<Link
href="/u/me/settings"
aria-label="Reading settings"
title="Reading settings"
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"border border-border text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-4" />
</Link>
) : null}
</div>
) : null}
</header>
+82 -48
View File
@@ -235,11 +235,23 @@ type TriggerRenderer = (state: {
* the popover off the bottom of the viewport. */
type Placement = "below-right" | "above-left";
/** How the controls are presented.
*
* - "popover" (default) — the icon trigger + floating panel pattern
* used in SiteHeader/sidebar. Centred triggers on narrow viewports
* can push the panel off-screen, which is why auth users get the
* dedicated settings page instead.
* - "inline" — render the controls flat as part of normal page flow,
* full-width up to a sensible cap. Used by /u/me/settings. */
type Variant = "popover" | "inline";
export function ReadingSettings({
variant = "popover",
renderTrigger,
placement = "below-right",
onOpenChange,
}: {
variant?: Variant;
renderTrigger?: TriggerRenderer;
placement?: Placement;
/** Fires every time the popover opens or closes. Used by the sidebar
@@ -456,6 +468,9 @@ export function ReadingSettings({
visiblePalettes.find((p) => p.id === activePaletteId)?.name ?? "";
if (!mounted) {
// Inline variant: render nothing until mounted so we don't flash a
// misaligned skeleton on the dedicated settings page.
if (variant === "inline") return null;
return (
<Button variant="ghost" size="icon-sm" aria-label="Reading settings">
<Settings className="size-4" />
@@ -463,53 +478,9 @@ export function ReadingSettings({
);
}
const toggle = () => setOpen((v) => !v);
return (
<div className="relative">
{renderTrigger ? (
renderTrigger({ open, toggle, buttonRef })
) : (
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
aria-label="Reading settings"
aria-expanded={open}
onClick={toggle}
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-5 md:size-4" />
</button>
)}
{open && (
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
<div
ref={panelRef}
style={
placement === "above-left" && portalPos
? {
position: "fixed",
left: portalPos.left,
bottom: portalPos.bottom,
}
: undefined
}
className={cn(
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
"max-h-[80vh] overflow-y-auto",
placement === "above-left" && portalPos
? "z-[100]"
: placement === "above-left"
? "absolute bottom-full left-0 mb-2 z-50"
: "absolute top-full right-0 mt-2 z-50",
)}
>
{/* Theme selector */}
const panelInner = (
<>
{/* Theme selector */}
<div className="mb-4">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Theme
@@ -635,7 +606,70 @@ export function ReadingSettings({
</div>
</div>
</div>
</div>
</>
);
// ── Inline variant ──────────────────────────────────────────────────
// Renders the same controls as a flush, full-width card. No popover
// positioning, so it can't drift off-viewport on a narrow screen. Used
// by /u/me/settings.
if (variant === "inline") {
return (
<div className="w-full max-w-xl rounded-lg border border-border bg-card p-4 sm:p-5">
{panelInner}
</div>
);
}
// ── Popover variant (default) ───────────────────────────────────────
const toggle = () => setOpen((v) => !v);
return (
<div className="relative">
{renderTrigger ? (
renderTrigger({ open, toggle, buttonRef })
) : (
<button
ref={buttonRef as React.Ref<HTMLButtonElement>}
aria-label="Reading settings"
aria-expanded={open}
onClick={toggle}
className={cn(
"size-9 inline-flex items-center justify-center rounded-lg",
"text-muted-foreground hover:text-foreground hover:bg-muted",
"transition-colors outline-none",
"focus-visible:ring-2 focus-visible:ring-ring",
)}
>
<Settings className="size-5 md:size-4" />
</button>
)}
{open && (
<MaybePortal portal={placement === "above-left" && portalPos !== null}>
<div
ref={panelRef}
style={
placement === "above-left" && portalPos
? {
position: "fixed",
left: portalPos.left,
bottom: portalPos.bottom,
}
: undefined
}
className={cn(
"w-72 rounded-lg border border-border bg-card shadow-lg p-4",
"max-h-[80vh] overflow-y-auto",
placement === "above-left" && portalPos
? "z-[100]"
: placement === "above-left"
? "absolute bottom-full left-0 mb-2 z-50"
: "absolute top-full right-0 mt-2 z-50",
)}
>
{panelInner}
</div>
</MaybePortal>
)}
</div>
+73 -20
View File
@@ -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}
</>
);
}
+1 -1
View File
@@ -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>
);
+1 -1
View File
@@ -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 ? (
+380
View File
@@ -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>
);
}