stream-chat 10.0.0-rc.12 → 10.0.0-rc.13

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 (70) hide show
  1. package/dist/cjs/index.browser.js +775 -638
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +780 -639
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +775 -638
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/channel.d.ts +39 -76
  8. package/dist/types/entityStore/EntityStore.d.ts +7 -0
  9. package/dist/types/entityStore/index.d.ts +0 -1
  10. package/dist/types/formatMessageMemo.d.ts +5 -0
  11. package/dist/types/messageComposer/attachmentIdentity.d.ts +7 -0
  12. package/dist/types/messageComposer/attachmentUtils.d.ts +34 -0
  13. package/dist/types/messageComposer/configuration/types.d.ts +12 -0
  14. package/dist/types/messageComposer/index.d.ts +1 -0
  15. package/dist/types/messageComposer/linkPreviewsManager.d.ts +3 -3
  16. package/dist/types/messageComposer/messageComposer.d.ts +60 -16
  17. package/dist/types/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.d.ts +1 -1
  18. package/dist/types/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.d.ts +1 -6
  19. package/dist/types/messageComposer/middleware/messageComposer/attachments.d.ts +7 -20
  20. package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +1 -22
  21. package/dist/types/messageDelivery/MessageReceiptsTracker.d.ts +34 -0
  22. package/dist/types/messageOperations/MessageOperations.d.ts +35 -4
  23. package/dist/types/messageOperations/WithMessageOperations.d.ts +35 -0
  24. package/dist/types/messageOperations/createMessageOperations.d.ts +11 -0
  25. package/dist/types/messageOperations/index.d.ts +6 -0
  26. package/dist/types/messageOperations/reflectReactionEvent.d.ts +24 -0
  27. package/dist/types/messageOperations/sendOrdering.d.ts +17 -0
  28. package/dist/types/messageOperations/settlePendingAttachmentUploads.d.ts +28 -0
  29. package/dist/types/messageOperations/types.d.ts +34 -0
  30. package/dist/types/notifications/types.d.ts +4 -0
  31. package/dist/types/pagination/paginators/BasePaginator.d.ts +22 -0
  32. package/dist/types/pagination/paginators/MessageIntervalPaginator.d.ts +5 -43
  33. package/dist/types/thread.d.ts +22 -42
  34. package/dist/types/uploadManager.d.ts +19 -1
  35. package/dist/types/utils.d.ts +2 -4
  36. package/package.json +1 -1
  37. package/src/channel.ts +60 -296
  38. package/src/configuration/shape.ts +12 -0
  39. package/src/entityStore/EntityStore.ts +22 -1
  40. package/src/entityStore/index.ts +0 -1
  41. package/src/formatMessageMemo.ts +23 -0
  42. package/src/messageComposer/attachmentIdentity.ts +12 -0
  43. package/src/messageComposer/attachmentUtils.ts +58 -0
  44. package/src/messageComposer/configuration/configuration.ts +2 -0
  45. package/src/messageComposer/configuration/types.ts +12 -0
  46. package/src/messageComposer/index.ts +1 -0
  47. package/src/messageComposer/messageComposer.ts +173 -27
  48. package/src/messageComposer/middleware/attachmentManager/postUpload/attachmentEnrichment.ts +1 -1
  49. package/src/messageComposer/middleware/messageComposer/MessageComposerMiddlewareExecutor.ts +0 -9
  50. package/src/messageComposer/middleware/messageComposer/attachments.ts +43 -56
  51. package/src/messageComposer/middleware/messageComposer/linkPreviews.ts +20 -10
  52. package/src/messageComposer/middleware/messageComposer/types.ts +1 -22
  53. package/src/messageDelivery/MessageReceiptsTracker.ts +66 -0
  54. package/src/messageOperations/MessageOperations.ts +140 -15
  55. package/src/messageOperations/WithMessageOperations.ts +60 -0
  56. package/src/messageOperations/createMessageOperations.ts +139 -0
  57. package/src/messageOperations/index.ts +6 -0
  58. package/src/messageOperations/optimistic.ts +1 -1
  59. package/src/messageOperations/reflectReactionEvent.ts +51 -0
  60. package/src/messageOperations/sendOrdering.ts +28 -0
  61. package/src/messageOperations/settlePendingAttachmentUploads.ts +110 -0
  62. package/src/messageOperations/types.ts +38 -0
  63. package/src/notifications/types.ts +4 -0
  64. package/src/pagination/paginators/BasePaginator.ts +73 -23
  65. package/src/pagination/paginators/MessageIntervalPaginator.ts +39 -97
  66. package/src/thread.ts +15 -240
  67. package/src/uploadManager.ts +37 -5
  68. package/src/utils.ts +12 -5
  69. /package/dist/types/{entityStore → messageOperations}/applyReactionLocally.d.ts +0 -0
  70. /package/src/{entityStore → messageOperations}/applyReactionLocally.ts +0 -0
