stream-chat 10.0.0-rc.15 → 10.0.0-rc.17

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 (54) hide show
  1. package/dist/cjs/index.browser.js +855 -336
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +858 -336
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +853 -334
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/api-client.d.ts +6 -1
  8. package/dist/types/client.d.ts +46 -12
  9. package/dist/types/connection/ConnectionIdManager.d.ts +9 -5
  10. package/dist/types/connection/ConnectionRecoveryManager.d.ts +17 -24
  11. package/dist/types/connection/networkConnection/NetworkConnectionObserver.d.ts +8 -0
  12. package/dist/types/connection/networkConnection/reporters.d.ts +4 -4
  13. package/dist/types/connection/wsConnection/WSConnection.d.ts +34 -9
  14. package/dist/types/connection/wsConnection/WSConnectionFallback.d.ts +55 -0
  15. package/dist/types/connection/wsConnection/types.d.ts +17 -4
  16. package/dist/types/entityStore/EntityStore.d.ts +4 -0
  17. package/dist/types/errors.d.ts +1 -0
  18. package/dist/types/gen/chat/ChatApi.d.ts +1 -0
  19. package/dist/types/messageComposer/linkPreviewsManager.d.ts +3 -3
  20. package/dist/types/messageComposer/messageComposer.d.ts +2 -2
  21. package/dist/types/offline-support/offline_support_api.d.ts +5 -6
  22. package/dist/types/pagination/paginators/BasePaginator.d.ts +8 -1
  23. package/dist/types/pagination/paginators/ChannelPaginator.d.ts +5 -1
  24. package/dist/types/pagination/paginators/ThreadPaginator.d.ts +57 -0
  25. package/dist/types/pagination/paginators/index.d.ts +1 -0
  26. package/dist/types/thread.d.ts +30 -21
  27. package/dist/types/thread_manager.d.ts +74 -32
  28. package/dist/types/types.d.ts +8 -0
  29. package/package.json +1 -1
  30. package/src/api-client.ts +25 -24
  31. package/src/client.ts +97 -40
  32. package/src/configuration/shape.ts +7 -1
  33. package/src/connection/ConnectionIdManager.ts +9 -5
  34. package/src/connection/ConnectionRecoveryManager.ts +30 -30
  35. package/src/connection/networkConnection/NetworkConnectionObserver.ts +19 -1
  36. package/src/connection/networkConnection/reporters.ts +4 -4
  37. package/src/connection/wsConnection/WSConnection.ts +86 -14
  38. package/src/connection/wsConnection/WSConnectionFallback.ts +259 -0
  39. package/src/connection/wsConnection/config.ts +1 -0
  40. package/src/connection/wsConnection/types.ts +17 -4
  41. package/src/entityStore/EntityStore.ts +10 -0
  42. package/src/errors.ts +4 -0
  43. package/src/gen/chat/ChatApi.ts +2 -1
  44. package/src/messageComposer/messageComposer.ts +3 -3
  45. package/src/messageOperations/createMessageOperations.ts +3 -3
  46. package/src/offline-support/offline_support_api.ts +5 -6
  47. package/src/offline-support/queueableOperations.ts +1 -1
  48. package/src/pagination/paginators/BasePaginator.ts +45 -8
  49. package/src/pagination/paginators/ChannelPaginator.ts +17 -3
  50. package/src/pagination/paginators/ThreadPaginator.ts +180 -0
  51. package/src/pagination/paginators/index.ts +1 -0
  52. package/src/thread.ts +188 -220
  53. package/src/thread_manager.ts +257 -173
  54. package/src/types.ts +6 -0
