stream-chat 9.51.0 → 9.52.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/cjs/index.browser.js +418 -215
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +427 -216
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +416 -213
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/channel.d.ts +5 -3
  8. package/dist/types/client.d.ts +10 -0
  9. package/dist/types/index.d.ts +7 -0
  10. package/dist/types/messageComposer/attachmentIdentity.d.ts +4 -0
  11. package/dist/types/messageComposer/attachmentManager.d.ts +8 -0
  12. package/dist/types/messageComposer/messageComposer.d.ts +13 -0
  13. package/dist/types/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.d.ts +20 -1
  14. package/dist/types/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.d.ts +6 -1
  15. package/dist/types/messageComposer/middleware/messageComposer/attachments.d.ts +24 -0
  16. package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +22 -1
  17. package/dist/types/messageComposer/types.d.ts +5 -0
  18. package/dist/types/middleware.d.ts +8 -0
  19. package/dist/types/uploadManager.d.ts +30 -0
  20. package/dist/types/utils.d.ts +29 -6
  21. package/package.json +1 -1
  22. package/src/channel.ts +42 -26
  23. package/src/client.ts +27 -14
  24. package/src/index.ts +12 -0
  25. package/src/messageComposer/attachmentIdentity.ts +27 -0
  26. package/src/messageComposer/attachmentManager.ts +61 -12
  27. package/src/messageComposer/messageComposer.ts +43 -8
  28. package/src/messageComposer/middleware/attachmentManager/postUpload/AttachmentPostUploadMiddlewareExecutor.ts +1 -1
  29. package/src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts +58 -33
  30. package/src/messageComposer/middleware/attachmentManager/postUpload/uploadErrorHandler.ts +4 -0
  31. package/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +9 -0
  32. package/src/messageComposer/middleware/messageComposer/attachments.ts +109 -0
  33. package/src/messageComposer/middleware/messageComposer/types.ts +22 -1
  34. package/src/messageComposer/types.ts +5 -0
  35. package/src/messageDelivery/MessageDeliveryReporter.ts +5 -4
  36. package/src/middleware.ts +11 -0
  37. package/src/offline-support/offline_support_api.ts +4 -0
  38. package/src/uploadManager.ts +45 -0
  39. package/src/utils.ts +102 -7
