[bskyembed] Add gallery embed support

Renders app.bsky.embed.gallery#view in the embed iframe using the same
1/2/3/4 layouts as legacy images, plus a 2x2 grid with a +N overlay on
the 4th tile for 5+ photo posts. Embed remains non-interactive (whole
embed navigates to the post), so no carousel.

Bumps @atproto/api from ^0.15.25 to 0.20.11 to match the main app and
pick up AppBskyEmbedGallery types.

APP-2326
This commit is contained in:
vineyardbovines
2026-06-09 12:53:13 -04:00
parent 750c55eff8
commit dce88acc04
3 changed files with 123 additions and 86 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
"format": "prettier -w src" "format": "prettier -w src"
}, },
"dependencies": { "dependencies": {
"@atproto/api": "^0.15.25", "@atproto/api": "0.20.11",
"preact": "^10.4.8" "preact": "^10.4.8"
}, },
"devDependencies": { "devDependencies": {
+56 -67
View File
@@ -9,8 +9,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: ^0.15.25 specifier: 0.20.11
version: 0.15.27 version: 0.20.11
preact: preact:
specifier: ^10.4.8 specifier: ^10.4.8
version: 10.29.1 version: 10.29.1
@@ -73,32 +73,33 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'} engines: {node: '>=10'}
'@atproto/api@0.15.27': '@atproto/api@0.20.11':
resolution: {integrity: sha512-ok/WGafh1nz4t8pEQGtAF/32x2E2VDWU4af6BajkO5Gky2jp2q6cv6aB2A5yuvNNcc3XkYMYipsqVHVwLPMF9g==} resolution: {integrity: sha512-1NoVJpBDAdotxo1iMZdMd75JstpdKWgBYOnxfVD4m+52bRjgU4cFg3EOGNUognZntFgL/bIHyEgyN7SJWVf6Ig==}
engines: {node: '>=22'}
'@atproto/common-web@0.4.21': '@atproto/common-web@0.5.0':
resolution: {integrity: sha512-Odq+wdk3YNasGCjjlpl3bCIPvqYHige5DLfMkIffNv/2PI/iIj5ZvAvMvJlJ59OhReKSxtpI0invx5UQPc3+fw==} resolution: {integrity: sha512-ReWnkuZdDU/74/I47gaI26uxQjHmpq4edp41NnZZQ5vIIKGb7Ei6pZHzDTUD9JURo109SKrPx9RMP2IQm0fOKA==}
engines: {node: '>=22'}
'@atproto/lex-data@0.0.15': '@atproto/lex-data@0.1.1':
resolution: {integrity: sha512-ZsbGiaM5S3CnGrcTMbDGON3bLZzCi/Mx9UvcMREKSRujnF68eHgMiXxJqvykP7+QpOX6tYCK93axZkuJVhtSEw==} resolution: {integrity: sha512-/xza8nU/YhtzhETnHL3QKKofaJ28/0NCzhT7LaYoUkm8EgypWp5ykEtmW52yLhQM2JF6fVa25g1soQmNTGqtSg==}
engines: {node: '>=22'}
'@atproto/lex-json@0.0.16': '@atproto/lex-json@0.1.0':
resolution: {integrity: sha512-IgLgQ0krshVlrIYZ+heTBDbCnM3LmAgWvsaYn5MxvKA3LcBot3PG3ptdO8VOweVZ+WgCLuo39cz9EbUmIbqdtg==} resolution: {integrity: sha512-oWUrRMwFyWpmi/5k1Se3xBTbP06XdxBS5iFuUz9LmqItaPXwrWRD87a9ldPvINQ/A2/mn7J6/qug8sDVlhD+vQ==}
engines: {node: '>=22'}
'@atproto/lexicon@0.4.14': '@atproto/lexicon@0.7.1':
resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==} resolution: {integrity: sha512-voNfNED5KUxn3vpo7N5DMRblBDfWf7kSfdKhJFC1RrLCxg38YbBzzURNVQJ32bp13Oot8kYfyXBWxTgtKLvw8w==}
engines: {node: '>=22'}
'@atproto/lexicon@0.6.2': '@atproto/syntax@0.6.1':
resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} resolution: {integrity: sha512-kA4dQDoMPpWCH8N0Q4KoSq024u5MkVfDVa8DdhyLjGA72z/khbOf1jXKPv7NIL2oEc9aj7geKELdvqyf4ogopA==}
engines: {node: '>=22'}
'@atproto/syntax@0.4.3': '@atproto/xrpc@0.8.0':
resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} resolution: {integrity: sha512-NJy02bIKrWlE2NQkRV1kT0Cj0ixbuxlF/MejBdo4cPWAa9v3oZexvAcjjb0zaOYeABkaU14iyIhvn2G4e/oLpw==}
engines: {node: '>=22'}
'@atproto/syntax@0.5.4':
resolution: {integrity: sha512-9XJOpMAgsGFxMEIp8nJ8AIWv+krrY1xQMj+wULbbXhQztQV+9aZ0TbG9Jtn3Op2or8Kr6OqyWR4ga9Z189kKDw==}
'@atproto/xrpc@0.7.7':
resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==}
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
@@ -1101,8 +1102,8 @@ packages:
peerDependencies: peerDependencies:
postcss: ^8.1.0 postcss: ^8.1.0
await-lock@2.2.2: await-lock@3.0.0:
resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} resolution: {integrity: sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA==}
babel-plugin-polyfill-corejs2@0.4.17: babel-plugin-polyfill-corejs2@0.4.17:
resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==} resolution: {integrity: sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==}
@@ -1787,8 +1788,8 @@ packages:
ms@2.1.3: ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
multiformats@9.9.0: multiformats@13.4.2:
resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==}
mz@2.7.0: mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
@@ -2194,8 +2195,8 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
uint8arrays@3.0.0: uint8arrays@5.1.1:
resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==}
unicode-canonical-property-names-ecmascript@2.0.1: unicode-canonical-property-names-ecmascript@2.0.1:
resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==}
@@ -2324,63 +2325,51 @@ snapshots:
'@alloc/quick-lru@5.2.0': {} '@alloc/quick-lru@5.2.0': {}
'@atproto/api@0.15.27': '@atproto/api@0.20.11':
dependencies: dependencies:
'@atproto/common-web': 0.4.21 '@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.4.14 '@atproto/lexicon': 0.7.1
'@atproto/syntax': 0.4.3 '@atproto/syntax': 0.6.1
'@atproto/xrpc': 0.7.7 '@atproto/xrpc': 0.8.0
await-lock: 2.2.2 await-lock: 3.0.0
multiformats: 9.9.0 multiformats: 13.4.2
tlds: 1.261.0 tlds: 1.261.0
zod: 3.25.76 zod: 3.25.76
'@atproto/common-web@0.4.21': '@atproto/common-web@0.5.0':
dependencies: dependencies:
'@atproto/lex-data': 0.0.15 '@atproto/lex-data': 0.1.1
'@atproto/lex-json': 0.0.16 '@atproto/lex-json': 0.1.0
'@atproto/syntax': 0.5.4 '@atproto/syntax': 0.6.1
zod: 3.25.76 zod: 3.25.76
'@atproto/lex-data@0.0.15': '@atproto/lex-data@0.1.1':
dependencies: dependencies:
multiformats: 9.9.0 multiformats: 13.4.2
tslib: 2.8.1 tslib: 2.8.1
uint8arrays: 3.0.0 uint8arrays: 5.1.1
unicode-segmenter: 0.14.5 unicode-segmenter: 0.14.5
'@atproto/lex-json@0.0.16': '@atproto/lex-json@0.1.0':
dependencies: dependencies:
'@atproto/lex-data': 0.0.15 '@atproto/lex-data': 0.1.1
tslib: 2.8.1 tslib: 2.8.1
'@atproto/lexicon@0.4.14': '@atproto/lexicon@0.7.1':
dependencies: dependencies:
'@atproto/common-web': 0.4.21 '@atproto/common-web': 0.5.0
'@atproto/syntax': 0.4.3 '@atproto/syntax': 0.6.1
iso-datestring-validator: 2.2.2 multiformats: 13.4.2
multiformats: 9.9.0
zod: 3.25.76 zod: 3.25.76
'@atproto/lexicon@0.6.2': '@atproto/syntax@0.6.1':
dependencies: dependencies:
'@atproto/common-web': 0.4.21
'@atproto/syntax': 0.5.4
iso-datestring-validator: 2.2.2 iso-datestring-validator: 2.2.2
multiformats: 9.9.0
zod: 3.25.76
'@atproto/syntax@0.4.3':
dependencies:
tslib: 2.8.1 tslib: 2.8.1
'@atproto/syntax@0.5.4': '@atproto/xrpc@0.8.0':
dependencies: dependencies:
tslib: 2.8.1 '@atproto/lexicon': 0.7.1
'@atproto/xrpc@0.7.7':
dependencies:
'@atproto/lexicon': 0.6.2
zod: 3.25.76 zod: 3.25.76
'@babel/code-frame@7.29.0': '@babel/code-frame@7.29.0':
@@ -3536,7 +3525,7 @@ snapshots:
postcss: 8.5.14 postcss: 8.5.14
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
await-lock@2.2.2: {} await-lock@3.0.0: {}
babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0): babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.0):
dependencies: dependencies:
@@ -4142,7 +4131,7 @@ snapshots:
ms@2.1.3: {} ms@2.1.3: {}
multiformats@9.9.0: {} multiformats@13.4.2: {}
mz@2.7.0: mz@2.7.0:
dependencies: dependencies:
@@ -4546,9 +4535,9 @@ snapshots:
typescript@6.0.3: {} typescript@6.0.3: {}
uint8arrays@3.0.0: uint8arrays@5.1.1:
dependencies: dependencies:
multiformats: 9.9.0 multiformats: 13.4.2
unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-canonical-property-names-ecmascript@2.0.1: {}
+66 -18
View File
@@ -1,5 +1,6 @@
import { import {
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedGallery,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
@@ -43,6 +44,11 @@ export function Embed({
return <ImageEmbed content={content} labelInfo={labelInfo} /> return <ImageEmbed content={content} labelInfo={labelInfo} />
} }
// Case 1b: Gallery (Photos v2)
if (AppBskyEmbedGallery.isView(content)) {
return <GalleryEmbed content={content} labelInfo={labelInfo} />
}
// Case 2: External link // Case 2: External link
if (AppBskyEmbedExternal.isView(content)) { if (AppBskyEmbedExternal.isView(content)) {
return <ExternalEmbed content={content} labelInfo={labelInfo} /> return <ExternalEmbed content={content} labelInfo={labelInfo} />
@@ -230,6 +236,8 @@ function Info({children}: {children: ComponentChildren}) {
) )
} }
type GridImage = {thumb: string; alt: string}
function ImageEmbed({ function ImageEmbed({
content, content,
labelInfo, labelInfo,
@@ -240,20 +248,45 @@ function ImageEmbed({
if (labelInfo) { if (labelInfo) {
return <Info>{labelInfo}</Info> return <Info>{labelInfo}</Info>
} }
return (
<ImageGrid
images={content.images.map(i => ({thumb: i.thumb, alt: i.alt}))}
/>
)
}
switch (content.images.length) { function GalleryEmbed({
content,
labelInfo,
}: {
content: AppBskyEmbedGallery.View
labelInfo?: string
}) {
if (labelInfo) {
return <Info>{labelInfo}</Info>
}
const images = content.items
.filter(AppBskyEmbedGallery.isViewImage)
.map(i => ({thumb: i.thumbnail, alt: i.alt}))
return <ImageGrid images={images} />
}
function ImageGrid({images}: {images: GridImage[]}) {
switch (images.length) {
case 0:
return null
case 1: case 1:
return ( return (
<img <img
src={content.images[0].thumb} src={images[0].thumb}
alt={content.images[0].alt} alt={images[0].alt}
className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]" className="w-full rounded-xl overflow-hidden object-cover h-auto max-h-[1000px]"
/> />
) )
case 2: case 2:
return ( return (
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]"> <div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
{content.images.map((image, i) => ( {images.map((image, i) => (
<img <img
key={i} key={i}
src={image.thumb} src={image.thumb}
@@ -268,13 +301,13 @@ function ImageEmbed({
<div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]"> <div className="flex gap-1 rounded-xl overflow-hidden w-full aspect-[2/1]">
<div className="flex-1 aspect-square"> <div className="flex-1 aspect-square">
<img <img
src={content.images[0].thumb} src={images[0].thumb}
alt={content.images[0].alt} alt={images[0].alt}
className="w-full h-full object-cover rounded-sm" className="w-full h-full object-cover rounded-sm"
/> />
</div> </div>
<div className="flex flex-col gap-1 flex-1"> <div className="flex flex-col gap-1 flex-1">
{content.images.slice(1).map((image, i) => ( {images.slice(1).map((image, i) => (
<img <img
key={i} key={i}
src={image.thumb} src={image.thumb}
@@ -285,21 +318,36 @@ function ImageEmbed({
</div> </div>
</div> </div>
) )
case 4: default: {
const remaining = images.length - 4
return ( return (
<div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden"> <div className="grid grid-cols-2 gap-1 rounded-xl overflow-hidden">
{content.images.map((image, i) => ( {images.slice(0, 4).map((image, i) => {
<img const isOverflowCell = i === 3 && remaining > 0
key={i} return (
src={image.thumb} <div
alt={image.alt} key={i}
className="aspect-[3/2] w-full object-cover rounded-sm" className="relative aspect-[3/2] rounded-sm overflow-hidden">
/> <img
))} src={image.thumb}
alt={image.alt}
className="absolute inset-0 w-full h-full object-cover"
/>
{isOverflowCell && (
<div
aria-label={`+${remaining} more image${remaining === 1 ? '' : 's'}, view post to see all`}
className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-white text-2xl font-semibold">
+{remaining}
</span>
</div>
)}
</div>
)
})}
</div> </div>
) )
default: }
return null
} }
} }