Add app.bsky.embed.gallery embed type support

Wires the new gallery embed (atproto PR #4827) through the display and
compose pipelines while the lexicon is unpublished. Gallery posts render
via the existing carousel for >4 items and grid for <=4. Compose now
allows up to 10 images and auto-promotes images -> gallery above 4
(demotes back when count drops to <=4 so old clients still see legacy
embeds when possible). Drafts persist as the new `embedGallery` field.

Local shim types in `src/lib/api/gallery-embed-shim*.ts` mirror the
lexicon shape; delete both files once @atproto/api ships the generated
types.
This commit is contained in:
vineyardbovines
2026-06-03 13:12:27 -04:00
parent b6650d8d5c
commit 9aea4362d5
15 changed files with 408 additions and 84 deletions
+22
View File
@@ -47,6 +47,28 @@ export function Embed({
)} )}
</Outer> </Outer>
) )
} else if (e.type === 'gallery') {
return (
<Outer style={style}>
{e.view.items.map(item => {
const image: AppBskyEmbedImages.ViewImage = {
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}
return peekable ? (
<PeekableImageItem key={item.thumbnail} image={image} />
) : (
<ImageItem
key={item.thumbnail}
thumbnail={item.thumbnail}
alt={item.alt}
/>
)
})}
</Outer>
)
} else if (e.type === 'link') { } else if (e.type === 'link') {
if (!e.view.external.thumb) return null if (!e.view.external.thumb) return null
if (!isGifEmbed(e.view.external.uri)) return null if (!isGifEmbed(e.view.external.uri)) return null
+18 -4
View File
@@ -2,6 +2,7 @@ import {useRef} from 'react'
import {InteractionManager, View} from 'react-native' import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated' import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, tokens} from '#/alf' import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {AutoSizedImage} from '#/components/images/AutoSizedImage'
@@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post' import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types' import {type CommonProps} from './types'
const GRID_TO_CAROUSEL_THRESHOLD = 4
export function ImageEmbed({ export function ImageEmbed({
embed, embed,
...rest ...rest
}: CommonProps & { }: CommonProps & {
embed: EmbedType<'images'> embed: EmbedType<'images'> | EmbedType<'gallery'>
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const {images} = embed.view const images: AppBskyEmbedImages.ViewImage[] =
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) embed.type === 'gallery'
? embed.view.items.map(item => ({
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}))
: embed.view.images
const carouselEnabled =
embed.type === 'gallery'
? images.length > GRID_TO_CAROUSEL_THRESHOLD
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
// Captured from AutoSizedImage so the peek-commit handler can reuse the same // Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact. // ref + dims that a tap would — keeps the lightbox's return animation intact.
@@ -113,7 +127,7 @@ export function ImageEmbed({
) )
} }
if (galleryEnabled) { if (carouselEnabled) {
return ( return (
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<Gallery <Gallery
+3 -1
View File
@@ -52,6 +52,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
switch (embed.type) { switch (embed.type) {
case 'images': case 'images':
case 'gallery':
case 'link': case 'link':
case 'video': { case 'video': {
return <MediaEmbed embed={embed} {...rest} /> return <MediaEmbed embed={embed} {...rest} />
@@ -87,7 +88,8 @@ function MediaEmbed({
embed: TEmbed embed: TEmbed
}) { }) {
switch (embed.type) { switch (embed.type) {
case 'images': { case 'images':
case 'gallery': {
return ( return (
<ContentHider <ContentHider
modui={rest.moderation?.ui('contentMedia')} modui={rest.moderation?.ui('contentMedia')}
@@ -7,6 +7,7 @@ import {
type ModerationUI, type ModerationUI,
} from '@atproto/api' } from '@atproto/api'
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {unique} from '#/lib/moderation' import {unique} from '#/lib/moderation'
import {type AppModerationCause} from '#/components/Pills' import {type AppModerationCause} from '#/components/Pills'
import {Features, features} from '#/analytics/features' import {Features, features} from '#/analytics/features'
@@ -49,6 +50,12 @@ export function maybeApplyGalleryOffsetStyles(
embed, embed,
AppBskyEmbedImages.isMain, AppBskyEmbedImages.isMain,
) )
const isGalleryEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed,
AppBskyEmbedGallery.isMain,
)
const isRecordWithMedia = const isRecordWithMedia =
embed && embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>( bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
@@ -61,6 +68,11 @@ export function maybeApplyGalleryOffsetStyles(
if (embed.images.length === 1) return if (embed.images.length === 1) return
hasImages = true hasImages = true
} }
if (isGalleryEmbed) {
// one image, not a gallery
if (embed.items.length === 1) return
hasImages = true
}
if (isRecordWithMedia) { if (isRecordWithMedia) {
if ( if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>( bsky.dangerousIsType<AppBskyEmbedImages.Main>(
@@ -71,6 +83,15 @@ export function maybeApplyGalleryOffsetStyles(
// one image, not a gallery // one image, not a gallery
if (embed.media.images.length === 1) return if (embed.media.images.length === 1) return
} }
if (
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed.media,
AppBskyEmbedGallery.isMain,
)
) {
// one image, not a gallery
if (embed.media.items.length === 1) return
}
hasImages = true hasImages = true
} }
if (!hasImages) return if (!hasImages) return
+69
View File
@@ -0,0 +1,69 @@
/**
* Implementation backing `AppBskyEmbedGallery`. See gallery-embed-shim.ts.
*/
import {type AppBskyEmbedDefs, type BlobRef} from '@atproto/api'
export interface Main {
$type?: 'app.bsky.embed.gallery'
items: Image[]
}
export interface Image {
$type?: 'app.bsky.embed.gallery#image'
image: BlobRef
alt: string
aspectRatio: AppBskyEmbedDefs.AspectRatio
}
export interface View {
$type?: 'app.bsky.embed.gallery#view'
items: ViewImage[]
}
export interface ViewImage {
$type?: 'app.bsky.embed.gallery#viewImage'
thumbnail: string
fullsize: string
alt: string
aspectRatio: AppBskyEmbedDefs.AspectRatio
}
export function isMain<V>(
v: V,
): v is V & Main & {$type: 'app.bsky.embed.gallery'} {
return (
typeof v === 'object' &&
v !== null &&
(v as {$type?: string}).$type === 'app.bsky.embed.gallery'
)
}
export function isImage<V>(
v: V,
): v is V & Image & {$type: 'app.bsky.embed.gallery#image'} {
return (
typeof v === 'object' &&
v !== null &&
(v as {$type?: string}).$type === 'app.bsky.embed.gallery#image'
)
}
export function isView<V>(
v: V,
): v is V & View & {$type: 'app.bsky.embed.gallery#view'} {
return (
typeof v === 'object' &&
v !== null &&
(v as {$type?: string}).$type === 'app.bsky.embed.gallery#view'
)
}
export function isViewImage<V>(
v: V,
): v is V & ViewImage & {$type: 'app.bsky.embed.gallery#viewImage'} {
return (
typeof v === 'object' &&
v !== null &&
(v as {$type?: string}).$type === 'app.bsky.embed.gallery#viewImage'
)
}
+12
View File
@@ -0,0 +1,12 @@
/**
* Local shim for `app.bsky.embed.gallery` until @atproto/api ships the
* generated types. Mirrors the shape from atproto PR #4827:
* https://github.com/bluesky-social/atproto/pull/4827
*
* Once the lexicon ships and we bump @atproto/api, delete this file and
* replace `import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'`
* with `import {AppBskyEmbedGallery} from '@atproto/api'`.
*/
import * as gallery from './gallery-embed-shim.impl'
export {gallery as AppBskyEmbedGallery}
+28
View File
@@ -21,6 +21,7 @@ import {sha256} from 'js-sha256'
import {CID} from 'multiformats/cid' import {CID} from 'multiformats/cid'
import * as Hasher from 'multiformats/hashes/hasher' import * as Hasher from 'multiformats/hashes/hasher'
import {type AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {isNetworkError} from '#/lib/strings/errors' import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger' import {logger} from '#/logger'
@@ -252,6 +253,7 @@ async function resolveEmbed(
onStateChange: ((state: string) => void) | undefined, onStateChange: ((state: string) => void) | undefined,
): Promise< ): Promise<
| $Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedVideo.Main>
| $Typed<AppBskyEmbedExternal.Main> | $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedRecord.Main> | $Typed<AppBskyEmbedRecord.Main>
@@ -311,6 +313,7 @@ async function resolveMedia(
): Promise< ): Promise<
| $Typed<AppBskyEmbedExternal.Main> | $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedVideo.Main>
| undefined | undefined
> { > {
@@ -338,6 +341,31 @@ async function resolveMedia(
images, images,
} }
} }
if (embedDraft.media?.type === 'gallery') {
const imagesDraft = embedDraft.media.images
logger.debug(`Uploading gallery items`, {
count: imagesDraft.length,
})
onStateChange?.(t`Uploading images...`)
const items: AppBskyEmbedGallery.Image[] = await Promise.all(
imagesDraft.map(async (image, i) => {
logger.debug(`Compressing gallery image #${i}`)
const {path, width, height, mime} = await compressImage(image)
logger.debug(`Uploading gallery image #${i}`)
const res = await uploadBlob(agent, path, mime)
return {
$type: 'app.bsky.embed.gallery#image',
image: res.data.blob,
alt: image.alt,
aspectRatio: {width, height},
}
}),
)
return {
$type: 'app.bsky.embed.gallery',
items,
}
}
if ( if (
embedDraft.media?.type === 'video' && embedDraft.media?.type === 'video' &&
embedDraft.media.video.status === 'done' embedDraft.media.video.status === 'done'
+11
View File
@@ -10,6 +10,8 @@ import {
AppBskyLabelerDefs, AppBskyLabelerDefs,
} from '@atproto/api' } from '@atproto/api'
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
export type Embed = export type Embed =
| { | {
type: 'post' type: 'post'
@@ -47,6 +49,10 @@ export type Embed =
type: 'images' type: 'images'
view: $Typed<AppBskyEmbedImages.View> view: $Typed<AppBskyEmbedImages.View>
} }
| {
type: 'gallery'
view: $Typed<AppBskyEmbedGallery.View>
}
| { | {
type: 'link' type: 'link'
view: $Typed<AppBskyEmbedExternal.View> view: $Typed<AppBskyEmbedExternal.View>
@@ -122,6 +128,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
type: 'images', type: 'images',
view: embed, view: embed,
} }
} else if (AppBskyEmbedGallery.isView(embed)) {
return {
type: 'gallery',
view: embed,
}
} else if (AppBskyEmbedExternal.isView(embed)) { } else if (AppBskyEmbedExternal.isView(embed)) {
return { return {
type: 'link', type: 'link',
+11 -6
View File
@@ -159,7 +159,7 @@ import {
composerReducer, composerReducer,
createComposerState, createComposerState,
type EmbedDraft, type EmbedDraft,
MAX_IMAGES, MAX_GALLERY_IMAGES,
type PostAction, type PostAction,
type PostDraft, type PostDraft,
type ThreadDraft, type ThreadDraft,
@@ -1708,7 +1708,7 @@ function ComposerEmbeds({
const video = embed.media?.type === 'video' ? embed.media.video : null const video = embed.media?.type === 'video' ? embed.media.video : null
return ( return (
<> <>
{embed.media?.type === 'images' && ( {(embed.media?.type === 'images' || embed.media?.type === 'gallery') && (
<Gallery images={embed.media.images} dispatch={dispatch} /> <Gallery images={embed.media.images} dispatch={dispatch} />
)} )}
@@ -1908,15 +1908,16 @@ function ComposerFooter({
>(undefined) >(undefined)
const media = post.embed.media const media = post.embed.media
const images = media?.type === 'images' ? media.images : [] const images =
media?.type === 'images' || media?.type === 'gallery' ? media.images : []
const video = media?.type === 'video' ? media.video : null const video = media?.type === 'video' ? media.video : null
const isMaxImages = images.length >= MAX_IMAGES const isMaxImages = images.length >= MAX_GALLERY_IMAGES
const isMaxVideos = !!video const isMaxVideos = !!video
let selectedAssetsCount = 0 let selectedAssetsCount = 0
let isMediaSelectionDisabled = false let isMediaSelectionDisabled = false
if (media?.type === 'images') { if (media?.type === 'images' || media?.type === 'gallery') {
isMediaSelectionDisabled = isMaxImages isMediaSelectionDisabled = isMaxImages
selectedAssetsCount = images.length selectedAssetsCount = images.length
} else if (media?.type === 'video') { } else if (media?.type === 'video') {
@@ -2017,7 +2018,11 @@ function ComposerFooter({
autoOpen={openGallery} autoOpen={openGallery}
/> />
<OpenCameraBtn <OpenCameraBtn
disabled={media?.type === 'images' ? isMaxImages : !!media} disabled={
media?.type === 'images' || media?.type === 'gallery'
? isMaxImages
: !!media
}
onAdd={onImageAdd} onAdd={onImageAdd}
/> />
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} /> <SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
+22 -5
View File
@@ -10,6 +10,7 @@ import {
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles' import {sanitizeHandle} from '#/lib/strings/handles'
import {type ComposerOptsPostRef} from '#/state/shell/composer' import {type ComposerOptsPostRef} from '#/state/shell/composer'
@@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const images = useMemo(() => { const images = useMemo(() => {
if (AppBskyEmbedImages.isView(embed)) { if (AppBskyEmbedImages.isView(embed)) {
return embed.images return embed.images
} else if ( } else if (AppBskyEmbedGallery.isView(embed)) {
AppBskyEmbedRecordWithMedia.isView(embed) && return galleryItemsToImages(embed.items)
AppBskyEmbedImages.isView(embed.media) } else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
) { if (AppBskyEmbedImages.isView(embed.media)) {
return embed.media.images return embed.media.images
} else if (AppBskyEmbedGallery.isView(embed.media)) {
return galleryItemsToImages(embed.media.items)
}
} }
}, [embed]) }, [embed])
@@ -129,6 +133,19 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
) )
} }
function galleryItemsToImages(
items: AppBskyEmbedGallery.ViewImage[],
): AppBskyEmbedImages.ViewImage[] {
// The reply-to thumbnail only renders up to 4 tiles; slicing here keeps
// the existing layout switch valid for galleries up to 10 items.
return items.slice(0, 4).map(item => ({
thumb: item.thumbnail,
fullsize: item.fullsize,
alt: item.alt,
aspectRatio: item.aspectRatio,
}))
}
function ComposerReplyToImages({ function ComposerReplyToImages({
images, images,
}: { }: {
+11 -8
View File
@@ -16,7 +16,7 @@ import {
} from '#/lib/hooks/usePermissions' } from '#/lib/hooks/usePermissions'
import {openUnifiedPicker} from '#/lib/media/picker' import {openUnifiedPicker} from '#/lib/media/picker'
import {extractDataUriMime} from '#/lib/media/util' import {extractDataUriMime} from '#/lib/media/util'
import {MAX_IMAGES} from '#/view/com/composer/state/composer' import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
@@ -393,7 +393,9 @@ export function SelectMediaButton({
const t = useTheme() const t = useTheme()
const hasAutoOpened = useRef(false) const hasAutoOpened = useRef(false)
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount // Picker uses the gallery cap; the reducer decides which embed variant
// to land in based on the final image count.
const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount
const processSelectedAssets = useCallback( const processSelectedAssets = useCallback(
async (rawAssets: ImagePickerAsset[]) => { async (rawAssets: ImagePickerAsset[]) => {
@@ -419,10 +421,10 @@ export function SelectMediaButton({
), ),
[SelectedAssetError.MaxImages]: _( [SelectedAssetError.MaxImages]: _(
msg({ msg({
message: `You can select up to ${plural(MAX_IMAGES, { message: `You can select up to ${plural(MAX_GALLERY_IMAGES, {
other: '# images', other: '# images',
})} in total.`, })} in total.`,
comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, comment: `Error message for maximum number of images that can be selected to add to a post.`,
}), }),
), ),
[SelectedAssetError.MaxVideos]: _( [SelectedAssetError.MaxVideos]: _(
@@ -507,10 +509,11 @@ export function SelectMediaButton({
)} )}
accessibilityHint={_( accessibilityHint={_(
msg({ msg({
message: `Opens device gallery to select up to ${plural(MAX_IMAGES, { message: `Opens device gallery to select up to ${plural(
other: '# images', MAX_GALLERY_IMAGES,
})}, or a single video or GIF.`, {other: '# images'},
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, )}, or a single video or GIF.`,
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post.`,
}), }),
)} )}
style={a.p_sm} style={a.p_sm}
+99 -43
View File
@@ -4,6 +4,13 @@
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure' import {nanoid} from 'nanoid/non-secure'
// Shim: AppBskyDraftDefs.DraftPost gains an `embedGallery` field in atproto
// PR #4827. Until @atproto/api ships those types, we widen the shape locally.
// Delete this once the lexicon publishes.
type DraftPostWithGallery = AppBskyDraftDefs.DraftPost & {
embedGallery?: AppBskyDraftDefs.DraftEmbedImage[]
}
import {resolveLink} from '#/lib/api/resolve' import {resolveLink} from '#/lib/api/resolve'
import {getDeviceName} from '#/lib/deviceName' import {getDeviceName} from '#/lib/deviceName'
import {getImageDim} from '#/lib/media/manip' import {getImageDim} from '#/lib/media/manip'
@@ -115,6 +122,11 @@ async function postDraftToServerPost(
post.embed.media.images, post.embed.media.images,
localRefPaths, localRefPaths,
) )
} else if (post.embed.media.type === 'gallery') {
;(draftPost as DraftPostWithGallery).embedGallery = serializeImages(
post.embed.media.images,
localRefPaths,
)
} else if (post.embed.media.type === 'video') { } else if (post.embed.media.type === 'video') {
const video = await serializeVideo(post.embed.media.video, localRefPaths) const video = await serializeVideo(post.embed.media.video, localRefPaths)
if (video) { if (video) {
@@ -269,6 +281,59 @@ function serializeGif(gifMedia: {
} }
} }
/**
* Restore an array of draft image refs back to ComposerImages. Shared by
* both the `embedImages` and `embedGallery` paths in draftToComposerPosts.
*/
async function restoreDraftImages(
draftImages: AppBskyDraftDefs.DraftEmbedImage[],
loadedMedia: Map<string, string>,
): Promise<ComposerImage[]> {
const imagePromises = draftImages.map(async img => {
const path = loadedMedia.get(img.localRef.path)
if (!path) {
return null
}
let width = 0
let height = 0
try {
const dims = await getImageDim(path)
width = dims.width
height = dims.height
} catch (e) {
logger.warn('Failed to get image dimensions', {
path,
error: e,
})
}
logger.debug('restoring image with localRefPath', {
localRefPath: img.localRef.path,
loadedPath: path,
width,
height,
})
return {
alt: img.alt || '',
// Preserve the original localRefPath for reuse when saving
localRefPath: img.localRef.path,
source: {
id: nanoid(),
path,
width,
height,
mime: 'image/jpeg',
},
} as ComposerImage
})
return (await Promise.all(imagePromises)).filter(
(img): img is ComposerImage => img !== null,
)
}
/** /**
* Convert server DraftView to DraftSummary for list display. * Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally. * Also checks which media files exist locally.
@@ -314,6 +379,24 @@ export function draftViewToSummary({
} }
} }
// Process gallery
const summaryEmbedGallery = (post as DraftPostWithGallery).embedGallery
if (summaryEmbedGallery) {
for (const img of summaryEmbedGallery) {
meta.mediaCount++
meta.hasMedia = true
const exists = storage.mediaExists(img.localRef.path)
if (!exists) {
meta.hasMissingMedia = true
}
images.push({
localPath: img.localRef.path,
altText: img.alt || '',
exists,
})
}
}
// Process videos // Process videos
if (post.embedVideos) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
@@ -433,54 +516,21 @@ export async function draftToComposerPosts(
// Restore images // Restore images
if (post.embedImages && post.embedImages.length > 0) { if (post.embedImages && post.embedImages.length > 0) {
const imagePromises = post.embedImages.map(async img => { const images = await restoreDraftImages(post.embedImages, loadedMedia)
const path = loadedMedia.get(img.localRef.path)
if (!path) {
return null
}
let width = 0
let height = 0
try {
const dims = await getImageDim(path)
width = dims.width
height = dims.height
} catch (e) {
logger.warn('Failed to get image dimensions', {
path,
error: e,
})
}
logger.debug('restoring image with localRefPath', {
localRefPath: img.localRef.path,
loadedPath: path,
width,
height,
})
return {
alt: img.alt || '',
// Preserve the original localRefPath for reuse when saving
localRefPath: img.localRef.path,
source: {
id: nanoid(),
path,
width,
height,
mime: 'image/jpeg',
},
} as ComposerImage
})
const images = (await Promise.all(imagePromises)).filter(
(img): img is ComposerImage => img !== null,
)
if (images.length > 0) { if (images.length > 0) {
embed.media = {type: 'images', images} embed.media = {type: 'images', images}
} }
} }
// Restore gallery
const embedGallery = (post as DraftPostWithGallery).embedGallery
if (embedGallery && embedGallery.length > 0) {
const images = await restoreDraftImages(embedGallery, loadedMedia)
if (images.length > 0) {
embed.media = {type: 'gallery', images}
}
}
// Restore GIF from external embed // Restore GIF from external embed
if (post.embedExternals) { if (post.embedExternals) {
for (const ext of post.embedExternals) { for (const ext of post.embedExternals) {
@@ -630,6 +680,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
refs.add(img.localRef.path) refs.add(img.localRef.path)
} }
} }
const embedGallery = (post as DraftPostWithGallery).embedGallery
if (embedGallery) {
for (const img of embedGallery) {
refs.add(img.localRef.path)
}
}
if (post.embedVideos) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
refs.add(vid.localRef.path) refs.add(vid.localRef.path)
@@ -1,4 +1,10 @@
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api' import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
// Shim: AppBskyDraftDefs.DraftPost gains `embedGallery` in atproto PR #4827.
// Delete once @atproto/api publishes the new lexicon.
type DraftPostWithGallery = AppBskyDraftDefs.DraftPost & {
embedGallery?: AppBskyDraftDefs.DraftEmbedImage[]
}
import { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, useMutation,
@@ -74,6 +80,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
} }
} }
} }
// Load gallery
const embedGallery = (post as DraftPostWithGallery).embedGallery
if (embedGallery) {
for (const img of embedGallery) {
try {
const url = await storage.loadMediaFromLocal(img.localRef.path)
loadedMedia.set(img.localRef.path, url)
} catch (e) {
logger.error('Failed to load draft gallery image', {
path: img.localRef.path,
safeMessage: e instanceof Error ? e.message : String(e),
})
}
}
}
// Load videos // Load videos
if (post.embedVideos) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
@@ -226,6 +247,12 @@ export function useDeleteDraftMutation() {
await storage.deleteMediaFromLocal(img.localRef.path) await storage.deleteMediaFromLocal(img.localRef.path)
} }
} }
const embedGallery = (post as DraftPostWithGallery).embedGallery
if (embedGallery) {
for (const img of embedGallery) {
await storage.deleteMediaFromLocal(img.localRef.path)
}
}
if (post.embedVideos) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
await storage.deleteMediaFromLocal(vid.localRef.path) await storage.deleteMediaFromLocal(vid.localRef.path)
+51 -14
View File
@@ -38,6 +38,11 @@ type ImagesMedia = {
images: ComposerImage[] images: ComposerImage[]
} }
type GalleryMedia = {
type: 'gallery'
images: ComposerImage[]
}
type VideoMedia = { type VideoMedia = {
type: 'video' type: 'video'
video: VideoState video: VideoState
@@ -59,7 +64,7 @@ type Link = {
export type EmbedDraft = { export type EmbedDraft = {
// We'll always submit quote and actual media (images, video, gifs) chosen by the user. // We'll always submit quote and actual media (images, video, gifs) chosen by the user.
quote: Link | undefined quote: Link | undefined
media: ImagesMedia | VideoMedia | GifMedia | undefined media: ImagesMedia | GalleryMedia | VideoMedia | GifMedia | undefined
// This field may end up ignored if we have more important things to display than a link card: // This field may end up ignored if we have more important things to display than a link card:
link: Link | undefined link: Link | undefined
} }
@@ -155,6 +160,7 @@ export type ComposerAction =
} }
export const MAX_IMAGES = 4 export const MAX_IMAGES = 4
export const MAX_GALLERY_IMAGES = 10
export function composerReducer( export function composerReducer(
state: ComposerState, state: ComposerState,
@@ -338,14 +344,38 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
const prevMedia = state.embed.media const prevMedia = state.embed.media
let nextMedia = prevMedia let nextMedia = prevMedia
if (!prevMedia) { if (!prevMedia) {
nextMedia = { // First selection: pick the variant based on count. ImagesMedia caps
type: 'images', // at 4 (legacy `app.bsky.embed.images`); above that promotes to the
images: action.images.slice(0, MAX_IMAGES), // new `app.bsky.embed.gallery` (capped at 10).
if (action.images.length <= MAX_IMAGES) {
nextMedia = {
type: 'images',
images: action.images.slice(0, MAX_IMAGES),
}
} else {
nextMedia = {
type: 'gallery',
images: action.images.slice(0, MAX_GALLERY_IMAGES),
}
} }
} else if (prevMedia.type === 'images') { } else if (prevMedia.type === 'images') {
const combined = [...prevMedia.images, ...action.images]
if (combined.length <= MAX_IMAGES) {
nextMedia = {...prevMedia, images: combined}
} else {
// Adding more than 4 promotes the existing images into a gallery.
nextMedia = {
type: 'gallery',
images: combined.slice(0, MAX_GALLERY_IMAGES),
}
}
} else if (prevMedia.type === 'gallery') {
nextMedia = { nextMedia = {
...prevMedia, ...prevMedia,
images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), images: [...prevMedia.images, ...action.images].slice(
0,
MAX_GALLERY_IMAGES,
),
} }
} }
return { return {
@@ -358,7 +388,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
} }
case 'embed_update_image': { case 'embed_update_image': {
const prevMedia = state.embed.media const prevMedia = state.embed.media
if (prevMedia?.type === 'images') { if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
const updatedImage = action.image const updatedImage = action.image
const nextMedia = { const nextMedia = {
...prevMedia, ...prevMedia,
@@ -382,19 +412,26 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
case 'embed_remove_image': { case 'embed_remove_image': {
const prevMedia = state.embed.media const prevMedia = state.embed.media
let nextLabels = state.labels let nextLabels = state.labels
if (prevMedia?.type === 'images') { if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
const removedImage = action.image const removedImage = action.image
let nextMedia: ImagesMedia | undefined = { const remainingImages = prevMedia.images.filter(img => {
...prevMedia, return img.source.id !== removedImage.source.id
images: prevMedia.images.filter(img => { })
return img.source.id !== removedImage.source.id let nextMedia: ImagesMedia | GalleryMedia | undefined
}), if (remainingImages.length === 0) {
}
if (nextMedia.images.length === 0) {
nextMedia = undefined nextMedia = undefined
if (!state.embed.link) { if (!state.embed.link) {
nextLabels = [] nextLabels = []
} }
} else if (
prevMedia.type === 'gallery' &&
remainingImages.length <= MAX_IMAGES
) {
// Drop back to the legacy `app.bsky.embed.images` shape when a
// gallery shrinks to <=4 items, so old clients still see it.
nextMedia = {type: 'images', images: remainingImages}
} else {
nextMedia = {...prevMedia, images: remainingImages}
} }
return { return {
...state, ...state,
+3 -3
View File
@@ -12,7 +12,7 @@ import {
} from '#/lib/hooks/usePermissions' } from '#/lib/hooks/usePermissions'
import {openCamera, openUnifiedPicker} from '#/lib/media/picker' import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {MAX_IMAGES} from '#/view/com/composer/state/composer' import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
@@ -64,7 +64,7 @@ export function ComposerPrompt() {
Keyboard.dismiss() Keyboard.dismiss()
} }
const selectionCountRemaining = MAX_IMAGES const selectionCountRemaining = MAX_GALLERY_IMAGES
const {assets, canceled} = await sheetWrapper( const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}), openUnifiedPicker({selectionCountRemaining}),
) )
@@ -76,7 +76,7 @@ export function ComposerPrompt() {
if (assets.length > 0) { if (assets.length > 0) {
const imageUris = assets const imageUris = assets
.filter(asset => asset.mimeType?.startsWith('image/')) .filter(asset => asset.mimeType?.startsWith('image/'))
.slice(0, MAX_IMAGES) .slice(0, MAX_GALLERY_IMAGES)
.map(asset => ({ .map(asset => ({
uri: asset.uri, uri: asset.uri,
width: asset.width, width: asset.width,