diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index 9aad6da864..98628844f9 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -47,6 +47,28 @@ export function Embed({ )} ) + } else if (e.type === 'gallery') { + return ( + + {e.view.items.map(item => { + const image: AppBskyEmbedImages.ViewImage = { + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + } + return peekable ? ( + + ) : ( + + ) + })} + + ) } else if (e.type === 'link') { if (!e.view.external.thumb) return null if (!isGifEmbed(e.view.external.uri)) return null diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index ccd1ceb270..c3f77bd33f 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -2,6 +2,7 @@ import {useRef} from 'react' import {InteractionManager, View} from 'react-native' import {type AnimatedRef} from 'react-native-reanimated' import {Image} from 'expo-image' +import {type AppBskyEmbedImages} from '@atproto/api' import {atoms as a, tokens} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' @@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' import {type CommonProps} from './types' +const GRID_TO_CAROUSEL_THRESHOLD = 4 + export function ImageEmbed({ embed, ...rest }: CommonProps & { - embed: EmbedType<'images'> + embed: EmbedType<'images'> | EmbedType<'gallery'> }) { const ax = useAnalytics() const {openLightbox} = useLightboxControls() - const {images} = embed.view - const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) + const images: AppBskyEmbedImages.ViewImage[] = + 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 // 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 ( @@ -87,7 +88,8 @@ function MediaEmbed({ embed: TEmbed }) { switch (embed.type) { - case 'images': { + case 'images': + case 'gallery': { return ( ( + embed, + AppBskyEmbedGallery.isMain, + ) const isRecordWithMedia = embed && bsky.dangerousIsType( @@ -61,6 +68,11 @@ export function maybeApplyGalleryOffsetStyles( if (embed.images.length === 1) return hasImages = true } + if (isGalleryEmbed) { + // one image, not a gallery + if (embed.items.length === 1) return + hasImages = true + } if (isRecordWithMedia) { if ( bsky.dangerousIsType( @@ -71,6 +83,15 @@ export function maybeApplyGalleryOffsetStyles( // one image, not a gallery if (embed.media.images.length === 1) return } + if ( + bsky.dangerousIsType( + embed.media, + AppBskyEmbedGallery.isMain, + ) + ) { + // one image, not a gallery + if (embed.media.items.length === 1) return + } hasImages = true } if (!hasImages) return diff --git a/src/lib/api/gallery-embed-shim.impl.ts b/src/lib/api/gallery-embed-shim.impl.ts new file mode 100644 index 0000000000..3788eb9e7b --- /dev/null +++ b/src/lib/api/gallery-embed-shim.impl.ts @@ -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 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 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 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 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' + ) +} diff --git a/src/lib/api/gallery-embed-shim.ts b/src/lib/api/gallery-embed-shim.ts new file mode 100644 index 0000000000..b65a098985 --- /dev/null +++ b/src/lib/api/gallery-embed-shim.ts @@ -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} diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index d9a026ca83..283f552ec6 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -21,6 +21,7 @@ import {sha256} from 'js-sha256' import {CID} from 'multiformats/cid' import * as Hasher from 'multiformats/hashes/hasher' +import {type AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim' import {isNetworkError} from '#/lib/strings/errors' import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip' import {logger} from '#/logger' @@ -252,6 +253,7 @@ async function resolveEmbed( onStateChange: ((state: string) => void) | undefined, ): Promise< | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -311,6 +313,7 @@ async function resolveMedia( ): Promise< | $Typed | $Typed + | $Typed | $Typed | undefined > { @@ -338,6 +341,31 @@ async function resolveMedia( 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 ( embedDraft.media?.type === 'video' && embedDraft.media.video.status === 'done' diff --git a/src/types/bsky/post.ts b/src/types/bsky/post.ts index fada39da81..4020c1632d 100644 --- a/src/types/bsky/post.ts +++ b/src/types/bsky/post.ts @@ -10,6 +10,8 @@ import { AppBskyLabelerDefs, } from '@atproto/api' +import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim' + export type Embed = | { type: 'post' @@ -47,6 +49,10 @@ export type Embed = type: 'images' view: $Typed } + | { + type: 'gallery' + view: $Typed + } | { type: 'link' view: $Typed @@ -122,6 +128,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed { type: 'images', view: embed, } + } else if (AppBskyEmbedGallery.isView(embed)) { + return { + type: 'gallery', + view: embed, + } } else if (AppBskyEmbedExternal.isView(embed)) { return { type: 'link', diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 5c9f1a05fe..b75ee8e3bc 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -159,7 +159,7 @@ import { composerReducer, createComposerState, type EmbedDraft, - MAX_IMAGES, + MAX_GALLERY_IMAGES, type PostAction, type PostDraft, type ThreadDraft, @@ -1708,7 +1708,7 @@ function ComposerEmbeds({ const video = embed.media?.type === 'video' ? embed.media.video : null return ( <> - {embed.media?.type === 'images' && ( + {(embed.media?.type === 'images' || embed.media?.type === 'gallery') && ( )} @@ -1908,15 +1908,16 @@ function ComposerFooter({ >(undefined) 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 isMaxImages = images.length >= MAX_IMAGES + const isMaxImages = images.length >= MAX_GALLERY_IMAGES const isMaxVideos = !!video let selectedAssetsCount = 0 let isMediaSelectionDisabled = false - if (media?.type === 'images') { + if (media?.type === 'images' || media?.type === 'gallery') { isMediaSelectionDisabled = isMaxImages selectedAssetsCount = images.length } else if (media?.type === 'video') { @@ -2017,7 +2018,11 @@ function ComposerFooter({ autoOpen={openGallery} /> diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index a6ef1a344c..8a889d7f9c 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -10,6 +10,7 @@ import { import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' +import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim' import {sanitizeDisplayName} from '#/lib/strings/display-names' import {sanitizeHandle} from '#/lib/strings/handles' import {type ComposerOptsPostRef} from '#/state/shell/composer' @@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { const images = useMemo(() => { if (AppBskyEmbedImages.isView(embed)) { return embed.images - } else if ( - AppBskyEmbedRecordWithMedia.isView(embed) && - AppBskyEmbedImages.isView(embed.media) - ) { - return embed.media.images + } else if (AppBskyEmbedGallery.isView(embed)) { + return galleryItemsToImages(embed.items) + } else if (AppBskyEmbedRecordWithMedia.isView(embed)) { + if (AppBskyEmbedImages.isView(embed.media)) { + return embed.media.images + } else if (AppBskyEmbedGallery.isView(embed.media)) { + return galleryItemsToImages(embed.media.items) + } } }, [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({ images, }: { diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx index 2d70195488..1de39f5f54 100644 --- a/src/view/com/composer/SelectMediaButton.tsx +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -16,7 +16,7 @@ import { } from '#/lib/hooks/usePermissions' import {openUnifiedPicker} from '#/lib/media/picker' 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 {Button} from '#/components/Button' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' @@ -393,7 +393,9 @@ export function SelectMediaButton({ const t = useTheme() 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( async (rawAssets: ImagePickerAsset[]) => { @@ -419,10 +421,10 @@ export function SelectMediaButton({ ), [SelectedAssetError.MaxImages]: _( msg({ - message: `You can select up to ${plural(MAX_IMAGES, { + message: `You can select up to ${plural(MAX_GALLERY_IMAGES, { other: '# images', })} 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]: _( @@ -507,10 +509,11 @@ export function SelectMediaButton({ )} accessibilityHint={_( msg({ - message: `Opens device gallery to select up to ${plural(MAX_IMAGES, { - other: '# images', - })}, or a single video or GIF.`, - 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.`, + message: `Opens device gallery to select up to ${plural( + MAX_GALLERY_IMAGES, + {other: '# images'}, + )}, 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} diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 1099c1a0fd..cca5f2b855 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -4,6 +4,13 @@ import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' 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 {getDeviceName} from '#/lib/deviceName' import {getImageDim} from '#/lib/media/manip' @@ -115,6 +122,11 @@ async function postDraftToServerPost( post.embed.media.images, 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') { const video = await serializeVideo(post.embed.media.video, localRefPaths) 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, +): Promise { + 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. * 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 if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -433,54 +516,21 @@ export async function draftToComposerPosts( // Restore images if (post.embedImages && post.embedImages.length > 0) { - const imagePromises = post.embedImages.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 - }) - - const images = (await Promise.all(imagePromises)).filter( - (img): img is ComposerImage => img !== null, - ) + const images = await restoreDraftImages(post.embedImages, loadedMedia) if (images.length > 0) { 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 if (post.embedExternals) { for (const ext of post.embedExternals) { @@ -630,6 +680,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set { 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) { for (const vid of post.embedVideos) { refs.add(vid.localRef.path) diff --git a/src/view/com/composer/drafts/state/queries.ts b/src/view/com/composer/drafts/state/queries.ts index e66c9d023b..c0d896da61 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -1,4 +1,10 @@ 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 { useInfiniteQuery, 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 if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -226,6 +247,12 @@ export function useDeleteDraftMutation() { 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) { for (const vid of post.embedVideos) { await storage.deleteMediaFromLocal(vid.localRef.path) diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index d2b9af7b8e..ada15940c9 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -38,6 +38,11 @@ type ImagesMedia = { images: ComposerImage[] } +type GalleryMedia = { + type: 'gallery' + images: ComposerImage[] +} + type VideoMedia = { type: 'video' video: VideoState @@ -59,7 +64,7 @@ type Link = { export type EmbedDraft = { // We'll always submit quote and actual media (images, video, gifs) chosen by the user. 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: link: Link | undefined } @@ -155,6 +160,7 @@ export type ComposerAction = } export const MAX_IMAGES = 4 +export const MAX_GALLERY_IMAGES = 10 export function composerReducer( state: ComposerState, @@ -338,14 +344,38 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { const prevMedia = state.embed.media let nextMedia = prevMedia if (!prevMedia) { - nextMedia = { - type: 'images', - images: action.images.slice(0, MAX_IMAGES), + // First selection: pick the variant based on count. ImagesMedia caps + // at 4 (legacy `app.bsky.embed.images`); above that promotes to the + // 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') { + 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 = { ...prevMedia, - images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), + images: [...prevMedia.images, ...action.images].slice( + 0, + MAX_GALLERY_IMAGES, + ), } } return { @@ -358,7 +388,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { } case 'embed_update_image': { const prevMedia = state.embed.media - if (prevMedia?.type === 'images') { + if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') { const updatedImage = action.image const nextMedia = { ...prevMedia, @@ -382,19 +412,26 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { case 'embed_remove_image': { const prevMedia = state.embed.media let nextLabels = state.labels - if (prevMedia?.type === 'images') { + if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') { const removedImage = action.image - let nextMedia: ImagesMedia | undefined = { - ...prevMedia, - images: prevMedia.images.filter(img => { - return img.source.id !== removedImage.source.id - }), - } - if (nextMedia.images.length === 0) { + const remainingImages = prevMedia.images.filter(img => { + return img.source.id !== removedImage.source.id + }) + let nextMedia: ImagesMedia | GalleryMedia | undefined + if (remainingImages.length === 0) { nextMedia = undefined if (!state.embed.link) { 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 { ...state, diff --git a/src/view/com/feeds/ComposerPrompt.tsx b/src/view/com/feeds/ComposerPrompt.tsx index b35c1c1adf..592266871e 100644 --- a/src/view/com/feeds/ComposerPrompt.tsx +++ b/src/view/com/feeds/ComposerPrompt.tsx @@ -12,7 +12,7 @@ import { } from '#/lib/hooks/usePermissions' import {openCamera, openUnifiedPicker} from '#/lib/media/picker' 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 {atoms as a, native, useTheme, web} from '#/alf' import {Button} from '#/components/Button' @@ -64,7 +64,7 @@ export function ComposerPrompt() { Keyboard.dismiss() } - const selectionCountRemaining = MAX_IMAGES + const selectionCountRemaining = MAX_GALLERY_IMAGES const {assets, canceled} = await sheetWrapper( openUnifiedPicker({selectionCountRemaining}), ) @@ -76,7 +76,7 @@ export function ComposerPrompt() { if (assets.length > 0) { const imageUris = assets .filter(asset => asset.mimeType?.startsWith('image/')) - .slice(0, MAX_IMAGES) + .slice(0, MAX_GALLERY_IMAGES) .map(asset => ({ uri: asset.uri, width: asset.width,