@@ -27,6 +27,7 @@ import type {
27
27
  FileLike,
28
28
  FileReference,
29
29
  LocalAttachment,
30
+ LocalAttachmentUploadMetadata,
30
31
  LocalNotImageAttachment,
31
32
  LocalUploadAttachment,
32
33
  UploadPermissionCheckResult,
@@ -34,6 +35,7 @@ import type {
34
35
  import type { ChannelResponse, DraftMessage, LocalMessage } from '../types';
35
36
  import type { MessageComposer } from './messageComposer';
36
37
  import { mergeWithDiff } from '../utils/mergeWith';
38
+ import { isUploadCancellation } from '../uploadManager';
37
39
 
38
40
  export type FileUploadFilter = (file: Partial<LocalUploadAttachment>) => boolean;
39
41
 
@@ -233,6 +235,7 @@ export class AttachmentManager {
233
235
  ...attachment,
234
236
  localMetadata: {
235
237
  ...attachment.localMetadata,
238
+ uploadConfirmationPending: false,
236
239
  uploadProgress: undefined,
237
240
  uploadState: 'failed',
238
241
  },
@@ -332,9 +335,28 @@ export class AttachmentManager {
332
335
  }
333
336
  };
334
337
 
338
+ /**
339
+ * Releases a local preview URL the SDK minted, if it minted one.
340
+ *
341
+ * Only `blob:` URLs are ours to revoke: `toLocalUploadAttachment` puts a
342
+ * `URL.createObjectURL` result in `previewUri` for a `File`, but for a React Native
343
+ * `FileReference` it stores the caller's own native file URI, which must be left alone.
344
+ */
345
+ private revokePreviewUri = (attachment: LocalAttachment) => {
346
+ const previewUri = (
347
+ attachment.localMetadata as Partial<LocalAttachmentUploadMetadata>
348
+ )?.previewUri;
349
+
350
+ if (previewUri?.startsWith('blob:')) URL.revokeObjectURL?.(previewUri);
351
+ };
352
+
335
353
  removeAttachments = (localAttachmentIds: string[]) => {
336
354
  if (!localAttachmentIds.length) return;
337
355
 
356
+ const removedAttachments = this.attachments.filter((attachment) =>
357
+ localAttachmentIds.includes(attachment.localMetadata?.id),
358
+ );
359
+
338
360
  this.state.partialNext({
339
361
  attachments: this.attachments.filter(
340
362
  (attachment) => !localAttachmentIds.includes(attachment.localMetadata?.id),
@@ -344,6 +366,18 @@ export class AttachmentManager {
344
366
  for (const id of localAttachmentIds) {
345
367
  this.client.uploadManager.deleteUploadRecord(id);
346
368
  }
369
+
370
+ // Removal is the end of the line for these attachments, so the preview blob goes with
371
+ // them - otherwise every removed (or cancelled mid-upload) attachment leaks one blob URL
372
+ // until the page unloads. Revoked after the record is deleted, so nothing is still
373
+ // rendering from it.
374
+ //
375
+ // Deliberately not done in `initState`/`restoreSnapshot`: those drop the composer's *view*
376
+ // of the attachments without ending their life. A finished upload has already had its
377
+ // `previewUri` revoked and removed by the post-upload middleware, and an unfinished one may
378
+ // still be rendered by whoever took it over (an optimistic message sent with the upload
379
+ // still in flight, for instance).
380
+ removedAttachments.forEach(this.revokePreviewUri);
347
381
  };
348
382
 
349
383
  getUploadConfigCheck = async (
@@ -619,21 +653,30 @@ export class AttachmentManager {
619
653
  ...attachment.localMetadata,
620
654
  uploadState: 'failed',
621
655
  uploadProgress: undefined,
656
+ // `false`, not `undefined`: these updates go through `updateAttachment`, which merges
657
+ // via `mergeWith` — and that skips `undefined` source values, so `undefined` would
658
+ // leave a stale `true` in place.
659
+ uploadConfirmationPending: false,
622
660
  },
623
661
  };
624
662
 
625
- this.client.notifications.addError({
626
- message: 'Error uploading attachment',
627
- origin: {
628
- emitter: 'AttachmentManager',
629
- context: { attachment, failedAttachment },
630
- },
631
- options: {
632
- type: 'api:attachment:upload:failed',
633
- metadata: { reason },
634
- originalError: error instanceof Error ? error : undefined,
635
- },
636
- });
663
+ // A cancellation is the user getting what they asked for, so it gets no error
664
+ // notification. This path predates the post-upload middleware chain and notifies
665
+ // directly, so it needs the same guard `createUploadErrorHandlerMiddleware` applies.
666
+ if (!isUploadCancellation(error)) {
667
+ this.client.notifications.addError({
668
+ message: 'Error uploading attachment',
669
+ origin: {
670
+ emitter: 'AttachmentManager',
671
+ context: { attachment, failedAttachment },
672
+ },
673
+ options: {
674
+ type: 'api:attachment:upload:failed',
675
+ metadata: { reason },
676
+ originalError: error instanceof Error ? error : undefined,
677
+ },
678
+ });
679
+ }
637
680
 
638
681
  this.updateAttachment(failedAttachment);
639
682
  return failedAttachment;
@@ -654,6 +697,8 @@ export class AttachmentManager {
654
697
  ...attachment.localMetadata,
655
698
  uploadState: 'finished',
656
699
  uploadProgress: undefined,
700
+ // See the note in the failure branch: `undefined` cannot clear through `mergeWith`.
701
+ uploadConfirmationPending: false,
657
702
  },
658
703
  };
659
704
 
@@ -715,6 +760,8 @@ export class AttachmentManager {
715
760
  ...attachment.localMetadata,
716
761
  uploadState: error ? 'failed' : 'finished',
717
762
  uploadProgress: undefined,
763
+ // See the note in `uploadAttachment`: `undefined` cannot clear through `mergeWith`.
764
+ uploadConfirmationPending: false,
718
765
  },
719
766
  },
720
767
  error,
@@ -759,6 +806,7 @@ export class AttachmentManager {
759
806
  ...attachment.localMetadata,
760
807
  uploadState: 'uploading',
761
808
  uploadProgress: this.config.trackUploadProgress ? 0 : undefined,
809
+ uploadConfirmationPending: false,
762
810
  },
763
811
  },
764
812
  ]);
@@ -773,6 +821,7 @@ export class AttachmentManager {
773
821
  ...attachment.localMetadata,
774
822
  uploadState: 'uploading',
775
823
  uploadProgress: nextUpload.uploadProgress,
824
+ uploadConfirmationPending: nextUpload.uploadConfirmationPending,
776
825
  },
777
826
  });
