stream-chat 9.50.3 → 10.0.0-rc.2

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 (242) hide show
  1. package/dist/cjs/index.browser.js +19798 -14472
  2. package/dist/cjs/index.browser.js.map +4 -4
  3. package/dist/cjs/index.node.js +19817 -14482
  4. package/dist/cjs/index.node.js.map +4 -4
  5. package/dist/esm/index.mjs +19795 -14469
  6. package/dist/esm/index.mjs.map +4 -4
  7. package/dist/types/ChannelManager.d.ts +221 -0
  8. package/dist/types/EventHandlerPipeline.d.ts +104 -0
  9. package/dist/types/api-client.d.ts +26 -0
  10. package/dist/types/campaign.d.ts +0 -44
  11. package/dist/types/channel.d.ts +454 -479
  12. package/dist/types/channel_batch_updater.d.ts +0 -93
  13. package/dist/types/channel_state.d.ts +53 -201
  14. package/dist/types/client.d.ts +451 -1920
  15. package/dist/types/client_state.d.ts +4 -4
  16. package/dist/types/configuration/InstanceConfigurationService.d.ts +26 -0
  17. package/dist/types/configuration/index.d.ts +1 -0
  18. package/dist/types/configuration/types.d.ts +53 -0
  19. package/dist/types/connection.d.ts +49 -41
  20. package/dist/types/connection_fallback.d.ts +6 -4
  21. package/dist/types/constants.d.ts +0 -4
  22. package/dist/types/custom_types.d.ts +2 -0
  23. package/dist/types/entityStore/EntityStore.d.ts +111 -0
  24. package/dist/types/entityStore/StoreBackedItemIndex.d.ts +60 -0
  25. package/dist/types/entityStore/applyReactionLocally.d.ts +26 -0
  26. package/dist/types/entityStore/index.d.ts +2 -0
  27. package/dist/types/errors.d.ts +10 -2
  28. package/dist/types/gen/chat/ChannelApi.d.ts +45 -0
  29. package/dist/types/gen/chat/ChatApi.d.ts +334 -0
  30. package/dist/types/gen/model-decoders/decoders.d.ts +3 -0
  31. package/dist/types/gen/model-decoders/event-decoder-mapping.d.ts +6 -0
  32. package/dist/types/gen/models/index.d.ts +8836 -0
  33. package/dist/types/gen/moderation/ModerationApi.d.ts +50 -0
  34. package/dist/types/gen-imports.d.ts +3 -0
  35. package/dist/types/index.d.ts +8 -7
  36. package/dist/types/insights.d.ts +9 -8
  37. package/dist/types/logger.d.ts +9 -0
  38. package/dist/types/messageComposer/LocationComposer.d.ts +7 -4
  39. package/dist/types/messageComposer/attachmentIdentity.d.ts +2 -2
  40. package/dist/types/messageComposer/attachmentManager.d.ts +8 -4
  41. package/dist/types/messageComposer/configuration/types.d.ts +11 -11
  42. package/dist/types/messageComposer/linkPreviewsManager.d.ts +25 -13
  43. package/dist/types/messageComposer/messageComposer.d.ts +10 -10
  44. package/dist/types/messageComposer/middleware/messageComposer/types.d.ts +3 -3
  45. package/dist/types/messageComposer/middleware/pollComposer/types.d.ts +4 -7
  46. package/dist/types/messageComposer/middleware/textComposer/commandUtils.d.ts +3 -3
  47. package/dist/types/messageComposer/middleware/textComposer/commands.d.ts +7 -7
  48. package/dist/types/messageComposer/middleware/textComposer/mentions.d.ts +218 -4
  49. package/dist/types/messageComposer/middleware/textComposer/types.d.ts +9 -4
  50. package/dist/types/messageComposer/pollComposer.d.ts +5 -4
  51. package/dist/types/messageComposer/textComposer.d.ts +8 -3
  52. package/dist/types/messageComposer/types.d.ts +6 -28
  53. package/dist/types/messageDelivery/MessageDeliveryReporter.d.ts +40 -25
  54. package/dist/types/messageDelivery/MessageReceiptsTracker.d.ts +53 -12
  55. package/dist/types/messageOperations/MessageOperationStatePolicy.d.ts +19 -0
  56. package/dist/types/messageOperations/MessageOperations.d.ts +17 -0
  57. package/dist/types/messageOperations/index.d.ts +3 -0
  58. package/dist/types/messageOperations/types.d.ts +49 -0
  59. package/dist/types/moderation.d.ts +21 -203
  60. package/dist/types/notifications/types.d.ts +8 -7
  61. package/dist/types/offline-support/offline_support_api.d.ts +111 -87
  62. package/dist/types/offline-support/offline_sync_manager.d.ts +0 -7
  63. package/dist/types/offline-support/types.d.ts +19 -23
  64. package/dist/types/offline-support/util.d.ts +4 -2
  65. package/dist/types/pagination/FilterBuilder.d.ts +1 -1
  66. package/dist/types/pagination/ItemIndex.d.ts +25 -0
  67. package/dist/types/pagination/cursorDerivation/createdAtAroundPaginationFlags.d.ts +9 -0
  68. package/dist/types/pagination/cursorDerivation/idAroundPaginationFlags.d.ts +6 -0
  69. package/dist/types/pagination/cursorDerivation/index.d.ts +1 -0
  70. package/dist/types/pagination/cursorDerivation/linearPaginationFlags.d.ts +6 -0
  71. package/dist/types/pagination/filterCompiler.d.ts +7 -0
  72. package/dist/types/pagination/index.d.ts +1 -3
  73. package/dist/types/pagination/paginators/BasePaginator.d.ts +848 -0
  74. package/dist/types/pagination/paginators/ChannelPaginator.d.ts +212 -0
  75. package/dist/types/pagination/paginators/MessageIntervalPaginator.d.ts +282 -0
  76. package/dist/types/pagination/paginators/MessagePaginator.d.ts +208 -0
  77. package/dist/types/pagination/paginators/PinnedMessagePaginator.d.ts +39 -0
  78. package/dist/types/pagination/paginators/ReminderPaginator.d.ts +19 -0
  79. package/dist/types/pagination/paginators/UserGroupPaginator.d.ts +24 -0
  80. package/dist/types/pagination/paginators/index.d.ts +7 -0
  81. package/dist/types/pagination/paginators/stateThrottling.d.ts +2 -0
  82. package/dist/types/pagination/sortCompiler.d.ts +49 -0
  83. package/dist/types/pagination/types.normalization.d.ts +11 -0
  84. package/dist/types/pagination/utility.normalization.d.ts +18 -0
  85. package/dist/types/pagination/utility.queryChannel.d.ts +26 -0
  86. package/dist/types/pagination/utility.search.d.ts +13 -0
  87. package/dist/types/permissions.d.ts +1 -1
  88. package/dist/types/poll.d.ts +29 -32
  89. package/dist/types/poll_manager.d.ts +2 -2
  90. package/dist/types/reminders/Reminder.d.ts +2 -5
  91. package/dist/types/reminders/ReminderManager.d.ts +2 -9
  92. package/dist/types/search/MessageSearchSource.d.ts +4 -4
  93. package/dist/types/search/UserSearchSource.d.ts +1 -1
  94. package/dist/types/search/types.d.ts +3 -3
  95. package/dist/types/segment.d.ts +0 -34
  96. package/dist/types/signing.d.ts +105 -65
  97. package/dist/types/store.d.ts +1 -0
  98. package/dist/types/thread.d.ts +70 -34
  99. package/dist/types/thread_manager.d.ts +3 -3
  100. package/dist/types/token_manager.d.ts +15 -10
  101. package/dist/types/types.d.ts +197 -3404
  102. package/dist/types/utils/FixedSizeQueueCache.d.ts +12 -6
  103. package/dist/types/utils/WithSubscriptions.d.ts +2 -1
  104. package/dist/types/utils/concurrency.d.ts +4 -4
  105. package/dist/types/utils/mergeWith/mergeWith.d.ts +3 -3
  106. package/dist/types/utils/mergeWith/mergeWithCore.d.ts +8 -3
  107. package/dist/types/utils/retryable.d.ts +34 -0
  108. package/dist/types/utils/throttling/throttle.d.ts +36 -0
  109. package/dist/types/utils.d.ts +100 -194
  110. package/package.json +7 -3
  111. package/src/ChannelManager.ts +724 -0
  112. package/src/CooldownTimer.ts +2 -2
  113. package/src/EventHandlerPipeline.ts +228 -0
  114. package/src/LiveLocationManager.ts +12 -11
  115. package/src/api-client.ts +275 -0
  116. package/src/campaign.ts +1 -77
  117. package/src/channel.ts +1344 -1282
  118. package/src/channel_batch_updater.ts +1 -211
  119. package/src/channel_state.ts +144 -1013
  120. package/src/client.ts +936 -3995
  121. package/src/client_state.ts +4 -4
  122. package/src/configuration/InstanceConfigurationService.ts +73 -0
  123. package/src/configuration/index.ts +1 -0
  124. package/src/configuration/types.ts +81 -0
  125. package/src/connection.ts +145 -94
  126. package/src/connection_fallback.ts +29 -27
  127. package/src/constants.ts +1 -5
  128. package/src/custom_types.ts +4 -1
  129. package/src/entityStore/EntityStore.ts +208 -0
  130. package/src/entityStore/StoreBackedItemIndex.ts +115 -0
  131. package/src/entityStore/applyReactionLocally.ts +113 -0
  132. package/src/entityStore/index.ts +2 -0
  133. package/src/errors.ts +14 -2
  134. package/src/gen/chat/ChannelApi.ts +275 -0
  135. package/src/gen/chat/ChatApi.ts +2554 -0
  136. package/src/gen/model-decoders/decoders.ts +2680 -0
  137. package/src/gen/model-decoders/event-decoder-mapping.ts +198 -0
  138. package/src/gen/models/index.ts +12673 -0
  139. package/src/gen/moderation/ModerationApi.ts +607 -0
  140. package/src/gen-imports.ts +3 -0
  141. package/src/index.ts +11 -12
  142. package/src/insights.ts +6 -5
  143. package/src/logger.ts +28 -0
  144. package/src/messageComposer/LocationComposer.ts +9 -5
  145. package/src/messageComposer/MessageComposerEffectHandlers.ts +1 -0
  146. package/src/messageComposer/attachmentIdentity.ts +16 -9
  147. package/src/messageComposer/attachmentManager.ts +13 -8
  148. package/src/messageComposer/configuration/types.ts +11 -11
  149. package/src/messageComposer/linkPreviewsManager.ts +9 -10
  150. package/src/messageComposer/messageComposer.ts +57 -53
  151. package/src/messageComposer/middleware/messageComposer/attachments.ts +1 -2
  152. package/src/messageComposer/middleware/messageComposer/cleanData.ts +1 -1
  153. package/src/messageComposer/middleware/messageComposer/compositionValidation.ts +2 -2
  154. package/src/messageComposer/middleware/messageComposer/messageComposerState.ts +2 -2
  155. package/src/messageComposer/middleware/messageComposer/sharedLocation.ts +4 -3
  156. package/src/messageComposer/middleware/messageComposer/textComposer.ts +2 -2
  157. package/src/messageComposer/middleware/messageComposer/types.ts +3 -4
  158. package/src/messageComposer/middleware/messageComposer/userDataInjection.ts +9 -5
  159. package/src/messageComposer/middleware/pollComposer/state.ts +0 -2
  160. package/src/messageComposer/middleware/pollComposer/types.ts +4 -7
  161. package/src/messageComposer/middleware/textComposer/TextComposerMiddlewareExecutor.ts +10 -1
  162. package/src/messageComposer/middleware/textComposer/commandEffects.ts +4 -4
  163. package/src/messageComposer/middleware/textComposer/commandUtils.ts +3 -6
  164. package/src/messageComposer/middleware/textComposer/commands.ts +3 -3
  165. package/src/messageComposer/middleware/textComposer/mentionUtils.ts +1 -1
  166. package/src/messageComposer/middleware/textComposer/mentions.ts +31 -15
  167. package/src/messageComposer/middleware/textComposer/types.ts +9 -4
  168. package/src/messageComposer/pollComposer.ts +6 -8
  169. package/src/messageComposer/textComposer.ts +27 -2
  170. package/src/messageComposer/types.ts +6 -28
  171. package/src/messageDelivery/MessageDeliveryReporter.ts +101 -45
  172. package/src/messageDelivery/MessageReceiptsTracker.ts +294 -21
  173. package/src/messageOperations/MessageOperationStatePolicy.ts +77 -0
  174. package/src/messageOperations/MessageOperations.ts +212 -0
  175. package/src/messageOperations/index.ts +10 -0
  176. package/src/messageOperations/types.ts +64 -0
  177. package/src/moderation.ts +38 -431
  178. package/src/notifications/types.ts +8 -7
  179. package/src/offline-support/offline_support_api.ts +200 -133
  180. package/src/offline-support/offline_sync_manager.ts +23 -44
  181. package/src/offline-support/types.ts +23 -26
  182. package/src/offline-support/util.ts +5 -4
  183. package/src/pagination/FilterBuilder.ts +4 -1
  184. package/src/pagination/ItemIndex.ts +25 -0
  185. package/src/pagination/cursorDerivation/createdAtAroundPaginationFlags.ts +73 -0
  186. package/src/pagination/cursorDerivation/idAroundPaginationFlags.ts +53 -0
  187. package/src/pagination/cursorDerivation/index.ts +1 -0
  188. package/src/pagination/cursorDerivation/linearPaginationFlags.ts +86 -0
  189. package/src/pagination/filterCompiler.ts +196 -0
  190. package/src/pagination/index.ts +1 -3
  191. package/src/pagination/paginators/BasePaginator.ts +2859 -0
  192. package/src/pagination/paginators/ChannelPaginator.ts +729 -0
  193. package/src/pagination/paginators/MessageIntervalPaginator.ts +1116 -0
  194. package/src/pagination/paginators/MessagePaginator.ts +508 -0
  195. package/src/pagination/paginators/PinnedMessagePaginator.ts +108 -0
  196. package/src/pagination/paginators/ReminderPaginator.ts +107 -0
  197. package/src/pagination/paginators/UserGroupPaginator.ts +127 -0
  198. package/src/pagination/paginators/index.ts +7 -0
  199. package/src/pagination/paginators/stateThrottling.ts +31 -0
  200. package/src/pagination/sortCompiler.ts +193 -0
  201. package/src/pagination/types.normalization.ts +14 -0
  202. package/src/pagination/utility.normalization.ts +106 -0
  203. package/src/pagination/utility.queryChannel.ts +84 -0
  204. package/src/pagination/utility.search.ts +80 -0
  205. package/src/permissions.ts +7 -8
  206. package/src/poll.ts +101 -110
  207. package/src/poll_manager.ts +14 -8
  208. package/src/reminders/Reminder.ts +2 -5
  209. package/src/reminders/ReminderManager.ts +19 -21
  210. package/src/search/BaseSearchSource.ts +0 -3
  211. package/src/search/ChannelMemberSearchSource.ts +7 -1
  212. package/src/search/ChannelSearchSource.ts +10 -3
  213. package/src/search/MessageSearchSource.ts +29 -30
  214. package/src/search/UserSearchSource.ts +12 -8
  215. package/src/search/types.ts +3 -4
  216. package/src/segment.ts +1 -95
  217. package/src/signing.ts +107 -67
  218. package/src/store.ts +2 -1
  219. package/src/thread.ts +562 -238
  220. package/src/thread_manager.ts +37 -16
  221. package/src/token_manager.ts +21 -10
  222. package/src/types.ts +364 -4533
  223. package/src/uploadManager.ts +8 -0
  224. package/src/utils/FixedSizeQueueCache.ts +12 -6
  225. package/src/utils/WithSubscriptions.ts +2 -1
  226. package/src/utils/concurrency.ts +4 -4
  227. package/src/utils/mergeWith/mergeWith.ts +3 -3
  228. package/src/utils/mergeWith/mergeWithCore.ts +140 -164
  229. package/src/utils/mergeWith/mergeWithDiff.ts +3 -3
  230. package/src/utils/retryable.ts +117 -0
  231. package/src/utils/throttling/throttle.ts +130 -0
  232. package/src/utils.ts +208 -754
  233. package/dist/types/channel_manager.d.ts +0 -122
  234. package/dist/types/events.d.ts +0 -72
  235. package/dist/types/pagination/BasePaginator.d.ts +0 -69
  236. package/dist/types/pagination/ReminderPaginator.d.ts +0 -16
  237. package/dist/types/pagination/UserGroupPaginator.d.ts +0 -21
  238. package/src/channel_manager.ts +0 -830
  239. package/src/events.ts +0 -75
  240. package/src/pagination/BasePaginator.ts +0 -184
  241. package/src/pagination/ReminderPaginator.ts +0 -56
  242. package/src/pagination/UserGroupPaginator.ts +0 -93
