Skip to content

Commit 9204cd7

Browse files
authored
Merge branch 'master' into mod210
2 parents 3f15d4a + c8f6d7c commit 9204cd7

26 files changed

+1179
-112
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
## [9.14.0](https://github.com/GetStream/stream-chat-js/compare/v9.13.0...v9.14.0) (2025-08-01)
2+
3+
### Bug Fixes
4+
5+
* consider locally filtered messages in message pagination params calculation ([#1595](https://github.com/GetStream/stream-chat-js/issues/1595)) ([b2c01e8](https://github.com/GetStream/stream-chat-js/commit/b2c01e8ee5937a14d4f7771f14bde9f42dd5472f))
6+
* keep pinned message data upon message editing ([#1590](https://github.com/GetStream/stream-chat-js/issues/1590)) ([3bcfc28](https://github.com/GetStream/stream-chat-js/commit/3bcfc283f77e819b2a11c7c8231bf0195da055fd))
7+
8+
### Features
9+
10+
* add attachment manager middleware for pre-upload and post-upload events ([#1588](https://github.com/GetStream/stream-chat-js/issues/1588)) ([a8b0497](https://github.com/GetStream/stream-chat-js/commit/a8b0497ad22e297b3cab7decb0e9ed0e3539f95f))
11+
12+
### Chores
13+
14+
* **deps:** upgrade form-data to version 4.0.4 ([#1592](https://github.com/GetStream/stream-chat-js/issues/1592)) ([2dd6676](https://github.com/GetStream/stream-chat-js/commit/2dd6676e7bb69b881273f7f91dc512383a3d8e5b))
15+
116
## [9.13.0](https://github.com/GetStream/stream-chat-js/compare/v9.12.0...v9.13.0) (2025-07-30)
217

318
### Bug Fixes

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
"@types/ws": "^8.5.14",
5454
"axios": "^1.6.0",
5555
"base64-js": "^1.5.1",
56-
"form-data": "^4.0.0",
56+
"form-data": "^4.0.4",
5757
"isomorphic-ws": "^5.0.0",
5858
"jsonwebtoken": "^9.0.2",
5959
"linkifyjs": "^4.2.0",

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
};

src/messageComposer/messageComposer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -700,7 +700,7 @@ export class MessageComposer extends WithSubscriptions {
700700
id: this.id,
701701
mentioned_users: [],
702702
parent_id: this.threadId ?? undefined,
703-
pinned_at: null,
703+
pinned_at: this.editedMessage?.pinned_at || null,
704704
reaction_groups: null,
705705
status: this.editedMessage ? this.editedMessage.status : 'sending',
706706
text,
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';

0 commit comments

Comments
 (0)