Skip to content

Commit dc0c815

Browse files
authored
feat: allow tracking file/image upload progress (#1708)
## CLA - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required). - [ ] Code changes are tested ## Description of the changes, What, Why and How? https://linear.app/stream/issue/REACT-925/upload-progress-tracking ## Changelog -
1 parent ff4bd77 commit dc0c815

8 files changed

Lines changed: 603 additions & 23 deletions

File tree

docs/fileUpload.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,3 +71,57 @@ console.log('file url: ', response.file);
7171
</body>
7272
</html>
7373
```
74+
75+
## `axiosRequestConfig` (channel and client uploads)
76+
77+
Channel uploads use Axios under the hood. Both **`channel.sendFile`** and **`channel.sendImage`** accept an optional **fifth argument** `axiosRequestConfig` (`AxiosRequestConfig` from axios). The same optional argument exists on **`client.uploadFile`** and **`client.uploadImage`**.
78+
79+
The client merges your config **after** its upload defaults (`timeout: 0`, large `maxContentLength` / `maxBodyLength`, and multipart headers from the form data). Any property you set can override or extend those defaults.
80+
81+
Typical uses:
82+
83+
- **`onUploadProgress`** — track bytes sent (see below)
84+
- **`signal`** — pass `AbortSignal` from an `AbortController` to cancel an in-flight upload
85+
- Other Axios per-request options your runtime supports
86+
87+
### Upload progress (`onUploadProgress`)
88+
89+
```js
90+
// client.uploadFile with progress
91+
const response = await client.uploadFile(file, file.name, file.type, undefined, {
92+
onUploadProgress: (progressEvent) => {
93+
const percent = progressEvent.total
94+
? Math.round((progressEvent.loaded * 100) / progressEvent.total)
95+
: 0;
96+
console.log(`Upload: ${percent}%`);
97+
},
98+
});
99+
100+
// channel.sendFile with progress
101+
const response = await channel.sendFile(file, file.name, file.type, undefined, {
102+
onUploadProgress: (progressEvent) => {
103+
const percent = progressEvent.total
104+
? Math.round((progressEvent.loaded * 100) / progressEvent.total)
105+
: 0;
106+
console.log(`Upload: ${percent}%`);
107+
},
108+
});
109+
110+
// channel.sendImage with progress (same fifth argument)
111+
const imageResponse = await channel.sendImage(file, file.name, file.type, undefined, {
112+
onUploadProgress: (progressEvent) => {
113+
const percent = progressEvent.total
114+
? Math.round((progressEvent.loaded * 100) / progressEvent.total)
115+
: 0;
116+
console.log(`Image upload: ${percent}%`);
117+
},
118+
});
119+
```
120+
121+
## Message composer / attachment manager
122+
123+
When using the message composer’s attachment manager, upload progress is tracked when `config.attachments.trackUploadProgress` is `true` (the default). Progress is stored on each attachment’s `localMetadata.uploadProgress` (0–100 for the default upload path, from the axios progress event; the initial state is 0% when the upload starts).
124+
125+
With a custom `doUploadRequest`, the function receives an optional second argument `options` with `onProgress?: (percent: number | undefined) => void`. Call `onProgress` from your upload implementation to drive the same `localMetadata.uploadProgress` updates. If you do not call it, `uploadProgress` stays at 0 until the upload finishes.
126+
127+
Set `trackUploadProgress` to `false` to skip setting `uploadProgress` (will be `undefined` in this case) and to omit progress callbacks to both the default channel upload and custom `doUploadRequest`.

src/channel.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { AxiosRequestConfig } from 'axios';
12
import { ChannelState } from './channel_state';
23
import { CooldownTimer } from './CooldownTimer';
34
import { MessageComposer } from './messageComposer';
@@ -245,33 +246,57 @@ export class Channel {
245246
return await this._sendMessage(message, options);
246247
}
247248

249+
/**
250+
* Upload a file to this channel’s file endpoint (multipart). Forwards to the client’s `sendFile` implementation.
251+
*
252+
* @param uri File source: URL string, `File`, `Buffer`, or readable stream (Node).
253+
* @param name File name sent in the multipart body.
254+
* @param contentType MIME type; defaults are applied when omitted.
255+
* @param user Optional user payload appended to the form as JSON.
256+
* @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal` from `AbortController`).
257+
* @return Promise resolving to `{ file: string, ... }` with the CDN URL.
258+
*/
248259
sendFile(
249260
uri: string | NodeJS.ReadableStream | Buffer | File,
250261
name?: string,
251262
contentType?: string,
252263
user?: UserResponse,
264+
axiosRequestConfig?: AxiosRequestConfig,
253265
) {
254266
return this.getClient().sendFile(
255267
`${this._channelURL()}/file`,
256268
uri,
257269
name,
258270
contentType,
259271
user,
272+
axiosRequestConfig,
260273
);
261274
}
262275

