Close remaining gallery-embed review gaps

- drafts/state/api.ts: restore path now picks images vs gallery
  variant by count instead of trusting the server slot, mirroring
  imagesToMediaVariant. Save path was already correct in prior commits
- Composer.tsx: add applyGalleryCap helper and wire it into both
  onImageAdd callsites (covers picker, paste, camera, drag-drop) so
  the user gets a toast when adds would exceed MAX_GALLERY_IMAGES.
  Reducer-level logger.warn remains as defense in depth
- ImageLayoutGrid/Item: propagate isWithinQuote into GalleryItem so
  hideBadges also honors the explicit prop, matching the layout fix
- MessageOverlays: keep a snapshot of the last-known reactions message
  so the bottom sheet plays its close animation through when the
  underlying message is deleted; live updates while open are unchanged
This commit is contained in:
vineyardbovines
2026-06-03 18:28:47 -04:00
parent af2eec0f84
commit 06298eb58a
5 changed files with 120 additions and 18 deletions
+17 -2
View File
@@ -63,6 +63,8 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
const [reactionsTargetId, setReactionsTargetId] = useState<string | null>(
null,
)
const [lastKnownReactionsMessage, setLastKnownReactionsMessage] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const reactionsOpenRequestedFor = useRef<string | null>(null)
const liveReactionsMessage = useMemo(() => {
@@ -78,6 +80,9 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
return null
}, [convo.items, reactionsTargetId])
const displayReactionsMessage =
liveReactionsMessage ?? lastKnownReactionsMessage
const openDeleteMessage = useCallback(
(message: ChatBskyConvoDefs.MessageView) => {
setDeleteTarget(message)
@@ -118,6 +123,15 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
}
}, [liveReactionsMessage, reactionsControl])
// Keep a snapshot of the live message so the dialog can finish its close
// animation if the underlying message disappears from convo.items.
useEffect(() => {
if (liveReactionsMessage) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLastKnownReactionsMessage(liveReactionsMessage)
}
}, [liveReactionsMessage])
useEffect(() => {
if (reactionsTargetId && !liveReactionsMessage) {
reactionsControl.close()
@@ -188,13 +202,14 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
onClose={() => setAfterReportTarget(null)}
/>
)}
{liveReactionsMessage && (
{displayReactionsMessage && (
<ReactionsDialog
control={reactionsControl}
relatedProfiles={convo.relatedProfiles}
message={liveReactionsMessage}
message={displayReactionsMessage}
onClose={() => {
setReactionsTargetId(null)
setLastKnownReactionsMessage(null)
reactionsOpenRequestedFor.current = null
}}
/>
+6 -1
View File
@@ -36,7 +36,11 @@ export function ImageLayoutGrid({
return (
<View style={style}>
<View style={[gap, a.rounded_md, a.overflow_hidden]}>
<ImageLayoutGridInner {...props} gap={gap} />
<ImageLayoutGridInner
{...props}
gap={gap}
isWithinQuote={isWithinQuote}
/>
</View>
</View>
)
@@ -52,6 +56,7 @@ interface ImageLayoutGridInnerProps {
onLongPress?: (index: number) => void
onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
gap: {gap: number}
}
@@ -29,6 +29,7 @@ interface Props {
onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.RefObject<(Dimensions | null)[]>
@@ -42,6 +43,7 @@ export function GalleryItem({
onPressIn,
onLongPress,
viewContext,
isWithinQuote,
insetBorderStyle,
containerRefs,
thumbDimsRef,
@@ -52,6 +54,7 @@ export function GalleryItem({
const image = images[index]
const hasAlt = !!image.alt
const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect =
+74 -4
View File
@@ -178,6 +178,27 @@ type CancelRef = {
onPressCancel: () => void
}
function applyGalleryCap(
currentCount: number,
incoming: ComposerImage[],
):
| {status: 'full'}
| {status: 'partial'; accepted: ComposerImage[]; dropped: number}
| {status: 'ok'; accepted: ComposerImage[]} {
const remaining = MAX_GALLERY_IMAGES - currentCount
if (remaining <= 0) {
return {status: 'full'}
}
if (incoming.length > remaining) {
return {
status: 'partial',
accepted: incoming.slice(0, remaining),
dropped: incoming.length - remaining,
}
}
return {status: 'ok', accepted: incoming}
}
type Props = ComposerOpts
export const ComposePost = ({
replyTo,
@@ -1407,12 +1428,39 @@ let ComposerPost = memo(function ComposerPost({
const onImageAdd = useCallback(
(next: ComposerImage[]) => {
const media = post.embed.media
const currentCount =
media?.type === 'images' || media?.type === 'gallery'
? media.images.length
: 0
const result = applyGalleryCap(currentCount, next)
if (result.status === 'full') {
Toast.show(
l({
message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`,
comment:
'Toast shown when the user tries to add more images but the post gallery is already at the cap',
}),
{type: 'warning'},
)
return
}
if (result.status === 'partial') {
Toast.show(
l({
message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`,
comment:
'Toast shown when adding images would exceed the post gallery cap; only the first N are kept',
}),
{type: 'warning'},
)
}
dispatchPost({
type: 'embed_add_images',
images: next,
images: result.accepted,
})
},
[dispatchPost],
[dispatchPost, l, post.embed.media],
)
const onNewLink = useCallback(
@@ -1943,12 +1991,34 @@ function ComposerFooter({
const onImageAdd = useCallback(
(next: ComposerImage[]) => {
const result = applyGalleryCap(images.length, next)
if (result.status === 'full') {
Toast.show(
l({
message: `You can only add up to ${MAX_GALLERY_IMAGES} images per post`,
comment:
'Toast shown when the user tries to add more images but the post gallery is already at the cap',
}),
{type: 'warning'},
)
return
}
if (result.status === 'partial') {
Toast.show(
l({
message: `Only ${result.accepted.length} of ${next.length} ${plural(next.length, {one: 'image', other: 'images'})} added; limit is ${MAX_GALLERY_IMAGES}`,
comment:
'Toast shown when adding images would exceed the post gallery cap; only the first N are kept',
}),
{type: 'warning'},
)
}
dispatch({
type: 'embed_add_images',
images: next,
images: result.accepted,
})
},
[dispatch],
[dispatch, images.length, l],
)
const onSelectGif = useCallback(
+20 -11
View File
@@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent'
import {
type ComposerState,
type EmbedDraft,
MAX_IMAGES,
type PostDraft,
} from '#/view/com/composer/state/composer'
import {type VideoState} from '#/view/com/composer/state/video'
@@ -512,23 +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 images = await restoreDraftImages(post.embedImages, loadedMedia)
if (images.length > 0) {
embed.media = {type: 'images', images}
}
restoredImages.push(
...(await restoreDraftImages(post.embedImages, loadedMedia)),
)
}
// Restore gallery
if (post.embedGallery && post.embedGallery.items.length > 0) {
const galleryImages = post.embedGallery.items.filter(
AppBskyDraftDefs.isDraftEmbedImage,
)
const images = await restoreDraftImages(galleryImages, loadedMedia)
if (images.length > 0) {
embed.media = {type: 'gallery', images}
}
restoredImages.push(
...(await restoreDraftImages(galleryImages, loadedMedia)),
)
}
if (restoredImages.length > 0) {
embed.media =
restoredImages.length <= MAX_IMAGES
? {type: 'images', images: restoredImages}
: {type: 'gallery', images: restoredImages}
}
// Restore GIF from external embed