From f83338fbf406a47f8cd4e557c2285deff8cad5e2 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 19 Feb 2026 15:27:18 +0100 Subject: [PATCH 1/4] fix: scroll to last item when adding more --- .../AttachmentUploadPreviewList.tsx | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 9e0273548e..e06795099f 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 } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { FlatList, StyleSheet, View } from 'react-native'; import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; @@ -75,6 +75,8 @@ const UnMemoizedAttachmentUploadPreviewList = ( } = props; const { attachmentManager } = useMessageComposer(); const { attachments } = useAttachmentManagerState(); + const attachmentListRef = useRef>(null); + const previousAttachmentsLengthRef = useRef(attachments.length); const { theme: { @@ -184,6 +186,21 @@ const UnMemoizedAttachmentUploadPreviewList = ( ], ); + useEffect(() => { + const previousLength = previousAttachmentsLengthRef.current; + const nextLength = attachments.length; + const didAddAttachment = nextLength > previousLength; + previousAttachmentsLengthRef.current = nextLength; + + if (!didAddAttachment) { + return; + } + + requestAnimationFrame(() => { + attachmentListRef.current?.scrollToEnd({ animated: true }); + }); + }, [attachments.length]); + if (!attachments.length) { return null; } @@ -195,6 +212,7 @@ const UnMemoizedAttachmentUploadPreviewList = ( horizontal ItemSeparatorComponent={ItemSeparatorComponent} keyExtractor={(item) => item.localMetadata.id} + ref={attachmentListRef} renderItem={renderItem} showsHorizontalScrollIndicator={false} style={[styles.flatList, flatList]} From eab5e73209845e1337ea1004dfaacc09c2c95944 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 19 Feb 2026 16:19:53 +0100 Subject: [PATCH 2/4] fix: preview list removal animations --- .../AttachmentUploadPreviewList.tsx | 154 ++++++++++++------ 1 file changed, 108 insertions(+), 46 deletions(-) diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index e06795099f..02984cc62a 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -1,7 +1,22 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { FlatList, StyleSheet, View } from 'react-native'; +import { + FlatList, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, + StyleSheet, + View, +} from 'react-native'; -import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; +import Animated, { + cancelAnimation, + ZoomIn, + ZoomOut, + LinearTransition, + useAnimatedStyle, + useSharedValue, + withSpring, +} from 'react-native-reanimated'; import { isLocalAudioAttachment, @@ -22,8 +37,8 @@ import { useTheme } from '../../../../contexts/themeContext/ThemeContext'; import { isSoundPackageAvailable } from '../../../../native'; import { primitives } from '../../../../theme'; -const IMAGE_PREVIEW_SIZE = 72; -const FILE_PREVIEW_HEIGHT = 224; +const END_ANCHOR_THRESHOLD = 16; +const END_SHRINK_COMPENSATION_DURATION = 200; export type AttachmentUploadListPreviewPropsWithContext = Pick< MessageInputContextValue, @@ -44,22 +59,6 @@ const ItemSeparatorComponent = () => { return ; }; -const getItemLayout = (data: ArrayLike | null | undefined, index: number) => { - const item = data?.[index]; - if (item && isLocalImageAttachment(item as LocalAttachment)) { - return { - index, - length: IMAGE_PREVIEW_SIZE + 8, - offset: (IMAGE_PREVIEW_SIZE + 8) * index, - }; - } - return { - index, - length: FILE_PREVIEW_HEIGHT + 8, - offset: (FILE_PREVIEW_HEIGHT + 8) * index, - }; -}; - /** * AttachmentUploadPreviewList * UI Component to preview the files set for upload @@ -77,6 +76,10 @@ const UnMemoizedAttachmentUploadPreviewList = ( const { attachments } = useAttachmentManagerState(); const attachmentListRef = useRef>(null); const previousAttachmentsLengthRef = useRef(attachments.length); + const contentWidthRef = useRef(0); + const viewportWidthRef = useRef(0); + const scrollOffsetXRef = useRef(0); + const endShrinkCompensationX = useSharedValue(0); const { theme: { @@ -91,8 +94,8 @@ const UnMemoizedAttachmentUploadPreviewList = ( if (isLocalImageAttachment(item)) { return ( ) => { + scrollOffsetXRef.current = event.nativeEvent.contentOffset.x; + }, []); + + const onLayoutHandler = useCallback((event: LayoutChangeEvent) => { + viewportWidthRef.current = event.nativeEvent.layout.width; + }, []); + + const onContentSizeChangeHandler = useCallback( + (width: number) => { + const previousContentWidth = contentWidthRef.current; + contentWidthRef.current = width; + + if (!previousContentWidth || width >= previousContentWidth) { + return; + } + + const oldMaxOffset = Math.max(0, previousContentWidth - viewportWidthRef.current); + const newMaxOffset = Math.max(0, width - 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) { + return; + } + + if (overshoot > 0) { + attachmentListRef.current?.scrollToOffset({ + animated: false, + offset: newMaxOffset, + }); + scrollOffsetXRef.current = newMaxOffset; + } + + const compensation = newMaxOffset - oldMaxOffset; + if (compensation !== 0) { + cancelAnimation(endShrinkCompensationX); + endShrinkCompensationX.value = compensation; + endShrinkCompensationX.value = withSpring(0, { + duration: END_SHRINK_COMPENSATION_DURATION, + }); + } + }, + [endShrinkCompensationX], + ); + useEffect(() => { const previousLength = previousAttachmentsLengthRef.current; const nextLength = attachments.length; @@ -196,28 +247,40 @@ const UnMemoizedAttachmentUploadPreviewList = ( return; } + cancelAnimation(endShrinkCompensationX); + endShrinkCompensationX.value = 0; requestAnimationFrame(() => { attachmentListRef.current?.scrollToEnd({ animated: true }); }); - }, [attachments.length]); + }, [attachments.length, endShrinkCompensationX]); + + const animatedListWrapperStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: endShrinkCompensationX.value }], + })); if (!attachments.length) { return null; } return ( - item.localMetadata.id} - ref={attachmentListRef} - renderItem={renderItem} - showsHorizontalScrollIndicator={false} - style={[styles.flatList, flatList]} - testID={'attachment-upload-preview-list'} - /> + + item.localMetadata.id} + 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'} + /> + ); }; @@ -258,7 +321,6 @@ const styles = StyleSheet.create({ itemSeparator: { width: primitives.spacingXs, }, - wrapper: {}, }); AttachmentUploadPreviewList.displayName = From 4d421a57a7e5d82629280c40bb263c88a337a6c6 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 19 Feb 2026 16:22:02 +0100 Subject: [PATCH 3/4] fix: cleanup --- .../AttachmentUploadPreviewList.tsx | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 02984cc62a..739c0d9b9f 100644 --- a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx +++ b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx @@ -48,6 +48,16 @@ export type AttachmentUploadListPreviewPropsWithContext = Pick< | 'VideoAttachmentUploadPreview' >; +const AttachmentPreviewCell = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + const ItemSeparatorComponent = () => { const { theme: { @@ -93,89 +103,65 @@ const UnMemoizedAttachmentUploadPreviewList = ( ({ item }: { item: LocalAttachment }) => { if (isLocalImageAttachment(item)) { return ( - + - + ); } else if (isLocalVoiceRecordingAttachment(item)) { return ( - + - + ); } else if (isLocalAudioAttachment(item)) { if (isSoundPackageAvailable()) { return ( - + - + ); } else { return ( - + - + ); } } else if (isVideoAttachment(item)) { return ( - + - + ); } else if (isLocalFileAttachment(item)) { return ( - + - + ); } else return null; }, From 2def416f30fa78965d7c1932efff4b80d82c6284 Mon Sep 17 00:00:00 2001 From: Ivan Sekovanikj Date: Thu, 19 Feb 2026 16:28:54 +0100 Subject: [PATCH 4/4] fix: preview time formatting --- package/src/utils/__tests__/utils.test.js | 22 +++++++++++++++++++++- package/src/utils/utils.ts | 15 ++++++++++----- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/package/src/utils/__tests__/utils.test.js b/package/src/utils/__tests__/utils.test.js index 6a75bbab3b..fd41bbcd78 100644 --- a/package/src/utils/__tests__/utils.test.js +++ b/package/src/utils/__tests__/utils.test.js @@ -1,4 +1,4 @@ -import { getUrlWithoutParams } from '../utils'; +import { formatMsToMinSec, getUrlWithoutParams } from '../utils'; describe('getUrlWithoutParams', () => { const testUrlMap = { @@ -17,3 +17,23 @@ describe('getUrlWithoutParams', () => { }); }); }); + +describe('formatMsToMinSec', () => { + it('should format values below 1 minute as seconds', () => { + expect(formatMsToMinSec(0)).toBe('0s'); + expect(formatMsToMinSec(999)).toBe('0s'); + expect(formatMsToMinSec(59_999)).toBe('59s'); + }); + + it('should format values from 1 minute to below 1 hour as minutes', () => { + expect(formatMsToMinSec(60_000)).toBe('1m'); + expect(formatMsToMinSec(61_000)).toBe('1m'); + expect(formatMsToMinSec(3_599_999)).toBe('59m'); + }); + + it('should format values from 1 hour and above as hours', () => { + expect(formatMsToMinSec(3_600_000)).toBe('1h'); + expect(formatMsToMinSec(3_661_000)).toBe('1h'); + expect(formatMsToMinSec(7_200_000)).toBe('2h'); + }); +}); diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 56d06b93ab..f40c9bde89 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -241,13 +241,18 @@ export const getDurationLabelFromDuration = (duration: number) => { export const formatMsToMinSec = (ms: number) => { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; + const totalMinutes = Math.floor(totalSeconds / 60); + const totalHours = Math.floor(totalMinutes / 60); - const mm = minutes; // no padding for minutes - const ss = minutes ? String(seconds).padStart(2, '0') : String(seconds); + if (totalHours >= 1) { + return `${totalHours}h`; + } + + if (totalMinutes >= 1) { + return `${totalMinutes}m`; + } - return `${mm}m ${ss}s`.replace(/^0m\s/, ''); + return `${totalSeconds}s`; }; /**