Files
eranos/src/components/LinkEmbed.tsx
T
Alex Gleason 630dfd10dc 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.
2026-03-02 00:46:47 -06:00

125 lines
4.3 KiB
TypeScript

import { useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { MessageSquare } from 'lucide-react';
import { BlueskyEmbed } from '@/components/BlueskyEmbed';
import { ExternalFavicon } from '@/components/ExternalFavicon';
import { LinkPreview } from '@/components/LinkPreview';
import { MastodonEmbed } from '@/components/MastodonEmbed';
import { RedditEmbed } from '@/components/RedditEmbed';
import { SpotifyEmbed } from '@/components/SpotifyEmbed';
import { TweetEmbed } from '@/components/TweetEmbed';
import { YouTubeEmbed } from '@/components/YouTubeEmbed';
import {
extractYouTubeId,
extractTweetId,
extractBlueskyPost,
extractMastodonPost,
extractSpotifyEmbed,
extractRedditPost,
} from '@/lib/linkEmbed';
import { cn } from '@/lib/utils';
interface LinkEmbedProps {
url: string;
className?: string;
/** Show a "Discuss" link to the /i/:uri page. Defaults to true. */
showDiscuss?: boolean;
/** When true, hides the thumbnail image in generic link preview cards. */
hideImage?: boolean;
}
// ---------------------------------------------------------------------------
// Unified embed component
// ---------------------------------------------------------------------------
/**
* 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` (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) {
const youtubeId = useMemo(() => extractYouTubeId(url), [url]);
const tweetId = useMemo(() => extractTweetId(url), [url]);
const blueskyPost = useMemo(() => extractBlueskyPost(url), [url]);
const mastodonUrl = useMemo(() => extractMastodonPost(url), [url]);
const spotifyEmbed = useMemo(() => extractSpotifyEmbed(url), [url]);
const redditUrl = useMemo(() => extractRedditPost(url), [url]);
let embed: React.ReactNode;
if (youtubeId) {
embed = <YouTubeEmbed videoId={youtubeId} />;
} else if (tweetId) {
embed = <TweetEmbed tweetId={tweetId} />;
} else if (blueskyPost) {
// BlueskyEmbed has built-in /i/ navigation, no DiscussBar needed
return <BlueskyEmbed author={blueskyPost.author} rkey={blueskyPost.rkey} className={className} />;
} else if (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) {
embed = <RedditEmbed url={redditUrl} />;
} else {
// LinkPreview has its own built-in Discuss button
return <LinkPreview url={url} className={className} hideImage={hideImage} />;
}
return (
<div className={cn('space-y-0', className)}>
{embed}
{showDiscuss && <DiscussBar url={url} />}
</div>
);
}
/** Small bar below an embed with favicon, domain, and a "Discuss" link. */
function DiscussBar({ url }: { url: string }) {
const navigate = useNavigate();
const domain = useMemo(() => {
try {
return new URL(url).hostname.replace(/^www\./, '');
} catch {
return url;
}
}, [url]);
return (
<div className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground">
<ExternalFavicon url={url} size={14} className="shrink-0" />
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{domain}
</a>
<button
type="button"
className={cn(
'ml-auto flex items-center gap-1 px-2 py-0.5 rounded-full',
'text-xs text-muted-foreground',
'hover:bg-primary/10 hover:text-primary transition-colors',
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
navigate(`/i/${encodeURIComponent(url)}`);
}}
>
<MessageSquare className="size-3" />
<span>Discuss</span>
</button>
</div>
);
}