Add gallery embed type support (display + compose) (#10707)
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
}: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<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.
|
||||
* 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<typeof img> => 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<string> {
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user