diff --git a/src/components/dms/MessageOverlays.tsx b/src/components/dms/MessageOverlays.tsx index 12bf72adc8..7849147104 100644 --- a/src/components/dms/MessageOverlays.tsx +++ b/src/components/dms/MessageOverlays.tsx @@ -63,6 +63,8 @@ export function MessageOverlays({children}: {children: React.ReactNode}) { const [reactionsTargetId, setReactionsTargetId] = useState( null, ) + const [lastKnownReactionsMessage, setLastKnownReactionsMessage] = + useState(null) const reactionsOpenRequestedFor = useRef(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 && ( { setReactionsTargetId(null) + setLastKnownReactionsMessage(null) reactionsOpenRequestedFor.current = null }} /> diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 4943ef9adb..5b4ba608b2 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -36,7 +36,11 @@ export function ImageLayoutGrid({ return ( - + ) @@ -52,6 +56,7 @@ interface ImageLayoutGridInnerProps { onLongPress?: (index: number) => void onPressIn?: (index: number) => void viewContext?: PostEmbedViewContext + isWithinQuote?: boolean gap: {gap: number} } diff --git a/src/components/images/ImageLayoutGridItem.tsx b/src/components/images/ImageLayoutGridItem.tsx index 0c349b6612..114e82985c 100644 --- a/src/components/images/ImageLayoutGridItem.tsx +++ b/src/components/images/ImageLayoutGridItem.tsx @@ -29,6 +29,7 @@ interface Props { onPressIn?: EventFunction imageStyle?: StyleProp viewContext?: PostEmbedViewContext + isWithinQuote?: boolean insetBorderStyle?: StyleProp containerRefs: AnimatedRef[] 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 = diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index b622022070..fa1394ff27 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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( diff --git a/src/view/com/composer/drafts/state/api.ts b/src/view/com/composer/drafts/state/api.ts index 75475fc142..a6aab1425a 100644 --- a/src/view/com/composer/drafts/state/api.ts +++ b/src/view/com/composer/drafts/state/api.ts @@ -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