diff --git a/src/lib/microcosm/hydrate.ts b/src/lib/microcosm/hydrate.ts index 52a9c4d..4f2a6ca 100644 --- a/src/lib/microcosm/hydrate.ts +++ b/src/lib/microcosm/hydrate.ts @@ -117,11 +117,33 @@ type RawImage = { alt?: string aspectRatio?: {width: number; height: number} } +type StrongRef = {uri: string; cid?: string} type RawExternal = { uri?: string title?: string description?: string thumb?: unknown + /** + * StrongRefs to the atproto records backing this link (e.g. a + * site.standard.publication / site.standard.document). Written into the post + * record by the author; the AppView surfaces them as `associatedRefs` and uses + * them to render the rich Standard publication card. + */ + associatedRefs?: StrongRef[] +} + +type RgbColor = {r?: number; g?: number; b?: number} +type StandardTheme = { + primary?: RgbColor + accentText?: RgbColor + backgroundColor?: RgbColor + pageBackground?: RgbColor +} +type StandardPublicationRecord = { + url?: string + name?: string + icon?: unknown + theme?: StandardTheme } function hydrateImagesView(did: string, e: {images?: unknown}): unknown { @@ -139,9 +161,64 @@ function hydrateImagesView(did: string, e: {images?: unknown}): unknown { } } -function hydrateExternalView(did: string, e: {external?: unknown}): unknown { +function rgb(c: RgbColor | undefined) { + if (!c || c.r == null || c.g == null || c.b == null) return undefined + return {$type: 'app.bsky.embed.external#colorRGB', r: c.r, g: c.g, b: c.b} +} + +/** + * Build the rich `source` for a Standard publication embed. The post record's + * external `associatedRefs` point at the backing `site.standard.publication` + * record; we fetch it for the publication url/title/icon/theme. Returns + * undefined for non-Standard embeds or if the publication can't be fetched. + */ +async function hydrateStandardSource( + associatedRefs: StrongRef[] | undefined, + signal?: AbortSignal, +): Promise | undefined> { + const pubRef = associatedRefs?.find( + r => parseAtUri(r.uri)?.collection === 'site.standard.publication', + ) + if (!pubRef) return undefined + const parsed = parseAtUri(pubRef.uri) + if (!parsed) return undefined + + const rec = await getRecordByUri(pubRef.uri, undefined, signal).catch( + () => undefined, + ) + if (!rec) return undefined + const pub = rec.value as StandardPublicationRecord + + const accentRGB = rgb(pub.theme?.primary) + const accentForegroundRGB = rgb(pub.theme?.accentText) + return { + $type: 'app.bsky.embed.external#viewExternalSource', + uri: pub.url ?? '', + title: pub.name ?? '', + icon: cdnUrl('avatar', parsed.did, pub.icon), + theme: + accentRGB || accentForegroundRGB + ? { + $type: 'app.bsky.embed.external#viewExternalSourceTheme', + accentRGB, + accentForegroundRGB, + backgroundRGB: rgb(pub.theme?.backgroundColor), + foregroundRGB: rgb(pub.theme?.pageBackground), + } + : undefined, + } +} + +async function hydrateExternalView( + did: string, + e: {external?: unknown}, + signal?: AbortSignal, +): Promise { const ext = e.external as RawExternal | undefined if (!ext) return undefined + const source = await hydrateStandardSource(ext.associatedRefs, signal).catch( + () => undefined, + ) return { $type: 'app.bsky.embed.external#view', external: { @@ -149,6 +226,12 @@ function hydrateExternalView(did: string, e: {external?: unknown}): unknown { title: ext.title ?? '', description: ext.description ?? '', thumb: cdnUrl('feed_thumbnail', did, ext.thumb), + associatedRefs: ext.associatedRefs?.map(r => ({ + $type: 'com.atproto.repo.strongRef', + uri: r.uri, + cid: r.cid, + })), + source, }, } } @@ -193,7 +276,7 @@ async function hydrateEmbed( } if (e.$type === 'app.bsky.embed.external') { - return hydrateExternalView(did, e) + return hydrateExternalView(did, e, signal) } if (e.$type === 'app.bsky.embed.record') { @@ -219,13 +302,11 @@ async function hydrateEmbed( .then(p => (p ? quotedPostToViewRecord(p) : undefined)) .catch(() => undefined) : Promise.resolve(undefined), - Promise.resolve( - media?.$type === 'app.bsky.embed.images' - ? hydrateImagesView(did, media as {images?: unknown}) - : media?.$type === 'app.bsky.embed.external' - ? hydrateExternalView(did, media as {external?: unknown}) - : undefined, - ), + media?.$type === 'app.bsky.embed.images' + ? Promise.resolve(hydrateImagesView(did, media as {images?: unknown})) + : media?.$type === 'app.bsky.embed.external' + ? hydrateExternalView(did, media as {external?: unknown}, signal) + : Promise.resolve(undefined), ]) if (!recordView && !mediaView) return undefined return {