stream-chat 10.0.0-rc.7 → 10.0.0-rc.8

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 (58) hide show
  1. package/dist/cjs/index.browser.js +1326 -671
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +1328 -671
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +1326 -671
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/ChannelManager.d.ts +31 -0
  8. package/dist/types/ConnectionRecoveryManager.d.ts +156 -0
  9. package/dist/types/EventHandlerPipeline.d.ts +11 -15
  10. package/dist/types/channel.d.ts +31 -17
  11. package/dist/types/client.d.ts +28 -13
  12. package/dist/types/configuration/types.d.ts +2 -0
  13. package/dist/types/errors.d.ts +10 -0
  14. package/dist/types/index.d.ts +1 -0
  15. package/dist/types/messageComposer/linkPreviewsManager.d.ts +1 -1
  16. package/dist/types/messageOperations/MessageOperationStatePolicy.d.ts +51 -5
  17. package/dist/types/messageOperations/MessageOperations.d.ts +5 -0
  18. package/dist/types/messageOperations/index.d.ts +4 -0
  19. package/dist/types/messageOperations/optimistic.d.ts +118 -0
  20. package/dist/types/messageOperations/persistence.d.ts +21 -0
  21. package/dist/types/messageOperations/types.d.ts +21 -0
  22. package/dist/types/offline-support/offline_support_api.d.ts +66 -5
  23. package/dist/types/offline-support/queueableOperations.d.ts +86 -0
  24. package/dist/types/offline-support/types.d.ts +53 -22
  25. package/dist/types/pagination/filterCompiler.d.ts +5 -0
  26. package/dist/types/pagination/paginators/BasePaginator.d.ts +5 -0
  27. package/dist/types/pagination/paginators/MessageIntervalPaginator.d.ts +1 -1
  28. package/dist/types/thread.d.ts +36 -6
  29. package/dist/types/types.d.ts +0 -10
  30. package/dist/types/types.utility.d.ts +8 -0
  31. package/package.json +1 -1
  32. package/src/ChannelManager.ts +129 -0
  33. package/src/ConnectionRecoveryManager.ts +293 -0
  34. package/src/EventHandlerPipeline.ts +12 -22
  35. package/src/channel.ts +162 -179
  36. package/src/client.ts +51 -89
  37. package/src/configuration/shape.ts +19 -0
  38. package/src/configuration/types.ts +2 -0
  39. package/src/connection.ts +1 -3
  40. package/src/errors.ts +15 -0
  41. package/src/index.ts +1 -0
  42. package/src/messageOperations/MessageOperationStatePolicy.ts +234 -12
  43. package/src/messageOperations/MessageOperations.ts +38 -9
  44. package/src/messageOperations/index.ts +15 -0
  45. package/src/messageOperations/optimistic.ts +249 -0
  46. package/src/messageOperations/persistence.ts +40 -0
  47. package/src/messageOperations/types.ts +21 -0
  48. package/src/offline-support/offline_support_api.ts +233 -96
  49. package/src/offline-support/offline_sync_manager.ts +10 -0
  50. package/src/offline-support/queueableOperations.ts +218 -0
  51. package/src/offline-support/types.ts +57 -31
  52. package/src/pagination/filterCompiler.ts +17 -0
  53. package/src/pagination/paginators/BasePaginator.ts +19 -0
  54. package/src/pagination/paginators/ChannelPaginator.ts +2 -22
  55. package/src/pagination/paginators/MessageIntervalPaginator.ts +49 -3
  56. package/src/thread.ts +118 -61
  57. package/src/types.ts +0 -10
  58. package/src/types.utility.ts +14 -0