778
827
  },
@@ -1,4 +1,5 @@
1
1
  import { AttachmentManager } from './attachmentManager';
2
+ import { isFinishedUpload, isPendingUpload } from './attachmentIdentity';
2
3
  import { CustomDataManager } from './CustomDataManager';
3
4
  import { LinkPreviewsManager } from './linkPreviewsManager';
4
5
  import { LocationComposer } from './LocationComposer';
@@ -432,16 +433,50 @@ export class MessageComposer extends WithSubscriptions {
432
433
  return !currentCommand || this.validateCommandSendability(currentCommand).ready;
433
434
  }
434
435
 
436
+ /**
437
+ * Whether the installed composition middleware composes messages whose uploads are still in
438
+ * flight — see `MessageCompositionMiddleware.allowsPendingUploads`.
439
+ *
440
+ * Derived from the chain rather than from config, so there is exactly one switch: the UI SDK
441
+ * that implements the rest of the flow installs the middleware, and everything that has to
442
+ * follow from that (starting with {@link hasSendableData}) follows on its own.
443
+ *
444
+ * **Temporary API.** In v10 sending with pending uploads becomes part of the composer
445
+ * configuration (`MessageComposerConfig`, set through `updateConfig`), and this getter,
446
+ * together with the middleware declaration it reads, is replaced by that config field.
447
+ */
448
+ get allowsPendingUploads() {
449
+ return this.compositionMiddlewareExecutor.installedMiddleware.some(
450
+ (middleware) => middleware.allowsPendingUploads,
451
+ );
452
+ }
453
+
435
454
  get hasSendableData() {
436
- return (
437
- this.isCommandSendable &&
438
- !!(
439
- (!this.attachmentManager.uploadsInProgressCount &&
440
- (!this.textComposer.textIsEmpty ||
441
- this.attachmentManager.successfulUploadsCount > 0)) ||
455
+ if (!this.isCommandSendable) return false;
456
+
457
+ if (this.allowsPendingUploads) {
458
+ // An upload in flight is no longer a blocker, and a pending attachment counts as content
459
+ // in its own right — otherwise an attachment-only message could never be sent before its
460
+ // upload finished, which is the point of the flow. `failed` and `blocked` attachments
461
+ // still do not count: a message whose only attachment was rejected must not look sendable.
462
+ const hasSendableAttachment = this.attachmentManager.attachments.some(
463
+ (attachment) => isFinishedUpload(attachment) || isPendingUpload(attachment),
464
+ );
465
+
466
+ return !!(
467
+ !this.textComposer.textIsEmpty ||
468
+ hasSendableAttachment ||
442
469
  this.pollId ||
443
- !!this.locationComposer.validLocation
444
- )
470
+ this.locationComposer.validLocation
471
+ );
472
+ }
473
+
474
+ return !!(
475
+ (!this.attachmentManager.uploadsInProgressCount &&
476
+ (!this.textComposer.textIsEmpty ||
477
+ this.attachmentManager.successfulUploadsCount > 0)) ||
478
+ this.pollId ||
479
+ !!this.locationComposer.validLocation
445
480
  );
446
481
  }
447
482
 
@@ -14,7 +14,7 @@ export class AttachmentPostUploadMiddlewareExecutor extends MiddlewareExecutor<
14
14
  super();
15
15
  this.use([
16
16
  createUploadErrorHandlerMiddleware(composer),
17
- createPostUploadAttachmentEnrichmentMiddleware(),
17
+ createPostUploadAttachmentEnrichmentMiddleware(composer),
18
18
  ]);
19
19
  }