276+
/**
277+
* Upload an image to this channel’s image endpoint (multipart). Uses the same transport as `sendFile`.
278+
*
279+
* @param uri Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead.
280+
* @param name File name sent in the multipart body.
281+
* @param contentType MIME type.
282+
* @param user Optional user payload appended to the form as JSON.
283+
* @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal`).
284+
* @return Promise resolving to `{ file: string, ... }` with the CDN URL.
285+
*/
263286
sendImage(
264287
uri: string | NodeJS.ReadableStream | File,
265288
name?: string,
266289
contentType?: string,
267290
user?: UserResponse,
291+
axiosRequestConfig?: AxiosRequestConfig,
268292
) {
269293
return this.getClient().sendFile(
270294
`${this._channelURL()}/image`,
271295
uri,
272296
name,
273297
contentType,
274298
user,
299+
axiosRequestConfig,
275300
);
276301
}
277302

src/client.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,7 @@ export class StreamChat {
13111311
name?: string,
13121312
contentType?: string,
13131313
user?: UserResponse,
1314+
axiosRequestConfig?: AxiosRequestConfig,
13141315
) {
13151316
const data = addFileToFormData(uri, name, contentType || 'multipart/form-data');
13161317
if (user != null) data.append('user', JSON.stringify(user));
@@ -1321,6 +1322,7 @@ export class StreamChat {
13211322
timeout: 0,
13221323
maxContentLength: Infinity,
13231324
maxBodyLength: Infinity,
1325+
...axiosRequestConfig,
13241326
},
13251327
});
13261328
}
@@ -4866,6 +4868,7 @@ export class StreamChat {
48664868
* @param {string} [name] The name of the file
48674869
* @param {string} [contentType] The content type of the file
48684870
* @param {UserResponse} [user] Optional user information
4871+
* @param {AxiosRequestConfig} [axiosRequestConfig] Optional axios config (e.g. onUploadProgress for progress tracking)
48694872
*
48704873
* @return {Promise<SendFileAPIResponse>} Response containing the file URL
48714874
*/
@@ -4874,8 +4877,16 @@ export class StreamChat {
48744877
name?: string,
48754878
contentType?: string,
48764879
user?: UserResponse,
4880+
axiosRequestConfig?: AxiosRequestConfig,
48774881
) {
4878-
return this.sendFile(`${this.baseURL}/uploads/file`, uri, name, contentType, user);
4882+
return this.sendFile(
4883+
`${this.baseURL}/uploads/file`,
4884+
uri,
4885+
name,
4886+
contentType,
4887+
user,
4888+
axiosRequestConfig,
4889+
);
48794890
}
48804891

48814892
/**
@@ -4885,6 +4896,7 @@ export class StreamChat {
48854896
* @param {string} [name] The name of the image
48864897
* @param {string} [contentType] The content type of the image
48874898
* @param {UserResponse} [user] Optional user information
4899+
* @param {AxiosRequestConfig} [axiosRequestConfig] Optional axios config (e.g. onUploadProgress for progress tracking)
48884900
*
48894901
* @return {Promise<SendFileAPIResponse>} Response containing the image URL
48904902
*/
@@ -4893,8 +4905,16 @@ export class StreamChat {
48934905
name?: string,
48944906
contentType?: string,
48954907
user?: UserResponse,
4908+
axiosRequestConfig?: AxiosRequestConfig,
48964909
) {
4897-
return this.sendFile(`${this.baseURL}/uploads/image`, uri, name, contentType, user);
4910+
return this.sendFile(
4911+
`${this.baseURL}/uploads/image`,
4912+
uri,
4913+
name,
4914+
contentType,
4915+
user,
4916+
axiosRequestConfig,
4917+
);
48984918
}
48994919

49004920
/**

src/messageComposer/attachmentManager.ts

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type {
22
AttachmentManagerConfig,
33
MinimumUploadRequestResult,
44
UploadRequestFn,
5+
UploadRequestOptions,
56
} from './configuration';
67
import { isLocalImageAttachment, isUploadedAttachment } from './attachmentIdentity';
78
import {
@@ -445,12 +446,31 @@ export class AttachmentManager {
445446
* Method to perform the default upload behavior without checking for custom upload functions
446447
* to prevent recursive calls
447448
*/
448-
doDefaultUploadRequest = async (fileLike: FileReference | FileLike) => {
449+
doDefaultUploadRequest = async (
450+
fileLike: FileReference | FileLike,
451+
options?: UploadRequestOptions,
452+
) => {
453+
const progressHandler = options?.onProgress
454+
? (progressEvent: {
455+
loaded: number;
456+
total?: number;
457+
lengthComputable?: boolean;
458+
}) => {
459+
const percent =
460+
progressEvent.lengthComputable && progressEvent.total
461+
? Math.round((progressEvent.loaded * 100) / progressEvent.total)
462+
: undefined;
463+
options.onProgress?.(percent);
464+
}
465+
: undefined;
466+
449467
if (isFileReference(fileLike)) {
450468
return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](
451469
fileLike.uri,
452470
fileLike.name,
453471
fileLike.type,
472+
undefined,
473+
progressHandler ? { onUploadProgress: progressHandler } : undefined,
454474
);
455475
}
456476

@@ -463,22 +483,32 @@ export class AttachmentManager {
463483
});
464484

465485
// eslint-disable-next-line @typescript-eslint/no-unused-vars
466-
const { duration, ...result } =
467-
await this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](file);
486+
const { duration, ...result } = await this.channel[
487+
isImageFile(fileLike) ? 'sendImage' : 'sendFile'
488+
](
489+
file,
490+
undefined,
491+
undefined,
492+
undefined,
493+
progressHandler ? { onUploadProgress: progressHandler } : undefined,
494+
);
468495
return result;
469496
};
470497

