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
package/src/channel.ts CHANGED
@@ -1,101 +1,163 @@
1
1
  import type { AxiosRequestConfig } from 'axios';
2
2
  import { ChannelState } from './channel_state';
3
3
  import { CooldownTimer } from './CooldownTimer';
4
+ import { isEphemeral } from './errors';
5
+ import { applyReactionLocally } from './entityStore';
4
6
  import { MessageComposer } from './messageComposer';
5
7
  import { MessageReceiptsTracker } from './messageDelivery';
8
+ import type { ReadStoreReconcileMeta } from './messageDelivery';
9
+ import { MessagePaginator, PinnedMessagePaginator } from './pagination/paginators';
10
+ import { MessageOperations } from './messageOperations';
6
11
  import {
7
12
  channelHasReadEvents,
13
+ formatMessage,
8
14
  generateChannelTempCid,
15
+ localMessageToNewMessagePayload,
9
16
  logChatPromiseExecution,
10
- messageSetPagination,
11
- normalizeQuerySort,
12
17
  } from './utils';
13
18
  import type { StreamChat } from './client';
19
+ import { chatLoggerSystem } from './logger';
14
20
  import { DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE } from './constants';
15
21
  import type {
16
22
  AIState,
17
23
  APIResponse,
18
- AscDesc,
19
24
  BanUserOptions,
20
- ChannelAPIResponse,
21
25
  ChannelData,
22
- ChannelFilters,
23
- ChannelMemberAPIResponse,
26
+ ChannelGetOrCreateRequest,
24
27
  ChannelMemberResponse,
25
- ChannelPushPreference,
26
- ChannelQueryOptions,
27
28
  ChannelResponse,
29
+ ChannelStateResponseFields,
28
30
  ChannelUpdateOptions,
29
31
  CreateDraftResponse,
30
- DeleteChannelAPIResponse,
31
- DraftMessagePayload,
32
+ DeleteMessageOptions,
32
33
  Event,
33
34
  EventAPIResponse,
34
35
  EventHandler,
35
- EventTypes,
36
- GetDraftResponse,
37
- GetMultipleMessagesAPIResponse,
38
- GetReactionsAPIResponse,
36
+ EventPayload,
37
+ EventType,
39
38
  GetRepliesAPIResponse,
40
- LiveLocationPayload,
39
+ GetRepliesRequest,
41
40
  LocalMessage,
42
- MarkReadOptions,
43
- MarkUnreadOptions,
44
- MemberFilters,
45
- MemberSort,
46
- Message,
47
- MessageFilters,
48
- MessageOptions,
41
+ MarkReadRequest,
42
+ MarkUnreadRequest,
49
43
  MessagePaginationOptions,
44
+ MessageRequest,
50
45
  MessageResponse,
51
46
  MessageSetType,
52
- MuteChannelAPIResponse,
53
- NewMemberPayload,
54
- PartialUpdateChannel,
55
- PartialUpdateChannelAPIResponse,
56
- PartialUpdateMember,
57
- PartialUpdateMemberAPIResponse,
58
47
  PinnedMessagePaginationOptions,
59
48
  PinnedMessagesSort,
60
- PollVoteData,
61
- QueryChannelAPIResponse,
62
- QueryMembersOptions,
63
- Reaction,
49
+ QueryMembersPayload,
64
50
  ReactionAPIResponse,
65
- SearchAPIResponse,
66
- SearchMessageSortBase,
67
- SearchOptions,
51
+ ReactionRequest,
68
52
  SearchPayload,
69
- SendMessageAPIResponse,
70
53
  SendMessageOptions,
71
- SendReactionOptions,
72
- StaticLocationPayload,
73
- TruncateChannelAPIResponse,
74
- TruncateOptions,
54
+ SendReactionRequest,
55
+ SharedLocation,
75
56
  UnBanUserOptions,
76
- UpdateChannelAPIResponse,
77
- UpdateChannelOptions,
78
- UpdateLocationPayload,
57
+ UpdateChannelPartialRequest,
58
+ UpdateLiveLocationRequest,
59
+ UpdateMessageOptions,
79
60
  UserResponse,
80
61
  } from './types';
81
- import type { Role } from './permissions';
82
- import type { CustomChannelData } from './custom_types';
62
+ import type { RoleName } from './permissions';
63
+ import { StateStore } from './store';
64
+ import type {
65
+ ChannelMemberRequest as Gen_ChannelMemberRequest,
66
+ ChannelPushPreferencesResponse as Gen_ChannelPushPreferencesResponse,
67
+ ChannelStopWatchingRequest as Gen_ChannelStopWatchingRequest,
68
+ CreateDraftRequest as Gen_CreateDraftRequest,
69
+ HideChannelRequest as Gen_HideChannelRequest,
70
+ MuteChannelRequest as Gen_MuteChannelRequest,
71
+ SendMessageRequest as Gen_SendMessageRequest,
72
+ ShowChannelRequest as Gen_ShowChannelRequest,
73
+ UnmuteChannelRequest as Gen_UnmuteChannelRequest,
74
+ UpdateChannelRequest as Gen_UpdateChannelRequest,
75
+ WSEvent,
76
+ } from './gen/models';
77
+ import type { ChatApi } from './gen/chat/ChatApi';
78
+ import { ChannelApi } from './gen/chat/ChannelApi';
79
+
80
+ const logger = chatLoggerSystem.getLogger('channel');
81
+ const offlineDbLogger = chatLoggerSystem.getLogger('offline-db');
82
+
83
+ // todo: move to dedicated file
84
+ export type SendMessageWithStateUpdateParams = {
85
+ localMessage: LocalMessage;
86
+ message?: MessageRequest;
87
+ options?: SendMessageOptions;
88
+ /**
89
+ * Per-call override for the send/retry request (advanced).
90
+ * If set, it takes precedence over channel instance configuration handlers.
91
+ */
92
+ sendMessageRequestFn?: CustomSendMessageRequestFn;
93
+ };
94
+
95
+ export type RetrySendMessageWithLocalUpdateParams = Omit<
96
+ SendMessageWithStateUpdateParams,
97
+ 'message'
98
+ >;
99
+
100
+ export type UpdateMessageWithStateUpdateParams = {
101
+ localMessage: LocalMessage;
102
+ options?: UpdateMessageOptions;
103
+ /**
104
+ * Per-call override for the update request (advanced).
105
+ * If set, it takes precedence over channel instance configuration handlers.
106
+ */
107
+ updateMessageRequestFn?: CustomUpdateMessageRequestFn;
108
+ };
109
+
110
+ export type DeleteMessageWithStateUpdateParams = {
111
+ localMessage: LocalMessage;
112
+ options?: DeleteMessageOptions;
113
+ /**
114
+ * Per-call override for the delete request (advanced).
115
+ * If set, it takes precedence over channel instance configuration handlers.
116
+ */
117
+ deleteMessageRequestFn?: CustomDeleteMessageRequestFn;
118
+ };
119
+
120
+ // Custom request function types for configuration
121
+ export type CustomSendMessageRequestFn = (
122
+ params: Omit<SendMessageWithStateUpdateParams, 'sendMessageRequestFn'>,
123
+ ) => Promise<{ message: MessageResponse }>;
124
+
125
+ export type CustomUpdateMessageRequestFn = (
126
+ params: Omit<UpdateMessageWithStateUpdateParams, 'updateMessageRequestFn'>,
127
+ ) => Promise<{ message: MessageResponse }>;
128
+
129
+ export type CustomDeleteMessageRequestFn = (
130
+ params: Omit<DeleteMessageWithStateUpdateParams, 'deleteMessageRequestFn'>,
131
+ ) => Promise<{ message: MessageResponse }>;
132
+
133
+ export type CustomMarkReadRequestFn = (params: {
134
+ channel: Channel;
135
+ options?: MarkReadRequest;
136
+ }) => Promise<EventAPIResponse | null>;
137
+
138
+ export type ChannelInstanceConfig = {
139
+ requestHandlers?: {
140
+ deleteMessageRequest?: CustomDeleteMessageRequestFn;
141
+ markReadRequest?: CustomMarkReadRequestFn;
142
+ sendMessageRequest?: CustomSendMessageRequestFn;
143
+ retrySendMessageRequest?: CustomSendMessageRequestFn;
144
+ updateMessageRequest?: CustomUpdateMessageRequestFn;
145
+ };
146
+ };
83
147
 
84
148
  /**
85
- * Channel - The Channel class manages it's own state.
149
+ * The Channel class manages its own state.
86
150
  */