@@ -71,6 +71,8 @@ __export(src_exports, {
71
71
  DEFAULT_STOP_REFRESH_BOUNDARY_MS: () => DEFAULT_STOP_REFRESH_BOUNDARY_MS,
72
72
  DEFAULT_TEXT_COMPOSER_CONFIG: () => DEFAULT_TEXT_COMPOSER_CONFIG,
73
73
  DEFAULT_THREAD_MANAGER_CONFIG: () => DEFAULT_THREAD_MANAGER_CONFIG,
74
+ DEFAULT_THREAD_PAGE_SIZE: () => DEFAULT_THREAD_PAGE_SIZE,
75
+ DEFAULT_THREAD_QUERY: () => DEFAULT_THREAD_QUERY,
74
76
  DEFAULT_WS_CONNECTION_CONFIG: () => DEFAULT_WS_CONNECTION_CONFIG,
75
77
  EntityStore: () => EntityStore,
76
78
  EventHandlerPipeline: () => EventHandlerPipeline,
@@ -125,6 +127,7 @@ __export(src_exports, {
125
127
  TextComposerMiddlewareExecutor: () => TextComposerMiddlewareExecutor,
126
128
  Thread: () => Thread,
127
129
  ThreadManager: () => ThreadManager,
130
+ ThreadPaginator: () => ThreadPaginator,
128
131
  TokenManager: () => TokenManager,
129
132
  UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT: () => UPDATE_LIVE_LOCATION_REQUEST_MIN_THROTTLE_TIMEOUT,
130
133
  UploadManager: () => UploadManager,
@@ -263,7 +266,7 @@ __export(src_exports, {
263
266
  module.exports = __toCommonJS(src_exports);
264
267
 
265
268
  // src/client.ts
266
- var import_axios4 = __toESM(require("axios"));
269
+ var import_axios5 = __toESM(require("axios"));
267
270
 
268
271
  // src/gen/models/index.ts
269
272
  var ChannelOwnCapability = {
@@ -1446,6 +1449,14 @@ var EntityStore = class {
1446
1449
  has(id) {
1447
1450
  return this.byId.has(id);
1448
1451
  }
1452
+ /** Whether `subscriber` is one of the holders of `id`. */
1453
+ isHeldBy(id, subscriber) {
1454
+ return this.subscribers.get(id)?.has(subscriber) ?? false;
1455
+ }
1456
+ /** Every entity currently in the store. */
1457
+ values() {
1458
+ return Array.from(this.byId.values());
1459
+ }
1449
1460
  // ---- writes ----
1450
1461
  /**
1451
1462
  * Replaces the canonical copy of `entity` and notifies every subscriber watching its id —
@@ -2613,6 +2624,16 @@ var BasePaginator = class {
2613
2624
  * {@link setIntervals}), which bypasses the per-interval {@link commitInterval}/{@link dropInterval}
2614
2625
  * publishing. No-ops when the views are already empty so a reset does not emit needlessly.
2615
2626
  */
2627
+ /**
2628
+ * Drops every loaded interval and item without publishing anything, so the next ingest starts from
2629
+ * empty storage while `state.items` keeps showing the previous window until the caller publishes.
2630
+ */
2631
+ clearItemStorage() {
2632
+ this.setIntervals([]);
2633
+ this.setActiveInterval(void 0);
2634
+ this._itemIndex.clear();
2635
+ this.clearIntervalViews();
2636
+ }
2616
2637
  clearIntervalViews() {
2617
2638
  this._pendingViewChangedIds.clear();
2618
2639
  const { logicalHead, logicalTail, anchoredHead } = this.intervalViews.getLatestValue();
@@ -4080,10 +4101,7 @@ var BasePaginator = class {
4080
4101
  if (!this.canExecuteQuery({ direction, reset })) return;
4081
4102
  const isForcedReset = reset === "yes" && !keepPreviousItems;
4082
4103
  if (isForcedReset) {
4083
- this.setIntervals([]);
4084
- this.setActiveInterval(void 0);
4085
- this._itemIndex.clear();
4086
- this.clearIntervalViews();
4104
+ this.clearItemStorage();
4087
4105
  this.state.next(this.getStateBeforeFirstQuery());
4088
4106
  } else if (reset === "yes" && !forcedQueryShape) {
4089
4107
  this.state.partialNext({
@@ -4091,7 +4109,9 @@ var BasePaginator = class {
4091
4109
  offset: this.config.initialOffset ?? 0
4092
4110
  });
4093
4111
  }
4094
- const queryShape = forcedQueryShape ?? this.getNextQueryShape({ direction });
4112
+ const isRefresh = reset === "yes" && keepPreviousItems && !forcedQueryShape;
4113
+ const pageSize = isRefresh ? Math.max(this.pageSize, this.items?.length ?? 0) : this.pageSize;
4114
+ const queryShape = forcedQueryShape ?? this.getNextQueryShape({ direction, pageSize });
4095
4115
  const isFirstPage = this.isFirstPageQuery({ queryShape, reset });
4096
4116
  if (isFirstPage && !keepPreviousItems) {
4097
4117
  const state = this.getStateBeforeFirstQuery();
@@ -4155,6 +4175,7 @@ var BasePaginator = class {
4155
4175
  const filteredItems = this.filterQueryResults(items);
4156
4176
  stateUpdate.items = filteredItems;
4157
4177
  const isJumpQuery = !!queryShape && this.isJumpQueryShape(queryShape);
4178
+ const keptItemIds = isFirstPage && keepPreviousItems && !this.isCursorPagination && this.items?.length ? new Set(this.items.map((item) => this.getItemId(item))) : void 0;
4158
4179
  const interval = this.ingestPage({
4159
4180
  page: stateUpdate.items,
4160
4181
  policy: isJumpQuery ? "strict-overlap-only" : "auto",
@@ -4196,7 +4217,15 @@ var BasePaginator = class {
4196
4217
  } else {
4197
4218
  const startOffset = this.offset ?? 0;
4198
4219
  stateUpdate.offset = startOffset + items.length;
4199
- stateUpdate.hasMoreTail = items.length === this.pageSize;
4220
+ const lastPageItem = items[items.length - 1];
4221
+ if (interval && keptItemIds && lastPageItem && keptItemIds.has(this.getItemId(lastPageItem))) {
4222
+ stateUpdate.offset = Math.max(
4223
+ stateUpdate.offset,
4224
+ startOffset + this.intervalToItems(interval).length
4225
+ );
4226
+ }
4227
+ stateUpdate.hasMoreTail = items.length >= this.pageSize;
4228
+ if (isFirstPage) stateUpdate.hasMoreHead = (this.config.initialOffset ?? 0) > 0;
4200
4229
  }
4201
4230
  if (interval) {
4202
4231
  const current = this.state.getLatestValue();
@@ -4224,7 +4253,7 @@ var BasePaginator = class {
4224
4253
  }
4225
4254
  const state = this.getStateAfterQuery(stateUpdate, isFirstPage);
4226
4255
  if (updateState) this.state.next(state);
4227
- this.populateOfflineDbAfterQuery({ items: state.items, queryShape });
4256
+ if (isFirstPage) this.populateOfflineDbAfterQuery({ items: state.items, queryShape });
4228
4257
  return {
4229
4258
  stateCandidate: state,
4230
4259
  targetInterval: interval
@@ -5854,7 +5883,7 @@ var QUEUEABLE_OPERATIONS = {
5854
5883
  const message = result?.message;
5855
5884
  if (!message) return;
5856
5885
  if (message.parent_id) {
5857
- client.threads.threadsById[message.parent_id]?.upsertReplyLocally({
5886
+ client.threads.get(message.parent_id)?.upsertReplyLocally({
5858
5887
  message,
5859
5888
  timestampChanged: true
5860
5889
  });
@@ -7715,6 +7744,9 @@ function isErrorRetryable(error) {
7715
7744
  if (!err) return false;
7716
7745
  return err.retryable;
7717
7746
  }
7747
+ function isConnectionIDError(error) {
7748
+ return error.code === 46;
7749
+ }
7718
7750
  function isEphemeral(error) {
7719
7751
  if ((0, import_axios2.isCancel)(error)) return false;
7720
7752
  if (!error.response) return true;
@@ -8153,6 +8185,8 @@ var ChannelPaginator = class extends BasePaginator {
8153
8185
  * continuation rather than a first page. See `executeQuery`.
8154
8186
  */
8155
8187
  this._itemsLoadedFromOfflineDB = false;
8188
+ /** The query in flight refreshes a cache-only window, so its response replaces the window. */
8189
+ this._replaceOfflineSeedOnResponse = false;
8156
8190
  /**
8157
8191
  * Filters sent to the server with the next query (and used as the offline-db query key). Deliberately
8158
8192
  * built from the locally configured filters only — feeding the backend-resolved filter back in would
@@ -8209,6 +8243,7 @@ var ChannelPaginator = class extends BasePaginator {
8209
8243
  });
8210
8244
  };
8211
8245
  this.query = async () => {
8246
+ if (this._itemsLoadedFromOfflineDB) this._replaceOfflineSeedOnResponse = true;
8212
8247
  this._itemsLoadedFromOfflineDB = false;
8213
8248
  if (!this._nextQueryShape) {
8214
8249
  this._nextQueryShape = this.getNextQueryShape();
@@ -8376,14 +8411,22 @@ var ChannelPaginator = class extends BasePaginator {
8376
8411
  if (params.isFirstPage && params.results) {
8377
8412
  this.applyPredefinedFilterResponse(pendingPredefinedFilter);
8378
8413
  }
8414
+ const replaceOfflineSeed = this._replaceOfflineSeedOnResponse;
8415
+ this._replaceOfflineSeedOnResponse = false;
8416
+ if (replaceOfflineSeed && params.results) {
8417
+ this.clearItemStorage();
8418
+ return super.postQueryReconcile({ ...params, keepPreviousItems: false });
8419
+ }
8379
8420
  return super.postQueryReconcile(params);
8380
8421
  }
8381
8422
  // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor;
8382
- getNextQueryShape() {
8423
+ getNextQueryShape({
8424
+ pageSize
8425
+ } = {}) {
8383
8426
  const shape = {
8384
8427
  filter_conditions: this.buildQueryFilters(),
8385
8428
  ...this.options,
8386
- limit: this.pageSize,
8429
+ limit: pageSize ?? this.pageSize,
8387
8430
  offset: this.offset
8388
8431
  };
8389
8432
  if (this.sort) {
@@ -8420,7 +8463,7 @@ var ChannelPaginator = class extends BasePaginator {
8420
8463
  }) {
8421
8464
  this.client.offlineDb?.executeQuerySafely(
8422
8465
  (db) => db.upsertCidsForQuery({
8423
- cids,
8466
+ cids: cids.slice(0, this.pageSize),
8424
8467
  filters: request.filter_conditions,
8425
8468
  options: request,
8426
8469
  predefinedFilter: this._predefinedFilter,
@@ -9022,6 +9065,130 @@ var UserGroupPaginator = class extends BasePaginator {
9022
9065
  }
9023
9066
  };
9024
9067
 
9068
+ // src/pagination/paginators/ThreadPaginator.ts
9069
+ var DEFAULT_THREAD_PAGE_SIZE = 25;
9070
+ var DEFAULT_THREAD_QUERY = {
9071
+ participant_limit: 10,
9072
+ reply_limit: 10,
9073
+ watch: true
9074
+ };
9075
+ var getQueryIdentity2 = (queryShape) => {
9076
+ if (!queryShape) return queryShape;
9077
+ const { limit: _, next: __, prev: ___, ...identity } = queryShape;
9078
+ return identity;
9079
+ };
9080
+ var hasPaginationQueryShapeChanged3 = (prevQueryShape, nextQueryShape) => !isEqual(getQueryIdentity2(prevQueryShape), getQueryIdentity2(nextQueryShape));
9081
+ var ThreadPaginator = class extends BasePaginator {
9082
+ constructor({
9083
+ client,
9084
+ store = new EntityStore({ getEntityId: (thread) => thread.id }),
9085
+ paginatorOptions
9086
+ }) {
9087
+ super({
9088
+ hasPaginationQueryShapeChanged: hasPaginationQueryShapeChanged3,
9089
+ initialCursor: ZERO_PAGE_CURSOR,
9090
+ itemIndex: new StoreBackedItemIndex({
9091
+ getEntityId: (thread) => thread.id,
9092
+ store
9093
+ }),
9094
+ pageSize: DEFAULT_THREAD_PAGE_SIZE,
9095
+ ...paginatorOptions
9096
+ });
9097
+ this.query = async ({
9098
+ queryShape
9099
+ }) => {
9100
+ const { threads, next } = await this.client.queryThreadsAndHydrate(queryShape);
9101
+ return { items: threads.map(this.toStoredInstance), tailward: next };
9102
+ };
9103
+ /**
9104
+ * The stored instance for a queried thread, so there's one per id. The index upserts, so a second
9105
+ * instance would replace the stored one. A stale stored thread is refreshed from the query, unless
9106
+ * it's open (active) at which point it reloads itself and this would replace the replies on screen.
9107
+ */
9108
+ this.toStoredInstance = (incoming) => {
9109
+ const stored = this.store.get(incoming.id);
9110
+ if (!stored) return incoming;
9111
+ if (stored.hasStaleState && !stored.state.getLatestValue().active) {
9112
+ stored.hydrateState(incoming);
9113
+ }
9114
+ return stored;
9115
+ };
9116
+ this.filterQueryResults = (threads) => threads;
9117
+ /**
9118
+ * Re-queries the first page and swaps the list in place, in server order; also the first load. The
9119
+ * current threads stay until the response lands, and a failed query leaves them untouched and
9120
+ * records `lastQueryError`; a successful one clears it. Like any paginator, the first load publishes
9121
+ * `isLoading`; a reload of a loaded list does not, since `isLoading` with items means the next page.
9122
+ */
9123
+ this.reload = async ({ limit = this.pageSize } = {}) => {
9124
+ const isFirstLoad = !this.hasResults;
9125
+ if (isFirstLoad) this.state.partialNext({ isLoading: true });
9126
+ let replaced = false;
9127
+ try {
9128
+ const results = await this.runQueryRetryable({
9129
+ queryShape: { ...DEFAULT_THREAD_QUERY, limit },
9130
+ retryCount: this.config.retryCount
9131
+ });
9132
+ if (!results) return;
9133
+ const nextIds = new Set(results.items.map((thread) => thread.id));
9134
+ this.setIntervals([]);
9135
+ this.setActiveInterval(void 0);
9136
+ for (const [id] of this._itemIndex.entries()) {
9137
+ if (!nextIds.has(id)) this._itemIndex.remove(id);
9138
+ }
9139
+ this.setItems({
9140
+ cursor: { headward: void 0, tailward: results.tailward },
9141
+ isFirstPage: true,
9142
+ isLastPage: !results.tailward,
9143
+ valueOrFactory: results.items
9144
+ });
9145
+ replaced = true;
9146
+ } finally {
9147
+ const clearError = replaced && this.lastQueryError;
9148
+ if (isFirstLoad || clearError) {
9149
+ this.state.partialNext({
9150
+ ...isFirstLoad ? { isLoading: false } : {},
9151
+ ...clearError ? { lastQueryError: void 0 } : {}
9152
+ });
9153
+ }
9154
+ }
9155
+ };
9156
+ this.client = client;
9157
+ this.store = store;
9158
+ }
9159
+ /**
9160
+ * No next page until a load has landed (`master`'s `nextCursor: null`), so `toTail()` is a no-op
9161
+ * before the first successful reload, e.g. when an empty list fires `onEndReached` on mount.
9162
+ */
9163
+ get initialState() {
9164
+ return {
9165
+ ...super.initialState,
9166
+ hasMoreTail: false
9167
+ };
9168
+ }
9169
+ getItemId(thread) {
9170
+ return thread.id;
9171
+ }
9172
+ getNextQueryShape({
9173
+ direction
9174
+ }) {
9175
+ const cursor = this.cursor?.[direction] ?? void 0;
9176
+ return {
9177
+ ...DEFAULT_THREAD_QUERY,
9178
+ limit: this.pageSize,
9179
+ ...cursor ? { [direction === "tailward" ? "next" : "prev"]: cursor } : {}
9180
+ };
9181
+ }
9182
+ /**
9183
+ * Also releases the list's hold on its threads, which the base reset leaves in the index. Released
9184
+ * before the base publishes, so a subscriber sees the threads already gone from the registry.
9185
+ */
9186
+ resetState() {
9187
+ this._itemIndex.clear();
9188
+ super.resetState();
9189
+ }
9190
+ };
9191
+
9025
9192
  // src/search/ChannelMemberSearchSource.ts
9026
9193
  var ChannelMemberSearchSource = class extends BaseSearchSource {
9027
9194
  constructor(channel, options, filterBuilderOptions = {}) {
@@ -12621,7 +12788,7 @@ var createMessageOperations = (collection) => {
12621
12788
  channel.messagePaginator.removeItem({ id });
12622
12789
  channel.pinnedMessagesPaginator.removeItem({ id });
12623
12790
  if (parentId) {
12624
- client.threads.threadsById[parentId]?.messagePaginator.removeItem({ id });
12791
+ client.threads.get(parentId)?.messagePaginator.removeItem({ id });
12625
12792
  }
12626
12793
  },
12627
12794
  ...parentMessageId ? {
@@ -12666,7 +12833,7 @@ var createMessageOperations = (collection) => {
12666
12833
  update: async (m, o) => {
12667
12834
  const result = await channel.getClient().updateMessage({
12668
12835
  id: m.id,
12669
- message: localMessageToNewMessagePayload(m),
12836
+ message: toUpdatedMessagePayload(m),
12670
12837
  ...o
12671
12838
  });
12672
12839
  return { message: result.message };
@@ -12777,10 +12944,15 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12777
12944
  * reload on reconnect, so an unbalanced `deactivate()` now costs more than a missed auto-read —
12778
12945
  * hence the refcount, matching `channel.activate()`. A thread held by more than one mount stays
12779
12946
  * active until the last holder releases it.
12947
+ *
12948
+ * The first activation also keeps the thread in `client.threads` for the rest of the session:
12949
+ * it stays subscribed and resolvable through `client.threads.get(id)` after it is deactivated,
12950
+ * whether or not the thread list holds it. Reopening it therefore needs no fetch unless it went stale.
12780
12951
  */
12781
12952
  this.activate = () => {
12782
12953
  this._activeRefCount += 1;
12783
12954
  if (this._activeRefCount === 1) {
12955
+ this.client.threads.register(this);
12784
12956
  this.state.partialNext({ active: true });
12785
12957
  }
12786
12958
  };
@@ -12802,10 +12974,9 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12802
12974
  *
12803
12975
  * Preserves failed (unsent) replies. They are read out of the reply paginator rather than out of
12804
12976
  * `failedRepliesMap`, because that map is only written by `upsertReplyLocally`, whose callers are
12805
- * this thread's own subscriptions and the offline-DB path keyed on `ThreadManager.threadsById` —
12806
- * neither of which covers a thread constructed directly and never registered (the common path in
12807
- * the React Native SDK, which resolves `threadsById[id] ?? new Thread(...)`). Reading the paginator
12808
- * is true for managed and unmanaged threads alike. An overlap merge keeps them anyway (the
12977
+ * this thread's own subscriptions and the offline-DB path resolved through `client.threads.get` —
12978
+ * neither of which covers a thread that was never activated or listed. Reading the paginator
12979
+ * is true for registered and unregistered threads alike. An overlap merge keeps them anyway (the
12809
12980
  * reconcile's provenance guard never prunes a non-server message); only a disjoint rebuild can drop
12810
12981
  * them, so any that actually fell out are re-ingested below.
12811
12982
  *
@@ -12889,6 +13060,7 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12889
13060
  this.client.messageStore.upsert(parentMessage);
12890
13061
  }
12891
13062
  const incomingReplies = thread.messagePaginator.state.getLatestValue().items ?? [];
13063
+ if (!options?.reconcile) this.messagePaginator.clearStateAndCache();
12892
13064
  if (typeof this.messagePaginator.items === "undefined") {
12893
13065
  this.messagePaginator.setItems({
12894
13066
  valueOrFactory: incomingReplies,
@@ -12907,16 +13079,9 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12907
13079
  }
12908
13080
  this.addUnsubscribeFunction(this.subscribeThreadSetupStateChange());
12909
13081
  this.addUnsubscribeFunction(this.subscribeParentMessageFromStore());
12910
- this.addUnsubscribeFunction(this.subscribeThreadUpdated());
12911
13082
  this.addUnsubscribeFunction(this.subscribeMarkActiveThreadRead());
12912
13083
  this.addUnsubscribeFunction(this.subscribeReloadActiveStaleThread());
12913
- this.addUnsubscribeFunction(this.subscribeMarkThreadStale());
12914
- this.addUnsubscribeFunction(this.subscribeNewReplies());
12915
- this.addUnsubscribeFunction(this.subscribeRepliesRead());
12916
- this.addUnsubscribeFunction(this.subscribeRepliesUnread());
12917
- this.addUnsubscribeFunction(this.subscribeMessageDeleted());
12918
- this.addUnsubscribeFunction(this.subscribeMessageUpdated());
12919
- this.addUnsubscribeFunction(this.subscribeUserMessagesDeleted());
13084
+ this.addUnsubscribeFunction(this.subscribeMarkStaleOnStopWatching());
12920
13085
  };
12921
13086
  /**
12922
13087
  * Subscribes this thread to the `'thread'` configuration key. Registered through
@@ -12933,10 +13098,10 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12933
13098
  * `hasLiveInstances('thread')` does not count it when deciding whether to warn about a
12934
13099
  * construction-only path registered too late.
12935
13100
  *
12936
- * So read "declarative configuration is unaffected" as *at construction only*. A thread held by a
12937
- * `ThreadManager` that has itself registered is covered — `subscribeManageThreadSubscriptions` calls
12938
- * `registerSubscriptions()` on every thread entering its state — so the common path is fine. A thread
12939
- * constructed directly, or held by an unregistered manager, is not.
13101
+ * So read "declarative configuration is unaffected" as *at construction only*. A thread registered
13102
+ * with a `ThreadManager` that has itself registered is covered — the manager calls
13103
+ * `registerSubscriptions()` on every thread the list holds or that is active — so the common path
13104
+ * is fine. A thread that is neither, or held by an unregistered manager, is not.
12940
13105
  *
12941
13106
  * The alternative — applying the setup function at construction — would break the teardown symmetry
12942
13107
  * `WithSubscriptions` provides, which is why the asymmetry stands.
@@ -12952,7 +13117,7 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12952
13117
  // on a change there, so the setup function's overrides survive.
12953
13118
  alsoWatch: ["messagePaginator", "messageOperations"]
12954
13119
  });
12955
- this.subscribeThreadUpdated = () => this.client.on("thread.updated", (event) => {
13120
+ this.handleThreadUpdated = (event) => {
12956
13121
  if (!event.thread || event.thread.parent_message_id !== this.id) {
12957
13122
  return;
12958
13123
  }
@@ -12963,7 +13128,7 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12963
13128
  deletedAt: threadData.deleted_at ?? null,
12964
13129
  custom: threadData.custom ?? {}
12965
13130
  });
12966
- }).unsubscribe;
13131
+ };
12967
13132
  this.subscribeMarkActiveThreadRead = () => this.state.subscribeWithSelector(
12968
13133
  (nextValue) => ({
12969
13134
  active: nextValue.active,
@@ -12982,14 +13147,19 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12982
13147
  }
12983
13148
  }
12984
13149
  );
12985
- this.subscribeMarkThreadStale = () => this.client.on("user.watching.stop", (event) => {
12986
- const { channel } = this.state.getLatestValue();
12987
- if (!this.client.userId || this.client.userId !== event.user?.id || event.channel?.cid !== channel.cid) {
12988
- return;
13150
+ /**
13151
+ * A watch stopped while the connection stays up (`NotWatching`) ends the channel's events with no
13152
+ * reconnect to catch it. A dropped connection (`WasWatching`) is left to recovery.
13153
+ */
13154
+ this.subscribeMarkStaleOnStopWatching = () => this.channel.state.subscribeWithSelector(
13155
+ ({ watchStatus }) => ({ watchStatus }),
13156
+ ({ watchStatus }, previous) => {
13157
+ if (previous?.watchStatus === ChannelWatchStatus.Watching && watchStatus === ChannelWatchStatus.NotWatching) {
13158
+ this.state.partialNext({ isStateStale: true });
13159
+ }
12989
13160
  }
12990
- this.state.partialNext({ isStateStale: true });
12991
- }).unsubscribe;
12992
- this.subscribeRepliesUnread = () => this.client.on("notification.mark_unread", (event) => {
13161
+ );
13162
+ this.handleRepliesUnread = (event) => {
12993
13163
  if (!event.user || event.created_at == null || !event.thread_id) return;
12994
13164
  if (event.thread_id !== this.id) return;
12995
13165
  const userId = event.user.id;
@@ -13008,8 +13178,8 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13008
13178
  }
13009
13179
  }
13010
13180
  }));
13011
- }).unsubscribe;
13012
- this.subscribeNewReplies = () => this.client.on("message.new", (event) => {
13181
+ };
13182
+ this.handleNewReply = (event) => {
13013
13183
  if (!this.client.userId || event.message?.parent_id !== this.id) {
13014
13184
  return;
13015
13185
  }
@@ -13047,8 +13217,8 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13047
13217
  }
13048
13218
  }
13049
13219
  this.state.partialNext({ read: nextRead });
13050
- }).unsubscribe;
13051
- this.subscribeRepliesRead = () => this.client.on("message.read", (event) => {
13220
+ };
13221
+ this.handleRepliesRead = (event) => {
13052
13222
  if (!event.user || event.created_at == null || !event.thread) return;
13053
13223
  if (event.thread.parent_message_id !== this.id) return;
13054
13224
  const userId = event.user.id;
@@ -13066,8 +13236,8 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13066
13236
  }
13067
13237
  }
13068
13238
  }));
13069
- }).unsubscribe;
13070
- this.subscribeMessageDeleted = () => this.client.on("message.deleted", (event) => {
13239
+ };
13240
+ this.handleMessageDeleted = (event) => {
13071
13241
  if (!event.message) return;
13072
13242
  const formattedMessage = formatMessage(event.message);
13073
13243
  if (event.message.parent_id === this.id) {
@@ -13081,52 +13251,35 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13081
13251
  this.updateParentMessageLocally({ message: event.message });
13082
13252
  }
13083
13253
  this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage);
13084
- }).unsubscribe;
13085
- this.subscribeMessageUpdated = () => {
13086
- const messageUpdateTypes = ["message.updated", "message.undeleted"];
13087
- const reactionTypes = [
13088
- "reaction.new",
13089
- "reaction.deleted",
13090
- "reaction.updated"
13091
- ];
13092
- const unsubscribeMessageUpdated = messageUpdateTypes.map(
13093
- (eventType) => this.client.on(eventType, (event) => {
13094
- if (!event.message) return;
13095
- const message = event.message.parent_id === this.id ? {
13096
- ...event.message,
13097
- own_reactions: this.messagePaginator.getItem(event.message.id)?.own_reactions ?? event.message.own_reactions
13098
- } : !event.message.parent_id && event.message.id === this.id ? {
13099
- ...event.message,
13100
- own_reactions: this.state.getLatestValue().parentMessage?.own_reactions ?? event.message.own_reactions
13101
- } : event.message;
13102
- this.updateParentMessageOrReplyLocally(message);
13103
- this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(event.message));
13104
- }).unsubscribe
13105
- );
13106
- const unsubscribeReactions = reactionTypes.map(
13107
- (eventType) => this.client.on(eventType, (event) => {
13108
- if (!event.message || !event.reaction) return;
13109
- const { message } = event;
13110
- this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(message));
13111
- }).unsubscribe
13112
- );
13113
- const unsubscribeFunctions = [...unsubscribeMessageUpdated, ...unsubscribeReactions];
13114
- return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
13115
13254
  };
13116
- this.subscribeUserMessagesDeleted = () => {
13117
- const eventTypes = ["user.messages.deleted", "user.deleted"];
13118
- const unsubscribeFunctions = eventTypes.map(
13119
- (eventType) => this.client.on(eventType, (event) => {
13120
- if (!event.user) return;
13121
- const deletedAtSource = eventType === "user.deleted" ? event.user.deleted_at : event.created_at;
13122
- this.messagePaginator.applyMessageDeletionForUser({
13123
- userId: event.user.id,
13124
- hardDelete: !!event.hard_delete,
13125
- deletedAt: deletedAtSource ?? nowNs()
13126
- });
13127
- }).unsubscribe
13128
- );
13129
- return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
13255
+ this.handleMessageUpdated = (event) => {
13256
+ if (!event.message) return;
13257
+ const message = event.message.parent_id === this.id ? {
13258
+ ...event.message,
13259
+ own_reactions: this.messagePaginator.getItem(event.message.id)?.own_reactions ?? event.message.own_reactions
13260
+ } : !event.message.parent_id && event.message.id === this.id ? {
13261
+ ...event.message,
13262
+ own_reactions: this.state.getLatestValue().parentMessage?.own_reactions ?? event.message.own_reactions
13263
+ } : event.message;
13264
+ this.updateParentMessageOrReplyLocally(message);
13265
+ this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(event.message));
13266
+ };
13267
+ this.handleReactionChanged = (event) => {
13268
+ if (!event.message || !event.reaction) return;
13269
+ const { message } = event;
13270
+ this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(message));
13271
+ };
13272
+ // Apply a user ban / deletion to this thread's own reply list. Previously
13273
+ // channel.state.deleteUserMessages marked banned-user replies deleted in the (now removed)
13274
+ // channel.state.threads shadow; the reply paginator is the thread's source of truth now.
13275
+ this.handleUserMessagesDeleted = (event) => {
13276
+ if (!event.user) return;
13277
+ const deletedAtSource = event.type === "user.deleted" ? event.user.deleted_at : event.created_at;
13278
+ this.messagePaginator.applyMessageDeletionForUser({
13279
+ userId: event.user.id,
13280
+ hardDelete: !!event.hard_delete,
13281
+ deletedAt: deletedAtSource ?? nowNs()
13282
+ });
13130
13283
  };
13131
13284
  // The parent message lives in the client-global message store (one canonical POJO per id);
13132
13285
  // `state.parentMessage` and the fields derived from it are a projection of that copy. Every update
@@ -14221,13 +14374,13 @@ var _MessageComposer = class _MessageComposer extends WithSubscriptions {
14221
14374
  *
14222
14375
  * Thread replies live in `thread.messagePaginator`, which is independent of the channel's, so
14223
14376
  * dispatching to the wrong one leaves the reply invisible where it was written. An edit composer
14224
- * is built from the message rather than from its thread, so it can only find the thread when the
14225
- * manager has it loaded. No composer reachable from a UI SDK is built that way today.
14377
+ * is built from the message rather than from its thread, so it finds the thread only when
14378
+ * `client.threads` has it registered — listed, or active (open somewhere).
14226
14379
  */
14227
14380
  get defaultSubmitTarget() {
14228
14381
  if (this.compositionContext instanceof Thread) return this.compositionContext;
14229
14382
  const { threadId } = this;
14230
- return threadId && this.client.threads.threadsById[threadId] || this.channel;
14383
+ return threadId && this.client.threads.get(threadId) || this.channel;
14231
14384
  }
14232
14385
  /**
14233
14386
  * Whether submitting keeps the composer's contents rather than clearing them - see
@@ -17697,6 +17850,7 @@ var runWithRetry = (f, {
17697
17850
  // src/api-client.ts
17698
17851
  var logger9 = chatLoggerSystem.getLogger("api-client");
17699
17852
  var MULTIPART_CONTENT_TYPE = "multipart/form-data";
17853
+ var LONG_POLL_PATH = "/api/v2/longpoll";
17700
17854
  var UPLOAD_REQUEST_DEFAULTS = {
17701
17855
  timeout: 0,
17702
17856
  maxContentLength: Infinity,
@@ -17717,8 +17871,8 @@ var ApiClient = class {
17717
17871
  params: queryParams,
17718
17872
  headers: { "Content-Type": requestContentType },
17719
17873
  ...isMultipart ? UPLOAD_REQUEST_DEFAULTS : {},
17720
- // Keep this last so a caller-supplied signal wins - and keep it returning only the keys
17721
- // it owns, so it can never clobber the upload defaults above.
17874
+ // Keep this last so a caller-supplied signal and timeout win - and keep it returning only
17875
+ // the keys it owns, so it can never clobber the upload defaults above.
17722
17876
  ...toAxiosRequestConfig(options)
17723
17877
  });
17724
17878
  }
@@ -17749,7 +17903,11 @@ var ApiClient = class {
17749
17903
  }
17750
17904
  }
17751
17905
  if (resolved.startsWith("/")) {
17752
- resolved = this.client.baseURL + resolved;
17906
+ const baseURL = resolved === LONG_POLL_PATH ? (
17907
+ // replace port if present for testing with local API
17908
+ this.client.baseURL?.replace(":3030", ":8900")
17909
+ ) : this.client.baseURL;
17910
+ resolved = baseURL + resolved;
17753
17911
  }
17754
17912
  return resolved;
17755
17913
  }
@@ -17803,6 +17961,7 @@ var ApiClient = class {
17803
17961
  params: { ...additionalConfig.params, connection_id: connectionId }
17804
17962
  };
17805
17963
  }
17964
+ await this.client.tokenManager.tokenReady();
17806
17965
  const initialRequestConfig = this.populateRequestConfigWithDefaults(additionalConfig);
17807
17966
  const clientRequestId = initialRequestConfig.headers?.["x-client-request-id"];
17808
17967
  try {
@@ -17864,25 +18023,23 @@ var ApiClient = class {
17864
18023
  var requiresConnectionId = (params, body) => {
17865
18024
  const payload = params?.payload;
17866
18025
  const requestBody = typeof body === "object" && body !== null ? body : void 0;
17867
- if (params?.watch || params?.presence || payload?.watch || payload?.presence || requestBody?.watch || requestBody?.presence) {
17868
- return true;
17869
- }
17870
- if ([params, payload, requestBody].some(
17871
- (source) => source && ("watch" in source || "presence" in source)
17872
- )) {
17873
- return false;
17874
- }
17875
- return Boolean(params && "connection_id" in params);
18026
+ return Boolean(
18027
+ params?.watch || params?.presence || payload?.watch || payload?.presence || requestBody?.watch || requestBody?.presence
18028
+ );
17876
18029
  };
17877
18030
  var isUsableAbortSignal = (signal) => typeof signal?.addEventListener === "function";
17878
18031
  var toAxiosRequestConfig = ({
17879
18032
  signal,
17880
- onUploadProgress
18033
+ onUploadProgress,
18034
+ timeout
17881
18035
  } = {}) => ({
17882
18036
  signal: isUsableAbortSignal(signal) ? signal : void 0,
17883
18037
  // Same reasoning as `isUsableAbortSignal`: an options object revived from a persisted
17884
18038
  // offline-db task payload has lost its functions.
17885
- onUploadProgress: typeof onUploadProgress === "function" ? onUploadProgress : void 0
18039
+ onUploadProgress: typeof onUploadProgress === "function" ? onUploadProgress : void 0,
18040
+ // Only when set: an explicit `undefined` would clobber the upload defaults' `timeout: 0`, and
18041
+ // axios would fall back to the instance timeout.
18042
+ ...typeof timeout === "number" ? { timeout } : {}
17886
18043
  });
17887
18044
  var errorIsApiError = (error) => {
17888
18045
  if (!(error instanceof import_axios3.AxiosError)) return false;
@@ -18457,26 +18614,126 @@ var eventCarriesOwnUser = (event) => Object.hasOwn(event, "me");
18457
18614
  var DEFAULT_THREAD_MANAGER_CONFIG = deepFreezeConfig({
18458
18615
  connectionRecoveryThrottleMs: 1e3
18459
18616
  });
18460
- var MAX_QUERY_THREADS_LIMIT = 25;
18461
18617
  var THREAD_MANAGER_INITIAL_STATE = {
18462
18618
  active: false,
18463
- wasActivatedAtLeastOnce: false,
18464
18619
  isThreadOrderStale: false,
18465
- threads: [],
18466
18620
  unreadThreadCount: 0,
18467
18621
  unseenThreadIds: [],
18468
- pagination: {
18469
- isLoading: false,
18470
- isLoadingNext: false,
18471
- nextCursor: null
18472
- },
18473
- ready: false
18622
+ wasActivatedAtLeastOnce: false
18474
18623
  };
18475
18624
  var logger10 = chatLoggerSystem.getLogger("thread-manager");
18625
+ var getThreadId = (thread) => thread.id;
18626
+ var REGISTERED_HOLDER = { onEntitiesChanged: () => void 0 };
18476
18627
  var ThreadManager = class extends WithSubscriptions {
18477
18628
  constructor({ client }) {
18478
18629
  super();
18630
+ /**
18631
+ * Every live thread, held by the list (the paginator's own index over this store) and/or
18632
+ * `REGISTERED_HOLDER` (opened this session). A thread leaves once neither holds it.
18633
+ */
18634
+ this.threadStore = new EntityStore({ getEntityId: getThreadId });
18635
+ /** A reload is in flight; a second one is dropped (`master`'s `pagination.isLoading` guard). */
18636
+ this.isReloadInFlight = false;
18637
+ /**
18638
+ * A local `pendingDisposal` listener (a state subscription, not a server watch) per channel instance
18639
+ * that has had a listed or opened thread; removed when that channel is disposed or on reset.
18640
+ */
18641
+ this.disposalListeners = /* @__PURE__ */ new Map();
18642
+ /**
18643
+ * The live thread for `id`: one in the list, or one opened this session (e.g. from a message
18644
+ * list). Resolve threads through this, not `paginator.items`, which is the list only; list
18645
+ * membership is `paginator.getItem(id)`.
18646
+ */
18647
+ this.get = (id) => this.threadStore.get(id);
18648
+ /**
18649
+ * Returns the thread for `parentMessage`, creating it if it doesn't exist yet. Use this to open a
18650
+ * thread instead of calling `new Thread()`.
18651
+ *
18652
+ * The returned thread is registered right away. If the thread list loads it before the UI
18653
+ * activates it, the list reuses this instance instead of creating a duplicate.
18654
+ *
18655
+ * A thread created here only has its parent message, so it starts stale and it loads its thread data
18656
+ * (participants, read state, replies) once, the first time it's opened.
18657
+ */
18658
+ this.ensure = ({
18659
+ channel,
18660
+ parentMessage
18661
+ }) => {
18662
+ let thread = this.threadStore.get(parentMessage.id);
18663
+ if (!thread) {
18664
+ thread = new Thread({ channel, client: this.client, parentMessage });
18665
+ thread.state.partialNext({ isStateStale: true });
18666
+ }
18667
+ this.register(thread);
18668
+ return thread;
18669
+ };
18670
+ /**
18671
+ * Called by `thread.activate()`. Keeps `thread` for the session: it resolves through
18672
+ * {@link ThreadManager.get} and stays subscribed whether or not the list holds it, until its
18673
+ * channel is disposed or the user disconnects.
18674
+ *
18675
+ * @internal
18676
+ */
18677
+ this.register = (thread) => {
18678
+ const { id } = thread;
18679
+ const registered = this.threadStore.get(id);
18680
+ if (registered && registered !== thread) {
18681
+ logger10.withExtraTags("register").warn(
18682
+ "Another instance of this thread is registered; this one stays unmanaged.",
18683
+ {
18684
+ threadId: id
18685
+ }
18686
+ );
18687
+ return;
18688
+ }
18689
+ this.threadStore.link(id, REGISTERED_HOLDER);
18690
+ this.threadStore.upsert(thread, REGISTERED_HOLDER);
18691
+ if (this.hasSubscriptions) thread.registerSubscriptions();
18692
+ this.listenForDisposal(thread);
18693
+ };
18694
+ /** Releases the `REGISTERED_HOLDER` hold on `thread`; the list's own diff unsubscribes it if it is still listed. */
18695
+ this.release = (thread) => {
18696
+ this.threadStore.unlink(thread.id, REGISTERED_HOLDER);
18697
+ if (this.hasSubscriptions && !this.paginator.items?.includes(thread)) {
18698
+ thread.unregisterSubscriptions();
18699
+ }
18700
+ };
18701
+ /** Listens for its channel's disposal the first time a thread on that channel is seen. */
18702
+ this.listenForDisposal = (thread) => {
18703
+ const { channel } = thread;
18704
+ if (this.disposalListeners.has(channel)) return;
18705
+ if (channel.pendingDisposal) {
18706
+ this.releaseChannel(channel);
18707
+ return;
18708
+ }
18709
+ this.disposalListeners.set(
18710
+ channel,
18711
+ channel.state.subscribeWithSelector(
18712
+ ({ pendingDisposal }) => ({ pendingDisposal }),
18713
+ ({ pendingDisposal }) => {
18714
+ if (pendingDisposal) this.releaseChannel(channel);
18715
+ }
18716
+ )
18717
+ );
18718
+ };
18719
+ /** The channel was disposed, so its threads are unusable: release every one of them. */
18720
+ this.releaseChannel = (channel) => {
18721
+ this.disposalListeners.get(channel)?.();
18722
+ this.disposalListeners.delete(channel);
18723
+ const onChannel = (thread) => thread.channel === channel;
18724
+ this.threadStore.values().filter(onChannel).forEach(this.release);
18725
+ const listed = (this.paginator.items ?? []).filter(onChannel);
18726
+ if (!listed.length) return;
18727
+ this.paginator.batch(
18728
+ () => listed.forEach(({ id }) => this.paginator.removeItem({ id })),
18729
+ { coalesce: true }
18730
+ );
18731
+ };
18479
18732
  this.resetState = () => {
18733
+ this.threadStore.values().forEach(this.release);
18734
+ this.paginator.resetState();
18735
+ this.disposalListeners.forEach((unsubscribe) => unsubscribe());
18736
+ this.disposalListeners.clear();
18480
18737
  this.state.next(THREAD_MANAGER_INITIAL_STATE);
18481
18738
  };
18482
18739
  this.activate = () => {
@@ -18492,7 +18749,65 @@ var ThreadManager = class extends WithSubscriptions {
18492
18749
  this.addUnsubscribeFunction(this.subscribeReloadOnActivation());
18493
18750
  this.addUnsubscribeFunction(this.subscribeNewReplies());
18494
18751
  this.addUnsubscribeFunction(this.subscribeReloadOnConnectionRecovered());
18495
- this.addUnsubscribeFunction(this.subscribeChannelDeleted());
18752
+ this.addUnsubscribeFunction(this.subscribeThreadEvents());
18753
+ this.threadStore.values().forEach((thread) => thread.registerSubscriptions());
18754
+ };
18755
+ /**
18756
+ * Hands client events to the threads in the store by id, as `PollManager` does for polls: a thread
18757
+ * doesn't listen to the client itself. A message event goes to the thread the message is a reply
18758
+ * in and the thread it starts; quoted-message updates stay within the reply's own thread, as on
18759
+ * `master`. A thread follows its channel's `watchStatus` itself, so watch events need no routing.
18760
+ */
18761
+ this.subscribeThreadEvents = () => {
18762
+ const threadsOf = (message) => message ? [
18763
+ message.parent_id ? this.get(message.parent_id) : void 0,
18764
+ this.get(message.id)
18765
+ ] : [];
18766
+ const unsubscribeFunctions = [
18767
+ this.client.on("message.new", (event) => {
18768
+ if (event.message?.parent_id)
18769
+ this.get(event.message.parent_id)?.handleNewReply(event);
18770
+ }),
18771
+ this.client.on("message.read", (event) => {
18772
+ if (event.thread)
18773
+ this.get(event.thread.parent_message_id)?.handleRepliesRead(event);
18774
+ }),
18775
+ this.client.on("notification.mark_unread", (event) => {
18776
+ if (event.thread_id) this.get(event.thread_id)?.handleRepliesUnread(event);
18777
+ }),
18778
+ this.client.on("thread.updated", (event) => {
18779
+ if (event.thread)
18780
+ this.get(event.thread.parent_message_id)?.handleThreadUpdated(event);
18781
+ }),
18782
+ this.client.on(
18783
+ "message.deleted",
18784
+ (event) => threadsOf(event.message).forEach((thread) => thread?.handleMessageDeleted(event))
18785
+ ),
18786
+ ...["message.updated", "message.undeleted"].map(
18787
+ (eventType) => this.client.on(
18788
+ eventType,
18789
+ (event) => threadsOf(event.message).forEach(
18790
+ (thread) => thread?.handleMessageUpdated(event)
18791
+ )
18792
+ )
18793
+ ),
18794
+ ...["reaction.new", "reaction.deleted", "reaction.updated"].map(
18795
+ (eventType) => this.client.on(
18796
+ eventType,
18797
+ (event) => threadsOf(event.message).forEach(
18798
+ (thread) => thread?.handleReactionChanged(event)
18799
+ )
18800
+ )
18801
+ ),
18802
+ // A banned or deleted user's replies can be in any thread, so these two are the exception.
18803
+ ...["user.messages.deleted", "user.deleted"].map(
18804
+ (eventType) => this.client.on(
18805
+ eventType,
18806
+ (event) => this.threadStore.values().forEach((thread) => thread.handleUserMessagesDeleted(event))
18807
+ )
18808
+ )
18809
+ ].map(({ unsubscribe }) => unsubscribe);
18810
+ return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
18496
18811
  };
18497
18812
  this.subscribeUnreadThreadsCountChange = () => {
18498
18813
  const { unread_threads: unreadThreadCount = 0 } = this.client.user ?? {};
@@ -18514,18 +18829,13 @@ var ThreadManager = class extends WithSubscriptions {
18514
18829
  );
18515
18830
  return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
18516
18831
  };
18517
- this.subscribeChannelDeleted = () => this.client.on("notification.channel_deleted", (event) => {
18518
- const { cid } = event;
18519
- const { threads } = this.state.getLatestValue();
18520
- const newThreads = threads.filter((thread) => thread.channel.cid !== cid);
18521
- this.state.partialNext({ threads: newThreads });
18522
- }).unsubscribe;
18523
- this.subscribeManageThreadSubscriptions = () => this.state.subscribeWithSelector(
18524
- (nextValue) => ({ threads: nextValue.threads }),
18832
+ this.subscribeManageThreadSubscriptions = () => this.paginator.state.subscribeWithSelector(
18833
+ (nextValue) => ({ threads: nextValue.items ?? [] }),
18525
18834
  ({ threads: nextThreads }, prev) => {
18526
18835
  const { threads: prevThreads = [] } = prev ?? {};
18836
+ const listed = new Set(nextThreads);
18527
18837
  const removedThreads = prevThreads.filter(
18528
- (thread) => thread !== this.threadsById[thread.id]
18838
+ (thread) => !listed.has(thread) && !this.threadStore.isHeldBy(thread.id, REGISTERED_HOLDER)
18529
18839
  );
18530
18840
  nextThreads.forEach((thread) => thread.registerSubscriptions());
18531
18841
  removedThreads.forEach((thread) => thread.unregisterSubscriptions());
@@ -18540,9 +18850,9 @@ var ThreadManager = class extends WithSubscriptions {
18540
18850
  this.subscribeNewReplies = () => this.client.on("notification.thread_message_new", (event) => {
18541
18851
  const parentId = event.message?.parent_id;
18542
18852
  if (!parentId) return;
18543
- const { unseenThreadIds, ready } = this.state.getLatestValue();
18544
- if (!ready) return;
18545
- if (this.threadsById[parentId]) {
18853
+ if (!this.paginator.hasResults) return;
18854
+ const { unseenThreadIds } = this.state.getLatestValue();
18855
+ if (this.paginator.getItem(parentId)) {
18546
18856
  this.state.partialNext({ isThreadOrderStale: true });
18547
18857
  } else if (!unseenThreadIds.includes(parentId)) {
18548
18858
  this.state.partialNext({ unseenThreadIds: unseenThreadIds.concat(parentId) });
@@ -18569,94 +18879,33 @@ var ThreadManager = class extends WithSubscriptions {
18569
18879
  return this.client.on("connection.recovered", throttledHandleConnectionRecovered).unsubscribe;
18570
18880
  };
18571
18881
  this.unregisterSubscriptions = () => {
18572
- this.state.getLatestValue().threads.forEach((thread) => thread.unregisterSubscriptions());
18882
+ this.threadStore.values().forEach((thread) => thread.unregisterSubscriptions());
18573
18883
  return super.unregisterSubscriptions();
18574
18884
  };
18885
+ /**
18886
+ * Loads the first page, or re-queries a loaded list in place, sized to what is loaded plus the
18887
+ * unseen threads. Skipped once loaded unless forced or something changed; the list survives a
18888
+ * failure. Guarded only against another reload, as on `master`. A reload of a loaded list shows
18889
+ * no loading state; the first load shows through `paginator.isLoading`.
18890
+ */
18575
18891
  this.reload = async ({ force = false } = {}) => {
18576
- const { threads, unseenThreadIds, isThreadOrderStale, pagination, ready } = this.state.getLatestValue();
18577
- if (pagination.isLoading) return;
18578
- if (!force && ready && !unseenThreadIds.length && !isThreadOrderStale) return;
18579
- const limit = threads.length + unseenThreadIds.length;
18892
+ if (this.isReloadInFlight) return;
18893
+ const { isThreadOrderStale, unseenThreadIds } = this.state.getLatestValue();
18894
+ if (!force && this.paginator.hasResults && !unseenThreadIds.length && !isThreadOrderStale)
18895
+ return;
18896
+ const { items, pageSize } = this.paginator;
18897
+ const limit = (items?.length ?? 0) + unseenThreadIds.length;
18898
+ this.isReloadInFlight = true;
18580
18899
  try {
18581
- this.state.next((current) => ({
18582
- ...current,
18583
- pagination: {
18584
- ...current.pagination,
18585
- isLoading: true
18586
- }
18587
- }));
18588
- const response = await this.queryThreads({
18589
- limit: Math.min(limit, MAX_QUERY_THREADS_LIMIT) || MAX_QUERY_THREADS_LIMIT
18590
- });
18591
- const nextThreads = [];
18592
- for (const incomingThread of response.threads) {
18593
- const existingThread = this.threadsById[incomingThread.id];
18594
- if (existingThread) {
18595
- nextThreads.push(existingThread);
18596
- if (existingThread.hasStaleState) {
18597
- existingThread.hydrateState(incomingThread);
18598
- }
18599
- } else {
18600
- nextThreads.push(incomingThread);
18601
- }
18900
+ await this.paginator.reload({ limit: Math.min(limit, pageSize) || pageSize });
18901
+ } finally {
18902
+ this.isReloadInFlight = false;
18903
+ const error = this.paginator.lastQueryError;
18904
+ if (error) {
18905
+ logger10.withExtraTags("reload").error("Failed to reload the thread list.", { error });
18906
+ } else {
18907
+ this.state.partialNext({ isThreadOrderStale: false, unseenThreadIds: [] });
18602
18908
  }
18603
- this.state.next((current) => ({
18604
- ...current,
18605
- threads: nextThreads,
18606
- unseenThreadIds: [],
18607
- isThreadOrderStale: false,
18608
- pagination: {
18609
- ...current.pagination,
18610
- isLoading: false,
18611
- nextCursor: response.next ?? null
18612
- },
18613
- ready: true
18614
- }));
18615
- } catch (error) {
18616
- logger10.withExtraTags("reload").error("Failed to reload the thread list.", { error });
18617
- this.state.next((current) => ({
18618
- ...current,
18619
- pagination: {
18620
- ...current.pagination,
18621
- isLoading: false
18622
- }
18623
- }));
18624
- }
18625
- };
18626
- this.queryThreads = (options = {}) => this.client.queryThreadsAndHydrate({
18627
- limit: 25,
18628
- participant_limit: 10,
18629
- reply_limit: 10,
18630
- watch: true,
18631
- ...options
18632
- });
18633
- this.loadNextPage = async (options = {}) => {
18634
- const { pagination } = this.state.getLatestValue();
18635
- if (pagination.isLoadingNext || !pagination.nextCursor) return;
18636
- try {
18637
- this.state.partialNext({ pagination: { ...pagination, isLoadingNext: true } });
18638
- const response = await this.queryThreads({
18639
- ...options,
18640
- next: pagination.nextCursor
18641
- });
18642
- this.state.next((current) => ({
18643
- ...current,
18644
- threads: response.threads.length ? current.threads.concat(response.threads) : current.threads,
18645
- pagination: {
18646
- ...current.pagination,
18647
- nextCursor: response.next ?? null,
18648
- isLoadingNext: false
18649
- }
18650
- }));
18651
- } catch (error) {
18652
- logger10.withExtraTags("loadNextPage").error("Failed to load the next page of threads.", { error });
18653
- this.state.next((current) => ({
18654
- ...current,
18655
- pagination: {
18656
- ...current.pagination,
18657
- isLoadingNext: false
18658
- }
18659
- }));
18660
18909
  }
18661
18910
  };
18662
18911
  this.configController = new ConfigController({
@@ -18664,7 +18913,11 @@ var ThreadManager = class extends WithSubscriptions {
18664
18913
  });
18665
18914
  this.client = client;
18666
18915
  this.state = new import_state_store21.StateStore(THREAD_MANAGER_INITIAL_STATE);
18667
- this.threadsByIdGetterCache = { threads: [], threadsById: {} };
18916
+ this.paginator = new ThreadPaginator({ client, store: this.threadStore });
18917
+ this.paginator.state.subscribeWithSelector(
18918
+ ({ items }) => ({ items }),
18919
+ ({ items }) => items?.forEach(this.listenForDisposal)
18920
+ );
18668
18921
  }
18669
18922
  /**
18670
18923
  * Resolved configuration, as a store — the shape every configurable class exposes
@@ -18698,21 +18951,13 @@ var ThreadManager = class extends WithSubscriptions {
18698
18951
  initializeConfig(config) {
18699
18952
  this.configController.initialize(config);
18700
18953
  }
18701
- get threadsById() {
18702
- const { threads } = this.state.getLatestValue();
18703
- if (threads === this.threadsByIdGetterCache.threads) {
18704
- return this.threadsByIdGetterCache.threadsById;
18705
- }
18706
- const threadsById = threads.reduce(
18707
- (newThreadsById, thread) => {
18708
- newThreadsById[thread.id] = thread;
18709
- return newThreadsById;
18710
- },
18711
- {}
18712
- );
18713
- this.threadsByIdGetterCache.threads = threads;
18714
- this.threadsByIdGetterCache.threadsById = threadsById;
18715
- return threadsById;
18954
+ /**
18955
+ * Every registered thread — the list's plus the opened ones.
18956
+ *
18957
+ * @internal
18958
+ */
18959
+ get registeredThreads() {
18960
+ return this.threadStore.values();
18716
18961
  }
18717
18962
  };
18718
18963
 
@@ -20096,6 +20341,7 @@ var ConnectionRecoveryManager = class extends WithSubscriptions {
20096
20341
  }
20097
20342
  const channels = this.recoverableActiveChannels;
20098
20343
  const threads = this.recoverableActiveThreads;
20344
+ this.markInactiveThreadsStale();
20099
20345
  const socketDropBefore = this.client.wsConnection.state.getLatestValue().lastUnhealthyAt;
20100
20346
  this.isRecovering = true;
20101
20347
  logger12.withExtraTags("connectionRecovery").info(
@@ -20182,41 +20428,34 @@ var ConnectionRecoveryManager = class extends WithSubscriptions {
20182
20428
  * Threads need their own pass because nothing else brings an open thread's replies back — a
20183
20429
  * channel reload refreshes the main message list, `ChannelManager.recover()` refreshes the channel
20184
20430
  * lists*, and `ThreadManager`'s own recovery refreshes the thread *list*, reusing existing
20185
- * instances without rehydrating them unless they were separately marked stale (which only
20186
- * `user.watching.stop` does, never a reconnect).
20431
+ * instances without rehydrating them unless they were separately marked stale.
20187
20432
  *
20188
- * `active` is the filter that matters: `threadsById` holds every thread the list has paged in,
20189
- * which is not what should be re-fetched on a reconnect — only what someone is actually reading.
20433
+ * `active` is the filter that matters: the registry also holds every thread the list has paged
20434
+ * in, which is not what should be re-fetched on a reconnect — only what someone is actually
20435
+ * reading. An active thread is always registered, listed or not (`thread.activate()` registers it).
20190
20436
  * Guarded on the owning channel the same way active channels are: a thread whose channel is being
20191
20437
  * torn down has nothing to recover into.
20192
- *
20193
- * NOTE: `threadsById` is the thread LIST, not a thread cache. A thread opened from a message list
20194
- * is constructed directly (`threadsById[id] ?? new Thread(...)`) and only reaches the list because
20195
- * the UI SDKs adopt it there — prepended, once its replies have loaded — which is a workaround, not
20196
- * a contract. Two consequences, both accepted for now and both narrow:
20197
- *
20198
- * - A reconnect landing before that adoption misses the thread. Self-limiting: the UI SDKs re-issue
20199
- * the load while the reply list is still unloaded, so a thread that failed to load gets adopted on
20200
- * the retry.
20201
- * - `ThreadManager.reload()` rebuilds `state.threads` purely from the query response, so an adopted
20202
- * thread absent from that response is evicted while still active. It cannot bite within the
20203
- * reconnect that caused it: this getter is read before `connection.recovered` is dispatched, and
20204
- * that event is what triggers the UI SDKs' list reload. It would take a LATER reconnect, after an
20205
- * eviction, to miss the thread.
20206
- *
20207
- * The fix is a real off-list registry — see the commented-out `threadCache` in `ThreadManager` — at
20208
- * which point this getter reads from that instead, with no change to the `active` filter.
20209
20438
  */
20210
20439
  get recoverableActiveThreads() {
20211
20440
  const threads = [];
20212
- for (const thread of Object.values(this.client.threads.threadsById)) {
20213
- if (!thread) continue;
20441
+ for (const thread of this.client.threads.registeredThreads) {
20214
20442
  const { active } = thread.state.getLatestValue();
20215
20443
  if (!active || thread.channel.pendingDisposal) continue;
20216
20444
  threads.push(thread);
20217
20445
  }
20218
20446
  return threads;
20219
20447
  }
20448
+ /**
20449
+ * An opened thread stays registered and subscribed after it closes, but a drop loses events it
20450
+ * cannot replay. Flag only, no request: a listed one is rehydrated by the thread list's reload,
20451
+ * and any other reloads on its next `activate()`.
20452
+ */
20453
+ markInactiveThreadsStale() {
20454
+ for (const thread of this.client.threads.registeredThreads) {
20455
+ const { active, isStateStale } = thread.state.getLatestValue();
20456
+ if (!active && !isStateStale) thread.state.partialNext({ isStateStale: true });
20457
+ }
20458
+ }
20220
20459
  };
20221
20460
 
20222
20461
  // src/connection/networkConnection/NetworkConnectionObserver.ts
@@ -20348,6 +20587,16 @@ var NetworkConnectionObserver = class extends WithSubscriptions {
20348
20587
  get isOnline() {
20349
20588
  return this.state.getLatestValue().isOnline;
20350
20589
  }
20590
+ /**
20591
+ * Whether the installed reporter is the host default that mirrors this client's WebSocket
20592
+ * (`createWSConnectionNetworkStatusReporter`). Under it `isOnline === false` means "the socket is
20593
+ * down", not "the device is offline".
20594
+ *
20595
+ * @internal
20596
+ */
20597
+ get usesDefaultWSConnectionNetworkStatusReporter() {
20598
+ return this.installedReporter !== void 0 && this.installedReporter === this.defaultReporter && this.defaultReporter !== browserNetworkStatusReporter;
20599
+ }
20351
20600
  /**
20352
20601
  * Installs the platform listener that reports device network status, unsubscribing whatever was
20353
20602
  * there. Pass `null` to clear it, which leaves the last known status alone rather than reverting to
@@ -20399,6 +20648,7 @@ var NetworkConnectionObserver = class extends WithSubscriptions {
20399
20648
  // src/connection/wsConnection/config.ts
20400
20649
  var DEFAULT_WS_CONNECTION_CONFIG = deepFreezeConfig({
20401
20650
  connectTimeoutMs: 15 * 1e3,
20651
+ enableWSFallback: false,
20402
20652
  pingIntervalMs: 25 * 1e3,
20403
20653
  healthCheckGracePeriodMs: 10 * 1e3,
20404
20654
  offlineNotificationDisplayDelayMs: 5 * 1e3,
@@ -20986,7 +21236,190 @@ var StableWSConnection = class {
20986
21236
 
20987
21237
  // src/connection/wsConnection/WSConnection.ts
20988
21238
  var import_state_store25 = require("@stream-io/state-store");
20989
- var logger15 = chatLoggerSystem.getLogger("client");
21239
+
21240
+ // src/connection/wsConnection/WSConnectionFallback.ts
21241
+ var import_axios4 = __toESM(require("axios"));
21242
+ var logger15 = chatLoggerSystem.getLogger("connection");
21243
+ var WSConnectionFallback = class {
21244
+ constructor({ client }) {
21245
+ /**
21246
+ * Applies a change in the device's network status. Stands in for v9's `window` online/offline
21247
+ * listeners: `WSConnection` routes the network-status store here once it has switched to this
21248
+ * long-poll.
21249
+ *
21250
+ * @internal
21251
+ */
21252
+ this._applyNetworkStatus = (online) => {
21253
+ if (this.state === "DISCONNECTED" /* Disconnected */) return;
21254
+ this._log(`_applyNetworkStatus() - ${online ? "online" : "offline"}`);
21255
+ if (!online) {
21256
+ this._setState("CLOSED" /* Closed */);
21257
+ this.abortController?.abort();
21258
+ this.abortController = void 0;
21259
+ return;
21260
+ }
21261
+ if (this.state === "CLOSED" /* Closed */) {
21262
+ this.connect(true);
21263
+ }
21264
+ };
21265
+ /**
21266
+ * connect try to open a longpoll request
21267
+ *
21268
+ * @param reconnect - should be false for first call and true for subsequent calls to keep the connection alive and settle the connect promises
21269
+ *
21270
+ * @internal
21271
+ */
21272
+ this.connect = async (reconnect = false) => {
21273
+ if (this.state === "CONNECTING" /* Connecting */) {
21274
+ this._log("connect() - connecting already in progress", { reconnect }, "warn");
21275
+ return;
21276
+ }
21277
+ if (this.state === "CONNECTED" /* Connected */) {
21278
+ this._log("connect() - already connected and polling", { reconnect }, "warn");
21279
+ return;
21280
+ }
21281
+ this._setState("CONNECTING" /* Connecting */);
21282
+ this.client.connectionIdManager.arm();
21283
+ try {
21284
+ const { event } = await this._req(
21285
+ // Authenticated by the request's `Authorization` header, so the message carries only a
21286
+ // placeholder token.
21287
+ { json: this.client._buildWSAuthPayload() },
21288
+ { timeout: 8e3 },
21289
+ // 8s
21290
+ reconnect
21291
+ );
21292
+ this.client.connectionIdManager.resolveConnectionId(event.connection_id);
21293
+ this._setState("CONNECTED" /* Connected */);
21294
+ this.client.dispatchEvent(event);
21295
+ this._poll();
21296
+ if (reconnect) {
21297
+ this.client._settleConnectPromises();
21298
+ }
21299
+ return event;
21300
+ } catch (err) {
21301
+ if (this.state !== "DISCONNECTED" /* Disconnected */)
21302
+ this._setState("CLOSED" /* Closed */);
21303
+ if (!import_axios4.default.isCancel(err)) this.client.connectionIdManager.rejectConnectionId(err);
21304
+ throw err;
21305
+ }
21306
+ };
21307
+ /**
21308
+ * Stops polling and tells the server to close `connectionId`.
21309
+ *
21310
+ * @param timeout - The close request's timeout, in milliseconds.
21311
+ * @param connectionId - The id to close. Passed in rather than read from
21312
+ * `client.connectionIdManager`, because `WSConnection.disconnect()` disconnects the old socket
21313
+ * first, and that drops the manager's id.
21314
+ *
21315
+ * @internal
21316
+ */
21317
+ this.disconnect = async (timeout = 2e3, connectionId) => {
21318
+ this._setState("DISCONNECTED" /* Disconnected */);
21319
+ this.abortController?.abort();
21320
+ this.abortController = void 0;
21321
+ try {
21322
+ await this._req({ close: true, connection_id: connectionId }, { timeout }, false);
21323
+ this._log(`disconnect() - Closed connectionID`);
21324
+ } catch (err) {
21325
+ this._log(`disconnect() - Failed`, { err }, "error");
21326
+ }
21327
+ };
21328
+ this._req = async (params, config, retry) => {
21329
+ if (!this.abortController && !params.close) {
21330
+ this.abortController = new AbortController();
21331
+ }
21332
+ try {
21333
+ const res = await this.client.longPoll(params, {
21334
+ ...config,
21335
+ signal: this.abortController?.signal
21336
+ });
21337
+ this.consecutiveFailures = 0;
21338
+ return res;
21339
+ } catch (error) {
21340
+ this.consecutiveFailures += 1;
21341
+ if (retry && isErrorRetryable(error)) {
21342
+ this._log(`_req() - Retryable error, retrying request`);
21343
+ await sleep(retryInterval(this.consecutiveFailures));
21344
+ if (params.connection_id && params.connection_id !== this.client.connectionIdManager.connectionId) {
21345
+ this._log(`_req() - Connection id changed, dropping the retry`);
21346
+ throw new import_axios4.CanceledError(
21347
+ "The connection id changed while the retry was waiting"
21348
+ );
21349
+ }
21350
+ return this._req(params, config, retry);
21351
+ }
21352
+ throw error;
21353
+ }
21354
+ };
21355
+ this._poll = async () => {
21356
+ while (this.state === "CONNECTED" /* Connected */) {
21357
+ try {
21358
+ const data = await this._req(
21359
+ { connection_id: this.client.connectionIdManager.connectionId },
21360
+ { timeout: 3e4 },
21361
+ // 30s => API responds in 20s if there is no event
21362
+ true
21363
+ );
21364
+ if (data.events?.length) {
21365
+ for (let i = 0; i < data.events.length; i++) {
21366
+ this.client.dispatchEvent(data.events[i]);
21367
+ }
21368
+ }
21369
+ } catch (error) {
21370
+ if (import_axios4.default.isCancel(error)) {
21371
+ this._log(`_poll() - axios canceled request`);
21372
+ return;
21373
+ }
21374
+ if (isConnectionIDError(error)) {
21375
+ this._log(`_poll() - ConnectionID error, connecting without ID...`);
21376
+ this._setState("DISCONNECTED" /* Disconnected */);
21377
+ this.connect(true);
21378
+ return;
21379
+ }
21380
+ if (isAPIError(error) && !isErrorRetryable(error)) {
21381
+ this._setState("CLOSED" /* Closed */);
21382
+ this.client.connectionIdManager.rejectConnectionId(error);
21383
+ return;
21384
+ }
21385
+ await sleep(retryInterval(this.consecutiveFailures));
21386
+ }
21387
+ }
21388
+ };
21389
+ this.client = client;
21390
+ this.state = "INIT" /* Init */;
21391
+ this.consecutiveFailures = 0;
21392
+ }
21393
+ /**
21394
+ * Whether a connect request is in flight.
21395
+ *
21396
+ * @internal
21397
+ */
21398
+ get isConnecting() {
21399
+ return this.state === "CONNECTING" /* Connecting */;
21400
+ }
21401
+ _log(msg, extra = {}, level = "info") {
21402
+ const log = logger15.withExtraTags("connection_fallback");
21403
+ log[level]("WSConnectionFallback:" + msg, extra);
21404
+ }
21405
+ _setState(state) {
21406
+ this._log(`_setState() - ${state}`);
21407
+ const previous = this.state;
21408
+ this.state = state;
21409
+ if (previous === "CONNECTING" /* Connecting */ && this.state === "CONNECTED" /* Connected */) {
21410
+ this.client.wsConnection._setStatus({ isHealthy: true });
21411
+ }
21412
+ if (this.state === "CLOSED" /* Closed */ || this.state === "DISCONNECTED" /* Disconnected */) {
21413
+ this.client.connectionIdManager.invalidate();
21414
+ if (this.client.wsConnection._setStatus({ isHealthy: false })) {
21415
+ this.client._markActiveChannelsWatchInterrupted();
21416
+ }
21417
+ }
21418
+ }
21419
+ };
21420
+
21421
+ // src/connection/wsConnection/WSConnection.ts
21422
+ var logger16 = chatLoggerSystem.getLogger("client");
20990
21423
  var WSConnection = class extends WithSubscriptions {
20991
21424
  constructor({ client }) {
20992
21425
  super();
@@ -21012,6 +21445,9 @@ var WSConnection = class extends WithSubscriptions {
21012
21445
  *
21013
21446
  * Read from the store rather than from an event, so there is one description of the device's
21014
21447
  * network rather than two that can disagree.
21448
+ *
21449
+ * After an `enableWSFallback` switch it routes to {@link fallback} instead, leaving the
21450
+ * disconnected socket out of it.
21015
21451
  */
21016
21452
  this.registerSubscriptions = () => {
21017
21453
  if (!this.hasSubscriptions) {
@@ -21020,7 +21456,7 @@ var WSConnection = class extends WithSubscriptions {
21020
21456
  ({ isOnline }) => ({ isOnline }),
21021
21457
  ({ isOnline }) => {
21022
21458
  if (typeof isOnline !== "boolean") return;
21023
- this.connection?._applyNetworkStatus(isOnline);
21459
+ (this.fallback ?? this.connection)?._applyNetworkStatus(isOnline);
21024
21460
  }
21025
21461
  )
21026
21462
  );
@@ -21073,7 +21509,7 @@ var WSConnection = class extends WithSubscriptions {
21073
21509
  for (const correction of corrections) {
21074
21510
  if (this.warnedClamps.get(correction.field) === correction.requested) continue;
21075
21511
  this.warnedClamps.set(correction.field, correction.requested);
21076
- logger15.withExtraTags("wsConnection").warn(
21512
+ logger16.withExtraTags("wsConnection").warn(
21077
21513
  `The WebSocket setting '${correction.field}' is outside its supported range; using ${correction.used}ms instead of ${correction.requested}ms.`,
21078
21514
  { bounds: WS_CONNECTION_CONFIG_BOUNDS[correction.field] }
21079
21515
  );
@@ -21092,22 +21528,24 @@ var WSConnection = class extends WithSubscriptions {
21092
21528
  this.configController.initialize(config);
21093
21529
  }
21094
21530
  /**
21095
- * Is this WebSocket up. Read from {@link state} rather than from the current socket, so it
21096
- * survives the socket being replaced and answers `false` rather than throwing before the first
21097
- * connect.
21531
+ * Is this WebSocket up — or, after an `enableWSFallback` switch, the long-poll. Read from
21532
+ * {@link state} rather than from the current socket, so it survives the socket being replaced and
21533
+ * answers `false` rather than throwing before the first connect.
21098
21534
  */
21099
21535
  get isHealthy() {
21100
21536
  return this.state.getLatestValue().isHealthy;
21101
21537
  }
21102
- /** Whether a connection attempt is in flight. */
21538
+ /** Whether a connection attempt is in flight — the long-poll's after an `enableWSFallback` switch. */
21103
21539
  get isConnecting() {
21104
- return this.connection?.isConnecting ?? false;
21540
+ return (this.fallback ?? this.connection)?.isConnecting ?? false;
21105
21541
  }
21106
21542
  /**
21107
- * Records this WebSocket's status. Returns whether it changed.
21543
+ * Records this connection's status. Returns whether it changed.
21108
21544
  *
21109
- * {@link StableWSConnection} is the only caller, and it routes **every** status transition through
21110
- * here — including `disconnect()`, which `closeConnection()` uses, and the two error paths.
21545
+ * {@link StableWSConnection} routes **every** status transition through here — including
21546
+ * `disconnect()`, which `closeConnection()` uses, and the two error paths. The one other
21547
+ * caller is {@link fallback}, which takes over once `enableWSFallback` has switched to
21548
+ * long-polling.
21111
21549
  *
21112
21550
  * @internal
21113
21551
  */
@@ -21130,19 +21568,45 @@ var WSConnection = class extends WithSubscriptions {
21130
21568
  * Creation lives here rather than in the constructor because a client that never calls
21131
21569
  * `connectUser` should never build a socket, and here rather than in `client.connect()` because a
21132
21570
  * field belongs to the object that owns it.
21571
+ *
21572
+ * With `enableWSFallback`, a socket that fails with a network error is replaced by the long-poll
21573
+ * ({@link fallback}), which every later call connects instead of building a socket.
21133
21574
  */
21134
- connect(timeout) {
21575
+ async connect(timeout) {
21576
+ if (this.fallback) {
21577
+ return await this.fallback.connect();
21578
+ }
21135
21579
  const next = this.buildConnection();
21136
21580
  const previous = this.connection;
21137
21581
  if (previous && previous !== next) {
21138
21582
  void Promise.resolve(previous.disconnect()).catch((error) => {
21139
- logger15.withExtraTags("wsConnection").debug("Closing the replaced WebSocket failed; discarding it anyway.", {
21583
+ logger16.withExtraTags("wsConnection").debug("Closing the replaced WebSocket failed; discarding it anyway.", {
21140
21584
  error
21141
21585
  });
21142
21586
  });
21143
21587
  }
21144
21588
  this.connection = next;
21145
- return next.connect(timeout);
21589
+ try {
21590
+ return await next.connect(timeout);
21591
+ } catch (error) {
21592
+ const { networkConnection } = this.client;
21593
+ const isDeviceOffline = networkConnection.isOnline === false && !networkConnection.usesDefaultWSConnectionNetworkStatusReporter;
21594
+ if (this.config.enableWSFallback && isWSFailure(error) && !isDeviceOffline) {
21595
+ logger16.withExtraTags("connect").info("WS failed, fallback to longpoll");
21596
+ this.client.dispatchEvent({
21597
+ type: "connection.fallback_activated",
21598
+ mode: "longpoll"
21599
+ });
21600
+ next._destroyCurrentWSConnection();
21601
+ void next.disconnect();
21602
+ this.fallback = new WSConnectionFallback({ client: this.client });
21603
+ return await this.fallback.connect();
21604
+ }
21605
+ if (!isWSFailure(error)) {
21606
+ this.client.connectionIdManager.rejectConnectionId(error);
21607
+ }
21608
+ throw error;
21609
+ }
21146
21610
  }
21147
21611
  /**
21148
21612
  * `config.connection` lets a caller supply a pre-built socket — used by unit tests, and the reason
@@ -21156,8 +21620,17 @@ var WSConnection = class extends WithSubscriptions {
21156
21620
  }
21157
21621
  return new StableWSConnection({ wsConnection: this });
21158
21622
  }
21159
- disconnect(timeout) {
21160
- return this.connection?.disconnect(timeout);
21623
+ /**
21624
+ * Closes the socket and, after an `enableWSFallback` switch, the long-poll too, telling the
21625
+ * server to close its connection id. The long-poll's close request uses `timeout` as its timeout
21626
+ * (2s when omitted).
21627
+ */
21628
+ async disconnect(timeout) {
21629
+ const connectionId = this.client.connectionIdManager.connectionId;
21630
+ await Promise.all([
21631
+ this.connection?.disconnect(timeout),
21632
+ this.fallback?.disconnect(timeout, connectionId)
21633
+ ]);
21161
21634
  }
21162
21635
  /**
21163
21636
  * @deprecated Report network status through `client.networkConnection.setStatus(isOnline)` instead,
@@ -21659,7 +22132,7 @@ var CONSTRUCTION_ONLY_CONFIG_PATHS = {
21659
22132
  };
21660
22133
 
21661
22134
  // src/configuration/InstanceConfigurationRegistry.ts
21662
- var logger16 = chatLoggerSystem.getLogger("instance-configuration");
22135
+ var logger17 = chatLoggerSystem.getLogger("instance-configuration");
21663
22136
  var InstanceConfigurationRegistry = class {
21664
22137
  constructor() {
21665
22138
  /**
@@ -21857,7 +22330,7 @@ var InstanceConfigurationRegistry = class {
21857
22330
  try {
21858
22331
  instance.reinitializeConfig?.();
21859
22332
  } catch (error) {
21860
- logger16.error("reinitializeConfig threw during reset", error);
22333
+ logger17.error("reinitializeConfig threw during reset", error);
21861
22334
  }
21862
22335
  }
21863
22336
  }
@@ -21915,7 +22388,7 @@ var InstanceConfigurationRegistry = class {
21915
22388
  warnIfKeyLooksUnknown(key) {
21916
22389
  if (INSTANCE_CONFIG_TREE_KEYS.includes(key)) return;
21917
22390
  if (this.hasLiveInstances(key)) return;
21918
- logger16.withExtraTags(key).warn(
22391
+ logger17.withExtraTags(key).warn(
21919
22392
  "Configuration registered for a key this package does not define, with nothing subscribed to it. Check the spelling; a key declared by a downstream SDK is expected here only if its owner subscribes later."
21920
22393
  );
21921
22394
  }
@@ -21942,7 +22415,7 @@ var InstanceConfigurationRegistry = class {
21942
22415
  return !isEqual(getPath(registered, path), getPath(config, path));
21943
22416
  });
21944
22417
  if (late.length === 0) return;
21945
- logger16.withExtraTags(key).warn(
22418
+ logger17.withExtraTags(key).warn(
21946
22419
  `These paths are read once during construction, so they will not affect the ${key} instance(s) that already exist: ${late.join(", ")}. Register configuration before the instances are created \u2014 typically alongside StreamChat.getInstance().`
21947
22420
  );
21948
22421
  }
@@ -23293,6 +23766,7 @@ var ChatApi = class {
23293
23766
  }
23294
23767
  async longPoll(request, requestOptions) {
23295
23768
  const queryParams = {
23769
+ close: request?.close,
23296
23770
  connection_id: request?.connection_id,
23297
23771
  json: request?.json
23298
23772
  };
@@ -23905,8 +24379,11 @@ var ChatApi = class {
23905
24379
  function isString(value) {
23906
24380
  return typeof value === "string" || value instanceof String;
23907
24381
  }
23908
- var logger17 = chatLoggerSystem.getLogger("client");
24382
+ var logger18 = chatLoggerSystem.getLogger("client");
23909
24383
  var offlineDbLogger2 = chatLoggerSystem.getLogger("offline-db");
24384
+ var markThreadChannelWatched = ({ channel }) => {
24385
+ channel.watchStatus = ChannelWatchStatus.Watching;
24386
+ };
23910
24387
  var StreamChat = class _StreamChat extends ChatApi {
23911
24388
  /**
23912
24389
  * Initializes a client.
@@ -23961,7 +24438,7 @@ var StreamChat = class _StreamChat extends ChatApi {
23961
24438
  throw new Error('The "id" field on the user is missing');
23962
24439
  }
23963
24440
  if (this.userId === user.id && this.setUserPromise) {
23964
- logger17.withExtraTags("connectUser").warn(
24441
+ logger18.withExtraTags("connectUser").warn(
23965
24442
  "Detected consecutive calls to connectUser. Ideally, this function should only be called once."
23966
24443
  );
23967
24444
  return this.setUserPromise;
@@ -23972,7 +24449,7 @@ var StreamChat = class _StreamChat extends ChatApi {
23972
24449
  );
23973
24450
  }
23974
24451
  if (this.node && !this.options.allowServerSideConnect) {
23975
- logger17.withExtraTags("connectUser").warn(
24452
+ logger18.withExtraTags("connectUser").warn(
23976
24453
  'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in the client options to disable this warning.'
23977
24454
  );
23978
24455
  }
@@ -24018,8 +24495,12 @@ var StreamChat = class _StreamChat extends ChatApi {
24018
24495
  * So when your app goes to background, you can call `client.closeConnection`.
24019
24496
  * And when app comes back to foreground, call `client.openConnection`.
24020
24497
  *
24498
+ * After an `enableWSFallback` switch it closes the long-poll as well, telling the server to
24499
+ * close its connection id.
24500
+ *
24021
24501
  * @param timeout - Max number of milliseconds to wait for the WebSocket close event before forcefully assuming
24022
24502
  * successful disconnection. See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent (optional).
24503
+ * The long-poll's close request uses it as its timeout (2s when omitted).
24023
24504
  */
24024
24505
  this.closeConnection = async (timeout) => {
24025
24506
  this._resetAIStateOnActiveChannels();
@@ -24043,7 +24524,8 @@ var StreamChat = class _StreamChat extends ChatApi {
24043
24524
  return Promise.resolve();
24044
24525
  };
24045
24526
  /**
24046
- * Creates a new WebSocket connection with the current user.
24527
+ * Creates a new WebSocket connection with the current user. After an `enableWSFallback` switch it
24528
+ * reconnects the long-poll instead: the client never goes back to the WebSocket.
24047
24529
  *
24048
24530
  * @returns The WebSocket connect promise, or an empty resolved promise if a connection is already active.
24049
24531
  */
@@ -24054,11 +24536,11 @@ var StreamChat = class _StreamChat extends ChatApi {
24054
24536
  );
24055
24537
  }
24056
24538
  if (this.wsConnection?.isConnecting && this.wsPromise) {
24057
- logger17.withExtraTags("openConnection").debug("A connection attempt is already in progress.");
24539
+ logger18.withExtraTags("openConnection").debug("A connection attempt is already in progress.");
24058
24540
  return this.wsPromise;
24059
24541
  }
24060
24542
  if (this.wsConnection?.isHealthy && this.connectionIdManager.connectionId) {
24061
- logger17.withExtraTags("openConnection").debug("openConnection was called twice; a healthy connection already exists.");
24543
+ logger18.withExtraTags("openConnection").debug("openConnection was called twice; a healthy connection already exists.");
24062
24544
  return;
24063
24545
  }
24064
24546
  this.clientId = `${this.userId}--${randomId()}`;
@@ -24115,7 +24597,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24115
24597
  * @returns The close-connection promise.
24116
24598
  */
24117
24599
  this.disconnectUser = (timeout) => {
24118
- logger17.withExtraTags("disconnectUser").info("Disconnecting the client.");
24600
+ logger18.withExtraTags("disconnectUser").info("Disconnecting the client.");
24119
24601
  delete this.user;
24120
24602
  delete this._user;
24121
24603
  const teardownReason = new Error(
@@ -24123,8 +24605,8 @@ var StreamChat = class _StreamChat extends ChatApi {
24123
24605
  );
24124
24606
  this._rejectPendingWsPromise(teardownReason);
24125
24607
  this.wsPromise = null;
24126
- this.connectionIdManager.rejectConnectionId(teardownReason);
24127
24608
  const closePromise = this.closeConnection(timeout);
24609
+ this.connectionIdManager.rejectConnectionId(teardownReason);
24128
24610
  for (const channel of Object.values(this.activeChannels)) {
24129
24611
  channel._disconnect();
24130
24612
  }
@@ -24138,9 +24620,9 @@ var StreamChat = class _StreamChat extends ChatApi {
24138
24620
  this.unsubscribeClientConfiguration?.();
24139
24621
  this.unsubscribeClientConfiguration = void 0;
24140
24622
  closePromise.finally(() => {
24141
- this.tokenManager.reset();
24623
+ if (!this.userId) this.tokenManager.reset();
24142
24624
  }).catch(
24143
- (err) => logger17.withExtraTags("disconnectUser").error("The close promise rejected during disconnect.", { error: err })
24625
+ (err) => logger18.withExtraTags("disconnectUser").error("The close promise rejected during disconnect.", { error: err })
24144
24626
  );
24145
24627
  return closePromise;
24146
24628
  };
@@ -24158,7 +24640,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24158
24640
  */
24159
24641
  this.connectAnonymousUser = () => {
24160
24642
  if (this.node && !this.options.allowServerSideConnect) {
24161
- logger17.withExtraTags("connectAnonymousUser").warn(
24643
+ logger18.withExtraTags("connectAnonymousUser").warn(
24162
24644
  'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in the client options to disable this warning.'
24163
24645
  );
24164
24646
  }
@@ -24311,7 +24793,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24311
24793
  ...this.listeners.get(event.type) ?? []
24312
24794
  ];
24313
24795
  for (const listener of listeners) {
24314
- invokeEventListener(listener, event, logger17);
24796
+ invokeEventListener(listener, event, logger18);
24315
24797
  }
24316
24798
  };
24317
24799
  /**
@@ -24322,15 +24804,16 @@ var StreamChat = class _StreamChat extends ChatApi {
24322
24804
  * Requests no longer settle against these: waiting for a connection id is the
24323
24805
  * {@link ConnectionIdManager}'s job, applied centrally in `ApiClient`.
24324
24806
  *
24325
- * Called by `StableWSConnection._reconnect()`. Recovery itself is owned by
24326
- * {@link ConnectionRecoveryManager}, which subscribes to the connection lifecycle and so covers
24327
- * every reconnect path — including `closeConnection()` → `openConnection()` (mobile backgrounding),
24328
- * which never reaches `_reconnect()` at all.
24807
+ * Called by `StableWSConnection._reconnect()`, and by `WSConnectionFallback.connect(true)`
24808
+ * for the long-poll's own reconnects. Recovery itself is owned by
24809
+ * {@link ConnectionRecoveryManager}, which subscribes to the connection lifecycle and so
24810
+ * covers every reconnect path — including `closeConnection()` → `openConnection()` (mobile
24811
+ * backgrounding), which reaches neither.
24329
24812
  *
24330
24813
  * @internal
24331
24814
  */
24332
24815
  this._settleConnectPromises = () => {
24333
- logger17.withExtraTags("_settleConnectPromises").info(
24816
+ logger18.withExtraTags("_settleConnectPromises").info(
24334
24817
  `Connection re-established with connection ID ${this.connectionIdManager.connectionId}.`
24335
24818
  );
24336
24819
  if (this._wsPromiseSettled) {
@@ -24420,7 +24903,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24420
24903
  return this.userAgent;
24421
24904
  }
24422
24905
  if (!this.cachedUserAgent) {
24423
- const version = "10.0.0-rc.15";
24906
+ const version = "10.0.0-rc.17";
24424
24907
  const { name: sdkName, version: sdkVersion } = this.sdkIdentifier ?? {};
24425
24908
  const { name: appName, version: appVersion } = this.appIdentifier ?? {};
24426
24909
  const { os, model: deviceModel } = this.deviceIdentifier ?? {};
@@ -24450,11 +24933,21 @@ var StreamChat = class _StreamChat extends ChatApi {
24450
24933
  *
24451
24934
  * @returns The JSON-encoded auth message.
24452
24935
  */
24453
- this._buildWSAuthMessage = () => JSON.stringify({
24454
- // The server requires a non-empty token even for anonymous connections, but
24455
- // skips JWT parsing for any string that is not shaped like one. Anonymous users
24456
- // have no token, so send a placeholder the server accepts and ignores.
24457
- token: this.tokenManager.getToken() || "anonymous",
24936
+ this._buildWSAuthMessage = () => JSON.stringify(this._buildWSAuthPayload(this.tokenManager.getToken()));
24937
+ /**
24938
+ * The auth message itself — what {@link _buildWSAuthMessage} encodes for the WebSocket, and what
24939
+ * the long-poll fallback sends as `longPoll()`'s `json` query param.
24940
+ *
24941
+ * @private
24942
+ *
24943
+ * @param token - The token the message carries. The long-poll passes none: it authenticates
24944
+ * through the request's `Authorization` header instead.
24945
+ */
24946
+ this._buildWSAuthPayload = (token) => ({
24947
+ // The server requires a non-empty token, but skips JWT parsing for any string that is not
24948
+ // shaped like one. Anonymous users have no token, and the long-poll does not send one, so both
24949
+ // send a placeholder the server accepts and ignores.
24950
+ token: token || "anonymous",
24458
24951
  // `connect()` rejects before reaching this when `_user` is unset.
24459
24952
  user_details: this._user,
24460
24953
  products: ["chat"]
@@ -24477,7 +24970,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24477
24970
  this.browser = options.browser ?? typeof window !== "undefined";
24478
24971
  this.node = !this.browser;
24479
24972
  this.options = { ...options };
24480
- this.axiosInstance = import_axios4.default.create({
24973
+ this.axiosInstance = import_axios5.default.create({
24481
24974
  timeout: 3e3,
24482
24975
  withCredentials: false,
24483
24976
  ...this.options.axiosRequestConfig,
@@ -24734,14 +25227,14 @@ var StreamChat = class _StreamChat extends ChatApi {
24734
25227
  const key = callbackOrNothing ? callbackOrString : "all";
24735
25228
  const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
24736
25229
  const set = this.listeners.get(key) ?? /* @__PURE__ */ new Set();
24737
- logger17.withExtraTags("on").debug(`Attaching a listener for the "${key}" event.`);
25230
+ logger18.withExtraTags("on").debug(`Attaching a listener for the "${key}" event.`);
24738
25231
  set.add(callback);
24739
25232
  if (!this.listeners.has(key)) {
24740
25233
  this.listeners.set(key, set);
24741
25234
  }
24742
25235
  return {
24743
25236
  unsubscribe: () => {
24744
- logger17.withExtraTags("on").debug(`Removing the listener for the "${key}" event.`);
25237
+ logger18.withExtraTags("on").debug(`Removing the listener for the "${key}" event.`);
24745
25238
  set.delete(callback);
24746
25239
  if (!set.size) {
24747
25240
  this.listeners.delete(key);
@@ -24752,7 +25245,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24752
25245
  off(callbackOrString, callbackOrNothing) {
24753
25246
  const key = callbackOrNothing ? callbackOrString : "all";
24754
25247
  const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
24755
- logger17.withExtraTags("off").debug(`Removing the listener for the "${key}" event.`);
25248
+ logger18.withExtraTags("off").debug(`Removing the listener for the "${key}" event.`);
24756
25249
  const set = this.listeners.get(key);
24757
25250
  set?.delete(callback);
24758
25251
  if (!set?.size) {
@@ -24762,7 +25255,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24762
25255
  _handleClientEvent(event) {
24763
25256
  const client = this;
24764
25257
  const postListenerCallbacks = [];
24765
- logger17.withExtraTags("_handleClientEvent").debug(`Received an event of type "${event.type}".`, { event });
25258
+ logger18.withExtraTags("_handleClientEvent").debug(`Received an event of type "${event.type}".`, { event });
24766
25259
  if (event.type === "user.presence.changed" || event.type === "user.updated" || event.type === "user.deleted") {
24767
25260
  this._handleUserEvent(event);
24768
25261
  }
@@ -24839,10 +25332,13 @@ var StreamChat = class _StreamChat extends ChatApi {
24839
25332
  * Only `Watching` is demoted: a channel the consumer stopped on purpose, or one that was torn
24840
25333
  * down, stays `NotWatching` and must not be resurrected by a reconnect.
24841
25334
  *
24842
- * Invoked from two places, because neither covers the other: `StableWSConnection._setHealth(false)`
24843
- * for an abnormal close/error, and `closeConnection()` for a deliberate shutdown (e.g. mobile
24844
- * backgrounding), whose `disconnect()` writes the status through `_applyHealth` and so never
24845
- * reaches `_setHealth`.
25335
+ * Invoked from two places on the WebSocket, because neither covers the other:
25336
+ * `StableWSConnection._setHealth(false)` for an abnormal close/error, and
25337
+ * `closeConnection()` for a deliberate shutdown (e.g. mobile backgrounding), whose
25338
+ * `disconnect()` writes the status through `_applyHealth` and so never reaches
25339
+ * `_setHealth`. After an `enableWSFallback` switch the long-poll calls it too, from
25340
+ * `WSConnectionFallback._setState()` whenever going closed or disconnected takes the
25341
+ * status offline.
24846
25342
  */
24847
25343
  _markActiveChannelsWatchInterrupted() {
24848
25344
  for (const cid in this.activeChannels) {
@@ -24891,14 +25387,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24891
25387
  if (!this.clientId) {
24892
25388
  throw Error("Property clientId is not set");
24893
25389
  }
24894
- try {
24895
- return await this.wsConnection.connect();
24896
- } catch (error) {
24897
- if (!isWSFailure(error)) {
24898
- this.connectionIdManager.rejectConnectionId(error);
24899
- }
24900
- throw error;
24901
- }
25390
+ return await this.wsConnection.connect();
24902
25391
  }
24903
25392
  /**
24904
25393
  * Queries users and watches user presence.
@@ -24915,6 +25404,31 @@ var StreamChat = class _StreamChat extends ChatApi {
24915
25404
  this.state.updateUsers(data.users);
24916
25405
  return data;
24917
25406
  }
25407
+ /**
25408
+ * Stops watching a channel on this client's connection.
25409
+ *
25410
+ * It carries no `watch` flag, so the request layer does not hold it, but it is connection-scoped
25411
+ * by definition: it tells the server which connection should stop watching. So it waits for this
25412
+ * client's connection id exactly as a watching request does — through a handshake or a reconnect,
25413
+ * until the caller's abort signal fires — and throws when there is no connection and none is being
25414
+ * established.
25415
+ *
25416
+ * @param ...args - `[request, requestOptions]`. `request.connection_id` is replaced by this
25417
+ * client's connection id.
25418
+ * @returns The server response.
25419
+ */
25420
+ async stopWatchingChannel(...args) {
25421
+ const [request, requestOptions] = args;
25422
+ const signal = requestOptions?.signal;
25423
+ const connectionId = await this.connectionIdManager.getConnectionId(
25424
+ // Check if signal is still usable - if it is read from offline DB, it has lost `addEventListener`.
25425
+ typeof signal?.addEventListener === "function" ? signal : void 0
25426
+ );
25427
+ return super.stopWatchingChannel(
25428
+ { ...request, connection_id: connectionId },
25429
+ requestOptions
25430
+ );
25431
+ }
24918
25432
  /**
24919
25433
  * Queries channels and returns the full API response including top-level metadata such as
24920
25434
  * `predefined_filter`.
@@ -25369,12 +25883,11 @@ var StreamChat = class _StreamChat extends ChatApi {
25369
25883
  const response = await this.queryThreads(requestBody, requestOptions);
25370
25884
  const parentMessages = response.threads.map((thread) => thread.parent_message);
25371
25885
  this.polls.hydratePollCache(parentMessages);
25372
- return {
25373
- threads: response.threads.map(
25374
- (thread) => new Thread({ client: this, threadData: thread })
25375
- ),
25376
- next: response.next
25377
- };
25886
+ const threads = response.threads.map(
25887
+ (thread) => new Thread({ client: this, threadData: thread })
25888
+ );
25889
+ if (optionsWithDefaults.watch) threads.forEach(markThreadChannelWatched);
25890
+ return { threads, next: response.next };
25378
25891
  }
25379
25892
  /**
25380
25893
  * Returns the thread of a message by its ID, wrapped in a hydrated `Thread` instance.
@@ -25406,7 +25919,9 @@ var StreamChat = class _StreamChat extends ChatApi {
25406
25919
  },
25407
25920
  requestOptions
25408
25921
  );
25409
- return new Thread({ client: this, threadData: response.thread });
25922
+ const thread = new Thread({ client: this, threadData: response.thread });
25923
+ if (optionsWithDefaults.watch) markThreadChannelWatched(thread);
25924
+ return thread;
25410
25925
  }
25411
25926
  /**
25412
25927
  * Sets the user agent string.
@@ -25841,6 +26356,11 @@ var WS_CONNECTION_FIELDS = {
25841
26356
  kind: "value",
25842
26357
  type: "number"
25843
26358
  },
26359
+ enableWSFallback: {
26360
+ description: "Falls back to HTTP long-polling (`/api/v2/longpoll`) when the WebSocket cannot connect, for networks that block WebSockets. The WebSocket connects within `connectTimeoutMs`, as without the flag; lower it to switch sooner. Dispatches `connection.fallback_activated` when it switches, and stays on long-poll from then on. Defaults to `false`. Was `StreamChatOptions.enableWSFallback`.",
26361
+ kind: "value",
26362
+ type: "boolean"
26363
+ },
25844
26364
  offlineNotificationDisplayDelayMs: {
25845
26365
  description: "How long a drop must last before a UI reports it. Most drops resolve in under a second, so a banner showing all of them makes a working application look broken. Nothing in this package waits on it: it is here so the UI SDKs share one value. Zero holds nothing back, landing on the next task.",
25846
26366
  kind: "value",
@@ -25899,7 +26419,7 @@ var CLIENT_FIELDS = {
25899
26419
  kind: "group"
25900
26420
  },
25901
26421
  wsConnection: {
25902
- description: "This client's WebSocket: how long to wait for it, how often to ping it, how long it may go quiet before being declared dead, and how long a drop must last before a UI reports it.",
26422
+ description: "This client's WebSocket: how long to wait for it, how often to ping it, how long it may go quiet before being declared dead, how long a drop must last before a UI reports it, and whether to fall back to long-polling when it cannot connect.",
25903
26423
  fields: WS_CONNECTION_FIELDS,
25904
26424
  kind: "group"
25905
26425
  }
@@ -26123,8 +26643,8 @@ var OfflineError = class extends Error {
26123
26643
  };
26124
26644
 
26125
26645
  // src/offline-support/offline_sync_manager.ts
26126
- var import_axios5 = require("axios");
26127
- var logger18 = chatLoggerSystem.getLogger("offline-db");
26646
+ var import_axios6 = require("axios");
26647
+ var logger19 = chatLoggerSystem.getLogger("offline-db");
26128
26648
  var OfflineDBSyncManager = class {
26129
26649
  constructor({
26130
26650
  client,
@@ -26168,7 +26688,7 @@ var OfflineDBSyncManager = class {
26168
26688
  }
26169
26689
  );
26170
26690
  } catch (error) {
26171
- logger18.withExtraTags("init").error("Failed to initialize the offline DB sync manager.", { error });
26691
+ logger19.withExtraTags("init").error("Failed to initialize the offline DB sync manager.", { error });
26172
26692
  }
26173
26693
  };
26174
26694
  /**
@@ -26258,7 +26778,7 @@ var OfflineDBSyncManager = class {
26258
26778
  const { syncMaxEventCount } = this;
26259
26779
  const exceedsLimit = typeof syncMaxEventCount === "number" && syncMaxEventCount > 0 && result.events.length > syncMaxEventCount;
26260
26780
  if (exceedsLimit) {
26261
- logger18.withExtraTags("sync").warn(
26781
+ logger19.withExtraTags("sync").warn(
26262
26782
  `Skipping sync event replay: received ${result.events.length} events, which exceeds the configured limit of ${syncMaxEventCount}. Visible channels are refreshed on reconnect instead.`
26263
26783
  );
26264
26784
  } else {
@@ -26278,8 +26798,8 @@ var OfflineDBSyncManager = class {
26278
26798
  lastSyncedAt: (/* @__PURE__ */ new Date()).toString()
26279
26799
  });
26280
26800
  } catch (e) {
26281
- logger18.withExtraTags("syncAndExecutePendingTasks").error("An error occurred while syncing the database.", { error: e });
26282
- if ((0, import_axios5.isAxiosError)(e) && e.code === "ECONNABORTED") {
26801
+ logger19.withExtraTags("syncAndExecutePendingTasks").error("An error occurred while syncing the database.", { error: e });
26802
+ if ((0, import_axios6.isAxiosError)(e) && e.code === "ECONNABORTED") {
26283
26803
  return;
26284
26804
  }
26285
26805
  const error = e;
@@ -26319,7 +26839,7 @@ var OfflineDBSyncManager = class {
26319
26839
 
26320
26840
  // src/offline-support/offline_support_api.ts
26321
26841
  var import_state_store30 = require("@stream-io/state-store");
26322
- var logger19 = chatLoggerSystem.getLogger("offline-db");
26842
+ var logger20 = chatLoggerSystem.getLogger("offline-db");
26323
26843
  var AbstractOfflineDB = class {
26324
26844
  constructor({
26325
26845
  client,
@@ -26344,7 +26864,7 @@ var AbstractOfflineDB = class {
26344
26864
  }
26345
26865
  } catch (error) {
26346
26866
  this.state.partialNext({ initialized: false, userId: void 0 });
26347
- logger19.withExtraTags("init").error("Failed to initialize the offline database.", { error });
26867
+ logger20.withExtraTags("init").error("Failed to initialize the offline database.", { error });
26348
26868
  }
26349
26869
  };
26350
26870
  /**
@@ -26436,7 +26956,7 @@ var AbstractOfflineDB = class {
26436
26956
  }
26437
26957
  }
26438
26958
  if (!channelData) {
26439
- logger19.withExtraTags("queriesWithChannelGuard").warn(
26959
+ logger20.withExtraTags("queriesWithChannelGuard").warn(
26440
26960
  `Received a "${type}" event for a non-initialized channel that is not in the database. Skipping the event.`,
26441
26961
  { event }
26442
26962
  );
@@ -26447,7 +26967,7 @@ var AbstractOfflineDB = class {
26447
26967
  execute: false
26448
26968
  });
26449
26969
  if (!channelQuery) {
26450
- logger19.withExtraTags("queriesWithChannelGuard").warn(
26970
+ logger20.withExtraTags("queriesWithChannelGuard").warn(
26451
26971
  `Could not create channel queries on a "${type}" event for an initialized channel that is not in the database. Skipping the event.`,
26452
26972
  { event }
26453
26973
  );
@@ -26642,7 +27162,7 @@ var AbstractOfflineDB = class {
26642
27162
  }) => {
26643
27163
  const channel = this.client.activeChannels[cid];
26644
27164
  if (!channel?.initialized || channel.pendingDisposal) {
26645
- logger19.withExtraTags("upsertMessageWithChannelGuard", cid).warn("Skipping message persistence: the channel is not in the database.");
27165
+ logger20.withExtraTags("upsertMessageWithChannelGuard", cid).warn("Skipping message persistence: the channel is not in the database.");
26646
27166
  return [];
26647
27167
  }
26648
27168
  const queries = [
@@ -27057,13 +27577,12 @@ var AbstractOfflineDB = class {
27057
27577
  * message ordered against everything queued after it.
27058
27578
  *
27059
27579
  * Keyed on a queued `send-message` task existing for this id - the only reliable statement of "the
27060
- * server has never seen this message". NOT on the edited message's status: both payloads are built
27061
- * by `localMessageToNewMessagePayload`, which strips `status` along with every other client-only
27062
- * field, so nothing arriving here carries one.
27580
+ * server has never seen this message". NOT on the edited message's status: `failed` is also what a
27581
+ * rejected edit of a message the server has carries.
27063
27582
  *
27064
- * That same normalization is why the two merge by a plain spread: both are already `MessageRequest`s,
27065
- * and normalizing again would map an already-flattened `mentioned_users` back over itself as
27066
- * `[undefined]`.
27583
+ * Both payloads are already `MessageRequest`s (the send's from `localMessageToNewMessagePayload`, the
27584
+ * edit's from `toUpdatedMessagePayload`), which is why they merge by a plain spread: normalizing again
27585
+ * would map an already-flattened `mentioned_users` back over itself as `[undefined]`.
27067
27586
  *
27068
27587
  * With nothing to fold into, the edit is queued on its own and replays as an ordinary update.
27069
27588
  */