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