Skip to content

Commit e2106e6

Browse files
committed
track upload progress
1 parent 30ed9b8 commit e2106e6

17 files changed

+406
-139
lines changed

package/src/components/Attachment/Attachment.tsx

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ import {
99
isVideoAttachment,
1010
isVoiceRecordingAttachment,
1111
type Attachment as AttachmentType,
12+
type LocalMessage,
1213
} from 'stream-chat';
1314

1415
import { AudioAttachment as AudioAttachmentDefault } from './Audio';
16+
import type { AudioAttachmentProps } from './Audio/AudioAttachment';
1517

1618
import { UnsupportedAttachment as UnsupportedAttachmentDefault } from './UnsupportedAttachment';
1719
import { URLPreview as URLPreviewDefault } from './UrlPreview';
1820
import { URLPreviewCompact as URLPreviewCompactDefault } from './UrlPreview/URLPreviewCompact';
1921

22+
import { AttachmentFileUploadProgressIndicator } from '../../components/Attachment/AttachmentFileUploadProgressIndicator';
2023
import { FileAttachment as FileAttachmentDefault } from '../../components/Attachment/FileAttachment';
2124
import { Gallery as GalleryDefault } from '../../components/Attachment/Gallery';
2225
import { Giphy as GiphyDefault } from '../../components/Attachment/Giphy';
@@ -30,9 +33,11 @@ import {
3033
MessagesContextValue,
3134
useMessagesContext,
3235
} from '../../contexts/messagesContext/MessagesContext';
36+
import { usePendingAttachmentUpload } from '../../hooks/usePendingAttachmentUpload';
3337
import { isSoundPackageAvailable, isVideoPlayerAvailable } from '../../native';
3438

3539
import { primitives } from '../../theme';
40+
import type { DefaultAttachmentData } from '../../types/types';
3641
import { FileTypes } from '../../types/types';
3742

