From 144ba30ea647f4d80a2e76b14a380f3eb0a02149 Mon Sep 17 00:00:00 2001 From: Spence Pope Date: Thu, 4 Jun 2026 17:22:40 -0400 Subject: [PATCH] Add gallery embed type support (display + compose) (#10707) --- package.json | 2 +- pnpm-lock.yaml | 10 +- src/components/MediaPreview.tsx | 34 +++- src/components/Post/Embed/ImageEmbed.tsx | 23 ++- src/components/Post/Embed/index.tsx | 4 +- .../Gallery/maybeApplyGalleryOffsetStyles.ts | 33 +++- src/components/images/ImageLayoutGrid.tsx | 22 ++- src/components/images/ImageLayoutGridItem.tsx | 3 + .../ReportDialog/utils/parseReportSubject.ts | 5 +- src/lib/api/index.ts | 31 ++++ src/types/bsky/post.ts | 10 ++ src/view/com/composer/Composer.tsx | 124 ++++++++++---- src/view/com/composer/ComposerReplyTo.tsx | 30 +++- src/view/com/composer/SelectMediaButton.tsx | 19 ++- src/view/com/composer/drafts/state/api.ts | 159 ++++++++++++------ src/view/com/composer/drafts/state/queries.ts | 23 ++- src/view/com/composer/photos/Gallery.tsx | 7 +- src/view/com/composer/state/composer.ts | 92 +++++++--- src/view/com/feeds/ComposerPrompt.tsx | 6 +- 19 files changed, 494 insertions(+), 143 deletions(-) diff --git a/package.json b/package.json index c515ecdcee..a879182d9f 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "prettier": "prettier --check ." }, "dependencies": { - "@atproto/api": "0.20.8", + "@atproto/api": "0.20.9", "@atproto/syntax": "0.6.1", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d0136363f7..0ac8974cf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,8 +242,8 @@ importers: .: dependencies: '@atproto/api': - specifier: 0.20.8 - version: 0.20.8 + specifier: 0.20.9 + version: 0.20.9 '@atproto/syntax': specifier: 0.6.1 version: 0.6.1 @@ -877,8 +877,8 @@ packages: graphql: optional: true - '@atproto/api@0.20.8': - resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==} + '@atproto/api@0.20.9': + resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==} engines: {node: '>=22'} '@atproto/common-web@0.5.0': @@ -9493,7 +9493,7 @@ snapshots: '@0no-co/graphql.web@1.2.0': {} - '@atproto/api@0.20.8': + '@atproto/api@0.20.9': dependencies: '@atproto/common-web': 0.5.0 '@atproto/lexicon': 0.7.1 diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx index b36705d840..034094556c 100644 --- a/src/components/MediaPreview.tsx +++ b/src/components/MediaPreview.tsx @@ -1,6 +1,10 @@ import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {Image} from 'expo-image' -import {type AppBskyEmbedImages, type AppBskyFeedDefs} from '@atproto/api' +import { + AppBskyEmbedGallery, + type AppBskyEmbedImages, + type AppBskyFeedDefs, +} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' import {shareImageModal} from '#/lib/media/manip' @@ -47,6 +51,34 @@ export function Embed({ )} ) + } else if (e.type === 'gallery') { + // Notification/DM preview is a narrow inline strip; cap at 4 tiles so + // a 10-image gallery doesn't blow out the row width. Single pass instead + // of filter().slice().map() so we stop at 4 viewable items rather than + // walking every item in a 10-image gallery. + const tiles: React.ReactNode[] = [] + for (const item of e.view.items) { + if (tiles.length >= 4) break + if (!AppBskyEmbedGallery.isViewImage(item)) continue + if (peekable) { + const image: AppBskyEmbedImages.ViewImage = { + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + } + tiles.push() + } else { + tiles.push( + , + ) + } + } + return {tiles} } 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 2c6bb6c5de..0a620efde4 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 {AppBskyEmbedGallery, 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 MAX_GRID_IMAGES = 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.filter(AppBskyEmbedGallery.isViewImage).map(item => ({ + thumb: item.thumbnail, + fullsize: item.fullsize, + alt: item.alt, + aspectRatio: item.aspectRatio, + })) + : embed.view.images + const useExpandedLayout = + embed.type === 'gallery' + ? images.length > MAX_GRID_IMAGES + : 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. @@ -109,7 +123,7 @@ export function ImageEmbed({ ) } - if (galleryEnabled) { + if (useExpandedLayout) { return ( ) diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index ab6f5dc439..28eed2aaa9 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -54,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) { switch (embed.type) { case 'images': + case 'gallery': case 'link': case 'video': { return @@ -89,7 +90,8 @@ function MediaEmbed({ embed: TEmbed }) { switch (embed.type) { - case 'images': { + case 'images': + case 'gallery': { return ( ( post.record, @@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles( return } + // The gate only controls whether legacy image embeds opt into the new + // expanded gallery layout. Gallery embeds always render expanded by item + // count, so their offset must apply regardless of the gate. + const isPostGalleryEmbedEnabled = features.isOn( + Features.PostGalleryEmbedEnable, + ) + /* * First check if we even have images */ @@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles( embed, AppBskyEmbedImages.isMain, ) + const isGalleryEmbed = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedGallery.isMain, + ) const isRecordWithMedia = embed && bsky.dangerousIsType( @@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles( ) let hasImages = false if (isImageEmbed) { + if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.images.length === 1) return hasImages = true } + if (isGalleryEmbed) { + // single (or empty) gallery - no offset needed + if (embed.items.length <= 1) return + hasImages = true + } if (isRecordWithMedia) { if ( bsky.dangerousIsType( @@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles( AppBskyEmbedImages.isMain, ) ) { + if (!isPostGalleryEmbedEnabled) return // one image, not a gallery if (embed.media.images.length === 1) return } + if ( + bsky.dangerousIsType( + embed.media, + AppBskyEmbedGallery.isMain, + ) + ) { + // single (or empty) gallery - no offset needed + if (embed.media.items.length <= 1) return + } hasImages = true } if (!hasImages) return diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 0017ddf9cf..5b4ba608b2 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -19,21 +19,28 @@ interface ImageLayoutGridProps { onPressIn?: (index: number) => void style?: StyleProp viewContext?: PostEmbedViewContext + isWithinQuote?: boolean } -export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { +export function ImageLayoutGrid({ + style, + isWithinQuote: isWithinQuoteProp, + ...props +}: ImageLayoutGridProps) { const {gtMobile} = useBreakpoints() - const gap = + const isWithinQuote = + isWithinQuoteProp ?? props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - ? gtMobile - ? a.gap_xs - : a.gap_2xs - : a.gap_xs + const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs return ( - + ) @@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps { onLongPress?: (index: number) => void onPressIn?: (index: number) => void viewContext?: PostEmbedViewContext + isWithinQuote?: boolean gap: {gap: number} } diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index d6cbc0aded..640ecd8ba2 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -29,6 +29,7 @@ interface Props { onPressIn?: EventFunction imageStyle?: StyleProp viewContext?: PostEmbedViewContext + isWithinQuote?: boolean insetBorderStyle?: StyleProp containerRefs: AnimatedRef[] thumbDimsRef: React.RefObject<(Dimensions | null)[]> @@ -42,6 +43,7 @@ export function GalleryItem({ onPressIn, onLongPress, viewContext, + isWithinQuote, insetBorderStyle, containerRefs, thumbDimsRef, @@ -52,6 +54,7 @@ export function GalleryItem({ const image = images[index] const hasAlt = !!image.alt const hideBadges = + isWithinQuote ?? viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia const aspect = diff --git a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts index 405640c453..a7d4b94c32 100644 --- a/src/components/moderation/ReportDialog/utils/parseReportSubject.ts +++ b/src/components/moderation/ReportDialog/utils/parseReportSubject.ts @@ -87,7 +87,10 @@ export function parseReportSubject( reply: !!record.reply, image: embed.type === 'images' || - (embed.type === 'post_with_media' && embed.media.type === 'images'), + embed.type === 'gallery' || + (embed.type === 'post_with_media' && + (embed.media.type === 'images' || + embed.media.type === 'gallery')), video: embed.type === 'video' || (embed.type === 'post_with_media' && embed.media.type === 'video'), diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 7de4e13e56..6f92e2a1dd 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -1,6 +1,7 @@ import { type $Typed, type AppBskyEmbedExternal, + type AppBskyEmbedGallery, type AppBskyEmbedImages, type AppBskyEmbedRecord, type AppBskyEmbedRecordWithMedia, @@ -254,6 +255,7 @@ async function resolveEmbed( onStateChange: ((state: string) => void) | undefined, ): Promise< | $Typed + | $Typed | $Typed | $Typed | $Typed @@ -313,6 +315,7 @@ async function resolveMedia( ): Promise< | $Typed | $Typed + | $Typed | $Typed | undefined > { @@ -343,6 +346,34 @@ async function resolveMedia( images, } } + if (embedDraft.media?.type === 'gallery') { + const imagesDraft = embedDraft.media.images + logger.debug(`Uploading images`, { + count: imagesDraft.length, + }) + onStateChange?.(t`Uploading images...`) + const items: $Typed[] = await Promise.all( + imagesDraft.map(async (image, i) => { + logger.debug(`Compressing image #${i}`) + const {path, width, height, mime} = await compressImage( + image, + IMAGE_SIZE_CONFIG_POSTS, + ) + logger.debug(`Uploading image #${i}`) + const res = await uploadBlob(agent, path, mime) + return { + $type: 'app.bsky.embed.gallery#image' as const, + 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..43621ef63b 100644 --- a/src/types/bsky/post.ts +++ b/src/types/bsky/post.ts @@ -1,6 +1,7 @@ import { type $Typed, AppBskyEmbedExternal, + AppBskyEmbedGallery, AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, @@ -47,6 +48,10 @@ export type Embed = type: 'images' view: $Typed } + | { + type: 'gallery' + view: $Typed + } | { type: 'link' view: $Typed @@ -122,6 +127,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..a2493557b7 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, @@ -178,6 +178,65 @@ type CancelRef = { onPressCancel: () => void } +function applyGalleryCap( + currentCount: number, + incoming: ComposerImage[], +): + | {status: 'full'} + | {status: 'partial'; accepted: ComposerImage[]; dropped: number} + | {status: 'ok'; accepted: ComposerImage[]} { + const remaining = MAX_GALLERY_IMAGES - currentCount + if (remaining <= 0) { + return {status: 'full'} + } + if (incoming.length > remaining) { + return { + status: 'partial', + accepted: incoming.slice(0, remaining), + dropped: incoming.length - remaining, + } + } + return {status: 'ok', accepted: incoming} +} + +function useAddImagesWithCap( + currentCount: number, + dispatchPostAction: (action: PostAction) => void, +) { + const {t: l} = useLingui() + return useCallback( + (next: ComposerImage[]) => { + const result = applyGalleryCap(currentCount, next) + if (result.status === 'full') { + Toast.show( + l({ + message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`, + comment: + 'Toast shown when the user tries to add more images but the post gallery is already at the cap', + }), + {type: 'warning'}, + ) + return + } + if (result.status === 'partial') { + Toast.show( + l({ + message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`, + comment: + 'Toast shown when adding images would exceed the post gallery cap; only the first N are kept', + }), + {type: 'warning'}, + ) + } + dispatchPostAction({ + type: 'embed_add_images', + images: result.accepted, + }) + }, + [currentCount, dispatchPostAction, l], + ) +} + type Props = ComposerOpts export const ComposePost = ({ replyTo, @@ -611,7 +670,11 @@ export const ComposePost = ({ ax.metric('draft:save', { isNewDraft, hasText: posts.some(p => p.richtext.text.trim().length > 0), - hasImages: posts.some(p => p.embed.media?.type === 'images'), + hasImages: posts.some( + p => + p.embed.media?.type === 'images' || + p.embed.media?.type === 'gallery', + ), hasVideo: posts.some(p => p.embed.media?.type === 'video'), hasGif: posts.some(p => p.embed.media?.type === 'gif'), hasQuote: posts.some(p => !!p.embed.quote), @@ -780,7 +843,10 @@ export const ComposePost = ({ for (let i = 0; i < thread.posts.length; i++) { const media = thread.posts[i].embed.media if (media) { - if (media.type === 'images' && media.images.some(img => !img.alt)) { + if ( + (media.type === 'images' || media.type === 'gallery') && + media.images.some(img => !img.alt) + ) { return l`One or more images is missing alt text.` } if (media.type === 'gif' && !media.alt) { @@ -931,7 +997,9 @@ export const ComposePost = ({ logger.error(e, { message: `Composer: create post failed`, hasImages: filteredThread.posts.some( - p => p.embed.media?.type === 'images', + p => + p.embed.media?.type === 'images' || + p.embed.media?.type === 'gallery', ), }) @@ -953,7 +1021,8 @@ export const ComposePost = ({ for (let post of filteredThread.posts) { ax.metric('post:create', { imageCount: - post.embed.media?.type === 'images' + post.embed.media?.type === 'images' || + post.embed.media?.type === 'gallery' ? post.embed.media.images.length : 0, isReply: index > 0 || !!replyTo, @@ -1395,15 +1464,11 @@ let ComposerPost = memo(function ComposerPost({ [dispatch, post.id], ) - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatchPost({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatchPost], - ) + const postImagesCount = + post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery' + ? post.embed.media.images.length + : 0 + const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost) const onNewLink = useCallback( (uri: string) => { @@ -1708,7 +1773,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') && ( )} @@ -1819,7 +1884,11 @@ function ComposerPills({ }) { const t = useTheme() const media = post.embed.media - const hasMedia = media?.type === 'images' || media?.type === 'video' + const hasMedia = + media?.type === 'images' || + media?.type === 'gallery' || + media?.type === 'gif' || + media?.type === 'video' const hasLink = !!post.embed.link // Don't render anything if no pills are going to be displayed @@ -1908,15 +1977,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') { @@ -1926,15 +1996,7 @@ function ComposerFooter({ isMediaSelectionDisabled = !!media } - const onImageAdd = useCallback( - (next: ComposerImage[]) => { - dispatch({ - type: 'embed_add_images', - images: next, - }) - }, - [dispatch], - ) + const onImageAdd = useAddImagesWithCap(images.length, dispatch) const onSelectGif = useCallback( (gif: Gif) => { @@ -2017,7 +2079,11 @@ function ComposerFooter({ autoOpen={openGallery} /> diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx index bac3e74d31..235139ef86 100644 --- a/src/view/com/composer/ComposerReplyTo.tsx +++ b/src/view/com/composer/ComposerReplyTo.tsx @@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react' import {LayoutAnimation, Pressable, View} from 'react-native' import {Image} from 'expo-image' import { + AppBskyEmbedGallery, AppBskyEmbedImages, AppBskyEmbedRecord, AppBskyEmbedRecordWithMedia, @@ -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,22 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) { ) } +function galleryItemsToImages( + items: AppBskyEmbedGallery.View['items'], +): 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 + .filter(AppBskyEmbedGallery.isViewImage) + .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 8f178d5282..817820effb 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -1,7 +1,7 @@ /** * Type converters for Draft API - convert between ComposerState and server Draft types. */ -import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' +import {AppBskyDraftDefs, AtUri, RichText} from '@atproto/api' import {nanoid} from 'nanoid/non-secure' import {resolveLink} from '#/lib/api/resolve' @@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent' import { type ComposerState, type EmbedDraft, + LEGACY_IMAGES_EMBED_MAX, type PostDraft, } from '#/view/com/composer/state/composer' import {type VideoState} from '#/view/com/composer/state/video' @@ -115,6 +116,16 @@ async function postDraftToServerPost( post.embed.media.images, localRefPaths, ) + } else if (post.embed.media.type === 'gallery') { + draftPost.embedGallery = { + $type: 'app.bsky.draft.defs#draftEmbedGallery', + items: serializeImages(post.embed.media.images, localRefPaths).map( + img => ({ + $type: 'app.bsky.draft.defs#draftEmbedImage' as const, + ...img, + }), + ), + } } else if (post.embed.media.type === 'video') { const video = await serializeVideo(post.embed.media.video, localRefPaths) if (video) { @@ -269,6 +280,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', + }, + } satisfies ComposerImage + }) + + return (await Promise.all(imagePromises)).filter( + (img): img is NonNullable => img !== null, + ) +} + /** * Convert server DraftView to DraftSummary for list display. * Also checks which media files exist locally. @@ -314,6 +378,24 @@ export function draftViewToSummary({ } } + // Process gallery + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + meta.mediaCount++ + meta.hasMedia = true + const exists = storage.mediaExists(item.localRef.path) + if (!exists) { + meta.hasMissingMedia = true + } + images.push({ + localPath: item.localRef.path, + altText: item.alt || '', + exists, + }) + } + } + // Process videos if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -431,54 +513,31 @@ export async function draftToComposerPosts( media: undefined, } - // Restore images + // Restore images / gallery. Pick the variant from the restored count so + // we match the composer reducer's `imagesToMediaVariant` rule (<=4 stays + // legacy `images`, >4 promotes to `gallery`). This keeps restore robust + // to drafts whose server slot disagrees with their count - e.g. a draft + // saved in `embedImages` with 5 items would otherwise restore as a + // broken `images` variant the rest of the composer can't grow. + const restoredImages: ComposerImage[] = [] 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', - }, - } satisfies ComposerImage - }) - - const images = (await Promise.all(imagePromises)).filter( - (img): img is NonNullable => img !== null, + restoredImages.push( + ...(await restoreDraftImages(post.embedImages, loadedMedia)), ) - if (images.length > 0) { - embed.media = {type: 'images', images} - } + } + if (post.embedGallery && post.embedGallery.items.length > 0) { + const galleryImages = post.embedGallery.items.filter( + AppBskyDraftDefs.isDraftEmbedImage, + ) + restoredImages.push( + ...(await restoreDraftImages(galleryImages, loadedMedia)), + ) + } + if (restoredImages.length > 0) { + embed.media = + restoredImages.length <= LEGACY_IMAGES_EMBED_MAX + ? {type: 'images', images: restoredImages} + : {type: 'gallery', images: restoredImages} } // Restore GIF from external embed @@ -630,6 +689,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set { refs.add(img.localRef.path) } } + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + refs.add(item.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..07dd67dd88 100644 --- a/src/view/com/composer/drafts/state/queries.ts +++ b/src/view/com/composer/drafts/state/queries.ts @@ -1,4 +1,4 @@ -import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api' +import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api' import { useInfiniteQuery, useMutation, @@ -74,6 +74,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{ } } } + // Load gallery + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + try { + const url = await storage.loadMediaFromLocal(item.localRef.path) + loadedMedia.set(item.localRef.path, url) + } catch (e) { + logger.error('Failed to load draft gallery image', { + path: item.localRef.path, + safeMessage: e instanceof Error ? e.message : String(e), + }) + } + } + } // Load videos if (post.embedVideos) { for (const vid of post.embedVideos) { @@ -226,6 +241,12 @@ export function useDeleteDraftMutation() { await storage.deleteMediaFromLocal(img.localRef.path) } } + if (post.embedGallery) { + for (const item of post.embedGallery.items) { + if (!AppBskyDraftDefs.isDraftEmbedImage(item)) continue + await storage.deleteMediaFromLocal(item.localRef.path) + } + } if (post.embedVideos) { for (const vid of post.embedVideos) { await storage.deleteMediaFromLocal(vid.localRef.path) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 2cc000019f..98bfac779d 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { const {isMobile} = useWebMediaQueries() const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => { + // Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high; + // n > 4 wraps to multiple rows via flexWrap on the gallery container. + const columns = Math.min(images.length, 4) const side = images.length === 1 ? 250 - : (containerInfo.width - IMAGE_GAP * (images.length - 1)) / - images.length + : (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns const isOverflow = isMobile && images.length > 2 @@ -273,6 +275,7 @@ const styles = StyleSheet.create({ gallery: { flex: 1, flexDirection: 'row', + flexWrap: 'wrap', gap: IMAGE_GAP, marginTop: 16, }, diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index d2b9af7b8e..35e1706007 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -16,6 +16,7 @@ import { postUriToRelativePath, toBskyAppUrl, } from '#/lib/strings/url-helpers' +import {logger} from '#/logger' import {type ComposerImage, createInitialImages} from '#/state/gallery' import {createPostgateRecord} from '#/state/queries/postgate/util' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' @@ -38,6 +39,11 @@ type ImagesMedia = { images: ComposerImage[] } +type GalleryMedia = { + type: 'gallery' + images: ComposerImage[] +} + type VideoMedia = { type: 'video' video: VideoState @@ -59,7 +65,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 } @@ -154,7 +160,31 @@ export type ComposerAction = draftId: string } -export const MAX_IMAGES = 4 +/** + * Threshold for picking between embed variants. <= this count uses the + * legacy `app.bsky.embed.images` shape; > this count promotes to + * `app.bsky.embed.gallery`. Named to flag that if/when we deprecate the + * legacy images embed entirely, this constant (and the variant split it + * gates) should go away. + */ +export const LEGACY_IMAGES_EMBED_MAX = 4 +export const MAX_GALLERY_IMAGES = 10 + +/** + * Picks the embed variant for a set of images. <=4 lands in the legacy + * `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`. + * Anything beyond the gallery cap is dropped by the hard slice; callers + * should already have enforced the cap upstream (picker, paste, etc), + * and the reducer logs a warning when the cap is exceeded so the UI + * layer can surface a toast. + */ +function imagesToMediaVariant( + images: ComposerImage[], +): ImagesMedia | GalleryMedia { + return images.length <= LEGACY_IMAGES_EMBED_MAX + ? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)} + : {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)} +} export function composerReducer( state: ComposerState, @@ -337,16 +367,28 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft { } const prevMedia = state.embed.media let nextMedia = prevMedia + const prevCount = + prevMedia?.type === 'images' || prevMedia?.type === 'gallery' + ? prevMedia.images.length + : 0 + const incomingCount = prevCount + action.images.length + if (incomingCount > MAX_GALLERY_IMAGES) { + // Defense in depth: callers (applyGalleryCap in Composer) should have + // already trimmed and surfaced a toast. The hard slice in + // imagesToMediaVariant still drops the excess so the cap holds. + logger.warn('composer: image add exceeds MAX_GALLERY_IMAGES', { + prevCount, + incomingCount, + dropped: incomingCount - MAX_GALLERY_IMAGES, + }) + } if (!prevMedia) { - nextMedia = { - type: 'images', - images: action.images.slice(0, MAX_IMAGES), - } - } else if (prevMedia.type === 'images') { - nextMedia = { - ...prevMedia, - images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES), - } + nextMedia = imagesToMediaVariant(action.images) + } else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') { + nextMedia = imagesToMediaVariant([ + ...prevMedia.images, + ...action.images, + ]) } return { ...state, @@ -358,7 +400,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 +424,22 @@ 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 { + // Re-pick the variant so a gallery that shrinks to <=4 demotes + // back to the legacy `app.bsky.embed.images` shape - keeps old + // clients rendering it when possible. + nextMedia = imagesToMediaVariant(remainingImages) } return { ...state, @@ -581,12 +626,9 @@ export function createComposerState({ | AppBskyActorDefs.PostInteractionSettingsPref | undefined }): ComposerState { - let media: ImagesMedia | undefined + let media: ImagesMedia | GalleryMedia | undefined if (initImageUris?.length) { - media = { - type: 'images', - images: createInitialImages(initImageUris), - } + media = imagesToMediaVariant(createInitialImages(initImageUris)) } let quote: Link | undefined if (initQuoteUri) { 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,