From 531ea884f64e2a8426a05705ff91eca562252fb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 18 May 2026 01:46:19 +0000 Subject: [PATCH] Use post embed media as og:image for link previews Post pages were emitting only the author's avatar thumbnail as og:image with twitter:card=summary, so iMessage and other rich-link previewers rendered a tiny avatar next to the post text instead of the post's photo (the way bsky.app does). Now generateMetadata pulls the first image, video thumbnail, or external thumb out of the post embed, includes its aspect ratio + alt, and switches the Twitter card to summary_large_image when media is present. Text-only and quote-only posts still fall back to the avatar. --- src/app/[handle]/[collection]/[rkey]/page.tsx | 25 +++++- src/app/profile/[handle]/post/[rkey]/page.tsx | 25 +++++- src/utils/postOgImage.ts | 87 +++++++++++++++++++ 3 files changed, 131 insertions(+), 6 deletions(-) create mode 100644 src/utils/postOgImage.ts 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; +} -- 2.51.2