diff --git a/src/components/MediaPreview.tsx b/src/components/MediaPreview.tsx
index 9aad6da864..98628844f9 100644
--- a/src/components/MediaPreview.tsx
+++ b/src/components/MediaPreview.tsx
@@ -47,6 +47,28 @@ export function Embed({
)}
)
+ } else if (e.type === 'gallery') {
+ return (
+
+ {e.view.items.map(item => {
+ const image: AppBskyEmbedImages.ViewImage = {
+ thumb: item.thumbnail,
+ fullsize: item.fullsize,
+ alt: item.alt,
+ aspectRatio: item.aspectRatio,
+ }
+ return peekable ? (
+
+ ) : (
+
+ )
+ })}
+
+ )
} 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 ccd1ceb270..c3f77bd33f 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 {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 GRID_TO_CAROUSEL_THRESHOLD = 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.map(item => ({
+ thumb: item.thumbnail,
+ fullsize: item.fullsize,
+ alt: item.alt,
+ aspectRatio: item.aspectRatio,
+ }))
+ : embed.view.images
+ const carouselEnabled =
+ embed.type === 'gallery'
+ ? images.length > GRID_TO_CAROUSEL_THRESHOLD
+ : 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.
@@ -113,7 +127,7 @@ export function ImageEmbed({
)
}
- if (galleryEnabled) {
+ if (carouselEnabled) {
return (
@@ -87,7 +88,8 @@ function MediaEmbed({
embed: TEmbed
}) {
switch (embed.type) {
- case 'images': {
+ case 'images':
+ case 'gallery': {
return (
(
+ embed,
+ AppBskyEmbedGallery.isMain,
+ )
const isRecordWithMedia =
embed &&
bsky.dangerousIsType(
@@ -61,6 +68,11 @@ export function maybeApplyGalleryOffsetStyles(
if (embed.images.length === 1) return
hasImages = true
}
+ if (isGalleryEmbed) {
+ // one image, not a gallery
+ if (embed.items.length === 1) return
+ hasImages = true
+ }
if (isRecordWithMedia) {
if (
bsky.dangerousIsType(
@@ -71,6 +83,15 @@ export function maybeApplyGalleryOffsetStyles(
// one image, not a gallery
if (embed.media.images.length === 1) return
}
+ if (
+ bsky.dangerousIsType(
+ embed.media,
+ AppBskyEmbedGallery.isMain,
+ )
+ ) {
+ // one image, not a gallery
+ if (embed.media.items.length === 1) return
+ }
hasImages = true
}
if (!hasImages) return
diff --git a/src/lib/api/gallery-embed-shim.impl.ts b/src/lib/api/gallery-embed-shim.impl.ts
new file mode 100644
index 0000000000..3788eb9e7b
--- /dev/null
+++ b/src/lib/api/gallery-embed-shim.impl.ts
@@ -0,0 +1,69 @@
+/**
+ * Implementation backing `AppBskyEmbedGallery`. See gallery-embed-shim.ts.
+ */
+import {type AppBskyEmbedDefs, type BlobRef} from '@atproto/api'
+
+export interface Main {
+ $type?: 'app.bsky.embed.gallery'
+ items: Image[]
+}
+
+export interface Image {
+ $type?: 'app.bsky.embed.gallery#image'
+ image: BlobRef
+ alt: string
+ aspectRatio: AppBskyEmbedDefs.AspectRatio
+}
+
+export interface View {
+ $type?: 'app.bsky.embed.gallery#view'
+ items: ViewImage[]
+}
+
+export interface ViewImage {
+ $type?: 'app.bsky.embed.gallery#viewImage'
+ thumbnail: string
+ fullsize: string
+ alt: string
+ aspectRatio: AppBskyEmbedDefs.AspectRatio
+}
+
+export function isMain(
+ v: V,
+): v is V & Main & {$type: 'app.bsky.embed.gallery'} {
+ return (
+ typeof v === 'object' &&
+ v !== null &&
+ (v as {$type?: string}).$type === 'app.bsky.embed.gallery'
+ )
+}
+
+export function isImage(
+ v: V,
+): v is V & Image & {$type: 'app.bsky.embed.gallery#image'} {
+ return (
+ typeof v === 'object' &&
+ v !== null &&
+ (v as {$type?: string}).$type === 'app.bsky.embed.gallery#image'
+ )
+}
+
+export function isView(
+ v: V,
+): v is V & View & {$type: 'app.bsky.embed.gallery#view'} {
+ return (
+ typeof v === 'object' &&
+ v !== null &&
+ (v as {$type?: string}).$type === 'app.bsky.embed.gallery#view'
+ )
+}
+
+export function isViewImage(
+ v: V,
+): v is V & ViewImage & {$type: 'app.bsky.embed.gallery#viewImage'} {
+ return (
+ typeof v === 'object' &&
+ v !== null &&
+ (v as {$type?: string}).$type === 'app.bsky.embed.gallery#viewImage'
+ )
+}
diff --git a/src/lib/api/gallery-embed-shim.ts b/src/lib/api/gallery-embed-shim.ts
new file mode 100644
index 0000000000..b65a098985
--- /dev/null
+++ b/src/lib/api/gallery-embed-shim.ts
@@ -0,0 +1,12 @@
+/**
+ * Local shim for `app.bsky.embed.gallery` until @atproto/api ships the
+ * generated types. Mirrors the shape from atproto PR #4827:
+ * https://github.com/bluesky-social/atproto/pull/4827
+ *
+ * Once the lexicon ships and we bump @atproto/api, delete this file and
+ * replace `import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'`
+ * with `import {AppBskyEmbedGallery} from '@atproto/api'`.
+ */
+import * as gallery from './gallery-embed-shim.impl'
+
+export {gallery as AppBskyEmbedGallery}
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index d9a026ca83..283f552ec6 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -21,6 +21,7 @@ import {sha256} from 'js-sha256'
import {CID} from 'multiformats/cid'
import * as Hasher from 'multiformats/hashes/hasher'
+import {type AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {isNetworkError} from '#/lib/strings/errors'
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
import {logger} from '#/logger'
@@ -252,6 +253,7 @@ async function resolveEmbed(
onStateChange: ((state: string) => void) | undefined,
): Promise<
| $Typed
+ | $Typed
| $Typed
| $Typed
| $Typed
@@ -311,6 +313,7 @@ async function resolveMedia(
): Promise<
| $Typed
| $Typed
+ | $Typed
| $Typed
| undefined
> {
@@ -338,6 +341,31 @@ async function resolveMedia(
images,
}
}
+ if (embedDraft.media?.type === 'gallery') {
+ const imagesDraft = embedDraft.media.images
+ logger.debug(`Uploading gallery items`, {
+ count: imagesDraft.length,
+ })
+ onStateChange?.(t`Uploading images...`)
+ const items: AppBskyEmbedGallery.Image[] = await Promise.all(
+ imagesDraft.map(async (image, i) => {
+ logger.debug(`Compressing gallery image #${i}`)
+ const {path, width, height, mime} = await compressImage(image)
+ logger.debug(`Uploading gallery image #${i}`)
+ const res = await uploadBlob(agent, path, mime)
+ return {
+ $type: 'app.bsky.embed.gallery#image',
+ 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..4020c1632d 100644
--- a/src/types/bsky/post.ts
+++ b/src/types/bsky/post.ts
@@ -10,6 +10,8 @@ import {
AppBskyLabelerDefs,
} from '@atproto/api'
+import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
+
export type Embed =
| {
type: 'post'
@@ -47,6 +49,10 @@ export type Embed =
type: 'images'
view: $Typed
}
+ | {
+ type: 'gallery'
+ view: $Typed
+ }
| {
type: 'link'
view: $Typed
@@ -122,6 +128,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..b75ee8e3bc 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,
@@ -1708,7 +1708,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') && (
)}
@@ -1908,15 +1908,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') {
@@ -2017,7 +2018,11 @@ function ComposerFooter({
autoOpen={openGallery}
/>
diff --git a/src/view/com/composer/ComposerReplyTo.tsx b/src/view/com/composer/ComposerReplyTo.tsx
index a6ef1a344c..8a889d7f9c 100644
--- a/src/view/com/composer/ComposerReplyTo.tsx
+++ b/src/view/com/composer/ComposerReplyTo.tsx
@@ -10,6 +10,7 @@ import {
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
+import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {type ComposerOptsPostRef} from '#/state/shell/composer'
@@ -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,19 @@ export function ComposerReplyTo({replyTo}: {replyTo: ComposerOptsPostRef}) {
)
}
+function galleryItemsToImages(
+ items: AppBskyEmbedGallery.ViewImage[],
+): 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.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 1099c1a0fd..cca5f2b855 100644
--- a/src/view/com/composer/drafts/state/api.ts
+++ b/src/view/com/composer/drafts/state/api.ts
@@ -4,6 +4,13 @@
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
import {nanoid} from 'nanoid/non-secure'
+// Shim: AppBskyDraftDefs.DraftPost gains an `embedGallery` field in atproto
+// PR #4827. Until @atproto/api ships those types, we widen the shape locally.
+// Delete this once the lexicon publishes.
+type DraftPostWithGallery = AppBskyDraftDefs.DraftPost & {
+ embedGallery?: AppBskyDraftDefs.DraftEmbedImage[]
+}
+
import {resolveLink} from '#/lib/api/resolve'
import {getDeviceName} from '#/lib/deviceName'
import {getImageDim} from '#/lib/media/manip'
@@ -115,6 +122,11 @@ async function postDraftToServerPost(
post.embed.media.images,
localRefPaths,
)
+ } else if (post.embed.media.type === 'gallery') {
+ ;(draftPost as DraftPostWithGallery).embedGallery = serializeImages(
+ post.embed.media.images,
+ localRefPaths,
+ )
} else if (post.embed.media.type === 'video') {
const video = await serializeVideo(post.embed.media.video, localRefPaths)
if (video) {
@@ -269,6 +281,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',
+ },
+ } as ComposerImage
+ })
+
+ return (await Promise.all(imagePromises)).filter(
+ (img): img is ComposerImage => img !== null,
+ )
+}
+
/**
* Convert server DraftView to DraftSummary for list display.
* Also checks which media files exist locally.
@@ -314,6 +379,24 @@ export function draftViewToSummary({
}
}
+ // Process gallery
+ const summaryEmbedGallery = (post as DraftPostWithGallery).embedGallery
+ if (summaryEmbedGallery) {
+ for (const img of summaryEmbedGallery) {
+ meta.mediaCount++
+ meta.hasMedia = true
+ const exists = storage.mediaExists(img.localRef.path)
+ if (!exists) {
+ meta.hasMissingMedia = true
+ }
+ images.push({
+ localPath: img.localRef.path,
+ altText: img.alt || '',
+ exists,
+ })
+ }
+ }
+
// Process videos
if (post.embedVideos) {
for (const vid of post.embedVideos) {
@@ -433,54 +516,21 @@ export async function draftToComposerPosts(
// Restore images
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',
- },
- } as ComposerImage
- })
-
- const images = (await Promise.all(imagePromises)).filter(
- (img): img is ComposerImage => img !== null,
- )
+ const images = await restoreDraftImages(post.embedImages, loadedMedia)
if (images.length > 0) {
embed.media = {type: 'images', images}
}
}
+ // Restore gallery
+ const embedGallery = (post as DraftPostWithGallery).embedGallery
+ if (embedGallery && embedGallery.length > 0) {
+ const images = await restoreDraftImages(embedGallery, loadedMedia)
+ if (images.length > 0) {
+ embed.media = {type: 'gallery', images}
+ }
+ }
+
// Restore GIF from external embed
if (post.embedExternals) {
for (const ext of post.embedExternals) {
@@ -630,6 +680,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set {
refs.add(img.localRef.path)
}
}
+ const embedGallery = (post as DraftPostWithGallery).embedGallery
+ if (embedGallery) {
+ for (const img of embedGallery) {
+ refs.add(img.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..c0d896da61 100644
--- a/src/view/com/composer/drafts/state/queries.ts
+++ b/src/view/com/composer/drafts/state/queries.ts
@@ -1,4 +1,10 @@
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
+
+// Shim: AppBskyDraftDefs.DraftPost gains `embedGallery` in atproto PR #4827.
+// Delete once @atproto/api publishes the new lexicon.
+type DraftPostWithGallery = AppBskyDraftDefs.DraftPost & {
+ embedGallery?: AppBskyDraftDefs.DraftEmbedImage[]
+}
import {
useInfiniteQuery,
useMutation,
@@ -74,6 +80,21 @@ export async function loadDraftMedia(draft: AppBskyDraftDefs.Draft): Promise<{
}
}
}
+ // Load gallery
+ const embedGallery = (post as DraftPostWithGallery).embedGallery
+ if (embedGallery) {
+ for (const img of embedGallery) {
+ try {
+ const url = await storage.loadMediaFromLocal(img.localRef.path)
+ loadedMedia.set(img.localRef.path, url)
+ } catch (e) {
+ logger.error('Failed to load draft gallery image', {
+ path: img.localRef.path,
+ safeMessage: e instanceof Error ? e.message : String(e),
+ })
+ }
+ }
+ }
// Load videos
if (post.embedVideos) {
for (const vid of post.embedVideos) {
@@ -226,6 +247,12 @@ export function useDeleteDraftMutation() {
await storage.deleteMediaFromLocal(img.localRef.path)
}
}
+ const embedGallery = (post as DraftPostWithGallery).embedGallery
+ if (embedGallery) {
+ for (const img of embedGallery) {
+ await storage.deleteMediaFromLocal(img.localRef.path)
+ }
+ }
if (post.embedVideos) {
for (const vid of post.embedVideos) {
await storage.deleteMediaFromLocal(vid.localRef.path)
diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts
index d2b9af7b8e..ada15940c9 100644
--- a/src/view/com/composer/state/composer.ts
+++ b/src/view/com/composer/state/composer.ts
@@ -38,6 +38,11 @@ type ImagesMedia = {
images: ComposerImage[]
}
+type GalleryMedia = {
+ type: 'gallery'
+ images: ComposerImage[]
+}
+
type VideoMedia = {
type: 'video'
video: VideoState
@@ -59,7 +64,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
}
@@ -155,6 +160,7 @@ export type ComposerAction =
}
export const MAX_IMAGES = 4
+export const MAX_GALLERY_IMAGES = 10
export function composerReducer(
state: ComposerState,
@@ -338,14 +344,38 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
const prevMedia = state.embed.media
let nextMedia = prevMedia
if (!prevMedia) {
- nextMedia = {
- type: 'images',
- images: action.images.slice(0, MAX_IMAGES),
+ // First selection: pick the variant based on count. ImagesMedia caps
+ // at 4 (legacy `app.bsky.embed.images`); above that promotes to the
+ // new `app.bsky.embed.gallery` (capped at 10).
+ if (action.images.length <= MAX_IMAGES) {
+ nextMedia = {
+ type: 'images',
+ images: action.images.slice(0, MAX_IMAGES),
+ }
+ } else {
+ nextMedia = {
+ type: 'gallery',
+ images: action.images.slice(0, MAX_GALLERY_IMAGES),
+ }
}
} else if (prevMedia.type === 'images') {
+ const combined = [...prevMedia.images, ...action.images]
+ if (combined.length <= MAX_IMAGES) {
+ nextMedia = {...prevMedia, images: combined}
+ } else {
+ // Adding more than 4 promotes the existing images into a gallery.
+ nextMedia = {
+ type: 'gallery',
+ images: combined.slice(0, MAX_GALLERY_IMAGES),
+ }
+ }
+ } else if (prevMedia.type === 'gallery') {
nextMedia = {
...prevMedia,
- images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES),
+ images: [...prevMedia.images, ...action.images].slice(
+ 0,
+ MAX_GALLERY_IMAGES,
+ ),
}
}
return {
@@ -358,7 +388,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 +412,26 @@ 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 if (
+ prevMedia.type === 'gallery' &&
+ remainingImages.length <= MAX_IMAGES
+ ) {
+ // Drop back to the legacy `app.bsky.embed.images` shape when a
+ // gallery shrinks to <=4 items, so old clients still see it.
+ nextMedia = {type: 'images', images: remainingImages}
+ } else {
+ nextMedia = {...prevMedia, images: remainingImages}
}
return {
...state,
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,