Skip to content

Commit 878dc6a

Browse files
committed
fix: upload progress animations
1 parent eeb0d4f commit 878dc6a

5 files changed

Lines changed: 348 additions & 7 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React, { PropsWithChildren } from 'react';
2+
3+
import { act, renderHook } from '@testing-library/react-native';
4+
import { StateStore } from 'stream-chat';
5+
6+
import { ChatProvider } from '../../contexts/chatContext/ChatContext';
7+
import { usePendingAttachmentUpload } from '../usePendingAttachmentUpload';
8+
9+
type UploadManagerState = {
10+
uploads: Array<{
11+
id: string;
12+
uploadProgress?: number;
13+
}>;
14+
};
15+
16+
const createWrapper = (state: StateStore<UploadManagerState>) => {
17+
const client = {
18+
uploadManager: {
19+
state,
20+
},
21+
};
22+
23+
return ({ children }: PropsWithChildren) => (
24+
<ChatProvider value={{ client } as never}>{children}</ChatProvider>
25+
);
26+
};
27+
28+
describe('usePendingAttachmentUpload', () => {
29+
beforeEach(() => {
30+
jest.useFakeTimers();
31+
});
32+
33+
afterEach(() => {
34+
jest.useRealTimers();
35+
});
36+
37+
it('briefly holds completed upload progress after a ready upload record disappears', () => {
38+
const state = new StateStore<UploadManagerState>({ uploads: [] });
39+
const { result } = renderHook(() => usePendingAttachmentUpload('upload-id'), {
40+
wrapper: createWrapper(state),
41+
});
42+
43+
expect(result.current).toEqual({
44+
isUploading: false,
45+
uploadProgress: undefined,
46+
});
47+
48+
act(() => {
49+
state.partialNext({
50+
uploads: [{ id: 'upload-id', uploadProgress: 90 }],
51+
});
52+
});
53+
54+
expect(result.current).toEqual({
55+
isUploading: true,
56+
uploadProgress: 90,
57+
});
58+
59+
act(() => {
60+
state.partialNext({ uploads: [] });
61+
});
62+
63+
expect(result.current).toEqual({
64+
isUploading: true,
65+
uploadProgress: 100,
66+
});
67+
68+
act(() => {
69+
jest.advanceTimersByTime(350);
70+
});
71+
72+
expect(result.current).toEqual({
73+
isUploading: false,
74+
uploadProgress: undefined,
75+
});
76+
});
77+
78+
it('does not hold completed progress when an upload record disappears before reaching the ready threshold', () => {
79+
const state = new StateStore<UploadManagerState>({ uploads: [] });
80+
const { result } = renderHook(() => usePendingAttachmentUpload('upload-id'), {
81+
wrapper: createWrapper(state),
82+
});
83+
84+
act(() => {
85+
state.partialNext({
86+
uploads: [{ id: 'upload-id', uploadProgress: 50 }],
87+
});
88+
});
89+
90+
act(() => {
91+
state.partialNext({ uploads: [] });
92+
});
93+
94+
expect(result.current).toEqual({
95+
isUploading: false,
96+
uploadProgress: undefined,
97+
});
98+
});
99+
});

package/src/hooks/usePendingAttachmentUpload.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback } from 'react';
1+
import { useCallback, useEffect, useRef, useState } from 'react';
22

33
import type { UploadManagerState } from 'stream-chat';
44

@@ -21,11 +21,28 @@ const idle: PendingAttachmentUpload = {
2121
uploadProgress: undefined,
2222
};
2323

