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`;
};
/**