stream-chat 10.0.0-rc.15 → 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 (31) hide show
  1. package/dist/cjs/index.browser.js +485 -250
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +488 -250
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +485 -250
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/connection/ConnectionRecoveryManager.d.ts +11 -22
  8. package/dist/types/entityStore/EntityStore.d.ts +4 -0
  9. package/dist/types/messageComposer/linkPreviewsManager.d.ts +3 -3
  10. package/dist/types/messageComposer/messageComposer.d.ts +2 -2
  11. package/dist/types/offline-support/offline_support_api.d.ts +5 -6
  12. package/dist/types/pagination/paginators/BasePaginator.d.ts +8 -1
  13. package/dist/types/pagination/paginators/ChannelPaginator.d.ts +5 -1
  14. package/dist/types/pagination/paginators/ThreadPaginator.d.ts +57 -0
  15. package/dist/types/pagination/paginators/index.d.ts +1 -0
  16. package/dist/types/thread.d.ts +30 -21
  17. package/dist/types/thread_manager.d.ts +74 -32
  18. package/package.json +1 -1
  19. package/src/client.ts +18 -7
  20. package/src/connection/ConnectionRecoveryManager.ts +19 -24
  21. package/src/entityStore/EntityStore.ts +10 -0
  22. package/src/messageComposer/messageComposer.ts +3 -3
  23. package/src/messageOperations/createMessageOperations.ts +3 -3
  24. package/src/offline-support/offline_support_api.ts +5 -6
  25. package/src/offline-support/queueableOperations.ts +1 -1
  26. package/src/pagination/paginators/BasePaginator.ts +45 -8
  27. package/src/pagination/paginators/ChannelPaginator.ts +17 -3
  28. package/src/pagination/paginators/ThreadPaginator.ts +180 -0
  29. package/src/pagination/paginators/index.ts +1 -0
  30. package/src/thread.ts +188 -220
  31. package/src/thread_manager.ts +257 -173
@@ -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,
@@ -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
@@ -18457,26 +18607,126 @@ var eventCarriesOwnUser = (event) => Object.hasOwn(event, "me");
18457
18607
  var DEFAULT_THREAD_MANAGER_CONFIG = deepFreezeConfig({
18458
18608
  connectionRecoveryThrottleMs: 1e3
18459
18609
  });
18460
- var MAX_QUERY_THREADS_LIMIT = 25;
18461
18610
  var THREAD_MANAGER_INITIAL_STATE = {
18462
18611
  active: false,
18463
- wasActivatedAtLeastOnce: false,
18464
18612
  isThreadOrderStale: false,
18465
- threads: [],
18466
18613
  unreadThreadCount: 0,
18467
18614
  unseenThreadIds: [],
18468
- pagination: {
18469
- isLoading: false,
18470
- isLoadingNext: false,
18471
- nextCursor: null
18472
- },
18473
- ready: false
18615
+ wasActivatedAtLeastOnce: false
18474
18616
  };
18475
18617
  var logger10 = chatLoggerSystem.getLogger("thread-manager");