@@ -0,0 +1,508 @@
1
+ import type {
2
+ ExecuteQueryReturnValue,
3
+ Interval,
4
+ PostQueryReconcileParams,
5
+ } from './BasePaginator';
6
+ import {
7
+ type MessagePaginatorOptions as BaseMessagePaginatorOptions,
8
+ getMessageCreatedAtTimestamp,
9
+ type JumpToMessageOptions,
10
+ MessageIntervalPaginator,
11
+ type MessageQueryShape,
12
+ } from './MessageIntervalPaginator';
13
+ import type { LocalMessage } from '../../types';
14
+ import { StateStore } from '../../store';
15
+
16
+ export type {
17
+ JumpToMessageOptions,
18
+ MessageFocusReason,
19
+ MessageFocusSignal,
20
+ MessageFocusSignalState,
21
+ MessagePaginatorFilter,
22
+ MessagePaginatorSort,
23
+ MessagePaginatorState,
24
+ MessageQueryShape,
25
+ } from './MessageIntervalPaginator';
26
+ export { MessageIntervalPaginator } from './MessageIntervalPaginator';
27
+
28
+ /**
29
+ * Auxiliary (non-pagination) state for the message paginator: whole-collection aggregates that are
30
+ * independent of the active pagination window (the dual of pagination — values over the entire set,
31
+ * not a page of it). `lastMessageAt` is effectively `MAX(created_at)` over the channel-relevant
32
+ * messages and is the source of truth for channel-list ordering
33
+ * (`channel.messagePaginator.lastMessageAt`): seeded from `ChannelResponse.last_message_at`, then
34
+ * advanced monotonically as newer messages are ingested.
35
+ */
36
+ export type MessagePaginatorAggregateState = {
37
+ /**
38
+ * The newest channel-relevant message, for display (e.g. a channel/thread list item's last-message
39
+ * preview or latest-reply avatar). A LIVE reference: it advances to a strictly newer message, is
40
+ * refreshed in place when that message is edited/soft-deleted/reacted-to, and is recomputed to the
41
+ * next newest when it is hard-removed. Respects `shouldAdvanceLastMessage` (skips system messages
42
+ * per `skip_last_msg_update_for_system_msgs`, and thread-only replies). `null` until one is ingested.
43
+ *
44
+ * Lives here, NOT derived from pagination `state`, so it stays reactive when a WS message lands in
45
+ * the head interval while an older window is active — the pagination store only emits when the
46
+ * active* interval is impacted (see `BasePaginator.ingestItem`), so a `state`-derived latest would
47
+ * go stale in that case.
48
+ */
49
+ lastMessage: LocalMessage | null;
50
+ /**
51
+ * Server-provided `ChannelResponse.last_message_at` floor, for channels whose newest message is not
52
+ * loaded (e.g. surfaced by a channel-list query). Kept SEPARATE from {@link lastMessage} so it can
53
+ * outrank a stale/absent loaded message for sorting without overwriting the display message. The
54
+ * sort key {@link MessagePaginator.lastMessageAt} is derived as the max of the two, so the two can
55
+ * never drift out of sync.
56
+ */
57
+ seededLastMessageAt: Date | null;
58
+ };
59
+
60
+ export type MessagePaginatorOptions = BaseMessagePaginatorOptions & {
61
+ /**
62
+ * Controls whether `jumpToTheFirstUnreadMessage()` should prefer the `unreadStateSnapshot`
63
+ * state over `channel.state.read[...]`.
64
+ *
65
+ * - 'snapshot' (default): retrieve the first unread message id from the unreadStateSnapshot state when jumping to the first unread message
66
+ * - 'read-state-only': retrieve the last read message id from the channel read state when jumping to the first unread message
67
+ */
68
+ unreadReferencePolicy?: 'snapshot' | 'read-state-only';
69
+ };
70
+
71
+ export type UnreadSnapshotState = {
72
+ lastReadAt: Date | null;
73
+ unreadCount: number;
74
+ /**
75
+ * Snapshot of the first unread message id for the user.
76
+ * This is intentionally decoupled from `channel.state.read[...]` because apps
77
+ * may mark the channel read immediately on open, while still wanting to render
78
+ * UI indicators that jump to the previously-unread location.
79
+ */
80
+ firstUnreadMessageId: string | null;
81
+ /**
82
+ * Snapshot of the last read message id for the user (fallback when first unread
83
+ * is not known).
84
+ */
85
+ lastReadMessageId: string | null;
86
+ };
87
+
88
+ /**
89
+ * External, UI-driven signal: `true` while the user is actively viewing the latest messages of
90
+ * this collection (app foregrounded AND the newest message on screen). The owning SDK sets it — the
91
+ * state layer has no viewport. When live, an incoming message is NOT counted as unread (the count /
92
+ * snapshot bump is skipped), so the "N new" separator/banner never flash for a message the user is
93
+ * already looking at. Defaults to `false` (assume not viewing until the SDK proves otherwise).
94
+ */
95
+ export type LiveViewState = {
96
+ isViewingLive: boolean;
97
+ };
98
+
99
+ /**
100
+ * MessagePaginator extends {@link MessageIntervalPaginator} with the unread/live-view concern:
101
+ * an independent unread reference snapshot, the UI-driven "viewing the latest messages" signal, and
102
+ * the "jump to first unread" navigation built on top of them.
103
+ */
104
+ export class MessagePaginator extends MessageIntervalPaginator {
105
+ private unreadReferencePolicy: 'snapshot' | 'read-state-only';
106
+ /**
107
+ * Independent unread reference state (not tied to `channel.state.read`).
108
+ * Consumers may set this right before calling markRead / when opening a channel.
109
+ */
110
+ readonly unreadStateSnapshot: StateStore<UnreadSnapshotState>;
111
+ /**
112
+ * UI-driven "viewing the latest messages" signal (see {@link LiveViewState}). Set by the SDK via
113
+ * {@link setViewingLive}; read by the channel to gate the unread bump on `message.new`. Subscribe to
114
+ * this store for reactivity, or read the current boolean directly via the {@link isViewingLive} getter.
115
+ */
116
+ readonly liveViewState: StateStore<LiveViewState>;
117
+ /**
118
+ * Auxiliary (non-pagination) state — see {@link MessagePaginatorAggregateState}. A store separate
119
+ * from `state` so `lastMessageAt` can be advanced from inside a `state.next` updater
120
+ * (`ingestPage`) without being clobbered, and so consumers subscribe to a quiet signal that only
121
+ * emits when the aggregate actually changes (not on every scroll/pagination emission). This is
122
+ * distinct from the base's `intervalViews` interval-projection store.
123
+ */
124
+ readonly aggregateState: StateStore<MessagePaginatorAggregateState>;
125
+
126
+ constructor({
127
+ unreadReferencePolicy = 'snapshot',
128
+ ...options
129
+ }: MessagePaginatorOptions) {
130
+ super({
131
+ ...options,
132
+ paginatorOptions: {
133
+ // Throttle message-list `state` publishes to at most once per 500ms (leading + trailing), so a
134
+ // burst of events coalesces into ~2 renders/sec instead of one per event. Optimistic
135
+ // (local-user) writes bypass the throttle via EntityStore.flushSubscribers → flushState.
136
+ // Overridable per-instance via `paginatorOptions.stateThrottleMs`.
137
+ stateThrottleMs: 500,
138
+ ...options.paginatorOptions,
139
+ },
140
+ // NB: the store-backed item index is provided by MessageIntervalPaginator (the common
141
+ // ancestor), so both the main list and the pinned list share the client-global message store.
142
+ });
143
+ this.unreadReferencePolicy = unreadReferencePolicy;
144
+ this.unreadStateSnapshot = new StateStore<UnreadSnapshotState>({
145
+ lastReadAt: null,
146
+ firstUnreadMessageId: null,
147
+ lastReadMessageId: null,
148
+ unreadCount: 0,
149
+ });
150
+ this.liveViewState = new StateStore<LiveViewState>({
151
+ isViewingLive: false,
152
+ });
153
+ this.aggregateState = new StateStore<MessagePaginatorAggregateState>({
154
+ lastMessage: null,
155
+ seededLastMessageAt: null,
156
+ });
157
+ }
158
+
159
+ /**
160
+ * Channel-list sort key: the later of the newest loaded message's `created_at` and the server seed.
161
+ * **Derived** (never stored) so it cannot drift from {@link lastMessage}. `null` until seeded or a
162
+ * message is ingested.
163
+ */
164
+ get lastMessageAt(): Date | null {
165
+ const { lastMessage, seededLastMessageAt } = this.aggregateState.getLatestValue();
166
+ const fromMessage =
167
+ lastMessage?.created_at instanceof Date ? lastMessage.created_at : null;
168
+ if (fromMessage && seededLastMessageAt) {
169
+ return fromMessage >= seededLastMessageAt ? fromMessage : seededLastMessageAt;
170
+ }
171
+ return fromMessage ?? seededLastMessageAt;
172
+ }
173
+
174
+ /**
175
+ * The newest channel-relevant message (the monotonic tracked latest), for display. Convenience read
176
+ * of {@link aggregateState}; subscribe to `aggregateState` for reactivity. `null` until a message is
177
+ * ingested (a server-only seed advances {@link lastMessageAt} but leaves this `null`).
178
+ */
179
+ get lastMessage(): LocalMessage | null {
180
+ return this.aggregateState.getLatestValue().lastMessage;
181
+ }
182
+
183
+ /**
184
+ * The main channel list's notion of "latest message" for `channel.state.last_message_at`: on top of
185
+ * the base rule (not shadowed) this excludes thread-only replies (a reply with a `parent_id` that is
186
+ * not shown in the channel is not part of the channel list) and, when the channel is configured with
187
+ * `skip_last_msg_update_for_system_msgs`, system messages. Mirrors the legacy
188
+ * `Channel._trackLastMessage` skip logic.
189
+ *
190
+ * These exclusions are specific to the MAIN channel list. A reply list (thread paginator, which has
191
+ * a `parentMessageId`) is made ENTIRELY of thread replies — there the newest reply is exactly the
192
+ * "latest message", so the exclusions are skipped and only the base rule applies.
193
+ */
194
+ protected shouldAdvanceLastMessage(message: LocalMessage): boolean {
195
+ if (message.shadowed) return false;
196
+ if (this.parentMessageId) return true;
197
+ const isThreadOnlyReply = !!message.parent_id && !message.show_in_channel;
198
+ if (isThreadOnlyReply) return false;
199
+ const skipSystemMessage =
200
+ !!this.channel.getConfig?.()?.skip_last_msg_update_for_system_msgs &&
201
+ message.type === 'system';
202
+ return !skipSystemMessage;
203
+ }
204
+
205
+ /**
206
+ * Monotonically advance {@link lastMessageAt} to `message.created_at` when it is newer than the
207
+ * current value and {@link shouldAdvanceLastMessage} permits it. Writes the dedicated
208
+ * {@link aggregateState} store (not `state`), so it is safe to call from inside a `state.next`
209
+ * updater (`ingestPage`). Returns `true` when the value moved.
210
+ *
211
+ * Called internally on ingest; also public for callers that advance the aggregate without a normal
212
+ * in-window ingest — e.g. offline pending-message replay, where the sent message has not yet been
213
+ * ingested via the `message.new` event.
214
+ */
215
+ trackLastMessage(message: LocalMessage): boolean {
216
+ if (!this.shouldAdvanceLastMessage(message)) return false;
217
+ const incoming = getMessageCreatedAtTimestamp(message);
218
+ if (incoming === null) return false;
219
+ const current = this.aggregateState.getLatestValue().lastMessage;
220
+ // Refresh in place when this IS the current latest being updated — an edit, soft-delete,
221
+ // reaction, or quoted-message update all re-ingest the same id (same `created_at`). This keeps
222
+ // `lastMessage` a LIVE reference (so a preview shows "deleted"/edited text), and because
223
+ // `aggregateState` emits regardless of the active interval, it stays reactive off-window.
224
+ if (current && current.id === message.id) {
225
+ this.aggregateState.partialNext({ lastMessage: message });
226
+ return true;
227
+ }
228
+ // Otherwise only advance to a strictly newer message. Guard against the current message's own
229
+ // timestamp — NOT `lastMessageAt` (which the server seed can inflate); otherwise a seed newer than
230
+ // the loaded window would reject the very message it was derived from.
231
+ const currentTs = current ? getMessageCreatedAtTimestamp(current) : null;
232
+ if (currentTs !== null && incoming <= currentTs) return false;
233
+ this.aggregateState.partialNext({ lastMessage: message });
234
+ return true;
235
+ }
236
+
237
+ /**
238
+ * Seed {@link lastMessageAt} from the server-provided `ChannelResponse.last_message_at`, the
239
+ * authoritative whole-channel aggregate. Monotonic: a no-op when the paginator already advanced
240
+ * past it (e.g. from ingested messages), so seed order does not matter.
241
+ */
242
+ seedLastMessageAt(value: string | Date | null | undefined) {
243
+ if (!value) return;
244
+ const date = value instanceof Date ? value : new Date(value);
245
+ const timestamp = date.getTime();
246
+ if (!Number.isFinite(timestamp)) return;
247
+ const current = this.aggregateState.getLatestValue().seededLastMessageAt;
248
+ if (current && timestamp <= current.getTime()) return;
249
+ this.aggregateState.partialNext({ seededLastMessageAt: date });
250
+ }
251
+
252
+ ingestItem(item: LocalMessage): boolean {
253
+ // Only items that survive the filter advance the aggregate (`ingestItem` also handles removals
254
+ // of items that no longer match). The advance writes the separate `aggregateState` store, so it
255
+ // is independent of `super`'s `state`/index mutations.
256
+ if (this.matchesFilter(item)) {
257
+ this.trackLastMessage(item);
258
+ }
259
+ return super.ingestItem(item);
260
+ }
261
+
262
+ ingestPage(
263
+ params: Parameters<MessageIntervalPaginator['ingestPage']>[0],
264
+ ): Interval | null {
265
+ const interval = super.ingestPage(params);
266
+ // Advance from each page item; the monotonic max over the page yields the newest. Comparison is
267
+ // by `created_at` against `aggregateState` (no index lookup), so order vs `super` is irrelevant.
268
+ if (params.page?.length) {
269
+ for (const item of params.page) this.trackLastMessage(item);
270
+ }
271
+ return interval;
272
+ }
273
+
274
+ removeItem(
275
+ params: Parameters<MessageIntervalPaginator['removeItem']>[0],
276
+ ): ReturnType<MessageIntervalPaginator['removeItem']> {
277
+ const removedId =
278
+ params.id ?? (params.item ? this.getItemId(params.item) : undefined);
279
+ const wasLatest =
280
+ !!removedId && this.aggregateState.getLatestValue().lastMessage?.id === removedId;
281
+ const result = super.removeItem(params);
282
+ if (wasLatest) {
283
+ // The tracked latest was hard-removed; fall back to the newest still-loaded message that
284
+ // passes the filter. `trackLastMessage` cannot do this (it only advances), so recompute here.
285
+ this.aggregateState.partialNext({ lastMessage: this.recomputeLastMessage() });
286
+ }
287
+ return result;
288
+ }
289
+
290
+ /**
291
+ * The still-loaded message with the greatest `created_at` that passes
292
+ * {@link shouldAdvanceLastMessage} (skips system / thread-only per config), from the newest-loaded
293
+ * window. Used to recompute the tracked latest after the current one is removed. `null` when nothing
294
+ * loaded qualifies.
295
+ *
296
+ * "Latest" is defined by `created_at` (matching {@link trackLastMessage}), NOT by the paginator's
297
+ * display order — so this compares timestamps rather than assuming a position, and stays correct
298
+ * whatever `itemOrder` / `requestSort` are configured to.
299
+ */
300
+ private recomputeLastMessage(): LocalMessage | null {
301
+ let latest: LocalMessage | null = null;
302
+ let latestTimestamp = -Infinity;
303
+ for (const item of this.headItems) {
304
+ if (!this.shouldAdvanceLastMessage(item)) continue;
305
+ const timestamp = getMessageCreatedAtTimestamp(item);
306
+ if (timestamp === null || timestamp <= latestTimestamp) continue;
307
+ latest = item;
308
+ latestTimestamp = timestamp;
309
+ }
310
+ return latest;
311
+ }
312
+
313
+ /**
314
+ * (Re)seed the unread state snapshot from the current own read state.
315
+ *
316
+ * Called after the first-page query, and can be called again by the SDK whenever a channel is
317
+ * (re)opened from a cached paginator — where no fresh first-page query runs — so the snapshot
318
+ * reflects the CURRENT read boundary rather than one frozen at the very first open. Without a
319
+ * re-seed the separator position, unread count and "jump to first unread" target all go stale
320
+ * across reopens.
321
+ *
322
+ * `firstUnreadMessageId` is intentionally reset to `null`: it represents an EXPLICIT mark-unread
323
+ * (set by `notification.mark_unread`), not a live/computed boundary. Persisting a computed value
324
+ * here would make the channel look explicitly marked-unread and suppress auto-mark-read.
325
+ */
326
+ seedUnreadSnapshot = () => {
327
+ // A paginator query (BasePaginator.executeQuery) awaits the network before running its
328
+ // synchronous postQueryReconcile, which calls this on the first page. If the channel was
329
+ // disconnected while that request was in flight, reading the client below throws ("You can't
330
+ // use a channel after client.disconnect()"), so guard against that.
331
+ if (this.channel.disconnected) return;
332
+ const ownUserId = this.channel.getClient().user?.id;
333
+ const ownReadState = ownUserId ? this.channel.state.read[ownUserId] : undefined;
334
+ if (!ownReadState) return;
335
+ this.setUnreadSnapshot({
336
+ firstUnreadMessageId: null,
337
+ lastReadAt: ownReadState.last_read ?? null,
338
+ lastReadMessageId: ownReadState.last_read_message_id ?? null,
339
+ unreadCount: ownReadState.unread_messages ?? 0,
340
+ });
341
+ };
342
+
343
+ /**
344
+ * Invokes super.postQueryReconcile() and takes an unread state snapshot on the first-page query.
345
+ * The snapshot has to be taken immediately after the query as the viewed channel is marked read
346
+ * immediately after opening it. The snapshot can be used to display unread UI indicators.
347
+ */
348
+ postQueryReconcile(
349
+ params: PostQueryReconcileParams<LocalMessage, MessageQueryShape>,
350
+ ): ExecuteQueryReturnValue<LocalMessage> {
351
+ const result = super.postQueryReconcile(params);
352
+
353
+ if (params.isFirstPage) {
354
+ this.seedUnreadSnapshot();
355
+ }
356
+ return result;
357
+ }
358
+
359
+ /**
360
+ * Jumps to the unread reference message.
361
+ *
362
+ * IMPORTANT: This intentionally does *not* rely on `channel.state.read[ownUserId]` only,
363
+ * because apps may mark a channel read immediately after opening it, while still
364
+ * wanting to keep "jump to unread" UI indicators alive (based on a snapshot).
365
+ *
366
+ * Resolution order:
367
+ * 1) explicit first-unread id from snapshot/read-state (mark-unread) — jump straight to it
368
+ * 2) last-read timestamp: infer the FIRST UNREAD message from it — using the already-loaded
369
+ * window when it straddles the boundary, otherwise a `created_at_around` query — and jump there
370
+ * 3) last read id from snapshot/read-state (last-resort fallback when no timestamp is available)
371
+ *
372
+ * The timestamp inference (2) is preferred over jumping to the last-read id so that the jump lands
373
+ * ON (and highlights) the first unread message rather than the last read one. The last-read id is
374
+ * only used as a fallback when we cannot infer an unread boundary. The inference is NOT persisted
375
+ * back into the snapshot (that is reserved for explicit mark-unread).
376
+ */
377
+ jumpToTheFirstUnreadMessage = async (options?: JumpToMessageOptions) => {
378
+ const ownUserId = this.channel.getClient().user?.id;
379
+ if (!ownUserId) return false;
380
+
381
+ const unreadSnapshot =
382
+ this.unreadReferencePolicy === 'snapshot'
383
+ ? this.unreadStateSnapshot.getLatestValue()
384
+ : { firstUnreadMessageId: null, lastReadAt: null, lastReadMessageId: null };
385
+ const firstUnreadFromSnapshot = unreadSnapshot.firstUnreadMessageId;
386
+ const lastReadAtFromSnapshot = unreadSnapshot.lastReadAt;
387
+ const lastReadIdFromSnapshot = unreadSnapshot.lastReadMessageId;
388
+
389
+ const ownReadState = this.channel.state.read[ownUserId];
390
+ const firstUnreadFromReadState = ownReadState?.first_unread_message_id ?? null;
391
+ const lastReadAtFromReadState = ownReadState?.last_read ?? null;
392
+ const lastReadIdFromReadState = ownReadState?.last_read_message_id ?? null;
393
+
394
+ // 1) A stable first-unread id is known (mark-unread, or a previously persisted inference):
395
+ // jump straight to it.
396
+ const firstUnreadMessageId = firstUnreadFromSnapshot ?? firstUnreadFromReadState;
397
+ if (firstUnreadMessageId) {
398
+ return await this.jumpToMessage(firstUnreadMessageId, {
399
+ ...options,
400
+ focusReason: 'jump-to-first-unread',
401
+ });
402
+ }
403
+
404
+ const lastReadMessageId = lastReadIdFromSnapshot ?? lastReadIdFromReadState;
405
+ // Prefer the SNAPSHOT timestamp over the fresh read-state one. The snapshot is the frozen UI
406
+ // boundary the "N new" separator/banner render from, so a banner tap must jump to where that
407
+ // indicator points. On (re)open the SDK marks the channel read (`markReadOnMount`) which advances
408
+ // the read-state `last_read` to ~now and clears the server unread — so preferring read-state
409
+ // would make a later banner tap infer "nothing unread" and jump to the latest message. The
410
+ // snapshot is kept fresh on genuine catch-up (the SDK's mark-read resets it), so it is only
411
+ // "frozen" precisely while there are unreads to jump to.
412
+ const lastReadAt = lastReadAtFromSnapshot ?? lastReadAtFromReadState;
413
+
414
+ // 2) No explicit first-unread id, but we know when the channel was last read. Infer the first
415
+ // unread message from that timestamp so we land ON (and highlight) the first unread message
416
+ // instead of the last read one. Prefer the already-loaded window (the common "a few unreads at
417
+ // the bottom" case — no extra request); only query a page around last-read when the loaded
418
+ // window does not straddle the boundary (i.e. every loaded message is newer than last-read).
419
+ //
420
+ // We deliberately do NOT persist the inferred boundary back into the snapshot: writing
421
+ // `firstUnreadMessageId` would make the channel look explicitly marked-unread and suppress
422
+ // auto-mark-read at the bottom. The separator reads the (re-seeded) snapshot directly.
423
+ if (lastReadAt) {
424
+ let {
425
+ firstUnreadMessageId: inferredFirstUnreadMessageId,
426
+ lastReadMessageId: inferredLastReadMessageId,
427
+ } = this.resolveUnreadBoundaryIdsByTimestamp({
428
+ lastReadAt,
429
+ messages: this.state.getLatestValue().items ?? [],
430
+ });
431
+
432
+ if (!inferredLastReadMessageId) {
433
+ const result = await this.executeQuery({
434
+ queryShape: {
435
+ created_at_around: lastReadAt.toISOString(),
436
+ limit: options?.pageSize,
437
+ },
438
+ updateState: false,
439
+ });
440
+ if (result) {
441
+ ({
442
+ firstUnreadMessageId: inferredFirstUnreadMessageId,
443
+ lastReadMessageId: inferredLastReadMessageId,
444
+ } = this.resolveUnreadBoundaryIdsByTimestamp({
445
+ lastReadAt,
446
+ messages: result.stateCandidate.items ?? [],
447
+ }));
448
+ }
449
+ }
450
+
451
+ const targetMessageId =
452
+ inferredFirstUnreadMessageId ?? inferredLastReadMessageId ?? lastReadMessageId;
453
+ if (targetMessageId) {
454
+ return await this.jumpToMessage(targetMessageId, {
455
+ ...options,
456
+ focusReason: 'jump-to-first-unread',
457
+ });
458
+ }
459
+ }
460
+
461
+ // 3) Last resort: jump to the known last-read message when that is all we have.
462
+ if (lastReadMessageId) {
463
+ return await this.jumpToMessage(lastReadMessageId, {
464
+ ...options,
465
+ focusReason: 'jump-to-first-unread',
466
+ });
467
+ }
468
+
469
+ return false;
470
+ };
471
+
472
+ setUnreadSnapshot = (next: Partial<UnreadSnapshotState>): UnreadSnapshotState => {
473
+ this.unreadStateSnapshot.partialNext(next);
474
+ return this.unreadStateSnapshot.getLatestValue();
475
+ };
476
+
477
+ /**
478
+ * Set the UI-driven "viewing the latest messages" signal (see {@link LiveViewState}). Called by
479
+ * the SDK from its message-list viewability + app-state tracking. No-ops when unchanged.
480
+ *
481
+ * Intentionally NOT reset by `clearStateAndCache` — it is an external input owned by the SDK, not
482
+ * derived pagination state.
483
+ */
484
+ /** Current "viewing the latest messages" boolean (convenience read of {@link liveViewState}). */
485
+ get isViewingLive(): boolean {
486
+ return this.liveViewState.getLatestValue().isViewingLive;
487
+ }
488
+
489
+ setViewingLive = (isViewingLive: boolean) => {
490
+ if (this.isViewingLive === isViewingLive) return;
491
+ this.liveViewState.next({ isViewingLive });
492
+ };
493
+
494
+ clearUnreadSnapshot = () => {
495
+ this.unreadStateSnapshot.next({
496
+ firstUnreadMessageId: null,
497
+ lastReadMessageId: null,
498
+ lastReadAt: null,
499
+ unreadCount: 0,
500
+ });
501
+ };
502
+
503
+ clearStateAndCache() {
504
+ super.clearStateAndCache();
505
+ this.clearUnreadSnapshot();
506
+ this.aggregateState.next({ lastMessage: null, seededLastMessageAt: null });
507
+ }
508
+ }
@@ -0,0 +1,108 @@
1
+ import type { PaginatorCursor, PaginatorOptions } from './BasePaginator';
2
+ import {
3
+ MessageIntervalPaginator,
4
+ type MessageQueryShape,
5
+ } from './MessageIntervalPaginator';
6
+ import type {
7
+ LocalMessage,
8
+ PinnedMessagePaginationOptions,
9
+ SortParamRequest,
10
+ } from '../../types';
11
+ import type { Channel } from '../../channel';
12
+ import { formatMessage, generateUUIDv4 } from '../../utils';
13
+ import { makeComparator } from '../sortCompiler';
14
+ import { resolveDotPathValue } from '../utility.normalization';
15
+ import type { ItemIndexApi } from '../ItemIndex';
16
+
17
+ export type PinnedMessagePaginatorFilter = {
18
+ cid: string;
19
+ pinned: boolean;
20
+ };
21
+
22
+ export type PinnedMessagePaginatorOptions = {
23
+ channel: Channel;
24
+ id?: string;
25
+ itemIndex?: ItemIndexApi<LocalMessage>;
26
+ paginatorOptions?: PaginatorOptions<LocalMessage, MessageQueryShape>;
27
+ };
28
+
29
+ /**
30
+ * Pinned-message list paginator.
31
+ *
32
+ * Extends the unread-free {@link MessageIntervalPaginator} base — pinned messages are a subset of
33
+ * the channel and MUST NOT participate in read/unread or delivery-receipt tracking (that belongs to
34
+ * the channel and thread message timelines). By extending the base rather than {@link MessagePaginator}
35
+ * it simply never gets the unread surface.
36
+ *
37
+ * Differences from the main list:
38
+ * - fetches from the `/pinned_messages` endpoint (`channel.getPinnedMessages`) rather than
39
+ * `channel.query({ messages })`;
40
+ * - includes only pinned, non-shadowed messages (`shouldIncludeMessageInInterval`), and filters on
41
+ * `{ cid, pinned: true }` so `ingestItem` auto-adds on pin and auto-removes on unpin;
42
+ * - orders by `pinned_at` ascending (oldest-pinned first), matching the legacy
43
+ * `channel.state.pinnedMessages` order.
44
+ *
45
+ * Navigation (`jumpToMessage` / `jumpToTheLatestMessage`) is inherited and meaningful — the endpoint
46
+ * supports `id_around` — but no unread-coupled navigation exists.
47
+ */
48
+ export class PinnedMessagePaginator extends MessageIntervalPaginator {
49
+ constructor({
50
+ channel,
51
+ id,
52
+ itemIndex,
53
+ paginatorOptions,
54
+ }: PinnedMessagePaginatorOptions) {
55
+ super({
56
+ channel,
57
+ id: id ?? `pinned-message-paginator-${generateUUIDv4()}`,
58
+ // No explicit index: inherit the store-backed item index from MessageIntervalPaginator so a
59
+ // pinned message shares the single canonical copy with the main list / thread and reflects
60
+ // reactions & edits applied elsewhere. A caller may still inject a custom `itemIndex`.
61
+ itemIndex,
62
+ paginatorOptions,
63
+ });
64
+
65
+ // Order by pinned_at (ascending), overriding the base's created_at comparators. Ascending keeps
66
+ // the head edge (most-recently-pinned) at the end of an interval, matching the base's interval
67
+ // direction getters (which are shared with created_at-asc semantics).
68
+ const tiebreaker = (l: LocalMessage, r: LocalMessage) => {
69
+ const leftId = this.getItemId(l);
70
+ const rightId = this.getItemId(r);
71
+ return leftId < rightId ? -1 : leftId > rightId ? 1 : 0;
72
+ };
73
+ const pinnedAtSort: SortParamRequest[] = [{ field: 'pinned_at', direction: 1 }];
74
+ this.sortComparator = makeComparator<LocalMessage>({
75
+ sort: pinnedAtSort,
76
+ resolvePathValue: resolveDotPathValue,
77
+ tiebreaker,
78
+ });
79
+ this.config.itemOrderComparator = makeComparator<LocalMessage>({
80
+ sort: pinnedAtSort,
81
+ resolvePathValue: resolveDotPathValue,
82
+ tiebreaker,
83
+ });
84
+
85
+ // Fetch from the pinned-messages endpoint. The base `query` feeds the resolved query shape
86
+ // (including `id_around` jumps) here as `options`; we return both cursors and let the base gate
87
+ // them by direction.
88
+ this.config.doRequest = async (
89
+ options: MessageQueryShape,
90
+ ): Promise<{ cursor?: PaginatorCursor; items: LocalMessage[] }> => {
91
+ const { messages } = await this.channel.getPinnedMessages(
92
+ options as PinnedMessagePaginationOptions,
93
+ [{ direction: 1, field: 'pinned_at' }],
94
+ );
95
+ const items = messages.map(formatMessage);
96
+ return { cursor: this.getCursorFromQueryResults({ items }), items };
97
+ };
98
+ }
99
+
100
+ buildMatchFilters = (): PinnedMessagePaginatorFilter => ({
101
+ cid: this.channel.cid,
102
+ pinned: true,
103
+ });
104
+
105
+ shouldIncludeMessageInInterval(message: LocalMessage): boolean {
106
+ return !message.shadowed && !!message.pinned;
107
+ }
108
+ }