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.
This commit is contained in:
Alex Gleason
2026-03-02 00:46:47 -06:00
parent dcf6838574
commit 630dfd10dc
6 changed files with 454 additions and 327 deletions
+20 -207
View File
@@ -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 <BlueskyEmbedSkeleton className={className} />;
}
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 (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
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 && (
<div className={cn(
'w-full overflow-hidden',
post.images.length > 1 ? 'grid grid-cols-2 gap-px' : '',
)}>
{post.images.slice(0, 4).map((img, i) => (
<img
key={i}
src={img.thumb}
alt={img.alt || ''}
className={cn(
'w-full object-cover',
post.images!.length === 1 ? 'max-h-[300px]' : 'h-[150px]',
)}
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
))}
</div>
)}
{/* External link card (if no images) */}
{!post.images && post.external?.thumb && (
<div className="w-full overflow-hidden">
<img
src={post.external.thumb}
alt=""
className="w-full h-[160px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
{/* Post content */}
<div className="px-3 py-2 space-y-1.5">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
navigate(`/i/${encodeURIComponent(profileUrl)}`);
}}
>
<Avatar className="size-5">
<AvatarImage src={post.author.avatar} alt={displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
<button
type="button"
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => {
e.stopPropagation();
navigate(`/i/${encodeURIComponent(profileUrl)}`);
}}
>
{displayName}
</button>
<span className="text-xs text-muted-foreground truncate">
@{post.author.handle}
</span>
<span className="text-xs text-muted-foreground shrink-0">
· {formatDate(post.createdAt)}
</span>
</div>
{/* Text content */}
{post.text && (
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-hidden line-clamp-6">
{post.text}
</p>
)}
{/* External link title (shown as inline context if text also exists) */}
{post.external && post.external.title && (
<div className="text-xs text-muted-foreground truncate">
{post.external.title}
</div>
)}
{/* Interaction stats */}
<div className="flex items-center gap-4 pt-0.5">
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<MessageCircle className="size-3.5" />
{formatCount(post.replyCount)}
</span>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Repeat2 className="size-3.5" />
{formatCount(post.repostCount + post.quoteCount)}
</span>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Heart className="size-3.5" />
{formatCount(post.likeCount)}
</span>
{/* Bluesky branding */}
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
<BlueskyLogo className="size-3.5" />
</span>
</div>
</div>
</div>
);
}
/** 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 <ExternalPostCardSkeleton className={className} />;
}
if (isError || !post) {
return null;
}
return (
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
<div className="px-3 py-2.5 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-10" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-4/5" />
</div>
<div className="flex items-center gap-4 pt-0.5">
<Skeleton className="h-3 w-10" />
<Skeleton className="h-3 w-10" />
<Skeleton className="h-3 w-10" />
</div>
</div>
</div>
<ExternalPostCard
post={{ ...post, brandIcon: <BlueskyLogo className="size-3.5" /> }}
className={className}
/>
);
}
+253
View File
@@ -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 (
<div
className={cn(
'group block rounded-2xl border border-border overflow-hidden',
'hover:bg-secondary/40 transition-colors cursor-pointer',
className,
)}
role="link"
tabIndex={0}
onClick={(e) => {
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 && (
<div className={cn(
'w-full overflow-hidden',
post.images.length > 1 ? 'grid grid-cols-2 gap-px' : '',
)}>
{post.images.slice(0, 4).map((img, i) => (
<img
key={i}
src={img.thumb}
alt={img.alt || ''}
className={cn(
'w-full object-cover',
post.images!.length === 1 ? 'max-h-[300px]' : 'h-[150px]',
)}
loading="lazy"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none';
}}
/>
))}
</div>
)}
{/* External link card (if no images) */}
{!post.images && post.external?.thumb && (
<div className="w-full overflow-hidden">
<img
src={post.external.thumb}
alt=""
className="w-full h-[160px] object-cover"
loading="lazy"
onError={(e) => {
(e.currentTarget.parentElement as HTMLElement).style.display = 'none';
}}
/>
</div>
)}
{/* Post content */}
<div className="px-3 py-2 space-y-1.5">
{/* Author row */}
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
navigate(`/i/${encodeURIComponent(post.profileUrl)}`);
}}
>
<Avatar className="size-5">
<AvatarImage src={post.avatar} alt={post.displayName} />
<AvatarFallback className="bg-primary/20 text-primary text-[10px]">
{post.displayName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
</button>
<button
type="button"
className="text-sm font-semibold truncate hover:underline"
onClick={(e) => {
e.stopPropagation();
navigate(`/i/${encodeURIComponent(post.profileUrl)}`);
}}
>
{post.displayName}
</button>
<span className="text-xs text-muted-foreground truncate">
@{post.handle}
</span>
<span className="text-xs text-muted-foreground shrink-0">
· {formatDate(post.createdAt)}
</span>
</div>
{/* Text content */}
{post.text && (
<p className="text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-hidden line-clamp-6">
{post.text}
</p>
)}
{/* External link title */}
{post.external && post.external.title && (
<div className="text-xs text-muted-foreground truncate">
{post.external.title}
</div>
)}
{/* Interaction stats */}
<div className="flex items-center gap-4 pt-0.5">
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<MessageCircle className="size-3.5" />
{formatCount(post.replyCount)}
</span>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Repeat2 className="size-3.5" />
{formatCount(post.repostCount)}
</span>
<span className="inline-flex items-center gap-1 text-[11px] text-muted-foreground">
<Heart className="size-3.5" />
{formatCount(post.likeCount)}
</span>
{/* Platform branding */}
{post.brandIcon && (
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
{post.brandIcon}
</span>
)}
</div>
</div>
</div>
);
}
export function ExternalPostCardSkeleton({ className }: { className?: string }) {
return (
<div className={cn('rounded-2xl border border-border overflow-hidden', className)}>
<div className="px-3 py-2.5 space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="size-5 rounded-full" />
<Skeleton className="h-3.5 w-24" />
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-10" />
</div>
<div className="space-y-1.5">
<Skeleton className="h-3.5 w-full" />
<Skeleton className="h-3.5 w-4/5" />
</div>
<div className="flex items-center gap-4 pt-0.5">
<Skeleton className="h-3 w-10" />
<Skeleton className="h-3 w-10" />
<Skeleton className="h-3 w-10" />
</div>
</div>
</div>
);
}
+6 -4
View File
@@ -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 = <TweetEmbed tweetId={tweetId} />;
} else if (blueskyPost) {
embed = <BlueskyEmbed author={blueskyPost.author} rkey={blueskyPost.rkey} />;
// BlueskyEmbed has built-in /i/ navigation, no DiscussBar needed
return <BlueskyEmbed author={blueskyPost.author} rkey={blueskyPost.rkey} className={className} />;
} else if (mastodonUrl) {
embed = <MastodonEmbed url={mastodonUrl} />;
// MastodonEmbed has built-in /i/ navigation, no DiscussBar needed
return <MastodonEmbed url={mastodonUrl} className={className} />;
} else if (spotifyEmbed) {
embed = <SpotifyEmbed type={spotifyEmbed.type} id={spotifyEmbed.id} />;
} else if (redditUrl) {
+37 -55
View File
@@ -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<HTMLIFrameElement>(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 (
<div className={cn('overflow-hidden rounded-2xl', className)}>
<iframe
ref={iframeRef}
src={embedUrl}
title="Mastodon post"
className="w-full border-0 rounded-2xl"
style={{ minHeight: 200 }}
scrolling="no"
allowFullScreen
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-popups"
/>
</div>
<svg
className={className}
viewBox="0 0 216.4 232"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
aria-label="Mastodon"
>
<path d="M211.8 139.1c-3.2 16.4-28.6 34.3-57.8 37.8-15.2 1.8-30.2 3.5-46.1 2.8-26.1-1.2-46.7-6.2-46.7-6.2 0 2.5.2 4.9.5 7.2 3.8 28.6 28.4 30.3 51.7 31.1 23.5.7 44.5-5.8 44.5-5.8l1 21.7s-16.5 8.8-45.8 10.5c-16.2.9-36.3-.4-59.7-6.7C7.5 213.8 1.2 165.4.2 116.3c-.3-14.6-.1-28.3-.1-39.8 0-50.2 32.9-64.9 32.9-64.9C49.6 3.5 78.1.2 107.8 0h.7c29.7.2 58.2 3.5 74.9 11.6 0 0 32.9 14.7 32.9 64.9 0 0 .4 37.1-4.5 62.6" />
<path d="M177.5 80.5v60.3h-23.9v-58.5c0-12.3-5.2-18.6-15.5-18.6-11.4 0-17.2 7.4-17.2 22.1v32h-23.8V85.8c0-14.7-5.7-22.1-17.2-22.1-10.3 0-15.5 6.3-15.5 18.6v58.5H40.5V80.5c0-12.3 3.1-22.1 9.4-29.4 6.5-7.3 15-11 25.5-11 12.2 0 21.4 4.7 27.4 14.1l5.9 9.9 5.9-9.9c6-9.4 15.2-14.1 27.4-14.1 10.5 0 19 3.7 25.5 11 6.3 7.3 9.4 17.1 9.4 29.4" fill="var(--background, #fff)" />
</svg>
);
}
/**
* Renders a Mastodon post as a native quote-post card, fetching data from
* the instance's public API.
*/
export function MastodonEmbed({ url, className }: MastodonEmbedProps) {
const { data: post, isLoading, isError } = useMastodonPost(url);
if (isLoading) {
return <ExternalPostCardSkeleton className={className} />;
}
if (isError || !post) {
return null;
}
return (
<ExternalPostCard
post={{ ...post, brandIcon: <MastodonLogo className="size-3.5" /> }}
className={className}
/>
);
}
+25 -61
View File
@@ -1,43 +1,6 @@
import { useQuery } from '@tanstack/react-query';
/** Author profile data from the Bluesky API. */
export interface BlueskyAuthor {
did: string;
handle: string;
displayName?: string;
avatar?: string;
}
/** A single image in a Bluesky image embed. */
export interface BlueskyImage {
thumb: string;
fullsize: string;
alt: string;
aspectRatio?: { width: number; height: number };
}
/** External link embed (link card). */
export interface BlueskyExternal {
uri: string;
title: string;
description: string;
thumb?: string;
}
/** Bluesky post data returned by the hook. */
export interface BlueskyPostData {
uri: string;
cid: string;
author: BlueskyAuthor;
text: string;
createdAt: string;
likeCount: number;
repostCount: number;
replyCount: number;
quoteCount: number;
images?: BlueskyImage[];
external?: BlueskyExternal;
}
import type { ExternalImage, ExternalExternal, ExternalPostData } from '@/components/ExternalPostCard';
/** Raw API shape for the post view from getPostThread. */
interface RawPostView {
@@ -55,8 +18,8 @@ interface RawPostView {
};
embed?: {
$type: string;
images?: BlueskyImage[];
external?: BlueskyExternal;
images?: ExternalImage[];
external?: ExternalExternal & { uri: string; description: string };
};
likeCount?: number;
repostCount?: number;
@@ -65,7 +28,8 @@ interface RawPostView {
}
/**
* Fetches a Bluesky post via the public API and returns structured post data.
* Fetches a Bluesky post via the public API and returns it as an
* `ExternalPostData` object for rendering in `ExternalPostCard`.
*
* Uses `app.bsky.feed.getPostThread` with `depth=0` to fetch just the post
* without replies. The author handle is resolved to a DID first if needed.
@@ -96,7 +60,7 @@ export function useBlueskyPost(author: string, rkey: string) {
// Step 2: Fetch the post thread (depth=0 for just the post)
const query = useQuery({
queryKey: ['bsky-post', did, rkey],
queryFn: async ({ signal }): Promise<BlueskyPostData | null> => {
queryFn: async ({ signal }): Promise<ExternalPostData | null> => {
const atUri = `at://${did}/app.bsky.feed.post/${rkey}`;
const res = await fetch(
`https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=${encodeURIComponent(atUri)}&depth=0`,
@@ -108,36 +72,36 @@ export function useBlueskyPost(author: string, rkey: string) {
const post = json.thread?.post;
if (!post) return null;
const images = post.embed?.$type === 'app.bsky.embed.images#view'
? post.embed.images
: undefined;
const handle = post.author.handle;
const external = post.embed?.$type === 'app.bsky.embed.external#view'
? post.embed.external
: undefined;
const images: ExternalImage[] | undefined =
post.embed?.$type === 'app.bsky.embed.images#view' && post.embed.images
? post.embed.images.map((img) => ({ thumb: img.thumb, alt: img.alt }))
: undefined;
const external: ExternalExternal | undefined =
post.embed?.$type === 'app.bsky.embed.external#view' && post.embed.external
? { title: post.embed.external.title, thumb: post.embed.external.thumb }
: undefined;
return {
uri: post.uri,
cid: post.cid,
author: {
did: post.author.did,
handle: post.author.handle,
displayName: post.author.displayName,
avatar: post.author.avatar,
},
displayName: post.author.displayName || handle,
handle,
avatar: post.author.avatar,
text: post.record.text ?? '',
createdAt: post.record.createdAt ?? '',
likeCount: post.likeCount ?? 0,
repostCount: post.repostCount ?? 0,
postUrl: `https://bsky.app/profile/${handle}/post/${rkey}`,
profileUrl: `https://bsky.app/profile/${handle}`,
replyCount: post.replyCount ?? 0,
quoteCount: post.quoteCount ?? 0,
repostCount: (post.repostCount ?? 0) + (post.quoteCount ?? 0),
likeCount: post.likeCount ?? 0,
images,
external,
};
},
enabled: !!did,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60, // 1 hour
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 60,
retry: false,
});
+113
View File
@@ -0,0 +1,113 @@
import { useQuery } from '@tanstack/react-query';
import type { ExternalImage, ExternalExternal, ExternalPostData } from '@/components/ExternalPostCard';
/** Raw Mastodon API status shape (subset of fields we use). */
interface RawMastodonStatus {
id: string;
created_at: string;
content: string;
url: string | null;
account: {
display_name: string;
username: string;
acct: string;
avatar: string;
url: string;
};
media_attachments: {
type: string;
url: string;
preview_url: string | null;
description: string | null;
}[];
card?: {
title: string;
image: string | null;
} | null;
favourites_count: number;
reblogs_count: number;
replies_count: number;
}
/** Strip HTML tags and decode common HTML entities to get plaintext. */
function htmlToPlaintext(html: string): string {
return html
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/p>\s*<p>/gi, '\n\n')
.replace(/<[^>]+>/g, '')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.trim();
}
/**
* Fetches a Mastodon post via the instance's public API and returns it as
* an `ExternalPostData` object for rendering in `ExternalPostCard`.
*
* @param url - Full Mastodon post URL, e.g. `https://mastodon.social/@Gargron/123456`
*/
export function useMastodonPost(url: string) {
return useQuery({
queryKey: ['mastodon-post', url],
queryFn: async ({ signal }): Promise<ExternalPostData | null> => {
const parsed = new URL(url);
const origin = parsed.origin;
// Extract status ID from the last path segment: /@user/123456
const segments = parsed.pathname.split('/');
const statusId = segments[segments.length - 1];
if (!statusId || !/^\d+$/.test(statusId)) return null;
const res = await fetch(
`${origin}/api/v1/statuses/${statusId}`,
{ signal },
);
if (!res.ok) return null;
const status = await res.json() as RawMastodonStatus;
const images: ExternalImage[] | undefined =
status.media_attachments.length > 0
? status.media_attachments
.filter((a) => a.type === 'image')
.map((a) => ({
thumb: a.preview_url || a.url,
alt: a.description || '',
}))
: undefined;
const external: ExternalExternal | undefined =
!images && status.card?.title
? { title: status.card.title, thumb: status.card.image ?? undefined }
: undefined;
// For remote users, acct includes @domain; for local users it's just the username
const handle = status.account.acct.includes('@')
? status.account.acct
: `${status.account.acct}@${parsed.hostname}`;
return {
displayName: status.account.display_name || status.account.username,
handle,
avatar: status.account.avatar,
text: htmlToPlaintext(status.content),
createdAt: status.created_at,
postUrl: status.url || url,
profileUrl: status.account.url,
replyCount: status.replies_count,
repostCount: status.reblogs_count,
likeCount: status.favourites_count,
images: images && images.length > 0 ? images : undefined,
external,
};
},
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 60,
retry: false,
});
}