Add gallery embed type support (display + compose) (#10707)

This commit is contained in:
Spence Pope
2026-06-04 17:22:40 -04:00
committed by GitHub
parent de926d1838
commit 144ba30ea6
19 changed files with 494 additions and 143 deletions
+1 -1
View File
@@ -93,7 +93,7 @@
"prettier": "prettier --check ." "prettier": "prettier --check ."
}, },
"dependencies": { "dependencies": {
"@atproto/api": "0.20.8", "@atproto/api": "0.20.9",
"@atproto/syntax": "0.6.1", "@atproto/syntax": "0.6.1",
"@bitdrift/react-native": "^0.6.8", "@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2", "@braintree/sanitize-url": "^6.0.2",
+5 -5
View File
@@ -242,8 +242,8 @@ importers:
.: .:
dependencies: dependencies:
'@atproto/api': '@atproto/api':
specifier: 0.20.8 specifier: 0.20.9
version: 0.20.8 version: 0.20.9
'@atproto/syntax': '@atproto/syntax':
specifier: 0.6.1 specifier: 0.6.1
version: 0.6.1 version: 0.6.1
@@ -877,8 +877,8 @@ packages:
graphql: graphql:
optional: true optional: true
'@atproto/api@0.20.8': '@atproto/api@0.20.9':
resolution: {integrity: sha512-rTkA6kOmA2axSrg6VgpdXpsCFWpofnHBOn6pKg69Ju5MpIHqk4haQMgjBcVh1G3kUxzwgSAr7SYrPS3dFe5Etg==} resolution: {integrity: sha512-Yuw7Ewn+yMJZ8GskbuvI3lKPW65rsXic1xjFA2Dpq6H8WjVYs6xNZ31bkwtTYDDwjKIZcJmAVbAVgdfjo4T9iw==}
engines: {node: '>=22'} engines: {node: '>=22'}
'@atproto/common-web@0.5.0': '@atproto/common-web@0.5.0':
@@ -9493,7 +9493,7 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {} '@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.8': '@atproto/api@0.20.9':
dependencies: dependencies:
'@atproto/common-web': 0.5.0 '@atproto/common-web': 0.5.0
'@atproto/lexicon': 0.7.1 '@atproto/lexicon': 0.7.1
+33 -1
View File
@@ -1,6 +1,10 @@
import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native' import {type StyleProp, StyleSheet, View, type ViewStyle} from 'react-native'
import {Image} from 'expo-image' 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 {Trans, useLingui} from '@lingui/react/macro'
import {shareImageModal} from '#/lib/media/manip' import {shareImageModal} from '#/lib/media/manip'
@@ -47,6 +51,34 @@ export function Embed({
)} )}
</Outer> </Outer>
) )
} 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(<PeekableImageItem key={item.thumbnail} image={image} />)
} else {
tiles.push(
<ImageItem
key={item.thumbnail}
thumbnail={item.thumbnail}
alt={item.alt}
/>,
)
}
}
return <Outer style={style}>{tiles}</Outer>
} else if (e.type === 'link') { } else if (e.type === 'link') {
if (!e.view.external.thumb) return null if (!e.view.external.thumb) return null
if (!isGifEmbed(e.view.external.uri)) return null if (!isGifEmbed(e.view.external.uri)) return null
+19 -4
View File
@@ -2,6 +2,7 @@ import {useRef} from 'react'
import {InteractionManager, View} from 'react-native' import {InteractionManager, View} from 'react-native'
import {type AnimatedRef} from 'react-native-reanimated' import {type AnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyEmbedGallery, type AppBskyEmbedImages} from '@atproto/api'
import {atoms as a, tokens} from '#/alf' import {atoms as a, tokens} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {AutoSizedImage} from '#/components/images/AutoSizedImage'
@@ -15,16 +16,29 @@ import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post' import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types' import {type CommonProps} from './types'
const MAX_GRID_IMAGES = 4
export function ImageEmbed({ export function ImageEmbed({
embed, embed,
...rest ...rest
}: CommonProps & { }: CommonProps & {
embed: EmbedType<'images'> embed: EmbedType<'images'> | EmbedType<'gallery'>
}) { }) {
const ax = useAnalytics() const ax = useAnalytics()
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const {images} = embed.view const images: AppBskyEmbedImages.ViewImage[] =
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) embed.type === 'gallery'
? embed.view.items.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 // Captured from AutoSizedImage so the peek-commit handler can reuse the same
// ref + dims that a tap would — keeps the lightbox's return animation intact. // ref + dims that a tap would — keeps the lightbox's return animation intact.
@@ -109,7 +123,7 @@ export function ImageEmbed({
) )
} }
if (galleryEnabled) { if (useExpandedLayout) {
return ( return (
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<Gallery <Gallery
@@ -130,6 +144,7 @@ export function ImageEmbed({
onPress={onPress} onPress={onPress}
onPressIn={onPressIn} onPressIn={onPressIn}
viewContext={rest.viewContext} viewContext={rest.viewContext}
isWithinQuote={rest.isWithinQuote}
/> />
</View> </View>
) )
+3 -1
View File
@@ -54,6 +54,7 @@ export function Embed({embed: rawEmbed, ...rest}: EmbedProps) {
switch (embed.type) { switch (embed.type) {
case 'images': case 'images':
case 'gallery':
case 'link': case 'link':
case 'video': { case 'video': {
return <MediaEmbed embed={embed} {...rest} /> return <MediaEmbed embed={embed} {...rest} />
@@ -89,7 +90,8 @@ function MediaEmbed({
embed: TEmbed embed: TEmbed
}) { }) {
switch (embed.type) { switch (embed.type) {
case 'images': { case 'images':
case 'gallery': {
return ( return (
<ContentHider <ContentHider
modui={rest.moderation?.ui('contentMedia')} modui={rest.moderation?.ui('contentMedia')}
@@ -1,4 +1,5 @@
import { import {
AppBskyEmbedGallery,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs, type AppBskyFeedDefs,
@@ -27,9 +28,6 @@ export function maybeApplyGalleryOffsetStyles(
additionalCauses?: ModerationCause[] | AppModerationCause[] additionalCauses?: ModerationCause[] | AppModerationCause[]
}, },
) { ) {
// don't ever check gates like this, except this one time
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
if ( if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>( !bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record, post.record,
@@ -39,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles(
return 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 * First check if we even have images
*/ */
@@ -49,6 +54,12 @@ export function maybeApplyGalleryOffsetStyles(
embed, embed,
AppBskyEmbedImages.isMain, AppBskyEmbedImages.isMain,
) )
const isGalleryEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed,
AppBskyEmbedGallery.isMain,
)
const isRecordWithMedia = const isRecordWithMedia =
embed && embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>( bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
@@ -57,10 +68,16 @@ export function maybeApplyGalleryOffsetStyles(
) )
let hasImages = false let hasImages = false
if (isImageEmbed) { if (isImageEmbed) {
if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery // one image, not a gallery
if (embed.images.length === 1) return if (embed.images.length === 1) return
hasImages = true hasImages = true
} }
if (isGalleryEmbed) {
// single (or empty) gallery - no offset needed
if (embed.items.length <= 1) return
hasImages = true
}
if (isRecordWithMedia) { if (isRecordWithMedia) {
if ( if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>( bsky.dangerousIsType<AppBskyEmbedImages.Main>(
@@ -68,9 +85,19 @@ export function maybeApplyGalleryOffsetStyles(
AppBskyEmbedImages.isMain, AppBskyEmbedImages.isMain,
) )
) { ) {
if (!isPostGalleryEmbedEnabled) return
// one image, not a gallery // one image, not a gallery
if (embed.media.images.length === 1) return if (embed.media.images.length === 1) return
} }
if (
bsky.dangerousIsType<AppBskyEmbedGallery.Main>(
embed.media,
AppBskyEmbedGallery.isMain,
)
) {
// single (or empty) gallery - no offset needed
if (embed.media.items.length <= 1) return
}
hasImages = true hasImages = true
} }
if (!hasImages) return if (!hasImages) return
+15 -7
View File
@@ -19,21 +19,28 @@ interface ImageLayoutGridProps {
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
} }
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) { export function ImageLayoutGrid({
style,
isWithinQuote: isWithinQuoteProp,
...props
}: ImageLayoutGridProps) {
const {gtMobile} = useBreakpoints() const {gtMobile} = useBreakpoints()
const gap = const isWithinQuote =
isWithinQuoteProp ??
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
? gtMobile const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
? a.gap_xs
: a.gap_2xs
: a.gap_xs
return ( return (
<View style={style}> <View style={style}>
<View style={[gap, a.rounded_md, a.overflow_hidden]}> <View style={[gap, a.rounded_md, a.overflow_hidden]}>
<ImageLayoutGridInner {...props} gap={gap} /> <ImageLayoutGridInner
{...props}
gap={gap}
isWithinQuote={isWithinQuote}
/>
</View> </View>
</View> </View>
) )
@@ -49,6 +56,7 @@ interface ImageLayoutGridInnerProps {
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
gap: {gap: number} gap: {gap: number}
} }
@@ -29,6 +29,7 @@ interface Props {
onPressIn?: EventFunction onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle> imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
insetBorderStyle?: StyleProp<ViewStyle> insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<any>[] containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.RefObject<(Dimensions | null)[]> thumbDimsRef: React.RefObject<(Dimensions | null)[]>
@@ -42,6 +43,7 @@ export function GalleryItem({
onPressIn, onPressIn,
onLongPress, onLongPress,
viewContext, viewContext,
isWithinQuote,
insetBorderStyle, insetBorderStyle,
containerRefs, containerRefs,
thumbDimsRef, thumbDimsRef,
@@ -52,6 +54,7 @@ export function GalleryItem({
const image = images[index] const image = images[index]
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges = const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect = const aspect =
@@ -87,7 +87,10 @@ export function parseReportSubject(
reply: !!record.reply, reply: !!record.reply,
image: image:
embed.type === 'images' || 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: video:
embed.type === 'video' || embed.type === 'video' ||
(embed.type === 'post_with_media' && embed.media.type === 'video'), (embed.type === 'post_with_media' && embed.media.type === 'video'),
+31
View File
@@ -1,6 +1,7 @@
import { import {
type $Typed, type $Typed,
type AppBskyEmbedExternal, type AppBskyEmbedExternal,
type AppBskyEmbedGallery,
type AppBskyEmbedImages, type AppBskyEmbedImages,
type AppBskyEmbedRecord, type AppBskyEmbedRecord,
type AppBskyEmbedRecordWithMedia, type AppBskyEmbedRecordWithMedia,
@@ -254,6 +255,7 @@ async function resolveEmbed(
onStateChange: ((state: string) => void) | undefined, onStateChange: ((state: string) => void) | undefined,
): Promise< ): Promise<
| $Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedVideo.Main>
| $Typed<AppBskyEmbedExternal.Main> | $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedRecord.Main> | $Typed<AppBskyEmbedRecord.Main>
@@ -313,6 +315,7 @@ async function resolveMedia(
): Promise< ): Promise<
| $Typed<AppBskyEmbedExternal.Main> | $Typed<AppBskyEmbedExternal.Main>
| $Typed<AppBskyEmbedImages.Main> | $Typed<AppBskyEmbedImages.Main>
| $Typed<AppBskyEmbedGallery.Main>
| $Typed<AppBskyEmbedVideo.Main> | $Typed<AppBskyEmbedVideo.Main>
| undefined | undefined
> { > {
@@ -343,6 +346,34 @@ async function resolveMedia(
images, 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<AppBskyEmbedGallery.Image>[] = 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 ( if (
embedDraft.media?.type === 'video' && embedDraft.media?.type === 'video' &&
embedDraft.media.video.status === 'done' embedDraft.media.video.status === 'done'
+10
View File
@@ -1,6 +1,7 @@
import { import {
type $Typed, type $Typed,
AppBskyEmbedExternal, AppBskyEmbedExternal,
AppBskyEmbedGallery,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
@@ -47,6 +48,10 @@ export type Embed =
type: 'images' type: 'images'
view: $Typed<AppBskyEmbedImages.View> view: $Typed<AppBskyEmbedImages.View>
} }
| {
type: 'gallery'
view: $Typed<AppBskyEmbedGallery.View>
}
| { | {
type: 'link' type: 'link'
view: $Typed<AppBskyEmbedExternal.View> view: $Typed<AppBskyEmbedExternal.View>
@@ -122,6 +127,11 @@ export function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): Embed {
type: 'images', type: 'images',
view: embed, view: embed,
} }
} else if (AppBskyEmbedGallery.isView(embed)) {
return {
type: 'gallery',
view: embed,
}
} else if (AppBskyEmbedExternal.isView(embed)) { } else if (AppBskyEmbedExternal.isView(embed)) {
return { return {
type: 'link', type: 'link',
+95 -29
View File
@@ -159,7 +159,7 @@ import {
composerReducer, composerReducer,
createComposerState, createComposerState,
type EmbedDraft, type EmbedDraft,
MAX_IMAGES, MAX_GALLERY_IMAGES,
type PostAction, type PostAction,
type PostDraft, type PostDraft,
type ThreadDraft, type ThreadDraft,
@@ -178,6 +178,65 @@ type CancelRef = {
onPressCancel: () => void 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 type Props = ComposerOpts
export const ComposePost = ({ export const ComposePost = ({
replyTo, replyTo,
@@ -611,7 +670,11 @@ export const ComposePost = ({
ax.metric('draft:save', { ax.metric('draft:save', {
isNewDraft, isNewDraft,
hasText: posts.some(p => p.richtext.text.trim().length > 0), 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'), hasVideo: posts.some(p => p.embed.media?.type === 'video'),
hasGif: posts.some(p => p.embed.media?.type === 'gif'), hasGif: posts.some(p => p.embed.media?.type === 'gif'),
hasQuote: posts.some(p => !!p.embed.quote), hasQuote: posts.some(p => !!p.embed.quote),
@@ -780,7 +843,10 @@ export const ComposePost = ({
for (let i = 0; i < thread.posts.length; i++) { for (let i = 0; i < thread.posts.length; i++) {
const media = thread.posts[i].embed.media const media = thread.posts[i].embed.media
if (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.` return l`One or more images is missing alt text.`
} }
if (media.type === 'gif' && !media.alt) { if (media.type === 'gif' && !media.alt) {
@@ -931,7 +997,9 @@ export const ComposePost = ({
logger.error(e, { logger.error(e, {
message: `Composer: create post failed`, message: `Composer: create post failed`,
hasImages: filteredThread.posts.some( 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) { for (let post of filteredThread.posts) {
ax.metric('post:create', { ax.metric('post:create', {
imageCount: imageCount:
post.embed.media?.type === 'images' post.embed.media?.type === 'images' ||
post.embed.media?.type === 'gallery'
? post.embed.media.images.length ? post.embed.media.images.length
: 0, : 0,
isReply: index > 0 || !!replyTo, isReply: index > 0 || !!replyTo,
@@ -1395,15 +1464,11 @@ let ComposerPost = memo(function ComposerPost({
[dispatch, post.id], [dispatch, post.id],
) )
const onImageAdd = useCallback( const postImagesCount =
(next: ComposerImage[]) => { post.embed.media?.type === 'images' || post.embed.media?.type === 'gallery'
dispatchPost({ ? post.embed.media.images.length
type: 'embed_add_images', : 0
images: next, const onImageAdd = useAddImagesWithCap(postImagesCount, dispatchPost)
})
},
[dispatchPost],
)
const onNewLink = useCallback( const onNewLink = useCallback(
(uri: string) => { (uri: string) => {
@@ -1708,7 +1773,7 @@ function ComposerEmbeds({
const video = embed.media?.type === 'video' ? embed.media.video : null const video = embed.media?.type === 'video' ? embed.media.video : null
return ( return (
<> <>
{embed.media?.type === 'images' && ( {(embed.media?.type === 'images' || embed.media?.type === 'gallery') && (
<Gallery images={embed.media.images} dispatch={dispatch} /> <Gallery images={embed.media.images} dispatch={dispatch} />
)} )}
@@ -1819,7 +1884,11 @@ function ComposerPills({
}) { }) {
const t = useTheme() const t = useTheme()
const media = post.embed.media 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 const hasLink = !!post.embed.link
// Don't render anything if no pills are going to be displayed // Don't render anything if no pills are going to be displayed
@@ -1908,15 +1977,16 @@ function ComposerFooter({
>(undefined) >(undefined)
const media = post.embed.media const media = post.embed.media
const images = media?.type === 'images' ? media.images : [] const images =
media?.type === 'images' || media?.type === 'gallery' ? media.images : []
const video = media?.type === 'video' ? media.video : null const video = media?.type === 'video' ? media.video : null
const isMaxImages = images.length >= MAX_IMAGES const isMaxImages = images.length >= MAX_GALLERY_IMAGES
const isMaxVideos = !!video const isMaxVideos = !!video
let selectedAssetsCount = 0 let selectedAssetsCount = 0
let isMediaSelectionDisabled = false let isMediaSelectionDisabled = false
if (media?.type === 'images') { if (media?.type === 'images' || media?.type === 'gallery') {
isMediaSelectionDisabled = isMaxImages isMediaSelectionDisabled = isMaxImages
selectedAssetsCount = images.length selectedAssetsCount = images.length
} else if (media?.type === 'video') { } else if (media?.type === 'video') {
@@ -1926,15 +1996,7 @@ function ComposerFooter({
isMediaSelectionDisabled = !!media isMediaSelectionDisabled = !!media
} }
const onImageAdd = useCallback( const onImageAdd = useAddImagesWithCap(images.length, dispatch)
(next: ComposerImage[]) => {
dispatch({
type: 'embed_add_images',
images: next,
})
},
[dispatch],
)
const onSelectGif = useCallback( const onSelectGif = useCallback(
(gif: Gif) => { (gif: Gif) => {
@@ -2017,7 +2079,11 @@ function ComposerFooter({
autoOpen={openGallery} autoOpen={openGallery}
/> />
<OpenCameraBtn <OpenCameraBtn
disabled={media?.type === 'images' ? isMaxImages : !!media} disabled={
media?.type === 'images' || media?.type === 'gallery'
? isMaxImages
: !!media
}
onAdd={onImageAdd} onAdd={onImageAdd}
/> />
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} /> <SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />
+25 -5
View File
@@ -2,6 +2,7 @@ import {useCallback, useMemo, useState} from 'react'
import {LayoutAnimation, Pressable, View} from 'react-native' import {LayoutAnimation, Pressable, View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import { import {
AppBskyEmbedGallery,
AppBskyEmbedImages, AppBskyEmbedImages,
AppBskyEmbedRecord, AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia, AppBskyEmbedRecordWithMedia,
@@ -61,11 +62,14 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
const images = useMemo(() => { const images = useMemo(() => {
if (AppBskyEmbedImages.isView(embed)) { if (AppBskyEmbedImages.isView(embed)) {
return embed.images return embed.images
} else if ( } else if (AppBskyEmbedGallery.isView(embed)) {
AppBskyEmbedRecordWithMedia.isView(embed) && return galleryItemsToImages(embed.items)
AppBskyEmbedImages.isView(embed.media) } else if (AppBskyEmbedRecordWithMedia.isView(embed)) {
) { if (AppBskyEmbedImages.isView(embed.media)) {
return embed.media.images return embed.media.images
} else if (AppBskyEmbedGallery.isView(embed.media)) {
return galleryItemsToImages(embed.media.items)
}
} }
}, [embed]) }, [embed])
@@ -129,6 +133,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({ function ComposerReplyToImages({
images, images,
}: { }: {
+11 -8
View File
@@ -16,7 +16,7 @@ import {
} from '#/lib/hooks/usePermissions' } from '#/lib/hooks/usePermissions'
import {openUnifiedPicker} from '#/lib/media/picker' import {openUnifiedPicker} from '#/lib/media/picker'
import {extractDataUriMime} from '#/lib/media/util' import {extractDataUriMime} from '#/lib/media/util'
import {MAX_IMAGES} from '#/view/com/composer/state/composer' import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
@@ -393,7 +393,9 @@ export function SelectMediaButton({
const t = useTheme() const t = useTheme()
const hasAutoOpened = useRef(false) const hasAutoOpened = useRef(false)
const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount // Picker uses the gallery cap; the reducer decides which embed variant
// to land in based on the final image count.
const selectionCountRemaining = MAX_GALLERY_IMAGES - selectedAssetsCount
const processSelectedAssets = useCallback( const processSelectedAssets = useCallback(
async (rawAssets: ImagePickerAsset[]) => { async (rawAssets: ImagePickerAsset[]) => {
@@ -419,10 +421,10 @@ export function SelectMediaButton({
), ),
[SelectedAssetError.MaxImages]: _( [SelectedAssetError.MaxImages]: _(
msg({ msg({
message: `You can select up to ${plural(MAX_IMAGES, { message: `You can select up to ${plural(MAX_GALLERY_IMAGES, {
other: '# images', other: '# images',
})} in total.`, })} in total.`,
comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, comment: `Error message for maximum number of images that can be selected to add to a post.`,
}), }),
), ),
[SelectedAssetError.MaxVideos]: _( [SelectedAssetError.MaxVideos]: _(
@@ -507,10 +509,11 @@ export function SelectMediaButton({
)} )}
accessibilityHint={_( accessibilityHint={_(
msg({ msg({
message: `Opens device gallery to select up to ${plural(MAX_IMAGES, { message: `Opens device gallery to select up to ${plural(
other: '# images', MAX_GALLERY_IMAGES,
})}, or a single video or GIF.`, {other: '# images'},
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, )}, or a single video or GIF.`,
comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post.`,
}), }),
)} )}
style={a.p_sm} style={a.p_sm}
+112 -47
View File
@@ -1,7 +1,7 @@
/** /**
* Type converters for Draft API - convert between ComposerState and server Draft types. * 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 {nanoid} from 'nanoid/non-secure'
import {resolveLink} from '#/lib/api/resolve' import {resolveLink} from '#/lib/api/resolve'
@@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent'
import { import {
type ComposerState, type ComposerState,
type EmbedDraft, type EmbedDraft,
LEGACY_IMAGES_EMBED_MAX,
type PostDraft, type PostDraft,
} from '#/view/com/composer/state/composer' } from '#/view/com/composer/state/composer'
import {type VideoState} from '#/view/com/composer/state/video' import {type VideoState} from '#/view/com/composer/state/video'
@@ -115,6 +116,16 @@ async function postDraftToServerPost(
post.embed.media.images, post.embed.media.images,
localRefPaths, 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') { } else if (post.embed.media.type === 'video') {
const video = await serializeVideo(post.embed.media.video, localRefPaths) const video = await serializeVideo(post.embed.media.video, localRefPaths)
if (video) { if (video) {
@@ -269,6 +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<string, string>,
): Promise<ComposerImage[]> {
const imagePromises = draftImages.map(async img => {
const path = loadedMedia.get(img.localRef.path)
if (!path) {
return null
}
let width = 0
let height = 0
try {
const dims = await getImageDim(path)
width = dims.width
height = dims.height
} catch (e) {
logger.warn('Failed to get image dimensions', {
path,
error: e,
})
}
logger.debug('restoring image with localRefPath', {
localRefPath: img.localRef.path,
loadedPath: path,
width,
height,
})
return {
alt: img.alt || '',
// Preserve the original localRefPath for reuse when saving
localRefPath: img.localRef.path,
source: {
id: nanoid(),
path,
width,
height,
mime: 'image/jpeg',
},
} satisfies ComposerImage
})
return (await Promise.all(imagePromises)).filter(
(img): img is NonNullable<typeof img> => img !== null,
)
}
/** /**
* Convert server DraftView to DraftSummary for list display. * Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally. * Also checks which media files exist locally.
@@ -314,6 +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 // Process videos
if (post.embedVideos) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
@@ -431,54 +513,31 @@ export async function draftToComposerPosts(
media: undefined, 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) { if (post.embedImages && post.embedImages.length > 0) {
const imagePromises = post.embedImages.map(async img => { restoredImages.push(
const path = loadedMedia.get(img.localRef.path) ...(await restoreDraftImages(post.embedImages, loadedMedia)),
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<typeof img> => img !== null,
) )
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 // Restore GIF from external embed
@@ -630,6 +689,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
refs.add(img.localRef.path) 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) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
refs.add(vid.localRef.path) refs.add(vid.localRef.path)
+22 -1
View File
@@ -1,4 +1,4 @@
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api' import {AppBskyDraftCreateDraft, AppBskyDraftDefs} from '@atproto/api'
import { import {
useInfiniteQuery, useInfiniteQuery,
useMutation, 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 // Load videos
if (post.embedVideos) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
@@ -226,6 +241,12 @@ export function useDeleteDraftMutation() {
await storage.deleteMediaFromLocal(img.localRef.path) 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) { if (post.embedVideos) {
for (const vid of post.embedVideos) { for (const vid of post.embedVideos) {
await storage.deleteMediaFromLocal(vid.localRef.path) await storage.deleteMediaFromLocal(vid.localRef.path)
+5 -2
View File
@@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries() const {isMobile} = useWebMediaQueries()
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => { 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 = const side =
images.length === 1 images.length === 1
? 250 ? 250
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) / : (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns
images.length
const isOverflow = isMobile && images.length > 2 const isOverflow = isMobile && images.length > 2
@@ -273,6 +275,7 @@ const styles = StyleSheet.create({
gallery: { gallery: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap',
gap: IMAGE_GAP, gap: IMAGE_GAP,
marginTop: 16, marginTop: 16,
}, },
+67 -25
View File
@@ -16,6 +16,7 @@ import {
postUriToRelativePath, postUriToRelativePath,
toBskyAppUrl, toBskyAppUrl,
} from '#/lib/strings/url-helpers' } from '#/lib/strings/url-helpers'
import {logger} from '#/logger'
import {type ComposerImage, createInitialImages} from '#/state/gallery' import {type ComposerImage, createInitialImages} from '#/state/gallery'
import {createPostgateRecord} from '#/state/queries/postgate/util' import {createPostgateRecord} from '#/state/queries/postgate/util'
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate' import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
@@ -38,6 +39,11 @@ type ImagesMedia = {
images: ComposerImage[] images: ComposerImage[]
} }
type GalleryMedia = {
type: 'gallery'
images: ComposerImage[]
}
type VideoMedia = { type VideoMedia = {
type: 'video' type: 'video'
video: VideoState video: VideoState
@@ -59,7 +65,7 @@ type Link = {
export type EmbedDraft = { export type EmbedDraft = {
// We'll always submit quote and actual media (images, video, gifs) chosen by the user. // We'll always submit quote and actual media (images, video, gifs) chosen by the user.
quote: Link | undefined quote: Link | undefined
media: ImagesMedia | VideoMedia | GifMedia | undefined media: ImagesMedia | GalleryMedia | VideoMedia | GifMedia | undefined
// This field may end up ignored if we have more important things to display than a link card: // This field may end up ignored if we have more important things to display than a link card:
link: Link | undefined link: Link | undefined
} }
@@ -154,7 +160,31 @@ export type ComposerAction =
draftId: string 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( export function composerReducer(
state: ComposerState, state: ComposerState,
@@ -337,16 +367,28 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
} }
const prevMedia = state.embed.media const prevMedia = state.embed.media
let nextMedia = prevMedia 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) { if (!prevMedia) {
nextMedia = { nextMedia = imagesToMediaVariant(action.images)
type: 'images', } else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
images: action.images.slice(0, MAX_IMAGES), nextMedia = imagesToMediaVariant([
} ...prevMedia.images,
} else if (prevMedia.type === 'images') { ...action.images,
nextMedia = { ])
...prevMedia,
images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES),
}
} }
return { return {
...state, ...state,
@@ -358,7 +400,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
} }
case 'embed_update_image': { case 'embed_update_image': {
const prevMedia = state.embed.media const prevMedia = state.embed.media
if (prevMedia?.type === 'images') { if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
const updatedImage = action.image const updatedImage = action.image
const nextMedia = { const nextMedia = {
...prevMedia, ...prevMedia,
@@ -382,19 +424,22 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
case 'embed_remove_image': { case 'embed_remove_image': {
const prevMedia = state.embed.media const prevMedia = state.embed.media
let nextLabels = state.labels let nextLabels = state.labels
if (prevMedia?.type === 'images') { if (prevMedia?.type === 'images' || prevMedia?.type === 'gallery') {
const removedImage = action.image const removedImage = action.image
let nextMedia: ImagesMedia | undefined = { const remainingImages = prevMedia.images.filter(img => {
...prevMedia, return img.source.id !== removedImage.source.id
images: prevMedia.images.filter(img => { })
return img.source.id !== removedImage.source.id let nextMedia: ImagesMedia | GalleryMedia | undefined
}), if (remainingImages.length === 0) {
}
if (nextMedia.images.length === 0) {
nextMedia = undefined nextMedia = undefined
if (!state.embed.link) { if (!state.embed.link) {
nextLabels = [] nextLabels = []
} }
} else {
// 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 { return {
...state, ...state,
@@ -581,12 +626,9 @@ export function createComposerState({
| AppBskyActorDefs.PostInteractionSettingsPref | AppBskyActorDefs.PostInteractionSettingsPref
| undefined | undefined
}): ComposerState { }): ComposerState {
let media: ImagesMedia | undefined let media: ImagesMedia | GalleryMedia | undefined
if (initImageUris?.length) { if (initImageUris?.length) {
media = { media = imagesToMediaVariant(createInitialImages(initImageUris))
type: 'images',
images: createInitialImages(initImageUris),
}
} }
let quote: Link | undefined let quote: Link | undefined
if (initQuoteUri) { if (initQuoteUri) {
+3 -3
View File
@@ -12,7 +12,7 @@ import {
} from '#/lib/hooks/usePermissions' } from '#/lib/hooks/usePermissions'
import {openCamera, openUnifiedPicker} from '#/lib/media/picker' import {openCamera, openUnifiedPicker} from '#/lib/media/picker'
import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile'
import {MAX_IMAGES} from '#/view/com/composer/state/composer' import {MAX_GALLERY_IMAGES} from '#/view/com/composer/state/composer'
import {UserAvatar} from '#/view/com/util/UserAvatar' import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, native, useTheme, web} from '#/alf' import {atoms as a, native, useTheme, web} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
@@ -64,7 +64,7 @@ export function ComposerPrompt() {
Keyboard.dismiss() Keyboard.dismiss()
} }
const selectionCountRemaining = MAX_IMAGES const selectionCountRemaining = MAX_GALLERY_IMAGES
const {assets, canceled} = await sheetWrapper( const {assets, canceled} = await sheetWrapper(
openUnifiedPicker({selectionCountRemaining}), openUnifiedPicker({selectionCountRemaining}),
) )
@@ -76,7 +76,7 @@ export function ComposerPrompt() {
if (assets.length > 0) { if (assets.length > 0) {
const imageUris = assets const imageUris = assets
.filter(asset => asset.mimeType?.startsWith('image/')) .filter(asset => asset.mimeType?.startsWith('image/'))
.slice(0, MAX_IMAGES) .slice(0, MAX_GALLERY_IMAGES)
.map(asset => ({ .map(asset => ({
uri: asset.uri, uri: asset.uri,
width: asset.width, width: asset.width,