@@ -167,7 +167,6 @@ __export(src_exports, {
167
167
  createPollComposerStateMiddleware: () => createPollComposerStateMiddleware,
168
168
  createPostUploadAttachmentEnrichmentMiddleware: () => createPostUploadAttachmentEnrichmentMiddleware,
169
169
  createPriorityOwnershipResolver: () => createPriorityOwnershipResolver,
170
- createSendWithPendingUploadsAttachmentsMiddleware: () => createSendWithPendingUploadsAttachmentsMiddleware,
171
170
  createSharedLocationCompositionMiddleware: () => createSharedLocationCompositionMiddleware,
172
171
  createTextComposerCompositionMiddleware: () => createTextComposerCompositionMiddleware,
173
172
  createTextComposerPreValidationMiddleware: () => createTextComposerPreValidationMiddleware,
@@ -186,6 +185,7 @@ __export(src_exports, {
186
185
  formatMessage: () => formatMessage,
187
186
  generateFileName: () => generateFileName,
188
187
  getAllowedMentionTypesFromCapabilities: () => getAllowedMentionTypesFromCapabilities,
188
+ getAttachmentPreviewUrl: () => getAttachmentPreviewUrl,
189
189
  getAttachmentTypeFromMimeType: () => getAttachmentTypeFromMimeType,
190
190
  getCommandByName: () => getCommandByName,
191
191
  getCompleteCommandInString: () => getCompleteCommandInString,
@@ -200,6 +200,7 @@ __export(src_exports, {
200
200
  insertItemWithTrigger: () => insertItemWithTrigger,
201
201
  isAudioAttachment: () => isAudioAttachment,
202
202
  isBlobButNotFile: () => isBlobButNotFile,
203
+ isFailedUpload: () => isFailedUpload,
203
204
  isFile: () => isFile,
204
205
  isFileAttachment: () => isFileAttachment,
205
206
  isFileList: () => isFileList,
@@ -225,6 +226,7 @@ __export(src_exports, {
225
226
  isSharedLocationResponseData: () => isSharedLocationResponseData,
226
227
  isTargetedOptionTextUpdate: () => isTargetedOptionTextUpdate,
227
228
  isUploadCancellation: () => isUploadCancellation,
229
+ isUploadConfirmationPending: () => isUploadConfirmationPending,
228
230
  isUploadedAttachment: () => isUploadedAttachment,
229
231
  isVideoAttachment: () => isVideoAttachment,
230
232
  isVoiceRecordingAttachment: () => isVoiceRecordingAttachment,
@@ -247,6 +249,8 @@ __export(src_exports, {
247
249
  readFileAsArrayBuffer: () => readFileAsArrayBuffer,
248
250
  removeDiacritics: () => removeDiacritics,
249
251
  replaceWordWithEntity: () => replaceWordWithEntity,
252
+ resolveAttachmentFileSize: () => resolveAttachmentFileSize,
253
+ resolveAttachmentFullByteSize: () => resolveAttachmentFullByteSize,
250
254
  settled: () => settled,
251
255
  stripCommandFromText: () => stripCommandFromText,
252
256
  stripMentionTokens: () => stripMentionTokens,
@@ -389,6 +393,13 @@ var LOCAL_MESSAGE_FIELDS = Object.freeze({ error: true });
389
393
  var DEFAULT_QUERY_CHANNELS_RETRY_COUNT = 3;
390
394
  var DEFAULT_QUERY_CHANNELS_MS_BETWEEN_RETRIES = 1e3;
391
395
 
396
+ // src/formatMessageMemo.ts
397
+ var memo = /* @__PURE__ */ new WeakMap();
398
+ var getMemoizedFormat = (source) => memo.get(source);
399
+ var memoizeFormat = (source, formatted) => {
400
+ memo.set(source, formatted);
401
+ };
402
+
392
403
  // src/logger.ts
393
404
  var scopedLogger = __toESM(require("@stream-io/logger"));
394
405
  var import_logger = require("@stream-io/logger");
@@ -594,6 +605,8 @@ var axiosParamsSerializer = (params) => {
594
605
  return newParams.join("&");
595
606
  };
596
607
  function formatMessage(message) {
608
+ const memoized = getMemoizedFormat(message);
609
+ if (memoized) return memoized;
597
610
  const toLocalMessageBase = (msg) => {
598
611
  if (!msg) return null;
599
612
  return {
@@ -613,11 +626,13 @@ function formatMessage(message) {
613
626
  updated_at: msg.updated_at ?? nowNs()
614
627
  };
615
628
  };
616
- return {
629
+ const formatted = {
617
630
  ...toLocalMessageBase(message),
618
631
  error: message.error ?? void 0,
619
632
  quoted_message: toLocalMessageBase(message.quoted_message) ?? void 0
620
633
  };
634
+ memoizeFormat(message, formatted);
635
+ return formatted;
621
636
  }
622
637
  function computeOwnReactions({
623
638
  current,
@@ -1488,7 +1503,12 @@ var EntityStore = class {
1488
1503
  return fn();
1489
1504
  } finally {
1490
1505
  this.transactionDepth -= 1;
1491
- if (this.transactionDepth === 0) this.flush();
1506
+ if (this.transactionDepth === 0) {
1507
+ this.flush();
1508
+ const ids = this.pendingFlushIds;
1509
+ this.pendingFlushIds = void 0;
1510
+ if (ids) for (const id of ids) this.flushSubscribersNow(id);
1511
+ }
1492
1512
  }
1493
1513
  }
1494
1514
  /**
@@ -1496,8 +1516,19 @@ var EntityStore = class {
1496
1516
  * {@link EntityStoreSubscriber.flushState}). Called after an optimistic (local-user) write to
1497
1517
  * `id` so it renders without the throttle delay. Only that id's own subscribers are flushed — the
1498
1518
  * write touched no other id — and flushing a subscriber with nothing pending is a no-op.
1519
+ *
1520
+ * Inside a transaction it is deferred to the outermost exit — inline there it would be a no-op,
1521
+ * since `markDirty` has not notified anyone yet. Getting this wrong costs the siblings (pinned
1522
+ * list, open thread) up to 500ms on an optimistic write, while the writer updates inline.
1499
1523
  */
1500
1524
  flushSubscribers(id) {
1525
+ if (this.transactionDepth > 0) {
1526
+ (this.pendingFlushIds ?? (this.pendingFlushIds = /* @__PURE__ */ new Set())).add(id);
1527
+ return;
1528
+ }
1529
+ this.flushSubscribersNow(id);
1530
+ }
1531
+ flushSubscribersNow(id) {
1501
1532
  const subscribers = this.subscribers.get(id);
1502
1533
  if (!subscribers) return;
1503
1534
  for (const subscriber of subscribers) subscriber.flushState?.();
@@ -1596,11 +1627,11 @@ var isClassInstance = (value) => {
1596
1627
  if (proto === null || proto === Object.prototype) return false;
1597
1628
  return value.constructor && value.constructor !== Object;
1598
1629
  };
1599
- function memoHasOrAdd(memo, a, b) {
1600
- const set = memo.get(a);
1630
+ function memoHasOrAdd(memo2, a, b) {
1631
+ const set = memo2.get(a);
1601
1632
  if (set && set.has(b)) return true;
1602
1633
  if (set) set.add(b);
1603
- else memo.set(a, new WeakSet([b]));
1634
+ else memo2.set(a, new WeakSet([b]));
1604
1635
  return false;
1605
1636
  }
1606
1637
  var isEqual = (value1, value2, pairMemo = /* @__PURE__ */ new WeakMap()) => {
@@ -2671,6 +2702,41 @@ var BasePaginator = class {
2671
2702
  scheduleWindowPublish() {
2672
2703
  this._windowPublishThrottle?.throttledFn();
2673
2704
  }
2705
+ /**
2706
+ * Run `fn` with this paginator's window publishes suspended, emitting once on exit.
2707
+ *
2708
+ * A replace is internally remove-then-insert and each half changes the window, so without this one
2709
+ * logical `ingestItem` emits twice.
2710
+ *
2711
+ * `exit` decides how the single emit leaves the scope: `auto` rides the throttle when there is one
2712
+ * (live ops, where successive events should coalesce across the trailing edge), `sync` always
2713
+ * flushes ({@link batch}, whose contract is that a one-shot operation settles in a single update).
2714
+ *
2715
+ * **`lockItemOrder` lists are excluded.** Their emit is not a re-projection but a republish of the
2716
+ * last-published array with the item spliced back at its original index, and exiting through
2717
+ * {@link flushWindowPublish} re-derives from the active interval — reordering the list and dropping
2718
+ * whatever the interval no longer holds, which is the whole point of locking the order.
2719
+ *
2720
+ * Re-entrant: only the outermost scope emits, so an `ingestItem` inside a coalescing `batch`
2721
+ * defers to the batch.
2722
+ */
2723
+ withSingleWindowPublish(fn, { exit = "auto" } = {}) {
2724
+ if (this.config.lockItemOrder) {
2725
+ fn();
2726
+ return;
2727
+ }
2728
+ const isOutermost = this._windowPublishSuspendDepth === 0;
2729
+ this._windowPublishSuspendDepth += 1;
2730
+ try {
2731
+ fn();
2732
+ } finally {
2733
+ this._windowPublishSuspendDepth -= 1;
2734
+ }
2735
+ if (!isOutermost || !this._suspendedWindowDirty) return;
2736
+ this._suspendedWindowDirty = false;
2737
+ if (exit === "auto" && this.isStateThrottled) this.scheduleWindowPublish();
2738
+ else this.flushWindowPublish();
2739
+ }
2674
2740
  /**
2675
2741
  * Flush any pending throttled window + interval-view publishes immediately. No-op when nothing
2676
2742
  * is pending or throttling is off.
@@ -2899,8 +2965,18 @@ var BasePaginator = class {
2899
2965
  const activeInterval = this._itemIntervals.get(this._activeIntervalId);
2900
2966
  if (!activeInterval) return;
2901
2967
  if (this.isStateThrottled) {
2902
- for (const id of activeInterval.itemIds) {
2903
- if (changedIds.has(id)) {
2968
+ const currentItems2 = this.items;
2969
+ if (!currentItems2 || currentItems2.length !== activeInterval.itemIds.length) {
2970
+ if (this.intervalHoldsAnyChangedId(activeInterval, changedIds)) {
2971
+ this.scheduleWindowPublish();
2972
+ }
2973
+ return;
2974
+ }
2975
+ for (let i = 0; i < currentItems2.length; i++) {
2976
+ const id = this.getItemId(currentItems2[i]);
2977
+ if (!changedIds.has(id)) continue;
2978
+ const updated = this._itemIndex.get(id);
2979
+ if (!updated || updated !== currentItems2[i]) {
2904
2980
  this.scheduleWindowPublish();
2905
2981
  return;
2906
2982
  }
@@ -2929,11 +3005,8 @@ var BasePaginator = class {
2929
3005
  return;
2930
3006
  }
2931
3007
  }
2932
- for (const id of activeInterval.itemIds) {
2933
- if (changedIds.has(id)) {
2934
- this.state.partialNext({ items: this.intervalToItems(activeInterval) });
2935
- return;
2936
- }
3008
+ if (this.intervalHoldsAnyChangedId(activeInterval, changedIds)) {
3009
+ this.state.partialNext({ items: this.intervalToItems(activeInterval) });
2937
3010
  }
2938
3011
  }
2939
3012
  // ---------------------------------------------------------------------------
@@ -3603,6 +3676,13 @@ var BasePaginator = class {
3603
3676
  * - if this is the active interval, re-emit state.items from interval
3604
3677
  */
3605
3678
  ingestItem(ingestedItem) {
3679
+ let ingested = false;
3680
+ this.withSingleWindowPublish(() => {
3681
+ ingested = this.applyItemIngestion(ingestedItem);
3682
+ });
3683
+ return ingested;
3684
+ }
3685
+ applyItemIngestion(ingestedItem) {
3606
3686
  const id = this.getItemId(ingestedItem);
3607
3687
  const previousItem = this._itemIndex.get(id);
3608
3688
  const previousCoords = this.locateByItem(previousItem || ingestedItem);
@@ -3614,7 +3694,9 @@ var BasePaginator = class {
3614
3694
  removedItemCoordinates = this.removeItemAtCoordinates(previousCoords);
3615
3695
  }
3616
3696
  const itemHasBeenRemoved = !!removedItemCoordinates?.state && removedItemCoordinates.state.currentIndex > -1;
3617
- this._itemIndex.setOne(ingestedItem);
3697
+ if (this._itemIndex.has(id) || this.matchesFilter(ingestedItem)) {
3698
+ this._itemIndex.setOne(ingestedItem);
3699
+ }
3618
3700
  if (!this.matchesFilter(ingestedItem)) {
3619
3701
  this.shrinkOffsetAfterRemoval(removedItemCoordinates);
3620
3702
  if (!this.isWindowPublishSuspended && this.isStateThrottled && itemHasBeenRemoved)
@@ -3733,16 +3815,7 @@ var BasePaginator = class {
3733
3815
  this._itemIndex.batch(fn);
3734
3816
  return;
3735
3817
  }
3736
- this._windowPublishSuspendDepth += 1;
3737
- try {
3738
- this._itemIndex.batch(fn);
3739
- } finally {
3740
- this._windowPublishSuspendDepth -= 1;
3741
- }
3742
- if (this._windowPublishSuspendDepth === 0 && this._suspendedWindowDirty) {
3743
- this._suspendedWindowDirty = false;
3744
- this.flushWindowPublish();
3745
- }
3818
+ this.withSingleWindowPublish(() => this._itemIndex.batch(fn), { exit: "sync" });
3746
3819
  }
3747
3820
  // ---------------------------------------------------------------------------
3748
3821
  // Remove / contains
@@ -4359,6 +4432,10 @@ var CORE_NOTIFICATION_TYPE = {
4359
4432
  messageJumpFailed: "api:message:jump:failed",
4360
4433
  /** Jumping to the latest message failed. */
4361
4434
  messageJumpToLatestFailed: "api:message:jumpToLatest:failed",
4435
+ /** Sending a composed message failed. The message stays in the list, marked failed. */
4436
+ messageSendFailed: "api:message:send:failed",
4437
+ /** Saving an edit failed. The edit is kept on the message, marked failed. */
4438
+ messageUpdateFailed: "api:message:update:failed",
4362
4439
  pollCastVoteLimit: "validation:poll:castVote:limit",
4363
4440
  pollCreateFailed: "api:poll:create:failed"
4364
4441
  };
@@ -5021,34 +5098,37 @@ var MessageIntervalPaginator = class extends BasePaginator {
5021
5098
  deletedAt
5022
5099
  }) => {
5023
5100
  const loadedMessages = this.items ?? [];
5024
- this.batch(() => {
5025
- for (const message of loadedMessages) {
5026
- if (message.user?.id === userId) {
5027
- if (hardDelete) {
5028
- this.removeItem({ id: message.id });
5029
- } else {
5030
- this.ingestItem(
5031
- toDeletedMessage({
5032
- message,
5101
+ this.batch(
5102
+ () => {
5103
+ for (const message of loadedMessages) {
5104
+ if (message.user?.id === userId) {
5105
+ if (hardDelete) {
5106
+ this.removeItem({ id: message.id });
5107
+ } else {
5108
+ this.ingestItem(
5109
+ toDeletedMessage({
5110
+ message,
5111
+ hardDelete,
5112
+ deletedAt
5113
+ })
5114
+ );
5115
+ }
5116
+ continue;
5117
+ }
5118
+ if (message.quoted_message?.user?.id === userId && message.quoted_message.type !== "deleted") {
5119
+ this.ingestItem({
5120
+ ...message,
5121
+ quoted_message: toDeletedMessage({
5122
+ message: formatMessage(message.quoted_message),
5033
5123
  hardDelete,
5034
5124
  deletedAt
5035
5125
  })
5036
- );
5126
+ });
5037
5127
  }
5038
- continue;
5039
- }
5040
- if (message.quoted_message?.user?.id === userId && message.quoted_message.type !== "deleted") {
5041
- this.ingestItem({
5042
- ...message,
5043
- quoted_message: toDeletedMessage({
5044
- message: formatMessage(message.quoted_message),
5045
- hardDelete,
5046
- deletedAt
5047
- })
5048
- });
5049
5128
  }
5050
- }
5051
- });
5129
+ },
5130
+ { coalesce: true }
5131
+ );
5052
5132
  };
5053
5133
  /**
5054
5134
  * Ensures quoted-message snapshots across loaded paginator cache are in sync
@@ -5084,6 +5164,7 @@ var MessageIntervalPaginator = class extends BasePaginator {
5084
5164
  this.batch(() => {
5085
5165
  for (const message of this._itemIndex.values()) {
5086
5166
  if (message.user?.id !== user.id) continue;
5167
+ if (message.user === user) continue;
5087
5168
  this._itemIndex.setOne({ ...message, user });
5088
5169
  if (activeIds.has(this.getItemId(message))) activeAffected = true;
5089
5170
  }
@@ -5094,58 +5175,6 @@ var MessageIntervalPaginator = class extends BasePaginator {
5094
5175
  });
5095
5176
  }
5096
5177
  };
5097
- /**
5098
- * Apply a reaction WS event (`reaction.new` / `reaction.updated` / `reaction.deleted`) to the
5099
- * cached message. The event's `message` already carries the server-updated
5100
- * `reaction_groups` / `latest_reactions`; only `own_reactions` needs local preservation so a
5101
- * cross-user reaction does not wipe the current user's reactions. This re-homes what
5102
- * `ChannelState.addReaction` / `removeReaction` used to do off the now-removed
5103
- * `channel.state.messages` / `channel.state.threads` caches (the same logic backs the thread
5104
- * paginator via `Thread.messagePaginator`).
5105
- *
5106
- * `own_reactions` is seeded from the currently cached item (so another user's reaction keeps ours),
5107
- * falling back to the event's own_reactions when the message is not loaded — matching the legacy
5108
- * behavior where `_updateMessage` only mutated a message that existed locally.
5109
- *
5110
- * @param params - The reaction event payload.
5111
- * @param params.message - The reaction event's message, carrying the
5112
- * server-computed `reaction_groups` / `latest_reactions`. Ingested as-is except for `own_reactions`.
5113
- * @param params.reaction - The reaction from the event. Only added to/removed from
5114
- * `own_reactions` when its `user_id` is the current user; otherwise the current user's
5115
- * `own_reactions` are left untouched.
5116
- * @param [params.removed=false] - `true` for `reaction.deleted` (remove the reaction from
5117
- * `own_reactions`); `false` for `reaction.new` / `reaction.updated` (add it).
5118
- * @param [params.enforceUnique=false] - When adding, first clear the current user's existing
5119
- * `own_reactions` so only the incoming one remains (used by `reaction.updated`, where a user's
5120
- * reaction replaces their previous one).
5121
- *
5122
- * TODO(reactive-store): reflect reactions ONCE at the store level, not per-paginator. Both the
5123
- * channel handler (channel.ts) and the thread handler (thread.ts) call this on every reaction.*
5124
- * event, so a message held in more than one collection (a show_in_channel reply, or the thread
5125
- * parent) is reflected TWICE: two writes to the same canonical slot, each fanning out to the
5126
- * other holder (double re-projection) and minting a fresh ref that defeats the reconcile
5127
- * ref-equality bail. Idempotent (counts come wholesale from the event) so the result is correct,
5128
- * just wasteful. The store already fans out to every holder, so reflect once (by id, if held) and
5129
- * retire the per-collection reflect calls + parent path + enforce_unique branch.
5130
- */
5131
- this.reflectReaction = ({
5132
- enforceUnique = false,
5133
- message,
5134
- reaction,
5135
- removed = false
5136
- }) => {
5137
- const formatted = formatMessage(message);
5138
- const existing = this.getItem(formatted.id);
5139
- const baseOwnReactions = existing?.own_reactions ?? formatted.own_reactions ?? [];
5140
- const own_reactions = computeOwnReactions({
5141
- current: baseOwnReactions,
5142
- enforceUnique,
5143
- reaction,
5144
- removed,
5145
- userId: this.channel.getClient().userId
5146
- });
5147
- this.ingestItem({ ...formatted, own_reactions });
5148
- };
5149
5178
  /**
5150
5179
  * Map a timestamp to a loaded message — the first message in the latest (head) window whose
5151
5180
  * `created_at` is >= `timestamp` (mirrors the legacy `ChannelState.findMessageByTimestamp`
@@ -5883,6 +5912,7 @@ var PENDING_UPLOAD_STATES = [
5883
5912
  ];
5884
5913
  var isPendingUpload = (attachment) => isLocalUploadAttachment(attachment) && PENDING_UPLOAD_STATES.includes(attachment.localMetadata.uploadState);
5885
5914
  var isFinishedUpload = (attachment) => isLocalUploadAttachment(attachment) && attachment.localMetadata.uploadState === "finished";
5915
+ var isFailedUpload = (attachment) => isLocalUploadAttachment(attachment) && attachment.localMetadata.uploadState === "failed";
5886
5916
  var isFileAttachment = (attachment, supportedVideoFormat = []) => attachment.type === "file" || !!(attachment.custom?.mime_type && supportedVideoFormat.indexOf(
5887
5917
  attachment.custom?.mime_type
5888
5918
  ) === -1 && attachment.type !== "video");
@@ -5901,6 +5931,27 @@ var isUploadedAttachment = (attachment) => isAudioAttachment(attachment) || isFi
5901
5931
  var isSharedLocationResponseData = (location) => !!location.latitude && !!location.longitude && !!location.channel_cid;
5902
5932
  var isGiphyAttachment = (attachment) => attachment.type === "giphy";
5903
5933
 
5934
+ // src/messageComposer/attachmentUtils.ts
5935
+ var getAttachmentPreviewUrl = (attachment, ...urls) => {
5936
+ const resolved = urls.find(Boolean);
5937
+ if (resolved) return resolved;
5938
+ return isLocalUploadAttachment(attachment) ? attachment.localMetadata.previewUri : void 0;
5939
+ };
5940
+ var resolveAttachmentFileSize = (attachment) => attachment?.custom?.file_size ?? attachment?.file_size;
5941
+ var resolveAttachmentFullByteSize = (attachment) => {
5942
+ const fromFile = attachment?.localMetadata?.file?.size;
5943
+ if (typeof fromFile === "number" && Number.isFinite(fromFile) && fromFile >= 0) {
5944
+ return fromFile;
5945
+ }
5946
+ const raw = resolveAttachmentFileSize(attachment);
5947
+ if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return raw;
5948
+ if (typeof raw === "string") {
5949
+ const parsed = parseFloat(raw);
5950
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed;
5951
+ }
5952
+ return void 0;
5953
+ };
5954
+
5904
5955
  // src/messageComposer/fileUtils.ts
5905
5956
  var isFile = (fileLike) => !!fileLike.lastModified && !("uri" in fileLike);
5906
5957
  var isFileList = (obj) => {
@@ -6167,6 +6218,12 @@ var import_axios = __toESM(require("axios"));
6167
6218
  var import_state_store7 = require("@stream-io/state-store");
6168
6219
  var logger3 = chatLoggerSystem.getLogger("upload-manager");
6169
6220
  var isUploadCancellation = (error) => import_axios.default.isCancel(error) || error instanceof Error && error.name === "AbortError";
6221
+ var isUploadConfirmationPending = (localMetadata) => {
6222
+ if (!localMetadata || localMetadata.uploadState !== "uploading") return false;
6223
+ if (localMetadata.uploadConfirmationPending !== void 0)
6224
+ return localMetadata.uploadConfirmationPending;
6225
+ return localMetadata.uploadProgress !== void 0 && localMetadata.uploadProgress >= 100;
6226
+ };
6170
6227
  var initState = () => ({ uploads: {} });
6171
6228
  var upsertById = (uploads, record) => ({
6172
6229
  ...uploads,
@@ -6290,12 +6347,12 @@ var UploadManager = class {
6290
6347
  this.state = new import_state_store7.StateStore(initState());
6291
6348
  }
6292
6349
  resolveAttachmentManager(channelCid) {
6293
- const colon = channelCid.indexOf(":");
6294
- if (colon <= 0 || colon === channelCid.length - 1) {
6350
+ const colon = ":";
6351
+ const colonIndex = channelCid.indexOf(colon);
6352
+ if (colonIndex <= 0 || colonIndex === channelCid.length - 1) {
6295
6353
  throw new Error(`Invalid channelCid: ${channelCid}`);
6296
6354
  }
6297
- const channelType = channelCid.slice(0, colon);
6298
- const channelId = channelCid.slice(colon + 1);
6355
+ const [channelType, channelId] = channelCid.split(colon);
6299
6356
  return this.client.channel(channelType, channelId).messageComposer.attachmentManager;
6300
6357
  }
6301
6358
  get uploads() {
@@ -7029,13 +7086,15 @@ var composeWithPendingUploads = ({
7029
7086
  composer,
7030
7087
  state
7031
7088
  }) => {
7032
- const composerIsKeptAsDraft = !!composer.pollId;
7089
+ const composerIsKeptAsDraft = composer.retainsCompositionOnSubmit;
7033
7090
  const relevantAttachments = composer.attachmentManager.attachments.filter(
7034
- (attachment) => isFinishedUpload(attachment) || !composerIsKeptAsDraft && isPendingUpload(attachment)
7091
+ (attachment) => isFinishedUpload(attachment) || !composerIsKeptAsDraft && (isPendingUpload(attachment) || isFailedUpload(attachment))
7035
7092
  );
7036
7093
  const localAttachments = (state.localMessage.attachments ?? []).concat(
7094
+ // Only a finished upload has a URL of its own. Everything else keeps `localMetadata`, which
7095
+ // is what the message list joins to the live `uploadManager` record and what a retry needs.
7037
7096
  relevantAttachments.map(
7038
- (attachment) => isPendingUpload(attachment) ? attachment : localAttachmentToAttachment(attachment)
7097
+ (attachment) => isFinishedUpload(attachment) ? localAttachmentToAttachment(attachment) : attachment
7039
7098
  )
7040
7099
  );
7041
7100
  const messageAttachments = (state.message.attachments ?? []).concat(
@@ -7054,21 +7113,6 @@ var composeWithPendingUploads = ({
7054
7113
  } : state.message
7055
7114
  };
7056
7115
  };
7057
- var createSendWithPendingUploadsAttachmentsMiddleware = (composer) => ({
7058
- allowsPendingUploads: true,
7059
- id: "stream-io/message-composer-middleware/attachments",
7060
- handlers: {
7061
- compose: ({
7062
- state,
7063
- next,
7064
- forward
7065
- }) => {
7066
- const { attachmentManager } = composer;
7067
- if (!attachmentManager) return forward();
7068
- return next(composeWithPendingUploads({ composer, state }));
7069
- }
7070
- }
7071
- });
7072
7116
  var createAttachmentsCompositionMiddleware = (composer) => ({
7073
7117
  id: "stream-io/message-composer-middleware/attachments",
7074
7118
  handlers: {
@@ -7080,6 +7124,9 @@ var createAttachmentsCompositionMiddleware = (composer) => ({
7080
7124
  }) => {
7081
7125
  const { attachmentManager } = composer;
7082
7126
  if (!attachmentManager) return forward();
7127
+ if (attachmentManager.config.pendingUploadsEnabled) {
7128
+ return next(composeWithPendingUploads({ composer, state }));
7129
+ }
7083
7130
  if (attachmentManager.uploadsInProgressCount > 0) {
7084
7131
  composer.client.notifications.addWarning({
7085
7132
  message: "Wait until all attachments have uploaded",
@@ -9805,10 +9852,11 @@ var createLinkPreviewsCompositionMiddleware = (composer) => ({
9805
9852
  const linkPreviews = linkPreviewsManager.loadingPreviews.length > 0 ? [] : linkPreviewsManager.loadedPreviews.map(
9806
9853
  (preview) => LinkPreviewsManager.getPreviewData(preview)
9807
9854
  );
9808
- const attachments = (state.message.attachments ?? []).concat(
9855
+ const messageAttachments = (state.message.attachments ?? []).concat(
9809
9856
  linkPreviews
9810
9857
  );
9811
- if (!attachments.length) return forward();
9858
+ const localAttachments = (state.localMessage.attachments ?? []).concat(linkPreviews);
9859
+ if (!messageAttachments.length && !localAttachments.length) return forward();
9812
9860
  const sendOptions = { ...state.sendOptions };
9813
9861
  const skip_enrich_url = !someLinkPreviewsLoading && linkPreviews.length > 0 || someLinkPreviewsDismissed;
9814
9862
  if (skip_enrich_url) {
@@ -9816,14 +9864,14 @@ var createLinkPreviewsCompositionMiddleware = (composer) => ({
9816
9864
  }
9817
9865
  return next({
9818
9866
  ...state,
9819
- message: {
9867
+ message: messageAttachments.length ? {
9820
9868
  ...state.message,
9821
- attachments
9822
- },
9823
- localMessage: {
9869
+ attachments: messageAttachments
9870
+ } : state.message,
9871
+ localMessage: localAttachments.length ? {
9824
9872
  ...state.localMessage,
9825
- attachments
9826
- },
9873
+ attachments: localAttachments
9874
+ } : state.localMessage,
9827
9875
  sendOptions
9828
9876
  });
9829
9877
  }
@@ -10180,13 +10228,6 @@ var MessageComposerMiddlewareExecutor = class extends MiddlewareExecutor {
10180
10228
  createCompositionDataCleanupMiddleware(composer)
10181
10229
  ]);
10182
10230
  }
10183
- /**
10184
- * Narrows the base accessor to the composition middleware type, so callers can read the
10185
- * declarations it carries (`allowsPendingUploads`) without casting.
10186
- */
10187
- get installedMiddleware() {
10188
- return super.installedMiddleware;
10189
- }
10190
10231
  };
10191
10232
  var MessageDraftComposerMiddlewareExecutor = class extends MiddlewareExecutor {
10192
10233
  constructor({ composer }) {
@@ -11335,6 +11376,7 @@ var DEFAULT_ATTACHMENT_MANAGER_CONFIG = {
11335
11376
  enabled: true,
11336
11377
  fileUploadFilter: () => true,
11337
11378
  maxNumberOfFilesPerMessage: API_MAX_FILES_ALLOWED_PER_MESSAGE,
11379
+ pendingUploadsEnabled: false,
11338
11380
  trackUploadProgress: true
11339
11381
  };
11340
11382
  var DEFAULT_TEXT_COMPOSER_CONFIG = {
@@ -11356,6 +11398,7 @@ var DEFAULT_COMPOSER_CONFIG = deepFreezeConfig({
11356
11398
  linkPreviews: DEFAULT_LINK_PREVIEW_MANAGER_CONFIG,
11357
11399
  location: DEFAULT_LOCATION_COMPOSER_CONFIG,
11358
11400
  polls: DEFAULT_POLL_COMPOSER_CONFIG,
11401
+ retainCompositionOnSubmit: (composer) => !!composer.pollId,
11359
11402
  text: DEFAULT_TEXT_COMPOSER_CONFIG
11360
11403
  });
11361
11404
 
@@ -11918,6 +11961,123 @@ var mergeServerRestrictions = (requested, restrictions, upperBounds) => {
11918
11961
  // src/thread.ts
11919
11962
  var import_state_store18 = require("@stream-io/state-store");
11920
11963
 
11964
+ // src/messageOperations/applyReactionLocally.ts
11965
+ var applyReactionLocally = (client, {
11966
+ enforceUnique = false,
11967
+ messageId,
11968
+ reaction,
11969
+ removed = false
11970
+ }) => {
11971
+ const store = client.messageStore;
11972
+ const user = client.user;
11973
+ const existing = store.get(messageId);
11974
+ if (!user || !existing) return;
11975
+ const now = nowNs();
11976
+ const reactionResponse = {
11977
+ ...reaction,
11978
+ created_at: reaction.created_at ?? now,
11979
+ custom: reaction.custom ?? {},
11980
+ message_id: messageId,
11981
+ score: reaction.score ?? 1,
11982
+ type: reaction.type,
11983
+ updated_at: reaction.updated_at ?? now,
11984
+ user,
11985
+ user_id: user.id
11986
+ };
11987
+ const removedReactions = removed ? existing.own_reactions?.filter((r) => r.type === reactionResponse.type) ?? [] : enforceUnique ? existing.own_reactions ?? [] : [];
11988
+ const withCounts = removed ? messageWithReactionRemoved(existing, reactionResponse) : messageWithReactionAdded(existing, reactionResponse, enforceUnique);
11989
+ const own_reactions = computeOwnReactions({
11990
+ current: existing.own_reactions ?? [],
11991
+ enforceUnique,
11992
+ reaction: reactionResponse,
11993
+ removed,
11994
+ userId: user.id
11995
+ });
11996
+ store.upsert({ ...withCounts, own_reactions });
11997
+ store.flushSubscribers(messageId);
11998
+ const persisted = store.get(messageId);
11999
+ if (persisted) {
12000
+ client.offlineDb?.executeQuerySafely(
12001
+ (db) => removed ? db.deleteReaction({ message: persisted, reaction: reactionResponse }) : enforceUnique ? db.updateReaction({ message: persisted, reaction: reactionResponse }) : db.insertReaction({ message: persisted, reaction: reactionResponse }),
12002
+ { method: "applyReactionLocally" }
12003
+ );
12004
+ }
12005
+ return () => {
12006
+ if (!removed) {
12007
+ applyReactionLocally(client, {
12008
+ messageId,
12009
+ reaction: reactionResponse,
12010
+ removed: true
12011
+ });
12012
+ }
12013
+ for (const removedReaction of removedReactions) {
12014
+ applyReactionLocally(client, {
12015
+ messageId,
12016
+ reaction: removedReaction,
12017
+ removed: false
12018
+ });
12019
+ }
12020
+ };
12021
+ };
12022
+
12023
+ // src/messageOperations/optimistic.ts
12024
+ var isQueuedForReplay = async (client, messageId, types) => {
12025
+ const { offlineDb } = client;
12026
+ if (!offlineDb?.state.getLatestValue().initialized) return false;
12027
+ const pending = await offlineDb.getPendingTasks({ messageId });
12028
+ return !!pending?.some((task) => types.includes(task.type));
12029
+ };
12030
+ var reconcileHeldMessage = (channel, message) => {
12031
+ if (!message) return;
12032
+ const { messageStore } = channel.getClient();
12033
+ if (!messageStore.has(message.id)) return;
12034
+ messageStore.upsert(formatMessage(message));
12035
+ };
12036
+ var addReactionOptimistically = async ({
12037
+ channel,
12038
+ messageId,
12039
+ options,
12040
+ reaction
12041
+ }) => {
12042
+ const client = channel.getClient();
12043
+ const { created_at, updated_at, ...restOfReaction } = reaction;
12044
+ const undo = applyReactionLocally(client, {
12045
+ enforceUnique: options?.enforce_unique ?? false,
12046
+ messageId,
12047
+ reaction: {
12048
+ ...restOfReaction,
12049
+ ...created_at ? { created_at: dateToNs(created_at) } : {},
12050
+ ...updated_at ? { updated_at: dateToNs(updated_at) } : {}
12051
+ }
12052
+ });
12053
+ try {
12054
+ const response = await channel.sendReaction({ id: messageId, reaction, ...options });
12055
+ reconcileHeldMessage(channel, response?.message);
12056
+ } catch (error) {
12057
+ if (!await isQueuedForReplay(client, messageId, ["send-reaction"])) undo?.();
12058
+ throw error;
12059
+ }
12060
+ };
12061
+ var deleteReactionOptimistically = async ({
12062
+ channel,
12063
+ messageId,
12064
+ type
12065
+ }) => {
12066
+ const client = channel.getClient();
12067
+ const undo = applyReactionLocally(client, {
12068
+ messageId,
12069
+ reaction: { type },
12070
+ removed: true
12071
+ });
12072
+ try {
12073
+ const response = await channel.deleteReaction({ id: messageId, type });
12074
+ reconcileHeldMessage(channel, response?.message);
12075
+ } catch (error) {
12076
+ if (!await isQueuedForReplay(client, messageId, ["delete-reaction"])) undo?.();
12077
+ throw error;
12078
+ }
12079
+ };
12080
+
11921
12081
  // src/messageOperations/MessageOperationStatePolicy.ts
11922
12082
  var parseError = (error) => {
11923
12083
  const stringError = JSON.stringify(error);
@@ -12172,15 +12332,57 @@ var MessageOperations = class {
12172
12332
  });
12173
12333
  }
12174
12334
  /**
12175
- * The shared lifecycle: apply the optimistic state, fire the request, then reconcile or record the
12176
- * failure. `kind` is threaded through because the three operations want materially different state
12177
- * transitions — see {@link MessageOperationStatePolicy}.
12335
+ * Resolves any in-flight upload and folds the URLs back into both payloads. A no-op without
12336
+ * {@link MessageOperationsContext.settlePendingUploads} or a pending attachment.
12337
+ */
12338
+ async settlePendingUploads(params) {
12339
+ const settle = this.ctx.settlePendingUploads;
12340
+ const attachments = params.localMessage.attachments;
12341
+ if (!settle || !attachments?.length) return { params };
12342
+ const { attachments: settledAttachments, failureReason } = await settle(attachments);
12343
+ if (settledAttachments === attachments) return { failureReason, params };
12344
+ const wirePayload = "message" in params && params.message ? { message: { ...params.message, attachments: settledAttachments } } : {};
12345
+ return {
12346
+ failureReason,
12347
+ params: {
12348
+ ...params,
12349
+ ...wirePayload,
12350
+ localMessage: { ...params.localMessage, attachments: settledAttachments }
12351
+ }
12352
+ };
12353
+ }
12354
+ /**
12355
+ * Applies the owner's request-ordering policy for this kind, if it supplied one.
12356
+ */
12357
+ withRequestSequencing(kind, request) {
12358
+ return this.ctx.sequenceRequests ? this.ctx.sequenceRequests(kind, request) : request();
12359
+ }
12360
+ /**
12361
+ * The shared lifecycle: apply the optimistic state, settle any attachment upload still in flight,
12362
+ * fire the request, then reconcile or record the failure. `kind` is threaded through because the
12363
+ * three operations want materially different state transitions — see
12364
+ * {@link MessageOperationStatePolicy}.
12178
12365
  */
12179
12366
  async run(kind, params, doRequest) {
12180
12367
  const messageId = params.localMessage.id;
12181
12368
  const optimistic = this.policy.optimistic(kind, params);
12369
+ let effective = params;
12182
12370
  try {
12183
- const response = await doRequest(params);
12371
+ const response = await this.withRequestSequencing(kind, async () => {
12372
+ if (kind !== "delete") {
12373
+ const settled2 = await this.settlePendingUploads(params);
12374
+ effective = settled2.params;
12375
+ if (settled2.failureReason) throw settled2.failureReason;
12376
+ const settledAttachments = effective.localMessage.attachments;
12377
+ const held = settledAttachments === params.localMessage.attachments ? void 0 : this.ctx.get(messageId);
12378
+ if (held) {
12379
+ const withUrls = { ...held, attachments: settledAttachments };
12380
+ this.ctx.ingest(withUrls);
12381
+ this.ctx.persist(withUrls);
12382
+ }
12383
+ }
12384
+ return await doRequest(effective);
12385
+ });
12184
12386
  this.policy.success({
12185
12387
  kind,
12186
12388
  messageFromResponse: response?.message,
@@ -12192,7 +12394,7 @@ var MessageOperations = class {
12192
12394
  await this.policy.failure({
12193
12395
  error: e,
12194
12396
  kind,
12195
- localMessage: params.localMessage,
12397
+ localMessage: effective.localMessage,
12196
12398
  messageId,
12197
12399
  optimistic,
12198
12400
  options: params.options
@@ -12200,6 +12402,13 @@ var MessageOperations = class {
12200
12402
  throw e;
12201
12403
  }
12202
12404
  }
12405
+ /*
12406
+ * Each operation resolves its request as `requestFn ?? handlers.<kind> ?? defaults.<kind>`.
12407
+ *
12408
+ * `handlers` is the integrator seam (`ChannelConfig.requestHandlers`). `requestFn` is a direct
12409
+ * injection point for tests; it is not exposed on the public methods because it wins over
12410
+ * `handlers`, so a caller reaching for it would bypass whatever the host SDK registered.
12411
+ */
12203
12412
  async send(params, requestFn) {
12204
12413
  const handlers = this.ctx.handlers();
12205
12414
  const messageToSend = this.normalizeMessage(
@@ -12272,122 +12481,27 @@ var MessageOperations = class {
12272
12481
  const doRequest = requestFn ?? handlers.delete ?? (async (p) => await this.ctx.defaults.delete(p.localMessage.id, p.options));
12273
12482
  return await this.run("delete", params, doRequest);
12274
12483
  }
12275
- };
12276
-
12277
- // src/entityStore/applyReactionLocally.ts
12278
- var applyReactionLocally = (client, {
12279
- enforceUnique = false,
12280
- messageId,
12281
- reaction,
12282
- removed = false
12283
- }) => {
12284
- const store = client.messageStore;
12285
- const user = client.user;
12286
- const existing = store.get(messageId);
12287
- if (!user || !existing) return;
12288
- const now = nowNs();
12289
- const reactionResponse = {
12290
- ...reaction,
12291
- created_at: reaction.created_at ?? now,
12292
- custom: reaction.custom ?? {},
12293
- message_id: messageId,
12294
- score: reaction.score ?? 1,
12295
- type: reaction.type,
12296
- updated_at: reaction.updated_at ?? now,
12297
- user,
12298
- user_id: user.id
12299
- };
12300
- const removedReactions = removed ? existing.own_reactions?.filter((r) => r.type === reactionResponse.type) ?? [] : enforceUnique ? existing.own_reactions ?? [] : [];
12301
- const withCounts = removed ? messageWithReactionRemoved(existing, reactionResponse) : messageWithReactionAdded(existing, reactionResponse, enforceUnique);
12302
- const own_reactions = computeOwnReactions({
12303
- current: existing.own_reactions ?? [],
12304
- enforceUnique,
12305
- reaction: reactionResponse,
12306
- removed,
12307
- userId: user.id
12308
- });
12309
- store.upsert({ ...withCounts, own_reactions });
12310
- store.flushSubscribers(messageId);
12311
- const persisted = store.get(messageId);
12312
- if (persisted) {
12313
- client.offlineDb?.executeQuerySafely(
12314
- (db) => removed ? db.deleteReaction({ message: persisted, reaction: reactionResponse }) : enforceUnique ? db.updateReaction({ message: persisted, reaction: reactionResponse }) : db.insertReaction({ message: persisted, reaction: reactionResponse }),
12315
- { method: "applyReactionLocally" }
12316
- );
12317
- }
12318
- return () => {
12319
- if (!removed) {
12320
- applyReactionLocally(client, {
12321
- messageId,
12322
- reaction: reactionResponse,
12323
- removed: true
12324
- });
12325
- }
12326
- for (const removedReaction of removedReactions) {
12327
- applyReactionLocally(client, {
12328
- messageId,
12329
- reaction: removedReaction,
12330
- removed: false
12331
- });
12332
- }
12333
- };
12334
- };
12335
-
12336
- // src/messageOperations/optimistic.ts
12337
- var isQueuedForReplay = async (client, messageId, types) => {
12338
- const { offlineDb } = client;
12339
- if (!offlineDb?.state.getLatestValue().initialized) return false;
12340
- const pending = await offlineDb.getPendingTasks({ messageId });
12341
- return !!pending?.some((task) => types.includes(task.type));
12342
- };
12343
- var reconcileHeldMessage = (channel, message) => {
12344
- if (!message) return;
12345
- const { messageStore } = channel.getClient();
12346
- if (!messageStore.has(message.id)) return;
12347
- messageStore.upsert(formatMessage(message));
12348
- };
12349
- var addReactionOptimistically = async ({
12350
- channel,
12351
- messageId,
12352
- options,
12353
- reaction
12354
- }) => {
12355
- const client = channel.getClient();
12356
- const { created_at, updated_at, ...restOfReaction } = reaction;
12357
- const undo = applyReactionLocally(client, {
12358
- enforceUnique: options?.enforce_unique ?? false,
12359
- messageId,
12360
- reaction: {
12361
- ...restOfReaction,
12362
- ...created_at ? { created_at: dateToNs(created_at) } : {},
12363
- ...updated_at ? { updated_at: dateToNs(updated_at) } : {}
12364
- }
12365
- });
12366
- try {
12367
- const response = await channel.sendReaction({ id: messageId, reaction, ...options });
12368
- reconcileHeldMessage(channel, response?.message);
12369
- } catch (error) {
12370
- if (!await isQueuedForReplay(client, messageId, ["send-reaction"])) undo?.();
12371
- throw error;
12484
+ async retrySendWithLocalUpdate({
12485
+ localMessage,
12486
+ options
12487
+ }) {
12488
+ await this.retry({ localMessage: { ...localMessage, type: "regular" }, options });
12372
12489
  }
12373
- };
12374
- var deleteReactionOptimistically = async ({
12375
- channel,
12376
- messageId,
12377
- type
12378
- }) => {
12379
- const client = channel.getClient();
12380
- const undo = applyReactionLocally(client, {
12381
- messageId,
12382
- reaction: { type },
12383
- removed: true
12384
- });
12385
- try {
12386
- const response = await channel.deleteReaction({ id: messageId, type });
12387
- reconcileHeldMessage(channel, response?.message);
12388
- } catch (error) {
12389
- if (!await isQueuedForReplay(client, messageId, ["delete-reaction"])) undo?.();
12390
- throw error;
12490
+ /**
12491
+ * Adds a reaction with an optimistic local state update — see {@link addReactionOptimistically}.
12492
+ *
12493
+ * The request routes through the channel because reactions are channel-level, while the local write
12494
+ * is addressed by message id and so reaches a pure thread reply that no channel collection holds.
12495
+ */
12496
+ async addReactionWithLocalUpdate(params) {
12497
+ await addReactionOptimistically({ channel: this.ctx.channel, ...params });
12498
+ }
12499
+ /**
12500
+ * Removes the current user's reaction with an optimistic local state update, mirroring
12501
+ * {@link MessageOperations.addReactionWithLocalUpdate}.
12502
+ */
12503
+ async deleteReactionWithLocalUpdate(params) {
12504
+ await deleteReactionOptimistically({ channel: this.ctx.channel, ...params });
12391
12505
  }
12392
12506
  };
12393
12507
 
@@ -12409,6 +12523,210 @@ var createMessageOperationsPersistence = ({
12409
12523
  isQueued: (messageId, types) => isQueuedForReplay(channel.getClient(), messageId, types)
12410
12524
  });
12411
12525
 
12526
+ // src/messageOperations/sendOrdering.ts
12527
+ var keepSendOrderWhilePendingUploadsAllowed = ({
12528
+ channelCid,
12529
+ composer,
12530
+ kind,
12531
+ request
12532
+ }) => (kind === "send" || kind === "retry") && composer.allowsPendingUploads ? withoutConcurrency(`stream-chat/send-message/${channelCid}`, request) : request();
12533
+
12534
+ // src/messageOperations/settlePendingAttachmentUploads.ts
12535
+ var applyUploadResult = (attachment, response) => {
12536
+ const enriched = { ...attachment };
12537
+ if (isLocalImageAttachment(enriched)) {
12538
+ enriched.image_url = response.file;
12539
+ } else {
12540
+ enriched.asset_url = response.file;
12541
+ }
12542
+ if (response.thumb_url) {
12543
+ enriched.thumb_url = response.thumb_url;
12544
+ }
12545
+ const { previewUri } = attachment.localMetadata;
12546
+ if (previewUri?.startsWith("blob:")) URL.revokeObjectURL?.(previewUri);
12547
+ const { localMetadata: _localMetadata, ...rest } = enriched;
12548
+ return rest;
12549
+ };
12550
+ var settlePendingAttachmentUploads = async ({
12551
+ attachments,
12552
+ channelCid,
12553
+ client
12554
+ }) => {
12555
+ const unsettledIndexes = attachments.reduce((indexes, attachment, index) => {
12556
+ if (isPendingUpload(attachment) || isFailedUpload(attachment)) indexes.push(index);
12557
+ return indexes;
12558
+ }, []);
12559
+ if (!unsettledIndexes.length) return { attachments };
12560
+ const settled2 = await Promise.allSettled(
12561
+ unsettledIndexes.map((index) => {
12562
+ const { file, id } = attachments[index].localMetadata;
12563
+ return client.uploadManager.upload({ channelCid, file, id });
12564
+ })
12565
+ );
12566
+ const nextAttachments = [...attachments];
12567
+ let failureReason;
12568
+ settled2.forEach((result, position) => {
12569
+ const index = unsettledIndexes[position];
12570
+ const attachment = nextAttachments[index];
12571
+ if (result.status === "fulfilled" && result.value) {
12572
+ nextAttachments[index] = applyUploadResult(attachment, result.value);
12573
+ return;
12574
+ }
12575
+ if (!failureReason) {
12576
+ failureReason = result.status === "rejected" ? result.reason : new Error("Attachment upload returned no result");
12577
+ }
12578
+ });
12579
+ return { attachments: nextAttachments, failureReason };
12580
+ };
12581
+
12582
+ // src/messageOperations/createMessageOperations.ts
12583
+ var createMessageOperations = (collection) => {
12584
+ const paginator = collection.messagePaginator;
12585
+ const { channel, parentMessageId } = paginator;
12586
+ return new MessageOperations({
12587
+ sequenceRequests: (kind, request) => keepSendOrderWhilePendingUploadsAllowed({
12588
+ channelCid: channel.cid,
12589
+ composer: collection.messageComposer,
12590
+ kind,
12591
+ request
12592
+ }),
12593
+ settlePendingUploads: (attachments) => settlePendingAttachmentUploads({
12594
+ attachments,
12595
+ // A reply uploads against the channel the thread belongs to - there is no thread-scoped
12596
+ // upload target.
12597
+ channelCid: channel.cid,
12598
+ client: channel.getClient()
12599
+ }),
12600
+ ...createMessageOperationsPersistence({ channel }),
12601
+ channel,
12602
+ ingest: (m) => {
12603
+ const store = channel.getClient().messageStore;
12604
+ if (paginator.matchesFilter(m)) {
12605
+ paginator.ingestItem(m);
12606
+ } else if (store.has(m.id)) {
12607
+ store.upsert(m);
12608
+ }
12609
+ store.flushSubscribers(m.id);
12610
+ },
12611
+ // Mirrors `ingest`'s routing: a message this paginator does not hold can still be held by the
12612
+ // client-global store (a thread parent, a message displayed by another collection), and the
12613
+ // policy uses this both for its freshness comparison and to decide whether there is anything to
12614
+ // update optimistically at all. Reading only the paginator would make those two disagree.
12615
+ get: (id) => paginator.getItem(id) ?? channel.getClient().messageStore.get(id),
12616
+ remove: (id) => {
12617
+ const client = channel.getClient();
12618
+ const parentId = paginator.getItem(id)?.parent_id ?? client.messageStore.get(id)?.parent_id;
12619
+ channel.messagePaginator.removeItem({ id });
12620
+ channel.pinnedMessagesPaginator.removeItem({ id });
12621
+ if (parentId) {
12622
+ client.threads.threadsById[parentId]?.messagePaginator.removeItem({ id });
12623
+ }
12624
+ },
12625
+ ...parentMessageId ? {
12626
+ normalizeOutgoingMessage: (m) => ({ ...m, parent_id: parentMessageId })
12627
+ } : {},
12628
+ handlers: () => {
12629
+ const { requestHandlers } = channel.configState.getLatestValue();
12630
+ const deleteMessageRequest = requestHandlers?.deleteMessageRequest;
12631
+ const sendMessageRequest = requestHandlers?.sendMessageRequest;
12632
+ const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest;
12633
+ const updateMessageRequest = requestHandlers?.updateMessageRequest;
12634
+ return {
12635
+ delete: deleteMessageRequest ? (p) => deleteMessageRequest({
12636
+ localMessage: p.localMessage,
12637
+ options: p.options
12638
+ }) : void 0,
12639
+ send: sendMessageRequest ? (p) => sendMessageRequest({
12640
+ localMessage: p.localMessage,
12641
+ message: p.message,
12642
+ options: p.options
12643
+ }) : void 0,
12644
+ retry: retrySendMessageRequest ? (p) => retrySendMessageRequest({
12645
+ localMessage: p.localMessage,
12646
+ message: p.message,
12647
+ options: p.options
12648
+ }) : void 0,
12649
+ update: updateMessageRequest ? (p) => updateMessageRequest({
12650
+ localMessage: p.localMessage,
12651
+ options: p.options
12652
+ }) : void 0
12653
+ };
12654
+ },
12655
+ defaults: {
12656
+ delete: async (id, o) => {
12657
+ const result = await channel.getClient().deleteMessage({ id, ...o });
12658
+ return { message: result.message };
12659
+ },
12660
+ send: async (m, o) => {
12661
+ const result = await channel.sendMessage({ message: m, ...o });
12662
+ return { message: result.message };
12663
+ },
12664
+ update: async (m, o) => {
12665
+ const result = await channel.getClient().updateMessage({
12666
+ id: m.id,
12667
+ message: localMessageToNewMessagePayload(m),
12668
+ ...o
12669
+ });
12670
+ return { message: result.message };
12671
+ }
12672
+ }
12673
+ });
12674
+ };
12675
+
12676
+ // src/messageOperations/WithMessageOperations.ts
12677
+ var WithMessageOperations = (Base) => {
12678
+ class WithMessageOperationsMixin extends Base {
12679
+ /** Sends a message with optimistic local state update. */
12680
+ async sendMessageWithLocalUpdate(params) {
12681
+ await this.messageOperations.send(params);
12682
+ }
12683
+ /** Retry sending a failed message. */
12684
+ async retrySendMessageWithLocalUpdate(params) {
12685
+ await this.messageOperations.retrySendWithLocalUpdate(params);
12686
+ }
12687
+ /** Updates a message with optimistic local state update. */
12688
+ async updateMessageWithLocalUpdate(params) {
12689
+ await this.messageOperations.update(params);
12690
+ }
12691
+ /** Deletes a message with local state update. */
12692
+ async deleteMessageWithLocalUpdate(params) {
12693
+ await this.messageOperations.delete(params);
12694
+ }
12695
+ /** Adds a reaction with an optimistic local state update. */
12696
+ async addReactionWithLocalUpdate(params) {
12697
+ await this.messageOperations.addReactionWithLocalUpdate(params);
12698
+ }
12699
+ /** Removes the current user's reaction with an optimistic local state update. */
12700
+ async deleteReactionWithLocalUpdate(params) {
12701
+ await this.messageOperations.deleteReactionWithLocalUpdate(params);
12702
+ }
12703
+ }
12704
+ return WithMessageOperationsMixin;
12705
+ };
12706
+
12707
+ // src/messageOperations/reflectReactionEvent.ts
12708
+ var reflectReactionEvent = (client, {
12709
+ enforceUnique = false,
12710
+ message,
12711
+ reaction,
12712
+ removed = false
12713
+ }) => {
12714
+ const store = client.messageStore;
12715
+ if (!store.has(message.id)) return;
12716
+ const formatted = formatMessage(message);
12717
+ const existing = store.get(message.id);
12718
+ store.upsert({
12719
+ ...formatted,
12720
+ own_reactions: computeOwnReactions({
12721
+ current: existing?.own_reactions ?? formatted.own_reactions ?? [],
12722
+ enforceUnique,
12723
+ reaction,
12724
+ removed,
12725
+ userId: client.userId
12726
+ })
12727
+ });
12728
+ };
12729
+
12412
12730
  // src/configuration/utils/declarativeSlices.ts
12413
12731
  var mergeDeclarativeSlice = (general, specific) => {
12414
12732
  if (!general) return specific;
@@ -12434,7 +12752,7 @@ var DEFAULT_PAGE_LIMIT = 50;
12434
12752
  var DEFAULT_SORT2 = [{ field: "created_at", direction: -1 }];
12435
12753
  var DEFAULT_ITEM_ORDER = [{ field: "created_at", direction: 1 }];
12436
12754
  var DEFAULT_THREAD_CONFIG = deepFreezeConfig({});
12437
- var Thread = class extends WithSubscriptions {
12755
+ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12438
12756
  constructor({
12439
12757
  client,
12440
12758
  threadData,
@@ -12786,24 +13104,7 @@ var Thread = class extends WithSubscriptions {
12786
13104
  const unsubscribeReactions = reactionTypes.map(
12787
13105
  (eventType) => this.client.on(eventType, (event) => {
12788
13106
  if (!event.message || !event.reaction) return;
12789
- const { message, reaction } = event;
12790
- if (message.parent_id === this.id) {
12791
- this.messagePaginator.reflectReaction({
12792
- enforceUnique: eventType === "reaction.updated",
12793
- message,
12794
- reaction,
12795
- removed: eventType === "reaction.deleted"
12796
- });
12797
- } else if (!message.parent_id && message.id === this.id) {
12798
- const own_reactions = computeOwnReactions({
12799
- current: this.state.getLatestValue().parentMessage?.own_reactions ?? message.own_reactions ?? [],
12800
- enforceUnique: eventType === "reaction.updated",
12801
- reaction,
12802
- removed: eventType === "reaction.deleted",
12803
- userId: this.client.userId
12804
- });
12805
- this.updateParentMessageLocally({ message: { ...message, own_reactions } });
12806
- }
13107
+ const { message } = event;
12807
13108
  this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(message));
12808
13109
  }).unsubscribe
12809
13110
  );
@@ -12869,6 +13170,7 @@ var Thread = class extends WithSubscriptions {
12869
13170
  } else if (this.failedRepliesMap.has(message.id)) {
12870
13171
  this.failedRepliesMap.delete(message.id);
12871
13172
  }
13173
+ if (this.messagePaginator.getItem(formattedMessage.id) === formattedMessage) return;
12872
13174
  this.messagePaginator.ingestItem(formattedMessage);
12873
13175
  };
12874
13176
  // todo: can be removed with the next breaking change and use MessagePaginator only
@@ -13008,77 +13310,7 @@ var Thread = class extends WithSubscriptions {
13008
13310
  composition: threadData?.draft ?? draft,
13009
13311
  compositionContext: this
13010
13312
  });
13011
- this.messageOperations = new MessageOperations({
13012
- ...createMessageOperationsPersistence({ channel: this.channel }),
13013
- ingest: (m) => {
13014
- const store = this.channel.getClient().messageStore;
13015
- if (this.messagePaginator.matchesFilter(m)) {
13016
- this.messagePaginator.ingestItem(m);
13017
- } else if (store.has(m.id)) {
13018
- store.upsert(m);
13019
- }
13020
- store.flushSubscribers(m.id);
13021
- },
13022
- // Mirrors `ingest`'s routing: a message this paginator does not hold can still be held by the
13023
- // client-global store (a thread parent, a message displayed by another collection), and the
13024
- // policy uses this both for its freshness comparison and to decide whether there is anything to
13025
- // update optimistically at all. Reading only the paginator would make those two disagree.
13026
- get: (id) => this.messagePaginator.getItem(id) ?? this.channel.getClient().messageStore.get(id),
13027
- remove: (id) => {
13028
- this.messagePaginator.removeItem({ id });
13029
- this.channel.messagePaginator.removeItem({ id });
13030
- this.channel.pinnedMessagesPaginator.removeItem({ id });
13031
- },
13032
- normalizeOutgoingMessage: (m) => ({
13033
- ...m,
13034
- parent_id: this.id
13035
- }),
13036
- handlers: () => {
13037
- const { requestHandlers } = this.channel.configState.getLatestValue();
13038
- const deleteMessageRequest = requestHandlers?.deleteMessageRequest;
13039
- const sendMessageRequest = requestHandlers?.sendMessageRequest;
13040
- const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest;
13041
- const updateMessageRequest = requestHandlers?.updateMessageRequest;
13042
- return {
13043
- delete: deleteMessageRequest ? (p) => deleteMessageRequest({
13044
- localMessage: p.localMessage,
13045
- options: p.options
13046
- }) : void 0,
13047
- send: sendMessageRequest ? (p) => sendMessageRequest({
13048
- localMessage: p.localMessage,
13049
- message: p.message,
13050
- options: p.options
13051
- }) : void 0,
13052
- retry: retrySendMessageRequest ? (p) => retrySendMessageRequest({
13053
- localMessage: p.localMessage,
13054
- message: p.message,
13055
- options: p.options
13056
- }) : void 0,
13057
- update: updateMessageRequest ? (p) => updateMessageRequest({
13058
- localMessage: p.localMessage,
13059
- options: p.options
13060
- }) : void 0
13061
- };
13062
- },
13063
- defaults: {
13064
- delete: async (id, o) => {
13065
- const result = await this.channel.getClient().deleteMessage({ id, ...o });
13066
- return { message: result.message };
13067
- },
13068
- send: async (m, o) => {
13069
- const result = await this.channel.sendMessage({ message: m, ...o });
13070
- return { message: result.message };
13071
- },
13072
- update: async (m, o) => {
13073
- const result = await this.channel.getClient().updateMessage({
13074
- id: m.id,
13075
- message: localMessageToNewMessagePayload(m),
13076
- ...o
13077
- });
13078
- return { message: result.message };
13079
- }
13080
- }
13081
- });
13313
+ this.messageOperations = createMessageOperations(this);
13082
13314
  this.initializeConfig(declarativeConfig);
13083
13315
  }
13084
13316
  /**
@@ -13133,90 +13365,6 @@ var Thread = class extends WithSubscriptions {
13133
13365
  get ownUnreadCount() {
13134
13366
  return ownUnreadCountSelector(this.client.userId)(this.state.getLatestValue());
13135
13367
  }
13136
- /**
13137
- * Sends a message with optimistic local state update.
13138
- */
13139
- async sendMessageWithLocalUpdate({
13140
- localMessage,
13141
- message,
13142
- options,
13143
- sendMessageRequestFn
13144
- }) {
13145
- await this.messageOperations.send(
13146
- {
13147
- localMessage,
13148
- message,
13149
- options
13150
- },
13151
- sendMessageRequestFn
13152
- );
13153
- }
13154
- /**
13155
- * Retry sending a failed message.
13156
- */
13157
- async retrySendMessageWithLocalUpdate(params) {
13158
- await this.messageOperations.retry(
13159
- {
13160
- localMessage: { ...params.localMessage, type: "regular" },
13161
- options: params.options
13162
- },
13163
- params.sendMessageRequestFn
13164
- );
13165
- }
13166
- /**
13167
- * Updates a message with optimistic local state update.
13168
- *
13169
- * The update flows through `messagePaginator`, which is the sole reply source.
13170
- */
13171
- async updateMessageWithLocalUpdate(params) {
13172
- await this.messageOperations.update(
13173
- {
13174
- localMessage: params.localMessage,
13175
- options: params.options
13176
- },
13177
- params.updateMessageRequestFn
13178
- );
13179
- }
13180
- /**
13181
- * Deletes a message with local state update.
13182
- */
13183
- async deleteMessageWithLocalUpdate(params) {
13184
- await this.messageOperations.delete(
13185
- {
13186
- localMessage: params.localMessage,
13187
- options: params.options
13188
- },
13189
- params.deleteMessageRequestFn
13190
- );
13191
- }
13192
- /**
13193
- * Adds a reaction to a reply with an optimistic local state update — see
13194
- * {@link addReactionOptimistically}, which `Channel` shares. The request routes through the parent
13195
- * channel because reactions are channel-level, while the local write is addressed by message id and
13196
- * so reaches a pure reply no channel collection holds.
13197
- */
13198
- async addReactionWithLocalUpdate({
13199
- messageId,
13200
- reaction,
13201
- options
13202
- }) {
13203
- await addReactionOptimistically({
13204
- channel: this.channel,
13205
- messageId,
13206
- options,
13207
- reaction
13208
- });
13209
- }
13210
- /**
13211
- * Removes the current user's reaction from a reply with an optimistic local state update — see
13212
- * {@link deleteReactionOptimistically}, which `Channel` shares.
13213
- */
13214
- async deleteReactionWithLocalUpdate({
13215
- messageId,
13216
- type
13217
- }) {
13218
- await deleteReactionOptimistically({ channel: this.channel, messageId, type });
13219
- }
13220
13368
  };
13221
13369
  var normalizeThreadParticipants = (participants, channelCid) => {
13222
13370
  if (!participants) return void 0;
@@ -13808,6 +13956,99 @@ var _MessageComposer = class _MessageComposer extends WithSubscriptions {
13808
13956
  throw error;
13809
13957
  }
13810
13958
  };
13959
+ /**
13960
+ * Lets go of what the submitted message took with it.
13961
+ *
13962
+ * A poll message carries no text or attachments of its own, so the composer keeps whatever else
13963
+ * was drafted and releases only the poll. Everything else is a full clear.
13964
+ */
13965
+ this.releaseSubmittedComposition = () => {
13966
+ if (this.retainsCompositionOnSubmit) {
13967
+ this.state.partialNext({
13968
+ id: _MessageComposer.generateId(),
13969
+ // Unconditional, and safe whatever decided to retain: a poll that exists is always a poll
13970
+ // that just went out, because `createMessageComposerStateCompositionMiddleware` copies a
13971
+ // set `pollId` onto every composition - threads and edits included, where the poll-only
13972
+ // middleware forwards. There is no state where this detaches one the submission did not
13973
+ // carry.
13974
+ //
13975
+ // Detaching it is also not optional. While `pollId` is set, `hasSendableData` counts it as
13976
+ // content on its own and `createPollOnlyCompositionMiddleware` turns every composition
13977
+ // into a poll-only message, so a composer left bound to a spent poll nullifies whatever is
13978
+ // typed next and re-sends the same poll.
13979
+ pollId: null
13980
+ });
13981
+ return;
13982
+ }
13983
+ this.clear();
13984
+ };
13985
+ this.reportSubmitFailure = (type, message, error) => {
13986
+ this.client.notifications.addError({
13987
+ message,
13988
+ origin: {
13989
+ emitter: "MessageComposer",
13990
+ context: { composer: this }
13991
+ },
13992
+ options: {
13993
+ type,
13994
+ metadata: { reason: error.message },
13995
+ originalError: error instanceof Error ? error : void 0
13996
+ }
13997
+ });
13998
+ };
13999
+ /**
14000
+ * Composes the message and sends it, releasing the composer as it goes.
14001
+ *
14002
+ * Never rejects - see {@link MessageComposerSubmitResult} for what each outcome means and where
14003
+ * the content ends up. A failure is reported through `client.notifications`, and the message
14004
+ * stays in the list marked `failed`, which is where the user retries it. Callers rendering their
14005
+ * own post-send feedback have to check the result.
14006
+ */
14007
+ this.send = async () => {
14008
+ const composition = await this.compose();
14009
+ if (!composition?.message) return "nothing-to-send";
14010
+ const { localMessage, message, sendOptions } = composition;
14011
+ this.releaseSubmittedComposition();
14012
+ try {
14013
+ await this.defaultSubmitTarget.sendMessageWithLocalUpdate({
14014
+ localMessage,
14015
+ message,
14016
+ options: sendOptions
14017
+ });
14018
+ return "sent";
14019
+ } catch (error) {
14020
+ this.reportSubmitFailure(
14021
+ CORE_NOTIFICATION_TYPE.messageSendFailed,
14022
+ "Send message request failed",
14023
+ error
14024
+ );
14025
+ return "failed";
14026
+ }
14027
+ };
14028
+ /**
14029
+ * Composes the edit and saves it, on the same terms as {@link send} - including releasing the
14030
+ * composer before the request rather than after it.
14031
+ */
14032
+ this.update = async () => {
14033
+ const composition = await this.compose();
14034
+ if (!composition?.message) return "nothing-to-send";
14035
+ const { localMessage, sendOptions } = composition;
14036
+ this.releaseSubmittedComposition();
14037
+ try {
14038
+ await this.defaultSubmitTarget.updateMessageWithLocalUpdate({
14039
+ localMessage,
14040
+ options: sendOptions
14041
+ });
14042
+ return "sent";
14043
+ } catch (error) {
14044
+ this.reportSubmitFailure(
14045
+ CORE_NOTIFICATION_TYPE.messageUpdateFailed,
14046
+ "Edit message request failed",
14047
+ error
14048
+ );
14049
+ return "failed";
14050
+ }
14051
+ };
13811
14052
  this.sendLocation = async () => {
13812
14053
  const location = this.locationComposer.validLocation;
13813
14054
  if (this.threadId || !location) return;
@@ -13900,9 +14141,6 @@ var _MessageComposer = class _MessageComposer extends WithSubscriptions {
13900
14141
  if (compositionContext instanceof Thread) {
13901
14142
  return "thread";
13902
14143
  }
13903
- if (typeof compositionContext.legacyThreadId === "string") {
13904
- return "legacy_thread";
13905
- }
13906
14144
  return "message";
13907
14145
  }
13908
14146
  static constructTag(compositionContext) {
@@ -13938,9 +14176,6 @@ var _MessageComposer = class _MessageComposer extends WithSubscriptions {
13938
14176
  if (this.compositionContext instanceof Thread) {
13939
14177
  return this.compositionContext.id;
13940
14178
  }
13941
- if (typeof this.compositionContext.legacyThreadId === "string") {
13942
- return this.compositionContext.legacyThreadId;
13943
- }
13944
14179
  if (typeof this.compositionContext.parent_id === "string") {
13945
14180
  return this.compositionContext.parent_id;
13946
14181
  }
@@ -13972,27 +14207,41 @@ var _MessageComposer = class _MessageComposer extends WithSubscriptions {
13972
14207
  return !currentCommand || this.validateCommandSendability(currentCommand).ready;
13973
14208
  }
13974
14209
  /**
13975
- * Whether the installed composition middleware composes messages whose uploads are still in
13976
- * flight — see `MessageCompositionMiddleware.allowsPendingUploads`.
14210
+ * Whether a message may be sent while its attachments are still uploading — see
14211
+ * {@link AttachmentManagerConfig.pendingUploadsEnabled}.
14212
+ */
14213
+ get allowsPendingUploads() {
14214
+ return this.attachmentManager.config.pendingUploadsEnabled;
14215
+ }
14216
+ /**
14217
+ * Where a composed message goes: the thread when this composer belongs to one, the channel
14218
+ * otherwise.
13977
14219
  *
13978
- * Derived from the chain rather than from config, so there is exactly one switch: the UI SDK
13979
- * that implements the rest of the flow installs the middleware, and everything that has to
13980
- * follow from that (starting with {@link hasSendableData}) follows on its own.
14220
+ * Thread replies live in `thread.messagePaginator`, which is independent of the channel's, so
14221
+ * dispatching to the wrong one leaves the reply invisible where it was written. An edit composer
14222
+ * is built from the message rather than from its thread, so it can only find the thread when the
14223
+ * manager has it loaded. No composer reachable from a UI SDK is built that way today.
14224
+ */
14225
+ get defaultSubmitTarget() {
14226
+ if (this.compositionContext instanceof Thread) return this.compositionContext;
14227
+ const { threadId } = this;
14228
+ return threadId && this.client.threads.threadsById[threadId] || this.channel;
14229
+ }
14230
+ /**
14231
+ * Whether submitting keeps the composer's contents rather than clearing them - see
14232
+ * {@link MessageComposerConfig.retainCompositionOnSubmit}.
13981
14233
  *
13982
- * **Temporary API.** In v10 sending with pending uploads becomes part of the composer
13983
- * configuration (`MessageComposerConfig`, set through `updateConfig`), and this getter,
13984
- * together with the middleware declaration it reads, is replaced by that config field.
14234
+ * Read by the attachments composition step too: content the composer keeps must not also ride
14235
+ * along on the submitted message, or one `localMetadata.id` ends up owned in two places.
13985
14236
  */
13986
- get allowsPendingUploads() {
13987
- return this.compositionMiddlewareExecutor.installedMiddleware.some(
13988
- (middleware) => middleware.allowsPendingUploads
13989
- );
14237
+ get retainsCompositionOnSubmit() {
14238
+ return this.config.retainCompositionOnSubmit(this);
13990
14239
  }
13991
14240
  get hasSendableData() {
13992
14241
  if (!this.isCommandSendable) return false;
13993
14242
  if (this.allowsPendingUploads) {
13994
14243
  const hasSendableAttachment = this.attachmentManager.attachments.some(
13995
- (attachment) => isFinishedUpload(attachment) || isPendingUpload(attachment)
14244
+ (attachment) => isFinishedUpload(attachment) || isPendingUpload(attachment) || isFailedUpload(attachment)
13996
14245
  );
13997
14246
  return !!(!this.textComposer.textIsEmpty || hasSendableAttachment || this.pollId || this.locationComposer.validLocation);
13998
14247
  }
@@ -14462,7 +14711,9 @@ var MessageReceiptsTracker = class extends WithSubscriptions {
14462
14711
  this.snapshotStore = new import_state_store20.StateStore({
14463
14712
  revision: 0,
14464
14713
  readersByMessageId: {},
14465
- deliveredByMessageId: {}
14714
+ deliveredByMessageId: {},
14715
+ lastDeliveredRefByOthers: null,
14716
+ lastReadRefByOthers: null
14466
14717
  });
14467
14718
  this.registerSubscriptions = () => {
14468
14719
  this.incrementRefCount();
@@ -14658,6 +14909,21 @@ var MessageReceiptsTracker = class extends WithSubscriptions {
14658
14909
  );
14659
14910
  return this.deliveredSorted.slice(pos).map((x) => x.user);
14660
14911
  }
14912
+ /**
14913
+ * Whether at least one other member has this message delivered.
14914
+ *
14915
+ * Reads the single furthest cursor rather than asking who is past this message, so the answer is
14916
+ * monotonic by construction: an older message can never report less than a newer one.
14917
+ */
14918
+ isDeliveredToOthers(msgRef) {
14919
+ const ref = this.snapshotStore.getLatestValue().lastDeliveredRefByOthers;
14920
+ return !!ref && compareRefsAsc(msgRef, ref) <= 0;
14921
+ }
14922
+ /** As {@link isDeliveredToOthers}, for reads. */
14923
+ isReadByOthers(msgRef) {
14924
+ const ref = this.snapshotStore.getLatestValue().lastReadRefByOthers;
14925
+ return !!ref && compareRefsAsc(msgRef, ref) <= 0;
14926
+ }
14661
14927
  /** Users who delivered but have NOT read. */
14662
14928
  deliveredNotReadForMessage(msgRef) {
14663
14929
  const pos = findIndex(
@@ -14833,6 +15099,19 @@ var MessageReceiptsTracker = class extends WithSubscriptions {
14833
15099
  []
14834
15100
  );
14835
15101
  }
15102
+ /**
15103
+ * The furthest ref in an ascending-by-cursor array, ignoring this user's own progress - your own
15104
+ * cursor says nothing about whether anyone received the message.
15105
+ */
15106
+ furthestRefByOthers(sorted, refOf) {
15107
+ const ownUserId = this.channel.getClient().userID;
15108
+ for (let i = sorted.length - 1; i >= 0; i -= 1) {
15109
+ if (sorted[i].user.id === ownUserId) continue;
15110
+ const ref = refOf(sorted[i]);
15111
+ return Number.isFinite(ref.timestamp) ? ref : null;
15112
+ }
15113
+ return null;
15114
+ }
14836
15115
  emitSnapshot() {
14837
15116
  const readersByMessageId = this.groupUsersByLastReadMessage();
14838
15117
  const deliveredByMessageId = this.groupUsersByLastDeliveredMessage();
@@ -14840,7 +15119,15 @@ var MessageReceiptsTracker = class extends WithSubscriptions {
14840
15119
  this.snapshotStore.next({
14841
15120
  revision: currentSnapshot.revision + 1,
14842
15121
  readersByMessageId,
14843
- deliveredByMessageId
15122
+ deliveredByMessageId,
15123
+ lastDeliveredRefByOthers: this.furthestRefByOthers(
15124
+ this.deliveredSorted,
15125
+ ({ lastDeliveredRef }) => lastDeliveredRef
15126
+ ),
15127
+ lastReadRefByOthers: this.furthestRefByOthers(
15128
+ this.readSorted,
15129
+ ({ lastReadRef }) => lastReadRef
15130
+ )
14844
15131
  });
14845
15132
  }
14846
15133
  };
@@ -15164,7 +15451,7 @@ var DEFAULT_CHANNEL_CONFIG = deepFreezeConfig({
15164
15451
  typingEvents: { enabled: true },
15165
15452
  userMessageReminders: { enabled: true }
15166
15453
  });
15167
- var Channel = class extends ChannelApi {
15454
+ var Channel = class extends WithMessageOperations(ChannelApi) {
15168
15455
  /**
15169
15456
  * Creates a `Channel` instance bound to the given chat client.
15170
15457
  *
@@ -15277,78 +15564,7 @@ var Channel = class extends ChannelApi {
15277
15564
  this.messageReceiptsTracker.registerSubscriptions();
15278
15565
  this.cooldownTimer = new CooldownTimer({ channel: this });
15279
15566
  this.cooldownTimer.registerSubscriptions();
15280
- this.messageOperations = new MessageOperations({
15281
- ...createMessageOperationsPersistence({ channel: this }),
15282
- ingest: (m) => {
15283
- const store = this.getClient().messageStore;
15284
- if (this.messagePaginator.matchesFilter(m)) {
15285
- this.messagePaginator.ingestItem(m);
15286
- } else if (store.has(m.id)) {
15287
- store.upsert(m);
15288
- }
15289
- store.flushSubscribers(m.id);
15290
- },
15291
- // Mirrors `ingest`'s routing: a message this paginator does not hold can still be held by the
15292
- // client-global store (a thread parent, a message displayed by another collection), and the
15293
- // policy uses this both for its freshness comparison and to decide whether there is anything to
15294
- // update optimistically at all. Reading only the paginator would make those two disagree.
15295
- get: (id2) => this.messagePaginator.getItem(id2) ?? this.getClient().messageStore.get(id2),
15296
- remove: (id2) => {
15297
- const parentId = this.messagePaginator.getItem(id2)?.parent_id ?? this.getClient().messageStore.get(id2)?.parent_id;
15298
- this.messagePaginator.removeItem({ id: id2 });
15299
- this.pinnedMessagesPaginator.removeItem({ id: id2 });
15300
- if (parentId) {
15301
- this.getClient().threads.threadsById[parentId]?.messagePaginator.removeItem({
15302
- id: id2
15303
- });
15304
- }
15305
- },
15306
- handlers: () => {
15307
- const { requestHandlers } = this.configState.getLatestValue();
15308
- const deleteMessageRequest = requestHandlers?.deleteMessageRequest;
15309
- const sendMessageRequest = requestHandlers?.sendMessageRequest;
15310
- const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest;
15311
- const updateMessageRequest = requestHandlers?.updateMessageRequest;
15312
- return {
15313
- delete: deleteMessageRequest ? (p) => deleteMessageRequest({
15314
- localMessage: p.localMessage,
15315
- options: p.options
15316
- }) : void 0,
15317
- send: sendMessageRequest ? (p) => sendMessageRequest({
15318
- localMessage: p.localMessage,
15319
- message: p.message,
15320
- options: p.options
15321
- }) : void 0,
15322
- retry: retrySendMessageRequest ? (p) => retrySendMessageRequest({
15323
- localMessage: p.localMessage,
15324
- message: p.message,
15325
- options: p.options
15326
- }) : void 0,
15327
- update: updateMessageRequest ? (p) => updateMessageRequest({
15328
- localMessage: p.localMessage,
15329
- options: p.options
15330
- }) : void 0
15331
- };
15332
- },
15333
- defaults: {
15334
- delete: async (id2, o) => {
15335
- const result = await this.getClient().deleteMessage({ id: id2, ...o });
15336
- return { message: result.message };
15337
- },
15338
- send: async (m, o) => {
15339
- const result = await this.sendMessage({ message: m, ...o });
15340
- return { message: result.message };
15341
- },
15342
- update: async (m, o) => {
15343
- const result = await this.getClient().updateMessage({
15344
- id: m.id,
15345
- message: localMessageToNewMessagePayload(m),
15346
- ...o
15347
- });
15348
- return { message: result.message };
15349
- }
15350
- }
15351
- });
15567
+ this.messageOperations = createMessageOperations(this);
15352
15568
  this._syncMuteStatus();
15353
15569
  this.configController = new ConfigController({
15354
15570
  defaults: DEFAULT_CHANNEL_CONFIG,
@@ -15529,74 +15745,16 @@ var Channel = class extends ChannelApi {
15529
15745
  });
15530
15746
  }
15531
15747
  /**
15532
- * Sends a message with optimistic local state update.
15748
+ * Sends a message with optimistic local state update, stopping the typing indicator first.
15749
+ *
15750
+ * The only collection-specific part of the optimistic API: typing is a channel concern and a
15751
+ * `Thread` has no counterpart, so it is an override rather than part of the mixin.
15533
15752
  */
15534
15753
  async sendMessageWithLocalUpdate(params) {
15535
- await this.messageOperations.send(
15536
- {
15537
- localMessage: params.localMessage,
15538
- message: params.message,
15539
- options: params.options
15540
- },
15541
- params.sendMessageRequestFn
15542
- );
15543
- if (this.messageComposer.config.text.publishTypingEvents) await this.stopTyping();
15544
- }
15545
- /**
15546
- * Retry sending a failed message.
15547
- */
15548
- async retrySendMessageWithLocalUpdate(params) {
15549
- await this.messageOperations.retry(
15550
- {
15551
- localMessage: { ...params.localMessage, type: "regular" },
15552
- options: params.options
15553
- },
15554
- params.sendMessageRequestFn
15555
- );
15556
- }
15557
- /**
15558
- * Updates a message with optimistic local state update.
15559
- */
15560
- async updateMessageWithLocalUpdate(params) {
15561
- await this.messageOperations.update(
15562
- {
15563
- localMessage: params.localMessage,
15564
- options: params.options
15565
- },
15566
- params.updateMessageRequestFn
15567
- );
15568
- }
15569
- /**
15570
- * Deletes a message with local state update.
15571
- */
15572
- async deleteMessageWithLocalUpdate(params) {
15573
- await this.messageOperations.delete(
15574
- {
15575
- localMessage: params.localMessage,
15576
- options: params.options
15577
- },
15578
- params.deleteMessageRequestFn
15579
- );
15580
- }
15581
- /**
15582
- * Adds a reaction with an optimistic local state update - see {@link addReactionOptimistically}.
15583
- */
15584
- async addReactionWithLocalUpdate({
15585
- messageId,
15586
- reaction,
15587
- options
15588
- }) {
15589
- await addReactionOptimistically({ channel: this, messageId, options, reaction });
15590
- }
15591
- /**
15592
- * Removes the current user's reaction with an optimistic local state update - see
15593
- * {@link deleteReactionOptimistically}.
15594
- */
15595
- async deleteReactionWithLocalUpdate({
15596
- messageId,
15597
- type
15598
- }) {
15599
- await deleteReactionOptimistically({ channel: this, messageId, type });
15754
+ if (this.messageComposer.config.text.publishTypingEvents) {
15755
+ this.stopTyping().catch(() => void 0);
15756
+ }
15757
+ await super.sendMessageWithLocalUpdate(params);
15600
15758
  }
15601
15759
  /**
15602
15760
  * Upload a file to this channel's file endpoint (multipart).
@@ -16927,8 +17085,8 @@ var Channel = class extends ChannelApi {
16927
17085
  this.messagePaginator.removeItem({ id: event.message.id });
16928
17086
  this.pinnedMessagesPaginator.removeItem({ id: event.message.id });
16929
17087
  } else {
16930
- this.messagePaginator.ingestItem(formattedMessage);
16931
- this.pinnedMessagesPaginator.ingestItem(formattedMessage);
17088
+ const store = this.getClient().messageStore;
17089
+ if (store.has(event.message.id)) store.upsert(formattedMessage);
16932
17090
  }
16933
17091
  }
16934
17092
  this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage);
@@ -16957,8 +17115,9 @@ var Channel = class extends ChannelApi {
16957
17115
  const ownMessage = event.user?.id === client.user?.id;
16958
17116
  const isThreadMessage = event.message.parent_id && !event.message.show_in_channel;
16959
17117
  if (!isThreadMessage) {
16960
- this.messagePaginator.ingestItem(formatMessage(event.message));
16961
- this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message));
17118
+ const formattedMessage = formatMessage(event.message);
17119
+ this.messagePaginator.ingestItem(formattedMessage);
17120
+ this.pinnedMessagesPaginator.ingestItem(formattedMessage);
16962
17121
  }
16963
17122
  const preventUnreadCountUpdate = ownMessage || isThreadMessage;
16964
17123
  if (preventUnreadCountUpdate) break;
@@ -17038,8 +17197,9 @@ var Channel = class extends ChannelApi {
17038
17197
  this.pinnedMessagesPaginator.clearStateAndCache();
17039
17198
  }
17040
17199
  if (event.message) {
17041
- this.messagePaginator.ingestItem(formatMessage(event.message));
17042
- this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message));
17200
+ const formattedMessage = formatMessage(event.message);
17201
+ this.messagePaginator.ingestItem(formattedMessage);
17202
+ this.pinnedMessagesPaginator.ingestItem(formattedMessage);
17043
17203
  }
17044
17204
  break;
17045
17205
  }
@@ -17120,49 +17280,16 @@ var Channel = class extends ChannelApi {
17120
17280
  }
17121
17281
  break;
17122
17282
  case "reaction.new":
17123
- if (event.message && event.reaction) {
17124
- const { reaction } = event;
17125
- if (!event.message?.parent_id || event.message.show_in_channel) {
17126
- this.messagePaginator.reflectReaction({ message: event.message, reaction });
17127
- this.pinnedMessagesPaginator.reflectReaction({
17128
- message: event.message,
17129
- reaction
17130
- });
17131
- }
17132
- }
17133
- break;
17134
- case "reaction.deleted":
17135
- if (event.message && event.reaction) {
17136
- const { reaction } = event;
17137
- if (event.message && (!event.message.parent_id || event.message.show_in_channel)) {
17138
- this.messagePaginator.reflectReaction({
17139
- message: event.message,
17140
- reaction,
17141
- removed: true
17142
- });
17143
- this.pinnedMessagesPaginator.reflectReaction({
17144
- message: event.message,
17145
- reaction,
17146
- removed: true
17147
- });
17148
- }
17149
- }
17150
- break;
17151
17283
  case "reaction.updated":
17284
+ case "reaction.deleted":
17152
17285
  if (event.message && event.reaction) {
17153
- const { reaction } = event;
17154
- if (!event.message?.parent_id || event.message.show_in_channel) {
17155
- this.messagePaginator.reflectReaction({
17156
- enforceUnique: true,
17157
- message: event.message,
17158
- reaction
17159
- });
17160
- this.pinnedMessagesPaginator.reflectReaction({
17161
- enforceUnique: true,
17162
- message: event.message,
17163
- reaction
17164
- });
17165
- }
17286
+ reflectReactionEvent(this.getClient(), {
17287
+ // reaction.updated is only sent when enforce_unique is set
17288
+ enforceUnique: event.type === "reaction.updated",
17289
+ message: event.message,
17290
+ reaction: event.reaction,
17291
+ removed: event.type === "reaction.deleted"
17292
+ });
17166
17293
  }
17167
17294
  break;
17168
17295
  case "channel.hidden": {
@@ -24277,7 +24404,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24277
24404
  return this.userAgent;
24278
24405
  }
24279
24406
  if (!this.cachedUserAgent) {
24280
- const version = "10.0.0-rc.12";
24407
+ const version = "10.0.0-rc.13";
24281
24408
  const { name: sdkName, version: sdkVersion } = this.sdkIdentifier ?? {};
24282
24409
  const { name: appName, version: appVersion } = this.appIdentifier ?? {};
24283
24410
  const { os, model: deviceModel } = this.deviceIdentifier ?? {};
@@ -25473,6 +25600,11 @@ var ATTACHMENTS_FIELDS = {
25473
25600
  kind: "value",
25474
25601
  type: "number"
25475
25602
  },
25603
+ pendingUploadsEnabled: {
25604
+ description: "Lets a message be sent while its attachments are still uploading. The message appears immediately and the request waits for the uploads; one failed upload fails the message.",
25605
+ kind: "value",
25606
+ type: "boolean"
25607
+ },
25476
25608
  trackUploadProgress: {
25477
25609
  description: "Reports upload progress on each attachment. Turning it off skips the progress bookkeeping.",
25478
25610
  kind: "value",
@@ -25593,6 +25725,11 @@ var MESSAGE_COMPOSER_FIELDS = {
25593
25725
  fields: POLLS_FIELDS,
25594
25726
  kind: "group"
25595
25727
  },
25728
+ retainCompositionOnSubmit: {
25729
+ description: "Decides whether submitting keeps the composer's contents instead of clearing them. Retains for a poll message by default.",
25730
+ kind: "value",
25731
+ type: "function"
25732
+ },
25596
25733
  text: { description: "The text input itself.", fields: TEXT_FIELDS, kind: "group" }
25597
25734
  };
25598
25735
  var MESSAGE_DELIVERY_FIELDS = {