18618
+ var getThreadId = (thread) => thread.id;
18619
+ var REGISTERED_HOLDER = { onEntitiesChanged: () => void 0 };
18476
18620
  var ThreadManager = class extends WithSubscriptions {
18477
18621
  constructor({ client }) {
18478
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
+ };
18479
18725
  this.resetState = () => {
18726
+ this.threadStore.values().forEach(this.release);
18727
+ this.paginator.resetState();
18728
+ this.disposalListeners.forEach((unsubscribe) => unsubscribe());
18729
+ this.disposalListeners.clear();
18480
18730
  this.state.next(THREAD_MANAGER_INITIAL_STATE);
18481
18731
  };
18482
18732
  this.activate = () => {
@@ -18492,7 +18742,65 @@ var ThreadManager = class extends WithSubscriptions {
18492
18742
  this.addUnsubscribeFunction(this.subscribeReloadOnActivation());
18493
18743
  this.addUnsubscribeFunction(this.subscribeNewReplies());
18494
18744
  this.addUnsubscribeFunction(this.subscribeReloadOnConnectionRecovered());
18495
- 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());
18496
18804
  };
18497
18805
  this.subscribeUnreadThreadsCountChange = () => {
18498
18806
  const { unread_threads: unreadThreadCount = 0 } = this.client.user ?? {};
@@ -18514,18 +18822,13 @@ var ThreadManager = class extends WithSubscriptions {
18514
18822
  );
18515
18823
  return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
18516
18824
  };
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 }),
18825
+ this.subscribeManageThreadSubscriptions = () => this.paginator.state.subscribeWithSelector(
18826
+ (nextValue) => ({ threads: nextValue.items ?? [] }),
18525
18827
  ({ threads: nextThreads }, prev) => {
18526
18828
  const { threads: prevThreads = [] } = prev ?? {};
18829
+ const listed = new Set(nextThreads);
18527
18830
  const removedThreads = prevThreads.filter(
18528
- (thread) => thread !== this.threadsById[thread.id]
18831
+ (thread) => !listed.has(thread) && !this.threadStore.isHeldBy(thread.id, REGISTERED_HOLDER)
18529
18832
  );
18530
18833
  nextThreads.forEach((thread) => thread.registerSubscriptions());
18531
18834
  removedThreads.forEach((thread) => thread.unregisterSubscriptions());
@@ -18540,9 +18843,9 @@ var ThreadManager = class extends WithSubscriptions {
18540
18843
  this.subscribeNewReplies = () => this.client.on("notification.thread_message_new", (event) => {
18541
18844
  const parentId = event.message?.parent_id;
18542
18845
  if (!parentId) return;
18543
- const { unseenThreadIds, ready } = this.state.getLatestValue();
18544
- if (!ready) return;
18545
- if (this.threadsById[parentId]) {
18846
+ if (!this.paginator.hasResults) return;
18847
+ const { unseenThreadIds } = this.state.getLatestValue();
18848
+ if (this.paginator.getItem(parentId)) {
18546
18849
  this.state.partialNext({ isThreadOrderStale: true });
18547
18850
  } else if (!unseenThreadIds.includes(parentId)) {
18548
18851
  this.state.partialNext({ unseenThreadIds: unseenThreadIds.concat(parentId) });
@@ -18569,94 +18872,33 @@ var ThreadManager = class extends WithSubscriptions {
18569
18872
  return this.client.on("connection.recovered", throttledHandleConnectionRecovered).unsubscribe;
18570
18873
  };
18571
18874
  this.unregisterSubscriptions = () => {
18572
- this.state.getLatestValue().threads.forEach((thread) => thread.unregisterSubscriptions());
18875
+ this.threadStore.values().forEach((thread) => thread.unregisterSubscriptions());
18573
18876
  return super.unregisterSubscriptions();
18574
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
+ */
18575
18884
  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;
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;
18580
18892
  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
- }
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: [] });
18602
18901
  }
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
18902
  }
18661
18903
  };
18662
18904
  this.configController = new ConfigController({
@@ -18664,7 +18906,11 @@ var ThreadManager = class extends WithSubscriptions {
18664
18906
  });
18665
18907
  this.client = client;
18666
18908
  this.state = new import_state_store21.StateStore(THREAD_MANAGER_INITIAL_STATE);
18667
- 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
+ );
18668
18914
  }
18669
18915
  /**
18670
18916
  * Resolved configuration, as a store — the shape every configurable class exposes
@@ -18698,21 +18944,13 @@ var ThreadManager = class extends WithSubscriptions {
18698
18944
  initializeConfig(config) {
18699
18945
  this.configController.initialize(config);
18700
18946
  }
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;
18947
+ /**
18948
+ * Every registered thread — the list's plus the opened ones.
18949
+ *
18950
+ * @internal
18951
+ */
18952
+ get registeredThreads() {
18953
+ return this.threadStore.values();
18716
18954
  }
18717
18955
  };
18718
18956
 
