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,