stream-chat 10.0.0-rc.14 → 10.0.0-rc.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/cjs/index.browser.js +507 -262
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +510 -262
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +507 -262
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/channel.d.ts +10 -4
  8. package/dist/types/connection/ConnectionRecoveryManager.d.ts +11 -22
  9. package/dist/types/entityStore/EntityStore.d.ts +4 -0
  10. package/dist/types/messageComposer/linkPreviewsManager.d.ts +3 -3
  11. package/dist/types/messageComposer/messageComposer.d.ts +2 -2
  12. package/dist/types/messageDelivery/MessageDeliveryReporter.d.ts +3 -2
  13. package/dist/types/offline-support/offline_support_api.d.ts +5 -6
  14. package/dist/types/pagination/paginators/BasePaginator.d.ts +8 -1
  15. package/dist/types/pagination/paginators/ChannelPaginator.d.ts +5 -1
  16. package/dist/types/pagination/paginators/ThreadPaginator.d.ts +57 -0
  17. package/dist/types/pagination/paginators/index.d.ts +1 -0
  18. package/dist/types/thread.d.ts +30 -21
  19. package/dist/types/thread_manager.d.ts +74 -32
  20. package/dist/types/types.d.ts +0 -6
  21. package/package.json +1 -1
  22. package/src/channel.ts +15 -12
  23. package/src/client.ts +19 -11
  24. package/src/configuration/shape.ts +6 -0
  25. package/src/connection/ConnectionRecoveryManager.ts +19 -24
  26. package/src/entityStore/EntityStore.ts +10 -0
  27. package/src/messageComposer/messageComposer.ts +3 -3
  28. package/src/messageDelivery/MessageDeliveryReporter.ts +18 -7
  29. package/src/messageOperations/createMessageOperations.ts +3 -3
  30. package/src/offline-support/offline_support_api.ts +5 -6
  31. package/src/offline-support/queueableOperations.ts +1 -1
  32. package/src/pagination/paginators/BasePaginator.ts +45 -8
  33. package/src/pagination/paginators/ChannelPaginator.ts +17 -3
  34. package/src/pagination/paginators/ThreadPaginator.ts +180 -0
  35. package/src/pagination/paginators/index.ts +1 -0
  36. package/src/thread.ts +188 -220
  37. package/src/thread_manager.ts +257 -173
  38. package/src/types.ts +0 -6
  39. package/src/utils.ts +1 -2
@@ -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,
@@ -552,7 +555,7 @@ var channelHasReadEvents = (channel) => {
552
555
  const ownCapabilities = channel?.data?.own_capabilities;
553
556
  return !(Array.isArray(ownCapabilities) && !ownCapabilities.includes("read-events"));
554
557
  };
555
- var channelTracksReadLocally = (channel) => !channelHasReadEvents(channel) && !!channel?.getClient().options.isLocalUnreadCountEnabled;
558
+ var channelTracksReadLocally = (channel) => !channelHasReadEvents(channel) && !!channel?.config.readEvents.localUnreadCountEnabled;
556
559
  function retryInterval(numberOfFailures) {
557
560
  const max = Math.min(500 + numberOfFailures * 2e3, 25e3);
558
561
  const min = Math.min(Math.max(250, (numberOfFailures - 1) * 2e3), 25e3);
@@ -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
  });
