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:
@@ -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
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user