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 ( -
-