87
- export class Channel {
151
+ export class Channel extends ChannelApi {
88
152
  _client: StreamChat;
89
- type: string;
90
- id: string | undefined;
91
- data: Partial<ChannelData & ChannelResponse> | undefined;
92
- _data: Partial<ChannelData & ChannelResponse>;
153
+ data: Partial<ChannelResponse> | undefined;
154
+ _data: ChannelData;
93
155
  cid: string;
94
156
  /** */
95
- listeners: { [key: string]: (string | EventHandler)[] };
157
+ listeners: Map<EventType, Set<EventHandler>>;
96
158
  state: ChannelState;
97
159
  /**
98
- * This boolean is a vague indication of weather the channel exists on chat backend.
160
+ * This boolean is a vague indication of whether the channel exists on chat backend.
99
161
  *
100
162
  * If the value is true, then that means the channel has been initialized by either calling
101
163
  * channel.create() or channel.query() or channel.watch().
@@ -105,7 +167,7 @@ export class Channel {
105
167
  */
106
168
  initialized: boolean;
107
169
  /**
108
- * Indicates weather channel has been initialized by manually populating the state with some messages, members etc.
170
+ * Indicates whether channel has been initialized by manually populating the state with some messages, members etc.
109
171
  * Static state indicates that channel exists on backend, but is not being watched yet.
110
172
  */
111
173
  offlineMode: boolean;
@@ -113,20 +175,23 @@ export class Channel {
113
175
  lastTypingEvent: Date | null;
114
176
  isTyping: boolean;
115
177
  disconnected: boolean;
116
- push_preferences?: ChannelPushPreference;
178
+ push_preferences?: Gen_ChannelPushPreferencesResponse;
179
+ public readonly configState = new StateStore<ChannelInstanceConfig>({});
117
180
  public readonly messageComposer: MessageComposer;
118
181
  public readonly messageReceiptsTracker: MessageReceiptsTracker;
182
+ public readonly messagePaginator: MessagePaginator;
183
+ public readonly pinnedMessagesPaginator: PinnedMessagePaginator;
184
+ public readonly messageOperations: MessageOperations;
119
185
  public readonly cooldownTimer: CooldownTimer;
120
186
 
121
187
  /**
122
- * constructor - Create a channel
188
+ * Creates a `Channel` instance bound to the given chat client.
123
189
  *
124
- * @param {StreamChat} client the chat client
125
- * @param {string} type the type of channel
126
- * @param {string} [id] the id of the chat
127
- * @param {ChannelData} data any additional custom params
128
- *
129
- * @return {Channel} Returns a new uninitialized channel
190
+ * @param client - The chat client.
191
+ * @param type - The type of channel.
192
+ * @param id - The ID of the chat (optional).
193
+ * @param data - Any additional custom params.
194
+ * @returns A new uninitialized channel.
130
195
  */
131
196
  constructor(
132
197
  client: StreamChat,
@@ -144,15 +209,15 @@ export class Channel {
144
209
  throw new Error(`Invalid chat id ${id}, letters, numbers and "!-_" are allowed`);
145
210
  }
146
211
 
212
+ super(client, type, id);
213
+
147
214
  this._client = client;
148
- this.type = type;
149
- this.id = id;
150
215
  // used by the frontend, gets updated:
151
- this.data = data;
216
+ this.data = data as Partial<ChannelResponse>;
152
217
  // this._data is used for the requests...
153
218
  this._data = { ...data };
154
219
  this.cid = `${type}:${id}`;
155
- this.listeners = {};
220
+ this.listeners = new Map();
156
221
  // perhaps the state variable should be private
157
222
  this.state = new ChannelState(this);
158
223
  this.initialized = false;
@@ -166,20 +231,87 @@ export class Channel {
166
231
  compositionContext: this,
167
232
  });
168
233
 
169
- this.messageReceiptsTracker = new MessageReceiptsTracker({
170
- locateMessage: (timestampMs) => {
171
- const msg = this.state.findMessageByTimestamp(timestampMs);
172
- return msg && { timestampMs, msgId: msg.id };
173
- },
174
- });
234
+ // Created before MessageReceiptsTracker and CooldownTimer: both read the message paginator
235
+ // (receipts resolve read cursors via findItemByTimestamp; CooldownTimer.refresh reads the
236
+ // latest window at construction).
237
+ this.messagePaginator = new MessagePaginator({ channel: this });
238
+ this.pinnedMessagesPaginator = new PinnedMessagePaginator({ channel: this });
239
+
240
+ this.messageReceiptsTracker = new MessageReceiptsTracker({ channel: this });
241
+ this.messageReceiptsTracker.registerSubscriptions();
175
242
 
176
243
  this.cooldownTimer = new CooldownTimer({ channel: this });
244
+
245
+ this.messageOperations = new MessageOperations({
246
+ ingest: (m) => {
247
+ this.messagePaginator.ingestItem(m);
248
+ this.getClient().messageStore.flushSubscribers(m.id);
249
+ },
250
+ get: (id) => this.messagePaginator.getItem(id),
251
+ handlers: () => {
252
+ const { requestHandlers } = this.configState.getLatestValue();
253
+ const deleteMessageRequest = requestHandlers?.deleteMessageRequest;
254
+ const sendMessageRequest = requestHandlers?.sendMessageRequest;
255
+ const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest;
256
+ const updateMessageRequest = requestHandlers?.updateMessageRequest;
257
+ return {
258
+ delete: deleteMessageRequest
259
+ ? (p) =>
260
+ deleteMessageRequest({
261
+ localMessage: p.localMessage,
262
+ options: p.options,
263
+ })
264
+ : undefined,
265
+ send: sendMessageRequest
266
+ ? (p) =>
267
+ sendMessageRequest({
268
+ localMessage: p.localMessage,
269
+ message: p.message,
270
+ options: p.options,
271
+ })
272
+ : undefined,
273
+ retry: retrySendMessageRequest
274
+ ? (p) =>
275
+ retrySendMessageRequest({
276
+ localMessage: p.localMessage,
277
+ message: p.message,
278
+ options: p.options,
279
+ })
280
+ : undefined,
281
+ update: updateMessageRequest
282
+ ? (p) =>
283
+ updateMessageRequest({
284
+ localMessage: p.localMessage,
285
+ options: p.options,
286
+ })
287
+ : undefined,
288
+ };
289
+ },
290
+ defaults: {
291
+ delete: async (id, o) => {
292
+ const result = await this.getClient().deleteMessage({ id, ...o });
293
+ return { message: result.message };
294
+ },
295
+ send: async (m, o) => {
296
+ const result = await this.sendMessage({ message: m, ...o });
297
+ return { message: result.message };
298
+ },
299
+ update: async (m, o) => {
300
+ const result = await this.getClient().updateMessage({
301
+ id: m.id,
302
+ message: localMessageToNewMessagePayload(m),
303
+ ...o,
304
+ });
305
+ return { message: result.message };
306
+ },
307
+ },
308
+ });
177
309
  }
178
310
 
179
311
  /**
180
- * getClient - Get the chat client for this channel. If client.disconnect() was called, this function will error
312
+ * Returns the chat client for this channel. Throws if `client.disconnect()` was called.
181
313
  *
182
- * @return {StreamChat}
314
+ * @returns The chat client.
183
315
  */
184
316
  getClient(): StreamChat {
185
317
  if (this.disconnected === true) {
@@ -189,73 +321,186 @@ export class Channel {
189
321
  }
190
322
 
191
323
  /**
192
- * getConfig - Get the config for this channel id (cid)
324
+ * Returns the config for this channel ID (CID).
193
325
  *
194
- * @return {Record<string, unknown>}
326
+ * @returns The channel config.
195
327
  */
196
328
  getConfig() {
197
329
  const client = this.getClient();
198
330
  return client.configs[this.cid];
199
331
  }
200
332
 
333
+ _sendMessage(request: Gen_SendMessageRequest) {
334
+ return super.sendMessage(request);
335
+ }
336
+
201
337
  /**
202
- * sendMessage - Send a message to this channel
338
+ * Sends a message to this channel.
203
339
  *
204
- * @param {Message} message The Message object
205
- * @param {boolean} [options.skip_enrich_url] Do not try to enrich the URLs within message
206
- * @param {boolean} [options.skip_push] Skip sending push notifications
207
- * @param {boolean} [options.is_pending_message] DEPRECATED, please use `pending` instead.
208
- * @param {boolean} [options.pending] Make this message pending
209
- * @param {Record<string,string>} [options.pending_message_metadata] Metadata for the pending message
210
- * @param {boolean} [options.force_moderation] Apply force moderation for server-side requests
211
- *
212
- * @return {Promise<SendMessageAPIResponse>} The Server Response
340
+ * @param request - The send message request payload, including the message body and optional flags
341
+ * such as `skip_enrich_url`, `skip_push`, and `keep_channel_hidden`.
342
+ * @returns The server response.
343
+ */
344
+ override async sendMessage(request: Gen_SendMessageRequest) {
345
+ try {
346
+ const offlineDb = this.getClient().offlineDb;
347
+ const messageId = request.message?.id;
348
+ if (offlineDb && messageId) {
349
+ return await offlineDb.queueTask<Awaited<ReturnType<ChatApi['sendMessage']>>>({
350
+ task: {
351
+ channelId: this.id as string,
352
+ channelType: this.type,
353
+ messageId,
354
+ payload: [request],
355
+ type: 'send-message',
356
+ },
357
+ });
358
+ }
359
+ } catch (error) {
360
+ offlineDbLogger
361
+ .withExtraTags('sendMessage', this.cid)
362
+ .error('Sending the message failed.', { error });
363
+ }
364
+ return await this._sendMessage(request);
365
+ }
366
+
367
+ /**
368
+ * Sends a message with optimistic local state update.
213
369
  */
214
- async _sendMessage(message: Message, options?: SendMessageOptions) {
215
- return await this.getClient().post<SendMessageAPIResponse>(
216
- this._channelURL() + '/message',
370
+ async sendMessageWithLocalUpdate(
371
+ params: SendMessageWithStateUpdateParams,
372
+ ): Promise<void> {
373
+ await this.messageOperations.send(
217
374
  {
218
- message,
219
- ...options,
375
+ localMessage: params.localMessage,
376
+ message: params.message,
377
+ options: params.options,
378
+ },
379
+ params.sendMessageRequestFn,
380
+ );
381
+ if (this.messageComposer.config.text.publishTypingEvents) await this.stopTyping();
382
+ }
383
+
384
+ /**
385
+ * Retry sending a failed message.
386
+ */
387
+ async retrySendMessageWithLocalUpdate(
388
+ params: Omit<SendMessageWithStateUpdateParams, 'message'>,
389
+ ) {
390
+ await this.messageOperations.retry(
391
+ {
392
+ localMessage: { ...params.localMessage, type: 'regular' },
393
+ options: params.options,
220
394
  },
395
+ params.sendMessageRequestFn,
221
396
  );
222
397
  }
223
398
 
224
- async sendMessage(message: Message, options?: SendMessageOptions) {
399
+ /**
400
+ * Updates a message with optimistic local state update.
401
+ */
402
+ async updateMessageWithLocalUpdate(params: UpdateMessageWithStateUpdateParams) {
403
+ await this.messageOperations.update(
404
+ {
405
+ localMessage: params.localMessage,
406
+ options: params.options,
407
+ },
408
+ params.updateMessageRequestFn,
409
+ );
410
+ }
411
+
412
+ /**
413
+ * Deletes a message with local state update.
414
+ */
415
+ async deleteMessageWithLocalUpdate(params: DeleteMessageWithStateUpdateParams) {
416
+ await this.messageOperations.delete(
417
+ {
418
+ localMessage: params.localMessage,
419
+ options: params.options,
420
+ },
421
+ params.deleteMessageRequestFn,
422
+ );
423
+ }
424
+
425
+ /**
426
+ * Adds a reaction with an optimistic local state update: the reaction is applied to the cached
427
+ * message immediately ({@link applyReactionLocally}), then the request is
428
+ * fired via {@link Channel.sendReaction} (which owns the offline-DB write + queue). The
429
+ * server-authoritative counts reconcile on the response; the message is rolled back on failure.
430
+ */
431
+ async addReactionWithLocalUpdate({
432
+ messageId,
433
+ reaction,
434
+ options,
435
+ }: {
436
+ messageId: string;
437
+ reaction: ReactionRequest;
438
+ options?: Pick<SendReactionRequest, 'enforce_unique' | 'skip_push'>;
439
+ }) {
440
+ const client = this.getClient();
441
+ const undo = applyReactionLocally(client, {
442
+ enforceUnique: options?.enforce_unique ?? false,
443
+ messageId,
444
+ reaction,
445
+ });
446
+
225
447
  try {
226
- const offlineDb = this.getClient().offlineDb;
227
- if (offlineDb) {
228
- const messageId = message.id;
229
- if (messageId) {
230
- return await offlineDb.queueTask<SendMessageAPIResponse>({
231
- task: {
232
- channelId: this.id as string,
233
- channelType: this.type,
234
- messageId,
235
- payload: [message, options],
236
- type: 'send-message',
237
- },
238
- });
239
- }
448
+ const response = await this.sendReaction({ id: messageId, reaction, ...options });
449
+ // reconcile the server copy only if we still hold it — a bare upsert of an unheld id would
450
+ // orphan it (the store's refcount GC only reclaims held ids).
451
+ if (response?.message && client.messageStore.has(response.message.id)) {
452
+ client.messageStore.upsert(formatMessage(response.message));
240
453
  }
241
454
  } catch (error) {
242
- this._client.logger('error', `offlineDb:send-message`, {
243
- tags: ['channel', 'offlineDb'],
244
- error,
245
- });
455
+ if (undo && (!client.offlineDb || !isEphemeral(error as Error))) {
456
+ undo();
457
+ }
458
+ throw error;
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Removes the current user's reaction with an optimistic local state update, mirroring
464
+ * {@link Channel.addReactionWithLocalUpdate}.
465
+ */
466
+ async deleteReactionWithLocalUpdate({
467
+ messageId,
468
+ type,
469
+ }: {
470
+ messageId: string;
471
+ type: string;
472
+ }) {
473
+ const client = this.getClient();
474
+ const undo = applyReactionLocally(client, {
475
+ messageId,
476
+ reaction: { type },
477
+ removed: true,
478
+ });
479
+
480
+ try {
481
+ const response = await this.deleteReaction({ id: messageId, type });
482
+ // reconcile the server copy only if we still hold it — a bare upsert of an unheld id would
483
+ // orphan it (the store's refcount GC only reclaims held ids).
484
+ if (response?.message && client.messageStore.has(response.message.id)) {
485
+ client.messageStore.upsert(formatMessage(response.message));
486
+ }
487
+ } catch (error) {
488
+ if (undo && (!client.offlineDb || !isEphemeral(error as Error))) {
489
+ undo();
490
+ }
491
+ throw error;
246
492
  }
247
- return await this._sendMessage(message, options);
248
493
  }
249
494
 
250
495
  /**
251
496
  * Upload a file to this channel’s file endpoint (multipart). Forwards to the client’s `sendFile` implementation.
252
497
  *
253
- * @param uri File source: URL string, `File`, `Buffer`, or readable stream (Node).
254
- * @param name File name sent in the multipart body.
255
- * @param contentType MIME type; defaults are applied when omitted.
256
- * @param user Optional user payload appended to the form as JSON.
257
- * @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal` from `AbortController`).
258
- * @return Promise resolving to `{ file: string, ... }` with the CDN URL.
498
+ * @param uri - File source: URL string, `File`, `Buffer`, or readable stream (Node).
499
+ * @param name - File name sent in the multipart body (optional).
500
+ * @param contentType - MIME type; defaults are applied when omitted (optional).
501
+ * @param user - User payload appended to the form as JSON (optional).
502
+ * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` from `AbortController` (optional).
503
+ * @returns A promise resolving to `{ file: string, ... }` with the CDN URL.
259
504
  */
260
505
  sendFile(
261
506
  uri: string | NodeJS.ReadableStream | Buffer | File,
@@ -264,7 +509,7 @@ export class Channel {
264
509
  user?: UserResponse,
265
510
  axiosRequestConfig?: AxiosRequestConfig,
266
511
  ) {
267
- return this.getClient().sendFile(
512
+ return this.getClient().api.sendFile(
268
513
  `${this._channelURL()}/file`,
269
514
  uri,
270
515
  name,
@@ -275,14 +520,14 @@ export class Channel {
275
520
  }
276
521
 
277
522
  /**
278
- * Upload an image to this channel’s image endpoint (multipart). Uses the same transport as `sendFile`.
523
+ * Upload an image to this channel's image endpoint (multipart). Uses the same transport as `sendFile`.
279
524
  *
280
- * @param uri Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead.
281
- * @param name File name sent in the multipart body.
282
- * @param contentType MIME type.
283
- * @param user Optional user payload appended to the form as JSON.
284
- * @param axiosRequestConfig Optional Axios per-request config, merged after upload defaults (e.g. `onUploadProgress`, `signal`).
285
- * @return Promise resolving to `{ file: string, ... }` with the CDN URL.
525
+ * @param uri - Image source: URL string, `File`, or readable stream (Node). For `Buffer` uploads, use `sendFile` toward the channel file endpoint instead.
526
+ * @param name - File name sent in the multipart body (optional).
527
+ * @param contentType - MIME type (optional).
528
+ * @param user - User payload appended to the form as JSON (optional).
529
+ * @param axiosRequestConfig - Axios per-request config, merged after upload defaults, e.g. `onUploadProgress`, `signal` (optional).
530
+ * @returns A promise resolving to `{ file: string, ... }` with the CDN URL.
286
531
  */
287
532
  sendImage(
288
533
  uri: string | NodeJS.ReadableStream | File,
@@ -291,7 +536,7 @@ export class Channel {
291
536
  user?: UserResponse,
292
537
  axiosRequestConfig?: AxiosRequestConfig,
293
538
  ) {
294
- return this.getClient().sendFile(
539
+ return this.getClient().api.sendFile(
295
540
  `${this._channelURL()}/image`,
296
541
  uri,
297
542
  name,
@@ -302,427 +547,223 @@ export class Channel {
302
547
  }
303
548
 
304
549
  deleteFile(url: string) {
305
- return this.getClient().delete<APIResponse>(`${this._channelURL()}/file`, { url });
550
+ return this.deleteChannelFile({ url });
306
551
  }
307
552
 
308
553
  deleteImage(url: string) {
309
- return this.getClient().delete<APIResponse>(`${this._channelURL()}/image`, { url });
554
+ return this.deleteChannelImage({ url });
310
555
  }
311
556
 
312
557
  /**
313
- * sendEvent - Send an event on this channel
314
- *
315
- * @param {Event} event for example {type: 'message.read'}
558
+ * Sends an event on this channel.
316
559
  *
317
- * @return {Promise<EventAPIResponse>} The Server Response
560
+ * @param event - For example `{ type: 'message.read' }`.
561
+ * @returns The server response.
318
562
  */
319
- async sendEvent(event: Event) {
563
+ override async sendEvent(request: { event: Event }) {
320
564
  this._checkInitialized();
321
- return await this.getClient().post<EventAPIResponse>(this._channelURL() + '/event', {
322
- event,
323
- });
565
+ return await super.sendEvent(request);
324
566
  }
325
567
 
326
568
  /**
327
- * search - Query messages
328
- *
329
- * @param {MessageFilters | string} query search query or object MongoDB style filters
330
- * @param {{client_id?: string; connection_id?: string; query?: string; message_filter_conditions?: MessageFilters}} options Option object, {user_id: 'tommaso'}
569
+ * Queries messages.
331
570
  *
332
- * @return {Promise<SearchAPIResponse>} search messages response
571
+ * @param request - The search request payload (optional). The inner `payload` accepts
572
+ * MongoDB-style filters and additional options such as `user_id`.
573
+ * @returns The search messages response.
333
574
  */
334
- async search(
335
- query: MessageFilters | string,
336
- options: SearchOptions & {
337
- client_id?: string;
338
- connection_id?: string;
339
- message_filter_conditions?: MessageFilters;
340
- message_options?: MessageOptions;
341
- query?: string;
342
- } = {},
343
- ) {
344
- if (options.offset && options.next) {
345
- throw Error(`Cannot specify offset with next`);
346
- }
347
- // Return a list of channels
348
- const payload: SearchPayload = {
349
- filter_conditions: { cid: this.cid } as ChannelFilters,
350
- ...options,
351
- sort: options.sort
352
- ? normalizeQuerySort<SearchMessageSortBase>(options.sort)
353
- : undefined,
354
- };
355
- if (typeof query === 'string') {
356
- payload.query = query;
357
- } else if (typeof query === 'object') {
358
- payload.message_filter_conditions = query;
359
- } else {
360
- throw Error(`Invalid type ${typeof query} for query parameter`);
361
- }
362
- // Make sure we wait for the connect promise if there is a pending one
363
- await this.getClient().wsPromise;
364
-
365
- return await this.getClient().get<SearchAPIResponse>(
366
- this.getClient().baseURL + '/search',
367
- {
368
- payload,
369
- },
370
- );
575
+ async search(request?: { payload?: SearchPayload }) {
576
+ return await this.getClient().search(request);
371
577
  }
372
578
 
373
579
  /**
374
- * queryMembers - Query Members
375
- *
376
- * @param {MemberFilters} filterConditions object MongoDB style filters
377
- * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}].
378
- * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}]
379
- * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10}
580
+ * Queries members.
380
581
  *
381
- * @return {Promise<ChannelMemberAPIResponse>} Query Members response
582
+ * @param request - The query members request payload (optional). The inner `payload` accepts
583
+ * MongoDB-style filters, sort directions (e.g. `[{ field: 'created_at', direction: -1 }]`),
584
+ * and pagination options (`limit`, `offset`).
585
+ * @returns The query members response.
382
586
  */
383
- async queryMembers(
384
- filterConditions: MemberFilters,
385
- sort: MemberSort = [],
386
- options: QueryMembersOptions = {},
387
- ) {
388
- let id: string | undefined;
389
- const type = this.type;
390
- let members: string[] | ChannelMemberResponse[] | undefined;
587
+ async queryMembers(request?: { payload?: Partial<QueryMembersPayload> }) {
588
+ const payload = {
589
+ type: this.type,
590
+ // TODO: these should be probably optional in the OAPI spec
591
+ // filter_conditions: ...
592
+ } as QueryMembersPayload;
593
+
391
594
  if (this.id) {
392
- id = this.id;
393
- } else if (this.data?.members && Array.isArray(this.data.members)) {
394
- members = this.data.members;
595
+ payload.id = this.id;
596
+ } else if (Array.isArray(this.data?.members)) {
597
+ payload.members = this.data.members.map((m) => ({
598
+ ...m,
599
+ // TODO: this should not be needed Gen_QueryMembersResponse should not come with user_id as optinal
600
+ user_id: (m.user_id ?? m.user?.id) as string,
601
+ }));
395
602
  }
396
603
  // Return a list of members
397
- return await this.getClient().get<ChannelMemberAPIResponse>(
398
- this.getClient().baseURL + '/members',
399
- {
400
- payload: {
401
- type,
402
- id,
403
- members,
404
- sort: normalizeQuerySort(sort),
405
- filter_conditions: filterConditions,
406
- ...options,
407
- },
604
+ return await this.getClient().queryMembers({
605
+ payload: {
606
+ ...payload,
607
+ ...request?.payload,
408
608
  },
409
- );
410
- }
411
-
412
- /**
413
- * updateMemberPartial - Partial update a member
414
- *
415
- * @param {PartialUpdateMember} updates
416
- * @param {{ user_id?: string }} [options] Option object, {user_id: 'jane'} to optionally specify the user id
417
-
418
- * @return {Promise<ChannelMemberResponse>} Updated member
419
- */
420
- async updateMemberPartial(updates: PartialUpdateMember, options?: { userId?: string }) {
421
- const url = new URL(`${this._channelURL()}/member`);
422
-
423
- if (options?.userId) {
424
- url.searchParams.append('user_id', options.userId);
425
- }
426
-
427
- return await this.getClient().patch<PartialUpdateMemberAPIResponse>(
428
- url.toString(),
429
- updates,
430
- );
431
- }
432
-
433
- /**
434
- * @deprecated Use `updateMemberPartial` instead
435
- * partialUpdateMember - Partial update a member
436
- *
437
- * @param {string} user_id member user id
438
- * @param {PartialUpdateMember} updates
439
- *
440
- * @return {Promise<ChannelMemberResponse>} Updated member
441
- */
442
- async partialUpdateMember(user_id: string, updates: PartialUpdateMember) {
443
- if (!user_id) {
444
- throw Error('Please specify the user id');
445
- }
446
-
447
- return await this.getClient().patch<PartialUpdateMemberAPIResponse>(
448
- this._channelURL() + `/member/${encodeURIComponent(user_id)}`,
449
- updates,
450
- );
609
+ });
451
610
  }
452
611
 
453
612
  /**
454
- * sendReaction - Sends a reaction to a message. If offline support is enabled, it will make sure
613
+ * Sends a reaction to a message. If offline support is enabled, it will make sure
455
614
  * that sending the reaction is queued up if it fails due to bad internet conditions and executed
456
615
  * later.
457
616
  *
458
- * @param {string} messageID the message id
459
- * @param {Reaction} reaction the reaction object for instance {type: 'love'}
460
- * @param {{ enforce_unique?: boolean, skip_push?: boolean }} [options] Option object, {enforce_unique: true, skip_push: true} to override any existing reaction or skip sending push notifications
461
- *
462
- * @return {Promise<ReactionAPIResponse>} The Server Response
617
+ * @param request - The send-reaction request payload, including the target message ID, the
618
+ * reaction object (e.g. `{ type: 'love' }`), and optional flags such as `enforce_unique` and
619
+ * `skip_push`.
620
+ * @returns The server response.
463
621
  */
464
- async sendReaction(
465
- messageID: string,
466
- reaction: Reaction,
467
- options?: SendReactionOptions,
468
- ) {
469
- if (!messageID) {
470
- throw Error(`Message id is missing`);
471
- }
472
- if (!reaction || Object.keys(reaction).length === 0) {
473
- throw Error(`Reaction object is missing`);
474
- }
622
+ async sendReaction(request: Parameters<ChatApi['sendReaction']>[0]) {
623
+ const { id: messageId } = request;
475
624
 
476
625
  try {
477
626
  const offlineDb = this.getClient().offlineDb;
478
627
  if (offlineDb) {
628
+ // The optimistic reaction row is written by the local-update layer
629
+ // (`applyReactionLocally`); here we only queue the request for replay.
479
630
  return await offlineDb.queueTask<ReactionAPIResponse>({
480
631
  task: {
481
632
  channelId: this.id as string,
482
633
  channelType: this.type,
483
- messageId: messageID,
484
- payload: [messageID, reaction, options],
634
+ messageId,
635
+ payload: [request],
485
636
  type: 'send-reaction',
486
637
  },
487
638
  });
488
639
  }
489
640
  } catch (error) {
490
- this._client.logger('error', `offlineDb:send-reaction`, {
491
- tags: ['channel', 'offlineDb'],
492
- error,
493
- });
641
+ offlineDbLogger
642
+ .withExtraTags('sendReaction', this.cid)
643
+ .error('Sending the reaction failed.', { error });
494
644
  }
495
645
 
496
- return this._sendReaction(messageID, reaction, options);
646
+ return this._sendReaction(request);
497
647
  }
498
648
 
499
- /**
500
- * sendReaction - Send a reaction about a message
501
- *
502
- * @param {string} messageID the message id
503
- * @param {Reaction} reaction the reaction object for instance {type: 'love'}
504
- * @param {{ enforce_unique?: boolean, skip_push?: boolean }} [options] Option object, {enforce_unique: true, skip_push: true} to override any existing reaction or skip sending push notifications
505
- *
506
- * @return {Promise<ReactionAPIResponse>} The Server Response
507
- */
508
- async _sendReaction(
509
- messageID: string,
510
- reaction: Reaction,
511
- options?: SendReactionOptions,
512
- ) {
513
- if (!messageID) {
514
- throw Error(`Message id is missing`);
515
- }
516
- if (!reaction || Object.keys(reaction).length === 0) {
517
- throw Error(`Reaction object is missing`);
518
- }
519
-
520
- return await this.getClient().post<ReactionAPIResponse>(
521
- this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/reaction`,
522
- {
523
- reaction,
524
- ...options,
525
- },
526
- );
649
+ _sendReaction(request: Parameters<ChatApi['sendReaction']>[0]) {
650
+ return this.getClient().sendReaction(request);
527
651
  }
528
652
 
529
- async deleteReaction(messageID: string, reactionType: string, user_id?: string) {
653
+ async deleteReaction(request: Parameters<ChatApi['deleteReaction']>[0]) {
530
654
  this._checkInitialized();
531
- if (!reactionType || !messageID) {
532
- throw Error(
533
- 'Deleting a reaction requires specifying both the message and reaction type',
534
- );
535
- }
536
655
 
537
656
  try {
538
657
  const offlineDb = this.getClient().offlineDb;
539
658
  if (offlineDb) {
540
- const message = this.state.messages.find(({ id }) => id === messageID);
541
- const reaction = {
542
- created_at: '',
543
- updated_at: '',
544
- message_id: messageID,
545
- type: reactionType,
546
- user_id: (this.getClient().userID as string) ?? user_id,
547
- };
548
-
549
- if (message) {
550
- await offlineDb.deleteReaction({
551
- message,
552
- reaction,
553
- });
554
- }
555
-
659
+ // The optimistic reaction-row removal is handled by the local-update layer
660
+ // (`applyReactionLocally`); here we only queue the request for replay.
556
661
  return await offlineDb.queueTask<ReactionAPIResponse>({
557
662
  task: {
558
663
  channelId: this.id as string,
559
664
  channelType: this.type,
560
- messageId: messageID,
561
- payload: [messageID, reactionType],
665
+ messageId: request.id,
666
+ payload: [request],
562
667
  type: 'delete-reaction',
563
668
  },
564
669
  });
565
670
  }
566
671
  } catch (error) {
567
- this._client.logger('error', `offlineDb:delete-reaction`, {
568
- tags: ['channel', 'offlineDb'],
569
- error,
570
- });
672
+ offlineDbLogger
673
+ .withExtraTags('deleteReaction', this.cid)
674
+ .error('Deleting the reaction failed.', { error });
571
675
  }
572
676
 
573
- return await this._deleteReaction(messageID, reactionType, user_id);
677
+ return await this._deleteReaction(request);
574
678
  }
575
679
 
576
680
  /**
577
- * deleteReaction - Delete a reaction by user and type
578
- *
579
- * @param {string} messageID the id of the message from which te remove the reaction
580
- * @param {string} reactionType the type of reaction that should be removed
581
- * @param {string} [user_id] the id of the user (used only for server side request) default null
681
+ * Deletes a reaction by user and type.
582
682
  *
583
- * @return {Promise<ReactionAPIResponse>} The Server Response
683
+ * @param request - The delete reaction request payload identifying the target message and reaction type.
684
+ * @returns The server response.
584
685
  */
585
- async _deleteReaction(messageID: string, reactionType: string, user_id?: string) {
586
- this._checkInitialized();
587
- if (!reactionType || !messageID) {
588
- throw Error(
589
- 'Deleting a reaction requires specifying both the message and reaction type',
590
- );
591
- }
592
-
593
- const url =
594
- this.getClient().baseURL +
595
- `/messages/${encodeURIComponent(messageID)}/reaction/${encodeURIComponent(
596
- reactionType,
597
- )}`;
598
- //provided when server side request
599
- if (user_id) {
600
- return await this.getClient().delete<ReactionAPIResponse>(url, { user_id });
601
- }
602
-
603
- return await this.getClient().delete<ReactionAPIResponse>(url, {});
686
+ async _deleteReaction(request: Parameters<ChatApi['deleteReaction']>[0]) {
687
+ return await this.getClient().deleteReaction(request);
604
688
  }
605
689
 
606
690
  /**
607
- * update - Edit the channel's custom properties
691
+ * Edit the channel using the inherited `update()` from `ChannelApi`. Caches the
692
+ * server-returned channel onto `this.data`.
608
693
  *
609
- * @param {ChannelData} channelData The object to update the custom properties of this channel with
610
- * @param {Message} [updateMessage] Optional message object for channel members notification
611
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
612
- * @return {Promise<UpdateChannelAPIResponse>} The server response
694
+ * @param request - Channel update payload, e.g. `{ data: { name: 'foo' }, message }` (optional).
695
+ * @returns The server response.
613
696
  */
614
- async update(
615
- channelData: Partial<ChannelData & ChannelResponse> = {},
616
- updateMessage?: Message,
617
- options?: ChannelUpdateOptions,
618
- ) {
619
- // Strip out reserved names that will result in API errors.
620
- // TODO: this needs to be typed better
621
- const reserved: Exclude<
622
- keyof (ChannelResponse & ChannelData),
623
- keyof CustomChannelData
624
- >[] = [
625
- 'config',
626
- 'cid',
627
- 'created_by',
628
- 'id',
629
- 'member_count',
630
- 'type',
631
- 'created_at',
632
- 'updated_at',
633
- 'last_message_at',
634
- 'own_capabilities',
635
- ];
636
-
637
- reserved.forEach((key) => {
638
- delete channelData[key];
639
- });
640
-
641
- return await this._update({
642
- message: updateMessage,
643
- data: channelData,
644
- ...options,
645
- });
697
+ override async update(request?: Gen_UpdateChannelRequest) {
698
+ const previousData = this.data;
699
+ const data = await super.update(request);
700
+ this.data = data.channel;
701
+ this._syncStateFromChannelData(this.data, previousData);
702
+ return data;
646
703
  }
647
704
 
648
705
  /**
649
- * updatePartial - partial update channel properties
706
+ * Partial update of channel properties.
650
707
  *
651
- * @param {PartialUpdateChannel} partial update request
652
- *
653
- * @return {Promise<PartialUpdateChannelAPIResponse>}
708
+ * @param update - The partial update request.
709
+ * @returns The server response.
654
710
  */
655
- async updatePartial(update: PartialUpdateChannel) {
656
- const data = await this.getClient().patch<PartialUpdateChannelAPIResponse>(
657
- this._channelURL(),
658
- update,
659
- );
711
+ async updatePartial(update: UpdateChannelPartialRequest) {
712
+ const data = await this.updateChannelPartial(update);
660
713
 
661
- const areCapabilitiesChanged =
662
- [...(data.channel.own_capabilities || [])].sort().join() !==
663
- [
664
- ...(Array.isArray(this.data?.own_capabilities)
665
- ? (this.data?.own_capabilities as string[])
666
- : []),
667
- ]
668
- .sort()
669
- .join();
670
- this.data = data.channel;
714
+ if (!this.getClient()._cacheEnabled) return data;
715
+
716
+ const channel = data.channel;
717
+ const currentCapabilities = this.data?.own_capabilities ?? [];
718
+ const newCapabilities = channel?.own_capabilities;
719
+
720
+ const capabilitiesChanged =
721
+ newCapabilities &&
722
+ [...currentCapabilities].sort().join() !== [...newCapabilities].sort().join();
723
+
724
+ const previousData = this.data;
725
+ this.data = channel;
726
+ this._syncStateFromChannelData(this.data, previousData);
671
727
  // If the capabiltities are changed, we trigger the `capabilities.changed` event.
672
- if (areCapabilitiesChanged) {
728
+ if (capabilitiesChanged) {
673
729
  this.getClient().dispatchEvent({
674
730
  type: 'capabilities.changed',
675
731
  cid: this.cid,
676
- own_capabilities: data.channel.own_capabilities,
732
+ own_capabilities: newCapabilities,
677
733
  });
678
734
  }
735
+
679
736
  return data;
680
737
  }
681
738
 
682
739
  /**
683
- * enableSlowMode - enable slow mode
740
+ * Enables slow mode.
684
741
  *
685
- * @param {number} coolDownInterval the cooldown interval in seconds
686
- * @return {Promise<UpdateChannelAPIResponse>} The server response
742
+ * @param coolDownInterval - The cooldown interval in seconds.
743
+ * @returns The server response.
687
744
  */
688
745
  async enableSlowMode(coolDownInterval: number) {
689
- const data = await this.getClient().post<UpdateChannelAPIResponse>(
690
- this._channelURL(),
691
- {
692
- cooldown: coolDownInterval,
693
- },
694
- );
695
- this.data = data.channel;
696
- return data;
746
+ return await this.update({ cooldown: coolDownInterval });
697
747
  }
698
748
 
699
749
  /**
700
- * disableSlowMode - disable slow mode
750
+ * Disables slow mode.
701
751
  *
702
- * @return {Promise<UpdateChannelAPIResponse>} The server response
752
+ * @returns The server response.
703
753
  */
704
754
  async disableSlowMode() {
705
- const data = await this.getClient().post<UpdateChannelAPIResponse>(
706
- this._channelURL(),
707
- {
708
- cooldown: 0,
709
- },
710
- );
711
- this.data = data.channel;
712
- return data;
755
+ return await this.update({ cooldown: 0 });
713
756
  }
714
757
 
715
- public async sendSharedLocation(
716
- location: StaticLocationPayload | LiveLocationPayload,
717
- userId?: string,
718
- ) {
758
+ public async sendSharedLocation(location: SharedLocation & { message_id?: string }) {
719
759
  const result = await this.sendMessage({
720
- id: location.message_id,
721
- shared_location: location,
722
- user: userId ? { id: userId } : undefined,
760
+ message: {
761
+ id: location.message_id,
762
+ shared_location: location,
763
+ },
723
764
  });
724
765
 
725
- if ((location as LiveLocationPayload).end_at) {
766
+ if (location.end_at) {
726
767
  this.getClient().dispatchEvent({
727
768
  message: result.message,
728
769
  type: 'live_location_sharing.started',
@@ -732,10 +773,10 @@ export class Channel {
732
773
  return result;
733
774
  }
734
775
 
735
- public async stopLiveLocationSharing(payload: UpdateLocationPayload) {
736
- const location = await this.getClient().updateLocation({
776
+ public async stopLiveLocationSharing(payload: UpdateLiveLocationRequest) {
777
+ const location = await this.getClient().updateLiveLocation({
737
778
  ...payload,
738
- end_at: new Date().toISOString(),
779
+ end_at: new Date(),
739
780
  });
740
781
  this.getClient().dispatchEvent({
741
782
  live_location: location,
@@ -744,359 +785,284 @@ export class Channel {
744
785
  }
745
786
 
746
787
  /**
747
- * delete - Delete the channel. Messages are permanently removed.
788
+ * Accepts an invitation to the channel.
748
789
  *
749
- * @param {boolean} [options.hard_delete] Defines if the channel is hard deleted or not
750
- *
751
- * @return {Promise<DeleteChannelAPIResponse>} The server response
790
+ * @param options - The object to update the custom properties of this channel with (optional, defaults to `{}`).
791
+ * @returns The server response.
752
792
  */
753
- async delete(options: { hard_delete?: boolean } = {}) {
754
- return await this.getClient().delete<DeleteChannelAPIResponse>(this._channelURL(), {
755
- ...options,
756
- });
793
+ async acceptInvite(options: ChannelUpdateOptions = {}) {
794
+ return await this.update({ accept_invite: true, ...options });
757
795
  }
758
796
 
759
797
  /**
760
- * truncate - Removes all messages from the channel
761
- * @param {TruncateOptions} [options] Defines truncation options
762
- * @return {Promise<TruncateChannelAPIResponse>} The server response
763
- */
764
- async truncate(options: TruncateOptions = {}) {
765
- return await this.getClient().post<TruncateChannelAPIResponse>(
766
- this._channelURL() + '/truncate',
767
- options,
768
- );
769
- }
770
-
771
- /**
772
- * acceptInvite - accept invitation to the channel
773
- *
774
- * @param {UpdateChannelOptions} [options] The object to update the custom properties of this channel with
798
+ * Rejects an invitation to the channel.
775
799
  *
776
- * @return {Promise<UpdateChannelAPIResponse>} The server response
800
+ * @param options - The object to update the custom properties of this channel with (optional, defaults to `{}`).
801
+ * @returns The server response.
777
802
  */
778
- async acceptInvite(options: UpdateChannelOptions = {}) {
779
- return await this._update({ accept_invite: true, ...options });
803
+ async rejectInvite(options: ChannelUpdateOptions = {}) {
804
+ return await this.update({ reject_invite: true, ...options });
780
805
  }
781
806
 
782
807
  /**
783
- * rejectInvite - reject invitation to the channel
808
+ * Adds members to the channel.
784
809
  *
785
- * @param {UpdateChannelOptions} [options] The object to update the custom properties of this channel with
786
- *
787
- * @return {Promise<UpdateChannelAPIResponse>} The server response
788
- */
789
- async rejectInvite(options: UpdateChannelOptions = {}) {
790
- return await this._update({ reject_invite: true, ...options });
791
- }
792
-
793
- /**
794
- * addMembers - add members to the channel
795
- *
796
- * @param {string[] | Array<NewMemberPayload>} members An array of members to add to the channel
797
- * @param {Message} [message] Optional message object for channel members notification
798
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
799
- * @return {Promise<UpdateChannelAPIResponse>} The server response
810
+ * @param members - An array of members to add to the channel.
811
+ * @param message - Message object for channel members notification (optional).
812
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
813
+ * @returns The server response.
800
814
  */
801
815
  async addMembers(
802
- members: string[] | Array<NewMemberPayload>,
803
- message?: Message,
816
+ members: string[] | Array<Gen_ChannelMemberRequest>,
817
+ message?: MessageRequest,
804
818
  options: ChannelUpdateOptions = {},
805
819
  ) {
806
- return await this._update({ add_members: members, message, ...options });
820
+ return await this.update({
821
+ add_members: members.map((member) =>
822
+ typeof member === 'string' ? { user_id: member } : member,
823
+ ),
824
+ message,
825
+ ...options,
826
+ });
807
827
  }
808
828
 
809
829
  /**
810
- * addFilterTags - add filter tags to the channel
830
+ * Adds filter tags to the channel.
811
831
  *
812
- * @param {string[]} tags An array of tags to add to the channel
813
- * @param {Message} [message] Optional message object for channel members notification
814
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
815
- * @return {Promise<UpdateChannelAPIResponse>} The server response
832
+ * @param tags - An array of tags to add to the channel.
833
+ * @param message - Message object for channel members notification (optional).
834
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
835
+ * @returns The server response.
816
836
  */
817
837
  async addFilterTags(
818
838
  tags: string[],
819
- message?: Message,
839
+ message?: MessageRequest,
820
840
  options: ChannelUpdateOptions = {},
821
841
  ) {
822
- return await this._update({ add_filter_tags: tags, message, ...options });
842
+ return await this.update({ add_filter_tags: tags, message, ...options });
823
843
  }
824
844
 
825
845
  /**
826
- * removeFilterTags - remove filter tags from the channel
846
+ * Removes filter tags from the channel.
827
847
  *
828
- * @param {string[]} tags An array of tags to remove from the channel
829
- * @param {Message} [message] Optional message object for channel members notification
830
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
831
- * @return {Promise<UpdateChannelAPIResponse>} The server response
848
+ * @param tags - An array of tags to remove from the channel.
849
+ * @param message - Message object for channel members notification (optional).
850
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
851
+ * @returns The server response.
832
852
  */
833
853
  async removeFilterTags(
834
854
  tags: string[],
835
- message?: Message,
855
+ message?: MessageRequest,
836
856
  options: ChannelUpdateOptions = {},
837
857
  ) {
838
- return await this._update({ remove_filter_tags: tags, message, ...options });
858
+ return await this.update({ remove_filter_tags: tags, message, ...options });
839
859
  }
840
860
 
841
861
  /**
842
- * addModerators - add moderators to the channel
862
+ * Adds moderators to the channel.
843
863
  *
844
- * @param {string[]} members An array of member identifiers
845
- * @param {Message} [message] Optional message object for channel members notification
846
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
847
- * @return {Promise<UpdateChannelAPIResponse>} The server response
864
+ * @param members - An array of member identifiers.
865
+ * @param message - Message object for channel members notification (optional).
866
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
867
+ * @returns The server response.
848
868
  */
849
869
  async addModerators(
850
870
  members: string[],
851
- message?: Message,
871
+ message?: MessageRequest,
852
872
  options: ChannelUpdateOptions = {},
853
873
  ) {
854
- return await this._update({ add_moderators: members, message, ...options });
874
+ return await this.update({ add_moderators: members, message, ...options });
855
875
  }
856
876
 
857
877
  /**
858
- * assignRoles - sets member roles in a channel
878
+ * Sets member roles in a channel.
859
879
  *
860
- * @param {{channel_role: Role, user_id: string}[]} roles List of role assignments
861
- * @param {Message} [message] Optional message object for channel members notification
862
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
863
- * @return {Promise<UpdateChannelAPIResponse>} The server response
880
+ * @param roles - List of role assignments.
881
+ * @param message - Message object for channel members notification (optional).
882
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
883
+ * @returns The server response.
864
884
  */
865
885
  async assignRoles(
866
- roles: { channel_role: Role; user_id: string }[],
867
- message?: Message,
886
+ roles: { channel_role: RoleName; user_id: string }[],
887
+ message?: MessageRequest,
868
888
  options: ChannelUpdateOptions = {},
869
889
  ) {
870
- return await this._update({ assign_roles: roles, message, ...options });
890
+ return await this.update({ assign_roles: roles, message, ...options });
871
891
  }
872
892
 
873
893
  /**
874
- * inviteMembers - invite members to the channel
894
+ * Invite members to the channel.
875
895
  *
876
- * @param {string[] | Array<NewMemberPayload>} members An array of members to invite to the channel
877
- * @param {Message} [message] Optional message object for channel members notification
878
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
879
- * @return {Promise<UpdateChannelAPIResponse>} The server response
896
+ * @param members - An array of members to invite to the channel.
897
+ * @param message - Message object for channel members notification (optional).
898
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
899
+ * @returns The server response.
880
900
  */
881
901
  async inviteMembers(
882
- members: string[] | Required<Omit<NewMemberPayload, 'channel_role'>>[],
883
- message?: Message,
902
+ members: string[] | Required<Omit<Gen_ChannelMemberRequest, 'channel_role'>>[],
903
+ message?: MessageRequest,
884
904
  options: ChannelUpdateOptions = {},
885
905
  ) {
886
- return await this._update({ invites: members, message, ...options });
906
+ return await this.update({
907
+ invites: members.map((member) =>
908
+ typeof member === 'string' ? { user_id: member } : member,
909
+ ),
910
+ message,
911
+ ...options,
912
+ });
887
913
  }
888
914
 
889
915
  /**
890
- * removeMembers - remove members from channel
916
+ * Removes members from the channel.
891
917
  *
892
- * @param {string[]} members An array of member identifiers
893
- * @param {Message} [message] Optional message object for channel members notification
894
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
895
- * @return {Promise<UpdateChannelAPIResponse>} The server response
918
+ * @param members - An array of member identifiers.
919
+ * @param message - Message object for channel members notification (optional).
920
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
921
+ * @returns The server response.
896
922
  */
897
923
  async removeMembers(
898
924
  members: string[],
899
- message?: Message,
925
+ message?: MessageRequest,
900
926
  options: ChannelUpdateOptions = {},
901
927
  ) {
902
- return await this._update({ remove_members: members, message, ...options });
928
+ return await this.update({ remove_members: members, message, ...options });
903
929
  }
904
930
 
905
931
  /**
906
- * demoteModerators - remove moderator role from channel members
932
+ * Removes the moderator role from channel members.
907
933
  *
908
- * @param {string[]} members An array of member identifiers
909
- * @param {Message} [message] Optional message object for channel members notification
910
- * @param {ChannelUpdateOptions} [options] Option object, configuration to control the behavior while updating
911
- * @return {Promise<UpdateChannelAPIResponse>} The server response
934
+ * @param members - An array of member identifiers.
935
+ * @param message - Message object for channel members notification (optional).
936
+ * @param options - Configuration to control the behavior while updating (optional, defaults to `{}`).
937
+ * @returns The server response.
912
938
  */
913
939
  async demoteModerators(
914
940
  members: string[],
915
- message?: Message,
941
+ message?: MessageRequest,
916
942
  options: ChannelUpdateOptions = {},
917
943
  ) {
918
- return await this._update({ demote_moderators: members, message, ...options });
944
+ return await this.update({ demote_moderators: members, message, ...options });
919
945
  }
920
946
 
921
947
  /**
922
- * _update - executes channel update request
923
- * @param payload Object Update Channel payload
924
- * @return {Promise<UpdateChannelAPIResponse>} The server response
925
- * TODO: introduce new type instead of Object in the next major update
926
- */
927
- async _update(payload: object) {
928
- const data = await this.getClient().post<UpdateChannelAPIResponse>(
929
- this._channelURL(),
930
- payload,
931
- );
932
- this.data = data.channel;
933
- return data;
934
- }
935
-
936
- /**
937
- * mute - mutes the current channel
938
- * @param {{ user_id?: string, expiration?: string }} opts expiration in minutes or user_id
939
- * @return {Promise<MuteChannelAPIResponse>} The server response
948
+ * Mutes the current channel.
940
949
  *
941
- * example with expiration:
942
- * await channel.mute({expiration: moment.duration(2, 'weeks')});
950
+ * @example
951
+ * // with expiration
952
+ * await channel.mute({ expiration: moment.duration(2, 'weeks') });
943
953
  *
944
- * example server side:
945
- * await channel.mute({user_id: userId});
954
+ * @example
955
+ * // server side
956
+ * await channel.mute({ user_id: userId });
946
957
  *
958
+ * @param options - Mute options (optional, defaults to `{}`).
959
+ * @param options.expiration - Expiration in minutes (optional).
960
+ * @returns The server response.
947
961
  */
948
- async mute(opts: { expiration?: number; user_id?: string } = {}) {
949
- return await this.getClient().post<MuteChannelAPIResponse>(
950
- this.getClient().baseURL + '/moderation/mute/channel',
951
- {
952
- channel_cid: this.cid,
953
- ...opts,
954
- },
955
- );
962
+ async mute(options?: Gen_MuteChannelRequest) {
963
+ return await this.getClient().muteChannel({
964
+ channel_cids: [this.cid],
965
+ ...options,
966
+ });
956
967
  }
957
968
 
958
969
  /**
959
- * unmute - mutes the current channel
960
- * @param {{ user_id?: string}} opts user_id
961
- * @return {Promise<APIResponse>} The server response
970
+ * Unmutes the current channel.
962
971
  *
963
- * example server side:
964
- * await channel.unmute({user_id: userId});
972
+ * @example
973
+ * // server side
974
+ * await channel.unmute({ user_id: userId });
975
+ *
976
+ * @param options - Unmute options (optional, defaults to `{}`).
977
+ * @param options.user_id - User ID (optional).
978
+ * @returns The server response.
965
979
  */
966
- async unmute(opts: { user_id?: string } = {}) {
967
- return await this.getClient().post<APIResponse>(
968
- this.getClient().baseURL + '/moderation/unmute/channel',
969
- {
970
- channel_cid: this.cid,
971
- ...opts,
972
- },
973
- );
980
+ async unmute(options?: Gen_UnmuteChannelRequest) {
981
+ return await this.getClient().unmuteChannel({
982
+ channel_cids: [this.cid],
983
+ ...options,
984
+ });
974
985
  }
975
986
 
976
987
  /**
977
- * archive - archives the current channel
978
- * @param {{ user_id?: string }} opts user_id if called server side
979
- * @return {Promise<ChannelMemberResponse>} The server response
980
- *
981
- * example:
982
- * await channel.archives();
988
+ * Archives the current channel.
983
989
  *
984
- * example server side:
985
- * await channel.archive({user_id: userId});
990
+ * @example
991
+ * await channel.archive();
986
992
  *
993
+ * @returns The server response.
987
994
  */
988
- async archive(opts: { user_id?: string } = {}) {
989
- const cli = this.getClient();
990
- const uid = opts.user_id || cli.userID;
991
- if (!uid) {
992
- throw Error('A user_id is required for archiving a channel');
993
- }
994
- const resp = await this.partialUpdateMember(uid, { set: { archived: true } });
995
- return resp.channel_member;
995
+ async archive() {
996
+ return await this.updateMemberPartial({ set: { archived: true } });
996
997
  }
997
998
 
998
999
  /**
999
- * unarchive - unarchives the current channel
1000
- * @param {{ user_id?: string }} opts user_id if called server side
1001
- * @return {Promise<ChannelMemberResponse>} The server response
1000
+ * Unarchives the current channel.
1002
1001
  *
1003
- * example:
1002
+ * @example
1004
1003
  * await channel.unarchive();
1005
1004
  *
1006
- * example server side:
1007
- * await channel.unarchive({user_id: userId});
1008
- *
1005
+ * @returns The server response.
1009
1006
  */
1010
- async unarchive(opts: { user_id?: string } = {}) {
1011
- const cli = this.getClient();
1012
- const uid = opts.user_id || cli.userID;
1013
- if (!uid) {
1014
- throw Error('A user_id is required for unarchiving a channel');
1015
- }
1016
- const resp = await this.partialUpdateMember(uid, { set: { archived: false } });
1017
- return resp.channel_member;
1007
+ async unarchive() {
1008
+ return await this.updateMemberPartial({ set: { archived: false } });
1018
1009
  }
1019
1010
 
1020
1011
  /**
1021
- * pin - pins the current channel
1022
- * @param {{ user_id?: string }} opts user_id if called server side
1023
- * @return {Promise<ChannelMemberResponse>} The server response
1012
+ * Pins the current channel.
1024
1013
  *
1025
- * example:
1014
+ * @example
1026
1015
  * await channel.pin();
1027
1016
  *
1028
- * example server side:
1029
- * await channel.pin({user_id: userId});
1030
- *
1017
+ * @returns The server response.
1031
1018
  */
1032
- async pin(opts: { user_id?: string } = {}) {
1033
- const cli = this.getClient();
1034
- const uid = opts.user_id || cli.userID;
1035
- if (!uid) {
1036
- throw new Error('A user_id is required for pinning a channel');
1037
- }
1038
- const resp = await this.partialUpdateMember(uid, { set: { pinned: true } });
1039
- return resp.channel_member;
1019
+ async pin() {
1020
+ return await this.updateMemberPartial({ set: { pinned: true } });
1040
1021
  }
1041
1022
 
1042
1023
  /**
1043
- * unpin - unpins the current channel
1044
- * @param {{ user_id?: string }} opts user_id if called server side
1045
- * @return {Promise<ChannelMemberResponse>} The server response
1024
+ * Unpins the current channel.
1046
1025
  *
1047
- * example:
1026
+ * @example
1048
1027
  * await channel.unpin();
1049
1028
  *
1050
- * example server side:
1051
- * await channel.unpin({user_id: userId});
1052
- *
1029
+ * @returns The server response.
1053
1030
  */
1054
- async unpin(opts: { user_id?: string } = {}) {
1055
- const cli = this.getClient();
1056
- const uid = opts.user_id || cli.userID;
1057
- if (!uid) {
1058
- throw new Error('A user_id is required for unpinning a channel');
1059
- }
1060
- const resp = await this.partialUpdateMember(uid, { set: { pinned: false } });
1061
- return resp.channel_member;
1031
+ async unpin() {
1032
+ return await this.updateMemberPartial({ set: { pinned: false } });
1062
1033
  }
1063
1034
 
1064
1035
  /**
1065
- * muteStatus - returns the mute status for the current channel
1066
- * @return {{ muted: boolean; createdAt: Date | null; expiresAt: Date | null }} { muted: true | false, createdAt: Date | null, expiresAt: Date | null}
1036
+ * Returns the mute status for the current channel.
1037
+ *
1038
+ * @returns An object of the form `{ muted: true | false, createdAt: Date | null, expiresAt: Date | null }`.
1067
1039
  */
1068
- muteStatus(): {
1069
- createdAt: Date | null;
1070
- expiresAt: Date | null;
1071
- muted: boolean;
1072
- } {
1040
+ muteStatus() {
1073
1041
  this._checkInitialized();
1074
1042
  return this.getClient()._muteStatus(this.cid);
1075
1043
  }
1076
1044
 
1077
- sendAction(messageID: string, formData: Record<string, string>) {
1045
+ sendAction(messageId: string, formData: Record<string, string>) {
1078
1046
  this._checkInitialized();
1079
- if (!messageID) {
1080
- throw Error(`Message id is missing`);
1047
+ if (!messageId) {
1048
+ throw Error(`Message ID is missing`);
1081
1049
  }
1082
- return this.getClient().post<SendMessageAPIResponse>(
1083
- this.getClient().baseURL + `/messages/${encodeURIComponent(messageID)}/action`,
1084
- {
1085
- message_id: messageID,
1086
- form_data: formData,
1087
- id: this.id,
1088
- type: this.type,
1089
- },
1090
- );
1050
+ return this.getClient().runMessageAction({
1051
+ id: messageId,
1052
+ form_data: formData,
1053
+ });
1091
1054
  }
1092
1055
 
1093
1056
  /**
1094
- * keystroke - First of the typing.start and typing.stop events based on the users keystrokes.
1095
- * Call this on every keystroke
1057
+ * First of the `typing.start` and `typing.stop` events based on the user's keystrokes.
1058
+ * Call this on every keystroke.
1059
+ *
1096
1060
  * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs}
1097
- * @param {string} [parent_id] set this field to `message.id` to indicate that typing event is happening in a thread
1061
+ *
1062
+ * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional).
1063
+ * @param options - Optional override carrying a `user_id` (optional).
1098
1064
  */
1099
- async keystroke(parent_id?: string, options?: { user_id: string }) {
1065
+ async keystroke(parentId?: string, options?: { user_id: string }) {
1100
1066
  if (!this._isTypingIndicatorsEnabled()) {
1101
1067
  return;
1102
1068
  }
@@ -1108,10 +1074,14 @@ export class Channel {
1108
1074
  if (diff === null || diff > 2000) {
1109
1075
  this.lastTypingEvent = new Date();
1110
1076
  await this.sendEvent({
1111
- type: 'typing.start',
1112
- parent_id,
1113
- ...(options || {}),
1114
- } as Event);
1077
+ event: {
1078
+ type: 'typing.start',
1079
+ parent_id: parentId,
1080
+ ...(options || {}),
1081
+ created_at: new Date(),
1082
+ custom: {},
1083
+ },
1084
+ });
1115
1085
  }
1116
1086
  }
1117
1087
 
@@ -1120,8 +1090,9 @@ export class Channel {
1120
1090
  * Typically used by the server connected to the AI service to notify clients of state changes.
1121
1091
  *
1122
1092
  * @param messageId - The ID of the message associated with the AI state.
1123
- * @param state - The new state of the AI process (e.g., thinking, generating).
1124
- * @param options - Optional parameters, such as `ai_message`, to include additional details in the event.
1093
+ * @param state - The new state of the AI process, e.g. thinking, generating.
1094
+ * @param options - Parameters such as `ai_message` to include additional details in the event (optional, defaults to `{}`).
1095
+ * @param options.ai_message - Additional message detail to include in the event (optional).
1125
1096
  */
1126
1097
  async updateAIState(
1127
1098
  messageId: string,
@@ -1129,11 +1100,15 @@ export class Channel {
1129
1100
  options: { ai_message?: string } = {},
1130
1101
  ) {
1131
1102
  await this.sendEvent({
1132
- ...options,
1133
- type: 'ai_indicator.update',
1134
- message_id: messageId,
1135
- ai_state: state,
1136
- } as Event);
1103
+ event: {
1104
+ ...options,
1105
+ type: 'ai_indicator.update',
1106
+ message_id: messageId,
1107
+ ai_state: state,
1108
+ created_at: new Date(),
1109
+ custom: {},
1110
+ },
1111
+ });
1137
1112
  }
1138
1113
 
1139
1114
  /**
@@ -1142,8 +1117,12 @@ export class Channel {
1142
1117
  */
1143
1118
  async clearAIIndicator() {
1144
1119
  await this.sendEvent({
1145
- type: 'ai_indicator.clear',
1146
- } as Event);
1120
+ event: {
1121
+ type: 'ai_indicator.clear',
1122
+ created_at: new Date(),
1123
+ custom: {},
1124
+ },
1125
+ });
1147
1126
  }
1148
1127
 
1149
1128
  /**
@@ -1152,26 +1131,37 @@ export class Channel {
1152
1131
  */
1153
1132
  async stopAIResponse() {
1154
1133
  await this.sendEvent({
1155
- type: 'ai_indicator.stop',
1156
- } as Event);
1134
+ event: {
1135
+ type: 'ai_indicator.stop',
1136
+ created_at: new Date(),
1137
+ custom: {},
1138
+ },
1139
+ });
1157
1140
  }
1158
1141
 
1159
1142
  /**
1160
- * stopTyping - Sets last typing to null and sends the typing.stop event
1143
+ * Sets last typing to null and sends the `typing.stop` event.
1144
+ *
1161
1145
  * @see {@link https://getstream.io/chat/docs/typing_indicators/?language=js|Docs}
1162
- * @param {string} [parent_id] set this field to `message.id` to indicate that typing event is happening in a thread
1146
+ *
1147
+ * @param parentId - Set this field to `message.id` to indicate that the typing event is happening in a thread (optional).
1148
+ * @param options - Optional override carrying a `user_id` (optional).
1163
1149
  */
1164
- async stopTyping(parent_id?: string, options?: { user_id: string }) {
1150
+ async stopTyping(parentId?: string, options?: { user_id: string }) {
1165
1151
  if (!this._isTypingIndicatorsEnabled()) {
1166
1152
  return;
1167
1153
  }
1168
1154
  this.lastTypingEvent = null;
1169
1155
  this.isTyping = false;
1170
1156
  await this.sendEvent({
1171
- type: 'typing.stop',
1172
- parent_id,
1173
- ...(options || {}),
1174
- } as Event);
1157
+ event: {
1158
+ type: 'typing.stop',
1159
+ parent_id: parentId,
1160
+ ...(options || {}),
1161
+ created_at: new Date(),
1162
+ custom: {},
1163
+ },
1164
+ });
1175
1165
  }
1176
1166
 
1177
1167
  _isTypingIndicatorsEnabled(): boolean {
@@ -1182,74 +1172,53 @@ export class Channel {
1182
1172
  }
1183
1173
 
1184
1174
  /**
1185
- * lastMessage - return the last message, takes into account that last few messages might not be perfectly sorted
1175
+ * Run this user's mark-read reporter for this channel. Delegates to
1176
+ * `MessageDeliveryReporter`, which batches the underlying `markRead` request
1177
+ * with the user's read receipts state.
1186
1178
  *
1187
- * @return {ReturnType<ChannelState['formatMessage']> | undefined} Description
1188
- */
1189
- lastMessage(): LocalMessage | undefined {
1190
- // get last 5 messages, sort, return the latest
1191
- // get a slice of the last 5
1192
- let min = this.state.latestMessages.length - 5;
1193
- if (min < 0) {
1194
- min = 0;
1195
- }
1196
- const max = this.state.latestMessages.length + 1;
1197
- const messageSlice = this.state.latestMessages.slice(min, max);
1198
-
1199
- // sort by pk desc
1200
- messageSlice.sort((a, b) => b.created_at.getTime() - a.created_at.getTime());
1201
-
1202
- return messageSlice[0];
1203
- }
1204
-
1205
- /**
1206
- * markRead - Send the mark read event for this user, only works if the `read_events` setting is enabled. Syncs the message delivery report candidates local state.
1179
+ * Use the inherited `markRead()` from `ChannelApi` for a direct, unbatched call.
1207
1180
  *
1208
- * @param {MarkReadOptions} data
1209
- * @return {Promise<EventAPIResponse | null>} Description
1181
+ * @param data - Mark read options (optional, defaults to `{}`).
1210
1182
  */
1211
- async markRead(data: MarkReadOptions = {}) {
1183
+ async markReadViaReporter(data: MarkReadRequest = {}) {
1212
1184
  return await this.getClient().messageDeliveryReporter.markRead(this, data);
1213
1185
  }
1214
1186
 
1215
1187
  /**
1216
- * markReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled
1188
+ * Override of the inherited `markRead()` from `ChannelApi` that requires the
1189
+ * channel to be initialized and respects the `read_events` channel config.
1217
1190
  *
1218
- * @param {MarkReadOptions} data
1219
- * @return {Promise<EventAPIResponse | null>} Description
1191
+ * @param data - Mark read options (optional, defaults to `{}`).
1192
+ * @returns The server response, or `null` if the request was skipped.
1220
1193
  */
1221
- async markAsReadRequest(data: MarkReadOptions = {}) {
1194
+ override async markRead(data?: MarkReadRequest) {
1222
1195
  this._checkInitialized();
1223
1196
 
1224
- if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) {
1225
- return null;
1197
+ if (!this.getConfig()?.read_events) {
1198
+ throw new Error('Read events are disabled for this application');
1226
1199
  }
1227
1200
 
1228
- return await this.getClient().post<EventAPIResponse>(this._channelURL() + '/read', {
1229
- ...data,
1230
- });
1201
+ return await super.markRead(data);
1231
1202
  }
1232
1203
 
1233
1204
  /**
1234
- * markUnread - Mark the channel as unread from messageID, only works if the `read_events` setting is enabled
1205
+ * Marks the channel as unread from `messageId`. Only works when the `read_events` setting is enabled.
1235
1206
  *
1236
- * @param {MarkUnreadOptions} data
1237
- * @return {APIResponse} An API response
1207
+ * @param data - Mark unread options.
1208
+ * @returns An API response, or `null` if the request was skipped.
1238
1209
  */
1239
- async markUnread(data: MarkUnreadOptions) {
1210
+ override async markUnread(data?: MarkUnreadRequest) {
1240
1211
  this._checkInitialized();
1241
1212
 
1242
- if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) {
1243
- return Promise.resolve(null);
1213
+ if (!this.getConfig()?.read_events) {
1214
+ throw new Error('Read events are disabled for this application');
1244
1215
  }
1245
1216
 
1246
- return await this.getClient().post<APIResponse>(this._channelURL() + '/unread', {
1247
- ...data,
1248
- });
1217
+ return await super.markUnread(data);
1249
1218
  }
1250
1219
 
1251
1220
  /**
1252
- * markReadLocally - Resets this user's unread count locally, without any backend call. Intended for
1221
+ * Resets this user's unread count locally, without any backend call. Intended for
1253
1222
  * channels that have read events disabled (e.g. livestreams) when the client is created with the
1254
1223
  * `isLocalUnreadCountEnabled` option. Dispatches a dedicated, client-only `message.read_locally` event
1255
1224
  * that runs through the same `_handleChannelEvent` read logic as a real `message.read` (minus the
@@ -1257,21 +1226,21 @@ export class Channel {
1257
1226
  * is enabled, the offline DB persists the reset for read-events-disabled channels, so the local
1258
1227
  * count stays consistent across app restarts.
1259
1228
  *
1260
- * @return {Event | undefined} The dispatched `message.read_locally` event, or `undefined` if there is no connected user.
1229
+ * @returns The dispatched `message.read_locally` event, or `undefined` if there is no connected user.
1261
1230
  */
1262
1231
  markReadLocally() {
1263
1232
  const client = this.getClient();
1264
- if (!client.userID) return;
1233
+ if (!client.userId) return;
1265
1234
 
1266
- const event: Event = {
1235
+ const event: EventPayload<'message.read_locally'> = {
1267
1236
  channel_id: this.id,
1268
1237
  channel_type: this.type,
1269
1238
  cid: this.cid,
1270
- created_at: new Date().toISOString(),
1271
- last_read_message_id: this.lastMessage()?.id,
1239
+ created_at: new Date(),
1240
+ last_read_message_id: this.messagePaginator.headmostItem?.id,
1272
1241
  team: this.data?.team,
1273
1242
  type: 'message.read_locally',
1274
- user: client.user,
1243
+ user: client.user as UserResponse,
1275
1244
  };
1276
1245
  client.dispatchEvent(event);
1277
1246
 
@@ -1279,7 +1248,7 @@ export class Channel {
1279
1248
  }
1280
1249
 
1281
1250
  /**
1282
- * clean - Cleans the channel state and fires stop typing if needed
1251
+ * Cleans the channel state and fires stop typing if needed.
1283
1252
  */
1284
1253
  clean() {
1285
1254
  if (this.lastKeyStroke) {
@@ -1294,13 +1263,12 @@ export class Channel {
1294
1263
  }
1295
1264
 
1296
1265
  /**
1297
- * watch - Loads the initial channel state and watches for changes
1298
- *
1299
- * @param {ChannelQueryOptions} options additional options for the query endpoint
1266
+ * Loads the initial channel state and watches for changes.
1300
1267
  *
1301
- * @return {Promise<QueryChannelAPIResponse>} The server response
1268
+ * @param options - Additional options for the query endpoint (optional).
1269
+ * @returns The server response.
1302
1270
  */
1303
- async watch(options?: ChannelQueryOptions) {
1271
+ async watch(options?: ChannelGetOrCreateRequest) {
1304
1272
  const defaultOptions = {
1305
1273
  state: true,
1306
1274
  watch: true,
@@ -1317,138 +1285,102 @@ export class Channel {
1317
1285
  const combined = { ...defaultOptions, ...options };
1318
1286
  const state = await this.query(combined, 'latest');
1319
1287
  this.initialized = true;
1288
+ const previousData = this.data;
1320
1289
  this.data = state.channel;
1290
+ this._syncStateFromChannelData(this.data, previousData);
1321
1291
 
1322
- this._client.logger(
1323
- 'info',
1324
- `channel:watch() - started watching channel ${this.cid}`,
1325
- {
1326
- tags: ['channel'],
1327
- channel: this,
1328
- },
1329
- );
1292
+ // The message paginator is seeded synchronously inside query() (before read-state hydration),
1293
+ // so a channel opened via watch() alone — a deep-link restore, a search result, a freshly
1294
+ // created DM — already has its latest page loaded here.
1295
+
1296
+ logger.withExtraTags('watch', this.cid).info('Started watching the channel.');
1330
1297
  return state;
1331
1298
  }
1332
1299
 
1333
1300
  /**
1334
- * stopWatching - Stops watching the channel
1301
+ * Stops watching the channel.
1335
1302
  *
1336
- * @return {Promise<APIResponse>} The server response
1303
+ * @param request - The stop-watching request payload (optional).
1304
+ * @returns The server response.
1337
1305
  */
1338
- async stopWatching() {
1339
- const response = await this.getClient().post<APIResponse>(
1340
- this._channelURL() + '/stop-watching',
1341
- {},
1342
- );
1306
+ override async stopWatching(request?: Gen_ChannelStopWatchingRequest) {
1307
+ const response = await super.stopWatching(request);
1343
1308
 
1344
- this._client.logger(
1345
- 'info',
1346
- `channel:watch() - stopped watching channel ${this.cid}`,
1347
- {
1348
- tags: ['channel'],
1349
- channel: this,
1350
- },
1351
- );
1309
+ logger.withExtraTags('stopWatching', this.cid).info('Stopped watching the channel.');
1352
1310
 
1353
1311
  return response;
1354
1312
  }
1355
1313
 
1356
1314
  /**
1357
- * getReplies - List the message replies for a parent message.
1315
+ * List the message replies for a parent message.
1358
1316
  *
1359
- * The recommended way of working with threads is to use the Thread class.
1317
+ * The recommended way of working with threads is to use the `Thread` class.
1360
1318
  *
1361
- * @param {string} parent_id The message parent id, ie the top of the thread
1362
- * @param {MessagePaginationOptions & { user?: UserResponse; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10}
1363
- *
1364
- * @return {Promise<GetRepliesAPIResponse>} A response with a list of messages
1319
+ * @param request - The get-replies request payload, including the parent message ID, pagination
1320
+ * params, and optional sort directions for `created_at`.
1321
+ * @returns A response with a list of messages.
1365
1322
  */
1366
- async getReplies(
1367
- parent_id: string,
1368
- options: MessagePaginationOptions & { user?: UserResponse; user_id?: string },
1369
- sort?: { created_at: AscDesc }[],
1370
- ) {
1371
- const normalizedSort = sort ? normalizeQuerySort(sort) : undefined;
1372
- const data = await this.getClient().get<GetRepliesAPIResponse>(
1373
- this.getClient().baseURL + `/messages/${encodeURIComponent(parent_id)}/replies`,
1374
- {
1375
- sort: normalizedSort,
1376
- ...options,
1377
- },
1378
- );
1379
-
1380
- // add any messages to our thread state
1381
- if (data.messages) {
1382
- this.state.addMessagesSorted(data.messages);
1383
- }
1323
+ async getReplies(request: GetRepliesRequest) {
1324
+ const data = await this.getClient().getReplies(request);
1384
1325
 
1326
+ // Thread reply state is owned by the Thread object (Thread.messagePaginator); the returned
1327
+ // replies are consumed there. The channel message list is owned by channel.messagePaginator.
1385
1328
  return data;
1386
1329
  }
1387
1330
 
1331
+ // TODO: find out v2 equivalent
1388
1332
  /**
1389
- * getPinnedMessages - List list pinned messages of the channel
1390
- *
1391
- * @param {PinnedMessagePaginationOptions & { user?: UserResponse; user_id?: string }} options Pagination params, ie {limit:10, id_lte: 10}
1392
- * @param {PinnedMessagesSort} sort defines sorting direction of pinned messages
1333
+ * List pinned messages of the channel.
1393
1334
  *
1394
- * @return {Promise<GetRepliesAPIResponse>} A response with a list of messages
1335
+ * @param options - Pagination params, e.g. `{ limit: 10, id_lte: 10 }`.
1336
+ * @param sort - Defines sorting direction of pinned messages (optional, defaults to `[]`).
1337
+ * @returns A response with a list of messages.
1395
1338
  */
1396
1339
  async getPinnedMessages(
1397
- options: PinnedMessagePaginationOptions & { user?: UserResponse; user_id?: string },
1340
+ options: PinnedMessagePaginationOptions,
1398
1341
  sort: PinnedMessagesSort = [],
1399
1342
  ) {
1400
- return await this.getClient().get<GetRepliesAPIResponse>(
1343
+ return await this.getClient().api.get<GetRepliesAPIResponse>(
1401
1344
  this._channelURL() + '/pinned_messages',
1402
1345
  {
1403
1346
  payload: {
1404
1347
  ...options,
1405
- sort: normalizeQuerySort(sort),
1348
+ sort,
1406
1349
  },
1407
1350
  },
1408
1351
  );
1409
1352
  }
1410
1353
 
1411
1354
  /**
1412
- * getReactions - List the reactions, supports pagination
1413
- *
1414
- * @param {string} message_id The message id
1415
- * @param {{ limit?: number; offset?: number }} options The pagination options
1355
+ * List the reactions; supports pagination.
1416
1356
  *
1417
- * @return {Promise<GetReactionsAPIResponse>} Server response
1357
+ * @param request - The request payload, including the target message ID and
1358
+ * pagination options (`limit`, `offset`).
1359
+ * @returns The server response.
1418
1360
  */
1419
- getReactions(message_id: string, options: { limit?: number; offset?: number }) {
1420
- return this.getClient().get<GetReactionsAPIResponse>(
1421
- this.getClient().baseURL + `/messages/${encodeURIComponent(message_id)}/reactions`,
1422
- {
1423
- ...options,
1424
- },
1425
- );
1361
+ getReactions(request: Parameters<ChatApi['getReactions']>[0]) {
1362
+ return this.getClient().getReactions(request);
1426
1363
  }
1427
1364
 
1428
1365
  /**
1429
- * getMessagesById - Retrieves a list of messages by ID
1366
+ * Retrieves a list of messages by ID.
1430
1367
  *
1431
- * @param {string[]} messageIds The ids of the messages to retrieve from this channel
1432
- *
1433
- * @return {Promise<GetMultipleMessagesAPIResponse>} Server response
1368
+ * @param messageIds - The IDs of the messages to retrieve from this channel.
1369
+ * @returns Server response.
1434
1370
  */
1435
1371
  getMessagesById(messageIds: string[]) {
1436
- return this.getClient().get<GetMultipleMessagesAPIResponse>(
1437
- this._channelURL() + '/messages',
1438
- {
1439
- ids: messageIds.join(','),
1440
- },
1441
- );
1372
+ return this.getManyMessages({ ids: messageIds });
1442
1373
  }
1443
1374
 
1444
1375
  /**
1445
- * lastRead - returns the last time the user marked the channel as read if the user never marked the channel as read, this will return null
1446
- * @return {Date | null | undefined}
1376
+ * Returns the last time the user marked the channel as read. If the user never marked the channel as read, this will return `null`.
1377
+ *
1378
+ * @returns The last-read `Date`, `null` if never read, or `undefined` if the user is unset.
1447
1379
  */
1448
1380
  lastRead() {
1449
- const { userID } = this.getClient();
1450
- if (userID) {
1451
- return this.state.read[userID] ? this.state.read[userID].last_read : null;
1381
+ const { userId } = this.getClient();
1382
+ if (userId) {
1383
+ return this.state.read[userId] ? this.state.read[userId].last_read : null;
1452
1384
  }
1453
1385
  }
1454
1386
 
@@ -1456,7 +1388,7 @@ export class Channel {
1456
1388
  if (message.shadowed) return false;
1457
1389
  if (message.silent) return false;
1458
1390
  if (message.parent_id && !message.show_in_channel) return false;
1459
- if (message.user?.id === this.getClient().userID) return false;
1391
+ if (message.user?.id === this.getClient().userId) return false;
1460
1392
  if (message.user?.id && this.getClient().userMuteStatus(message.user.id))
1461
1393
  return false;
1462
1394
 
@@ -1478,18 +1410,17 @@ export class Channel {
1478
1410
  }
1479
1411
 
1480
1412
  /**
1481
- * countUnread - Count of unread messages
1482
- *
1483
- * @param {Date | null} [lastRead] lastRead the time that the user read a message, defaults to current user's read state
1413
+ * Count of unread messages.
1484
1414
  *
1485
- * @return {number} Unread count
1415
+ * @param lastRead - The time that the user read a message (optional, defaults to the current user's read state).
1416
+ * @returns Unread count.
1486
1417
  */
1487
1418
  countUnread(lastRead?: Date | null) {
1488
1419
  if (!lastRead) return this.state.unreadCount;
1489
- // todo: prevent finding the latest message set on each iteration
1490
1420
  let count = 0;
1491
- for (let i = 0; i < this.state.latestMessages.length; i += 1) {
1492
- const message = this.state.latestMessages[i];
1421
+ const latestMessages = this.messagePaginator.headItems;
1422
+ for (let i = 0; i < latestMessages.length; i += 1) {
1423
+ const message = latestMessages[i];
1493
1424
  if (message.created_at > lastRead && this._countMessageAsUnread(message)) {
1494
1425
  count++;
1495
1426
  }
@@ -1498,21 +1429,22 @@ export class Channel {
1498
1429
  }
1499
1430
 
1500
1431
  /**
1501
- * countUnreadMentions - Count the number of unread messages mentioning the current user
1432
+ * Count the number of unread messages mentioning the current user.
1502
1433
  *
1503
- * @return {number} Unread mentions count
1434
+ * @returns Unread mentions count.
1504
1435
  */
1505
1436
  countUnreadMentions() {
1506
1437
  const lastRead = this.lastRead();
1507
- const userID = this.getClient().userID;
1438
+ const userId = this.getClient().userId;
1508
1439
 
1509
1440
  let count = 0;
1510
- for (let i = 0; i < this.state.latestMessages.length; i += 1) {
1511
- const message = this.state.latestMessages[i];
1441
+ const latestMessages = this.messagePaginator.headItems;
1442
+ for (let i = 0; i < latestMessages.length; i += 1) {
1443
+ const message = latestMessages[i];
1512
1444
  if (
1513
1445
  this._countMessageAsUnread(message) &&
1514
1446
  (!lastRead || message.created_at > lastRead) &&
1515
- message.mentioned_users?.some((user) => user.id === userID)
1447
+ message.mentioned_users?.some((user) => user.id === userId)
1516
1448
  ) {
1517
1449
  count++;
1518
1450
  }
@@ -1521,12 +1453,12 @@ export class Channel {
1521
1453
  }
1522
1454
 
1523
1455
  /**
1524
- * create - Creates a new channel
1525
- *
1526
- * @return {Promise<QueryChannelAPIResponse>} The Server Response
1456
+ * Creates a new channel.
1527
1457
  *
1458
+ * @param options - Channel query options (optional).
1459
+ * @returns The server response.
1528
1460
  */
1529
- create = async (options?: ChannelQueryOptions) => {
1461
+ create = async (options?: ChannelGetOrCreateRequest) => {
1530
1462
  const defaultOptions = {
1531
1463
  ...options,
1532
1464
  watch: false,
@@ -1537,53 +1469,42 @@ export class Channel {
1537
1469
  };
1538
1470
 
1539
1471
  /**
1540
- * query - Query the API, get messages, members or other channel fields
1541
- *
1542
- * @param {ChannelQueryOptions} options The query options
1543
- * @param {MessageSetType} messageSetToAddToIfDoesNotExist It's possible to load disjunct sets of a channel's messages into state, use `current` to load the initial channel state or if you want to extend the currently displayed messages, use `latest` if you want to load/extend the latest messages, `new` is used for loading a specific message and it's surroundings
1472
+ * Queries the API to load messages, members, or other channel fields.
1544
1473
  *
1545
- * @return {Promise<QueryChannelAPIResponse>} Returns a query response
1474
+ * @param options - The query options (optional, defaults to `{}`).
1475
+ * @param messageSetToAddToIfDoesNotExist - It's possible to load disjunct sets of a channel's
1476
+ * messages into state. Use `current` to load the initial channel state or to extend the
1477
+ * currently displayed messages; use `latest` to load/extend the latest messages; `new` is
1478
+ * used for loading a specific message and its surroundings (optional, defaults to `'current'`).
1479
+ * @returns A query response.
1546
1480
  */
1547
1481
  async query(
1548
- options: ChannelQueryOptions = {},
1482
+ options: ChannelGetOrCreateRequest = {},
1549
1483
  messageSetToAddToIfDoesNotExist: MessageSetType = 'current',
1550
1484
  ) {
1551
1485
  // Make sure we wait for the connect promise if there is a pending one
1552
1486
  await this.getClient().wsPromise;
1553
1487
 
1554
- const createdById =
1555
- options.created_by?.id ??
1556
- options.created_by_id ??
1557
- this._data?.created_by?.id ??
1558
- this._data?.created_by_id;
1559
-
1560
- if (this.getClient()._isUsingServerAuth() && typeof createdById !== 'string') {
1561
- this.getClient().logger(
1562
- 'warn',
1563
- 'Either `created_by` (with `id` property) or `created_by_id` are missing from both `Channel._data` and `options` parameter',
1564
- );
1565
- }
1488
+ const queryPayload: ChannelGetOrCreateRequest = {
1489
+ data: this._data,
1490
+ state: true,
1491
+ ...options,
1492
+ };
1566
1493
 
1567
- let queryURL = `${this.getClient().baseURL}/channels/${encodeURIComponent(
1568
- this.type,
1569
- )}`;
1570
- if (this.id) {
1571
- queryURL += `/${encodeURIComponent(this.id)}`;
1572
- }
1494
+ const state = this.id
1495
+ ? await this.getOrCreate(queryPayload)
1496
+ : await this.getClient().getOrCreateDistinctChannel({
1497
+ type: this.type,
1498
+ ...queryPayload,
1499
+ });
1573
1500
 
1574
- const state = await this.getClient().post<QueryChannelAPIResponse>(
1575
- queryURL + '/query',
1576
- {
1577
- data: this._data,
1578
- state: true,
1579
- ...options,
1580
- },
1581
- );
1501
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1502
+ const channel = state.channel!;
1582
1503
 
1583
1504
  // update the channel id if it was missing
1584
1505
  if (!this.id) {
1585
- this.id = state.channel.id;
1586
- this.cid = state.channel.cid;
1506
+ this.id = channel.id;
1507
+ this.cid = channel.cid;
1587
1508
  // set the channel as active...
1588
1509
 
1589
1510
  const tempChannelCid = generateChannelTempCid(
@@ -1605,34 +1526,41 @@ export class Channel {
1605
1526
  }
1606
1527
  }
1607
1528
 
1608
- this.getClient()._addChannelConfig(state.channel);
1529
+ this.getClient()._addChannelConfig(channel);
1609
1530
 
1610
1531
  // the only config param that is necessary to be updated based on server config soon as the config is delivered
1611
- if (typeof state.channel.config?.shared_locations !== 'undefined') {
1532
+ if (typeof channel.config?.shared_locations !== 'undefined') {
1612
1533
  this.messageComposer.updateConfig({
1613
- location: { enabled: state.channel.config.shared_locations },
1534
+ location: { enabled: channel.config.shared_locations },
1614
1535
  });
1615
1536
  }
1616
1537
 
1617
- // add any messages to our channel state
1618
- const { messageSet, filteredMessageIds } = this._initializeState(
1619
- state,
1620
- messageSetToAddToIfDoesNotExist,
1621
- );
1622
- messageSet.pagination = {
1623
- ...messageSet.pagination,
1624
- ...messageSetPagination({
1625
- parentSet: messageSet,
1626
- messagePaginationOptions: options?.messages,
1627
- requestedPageSize:
1628
- options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE,
1629
- returnedPage: state.messages,
1630
- filteredReturnedPage: state.messages.filter(
1631
- (m) => !filteredMessageIds.includes(m.id),
1632
- ),
1633
- logger: this.getClient().logger,
1634
- }),
1635
- };
1538
+ // Seed the message paginator with the first (latest) page BEFORE _initializeState, which
1539
+ // hydrates the read state and (via MessageReceiptsTracker) resolves read/delivered cursors
1540
+ // against this paginator. Seeding first guarantees the tracker sees a populated timeline; a
1541
+ // later async seed would run after the reconcile and mislabel delivery status. Only the
1542
+ // latest-page open paths (watch/create) pass 'latest' — the paginator's own pagination queries
1543
+ // use 'current' and must not be reseeded as a first page here.
1544
+ if (messageSetToAddToIfDoesNotExist === 'latest' && Array.isArray(state.messages)) {
1545
+ const requestedPageSize =
1546
+ options?.messages?.limit ?? DEFAULT_QUERY_CHANNEL_MESSAGE_LIST_PAGE_SIZE;
1547
+ // Pass the query's message pagination options through: a channel can be opened AROUND a
1548
+ // message (id_around / created_at_around), in which case the fetched page is a jump window,
1549
+ // not the latest page — the paginator must reconcile it with jump semantics.
1550
+ this.messagePaginator.seedFirstPageSync(
1551
+ state.messages.map(formatMessage),
1552
+ requestedPageSize,
1553
+ options?.messages,
1554
+ );
1555
+ }
1556
+
1557
+ // Seed read/members/pinned/thread-cleanup state; the message list is in the paginator.
1558
+ this._initializeState(state);
1559
+ // The queried page is the latest set unless this was a jump/around query.
1560
+ const isLatestMessageSet =
1561
+ messageSetToAddToIfDoesNotExist === 'latest' &&
1562
+ !options?.messages?.id_around &&
1563
+ !(options?.messages as MessagePaginationOptions | undefined)?.created_at_around;
1636
1564
 
1637
1565
  this.getClient().polls.hydratePollCache(state.messages, true);
1638
1566
  this.getClient().reminders.hydrateState(state.messages);
@@ -1640,7 +1568,7 @@ export class Channel {
1640
1568
  this.messageComposer.initStateFromChannelResponse(state);
1641
1569
 
1642
1570
  const areCapabilitiesChanged =
1643
- [...(state.channel.own_capabilities || [])].sort().join() !==
1571
+ [...(channel.own_capabilities || [])].sort().join() !==
1644
1572
  [
1645
1573
  ...(this.data && Array.isArray(this.data?.own_capabilities)
1646
1574
  ? this.data.own_capabilities
@@ -1648,7 +1576,9 @@ export class Channel {
1648
1576
  ]
1649
1577
  .sort()
1650
1578
  .join();
1651
- this.data = state.channel;
1579
+ const previousData = this.data;
1580
+ this.data = channel;
1581
+ this._syncStateFromChannelData(this.data, previousData);
1652
1582
  this.offlineMode = false;
1653
1583
  this.cooldownTimer.refresh();
1654
1584
 
@@ -1656,7 +1586,7 @@ export class Channel {
1656
1586
  this.getClient().dispatchEvent({
1657
1587
  type: 'capabilities.changed',
1658
1588
  cid: this.cid,
1659
- own_capabilities: state.channel.own_capabilities,
1589
+ own_capabilities: channel.own_capabilities ?? [],
1660
1590
  });
1661
1591
  }
1662
1592
 
@@ -1664,14 +1594,14 @@ export class Channel {
1664
1594
  type: 'channels.queried',
1665
1595
  queriedChannels: {
1666
1596
  channels: [state],
1667
- isLatestMessageSet: messageSet.isLatest,
1597
+ isLatestMessageSet,
1668
1598
  },
1669
1599
  });
1670
1600
  this.getClient().offlineDb?.executeQuerySafely(
1671
1601
  (db) =>
1672
1602
  db.upsertChannels?.({
1673
1603
  channels: [state],
1674
- isLatestMessagesSet: messageSet.isLatest,
1604
+ isLatestMessagesSet: isLatestMessageSet,
1675
1605
  }),
1676
1606
  { method: 'upsertChannels' },
1677
1607
  );
@@ -1681,15 +1611,15 @@ export class Channel {
1681
1611
  }
1682
1612
 
1683
1613
  /**
1684
- * banUser - Bans a user from a channel
1614
+ * Bans a user from a channel.
1685
1615
  *
1686
- * @param {string} targetUserID
1687
- * @param {BanUserOptions} options
1688
- * @returns {Promise<APIResponse>}
1616
+ * @param targetUserId - The user to ban.
1617
+ * @param options - Ban options.
1618
+ * @returns The server response.
1689
1619
  */
1690
- async banUser(targetUserID: string, options: BanUserOptions) {
1620
+ async banUser(targetUserId: string, options: BanUserOptions) {
1691
1621
  this._checkInitialized();
1692
- return await this.getClient().banUser(targetUserID, {
1622
+ return await this.getClient().banUser(targetUserId, {
1693
1623
  ...options,
1694
1624
  type: this.type,
1695
1625
  id: this.id,
@@ -1697,45 +1627,39 @@ export class Channel {
1697
1627
  }
1698
1628
 
1699
1629
  /**
1700
- * hides the channel from queryChannels for the user until a message is added
1701
- * If clearHistory is set to true - all messages will be removed for the user
1630
+ * Hides the channel from `queryChannels` for the user until a message is added.
1631
+ * If `clear_history` is set to `true`, all messages will be removed for the user.
1702
1632
  *
1703
- * @param {string | null} userId
1704
- * @param {boolean} clearHistory
1705
- * @returns {Promise<APIResponse>}
1633
+ * @param request - The hide channel request payload (optional). Pass `{ clear_history: true }`
1634
+ * to clear message history for the user.
1635
+ * @returns The server response.
1706
1636
  */
1707
- async hide(userId: string | null = null, clearHistory = false) {
1637
+ override async hide(request?: Gen_HideChannelRequest) {
1708
1638
  this._checkInitialized();
1709
-
1710
- return await this.getClient().post<APIResponse>(`${this._channelURL()}/hide`, {
1711
- user_id: userId,
1712
- clear_history: clearHistory,
1713
- });
1639
+ return await super.hide(request);
1714
1640
  }
1715
1641
 
1716
1642
  /**
1717
- * removes the hidden status for a channel
1643
+ * Removes the hidden status for a channel. Ensures the channel is initialized first.
1718
1644
  *
1719
- * @param {string | null} userId
1720
- * @returns {Promise<APIResponse>}
1645
+ * @param request - The show channel request payload (optional).
1646
+ * @returns The server response.
1721
1647
  */
1722
- async show(userId: string | null = null) {
1648
+ override async show(request?: Gen_ShowChannelRequest) {
1723
1649
  this._checkInitialized();
1724
- return await this.getClient().post<APIResponse>(`${this._channelURL()}/show`, {
1725
- user_id: userId,
1726
- });
1650
+ return await super.show(request);
1727
1651
  }
1728
1652
 
1729
1653
  /**
1730
- * unbanUser - Removes the bans for a user on a channel
1654
+ * Removes the bans for a user on a channel.
1731
1655
  *
1732
- * @param {string} targetUserID
1733
- * @param {UnBanUserOptions} options
1734
- * @returns {Promise<APIResponse>}
1656
+ * @param targetUserId - The user to unban.
1657
+ * @param options - Unban options (optional).
1658
+ * @returns The server response.
1735
1659
  */
1736
- async unbanUser(targetUserID: string, options?: UnBanUserOptions) {
1660
+ async unbanUser(targetUserId: string, options?: UnBanUserOptions) {
1737
1661
  this._checkInitialized();
1738
- return await this.getClient().unbanUser(targetUserID, {
1662
+ return await this.getClient().unbanUser(targetUserId, {
1739
1663
  ...options,
1740
1664
  type: this.type,
1741
1665
  id: this.id,
@@ -1743,15 +1667,15 @@ export class Channel {
1743
1667
  }
1744
1668
 
1745
1669
  /**
1746
- * shadowBan - Shadow bans a user from a channel
1670
+ * Shadow bans a user from a channel.
1747
1671
  *
1748
- * @param {string} targetUserID
1749
- * @param {BanUserOptions} options
1750
- * @returns {Promise<APIResponse>}
1672
+ * @param targetUserId - The user to shadow ban.
1673
+ * @param options - Ban options.
1674
+ * @returns The server response.
1751
1675
  */
1752
- async shadowBan(targetUserID: string, options: BanUserOptions) {
1676
+ async shadowBan(targetUserId: string, options: BanUserOptions) {
1753
1677
  this._checkInitialized();
1754
- return await this.getClient().shadowBan(targetUserID, {
1678
+ return await this.getClient().shadowBan(targetUserId, {
1755
1679
  ...options,
1756
1680
  type: this.type,
1757
1681
  id: this.id,
@@ -1759,242 +1683,245 @@ export class Channel {
1759
1683
  }
1760
1684
 
1761
1685
  /**
1762
- * removeShadowBan - Removes the shadow ban for a user on a channel
1686
+ * Removes the shadow ban for a user on a channel.
1763
1687
  *
1764
- * @param {string} targetUserID
1765
- * @returns {Promise<APIResponse>}
1688
+ * @param targetUserId - The user to remove the shadow ban for.
1689
+ * @returns The server response.
1766
1690
  */
1767
- async removeShadowBan(targetUserID: string) {
1691
+ async removeShadowBan(targetUserId: string) {
1768
1692
  this._checkInitialized();
1769
- return await this.getClient().removeShadowBan(targetUserID, {
1693
+ return await this.getClient().removeShadowBan(targetUserId, {
1770
1694
  type: this.type,
1771
1695
  id: this.id,
1772
1696
  });
1773
1697
  }
1774
1698
 
1775
1699
  /**
1776
- * Cast or cancel one or more votes on a poll
1777
- * @param pollId string The poll id
1778
- * @param votes PollVoteData[] The votes that will be casted (or canceled in case of an empty array)
1779
- * @returns {APIResponse & PollVoteResponse} The poll votes
1700
+ * Casts or cancels one or more votes on a poll.
1701
+ *
1702
+ * @param request - The cast-poll-vote request payload, including the target message ID, poll ID,
1703
+ * and the vote to cast (or an empty payload to cancel).
1704
+ * @returns The poll vote response.
1780
1705
  */
1781
- async vote(messageId: string, pollId: string, vote: PollVoteData) {
1782
- return await this.getClient().castPollVote(messageId, pollId, vote);
1706
+ async vote(request: Parameters<ChatApi['castPollVote']>[0]) {
1707
+ return await this.getClient().castPollVote(request);
1783
1708
  }
1784
1709
 
1785
- async removeVote(messageId: string, pollId: string, voteId: string) {
1786
- return await this.getClient().removePollVote(messageId, pollId, voteId);
1710
+ async removeVote(request: Parameters<ChatApi['deletePollVote']>[0]) {
1711
+ return await this.getClient().deletePollVote(request);
1787
1712
  }
1788
1713
 
1789
- /**
1790
- * createDraft - Creates or updates a draft message in a channel
1791
- *
1792
- * @param {DraftMessagePayload} message The draft message to create or update
1793
- *
1794
- * @return {Promise<CreateDraftResponse>} Response containing the created draft
1795
- */
1796
- async _createDraft(message: DraftMessagePayload) {
1797
- return await this.getClient().post<CreateDraftResponse>(
1798
- this._channelURL() + '/draft',
1799
- {
1800
- message,
1801
- },
1802
- );
1714
+ async _createDraft(request: Gen_CreateDraftRequest) {
1715
+ return await super.createDraft(request);
1803
1716
  }
1804
1717
 
1805
1718
  /**
1806
- * createDraft - Creates or updates a draft message in a channel. If offline support is
1807
- * enabled, it will make sure that creating the draft is queued up if it fails due to
1808
- * bad internet conditions and executed later.
1809
- *
1810
- * @param {DraftMessagePayload} message The draft message to create or update
1811
- *
1812
- * @return {Promise<CreateDraftResponse>} Response containing the created draft
1719
+ * Creates or updates a draft message in a channel. If offline support is enabled, the
1720
+ * call is queued so it is replayed on reconnect.
1813
1721
  */
1814
- async createDraft(message: DraftMessagePayload) {
1722
+ override async createDraft(request: Gen_CreateDraftRequest) {
1815
1723
  try {
1816
1724
  const offlineDb = this.getClient().offlineDb;
1817
1725
  if (offlineDb) {
1818
- return await offlineDb.queueTask<CreateDraftResponse>({
1726
+ return (await offlineDb.queueTask<CreateDraftResponse>({
1819
1727
  task: {
1820
1728
  channelId: this.id as string,
1821
1729
  channelType: this.type,
1822
- threadId: message.parent_id,
1823
- payload: [message],
1730
+ threadId: request.message?.parent_id,
1731
+ payload: [request],
1824
1732
  type: 'create-draft',
1825
1733
  },
1826
- });
1734
+ })) as Awaited<ReturnType<ChannelApi['createDraft']>>;
1827
1735
  }
1828
1736
  } catch (error) {
1829
- this._client.logger('error', `offlineDb:create-draft`, {
1830
- tags: ['channel', 'offlineDb'],
1831
- error,
1832
- });
1737
+ offlineDbLogger
1738
+ .withExtraTags('createDraft', this.cid)
1739
+ .error('Creating the draft in the offline database failed.', { error });
1833
1740
  }
1834
1741
 
1835
- return this._createDraft(message);
1742
+ return this._createDraft(request);
1836
1743
  }
1837
1744
 
1838
- /**
1839
- * deleteDraft - Deletes a draft message from a channel or a thread.
1840
- *
1841
- * @param {Object} options
1842
- * @param {string} options.parent_id Optional parent message ID for drafts in threads
1843
- *
1844
- * @return {Promise<APIResponse>} API response
1845
- */
1846
- async _deleteDraft({ parent_id }: { parent_id?: string } = {}) {
1847
- return await this.getClient().delete<APIResponse>(this._channelURL() + '/draft', {
1848
- parent_id,
1849
- });
1745
+ async _deleteDraft(request?: Parameters<ChannelApi['deleteDraft']>[0]) {
1746
+ return await super.deleteDraft(request);
1850
1747
  }
1851
1748
 
1852
1749
  /**
1853
- * deleteDraft - Deletes a draft message from a channel or a thread. If offline support is
1854
- * enabled, it will make sure that deleting the draft is queued up if it fails due to
1855
- * bad internet conditions and executed later.
1856
- *
1857
- * @param {Object} options
1858
- * @param {string} options.parent_id Optional parent message ID for drafts in threads
1859
- *
1860
- * @return {Promise<APIResponse>} API response
1750
+ * Deletes a draft message from a channel or a thread. If offline support is enabled, the
1751
+ * call is queued so it is replayed on reconnect.
1861
1752
  */
1862
- async deleteDraft(options: { parent_id?: string } = {}) {
1863
- const { parent_id } = options;
1753
+ override async deleteDraft(request?: Parameters<ChannelApi['deleteDraft']>[0]) {
1864
1754
  try {
1865
1755
  const offlineDb = this.getClient().offlineDb;
1866
1756
  if (offlineDb) {
1867
- return await offlineDb.queueTask<APIResponse>({
1757
+ return (await offlineDb.queueTask<APIResponse>({
1868
1758
  task: {
1869
1759
  channelId: this.id as string,
1870
1760
  channelType: this.type,
1871
- threadId: parent_id,
1872
- payload: [options],
1761
+ threadId: request?.parent_id,
1762
+ payload: [request],
1873
1763
  type: 'delete-draft',
1874
1764
  },
1875
- });
1765
+ })) as Awaited<ReturnType<ChannelApi['deleteDraft']>>;
1876
1766
  }
1877
1767
  } catch (error) {
1878
- this._client.logger('error', `offlineDb:delete-draft`, {
1879
- tags: ['channel', 'offlineDb'],
1880
- error,
1881
- });
1768
+ offlineDbLogger
1769
+ .withExtraTags('deleteDraft', this.cid)
1770
+ .error('Deleting the draft from the offline database failed.', { error });
1882
1771
  }
1883
1772
 
1884
- return this._deleteDraft(options);
1773
+ return this._deleteDraft(request);
1885
1774
  }
1886
1775
 
1887
1776
  /**
1888
- * getDraft - Retrieves a draft message from a channel
1777
+ * Listens to events on this channel.
1889
1778
  *
1890
- * @param {Object} options
1891
- * @param {string} options.parent_id Optional parent message ID for drafts in threads
1892
- *
1893
- * @return {Promise<GetDraftResponse>} Response containing the draft
1894
- */
1895
- async getDraft({ parent_id }: { parent_id?: string } = {}) {
1896
- return await this.getClient().get<GetDraftResponse>(this._channelURL() + '/draft', {
1897
- parent_id,
1898
- });
1899
- }
1900
-
1901
- /**
1902
- * on - Listen to events on this channel.
1779
+ * @example
1780
+ * channel.on('message.new', (event) => {
1781
+ * console.log('my new message', event, channel.state.messages);
1782
+ * });
1903
1783
  *
1904
- * channel.on('message.new', event => {console.log("my new message", event, channel.state.messages)})
1905
- * or
1906
- * channel.on(event => {console.log(event.type)})
1784
+ * @example
1785
+ * channel.on((event) => {
1786
+ * console.log(event.type);
1787
+ * });
1907
1788
  *
1908
- * @param {EventHandler | EventTypes} callbackOrString The event type to listen for (optional)
1909
- * @param {EventHandler} [callbackOrNothing] The callback to call
1789
+ * @param callbackOrString - The event type to listen for, or the callback when listening to all events.
1790
+ * @param callbackOrNothing - The callback to call when an event type was provided (optional).
1791
+ * @returns An object with an `unsubscribe()` method.
1910
1792
  */
1911
- on(eventType: EventTypes, callback: EventHandler): { unsubscribe: () => void };
1793
+ on<T extends EventType | string>(
1794
+ eventType: T,
1795
+ callback: EventHandler<T>,
1796
+ ): { unsubscribe: () => void };
1912
1797
  on(callback: EventHandler): { unsubscribe: () => void };
1913
1798
  on(
1914
- callbackOrString: EventHandler | EventTypes,
1799
+ callbackOrString: EventHandler | string,
1915
1800
  callbackOrNothing?: EventHandler,
1916
1801
  ): { unsubscribe: () => void } {
1917
- const key = callbackOrNothing ? (callbackOrString as string) : 'all';
1918
- const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
1919
- if (!(key in this.listeners)) {
1920
- this.listeners[key] = [];
1921
- }
1922
- this._client.logger(
1923
- 'info',
1924
- `Attaching listener for ${key} event on channel ${this.cid}`,
1925
- {
1926
- tags: ['event', 'channel'],
1927
- channel: this,
1928
- },
1929
- );
1802
+ const key = callbackOrNothing ? (callbackOrString as EventType) : 'all';
1803
+ const callback = callbackOrNothing
1804
+ ? callbackOrNothing
1805
+ : (callbackOrString as EventHandler);
1806
+
1807
+ const set = this.listeners.get(key) ?? new Set();
1930
1808
 
1931
- this.listeners[key].push(callback);
1809
+ logger
1810
+ .withExtraTags('on', this.cid)
1811
+ .debug(`Attaching a listener for the "${key}" event.`);
1812
+ set.add(callback);
1813
+
1814
+ if (!this.listeners.has(key)) {
1815
+ this.listeners.set(key, set);
1816
+ }
1932
1817
 
1933
1818
  return {
1934
1819
  unsubscribe: () => {
1935
- this._client.logger(
1936
- 'info',
1937
- `Removing listener for ${key} event from channel ${this.cid}`,
1938
- {
1939
- tags: ['event', 'channel'],
1940
- channel: this,
1941
- },
1942
- );
1943
-
1944
- this.listeners[key] = this.listeners[key].filter((el) => el !== callback);
1820
+ logger
1821
+ .withExtraTags('on', this.cid)
1822
+ .debug(`Removing the listener for the "${key}" event.`);
1823
+ set.delete(callback);
1824
+ if (!set.size) {
1825
+ this.listeners.delete(key);
1826
+ }
1945
1827
  },
1946
1828
  };
1947
1829
  }
1948
1830
 
1949
1831
  /**
1950
- * off - Remove the event handler
1832
+ * Removes the event handler.
1951
1833
  *
1834
+ * @param callbackOrString - The event type, or the callback when removing an all-events listener.
1835
+ * @param callbackOrNothing - The callback to remove when an event type was provided (optional).
1952
1836
  */
1953
- off(eventType: EventTypes, callback: EventHandler): void;
1837
+ off<T extends EventType | string>(eventType: T, callback: EventHandler): void;
1954
1838
  off(callback: EventHandler): void;
1955
- off(
1956
- callbackOrString: EventHandler | EventTypes,
1957
- callbackOrNothing?: EventHandler,
1958
- ): void {
1959
- const key = callbackOrNothing ? (callbackOrString as string) : 'all';
1960
- const callback = callbackOrNothing ? callbackOrNothing : callbackOrString;
1961
- if (!(key in this.listeners)) {
1962
- this.listeners[key] = [];
1839
+ off(callbackOrString: EventHandler | string, callbackOrNothing?: EventHandler): void {
1840
+ const key = callbackOrNothing ? (callbackOrString as EventType) : 'all';
1841
+ const callback = callbackOrNothing
1842
+ ? callbackOrNothing
1843
+ : (callbackOrString as EventHandler);
1844
+
1845
+ logger
1846
+ .withExtraTags('off', this.cid)
1847
+ .debug(`Removing the listener for the "${key}" event.`);
1848
+
1849
+ const set = this.listeners.get(key);
1850
+
1851
+ set?.delete(callback);
1852
+
1853
+ if (!set?.size) {
1854
+ this.listeners.delete(key);
1963
1855
  }
1856
+ }
1964
1857
 
1965
- this._client.logger(
1966
- 'info',
1967
- `Removing listener for ${key} event from channel ${this.cid}`,
1968
- {
1969
- tags: ['event', 'channel'],
1970
- channel: this,
1971
- },
1972
- );
1973
- this.listeners[key] = this.listeners[key].filter((value) => value !== callback);
1858
+ private _patchReadState(
1859
+ patch: (currentReadState: ChannelState['read']) => ChannelState['read'],
1860
+ reconcileMeta?: ReadStoreReconcileMeta,
1861
+ ) {
1862
+ let hasStateChanged = false;
1863
+ this.messageReceiptsTracker.setPendingReadStoreReconcileMeta(reconcileMeta);
1864
+
1865
+ this.state.readStore.next((currentReadStoreState) => {
1866
+ const nextReadState = patch(currentReadStoreState.read);
1867
+
1868
+ if (nextReadState === currentReadStoreState.read) {
1869
+ return currentReadStoreState;
1870
+ }
1871
+ hasStateChanged = true;
1872
+
1873
+ return {
1874
+ ...currentReadStoreState,
1875
+ read: nextReadState,
1876
+ };
1877
+ });
1878
+
1879
+ if (!hasStateChanged) {
1880
+ this.messageReceiptsTracker.setPendingReadStoreReconcileMeta(undefined);
1881
+ }
1882
+ }
1883
+
1884
+ private _upsertReadState(
1885
+ userId: string,
1886
+ update: (
1887
+ currentUserReadState: ChannelState['read'][string] | undefined,
1888
+ ) => ChannelState['read'][string],
1889
+ reconcileMeta?: ReadStoreReconcileMeta,
1890
+ ) {
1891
+ let nextUserReadState: ChannelState['read'][string] | undefined;
1892
+
1893
+ this._patchReadState((currentReadState) => {
1894
+ const currentUserReadState = currentReadState[userId];
1895
+ const updatedUserReadState = update(currentUserReadState);
1896
+
1897
+ nextUserReadState = updatedUserReadState;
1898
+
1899
+ return {
1900
+ ...currentReadState,
1901
+ [userId]: updatedUserReadState,
1902
+ };
1903
+ }, reconcileMeta);
1904
+
1905
+ return nextUserReadState;
1974
1906
  }
1975
1907
 
1976
1908
  _handleChannelEvent(event: Event) {
1977
1909
  // eslint-disable-next-line @typescript-eslint/no-this-alias
1978
1910
  const channel = this;
1979
- this._client.logger(
1980
- 'info',
1981
- `channel:_handleChannelEvent - Received event of type { ${event.type} } on ${this.cid}`,
1982
- {
1983
- tags: ['event', 'channel'],
1984
- channel: this,
1985
- },
1986
- );
1911
+ logger
1912
+ .withExtraTags('_handleChannelEvent', this.cid)
1913
+ .debug(`Received an event of type "${event.type}".`, { event });
1987
1914
 
1988
1915
  const channelState = channel.state;
1989
1916
  switch (event.type) {
1990
1917
  case 'typing.start':
1991
1918
  if (event.user?.id) {
1992
- channelState.typing[event.user.id] = event;
1919
+ channelState.setTypingEvent(event.user.id, event);
1993
1920
  }
1994
1921
  break;
1995
1922
  case 'typing.stop':
1996
1923
  if (event.user?.id) {
1997
- delete channelState.typing[event.user.id];
1924
+ channelState.removeTypingEvent(event.user.id);
1998
1925
  }
1999
1926
  break;
2000
1927
  // `message.read_locally` is the client-only event dispatched by `markReadLocally()` when read
@@ -2004,22 +1931,45 @@ export class Channel {
2004
1931
  case 'message.read_locally':
2005
1932
  case 'message.read':
2006
1933
  if (event.user?.id && event.created_at) {
2007
- const previousReadState = channelState.read[event.user.id];
2008
- channelState.read[event.user.id] = {
2009
- // in case we already have delivery information
2010
- ...previousReadState,
2011
- last_read: new Date(event.created_at),
2012
- last_read_message_id: event.last_read_message_id,
2013
- user: event.user,
2014
- unread_messages: 0,
2015
- };
2016
- this.messageReceiptsTracker.onMessageRead({
2017
- user: event.user,
2018
- readAt: event.created_at,
2019
- lastReadMessageId: event.last_read_message_id,
2020
- });
2021
- const client = this.getClient();
1934
+ const eventUser = event.user;
1935
+ const readAtDate = new Date(event.created_at);
1936
+ const toDate = (value?: string | Date) =>
1937
+ value ? (value instanceof Date ? value : new Date(value)) : undefined;
1938
+ const userReadState = this._upsertReadState(
1939
+ eventUser.id,
1940
+ (currentUserReadState) => {
1941
+ const currentDeliveredAt = toDate(currentUserReadState?.last_delivered_at);
1942
+
1943
+ return {
1944
+ // preserve delivery information already known for user
1945
+ ...currentUserReadState,
1946
+ ...(currentUserReadState?.last_read
1947
+ ? { last_read: toDate(currentUserReadState.last_read) }
1948
+ : null),
1949
+ ...(currentDeliveredAt
1950
+ ? { last_delivered_at: currentDeliveredAt }
1951
+ : null),
1952
+ last_read: readAtDate,
1953
+ last_read_message_id: event.last_read_message_id,
1954
+ last_delivered_at:
1955
+ !currentDeliveredAt || currentDeliveredAt < readAtDate
1956
+ ? readAtDate
1957
+ : currentDeliveredAt,
1958
+ last_delivered_message_id:
1959
+ !currentDeliveredAt || currentDeliveredAt < readAtDate
1960
+ ? (event.last_read_message_id ??
1961
+ currentUserReadState?.last_delivered_message_id)
1962
+ : currentUserReadState?.last_delivered_message_id,
1963
+ first_unread_message_id: undefined,
1964
+ user: eventUser,
1965
+ unread_messages: 0,
1966
+ };
1967
+ },
1968
+ { changedUserIds: [eventUser.id] },
1969
+ );
1970
+ void userReadState;
2022
1971
 
1972
+ const client = this.getClient();
2023
1973
  const isOwnEvent = event.user?.id === client.user?.id;
2024
1974
 
2025
1975
  if (isOwnEvent) {
@@ -2035,21 +1985,40 @@ export class Channel {
2035
1985
  case 'message.delivered':
2036
1986
  // todo: update also on thread
2037
1987
  if (event.user?.id && event.created_at) {
2038
- const previousReadState = channelState.read[event.user.id];
2039
- channelState.read[event.user.id] = {
2040
- ...previousReadState,
2041
- last_delivered_at: event.last_delivered_at
2042
- ? new Date(event.last_delivered_at)
2043
- : undefined,
2044
- last_delivered_message_id: event.last_delivered_message_id,
2045
- user: event.user,
2046
- };
2047
-
2048
- this.messageReceiptsTracker.onMessageDelivered({
2049
- user: event.user,
2050
- deliveredAt: event.created_at,
2051
- lastDeliveredMessageId: event.last_delivered_message_id,
2052
- });
1988
+ const eventUser = event.user;
1989
+ const createdAt = event.created_at;
1990
+ const toDate = (value?: string | Date) =>
1991
+ value ? (value instanceof Date ? value : new Date(value)) : undefined;
1992
+ const resolvedDeliveredAt = new Date(event.last_delivered_at ?? createdAt);
1993
+ const userReadState = this._upsertReadState(
1994
+ eventUser.id,
1995
+ (currentUserReadState) => {
1996
+ const currentDeliveredAt = toDate(currentUserReadState?.last_delivered_at);
1997
+ const currentReadAt = toDate(currentUserReadState?.last_read);
1998
+
1999
+ return {
2000
+ ...currentUserReadState,
2001
+ ...(currentReadAt ? { last_read: currentReadAt } : null),
2002
+ ...(currentDeliveredAt
2003
+ ? { last_delivered_at: currentDeliveredAt }
2004
+ : null),
2005
+ last_delivered_at:
2006
+ currentDeliveredAt && currentDeliveredAt > resolvedDeliveredAt
2007
+ ? currentDeliveredAt
2008
+ : resolvedDeliveredAt,
2009
+ last_delivered_message_id:
2010
+ currentDeliveredAt && currentDeliveredAt > resolvedDeliveredAt
2011
+ ? currentUserReadState?.last_delivered_message_id
2012
+ : event.last_delivered_message_id,
2013
+ user: eventUser,
2014
+ // delivery events can be received before read events
2015
+ last_read: currentReadAt ?? new Date(createdAt),
2016
+ unread_messages: currentUserReadState?.unread_messages ?? 0,
2017
+ };
2018
+ },
2019
+ { changedUserIds: [eventUser.id] },
2020
+ );
2021
+ void userReadState;
2053
2022
 
2054
2023
  const client = this.getClient();
2055
2024
  const isOwnEvent = event.user?.id === client.user?.id;
@@ -2075,23 +2044,37 @@ export class Channel {
2075
2044
  case 'message.deleted':
2076
2045
  if (event.message) {
2077
2046
  this._extendEventWithOwnReactions(event);
2078
- if (event.hard_delete) channelState.removeMessage(event.message);
2079
- else channelState.addMessageSorted(event.message, false, false);
2080
-
2081
- channelState.removeQuotedMessageReferences(event.message);
2082
-
2083
- if (event.message.pinned) {
2084
- channelState.removePinnedMessage(event.message);
2047
+ const formattedMessage = formatMessage(event.message);
2048
+ const isThreadReply =
2049
+ !!event.message.parent_id && !event.message.show_in_channel;
2050
+ // Thread-only replies are handled by the Thread object; the channel owns the main list.
2051
+ if (!isThreadReply) {
2052
+ if (event.hard_delete) {
2053
+ this.messagePaginator.removeItem({ id: event.message.id });
2054
+ this.pinnedMessagesPaginator.removeItem({ id: event.message.id });
2055
+ } else {
2056
+ this.messagePaginator.ingestItem(formattedMessage);
2057
+ this.pinnedMessagesPaginator.ingestItem(formattedMessage);
2058
+ }
2085
2059
  }
2060
+ this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage);
2061
+ this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage);
2086
2062
  }
2087
2063
  break;
2088
2064
  case 'user.messages.deleted':
2089
2065
  if (event.user) {
2090
- this.state.deleteUserMessages(
2091
- event.user,
2092
- !!event.hard_delete,
2093
- new Date(event.created_at ?? Date.now()),
2094
- );
2066
+ const deletedAt = new Date(event.created_at ?? Date.now());
2067
+ const hardDelete = !!event.hard_delete;
2068
+ this.messagePaginator.applyMessageDeletionForUser({
2069
+ userId: event.user.id,
2070
+ hardDelete,
2071
+ deletedAt,
2072
+ });
2073
+ this.pinnedMessagesPaginator.applyMessageDeletionForUser({
2074
+ userId: event.user.id,
2075
+ hardDelete,
2076
+ deletedAt,
2077
+ });
2095
2078
  }
2096
2079
  break;
2097
2080
  case 'message.new':
@@ -2102,12 +2085,13 @@ export class Channel {
2102
2085
  const isThreadMessage =
2103
2086
  event.message.parent_id && !event.message.show_in_channel;
2104
2087
 
2105
- if (this.state.isUpToDate || isThreadMessage) {
2106
- channelState.addMessageSorted(event.message, ownMessage);
2107
- }
2108
-
2109
- if (event.message.pinned) {
2110
- channelState.addPinnedMessage(event.message);
2088
+ if (!isThreadMessage) {
2089
+ // ingestItem advances the paginator's tracked latest message (→ last_message_at). A
2090
+ // message that arrives while the viewer has scrolled to an older window lands in the
2091
+ // head interval, not the active one, so the view is preserved without an isUpToDate flag.
2092
+ this.messagePaginator.ingestItem(formatMessage(event.message));
2093
+ // ingestItem auto-adds when pinned (matchesFilter { pinned: true }).
2094
+ this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message));
2111
2095
  }
2112
2096
 
2113
2097
  // do not increase the unread count - the back-end does not increase the count neither in the following cases:
@@ -2120,23 +2104,54 @@ export class Channel {
2120
2104
  if (preventUnreadCountUpdate) break;
2121
2105
 
2122
2106
  if (event.user?.id) {
2123
- for (const userId in channelState.read) {
2124
- if (userId === event.user.id) {
2125
- channelState.read[event.user.id] = {
2126
- last_read: new Date(event.created_at as string),
2127
- user: event.user,
2128
- unread_messages: 0,
2129
- last_delivered_at: new Date(event.created_at as string),
2130
- last_delivered_message_id: event.message.id,
2131
- };
2132
- } else {
2133
- channelState.read[userId].unread_messages += 1;
2134
- }
2135
- }
2107
+ const eventUser = event.user;
2108
+ const eventUserId = eventUser.id;
2109
+ const createdAt = new Date(event.created_at ?? Date.now());
2110
+ const eventMessageId = event.message.id;
2111
+ this._patchReadState(
2112
+ (currentReadState) => {
2113
+ const userIds = Object.keys(currentReadState);
2114
+ if (!userIds.length) return currentReadState;
2115
+
2116
+ const nextReadState = { ...currentReadState };
2117
+
2118
+ for (const userId of userIds) {
2119
+ if (userId === eventUserId) {
2120
+ nextReadState[eventUserId] = {
2121
+ last_read: createdAt,
2122
+ user: eventUser,
2123
+ unread_messages: 0,
2124
+ last_delivered_at: createdAt,
2125
+ last_delivered_message_id: eventMessageId,
2126
+ };
2127
+ } else {
2128
+ nextReadState[userId] = {
2129
+ ...currentReadState[userId],
2130
+ unread_messages:
2131
+ (currentReadState[userId]?.unread_messages ?? 0) + 1,
2132
+ };
2133
+ }
2134
+ }
2135
+
2136
+ return nextReadState;
2137
+ },
2138
+ { changedUserIds: Object.keys(channelState.read) },
2139
+ );
2136
2140
  }
2137
2141
 
2138
- if (this._countMessageAsUnread(event.message)) {
2142
+ // Skip the own-unread bump when the user is actively viewing the latest messages (app
2143
+ // foregrounded + newest message on screen). Without this, a message read in real time
2144
+ // momentarily bumps `unreadCount`/the snapshot — the "N new" separator/banner + the
2145
+ // channel-list badge would flash until the SDK's mark-read resets it. The SDK reports the
2146
+ // viewing state via `messagePaginator.setViewingLive` and marks the message read itself.
2147
+ // Only the OWN unread accounting is gated; the per-user read/receipt tracking above is
2148
+ // intentionally left intact.
2149
+ const isViewingLive = this.messagePaginator.isViewingLive;
2150
+ if (!isViewingLive && this._countMessageAsUnread(event.message)) {
2139
2151
  channelState.unreadCount = channelState.unreadCount + 1;
2152
+ this.messagePaginator.setUnreadSnapshot({
2153
+ unreadCount: channelState.unreadCount,
2154
+ });
2140
2155
  }
2141
2156
 
2142
2157
  client.syncDeliveredCandidates([this]);
@@ -2146,44 +2161,38 @@ export class Channel {
2146
2161
  case 'message.undeleted':
2147
2162
  if (event.message) {
2148
2163
  this._extendEventWithOwnReactions(event);
2149
- channelState.addMessageSorted(event.message, false, false);
2150
- channelState._updateQuotedMessageReferences({ message: event.message });
2151
- if (event.message.pinned) {
2152
- channelState.addPinnedMessage(event.message);
2153
- } else {
2154
- channelState.removePinnedMessage(event.message);
2164
+ const formattedMessage = formatMessage(event.message);
2165
+ if (!event.message.parent_id) {
2166
+ this.messagePaginator.ingestItem(formattedMessage);
2167
+ this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage);
2168
+ // ingestItem auto-adds on pin / auto-removes on unpin (matchesFilter { pinned: true }).
2169
+ this.pinnedMessagesPaginator.ingestItem(formattedMessage);
2170
+ this.pinnedMessagesPaginator.reflectQuotedMessageUpdate(formattedMessage);
2155
2171
  }
2156
2172
  }
2157
2173
  break;
2158
2174
  case 'channel.truncated':
2159
2175
  if (event.channel?.truncated_at) {
2160
- const truncatedAt = +new Date(event.channel.truncated_at);
2161
-
2162
- channelState.messageSets.forEach((messageSet, messageSetIndex) => {
2163
- messageSet.messages.forEach(({ created_at: createdAt, id }) => {
2164
- if (truncatedAt > +createdAt)
2165
- channelState.removeMessage({ id, messageSetIndex });
2166
- });
2167
- });
2168
-
2169
- channelState.pinnedMessages.forEach(({ id, created_at: createdAt }) => {
2170
- if (truncatedAt > +createdAt)
2171
- channelState.removePinnedMessage({ id } as MessageResponse);
2172
- });
2173
- channelState.unreadCount = this.countUnread(
2174
- new Date(event.channel.truncated_at),
2175
- );
2176
+ const truncatedAtDate = new Date(event.channel.truncated_at);
2177
+
2178
+ channelState.unreadCount = this.countUnread(truncatedAtDate);
2179
+ // Partial truncation: keep messages newer than the cutoff. clearStateAndCache would wipe
2180
+ // the whole paginator (readers now source from it), so use the partial truncate. The
2181
+ // channel-wide read/unread context is reset by the truncation, so drop the unread snapshot
2182
+ // too (clearStateAndCache did this for the full-truncate branch).
2183
+ this.messagePaginator.truncate({ truncatedAt: truncatedAtDate });
2184
+ this.messagePaginator.clearUnreadSnapshot();
2185
+ this.pinnedMessagesPaginator.truncate({ truncatedAt: truncatedAtDate });
2176
2186
  } else {
2177
- channelState.clearMessages();
2178
2187
  channelState.unreadCount = 0;
2188
+ this.messagePaginator.clearStateAndCache();
2189
+ this.pinnedMessagesPaginator.clearStateAndCache();
2179
2190
  }
2180
2191
 
2181
2192
  // system messages don't increment unread counts
2182
2193
  if (event.message) {
2183
- channelState.addMessageSorted(event.message);
2184
- if (event.message.pinned) {
2185
- channelState.addPinnedMessage(event.message);
2186
- }
2194
+ this.messagePaginator.ingestItem(formatMessage(event.message));
2195
+ this.pinnedMessagesPaginator.ingestItem(formatMessage(event.message));
2187
2196
  }
2188
2197
 
2189
2198
  break;
@@ -2208,7 +2217,7 @@ export class Channel {
2208
2217
  };
2209
2218
  }
2210
2219
 
2211
- const currentUserId = this.getClient().userID;
2220
+ const currentUserId = this.getClient().userId;
2212
2221
  if (
2213
2222
  typeof currentUserId === 'string' &&
2214
2223
  typeof memberCopy?.user?.id === 'string' &&
@@ -2233,26 +2242,33 @@ export class Channel {
2233
2242
  break;
2234
2243
  case 'notification.mark_unread': {
2235
2244
  const ownMessage = event.user?.id === this.getClient().user?.id;
2236
- if (!ownMessage || !event.user) break;
2237
-
2245
+ if (!ownMessage || !event.user || !event.last_read_at) break;
2246
+ const eventUser = event.user;
2247
+ const lastReadAt = event.last_read_at;
2238
2248
  const unreadCount = event.unread_messages ?? 0;
2239
- const currentState = channelState.read[event.user.id];
2240
- channelState.read[event.user.id] = {
2241
- // keep the message delivery info
2242
- ...currentState,
2243
- first_unread_message_id: event.first_unread_message_id,
2244
- last_read: new Date(event.last_read_at as string),
2245
- last_read_message_id: event.last_read_message_id,
2246
- user: event.user,
2247
- unread_messages: unreadCount,
2248
- };
2249
+ this._upsertReadState(
2250
+ eventUser.id,
2251
+ (currentUserReadState) => ({
2252
+ // keep the message delivery info
2253
+ ...currentUserReadState,
2254
+ first_unread_message_id: event.first_unread_message_id,
2255
+ last_read: new Date(lastReadAt),
2256
+ last_read_message_id: event.last_read_message_id,
2257
+ user: eventUser,
2258
+ unread_messages: unreadCount,
2259
+ }),
2260
+ { changedUserIds: [eventUser.id] },
2261
+ );
2249
2262
 
2250
- channelState.unreadCount = unreadCount;
2251
- this.messageReceiptsTracker.onNotificationMarkUnread({
2252
- user: event.user,
2253
- lastReadAt: event.last_read_at,
2254
- lastReadMessageId: event.last_read_message_id,
2263
+ this.messagePaginator.setUnreadSnapshot({
2264
+ firstUnreadMessageId:
2265
+ channelState.read[event.user.id].first_unread_message_id ?? null,
2266
+ lastReadAt: channelState.read[event.user.id].last_read,
2267
+ lastReadMessageId: channelState.read[event.user.id].last_read_message_id,
2268
+ unreadCount,
2255
2269
  });
2270
+
2271
+ channelState.unreadCount = unreadCount;
2256
2272
  break;
2257
2273
  }
2258
2274
  case 'channel.updated':
@@ -2263,57 +2279,98 @@ export class Channel {
2263
2279
  if (isFrozenChanged) {
2264
2280
  this.query({ state: false, messages: { limit: 0 }, watchers: { limit: 0 } });
2265
2281
  }
2282
+ const previousChannelData = channel.data;
2266
2283
  const newChannelData = {
2267
2284
  ...event.channel,
2268
2285
  hidden: event.channel?.hidden ?? channel.data?.hidden,
2286
+ member_count: event.channel?.member_count ?? channel.data?.member_count,
2269
2287
  own_capabilities:
2270
2288
  event.channel?.own_capabilities ?? channel.data?.own_capabilities,
2271
2289
  };
2272
2290
  channel.data = newChannelData;
2291
+ channel._syncStateFromChannelData(channel.data, previousChannelData);
2273
2292
  this.cooldownTimer.refresh();
2274
2293
  }
2275
2294
  break;
2276
2295
  case 'reaction.new':
2277
2296
  if (event.message && event.reaction) {
2278
- const { message, reaction } = event;
2279
- event.message = channelState.addReaction(reaction, message) as MessageResponse;
2297
+ const { reaction } = event;
2298
+ // Reflect main messages AND show_in_channel replies (both live in these paginators);
2299
+ // pure replies are handled by the thread's own reaction subscription.
2300
+ if (!event.message?.parent_id || event.message.show_in_channel) {
2301
+ this.messagePaginator.reflectReaction({ message: event.message, reaction });
2302
+ this.pinnedMessagesPaginator.reflectReaction({
2303
+ message: event.message,
2304
+ reaction,
2305
+ });
2306
+ }
2280
2307
  }
2281
2308
  break;
2282
2309
  case 'reaction.deleted':
2283
2310
  if (event.message && event.reaction) {
2284
- const { message, reaction } = event;
2285
- event.message = channelState.removeReaction(reaction, message);
2311
+ const { reaction } = event;
2312
+ if (
2313
+ event.message &&
2314
+ (!event.message.parent_id || event.message.show_in_channel)
2315
+ ) {
2316
+ this.messagePaginator.reflectReaction({
2317
+ message: event.message,
2318
+ reaction,
2319
+ removed: true,
2320
+ });
2321
+ this.pinnedMessagesPaginator.reflectReaction({
2322
+ message: event.message,
2323
+ reaction,
2324
+ removed: true,
2325
+ });
2326
+ }
2286
2327
  }
2287
2328
  break;
2288
2329
  case 'reaction.updated':
2289
2330
  if (event.message && event.reaction) {
2290
- const { message, reaction } = event;
2331
+ const { reaction } = event;
2291
2332
  // assuming reaction.updated is only called if enforce_unique is true
2292
- event.message = channelState.addReaction(
2293
- reaction,
2294
- message,
2295
- true,
2296
- ) as MessageResponse;
2333
+ if (!event.message?.parent_id || event.message.show_in_channel) {
2334
+ this.messagePaginator.reflectReaction({
2335
+ enforceUnique: true,
2336
+ message: event.message,
2337
+ reaction,
2338
+ });
2339
+ this.pinnedMessagesPaginator.reflectReaction({
2340
+ enforceUnique: true,
2341
+ message: event.message,
2342
+ reaction,
2343
+ });
2344
+ }
2297
2345
  }
2298
2346
  break;
2299
- case 'channel.hidden':
2347
+ case 'channel.hidden': {
2348
+ const previousChannelData = channel.data;
2300
2349
  channel.data = {
2301
- ...channel.data,
2302
- blocked: !!event.channel?.blocked,
2350
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2351
+ ...channel.data!,
2352
+ blocked: event.channel?.blocked ?? false,
2303
2353
  hidden: true,
2304
2354
  };
2355
+ channel._syncStateFromChannelData(channel.data, previousChannelData);
2305
2356
  if (event.clear_history) {
2306
- channelState.clearMessages();
2357
+ this.messagePaginator.clearStateAndCache();
2358
+ this.pinnedMessagesPaginator.clearStateAndCache();
2307
2359
  }
2308
2360
  break;
2309
- case 'channel.visible':
2361
+ }
2362
+ case 'channel.visible': {
2363
+ const previousChannelData = channel.data;
2310
2364
  channel.data = {
2311
- ...channel.data,
2312
- blocked: !!event.channel?.blocked,
2365
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
2366
+ ...channel.data!,
2367
+ blocked: event.channel?.blocked ?? false,
2313
2368
  hidden: false,
2314
2369
  };
2370
+ channel._syncStateFromChannelData(channel.data, previousChannelData);
2315
2371
  this.getClient().offlineDb?.handleChannelVisibilityEvent({ event });
2316
2372
  break;
2373
+ }
2317
2374
  case 'user.banned':
2318
2375
  if (!event.user?.id) break;
2319
2376
  channelState.members[event.user.id] = {
@@ -2335,36 +2392,26 @@ export class Channel {
2335
2392
  default:
2336
2393
  }
2337
2394
 
2395
+ const typedEvent = event as Extract<WSEvent, { watcher_count?: any }>;
2338
2396
  // any event can send over the online count
2339
- if (event.watcher_count !== undefined) {
2340
- channel.state.watcher_count = event.watcher_count;
2397
+ if (typeof typedEvent.watcher_count !== 'undefined') {
2398
+ channel.state.watcher_count = typedEvent.watcher_count;
2341
2399
  }
2342
2400
  }
2343
2401
 
2344
- _callChannelListeners = (event: Event) => {
2345
- // eslint-disable-next-line @typescript-eslint/no-this-alias
2346
- const channel = this;
2347
- // gather and call the listeners
2348
- const listeners = [];
2349
- if (channel.listeners.all) {
2350
- listeners.push(...channel.listeners.all);
2351
- }
2352
- if (channel.listeners[event.type]) {
2353
- listeners.push(...channel.listeners[event.type]);
2354
- }
2402
+ _callChannelListeners = (event: WSEvent) => {
2403
+ const allSet = this.listeners.get('all');
2404
+ const targetSet = this.listeners.get(event.type);
2355
2405
 
2356
- // call the event and send it to the listeners
2357
- for (const listener of listeners) {
2358
- if (typeof listener !== 'string') {
2359
- listener(event);
2360
- }
2361
- }
2406
+ [allSet, targetSet].forEach((set) =>
2407
+ set?.forEach((handleEvent) => handleEvent(event)),
2408
+ );
2362
2409
  };
2363
2410
 
2364
2411
  /**
2365
- * _channelURL - Returns the channel url
2412
+ * Returns the channel url.
2366
2413
  *
2367
- * @return {string} The channel url
2414
+ * @returns The channel url.
2368
2415
  */
2369
2416
  _channelURL = () => {
2370
2417
  if (!this.id) {
@@ -2376,21 +2423,22 @@ export class Channel {
2376
2423
  };
2377
2424
 
2378
2425
  _checkInitialized() {
2379
- if (
2380
- !this.initialized &&
2381
- !this.offlineMode &&
2382
- !this.getClient()._isUsingServerAuth()
2383
- ) {
2426
+ if (!this.initialized && !this.offlineMode) {
2384
2427
  throw Error(
2385
2428
  `Channel ${this.cid} hasn't been initialized yet. Make sure to call .watch() and wait for it to resolve`,
2386
2429
  );
2387
2430
  }
2388
2431
  }
2389
2432
 
2390
- _initializeState(
2391
- state: ChannelAPIResponse,
2392
- messageSetToAddToIfDoesNotExist: MessageSetType = 'latest',
2433
+ _syncStateFromChannelData(
2434
+ data: Channel['data'],
2435
+ fallbackData: Channel['data'] = this.data,
2393
2436
  ) {
2437
+ this.state.syncOwnCapabilitiesFromChannelData(data, fallbackData);
2438
+ this.state.syncMemberCountFromChannelData(data, fallbackData);
2439
+ }
2440
+
2441
+ _initializeState(state: ChannelStateResponseFields) {
2394
2442
  const { state: clientState, user, userID } = this.getClient();
2395
2443
 
2396
2444
  // add the members and users
@@ -2404,24 +2452,22 @@ export class Channel {
2404
2452
  }
2405
2453
  }
2406
2454
 
2407
- this.state.membership = state.membership || {};
2408
-
2409
- const messages = state.messages || [];
2410
- if (!this.state.messages) {
2411
- this.state.initMessages();
2455
+ if (state.membership) {
2456
+ this.state.membership = state.membership;
2412
2457
  }
2413
- const { messageSet, filteredMessageIds } = this.state.addMessagesSorted(
2414
- messages,
2415
- false,
2416
- true,
2417
- true,
2418
- messageSetToAddToIfDoesNotExist,
2419
- );
2420
2458
 
2421
- if (!this.state.pinnedMessages) {
2422
- this.state.pinnedMessages = [];
2423
- }
2424
- this.state.addPinnedMessages(state.pinned_messages || []);
2459
+ // Seed the message paginator's `lastMessageAt` aggregate from the server's authoritative
2460
+ // `last_message_at`. The first-page seed (Channel.query / client.hydrateActiveChannels) also
2461
+ // advances it from ingested messages; both feed the same monotonic max, so this additionally
2462
+ // covers the path where the paginator seed is skipped (an already-loaded channel the viewer has
2463
+ // jumped away from, where re-seeding would clobber their window).
2464
+ this.messagePaginator.seedLastMessageAt(state.channel?.last_message_at);
2465
+
2466
+ // Seed the pinned-messages paginator from the same response.
2467
+ this.pinnedMessagesPaginator.seedFirstPageSync(
2468
+ (state.pinned_messages || []).map(formatMessage),
2469
+ this.pinnedMessagesPaginator.pageSize,
2470
+ );
2425
2471
  if (state.pending_messages) {
2426
2472
  this.state.pending_messages = state.pending_messages;
2427
2473
  }
@@ -2441,11 +2487,12 @@ export class Channel {
2441
2487
  // initialize read state to last message or current time if the channel is empty
2442
2488
  // if the user is a member, this value will be overwritten later on otherwise this ensures
2443
2489
  // that everything up to this point is not marked as unread
2490
+ const readUpdates: ChannelState['read'] = {};
2444
2491
  if (userID != null) {
2445
- const last_read = this.state.last_message_at || new Date();
2492
+ const last_read = this.messagePaginator.lastMessageAt || new Date();
2446
2493
  if (user) {
2447
- this.state.read[user.id] = {
2448
- user,
2494
+ readUpdates[user.id] = {
2495
+ user: user as UserResponse,
2449
2496
  last_read,
2450
2497
  unread_messages: 0,
2451
2498
  };
@@ -2455,7 +2502,7 @@ export class Channel {
2455
2502
  // apply read state if part of the state
2456
2503
  if (state.read) {
2457
2504
  for (const read of state.read) {
2458
- this.state.read[read.user.id] = {
2505
+ readUpdates[read.user.id] = {
2459
2506
  last_delivered_at: read.last_delivered_at
2460
2507
  ? new Date(read.last_delivered_at)
2461
2508
  : undefined,
@@ -2467,24 +2514,41 @@ export class Channel {
2467
2514
  };
2468
2515
 
2469
2516
  if (read.user.id === user?.id) {
2470
- this.state.unreadCount = this.state.read[read.user.id].unread_messages;
2517
+ this.state.unreadCount = readUpdates[read.user.id].unread_messages;
2471
2518
  }
2472
2519
  }
2473
-
2474
- this.messageReceiptsTracker.ingestInitial(state.read);
2475
2520
  }
2476
2521
 
2477
- return {
2478
- messageSet,
2479
- filteredMessageIds,
2480
- };
2522
+ const entries = Object.entries(readUpdates);
2523
+ if (entries.length) {
2524
+ this._patchReadState(
2525
+ (currentReadState) => {
2526
+ let hasChanges = false;
2527
+ const nextReadState = { ...currentReadState };
2528
+
2529
+ for (const [userId, readState] of entries) {
2530
+ if (nextReadState[userId] === readState) continue;
2531
+ nextReadState[userId] = readState;
2532
+ hasChanges = true;
2533
+ }
2534
+
2535
+ return hasChanges ? nextReadState : currentReadState;
2536
+ },
2537
+ { changedUserIds: entries.map(([userId]) => userId) },
2538
+ );
2539
+ }
2481
2540
  }
2482
2541
 
2483
- _extendEventWithOwnReactions(event: Event) {
2542
+ _extendEventWithOwnReactions(
2543
+ event: EventPayload<'message.undeleted' | 'message.updated' | 'message.deleted'>,
2544
+ ) {
2484
2545
  if (!event.message) {
2485
2546
  return;
2486
2547
  }
2487
- const message = this.state.findMessage(event.message.id, event.message.parent_id);
2548
+ // The channel message list is owned by the paginator; enrich from it. Thread-only replies are
2549
+ // not in the paginator (getItem returns undefined) — the Thread object preserves their
2550
+ // own_reactions on its own reply store.
2551
+ const message = this.messagePaginator.getItem(event.message.id);
2488
2552
  if (message) {
2489
2553
  event.message.own_reactions = message.own_reactions;
2490
2554
  }
@@ -2524,17 +2588,15 @@ export class Channel {
2524
2588
  }
2525
2589
 
2526
2590
  _disconnect() {
2527
- this._client.logger(
2528
- 'info',
2529
- `channel:disconnect() - Disconnecting the channel ${this.cid}`,
2530
- {
2531
- tags: ['connection', 'channel'],
2532
- channel: this,
2533
- },
2534
- );
2591
+ logger.withExtraTags('_disconnect', this.cid).info('Disconnecting the channel.');
2535
2592
 
2536
2593
  this.disconnected = true;
2594
+ this.messageReceiptsTracker.unregisterSubscriptions();
2537
2595
  this.cooldownTimer.clearTimeout();
2538
- this.state.setIsUpToDate(false);
2596
+ // Release the store-backed paginators so the message store no longer pins this removed channel
2597
+ // (and its whole message graph) through its subscriber registry. The channel is being discarded
2598
+ // here (disconnected + deleted from activeChannels, never reused), mirroring Thread teardown.
2599
+ this.messagePaginator.dispose();
2600
+ this.pinnedMessagesPaginator.dispose();
2539
2601
  }
2540
2602
  }