@@ -51,11 +51,13 @@ __export(src_exports, {
51
51
  ChannelWatchStatus: () => ChannelWatchStatus,
52
52
  ClientState: () => ClientState,
53
53
  CommandSearchSource: () => CommandSearchSource,
54
+ ConnectionRecoveryManager: () => ConnectionRecoveryManager,
54
55
  CustomDataManager: () => CustomDataManager,
55
56
  DEFAULT_ATTACHMENT_MANAGER_CONFIG: () => DEFAULT_ATTACHMENT_MANAGER_CONFIG,
56
57
  DEFAULT_CHANNEL_CONFIG: () => DEFAULT_CHANNEL_CONFIG,
57
58
  DEFAULT_COMMANDS_CONFIG: () => DEFAULT_COMMANDS_CONFIG,
58
59
  DEFAULT_COMPOSER_CONFIG: () => DEFAULT_COMPOSER_CONFIG,
60
+ DEFAULT_CONNECTION_RECOVERY_MANAGER_CONFIG: () => DEFAULT_CONNECTION_RECOVERY_MANAGER_CONFIG,
59
61
  DEFAULT_LINK_PREVIEW_MANAGER_CONFIG: () => DEFAULT_LINK_PREVIEW_MANAGER_CONFIG,
60
62
  DEFAULT_LIVE_LOCATION_MANAGER_CONFIG: () => DEFAULT_LIVE_LOCATION_MANAGER_CONFIG,
61
63
  DEFAULT_LOCATION_COMPOSER_CONFIG: () => DEFAULT_LOCATION_COMPOSER_CONFIG,
@@ -1394,228 +1396,100 @@ var CooldownTimer = class extends WithSubscriptions {
1394
1396
  }
1395
1397
  };
1396
1398
 
1397
- // src/errors.ts
1398
- var import_axios = require("axios");
1399
- var APIErrorCodes = {
1400
- "-1": { name: "InternalSystemError", retryable: true },
1401
- "2": { name: "AccessKeyError", retryable: false },
1402
- "3": { name: "AuthenticationFailedError", retryable: true },
1403
- "4": { name: "InputError", retryable: false },
1404
- "6": { name: "DuplicateUsernameError", retryable: false },
1405
- "9": { name: "RateLimitError", retryable: true },
1406
- "16": { name: "DoesNotExistError", retryable: false },
1407
- "17": { name: "NotAllowedError", retryable: false },
1408
- "18": { name: "EventNotSupportedError", retryable: false },
1409
- "19": { name: "ChannelFeatureNotSupportedError", retryable: false },
1410
- "20": { name: "MessageTooLongError", retryable: false },
1411
- "21": { name: "MultipleNestingLevelError", retryable: false },
1412
- "22": { name: "PayloadTooBigError", retryable: false },
1413
- "23": { name: "RequestTimeoutError", retryable: true },
1414
- "24": { name: "MaxHeaderSizeExceededError", retryable: false },
1415
- "40": { name: "AuthErrorTokenExpired", retryable: false },
1416
- "41": { name: "AuthErrorTokenNotValidYet", retryable: false },
1417
- "42": { name: "AuthErrorTokenUsedBeforeIssuedAt", retryable: false },
1418
- "43": { name: "AuthErrorTokenSignatureInvalid", retryable: false },
1419
- "44": { name: "CustomCommandEndpointMissingError", retryable: false },
1420
- "45": { name: "CustomCommandEndpointCallError", retryable: true },
1421
- "46": { name: "ConnectionIDNotFoundError", retryable: false },
1422
- "60": { name: "CoolDownError", retryable: true },
1423
- "69": { name: "ErrWrongRegion", retryable: false },
1424
- "70": { name: "ErrQueryChannelPermissions", retryable: false },
1425
- "71": { name: "ErrTooManyConnections", retryable: true },
1426
- "99": { name: "AppSuspendedError", retryable: false }
1427
- };
1428
- function isAPIError(error) {
1429
- return error.code !== void 0;
1430
- }
1431
- function isErrorRetryable(error) {
1432
- if (!error.code) return false;
1433
- const err = APIErrorCodes[`${error.code}`];
1434
- if (!err) return false;
1435
- return err.retryable;
1436
- }
1437
- function isEphemeral(error) {
1438
- if ((0, import_axios.isCancel)(error)) return false;
1439
- if (!error.response) return true;
1440
- return isErrorRetryable(error);
1441
- }
1442
-
1443
- // src/entityStore/applyReactionLocally.ts
1444
- var applyReactionLocally = (client, {
1445
- enforceUnique = false,
1446
- messageId,
1447
- reaction,
1448
- removed = false
1449
- }) => {
1450
- const store = client.messageStore;
1451
- const user = client.user;
1452
- const existing = store.get(messageId);
1453
- if (!user || !existing) return;
1454
- const now = /* @__PURE__ */ new Date();
1455
- const reactionResponse = {
1456
- ...reaction,
1457
- created_at: reaction.created_at ?? now,
1458
- custom: reaction.custom ?? {},
1459
- message_id: messageId,
1460
- score: reaction.score ?? 1,
1461
- type: reaction.type,
1462
- updated_at: reaction.updated_at ?? now,
1463
- user,
1464
- user_id: user.id
1465
- };
1466
- const removedReactions = removed ? existing.own_reactions?.filter((r) => r.type === reactionResponse.type) ?? [] : enforceUnique ? existing.own_reactions ?? [] : [];
1467
- const withCounts = removed ? messageWithReactionRemoved(existing, reactionResponse) : messageWithReactionAdded(existing, reactionResponse, enforceUnique);
1468
- const own_reactions = computeOwnReactions({
1469
- current: existing.own_reactions ?? [],
1470
- enforceUnique,
1471
- reaction: reactionResponse,
1472
- removed,
1473
- userId: user.id
1474
- });
1475
- store.upsert({ ...withCounts, own_reactions });
1476
- store.flushSubscribers(messageId);
1477
- const persisted = store.get(messageId);
1478
- if (persisted) {
1479
- client.offlineDb?.executeQuerySafely(
1480
- (db) => removed ? db.deleteReaction({ message: persisted, reaction: reactionResponse }) : enforceUnique ? db.updateReaction({ message: persisted, reaction: reactionResponse }) : db.insertReaction({ message: persisted, reaction: reactionResponse }),
1481
- { method: "applyReactionLocally" }
1399
+ // src/offline-support/queueableOperations.ts
1400
+ var logger2 = chatLoggerSystem.getLogger("offline-db");
1401
+ var channelOf = (client, task, channel) => {
1402
+ if (channel) return channel;
1403
+ const { channelId, channelType } = task;
1404
+ if (!channelType || !channelId) {
1405
+ throw new Error(
1406
+ `Cannot replay a "${task.type}" task without a channel type and id (message: ${task.messageId}).`
1482
1407
  );
1483
1408
  }
1484
- return () => {
1485
- if (!removed) {
1486
- applyReactionLocally(client, {
1487
- messageId,
1488
- reaction: reactionResponse,
1489
- removed: true
1490
- });
1491
- }
1492
- for (const removedReaction of removedReactions) {
1493
- applyReactionLocally(client, {
1494
- messageId,
1495
- reaction: removedReaction,
1496
- removed: false
1497
- });
1498
- }
1499
- };
1409
+ return client.channel(channelType, channelId);
1500
1410
  };
1501
-
1502
- // src/entityStore/EntityStore.ts
1503
- var EntityStore = class {
1504
- constructor({ getEntityId }) {
1505
- this.byId = /* @__PURE__ */ new Map();
1506
- this.subscribers = /* @__PURE__ */ new Map();
1507
- this.transactionDepth = 0;
1508
- this.pendingChanged = /* @__PURE__ */ new Map();
1509
- this.getEntityId = getEntityId;
1510
- }
1511
- // ---- reads ----
1512
- get(id) {
1513
- return typeof id === "string" ? this.byId.get(id) : void 0;
1514
- }
1515
- has(id) {
1516
- return this.byId.has(id);
1517
- }
1518
- // ---- writes ----
1519
- /**
1520
- * Replaces the canonical copy of `entity` and notifies every subscriber watching its id —
1521
- * except `subscriber`, the one that performed the write, which is expected to re-render itself
1522
- * (the paginator that performed the write already emits its own window inline, so it must not be
1523
- * notified a second time through the subscription).
1524
- */
1525
- upsert(entity, subscriber) {
1526
- const id = this.getEntityId(entity);
1527
- const previous = this.byId.get(id);
1528
- if (previous === entity) return;
1529
- this.byId.set(id, entity);
1530
- this.markDirty(id, subscriber);
1531
- this.autoFlush();
1532
- }
1533
- // ---- subscription registry / refcount ----
1534
- /** Registers `subscriber` for `id` (notification target + refcount). */
1535
- link(id, subscriber) {
1536
- let subscribers = this.subscribers.get(id);
1537
- if (!subscribers) {
1538
- subscribers = /* @__PURE__ */ new Set();
1539
- this.subscribers.set(id, subscribers);
1540
- }
1541
- subscribers.add(subscriber);
1542
- }
1543
- /** Drops `subscriber` from `id`; GCs the canonical copy when none remain. */
1544
- unlink(id, subscriber) {
1545
- const subscribers = this.subscribers.get(id);
1546
- if (!subscribers) return;
1547
- subscribers.delete(subscriber);
1548
- if (subscribers.size === 0) {
1549
- this.subscribers.delete(id);
1550
- this.byId.delete(id);
1551
- }
1552
- }
1553
- /**
1554
- * Sugar for atomic / ad-hoc consumers that watch a single id (e.g. a thread watching its parent
1555
- * message). Fires `handler` immediately with the current value (mirroring
1556
- * {@link StateStore.subscribe}) and on every subsequent change.
1557
- */
1558
- subscribe(id, handler) {
1559
- const subscriber = {
1560
- onEntitiesChanged: () => handler(this.byId.get(id))
1561
- };
1562
- this.link(id, subscriber);
1563
- handler(this.byId.get(id));
1564
- return () => this.unlink(id, subscriber);
1411
+ var QUEUEABLE_OPERATIONS = {
1412
+ "create-draft": {
1413
+ logFailureAs: {
1414
+ message: "Creating the draft in the offline database failed.",
1415
+ method: "createDraft"
1416
+ },
1417
+ run: ({ channel, client, task }) => channelOf(client, task, channel)._createDraft(...task.payload)
1418
+ },
1419
+ "delete-draft": {
1420
+ logFailureAs: {
1421
+ message: "Deleting the draft from the offline database failed.",
1422
+ method: "deleteDraft"
1423
+ },
1424
+ run: ({ channel, client, task }) => channelOf(client, task, channel)._deleteDraft(...task.payload)
1425
+ },
1426
+ "delete-message": {
1427
+ logFailureAs: { message: "Deleting the message failed.", method: "deleteMessage" },
1428
+ run: ({ client, task }) => client._deleteMessage(...task.payload)
1429
+ },
1430
+ "delete-reaction": {
1431
+ logFailureAs: { message: "Deleting the reaction failed.", method: "deleteReaction" },
1432
+ run: ({ channel, client, task }) => channelOf(client, task, channel)._deleteReaction(...task.payload)
1433
+ },
1434
+ "send-message": {
1435
+ logFailureAs: { message: "Sending the message failed.", method: "sendMessage" },
1436
+ /**
1437
+ * A replayed send is the first time local state learns the message exists server-side: the
1438
+ * optimistic layer that would normally reconcile it belongs to a `MessageOperations` call that
1439
+ * finished (and failed) long ago.
1440
+ */
1441
+ onReplay: ({ client, result, task }) => {
1442
+ const message = result?.message;
1443
+ if (!message) return;
1444
+ if (message.parent_id) {
1445
+ client.threads.threadsById[message.parent_id]?.upsertReplyLocally({
1446
+ message,
1447
+ timestampChanged: true
1448
+ });
1449
+ }
1450
+ channelOf(client, task).messagePaginator.trackLastMessage(formatMessage(message));
1451
+ },
1452
+ run: ({ channel, client, task }) => channelOf(client, task, channel)._sendMessage(...task.payload)
1453
+ },
1454
+ "send-reaction": {
1455
+ logFailureAs: { message: "Sending the reaction failed.", method: "sendReaction" },
1456
+ run: ({ channel, client, task }) => channelOf(client, task, channel)._sendReaction(...task.payload)
1457
+ },
1458
+ "update-message": {
1459
+ logFailureAs: { message: "Updating the message failed.", method: "updateMessage" },
1460
+ run: ({ client, task }) => client._updateMessage(...task.payload)
1565
1461
  }
1566
- // ---- batching ----
1567
- /**
1568
- * Runs `fn`, coalescing all notifications produced by writes inside it into a single flush on
1569
- * exit. Re-entrant: nested transactions flush only when the outermost exits.
1570
- */
1571
- transaction(fn) {
1572
- this.transactionDepth += 1;
1462
+ };
1463
+ var queueOrRun = async ({
1464
+ channel,
1465
+ client,
1466
+ queue = true,
1467
+ task
1468
+ }) => {
1469
+ const { offlineDb } = client;
1470
+ if (offlineDb && queue) {
1573
1471
  try {
1574
- return fn();
1575
- } finally {
1576
- this.transactionDepth -= 1;
1577
- if (this.transactionDepth === 0) this.flush();
1578
- }
1579
- }
1580
- /**
1581
- * Immediately flushes any throttled/pending state publish on the subscribers of `id` (via
1582
- * {@link EntityStoreSubscriber.flushState}). Called after an optimistic (local-user) write to
1583
- * `id` so it renders without the throttle delay. Only that id's own subscribers are flushed — the
1584
- * write touched no other id — and flushing a subscriber with nothing pending is a no-op.
1585
- */
1586
- flushSubscribers(id) {
1587
- const subscribers = this.subscribers.get(id);
1588
- if (!subscribers) return;
1589
- for (const subscriber of subscribers) subscriber.flushState?.();
1590
- }
1591
- /**
1592
- * Records `id` as changed for every subscriber watching it, except `subscriber` — the one that
1593
- * wrote it and is resposible for updating itself (see {@link EntityStore.upsert}).
1594
- */
1595
- markDirty(id, subscriber) {
1596
- const subscribers = this.subscribers.get(id);
1597
- if (!subscribers) return;
1598
- for (const other of subscribers) {
1599
- if (other === subscriber) continue;
1600
- let changed = this.pendingChanged.get(other);
1601
- if (!changed) {
1602
- changed = /* @__PURE__ */ new Set();
1603
- this.pendingChanged.set(other, changed);
1604
- }
1605
- changed.add(id);
1472
+ return await offlineDb.queueTask({ task });
1473
+ } catch (error) {
1474
+ const { message, method } = QUEUEABLE_OPERATIONS[task.type].logFailureAs;
1475
+ const cid = task.channelType && task.channelId ? [`${task.channelType}:${task.channelId}`] : [];
1476
+ logger2.withExtraTags(method, ...cid).error(message, { error });
1606
1477
  }
1607
1478
  }
1608
- autoFlush() {
1609
- if (this.transactionDepth === 0) this.flush();
1610
- }
1611
- flush() {
1612
- if (this.pendingChanged.size === 0) return;
1613
- const changedBySubscriber = this.pendingChanged;
1614
- this.pendingChanged = /* @__PURE__ */ new Map();
1615
- for (const [subscriber, changedIds] of changedBySubscriber) {
1616
- subscriber.onEntitiesChanged({ changedIds });
1617
- }
1479
+ return await runQueueableOperation({ channel, client, task });
1480
+ };
1481
+ var runQueueableOperation = async ({
1482
+ channel,
1483
+ client,
1484
+ task
1485
+ }) => {
1486
+ const operation = QUEUEABLE_OPERATIONS[task.type];
1487
+ if (!operation) {
1488
+ throw new Error(
1489
+ `Tried to execute invalid pending task type (${task.type}) while synchronizing the database.`
1490
+ );
1618
1491
  }
1492
+ return await operation.run({ channel, client, task });
1619
1493
  };
1620
1494
 
1621
1495
  // src/messageComposer/attachmentIdentity.ts
@@ -3802,6 +3676,55 @@ var TextComposer = class {
3802
3676
  // --- END TEXT PROCESSING ----
3803
3677
  };
3804
3678
 
3679
+ // src/errors.ts
3680
+ var import_axios = require("axios");
3681
+ var APIErrorCodes = {
3682
+ "-1": { name: "InternalSystemError", retryable: true },
3683
+ "2": { name: "AccessKeyError", retryable: false },
3684
+ "3": { name: "AuthenticationFailedError", retryable: true },
3685
+ "4": { name: "InputError", retryable: false },
3686
+ "6": { name: "DuplicateUsernameError", retryable: false },
3687
+ "9": { name: "RateLimitError", retryable: true },
3688
+ "16": { name: "DoesNotExistError", retryable: false },
3689
+ "17": { name: "NotAllowedError", retryable: false },
3690
+ "18": { name: "EventNotSupportedError", retryable: false },
3691
+ "19": { name: "ChannelFeatureNotSupportedError", retryable: false },
3692
+ "20": { name: "MessageTooLongError", retryable: false },
3693
+ "21": { name: "MultipleNestingLevelError", retryable: false },
3694
+ "22": { name: "PayloadTooBigError", retryable: false },
3695
+ "23": { name: "RequestTimeoutError", retryable: true },
3696
+ "24": { name: "MaxHeaderSizeExceededError", retryable: false },
3697
+ "40": { name: "AuthErrorTokenExpired", retryable: false },
3698
+ "41": { name: "AuthErrorTokenNotValidYet", retryable: false },
3699
+ "42": { name: "AuthErrorTokenUsedBeforeIssuedAt", retryable: false },
3700
+ "43": { name: "AuthErrorTokenSignatureInvalid", retryable: false },
3701
+ "44": { name: "CustomCommandEndpointMissingError", retryable: false },
3702
+ "45": { name: "CustomCommandEndpointCallError", retryable: true },
3703
+ "46": { name: "ConnectionIDNotFoundError", retryable: false },
3704
+ "60": { name: "CoolDownError", retryable: true },
3705
+ "69": { name: "ErrWrongRegion", retryable: false },
3706
+ "70": { name: "ErrQueryChannelPermissions", retryable: false },
3707
+ "71": { name: "ErrTooManyConnections", retryable: true },
3708
+ "99": { name: "AppSuspendedError", retryable: false }
3709
+ };
3710
+ function isAPIError(error) {
3711
+ return error.code !== void 0;
3712
+ }
3713
+ function isDoesNotExistError(error) {
3714
+ return isAPIError(error) && APIErrorCodes[`${error.code}`]?.name === "DoesNotExistError";
3715
+ }
3716
+ function isErrorRetryable(error) {
3717
+ if (!error.code) return false;
3718
+ const err = APIErrorCodes[`${error.code}`];
3719
+ if (!err) return false;
3720
+ return err.retryable;
3721
+ }
3722
+ function isEphemeral(error) {
3723
+ if ((0, import_axios.isCancel)(error)) return false;
3724
+ if (!error.response) return true;
3725
+ return isErrorRetryable(error);
3726
+ }
3727
+
3805
3728
  // src/search/BaseSearchSource.ts
3806
3729
  var DEFAULT_SEARCH_SOURCE_OPTIONS = {
3807
3730
  pageSize: 10,
@@ -4325,6 +4248,17 @@ function autoCompleteOp(value, query) {
4325
4248
  return value.some((v) => typeof v === "string" && matchOneString(v));
4326
4249
  return false;
4327
4250
  }
4251
+ var LOGICAL_FILTER_OPERATORS = ["$and", "$or", "$nor"];
4252
+ var filterConstrainsField = (filters, field) => {
4253
+ if (!filters || typeof filters !== "object") return false;
4254
+ if (Array.isArray(filters))
4255
+ return filters.some((entry) => filterConstrainsField(entry, field));
4256
+ const node = filters;
4257
+ if (field in node) return true;
4258
+ return LOGICAL_FILTER_OPERATORS.some(
4259
+ (operator) => filterConstrainsField(node[operator], field)
4260
+ );
4261
+ };
4328
4262
 
4329
4263
  // src/pagination/paginators/stateThrottling.ts
4330
4264
  var isTestRunner = () => {
@@ -4334,8 +4268,127 @@ var isTestRunner = () => {
4334
4268
  return false;
4335
4269
  }
4336
4270
  };
4337
- var enabled = !isTestRunner();
4338
- var isStateThrottlingEnabled = () => enabled;
4271
+ var enabled = !isTestRunner();
4272
+ var isStateThrottlingEnabled = () => enabled;
4273
+
4274
+ // src/entityStore/EntityStore.ts
4275
+ var EntityStore = class {
4276
+ constructor({ getEntityId }) {
4277
+ this.byId = /* @__PURE__ */ new Map();
4278
+ this.subscribers = /* @__PURE__ */ new Map();
4279
+ this.transactionDepth = 0;
4280
+ this.pendingChanged = /* @__PURE__ */ new Map();
4281
+ this.getEntityId = getEntityId;
4282
+ }
4283
+ // ---- reads ----
4284
+ get(id) {
4285
+ return typeof id === "string" ? this.byId.get(id) : void 0;
4286
+ }
4287
+ has(id) {
4288
+ return this.byId.has(id);
4289
+ }
4290
+ // ---- writes ----
4291
+ /**
4292
+ * Replaces the canonical copy of `entity` and notifies every subscriber watching its id —
4293
+ * except `subscriber`, the one that performed the write, which is expected to re-render itself
4294
+ * (the paginator that performed the write already emits its own window inline, so it must not be
4295
+ * notified a second time through the subscription).
4296
+ */
4297
+ upsert(entity, subscriber) {
4298
+ const id = this.getEntityId(entity);
4299
+ const previous = this.byId.get(id);
4300
+ if (previous === entity) return;
4301
+ this.byId.set(id, entity);
4302
+ this.markDirty(id, subscriber);
4303
+ this.autoFlush();
4304
+ }
4305
+ // ---- subscription registry / refcount ----
4306
+ /** Registers `subscriber` for `id` (notification target + refcount). */
4307
+ link(id, subscriber) {
4308
+ let subscribers = this.subscribers.get(id);
4309
+ if (!subscribers) {
4310
+ subscribers = /* @__PURE__ */ new Set();
4311
+ this.subscribers.set(id, subscribers);
4312
+ }
4313
+ subscribers.add(subscriber);
4314
+ }
4315
+ /** Drops `subscriber` from `id`; GCs the canonical copy when none remain. */
4316
+ unlink(id, subscriber) {
4317
+ const subscribers = this.subscribers.get(id);
4318
+ if (!subscribers) return;
4319
+ subscribers.delete(subscriber);
4320
+ if (subscribers.size === 0) {
4321
+ this.subscribers.delete(id);
4322
+ this.byId.delete(id);
4323
+ }
4324
+ }
4325
+ /**
4326
+ * Sugar for atomic / ad-hoc consumers that watch a single id (e.g. a thread watching its parent
4327
+ * message). Fires `handler` immediately with the current value (mirroring
4328
+ * {@link StateStore.subscribe}) and on every subsequent change.
4329
+ */
4330
+ subscribe(id, handler) {
4331
+ const subscriber = {
4332
+ onEntitiesChanged: () => handler(this.byId.get(id))
4333
+ };
4334
+ this.link(id, subscriber);
4335
+ handler(this.byId.get(id));
4336
+ return () => this.unlink(id, subscriber);
4337
+ }
4338
+ // ---- batching ----
4339
+ /**
4340
+ * Runs `fn`, coalescing all notifications produced by writes inside it into a single flush on
4341
+ * exit. Re-entrant: nested transactions flush only when the outermost exits.
4342
+ */
4343
+ transaction(fn) {
4344
+ this.transactionDepth += 1;
4345
+ try {
4346
+ return fn();
4347
+ } finally {
4348
+ this.transactionDepth -= 1;
4349
+ if (this.transactionDepth === 0) this.flush();
4350
+ }
4351
+ }
4352
+ /**
4353
+ * Immediately flushes any throttled/pending state publish on the subscribers of `id` (via
4354
+ * {@link EntityStoreSubscriber.flushState}). Called after an optimistic (local-user) write to
4355
+ * `id` so it renders without the throttle delay. Only that id's own subscribers are flushed — the
4356
+ * write touched no other id — and flushing a subscriber with nothing pending is a no-op.
4357
+ */
4358
+ flushSubscribers(id) {
4359
+ const subscribers = this.subscribers.get(id);
4360
+ if (!subscribers) return;
4361
+ for (const subscriber of subscribers) subscriber.flushState?.();
4362
+ }
4363
+ /**
4364
+ * Records `id` as changed for every subscriber watching it, except `subscriber` — the one that
4365
+ * wrote it and is resposible for updating itself (see {@link EntityStore.upsert}).
4366
+ */
4367
+ markDirty(id, subscriber) {
4368
+ const subscribers = this.subscribers.get(id);
4369
+ if (!subscribers) return;
4370
+ for (const other of subscribers) {
4371
+ if (other === subscriber) continue;
4372
+ let changed = this.pendingChanged.get(other);
4373
+ if (!changed) {
4374
+ changed = /* @__PURE__ */ new Set();
4375
+ this.pendingChanged.set(other, changed);
4376
+ }
4377
+ changed.add(id);
4378
+ }
4379
+ }
4380
+ autoFlush() {
4381
+ if (this.transactionDepth === 0) this.flush();
4382
+ }
4383
+ flush() {
4384
+ if (this.pendingChanged.size === 0) return;
4385
+ const changedBySubscriber = this.pendingChanged;
4386
+ this.pendingChanged = /* @__PURE__ */ new Map();
4387
+ for (const [subscriber, changedIds] of changedBySubscriber) {
4388
+ subscriber.onEntitiesChanged({ changedIds });
4389
+ }
4390
+ }
4391
+ };
4339
4392
 
4340
4393
  // src/entityStore/StoreBackedItemIndex.ts
4341
4394
  var NOOP_OWNER = { onEntitiesChanged: () => void 0 };
@@ -5750,6 +5803,7 @@ var BasePaginator = class {
5750
5803
  const itemHasBeenRemoved = !!removedItemCoordinates?.state && removedItemCoordinates.state.currentIndex > -1;
5751
5804
  this._itemIndex.setOne(ingestedItem);
5752
5805
  if (!this.matchesFilter(ingestedItem)) {
5806
+ this.shrinkOffsetAfterRemoval(removedItemCoordinates);
5753
5807
  if (!this.isWindowPublishSuspended && this.isStateThrottled && itemHasBeenRemoved)
5754
5808
  this.scheduleWindowPublish();
5755
5809
  return itemHasBeenRemoved;
@@ -5905,6 +5959,17 @@ var BasePaginator = class {
5905
5959
  }
5906
5960
  return result;
5907
5961
  }
5962
+ /**
5963
+ * Keeps `offset` addressing the same position in the *server's* result set after an item left the
5964
+ * loaded window.
5965
+ */
5966
+ shrinkOffsetAfterRemoval(coordinates) {
5967
+ if (this.isCursorPagination) return;
5968
+ if ((coordinates?.state?.currentIndex ?? -1) < 0) return;
5969
+ const { offset } = this.state.getLatestValue();
5970
+ if (typeof offset !== "number") return;
5971
+ this.state.partialNext({ offset: Math.max(0, offset - 1) });
5972
+ }
5908
5973
  /**
5909
5974
  * Meaning of location values
5910
5975
  * - currentIndex === -1 could not be found
@@ -5923,6 +5988,7 @@ var BasePaginator = class {
5923
5988
  if (!coords.state && !coords.interval) return noAction;
5924
5989
  const result = this.removeItemAtCoordinates(coords);
5925
5990
  this._itemIndex.remove(this.getItemId(item));
5991
+ this.shrinkOffsetAfterRemoval(result);
5926
5992
  if (!this.isWindowPublishSuspended && this.isStateThrottled)
5927
5993
  this.scheduleWindowPublish();
5928
5994
  return result;
@@ -6651,17 +6717,7 @@ var ChannelPaginator = class extends BasePaginator {
6651
6717
  }
6652
6718
  matchesFilter(channel) {
6653
6719
  const filters = this.buildMatchFilters();
6654
- const LOGICAL_FILTER_OPERATORS = ["$and", "$or", "$nor"];
6655
- const filterConstrainsHidden = (filters2) => {
6656
- if (!filters2 || typeof filters2 !== "object") return false;
6657
- if (Array.isArray(filters2)) return filters2.some(filterConstrainsHidden);
6658
- const node = filters2;
6659
- if ("hidden" in node) return true;
6660
- return LOGICAL_FILTER_OPERATORS.some(
6661
- (operator) => filterConstrainsHidden(node[operator])
6662
- );
6663
- };
6664
- if (channel.data?.hidden && !filterConstrainsHidden(filters)) return false;
6720
+ if (channel.data?.hidden && !filterConstrainsField(filters, "hidden")) return false;
6665
6721
  return itemMatchesFilter(channel, filters, {
6666
6722
  resolvers: this._filterFieldToDataResolvers
6667
6723
  });
@@ -7200,7 +7256,30 @@ var MessageIntervalPaginator = class extends BasePaginator {
7200
7256
  */
7201
7257
  this.mergeNewestPage = (page, options) => {
7202
7258
  const headInterval = this.itemIntervals[0];
7203
- if (!headInterval?.isHead) return;
7259
+ if (!headInterval?.isHead) {
7260
+ const logicalHead = this.liveHeadLogical;
7261
+ const hasAnchoredHead = this.itemIntervals.some(
7262
+ (interval2) => !isLogicalInterval(interval2) && interval2.isHead
7263
+ );
7264
+ if (!logicalHead || hasAnchoredHead || !page?.length) return;
7265
+ const takeOverView = this.isActiveInterval(logicalHead);
7266
+ const anchored = this.ingestPage({ page, isHead: true, setActive: false });
7267
+ if (!anchored || !takeOverView) return;
7268
+ const hasMoreTail2 = !this.pageReachedChannelStart(page, options);
7269
+ anchored.hasMoreTail = hasMoreTail2;
7270
+ anchored.isTail = !hasMoreTail2;
7271
+ this.setActiveInterval(anchored, { updateState: false });
7272
+ this.state.partialNext({
7273
+ items: this.intervalToItems(anchored),
7274
+ hasMoreHead: false,
7275
+ hasMoreTail: hasMoreTail2,
7276
+ cursor: {
7277
+ headward: null,
7278
+ tailward: hasMoreTail2 ? anchored.itemIds[0] ?? null : null
7279
+ }
7280
+ });
7281
+ return;
7282
+ }
7204
7283
  const wasAtChannelStart = !this.state.getLatestValue().hasMoreTail;
7205
7284
  const preMergeLoadedCount = headInterval.itemIds.length;
7206
7285
  if (!this.isActiveInterval(headInterval)) {
@@ -7529,7 +7608,7 @@ var MessageIntervalPaginator = class extends BasePaginator {
7529
7608
  enforceUnique,
7530
7609
  reaction,
7531
7610
  removed,
7532
- userId: this.channel.getClient().userID
7611
+ userId: this.channel.getClient().userId
7533
7612
  });
7534
7613
  this.ingestItem({ ...formatted, own_reactions });
7535
7614
  };
@@ -7687,6 +7766,9 @@ var MessageIntervalPaginator = class extends BasePaginator {
7687
7766
  };
7688
7767
  const isJump = this.isJumpQueryShape(queryShape);
7689
7768
  if (options?.reconcile && !isJump && typeof this.items !== "undefined") {
7769
+ if (this.state.getLatestValue().lastQueryError) {
7770
+ this.state.partialNext({ lastQueryError: void 0 });
7771
+ }
7690
7772
  this.mergeNewestPage(messages, {
7691
7773
  candidateIds: options.candidateIds,
7692
7774
  requestedLimit: requestedPageSize
@@ -8525,7 +8607,7 @@ var ChannelMemberSearchSource = class extends BaseSearchSource {
8525
8607
  };
8526
8608
 
8527
8609
  // src/configuration/utils/applyInstanceConfiguration.ts
8528
- var logger2 = chatLoggerSystem.getLogger("instance-configuration");
8610
+ var logger3 = chatLoggerSystem.getLogger("instance-configuration");
8529
8611
  var applyInstanceConfiguration = ({
8530
8612
  alsoWatch,
8531
8613
  applyConfig,
@@ -8534,7 +8616,7 @@ var applyInstanceConfiguration = ({
8534
8616
  key,
8535
8617
  reinitializeConfig
8536
8618
  }) => {
8537
- const scopedLogger2 = logger2.withExtraTags(key);
8619
+ const scopedLogger2 = logger3.withExtraTags(key);
8538
8620
  let tearDown = null;
8539
8621
  const runTearDown = () => {
8540
8622
  if (!tearDown) return;
@@ -10749,7 +10831,7 @@ var createTextComposerPreValidationMiddleware = (composer) => ({
10749
10831
  });
10750
10832
 
10751
10833
  // src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts
10752
- var logger3 = chatLoggerSystem.getLogger("text-composer");
10834
+ var logger4 = chatLoggerSystem.getLogger("text-composer");
10753
10835
  var TextComposerMiddlewareExecutor = class extends MiddlewareExecutor {
10754
10836
  constructor({ composer }) {
10755
10837
  super();
@@ -10772,7 +10854,7 @@ var TextComposerMiddlewareExecutor = class extends MiddlewareExecutor {
10772
10854
  });
10773
10855
  const { query, searchSource } = result.state.suggestions ?? {};
10774
10856
  searchSource?.search(query)?.catch(
10775
- (error) => logger3.withExtraTags("execute").error("Searching for suggestions failed.", { error })
10857
+ (error) => logger4.withExtraTags("execute").error("Searching for suggestions failed.", { error })
10776
10858
  );
10777
10859
  return result;
10778
10860
  }
@@ -11381,44 +11463,142 @@ var parseError = (error) => {
11381
11463
  return stringError ? JSON.parse(stringError) : {};
11382
11464
  };
11383
11465
  var isAlreadyExistsError = (error, parsed) => parsed.code === 4 && error instanceof Error && error.message.includes("already exists");
11466
+ var isHardDelete = (options) => !!options?.hard;
11384
11467
  var MessageOperationStatePolicy = class {
11385
11468
  constructor(ctx) {
11386
11469
  this.ctx = ctx;
11387
11470
  }
11388
- optimistic(localMessage) {
11389
- this.ctx.ingest({
11471
+ optimistic(kind, params) {
11472
+ const { localMessage } = params;
11473
+ const previous = this.ctx.get(localMessage.id);
11474
+ if (kind === "delete") {
11475
+ const deleteForMe = params.options?.delete_for_me;
11476
+ if (isHardDelete(params.options)) {
11477
+ this.ctx.remove(localMessage.id);
11478
+ this.ctx.purge(localMessage.id);
11479
+ return { previous };
11480
+ }
11481
+ if (!previous) return {};
11482
+ const applied2 = {
11483
+ ...localMessage,
11484
+ deleted_at: /* @__PURE__ */ new Date(),
11485
+ type: "deleted",
11486
+ ...deleteForMe ? { deleted_for_me: true } : {}
11487
+ };
11488
+ this.ctx.ingest(applied2);
11489
+ this.ctx.persist(applied2);
11490
+ return { applied: applied2, previous };
11491
+ }
11492
+ if (kind === "update") {
11493
+ const isFailed = localMessage.status === "failed";
11494
+ const editedAt = /* @__PURE__ */ new Date();
11495
+ const applied2 = {
11496
+ ...localMessage,
11497
+ error: isFailed ? localMessage.error : void 0,
11498
+ updated_at: editedAt,
11499
+ // `message_text_updated_at` is what drives the "edited" indicator, so it has to appear
11500
+ // immediately — but only for a message the server has actually seen. A failed message never
11501
+ // received a server-confirmed text update and must not appear to have had one.
11502
+ ...isFailed ? {} : { message_text_updated_at: editedAt }
11503
+ };
11504
+ this.ctx.ingest(applied2);
11505
+ this.ctx.persist(applied2);
11506
+ return { applied: applied2, previous };
11507
+ }
11508
+ const applied = {
11390
11509
  ...localMessage,
11391
11510
  error: void 0,
11392
11511
  status: !localMessage.status || localMessage.status === "failed" ? "sending" : localMessage.status
11393
- });
11512
+ };
11513
+ this.ctx.ingest(applied);
11514
+ this.ctx.persist({ ...applied, status: "failed" });
11515
+ return { applied, previous };
11394
11516
  }
11395
11517
  success({
11518
+ kind,
11396
11519
  messageFromResponse,
11397
- messageId
11520
+ messageId,
11521
+ optimistic,
11522
+ options
11398
11523
  }) {
11524
+ if (!messageFromResponse) return;
11399
11525
  const formatted = formatMessage({ ...messageFromResponse, status: "received" });
11526
+ if (kind === "delete") {
11527
+ if (isHardDelete(options)) {
11528
+ this.ctx.remove(messageId);
11529
+ this.ctx.purge(messageId);
11530
+ return;
11531
+ }
11532
+ if (!this.ctx.get(messageId)) return;
11533
+ this.ctx.ingest(formatted);
11534
+ this.ctx.persist(formatted);
11535
+ return;
11536
+ }
11400
11537
  const existing = this.ctx.get(messageId);
11538
+ const nothingWroteSinceOptimistic = !!optimistic?.applied && existing === optimistic.applied;
11401
11539
  const serverNewer = !existing || formatted.updated_at.getTime() > existing.updated_at.getTime();
11402
11540
  const serverSameOrNewer = !existing || formatted.updated_at.getTime() >= existing.updated_at.getTime();
11403
11541
  const existingIsOurOptimisticSend = existing?.status === "sending";
11404
- if (serverNewer || existingIsOurOptimisticSend && serverSameOrNewer) {
11405
- this.ctx.ingest(formatted);
11406
- }
11542
+ const applyServerCopy = nothingWroteSinceOptimistic || serverNewer || existingIsOurOptimisticSend && serverSameOrNewer;
11543
+ if (!applyServerCopy) return;
11544
+ this.ctx.ingest(formatted);
11545
+ this.ctx.persist(formatted);
11407
11546
  }
11408
- failure({
11547
+ async failure({
11409
11548
  error,
11549
+ kind,
11410
11550
  localMessage,
11411
- messageId
11551
+ messageId,
11552
+ optimistic,
11553
+ options
11412
11554
  }) {
11413
11555
  const parsed = parseError(error);
11414
11556
  if (isAlreadyExistsError(error, parsed)) {
11415
11557
  const existing = this.ctx.get(messageId);
11416
11558
  if (existing?.status === "sending") {
11417
- this.ctx.ingest({ ...localMessage, status: "received" });
11559
+ const received = { ...localMessage, status: "received" };
11560
+ this.ctx.ingest(received);
11561
+ this.ctx.persist(received);
11418
11562
  }
11419
11563
  return;
11420
11564
  }
11421
- this.ctx.ingest({ ...localMessage, status: "failed", error: parsed });
11565
+ const queued = kind !== "send" && kind !== "retry" && await this.ctx.isQueued(messageId, [
11566
+ kind === "delete" ? "delete-message" : "update-message"
11567
+ ]);
11568
+ if (kind === "delete") {
11569
+ if (!queued) this.revertDelete({ messageId, optimistic, options });
11570
+ return;
11571
+ }
11572
+ if (kind === "update") {
11573
+ if (queued) return;
11574
+ const failed2 = {
11575
+ ...this.ctx.get(messageId) ?? localMessage,
11576
+ error: parsed,
11577
+ status: "failed"
11578
+ };
11579
+ this.ctx.ingest(failed2);
11580
+ this.ctx.persist(failed2);
11581
+ return;
11582
+ }
11583
+ const failed = { ...localMessage, error: parsed, status: "failed" };
11584
+ this.ctx.ingest(failed);
11585
+ this.ctx.persist(failed);
11586
+ }
11587
+ revertDelete({
11588
+ messageId,
11589
+ optimistic,
11590
+ options
11591
+ }) {
11592
+ const previous = optimistic?.previous;
11593
+ if (!previous) return;
11594
+ if (isHardDelete(options)) {
11595
+ this.ctx.ingest(previous);
11596
+ this.ctx.persist(previous);
11597
+ return;
11598
+ }
11599
+ if (optimistic?.applied && this.ctx.get(messageId) !== optimistic.applied) return;
11600
+ this.ctx.ingest(previous);
11601
+ this.ctx.persist(previous);
11422
11602
  }
11423
11603
  };
11424
11604
 
@@ -11431,7 +11611,14 @@ var MessageOperations = class {
11431
11611
  constructor(ctx) {
11432
11612
  this.failedSendCache = /* @__PURE__ */ new Map();
11433
11613
  this.ctx = ctx;
11434
- this.policy = new MessageOperationStatePolicy({ ingest: ctx.ingest, get: ctx.get });
11614
+ this.policy = new MessageOperationStatePolicy({
11615
+ get: ctx.get,
11616
+ ingest: ctx.ingest,
11617
+ isQueued: ctx.isQueued,
11618
+ persist: ctx.persist,
11619
+ purge: ctx.purge,
11620
+ remove: ctx.remove
11621
+ });
11435
11622
  this.configController = new ConfigController({
11436
11623
  defaults: DEFAULT_MESSAGE_OPERATIONS_CONFIG
11437
11624
  });
@@ -11505,14 +11692,32 @@ var MessageOperations = class {
11505
11692
  clearCachedFailedSend(messageId) {
11506
11693
  this.failedSendCache.delete(messageId);
11507
11694
  }
11508
- async run(params, doRequest) {
11695
+ /**
11696
+ * The shared lifecycle: apply the optimistic state, fire the request, then reconcile or record the
11697
+ * failure. `kind` is threaded through because the three operations want materially different state
11698
+ * transitions — see {@link MessageOperationStatePolicy}.
11699
+ */
11700
+ async run(kind, params, doRequest) {
11509
11701
  const messageId = params.localMessage.id;
11510
- this.policy.optimistic(params.localMessage);
11702
+ const optimistic = this.policy.optimistic(kind, params);
11511
11703
  try {
11512
- const { message: messageFromResponse } = await doRequest(params);
11513
- this.policy.success({ messageFromResponse, messageId });
11704
+ const response = await doRequest(params);
11705
+ this.policy.success({
11706
+ kind,
11707
+ messageFromResponse: response?.message,
11708
+ messageId,
11709
+ optimistic,
11710
+ options: params.options
11711
+ });
11514
11712
  } catch (e) {
11515
- this.policy.failure({ error: e, localMessage: params.localMessage, messageId });
11713
+ await this.policy.failure({
11714
+ error: e,
11715
+ kind,
11716
+ localMessage: params.localMessage,
11717
+ messageId,
11718
+ optimistic,
11719
+ options: params.options
11720
+ });
11516
11721
  throw e;
11517
11722
  }
11518
11723
  }
@@ -11523,6 +11728,7 @@ var MessageOperations = class {
11523
11728
  );
11524
11729
  try {
11525
11730
  await this.run(
11731
+ "send",
11526
11732
  { ...params, message: messageToSend },
11527
11733
  requestFn ?? handlers.send ?? (async (p) => await this.ctx.defaults.send(p.message ?? messageToSend, p.options))
11528
11734
  );
@@ -11547,6 +11753,7 @@ var MessageOperations = class {
11547
11753
  const sendAsRetry = send ? (p) => send({ ...p }) : void 0;
11548
11754
  try {
11549
11755
  await this.run(
11756
+ "retry",
11550
11757
  {
11551
11758
  ...params,
11552
11759
  message: messageToSend,
@@ -11575,6 +11782,7 @@ var MessageOperations = class {
11575
11782
  updateOptions.skip_push = params.options.skip_push;
11576
11783
  }
11577
11784
  return await this.run(
11785
+ "update",
11578
11786
  params,
11579
11787
  requestFn ?? handlers.update ?? (async (p) => await this.ctx.defaults.update(p.localMessage, updateOptions))
11580
11788
  );
@@ -11582,11 +11790,140 @@ var MessageOperations = class {
11582
11790
  async delete(params, requestFn) {
11583
11791
  const handlers = this.ctx.handlers();
11584
11792
  const doRequest = requestFn ?? handlers.delete ?? (async (p) => await this.ctx.defaults.delete(p.localMessage.id, p.options));
11585
- const { message: messageFromResponse } = await doRequest(params);
11586
- this.ctx.ingest(formatMessage(messageFromResponse));
11793
+ return await this.run("delete", params, doRequest);
11794
+ }
11795
+ };
11796
+
11797
+ // src/entityStore/applyReactionLocally.ts
11798
+ var applyReactionLocally = (client, {
11799
+ enforceUnique = false,
11800
+ messageId,
11801
+ reaction,
11802
+ removed = false
11803
+ }) => {
11804
+ const store = client.messageStore;
11805
+ const user = client.user;
11806
+ const existing = store.get(messageId);
11807
+ if (!user || !existing) return;
11808
+ const now = /* @__PURE__ */ new Date();
11809
+ const reactionResponse = {
11810
+ ...reaction,
11811
+ created_at: reaction.created_at ?? now,
11812
+ custom: reaction.custom ?? {},
11813
+ message_id: messageId,
11814
+ score: reaction.score ?? 1,
11815
+ type: reaction.type,
11816
+ updated_at: reaction.updated_at ?? now,
11817
+ user,
11818
+ user_id: user.id
11819
+ };
11820
+ const removedReactions = removed ? existing.own_reactions?.filter((r) => r.type === reactionResponse.type) ?? [] : enforceUnique ? existing.own_reactions ?? [] : [];
11821
+ const withCounts = removed ? messageWithReactionRemoved(existing, reactionResponse) : messageWithReactionAdded(existing, reactionResponse, enforceUnique);
11822
+ const own_reactions = computeOwnReactions({
11823
+ current: existing.own_reactions ?? [],
11824
+ enforceUnique,
11825
+ reaction: reactionResponse,
11826
+ removed,
11827
+ userId: user.id
11828
+ });
11829
+ store.upsert({ ...withCounts, own_reactions });
11830
+ store.flushSubscribers(messageId);
11831
+ const persisted = store.get(messageId);
11832
+ if (persisted) {
11833
+ client.offlineDb?.executeQuerySafely(
11834
+ (db) => removed ? db.deleteReaction({ message: persisted, reaction: reactionResponse }) : enforceUnique ? db.updateReaction({ message: persisted, reaction: reactionResponse }) : db.insertReaction({ message: persisted, reaction: reactionResponse }),
11835
+ { method: "applyReactionLocally" }
11836
+ );
11837
+ }
11838
+ return () => {
11839
+ if (!removed) {
11840
+ applyReactionLocally(client, {
11841
+ messageId,
11842
+ reaction: reactionResponse,
11843
+ removed: true
11844
+ });
11845
+ }
11846
+ for (const removedReaction of removedReactions) {
11847
+ applyReactionLocally(client, {
11848
+ messageId,
11849
+ reaction: removedReaction,
11850
+ removed: false
11851
+ });
11852
+ }
11853
+ };
11854
+ };
11855
+
11856
+ // src/messageOperations/optimistic.ts
11857
+ var isQueuedForReplay = async (client, messageId, types) => {
11858
+ const { offlineDb } = client;
11859
+ if (!offlineDb?.state.getLatestValue().initialized) return false;
11860
+ const pending = await offlineDb.getPendingTasks({ messageId });
11861
+ return !!pending?.some((task) => types.includes(task.type));
11862
+ };
11863
+ var reconcileHeldMessage = (channel, message) => {
11864
+ if (!message) return;
11865
+ const { messageStore } = channel.getClient();
11866
+ if (!messageStore.has(message.id)) return;
11867
+ messageStore.upsert(formatMessage(message));
11868
+ };
11869
+ var addReactionOptimistically = async ({
11870
+ channel,
11871
+ messageId,
11872
+ options,
11873
+ reaction
11874
+ }) => {
11875
+ const client = channel.getClient();
11876
+ const undo = applyReactionLocally(client, {
11877
+ enforceUnique: options?.enforce_unique ?? false,
11878
+ messageId,
11879
+ reaction
11880
+ });
11881
+ try {
11882
+ const response = await channel.sendReaction({ id: messageId, reaction, ...options });
11883
+ reconcileHeldMessage(channel, response?.message);
11884
+ } catch (error) {
11885
+ if (!await isQueuedForReplay(client, messageId, ["send-reaction"])) undo?.();
11886
+ throw error;
11887
+ }
11888
+ };
11889
+ var deleteReactionOptimistically = async ({
11890
+ channel,
11891
+ messageId,
11892
+ type
11893
+ }) => {
11894
+ const client = channel.getClient();
11895
+ const undo = applyReactionLocally(client, {
11896
+ messageId,
11897
+ reaction: { type },
11898
+ removed: true
11899
+ });
11900
+ try {
11901
+ const response = await channel.deleteReaction({ id: messageId, type });
11902
+ reconcileHeldMessage(channel, response?.message);
11903
+ } catch (error) {
11904
+ if (!await isQueuedForReplay(client, messageId, ["delete-reaction"])) undo?.();
11905
+ throw error;
11587
11906
  }
11588
11907
  };
11589
11908
 
11909
+ // src/messageOperations/persistence.ts
11910
+ var createMessageOperationsPersistence = ({
11911
+ channel
11912
+ }) => ({
11913
+ persist: (message) => {
11914
+ channel.getClient().offlineDb?.executeQuerySafely(
11915
+ (db) => db.upsertMessageWithChannelGuard({ message: { ...message, cid: channel.cid } }),
11916
+ { method: "messageOperations:persist" }
11917
+ );
11918
+ },
11919
+ purge: (id) => {
11920
+ channel.getClient().offlineDb?.executeQuerySafely((db) => db.hardDeleteMessage({ id }), {
11921
+ method: "messageOperations:purge"
11922
+ });
11923
+ },
11924
+ isQueued: (messageId, types) => isQueuedForReplay(channel.getClient(), messageId, types)
11925
+ });
11926
+
11590
11927
  // src/configuration/utils/declarativeSlices.ts
11591
11928
  var mergeDeclarativeSlice = (general, specific) => {
11592
11929
  if (!general) return specific;
@@ -11625,23 +11962,60 @@ var Thread = class extends WithSubscriptions {
11625
11962
  defaults: DEFAULT_THREAD_CONFIG
11626
11963
  });
11627
11964
  this.failedRepliesMap = /* @__PURE__ */ new Map();
11965
+ /** How many consumers have called `activate()` without a matching `deactivate()`. */
11966
+ this._activeRefCount = 0;
11967
+ /**
11968
+ * Declares that a consumer is displaying this thread (mirrors `channel.activate()`).
11969
+ *
11970
+ * `active` is also what {@link ConnectionRecoveryManager} filters on to decide which threads to
11971
+ * reload on reconnect, so an unbalanced `deactivate()` now costs more than a missed auto-read —
11972
+ * hence the refcount, matching `channel.activate()`. A thread held by more than one mount stays
11973
+ * active until the last holder releases it.
11974
+ */
11628
11975
  this.activate = () => {
11629
- this.state.partialNext({ active: true });
11976
+ this._activeRefCount += 1;
11977
+ if (this._activeRefCount === 1) {
11978
+ this.state.partialNext({ active: true });
11979
+ }
11630
11980
  };
11981
+ /**
11982
+ * Declares that a consumer has stopped displaying this thread (mirrors `channel.deactivate()`).
11983
+ * Only flips `active` back to `false` once the last holder deactivates.
11984
+ */
11631
11985
  this.deactivate = () => {
11632
- this.state.partialNext({ active: false });
11986
+ if (this._activeRefCount === 0) return;
11987
+ this._activeRefCount -= 1;
11988
+ if (this._activeRefCount === 0) {
11989
+ this.state.partialNext({ active: false });
11990
+ }
11633
11991
  };
11992
+ /**
11993
+ * Re-queries this thread's replies, sized to the loaded window, and reconciles them in place — the
11994
+ * thread analogue of {@link Channel.reload}, and what connection recovery calls for every active
11995
+ * thread.
11996
+ *
11997
+ * Preserves failed (unsent) replies. They are read out of the reply paginator rather than out of
11998
+ * `failedRepliesMap`, because that map is only written by `upsertReplyLocally`, whose callers are
11999
+ * this thread's own subscriptions and the offline-DB path keyed on `ThreadManager.threadsById` —
12000
+ * neither of which covers a thread constructed directly and never registered (the common path in
12001
+ * the React Native SDK, which resolves `threadsById[id] ?? new Thread(...)`). Reading the paginator
12002
+ * is true for managed and unmanaged threads alike. An overlap merge keeps them anyway (the
12003
+ * reconcile's provenance guard never prunes a non-server message); only a disjoint rebuild can drop
12004
+ * them, so any that actually fell out are re-ingested below.
12005
+ *
12006
+ * Rethrows, except for a not-found answer on a thread that never had replies (see the catch).
12007
+ */
11634
12008
  this.reload = async () => {
11635
12009
  if (this.state.getLatestValue().isLoading) {
11636
12010
  return;
11637
12011
  }
11638
12012
  this.state.partialNext({ isLoading: true });
11639
12013
  try {
11640
- const loadedReplyCount = this.messagePaginator.items?.length ?? 0;
11641
- const requestedReplyLimit = loadedReplyCount || this.messagePaginator.pageSize;
11642
- const reconcileCandidateIds = new Set(
11643
- (this.messagePaginator.items ?? []).map((reply) => reply.id)
11644
- );
12014
+ const loadedReplies = this.messagePaginator.items ?? [];
12015
+ const loadedReplyCount = loadedReplies.length;
12016
+ const requestedReplyLimit = Math.max(loadedReplyCount, this.messagePaginator.pageSize ?? 0) || void 0;
12017
+ const reconcileCandidateIds = new Set(loadedReplies.map((reply) => reply.id));
12018
+ const failedBefore = loadedReplies.filter((reply) => reply.status === "failed");
11645
12019
  const thread = await this.client.getThreadAndHydrate(this.id, {
11646
12020
  watch: true,
11647
12021
  reply_limit: requestedReplyLimit
@@ -11652,6 +12026,22 @@ var Thread = class extends WithSubscriptions {
11652
12026
  candidateIds: reconcileCandidateIds
11653
12027
  }
11654
12028
  });
12029
+ if (failedBefore.length) {
12030
+ const visible = new Set((this.messagePaginator.items ?? []).map((r) => r.id));
12031
+ this.messagePaginator.batch(
12032
+ () => {
12033
+ for (const failed of failedBefore) {
12034
+ if (!visible.has(failed.id)) this.messagePaginator.ingestItem(failed);
12035
+ }
12036
+ },
12037
+ { coalesce: true }
12038
+ );
12039
+ }
12040
+ } catch (error) {
12041
+ const notFound = isDoesNotExistError(error) || error?.status === 404;
12042
+ const neverExisted = notFound && this.state.getLatestValue().replyCount === 0;
12043
+ if (neverExisted) return;
12044
+ throw error;
11655
12045
  } finally {
11656
12046
  this.state.partialNext({ isLoading: false });
11657
12047
  }
@@ -11918,7 +12308,7 @@ var Thread = class extends WithSubscriptions {
11918
12308
  enforceUnique: eventType === "reaction.updated",
11919
12309
  reaction,
11920
12310
  removed: eventType === "reaction.deleted",
11921
- userId: this.client.userID
12311
+ userId: this.client.userId
11922
12312
  });
11923
12313
  this.updateParentMessageLocally({ message: { ...message, own_reactions } });
11924
12314
  }
@@ -12127,11 +12517,26 @@ var Thread = class extends WithSubscriptions {
12127
12517
  compositionContext: this
12128
12518
  });
12129
12519
  this.messageOperations = new MessageOperations({
12520
+ ...createMessageOperationsPersistence({ channel: this.channel }),
12130
12521
  ingest: (m) => {
12131
- this.messagePaginator.ingestItem(m);
12132
- this.channel.getClient().messageStore.flushSubscribers(m.id);
12522
+ const store = this.channel.getClient().messageStore;
12523
+ if (this.messagePaginator.matchesFilter(m)) {
12524
+ this.messagePaginator.ingestItem(m);
12525
+ } else if (store.has(m.id)) {
12526
+ store.upsert(m);
12527
+ }
12528
+ store.flushSubscribers(m.id);
12529
+ },
12530
+ // Mirrors `ingest`'s routing: a message this paginator does not hold can still be held by the
12531
+ // client-global store (a thread parent, a message displayed by another collection), and the
12532
+ // policy uses this both for its freshness comparison and to decide whether there is anything to
12533
+ // update optimistically at all. Reading only the paginator would make those two disagree.
12534
+ get: (id) => this.messagePaginator.getItem(id) ?? this.channel.getClient().messageStore.get(id),
12535
+ remove: (id) => {
12536
+ this.messagePaginator.removeItem({ id });
12537
+ this.channel.messagePaginator.removeItem({ id });
12538
+ this.channel.pinnedMessagesPaginator.removeItem({ id });
12133
12539
  },
12134
- get: (id) => this.messagePaginator.getItem(id),
12135
12540
  normalizeOutgoingMessage: (m) => ({
12136
12541
  ...m,
12137
12542
  parent_id: this.id
@@ -12293,63 +12698,32 @@ var Thread = class extends WithSubscriptions {
12293
12698
  );
12294
12699
  }
12295
12700
  /**
12296
- * Adds a reaction to a reply with an optimistic local state update, mirroring
12297
- * {@link Channel.addReactionWithLocalUpdate}. The optimistic message is applied to THIS thread's
12298
- * paginator (so pure replies get optimism the channel paginator can't give); the request routes
12299
- * through the parent channel since reactions are channel-level.
12701
+ * Adds a reaction to a reply with an optimistic local state update — see
12702
+ * {@link addReactionOptimistically}, which `Channel` shares. The request routes through the parent
12703
+ * channel because reactions are channel-level, while the local write is addressed by message id and
12704
+ * so reaches a pure reply no channel collection holds.
12300
12705
  */
12301
12706
  async addReactionWithLocalUpdate({
12302
12707
  messageId,
12303
12708
  reaction,
12304
12709
  options
12305
12710
  }) {
12306
- const client = this.channel.getClient();
12307
- const undo = applyReactionLocally(client, {
12308
- enforceUnique: options?.enforce_unique ?? false,
12711
+ await addReactionOptimistically({
12712
+ channel: this.channel,
12309
12713
  messageId,
12714
+ options,
12310
12715
  reaction
12311
12716
  });
12312
- try {
12313
- const response = await this.channel.sendReaction({
12314
- id: messageId,
12315
- reaction,
12316
- ...options
12317
- });
12318
- if (response?.message && client.messageStore.has(response.message.id)) {
12319
- client.messageStore.upsert(formatMessage(response.message));
12320
- }
12321
- } catch (error) {
12322
- if (undo && (!client.offlineDb || !isEphemeral(error))) {
12323
- undo();
12324
- }
12325
- throw error;
12326
- }
12327
12717
  }
12328
12718
  /**
12329
- * Removes the current user's reaction from a reply with an optimistic local state update,
12330
- * mirroring {@link Thread.addReactionWithLocalUpdate}.
12719
+ * Removes the current user's reaction from a reply with an optimistic local state update — see
12720
+ * {@link deleteReactionOptimistically}, which `Channel` shares.
12331
12721
  */
12332
12722
  async deleteReactionWithLocalUpdate({
12333
12723
  messageId,
12334
12724
  type
12335
12725
  }) {
12336
- const client = this.channel.getClient();
12337
- const undo = applyReactionLocally(client, {
12338
- messageId,
12339
- reaction: { type },
12340
- removed: true
12341
- });
12342
- try {
12343
- const response = await this.channel.deleteReaction({ id: messageId, type });
12344
- if (response?.message && client.messageStore.has(response.message.id)) {
12345
- client.messageStore.upsert(formatMessage(response.message));
12346
- }
12347
- } catch (error) {
12348
- if (undo && (!client.offlineDb || !isEphemeral(error))) {
12349
- undo();
12350
- }
12351
- throw error;
12352
- }
12726
+ await deleteReactionOptimistically({ channel: this.channel, messageId, type });
12353
12727
  }
12354
12728
  };
12355
12729
  var normalizeThreadParticipants = (participants, channelCid) => {
@@ -12432,7 +12806,7 @@ var initState6 = (composition) => {
12432
12806
  editedMessage
12433
12807
  };
12434
12808
  };
12435
- var logger4 = chatLoggerSystem.getLogger("message-composer");
12809
+ var logger5 = chatLoggerSystem.getLogger("message-composer");
12436
12810
  var offlineDbLogger = chatLoggerSystem.getLogger("offline-db");
12437
12811
  var _MessageComposer = class _MessageComposer extends WithSubscriptions {
12438
12812
  // todo: mediaRecorder: MediaRecorderController;
@@ -12915,7 +13289,7 @@ var _MessageComposer = class _MessageComposer extends WithSubscriptions {
12915
13289
  );
12916
13290
  this.initState({ composition: draft });
12917
13291
  } catch (error) {
12918
- logger4.withExtraTags("getDraft", this.channel.cid).error("Retrieving the draft from the server failed.", { error });
13292
+ logger5.withExtraTags("getDraft", this.channel.cid).error("Retrieving the draft from the server failed.", { error });
12919
13293
  }
12920
13294
  };
12921
13295
  this.createPoll = async () => {
@@ -14249,8 +14623,7 @@ var ChannelApi = class {
14249
14623
  };
14250
14624
 
14251
14625
  // src/channel.ts
14252
- var logger5 = chatLoggerSystem.getLogger("channel");
14253
- var offlineDbLogger2 = chatLoggerSystem.getLogger("offline-db");
14626
+ var logger6 = chatLoggerSystem.getLogger("channel");
14254
14627
  var ownDeclarativeConfig = (slice) => {
14255
14628
  if (!slice) return void 0;
14256
14629
  const {
@@ -14390,11 +14763,31 @@ var Channel = class extends ChannelApi {
14390
14763
  this.cooldownTimer = new CooldownTimer({ channel: this });
14391
14764
  this.cooldownTimer.registerSubscriptions();
14392
14765
  this.messageOperations = new MessageOperations({
14766
+ ...createMessageOperationsPersistence({ channel: this }),
14393
14767
  ingest: (m) => {
14394
- this.messagePaginator.ingestItem(m);
14395
- this.getClient().messageStore.flushSubscribers(m.id);
14768
+ const store = this.getClient().messageStore;
14769
+ if (this.messagePaginator.matchesFilter(m)) {
14770
+ this.messagePaginator.ingestItem(m);
14771
+ } else if (store.has(m.id)) {
14772
+ store.upsert(m);
14773
+ }
14774
+ store.flushSubscribers(m.id);
14775
+ },
14776
+ // Mirrors `ingest`'s routing: a message this paginator does not hold can still be held by the
14777
+ // client-global store (a thread parent, a message displayed by another collection), and the
14778
+ // policy uses this both for its freshness comparison and to decide whether there is anything to
14779
+ // update optimistically at all. Reading only the paginator would make those two disagree.
14780
+ get: (id2) => this.messagePaginator.getItem(id2) ?? this.getClient().messageStore.get(id2),
14781
+ remove: (id2) => {
14782
+ const parentId = this.messagePaginator.getItem(id2)?.parent_id ?? this.getClient().messageStore.get(id2)?.parent_id;
14783
+ this.messagePaginator.removeItem({ id: id2 });
14784
+ this.pinnedMessagesPaginator.removeItem({ id: id2 });
14785
+ if (parentId) {
14786
+ this.getClient().threads.threadsById[parentId]?.messagePaginator.removeItem({
14787
+ id: id2
14788
+ });
14789
+ }
14396
14790
  },
14397
- get: (id2) => this.messagePaginator.getItem(id2),
14398
14791
  handlers: () => {
14399
14792
  const { requestHandlers } = this.configState.getLatestValue();
14400
14793
  const deleteMessageRequest = requestHandlers?.deleteMessageRequest;
@@ -14600,24 +14993,20 @@ var Channel = class extends ChannelApi {
14600
14993
  */
14601
14994
  async sendMessage(...args) {
14602
14995
  const [request] = args;
14603
- try {
14604
- const offlineDb = this.getClient().offlineDb;
14605
- const messageId = request.message?.id;
14606
- if (offlineDb && messageId) {
14607
- return await offlineDb.queueTask({
14608
- task: {
14609
- channelId: this.id,
14610
- channelType: this.type,
14611
- messageId,
14612
- payload: args,
14613
- type: "send-message"
14614
- }
14615
- });
14996
+ const messageId = request.message?.id;
14997
+ return await queueOrRun({
14998
+ channel: this,
14999
+ client: this.getClient(),
15000
+ // Nothing to key a queue entry on without a message id, so it runs but is not queued.
15001
+ queue: !!messageId,
15002
+ task: {
15003
+ channelId: this.id,
15004
+ channelType: this.type,
15005
+ messageId,
15006
+ payload: args,
15007
+ type: "send-message"
14616
15008
  }
14617
- } catch (error) {
14618
- offlineDbLogger2.withExtraTags("sendMessage", this.cid).error("Sending the message failed.", { error });
14619
- }
14620
- return await this._sendMessage(...args);
15009
+ });
14621
15010
  }
14622
15011
  /**
14623
15012
  * Sends a message with optimistic local state update.
@@ -14670,59 +15059,24 @@ var Channel = class extends ChannelApi {
14670
15059
  );
14671
15060
  }
14672
15061
  /**
14673
- * Adds a reaction with an optimistic local state update: the reaction is applied to the cached
14674
- * message immediately ({@link applyReactionLocally}), then the request is
14675
- * fired via {@link Channel.sendReaction} (which owns the offline-DB write + queue). The
14676
- * server-authoritative counts reconcile on the response; the message is rolled back on failure.
15062
+ * Adds a reaction with an optimistic local state update - see {@link addReactionOptimistically}.
14677
15063
  */
14678
15064
  async addReactionWithLocalUpdate({
14679
15065
  messageId,
14680
15066
  reaction,
14681
15067
  options
14682
15068
  }) {
14683
- const client = this.getClient();
14684
- const undo = applyReactionLocally(client, {
14685
- enforceUnique: options?.enforce_unique ?? false,
14686
- messageId,
14687
- reaction
14688
- });
14689
- try {
14690
- const response = await this.sendReaction({ id: messageId, reaction, ...options });
14691
- if (response?.message && client.messageStore.has(response.message.id)) {
14692
- client.messageStore.upsert(formatMessage(response.message));
14693
- }
14694
- } catch (error) {
14695
- if (undo && (!client.offlineDb || !isEphemeral(error))) {
14696
- undo();
14697
- }
14698
- throw error;
14699
- }
15069
+ await addReactionOptimistically({ channel: this, messageId, options, reaction });
14700
15070
  }
14701
15071
  /**
14702
- * Removes the current user's reaction with an optimistic local state update, mirroring
14703
- * {@link Channel.addReactionWithLocalUpdate}.
15072
+ * Removes the current user's reaction with an optimistic local state update - see
15073
+ * {@link deleteReactionOptimistically}.
14704
15074
  */
14705
15075
  async deleteReactionWithLocalUpdate({
14706
15076
  messageId,
14707
15077
  type
14708
15078
  }) {
14709
- const client = this.getClient();
14710
- const undo = applyReactionLocally(client, {
14711
- messageId,
14712
- reaction: { type },
14713
- removed: true
14714
- });
14715
- try {
14716
- const response = await this.deleteReaction({ id: messageId, type });
14717
- if (response?.message && client.messageStore.has(response.message.id)) {
14718
- client.messageStore.upsert(formatMessage(response.message));
14719
- }
14720
- } catch (error) {
14721
- if (undo && (!client.offlineDb || !isEphemeral(error))) {
14722
- undo();
14723
- }
14724
- throw error;
14725
- }
15079
+ await deleteReactionOptimistically({ channel: this, messageId, type });
14726
15080
  }
14727
15081
  /**
14728
15082
  * Upload a file to this channel's file endpoint (multipart).
@@ -14817,23 +15171,17 @@ var Channel = class extends ChannelApi {
14817
15171
  */
14818
15172
  async sendReaction(...args) {
14819
15173
  const [{ id: messageId }] = args;
14820
- try {
14821
- const offlineDb = this.getClient().offlineDb;
14822
- if (offlineDb) {
14823
- return await offlineDb.queueTask({
14824
- task: {
14825
- channelId: this.id,
14826
- channelType: this.type,
14827
- messageId,
14828
- payload: args,
14829
- type: "send-reaction"
14830
- }
14831
- });
15174
+ return await queueOrRun({
15175
+ channel: this,
15176
+ client: this.getClient(),
15177
+ task: {
15178
+ channelId: this.id,
15179
+ channelType: this.type,
15180
+ messageId,
15181
+ payload: args,
15182
+ type: "send-reaction"
14832
15183
  }
14833
- } catch (error) {
14834
- offlineDbLogger2.withExtraTags("sendReaction", this.cid).error("Sending the reaction failed.", { error });
14835
- }
14836
- return this._sendReaction(...args);
15184
+ });
14837
15185
  }
14838
15186
  _sendReaction(...args) {
14839
15187
  return this.getClient().sendReaction(...args);
@@ -14841,23 +15189,17 @@ var Channel = class extends ChannelApi {
14841
15189
  async deleteReaction(...args) {
14842
15190
  this._checkInitialized();
14843
15191
  const [request] = args;
14844
- try {
14845
- const offlineDb = this.getClient().offlineDb;
14846
- if (offlineDb) {
14847
- return await offlineDb.queueTask({
14848
- task: {
14849
- channelId: this.id,
14850
- channelType: this.type,
14851
- messageId: request.id,
14852
- payload: args,
14853
- type: "delete-reaction"
14854
- }
14855
- });
15192
+ return await queueOrRun({
15193
+ channel: this,
15194
+ client: this.getClient(),
15195
+ task: {
15196
+ channelId: this.id,
15197
+ channelType: this.type,
15198
+ messageId: request.id,
15199
+ payload: args,
15200
+ type: "delete-reaction"
14856
15201
  }
14857
- } catch (error) {
14858
- offlineDbLogger2.withExtraTags("deleteReaction", this.cid).error("Deleting the reaction failed.", { error });
14859
- }
14860
- return await this._deleteReaction(...args);
15202
+ });
14861
15203
  }
14862
15204
  /**
14863
15205
  * Deletes a reaction by user and type.
@@ -15547,7 +15889,7 @@ var Channel = class extends ChannelApi {
15547
15889
  const previousData = this.data;
15548
15890
  this.data = state.channel;
15549
15891
  this.state.syncStateFromChannelData(this.data, previousData);
15550
- logger5.withExtraTags("watch", this.cid).info("Started watching the channel.");
15892
+ logger6.withExtraTags("watch", this.cid).info("Started watching the channel.");
15551
15893
  return state;
15552
15894
  }
15553
15895
  /**
@@ -15559,13 +15901,17 @@ var Channel = class extends ChannelApi {
15559
15901
  * This is intentionally thin: it only re-issues `watch()` with a limit sized to the loaded window
15560
15902
  * (`items.length`, so the whole loaded window is refreshed — not the smaller channel-list page). The
15561
15903
  * actual fold + destructive reconciliation happens inside `query()` → `seedFirstPageSync`
15562
- * (the same path the channel-list re-hydrate and React's `recoverState` use), driven by the loaded-id
15904
+ * (the same path the channel-list re-hydrate and connection recovery use), driven by the loaded-id
15563
15905
  * snapshot `query()` captures before its await. Owning that single path is what lets the SDK stop
15564
15906
  * passing the reconciliation window/snapshot itself.
15565
15907
  *
15566
15908
  * Preserves failed (unsent) messages: an overlap merge keeps them (the reconcile's provenance guard
15567
15909
  * never prunes a non-server message); only a disjoint rebuild can drop them, so any that actually
15568
- * fell out are re-ingested below.
15910
+ * fell out are re-ingested below. The offline DB is consulted alongside the in-memory window, because
15911
+ * a failed message can be absent from memory and still be the user's unsent work — a cold boot starts
15912
+ * with an empty paginator, and an eviction can drop it mid-session. v9 got this from a second, lagging
15913
+ * copy of the list (the React SDK's own state); with one reactive source of truth the persisted row is
15914
+ * that buffer.
15569
15915
  */
15570
15916
  async reload() {
15571
15917
  if (this._reloading || !this.initialized && !this.offlineMode) return;
@@ -15573,18 +15919,28 @@ var Channel = class extends ChannelApi {
15573
15919
  try {
15574
15920
  const paginator = this.messagePaginator;
15575
15921
  const headItems = paginator.headItems;
15576
- const requestedLimit = headItems.length || paginator.pageSize;
15577
- const failedBefore = headItems.filter((message) => message.status === "failed");
15922
+ const requestedLimit = Math.max(headItems.length, paginator.pageSize ?? 0) || void 0;
15923
+ const offlineDb = this.getClient().offlineDb;
15924
+ const failedInMemory = headItems.filter((message) => message.status === "failed");
15925
+ const failedInDb = offlineDb ? await offlineDb.getFailedMessages({ cid: this.cid }) : [];
15926
+ const failedById = /* @__PURE__ */ new Map();
15927
+ for (const message of [...failedInDb, ...failedInMemory]) {
15928
+ failedById.set(message.id, message);
15929
+ }
15930
+ const failedBefore = [...failedById.values()];
15578
15931
  await this.watch({ messages: { limit: requestedLimit } });
15579
15932
  this.offlineMode = false;
15580
- paginator.batch(
15581
- () => {
15582
- for (const failed of failedBefore) {
15583
- if (!paginator.getItem(failed.id)) paginator.ingestItem(failed);
15584
- }
15585
- },
15586
- { coalesce: true }
15587
- );
15933
+ if (failedBefore.length) {
15934
+ const visible = new Set((paginator.items ?? []).map((message) => message.id));
15935
+ paginator.batch(
15936
+ () => {
15937
+ for (const failed of failedBefore) {
15938
+ if (!visible.has(failed.id)) paginator.ingestItem(failed);
15939
+ }
15940
+ },
15941
+ { coalesce: true }
15942
+ );
15943
+ }
15588
15944
  } finally {
15589
15945
  this._reloading = false;
15590
15946
  }
@@ -15600,7 +15956,7 @@ var Channel = class extends ChannelApi {
15600
15956
  async stopWatching(...args) {
15601
15957
  const response = await super.stopWatching(...args);
15602
15958
  this.watchStatus = ChannelWatchStatus.NotWatching;
15603
- logger5.withExtraTags("stopWatching", this.cid).info("Stopped watching the channel.");
15959
+ logger6.withExtraTags("stopWatching", this.cid).info("Stopped watching the channel.");
15604
15960
  return response;
15605
15961
  }
15606
15962
  /**
@@ -15848,23 +16204,17 @@ var Channel = class extends ChannelApi {
15848
16204
  */
15849
16205
  async createDraft(...args) {
15850
16206
  const [request] = args;
15851
- try {
15852
- const offlineDb = this.getClient().offlineDb;
15853
- if (offlineDb) {
15854
- return await offlineDb.queueTask({
15855
- task: {
15856
- channelId: this.id,
15857
- channelType: this.type,
15858
- threadId: request.message?.parent_id,
15859
- payload: args,
15860
- type: "create-draft"
15861
- }
15862
- });
16207
+ return await queueOrRun({
16208
+ channel: this,
16209
+ client: this.getClient(),
16210
+ task: {
16211
+ channelId: this.id,
16212
+ channelType: this.type,
16213
+ threadId: request.message?.parent_id,
16214
+ payload: args,
16215
+ type: "create-draft"
15863
16216
  }
15864
- } catch (error) {
15865
- offlineDbLogger2.withExtraTags("createDraft", this.cid).error("Creating the draft in the offline database failed.", { error });
15866
- }
15867
- return this._createDraft(...args);
16217
+ });
15868
16218
  }
15869
16219
  async _deleteDraft(...args) {
15870
16220
  return await super.deleteDraft(...args);
@@ -15875,38 +16225,30 @@ var Channel = class extends ChannelApi {
15875
16225
  */
15876
16226
  async deleteDraft(...args) {
15877
16227
  const [request] = args;
15878
- try {
15879
- const offlineDb = this.getClient().offlineDb;
15880
- if (offlineDb) {
15881
- return await offlineDb.queueTask(
15882
- {
15883
- task: {
15884
- channelId: this.id,
15885
- channelType: this.type,
15886
- threadId: request?.parent_id,
15887
- payload: args,
15888
- type: "delete-draft"
15889
- }
15890
- }
15891
- );
16228
+ return await queueOrRun({
16229
+ channel: this,
16230
+ client: this.getClient(),
16231
+ task: {
16232
+ channelId: this.id,
16233
+ channelType: this.type,
16234
+ threadId: request?.parent_id,
16235
+ payload: args,
16236
+ type: "delete-draft"
15892
16237
  }
15893
- } catch (error) {
15894
- offlineDbLogger2.withExtraTags("deleteDraft", this.cid).error("Deleting the draft from the offline database failed.", { error });
15895
- }
15896
- return this._deleteDraft(...args);
16238
+ });
15897
16239
  }
15898
16240
  on(callbackOrString, callbackOrNothing) {
15899
16241
  const key = callbackOrNothing ? callbackOrString : "all";
15900
16242
  const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
15901
16243
  const set = this.listeners.get(key) ?? /* @__PURE__ */ new Set();
15902
- logger5.withExtraTags("on", this.cid).debug(`Attaching a listener for the "${key}" event.`);
16244
+ logger6.withExtraTags("on", this.cid).debug(`Attaching a listener for the "${key}" event.`);
15903
16245
  set.add(callback);
15904
16246
  if (!this.listeners.has(key)) {
15905
16247
  this.listeners.set(key, set);
15906
16248
  }
15907
16249
  return {
15908
16250
  unsubscribe: () => {
15909
- logger5.withExtraTags("on", this.cid).debug(`Removing the listener for the "${key}" event.`);
16251
+ logger6.withExtraTags("on", this.cid).debug(`Removing the listener for the "${key}" event.`);
15910
16252
  set.delete(callback);
15911
16253
  if (!set.size) {
15912
16254
  this.listeners.delete(key);
@@ -15917,7 +16259,7 @@ var Channel = class extends ChannelApi {
15917
16259
  off(callbackOrString, callbackOrNothing) {
15918
16260
  const key = callbackOrNothing ? callbackOrString : "all";
15919
16261
  const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
15920
- logger5.withExtraTags("off", this.cid).debug(`Removing the listener for the "${key}" event.`);
16262
+ logger6.withExtraTags("off", this.cid).debug(`Removing the listener for the "${key}" event.`);
15921
16263
  const set = this.listeners.get(key);
15922
16264
  set?.delete(callback);
15923
16265
  if (!set?.size) {
@@ -15961,23 +16303,25 @@ var Channel = class extends ChannelApi {
15961
16303
  * `state.unreadCount` derives from — so channel-wide resets (`channel.truncated`, "all channels
15962
16304
  * read") must route through here rather than writing a count of their own.
15963
16305
  */
16306
+ /** @returns whether the count actually changed, so callers can react only to the channels it did. */
15964
16307
  _setOwnUnreadCount(unreadCount) {
15965
- if (this.pendingDisposal) return;
16308
+ if (this.pendingDisposal) return false;
15966
16309
  const userId = this.getClient().userId;
15967
- if (!userId) return;
16310
+ if (!userId) return false;
15968
16311
  const currentUserReadState = this.state.read[userId];
15969
16312
  if (!currentUserReadState || currentUserReadState.unread_messages === unreadCount) {
15970
- return;
16313
+ return false;
15971
16314
  }
15972
16315
  this._upsertReadState(
15973
16316
  userId,
15974
16317
  () => ({ ...currentUserReadState, unread_messages: unreadCount }),
15975
16318
  { changedUserIds: [userId] }
15976
16319
  );
16320
+ return true;
15977
16321
  }
15978
16322
  _handleChannelEvent(event) {
15979
16323
  const channel = this;
15980
- logger5.withExtraTags("_handleChannelEvent", this.cid).debug(`Received an event of type "${event.type}".`, { event });
16324
+ logger6.withExtraTags("_handleChannelEvent", this.cid).debug(`Received an event of type "${event.type}".`, { event });
15981
16325
  const channelState = channel.state;
15982
16326
  switch (event.type) {
15983
16327
  case "typing.start":
@@ -16001,13 +16345,14 @@ var Channel = class extends ChannelApi {
16001
16345
  case "ai_indicator.stop":
16002
16346
  channelState.partialNext({ aiState: AIStates.Stop });
16003
16347
  break;
16004
- // `message.read_locally` is the client-only event dispatched by `markReadLocally()` when read
16005
- // events are disabled (e.g. livestreams with `isLocalUnreadCountEnabled`). It reuses the exact
16006
- // `message.read` state logic so the read-state update lives in one place — only the
16007
- // delivery-report network sync below is skipped for it.
16348
+ // `notification.mark_read` is the only read signal for a channel the user is not watching, since
16349
+ // `message.read` goes to watchers only. `message.read_locally` is the local stand-in when read
16350
+ // events are disabled (livestreams). Both reuse the `message.read` logic, minus its delivery sync.
16351
+ case "notification.mark_read":
16008
16352
  case "message.read_locally":
16009
16353
  case "message.read":
16010
- if (event.user?.id && event.created_at) {
16354
+ if (event.user?.id && event.created_at && // the same event announces a thread read, which says nothing about the channel
16355
+ !(event.type === "notification.mark_read" && event.thread_id)) {
16011
16356
  const eventUser = event.user;
16012
16357
  const readAtDate = new Date(event.created_at);
16013
16358
  const toDate = (value) => value ? value instanceof Date ? value : new Date(value) : void 0;
@@ -16495,7 +16840,7 @@ var Channel = class extends ChannelApi {
16495
16840
  }
16496
16841
  }
16497
16842
  _disconnect() {
16498
- logger5.withExtraTags("_disconnect", this.cid).info("Disconnecting the channel.");
16843
+ logger6.withExtraTags("_disconnect", this.cid).info("Disconnecting the channel.");
16499
16844
  this.unsubscribeConfiguration?.();
16500
16845
  this.unsubscribeConfiguration = void 0;
16501
16846
  this.unsubscribeServerConfig?.();
@@ -18614,7 +18959,7 @@ var decodeWSEvent = (data) => {
18614
18959
  };
18615
18960
 
18616
18961
  // src/connection.ts
18617
- var logger6 = chatLoggerSystem.getLogger("connection");
18962
+ var logger7 = chatLoggerSystem.getLogger("connection");
18618
18963
  var decodeConnectionEvent = (data) => data.type === "connection.ok" ? decoders.HealthCheckEvent(data) : decodeWSEvent(data);
18619
18964
  var isCloseEvent = (res) => res.code !== void 0;
18620
18965
  var isErrorEvent = (res) => res.error !== void 0;
@@ -18645,10 +18990,10 @@ var StableWSConnection = class {
18645
18990
  */
18646
18991
  this.onlineStatusChanged = (event) => {
18647
18992
  if (event.type === "offline") {
18648
- logger6.withExtraTags("onlineStatusChanged").info("Network status changed to offline.");
18993
+ logger7.withExtraTags("onlineStatusChanged").info("Network status changed to offline.");
18649
18994
  this._setHealth(false);
18650
18995
  } else if (event.type === "online") {
18651
- logger6.withExtraTags("onlineStatusChanged").info(`Network status changed to online. isHealthy: ${this.isHealthy}.`);
18996
+ logger7.withExtraTags("onlineStatusChanged").info(`Network status changed to online. isHealthy: ${this.isHealthy}.`);
18652
18997
  if (!this.isHealthy) {
18653
18998
  this._reconnect({ interval: 10 });
18654
18999
  }
@@ -18656,7 +19001,7 @@ var StableWSConnection = class {
18656
19001
  };
18657
19002
  this.onopen = (wsId, authMessage) => {
18658
19003
  if (this.wsID !== wsId) return;
18659
- logger6.withExtraTags("onopen").debug("WebSocket onopen callback fired.", {
19004
+ logger7.withExtraTags("onopen").debug("WebSocket onopen callback fired.", {
18660
19005
  wsID: wsId
18661
19006
  });
18662
19007
  const { ws } = this;
@@ -18664,14 +19009,14 @@ var StableWSConnection = class {
18664
19009
  try {
18665
19010
  ws.send(authMessage);
18666
19011
  } catch (error) {
18667
- logger6.withExtraTags("onopen").error("Failed to send the WebSocket auth message.", { error });
19012
+ logger7.withExtraTags("onopen").error("Failed to send the WebSocket auth message.", { error });
18668
19013
  this.rejectPromise?.(this._errorFromWSEvent(error));
18669
19014
  this._destroyCurrentWSConnection();
18670
19015
  }
18671
19016
  };
18672
19017
  this.onmessage = (wsId, event) => {
18673
19018
  if (this.wsID !== wsId) return;
18674
- logger6.withExtraTags("onmessage").trace("WebSocket onmessage callback fired.", {
19019
+ logger7.withExtraTags("onmessage").trace("WebSocket onmessage callback fired.", {
18675
19020
  event,
18676
19021
  wsID: wsId
18677
19022
  });
@@ -18696,7 +19041,7 @@ var StableWSConnection = class {
18696
19041
  };
18697
19042
  this.onclose = (wsId, event) => {
18698
19043
  if (this.wsID !== wsId) return;
18699
- logger6.withExtraTags("onclose").debug(`WebSocket onclose callback fired with code ${event.code}.`, {
19044
+ logger7.withExtraTags("onclose").debug(`WebSocket onclose callback fired with code ${event.code}.`, {
18700
19045
  event,
18701
19046
  wsID: wsId
18702
19047
  });
@@ -18707,14 +19052,14 @@ var StableWSConnection = class {
18707
19052
  error.wasClean = event.wasClean;
18708
19053
  error.target = event.target;
18709
19054
  this.rejectPromise?.(error);
18710
- logger6.withExtraTags("onclose").warn(`The WebSocket connection was rejected: ${event.reason}.`, { event });
19055
+ logger7.withExtraTags("onclose").warn(`The WebSocket connection was rejected: ${event.reason}.`, { event });
18711
19056
  } else {
18712
19057
  this.consecutiveFailures += 1;
18713
19058
  this.totalFailures += 1;
18714
19059
  this._setHealth(false);
18715
19060
  this.isConnecting = false;
18716
19061
  this.rejectPromise?.(this._errorFromWSEvent(event));
18717
- logger6.withExtraTags("onclose").warn("The WebSocket connection was closed. Attempting to reconnect.", {
19062
+ logger7.withExtraTags("onclose").warn("The WebSocket connection was closed. Attempting to reconnect.", {
18718
19063
  event
18719
19064
  });
18720
19065
  this._reconnect();
@@ -18727,7 +19072,7 @@ var StableWSConnection = class {
18727
19072
  this._setHealth(false);
18728
19073
  this.isConnecting = false;
18729
19074
  this.rejectPromise?.(this._errorFromWSEvent(event));
18730
- logger6.withExtraTags("onerror").warn("The WebSocket connection raised an error.", { event });
19075
+ logger7.withExtraTags("onerror").warn("The WebSocket connection raised an error.", { event });
18731
19076
  this._reconnect();
18732
19077
  };
18733
19078
  /**
@@ -18769,7 +19114,7 @@ var StableWSConnection = class {
18769
19114
  statusCode = event.error.StatusCode;
18770
19115
  message = event.error.message;
18771
19116
  }
18772
- logger6.withExtraTags("_errorFromWSEvent").warn(`The WebSocket failed with code ${code}.`, { event });
19117
+ logger7.withExtraTags("_errorFromWSEvent").warn(`The WebSocket failed with code ${code}.`, { event });
18773
19118
  const error = new Error(
18774
19119
  `WS failed with code ${code} and reason - ${message}`
18775
19120
  );
@@ -18815,7 +19160,7 @@ var StableWSConnection = class {
18815
19160
  this.connectionCheckTimeoutRef = setTimeout(() => {
18816
19161
  const now = /* @__PURE__ */ new Date();
18817
19162
  if (this.lastEvent && now.getTime() - this.lastEvent.getTime() > this.connectionCheckTimeout) {
18818
- logger6.withExtraTags("scheduleConnectionCheck").warn("No events received within the health-check window. Reconnecting.");
19163
+ logger7.withExtraTags("scheduleConnectionCheck").warn("No events received within the health-check window. Reconnecting.");
18819
19164
  this._setHealth(false);
18820
19165
  this._reconnect();
18821
19166
  }
@@ -18853,13 +19198,13 @@ var StableWSConnection = class {
18853
19198
  try {
18854
19199
  const healthCheck = await this._connect();
18855
19200
  this.consecutiveFailures = 0;
18856
- logger6.withExtraTags("connect").info(`Established a WebSocket connection. Health check: ${healthCheck}.`);
19201
+ logger7.withExtraTags("connect").info(`Established a WebSocket connection. Health check: ${healthCheck}.`);
18857
19202
  } catch (error) {
18858
19203
  this.isHealthy = false;
18859
19204
  this.consecutiveFailures += 1;
18860
19205
  const e = error;
18861
19206
  if (e.code === chatCodes.TOKEN_EXPIRED && !this.client.tokenManager.isStatic()) {
18862
- logger6.withExtraTags("connect").warn(
19207
+ logger7.withExtraTags("connect").warn(
18863
19208
  "WebSocket connection failed due to an expired token. Reloading the token and reconnecting."
18864
19209
  );
18865
19210
  this._reconnect({ refreshToken: true });
@@ -18924,7 +19269,7 @@ var StableWSConnection = class {
18924
19269
  * @param timeout - Optional timeout in milliseconds to wait for the close frame from the server.
18925
19270
  */
18926
19271
  disconnect(timeout) {
18927
- logger6.withExtraTags("disconnect").info(`Closing the WebSocket connection for wsID ${this.wsID}.`);
19272
+ logger7.withExtraTags("disconnect").info(`Closing the WebSocket connection for wsID ${this.wsID}.`);
18928
19273
  this.wsID += 1;
18929
19274
  this.isConnecting = false;
18930
19275
  this.isDisconnected = true;
@@ -18941,7 +19286,7 @@ var StableWSConnection = class {
18941
19286
  if (ws && ws.close && ws.readyState === ws.OPEN) {
18942
19287
  isClosedPromise = new Promise((resolve) => {
18943
19288
  const onclose = (event) => {
18944
- logger6.withExtraTags("disconnect").debug(
19289
+ logger7.withExtraTags("disconnect").debug(
18945
19290
  `Resolving the close promise ${event ? "with" : "without"} a close frame.`,
18946
19291
  { event }
18947
19292
  );
@@ -18950,13 +19295,13 @@ var StableWSConnection = class {
18950
19295
  ws.onclose = onclose;
18951
19296
  setTimeout(onclose, timeout != null ? timeout : 1e3);
18952
19297
  });
18953
- logger6.withExtraTags("disconnect").debug("Manually closing the connection via client.disconnect().");
19298
+ logger7.withExtraTags("disconnect").debug("Manually closing the connection via client.disconnect().");
18954
19299
  ws.close(
18955
19300
  chatCodes.WS_CLOSED_SUCCESS,
18956
19301
  "Manually closed connection by calling client.disconnect()"
18957
19302
  );
18958
19303
  } else {
18959
- logger6.withExtraTags("disconnect").debug("The WebSocket connection does not exist or is already closed.");
19304
+ logger7.withExtraTags("disconnect").debug("The WebSocket connection does not exist or is already closed.");
18960
19305
  isClosedPromise = Promise.resolve();
18961
19306
  }
18962
19307
  delete this.ws;
@@ -18974,20 +19319,20 @@ var StableWSConnection = class {
18974
19319
  this.client.insightMetrics.connectionStartTimestamp = (/* @__PURE__ */ new Date()).getTime();
18975
19320
  let isTokenReady = false;
18976
19321
  try {
18977
- logger6.withExtraTags("_connect").debug("Waiting for the auth token.");
19322
+ logger7.withExtraTags("_connect").debug("Waiting for the auth token.");
18978
19323
  await this.client.tokenManager.tokenReady();
18979
19324
  isTokenReady = true;
18980
19325
  } catch (e) {
18981
19326
  }
18982
19327
  try {
18983
19328
  if (!isTokenReady) {
18984
- logger6.withExtraTags("_connect").warn("The token provider failed previously. Retrying.");
19329
+ logger7.withExtraTags("_connect").warn("The token provider failed previously. Retrying.");
18985
19330
  await this.client.tokenManager.loadToken();
18986
19331
  }
18987
19332
  this._setupConnectionPromise();
18988
19333
  const wsURL = this._buildUrl();
18989
19334
  const authMessage = this.client._buildWSAuthMessage();
18990
- logger6.withExtraTags("_connect").info(`Connecting to ${wsURL}.`, {
19335
+ logger7.withExtraTags("_connect").info(`Connecting to ${wsURL}.`, {
18991
19336
  wsURL,
18992
19337
  requestID: this.requestID
18993
19338
  });
@@ -19012,7 +19357,7 @@ var StableWSConnection = class {
19012
19357
  }
19013
19358
  } catch (error) {
19014
19359
  this.isConnecting = false;
19015
- logger6.withExtraTags("_connect").warn("An error occurred while connecting.", { error });
19360
+ logger7.withExtraTags("_connect").warn("An error occurred while connecting.", { error });
19016
19361
  if (this.client.options.enableInsights) {
19017
19362
  this.client.insightMetrics.wsConsecutiveFailures++;
19018
19363
  this.client.insightMetrics.wsTotalFailures++;
@@ -19033,9 +19378,9 @@ var StableWSConnection = class {
19033
19378
  * @param options.refreshToken - Reload/refresh the user token before attempting to reconnect.
19034
19379
  */
19035
19380
  async _reconnect(options = {}) {
19036
- logger6.withExtraTags("_reconnect").info("Initiating a reconnect.");
19381
+ logger7.withExtraTags("_reconnect").info("Initiating a reconnect.");
19037
19382
  if (this.isConnecting || this.isHealthy) {
19038
- logger6.withExtraTags("_reconnect").debug("Aborting reconnect: already connecting or healthy (check 1).");
19383
+ logger7.withExtraTags("_reconnect").debug("Aborting reconnect: already connecting or healthy (check 1).");
19039
19384
  return;
19040
19385
  }
19041
19386
  let interval = options.interval;
@@ -19044,39 +19389,37 @@ var StableWSConnection = class {
19044
19389
  }
19045
19390
  await sleep(interval);
19046
19391
  if (this.isConnecting || this.isHealthy) {
19047
- logger6.withExtraTags("_reconnect").debug("Aborting reconnect: already connecting or healthy (check 2).");
19392
+ logger7.withExtraTags("_reconnect").debug("Aborting reconnect: already connecting or healthy (check 2).");
19048
19393
  return;
19049
19394
  }
19050
19395
  if (this.isDisconnected) {
19051
- logger6.withExtraTags("_reconnect").debug("Aborting reconnect: disconnect() was called.");
19396
+ logger7.withExtraTags("_reconnect").debug("Aborting reconnect: disconnect() was called.");
19052
19397
  return;
19053
19398
  }
19054
- logger6.withExtraTags("_reconnect").info("Destroying the current WebSocket connection.");
19399
+ logger7.withExtraTags("_reconnect").info("Destroying the current WebSocket connection.");
19055
19400
  this._destroyCurrentWSConnection();
19056
19401
  if (options.refreshToken) {
19057
19402
  await this.client.tokenManager.loadToken();
19058
19403
  }
19059
19404
  try {
19060
19405
  await this._connect();
19061
- logger6.withExtraTags("_reconnect").debug("Waiting for the recover callback.");
19062
- await this.client.recoverState();
19063
- logger6.withExtraTags("_reconnect").debug("Finished the recover callback.");
19406
+ this.client._settleConnectPromises();
19064
19407
  this.consecutiveFailures = 0;
19065
19408
  } catch (error) {
19066
19409
  this.isHealthy = false;
19067
19410
  this.consecutiveFailures += 1;
19068
19411
  if (error.code === chatCodes.TOKEN_EXPIRED && !this.client.tokenManager.isStatic()) {
19069
- logger6.withExtraTags("_reconnect").warn(
19412
+ logger7.withExtraTags("_reconnect").warn(
19070
19413
  "WebSocket connection failed due to an expired token. Reloading the token and reconnecting."
19071
19414
  );
19072
19415
  return this._reconnect({ refreshToken: true });
19073
19416
  }
19074
19417
  if (error.isWSFailure) {
19075
- logger6.withExtraTags("_reconnect").warn("WebSocket connection failed. Retrying the reconnect.");
19418
+ logger7.withExtraTags("_reconnect").warn("WebSocket connection failed. Retrying the reconnect.");
19076
19419
  this._reconnect();
19077
19420
  }
19078
19421
  }
19079
- logger6.withExtraTags("_reconnect").debug("Reconnect attempt finished.");
19422
+ logger7.withExtraTags("_reconnect").debug("Reconnect attempt finished.");
19080
19423
  }
19081
19424
  /**
19082
19425
  * Removes the current WS connection.
@@ -19091,7 +19434,7 @@ var StableWSConnection = class {
19091
19434
  };
19092
19435
 
19093
19436
  // src/uploadManager.ts
19094
- var logger7 = chatLoggerSystem.getLogger("upload-manager");
19437
+ var logger8 = chatLoggerSystem.getLogger("upload-manager");
19095
19438
  var initState7 = () => ({ uploads: {} });
19096
19439
  var upsertById = (uploads, record) => ({
19097
19440
  ...uploads,
@@ -19181,7 +19524,7 @@ var UploadManager = class {
19181
19524
  resolvePromise(response);
19182
19525
  } catch (error) {
19183
19526
  if (!abortController.signal.aborted) {
19184
- logger7.withExtraTags("upload", channelCid).error(`Upload "${id}" failed.`, { error });
19527
+ logger8.withExtraTags("upload", channelCid).error(`Upload "${id}" failed.`, { error });
19185
19528
  }
19186
19529
  rejectPromise(error);
19187
19530
  } finally {
@@ -19232,7 +19575,7 @@ function UserFromToken(token) {
19232
19575
  }
19233
19576
 
19234
19577
  // src/token_manager.ts
19235
- var logger8 = chatLoggerSystem.getLogger("token-manager");
19578
+ var logger9 = chatLoggerSystem.getLogger("token-manager");
19236
19579
  var TokenManager = class {
19237
19580
  /**
19238
19581
  * Initializes the token manager.
@@ -19302,7 +19645,7 @@ var TokenManager = class {
19302
19645
  try {
19303
19646
  this.token = await this.tokenProvider();
19304
19647
  } catch (e) {
19305
- logger8.withExtraTags("loadToken").error("The token provider threw an error.", { error: e });
19648
+ logger9.withExtraTags("loadToken").error("The token provider threw an error.", { error: e });
19306
19649
  return reject(
19307
19650
  new Error(`Call to tokenProvider failed with message: ${e}`, { cause: e })
19308
19651
  );
@@ -19382,7 +19725,7 @@ var runWithRetry = (f, {
19382
19725
  };
19383
19726
 
19384
19727
  // src/api-client.ts
19385
- var logger9 = chatLoggerSystem.getLogger("api-client");
19728
+ var logger10 = chatLoggerSystem.getLogger("api-client");
19386
19729
  var MULTIPART_CONTENT_TYPE = "multipart/form-data";
19387
19730
  var UPLOAD_REQUEST_DEFAULTS = {
19388
19731
  timeout: 0,
@@ -19501,7 +19844,7 @@ var ApiClient = class {
19501
19844
  requestResponse = await this.client.axiosInstance.request(config);
19502
19845
  } catch (error) {
19503
19846
  if (isTokenExpiredError(error)) {
19504
- logger9.withExtraTags("_doRequest").debug(
19847
+ logger10.withExtraTags("_doRequest").debug(
19505
19848
  `The token expired on a ${type.toUpperCase()} request. Reloading the token before retrying.`,
19506
19849
  { url, config }
19507
19850
  );
@@ -20144,7 +20487,7 @@ var THREAD_MANAGER_INITIAL_STATE = {
20144
20487
  },
20145
20488
  ready: false
20146
20489
  };
20147
- var logger10 = chatLoggerSystem.getLogger("thread-manager");
20490
+ var logger11 = chatLoggerSystem.getLogger("thread-manager");
20148
20491
  var ThreadManager = class extends WithSubscriptions {
20149
20492
  constructor({ client }) {
20150
20493
  super();
@@ -20294,7 +20637,7 @@ var ThreadManager = class extends WithSubscriptions {
20294
20637
  ready: true
20295
20638
  }));
20296
20639
  } catch (error) {
20297
- logger10.withExtraTags("reload").error("Failed to reload the thread list.", { error });
20640
+ logger11.withExtraTags("reload").error("Failed to reload the thread list.", { error });
20298
20641
  this.state.next((current) => ({
20299
20642
  ...current,
20300
20643
  pagination: {
@@ -20330,7 +20673,7 @@ var ThreadManager = class extends WithSubscriptions {
20330
20673
  }
20331
20674
  }));
20332
20675
  } catch (error) {
20333
- logger10.withExtraTags("loadNextPage").error("Failed to load the next page of threads.", { error });
20676
+ logger11.withExtraTags("loadNextPage").error("Failed to load the next page of threads.", { error });
20334
20677
  this.state.next((current) => ({
20335
20678
  ...current,
20336
20679
  pagination: {
@@ -21070,6 +21413,13 @@ var ignoreEventsForUnknownChannels = ({ event, ctx: { channelManager } }) => {
21070
21413
  );
21071
21414
  if (!channel) return { action: "stop" };
21072
21415
  };
21416
+ var restoreInterruptedWatch = (channel, client) => {
21417
+ if (channel.pendingDisposal) return;
21418
+ if (channel.watchStatus !== ChannelWatchStatus.WasWatching) return;
21419
+ runDetached(getChannel({ channel, client }), {
21420
+ context: `restoreInterruptedWatch(${channel.cid})`
21421
+ });
21422
+ };
21073
21423
  var updateLists = async ({
21074
21424
  event,
21075
21425
  ctx: { channelManager }
@@ -21104,6 +21454,9 @@ var updateLists = async ({
21104
21454
  }
21105
21455
  paginator.ingestItem(channel);
21106
21456
  });
21457
+ if (event.type !== "channel.hidden") {
21458
+ restoreInterruptedWatch(channel, channelManager.client);
21459
+ }
21107
21460
  };
21108
21461
  var channelDeletedHandler = {
21109
21462
  handle: removeItem,
@@ -21133,6 +21486,22 @@ var messageNewHandler = {
21133
21486
  handle: updateLists,
21134
21487
  id: "ChannelManager:default-handler:message.new"
21135
21488
  };
21489
+ var READ_STATE_SORT_FIELDS = ["has_unread", "unread_count"];
21490
+ var dependsOnReadState = (paginator) => paginator.effectiveSort.some(
21491
+ ({ field }) => !!field && READ_STATE_SORT_FIELDS.includes(field)
21492
+ ) || filterConstrainsField(paginator.buildMatchFilters(), "has_unread");
21493
+ var readStateChangedHandler = {
21494
+ handle: ({ event, ctx: { channelManager } }) => {
21495
+ const { client } = channelManager;
21496
+ if (event.type === "message.read" && event.user?.id !== client.userId) return;
21497
+ if (event.thread_id) return;
21498
+ const channel = getCachedChannelFromEvent(event, client.activeChannels);
21499
+ if (!channel) return;
21500
+ if (!channelManager.paginators.some(dependsOnReadState)) return;
21501
+ channelManager.ingestChannel(channel);
21502
+ },
21503
+ id: "ChannelManager:default-handler:read-state-changed"
21504
+ };
21136
21505
  var notificationAddedToChannelHandler = {
21137
21506
  handle: updateLists,
21138
21507
  id: "ChannelManager:default-handler:notification.added_to_channel"
@@ -21210,6 +21579,33 @@ var _ChannelManager = class _ChannelManager extends WithSubscriptions {
21210
21579
  await paginator.reload();
21211
21580
  })
21212
21581
  );
21582
+ /**
21583
+ * Re-run every loaded list's own first-page query — the channel-list half of connection recovery,
21584
+ * driven by {@link ConnectionRecoveryManager}.
21585
+ *
21586
+ * Each list re-asserts its *own* `filters` / `sort` / `pageSize` rather than some substitute query,
21587
+ * and because `client.queryChannels` watches by default, that page's channels are re-watched as a
21588
+ * side effect. Channels below the first page are deliberately not touched — see
21589
+ * `restoreInterruptedWatch` for how they come back.
21590
+ *
21591
+ * `{ keepPreviousItems: true, reset: 'yes' }` is the non-destructive refresh: `reset` restarts the
21592
+ * window at page 1, while `keepPreviousItems` merges the fetched page into the loaded list instead
21593
+ * of replacing it and keeps the item index populated — the latter is what stops a concurrent
21594
+ * offline-replay ingest from rebuilding the list out of a cleared index. Notably NOT `silent`: the
21595
+ * `isLoading` transition it would suppress is the in-flight guard that keeps a second overlapping
21596
+ * query from being issued.
21597
+ *
21598
+ * Distinct from {@link ChannelManager.reload}, which is a destructive `reset` that blanks each list
21599
+ * to its loading state.
21600
+ *
21601
+ * Paginators that were never queried are skipped: they run their own first query when they mount,
21602
+ * and querying them here would race it.
21603
+ */
21604
+ this.recover = async () => await Promise.allSettled(
21605
+ this.paginators.filter((paginator) => paginator.isInitialized).map(async (paginator) => {
21606
+ await paginator.toTail({ keepPreviousItems: true, reset: "yes" });
21607
+ })
21608
+ );
21213
21609
  this.client = client;
21214
21610
  this.state = new StateStore({ paginators: paginators ?? [] });
21215
21611
  this.setOwnershipResolver(ownershipResolver);
@@ -21292,6 +21688,19 @@ var _ChannelManager = class _ChannelManager extends WithSubscriptions {
21292
21688
  }
21293
21689
  });
21294
21690
  }
21691
+ /**
21692
+ * Drops a channel from the lists whose filters it no longer matches, adding it nowhere.
21693
+ *
21694
+ * For state the client changes without an event naming the channel — zeroing every unread count on a
21695
+ * global read. Removal-only: the backend rejects `has_unread: false`, so becoming read can never make
21696
+ * a channel belong to a list, and `ingestChannel` would pull unloaded channels into matching ones.
21697
+ */
21698
+ dropFromUnmatchedLists(channel) {
21699
+ this.paginators.forEach((paginator) => {
21700
+ if (paginator.matchesFilter(channel)) return;
21701
+ paginator.removeItem({ item: channel });
21702
+ });
21703
+ }
21295
21704
  /**
21296
21705
  * Filter a page of query results for a specific paginator according to ownership rules.
21297
21706
  * If no owners are specified by the resolver, all matching paginators keep the item.
@@ -21508,14 +21917,191 @@ _ChannelManager.defaultEventHandlers = {
21508
21917
  "channel.visible": [channelVisibleHandler],
21509
21918
  "member.updated": [memberUpdatedHandler],
21510
21919
  "message.new": [messageNewHandler],
21920
+ "message.read": [readStateChangedHandler],
21921
+ "message.read_locally": [readStateChangedHandler],
21511
21922
  "notification.added_to_channel": [notificationAddedToChannelHandler],
21512
21923
  "notification.channel_mutes_updated": [notificationChannelMutesUpdatedHandler],
21924
+ "notification.mark_read": [readStateChangedHandler],
21925
+ "notification.mark_unread": [readStateChangedHandler],
21513
21926
  "notification.message_new": [notificationMessageNewHandler],
21514
21927
  "notification.removed_from_channel": [notificationRemovedFromChannelHandler],
21515
21928
  "user.presence.changed": [userPresenceChangedHandler]
21516
21929
  };
21517
21930
  var ChannelManager = _ChannelManager;
21518
21931
 
21932
+ // src/ConnectionRecoveryManager.ts
21933
+ var logger12 = chatLoggerSystem.getLogger("client");
21934
+ var DEFAULT_CONNECTION_RECOVERY_MANAGER_CONFIG = deepFreezeConfig({ enabled: true });
21935
+ var ConnectionRecoveryManager = class extends WithSubscriptions {
21936
+ constructor({ client }) {
21937
+ super();
21938
+ /** Set while a recovery is in flight; only used to keep the logs readable. */
21939
+ this.isRecovering = false;
21940
+ this.registerSubscriptions = () => {
21941
+ if (!this.hasSubscriptions) {
21942
+ this.addUnsubscribeFunction(
21943
+ this.client.on("connection.changed", (event) => {
21944
+ if (!event.online) return;
21945
+ runDetached(this.recoverChannelLists(), { context: "recoverChannelLists" });
21946
+ const syncEdgeDrivesActiveChannels = this.ensureSyncStatusSubscription();
21947
+ if (!syncEdgeDrivesActiveChannels) {
21948
+ runDetached(this.recoverActiveChannels(), {
21949
+ context: "recoverActiveChannels"
21950
+ });
21951
+ }
21952
+ }).unsubscribe
21953
+ );
21954
+ this.addUnsubscribeFunction(() => {
21955
+ this.unsubscribeSyncStatus?.();
21956
+ this.unsubscribeSyncStatus = void 0;
21957
+ });
21958
+ }
21959
+ this.incrementRefCount();
21960
+ return () => this.unregisterSubscriptions();
21961
+ };
21962
+ /**
21963
+ * Bind to the offline DB's sync-status edge, if there is one to bind to yet.
21964
+ *
21965
+ * Resolved off `this.client` rather than injected: the DB is already reachable there, it is simply
21966
+ * attached after this manager is constructed (and initialized later still), so the binding has to be
21967
+ * deferred rather than handed in. Doing it from the `connection.changed` handler is safe even when
21968
+ * this manager's listener runs before the sync manager's: `OfflineDBSyncManager` awaits
21969
+ * `syncAndExecutePendingTasks()` before publishing, so the edge cannot land in the same synchronous
21970
+ * dispatch that registers us.
21971
+ *
21972
+ * An offline DB whose `init()` never succeeded publishes no edge, so it must not be treated as the
21973
+ * trigger — hence the `initialized` check, which also means a later successful init is picked up on
21974
+ * the next reconnect.
21975
+ *
21976
+ * @returns whether the sync-status edge is now driving active-channel recovery.
21977
+ */
21978
+ this.ensureSyncStatusSubscription = () => {
21979
+ if (this.unsubscribeSyncStatus) return true;
21980
+ const { offlineDb } = this.client;
21981
+ if (!offlineDb?.state.getLatestValue().initialized) return false;
21982
+ const { unsubscribe } = offlineDb.syncManager.onSyncStatusChange((status) => {
21983
+ if (!status) return;
21984
+ runDetached(this.recoverActiveChannels(), { context: "recoverActiveChannels" });
21985
+ });
21986
+ this.unsubscribeSyncStatus = unsubscribe;
21987
+ return true;
21988
+ };
21989
+ this.recoverChannelLists = async () => {
21990
+ if (!this.config.enabled) return;
21991
+ await this.client.channelManager.recover();
21992
+ };
21993
+ this.recoverActiveChannels = async () => {
21994
+ if (!this.config.enabled) return;
21995
+ const channels = this.recoverableActiveChannels;
21996
+ const threads = this.recoverableActiveThreads;
21997
+ this.isRecovering = true;
21998
+ logger12.withExtraTags("connectionRecovery").info(
21999
+ `Recovering ${channels.length} active channel(s) and ${threads.length} active thread(s).`
22000
+ );
22001
+ await Promise.allSettled([
22002
+ ...channels.map((channel) => channel.reload()),
22003
+ ...threads.map((thread) => thread.reload())
22004
+ ]);
22005
+ this.isRecovering = false;
22006
+ this.client.dispatchEvent({ type: "connection.recovered" });
22007
+ };
22008
+ /**
22009
+ * Run a full recovery now, without waiting for a connection event.
22010
+ */
22011
+ this.recover = async () => {
22012
+ if (this.isRecovering) {
22013
+ logger12.withExtraTags("connectionRecovery").debug("A recovery is already in flight.");
22014
+ }
22015
+ await Promise.allSettled([this.recoverChannelLists(), this.recoverActiveChannels()]);
22016
+ };
22017
+ this.configController = new ConfigController({
22018
+ defaults: DEFAULT_CONNECTION_RECOVERY_MANAGER_CONFIG
22019
+ });
22020
+ this.client = client;
22021
+ }
22022
+ /**
22023
+ * Resolved configuration, as a store — the shape every configurable class exposes
22024
+ * (`configState` / `config` / `updateConfig`).
22025
+ */
22026
+ get configState() {
22027
+ return this.configController.state;
22028
+ }
22029
+ /** The current resolved configuration. `Readonly` — change it through {@link updateConfig}. */
22030
+ get config() {
22031
+ return this.configState.getLatestValue();
22032
+ }
22033
+ /** Merges a partial configuration into the resolved config and notifies subscribers. */
22034
+ updateConfig(config) {
22035
+ this.configController.patch(config);
22036
+ }
22037
+ /**
22038
+ * Rebuilds the resolved configuration from package defaults plus the declarative slice — the
22039
+ * derivation entry point every configurable entity exposes, so the client routes a slice here and
22040
+ * knows nothing about this manager's defaults or merge semantics.
22041
+ */
22042
+ initializeConfig(config) {
22043
+ this.configController.initialize(config);
22044
+ }
22045
+ /**
22046
+ * The channels this recovery reloads: the ones a consumer has declared it is currently reading.
22047
+ *
22048
+ * Deliberately NOT filtered on `watchStatus`. A channel opened while offline never reached
22049
+ * `Watching` at all (its `watch()` threw, leaving `offlineMode`), so a `WasWatching` filter here
22050
+ * would permanently strand the single most important offline case. `Channel.reload()`'s own
22051
+ * `initialized || offlineMode` guard is the correct gate, and this is not a "restore a lost watch"
22052
+ * decision anyway — it is "the channel being read must show the truth".
22053
+ */
22054
+ get recoverableActiveChannels() {
22055
+ const channels = [];
22056
+ for (const cid in this.client.activeChannels) {
22057
+ const channel = this.client.activeChannels[cid];
22058
+ if (channel?.active && !channel.pendingDisposal) channels.push(channel);
22059
+ }
22060
+ return channels;
22061
+ }
22062
+ /**
22063
+ * The threads this recovery reloads: the ones a consumer has declared it is displaying.
22064
+ *
22065
+ * Threads need their own pass because nothing else brings an open thread's replies back — a
22066
+ * channel reload refreshes the main message list, `ChannelManager.recover()` refreshes the channel
22067
+ * lists*, and `ThreadManager`'s own recovery refreshes the thread *list*, reusing existing
22068
+ * instances without rehydrating them unless they were separately marked stale (which only
22069
+ * `user.watching.stop` does, never a reconnect).
22070
+ *
22071
+ * `active` is the filter that matters: `threadsById` holds every thread the list has paged in,
22072
+ * which is not what should be re-fetched on a reconnect — only what someone is actually reading.
22073
+ * Guarded on the owning channel the same way active channels are: a thread whose channel is being
22074
+ * torn down has nothing to recover into.
22075
+ *
22076
+ * NOTE: `threadsById` is the thread LIST, not a thread cache. A thread opened from a message list
22077
+ * is constructed directly (`threadsById[id] ?? new Thread(...)`) and only reaches the list because
22078
+ * the UI SDKs adopt it there — prepended, once its replies have loaded — which is a workaround, not
22079
+ * a contract. Two consequences, both accepted for now and both narrow:
22080
+ *
22081
+ * - A reconnect landing before that adoption misses the thread. Self-limiting: the UI SDKs re-issue
22082
+ * the load while the reply list is still unloaded, so a thread that failed to load gets adopted on
22083
+ * the retry.
22084
+ * - `ThreadManager.reload()` rebuilds `state.threads` purely from the query response, so an adopted
22085
+ * thread absent from that response is evicted while still active. It cannot bite within the
22086
+ * reconnect that caused it: this getter is read before `connection.recovered` is dispatched, and
22087
+ * that event is what triggers the UI SDKs' list reload. It would take a LATER reconnect, after an
22088
+ * eviction, to miss the thread.
22089
+ *
22090
+ * The fix is a real off-list registry — see the commented-out `threadCache` in `ThreadManager` — at
22091
+ * which point this getter reads from that instead, with no change to the `active` filter.
22092
+ */
22093
+ get recoverableActiveThreads() {
22094
+ const threads = [];
22095
+ for (const thread of Object.values(this.client.threads.threadsById)) {
22096
+ if (!thread) continue;
22097
+ const { active } = thread.state.getLatestValue();
22098
+ if (!active || thread.channel.pendingDisposal) continue;
22099
+ threads.push(thread);
22100
+ }
22101
+ return threads;
22102
+ }
22103
+ };
22104
+
21519
22105
  // src/reminders/ReminderTimer.ts
21520
22106
  var oneMinute = 60 * 1e3;
21521
22107
  var oneHour = 60 * oneMinute;
@@ -22003,7 +22589,7 @@ var CONSTRUCTION_ONLY_CONFIG_PATHS = {
22003
22589
  };
22004
22590
 
22005
22591
  // src/configuration/InstanceConfigurationRegistry.ts
22006
- var logger11 = chatLoggerSystem.getLogger("instance-configuration");
22592
+ var logger13 = chatLoggerSystem.getLogger("instance-configuration");
22007
22593
  var InstanceConfigurationRegistry = class {
22008
22594
  constructor() {
22009
22595
  /**
@@ -22201,7 +22787,7 @@ var InstanceConfigurationRegistry = class {
22201
22787
  try {
22202
22788
  instance.reinitializeConfig?.();
22203
22789
  } catch (error) {
22204
- logger11.error("reinitializeConfig threw during reset", error);
22790
+ logger13.error("reinitializeConfig threw during reset", error);
22205
22791
  }
22206
22792
  }
22207
22793
  }
@@ -22259,7 +22845,7 @@ var InstanceConfigurationRegistry = class {
22259
22845
  warnIfKeyLooksUnknown(key) {
22260
22846
  if (INSTANCE_CONFIG_TREE_KEYS.includes(key)) return;
22261
22847
  if (this.hasLiveInstances(key)) return;
22262
- logger11.withExtraTags(key).warn(
22848
+ logger13.withExtraTags(key).warn(
22263
22849
  "Configuration registered for a key this package does not define, with nothing subscribed to it. Check the spelling; a key declared by a downstream SDK is expected here only if its owner subscribes later."
22264
22850
  );
22265
22851
  }
@@ -22286,7 +22872,7 @@ var InstanceConfigurationRegistry = class {
22286
22872
  return !isEqual(getPath(registered, path), getPath(config, path));
22287
22873
  });
22288
22874
  if (late.length === 0) return;
22289
- logger11.withExtraTags(key).warn(
22875
+ logger13.withExtraTags(key).warn(
22290
22876
  `These paths are read once during construction, so they will not affect the ${key} instance(s) that already exist: ${late.join(", ")}. Register configuration before the instances are created \u2014 typically alongside StreamChat.getInstance().`
22291
22877
  );
22292
22878
  }
@@ -24352,8 +24938,8 @@ var ChatApi = class {
24352
24938
  function isString(value) {
24353
24939
  return typeof value === "string" || value instanceof String;
24354
24940
  }
24355
- var logger12 = chatLoggerSystem.getLogger("client");
24356
- var offlineDbLogger3 = chatLoggerSystem.getLogger("offline-db");
24941
+ var logger14 = chatLoggerSystem.getLogger("client");
24942
+ var offlineDbLogger2 = chatLoggerSystem.getLogger("offline-db");
24357
24943
  var StreamChat = class _StreamChat extends ChatApi {
24358
24944
  /**
24359
24945
  * Initializes a client.
@@ -24411,7 +24997,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24411
24997
  throw new Error('The "id" field on the user is missing');
24412
24998
  }
24413
24999
  if (this.userId === user.id && this.setUserPromise) {
24414
- logger12.withExtraTags("connectUser").warn(
25000
+ logger14.withExtraTags("connectUser").warn(
24415
25001
  "Detected consecutive calls to connectUser. Ideally, this function should only be called once."
24416
25002
  );
24417
25003
  return this.setUserPromise;
@@ -24422,7 +25008,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24422
25008
  );
24423
25009
  }
24424
25010
  if (this.node && !this.options.allowServerSideConnect) {
24425
- logger12.withExtraTags("connectUser").warn(
25011
+ logger14.withExtraTags("connectUser").warn(
24426
25012
  'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in the client options to disable this warning.'
24427
25013
  );
24428
25014
  }
@@ -24501,11 +25087,11 @@ var StreamChat = class _StreamChat extends ChatApi {
24501
25087
  );
24502
25088
  }
24503
25089
  if (this.wsConnection?.isConnecting && this.wsPromise) {
24504
- logger12.withExtraTags("openConnection").debug("A connection attempt is already in progress.");
25090
+ logger14.withExtraTags("openConnection").debug("A connection attempt is already in progress.");
24505
25091
  return this.wsPromise;
24506
25092
  }
24507
25093
  if (this.wsConnection?.isHealthy && this._hasConnectionID()) {
24508
- logger12.withExtraTags("openConnection").debug("openConnection was called twice; a healthy connection already exists.");
25094
+ logger14.withExtraTags("openConnection").debug("openConnection was called twice; a healthy connection already exists.");
24509
25095
  return;
24510
25096
  }
24511
25097
  this.clientId = `${this.userId}--${randomId()}`;
@@ -24521,7 +25107,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24521
25107
  * @returns The close-connection promise.
24522
25108
  */
24523
25109
  this.disconnectUser = (timeout) => {
24524
- logger12.withExtraTags("disconnectUser").info("Disconnecting the client.");
25110
+ logger14.withExtraTags("disconnectUser").info("Disconnecting the client.");
24525
25111
  delete this.user;
24526
25112
  delete this._user;
24527
25113
  const closePromise = this.closeConnection(timeout);
@@ -24540,7 +25126,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24540
25126
  closePromise.finally(() => {
24541
25127
  this.tokenManager.reset();
24542
25128
  }).catch(
24543
- (err) => logger12.withExtraTags("disconnectUser").error("The close promise rejected during disconnect.", { error: err })
25129
+ (err) => logger14.withExtraTags("disconnectUser").error("The close promise rejected during disconnect.", { error: err })
24544
25130
  );
24545
25131
  return closePromise;
24546
25132
  };
@@ -24558,7 +25144,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24558
25144
  */
24559
25145
  this.connectAnonymousUser = () => {
24560
25146
  if (this.node && !this.options.allowServerSideConnect) {
24561
- logger12.withExtraTags("connectAnonymousUser").warn(
25147
+ logger14.withExtraTags("connectAnonymousUser").warn(
24562
25148
  'Do not use connectUser server-side. connectUser impacts MAU and concurrent connection usage, and therefore your bill. If you have a valid use case, set "allowServerSideConnect: true" in the client options to disable this warning.'
24563
25149
  );
24564
25150
  }
@@ -24712,27 +25298,19 @@ var StreamChat = class _StreamChat extends ChatApi {
24712
25298
  (set) => set?.forEach((handleEvent) => handleEvent(event))
24713
25299
  );
24714
25300
  };
24715
- this.recoverState = async () => {
24716
- logger12.withExtraTags("recoverState").info(`Starting state recovery with connection ID ${this._getConnectionID()}.`);
24717
- const cids = Object.keys(this.activeChannels);
24718
- if (cids.length && this.recoverStateOnReconnect) {
24719
- logger12.withExtraTags("recoverState").info(`Starting the query for ${cids.length} channel(s).`);
24720
- await this.queryChannelsAndHydrate({
24721
- filter_conditions: {
24722
- cid: { $in: cids }
24723
- },
24724
- limit: 30,
24725
- sort: [{ field: "last_message_at", direction: -1 }]
24726
- });
24727
- logger12.withExtraTags("recoverState").info("Finished querying channels.");
24728
- this.dispatchEvent({
24729
- type: "connection.recovered"
24730
- });
24731
- } else {
24732
- this.dispatchEvent({
24733
- type: "connection.recovered"
24734
- });
24735
- }
25301
+ /**
25302
+ * Settles the connect promises after a successful reconnect, so the `await this.wsPromise` gates
25303
+ * spread across the client stop resolving against a superseded (possibly rejected) attempt.
25304
+ *
25305
+ * Called by `StableWSConnection._reconnect()`. Recovery itself is owned by
25306
+ * {@link ConnectionRecoveryManager}, which subscribes to the connection lifecycle and so covers
25307
+ * every reconnect path — including `closeConnection()` → `openConnection()` (mobile backgrounding),
25308
+ * which never reaches `_reconnect()` at all.
25309
+ *
25310
+ * @internal
25311
+ */
25312
+ this._settleConnectPromises = () => {
25313
+ logger14.withExtraTags("_settleConnectPromises").info(`Connection re-established with connection ID ${this._getConnectionID()}.`);
24736
25314
  this.wsPromise = Promise.resolve();
24737
25315
  this.setUserPromise = Promise.resolve();
24738
25316
  };
@@ -24822,7 +25400,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24822
25400
  return this.userAgent;
24823
25401
  }
24824
25402
  if (!this.cachedUserAgent) {
24825
- const version = "10.0.0-rc.7";
25403
+ const version = "10.0.0-rc.8";
24826
25404
  const { name: sdkName, version: sdkVersion } = this.sdkIdentifier ?? {};
24827
25405
  const { name: appName, version: appVersion } = this.appIdentifier ?? {};
24828
25406
  const { os, model: deviceModel } = this.deviceIdentifier ?? {};
@@ -24881,7 +25459,6 @@ var StreamChat = class _StreamChat extends ChatApi {
24881
25459
  this.node = !this.browser;
24882
25460
  this.options = {
24883
25461
  warmUp: false,
24884
- recoverStateOnReconnect: true,
24885
25462
  disableCache: false,
24886
25463
  isLocalUnreadCountEnabled: false,
24887
25464
  wsUrlParams: new URLSearchParams({}),
@@ -24910,13 +25487,14 @@ var StreamChat = class _StreamChat extends ChatApi {
24910
25487
  this.tokenManager = new TokenManager();
24911
25488
  this.insightMetrics = new InsightMetrics();
24912
25489
  this.defaultWSTimeout = 15 * 1e3;
24913
- this.recoverStateOnReconnect = this.options.recoverStateOnReconnect;
24914
25490
  this.messageStore = new EntityStore({
24915
25491
  getEntityId: (message) => message.id
24916
25492
  });
24917
25493
  this.threads = new ThreadManager({ client: this });
24918
25494
  this.polls = new PollManager({ client: this });
24919
25495
  this.channelManager = new ChannelManager({ client: this });
25496
+ this.connectionRecovery = new ConnectionRecoveryManager({ client: this });
25497
+ this.connectionRecovery.registerSubscriptions();
24920
25498
  this.reminders = new ReminderManager({ client: this });
24921
25499
  this.messageDeliveryReporter = new MessageDeliveryReporter({ client: this });
24922
25500
  this.messageComposerCache = new FixedSizeQueueCache(64);
@@ -24983,6 +25561,7 @@ var StreamChat = class _StreamChat extends ChatApi {
24983
25561
  */
24984
25562
  initializeManagerConfig() {
24985
25563
  const config = this.config.getConfig("client");
25564
+ this.connectionRecovery.initializeConfig(config?.connectionRecovery);
24986
25565
  this.reminders.initializeConfig(config?.reminders);
24987
25566
  this.threads.initializeConfig(config?.threads);
24988
25567
  this.messageDeliveryReporter.initializeConfig(config?.messageDelivery);
@@ -25144,14 +25723,14 @@ var StreamChat = class _StreamChat extends ChatApi {
25144
25723
  const key = callbackOrNothing ? callbackOrString : "all";
25145
25724
  const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
25146
25725
  const set = this.listeners.get(key) ?? /* @__PURE__ */ new Set();
25147
- logger12.withExtraTags("on").debug(`Attaching a listener for the "${key}" event.`);
25726
+ logger14.withExtraTags("on").debug(`Attaching a listener for the "${key}" event.`);
25148
25727
  set.add(callback);
25149
25728
  if (!this.listeners.has(key)) {
25150
25729
  this.listeners.set(key, set);
25151
25730
  }
25152
25731
  return {
25153
25732
  unsubscribe: () => {
25154
- logger12.withExtraTags("on").debug(`Removing the listener for the "${key}" event.`);
25733
+ logger14.withExtraTags("on").debug(`Removing the listener for the "${key}" event.`);
25155
25734
  set.delete(callback);
25156
25735
  if (!set.size) {
25157
25736
  this.listeners.delete(key);
@@ -25162,7 +25741,7 @@ var StreamChat = class _StreamChat extends ChatApi {
25162
25741
  off(callbackOrString, callbackOrNothing) {
25163
25742
  const key = callbackOrNothing ? callbackOrString : "all";
25164
25743
  const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
25165
- logger12.withExtraTags("off").debug(`Removing the listener for the "${key}" event.`);
25744
+ logger14.withExtraTags("off").debug(`Removing the listener for the "${key}" event.`);
25166
25745
  const set = this.listeners.get(key);
25167
25746
  set?.delete(callback);
25168
25747
  if (!set?.size) {
@@ -25172,7 +25751,7 @@ var StreamChat = class _StreamChat extends ChatApi {
25172
25751
  _handleClientEvent(event) {
25173
25752
  const client = this;
25174
25753
  const postListenerCallbacks = [];
25175
- logger12.withExtraTags("_handleClientEvent").debug(`Received an event of type "${event.type}".`, { event });
25754
+ logger14.withExtraTags("_handleClientEvent").debug(`Received an event of type "${event.type}".`, { event });
25176
25755
  if (event.type === "user.presence.changed" || event.type === "user.updated" || event.type === "user.deleted") {
25177
25756
  this._handleUserEvent(event);
25178
25757
  }
@@ -25199,14 +25778,10 @@ var StreamChat = class _StreamChat extends ChatApi {
25199
25778
  this.mutedUsers = event.me.mutes;
25200
25779
  }
25201
25780
  if (event.type === "notification.mark_read" && event.unread_channels === 0) {
25202
- const activeChannelKeys = Object.keys(this.activeChannels);
25203
- activeChannelKeys.forEach(
25204
- (activeChannelKey) => (
25205
- // resets `read[userId].unread_messages`, which is what the unread badge reads, so it does
25206
- // not stay stale.
25207
- this.activeChannels[activeChannelKey]._setOwnUnreadCount(0)
25208
- )
25209
- );
25781
+ Object.values(this.activeChannels).forEach((channel) => {
25782
+ if (!channel._setOwnUnreadCount(0)) return;
25783
+ this.channelManager.dropFromUnmatchedLists(channel);
25784
+ });
25210
25785
  }
25211
25786
  if ((event.type === "channel.deleted" || event.type === "notification.channel_deleted") && event.cid) {
25212
25787
  const { cid } = event;
@@ -25461,7 +26036,7 @@ var StreamChat = class _StreamChat extends ChatApi {
25461
26036
  });
25462
26037
  }
25463
26038
  } catch (e) {
25464
- offlineDbLogger3.withExtraTags("queryReactionsAndHydrate").warn("An error occurred while querying offline reactions.", { error: e });
26039
+ offlineDbLogger2.withExtraTags("queryReactionsAndHydrate").warn("An error occurred while querying offline reactions.", { error: e });
25465
26040
  }
25466
26041
  }
25467
26042
  await this.wsPromise;
@@ -25729,21 +26304,15 @@ var StreamChat = class _StreamChat extends ChatApi {
25729
26304
  */
25730
26305
  async updateMessage(...args) {
25731
26306
  const [request] = args;
25732
- try {
25733
- if (this.offlineDb) {
25734
- return await this.offlineDb.queueTask({
25735
- task: {
25736
- ...getPendingTaskChannelData(request.message?.cid),
25737
- messageId: request.id,
25738
- payload: args,
25739
- type: "update-message"
25740
- }
25741
- });
26307
+ return await queueOrRun({
26308
+ client: this,
26309
+ task: {
26310
+ ...getPendingTaskChannelData(request.message?.cid),
26311
+ messageId: request.id,
26312
+ payload: args,
26313
+ type: "update-message"
25742
26314
  }
25743
- } catch (error) {
25744
- offlineDbLogger3.withExtraTags("updateMessage").error("Updating the message failed.", { error });
25745
- }
25746
- return await this._updateMessage(...args);
26315
+ });
25747
26316
  }
25748
26317
  async _updateMessage(...args) {
25749
26318
  return await super.updateMessage(...args);
@@ -25754,28 +26323,14 @@ var StreamChat = class _StreamChat extends ChatApi {
25754
26323
  */
25755
26324
  async deleteMessage(...args) {
25756
26325
  const [request] = args;
25757
- try {
25758
- if (this.offlineDb) {
25759
- if (request.hard) {
25760
- await this.offlineDb.hardDeleteMessage({ id: request.id });
25761
- } else {
25762
- await this.offlineDb.softDeleteMessage({
25763
- id: request.id,
25764
- deleteForMe: request.delete_for_me
25765
- });
25766
- }
25767
- return await this.offlineDb.queueTask({
25768
- task: {
25769
- messageId: request.id,
25770
- payload: args,
25771
- type: "delete-message"
25772
- }
25773
- });
26326
+ return await queueOrRun({
26327
+ client: this,
26328
+ task: {
26329
+ messageId: request.id,
26330
+ payload: args,
26331
+ type: "delete-message"
25774
26332
  }
25775
- } catch (error) {
25776
- offlineDbLogger3.withExtraTags("deleteMessage").error("Deleting the message failed.", { error });
25777
- }
25778
- return this._deleteMessage(...args);
26333
+ });
25779
26334
  }
25780
26335
  async _deleteMessage(...args) {
25781
26336
  const [request] = args;
@@ -26253,7 +26808,19 @@ var REMINDER_FIELDS = {
26253
26808
  type: "number"
26254
26809
  }
26255
26810
  };
26811
+ var CONNECTION_RECOVERY_FIELDS = {
26812
+ enabled: {
26813
+ description: "Whether the client recovers its own state on reconnect. Turn it off only if the application re-queries and re-watches everything on screen itself.",
26814
+ kind: "value",
26815
+ type: "boolean"
26816
+ }
26817
+ };
26256
26818
  var CLIENT_FIELDS = {
26819
+ connectionRecovery: {
26820
+ description: "Bringing the channels, lists and threads you are reading back in line after a reconnect.",
26821
+ fields: CONNECTION_RECOVERY_FIELDS,
26822
+ kind: "group"
26823
+ },
26257
26824
  messageDelivery: {
26258
26825
  description: "Delivery and read receipt reporting.",
26259
26826
  fields: MESSAGE_DELIVERY_FIELDS,
@@ -26426,7 +26993,7 @@ var OfflineError = class extends Error {
26426
26993
 
26427
26994
  // src/offline-support/offline_sync_manager.ts
26428
26995
  var import_axios5 = require("axios");
26429
- var logger13 = chatLoggerSystem.getLogger("offline-db");
26996
+ var logger15 = chatLoggerSystem.getLogger("offline-db");
26430
26997
  var OfflineDBSyncManager = class {
26431
26998
  constructor({
26432
26999
  client,
@@ -26464,7 +27031,7 @@ var OfflineDBSyncManager = class {
26464
27031
  }
26465
27032
  );
26466
27033
  } catch (error) {
26467
- logger13.withExtraTags("init").error("Failed to initialize the offline DB sync manager.", { error });
27034
+ logger15.withExtraTags("init").error("Failed to initialize the offline DB sync manager.", { error });
26468
27035
  }
26469
27036
  };
26470
27037
  /**
@@ -26551,10 +27118,11 @@ var OfflineDBSyncManager = class {
26551
27118
  channel_cids: cids,
26552
27119
  last_sync_at: lastSyncedAtDate
26553
27120
  });
27121
+ result.events = result.events?.map((event) => decodeWSEvent(event));
26554
27122
  const { syncMaxEventCount } = this;
26555
27123
  const exceedsLimit = typeof syncMaxEventCount === "number" && syncMaxEventCount > 0 && result.events.length > syncMaxEventCount;
26556
27124
  if (exceedsLimit) {
26557
- logger13.withExtraTags("sync").warn(
27125
+ logger15.withExtraTags("sync").warn(
26558
27126
  `Skipping sync event replay: received ${result.events.length} events, which exceeds the configured limit of ${syncMaxEventCount}. Visible channels are refreshed on reconnect instead.`
26559
27127
  );
26560
27128
  } else {
@@ -26574,7 +27142,7 @@ var OfflineDBSyncManager = class {
26574
27142
  lastSyncedAt: (/* @__PURE__ */ new Date()).toString()
26575
27143
  });
26576
27144
  } catch (e) {
26577
- logger13.withExtraTags("syncAndExecutePendingTasks").error("An error occurred while syncing the database.", { error: e });
27145
+ logger15.withExtraTags("syncAndExecutePendingTasks").error("An error occurred while syncing the database.", { error: e });
26578
27146
  if ((0, import_axios5.isAxiosError)(e) && e.code === "ECONNABORTED") {
26579
27147
  return;
26580
27148
  }
@@ -26614,7 +27182,7 @@ var OfflineDBSyncManager = class {
26614
27182
  };
26615
27183
 
26616
27184
  // src/offline-support/offline_support_api.ts
26617
- var logger14 = chatLoggerSystem.getLogger("offline-db");
27185
+ var logger16 = chatLoggerSystem.getLogger("offline-db");
26618
27186
  var AbstractOfflineDB = class {
26619
27187
  constructor({
26620
27188
  client,
@@ -26639,7 +27207,7 @@ var AbstractOfflineDB = class {
26639
27207
  }
26640
27208
  } catch (error) {
26641
27209
  this.state.partialNext({ initialized: false, userId: void 0 });
26642
- logger14.withExtraTags("init").error("Failed to initialize the offline database.", { error });
27210
+ logger16.withExtraTags("init").error("Failed to initialize the offline database.", { error });
26643
27211
  }
26644
27212
  };
26645
27213
  /**
@@ -26685,51 +27253,75 @@ var AbstractOfflineDB = class {
26685
27253
  }, createQueries) => {
26686
27254
  const channelFromEvent = event.channel;
26687
27255
  const cid = event.cid || channelFromEvent?.cid;
26688
- const type = event.type;
26689
27256
  if (!cid) {
26690
27257
  return await createQueries(execute);
26691
27258
  }
26692
- const shouldUpsertChannelData = forceUpdate || !await this.channelExists({ cid });
26693
- if (shouldUpsertChannelData) {
26694
- const event_ = event;
26695
- let channelData = channelFromEvent;
26696
- if (!channelData && event_.channel_type && event_.channel_id) {
26697
- const channelFromState = this.client.channel(
26698
- event_.channel_type,
26699
- event_.channel_id
26700
- );
26701
- if (channelFromState.initialized && !channelFromState.pendingDisposal) {
26702
- channelData = channelFromState.data;
26703
- }
27259
+ if (execute && !forceUpdate) {
27260
+ try {
27261
+ return await createQueries(true);
27262
+ } catch (error) {
27263
+ if (await this.channelExists({ cid })) throw error;
26704
27264
  }
26705
- if (channelData) {
26706
- const channelQuery = await this.upsertChannelData({
26707
- channel: channelData,
26708
- execute: false
26709
- });
26710
- if (channelQuery) {
26711
- const createdQueries = await createQueries(false);
26712
- const newQueries = [...channelQuery, ...createdQueries];
26713
- if (execute) {
26714
- await this.executeSqlBatch(newQueries);
26715
- }
26716
- return newQueries;
26717
- } else {
26718
- logger14.withExtraTags("queriesWithChannelGuard").warn(
26719
- `Could not create channel queries on a "${type}" event for an initialized channel that is not in the database. Skipping the event.`,
26720
- { event }
26721
- );
26722
- return [];
26723
- }
26724
- } else {
26725
- logger14.withExtraTags("queriesWithChannelGuard").warn(
26726
- `Received a "${type}" event for a non-initialized channel that is not in the database. Skipping the event.`,
26727
- { event }
26728
- );
26729
- return [];
27265
+ return await this.queriesBehindChannelGuard({
27266
+ createQueries,
27267
+ event,
27268
+ execute: true
27269
+ });
27270
+ }
27271
+ if (!forceUpdate && await this.channelExists({ cid })) {
27272
+ return await createQueries(execute);
27273
+ }
27274
+ return await this.queriesBehindChannelGuard({ createQueries, event, execute });
27275
+ };
27276
+ /**
27277
+ * Prepends the channel row to `createQueries`' output so the queries cannot fail on the `cid`
27278
+ * foreign key, sourcing the channel from the event and falling back to the in-memory channel.
27279
+ *
27280
+ * Skips the write entirely when neither can supply it: without a channel row the queries would fail
27281
+ * anyway, and dropping the event is what the caller expects.
27282
+ */
27283
+ this.queriesBehindChannelGuard = async ({
27284
+ createQueries,
27285
+ event,
27286
+ execute
27287
+ }) => {
27288
+ const type = event.type;
27289
+ const channelFromEvent = event.channel;
27290
+ const event_ = event;
27291
+ let channelData = channelFromEvent;
27292
+ if (!channelData && event_.channel_type && event_.channel_id) {
27293
+ const channelFromState = this.client.channel(
27294
+ event_.channel_type,
27295
+ event_.channel_id
27296
+ );
27297
+ if (channelFromState.initialized && !channelFromState.pendingDisposal) {
27298
+ channelData = channelFromState.data;
26730
27299
  }
26731
27300
  }
26732
- return await createQueries(execute);
27301
+ if (!channelData) {
27302
+ logger16.withExtraTags("queriesWithChannelGuard").warn(
27303
+ `Received a "${type}" event for a non-initialized channel that is not in the database. Skipping the event.`,
27304
+ { event }
27305
+ );
27306
+ return [];
27307
+ }
27308
+ const channelQuery = await this.upsertChannelData({
27309
+ channel: channelData,
27310
+ execute: false
27311
+ });
27312
+ if (!channelQuery) {
27313
+ logger16.withExtraTags("queriesWithChannelGuard").warn(
27314
+ `Could not create channel queries on a "${type}" event for an initialized channel that is not in the database. Skipping the event.`,
27315
+ { event }
27316
+ );
27317
+ return [];
27318
+ }
27319
+ const createdQueries = await createQueries(false);
27320
+ const newQueries = [...channelQuery, ...createdQueries];
27321
+ if (execute) {
27322
+ await this.executeSqlBatch(newQueries);
27323
+ }
27324
+ return newQueries;
26733
27325
  };
26734
27326
  /**
26735
27327
  * Handles a message.new event. Will always use a channel guard for the inner queries
@@ -26865,6 +27457,92 @@ var AbstractOfflineDB = class {
26865
27457
  }
26866
27458
  return queries;
26867
27459
  };
27460
+ /**
27461
+ * Upsert a single message, first making sure its channel row exists so the insert cannot fail on the
27462
+ * foreign key.
27463
+ *
27464
+ * Concrete on purpose — it composes {@link channelExists}, {@link upsertChannelData} and
27465
+ * {@link upsertMessages}, so every implementation inherits it and no custom offline DB has to
27466
+ * implement anything new (same reasoning as {@link hardDeleteMessages}).
27467
+ *
27468
+ * The guard matters because the message-operations engine writes optimistically, and the very first
27469
+ * message in a freshly created channel can reach here before that channel has ever been persisted.
27470
+ * `upsertMessages` alone is used rather than `updateMessage` because `updateMessage` is an
27471
+ * UPDATE — it no-ops when the row does not exist yet, which is exactly the write-ahead case.
27472
+ *
27473
+ * @param payload.message - The message to persist. Its `cid` decides which channel row is guarded.
27474
+ * @param payload.execute - Whether to immediately execute the operation (optional, defaults to `true`).
27475
+ */
27476
+ this.upsertMessageWithChannelGuard = async ({
27477
+ message,
27478
+ execute = true
27479
+ }) => {
27480
+ const { cid } = message;
27481
+ if (!cid) return [];
27482
+ const messages = [message];
27483
+ if (execute) {
27484
+ try {
27485
+ return await this.upsertMessages({ messages, execute: true });
27486
+ } catch (error) {
27487
+ if (await this.channelExists({ cid })) throw error;
27488
+ }
27489
+ return await this.upsertMessageBehindChannelGuard({ cid, messages, execute: true });
27490
+ }
27491
+ if (await this.channelExists({ cid })) {
27492
+ return await this.upsertMessages({ messages, execute: false });
27493
+ }
27494
+ return await this.upsertMessageBehindChannelGuard({ cid, messages, execute });
27495
+ };
27496
+ /**
27497
+ * Writes the channel row ahead of the messages so the insert cannot fail on the `cid` foreign key.
27498
+ * Skips the write when there is nothing to build a channel row from — the message stays correct in
27499
+ * memory either way, and an insert that cannot resolve its foreign key is not worth attempting.
27500
+ */
27501
+ this.upsertMessageBehindChannelGuard = async ({
27502
+ cid,
27503
+ execute,
27504
+ messages
27505
+ }) => {
27506
+ const channel = this.client.activeChannels[cid];
27507
+ if (!channel?.initialized || channel.pendingDisposal) {
27508
+ logger16.withExtraTags("upsertMessageWithChannelGuard", cid).warn("Skipping message persistence: the channel is not in the database.");
27509
+ return [];
27510
+ }
27511
+ const queries = [
27512
+ ...await this.upsertChannelData({
27513
+ channel: channel.data,
27514
+ execute: false
27515
+ }),
27516
+ ...await this.upsertMessages({ messages, execute: false })
27517
+ ];
27518
+ if (execute) {
27519
+ await this.executeSqlBatch(queries);
27520
+ }
27521
+ return queries;
27522
+ };
27523
+ /**
27524
+ * The messages of a channel that are still in a local `failed` state, i.e. composed by this user but
27525
+ * never accepted by the server.
27526
+ *
27527
+ * Concrete, composed from the existing {@link getChannels} primitive, so it adds nothing to
27528
+ * {@link OfflineDBApi} that a custom offline DB would have to implement.
27529
+ *
27530
+ * This is the buffer that lets a failed message survive. v9 recovered such messages by diffing a
27531
+ * second, lagging copy of the message list (the React SDK state) against `channel.state`; with a
27532
+ * single reactive source of truth the persisted row is the only place that memory can be
27533
+ * re-populated from — see `Channel.reload`.
27534
+ *
27535
+ * @param payload.cid - The channel whose failed messages to read.
27536
+ */
27537
+ this.getFailedMessages = async ({
27538
+ cid
27539
+ }) => {
27540
+ const { initialized, userId } = this.state.getLatestValue();
27541
+ if (!initialized || !userId) return [];
27542
+ const channels = await this.getChannels({ cids: [cid], userId });
27543
+ const messages = channels?.[0]?.messages ?? [];
27544
+ return messages.map((message) => formatMessage(message)).filter((message) => message.status === "failed");
27545
+ };
26868
27546
  /**
26869
27547
  * A utility method to handle read events. It will calculate the state of the reads if
26870
27548
  * present in the event, or optionally rely on the hard override in unreadMessages.
@@ -27322,46 +28000,23 @@ var AbstractOfflineDB = class {
27322
28000
  * @param isPendingTask - a control value telling us if it's an actual pending task being executed
27323
28001
  * or delayed execution
27324
28002
  */
28003
+ /**
28004
+ * Replays one queued operation, through the {@link QUEUEABLE_OPERATIONS} registry rather than a
28005
+ * per-type branch here — the same registry a first attempt resolves through, so the two cannot
28006
+ * describe different requests.
28007
+ *
28008
+ * @param task - the task to run.
28009
+ * @param isPendingTask - whether this is a replay rather than a first attempt. A first attempt has an
28010
+ * optimistic layer above it that settles local state; a replay does not, so some operations have to
28011
+ * settle it themselves (see `onReplay`).
28012
+ */
27325
28013
  this.executeTask = async ({ task }, isPendingTask = false) => {
27326
- if (task.type === "update-message") {
27327
- return await this.client._updateMessage(...task.payload);
27328
- }
27329
- if (task.type === "delete-message") {
27330
- return await this.client._deleteMessage(...task.payload);
27331
- }
27332
- const { channelType, channelId } = task;
27333
- if (channelType && channelId) {
27334
- const channel = this.client.channel(channelType, channelId);
27335
- if (task.type === "send-reaction") {
27336
- return await channel._sendReaction(...task.payload);
27337
- }
27338
- if (task.type === "delete-reaction") {
27339
- return await channel._deleteReaction(...task.payload);
27340
- }
27341
- if (task.type === "create-draft") {
27342
- return await channel._createDraft(...task.payload);
27343
- }
27344
- if (task.type === "delete-draft") {
27345
- return await channel._deleteDraft(...task.payload);
27346
- }
27347
- if (task.type === "send-message") {
27348
- const newMessageResponse = await channel._sendMessage(...task.payload);
27349
- const newMessage = newMessageResponse?.message;
27350
- if (isPendingTask && newMessage) {
27351
- if (newMessage?.parent_id) {
27352
- this.client.threads.threadsById[newMessage.parent_id]?.upsertReplyLocally({
27353
- message: newMessage,
27354
- timestampChanged: true
27355
- });
27356
- }
27357
- channel.messagePaginator.trackLastMessage(formatMessage(newMessage));
27358
- }
27359
- return newMessageResponse;
27360
- }
28014
+ const result = await runQueueableOperation({ client: this.client, task });
28015
+ if (isPendingTask) {
28016
+ const operation = QUEUEABLE_OPERATIONS[task.type];
28017
+ operation.onReplay?.({ client: this.client, result, task });
27361
28018
  }
27362
- throw new Error(
27363
- `Tried to execute invalid pending task type (${task.type}) while synchronizing the database.`
27364
- );
28019
+ return result;
27365
28020
  };
27366
28021
  /**
27367
28022
  * A utility method used to execute all pending tasks. As each task succeeds execution,