20
20
  }
@@ -4,41 +4,66 @@ import type {
4
4
  AttachmentPostUploadMiddlewareState,
5
5
  } from '../types';
6
6
  import { isLocalImageAttachment } from '../../../attachmentIdentity';
7
+ import type { MessageComposer } from '../../../messageComposer';
7
8
  import type { LocalNotImageAttachment } from '../../../types';
8
9
 
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();
10
+ /**
11
+ * Writes the uploaded URL onto the attachment and releases its local preview.
12
+ *
13
+ * The preview is a blob URL the composer created for the file. Releasing it once the CDN URL is
14
+ * in is what keeps the browser from holding the file in memory.
15
+ *
16
+ * Pass `composer` and the preview is released only while the attachment is still one of the
17
+ * composer's own — still listed in `attachmentManager.attachments`, i.e. still shown above the
18
+ * input, waiting to be sent.
19
+ *
20
+ * This runs when an upload finishes, which can be after the composer was cleared: `uploadFile`
21
+ * executes this chain before the (by then no-op) `updateAttachment`. If the attachment is gone
22
+ * from the composer, a message is rendering from the preview instead, and releasing it would
23
+ * blank that message out. A message can be sent with uploads still running — see
24
+ * `createSendWithPendingUploadsAttachmentsMiddleware`.
25
+ *
26
+ * Omit `composer` and the preview is always released.
27
+ */
28
+ export const createPostUploadAttachmentEnrichmentMiddleware = (
29
+ composer?: MessageComposer,
30
+ ): AttachmentPostUploadMiddleware => ({
31
+ id: 'stream-io/attachment-manager-middleware/post-upload-enrichment',
32
+ handlers: {
33
+ postProcess: ({
34
+ state,
35
+ discard,
36
+ forward,
37
+ next,
38
+ }: MiddlewareHandlerParams<AttachmentPostUploadMiddlewareState>) => {
39
+ const { attachment, error, response } = state;
40
+ if (error) return forward();
41
+ if (!attachment || !response) return discard();
22
42
 
23
- const enrichedAttachment = { ...attachment };
24
- const previewUri = attachment.localMetadata.previewUri;
25
- if (previewUri) {
26
- if (previewUri.startsWith('blob:')) URL.revokeObjectURL(previewUri);
27
- delete enrichedAttachment.localMetadata.previewUri;
28
- }
29
- if (isLocalImageAttachment(attachment)) {
30
- enrichedAttachment.image_url = response.file;
31
- } else {
32
- (enrichedAttachment as LocalNotImageAttachment).asset_url = response.file;
33
- }
34
- if (response.thumb_url) {
35
- (enrichedAttachment as LocalNotImageAttachment).thumb_url = response.thumb_url;
36
- }
43
+ const enrichedAttachment = { ...attachment };
44
+ const { id, previewUri } = attachment.localMetadata;
45
+ // `attachmentsById` is built from the composer's current attachment state, so a hit means
46
+ // the attachment is still in the composer and its preview is not in use elsewhere.
47
+ const attachmentIsStillInComposer =
48
+ !composer || !!composer.attachmentManager.attachmentsById[id];
37
49
 
38
- return next({
39
- ...state,
40
- attachment: enrichedAttachment,
41
- });
42
- },
50
+ if (previewUri && attachmentIsStillInComposer) {
51
+ if (previewUri.startsWith('blob:')) URL.revokeObjectURL?.(previewUri);
52
+ delete enrichedAttachment.localMetadata.previewUri;
53
+ }
54
+ if (isLocalImageAttachment(attachment)) {
55
+ enrichedAttachment.image_url = response.file;
56
+ } else {
57
+ (enrichedAttachment as LocalNotImageAttachment).asset_url = response.file;
58
+ }
59
+ if (response.thumb_url) {
60
+ (enrichedAttachment as LocalNotImageAttachment).thumb_url = response.thumb_url;
61
+ }
62
+
63
+ return next({
64
+ ...state,
65
+ attachment: enrichedAttachment,
66
+ });
43
67
  },
44
- });
68
+ },
69
+ });
@@ -4,6 +4,7 @@ import type {
4
4
  AttachmentPostUploadMiddleware,
5
5
  AttachmentPostUploadMiddlewareState,
6
6
  } from '../types';
