From 630dfd10dc37386f230b100295cf795f758cbbc0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 2 Mar 2026 00:46:47 -0600 Subject: [PATCH] Extract shared ExternalPostCard for Bluesky and Mastodon embeds Introduce ExternalPostData as a shared shape that both useBlueskyPost and the new useMastodonPost hook normalize into. Both BlueskyEmbed and MastodonEmbed are now thin wrappers that fetch data from their respective APIs and render via the shared ExternalPostCard component. MastodonEmbed now fetches from the instance's public API instead of using an iframe, matching the native card treatment already applied to Bluesky. Both embeds have built-in /i/ navigation so the DiscussBar is no longer rendered for them. --- src/components/BlueskyEmbed.tsx | 227 +++---------------------- src/components/ExternalPostCard.tsx | 253 ++++++++++++++++++++++++++++ src/components/LinkEmbed.tsx | 10 +- src/components/MastodonEmbed.tsx | 92 ++++------ src/hooks/useBlueskyPost.ts | 86 +++------- src/hooks/useMastodonPost.ts | 113 +++++++++++++ 6 files changed, 454 insertions(+), 327 deletions(-) create mode 100644 src/components/ExternalPostCard.tsx create mode 100644 src/hooks/useMastodonPost.ts diff --git a/src/components/BlueskyEmbed.tsx b/src/components/BlueskyEmbed.tsx index e39b17c2..4985c4d2 100644 --- a/src/components/BlueskyEmbed.tsx +++ b/src/components/BlueskyEmbed.tsx @@ -1,10 +1,5 @@ -import { useNavigate } from 'react-router-dom'; -import { Heart, MessageCircle, Repeat2 } from 'lucide-react'; - -import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; -import { Skeleton } from '@/components/ui/skeleton'; +import { ExternalPostCard, ExternalPostCardSkeleton } from '@/components/ExternalPostCard'; import { useBlueskyPost } from '@/hooks/useBlueskyPost'; -import { cn } from '@/lib/utils'; interface BlueskyEmbedProps { /** Handle or DID of the post author. */ @@ -14,187 +9,6 @@ interface BlueskyEmbedProps { className?: string; } -/** Format a count for display (e.g. 1234 → "1.2K"). */ -function formatCount(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`; - return String(n); -} - -/** Format a Bluesky ISO date string into a relative or short date. */ -function formatDate(iso: string): string { - const date = new Date(iso); - const now = Date.now(); - const diffSec = Math.floor((now - date.getTime()) / 1000); - - if (diffSec < 60) return `${diffSec}s`; - if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`; - if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`; - if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d`; - if (diffSec < 2592000) return `${Math.floor(diffSec / 604800)}w`; - - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); -} - -/** - * Renders a Bluesky post as a native quote-post card, fetching data from - * the public Bluesky API instead of using an iframe. - */ -export function BlueskyEmbed({ author, rkey, className }: BlueskyEmbedProps) { - const { data: post, isLoading, isError } = useBlueskyPost(author, rkey); - - if (isLoading) { - return ; - } - - if (isError || !post) { - return null; - } - - const navigate = useNavigate(); - const postUrl = `https://bsky.app/profile/${post.author.handle}/post/${rkey}`; - const profileUrl = `https://bsky.app/profile/${post.author.handle}`; - const displayName = post.author.displayName || post.author.handle; - - return ( -
{ - e.stopPropagation(); - navigate(`/i/${encodeURIComponent(postUrl)}`); - }} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - e.stopPropagation(); - navigate(`/i/${encodeURIComponent(postUrl)}`); - } - }} - > - {/* Images */} - {post.images && post.images.length > 0 && ( -
1 ? 'grid grid-cols-2 gap-px' : '', - )}> - {post.images.slice(0, 4).map((img, i) => ( - {img.alt { - (e.currentTarget as HTMLElement).style.display = 'none'; - }} - /> - ))} -
- )} - - {/* External link card (if no images) */} - {!post.images && post.external?.thumb && ( -
- { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - - {/* Post content */} -
- {/* Author row */} -
- - - - - - @{post.author.handle} - - - - · {formatDate(post.createdAt)} - -
- - {/* Text content */} - {post.text && ( -

- {post.text} -

- )} - - {/* External link title (shown as inline context if text also exists) */} - {post.external && post.external.title && ( -
- {post.external.title} -
- )} - - {/* Interaction stats */} -
- - - {formatCount(post.replyCount)} - - - - {formatCount(post.repostCount + post.quoteCount)} - - - - {formatCount(post.likeCount)} - - - {/* Bluesky branding */} - - - -
-
-
- ); -} - /** Bluesky butterfly logo as an inline SVG. */ function BlueskyLogo({ className }: { className?: string }) { return ( @@ -210,26 +24,25 @@ function BlueskyLogo({ className }: { className?: string }) { ); } -function BlueskyEmbedSkeleton({ className }: { className?: string }) { +/** + * Renders a Bluesky post as a native quote-post card, fetching data from + * the public Bluesky API. + */ +export function BlueskyEmbed({ author, rkey, className }: BlueskyEmbedProps) { + const { data: post, isLoading, isError } = useBlueskyPost(author, rkey); + + if (isLoading) { + return ; + } + + if (isError || !post) { + return null; + } + return ( -
-
-
- - - - -
-
- - -
-
- - - -
-
-
+ }} + className={className} + /> ); } diff --git a/src/components/ExternalPostCard.tsx b/src/components/ExternalPostCard.tsx new file mode 100644 index 00000000..1ded5338 --- /dev/null +++ b/src/components/ExternalPostCard.tsx @@ -0,0 +1,253 @@ +import { type ReactNode } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Heart, MessageCircle, Repeat2 } from 'lucide-react'; + +import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; +import { Skeleton } from '@/components/ui/skeleton'; +import { cn } from '@/lib/utils'; + +/** A single image attachment. */ +export interface ExternalImage { + thumb: string; + alt: string; +} + +/** An external link card. */ +export interface ExternalExternal { + title: string; + thumb?: string; +} + +/** + * Shared shape for a social post from any external platform + * (Bluesky, Mastodon, etc.). + */ +export interface ExternalPostData { + /** Display name of the post author. */ + displayName: string; + /** Handle / username (without leading @). */ + handle: string; + /** Avatar URL. */ + avatar?: string; + /** Plaintext content of the post. */ + text: string; + /** ISO-8601 creation date. */ + createdAt: string; + /** Canonical URL of the post on the source platform. */ + postUrl: string; + /** Canonical URL of the author's profile on the source platform. */ + profileUrl: string; + /** Reply count. */ + replyCount: number; + /** Repost / boost count (includes quotes if applicable). */ + repostCount: number; + /** Like / favourite count. */ + likeCount: number; + /** Image attachments. */ + images?: ExternalImage[]; + /** External link card (when there are no images). */ + external?: ExternalExternal; + /** Small branding icon rendered in the bottom-right. */ + brandIcon?: ReactNode; +} + +/** Format a count for display (e.g. 1234 → "1.2K"). */ +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}K`; + return String(n); +} + +/** Format an ISO date string into a relative or short date. */ +function formatDate(iso: string): string { + const date = new Date(iso); + const now = Date.now(); + const diffSec = Math.floor((now - date.getTime()) / 1000); + + if (diffSec < 60) return `${diffSec}s`; + if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`; + if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`; + if (diffSec < 604800) return `${Math.floor(diffSec / 86400)}d`; + if (diffSec < 2592000) return `${Math.floor(diffSec / 604800)}w`; + + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +interface ExternalPostCardProps { + post: ExternalPostData; + className?: string; +} + +/** + * Renders an external social post as a native quote-post card. + * + * Clicking the card body navigates to `/i/{postUrl}`. + * Clicking the avatar or display name navigates to `/i/{profileUrl}`. + */ +export function ExternalPostCard({ post, className }: ExternalPostCardProps) { + const navigate = useNavigate(); + + return ( +
{ + e.stopPropagation(); + navigate(`/i/${encodeURIComponent(post.postUrl)}`); + }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + e.stopPropagation(); + navigate(`/i/${encodeURIComponent(post.postUrl)}`); + } + }} + > + {/* Images */} + {post.images && post.images.length > 0 && ( +
1 ? 'grid grid-cols-2 gap-px' : '', + )}> + {post.images.slice(0, 4).map((img, i) => ( + {img.alt { + (e.currentTarget as HTMLElement).style.display = 'none'; + }} + /> + ))} +
+ )} + + {/* External link card (if no images) */} + {!post.images && post.external?.thumb && ( +
+ { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ )} + + {/* Post content */} +
+ {/* Author row */} +
+ + + + + + @{post.handle} + + + + · {formatDate(post.createdAt)} + +
+ + {/* Text content */} + {post.text && ( +

+ {post.text} +

+ )} + + {/* External link title */} + {post.external && post.external.title && ( +
+ {post.external.title} +
+ )} + + {/* Interaction stats */} +
+ + + {formatCount(post.replyCount)} + + + + {formatCount(post.repostCount)} + + + + {formatCount(post.likeCount)} + + + {/* Platform branding */} + {post.brandIcon && ( + + {post.brandIcon} + + )} +
+
+
+ ); +} + +export function ExternalPostCardSkeleton({ className }: { className?: string }) { + return ( +
+
+
+ + + + +
+
+ + +
+
+ + + +
+
+
+ ); +} diff --git a/src/components/LinkEmbed.tsx b/src/components/LinkEmbed.tsx index ab6d6023..20e21464 100644 --- a/src/components/LinkEmbed.tsx +++ b/src/components/LinkEmbed.tsx @@ -37,8 +37,8 @@ interface LinkEmbedProps { * Unified link embed component. Given a URL, renders the appropriate embed: * - YouTube URLs → `YouTubeEmbed` (click-to-play facade) * - Twitter/X tweet URLs → `TweetEmbed` (iframe embed) - * - Bluesky post URLs → `BlueskyEmbed` (iframe embed) - * - Mastodon post URLs → `MastodonEmbed` (iframe embed) + * - Bluesky post URLs → `BlueskyEmbed` (native card via Bluesky API) + * - Mastodon post URLs → `MastodonEmbed` (native card via Mastodon API) * - Everything else → `LinkPreview` (OEmbed link preview card) */ export function LinkEmbed({ url, className, showDiscuss = true, hideImage }: LinkEmbedProps) { @@ -56,9 +56,11 @@ export function LinkEmbed({ url, className, showDiscuss = true, hideImage }: Lin } else if (tweetId) { embed = ; } else if (blueskyPost) { - embed = ; + // BlueskyEmbed has built-in /i/ navigation, no DiscussBar needed + return ; } else if (mastodonUrl) { - embed = ; + // MastodonEmbed has built-in /i/ navigation, no DiscussBar needed + return ; } else if (spotifyEmbed) { embed = ; } else if (redditUrl) { diff --git a/src/components/MastodonEmbed.tsx b/src/components/MastodonEmbed.tsx index 1ec90f54..f58e52ff 100644 --- a/src/components/MastodonEmbed.tsx +++ b/src/components/MastodonEmbed.tsx @@ -1,6 +1,5 @@ -import { useEffect, useRef } from 'react'; - -import { cn } from '@/lib/utils'; +import { ExternalPostCard, ExternalPostCardSkeleton } from '@/components/ExternalPostCard'; +import { useMastodonPost } from '@/hooks/useMastodonPost'; interface MastodonEmbedProps { /** Full URL of the Mastodon post. */ @@ -8,58 +7,41 @@ interface MastodonEmbedProps { className?: string; } -/** - * Renders a Mastodon post embed using a direct iframe to the instance's - * embed endpoint (`{postUrl}/embed`). - * - * Listens for `setHeight` postMessage events from the embed to auto-size - * the iframe to fit the post content. - */ -export function MastodonEmbed({ url, className }: MastodonEmbedProps) { - const iframeRef = useRef(null); - - const origin = (() => { - try { - return new URL(url).origin; - } catch { - return ''; - } - })(); - - useEffect(() => { - if (!origin) return; - - const handleMessage = (e: MessageEvent) => { - if (e.origin !== origin) return; - if (!iframeRef.current || e.source !== iframeRef.current.contentWindow) return; - - const data = e.data; - if (typeof data !== 'object' || !data) return; - - if (data.type === 'setHeight' && typeof data.height === 'number' && data.height > 0) { - iframeRef.current.style.height = `${data.height}px`; - } - }; - - window.addEventListener('message', handleMessage); - return () => window.removeEventListener('message', handleMessage); - }, [origin]); - - const embedUrl = `${url}/embed`; - +/** Mastodon logo as an inline SVG. */ +function MastodonLogo({ className }: { className?: string }) { return ( -
-