diff --git a/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx b/package/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsx index 9e0273548e..739c0d9b9f 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 } from 'react'; -import { FlatList, StyleSheet, View } from 'react-native'; +import React, { useCallback, useEffect, useRef } from 'react'; +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, @@ -33,6 +48,16 @@ export type AttachmentUploadListPreviewPropsWithContext = Pick< | 'VideoAttachmentUploadPreview' >; +const AttachmentPreviewCell = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + const ItemSeparatorComponent = () => { const { theme: { @@ -44,22 +69,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 @@ -75,6 +84,12 @@ const UnMemoizedAttachmentUploadPreviewList = ( } = props; const { attachmentManager } = useMessageComposer(); 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: { @@ -88,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; }, @@ -184,22 +175,98 @@ const UnMemoizedAttachmentUploadPreviewList = ( ], ); + const onScrollHandler = useCallback((event: NativeSyntheticEvent) => { + 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; + const didAddAttachment = nextLength > previousLength; + previousAttachmentsLengthRef.current = nextLength; + + if (!didAddAttachment) { + return; + } + + cancelAnimation(endShrinkCompensationX); + endShrinkCompensationX.value = 0; + requestAnimationFrame(() => { + attachmentListRef.current?.scrollToEnd({ animated: true }); + }); + }, [attachments.length, endShrinkCompensationX]); + + const animatedListWrapperStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: endShrinkCompensationX.value }], + })); + if (!attachments.length) { return null; } return ( - item.localMetadata.id} - 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'} + /> + ); }; @@ -240,7 +307,6 @@ const styles = StyleSheet.create({ itemSeparator: { width: primitives.spacingXs, }, - wrapper: {}, }); AttachmentUploadPreviewList.displayName = 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`; }; /**