3843
export type ActionHandler = (name: string, value: string) => void;
@@ -104,12 +109,12 @@ const AttachmentWithContext = (props: AttachmentPropsWithContext) => {
104109
if (isAudioAttachment(attachment) || isVoiceRecordingAttachment(attachment)) {
105110
if (isSoundPackageAvailable()) {
106111
return (
107-
<AudioAttachment
108-
item={{ ...attachment, id: index?.toString() ?? '', type: attachment.type }}
112+
<MessageAudioAttachment
113+
AudioAttachment={AudioAttachment}
114+
attachment={attachment}
115+
audioAttachmentStyles={audioAttachmentStyles}
116+
index={index}
109117
message={message}
110-
showSpeedSettings={true}
111-
showTitle={false}
112-
styles={audioAttachmentStyles}
113118
/>
114119
);
115120
}
@@ -228,6 +233,45 @@ export const Attachment = (props: AttachmentProps) => {
228233
);
229234
};
230235

236+
type MessageAudioAttachmentProps = {
237+
AudioAttachment: React.ComponentType<AudioAttachmentProps>;
238+
attachment: AttachmentType;
239+
audioAttachmentStyles: AudioAttachmentProps['styles'];
240+
index?: number;
241+
message: LocalMessage | undefined;
242+
};
243+
244+
const MessageAudioAttachment = ({
245+
AudioAttachment: AudioAttachmentComponent,
246+
attachment,
247+
audioAttachmentStyles,
248+
index,
249+
message,
250+
}: MessageAudioAttachmentProps) => {
251+
const localId = (attachment as DefaultAttachmentData).localId;
252+
const { isUploading, uploadProgress } = usePendingAttachmentUpload(localId);
253+
254+
const indicator = isUploading ? (
255+
<AttachmentFileUploadProgressIndicator
256+
totalBytes={attachment.file_size}
257+
uploadProgress={uploadProgress}
258+
/>
259+
) : undefined;
260+
261+
const audioItemType = isVoiceRecordingAttachment(attachment) ? 'voiceRecording' : 'audio';
262+
263+
return (
264+
<AudioAttachmentComponent
265+
indicator={indicator}
266+
item={{ ...attachment, id: index?.toString() ?? '', type: audioItemType }}
267+
message={message}
268+
showSpeedSettings={true}
269+
showTitle={false}
270+
styles={audioAttachmentStyles}
271+
/>
272+
);
273+
};
274+
231275
const useAudioAttachmentStyles = () => {
232276
const {
233277
theme: { semantics },
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import React, { useMemo } from 'react';
2+
import { StyleSheet, Text, View } from 'react-native';
3+
4+
import { AttachmentUploadIndicator } from './AttachmentUploadIndicator';
5+
6+
import { useTheme } from '../../contexts/themeContext/ThemeContext';
7+
import { primitives } from '../../theme';
8+
9+
export type AttachmentFileUploadProgressIndicatorProps = {
10+
totalBytes?: number | string | null;
11+
uploadProgress: number | undefined;
12+
};
13+
14+
const parseTotalBytes = (value: number | string | null | undefined): number | null => {
15+
if (value == null) {
16+
return null;
17+
}
18+
if (typeof value === 'number' && Number.isFinite(value)) {
19+
return value;
20+
}
21+
if (typeof value === 'string') {
22+
const n = parseFloat(value);
23+
return Number.isFinite(n) ? n : null;
24+
}
25+
return null;
26+
};
27+
28+
const formatMegabytesOneDecimal = (bytes: number) => {
29+
if (!Number.isFinite(bytes) || bytes <= 0) {
30+
return '0.0 MB';
31+
}
32+
return `${(bytes / (1000 * 1000)).toFixed(1)} MB`;
33+
};
34+
35+
/**
36+
* Circular progress plus `uploaded / total` for file and audio attachments during upload.
37+
*/
38+
export const AttachmentFileUploadProgressIndicator = ({
39+
totalBytes,
40+
uploadProgress,
41+
}: AttachmentFileUploadProgressIndicatorProps) => {
42+
const {
43+
theme: { semantics },
44+
} = useTheme();
45+
46+
const progressLabel = useMemo(() => {
47+
const bytes = parseTotalBytes(totalBytes);
48+
if (bytes == null || bytes <= 0) {
49+
return null;
50+
}
51+
const uploaded = ((uploadProgress ?? 0) / 100) * bytes;
52+
return `${formatMegabytesOneDecimal(uploaded)} / ${formatMegabytesOneDecimal(bytes)}`;
53+
}, [totalBytes, uploadProgress]);
54+
55+
return (
56+
<View style={styles.row}>
57+
<AttachmentUploadIndicator uploadProgress={uploadProgress} />
58+
{progressLabel ? (
59+
<Text numberOfLines={1} style={[styles.label, { color: semantics.textSecondary }]}>
60+
{progressLabel}
61+
</Text>
62+
) : null}
63+
</View>
64+
);
65+
};
66+
67+
const styles = StyleSheet.create({
68+
label: {
69+
flex: 1,
70+
flexShrink: 1,
71+
fontSize: primitives.typographyFontSizeXs,
72+
fontWeight: primitives.typographyFontWeightRegular,
73+
lineHeight: primitives.typographyLineHeightTight,
74+
},
75+
row: {
76+
alignItems: 'center',
77+
flexDirection: 'row',
78+
gap: primitives.spacingXxs,
79+
},
80+
});
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import React from 'react';
2+
import { ActivityIndicator, StyleSheet, View } from 'react-native';
3+
import type { StyleProp, ViewStyle } from 'react-native';
4+
5+
import { CircularProgressIndicator } from './CircularProgressIndicator';
6+
7+
import { useTheme } from '../../contexts/themeContext/ThemeContext';
8+
9+
export type AttachmentUploadIndicatorProps = {
10+
size?: number;
11+
strokeWidth?: number;
12+
style?: StyleProp<ViewStyle>;
13+
testID?: string;
14+
/** When set, shows determinate `CircularProgressIndicator`; otherwise a generic spinner. */
15+
uploadProgress: number | undefined;
16+
};
17+
18+
/**
19+
* Upload state for attachment previews: determinate ring when progress is known, otherwise `ActivityIndicator`.
20+
*/
21+
export const AttachmentUploadIndicator = ({
22+
size = 16,
23+
strokeWidth = 2,
24+
style,
25+
testID,
26+
uploadProgress,
27+
}: AttachmentUploadIndicatorProps) => {
28+
const {
29+
theme: { semantics },
30+
} = useTheme();
31+
32+
if (uploadProgress === undefined) {
33+
return (
34+
<View
35+
pointerEvents='none'
36+
style={[styles.indeterminateWrap, { height: size, width: size }, style]}
37+
testID={testID}
38+
>
39+
<ActivityIndicator color={semantics.accentPrimary} size='small' />
40+
</View>
41+
);
42+
}
43+
44+
return (
45+
<CircularProgressIndicator
46+
color={semantics.accentPrimary}
47+
progress={uploadProgress}
48+
size={size}
49+
strokeWidth={strokeWidth}
50+
style={style}
51+
testID={testID}
52+
/>
53+
);
54+
};
55+
56+
const styles = StyleSheet.create({
57+
indeterminateWrap: {
58+
alignItems: 'center',
59+
justifyContent: 'center',
60+
},
61+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import React, { useEffect, useMemo, useRef } from 'react';
2+
import type { ColorValue } from 'react-native';
3+
import { Animated, Easing, StyleProp, ViewStyle } from 'react-native';
4+
import Svg, { Circle } from 'react-native-svg';
5+
6+
export type CircularProgressIndicatorProps = {
7+
/** Upload percent **0–100**. */
8+
progress: number;
9+
color: ColorValue;
10+
size?: number;
11+
strokeWidth?: number;
12+
style?: StyleProp<ViewStyle>;
13+
testID?: string;
14+
};
15+
16+
/**
17+
* Circular upload progress ring (determinate) or rotating arc (indeterminate).
18+
*/
19+
export const CircularProgressIndicator = ({
20+
color,
21+
progress,
22+
size = 16,
23+
strokeWidth = 2,
24+
style,
25+
testID,
26+
}: CircularProgressIndicatorProps) => {
27+
const spin = useRef(new Animated.Value(0)).current;
28+
29+
useEffect(() => {
30+
const loop = Animated.loop(
31+
Animated.timing(spin, {
32+
toValue: 1,
33+
duration: 900,
34+
easing: Easing.linear,
35+
useNativeDriver: true,
36+
}),
37+
);
38+
loop.start();
39+
return () => {
40+
loop.stop();
41+
spin.setValue(0);
42+
};
43+
}, [progress, spin]);
44+
45+
const rotate = useMemo(
46+
() =>
47+
spin.interpolate({
48+
inputRange: [0, 1],
49+
outputRange: ['0deg', '360deg'],
50+
}),
51+
[spin],
52+
);
53+
54+
const { cx, cy, r, circumference } = useMemo(() => {
55+
const pad = strokeWidth / 2;
56+
const rInner = size / 2 - pad;
57+
return {
58+
cx: size / 2,
59+
cy: size / 2,
60+
r: rInner,
61+
circumference: 2 * Math.PI * rInner,
62+
};
63+
}, [size, strokeWidth]);
64+
65+
const fraction =
66+
progress === undefined || Number.isNaN(progress)
67+
? undefined
68+
: Math.min(100, Math.max(0, progress)) / 100;
69+
70+
if (fraction !== undefined) {
71+
const offset = circumference * (1 - fraction);
72+
return (
73+
<Svg height={size} style={style} testID={testID} viewBox={`0 0 ${size} ${size}`} width={size}>
74+
<Circle
75+
cx={cx}
76+
cy={cy}
77+
fill='none'
78+
r={r}
79+
stroke={color as string}
80+
strokeDasharray={`${circumference}`}
81+
strokeDashoffset={offset}
82+
strokeLinecap='round'
83+
strokeWidth={strokeWidth}
84+
transform={`rotate(-90 ${cx} ${cy})`}
85+
/>
86+
</Svg>
87+
);
88+
}
89+
90+
const arc = circumference * 0.22;
91+
const gap = circumference - arc;
92+
93+
return (
94+
<Animated.View
95+
style={[{ height: size, width: size }, style, { transform: [{ rotate }] }]}
96+
testID={testID}
97+
>
98+
<Svg height={size} viewBox={`0 0 ${size} ${size}`} width={size}>
99+
<Circle
100+
cx={cx}
101+
cy={cy}
102+
fill='none'
103+
r={r}
104+
stroke={color as string}
105+
strokeDasharray={`${arc} ${gap}`}
106+
strokeLinecap='round'
107+
strokeWidth={strokeWidth}
108+
transform={`rotate(-90 ${cx} ${cy})`}
109+
/>
110+
</Svg>
111+
</Animated.View>
112+
);
113+
};

0 commit comments

Comments
 (0)