Skip to content

Commit a8b0497

Browse files
authored
feat: add attachment manager middleware for pre-upload and post-upload events (#1588)
1 parent 2dd6676 commit a8b0497

20 files changed

Lines changed: 1020 additions & 81 deletions

src/messageComposer/attachmentManager.ts

Lines changed: 116 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import {
1414
isFileReference,
1515
isImageFile,
1616
} from './fileUtils';
17+
import {
18+
AttachmentPostUploadMiddlewareExecutor,
19+
AttachmentPreUploadMiddlewareExecutor,
20+
} from './middleware/attachmentManager';
1721
import { StateStore } from '../store';
1822
import { generateUUIDv4 } from '../utils';
1923
import { DEFAULT_UPLOAD_SIZE_LIMIT_BYTES } from '../constants';
@@ -22,23 +26,14 @@ import type {
2226
FileLike,
2327
FileReference,
2428
LocalAttachment,
25-
LocalAudioAttachment,
26-
LocalFileAttachment,
29+
LocalNotImageAttachment,
2730
LocalUploadAttachment,
28-
LocalVideoAttachment,
29-
LocalVoiceRecordingAttachment,
3031
UploadPermissionCheckResult,
3132
} from './types';
3233
import type { ChannelResponse, DraftMessage, LocalMessage } from '../types';
3334
import type { MessageComposer } from './messageComposer';
3435
import { mergeWithDiff } from '../utils/mergeWith';
3536

36-
type LocalNotImageAttachment =
37-
| LocalFileAttachment
38-
| LocalAudioAttachment
39-
| LocalVideoAttachment
40-
| LocalVoiceRecordingAttachment;
41-
4237
export type FileUploadFilter = (file: Partial<LocalUploadAttachment>) => boolean;
4338

4439
export type AttachmentManagerState = {
@@ -71,6 +66,8 @@ const initState = ({
7166
export class AttachmentManager {
7267
readonly state: StateStore<AttachmentManagerState>;
7368
readonly composer: MessageComposer;
69+
readonly preUploadMiddlewareExecutor: AttachmentPreUploadMiddlewareExecutor;
70+
readonly postUploadMiddlewareExecutor: AttachmentPostUploadMiddlewareExecutor;
7471
private attachmentsByIdGetterCache: {
7572
attachmentsById: Record<string, LocalAttachment>;
7673
attachments: LocalAttachment[];
@@ -80,6 +77,13 @@ export class AttachmentManager {
8077
this.composer = composer;
8178
this.state = new StateStore<AttachmentManagerState>(initState({ message }));
8279
this.attachmentsByIdGetterCache = { attachmentsById: {}, attachments: [] };
80+
81+
this.preUploadMiddlewareExecutor = new AttachmentPreUploadMiddlewareExecutor({
82+
composer,
83+
});
84+
this.postUploadMiddlewareExecutor = new AttachmentPostUploadMiddlewareExecutor({
85+
composer,
86+
});
8387
}
8488

8589
get attachmentsById() {
@@ -122,10 +126,16 @@ export class AttachmentManager {
122126
this.composer.updateConfig({ attachments: { acceptedFiles } });
123127
}
124128

129+
/*
130+
@deprecated attachments can be filtered using injecting pre-upload middleware
131+
*/
125132
get fileUploadFilter() {
126133
return this.config.fileUploadFilter;
127134
}
128135

136+
/*
137+
@deprecated attachments can be filtered using injecting pre-upload middleware
138+
*/
129139
set fileUploadFilter(fileUploadFilter: AttachmentManagerConfig['fileUploadFilter']) {
130140
this.composer.updateConfig({ attachments: { fileUploadFilter } });
131141
}
@@ -333,9 +343,9 @@ export class AttachmentManager {
333343
return { uploadBlocked: false };
334344
};
335345

336-
fileToLocalUploadAttachment = async (
346+
static toLocalUploadAttachment = (
337347
fileLike: FileReference | FileLike,
338-
): Promise<LocalUploadAttachment> => {
348+
): LocalUploadAttachment => {
339349
const file =
340350
isFileReference(fileLike) || isFile(fileLike)
341351
? fileLike
@@ -345,16 +355,13 @@ export class AttachmentManager {
345355
mimeType: fileLike.type,
346356
});
347357

348-
const uploadPermissionCheck = await this.getUploadConfigCheck(file);
349-
350358
const localAttachment: LocalUploadAttachment = {
351359
file_size: file.size,
352360
mime_type: file.type,
353361
localMetadata: {
354362
file,
355363
id: generateUUIDv4(),
356-
uploadPermissionCheck,
357-
uploadState: uploadPermissionCheck.uploadBlocked ? 'blocked' : 'pending',
364+
uploadState: 'pending',
358365
},
359366
type: getAttachmentTypeFromMimeType(file.type),
360367
};
@@ -383,10 +390,26 @@ export class AttachmentManager {
383390
return localAttachment;
384391
};
385392

393+
// @deprecated use AttachmentManager.toLocalUploadAttachment(file)
394+
fileToLocalUploadAttachment = async (
395+
fileLike: FileReference | FileLike,
396+
): Promise<LocalUploadAttachment> => {
397+
const localAttachment = AttachmentManager.toLocalUploadAttachment(fileLike);
398+
const uploadPermissionCheck = await this.getUploadConfigCheck(
399+
localAttachment.localMetadata.file,
400+
);
401+
localAttachment.localMetadata.uploadPermissionCheck = uploadPermissionCheck;
402+
localAttachment.localMetadata.uploadState = uploadPermissionCheck.uploadBlocked
403+
? 'blocked'
404+
: 'pending';
405+
406+
return localAttachment;
407+
};
408+
386409
private ensureLocalUploadAttachment = async (
387410
attachment: Partial<LocalUploadAttachment>,
388411
) => {
389-
if (!attachment.localMetadata?.file || !attachment.localMetadata.id) {
412+
if (!attachment.localMetadata?.file) {
390413
this.client.notifications.addError({
391414
message: 'File is required for upload attachment',
392415
origin: { emitter: 'AttachmentManager', context: { attachment } },
@@ -395,6 +418,15 @@ export class AttachmentManager {
395418
return;
396419
}
397420

421+
if (!attachment.localMetadata.id) {
422+
this.client.notifications.addError({
423+
message: 'Local upload attachment missing local id',
424+
origin: { emitter: 'AttachmentManager', context: { attachment } },
425+
options: { type: 'validation:attachment:id:missing' },
426+
});
427+
return;
428+
}
429+
398430
if (!this.fileUploadFilter(attachment)) return;
399431

400432
const newAttachment = await this.fileToLocalUploadAttachment(
@@ -446,6 +478,7 @@ export class AttachmentManager {
446478
return this.doDefaultUploadRequest(fileLike);
447479
};
448480

481+
// @deprecated use attachmentManager.uploadFile(file)
449482
uploadAttachment = async (attachment: LocalUploadAttachment) => {
450483
if (!this.isUploadEnabled) return;
451484

@@ -546,20 +579,78 @@ export class AttachmentManager {
546579
return uploadedAttachment;
547580
};
548581

582+
uploadFile = async (file: FileReference | FileLike) => {
583+
const preUpload = await this.preUploadMiddlewareExecutor.execute({
584+
eventName: 'prepare',
585+
initialValue: {
586+
attachment: AttachmentManager.toLocalUploadAttachment(file),
587+
},
588+
mode: 'concurrent',
589+
});
590+
591+
let attachment: LocalUploadAttachment = preUpload.state.attachment;
592+
593+
if (preUpload.status === 'discard') return attachment;
594+
// todo: remove with the next major release as filtering can be done in middleware
595+
// should we return the attachment object?
596+
if (!this.fileUploadFilter(attachment)) return attachment;
597+
598+
if (attachment.localMetadata.uploadState === 'blocked') {
599+
this.upsertAttachments([attachment]);
600+
return preUpload.state.attachment;
601+
}
602+
603+
attachment = {
604+
...attachment,
605+
localMetadata: {
606+
...attachment.localMetadata,
607+
uploadState: 'uploading',
608+
},
609+
};
610+
this.upsertAttachments([attachment]);
611+
612+
let response: MinimumUploadRequestResult | undefined;
613+
let error: Error | undefined;
614+
try {
615+
response = await this.doUploadRequest(file);
616+
} catch (err) {
617+
error = err instanceof Error ? err : undefined;
618+
}
619+
620+
const postUpload = await this.postUploadMiddlewareExecutor.execute({
621+
eventName: 'postProcess',
622+
initialValue: {
623+
attachment: {
624+
...attachment,
625+
localMetadata: {
626+
...attachment.localMetadata,
627+
uploadState: error ? 'failed' : 'finished',
628+
},
629+
},
630+
error,
631+
response,
632+
},
633+
mode: 'concurrent',
634+
});
635+
attachment = postUpload.state.attachment;
636+
637+
if (postUpload.status === 'discard') {
638+
this.removeAttachments([attachment.localMetadata.id]);
639+
return attachment;
640+
}
641+
642+
this.updateAttachment(attachment);
643+
return attachment;
644+
};
645+
549646
uploadFiles = async (files: FileReference[] | FileList | FileLike[]) => {
550647
if (!this.isUploadEnabled) return;
551648
const iterableFiles: FileReference[] | FileLike[] = isFileList(files)
552649
? Array.from(files)
553650
: files;
554-
const attachments = await Promise.all(
555-
iterableFiles.map(this.fileToLocalUploadAttachment),
556-
);
557651

558-
return Promise.all(
559-
attachments
560-
.filter(this.fileUploadFilter)
561-
.slice(0, this.availableUploadSlots)
562-
.map(this.uploadAttachment),
652+
return await Promise.all(
653+
iterableFiles.slice(0, this.availableUploadSlots).map(this.uploadFile),
563654
);
564655
};
565656
}

src/messageComposer/configuration/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import type { LinkPreview } from '../linkPreviewsManager';
22
import type { FileUploadFilter } from '../attachmentManager';
33
import type { FileLike, FileReference } from '../types';
44

5-
export type MinimumUploadRequestResult = { file: string; thumb_url?: string };
5+
export type MinimumUploadRequestResult = { file: string; thumb_url?: string } & Partial<
6+
Record<string, unknown>
7+
>;
68

79
export type UploadRequestFn = (
810
fileLike: FileReference | FileLike,
@@ -39,7 +41,6 @@ export type AttachmentManagerConfig = {
3941
* describing which file types are allowed to be selected when uploading files.
4042
*/
4143
acceptedFiles: string[];
42-
// todo: refactor this. We want a pipeline where it would be possible to customize the preparation, upload, and post-upload steps.
4344
/** Function that allows to customize the upload request. */
4445
doUploadRequest?: UploadRequestFn;
4546
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './postUpload';
2+
export * from './preUpload';
3+
export * from './types';
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MiddlewareExecutor } from '../../../../middleware';
2+
import type {
3+
AttachmentPostUploadMiddlewareExecutorOptions,
4+
AttachmentPostUploadMiddlewareState,
5+
} from '../types';
6+
import { createPostUploadAttachmentEnrichmentMiddleware } from './attachmentEnrichment';
7+
import { createUploadErrorHandlerMiddleware } from './uploadErrorHandler';
8+
9+
export class AttachmentPostUploadMiddlewareExecutor extends MiddlewareExecutor<
10+
AttachmentPostUploadMiddlewareState,
11+
'postProcess'
12+
> {
13+
constructor({ composer }: AttachmentPostUploadMiddlewareExecutorOptions) {
14+
super();
15+
this.use([
16+
createUploadErrorHandlerMiddleware(composer),
17+
createPostUploadAttachmentEnrichmentMiddleware(),
18+
]);
19+
}
20+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import type { MiddlewareHandlerParams } from '../../../../middleware';
2+
import type {
3+
AttachmentPostUploadMiddleware,
4+
AttachmentPostUploadMiddlewareState,
5+
} from '../types';
6+
import { isLocalImageAttachment } from '../../../attachmentIdentity';
7+
import type { LocalNotImageAttachment } from '../../../types';
8+
9+
export const createPostUploadAttachmentEnrichmentMiddleware =
10+
(): AttachmentPostUploadMiddleware => ({
11+
id: 'stream-io/attachment-manager-middleware/post-upload-enrichment',
12+
handlers: {
13+
postProcess: ({
14+
state,
15+
discard,
16+
forward,
17+
next,
18+
}: MiddlewareHandlerParams<AttachmentPostUploadMiddlewareState>) => {
19+
const { attachment, error, response } = state;
20+
if (error) return forward();
21+
if (!attachment || !response) return discard();
22+
23+
const enrichedAttachment = { ...attachment };
24+
if (isLocalImageAttachment(attachment)) {
25+
if (attachment.localMetadata.previewUri) {
26+
URL.revokeObjectURL(attachment.localMetadata.previewUri);
27+
delete enrichedAttachment.localMetadata.previewUri;
28+
}
29+
enrichedAttachment.image_url = response.file;
30+
} else {
31+
(enrichedAttachment as LocalNotImageAttachment).asset_url = response.file;
32+
}
33+
if (response.thumb_url) {
34+
(enrichedAttachment as LocalNotImageAttachment).thumb_url = response.thumb_url;
35+
}
36+
37+
return next({
38+
...state,
39+
attachment: enrichedAttachment,
40+
});
41+
},
42+
},
43+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export * from './attachmentEnrichment';
2+
export * from './AttachmentPostUploadMiddlewareExecutor';
3+
export * from './uploadErrorHandler';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import type { MiddlewareHandlerParams } from '../../../../middleware';
2+
import type { MessageComposer } from '../../../messageComposer';
3+
import type {
4+
AttachmentPostUploadMiddleware,
5+
AttachmentPostUploadMiddlewareState,
6+
} from '../types';
7+
8+
export const createUploadErrorHandlerMiddleware = (
9+
composer: MessageComposer,
10+
): AttachmentPostUploadMiddleware => ({
11+
id: 'stream-io/attachment-manager-middleware/upload-error',
12+
handlers: {
13+
postProcess: ({
14+
state,
15+
discard,
16+
forward,
17+
}: MiddlewareHandlerParams<AttachmentPostUploadMiddlewareState>) => {
18+
const { attachment, error } = state;
19+
if (!error) return forward();
20+
if (!attachment) return discard();
21+
22+
const reason = error instanceof Error ? error.message : 'unknown error';
23+
composer.client.notifications.addError({
24+
message: 'Error uploading attachment',
25+
origin: {
26+
emitter: 'AttachmentManager',
27+
context: { attachment },
28+
},
29+
options: {
30+
type: 'api:attachment:upload:failed',
31+
metadata: { reason },
32+
originalError: error,
33+
},
34+
});
35+
36+
return forward();
37+
},
38+
},
39+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { MiddlewareExecutor } from '../../../../middleware';
2+
import type {
3+
AttachmentPreUploadMiddlewareExecutorOptions,
4+
AttachmentPreUploadMiddlewareState,
5+
} from '../types';
6+
import { createUploadConfigCheckMiddleware } from './serverUploadConfigCheck';
7+
import { createBlockedAttachmentUploadNotificationMiddleware } from './blockedUploadNotification';
8+
9+
export class AttachmentPreUploadMiddlewareExecutor extends MiddlewareExecutor<
10+
AttachmentPreUploadMiddlewareState,
11+
'prepare'
12+
> {
13+
constructor({ composer }: AttachmentPreUploadMiddlewareExecutorOptions) {
14+
super();
15+
this.use([
16+
createUploadConfigCheckMiddleware(composer),
17+
createBlockedAttachmentUploadNotificationMiddleware(composer),
18+
]);
19+
}
20+
}

0 commit comments

Comments
 (0)