Skip to content

Commit 11ecf89

Browse files
committed
feat: set custom upload fn, uploads in progress count toward available slots, preserve original attachment id
1 parent abe6fac commit 11ecf89

File tree

2 files changed

+203
-23
lines changed

2 files changed

+203
-23
lines changed

src/messageComposer/attachmentManager.ts

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { DEFAULT_ATTACHMENT_MANAGER_CONFIG } from './configuration';
12
import { isLocalImageAttachment, isUploadedAttachment } from './attachmentIdentity';
23
import {
34
createFileFromBlobs,
@@ -11,6 +12,7 @@ import {
1112
} from './fileUtils';
1213
import { StateStore } from '../store';
1314
import { generateUUIDv4 } from '../utils';
15+
import { mergeWith } from '../utils/mergeWith';
1416
import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants';
1517
import type { Channel } from '../channel';
1618
import type {
@@ -25,15 +27,12 @@ import type {
2527
LocalVoiceRecordingAttachment,
2628
UploadPermissionCheckResult,
2729
} from './types';
30+
import type { ChannelResponse, DraftMessage, LocalMessage } from '../types';
2831
import type {
29-
ChannelResponse,
30-
DraftMessage,
31-
LocalMessage,
32-
SendFileAPIResponse,
33-
} from '../types';
34-
import { mergeWith } from '../utils/mergeWith';
35-
import { DEFAULT_ATTACHMENT_MANAGER_CONFIG } from './configuration/configuration';
36-
import type { AttachmentManagerConfig } from './configuration/types';
32+
AttachmentManagerConfig,
33+
MinimumUploadRequestResult,
34+
UploadRequestFn,
35+
} from './configuration';
3736

3837
type LocalNotImageAttachment =
3938
| LocalFileAttachment
@@ -155,7 +154,11 @@ export class AttachmentManager {
155154
}
156155

157156
get availableUploadSlots() {
158-
return this.config.maxNumberOfFilesPerMessage - this.successfulUploadsCount;
157+
return (
158+
this.config.maxNumberOfFilesPerMessage -
159+
this.successfulUploadsCount -
160+
this.uploadsInProgressCount
161+
);
159162
}
160163

161164
getUploadsByState(state: AttachmentLoadingState) {
@@ -336,18 +339,24 @@ export class AttachmentManager {
336339
// the following is substitute for: if (noFiles && !isImage) return att
337340
if (!this.fileUploadFilter(attachment)) return;
338341

339-
return await this.fileToLocalUploadAttachment(attachment.localMetadata.file);
342+
const newAttachment = await this.fileToLocalUploadAttachment(
343+
attachment.localMetadata.file,
344+
);
345+
if (attachment.localMetadata.id) {
346+
newAttachment.localMetadata.id = attachment.localMetadata.id;
347+
}
348+
return newAttachment;
349+
};
350+
351+
setCustomUploadFn = (doUploadRequest: UploadRequestFn) => {
352+
this.configState.partialNext({ doUploadRequest });
340353
};
341354

342355
/**
343-
* todo: docs how to customize the image and file upload by overriding do
356+
* Method to perform the default upload behavior without checking for custom upload functions
357+
* to prevent recursive calls
344358
*/
345-
346-
doUploadRequest = (fileLike: FileReference | FileLike) => {
347-
if (this.config.doUploadRequest) {
348-
return this.config.doUploadRequest(fileLike);
349-
}
350-
359+
doDefaultUploadRequest = async (fileLike: FileReference | FileLike) => {
351360
if (isFileReference(fileLike)) {
352361
return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](
353362
fileLike.uri,
@@ -364,7 +373,23 @@ export class AttachmentManager {
364373
mimeType: fileLike.type,
365374
});
366375

367-
return this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](file);
376+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
377+
const { duration, ...result } =
378+
await this.channel[isImageFile(fileLike) ? 'sendImage' : 'sendFile'](file);
379+
return result;
380+
};
381+
382+
/**
383+
* todo: docs how to customize the image and file upload by overriding do
384+
*/
385+
386+
doUploadRequest = async (fileLike: FileReference | FileLike) => {
387+
const customUploadFn = this.config.doUploadRequest;
388+
if (customUploadFn) {
389+
return await customUploadFn(fileLike);
390+
}
391+
392+
return this.doDefaultUploadRequest(fileLike);
368393
};
369394

370395
uploadAttachment = async (attachment: LocalUploadAttachment) => {
@@ -376,7 +401,11 @@ export class AttachmentManager {
376401

377402
if (localAttachment.localMetadata.uploadState === 'blocked') {
378403
this.upsertAttachments([localAttachment]);
379-
return;
404+
this.client.notifications.addError({
405+
message: 'Error uploading attachment',
406+
origin: { emitter: 'AttachmentManager', context: { attachment } },
407+
});
408+
return localAttachment;
380409
}
381410

382411
this.upsertAttachments([
@@ -389,7 +418,7 @@ export class AttachmentManager {
389418
},
390419
]);
391420

392-
let response: SendFileAPIResponse;
421+
let response: MinimumUploadRequestResult;
393422
try {
394423
response = await this.doUploadRequest(localAttachment.localMetadata.file);
395424
} catch (error) {
@@ -417,8 +446,7 @@ export class AttachmentManager {
417446
};
418447

419448
this.upsertAttachments([failedAttachment]);
420-
421-
throw finalError;
449+
return failedAttachment;
422450
}
423451

424452
if (!response) {

test/unit/MessageComposer/attachmentManager.test.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,39 @@ describe('AttachmentManager', () => {
325325
// Should have max slots minus the number of attachments in the message
326326
expect(manager.availableUploadSlots).toBe(API_MAX_FILES_ALLOWED_PER_MESSAGE - 2);
327327
});
328+
329+
it('should take into consideration uploads in progress', () => {
330+
const { attachmentManager } = setup();
331+
332+
// Set up state with successful uploads and uploads in progress
333+
attachmentManager.state.next({
334+
attachments: [
335+
{
336+
type: 'image',
337+
image_url: 'test-image-url',
338+
localMetadata: {
339+
id: 'test-uuid-1',
340+
uploadState: 'finished',
341+
file: new File([''], 'test1.jpg', { type: 'image/jpeg' }),
342+
},
343+
},
344+
{
345+
type: 'image',
346+
image_url: 'test-image-url',
347+
localMetadata: {
348+
id: 'test-uuid-2',
349+
uploadState: 'uploading',
350+
file: new File([''], 'test2.jpg', { type: 'image/jpeg' }),
351+
},
352+
},
353+
],
354+
});
355+
356+
// Should have max slots minus successful uploads (1) minus uploads in progress (1)
357+
expect(attachmentManager.availableUploadSlots).toBe(
358+
API_MAX_FILES_ALLOWED_PER_MESSAGE - 2,
359+
);
360+
});
328361
});
329362

330363
describe('initState', () => {
@@ -691,7 +724,23 @@ describe('AttachmentManager', () => {
691724
mockChannel.sendImage.mockRejectedValueOnce(new Error('Upload failed'));
692725
const file = new File([''], 'test.jpg', { type: 'image/jpeg' });
693726

694-
await expect(attachmentManager.uploadFiles([file])).rejects.toThrowError();
727+
await expect(attachmentManager.uploadFiles([file])).resolves.toEqual([
728+
{
729+
fallback: 'test.jpg',
730+
file_size: 0,
731+
localMetadata: {
732+
id: 'test-uuid',
733+
file,
734+
uploadState: 'failed',
735+
previewUri: expect.any(String),
736+
uploadPermissionCheck: {
737+
uploadBlocked: false,
738+
},
739+
},
740+
mime_type: 'image/jpeg',
741+
type: 'image',
742+
},
743+
]);
695744

696745
expect(attachmentManager.failedUploadsCount).toBe(1);
697746
expect(mockClient.notifications.addError).toHaveBeenCalledWith({
@@ -703,6 +752,70 @@ describe('AttachmentManager', () => {
703752
});
704753
});
705754

755+
it('should register notification for blocked file', async () => {
756+
const { attachmentManager, mockClient } = setup();
757+
758+
// Create a blocked attachment
759+
const blockedAttachment = {
760+
type: 'image',
761+
localMetadata: {
762+
id: 'test-id',
763+
file: new File([''], 'test.jpg', { type: 'image/jpeg' }),
764+
},
765+
};
766+
767+
// Mock getUploadConfigCheck to return blocked
768+
vi.spyOn(attachmentManager, 'getUploadConfigCheck').mockResolvedValue({
769+
uploadBlocked: true,
770+
reason: 'size_limit',
771+
});
772+
773+
await attachmentManager.uploadAttachment(blockedAttachment);
774+
775+
// Verify notification was added
776+
expect(mockClient.notifications.addError).toHaveBeenCalledWith({
777+
message: 'Error uploading attachment',
778+
origin: {
779+
emitter: 'AttachmentManager',
780+
context: { attachment: blockedAttachment },
781+
},
782+
});
783+
});
784+
785+
it('should use custom upload function when provided', async () => {
786+
const { attachmentManager, mockChannel } = setup();
787+
788+
// Create a custom upload function
789+
const customUploadFn = vi.fn().mockResolvedValue({ file: 'custom-upload-url' });
790+
791+
// Set the custom upload function
792+
attachmentManager.setCustomUploadFn(customUploadFn);
793+
794+
// Create a file to upload
795+
const file = new File([''], 'test.jpg', { type: 'image/jpeg' });
796+
797+
// Mock fileToLocalUploadAttachment to return a valid attachment
798+
const attachment = {
799+
type: 'image',
800+
localMetadata: {
801+
id: 'test-id',
802+
file,
803+
uploadState: 'pending',
804+
},
805+
};
806+
807+
vi.spyOn(attachmentManager, 'ensureLocalUploadAttachment').mockResolvedValue(
808+
attachment,
809+
);
810+
811+
// Upload the attachment
812+
await attachmentManager.uploadAttachment(attachment);
813+
814+
// Verify the custom upload function was called
815+
expect(customUploadFn).toHaveBeenCalledWith(file);
816+
expect(mockChannel.sendImage).not.toHaveBeenCalled();
817+
});
818+
706819
it('should respect maxNumberOfFilesPerMessage', async () => {
707820
const { attachmentManager } = setup();
708821
const files = Array(API_MAX_FILES_ALLOWED_PER_MESSAGE + 1)
@@ -848,5 +961,44 @@ describe('AttachmentManager', () => {
848961

849962
expect(result).toEqual(expectedAttachment);
850963
});
964+
965+
it('should preserve original ID if it exists', async () => {
966+
const { attachmentManager } = setup();
967+
const ensureLocalUploadAttachment = (attachmentManager as any)
968+
.ensureLocalUploadAttachment;
969+
970+
// Set a fileUploadFilter that allows all files
971+
attachmentManager.fileUploadFilter = () => true;
972+
973+
// Create an attachment with an ID
974+
const originalId = 'original-test-id';
975+
const file = new File([''], 'test.jpg', { type: 'image/jpeg' });
976+
977+
// Mock fileToLocalUploadAttachment to return a new attachment
978+
const newAttachment = {
979+
type: 'image',
980+
image_url: 'test-url',
981+
localMetadata: {
982+
id: 'new-test-id', // Different ID
983+
file: new File([''], 'test.jpg', { type: 'image/jpeg' }),
984+
uploadState: 'finished',
985+
},
986+
};
987+
988+
vi.spyOn(attachmentManager, 'fileToLocalUploadAttachment').mockResolvedValue(
989+
newAttachment,
990+
);
991+
992+
// Call with original ID
993+
const result = await ensureLocalUploadAttachment({
994+
localMetadata: {
995+
id: originalId,
996+
file,
997+
},
998+
});
999+
1000+
// Verify the original ID was preserved
1001+
expect(result.localMetadata.id).toBe(originalId);
1002+
});
8511003
});
8521004
});

0 commit comments

Comments
 (0)