Address review feedback on gallery-embed branch
- Composer.tsx: thread gallery through reply pills (hasMedia), post:create/draft:save analytics, missingAltError, and publish-fail logger annotation - composer/state/composer.ts: surface a logger.warn when embed_add_images would exceed MAX_GALLERY_IMAGES (hard slice preserved); ascii-only comments - maybeApplyGalleryOffsetStyles.ts: scope PostGalleryEmbedEnable gate to image-embed branches so gallery posts always get no-content offsets - MessageOverlays.tsx: store reactionsTargetId instead of a frozen message snapshot; look up the live message each render. Memoize reportSubject on reportTarget+convoId so ReportDialog's parseReportSubject memo survives convo updates. Replace open-after-mount boolean with a ref keyed to id so reopening the same message reliably re-triggers - ImageEmbed.tsx + ImageLayoutGrid.tsx: thread isWithinQuote through the 2-4 image fallthrough; explicit prop overrides the viewContext heuristic - parseReportSubject.ts: flip the image attribute true for gallery embeds (top-level and post_with_media), matching the existing images branches - AppIconSettings/index.tsx: restore the IconName casts the previous change dropped; IconName is still a literal union - composer/photos/Gallery.tsx: wrap to a 4-column grid for n>4 so 10-image previews don't crush remove/alt-text controls - lib/api/index.ts: normalize gallery upload log nouns to image/images
This commit is contained in:
@@ -144,6 +144,7 @@ export function ImageEmbed({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
onPressIn={onPressIn}
|
onPressIn={onPressIn}
|
||||||
viewContext={rest.viewContext}
|
viewContext={rest.viewContext}
|
||||||
|
isWithinQuote={rest.isWithinQuote}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {LayoutAnimation} from 'react-native'
|
import {LayoutAnimation} from 'react-native'
|
||||||
@@ -59,8 +60,23 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
|||||||
} | null>(null)
|
} | null>(null)
|
||||||
const [afterReportTarget, setAfterReportTarget] =
|
const [afterReportTarget, setAfterReportTarget] =
|
||||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
||||||
const [reactionsTarget, setReactionsTarget] =
|
const [reactionsTargetId, setReactionsTargetId] = useState<string | null>(
|
||||||
useState<ChatBskyConvoDefs.MessageView | null>(null)
|
null,
|
||||||
|
)
|
||||||
|
const reactionsOpenRequestedFor = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const liveReactionsMessage = useMemo(() => {
|
||||||
|
if (!reactionsTargetId) return null
|
||||||
|
for (const item of convo.items) {
|
||||||
|
if (
|
||||||
|
(item.type === 'message' || item.type === 'pending-message') &&
|
||||||
|
item.message.id === reactionsTargetId
|
||||||
|
) {
|
||||||
|
return item.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}, [convo.items, reactionsTargetId])
|
||||||
|
|
||||||
const openDeleteMessage = useCallback(
|
const openDeleteMessage = useCallback(
|
||||||
(message: ChatBskyConvoDefs.MessageView) => {
|
(message: ChatBskyConvoDefs.MessageView) => {
|
||||||
@@ -83,19 +99,33 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
|||||||
|
|
||||||
const openReactions = useCallback(
|
const openReactions = useCallback(
|
||||||
(message: ChatBskyConvoDefs.MessageView) => {
|
(message: ChatBskyConvoDefs.MessageView) => {
|
||||||
setReactionsTarget(message)
|
reactionsOpenRequestedFor.current = message.id
|
||||||
|
setReactionsTargetId(message.id)
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
)
|
)
|
||||||
|
|
||||||
// These dialogs are conditionally mounted, so we can't open them in the same
|
// The dialog is conditionally mounted, so we can't open it in the same tick
|
||||||
// tick that we set their targets - the control refs aren't attached yet. Open
|
// that we set the target - the control ref isn't attached yet. Open in an
|
||||||
// in an effect after the dialog has mounted.
|
// effect after the dialog has mounted with a live message resolved.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (reactionsTarget) {
|
if (
|
||||||
|
liveReactionsMessage &&
|
||||||
|
reactionsOpenRequestedFor.current === liveReactionsMessage.id
|
||||||
|
) {
|
||||||
|
reactionsOpenRequestedFor.current = null
|
||||||
reactionsControl.open()
|
reactionsControl.open()
|
||||||
}
|
}
|
||||||
}, [reactionsTarget, reactionsControl])
|
}, [liveReactionsMessage, reactionsControl])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (reactionsTargetId && !liveReactionsMessage) {
|
||||||
|
reactionsControl.close()
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setReactionsTargetId(null)
|
||||||
|
reactionsOpenRequestedFor.current = null
|
||||||
|
}
|
||||||
|
}, [reactionsTargetId, liveReactionsMessage, reactionsControl])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (afterReportTarget) {
|
if (afterReportTarget) {
|
||||||
@@ -125,13 +155,18 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
|||||||
[openDeleteMessage, openReportMessage, openReactions],
|
[openDeleteMessage, openReportMessage, openReactions],
|
||||||
)
|
)
|
||||||
|
|
||||||
const reportSubject = reportTarget
|
const convoId = convo.convo.view.id
|
||||||
? ({
|
const reportSubject = useMemo(
|
||||||
view: 'message',
|
() =>
|
||||||
convoId: convo.convo.view.id,
|
reportTarget
|
||||||
message: reportTarget.message,
|
? ({
|
||||||
} as const)
|
view: 'message',
|
||||||
: undefined
|
convoId,
|
||||||
|
message: reportTarget.message,
|
||||||
|
} as const)
|
||||||
|
: undefined,
|
||||||
|
[reportTarget, convoId],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Context.Provider value={ctx}>
|
<Context.Provider value={ctx}>
|
||||||
@@ -153,12 +188,15 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
|
|||||||
onClose={() => setAfterReportTarget(null)}
|
onClose={() => setAfterReportTarget(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{reactionsTarget && (
|
{liveReactionsMessage && (
|
||||||
<ReactionsDialog
|
<ReactionsDialog
|
||||||
control={reactionsControl}
|
control={reactionsControl}
|
||||||
relatedProfiles={convo.relatedProfiles}
|
relatedProfiles={convo.relatedProfiles}
|
||||||
message={reactionsTarget}
|
message={liveReactionsMessage}
|
||||||
onClose={() => setReactionsTarget(null)}
|
onClose={() => {
|
||||||
|
setReactionsTargetId(null)
|
||||||
|
reactionsOpenRequestedFor.current = null
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Prompt.Basic
|
<Prompt.Basic
|
||||||
|
|||||||
@@ -28,9 +28,6 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
additionalCauses?: ModerationCause[] | AppModerationCause[]
|
additionalCauses?: ModerationCause[] | AppModerationCause[]
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
// don't ever check gates like this, except this one time
|
|
||||||
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
|
||||||
post.record,
|
post.record,
|
||||||
@@ -40,6 +37,13 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The gate only controls whether legacy image embeds opt into the new
|
||||||
|
// expanded gallery layout. Gallery embeds always render expanded by item
|
||||||
|
// count, so their offset must apply regardless of the gate.
|
||||||
|
const isPostGalleryEmbedEnabled = features.isOn(
|
||||||
|
Features.PostGalleryEmbedEnable,
|
||||||
|
)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* First check if we even have images
|
* First check if we even have images
|
||||||
*/
|
*/
|
||||||
@@ -64,12 +68,13 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
)
|
)
|
||||||
let hasImages = false
|
let hasImages = false
|
||||||
if (isImageEmbed) {
|
if (isImageEmbed) {
|
||||||
|
if (!isPostGalleryEmbedEnabled) return
|
||||||
// one image, not a gallery
|
// one image, not a gallery
|
||||||
if (embed.images.length === 1) return
|
if (embed.images.length === 1) return
|
||||||
hasImages = true
|
hasImages = true
|
||||||
}
|
}
|
||||||
if (isGalleryEmbed) {
|
if (isGalleryEmbed) {
|
||||||
// single (or empty) gallery — no offset needed
|
// single (or empty) gallery - no offset needed
|
||||||
if (embed.items.length <= 1) return
|
if (embed.items.length <= 1) return
|
||||||
hasImages = true
|
hasImages = true
|
||||||
}
|
}
|
||||||
@@ -80,6 +85,7 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
AppBskyEmbedImages.isMain,
|
AppBskyEmbedImages.isMain,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
if (!isPostGalleryEmbedEnabled) return
|
||||||
// one image, not a gallery
|
// one image, not a gallery
|
||||||
if (embed.media.images.length === 1) return
|
if (embed.media.images.length === 1) return
|
||||||
}
|
}
|
||||||
@@ -89,7 +95,7 @@ export function maybeApplyGalleryOffsetStyles(
|
|||||||
AppBskyEmbedGallery.isMain,
|
AppBskyEmbedGallery.isMain,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// single (or empty) gallery — no offset needed
|
// single (or empty) gallery - no offset needed
|
||||||
if (embed.media.items.length <= 1) return
|
if (embed.media.items.length <= 1) return
|
||||||
}
|
}
|
||||||
hasImages = true
|
hasImages = true
|
||||||
|
|||||||
@@ -19,16 +19,19 @@ interface ImageLayoutGridProps {
|
|||||||
onPressIn?: (index: number) => void
|
onPressIn?: (index: number) => void
|
||||||
style?: StyleProp<ViewStyle>
|
style?: StyleProp<ViewStyle>
|
||||||
viewContext?: PostEmbedViewContext
|
viewContext?: PostEmbedViewContext
|
||||||
|
isWithinQuote?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
|
export function ImageLayoutGrid({
|
||||||
|
style,
|
||||||
|
isWithinQuote: isWithinQuoteProp,
|
||||||
|
...props
|
||||||
|
}: ImageLayoutGridProps) {
|
||||||
const {gtMobile} = useBreakpoints()
|
const {gtMobile} = useBreakpoints()
|
||||||
const gap =
|
const isWithinQuote =
|
||||||
|
isWithinQuoteProp ??
|
||||||
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
props.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||||
? gtMobile
|
const gap = isWithinQuote ? (gtMobile ? a.gap_xs : a.gap_2xs) : a.gap_xs
|
||||||
? a.gap_xs
|
|
||||||
: a.gap_2xs
|
|
||||||
: a.gap_xs
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={style}>
|
<View style={style}>
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ export function parseReportSubject(
|
|||||||
reply: !!record.reply,
|
reply: !!record.reply,
|
||||||
image:
|
image:
|
||||||
embed.type === 'images' ||
|
embed.type === 'images' ||
|
||||||
(embed.type === 'post_with_media' && embed.media.type === 'images'),
|
embed.type === 'gallery' ||
|
||||||
|
(embed.type === 'post_with_media' &&
|
||||||
|
(embed.media.type === 'images' ||
|
||||||
|
embed.media.type === 'gallery')),
|
||||||
video:
|
video:
|
||||||
embed.type === 'video' ||
|
embed.type === 'video' ||
|
||||||
(embed.type === 'post_with_media' && embed.media.type === 'video'),
|
(embed.type === 'post_with_media' && embed.media.type === 'video'),
|
||||||
|
|||||||
@@ -343,15 +343,15 @@ async function resolveMedia(
|
|||||||
}
|
}
|
||||||
if (embedDraft.media?.type === 'gallery') {
|
if (embedDraft.media?.type === 'gallery') {
|
||||||
const imagesDraft = embedDraft.media.images
|
const imagesDraft = embedDraft.media.images
|
||||||
logger.debug(`Uploading gallery items`, {
|
logger.debug(`Uploading images`, {
|
||||||
count: imagesDraft.length,
|
count: imagesDraft.length,
|
||||||
})
|
})
|
||||||
onStateChange?.(t`Uploading images...`)
|
onStateChange?.(t`Uploading images...`)
|
||||||
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
|
const items: $Typed<AppBskyEmbedGallery.Image>[] = await Promise.all(
|
||||||
imagesDraft.map(async (image, i) => {
|
imagesDraft.map(async (image, i) => {
|
||||||
logger.debug(`Compressing gallery image #${i}`)
|
logger.debug(`Compressing image #${i}`)
|
||||||
const {path, width, height, mime} = await compressImage(image)
|
const {path, width, height, mime} = await compressImage(image)
|
||||||
logger.debug(`Uploading gallery image #${i}`)
|
logger.debug(`Uploading image #${i}`)
|
||||||
const res = await uploadBlob(agent, path, mime)
|
const res = await uploadBlob(agent, path, mime)
|
||||||
return {
|
return {
|
||||||
$type: 'app.bsky.embed.gallery#image' as const,
|
$type: 'app.bsky.embed.gallery#image' as const,
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ function getAppIconName(icon: string | false): DynamicAppIcon.IconName {
|
|||||||
if (!icon || icon === 'DEFAULT') {
|
if (!icon || icon === 'DEFAULT') {
|
||||||
return 'default_light'
|
return 'default_light'
|
||||||
} else {
|
} else {
|
||||||
return icon
|
return icon as DynamicAppIcon.IconName
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ function Group({
|
|||||||
values={[value]}
|
values={[value]}
|
||||||
maxSelections={1}
|
maxSelections={1}
|
||||||
onChange={vals => {
|
onChange={vals => {
|
||||||
if (vals[0]) onChange(vals[0])
|
if (vals[0]) onChange(vals[0] as DynamicAppIcon.IconName)
|
||||||
}}>
|
}}>
|
||||||
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
|
<View style={[a.flex_1, a.rounded_md, a.overflow_hidden]}>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -611,7 +611,11 @@ export const ComposePost = ({
|
|||||||
ax.metric('draft:save', {
|
ax.metric('draft:save', {
|
||||||
isNewDraft,
|
isNewDraft,
|
||||||
hasText: posts.some(p => p.richtext.text.trim().length > 0),
|
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'),
|
hasVideo: posts.some(p => p.embed.media?.type === 'video'),
|
||||||
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
|
hasGif: posts.some(p => p.embed.media?.type === 'gif'),
|
||||||
hasQuote: posts.some(p => !!p.embed.quote),
|
hasQuote: posts.some(p => !!p.embed.quote),
|
||||||
@@ -780,7 +784,10 @@ export const ComposePost = ({
|
|||||||
for (let i = 0; i < thread.posts.length; i++) {
|
for (let i = 0; i < thread.posts.length; i++) {
|
||||||
const media = thread.posts[i].embed.media
|
const media = thread.posts[i].embed.media
|
||||||
if (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.`
|
return l`One or more images is missing alt text.`
|
||||||
}
|
}
|
||||||
if (media.type === 'gif' && !media.alt) {
|
if (media.type === 'gif' && !media.alt) {
|
||||||
@@ -931,7 +938,9 @@ export const ComposePost = ({
|
|||||||
logger.error(e, {
|
logger.error(e, {
|
||||||
message: `Composer: create post failed`,
|
message: `Composer: create post failed`,
|
||||||
hasImages: filteredThread.posts.some(
|
hasImages: filteredThread.posts.some(
|
||||||
p => p.embed.media?.type === 'images',
|
p =>
|
||||||
|
p.embed.media?.type === 'images' ||
|
||||||
|
p.embed.media?.type === 'gallery',
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -953,7 +962,8 @@ export const ComposePost = ({
|
|||||||
for (let post of filteredThread.posts) {
|
for (let post of filteredThread.posts) {
|
||||||
ax.metric('post:create', {
|
ax.metric('post:create', {
|
||||||
imageCount:
|
imageCount:
|
||||||
post.embed.media?.type === 'images'
|
post.embed.media?.type === 'images' ||
|
||||||
|
post.embed.media?.type === 'gallery'
|
||||||
? post.embed.media.images.length
|
? post.embed.media.images.length
|
||||||
: 0,
|
: 0,
|
||||||
isReply: index > 0 || !!replyTo,
|
isReply: index > 0 || !!replyTo,
|
||||||
@@ -1819,7 +1829,11 @@ function ComposerPills({
|
|||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const media = post.embed.media
|
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
|
const hasLink = !!post.embed.link
|
||||||
|
|
||||||
// Don't render anything if no pills are going to be displayed
|
// Don't render anything if no pills are going to be displayed
|
||||||
|
|||||||
@@ -70,11 +70,13 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
|||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
|
||||||
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
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 =
|
const side =
|
||||||
images.length === 1
|
images.length === 1
|
||||||
? 250
|
? 250
|
||||||
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
|
: (containerInfo.width - IMAGE_GAP * (columns - 1)) / columns
|
||||||
images.length
|
|
||||||
|
|
||||||
const isOverflow = isMobile && images.length > 2
|
const isOverflow = isMobile && images.length > 2
|
||||||
|
|
||||||
@@ -272,6 +274,7 @@ const styles = StyleSheet.create({
|
|||||||
gallery: {
|
gallery: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
gap: IMAGE_GAP,
|
gap: IMAGE_GAP,
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
postUriToRelativePath,
|
postUriToRelativePath,
|
||||||
toBskyAppUrl,
|
toBskyAppUrl,
|
||||||
} from '#/lib/strings/url-helpers'
|
} from '#/lib/strings/url-helpers'
|
||||||
|
import {logger} from '#/logger'
|
||||||
import {type ComposerImage, createInitialImages} from '#/state/gallery'
|
import {type ComposerImage, createInitialImages} from '#/state/gallery'
|
||||||
import {createPostgateRecord} from '#/state/queries/postgate/util'
|
import {createPostgateRecord} from '#/state/queries/postgate/util'
|
||||||
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
import {threadgateRecordToAllowUISetting} from '#/state/queries/threadgate'
|
||||||
@@ -163,10 +164,12 @@ export const MAX_IMAGES = 4
|
|||||||
export const MAX_GALLERY_IMAGES = 10
|
export const MAX_GALLERY_IMAGES = 10
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Picks the embed variant for a set of images. ≤4 lands in the legacy
|
* 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`.
|
* `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`.
|
||||||
* Anything beyond the gallery cap is silently dropped — callers should
|
* Anything beyond the gallery cap is dropped by the hard slice; callers
|
||||||
* already have enforced the cap upstream (picker, paste, etc).
|
* 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(
|
function imagesToMediaVariant(
|
||||||
images: ComposerImage[],
|
images: ComposerImage[],
|
||||||
@@ -357,6 +360,21 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
}
|
}
|
||||||
const prevMedia = state.embed.media
|
const prevMedia = state.embed.media
|
||||||
let nextMedia = prevMedia
|
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) {
|
||||||
|
// TODO: surface this to the user via a toast once the composer
|
||||||
|
// state shape supports reducer-emitted errors. 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) {
|
if (!prevMedia) {
|
||||||
nextMedia = imagesToMediaVariant(action.images)
|
nextMedia = imagesToMediaVariant(action.images)
|
||||||
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
|
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
|
||||||
@@ -412,7 +430,7 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Re-pick the variant so a gallery that shrinks to <=4 demotes
|
// Re-pick the variant so a gallery that shrinks to <=4 demotes
|
||||||
// back to the legacy `app.bsky.embed.images` shape — keeps old
|
// back to the legacy `app.bsky.embed.images` shape - keeps old
|
||||||
// clients rendering it when possible.
|
// clients rendering it when possible.
|
||||||
nextMedia = imagesToMediaVariant(remainingImages)
|
nextMedia = imagesToMediaVariant(remainingImages)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user