Skip to content

Commit 3e43a2f

Browse files
authored
fix: attachment preview list fixes (#3416)
## 🎯 Goal This PR fixes: - Autoscrolling the newest attachment as we're adding - Improves animations - Adds shrinking animation of the `FlatList` when we're removing items ## πŸ›  Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## πŸ§ͺ Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## β˜‘οΈ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 536a64c commit 3e43a2f

File tree

3 files changed

+166
-75
lines changed

3 files changed

+166
-75
lines changed

β€Žpackage/src/components/MessageInput/components/AttachmentPreview/AttachmentUploadPreviewList.tsxβ€Ž

Lines changed: 135 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1-
import React, { useCallback } from 'react';
2-
import { FlatList, StyleSheet, View } from 'react-native';
1+
import React, { useCallback, useEffect, useRef } from 'react';
2+
import {
3+
FlatList,
4+
LayoutChangeEvent,
5+
NativeScrollEvent,
6+
NativeSyntheticEvent,
7+
StyleSheet,
8+
View,
9+
} from 'react-native';
310

4-
import Animated, { FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated';
11+
import Animated, {
12+
cancelAnimation,
13+
ZoomIn,
14+
ZoomOut,
15+
LinearTransition,
16+
useAnimatedStyle,
17+
useSharedValue,
18+
withSpring,
19+
} from 'react-native-reanimated';
520

621
import {
722
isLocalAudioAttachment,
@@ -22,8 +37,8 @@ import { useTheme } from '../../../../contexts/themeContext/ThemeContext';
2237
import { isSoundPackageAvailable } from '../../../../native';
2338
import { primitives } from '../../../../theme';
2439

25-
const IMAGE_PREVIEW_SIZE = 72;
26-
const FILE_PREVIEW_HEIGHT = 224;
40+
const END_ANCHOR_THRESHOLD = 16;
41+
const END_SHRINK_COMPENSATION_DURATION = 200;
2742

2843
export type AttachmentUploadListPreviewPropsWithContext = Pick<
2944
MessageInputContextValue,
@@ -33,6 +48,16 @@ export type AttachmentUploadListPreviewPropsWithContext = Pick<
3348
| 'VideoAttachmentUploadPreview'
3449
>;
3550

51+
const AttachmentPreviewCell = ({ children }: { children: React.ReactNode }) => (
52+
<Animated.View
53+
entering={ZoomIn.duration(200)}
54+
exiting={ZoomOut.duration(200)}
55+
layout={LinearTransition.duration(200)}
56+
>
57+
{children}
58+
</Animated.View>
59+
);
60+
3661
const ItemSeparatorComponent = () => {
3762
const {
3863
theme: {
@@ -44,22 +69,6 @@ const ItemSeparatorComponent = () => {
4469
return <View style={[styles.itemSeparator, itemSeparator]} />;
4570
};
4671

47-
const getItemLayout = (data: ArrayLike<LocalAttachment> | null | undefined, index: number) => {
48-
const item = data?.[index];
49-
if (item && isLocalImageAttachment(item as LocalAttachment)) {
50-
return {
51-
index,
52-
length: IMAGE_PREVIEW_SIZE + 8,
53-
offset: (IMAGE_PREVIEW_SIZE + 8) * index,
54-
};
55-
}
56-
return {
57-
index,
58-
length: FILE_PREVIEW_HEIGHT + 8,
59-
offset: (FILE_PREVIEW_HEIGHT + 8) * index,
60-
};
61-
};
62-
6372
/**
6473
* AttachmentUploadPreviewList
6574
* UI Component to preview the files set for upload
@@ -75,6 +84,12 @@ const UnMemoizedAttachmentUploadPreviewList = (
7584
} = props;
7685
const { attachmentManager } = useMessageComposer();
7786
const { attachments } = useAttachmentManagerState();
87+
const attachmentListRef = useRef<FlatList<LocalAttachment>>(null);
88+
const previousAttachmentsLengthRef = useRef(attachments.length);
89+
const contentWidthRef = useRef(0);
90+
const viewportWidthRef = useRef(0);
91+
const scrollOffsetXRef = useRef(0);
92+
const endShrinkCompensationX = useSharedValue(0);
7893

7994
const {
8095
theme: {
@@ -88,89 +103,65 @@ const UnMemoizedAttachmentUploadPreviewList = (
88103
({ item }: { item: LocalAttachment }) => {
89104
if (isLocalImageAttachment(item)) {
90105
return (
91-
<Animated.View
92-
entering={FadeIn.duration(200)}
93-
exiting={FadeOut.duration(200)}
94-
layout={LinearTransition.duration(200)}
95-
>
106+
<AttachmentPreviewCell>
96107
<ImageAttachmentUploadPreview
97108
attachment={item}
98109
handleRetry={attachmentManager.uploadAttachment}
99110
removeAttachments={attachmentManager.removeAttachments}
100111
/>
101-
</Animated.View>
112+
</AttachmentPreviewCell>
102113
);
103114
} else if (isLocalVoiceRecordingAttachment(item)) {
104115
return (
105-
<Animated.View
106-
entering={FadeIn.duration(200)}
107-
exiting={FadeOut.duration(200)}
108-
layout={LinearTransition.duration(200)}
109-
>
116+
<AttachmentPreviewCell>
110117
<AudioAttachmentUploadPreview
111118
attachment={item}
112119
handleRetry={attachmentManager.uploadAttachment}
113120
removeAttachments={attachmentManager.removeAttachments}
114121
/>
115-
</Animated.View>
122+
</AttachmentPreviewCell>
116123
);
117124
} else if (isLocalAudioAttachment(item)) {
118125
if (isSoundPackageAvailable()) {
119126
return (
120-
<Animated.View
121-
entering={FadeIn.duration(200)}
122-
exiting={FadeOut.duration(200)}
123-
layout={LinearTransition.duration(200)}
124-
>
127+
<AttachmentPreviewCell>
125128
<AudioAttachmentUploadPreview
126129
attachment={item}
127130
handleRetry={attachmentManager.uploadAttachment}
128131
removeAttachments={attachmentManager.removeAttachments}
129132
/>
130-
</Animated.View>
133+
</AttachmentPreviewCell>
131134
);
132135
} else {
133136
return (
134-
<Animated.View
135-
entering={FadeIn.duration(200)}
136-
exiting={FadeOut.duration(200)}
137-
layout={LinearTransition.duration(200)}
138-
>
137+
<AttachmentPreviewCell>
139138
<FileAttachmentUploadPreview
140139
attachment={item}
141140
handleRetry={attachmentManager.uploadAttachment}
142141
removeAttachments={attachmentManager.removeAttachments}
143142
/>
144-
</Animated.View>
143+
</AttachmentPreviewCell>
145144
);
146145
}
147146
} else if (isVideoAttachment(item)) {
148147
return (
149-
<Animated.View
150-
entering={FadeIn.duration(200)}
151-
exiting={FadeOut.duration(200)}
152-
layout={LinearTransition.duration(200)}
153-
>
148+
<AttachmentPreviewCell>
154149
<VideoAttachmentUploadPreview
155150
attachment={item}
156151
handleRetry={attachmentManager.uploadAttachment}
157152
removeAttachments={attachmentManager.removeAttachments}
158153
/>
159-
</Animated.View>
154+
</AttachmentPreviewCell>
160155
);
161156
} else if (isLocalFileAttachment(item)) {
162157
return (
163-
<Animated.View
164-
entering={FadeIn.duration(200)}
165-
exiting={FadeOut.duration(200)}
166-
layout={LinearTransition.duration(200)}
167-
>
158+
<AttachmentPreviewCell>
168159
<FileAttachmentUploadPreview
169160
attachment={item}
170161
handleRetry={attachmentManager.uploadAttachment}
171162
removeAttachments={attachmentManager.removeAttachments}
172163
/>
173-
</Animated.View>
164+
</AttachmentPreviewCell>
174165
);
175166
} else return null;
176167
},
@@ -184,22 +175,98 @@ const UnMemoizedAttachmentUploadPreviewList = (
184175
],
185176
);
186177

178+
const onScrollHandler = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
179+
scrollOffsetXRef.current = event.nativeEvent.contentOffset.x;
180+
}, []);
181+
182+
const onLayoutHandler = useCallback((event: LayoutChangeEvent) => {
183+
viewportWidthRef.current = event.nativeEvent.layout.width;
184+
}, []);
185+
186+
const onContentSizeChangeHandler = useCallback(
187+
(width: number) => {
188+
const previousContentWidth = contentWidthRef.current;
189+
contentWidthRef.current = width;
190+
191+
if (!previousContentWidth || width >= previousContentWidth) {
192+
return;
193+
}
194+
195+
const oldMaxOffset = Math.max(0, previousContentWidth - viewportWidthRef.current);
196+
const newMaxOffset = Math.max(0, width - viewportWidthRef.current);
197+
const offsetBefore = scrollOffsetXRef.current;
198+
const wasNearEnd = oldMaxOffset - offsetBefore <= END_ANCHOR_THRESHOLD;
199+
const overshoot = Math.max(0, offsetBefore - newMaxOffset);
200+
const shouldAnchorEnd = wasNearEnd || overshoot > 0;
201+
202+
if (!shouldAnchorEnd) {
203+
return;
204+
}
205+
206+
if (overshoot > 0) {
207+
attachmentListRef.current?.scrollToOffset({
208+
animated: false,
209+
offset: newMaxOffset,
210+
});
211+
scrollOffsetXRef.current = newMaxOffset;
212+
}
213+
214+
const compensation = newMaxOffset - oldMaxOffset;
215+
if (compensation !== 0) {
216+
cancelAnimation(endShrinkCompensationX);
217+
endShrinkCompensationX.value = compensation;
218+
endShrinkCompensationX.value = withSpring(0, {
219+
duration: END_SHRINK_COMPENSATION_DURATION,
220+
});
221+
}
222+
},
223+
[endShrinkCompensationX],
224+
);
225+
226+
useEffect(() => {
227+
const previousLength = previousAttachmentsLengthRef.current;
228+
const nextLength = attachments.length;
229+
const didAddAttachment = nextLength > previousLength;
230+
previousAttachmentsLengthRef.current = nextLength;
231+
232+
if (!didAddAttachment) {
233+
return;
234+
}
235+
236+
cancelAnimation(endShrinkCompensationX);
237+
endShrinkCompensationX.value = 0;
238+
requestAnimationFrame(() => {
239+
attachmentListRef.current?.scrollToEnd({ animated: true });
240+
});
241+
}, [attachments.length, endShrinkCompensationX]);
242+
243+
const animatedListWrapperStyle = useAnimatedStyle(() => ({
244+
transform: [{ translateX: endShrinkCompensationX.value }],
245+
}));
246+
187247
if (!attachments.length) {
188248
return null;
189249
}
190250

191251
return (
192-
<FlatList
193-
data={attachments}
194-
getItemLayout={getItemLayout}
195-
horizontal
196-
ItemSeparatorComponent={ItemSeparatorComponent}
197-
keyExtractor={(item) => item.localMetadata.id}
198-
renderItem={renderItem}
199-
showsHorizontalScrollIndicator={false}
200-
style={[styles.flatList, flatList]}
201-
testID={'attachment-upload-preview-list'}
202-
/>
252+
<Animated.View style={animatedListWrapperStyle}>
253+
<FlatList
254+
data={attachments}
255+
horizontal
256+
ItemSeparatorComponent={ItemSeparatorComponent}
257+
keyExtractor={(item) => item.localMetadata.id}
258+
onContentSizeChange={onContentSizeChangeHandler}
259+
onLayout={onLayoutHandler}
260+
onScroll={onScrollHandler}
261+
removeClippedSubviews={false}
262+
ref={attachmentListRef}
263+
renderItem={renderItem}
264+
scrollEventThrottle={16}
265+
showsHorizontalScrollIndicator={false}
266+
style={[styles.flatList, flatList]}
267+
testID={'attachment-upload-preview-list'}
268+
/>
269+
</Animated.View>
203270
);
204271
};
205272

@@ -240,7 +307,6 @@ const styles = StyleSheet.create({
240307
itemSeparator: {
241308
width: primitives.spacingXs,
242309
},
243-
wrapper: {},
244310
});
245311

246312
AttachmentUploadPreviewList.displayName =

β€Žpackage/src/utils/__tests__/utils.test.jsβ€Ž

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getUrlWithoutParams } from '../utils';
1+
import { formatMsToMinSec, getUrlWithoutParams } from '../utils';
22

33
describe('getUrlWithoutParams', () => {
44
const testUrlMap = {
@@ -17,3 +17,23 @@ describe('getUrlWithoutParams', () => {
1717
});
1818
});
1919
});
20+
21+
describe('formatMsToMinSec', () => {
22+
it('should format values below 1 minute as seconds', () => {
23+
expect(formatMsToMinSec(0)).toBe('0s');
24+
expect(formatMsToMinSec(999)).toBe('0s');
25+
expect(formatMsToMinSec(59_999)).toBe('59s');
26+
});
27+
28+
it('should format values from 1 minute to below 1 hour as minutes', () => {
29+
expect(formatMsToMinSec(60_000)).toBe('1m');
30+
expect(formatMsToMinSec(61_000)).toBe('1m');
31+
expect(formatMsToMinSec(3_599_999)).toBe('59m');
32+
});
33+
34+
it('should format values from 1 hour and above as hours', () => {
35+
expect(formatMsToMinSec(3_600_000)).toBe('1h');
36+
expect(formatMsToMinSec(3_661_000)).toBe('1h');
37+
expect(formatMsToMinSec(7_200_000)).toBe('2h');
38+
});
39+
});

β€Žpackage/src/utils/utils.tsβ€Ž

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -241,13 +241,18 @@ export const getDurationLabelFromDuration = (duration: number) => {
241241

242242
export const formatMsToMinSec = (ms: number) => {
243243
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
244-
const minutes = Math.floor(totalSeconds / 60);
245-
const seconds = totalSeconds % 60;
244+
const totalMinutes = Math.floor(totalSeconds / 60);
245+
const totalHours = Math.floor(totalMinutes / 60);
246246

247-
const mm = minutes; // no padding for minutes
248-
const ss = minutes ? String(seconds).padStart(2, '0') : String(seconds);
247+
if (totalHours >= 1) {
248+
return `${totalHours}h`;
249+
}
250+
251+
if (totalMinutes >= 1) {
252+
return `${totalMinutes}m`;
253+
}
249254

250-
return `${mm}m ${ss}s`.replace(/^0m\s/, '');
255+
return `${totalSeconds}s`;
251256
};
252257

253258
/**

0 commit comments

Comments
Β (0)