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

{
- (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) => (
+

{
+ (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 (
-
-
-
+
+ );
+}
+
+/**
+ * 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 ;
+ }
+
+ if (isError || !post) {
+ return null;
+ }
+
+ return (
+ }}
+ className={className}
+ />
);
}
diff --git a/src/hooks/useBlueskyPost.ts b/src/hooks/useBlueskyPost.ts
index 059f598e..14b52a45 100644
--- a/src/hooks/useBlueskyPost.ts
+++ b/src/hooks/useBlueskyPost.ts
@@ -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 => {
+ queryFn: async ({ signal }): Promise => {
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,
});
diff --git a/src/hooks/useMastodonPost.ts b/src/hooks/useMastodonPost.ts
new file mode 100644
index 00000000..d6d019ef
--- /dev/null
+++ b/src/hooks/useMastodonPost.ts
@@ -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(/
/gi, '\n')
+ .replace(/<\/p>\s*/gi, '\n\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/'/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 => {
+ 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,
+ });
+}