diff --git a/src/app/[handle]/[collection]/[rkey]/page.tsx b/src/app/[handle]/[collection]/[rkey]/page.tsx index ed8e1e5..a3b85e7 100644 --- a/src/app/[handle]/[collection]/[rkey]/page.tsx +++ b/src/app/[handle]/[collection]/[rkey]/page.tsx @@ -10,6 +10,7 @@ import Header from '@/components/Header'; import { parseURI, resolveHandle, getDisplayName } from '@/utils/uriParser'; import { fetchRecordData } from '@/utils/recordFetcher'; import { resolveDidToHandle } from '@/utils/didResolver'; +import { getPostOgImage } from '@/utils/postOgImage'; import { getMarginLexiconType, getMarginLexiconDisplayName, getMarginLexiconDescription } from '@/utils/marginLexicons'; import { MarginAnnotationPreview, @@ -70,6 +71,24 @@ export async function generateMetadata({ params }: Props): Promise { ? author.avatar.replace('/img/avatar/', '/img/avatar_thumbnail/') : ''; + // Prefer the post's embedded media (photo/video/external thumb) so + // rich-link previewers (iMessage, Twitter, Slack, etc.) render the + // large image like bsky.app does. Fall back to the avatar thumbnail + // for text-only or quote-only posts. + const postOgImage = getPostOgImage(post); + const ogImage = postOgImage + ? { + url: postOgImage.url, + ...(postOgImage.alt ? { alt: postOgImage.alt } : {}), + ...(postOgImage.width && postOgImage.height + ? { width: postOgImage.width, height: postOgImage.height } + : {}), + } + : avatarThumb + ? { url: avatarThumb } + : null; + const twitterCard = postOgImage ? 'summary_large_image' : 'summary'; + const canonicalUrl = `https://aturi.to/profile/${author.handle}/post/${rkey}`; const atUri = `at://${resolvedDid}/${collection}/${rkey}`; const oembedUrl = `https://aturi.to/api/oembed?format=json&url=${encodeURIComponent(atUri)}`; @@ -96,13 +115,13 @@ export async function generateMetadata({ params }: Props): Promise { url: canonicalUrl, siteName: 'Aturi', ...(publishedTime ? { publishedTime } : {}), - ...(avatarThumb ? { images: [{ url: avatarThumb }] } : {}), + ...(ogImage ? { images: [ogImage] } : {}), }, twitter: { - card: 'summary', + card: twitterCard, title: authorByline, description: postText || postDescription, - ...(avatarThumb ? { images: [avatarThumb] } : {}), + ...(ogImage ? { images: [ogImage.url] } : {}), }, other: { 'profile:username': author.handle, diff --git a/src/app/profile/[handle]/post/[rkey]/page.tsx b/src/app/profile/[handle]/post/[rkey]/page.tsx index ff3d620..07c7a4a 100644 --- a/src/app/profile/[handle]/post/[rkey]/page.tsx +++ b/src/app/profile/[handle]/post/[rkey]/page.tsx @@ -9,6 +9,7 @@ import Header from '@/components/Header'; import { parseURI, resolveHandle, getDisplayName } from '@/utils/uriParser'; import { fetchRecordData } from '@/utils/recordFetcher'; import { resolveDidToHandle } from '@/utils/didResolver'; +import { getPostOgImage } from '@/utils/postOgImage'; type Props = { params: Promise<{ handle: string; rkey: string }>; @@ -54,6 +55,24 @@ export async function generateMetadata({ params }: Props): Promise { ? author.avatar.replace('/img/avatar/', '/img/avatar_thumbnail/') : ''; + // Prefer the post's embedded media (photo/video/external thumb) so + // rich-link previewers (iMessage, Twitter, Slack, etc.) render the + // large image like bsky.app does. Fall back to the avatar thumbnail + // for text-only or quote-only posts. + const postOgImage = getPostOgImage(post); + const ogImage = postOgImage + ? { + url: postOgImage.url, + ...(postOgImage.alt ? { alt: postOgImage.alt } : {}), + ...(postOgImage.width && postOgImage.height + ? { width: postOgImage.width, height: postOgImage.height } + : {}), + } + : avatarThumb + ? { url: avatarThumb } + : null; + const twitterCard = postOgImage ? 'summary_large_image' : 'summary'; + const canonicalUrl = `https://aturi.to/profile/${author.handle}/post/${rkey}`; const atUri = `at://${resolvedDid}/${collection}/${rkey}`; const oembedUrl = `https://aturi.to/api/oembed?format=json&url=${encodeURIComponent(atUri)}`; @@ -80,13 +99,13 @@ export async function generateMetadata({ params }: Props): Promise { url: canonicalUrl, siteName: 'Aturi', ...(publishedTime ? { publishedTime } : {}), - ...(avatarThumb ? { images: [{ url: avatarThumb }] } : {}), + ...(ogImage ? { images: [ogImage] } : {}), }, twitter: { - card: 'summary', + card: twitterCard, title: authorByline, description: postText || description, - ...(avatarThumb ? { images: [avatarThumb] } : {}), + ...(ogImage ? { images: [ogImage.url] } : {}), }, other: { 'profile:username': author.handle, diff --git a/src/utils/postOgImage.ts b/src/utils/postOgImage.ts new file mode 100644 index 0000000..b6ba5a6 --- /dev/null +++ b/src/utils/postOgImage.ts @@ -0,0 +1,87 @@ +/** + * Extract the best preview image from a Bluesky post for use as og:image. + * + * When a post has an image, video, or external-link embed we want rich-link + * previewers (iMessage, Twitter, Slack, etc.) to render that media as the + * large card image — same as bsky.app does. Without this, all aturi post + * links fall back to the author's small avatar thumbnail. + */ + +import type { BskyPost } from './recordFetcher'; + +export type PostOgImage = { + url: string; + alt?: string; + width?: number; + height?: number; +}; + +type EmbedView = { + $type?: string; + images?: Array<{ + thumb?: string; + fullsize?: string; + alt?: string; + aspectRatio?: { width: number; height: number }; + }>; + external?: { + thumb?: string; + title?: string; + }; + thumbnail?: string; + alt?: string; + aspectRatio?: { width: number; height: number }; + media?: EmbedView; +}; + +function pickFromView(view: EmbedView | undefined): PostOgImage | null { + if (!view) return null; + + if ( + view.$type === 'app.bsky.embed.images#view' && + view.images && + view.images.length > 0 + ) { + const img = view.images[0]; + const url = img.fullsize || img.thumb; + if (!url) return null; + return { + url, + alt: img.alt, + width: img.aspectRatio?.width, + height: img.aspectRatio?.height, + }; + } + + if (view.$type === 'app.bsky.embed.video#view' && view.thumbnail) { + return { + url: view.thumbnail, + alt: view.alt, + width: view.aspectRatio?.width, + height: view.aspectRatio?.height, + }; + } + + if (view.$type === 'app.bsky.embed.external#view' && view.external?.thumb) { + return { + url: view.external.thumb, + alt: view.external.title, + }; + } + + return null; +} + +export function getPostOgImage(post: BskyPost): PostOgImage | null { + const embed = post.embed as EmbedView | undefined; + if (!embed) return null; + + const direct = pickFromView(embed); + if (direct) return direct; + + if (embed.$type === 'app.bsky.embed.recordWithMedia#view') { + return pickFromView(embed.media); + } + + return null; +}