7
+ import { isUploadCancellation } from '../../../../uploadManager';
7
8
 
8
9
  export const createUploadErrorHandlerMiddleware = (
9
10
  composer: MessageComposer,
@@ -18,6 +19,9 @@ export const createUploadErrorHandlerMiddleware = (
18
19
  const { attachment, error } = state;
19
20
  if (!error) return forward();
20
21
  if (!attachment) return discard();
22
+ // A cancellation is the user getting what they asked for, so it gets no error
23
+ // notification. `StreamChat.doAxiosRequest` draws the same line for the same reason.
24
+ if (isUploadCancellation(error)) return forward();
21
25
 
22
26
  const reason = error instanceof Error ? error.message : 'unknown error';
23
27
  composer.client.notifications.addError({
@@ -23,6 +23,7 @@ import { createCompositionDataCleanupMiddleware } from './cleanData';
23
23
  import type {
24
24
  MessageComposerMiddlewareExecutorOptions,
25
25
  MessageComposerMiddlewareState,
26
+ MessageCompositionMiddleware,
26
27
  MessageDraftComposerMiddlewareExecutorOptions,
27
28
  MessageDraftComposerMiddlewareValueState,
28
29
  } from './types';
@@ -55,6 +56,14 @@ export class MessageComposerMiddlewareExecutor extends MiddlewareExecutor<
55
56
  createCompositionDataCleanupMiddleware(composer),
56
57
  ]);
57
58
  }
59
+
60
+ /**
61
+ * Narrows the base accessor to the composition middleware type, so callers can read the
62
+ * declarations it carries (`allowsPendingUploads`) without casting.
63
+ */
64
+ get installedMiddleware(): ReadonlyArray<MessageCompositionMiddleware> {
65
+ return super.installedMiddleware as ReadonlyArray<MessageCompositionMiddleware>;
66
+ }
58
67
  }
59
68
 
60
69
  export class MessageDraftComposerMiddlewareExecutor extends MiddlewareExecutor<
@@ -1,3 +1,4 @@
1
+ import { isFinishedUpload, isPendingUpload } from '../../attachmentIdentity';
1
2
  import type { MiddlewareHandlerParams } from '../../../middleware';
2
3
  import type { Attachment } from '../../../types';
3
4
  import type { MessageComposer } from '../../messageComposer';
@@ -15,6 +16,114 @@ const localAttachmentToAttachment = (localAttachment: LocalAttachment) => {
15
16
  return attachment as Attachment;
16
17
  };
17
18
 
