Add app.bsky.embed.gallery embed type support
Wires the new gallery embed (atproto PR #4827) through the display and compose pipelines while the lexicon is unpublished. Gallery posts render via the existing carousel for >4 items and grid for <=4. Compose now allows up to 10 images and auto-promotes images -> gallery above 4 (demotes back when count drops to <=4 so old clients still see legacy embeds when possible). Drafts persist as the new `embedGallery` field. Local shim types in `src/lib/api/gallery-embed-shim*.ts` mirror the lexicon shape; delete both files once @atproto/api ships the generated types.
This commit is contained in:
@@ -47,6 +47,28 @@ export function Embed({
|
|||||||
)}
|
)}
|
||||||
</Outer>
|
</Outer>
|
||||||
)
|
)
|
||||||
|
} else if (e.type === 'gallery') {
|
||||||
|
return (
|
||||||
|
<Outer style={style}>
|
||||||
|
{e.view.items.map(item => {
|
||||||
|
const image: AppBskyEmbedImages.ViewImage = {
|
||||||
|
thumb: item.thumbnail,
|
||||||
|
fullsize: item.fullsize,
|
||||||
|
alt: item.alt,
|
||||||
|
aspectRatio: item.aspectRatio,
|
||||||
|
}
|
||||||
|
return peekable ? (
|
||||||
|
<PeekableImageItem key={item.thumbnail} image={image} />
|
||||||
|
) : (
|
||||||
|
<ImageItem
|
||||||
|
key={item.thumbnail}
|
||||||
|
thumbnail={item.thumbnail}
|
||||||
|
alt={item.alt}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</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
|
||||||
|
|||||||
@@ -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 {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 GRID_TO_CAROUSEL_THRESHOLD = 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.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
|
// 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.
|
||||||
@@ -113,7 +127,7 @@ export function ImageEmbed({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (galleryEnabled) {
|
if (carouselEnabled) {
|
||||||
return (
|
return (
|
||||||
<View style={[a.mt_sm, rest.style]}>
|
<View style={[a.mt_sm, rest.style]}>
|
||||||
<Gallery
|
<Gallery
|
||||||
|
|||||||
@@ -52,6 +52,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} />
|
||||||
@@ -87,7 +88,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')}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
type ModerationUI,
|
type ModerationUI,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||||
import {unique} from '#/lib/moderation'
|
import {unique} from '#/lib/moderation'
|
||||||
import {type AppModerationCause} from '#/components/Pills'
|
import {type AppModerationCause} from '#/components/Pills'
|
||||||
import {Features, features} from '#/analytics/features'
|
import {Features, features} from '#/analytics/features'
|
||||||
@@ -49,6 +50,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>(
|
||||||
@@ -61,6 +68,11 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
if (embed.images.length === 1) return
|
if (embed.images.length === 1) return
|
||||||
hasImages = true
|
hasImages = true
|
||||||
}
|
}
|
||||||
|
if (isGalleryEmbed) {
|
||||||
|
// one image, not a gallery
|
||||||
|
if (embed.items.length === 1) return
|
||||||
|
hasImages = true
|
||||||
|
}
|
||||||
if (isRecordWithMedia) {
|
if (isRecordWithMedia) {
|
||||||
if (
|
if (
|
||||||
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
|
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
|
||||||
@@ -71,6 +83,15 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
// 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,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// one image, not a gallery
|
||||||
|
if (embed.media.items.length === 1) return
|
||||||
|
}
|
||||||
hasImages = true
|
hasImages = true
|
||||||
}
|
}
|
||||||
if (!hasImages) return
|
if (!hasImages) return
|
||||||
|
|||||||
@@ -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,
|
||||||
|
): 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,
|
||||||
|
): 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,
|
||||||
|
): 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,
|
||||||
|
): 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'
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
@@ -21,6 +21,7 @@ import {sha256} from 'js-sha256'
|
|||||||
import {CID} from 'multiformats/cid'
|
import {CID} from 'multiformats/cid'
|
||||||
import * as Hasher from 'multiformats/hashes/hasher'
|
import * as Hasher from 'multiformats/hashes/hasher'
|
||||||
|
|
||||||
|
import {type AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||||
import {isNetworkError} from '#/lib/strings/errors'
|
import {isNetworkError} from '#/lib/strings/errors'
|
||||||
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
import {shortenLinks, stripInvalidMentions} from '#/lib/strings/rich-text-manip'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
@@ -252,6 +253,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>
|
||||||
@@ -311,6 +313,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
|
||||||
> {
|
> {
|
||||||
@@ -338,6 +341,31 @@ async function resolveMedia(
|
|||||||
images,
|
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 (
|
if (
|
||||||
embedDraft.media?.type === 'video' &&
|
embedDraft.media?.type === 'video' &&
|
||||||
embedDraft.media.video.status === 'done'
|
embedDraft.media.video.status === 'done'
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
AppBskyLabelerDefs,
|
AppBskyLabelerDefs,
|
||||||
} from '@atproto/api'
|
} from '@atproto/api'
|
||||||
|
|
||||||
|
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||||
|
|
||||||
export type Embed =
|
export type Embed =
|
||||||
| {
|
| {
|
||||||
type: 'post'
|
type: 'post'
|
||||||
@@ -47,6 +49,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 +128,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',
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -1708,7 +1708,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} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1908,15 +1908,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') {
|
||||||
@@ -2017,7 +2018,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} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import {msg} from '@lingui/core/macro'
|
import {msg} from '@lingui/core/macro'
|
||||||
import {useLingui} from '@lingui/react'
|
import {useLingui} from '@lingui/react'
|
||||||
|
|
||||||
|
import {AppBskyEmbedGallery} from '#/lib/api/gallery-embed-shim'
|
||||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||||
import {type ComposerOptsPostRef} from '#/state/shell/composer'
|
import {type ComposerOptsPostRef} from '#/state/shell/composer'
|
||||||
@@ -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,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({
|
function ComposerReplyToImages({
|
||||||
images,
|
images,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@
|
|||||||
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
import {type AppBskyDraftDefs, AtUri, RichText} from '@atproto/api'
|
||||||
import {nanoid} from 'nanoid/non-secure'
|
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 {resolveLink} from '#/lib/api/resolve'
|
||||||
import {getDeviceName} from '#/lib/deviceName'
|
import {getDeviceName} from '#/lib/deviceName'
|
||||||
import {getImageDim} from '#/lib/media/manip'
|
import {getImageDim} from '#/lib/media/manip'
|
||||||
@@ -115,6 +122,11 @@ async function postDraftToServerPost(
|
|||||||
post.embed.media.images,
|
post.embed.media.images,
|
||||||
localRefPaths,
|
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') {
|
} 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 +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<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',
|
||||||
|
},
|
||||||
|
} as ComposerImage
|
||||||
|
})
|
||||||
|
|
||||||
|
return (await Promise.all(imagePromises)).filter(
|
||||||
|
(img): img is ComposerImage => 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 +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
|
// Process videos
|
||||||
if (post.embedVideos) {
|
if (post.embedVideos) {
|
||||||
for (const vid of post.embedVideos) {
|
for (const vid of post.embedVideos) {
|
||||||
@@ -433,54 +516,21 @@ export async function draftToComposerPosts(
|
|||||||
|
|
||||||
// Restore images
|
// Restore images
|
||||||
if (post.embedImages && post.embedImages.length > 0) {
|
if (post.embedImages && post.embedImages.length > 0) {
|
||||||
const imagePromises = post.embedImages.map(async img => {
|
const images = await restoreDraftImages(post.embedImages, loadedMedia)
|
||||||
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,
|
|
||||||
)
|
|
||||||
if (images.length > 0) {
|
if (images.length > 0) {
|
||||||
embed.media = {type: 'images', images}
|
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
|
// Restore GIF from external embed
|
||||||
if (post.embedExternals) {
|
if (post.embedExternals) {
|
||||||
for (const ext of post.embedExternals) {
|
for (const ext of post.embedExternals) {
|
||||||
@@ -630,6 +680,12 @@ export function extractLocalRefs(draft: AppBskyDraftDefs.Draft): Set<string> {
|
|||||||
refs.add(img.localRef.path)
|
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) {
|
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)
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import {AppBskyDraftCreateDraft, type AppBskyDraftDefs} from '@atproto/api'
|
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 {
|
import {
|
||||||
useInfiniteQuery,
|
useInfiniteQuery,
|
||||||
useMutation,
|
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
|
// Load videos
|
||||||
if (post.embedVideos) {
|
if (post.embedVideos) {
|
||||||
for (const vid of post.embedVideos) {
|
for (const vid of post.embedVideos) {
|
||||||
@@ -226,6 +247,12 @@ export function useDeleteDraftMutation() {
|
|||||||
await storage.deleteMediaFromLocal(img.localRef.path)
|
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) {
|
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)
|
||||||
|
|||||||
@@ -38,6 +38,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 +64,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
|
||||||
}
|
}
|
||||||
@@ -155,6 +160,7 @@ export type ComposerAction =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const MAX_IMAGES = 4
|
export const MAX_IMAGES = 4
|
||||||
|
export const MAX_GALLERY_IMAGES = 10
|
||||||
|
|
||||||
export function composerReducer(
|
export function composerReducer(
|
||||||
state: ComposerState,
|
state: ComposerState,
|
||||||
@@ -338,14 +344,38 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
const prevMedia = state.embed.media
|
const prevMedia = state.embed.media
|
||||||
let nextMedia = prevMedia
|
let nextMedia = prevMedia
|
||||||
if (!prevMedia) {
|
if (!prevMedia) {
|
||||||
nextMedia = {
|
// First selection: pick the variant based on count. ImagesMedia caps
|
||||||
type: 'images',
|
// at 4 (legacy `app.bsky.embed.images`); above that promotes to the
|
||||||
images: action.images.slice(0, MAX_IMAGES),
|
// 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') {
|
} 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 = {
|
nextMedia = {
|
||||||
...prevMedia,
|
...prevMedia,
|
||||||
images: [...prevMedia.images, ...action.images].slice(0, MAX_IMAGES),
|
images: [...prevMedia.images, ...action.images].slice(
|
||||||
|
0,
|
||||||
|
MAX_GALLERY_IMAGES,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -358,7 +388,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 +412,26 @@ 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 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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user