@@ -8153,6 +8182,8 @@ var ChannelPaginator = class extends BasePaginator {
8153
8182
  * continuation rather than a first page. See `executeQuery`.
8154
8183
  */
8155
8184
  this._itemsLoadedFromOfflineDB = false;
8185
+ /** The query in flight refreshes a cache-only window, so its response replaces the window. */
8186
+ this._replaceOfflineSeedOnResponse = false;
8156
8187
  /**
8157
8188
  * Filters sent to the server with the next query (and used as the offline-db query key). Deliberately
8158
8189
  * built from the locally configured filters only — feeding the backend-resolved filter back in would
@@ -8209,6 +8240,7 @@ var ChannelPaginator = class extends BasePaginator {
8209
8240
  });
8210
8241
  };
8211
8242
  this.query = async () => {
8243
+ if (this._itemsLoadedFromOfflineDB) this._replaceOfflineSeedOnResponse = true;
8212
8244
  this._itemsLoadedFromOfflineDB = false;
8213
8245
  if (!this._nextQueryShape) {
8214
8246
  this._nextQueryShape = this.getNextQueryShape();
@@ -8376,14 +8408,22 @@ var ChannelPaginator = class extends BasePaginator {
8376
8408
  if (params.isFirstPage && params.results) {
8377
8409
  this.applyPredefinedFilterResponse(pendingPredefinedFilter);
8378
8410
  }
8411
+ const replaceOfflineSeed = this._replaceOfflineSeedOnResponse;
8412
+ this._replaceOfflineSeedOnResponse = false;
8413
+ if (replaceOfflineSeed && params.results) {
8414
+ this.clearItemStorage();
8415
+ return super.postQueryReconcile({ ...params, keepPreviousItems: false });
8416
+ }
8379
8417
  return super.postQueryReconcile(params);
8380
8418
  }
8381
8419
  // invoked inside BasePaginator.executeQuery() to keep it as a query descriptor;
8382
- getNextQueryShape() {
8420
+ getNextQueryShape({
8421
+ pageSize
8422
+ } = {}) {
8383
8423
  const shape = {
8384
8424
  filter_conditions: this.buildQueryFilters(),
8385
8425
  ...this.options,
8386
- limit: this.pageSize,
8426
+ limit: pageSize ?? this.pageSize,
8387
8427
  offset: this.offset
8388
8428
  };
8389
8429
  if (this.sort) {
@@ -8420,7 +8460,7 @@ var ChannelPaginator = class extends BasePaginator {
8420
8460
  }) {
8421
8461
  this.client.offlineDb?.executeQuerySafely(
8422
8462
  (db) => db.upsertCidsForQuery({
8423
- cids,
8463
+ cids: cids.slice(0, this.pageSize),
8424
8464
  filters: request.filter_conditions,
8425
8465
  options: request,
8426
8466
  predefinedFilter: this._predefinedFilter,
@@ -9022,6 +9062,130 @@ var UserGroupPaginator = class extends BasePaginator {
9022
9062
  }
9023
9063
  };
9024
9064
 
9065
+ // src/pagination/paginators/ThreadPaginator.ts
9066
+ var DEFAULT_THREAD_PAGE_SIZE = 25;
9067
+ var DEFAULT_THREAD_QUERY = {
9068
+ participant_limit: 10,
9069
+ reply_limit: 10,
9070
+ watch: true
9071
+ };
9072
+ var getQueryIdentity2 = (queryShape) => {
9073
+ if (!queryShape) return queryShape;
9074
+ const { limit: _, next: __, prev: ___, ...identity } = queryShape;
9075
+ return identity;
9076
+ };
9077
+ var hasPaginationQueryShapeChanged3 = (prevQueryShape, nextQueryShape) => !isEqual(getQueryIdentity2(prevQueryShape), getQueryIdentity2(nextQueryShape));
9078
+ var ThreadPaginator = class extends BasePaginator {
9079
+ constructor({
9080
+ client,
9081
+ store = new EntityStore({ getEntityId: (thread) => thread.id }),
9082
+ paginatorOptions
9083
+ }) {
9084
+ super({
9085
+ hasPaginationQueryShapeChanged: hasPaginationQueryShapeChanged3,
9086
+ initialCursor: ZERO_PAGE_CURSOR,
9087
+ itemIndex: new StoreBackedItemIndex({
9088
+ getEntityId: (thread) => thread.id,
9089
+ store
9090
+ }),
9091
+ pageSize: DEFAULT_THREAD_PAGE_SIZE,
9092
+ ...paginatorOptions
9093
+ });
9094
+ this.query = async ({
9095
+ queryShape
9096
+ }) => {
9097
+ const { threads, next } = await this.client.queryThreadsAndHydrate(queryShape);
9098
+ return { items: threads.map(this.toStoredInstance), tailward: next };
9099
+ };
9100
+ /**
9101
+ * The stored instance for a queried thread, so there's one per id. The index upserts, so a second
9102
+ * instance would replace the stored one. A stale stored thread is refreshed from the query, unless
9103
+ * it's open (active) at which point it reloads itself and this would replace the replies on screen.
9104
+ */
9105
+ this.toStoredInstance = (incoming) => {
9106
+ const stored = this.store.get(incoming.id);
9107
+ if (!stored) return incoming;
9108
+ if (stored.hasStaleState && !stored.state.getLatestValue().active) {
9109
+ stored.hydrateState(incoming);
9110
+ }
9111
+ return stored;
9112
+ };
9113
+ this.filterQueryResults = (threads) => threads;
9114
+ /**
9115
+ * Re-queries the first page and swaps the list in place, in server order; also the first load. The
9116
+ * current threads stay until the response lands, and a failed query leaves them untouched and
9117
+ * records `lastQueryError`; a successful one clears it. Like any paginator, the first load publishes
9118
+ * `isLoading`; a reload of a loaded list does not, since `isLoading` with items means the next page.
9119
+ */
9120
+ this.reload = async ({ limit = this.pageSize } = {}) => {
9121
+ const isFirstLoad = !this.hasResults;
9122
+ if (isFirstLoad) this.state.partialNext({ isLoading: true });
9123
+ let replaced = false;
9124
+ try {
9125
+ const results = await this.runQueryRetryable({
9126
+ queryShape: { ...DEFAULT_THREAD_QUERY, limit },
9127
+ retryCount: this.config.retryCount
9128
+ });
9129
+ if (!results) return;
9130
+ const nextIds = new Set(results.items.map((thread) => thread.id));
9131
+ this.setIntervals([]);
9132
+ this.setActiveInterval(void 0);
9133
+ for (const [id] of this._itemIndex.entries()) {
9134
+ if (!nextIds.has(id)) this._itemIndex.remove(id);
9135
+ }
9136
+ this.setItems({
9137
+ cursor: { headward: void 0, tailward: results.tailward },
9138
+ isFirstPage: true,
9139
+ isLastPage: !results.tailward,
9140
+ valueOrFactory: results.items
9141
+ });
9142
+ replaced = true;
9143
+ } finally {
9144
+ const clearError = replaced && this.lastQueryError;
9145
+ if (isFirstLoad || clearError) {
9146
+ this.state.partialNext({
9147
+ ...isFirstLoad ? { isLoading: false } : {},
9148
+ ...clearError ? { lastQueryError: void 0 } : {}
9149
+ });
9150
+ }
9151
+ }
9152
+ };
9153
+ this.client = client;
9154
+ this.store = store;
9155
+ }
9156
+ /**
9157
+ * No next page until a load has landed (`master`'s `nextCursor: null`), so `toTail()` is a no-op
9158
+ * before the first successful reload, e.g. when an empty list fires `onEndReached` on mount.
9159
+ */
9160
+ get initialState() {
9161
+ return {
9162
+ ...super.initialState,
9163
+ hasMoreTail: false
9164
+ };
9165
+ }
9166
+ getItemId(thread) {
9167
+ return thread.id;
9168
+ }
9169
+ getNextQueryShape({
9170
+ direction
9171
+ }) {
9172
+ const cursor = this.cursor?.[direction] ?? void 0;
9173
+ return {
9174
+ ...DEFAULT_THREAD_QUERY,
9175
+ limit: this.pageSize,
9176
+ ...cursor ? { [direction === "tailward" ? "next" : "prev"]: cursor } : {}
9177
+ };
9178
+ }
9179
+ /**
9180
+ * Also releases the list's hold on its threads, which the base reset leaves in the index. Released
9181
+ * before the base publishes, so a subscriber sees the threads already gone from the registry.
9182
+ */
9183
+ resetState() {
9184
+ this._itemIndex.clear();
9185
+ super.resetState();
9186
+ }
9187
+ };
9188
+
9025
9189
  // src/search/ChannelMemberSearchSource.ts