19
+ /**
20
+ * The composition step used by {@link createSendWithPendingUploadsAttachmentsMiddleware}.
21
+ *
22
+ * The two payloads part ways here: `localMessage.attachments` keeps `localMetadata` for
23
+ * anything still uploading - `id` (the `client.uploadManager` key), `file` (the handle needed
24
+ * to await the upload) and `previewUri` (so the message list can render the user's own file
25
+ * meanwhile) - while `message.attachments`, which goes to the API, carries only attachments
26
+ * that already resolved to a URL. Whoever performs the send fills in the rest once the uploads
27
+ * settle.
28
+ */
29
+ const composeWithPendingUploads = ({
30
+ composer,
31
+ state,
32
+ }: {
33
+ composer: MessageComposer;
34
+ state: MessageComposerMiddlewareState;
35
+ }): MessageComposerMiddlewareState => {
36
+ // `useSubmitHandler` in the UI SDKs deliberately skips `MessageComposer.clear()` when the
37
+ // composition carries a poll - it keeps the composer's contents as a draft. Handing a
38
+ // still-uploading attachment to such a message would leave the same `localMetadata.id` owned
39
+ // by both the sent message and the composer: the user could send it a second time, and the
40
+ // second `UploadManager.upload` call would restart the request under an id whose in-flight
41
+ // entry had already been cleaned up. So a pending upload stays in the composer in that case
42
+ // and rides along with the next message once it finishes.
43
+ const composerIsKeptAsDraft = !!composer.pollId;
44
+
45
+ // Composer order is preserved in both payloads so previews do not reshuffle when an upload
46
+ // settles.
47
+ const relevantAttachments = composer.attachmentManager.attachments.filter(
48
+ (attachment) =>
49
+ isFinishedUpload(attachment) ||
50
+ (!composerIsKeptAsDraft && isPendingUpload(attachment)),
51
+ );
52
+
53
+ const localAttachments = (state.localMessage.attachments ?? []).concat(
54
+ relevantAttachments.map((attachment) =>
55
+ isPendingUpload(attachment) ? attachment : localAttachmentToAttachment(attachment),
56
+ ),
57
+ );
58
+ const messageAttachments = (state.message.attachments ?? []).concat(
59
+ relevantAttachments.filter(isFinishedUpload).map(localAttachmentToAttachment),
60
+ );
61
+
62
+ if (!localAttachments.length && !messageAttachments.length) return state;
63
+
64
+ // Each payload gets the `attachments` key only when there is something to put in it, matching
65
+ // the default middleware. An empty array is not "nothing to say": on an edit the API reads it
66
+ // as "remove every attachment", and a message whose uploads are all still in flight produces
67
+ // exactly that - no finished upload to put in `message.attachments` yet.
68
+ return {
69
+ ...state,
70
+ localMessage: localAttachments.length
71
+ ? {
72
+ ...state.localMessage,
73
+ attachments: localAttachments,
74
+ }
75
+ : state.localMessage,
76
+ message: messageAttachments.length
77
+ ? {
78
+ ...state.message,
79
+ attachments: messageAttachments,
80
+ }
81
+ : state.message,
82
+ };
83
+ };
84
+
85
+ /**
86
+ * Drop-in replacement for {@link createAttachmentsCompositionMiddleware} that lets a message be
87
+ * composed while its attachments are still uploading.
88
+ *
89
+ * Reuses the same middleware id, so installing it with
90
+ * `compositionMiddlewareExecutor.replace([...])` keeps its position in the chain. The default
91
+ * refuses instead: it warns "Wait until all attachments have uploaded" and discards the
92
+ * composition.
93
+ *
94
+ * **Installing this is only half of the flow.** The composition it produces is not ready for the
95
+ * wire — `message.attachments` omits everything that has no URL yet. Whoever performs the send
96
+ * has to await those uploads (`UploadManager.upload` is idempotent by `localMetadata.id`, so
97
+ * calling it again returns the in-flight promise), write the resolved URLs in, and send after
98
+ * that.
99
+ *
100
+ * Sendability follows on its own: the `allowsPendingUploads` declaration below is what
101
+ * {@link MessageComposer.hasSendableData} reads to stop treating an upload in flight as a
102
+ * blocker, so installing this middleware is the only switch there is.
103
+ *
104
+ * This is why there is no config option turning it on: the switch belongs to the UI SDK that
105
+ * implements the other half. stream-chat-react exposes it as a `Chat` prop;
106
+ * stream-chat-react-native as `allowSendBeforeAttachmentsUpload` on the message input.
107
+ */
108
+ export const createSendWithPendingUploadsAttachmentsMiddleware = (
109
+ composer: MessageComposer,
110
+ ): MessageCompositionMiddleware => ({
111
+ allowsPendingUploads: true,
112
+ id: 'stream-io/message-composer-middleware/attachments',
113
+ handlers: {
114
+ compose: ({
115
+ state,
116
+ next,
117
+ forward,
118
+ }: MiddlewareHandlerParams<MessageComposerMiddlewareState>) => {
119
+ const { attachmentManager } = composer;
120
+ if (!attachmentManager) return forward();
121
+
122
+ return next(composeWithPendingUploads({ composer, state }));
123
+ },
124
+ },
125
+ });
126
+
18
127
  export const createAttachmentsCompositionMiddleware = (
19
128
  composer: MessageComposer,
20
129
  ): MessageCompositionMiddleware => ({
@@ -32,7 +32,28 @@ export type MessageDraftComposerMiddlewareExecutorOptions = {
32
32
  export type MessageCompositionMiddleware = Middleware<
33
33
  MessageComposerMiddlewareState,
34
34
  'compose'
35
- >;
35
+ > & {
36
+ /**
37
+ * Declares that this middleware composes messages whose attachment uploads are still in
38
+ * flight, instead of refusing to compose until they finish.
39
+ *
40
+ * {@link MessageComposer.hasSendableData} reads this: an upload in flight must not block the
41
+ * send while such a middleware is installed, or the send could never be triggered. Any
42
+ * middleware implementing that contract may set it - the composer keys off the declaration,
43
+ * not off a middleware id, so a custom implementation is treated the same as the one shipped
44
+ * here.
45
+ *
46
+ * Composing this way is only half of the flow: whoever performs the send has to await those
47
+ * uploads and write the resolved URLs into the payload. See
48
+ * {@link createSendWithPendingUploadsAttachmentsMiddleware}.
49
+ *
50
+ * **Temporary API.** In v10 sending with pending uploads becomes part of the composer
51
+ * configuration (`MessageComposerConfig`, set through `updateConfig`), and this declaration,
52
+ * together with {@link MessageComposer.allowsPendingUploads}, is replaced by that config
53
+ * field.
54
+ */
55
+ allowsPendingUploads?: boolean;
56
+ };
36
57
 
37
58
  export type MessageDraftCompositionMiddleware = Middleware<
38
59
  MessageDraftComposerMiddlewareValueState,
@@ -119,6 +119,11 @@ export type LocalAttachmentUploadMetadata = {
119
119
  */
120
120
  previewUri?: string;
121
121
  uploadState: AttachmentLoadingState;
122
+ /**
123
+ * Mirrors {@link UploadRecord.uploadConfirmationPending}: every byte has been sent, but the server has
124
+ * not confirmed yet - not returned response to the upload request. Render an indeterminate indicator rather than a full bar.
125
+ */
126
+ uploadConfirmationPending?: boolean;
122
127
  uploadPermissionCheck?: UploadPermissionCheckResult; // added new
123
128
  /** 0–100 while uploading when progress tracking is enabled; undefined otherwise or when indeterminate */
124
129
  uploadProgress?: number;
@@ -10,7 +10,7 @@ import type {
10
10
  MarkReadOptions,
11
11
  } from '../types';
12
12
  import { type APIErrorResponse } from '../types';
13
- import { throttle, userHasReadReceipts } from '../utils';
13
+ import { throttle } from '../utils';
14
14
  import { isAPIError, isErrorRetryable } from '../errors';
15
15
 
16
16
  const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const;
@@ -279,8 +279,6 @@ export class MessageDeliveryReporter {
279
279
  * @param options
280
280
  */
281
281
  public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => {
282
- if (!userHasReadReceipts(this.client)) return null;
283
-
284
282
  let result: EventAPIResponse | null = null;
285
283
  if (isChannel(collection)) {
286
284
  result = await collection.markAsReadRequest(options);
@@ -291,7 +289,10 @@ export class MessageDeliveryReporter {
291
289
  });
292
290
  }
293
291
 
294
- this.removeCandidateFor(collection);
292
+ // A read implies delivery, so the candidate is only dropped once the request actually went
293
+ // out. `markAsReadRequest` returns null for channels that do not track read state on the
294
+ // backend, and those candidates must survive for the next delivery report.
295
+ if (result) this.removeCandidateFor(collection);
295
296
  return result;
296
297
  };
297
298
 
package/src/middleware.ts CHANGED
@@ -58,6 +58,17 @@ export class MiddlewareExecutor<TValue, THandlers extends string> {
58
58
  this.id = generateUUIDv4();
59
59
  }
60
60
 
61
+ /**
62
+ * The middleware currently installed, in execution order.
63
+ *
64
+ * Read-only: use `use` / `insert` / `replace` / `setOrder` / `remove` to change the chain. It
65
+ * exists so components can ask what the chain is capable of - a middleware may declare
66
+ * behaviour its host has to account for, and the host has no other way to find out.
67
+ */
68
+ get installedMiddleware(): ReadonlyArray<Middleware<TValue, THandlers>> {
69
+ return this.middleware;
70
+ }
71
+
61
72
  use(middleware: Middleware<TValue, THandlers> | Middleware<TValue, THandlers>[]) {
62
73
  this.middleware = this.middleware.concat(middleware);
63
74
  return this;
@@ -1050,6 +1050,10 @@ export abstract class AbstractOfflineDB implements OfflineDBApi {
1050
1050
  }
1051
1051
 
1052
1052
  if (type === 'message.read' || type === 'notification.mark_read') {
1053
+ // We make sure not to update channel reads (which is what's stored in
1054
+ // the offline DB in any case) whenever we receive a read event for a
1055
+ // a thread specifically.
1056
+ if (event.thread) return [];
1053
1057
  return this.handleRead({ event, unreadMessages: 0, execute });
1054
1058
  }
1055
1059
 
@@ -1,10 +1,44 @@
1
+ import axios from 'axios';
2
+
1
3
  import type { StreamChat } from './client';
2
4
  import type { UploadRequestOptions } from './messageComposer/configuration/types';
3
5
  import { StateStore } from './store';
4
6
  import type { AttachmentManager } from '.';
5
7
 
8
+ /**
9
+ * Whether an upload ended because it was aborted rather than because it failed.
10
+ *
11
+ * {@link UploadManager.deleteUploadRecord} and {@link UploadManager.cancelAllUploads} abort the
12
+ * request through its `AbortController`, which is what happens when an attachment is removed
13
+ * from the composer mid-upload or the client disconnects. Axios reports that as a
14
+ * `CanceledError` (`axios.isCancel`); a custom `doUploadRequest` on another transport
15
+ * conventionally throws a `DOMException` named `AbortError`.
16
+ *
17
+ * Callers use this to keep a deliberate cancellation from being reported as an error.
18
+ */
19
+ export const isUploadCancellation = (error: unknown) =>
20
+ axios.isCancel(error) || (error instanceof Error && error.name === 'AbortError');
21
+
6
22
  export type UploadRecord = {
7
23
  id: string;
24
+ /**
25
+ * `true` once every byte has been handed to the transport but the server has not responded
26
+ * yet.
27
+ *
28
+ * Upload progress measures bytes *written to the connection*, not bytes the server
29
+ * acknowledged, so it reaches 100% the moment the request body is flushed — then the
30
+ * connection sits idle while the CDN ingests the file and the response travels back. On a
31
+ * large file over a slow link that window is long, and a UI that renders 100% as "done"
32
+ * claims the upload is confirmed while it is not.
33
+ *
34
+ * Nothing measurable happens during that window, so it is the point at which a determinate
35
+ * progress bar should hand over to an indeterminate one. Confirmation is the record being
36
+ * removed, not progress reaching 100.
37
+ *
38
+ * Requires `AttachmentManagerConfig.trackUploadProgress`; without progress reporting there is
39
+ * no way to tell when the flush happened, and this stays `false`.
40
+ */
41
+ uploadConfirmationPending?: boolean;
8
42
  uploadProgress?: number;
9
43
  };
10
44
 
@@ -126,6 +160,7 @@ export class UploadManager {
126
160
  const trackProgress = attachmentManager.config.trackUploadProgress;
127
161
  try {
128
162
  this.upsertUpload({
163
+ uploadConfirmationPending: false,
129
164
  id,
130
165
  uploadProgress: trackProgress ? 0 : undefined,
131
166
  });
@@ -135,6 +170,16 @@ export class UploadManager {
135
170
  this.updateUpload({
136
171
  id,
137
172
  uploadProgress: progress,
173
+ // Only a number says anything about the flush, so a report without one leaves
174
+ // the flag alone: `undefined` means the transport cannot measure this upload,
175
+ // not that bytes were un-sent. Lowering it there would drop a UI that had
176
+ // already gone indeterminate back to a determinate one with nothing to show.
177
+ //
178
+ // The record is removed in the `finally` below, so a record that still exists
179
+ // while reporting 100% can only mean "flushed, awaiting the response".
180
+ ...(typeof progress === 'number'
181
+ ? { uploadConfirmationPending: progress >= 100 }
182
+ : {}),
138
183
  });
139
184
  }
140
185
  : undefined;