Skip to content

Commit b172377

Browse files
authored
feat: global upload manager to track file uploads outside of AttachmentManager (#1715)
## 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? RN PR for reference: GetStream/stream-chat-react-native#3527 React Native allows hitting the send button before attachment uploads are completed. This means they need a way to track attachment upload status outside of `AttachmentManager`. To solve this we have an `UploadManager` (can be accessed via `client.uploadManager`) that uses the `AttachmentManager` of a given channel to do attachment upload, and has a state to track `uploadProgress` for attachments. Attachments are tracked by `id` in `UploadManager`. Long-term solution: we talked with @isekovanic that `messageComposer` should have support for this feature, but that is a bigger task, not the scope of this PR. ## Changelog -
1 parent ad3eb91 commit b172377

11 files changed

Lines changed: 953 additions & 129 deletions

File tree

docs/fileUpload.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,9 @@ const imageResponse = await channel.sendImage(file, file.name, file.type, undefi
122122

123123
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).
124124

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.
125+
With a custom `doUploadRequest`, the function receives an optional second argument `options` with:
126126

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`.
127+
- `onProgress?: (percent: number | undefined) => void` — call this 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.
128+
- `abortSignal?: AbortSignal` — the SDK aborts this signal when the upload is cancelled (for example the user removes the attachment, or `client.uploadManager.reset()` runs on disconnect). Forward it to your transport (axios `signal`, `fetch` `signal`, etc.) if you want to cancel upload request.
129+
130+
Set `trackUploadProgress` to `false` to skip setting `uploadProgress` (will be `undefined` in this case) and to omit `onProgress` to both the default channel upload and custom `doUploadRequest`.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"dependencies": {
5252
"@types/jsonwebtoken": "^9.0.8",
5353
"@types/ws": "^8.5.14",
54-
"axios": "^1.12.2",
54+
"axios": "^1.15.1",
5555
"base64-js": "^1.5.1",
5656
"form-data": "^4.0.4",
5757
"isomorphic-ws": "^5.0.0",

src/client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type WebSocket from 'isomorphic-ws';
99
import { Channel } from './channel';
1010
import { ClientState } from './client_state';
1111
import { StableWSConnection } from './connection';
12+
import { UploadManager } from './uploadManager';
1213
import { CheckSignature, DevToken, JWTUserToken } from './signing';
1314
import { TokenManager } from './token_manager';
1415
import { WSConnectionFallback } from './connection_fallback';
@@ -296,6 +297,10 @@ export type MessageComposerSetupState = {
296297
export class StreamChat {
297298
private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics
298299
messageDeliveryReporter: MessageDeliveryReporter;
300+
/**
301+
* @internal
302+
*/
303+
uploadManager: UploadManager;
299304
_user?: OwnUserResponse | UserResponse;
300305
appSettingsPromise?: Promise<AppSettingsAPIResponse>;
301306
activeChannels: {
@@ -401,6 +406,7 @@ export class StreamChat {
401406
this.moderation = new Moderation(this);
402407

403408
this.notifications = options?.notifications ?? new NotificationManager();
409+
this.uploadManager = new UploadManager(this);
404410

405411
// set the secret
406412
if (secretOrOptions && isString(secretOrOptions)) {
@@ -1020,6 +1026,7 @@ export class StreamChat {
10201026
this.state = new ClientState({ client: this });
10211027
// reset thread manager
10221028
this.threads.resetState();
1029+
this.uploadManager.reset();
10231030

10241031
// Since we wipe all user data already, we should reset token manager as well
10251032
closePromise

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export type {
3333
export * from './thread_manager';
3434
export * from './token_manager';
3535
export * from './types';
36+
export * from './uploadManager';
3637
export * from './channel_manager';
3738
export * from './offline-support';
3839
export * from './LiveLocationManager';

src/messageComposer/attachmentManager.ts

Lines changed: 65 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,12 @@ export class AttachmentManager {
237237
return attachments;
238238
}
239239
}
240-
return null;
240+
return stateAttachments;
241241
};
242242

243243
updateAttachment = (attachmentToUpdate: LocalAttachment) => {
244244
const updatedAttachments = this.prepareAttachmentUpdate(attachmentToUpdate);
245-
if (updatedAttachments) {
245+
if (updatedAttachments && updatedAttachments !== this.attachments) {
246246
this.state.partialNext({ attachments: updatedAttachments });
247247
}
248248
};
@@ -253,28 +253,35 @@ export class AttachmentManager {
253253
let hasUpdates = false;
254254
attachmentsToUpsert.forEach((attachment) => {
255255
const updatedAttachments = this.prepareAttachmentUpdate(attachment);
256-
if (updatedAttachments) {
257-
attachments = updatedAttachments;
258-
hasUpdates = true;
259-
} else {
256+
if (updatedAttachments === null) {
260257
const localAttachment = ensureIsLocalAttachment(attachment);
261258
if (localAttachment) {
262259
attachments.push(localAttachment);
263260
hasUpdates = true;
264261
}
262+
} else if (updatedAttachments !== this.attachments) {
263+
attachments = updatedAttachments;
264+
hasUpdates = true;
265265
}
266+
// else: id exists and merge was a no-op (`prepareAttachmentUpdate` returns current state)
266267
});
267268
if (hasUpdates) {
268269
this.state.partialNext({ attachments });
269270
}
270271
};
271272

272273
removeAttachments = (localAttachmentIds: string[]) => {
274+
if (!localAttachmentIds.length) return;
275+
273276
this.state.partialNext({
274277
attachments: this.attachments.filter(
275278
(attachment) => !localAttachmentIds.includes(attachment.localMetadata?.id),
276279
),
277280
});
281+
282+
for (const id of localAttachmentIds) {
283+
this.client.uploadManager.deleteUploadRecord(id);
284+
}
278285
};
279286

280287
getUploadConfigCheck = async (
@@ -464,13 +471,21 @@ export class AttachmentManager {
464471
}
465472
: undefined;
466473

474+
const axiosUploadConfig =
475+
progressHandler || options?.abortSignal
476+
? {
477+
...(progressHandler ? { onUploadProgress: progressHandler } : {}),
478+
...(options?.abortSignal ? { signal: options.abortSignal } : {}),
479+
}
480+
: undefined;
481+
467482
if (isFileReference(fileLike)) {
468483
return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](
469484
fileLike.uri,
470485
fileLike.name,
471486
fileLike.type,
472487
undefined,
473-
progressHandler ? { onUploadProgress: progressHandler } : undefined,
488+
axiosUploadConfig,
474489
);
475490
}
476491

@@ -485,13 +500,7 @@ export class AttachmentManager {
485500
// eslint-disable-next-line @typescript-eslint/no-unused-vars
486501
const { duration, ...result } = await this.channel[
487502
isImageFile(fileLike) ? 'sendImage' : 'sendFile'
488-
](
489-
file,
490-
undefined,
491-
undefined,
492-
undefined,
493-
progressHandler ? { onUploadProgress: progressHandler } : undefined,
494-
);
503+
](file, undefined, undefined, undefined, axiosUploadConfig);
495504
return result;
496505
};
497506

@@ -537,37 +546,9 @@ export class AttachmentManager {
537546
return localAttachment;
538547
}
539548

540-
const shouldTrackProgress = this.config.trackUploadProgress;
541-
const uploadingAttachment: LocalUploadAttachment = {
542-
...attachment,
543-
localMetadata: {
544-
...attachment.localMetadata,
545-
uploadState: 'uploading',
546-
...(shouldTrackProgress && { uploadProgress: 0 }),
547-
},
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;
564-
565549
let response: MinimumUploadRequestResult;
566550
try {
567-
response = await this.doUploadRequest(
568-
localAttachment.localMetadata.file,
569-
uploadOptions,
570-
);
551+
response = await this.upload(attachment);
571552
} catch (error) {
572553
const reason = error instanceof Error ? error.message : 'unknown error';
573554
const failedAttachment: LocalUploadAttachment = {
@@ -655,35 +636,10 @@ export class AttachmentManager {
655636
return preUpload.state.attachment;
656637
}
657638

658-
const shouldTrackProgress = this.config.trackUploadProgress;
659-
attachment = {
660-
...attachment,
661-
localMetadata: {
662-
...attachment.localMetadata,
663-
uploadState: 'uploading',
664-
...(shouldTrackProgress && { uploadProgress: 0 }),
665-
},
666-
};
667-
this.upsertAttachments([attachment]);
668-
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-
683639
let response: MinimumUploadRequestResult | undefined;
684640
let error: Error | undefined;
685641
try {
686-
response = await this.doUploadRequest(file, uploadOptions);
642+
response = await this.upload(attachment);
687643
} catch (err) {
688644
error = err instanceof Error ? err : undefined;
689645
}
@@ -725,4 +681,44 @@ export class AttachmentManager {
725681
iterableFiles.slice(0, this.availableUploadSlots).map(this.uploadFile),
726682
);
727683
};
684+
685+
private upload(attachment: LocalUploadAttachment) {
686+
const localId = attachment.localMetadata.id;
687+
688+
this.upsertAttachments([
689+
{
690+
...attachment,
691+
localMetadata: {
692+
...attachment.localMetadata,
693+
uploadState: 'uploading',
694+
uploadProgress: this.config.trackUploadProgress ? 0 : undefined,
695+
},
696+
},
697+
]);
698+
699+
const unsubscribe = this.client.uploadManager.state.subscribeWithSelector(
700+
(s) => ({ upload: s.uploads[localId] }),
701+
({ upload: nextUpload }) => {
702+
if (!nextUpload) return;
703+
this.updateAttachment({
704+
...attachment,
705+
localMetadata: {
706+
...attachment.localMetadata,
707+
uploadState: 'uploading',
708+
uploadProgress: nextUpload.uploadProgress,
709+
},
710+
});
711+
},
712+
);
713+
714+
return this.client.uploadManager
715+
.upload({
716+
id: localId,
717+
channelCid: this.channel.cid,
718+
file: attachment.localMetadata.file,
719+
})
720+
.finally(() => {
721+
unsubscribe();
722+
});
723+
}
728724
}

src/messageComposer/configuration/types.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ export type MinimumUploadRequestResult = { file: string; thumb_url?: string } &
66
Record<string, unknown>
77
>;
88

9-
/** Optional second argument to `UploadRequestFn`; integrators may call `onProgress` to report 0–100 or `undefined` when indeterminate. */
9+
/**
10+
* Optional second argument to `UploadRequestFn`.
11+
* - Call `onProgress` to report 0–100 or `undefined` when indeterminate.
12+
* - Forward `abortSignal` to your upload implementation to cancel the upload if the user deletes the attachment while it's uploading.
13+
*/
1014
export type UploadRequestOptions = {
1115
onProgress?: (percent: number | undefined) => void;
16+
abortSignal?: AbortSignal;
1217
};
1318

1419
export type UploadRequestFn = (

0 commit comments

Comments
 (0)