471498
/**
472499
* todo: docs how to customize the image and file upload by overriding do
473500
*/
474501

475-
doUploadRequest = async (fileLike: FileReference | FileLike) => {
502+
doUploadRequest = async (
503+
fileLike: FileReference | FileLike,
504+
options?: UploadRequestOptions,
505+
) => {
476506
const customUploadFn = this.config.doUploadRequest;
477507
if (customUploadFn) {
478-
return await customUploadFn(fileLike);
508+
return await customUploadFn(fileLike, options);
479509
}
480510

481-
return this.doDefaultUploadRequest(fileLike);
511+
return this.doDefaultUploadRequest(fileLike, options);
482512
};
483513

484514
// @deprecated use attachmentManager.uploadFile(file)
@@ -507,26 +537,45 @@ export class AttachmentManager {
507537
return localAttachment;
508538
}
509539

510-
this.upsertAttachments([
511-
{
512-
...attachment,
513-
localMetadata: {
514-
...attachment.localMetadata,
515-
uploadState: 'uploading',
516-
},
540+
const shouldTrackProgress = this.config.trackUploadProgress;
541+
const uploadingAttachment: LocalUploadAttachment = {
542+
...attachment,
543+
localMetadata: {
544+
...attachment.localMetadata,
545+
uploadState: 'uploading',
546+
...(shouldTrackProgress && { uploadProgress: 0 }),
517547
},
518-
]);
548+
};
549+
this.upsertAttachments([uploadingAttachment]);
550+
551+
const uploadOptions = shouldTrackProgress
552+
? {
553+
onProgress: (percent: number | undefined) => {
554+
this.updateAttachment({
555+
...uploadingAttachment,
556+
localMetadata: {
557+
...uploadingAttachment.localMetadata,
558+
uploadProgress: percent,
559+
},
560+
});
561+
},
562+
}
563+
: undefined;
519564

520565
let response: MinimumUploadRequestResult;
521566
try {
522-
response = await this.doUploadRequest(localAttachment.localMetadata.file);
567+
response = await this.doUploadRequest(
568+
localAttachment.localMetadata.file,
569+
uploadOptions,
570+
);
523571
} catch (error) {
524572
const reason = error instanceof Error ? error.message : 'unknown error';
525573
const failedAttachment: LocalUploadAttachment = {
526574
...attachment,
527575
localMetadata: {
528576
...attachment.localMetadata,
529577
uploadState: 'failed',
578+
uploadProgress: undefined,
530579
},
531580
};
532581

@@ -561,6 +610,7 @@ export class AttachmentManager {
561610
localMetadata: {
562611
...attachment.localMetadata,
563612
uploadState: 'finished',
613+
uploadProgress: undefined,
564614
},
565615
};
566616

@@ -605,19 +655,35 @@ export class AttachmentManager {
605655
return preUpload.state.attachment;
606656
}
607657

658+
const shouldTrackProgress = this.config.trackUploadProgress;
608659
attachment = {
609660
...attachment,
610661
localMetadata: {
611662
...attachment.localMetadata,
612663
uploadState: 'uploading',
664+
...(shouldTrackProgress && { uploadProgress: 0 }),
613665
},
614666
};
615667
this.upsertAttachments([attachment]);
616668

669+
const uploadOptions = shouldTrackProgress
670+
? {
671+
onProgress: (percent: number | undefined) => {
672+
this.updateAttachment({
673+
...attachment,
674+
localMetadata: {
675+
...attachment.localMetadata,
676+
uploadProgress: percent,
677+
},
678+
});
679+
},
680+
}
681+
: undefined;
682+
617683
let response: MinimumUploadRequestResult | undefined;
618684
let error: Error | undefined;
619685
try {
620-
response = await this.doUploadRequest(file);
686+
response = await this.doUploadRequest(file, uploadOptions);
621687
} catch (err) {
622688
error = err instanceof Error ? err : undefined;
623689
}
@@ -630,6 +696,7 @@ export class AttachmentManager {
630696
localMetadata: {
631697
...attachment.localMetadata,
632698
uploadState: error ? 'failed' : 'finished',
699+
uploadProgress: undefined,
633700
},
634701
},
635702
error,

src/messageComposer/configuration/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const DEFAULT_ATTACHMENT_MANAGER_CONFIG: AttachmentManagerConfig = {
3131
acceptedFiles: [], // an empty array means all files are accepted
3232
fileUploadFilter: () => true,
3333
maxNumberOfFilesPerMessage: API_MAX_FILES_ALLOWED_PER_MESSAGE,
34+
trackUploadProgress: true,
3435
};
3536

3637
export const DEFAULT_TEXT_COMPOSER_CONFIG: TextComposerConfig = {

0 commit comments

Comments
 (0)