9026
9190
  var ChannelMemberSearchSource = class extends BaseSearchSource {
9027
9191
  constructor(channel, options, filterBuilderOptions = {}) {
@@ -12621,7 +12785,7 @@ var createMessageOperations = (collection) => {
12621
12785
  channel.messagePaginator.removeItem({ id });
12622
12786
  channel.pinnedMessagesPaginator.removeItem({ id });
12623
12787
  if (parentId) {
12624
- client.threads.threadsById[parentId]?.messagePaginator.removeItem({ id });
12788
+ client.threads.get(parentId)?.messagePaginator.removeItem({ id });
12625
12789
  }
12626
12790
  },
12627
12791
  ...parentMessageId ? {
@@ -12666,7 +12830,7 @@ var createMessageOperations = (collection) => {
12666
12830
  update: async (m, o) => {
12667
12831
  const result = await channel.getClient().updateMessage({
12668
12832
  id: m.id,
12669
- message: localMessageToNewMessagePayload(m),
12833
+ message: toUpdatedMessagePayload(m),
12670
12834
  ...o
12671
12835
  });
12672
12836
  return { message: result.message };
@@ -12777,10 +12941,15 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12777
12941
  * reload on reconnect, so an unbalanced `deactivate()` now costs more than a missed auto-read —
12778
12942
  * hence the refcount, matching `channel.activate()`. A thread held by more than one mount stays
12779
12943
  * active until the last holder releases it.
12944
+ *
12945
+ * The first activation also keeps the thread in `client.threads` for the rest of the session:
12946
+ * it stays subscribed and resolvable through `client.threads.get(id)` after it is deactivated,
12947
+ * whether or not the thread list holds it. Reopening it therefore needs no fetch unless it went stale.
12780
12948
  */
12781
12949
  this.activate = () => {
12782
12950
  this._activeRefCount += 1;
12783
12951
  if (this._activeRefCount === 1) {
12952
+ this.client.threads.register(this);
12784
12953
  this.state.partialNext({ active: true });
12785
12954
  }
12786
12955
  };
@@ -12802,10 +12971,9 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12802
12971
  *
12803
12972
  * Preserves failed (unsent) replies. They are read out of the reply paginator rather than out of
12804
12973
  * `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
12974
+ * this thread's own subscriptions and the offline-DB path resolved through `client.threads.get` —
12975
+ * neither of which covers a thread that was never activated or listed. Reading the paginator
12976
+ * is true for registered and unregistered threads alike. An overlap merge keeps them anyway (the
12809
12977
  * reconcile's provenance guard never prunes a non-server message); only a disjoint rebuild can drop
12810
12978
  * them, so any that actually fell out are re-ingested below.
12811
12979
  *
@@ -12889,6 +13057,7 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12889
13057
  this.client.messageStore.upsert(parentMessage);
12890
13058
  }
12891
13059
  const incomingReplies = thread.messagePaginator.state.getLatestValue().items ?? [];
13060
+ if (!options?.reconcile) this.messagePaginator.clearStateAndCache();
12892
13061
  if (typeof this.messagePaginator.items === "undefined") {
12893
13062
  this.messagePaginator.setItems({
12894
13063
  valueOrFactory: incomingReplies,
@@ -12907,16 +13076,9 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12907
13076
  }
12908
13077
  this.addUnsubscribeFunction(this.subscribeThreadSetupStateChange());
12909
13078
  this.addUnsubscribeFunction(this.subscribeParentMessageFromStore());
12910
- this.addUnsubscribeFunction(this.subscribeThreadUpdated());
12911
13079
  this.addUnsubscribeFunction(this.subscribeMarkActiveThreadRead());
12912
13080
  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());
13081
+ this.addUnsubscribeFunction(this.subscribeMarkStaleOnStopWatching());
12920
13082
  };
12921
13083
  /**
12922
13084
  * Subscribes this thread to the `'thread'` configuration key. Registered through
@@ -12933,10 +13095,10 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12933
13095
  * `hasLiveInstances('thread')` does not count it when deciding whether to warn about a
12934
13096
  * construction-only path registered too late.
12935
13097
  *
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.
13098
+ * So read "declarative configuration is unaffected" as *at construction only*. A thread registered
13099
+ * with a `ThreadManager` that has itself registered is covered — the manager calls
13100
+ * `registerSubscriptions()` on every thread the list holds or that is active — so the common path
13101
+ * is fine. A thread that is neither, or held by an unregistered manager, is not.
12940
13102
  *
12941
13103
  * The alternative — applying the setup function at construction — would break the teardown symmetry
12942
13104
  * `WithSubscriptions` provides, which is why the asymmetry stands.
@@ -12952,7 +13114,7 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12952
13114
  // on a change there, so the setup function's overrides survive.
12953
13115
  alsoWatch: ["messagePaginator", "messageOperations"]
12954
13116
  });
12955
- this.subscribeThreadUpdated = () => this.client.on("thread.updated", (event) => {
13117
+ this.handleThreadUpdated = (event) => {
12956
13118
  if (!event.thread || event.thread.parent_message_id !== this.id) {
12957
13119
  return;
12958
13120
  }
@@ -12963,7 +13125,7 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12963
13125
  deletedAt: threadData.deleted_at ?? null,
12964
13126
  custom: threadData.custom ?? {}
12965
13127
  });
12966
- }).unsubscribe;
13128
+ };
12967
13129
  this.subscribeMarkActiveThreadRead = () => this.state.subscribeWithSelector(
12968
13130
  (nextValue) => ({
12969
13131
  active: nextValue.active,
@@ -12982,14 +13144,19 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
12982
13144
  }
12983
13145
  }
12984
13146
  );
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;
13147
+ /**
13148
+ * A watch stopped while the connection stays up (`NotWatching`) ends the channel's events with no
13149
+ * reconnect to catch it. A dropped connection (`WasWatching`) is left to recovery.
13150
+ */
13151
+ this.subscribeMarkStaleOnStopWatching = () => this.channel.state.subscribeWithSelector(
13152
+ ({ watchStatus }) => ({ watchStatus }),
13153
+ ({ watchStatus }, previous) => {
13154
+ if (previous?.watchStatus === ChannelWatchStatus.Watching && watchStatus === ChannelWatchStatus.NotWatching) {
13155
+ this.state.partialNext({ isStateStale: true });
13156
+ }
12989
13157
  }
12990
- this.state.partialNext({ isStateStale: true });
12991
- }).unsubscribe;
12992
- this.subscribeRepliesUnread = () => this.client.on("notification.mark_unread", (event) => {
13158
+ );
13159
+ this.handleRepliesUnread = (event) => {
12993
13160
  if (!event.user || event.created_at == null || !event.thread_id) return;
12994
13161
  if (event.thread_id !== this.id) return;
12995
13162
  const userId = event.user.id;
@@ -13008,8 +13175,8 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13008
13175
  }
13009
13176
  }
13010
13177
  }));
13011
- }).unsubscribe;
13012
- this.subscribeNewReplies = () => this.client.on("message.new", (event) => {
13178
+ };
13179
+ this.handleNewReply = (event) => {
13013
13180
  if (!this.client.userId || event.message?.parent_id !== this.id) {
13014
13181
  return;
13015
13182
  }
@@ -13047,8 +13214,8 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13047
13214
  }
13048
13215
  }
13049
13216
  this.state.partialNext({ read: nextRead });
13050
- }).unsubscribe;
13051
- this.subscribeRepliesRead = () => this.client.on("message.read", (event) => {
13217
+ };
13218
+ this.handleRepliesRead = (event) => {
13052
13219
  if (!event.user || event.created_at == null || !event.thread) return;
13053
13220
  if (event.thread.parent_message_id !== this.id) return;
13054
13221
  const userId = event.user.id;
@@ -13066,8 +13233,8 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13066
13233
  }
13067
13234
  }
13068
13235
  }));
13069
- }).unsubscribe;
13070
- this.subscribeMessageDeleted = () => this.client.on("message.deleted", (event) => {
13236
+ };
13237
+ this.handleMessageDeleted = (event) => {
13071
13238
  if (!event.message) return;
13072
13239
  const formattedMessage = formatMessage(event.message);
13073
13240
  if (event.message.parent_id === this.id) {
@@ -13081,52 +13248,35 @@ var Thread = class extends WithMessageOperations(WithSubscriptions) {
13081
13248
  this.updateParentMessageLocally({ message: event.message });
13082
13249
  }
13083
13250
  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
13251
  };
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());
13252
+ this.handleMessageUpdated = (event) => {
13253
+ if (!event.message) return;
13254
+ const message = event.message.parent_id === this.id ? {
13255
+ ...event.message,
13256
+ own_reactions: this.messagePaginator.getItem(event.message.id)?.own_reactions ?? event.message.own_reactions
13257
+ } : !event.message.parent_id && event.message.id === this.id ? {
13258
+ ...event.message,
13259
+ own_reactions: this.state.getLatestValue().parentMessage?.own_reactions ?? event.message.own_reactions
13260
+ } : event.message;
13261
+ this.updateParentMessageOrReplyLocally(message);
13262
+ this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(event.message));
13263
+ };
13264
+ this.handleReactionChanged = (event) => {
13265
+ if (!event.message || !event.reaction) return;
13266
+ const { message } = event;
13267
+ this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(message));
13268
+ };
13269
+ // Apply a user ban / deletion to this thread's own reply list. Previously
13270
+ // channel.state.deleteUserMessages marked banned-user replies deleted in the (now removed)
13271
+ // channel.state.threads shadow; the reply paginator is the thread's source of truth now.
13272
+ this.handleUserMessagesDeleted = (event) => {
13273
+ if (!event.user) return;
13274
+ const deletedAtSource = event.type === "user.deleted" ? event.user.deleted_at : event.created_at;
13275
+ this.messagePaginator.applyMessageDeletionForUser({
13276
+ userId: event.user.id,
13277
+ hardDelete: !!event.hard_delete,
13278
+ deletedAt: deletedAtSource ?? nowNs()
13279
+ });
13130
13280
  };
13131
13281
  // The parent message lives in the client-global message store (one canonical POJO per id);
13132
13282
  // `state.parentMessage` and the fields derived from it are a projection of that copy. Every update
@@ -14221,13 +14371,13 @@ var _MessageComposer = class _MessageComposer extends WithSubscriptions {
14221
14371
  *
14222
14372
  * Thread replies live in `thread.messagePaginator`, which is independent of the channel's, so
14223
14373
  * 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.
14374
+ * is built from the message rather than from its thread, so it finds the thread only when
14375
+ * `client.threads` has it registered — listed, or active (open somewhere).
14226
14376
  */
14227
14377
  get defaultSubmitTarget() {
14228
14378
  if (this.compositionContext instanceof Thread) return this.compositionContext;
14229
14379
  const { threadId } = this;
14230
- return threadId && this.client.threads.threadsById[threadId] || this.channel;
14380
+ return threadId && this.client.threads.get(threadId) || this.channel;
14231
14381
  }
14232
14382
  /**
14233
14383
  * Whether submitting keeps the composer's contents rather than clearing them - see
@@ -14478,11 +14628,12 @@ var MessageDeliveryReporter = class _MessageDeliveryReporter {
14478
14628
  }, this.requestTimeoutMs);
14479
14629
  };
14480
14630
  /**
14481
- * Delegates the mark-read call to the Channel or Thread instance.
14631
+ * Delegates the mark-read call to the Channel or Thread instance. A channel whose read events are
14632
+ * disabled is marked read locally instead, when the client opted into counting unread itself.
14482
14633
  *
14483
14634
  * @param collection - The channel or thread to mark as read.
14484
14635
  * @param options - Flags forwarded to the underlying `markRead` call (optional).
14485
- * @returns The server response, or `null` when the collection is unsupported.
14636
+ * @returns The server response, or `null` when no request went out.
14486
14637
  */
14487
14638
  this.markRead = async (collection, options) => {
14488
14639
  const isThreadCollection = isThread(collection);
@@ -14496,8 +14647,15 @@ var MessageDeliveryReporter = class _MessageDeliveryReporter {
14496
14647
  thread: collection
14497
14648
  }) ?? null : await channel.markRead(requestOptions);
14498
14649
  } else {
14499
- const markReadRequestHandler = channel.configState.getLatestValue().requestHandlers?.markReadRequest;
14500
- result = markReadRequestHandler ? await markReadRequestHandler({ channel, options: requestOptions }) ?? null : await channel.markRead(requestOptions);
14650
+ const { readEvents, requestHandlers } = channel.configState.getLatestValue();
14651
+ const markReadRequestHandler = requestHandlers?.markReadRequest;
14652
+ if (markReadRequestHandler) {
14653
+ result = await markReadRequestHandler({ channel, options: requestOptions }) ?? null;
14654
+ } else if (!readEvents.enabled && readEvents.localUnreadCountEnabled) {
14655
+ channel.markReadLocally();
14656
+ } else {
14657
+ result = await channel.markRead(requestOptions);
14658
+ }
14501
14659
  }
14502
14660
  if (result) this.removeCandidateFor(collection);
14503
14661
  return result;
@@ -15451,7 +15609,7 @@ var ownDeclarativeConfig = (slice) => {
15451
15609
  var DEFAULT_CHANNEL_CONFIG = deepFreezeConfig({
15452
15610
  availableCommands: [],
15453
15611
  deliveryEvents: { enabled: true },
15454
- readEvents: { enabled: true },
15612
+ readEvents: { enabled: true, localUnreadCountEnabled: false },
15455
15613
  replies: { enabled: true },
15456
15614
  typingEvents: { enabled: true },
15457
15615
  userMessageReminders: { enabled: true }
@@ -16478,7 +16636,7 @@ var Channel = class extends WithMessageOperations(ChannelApi) {
16478
16636
  /**
16479
16637
  * Resets this user's unread count locally, without any backend call. Intended for
16480
16638
  * channels that have read events disabled (e.g. livestreams) when the client is created with the
16481
- * `isLocalUnreadCountEnabled` option. Dispatches a dedicated, client-only `message.read_locally` event
16639
+ * `readEvents.localUnreadCountEnabled` configuration. Dispatches a dedicated, client-only `message.read_locally` event
16482
16640
  * that runs through the same `_handleChannelEvent` read logic as a real `message.read` (minus the
16483
16641
  * delivery-report network sync), so the read-state update lives in one place. When offline support
16484
16642
  * is enabled, the offline DB persists the reset for read-events-disabled channels, so the local
@@ -16640,7 +16798,7 @@ var Channel = class extends WithMessageOperations(ChannelApi) {
16640
16798
  if (message.user?.id === this.getClient().userId) return false;
16641
16799
  if (message.user?.id && this.getClient().userMuteStatus(message.user.id))
16642
16800
  return false;
16643
- if (!this.getClient().options.isLocalUnreadCountEnabled && !channelHasReadEvents(this)) {
16801
+ if (!this.config.readEvents.localUnreadCountEnabled && !channelHasReadEvents(this)) {
16644
16802
  return false;
16645
16803
  }
16646
16804
  if (this.muteStatus().muted) return false;
@@ -18449,26 +18607,126 @@ var eventCarriesOwnUser = (event) => Object.hasOwn(event, "me");
18449
18607
  var DEFAULT_THREAD_MANAGER_CONFIG = deepFreezeConfig({
18450
18608
  connectionRecoveryThrottleMs: 1e3
18451
18609
  });
18452
- var MAX_QUERY_THREADS_LIMIT = 25;
18453
18610
  var THREAD_MANAGER_INITIAL_STATE = {
18454
18611
  active: false,
18455
- wasActivatedAtLeastOnce: false,
18456
18612
  isThreadOrderStale: false,
18457
- threads: [],
18458
18613
  unreadThreadCount: 0,
18459
18614
  unseenThreadIds: [],
18460
- pagination: {
18461
- isLoading: false,
18462
- isLoadingNext: false,
18463
- nextCursor: null
18464
- },
18465
- ready: false
18615
+ wasActivatedAtLeastOnce: false
18466
18616
  };
18467
18617
  var logger10 = chatLoggerSystem.getLogger("thread-manager");
18618
+ var getThreadId = (thread) => thread.id;
18619
+ var REGISTERED_HOLDER = { onEntitiesChanged: () => void 0 };
18468
18620
  var ThreadManager = class extends WithSubscriptions {
18469
18621
  constructor({ client }) {
18470
18622
  super();
18623
+ /**
18624
+ * Every live thread, held by the list (the paginator's own index over this store) and/or
18625
+ * `REGISTERED_HOLDER` (opened this session). A thread leaves once neither holds it.
18626
+ */
18627
+ this.threadStore = new EntityStore({ getEntityId: getThreadId });
18628
+ /** A reload is in flight; a second one is dropped (`master`'s `pagination.isLoading` guard). */
18629
+ this.isReloadInFlight = false;
18630
+ /**
18631
+ * A local `pendingDisposal` listener (a state subscription, not a server watch) per channel instance
18632
+ * that has had a listed or opened thread; removed when that channel is disposed or on reset.
18633
+ */
18634
+ this.disposalListeners = /* @__PURE__ */ new Map();
18635
+ /**
18636
+ * The live thread for `id`: one in the list, or one opened this session (e.g. from a message
18637
+ * list). Resolve threads through this, not `paginator.items`, which is the list only; list
18638
+ * membership is `paginator.getItem(id)`.
18639
+ */
18640
+ this.get = (id) => this.threadStore.get(id);
18641
+ /**
18642
+ * Returns the thread for `parentMessage`, creating it if it doesn't exist yet. Use this to open a
18643
+ * thread instead of calling `new Thread()`.
18644
+ *
18645
+ * The returned thread is registered right away. If the thread list loads it before the UI
18646
+ * activates it, the list reuses this instance instead of creating a duplicate.
18647
+ *
18648
+ * A thread created here only has its parent message, so it starts stale and it loads its thread data
18649
+ * (participants, read state, replies) once, the first time it's opened.
18650
+ */
18651
+ this.ensure = ({
18652
+ channel,
18653
+ parentMessage
18654
+ }) => {
18655
+ let thread = this.threadStore.get(parentMessage.id);
18656
+ if (!thread) {
18657
+ thread = new Thread({ channel, client: this.client, parentMessage });
18658
+ thread.state.partialNext({ isStateStale: true });
18659
+ }
18660
+ this.register(thread);
18661
+ return thread;
18662
+ };
18663
+ /**
18664
+ * Called by `thread.activate()`. Keeps `thread` for the session: it resolves through
18665
+ * {@link ThreadManager.get} and stays subscribed whether or not the list holds it, until its
18666
+ * channel is disposed or the user disconnects.
18667
+ *
18668
+ * @internal
18669
+ */
18670
+ this.register = (thread) => {
18671
+ const { id } = thread;
18672
+ const registered = this.threadStore.get(id);
18673
+ if (registered && registered !== thread) {
18674
+ logger10.withExtraTags("register").warn(
18675
+ "Another instance of this thread is registered; this one stays unmanaged.",
18676
+ {
18677
+ threadId: id
18678
+ }
18679
+ );
18680
+ return;
18681
+ }
18682
+ this.threadStore.link(id, REGISTERED_HOLDER);
18683
+ this.threadStore.upsert(thread, REGISTERED_HOLDER);
18684
+ if (this.hasSubscriptions) thread.registerSubscriptions();
18685
+ this.listenForDisposal(thread);
18686
+ };
18687
+ /** Releases the `REGISTERED_HOLDER` hold on `thread`; the list's own diff unsubscribes it if it is still listed. */
18688
+ this.release = (thread) => {
18689
+ this.threadStore.unlink(thread.id, REGISTERED_HOLDER);
18690
+ if (this.hasSubscriptions && !this.paginator.items?.includes(thread)) {
18691
+ thread.unregisterSubscriptions();
18692
+ }
18693
+ };
18694
+ /** Listens for its channel's disposal the first time a thread on that channel is seen. */
18695
+ this.listenForDisposal = (thread) => {
18696
+ const { channel } = thread;
18697
+ if (this.disposalListeners.has(channel)) return;
18698
+ if (channel.pendingDisposal) {
18699
+ this.releaseChannel(channel);
18700
+ return;
18701
+ }
18702
+ this.disposalListeners.set(
18703
+ channel,
18704
+ channel.state.subscribeWithSelector(
18705
+ ({ pendingDisposal }) => ({ pendingDisposal }),
18706
+ ({ pendingDisposal }) => {
18707
+ if (pendingDisposal) this.releaseChannel(channel);
18708
+ }
18709
+ )
18710
+ );
18711
+ };
18712
+ /** The channel was disposed, so its threads are unusable: release every one of them. */
18713
+ this.releaseChannel = (channel) => {
18714
+ this.disposalListeners.get(channel)?.();
18715
+ this.disposalListeners.delete(channel);
18716
+ const onChannel = (thread) => thread.channel === channel;
18717
+ this.threadStore.values().filter(onChannel).forEach(this.release);
18718
+ const listed = (this.paginator.items ?? []).filter(onChannel);
18719
+ if (!listed.length) return;
18720
+ this.paginator.batch(
18721
+ () => listed.forEach(({ id }) => this.paginator.removeItem({ id })),
18722
+ { coalesce: true }
18723
+ );
18724
+ };
18471
18725
  this.resetState = () => {
18726
+ this.threadStore.values().forEach(this.release);
18727
+ this.paginator.resetState();
18728
+ this.disposalListeners.forEach((unsubscribe) => unsubscribe());
18729
+ this.disposalListeners.clear();
18472
18730
  this.state.next(THREAD_MANAGER_INITIAL_STATE);
18473
18731
  };
18474
18732
  this.activate = () => {
@@ -18484,7 +18742,65 @@ var ThreadManager = class extends WithSubscriptions {
18484
18742
  this.addUnsubscribeFunction(this.subscribeReloadOnActivation());
18485
18743
  this.addUnsubscribeFunction(this.subscribeNewReplies());
18486
18744
  this.addUnsubscribeFunction(this.subscribeReloadOnConnectionRecovered());
18487
- this.addUnsubscribeFunction(this.subscribeChannelDeleted());
18745
+ this.addUnsubscribeFunction(this.subscribeThreadEvents());
18746
+ this.threadStore.values().forEach((thread) => thread.registerSubscriptions());
18747
+ };
18748
+ /**
18749
+ * Hands client events to the threads in the store by id, as `PollManager` does for polls: a thread
18750
+ * doesn't listen to the client itself. A message event goes to the thread the message is a reply
18751
+ * in and the thread it starts; quoted-message updates stay within the reply's own thread, as on
18752
+ * `master`. A thread follows its channel's `watchStatus` itself, so watch events need no routing.
18753
+ */
18754
+ this.subscribeThreadEvents = () => {
18755
+ const threadsOf = (message) => message ? [
18756
+ message.parent_id ? this.get(message.parent_id) : void 0,
18757
+ this.get(message.id)
18758
+ ] : [];
18759
+ const unsubscribeFunctions = [
18760
+ this.client.on("message.new", (event) => {
18761
+ if (event.message?.parent_id)
18762
+ this.get(event.message.parent_id)?.handleNewReply(event);
18763
+ }),
18764
+ this.client.on("message.read", (event) => {
18765
+ if (event.thread)
18766
+ this.get(event.thread.parent_message_id)?.handleRepliesRead(event);
18767
+ }),
18768
+ this.client.on("notification.mark_unread", (event) => {
18769
+ if (event.thread_id) this.get(event.thread_id)?.handleRepliesUnread(event);
18770
+ }),
18771
+ this.client.on("thread.updated", (event) => {
18772
+ if (event.thread)
18773
+ this.get(event.thread.parent_message_id)?.handleThreadUpdated(event);
18774
+ }),
18775
+ this.client.on(
18776
+ "message.deleted",
18777
+ (event) => threadsOf(event.message).forEach((thread) => thread?.handleMessageDeleted(event))
18778
+ ),
18779
+ ...["message.updated", "message.undeleted"].map(
18780
+ (eventType) => this.client.on(
18781
+ eventType,
18782
+ (event) => threadsOf(event.message).forEach(
18783
+ (thread) => thread?.handleMessageUpdated(event)
18784
+ )
18785
+ )
18786
+ ),
18787
+ ...["reaction.new", "reaction.deleted", "reaction.updated"].map(
18788
+ (eventType) => this.client.on(
18789
+ eventType,
18790
+ (event) => threadsOf(event.message).forEach(
18791
+ (thread) => thread?.handleReactionChanged(event)
18792
+ )
18793
+ )
18794
+ ),
18795
+ // A banned or deleted user's replies can be in any thread, so these two are the exception.
18796
+ ...["user.messages.deleted", "user.deleted"].map(
18797
+ (eventType) => this.client.on(
18798
+ eventType,
18799
+ (event) => this.threadStore.values().forEach((thread) => thread.handleUserMessagesDeleted(event))
18800
+ )
18801
+ )
18802
+ ].map(({ unsubscribe }) => unsubscribe);
18803
+ return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
18488
18804
  };
18489
18805
  this.subscribeUnreadThreadsCountChange = () => {
18490
18806
  const { unread_threads: unreadThreadCount = 0 } = this.client.user ?? {};
@@ -18506,18 +18822,13 @@ var ThreadManager = class extends WithSubscriptions {
18506
18822
  );
18507
18823
  return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
18508
18824
  };
18509
- this.subscribeChannelDeleted = () => this.client.on("notification.channel_deleted", (event) => {
18510
- const { cid } = event;
18511
- const { threads } = this.state.getLatestValue();
18512
- const newThreads = threads.filter((thread) => thread.channel.cid !== cid);
18513
- this.state.partialNext({ threads: newThreads });
18514
- }).unsubscribe;
18515
- this.subscribeManageThreadSubscriptions = () => this.state.subscribeWithSelector(
18516
- (nextValue) => ({ threads: nextValue.threads }),
18825
+ this.subscribeManageThreadSubscriptions = () => this.paginator.state.subscribeWithSelector(
18826
+ (nextValue) => ({ threads: nextValue.items ?? [] }),
18517
18827
  ({ threads: nextThreads }, prev) => {
18518
18828
  const { threads: prevThreads = [] } = prev ?? {};
18829
+ const listed = new Set(nextThreads);
18519
18830
  const removedThreads = prevThreads.filter(
18520
- (thread) => thread !== this.threadsById[thread.id]
18831
+ (thread) => !listed.has(thread) && !this.threadStore.isHeldBy(thread.id, REGISTERED_HOLDER)
18521
18832
  );
18522
18833
  nextThreads.forEach((thread) => thread.registerSubscriptions());
18523
18834
  removedThreads.forEach((thread) => thread.unregisterSubscriptions());
@@ -18532,9 +18843,9 @@ var ThreadManager = class extends WithSubscriptions {
18532
18843
  this.subscribeNewReplies = () => this.client.on("notification.thread_message_new", (event) => {
18533
18844
  const parentId = event.message?.parent_id;
18534
18845
  if (!parentId) return;
18535
- const { unseenThreadIds, ready } = this.state.getLatestValue();
18536
- if (!ready) return;
18537
- if (this.threadsById[parentId]) {
18846
+ if (!this.paginator.hasResults) return;
18847
+ const { unseenThreadIds } = this.state.getLatestValue();
18848
+ if (this.paginator.getItem(parentId)) {
18538
18849
  this.state.partialNext({ isThreadOrderStale: true });
18539
18850
  } else if (!unseenThreadIds.includes(parentId)) {
18540
18851
  this.state.partialNext({ unseenThreadIds: unseenThreadIds.concat(parentId) });
@@ -18561,94 +18872,33 @@ var ThreadManager = class extends WithSubscriptions {
18561
18872
  return this.client.on("connection.recovered", throttledHandleConnectionRecovered).unsubscribe;
18562
18873
  };
18563
18874
  this.unregisterSubscriptions = () => {
18564
- this.state.getLatestValue().threads.forEach((thread) => thread.unregisterSubscriptions());
18875
+ this.threadStore.values().forEach((thread) => thread.unregisterSubscriptions());
18565
18876
  return super.unregisterSubscriptions();
18566
18877
  };
18878
+ /**
18879
+ * Loads the first page, or re-queries a loaded list in place, sized to what is loaded plus the
18880
+ * unseen threads. Skipped once loaded unless forced or something changed; the list survives a
18881
+ * failure. Guarded only against another reload, as on `master`. A reload of a loaded list shows
18882
+ * no loading state; the first load shows through `paginator.isLoading`.
18883
+ */
18567
18884
  this.reload = async ({ force = false } = {}) => {
18568
- const { threads, unseenThreadIds, isThreadOrderStale, pagination, ready } = this.state.getLatestValue();
18569
- if (pagination.isLoading) return;
18570
- if (!force && ready && !unseenThreadIds.length && !isThreadOrderStale) return;
18571
- const limit = threads.length + unseenThreadIds.length;
18885
+ if (this.isReloadInFlight) return;
18886
+ const { isThreadOrderStale, unseenThreadIds } = this.state.getLatestValue();
18887
+ if (!force && this.paginator.hasResults && !unseenThreadIds.length && !isThreadOrderStale)
18888
+ return;
18889
+ const { items, pageSize } = this.paginator;
18890
+ const limit = (items?.length ?? 0) + unseenThreadIds.length;
18891
+ this.isReloadInFlight = true;
18572
18892
  try {
18573
- this.state.next((current) => ({
18574
- ...current,
18575
- pagination: {
18576
- ...current.pagination,
18577
- isLoading: true
18578
- }
18579
- }));
18580
- const response = await this.queryThreads({
18581
- limit: Math.min(limit, MAX_QUERY_THREADS_LIMIT) || MAX_QUERY_THREADS_LIMIT
18582
- });
18583
- const nextThreads = [];
18584
- for (const incomingThread of response.threads) {
18585
- const existingThread = this.threadsById[incomingThread.id];
18586
- if (existingThread) {
18587
- nextThreads.push(existingThread);
18588
- if (existingThread.hasStaleState) {
18589
- existingThread.hydrateState(incomingThread);
18590
- }
18591
- } else {
18592
- nextThreads.push(incomingThread);
18593
- }
18893
+ await this.paginator.reload({ limit: Math.min(limit, pageSize) || pageSize });
18894
+ } finally {
18895
+ this.isReloadInFlight = false;
18896
+ const error = this.paginator.lastQueryError;
18897
+ if (error) {
18898
+ logger10.withExtraTags("reload").error("Failed to reload the thread list.", { error });
18899
+ } else {
18900
+ this.state.partialNext({ isThreadOrderStale: false, unseenThreadIds: [] });
18594
18901
  }
18595
- this.state.next((current) => ({
18596
- ...current,
18597
- threads: nextThreads,
18598
- unseenThreadIds: [],
18599
- isThreadOrderStale: false,
18600
- pagination: {
18601
- ...current.pagination,
18602
- isLoading: false,
18603
- nextCursor: response.next ?? null
18604
- },
18605
- ready: true
18606
- }));
18607
- } catch (error) {
18608
- logger10.withExtraTags("reload").error("Failed to reload the thread list.", { error });
18609
- this.state.next((current) => ({
18610
- ...current,
18611
- pagination: {
18612
- ...current.pagination,
18613
- isLoading: false
18614
- }
18615
- }));
18616
- }
18617
- };
18618
- this.queryThreads = (options = {}) => this.client.queryThreadsAndHydrate({
18619
- limit: 25,
18620
- participant_limit: 10,
18621
- reply_limit: 10,
18622
- watch: true,
18623
- ...options
18624
- });
18625
- this.loadNextPage = async (options = {}) => {
18626
- const { pagination } = this.state.getLatestValue();
18627
- if (pagination.isLoadingNext || !pagination.nextCursor) return;
18628
- try {
18629
- this.state.partialNext({ pagination: { ...pagination, isLoadingNext: true } });
18630
- const response = await this.queryThreads({
18631
- ...options,
18632
- next: pagination.nextCursor
18633
- });
18634
- this.state.next((current) => ({
18635
- ...current,
18636
- threads: response.threads.length ? current.threads.concat(response.threads) : current.threads,
18637
- pagination: {
18638
- ...current.pagination,
18639
- nextCursor: response.next ?? null,
18640
- isLoadingNext: false
18641
- }
18642
- }));
18643
- } catch (error) {
18644
- logger10.withExtraTags("loadNextPage").error("Failed to load the next page of threads.", { error });
18645
- this.state.next((current) => ({
18646
- ...current,
18647
- pagination: {
18648
- ...current.pagination,
18649
- isLoadingNext: false
18650
- }
18651
- }));
18652
18902
  }
18653
18903
  };
18654
18904
  this.configController = new ConfigController({
@@ -18656,7 +18906,11 @@ var ThreadManager = class extends WithSubscriptions {
18656
18906
  });
18657
18907
  this.client = client;
18658
18908
  this.state = new import_state_store21.StateStore(THREAD_MANAGER_INITIAL_STATE);
18659
- this.threadsByIdGetterCache = { threads: [], threadsById: {} };
18909
+ this.paginator = new ThreadPaginator({ client, store: this.threadStore });
18910
+ this.paginator.state.subscribeWithSelector(
18911
+ ({ items }) => ({ items }),
18912
+ ({ items }) => items?.forEach(this.listenForDisposal)
18913
+ );
18660
18914
  }
18661
18915
  /**
18662
18916
  * Resolved configuration, as a store — the shape every configurable class exposes
@@ -18690,21 +18944,13 @@ var ThreadManager = class extends WithSubscriptions {
18690
18944
  initializeConfig(config) {
18691
18945
  this.configController.initialize(config);
18692
18946
  }
18693
- get threadsById() {
18694
- const { threads } = this.state.getLatestValue();
18695
- if (threads === this.threadsByIdGetterCache.threads) {
18696
- return this.threadsByIdGetterCache.threadsById;
18697
- }
18698
- const threadsById = threads.reduce(
18699
- (newThreadsById, thread) => {
18700
- newThreadsById[thread.id] = thread;
18701
- return newThreadsById;
18702
- },
18703
- {}
18704
- );
18705
- this.threadsByIdGetterCache.threads = threads;
18706
- this.threadsByIdGetterCache.threadsById = threadsById;
18707
- return threadsById;
18947
+ /**
18948
+ * Every registered thread — the list's plus the opened ones.
18949
+ *
18950
+ * @internal
18951
+ */
18952
+ get registeredThreads() {
18953
+ return this.threadStore.values();
18708
18954
  }
18709
18955
  };
18710
18956
 
@@ -20088,6 +20334,7 @@ var ConnectionRecoveryManager = class extends WithSubscriptions {
20088
20334
  }
20089
20335
  const channels = this.recoverableActiveChannels;
20090
20336
  const threads = this.recoverableActiveThreads;
20337
+ this.markInactiveThreadsStale();
20091
20338
  const socketDropBefore = this.client.wsConnection.state.getLatestValue().lastUnhealthyAt;
20092
20339
  this.isRecovering = true;
20093
20340
  logger12.withExtraTags("connectionRecovery").info(
@@ -20174,41 +20421,34 @@ var ConnectionRecoveryManager = class extends WithSubscriptions {
20174
20421
  * Threads need their own pass because nothing else brings an open thread's replies back — a
20175
20422
  * channel reload refreshes the main message list, `ChannelManager.recover()` refreshes the channel
20176
20423
  * lists*, and `ThreadManager`'s own recovery refreshes the thread *list*, reusing existing
20177
- * instances without rehydrating them unless they were separately marked stale (which only
20178
- * `user.watching.stop` does, never a reconnect).
20424
+ * instances without rehydrating them unless they were separately marked stale.
20179
20425
  *
20180
- * `active` is the filter that matters: `threadsById` holds every thread the list has paged in,
20181
- * which is not what should be re-fetched on a reconnect — only what someone is actually reading.
20426
+ * `active` is the filter that matters: the registry also holds every thread the list has paged
20427
+ * in, which is not what should be re-fetched on a reconnect — only what someone is actually
20428
+ * reading. An active thread is always registered, listed or not (`thread.activate()` registers it).
20182
20429
  * Guarded on the owning channel the same way active channels are: a thread whose channel is being
20183
20430
  * torn down has nothing to recover into.
20184
- *
20185
- * NOTE: `threadsById` is the thread LIST, not a thread cache. A thread opened from a message list
20186
- * is constructed directly (`threadsById[id] ?? new Thread(...)`) and only reaches the list because
20187
- * the UI SDKs adopt it there — prepended, once its replies have loaded — which is a workaround, not
20188
- * a contract. Two consequences, both accepted for now and both narrow:
20189
- *
20190
- * - A reconnect landing before that adoption misses the thread. Self-limiting: the UI SDKs re-issue
20191
- * the load while the reply list is still unloaded, so a thread that failed to load gets adopted on
20192
- * the retry.
20193
- * - `ThreadManager.reload()` rebuilds `state.threads` purely from the query response, so an adopted
20194
- * thread absent from that response is evicted while still active. It cannot bite within the
20195
- * reconnect that caused it: this getter is read before `connection.recovered` is dispatched, and
20196
- * that event is what triggers the UI SDKs' list reload. It would take a LATER reconnect, after an
20197
- * eviction, to miss the thread.
20198
- *
20199
- * The fix is a real off-list registry — see the commented-out `threadCache` in `ThreadManager` — at
20200
- * which point this getter reads from that instead, with no change to the `active` filter.
20201
20431
  */
20202
20432
  get recoverableActiveThreads() {
20203
20433
  const threads = [];
20204
- for (const thread of Object.values(this.client.threads.threadsById)) {
20205
- if (!thread) continue;
20434
+ for (const thread of this.client.threads.registeredThreads) {
20206
20435
  const { active } = thread.state.getLatestValue();
20207
20436
  if (!active || thread.channel.pendingDisposal) continue;
20208
20437
  threads.push(thread);
20209
20438
  }
20210
20439
  return threads;
20211
20440
  }
20441
+ /**
20442
+ * An opened thread stays registered and subscribed after it closes, but a drop loses events it
20443
+ * cannot replay. Flag only, no request: a listed one is rehydrated by the thread list's reload,
20444
+ * and any other reloads on its next `activate()`.
20445
+ */
20446
+ markInactiveThreadsStale() {
20447
+ for (const thread of this.client.threads.registeredThreads) {
20448
+ const { active, isStateStale } = thread.state.getLatestValue();
20449
+ if (!active && !isStateStale) thread.state.partialNext({ isStateStale: true });
20450
+ }
20451
+ }
20212
20452
  };
20213
20453
 
20214
20454
  // src/connection/networkConnection/NetworkConnectionObserver.ts
@@ -23899,6 +24139,9 @@ function isString(value) {
23899
24139
  }
23900
24140
  var logger17 = chatLoggerSystem.getLogger("client");
23901
24141
  var offlineDbLogger2 = chatLoggerSystem.getLogger("offline-db");
24142
+ var markThreadChannelWatched = ({ channel }) => {
24143
+ channel.watchStatus = ChannelWatchStatus.Watching;
24144
+ };
23902
24145
  var StreamChat = class _StreamChat extends ChatApi {
23903
24146
  /**
23904
24147
  * Initializes a client.
@@ -24412,7 +24655,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24412
24655
  return this.userAgent;
24413
24656
  }
24414
24657
  if (!this.cachedUserAgent) {
24415
- const version = "10.0.0-rc.14";
24658
+ const version = "10.0.0-rc.16";
24416
24659
  const { name: sdkName, version: sdkVersion } = this.sdkIdentifier ?? {};
24417
24660
  const { name: appName, version: appVersion } = this.appIdentifier ?? {};
24418
24661
  const { os, model: deviceModel } = this.deviceIdentifier ?? {};
@@ -24468,10 +24711,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24468
24711
  this.uploadManager = new UploadManager(this);
24469
24712
  this.browser = options.browser ?? typeof window !== "undefined";
24470
24713
  this.node = !this.browser;
24471
- this.options = {
24472
- isLocalUnreadCountEnabled: false,
24473
- ...options
24474
- };
24714
+ this.options = { ...options };
24475
24715
  this.axiosInstance = import_axios4.default.create({
24476
24716
  timeout: 3e3,
24477
24717
  withCredentials: false,
@@ -25364,12 +25604,11 @@ var StreamChat = class _StreamChat extends ChatApi {
25364
25604
  const response = await this.queryThreads(requestBody, requestOptions);
25365
25605
  const parentMessages = response.threads.map((thread) => thread.parent_message);
25366
25606
  this.polls.hydratePollCache(parentMessages);
25367
- return {
25368
- threads: response.threads.map(
25369
- (thread) => new Thread({ client: this, threadData: thread })
25370
- ),
25371
- next: response.next
25372
- };
25607
+ const threads = response.threads.map(
25608
+ (thread) => new Thread({ client: this, threadData: thread })
25609
+ );
25610
+ if (optionsWithDefaults.watch) threads.forEach(markThreadChannelWatched);
25611
+ return { threads, next: response.next };
25373
25612
  }
25374
25613
  /**
25375
25614
  * Returns the thread of a message by its ID, wrapped in a hydrated `Thread` instance.
@@ -25401,7 +25640,9 @@ var StreamChat = class _StreamChat extends ChatApi {
25401
25640
  },
25402
25641
  requestOptions
25403
25642
  );
25404
- return new Thread({ client: this, threadData: response.thread });
25643
+ const thread = new Thread({ client: this, threadData: response.thread });
25644
+ if (optionsWithDefaults.watch) markThreadChannelWatched(thread);
25645
+ return thread;
25405
25646
  }
25406
25647
  /**
25407
25648
  * Sets the user agent string.
@@ -25961,6 +26202,11 @@ var CHANNEL_FIELDS = {
25961
26202
  description: 'Allows marking the channel read or unread. The server must also allow it per channel type (`read_events`), and a server "no" wins.',
25962
26203
  kind: "value",
25963
26204
  type: "boolean"
26205
+ },
26206
+ localUnreadCountEnabled: {
26207
+ description: "Counts unread messages client-side when `enabled` is false, reset by `channel.markReadLocally()` instead of a request. Not subject to server authority.",
26208
+ kind: "value",
26209
+ type: "boolean"
25964
26210
  }
25965
26211
  },
25966
26212
  kind: "group"
@@ -27047,13 +27293,12 @@ var AbstractOfflineDB = class {
27047
27293
  * message ordered against everything queued after it.
27048
27294
  *
27049
27295
  * Keyed on a queued `send-message` task existing for this id - the only reliable statement of "the
27050
- * server has never seen this message". NOT on the edited message's status: both payloads are built
27051
- * by `localMessageToNewMessagePayload`, which strips `status` along with every other client-only
27052
- * field, so nothing arriving here carries one.
27296
+ * server has never seen this message". NOT on the edited message's status: `failed` is also what a
27297
+ * rejected edit of a message the server has carries.
27053
27298
  *
27054
- * That same normalization is why the two merge by a plain spread: both are already `MessageRequest`s,
27055
- * and normalizing again would map an already-flattened `mentioned_users` back over itself as
27056
- * `[undefined]`.
27299
+ * Both payloads are already `MessageRequest`s (the send's from `localMessageToNewMessagePayload`, the
27300
+ * edit's from `toUpdatedMessagePayload`), which is why they merge by a plain spread: normalizing again
27301
+ * would map an already-flattened `mentioned_users` back over itself as `[undefined]`.
27057
27302
  *
27058
27303
  * With nothing to fold into, the edit is queued on its own and replays as an ordinary update.
27059
27304
  */