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>( const [reactionsTargetId, setReactionsTargetId] = useState<string | null>(
null, null,
) )
const [lastKnownReactionsMessage, setLastKnownReactionsMessage] =
useState<ChatBskyConvoDefs.MessageView | null>(null)
const reactionsOpenRequestedFor = useRef<string | null>(null) const reactionsOpenRequestedFor = useRef<string | null>(null)
const liveReactionsMessage = useMemo(() => { const liveReactionsMessage = useMemo(() => {
@@ -78,6 +80,9 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
return null return null
}, [convo.items, reactionsTargetId]) }, [convo.items, reactionsTargetId])
const displayReactionsMessage =
liveReactionsMessage ?? lastKnownReactionsMessage
const openDeleteMessage = useCallback( const openDeleteMessage = useCallback(
(message: ChatBskyConvoDefs.MessageView) => { (message: ChatBskyConvoDefs.MessageView) => {
setDeleteTarget(message) setDeleteTarget(message)
@@ -118,6 +123,15 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
} }
}, [liveReactionsMessage, reactionsControl]) }, [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(() => { useEffect(() => {
if (reactionsTargetId && !liveReactionsMessage) { if (reactionsTargetId && !liveReactionsMessage) {
reactionsControl.close() reactionsControl.close()
@@ -188,13 +202,14 @@ export function MessageOverlays({children}: {children: React.ReactNode}) {
onClose={() => setAfterReportTarget(null)} onClose={() => setAfterReportTarget(null)}
/> />
)} )}
{liveReactionsMessage && ( {displayReactionsMessage && (
<ReactionsDialog <ReactionsDialog
control={reactionsControl} control={reactionsControl}
relatedProfiles={convo.relatedProfiles} relatedProfiles={convo.relatedProfiles}
message={liveReactionsMessage} message={displayReactionsMessage}
onClose={() => { onClose={() => {
setReactionsTargetId(null) setReactionsTargetId(null)
setLastKnownReactionsMessage(null)
reactionsOpenRequestedFor.current = null reactionsOpenRequestedFor.current = null
}} }}
/> />
+6 -1
View File
@@ -36,7 +36,11 @@ export function ImageLayoutGrid({
return ( return (
<View style={style}> <View style={style}>
<View style={[gap, a.rounded_md, a.overflow_hidden]}> <View style={[gap, a.rounded_md, a.overflow_hidden]}>
<ImageLayoutGridInner {...props} gap={gap} /> <ImageLayoutGridInner
{...props}
gap={gap}
isWithinQuote={isWithinQuote}
/>
</View> </View>
</View> </View>
) )
@@ -52,6 +56,7 @@ interface ImageLayoutGridInnerProps {
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
gap: {gap: number} gap: {gap: number}
} }
@@ -29,6 +29,7 @@ interface Props {
onPressIn?: EventFunction onPressIn?: EventFunction
imageStyle?: StyleProp<ImageStyle> imageStyle?: StyleProp<ImageStyle>
viewContext?: PostEmbedViewContext viewContext?: PostEmbedViewContext
isWithinQuote?: boolean
insetBorderStyle?: StyleProp<ViewStyle> insetBorderStyle?: StyleProp<ViewStyle>
containerRefs: AnimatedRef<any>[] containerRefs: AnimatedRef<any>[]
thumbDimsRef: React.RefObject<(Dimensions | null)[]> thumbDimsRef: React.RefObject<(Dimensions | null)[]>
@@ -42,6 +43,7 @@ export function GalleryItem({
onPressIn, onPressIn,
onLongPress, onLongPress,
viewContext, viewContext,
isWithinQuote,
insetBorderStyle, insetBorderStyle,
containerRefs, containerRefs,
thumbDimsRef, thumbDimsRef,
@@ -52,6 +54,7 @@ export function GalleryItem({
const image = images[index] const image = images[index]
const hasAlt = !!image.alt const hasAlt = !!image.alt
const hideBadges = const hideBadges =
isWithinQuote ??
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const aspect = const aspect =
+74 -4
View File
@@ -178,6 +178,27 @@ type CancelRef = {
onPressCancel: () => void 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 type Props = ComposerOpts
export const ComposePost = ({ export const ComposePost = ({
replyTo, replyTo,
@@ -1407,12 +1428,39 @@ let ComposerPost = memo(function ComposerPost({
const onImageAdd = useCallback( const onImageAdd = useCallback(
(next: ComposerImage[]) => { (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({ dispatchPost({
type: 'embed_add_images', type: 'embed_add_images',
images: next, images: result.accepted,
}) })
}, },
[dispatchPost], [dispatchPost, l, post.embed.media],
) )
const onNewLink = useCallback( const onNewLink = useCallback(
@@ -1943,12 +1991,34 @@ function ComposerFooter({
const onImageAdd = useCallback( const onImageAdd = useCallback(
(next: ComposerImage[]) => { (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({ dispatch({
type: 'embed_add_images', type: 'embed_add_images',
images: next, images: result.accepted,
}) })
}, },
[dispatch], [dispatch, images.length, l],
) )
const onSelectGif = useCallback( const onSelectGif = useCallback(
+20 -11
View File
@@ -15,6 +15,7 @@ import {createPublicAgent} from '#/state/session/agent'
import { import {
type ComposerState, type ComposerState,
type EmbedDraft, type EmbedDraft,
MAX_IMAGES,
type PostDraft, type PostDraft,
} from '#/view/com/composer/state/composer' } from '#/view/com/composer/state/composer'
import {type VideoState} from '#/view/com/composer/state/video' import {type VideoState} from '#/view/com/composer/state/video'
@@ -512,23 +513,31 @@ export async function draftToComposerPosts(
media: undefined, 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) { if (post.embedImages && post.embedImages.length > 0) {
const images = await restoreDraftImages(post.embedImages, loadedMedia) restoredImages.push(
if (images.length > 0) { ...(await restoreDraftImages(post.embedImages, loadedMedia)),
embed.media = {type: 'images', images} )
}
} }
// Restore gallery
if (post.embedGallery && post.embedGallery.items.length > 0) { if (post.embedGallery && post.embedGallery.items.length > 0) {
const galleryImages = post.embedGallery.items.filter( const galleryImages = post.embedGallery.items.filter(
AppBskyDraftDefs.isDraftEmbedImage, AppBskyDraftDefs.isDraftEmbedImage,
) )
const images = await restoreDraftImages(galleryImages, loadedMedia) restoredImages.push(
if (images.length > 0) { ...(await restoreDraftImages(galleryImages, loadedMedia)),
embed.media = {type: 'gallery', images} )
} }
if (restoredImages.length > 0) {
embed.media =
restoredImages.length <= MAX_IMAGES
? {type: 'images', images: restoredImages}
: {type: 'gallery', images: restoredImages}
} }
// Restore GIF from external embed // Restore GIF from external embed