24+
const completed: PendingAttachmentUpload = {
25+
isUploading: true,
26+
uploadProgress: 100,
27+
};
28+
29+
const COMPLETION_HOLD_MS = 350;
30+
const COMPLETION_READY_PROGRESS = 90;
31+
32+
const now = () => Date.now();
33+
2434
/**
2535
* Subscribes to `client.uploadManager` for the pending attachment identified by `localId`.
2636
*/
2737
export function usePendingAttachmentUpload(localId: string | undefined): PendingAttachmentUpload {
2838
const { client } = useChatContext();
39+
const [, setRenderTick] = useState(0);
40+
const completedHoldUntilRef = useRef(0);
41+
const holdTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
42+
const lastUploadProgressRef = useRef<number | undefined>(undefined);
43+
const previousLocalIdRef = useRef<string | undefined>(localId);
44+
const wasUploadingRef = useRef(false);
45+
2946
const selector = useCallback(
3047
(state: UploadManagerState): PendingAttachmentUpload => {
3148
if (!localId) {
@@ -44,6 +61,71 @@ export function usePendingAttachmentUpload(localId: string | undefined): Pending
4461
);
4562

4663
const result = useStateStore(localId ? client.uploadManager.state : undefined, selector);
64+
const isUploading = result?.isUploading ?? false;
65+
const uploadProgress = result?.uploadProgress;
66+
67+
if (previousLocalIdRef.current !== localId) {
68+
previousLocalIdRef.current = localId;
69+
completedHoldUntilRef.current = 0;
70+
wasUploadingRef.current = false;
71+
lastUploadProgressRef.current = undefined;
72+
}
73+
74+
let pendingAttachmentUpload = result ?? idle;
75+
if (localId && isUploading) {
76+
completedHoldUntilRef.current = 0;
77+
wasUploadingRef.current = true;
78+
if (typeof uploadProgress === 'number') {
79+
lastUploadProgressRef.current = uploadProgress;
80+
}
81+
} else if (localId && completedHoldUntilRef.current > now()) {
82+
pendingAttachmentUpload = completed;
83+
} else if (localId) {
84+
const shouldStartCompletionHold =
85+
wasUploadingRef.current &&
86+
typeof lastUploadProgressRef.current === 'number' &&
87+
lastUploadProgressRef.current >= COMPLETION_READY_PROGRESS;
88+
89+
wasUploadingRef.current = false;
90+
lastUploadProgressRef.current = undefined;
91+
92+
if (shouldStartCompletionHold) {
93+
completedHoldUntilRef.current = now() + COMPLETION_HOLD_MS;
94+
pendingAttachmentUpload = completed;
95+
} else {
96+
completedHoldUntilRef.current = 0;
97+
}
98+
} else {
99+
completedHoldUntilRef.current = 0;
100+
wasUploadingRef.current = false;
101+
lastUploadProgressRef.current = undefined;
102+
}
103+
104+
useEffect(() => {
105+
if (holdTimeoutRef.current) {
106+
clearTimeout(holdTimeoutRef.current);
107+
holdTimeoutRef.current = undefined;
108+
}
109+
110+
const holdForMs = completedHoldUntilRef.current - now();
111+
if (holdForMs <= 0) {
112+
return;
113+
}
114+
115+
holdTimeoutRef.current = setTimeout(() => {
116+
holdTimeoutRef.current = undefined;
117+
setRenderTick((tick) => tick + 1);
118+
}, holdForMs);
119+
}, [localId, pendingAttachmentUpload]);
120+
121+
useEffect(
122+
() => () => {
123+
if (holdTimeoutRef.current) {
124+
clearTimeout(holdTimeoutRef.current);
125+
}
126+
},
127+
[],
128+
);
47129

48-
return result ?? idle;
130+
return pendingAttachmentUpload;
49131
}

package/src/native.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ export type NativeMultipartUploadPart =
6262
};
6363

6464
export type NativeMultipartUploadProgressConfig = {
65+
/**
66+
* Maximum progress percentage reported while the native request body is still being sent.
67+
* Completion is represented by the upload request resolving and the upload indicator being removed.
68+
*
69+
* @default 90
70+
*/
71+
completionProgressCap?: number;
6572
count?: number;
6673
intervalMs?: number;
6774
};

package/src/utils/__tests__/installNativeMultipartAdapter.test.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,112 @@ describe('installNativeMultipartAdapter', () => {
181181
);
182182
});
183183

184+
it('caps native multipart body progress to 90 percent while waiting for the response', async () => {
185+
NativeHandlers.multipartUpload = jest.fn().mockImplementation(({ onProgress }) => {
186+
onProgress?.({
187+
loaded: 100,
188+
total: 100,
189+
});
190+
191+
return {
192+
body: JSON.stringify({ file: 'https://example.com/file.jpg' }),
193+
headers: { 'content-type': 'application/json' },
194+
status: 200,
195+
statusText: 'OK',
196+
};
197+
});
198+
199+
const client = getTestClient();
200+
preserveRequestData(client);
201+
installNativeMultipartAdapter(client);
202+
const onUploadProgress = jest.fn();
203+
const formData = {
204+
_parts: [
205+
[
206+
'file',
207+
{
208+
name: 'test.jpg',
209+
type: 'image/jpeg',
210+
uri: 'file:///tmp/test.jpg',
211+
},
212+
],
213+
],
214+
};
215+
216+
await client.axiosInstance.post('/uploads/image', formData, { onUploadProgress });
217+
218+
expect(onUploadProgress).toHaveBeenCalledTimes(1);
219+
expect(onUploadProgress).toHaveBeenCalledWith(
220+
expect.objectContaining({
221+
bytes: 90,
222+
lengthComputable: true,
223+
loaded: 90,
224+
progress: 0.9,
225+
total: 100,
226+
}),
227+
);
228+
});
229+
230+
it('allows overriding the native multipart completion progress cap', async () => {
231+
NativeHandlers.multipartUpload = jest.fn().mockImplementation(({ onProgress }) => {
232+
onProgress?.({
233+
loaded: 100,
234+
total: 100,
235+
});
236+
237+
return {
238+
body: JSON.stringify({ file: 'https://example.com/file.jpg' }),
239+
headers: { 'content-type': 'application/json' },
240+
status: 200,
241+
statusText: 'OK',
242+
};
243+
});
244+
245+
const client = getTestClient();
246+
preserveRequestData(client);
247+
installNativeMultipartAdapter(client);
248+
const onUploadProgress = jest.fn();
249+
const formData = {
250+
_parts: [
251+
[
252+
'file',
253+
{
254+
name: 'test.jpg',
255+
type: 'image/jpeg',
256+
uri: 'file:///tmp/test.jpg',
257+
},
258+
],
259+
],
260+
};
261+
262+
await client.axiosInstance.post('/uploads/image', formData, {
263+
onUploadProgress,
264+
uploadProgressOptions: {
265+
completionProgressCap: 75,
266+
count: 10,
267+
intervalMs: 25,
268+
},
269+
});
270+
271+
expect(onUploadProgress).toHaveBeenCalledTimes(1);
272+
expect(onUploadProgress).toHaveBeenCalledWith(
273+
expect.objectContaining({
274+
bytes: 75,
275+
loaded: 75,
276+
progress: 0.75,
277+
total: 100,
278+
}),
279+
);
280+
expect(NativeHandlers.multipartUpload).toHaveBeenCalledWith(
281+
expect.objectContaining({
282+
progress: {
283+
count: 10,
284+
intervalMs: 25,
285+
},
286+
}),
287+
);
288+
});
289+
184290
it('uses the final config after user request interceptors run', async () => {
185291
const client = getTestClient();
186292
preserveRequestData(client);

0 commit comments

Comments
 (0)