Add gallery embed type support (display + compose) (#10707)
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user