@@ -20096,6 +20334,7 @@ var ConnectionRecoveryManager = class extends WithSubscriptions {
20096
20334
  }
20097
20335
  const channels = this.recoverableActiveChannels;
20098
20336
  const threads = this.recoverableActiveThreads;
20337
+ this.markInactiveThreadsStale();
20099
20338
  const socketDropBefore = this.client.wsConnection.state.getLatestValue().lastUnhealthyAt;
20100
20339
  this.isRecovering = true;
20101
20340
  logger12.withExtraTags("connectionRecovery").info(
@@ -20182,41 +20421,34 @@ var ConnectionRecoveryManager = class extends WithSubscriptions {
20182
20421
  * Threads need their own pass because nothing else brings an open thread's replies back — a
20183
20422
  * channel reload refreshes the main message list, `ChannelManager.recover()` refreshes the channel
20184
20423
  * 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).
20424
+ * instances without rehydrating them unless they were separately marked stale.
20187
20425
  *
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.
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).
20190
20429
  * Guarded on the owning channel the same way active channels are: a thread whose channel is being
20191
20430
  * 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
20431
  */
20210
20432
  get recoverableActiveThreads() {
20211
20433
  const threads = [];
20212
- for (const thread of Object.values(this.client.threads.threadsById)) {
20213
- if (!thread) continue;
20434
+ for (const thread of this.client.threads.registeredThreads) {
20214
20435
  const { active } = thread.state.getLatestValue();
20215
20436
  if (!active || thread.channel.pendingDisposal) continue;
20216
20437
  threads.push(thread);
20217
20438
  }
20218
20439
  return threads;
20219
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
+ }
20220
20452
  };
20221
20453
 
20222
20454
  // src/connection/networkConnection/NetworkConnectionObserver.ts
@@ -23907,6 +24139,9 @@ function isString(value) {
23907
24139
  }
23908
24140
  var logger17 = chatLoggerSystem.getLogger("client");
23909
24141
  var offlineDbLogger2 = chatLoggerSystem.getLogger("offline-db");
24142
+ var markThreadChannelWatched = ({ channel }) => {
24143
+ channel.watchStatus = ChannelWatchStatus.Watching;
24144
+ };
23910
24145
  var StreamChat = class _StreamChat extends ChatApi {
23911
24146
  /**
23912
24147
  * Initializes a client.
@@ -24420,7 +24655,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24420
24655
  return this.userAgent;
24421
24656
  }
24422
24657
  if (!this.cachedUserAgent) {
24423
- const version = "10.0.0-rc.15";
24658
+ const version = "10.0.0-rc.16";
24424
24659
  const { name: sdkName, version: sdkVersion } = this.sdkIdentifier ?? {};
24425
24660
  const { name: appName, version: appVersion } = this.appIdentifier ?? {};
24426
24661
  const { os, model: deviceModel } = this.deviceIdentifier ?? {};
@@ -25369,12 +25604,11 @@ var StreamChat = class _StreamChat extends ChatApi {
25369
25604
  const response = await this.queryThreads(requestBody, requestOptions);
25370
25605
  const parentMessages = response.threads.map((thread) => thread.parent_message);
25371
25606
  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
- };
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 };
25378
25612
  }
25379
25613
  /**
25380
25614
  * Returns the thread of a message by its ID, wrapped in a hydrated `Thread` instance.
@@ -25406,7 +25640,9 @@ var StreamChat = class _StreamChat extends ChatApi {
25406
25640
  },
25407
25641
  requestOptions
25408
25642
  );
25409
- 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;
25410
25646
  }
25411
25647
  /**
25412
25648
  * Sets the user agent string.
@@ -27057,13 +27293,12 @@ var AbstractOfflineDB = class {
27057
27293
  * message ordered against everything queued after it.
27058
27294
  *
27059
27295
  * 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.
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.
27063
27298
  *
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]`.
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]`.
27067
27302
  *
27068
27303
  * With nothing to fold into, the edit is queued on its own and replays as an ordinary update.
27069
27304
  */