From ff92f9093c22ea06d5ffc7c4d1740deb5b6ea021 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 17:37:47 +0200 Subject: [PATCH 1/3] fix: reanimated 4.3.0 preview list complete rework --- .../AttachmentUploadPreviewList.test.tsx | 54 +- .../AttachmentUploadPreviewList.tsx | 535 ++++++++++++------ 2 files changed, 422 insertions(+), 167 deletions(-) diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx index 20d9216c28..253db525c6 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx @@ -1,6 +1,6 @@ import React, { ComponentProps } from 'react'; -import { ActivityIndicator } from 'react-native'; +import { ActivityIndicator, FlatList } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; @@ -178,6 +178,58 @@ describe('AttachmentUploadPreviewList', () => { }); describe('FileAttachmentUploadPreview', () => { + it('anchors the preview list to the end when content shrinks near the end', () => { + const requestAnimationFrameSpy = jest + .spyOn(global, 'requestAnimationFrame') + .mockImplementation((callback) => { + callback(0); + return 0; + }); + const scrollToOffsetSpy = jest + .spyOn(FlatList.prototype, 'scrollToOffset') + .mockImplementation(() => undefined); + try { + const attachments = [ + generateFileAttachment({ + localMetadata: { + id: 'file-attachment-1', + uploadState: FileState.FINISHED, + }, + }), + generateFileAttachment({ + localMetadata: { + id: 'file-attachment-2', + uploadState: FileState.FINISHED, + }, + }), + ]; + const props = {}; + + act(() => { + channel.messageComposer.attachmentManager.upsertAttachments(attachments); + }); + + renderComponent({ channel, client, props }); + + const list = screen.UNSAFE_getByType(FlatList); + + act(() => { + fireEvent(list, 'layout', { nativeEvent: { layout: { width: 100 } } }); + list.props.onContentSizeChange(300, 0); + list.props.onScroll({ nativeEvent: { contentOffset: { x: 190 } } }); + list.props.onContentSizeChange(295, 0); + }); + + expect(scrollToOffsetSpy).toHaveBeenCalledWith({ + animated: true, + offset: 195, + }); + } finally { + requestAnimationFrameSpy.mockRestore(); + scrollToOffsetSpy.mockRestore(); + } + }); + it('should render FileAttachmentUploadPreview with all uploading files', async () => { const attachments = [ generateFileAttachment({ diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 15fbb71f6d..016f35e3fe 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -1,23 +1,17 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { - FlatList, I18nManager, LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, + ScrollView, + StyleProp, StyleSheet, View, + ViewStyle, } from 'react-native'; -import Animated, { - cancelAnimation, - ZoomIn, - ZoomOut, - LinearTransition, - useAnimatedStyle, - useSharedValue, - withSpring, -} from 'react-native-reanimated'; +import Animated, { LinearTransition, ZoomIn, ZoomOut } from 'react-native-reanimated'; import { isLocalAudioAttachment, @@ -39,22 +33,40 @@ import { isSoundPackageAvailable } from '../../../../native'; import { primitives } from '../../../../theme'; const END_ANCHOR_THRESHOLD = 16; -const END_SHRINK_COMPENSATION_DURATION = 200; +const ATTACHMENT_PREVIEW_ANIMATION_DURATION = 200; +const TRAILING_SPACER_RELEASE_DELAY = ATTACHMENT_PREVIEW_ANIMATION_DURATION + 80; const MAX_AUDIO_ATTACHMENTS_CONTAINER_WIDTH = 560; +const attachmentPreviewEntering = ZoomIn.duration(ATTACHMENT_PREVIEW_ANIMATION_DURATION); +const attachmentPreviewExiting = ZoomOut.duration(ATTACHMENT_PREVIEW_ANIMATION_DURATION); +const attachmentPreviewLayout = LinearTransition.duration(ATTACHMENT_PREVIEW_ANIMATION_DURATION); export type AttachmentUploadListPreviewPropsWithContext = Record; -const AttachmentPreviewCell = ({ children }: { children: React.ReactNode }) => ( +const AttachmentPreviewCell = ({ + children, + onLayout, + style, +}: { + children: React.ReactNode; + onLayout?: (event: LayoutChangeEvent) => void; + style?: StyleProp; +}) => ( {children} ); -const ItemSeparatorComponent = () => { +const ItemSeparatorComponent = ({ + onLayout, +}: { + onLayout?: (event: LayoutChangeEvent) => void; +}) => { const { theme: { messageComposer: { @@ -62,7 +74,7 @@ const ItemSeparatorComponent = () => { }, }, } = useTheme(); - return ; + return ; }; const getIsAudioAttachmentPreview = @@ -88,17 +100,23 @@ const UnMemoizedAttachmentUploadPreviewList = () => { const { attachmentManager } = useMessageComposer(); const { attachments } = useAttachmentManagerState(); const isRTL = I18nManager.isRTL; - const attachmentListRef = useRef>(null); + const attachmentListRef = useRef(null); const soundPackageAvailable = isSoundPackageAvailable(); const isAudioAttachmentPreview = getIsAudioAttachmentPreview(soundPackageAvailable); + const previousDataRef = useRef([]); const previousNonAudioAttachmentsLengthRef = useRef(0); const contentWidthRef = useRef(0); const itemsContentWidthRef = useRef(0); const viewportWidthRef = useRef(0); const scrollOffsetXRef = useRef(0); - const rtlLeadingSpacerWidthRef = useRef(0); - const endShrinkCompensationX = useSharedValue(0); - const [rtlLeadingSpacerWidth, setRtlLeadingSpacerWidth] = useState(0); + const attachmentCellWidthsRef = useRef>({}); + const itemSeparatorWidthRef = useRef(primitives.spacingXs); + const preparedRemovalIdsRef = useRef>(new Set()); + const spacerReleaseFramesRef = useRef>(new Set()); + const spacerReleaseTimeoutsRef = useRef>>(new Set()); + const shouldScrollToEndOnContentSizeChangeRef = useRef(false); + const trailingSpacerWidthRef = useRef(0); + const [trailingSpacerWidth, setTrailingSpacerWidth] = useState(0); const previewAttachments = attachments.filter( (attachment) => !(audioRecordingSendOnComplete && isLocalVoiceRecordingAttachment(attachment)), ); @@ -116,90 +134,254 @@ const UnMemoizedAttachmentUploadPreviewList = () => { }, } = useTheme(); - const updateRtlLeadingSpacerWidth = useCallback( - (itemsWidth: number, viewportWidth: number) => { - if (!isRTL || !viewportWidth) { - if (rtlLeadingSpacerWidthRef.current !== 0) { - rtlLeadingSpacerWidthRef.current = 0; - setRtlLeadingSpacerWidth(0); + const scrollToOffset = useCallback((offset: number, animated = false) => { + const nextOffset = Math.max(0, offset); + + attachmentListRef.current?.scrollTo({ + animated, + x: nextOffset, + }); + scrollOffsetXRef.current = nextOffset; + }, []); + + const setTrailingSpacerLayoutWidth = useCallback((width: number) => { + const nextWidth = Math.max(0, width); + trailingSpacerWidthRef.current = nextWidth; + setTrailingSpacerWidth(nextWidth); + }, []); + + const prepareTrailingSpacer = useCallback( + (width: number) => { + if (width <= 0) { + return; + } + + const nextWidth = trailingSpacerWidthRef.current + width; + setTrailingSpacerLayoutWidth(nextWidth); + }, + [setTrailingSpacerLayoutWidth], + ); + + const scheduleTrailingSpacerRelease = useCallback( + (width: number) => { + if (width <= 0) { + return; + } + + const timeout = setTimeout(() => { + spacerReleaseTimeoutsRef.current.delete(timeout); + + const firstFrame = requestAnimationFrame(() => { + spacerReleaseFramesRef.current.delete(firstFrame); + + const secondFrame = requestAnimationFrame(() => { + spacerReleaseFramesRef.current.delete(secondFrame); + setTrailingSpacerLayoutWidth(trailingSpacerWidthRef.current - width); + }); + + spacerReleaseFramesRef.current.add(secondFrame); + }); + + spacerReleaseFramesRef.current.add(firstFrame); + }, TRAILING_SPACER_RELEASE_DELAY); + + spacerReleaseTimeoutsRef.current.add(timeout); + }, + [setTrailingSpacerLayoutWidth], + ); + + const getRemovalMetrics = useCallback((ids: string[], baseData: LocalAttachment[]) => { + const removedIds = new Set(ids); + const remainingItems = baseData.filter( + (attachment) => !removedIds.has(attachment.localMetadata.id), + ); + const fallbackCellWidth = baseData.length ? itemsContentWidthRef.current / baseData.length : 0; + const offsetBefore = scrollOffsetXRef.current; + const oldMaxOffset = Math.max(0, itemsContentWidthRef.current - viewportWidthRef.current); + const wasNearEnd = oldMaxOffset - offsetBefore <= END_ANCHOR_THRESHOLD; + let firstCellWidth = 0; + let contentOffset = 0; + let removedContentWidth = 0; + let anchorCorrectionWidth = 0; + + baseData.forEach((attachment, index) => { + const attachmentId = attachment.localMetadata.id; + const cellWidth = attachmentCellWidthsRef.current[attachmentId] ?? fallbackCellWidth; + + if (index === 0) { + firstCellWidth = cellWidth; + } + + if (removedIds.has(attachmentId)) { + removedContentWidth += cellWidth; + if (contentOffset <= offsetBefore) { + anchorCorrectionWidth += cellWidth; } + } + + contentOffset += cellWidth; + }); + + const firstAttachmentId = baseData[0]?.localMetadata.id; + const didRemoveFirstItem = !!firstAttachmentId && removedIds.has(firstAttachmentId); + const hasRemainingAfterFirst = baseData + .slice(1) + .some((attachment) => !removedIds.has(attachment.localMetadata.id)); + + if (didRemoveFirstItem && hasRemainingAfterFirst) { + removedContentWidth += itemSeparatorWidthRef.current; + if (firstCellWidth <= offsetBefore) { + anchorCorrectionWidth += itemSeparatorWidthRef.current; + } + } + + if (!removedContentWidth || remainingItems.length === baseData.length) { + return { + removedContentWidth: 0, + scrollCorrectionWidth: 0, + }; + } + + return { + removedContentWidth, + scrollCorrectionWidth: wasNearEnd + ? removedContentWidth + : Math.min(anchorCorrectionWidth, removedContentWidth), + }; + }, []); + + const applyRemovalScrollCorrection = useCallback( + (removedContentWidth: number, scrollCorrectionWidth: number) => { + if (removedContentWidth <= 0 || isRTL) { return; } - const nextSpacerWidth = Math.max(0, viewportWidth - itemsWidth); - if (rtlLeadingSpacerWidthRef.current === nextSpacerWidth) { + const offsetBefore = scrollOffsetXRef.current; + const nextContentWidth = Math.max(0, itemsContentWidthRef.current - removedContentWidth); + const nextMaxOffset = Math.max(0, nextContentWidth - viewportWidthRef.current); + const nextOffset = Math.min(nextMaxOffset, Math.max(0, offsetBefore - scrollCorrectionWidth)); + + if (nextOffset !== offsetBefore) { + scrollToOffset(nextOffset, true); + } + }, + [isRTL, scrollToOffset], + ); + + const prepareForRemoval = useCallback( + (ids: string[], baseData: LocalAttachment[]) => { + const { removedContentWidth, scrollCorrectionWidth } = getRemovalMetrics(ids, baseData); + + if (!removedContentWidth) { return; } - rtlLeadingSpacerWidthRef.current = nextSpacerWidth; - setRtlLeadingSpacerWidth(nextSpacerWidth); + if (!isRTL) { + prepareTrailingSpacer(removedContentWidth); + } + applyRemovalScrollCorrection(removedContentWidth, scrollCorrectionWidth); + ids.forEach((id) => preparedRemovalIdsRef.current.add(id)); + }, + [applyRemovalScrollCorrection, getRemovalMetrics, isRTL, prepareTrailingSpacer], + ); + + const removeAttachments = useCallback( + (ids: string[]) => { + prepareForRemoval(ids, data); + attachmentManager.removeAttachments(ids); }, - [isRTL], + [attachmentManager, data, prepareForRemoval], ); - const renderItem = useCallback( - ({ item }: { item: LocalAttachment }) => { - if (isLocalImageAttachment(item)) { + useLayoutEffect(() => { + const previousData = previousDataRef.current; + const nextIds = new Set(data.map((attachment) => attachment.localMetadata.id)); + const removedIds = previousData + .map((attachment) => attachment.localMetadata.id) + .filter((id) => !nextIds.has(id)); + + if (removedIds.length) { + const { removedContentWidth } = getRemovalMetrics(removedIds, previousData); + const unpreparedRemovedIds = removedIds.filter( + (id) => !preparedRemovalIdsRef.current.has(id), + ); + + const didPrepareAfterRemovalCommit = unpreparedRemovedIds.length > 0; + + if (didPrepareAfterRemovalCommit) { + prepareForRemoval(unpreparedRemovedIds, previousData); + } + + removedIds.forEach((id) => preparedRemovalIdsRef.current.delete(id)); + if (!isRTL) { + scheduleTrailingSpacerRelease(removedContentWidth); + } + } + + previousDataRef.current = data; + }, [data, getRemovalMetrics, isRTL, prepareForRemoval, scheduleTrailingSpacerRelease]); + + useEffect( + () => () => { + spacerReleaseFramesRef.current.forEach(cancelAnimationFrame); + spacerReleaseFramesRef.current.clear(); + spacerReleaseTimeoutsRef.current.forEach(clearTimeout); + spacerReleaseTimeoutsRef.current.clear(); + }, + [], + ); + + const renderAttachmentPreview = useCallback( + (attachment: LocalAttachment) => { + if (isLocalImageAttachment(attachment)) { return ( - - - + ); - } else if (isLocalVoiceRecordingAttachment(item)) { + } else if (isLocalVoiceRecordingAttachment(attachment)) { return ( - - - + ); - } else if (isLocalAudioAttachment(item)) { + } else if (isLocalAudioAttachment(attachment)) { if (isSoundPackageAvailable()) { return ( - - - + ); } else { return ( - - - + ); } - } else if (isVideoAttachment(item)) { + } else if (isVideoAttachment(attachment)) { return ( - - - + ); - } else if (isLocalFileAttachment(item)) { + } else if (isLocalFileAttachment(attachment)) { return ( - - - + ); } else return null; }, @@ -208,8 +390,8 @@ const UnMemoizedAttachmentUploadPreviewList = () => { FileAttachmentUploadPreview, ImageAttachmentUploadPreview, VideoAttachmentUploadPreview, - attachmentManager.removeAttachments, attachmentManager.uploadAttachment, + removeAttachments, ], ); @@ -217,62 +399,62 @@ const UnMemoizedAttachmentUploadPreviewList = () => { scrollOffsetXRef.current = event.nativeEvent.contentOffset.x; }, []); - const onLayoutHandler = useCallback( - (event: LayoutChangeEvent) => { - const viewportWidth = event.nativeEvent.layout.width; - viewportWidthRef.current = viewportWidth; - updateRtlLeadingSpacerWidth(itemsContentWidthRef.current, viewportWidth); + const scrollToEndOffset = useCallback( + (contentWidth: number, animated = true) => { + if (isRTL) { + return; + } + + scrollToOffset(Math.max(0, contentWidth - viewportWidthRef.current), animated); }, - [updateRtlLeadingSpacerWidth], + [isRTL, scrollToOffset], ); + const onLayoutHandler = useCallback((event: LayoutChangeEvent) => { + viewportWidthRef.current = event.nativeEvent.layout.width; + }, []); + + const onAttachmentCellLayout = useCallback((id: string, event: LayoutChangeEvent) => { + attachmentCellWidthsRef.current[id] = event.nativeEvent.layout.width; + }, []); + + const onItemSeparatorLayout = useCallback((event: LayoutChangeEvent) => { + itemSeparatorWidthRef.current = event.nativeEvent.layout.width; + }, []); + const onContentSizeChangeHandler = useCallback( (width: number) => { - const itemsContentWidth = isRTL - ? Math.max(0, width - rtlLeadingSpacerWidthRef.current) - : width; + const scrollableContentWidth = width; + const itemsContentWidth = Math.max( + 0, + scrollableContentWidth - trailingSpacerWidthRef.current, + ); const previousContentWidth = contentWidthRef.current; contentWidthRef.current = itemsContentWidth; itemsContentWidthRef.current = itemsContentWidth; - updateRtlLeadingSpacerWidth(itemsContentWidth, viewportWidthRef.current); - if (!previousContentWidth || itemsContentWidth >= previousContentWidth) { + if ( + shouldScrollToEndOnContentSizeChangeRef.current && + itemsContentWidth > previousContentWidth + ) { + shouldScrollToEndOnContentSizeChangeRef.current = false; + scrollToEndOffset(scrollableContentWidth); return; } - const oldMaxOffset = Math.max(0, previousContentWidth - viewportWidthRef.current); - const newMaxOffset = Math.max(0, itemsContentWidth - viewportWidthRef.current); - const offsetBefore = scrollOffsetXRef.current; - const wasNearEnd = oldMaxOffset - offsetBefore <= END_ANCHOR_THRESHOLD; - const overshoot = Math.max(0, offsetBefore - newMaxOffset); - const shouldAnchorEnd = wasNearEnd || overshoot > 0; - - if (!shouldAnchorEnd) { + if (!previousContentWidth || itemsContentWidth >= previousContentWidth) { return; } - if (overshoot > 0) { - attachmentListRef.current?.scrollToOffset({ - animated: false, - offset: newMaxOffset, - }); - scrollOffsetXRef.current = newMaxOffset; - } - - if (isRTL) { - return; - } + const actualMaxOffset = Math.max(0, scrollableContentWidth - viewportWidthRef.current); + const offsetBefore = scrollOffsetXRef.current; + const overshoot = Math.max(0, offsetBefore - actualMaxOffset); - const compensation = newMaxOffset - oldMaxOffset; - if (compensation !== 0) { - cancelAnimation(endShrinkCompensationX); - endShrinkCompensationX.value = compensation; - endShrinkCompensationX.value = withSpring(0, { - duration: END_SHRINK_COMPENSATION_DURATION, - }); + if (overshoot > END_ANCHOR_THRESHOLD) { + scrollToOffset(actualMaxOffset); } }, - [endShrinkCompensationX, isRTL, updateRtlLeadingSpacerWidth], + [scrollToEndOffset, scrollToOffset], ); useEffect(() => { @@ -285,20 +467,8 @@ const UnMemoizedAttachmentUploadPreviewList = () => { return; } - cancelAnimation(endShrinkCompensationX); - endShrinkCompensationX.value = 0; - requestAnimationFrame(() => { - if (isRTL) { - return; - } - - attachmentListRef.current?.scrollToEnd({ animated: true }); - }); - }, [endShrinkCompensationX, isRTL, nonAudioAttachments.length]); - - const animatedListWrapperStyle = useAnimatedStyle(() => ({ - transform: [{ translateX: endShrinkCompensationX.value }], - })); + shouldScrollToEndOnContentSizeChangeRef.current = true; + }, [nonAudioAttachments.length]); if (!previewAttachments.length) { return null; @@ -308,9 +478,9 @@ const UnMemoizedAttachmentUploadPreviewList = () => { <> {audioAttachments.length ? ( {audioAttachments.map((attachment) => ( @@ -318,7 +488,7 @@ const UnMemoizedAttachmentUploadPreviewList = () => { ))} @@ -327,33 +497,46 @@ const UnMemoizedAttachmentUploadPreviewList = () => { {data.length ? ( - - item.localMetadata.id} - ListHeaderComponent={ - isRTL && rtlLeadingSpacerWidth > 0 ? ( - - ) : null - } - onContentSizeChange={onContentSizeChangeHandler} - onLayout={onLayoutHandler} - onScroll={onScrollHandler} - removeClippedSubviews={false} - ref={attachmentListRef} - renderItem={renderItem} - scrollEventThrottle={16} - showsHorizontalScrollIndicator={false} - style={[styles.flatList, flatList]} - testID={'attachment-upload-preview-list'} - /> - + + {data.map((attachment, index) => { + const attachmentId = attachment.localMetadata.id; + + return ( + onAttachmentCellLayout(attachmentId, event)} + style={styles.attachmentPreviewCell} + > + {index > 0 ? : null} + + {renderAttachmentPreview(attachment)} + + + ); + })} + {!isRTL ? ( + + ) : null} + ) : null} @@ -373,17 +556,37 @@ const MemoizedAttachmentUploadPreviewListWithContext = React.memo( export const AttachmentUploadPreviewList = () => ; const styles = StyleSheet.create({ + attachmentPreviewCell: { + alignItems: 'flex-start', + flexDirection: 'row', + flexShrink: 0, + }, + attachmentPreviewContent: { + flexShrink: 0, + }, audioAttachmentsContainer: { maxWidth: MAX_AUDIO_ATTACHMENTS_CONTAINER_WIDTH, width: '100%', }, flatList: { - overflow: 'visible', direction: 'ltr', + flexGrow: 0, + overflow: 'visible', + }, + flatListContentContainer: { + alignItems: 'flex-start', + }, + flatListContainer: { + alignSelf: 'flex-start', + flexShrink: 1, + maxWidth: '100%', }, itemSeparator: { width: primitives.spacingXs, }, + trailingSpacer: { + flexShrink: 0, + }, }); AttachmentUploadPreviewList.displayName = From 073c55754e357e2e66a51efc81206df4e7c10d0b Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 19:44:01 +0200 Subject: [PATCH 2/3] fix: remove spacer calculations --- examples/ExpoMessaging/yarn.lock | 48 +++++++++-- examples/SampleApp/yarn.lock | 45 +++++++--- .../AttachmentUploadPreviewList.tsx | 82 ++++++++----------- 3 files changed, 107 insertions(+), 68 deletions(-) diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index 01f8f87e8b..edfa2b6f3b 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -1578,10 +1578,10 @@ resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.4.tgz#9d5b4b6f23309260a12856cb574c5e64e6c133f7" integrity sha512-6m8+P+dE/RPl4OPzjTxcTbQ0rGeRyeTvAi9KwIffBVCiAMKrfXfLZaqD1F+m8t4B5/Q5aHsMozOgirkH1F5oMQ== -"@gorhom/bottom-sheet@5.1.8": - version "5.1.8" - resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.1.8.tgz#65547917f5b1dae5a1291dabd4ea8bfee09feba4" - integrity sha512-QuYIVjn3K9bW20n5bgOSjvxBYoWG4YQXiLGOheEAMgISuoT6sMcA270ViSkkb0fenPxcIOwzCnFNuxmr739T9A== +"@gorhom/bottom-sheet@5.2.9": + version "5.2.9" + resolved "https://registry.yarnpkg.com/@gorhom/bottom-sheet/-/bottom-sheet-5.2.9.tgz#57d26ab8a4a881bb4be8fd45a4b9539929c9f198" + integrity sha512-YwieCsEnTQnN2QW4VBKfCGszzxaw2ID7FydusEgqo7qB817fZ45N88kptcuNwZFnnauCjdyzKdrVBWmLmpl9oQ== dependencies: "@gorhom/portal" "1.0.14" invariant "^2.2.4" @@ -2583,6 +2583,15 @@ axios@^1.12.2: form-data "^4.0.4" proxy-from-env "^1.1.0" +axios@^1.15.1: + version "1.15.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" + integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== + dependencies: + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^2.1.0" + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -3750,6 +3759,11 @@ flow-enums-runtime@^0.0.6: resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787" integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw== +follow-redirects@^1.15.11: + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== + follow-redirects@^1.15.6: version "1.15.11" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" @@ -3771,6 +3785,17 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +form-data@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -5410,6 +5435,11 @@ proxy-from-env@^1.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + punycode@^2.1.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -6043,14 +6073,14 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.40.0: - version "9.41.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.0.tgz#ad88d7919aaf1d3c35b4a431a8cd464cb640f146" - integrity sha512-Rgp3vULGKYxHZ/aCeundly6ngdBGttTPz+YknmWhbqvNlEhPB/RM61CpQPHgPyfkSm+osJT3tEV9fKd+I/S77g== +stream-chat@^9.42.1: + version "9.42.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.42.1.tgz#8b6aa4e3e73a39ed07bb2a4f2a6829ba9354567a" + integrity sha512-o+9wQO4Ruu1A48T0IrX9ZH8+9F5xPgGLPvflaswaPeLyIZXcy8bsQdcT/HSrPmT7gs0WGD3qcbXaAJU5lMQezQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" - axios "^1.12.2" + axios "^1.15.1" base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 3dc3ab4a6a..b972bfb270 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -3565,14 +3565,14 @@ available-typed-arrays@^1.0.7: dependencies: possible-typed-array-names "^1.0.0" -axios@^1.12.2: - version "1.12.2" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.2.tgz#6c307390136cf7a2278d09cec63b136dfc6e6da7" - integrity sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw== +axios@^1.15.1: + version "1.15.2" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.15.2.tgz#eb8fb6d30349abace6ade5b4cb4d9e8a0dc23e5b" + integrity sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A== dependencies: - follow-redirects "^1.15.6" - form-data "^4.0.4" - proxy-from-env "^1.1.0" + follow-redirects "^1.15.11" + form-data "^4.0.5" + proxy-from-env "^2.1.0" axios@^1.6.0: version "1.7.9" @@ -5088,6 +5088,11 @@ flow-enums-runtime@^0.0.6: resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787" integrity sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw== +follow-redirects@^1.15.11: + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== + follow-redirects@^1.15.6: version "1.15.9" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" @@ -5129,6 +5134,17 @@ form-data@^4.0.4: hasown "^2.0.2" mime-types "^2.1.12" +form-data@^4.0.5: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" + mime-types "^2.1.12" + fresh@0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" @@ -7482,6 +7498,11 @@ proxy-from-env@^1.1.0: resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== +proxy-from-env@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" + integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== + punycode@^2.1.0, punycode@^2.1.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -8270,14 +8291,14 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.41.1: - version "9.41.1" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.41.1.tgz#a877c8aa800d78b497eec2fad636345d4422309c" - integrity sha512-W8zjfINYol2UtdRMz2t/NN2GyjDrvb4pJgKmhtuRYzCY1u0Cjezcsu5OCNgyAM0QsenlY6tRqnvAU8Qam5R49Q== +stream-chat@^9.42.1: + version "9.42.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.42.1.tgz#8b6aa4e3e73a39ed07bb2a4f2a6829ba9354567a" + integrity sha512-o+9wQO4Ruu1A48T0IrX9ZH8+9F5xPgGLPvflaswaPeLyIZXcy8bsQdcT/HSrPmT7gs0WGD3qcbXaAJU5lMQezQ== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" - axios "^1.12.2" + axios "^1.15.1" base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 016f35e3fe..3e3a1e9d80 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { I18nManager, LayoutChangeEvent, @@ -62,11 +62,7 @@ const AttachmentPreviewCell = ({ ); -const ItemSeparatorComponent = ({ - onLayout, -}: { - onLayout?: (event: LayoutChangeEvent) => void; -}) => { +const ItemSeparatorComponent = () => { const { theme: { messageComposer: { @@ -74,7 +70,7 @@ const ItemSeparatorComponent = ({ }, }, } = useTheme(); - return ; + return ; }; const getIsAudioAttachmentPreview = @@ -101,8 +97,12 @@ const UnMemoizedAttachmentUploadPreviewList = () => { const { attachments } = useAttachmentManagerState(); const isRTL = I18nManager.isRTL; const attachmentListRef = useRef(null); - const soundPackageAvailable = isSoundPackageAvailable(); - const isAudioAttachmentPreview = getIsAudioAttachmentPreview(soundPackageAvailable); + const soundPackageAvailable = useMemo(() => isSoundPackageAvailable(), []); + const isAudioAttachmentPreview = useMemo( + () => getIsAudioAttachmentPreview(soundPackageAvailable), + [soundPackageAvailable], + ); + const dataRef = useRef([]); const previousDataRef = useRef([]); const previousNonAudioAttachmentsLengthRef = useRef(0); const contentWidthRef = useRef(0); @@ -110,21 +110,32 @@ const UnMemoizedAttachmentUploadPreviewList = () => { const viewportWidthRef = useRef(0); const scrollOffsetXRef = useRef(0); const attachmentCellWidthsRef = useRef>({}); - const itemSeparatorWidthRef = useRef(primitives.spacingXs); const preparedRemovalIdsRef = useRef>(new Set()); const spacerReleaseFramesRef = useRef>(new Set()); const spacerReleaseTimeoutsRef = useRef>>(new Set()); const shouldScrollToEndOnContentSizeChangeRef = useRef(false); const trailingSpacerWidthRef = useRef(0); const [trailingSpacerWidth, setTrailingSpacerWidth] = useState(0); - const previewAttachments = attachments.filter( - (attachment) => !(audioRecordingSendOnComplete && isLocalVoiceRecordingAttachment(attachment)), + const previewAttachments = useMemo( + () => + attachments.filter( + (attachment) => + !(audioRecordingSendOnComplete && isLocalVoiceRecordingAttachment(attachment)), + ), + [attachments, audioRecordingSendOnComplete], ); - const audioAttachments = previewAttachments.filter(isAudioAttachmentPreview); - const nonAudioAttachments = previewAttachments.filter( - (attachment) => !isAudioAttachmentPreview(attachment), + const audioAttachments = useMemo( + () => previewAttachments.filter(isAudioAttachmentPreview), + [isAudioAttachmentPreview, previewAttachments], + ); + const nonAudioAttachments = useMemo( + () => previewAttachments.filter((attachment) => !isAudioAttachmentPreview(attachment)), + [isAudioAttachmentPreview, previewAttachments], + ); + const data = useMemo( + () => (isRTL ? nonAudioAttachments.toReversed() : nonAudioAttachments), + [isRTL, nonAudioAttachments], ); - const data = isRTL ? nonAudioAttachments.toReversed() : nonAudioAttachments; const { theme: { @@ -192,26 +203,18 @@ const UnMemoizedAttachmentUploadPreviewList = () => { const getRemovalMetrics = useCallback((ids: string[], baseData: LocalAttachment[]) => { const removedIds = new Set(ids); - const remainingItems = baseData.filter( - (attachment) => !removedIds.has(attachment.localMetadata.id), - ); const fallbackCellWidth = baseData.length ? itemsContentWidthRef.current / baseData.length : 0; const offsetBefore = scrollOffsetXRef.current; const oldMaxOffset = Math.max(0, itemsContentWidthRef.current - viewportWidthRef.current); const wasNearEnd = oldMaxOffset - offsetBefore <= END_ANCHOR_THRESHOLD; - let firstCellWidth = 0; let contentOffset = 0; let removedContentWidth = 0; let anchorCorrectionWidth = 0; - baseData.forEach((attachment, index) => { + baseData.forEach((attachment) => { const attachmentId = attachment.localMetadata.id; const cellWidth = attachmentCellWidthsRef.current[attachmentId] ?? fallbackCellWidth; - if (index === 0) { - firstCellWidth = cellWidth; - } - if (removedIds.has(attachmentId)) { removedContentWidth += cellWidth; if (contentOffset <= offsetBefore) { @@ -222,20 +225,7 @@ const UnMemoizedAttachmentUploadPreviewList = () => { contentOffset += cellWidth; }); - const firstAttachmentId = baseData[0]?.localMetadata.id; - const didRemoveFirstItem = !!firstAttachmentId && removedIds.has(firstAttachmentId); - const hasRemainingAfterFirst = baseData - .slice(1) - .some((attachment) => !removedIds.has(attachment.localMetadata.id)); - - if (didRemoveFirstItem && hasRemainingAfterFirst) { - removedContentWidth += itemSeparatorWidthRef.current; - if (firstCellWidth <= offsetBefore) { - anchorCorrectionWidth += itemSeparatorWidthRef.current; - } - } - - if (!removedContentWidth || remainingItems.length === baseData.length) { + if (!removedContentWidth) { return { removedContentWidth: 0, scrollCorrectionWidth: 0, @@ -287,10 +277,10 @@ const UnMemoizedAttachmentUploadPreviewList = () => { const removeAttachments = useCallback( (ids: string[]) => { - prepareForRemoval(ids, data); + prepareForRemoval(ids, dataRef.current); attachmentManager.removeAttachments(ids); }, - [attachmentManager, data, prepareForRemoval], + [attachmentManager, prepareForRemoval], ); useLayoutEffect(() => { @@ -319,6 +309,7 @@ const UnMemoizedAttachmentUploadPreviewList = () => { } previousDataRef.current = data; + dataRef.current = data; }, [data, getRemovalMetrics, isRTL, prepareForRemoval, scheduleTrailingSpacerRelease]); useEffect( @@ -350,7 +341,7 @@ const UnMemoizedAttachmentUploadPreviewList = () => { /> ); } else if (isLocalAudioAttachment(attachment)) { - if (isSoundPackageAvailable()) { + if (soundPackageAvailable) { return ( { VideoAttachmentUploadPreview, attachmentManager.uploadAttachment, removeAttachments, + soundPackageAvailable, ], ); @@ -418,10 +410,6 @@ const UnMemoizedAttachmentUploadPreviewList = () => { attachmentCellWidthsRef.current[id] = event.nativeEvent.layout.width; }, []); - const onItemSeparatorLayout = useCallback((event: LayoutChangeEvent) => { - itemSeparatorWidthRef.current = event.nativeEvent.layout.width; - }, []); - const onContentSizeChangeHandler = useCallback( (width: number) => { const scrollableContentWidth = width; @@ -523,7 +511,7 @@ const UnMemoizedAttachmentUploadPreviewList = () => { onLayout={(event) => onAttachmentCellLayout(attachmentId, event)} style={styles.attachmentPreviewCell} > - {index > 0 ? : null} + {index > 0 ? : null} {renderAttachmentPreview(attachment)} From bc840d3a5a92bd18fbf52031ed795ef9aed18693 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Fri, 24 Apr 2026 19:54:05 +0200 Subject: [PATCH 3/3] fix: tests and final touches --- .../AttachmentUploadPreviewList.test.tsx | 25 ++-- .../AttachmentUploadPreviewList.tsx | 127 +++++++++++------- 2 files changed, 89 insertions(+), 63 deletions(-) diff --git a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx index 253db525c6..4ee9617469 100644 --- a/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx +++ b/package/src/components/MessageInput/__tests__/AttachmentUploadPreviewList.test.tsx @@ -1,6 +1,6 @@ import React, { ComponentProps } from 'react'; -import { ActivityIndicator, FlatList } from 'react-native'; +import { ActivityIndicator, ScrollView } from 'react-native'; import type { ReactTestInstance } from 'react-test-renderer'; @@ -179,14 +179,8 @@ describe('AttachmentUploadPreviewList', () => { describe('FileAttachmentUploadPreview', () => { it('anchors the preview list to the end when content shrinks near the end', () => { - const requestAnimationFrameSpy = jest - .spyOn(global, 'requestAnimationFrame') - .mockImplementation((callback) => { - callback(0); - return 0; - }); - const scrollToOffsetSpy = jest - .spyOn(FlatList.prototype, 'scrollToOffset') + const scrollToSpy = jest + .spyOn(ScrollView.prototype, 'scrollTo') .mockImplementation(() => undefined); try { const attachments = [ @@ -211,22 +205,21 @@ describe('AttachmentUploadPreviewList', () => { renderComponent({ channel, client, props }); - const list = screen.UNSAFE_getByType(FlatList); + const list = screen.UNSAFE_getByType(ScrollView); act(() => { fireEvent(list, 'layout', { nativeEvent: { layout: { width: 100 } } }); list.props.onContentSizeChange(300, 0); list.props.onScroll({ nativeEvent: { contentOffset: { x: 190 } } }); - list.props.onContentSizeChange(295, 0); + list.props.onContentSizeChange(250, 0); }); - expect(scrollToOffsetSpy).toHaveBeenCalledWith({ - animated: true, - offset: 195, + expect(scrollToSpy).toHaveBeenCalledWith({ + animated: false, + x: 150, }); } finally { - requestAnimationFrameSpy.mockRestore(); - scrollToOffsetSpy.mockRestore(); + scrollToSpy.mockRestore(); } }); diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 3e3a1e9d80..ef7c2c0a70 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -73,6 +73,16 @@ const ItemSeparatorComponent = () => { return ; }; +const useLazyRef = (getInitialValue: () => T) => { + const ref = useRef(null); + + if (ref.current === null) { + ref.current = getInitialValue(); + } + + return ref as React.RefObject; +}; + const getIsAudioAttachmentPreview = (soundPackageAvailable: boolean) => ( @@ -102,17 +112,17 @@ const UnMemoizedAttachmentUploadPreviewList = () => { () => getIsAudioAttachmentPreview(soundPackageAvailable), [soundPackageAvailable], ); - const dataRef = useRef([]); - const previousDataRef = useRef([]); + const dataRef = useLazyRef(() => []); + const previousDataRef = useLazyRef(() => []); const previousNonAudioAttachmentsLengthRef = useRef(0); const contentWidthRef = useRef(0); const itemsContentWidthRef = useRef(0); const viewportWidthRef = useRef(0); const scrollOffsetXRef = useRef(0); - const attachmentCellWidthsRef = useRef>({}); - const preparedRemovalIdsRef = useRef>(new Set()); - const spacerReleaseFramesRef = useRef>(new Set()); - const spacerReleaseTimeoutsRef = useRef>>(new Set()); + const attachmentCellWidthsRef = useLazyRef>(() => ({})); + const preparedRemovalIdsRef = useLazyRef>(() => new Set()); + const spacerReleaseFramesRef = useLazyRef>(() => new Set()); + const spacerReleaseTimeoutsRef = useLazyRef>>(() => new Set()); const shouldScrollToEndOnContentSizeChangeRef = useRef(false); const trailingSpacerWidthRef = useRef(0); const [trailingSpacerWidth, setTrailingSpacerWidth] = useState(0); @@ -198,47 +208,52 @@ const UnMemoizedAttachmentUploadPreviewList = () => { spacerReleaseTimeoutsRef.current.add(timeout); }, - [setTrailingSpacerLayoutWidth], + [setTrailingSpacerLayoutWidth, spacerReleaseFramesRef, spacerReleaseTimeoutsRef], ); - const getRemovalMetrics = useCallback((ids: string[], baseData: LocalAttachment[]) => { - const removedIds = new Set(ids); - const fallbackCellWidth = baseData.length ? itemsContentWidthRef.current / baseData.length : 0; - const offsetBefore = scrollOffsetXRef.current; - const oldMaxOffset = Math.max(0, itemsContentWidthRef.current - viewportWidthRef.current); - const wasNearEnd = oldMaxOffset - offsetBefore <= END_ANCHOR_THRESHOLD; - let contentOffset = 0; - let removedContentWidth = 0; - let anchorCorrectionWidth = 0; - - baseData.forEach((attachment) => { - const attachmentId = attachment.localMetadata.id; - const cellWidth = attachmentCellWidthsRef.current[attachmentId] ?? fallbackCellWidth; - - if (removedIds.has(attachmentId)) { - removedContentWidth += cellWidth; - if (contentOffset <= offsetBefore) { - anchorCorrectionWidth += cellWidth; + const getRemovalMetrics = useCallback( + (ids: string[], baseData: LocalAttachment[]) => { + const removedIds = new Set(ids); + const fallbackCellWidth = baseData.length + ? itemsContentWidthRef.current / baseData.length + : 0; + const offsetBefore = scrollOffsetXRef.current; + const oldMaxOffset = Math.max(0, itemsContentWidthRef.current - viewportWidthRef.current); + const wasNearEnd = oldMaxOffset - offsetBefore <= END_ANCHOR_THRESHOLD; + let contentOffset = 0; + let removedContentWidth = 0; + let anchorCorrectionWidth = 0; + + baseData.forEach((attachment) => { + const attachmentId = attachment.localMetadata.id; + const cellWidth = attachmentCellWidthsRef.current[attachmentId] ?? fallbackCellWidth; + + if (removedIds.has(attachmentId)) { + removedContentWidth += cellWidth; + if (contentOffset <= offsetBefore) { + anchorCorrectionWidth += cellWidth; + } } - } - contentOffset += cellWidth; - }); + contentOffset += cellWidth; + }); + + if (!removedContentWidth) { + return { + removedContentWidth: 0, + scrollCorrectionWidth: 0, + }; + } - if (!removedContentWidth) { return { - removedContentWidth: 0, - scrollCorrectionWidth: 0, + removedContentWidth, + scrollCorrectionWidth: wasNearEnd + ? removedContentWidth + : Math.min(anchorCorrectionWidth, removedContentWidth), }; - } - - return { - removedContentWidth, - scrollCorrectionWidth: wasNearEnd - ? removedContentWidth - : Math.min(anchorCorrectionWidth, removedContentWidth), - }; - }, []); + }, + [attachmentCellWidthsRef], + ); const applyRemovalScrollCorrection = useCallback( (removedContentWidth: number, scrollCorrectionWidth: number) => { @@ -272,7 +287,13 @@ const UnMemoizedAttachmentUploadPreviewList = () => { applyRemovalScrollCorrection(removedContentWidth, scrollCorrectionWidth); ids.forEach((id) => preparedRemovalIdsRef.current.add(id)); }, - [applyRemovalScrollCorrection, getRemovalMetrics, isRTL, prepareTrailingSpacer], + [ + applyRemovalScrollCorrection, + getRemovalMetrics, + isRTL, + preparedRemovalIdsRef, + prepareTrailingSpacer, + ], ); const removeAttachments = useCallback( @@ -280,7 +301,7 @@ const UnMemoizedAttachmentUploadPreviewList = () => { prepareForRemoval(ids, dataRef.current); attachmentManager.removeAttachments(ids); }, - [attachmentManager, prepareForRemoval], + [attachmentManager, dataRef, prepareForRemoval], ); useLayoutEffect(() => { @@ -310,7 +331,16 @@ const UnMemoizedAttachmentUploadPreviewList = () => { previousDataRef.current = data; dataRef.current = data; - }, [data, getRemovalMetrics, isRTL, prepareForRemoval, scheduleTrailingSpacerRelease]); + }, [ + data, + dataRef, + getRemovalMetrics, + isRTL, + preparedRemovalIdsRef, + prepareForRemoval, + previousDataRef, + scheduleTrailingSpacerRelease, + ]); useEffect( () => () => { @@ -319,7 +349,7 @@ const UnMemoizedAttachmentUploadPreviewList = () => { spacerReleaseTimeoutsRef.current.forEach(clearTimeout); spacerReleaseTimeoutsRef.current.clear(); }, - [], + [spacerReleaseFramesRef, spacerReleaseTimeoutsRef], ); const renderAttachmentPreview = useCallback( @@ -406,9 +436,12 @@ const UnMemoizedAttachmentUploadPreviewList = () => { viewportWidthRef.current = event.nativeEvent.layout.width; }, []); - const onAttachmentCellLayout = useCallback((id: string, event: LayoutChangeEvent) => { - attachmentCellWidthsRef.current[id] = event.nativeEvent.layout.width; - }, []); + const onAttachmentCellLayout = useCallback( + (id: string, event: LayoutChangeEvent) => { + attachmentCellWidthsRef.current[id] = event.nativeEvent.layout.width; + }, + [attachmentCellWidthsRef], + ); const onContentSizeChangeHandler = useCallback( (width: number) => {