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
+95 -29
View File
@@ -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') && (
<Gallery images={embed.media.images} dispatch={dispatch} />
)}
@@ -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}
/>
<OpenCameraBtn
disabled={media?.type === 'images' ? isMaxImages : !!media}
disabled={
media?.type === 'images' || media?.type === 'gallery'
? isMaxImages
: !!media
}
onAdd={onImageAdd}
/>
<SelectGifBtn onSelectGif={onSelectGif} disabled={!!media} />