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/thread.ts CHANGED
@@ -1,29 +1,38 @@
1
1
  import { StateStore } from './store';
2
2
  import {
3
- addToMessageList,
4
- findIndexInSortedArray,
3
+ computeOwnReactions,
5
4
  formatMessage,
6
- throttle,
5
+ localMessageToNewMessagePayload,
7
6
  } from './utils';
7
+ import { applyReactionLocally } from './entityStore';
8
8
  import type {
9
- AscDesc,
10
- EventTypes,
9
+ DraftResponse,
10
+ EventAPIResponse,
11
+ EventType,
11
12
  LocalMessage,
12
- MessagePaginationOptions,
13
+ MarkReadRequest,
13
14
  MessageResponse,
14
- ReadResponse,
15
- ThreadResponse,
15
+ ReactionRequest,
16
+ ReadStateResponse,
17
+ SendReactionRequest,
18
+ SortParamRequest,
19
+ ThreadStateResponse,
16
20
  UserResponse,
17
21
  } from './types';
18
- import type { Channel } from './channel';
22
+ import { isEphemeral } from './errors';
23
+ import type {
24
+ Channel,
25
+ DeleteMessageWithStateUpdateParams,
26
+ SendMessageWithStateUpdateParams,
27
+ UpdateMessageWithStateUpdateParams,
28
+ } from './channel';
19
29
  import type { StreamChat } from './client';
20
30
  import type { CustomThreadData } from './custom_types';
21
31
  import { MessageComposer } from './messageComposer';
32
+ import { MessageOperations } from './messageOperations';
22
33
  import { WithSubscriptions } from './utils/WithSubscriptions';
23
-
24
- type QueryRepliesOptions = {
25
- sort?: { created_at: AscDesc }[];
26
- } & MessagePaginationOptions & { user?: UserResponse; user_id?: string };
34
+ import { MessagePaginator } from './pagination';
35
+ import type { PipelineEvent } from './EventHandlerPipeline';
27
36
 
28
37
  export type ThreadState = {
29
38
  /**
@@ -37,27 +46,18 @@ export type ThreadState = {
37
46
  deletedAt: Date | null;
38
47
  isLoading: boolean;
39
48
  isStateStale: boolean;
40
- pagination: ThreadRepliesPagination;
41
49
  /**
42
50
  * Thread is identified by and has a one-to-one relation with its parent message.
43
51
  * We use parent message id as a thread id.
44
52
  */
45
53
  parentMessage: LocalMessage;
46
- participants: ThreadResponse['thread_participants'];
54
+ participants: ThreadStateResponse['thread_participants'];
47
55
  read: ThreadReadState;
48
- replies: Array<LocalMessage>;
49
56
  replyCount: number;
50
57
  title: string;
51
58
  updatedAt: Date | null;
52
59
  };
53
60
 
54
- export type ThreadRepliesPagination = {
55
- isLoadingNext: boolean;
56
- isLoadingPrev: boolean;
57
- nextCursor: string | null;
58
- prevCursor: string | null;
59
- };
60
-
61
61
  export type ThreadUserReadState = {
62
62
  lastReadAt: Date;
63
63
  unreadMessageCount: number;
@@ -69,51 +69,27 @@ export type ThreadUserReadState = {
69
69
  export type ThreadReadState = Record<string, ThreadUserReadState | undefined>;
70
70
 
71
71
  const DEFAULT_PAGE_LIMIT = 50;
72
- const DEFAULT_SORT: { created_at: AscDesc }[] = [{ created_at: -1 }];
73
- const MARK_AS_READ_THROTTLE_TIMEOUT = 1000;
74
- // TODO: remove this once we move to API v2
75
- export const THREAD_RESPONSE_RESERVED_KEYS: Record<keyof ThreadResponse, true> = {
76
- active_participant_count: true,
77
- channel: true,
78
- channel_cid: true,
79
- created_at: true,
80
- created_by: true,
81
- created_by_user_id: true,
82
- deleted_at: true,
83
- draft: true,
84
- last_message_at: true,
85
- latest_replies: true,
86
- parent_message: true,
87
- parent_message_id: true,
88
- participant_count: true,
89
- read: true,
90
- reply_count: true,
91
- thread_participants: true,
92
- title: true,
93
- updated_at: true,
94
- };
72
+ const DEFAULT_SORT: SortParamRequest[] = [{ field: 'created_at', direction: -1 }];
73
+ const DEFAULT_ITEM_ORDER: SortParamRequest[] = [{ field: 'created_at', direction: 1 }];
95
74
 
96
- // TODO: remove this once we move to API v2
97
- const constructCustomDataObject = <T extends ThreadResponse>(threadData: T) => {
98
- const custom: CustomThreadData = {};
75
+ export type CustomThreadMarkReadRequestFn = (params: {
76
+ thread: Thread;
77
+ options?: MarkReadRequest;
78
+ }) => Promise<EventAPIResponse | null> | void;
99
79
 
100
- for (const key in threadData) {
101
- if (THREAD_RESPONSE_RESERVED_KEYS[key as keyof ThreadResponse]) {
102
- continue;
103
- }
104
-
105
- const customKey = key as keyof CustomThreadData;
106
-
107
- custom[customKey] = threadData[customKey];
108
- }
109
-
110
- return custom;
80
+ export type ThreadInstanceConfig = {
81
+ requestHandlers?: {
82
+ markReadRequest?: CustomThreadMarkReadRequestFn;
83
+ };
111
84
  };
112
85
 
113
86
  export class Thread extends WithSubscriptions {
87
+ public readonly configState = new StateStore<ThreadInstanceConfig>({});
114
88
  public readonly state: StateStore<ThreadState>;
115
89
  public readonly id: string;
116
90
  public readonly messageComposer: MessageComposer;
91
+ public readonly messagePaginator: MessagePaginator;
92
+ public readonly messageOperations: MessageOperations;
117
93
 
118
94
  private client: StreamChat;
119
95
  private failedRepliesMap: Map<string, LocalMessage> = new Map();
@@ -121,66 +97,203 @@ export class Thread extends WithSubscriptions {
121
97
  constructor({
122
98
  client,
123
99
  threadData,
100
+ channel,
101
+ parentMessage,
102
+ draft,
124
103
  }: {
125
104
  client: StreamChat;
126
- threadData: ThreadResponse;
105
+ threadData?: ThreadStateResponse;
106
+ channel?: Channel;
107
+ parentMessage?: MessageResponse | LocalMessage;
108
+ draft?: DraftResponse;
127
109
  }) {
128
110
  super();
111
+ if (threadData) {
112
+ if (!threadData.channel) {
113
+ throw new Error('Thread channel is required when threadData is provided');
114
+ }
115
+ if (!threadData.parent_message) {
116
+ throw new Error('Thread parent_message is required when threadData is provided');
117
+ }
118
+ const threadChannel = client.channel(
119
+ threadData.channel.type,
120
+ threadData.channel.id,
121
+ { custom: threadData.channel.custom },
122
+ );
123
+ threadChannel._hydrateMembers({
124
+ members: threadData.channel.members ?? [],
125
+ overrideCurrentState: false,
126
+ });
129
127
 
130
- const channel = client.channel(threadData.channel.type, threadData.channel.id, {
131
- // @ts-expect-error name is a "custom" property
132
- name: threadData.channel.name,
133
- });
134
- channel._hydrateMembers({
135
- members: threadData.channel.members ?? [],
136
- overrideCurrentState: false,
137
- });
128
+ this.state = new StateStore<ThreadState>({
129
+ // local only
130
+ active: false,
131
+ isLoading: false,
132
+ isStateStale: false,
133
+ // 99.9% should never change
134
+ channel: threadChannel,
135
+ createdAt: new Date(threadData.created_at),
136
+ // rest
137
+ deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null,
138
+ parentMessage: formatMessage(threadData.parent_message),
139
+ participants: threadData.thread_participants,
140
+ read: formatReadState(
141
+ !threadData.read || threadData.read.length === 0
142
+ ? getPlaceholderReadResponse(client.userID)
143
+ : threadData.read,
144
+ ),
145
+ // Use the parent message's reply_count, not the top-level threadData.reply_count. The
146
+ // thread endpoints (getThread/queryThreads) return a top level reply_count that EXCLUDES
147
+ // soft-deleted replies, while parent_message.reply_count (and the channel's own copy)
148
+ // INCLUDE them so the top level value renders fewer replies than the channel badge shows.
149
+ // parent_message.reply_count is the authoritative, channel consistent count.
150
+ replyCount: threadData.parent_message.reply_count ?? 0,
151
+ updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null,
152
+ title: threadData.title,
153
+ custom: threadData.custom ?? {},
154
+ });
138
155
 
139
- // For when read object is undefined and due to that unreadMessageCount for
140
- // the current user isn't being incremented on message.new
141
- const placeholderReadResponse: ReadResponse[] = client.userID
142
- ? [
143
- {
144
- user: { id: client.userID },
145
- unread_messages: 0,
146
- last_read: new Date().toISOString(),
147
- },
148
- ]
149
- : [];
156
+ this.id = threadData.parent_message_id;
157
+ } else {
158
+ if (!channel) {
159
+ throw new Error('Channel is required when threadData is not provided');
160
+ }
150
161
 
151
- this.state = new StateStore<ThreadState>({
152
- // local only
153
- active: false,
154
- isLoading: false,
155
- isStateStale: false,
156
- // 99.9% should never change
157
- channel,
158
- createdAt: new Date(threadData.created_at),
159
- // rest
160
- deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null,
161
- pagination: repliesPaginationFromInitialThread(threadData),
162
- parentMessage: formatMessage(threadData.parent_message),
163
- participants: threadData.thread_participants,
164
- read: formatReadState(
165
- !threadData.read || threadData.read.length === 0
166
- ? placeholderReadResponse
167
- : threadData.read,
168
- ),
169
- replies: threadData.latest_replies.map(formatMessage),
170
- replyCount: threadData.reply_count ?? 0,
171
- updatedAt: threadData.updated_at ? new Date(threadData.updated_at) : null,
172
- title: threadData.title,
173
- custom: constructCustomDataObject(threadData),
174
- });
162
+ if (!parentMessage || !parentMessage.id) {
163
+ throw new Error(
164
+ 'Parent message with a valid id is required when threadData is not provided',
165
+ );
166
+ }
167
+
168
+ const formattedParentMessage = formatMessage(parentMessage);
169
+ const createdAt = parentMessage.created_at
170
+ ? new Date(parentMessage.created_at)
171
+ : new Date();
172
+
173
+ this.state = new StateStore<ThreadState>({
174
+ active: false,
175
+ channel,
176
+ createdAt,
177
+ custom: {},
178
+ deletedAt: formattedParentMessage.deleted_at ?? null,
179
+ isLoading: false,
180
+ isStateStale: false,
181
+ parentMessage: formattedParentMessage,
182
+ participants: [],
183
+ read: formatReadState(getPlaceholderReadResponse(client.userID)),
184
+ replyCount: parentMessage.reply_count ?? 0,
185
+ title: '',
186
+ updatedAt: parentMessage.updated_at ? new Date(parentMessage.updated_at) : null,
187
+ });
188
+
189
+ this.id = parentMessage.id;
190
+ }
175
191
 
176
- this.id = threadData.parent_message_id;
177
192
  this.client = client;
178
193
 
194
+ this.messagePaginator = new MessagePaginator({
195
+ channel: this.channel,
196
+ parentMessageId: this.id,
197
+ requestSort: DEFAULT_SORT,
198
+ itemOrder: DEFAULT_ITEM_ORDER,
199
+ paginatorOptions: {
200
+ pageSize: DEFAULT_PAGE_LIMIT,
201
+ },
202
+ });
203
+
204
+ // Seed the reply paginator from the thread's `latest_replies` so a thread we already hold
205
+ // data for (queried via the ThreadManager or hydrated from a ThreadResponse) renders its
206
+ // first reply page instantly, without a network fetch on open. `latest_replies` is the most
207
+ // recent window of replies, so it reaches the head (nothing newer to load); it reaches the
208
+ // tail only when it already contains every reply. Threads built from a bare parent message
209
+ // carry no replies to seed and fall back to a paginator fetch on first open.
210
+ if (threadData?.latest_replies?.length) {
211
+ this.messagePaginator.setItems({
212
+ valueOrFactory: threadData.latest_replies.map(formatMessage),
213
+ isFirstPage: true,
214
+ isLastPage: threadData.latest_replies.length === (threadData.reply_count ?? 0),
215
+ });
216
+ }
217
+
218
+ // Seed the reply paginator's lastMessageAt floor from the thread's server-provided
219
+ // `last_message_at` (analogous to the channel seed in Channel._initializeState), so a thread whose
220
+ // newest reply is not among `latest_replies` still reports the correct latest-activity timestamp.
221
+ this.messagePaginator.seedLastMessageAt(threadData?.last_message_at);
222
+
179
223
  this.messageComposer = new MessageComposer({
180
224
  client,
181
- composition: threadData.draft,
225
+ composition: threadData?.draft ?? draft,
182
226
  compositionContext: this,
183
227
  });
228
+
229
+ this.messageOperations = new MessageOperations({
230
+ ingest: (m) => {
231
+ this.messagePaginator.ingestItem(m);
232
+ this.channel.getClient().messageStore.flushSubscribers(m.id);
233
+ },
234
+ get: (id) => this.messagePaginator.getItem(id),
235
+ normalizeOutgoingMessage: (m) => ({
236
+ ...m,
237
+ parent_id: this.id,
238
+ }),
239
+ handlers: () => {
240
+ const { requestHandlers } = this.channel.configState.getLatestValue();
241
+ const deleteMessageRequest = requestHandlers?.deleteMessageRequest;
242
+ const sendMessageRequest = requestHandlers?.sendMessageRequest;
243
+ const retrySendMessageRequest = requestHandlers?.retrySendMessageRequest;
244
+ const updateMessageRequest = requestHandlers?.updateMessageRequest;
245
+ return {
246
+ delete: deleteMessageRequest
247
+ ? (p) =>
248
+ deleteMessageRequest({
249
+ localMessage: p.localMessage,
250
+ options: p.options,
251
+ })
252
+ : undefined,
253
+ send: sendMessageRequest
254
+ ? (p) =>
255
+ sendMessageRequest({
256
+ localMessage: p.localMessage,
257
+ message: p.message,
258
+ options: p.options,
259
+ })
260
+ : undefined,
261
+ retry: retrySendMessageRequest
262
+ ? (p) =>
263
+ retrySendMessageRequest({
264
+ localMessage: p.localMessage,
265
+ message: p.message,
266
+ options: p.options,
267
+ })
268
+ : undefined,
269
+ update: updateMessageRequest
270
+ ? (p) =>
271
+ updateMessageRequest({
272
+ localMessage: p.localMessage,
273
+ options: p.options,
274
+ })
275
+ : undefined,
276
+ };
277
+ },
278
+ defaults: {
279
+ delete: async (id, o) => {
280
+ const result = await this.channel.getClient().deleteMessage({ id, ...o });
281
+ return { message: result.message };
282
+ },
283
+ send: async (m, o) => {
284
+ const result = await this.channel.sendMessage({ message: m, ...o });
285
+ return { message: result.message };
286
+ },
287
+ update: async (m, o) => {
288
+ const result = await this.channel.getClient().updateMessage({
289
+ id: m.id,
290
+ message: localMessageToNewMessagePayload(m),
291
+ ...o,
292
+ });
293
+ return { message: result.message };
294
+ },
295
+ },
296
+ });
184
297
  }
185
298
 
186
299
  get channel() {
@@ -211,7 +324,11 @@ export class Thread extends WithSubscriptions {
211
324
  this.state.partialNext({ isLoading: true });
212
325
 
213
326
  try {
214
- const thread = await this.client.getThread(this.id, { watch: true });
327
+ const loadedReplyCount = this.messagePaginator.items?.length ?? 0;
328
+ const thread = await this.client.getThreadAndHydrate(this.id, {
329
+ watch: true,
330
+ reply_limit: loadedReplyCount || this.messagePaginator.pageSize,
331
+ });
215
332
  this.hydrateState(thread);
216
333
  } finally {
217
334
  this.state.partialNext({ isLoading: false });
@@ -239,11 +356,12 @@ export class Thread extends WithSubscriptions {
239
356
  participants,
240
357
  read,
241
358
  replyCount,
242
- replies,
243
359
  updatedAt,
244
360
  } = thread.state.getLatestValue();
245
361
 
246
- // Preserve pending replies and append them to the updated list of replies
362
+ // Preserve pending (failed) replies so they survive the hydrate. The messagePaginator is now
363
+ // the sole reply source, so we merge the incoming newest page into it and re-ingest the
364
+ // pending replies (mirrors the previous state.replies concat behavior).
247
365
  const pendingReplies = Array.from(this.failedRepliesMap.values());
248
366
 
249
367
  this.state.partialNext({
@@ -255,10 +373,21 @@ export class Thread extends WithSubscriptions {
255
373
  participants,
256
374
  read,
257
375
  replyCount,
258
- replies: pendingReplies.length ? replies.concat(pendingReplies) : replies,
259
376
  updatedAt,
260
377
  isStateStale: false,
261
378
  });
379
+
380
+ if (parentMessage && this.hasSubscriptions) {
381
+ this.client.messageStore.upsert(parentMessage);
382
+ }
383
+
384
+ this.messagePaginator.mergeNewestPage(
385
+ thread.messagePaginator.state.getLatestValue().items ?? [],
386
+ );
387
+ pendingReplies.forEach((reply) => this.messagePaginator.ingestItem(reply));
388
+ // Carry the re-queried thread's last-activity floor so lastMessageAt stays fresh even when the
389
+ // merged page does not include the newest reply. Monotonic, so an older value is a no-op.
390
+ this.messagePaginator.seedLastMessageAt(thread.messagePaginator.lastMessageAt);
262
391
  };
263
392
 
264
393
  public registerSubscriptions = () => {
@@ -267,6 +396,7 @@ export class Thread extends WithSubscriptions {
267
396
  return;
268
397
  }
269
398
 
399
+ this.addUnsubscribeFunction(this.subscribeParentMessageFromStore());
270
400
  this.addUnsubscribeFunction(this.subscribeThreadUpdated());
271
401
  this.addUnsubscribeFunction(this.subscribeMarkActiveThreadRead());
272
402
  this.addUnsubscribeFunction(this.subscribeReloadActiveStaleThread());
@@ -276,6 +406,7 @@ export class Thread extends WithSubscriptions {
276
406
  this.addUnsubscribeFunction(this.subscribeRepliesUnread());
277
407
  this.addUnsubscribeFunction(this.subscribeMessageDeleted());
278
408
  this.addUnsubscribeFunction(this.subscribeMessageUpdated());
409
+ this.addUnsubscribeFunction(this.subscribeUserMessagesDeleted());
279
410
  };
280
411
 
281
412
  private subscribeThreadUpdated = () =>
@@ -290,8 +421,7 @@ export class Thread extends WithSubscriptions {
290
421
  title: threadData.title,
291
422
  updatedAt: new Date(threadData.updated_at),
292
423
  deletedAt: threadData.deleted_at ? new Date(threadData.deleted_at) : null,
293
- // TODO: use threadData.custom once we move to API v2
294
- custom: constructCustomDataObject(threadData),
424
+ custom: threadData.custom ?? {},
295
425
  });
296
426
  }).unsubscribe;
297
427
 
@@ -303,7 +433,7 @@ export class Thread extends WithSubscriptions {
303
433
  }),
304
434
  ({ active, unreadMessageCount }) => {
305
435
  if (!active || !unreadMessageCount) return;
306
- this.throttledMarkAsRead();
436
+ this.throttledMarkRead();
307
437
  },
308
438
  );
309
439
 
@@ -318,7 +448,7 @@ export class Thread extends WithSubscriptions {
318
448
  );
319
449
 
320
450
  private subscribeMarkThreadStale = () =>
321
- this.client.on('user.watching.stop', (event) => {
451
+ this.client.on('user.watching.stop', (event: PipelineEvent) => {
322
452
  const { channel } = this.state.getLatestValue();
323
453
 
324
454
  if (
@@ -370,13 +500,13 @@ export class Thread extends WithSubscriptions {
370
500
 
371
501
  this.upsertReplyLocally({
372
502
  message: event.message,
373
- // Message from current user could have been added optimistically,
503
+ // MessageRequest from current user could have been added optimistically,
374
504
  // so the actual timestamp might differ in the event
375
505
  timestampChanged: isOwnMessage,
376
506
  });
377
507
 
378
508
  if (active) {
379
- this.throttledMarkAsRead();
509
+ this.throttledMarkRead();
380
510
  }
381
511
 
382
512
  const nextRead: ThreadReadState = {};
@@ -439,6 +569,7 @@ export class Thread extends WithSubscriptions {
439
569
  private subscribeMessageDeleted = () =>
440
570
  this.client.on('message.deleted', (event) => {
441
571
  if (!event.message) return;
572
+ const formattedMessage = formatMessage(event.message);
442
573
 
443
574
  // Deleted message is a reply of this thread
444
575
  if (event.message.parent_id === this.id) {
@@ -454,62 +585,161 @@ export class Thread extends WithSubscriptions {
454
585
  if (event.message.id === this.id) {
455
586
  this.updateParentMessageLocally({ message: event.message });
456
587
  }
588
+
589
+ this.messagePaginator.reflectQuotedMessageUpdate(formattedMessage);
457
590
  }).unsubscribe;
458
591
 
459
592
  private subscribeMessageUpdated = () => {
460
- const eventTypes: EventTypes[] = [
461
- 'message.updated',
593
+ const messageUpdateTypes: EventType[] = ['message.updated', 'message.undeleted'];
594
+ const reactionTypes: EventType[] = [
462
595
  'reaction.new',
463
596
  'reaction.deleted',
464
597
  'reaction.updated',
465
598
  ];
466
599
 
467
- const unsubscribeFunctions = eventTypes.map(
600
+ const unsubscribeMessageUpdated = messageUpdateTypes.map(
601
+ (eventType) =>
602
+ this.client.on(eventType, (event: PipelineEvent) => {
603
+ if (!event.message) return;
604
+ // A `message.updated` WS event carries `own_reactions: []`; upserting it verbatim would
605
+ // wipe the current user's reactions on an edit. Preserve them off the copy we already hold
606
+ // — the reply paginator for a reply, `state.parentMessage` for the parent (the parent is
607
+ // not held in any paginator, so it needs the same treatment directly).
608
+ const message =
609
+ event.message.parent_id === this.id
610
+ ? {
611
+ ...event.message,
612
+ own_reactions:
613
+ this.messagePaginator.getItem(event.message.id)?.own_reactions ??
614
+ event.message.own_reactions,
615
+ }
616
+ : !event.message.parent_id && event.message.id === this.id
617
+ ? {
618
+ ...event.message,
619
+ own_reactions:
620
+ this.state.getLatestValue().parentMessage?.own_reactions ??
621
+ event.message.own_reactions,
622
+ }
623
+ : event.message;
624
+ this.updateParentMessageOrReplyLocally(message);
625
+ this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(event.message));
626
+ }).unsubscribe,
627
+ );
628
+
629
+ const unsubscribeReactions = reactionTypes.map(
468
630
  (eventType) =>
469
- this.client.on(eventType, (event) => {
470
- if (event.message) {
471
- this.updateParentMessageOrReplyLocally(event.message);
631
+ this.client.on(eventType, (event: PipelineEvent) => {
632
+ if (!event.message || !event.reaction) return;
633
+ const { message, reaction } = event;
634
+ if (message.parent_id === this.id) {
635
+ // Preserve/apply the current user's `own_reactions` off the reply paginator itself,
636
+ // independently of the channel (mirrors the channel's main-list reflectReaction).
637
+ this.messagePaginator.reflectReaction({
638
+ enforceUnique: eventType === 'reaction.updated',
639
+ message,
640
+ reaction,
641
+ removed: eventType === 'reaction.deleted',
642
+ });
643
+ } else if (!message.parent_id && message.id === this.id) {
644
+ // Reaction on the PARENT. The parent isn't in a paginator, so apply the current user's
645
+ // own_reactions delta here (mirroring the reply path's reflectReaction) rather than
646
+ // copying the WS event verbatim — which would drop own_reactions the event omits.
647
+ const own_reactions = computeOwnReactions({
648
+ current:
649
+ this.state.getLatestValue().parentMessage?.own_reactions ??
650
+ message.own_reactions ??
651
+ [],
652
+ enforceUnique: eventType === 'reaction.updated',
653
+ reaction,
654
+ removed: eventType === 'reaction.deleted',
655
+ userId: this.client.userID,
656
+ });
657
+ this.updateParentMessageLocally({ message: { ...message, own_reactions } });
472
658
  }
659
+ this.messagePaginator.reflectQuotedMessageUpdate(formatMessage(message));
473
660
  }).unsubscribe,
474
661
  );
475
662
 
663
+ const unsubscribeFunctions = [...unsubscribeMessageUpdated, ...unsubscribeReactions];
664
+
476
665
  return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
477
666
  };
478
667
 
668
+ private subscribeUserMessagesDeleted = () => {
669
+ // Apply a user ban / deletion to this thread's own reply list. Previously
670
+ // channel.state.deleteUserMessages marked banned-user replies deleted in the (now removed)
671
+ // channel.state.threads shadow; the reply paginator is the thread's source of truth now.
672
+ const eventTypes: EventType[] = ['user.messages.deleted', 'user.deleted'];
673
+
674
+ const unsubscribeFunctions = eventTypes.map(
675
+ (eventType) =>
676
+ this.client.on(eventType, (event: PipelineEvent) => {
677
+ if (!event.user) return;
678
+ // user.deleted carries the deletion time on the user; user.messages.deleted on the event.
679
+ const deletedAtSource =
680
+ eventType === 'user.deleted' ? event.user.deleted_at : event.created_at;
681
+ this.messagePaginator.applyMessageDeletionForUser({
682
+ userId: event.user.id,
683
+ hardDelete: !!event.hard_delete,
684
+ deletedAt: deletedAtSource ? new Date(deletedAtSource) : new Date(),
685
+ });
686
+ }).unsubscribe,
687
+ );
688
+
689
+ return () => unsubscribeFunctions.forEach((unsubscribe) => unsubscribe());
690
+ };
691
+
692
+ // The parent message lives in the client-global message store (one canonical POJO per id);
693
+ // `state.parentMessage` and the fields derived from it are a projection of that copy. Every update
694
+ // to the parent — optimistic reaction, WS reaction/edit/delete, reply-count bump — writes the
695
+ // store, which reflects it here through this single subscription (one per thread, not one per
696
+ // message). Seeds the store on first subscribe when no other collection holds the parent yet
697
+ // (e.g. a thread opened from a notification, its parent not in the channel window).
698
+ private subscribeParentMessageFromStore = () => {
699
+ const store = this.client.messageStore;
700
+ const parent = this.state.getLatestValue().parentMessage;
701
+ if (parent && !store.has(parent.id)) store.upsert(parent);
702
+
703
+ return store.subscribe(this.id, (message) => {
704
+ if (!message) return;
705
+ this.state.next((current) => ({
706
+ ...current,
707
+ deletedAt: message.deleted_at ?? null,
708
+ parentMessage: message,
709
+ participants:
710
+ normalizeThreadParticipants(message.thread_participants, current.channel.cid) ??
711
+ current.participants,
712
+ replyCount: message.reply_count ?? current.replyCount,
713
+ }));
714
+ });
715
+ };
716
+
479
717
  public unregisterSubscriptions = () => {
480
718
  const symbol = super.unregisterSubscriptions();
719
+ // Release the reply paginator's hold on the shared message store. The parent subscription is
720
+ // torn down by `super.unregisterSubscriptions()` (it was added as an unsubscribe function), but
721
+ // the reply paginator's per-id links live in its item index, not in the subscription list — so a
722
+ // removed thread would otherwise stay pinned by `messageStore.subscribers` and keep its replies
723
+ // alive. `getThread` builds a fresh instance if this thread is re-opened, so this is a discard.
724
+ this.messagePaginator.dispose();
481
725
  this.state.partialNext({ isStateStale: true });
482
726
  return symbol;
483
727
  };
484
728
 
729
+ // todo: can be removed with the next breaking change and use MessagePaginator only
485
730
  public deleteReplyLocally = ({ message }: { message: MessageResponse }) => {
486
- const { replies } = this.state.getLatestValue();
487
-
488
- const index = findIndexInSortedArray({
489
- needle: formatMessage(message),
490
- sortedArray: replies,
491
- sortDirection: 'ascending',
492
- selectValueToCompare: (reply) => reply.created_at.getTime(),
493
- selectKey: (reply) => reply.id,
494
- });
495
-
496
- if (replies[index]?.id !== message.id) {
497
- return;
498
- }
499
-
500
- const updatedReplies = [...replies];
501
- updatedReplies.splice(index, 1);
502
-
503
- this.state.partialNext({
504
- replies: updatedReplies,
505
- });
731
+ // The reply messagePaginator is the reply list source. removeItem is a no-op when the reply
732
+ // isn't loaded, so it's safe to run unconditionally.
733
+ this.messagePaginator.removeItem({ id: message.id });
506
734
  };
507
735
 
736
+ // todo: can be removed with the next breaking change and use MessagePaginator only
508
737
  public upsertReplyLocally = ({
509
738
  message,
510
- timestampChanged = false,
511
739
  }: {
512
740
  message: MessageResponse | LocalMessage;
741
+ // Accepted for backward compatibility but no longer used — the messagePaginator repositions
742
+ // by created_at on ingest, so a changed timestamp is handled without an explicit flag.
513
743
  timestampChanged?: boolean;
514
744
  }) => {
515
745
  if (message.parent_id !== this.id) {
@@ -518,36 +748,33 @@ export class Thread extends WithSubscriptions {
518
748
 
519
749
  const formattedMessage = formatMessage(message);
520
750
 
521
- if (message.status === 'failed') {
751
+ // todo: do we really need to keep the failedRepliesMap?
752
+ if (formattedMessage.status === 'failed') {
522
753
  // store failed reply so that it's not lost when reloading or hydrating
523
754
  this.failedRepliesMap.set(formattedMessage.id, formattedMessage);
524
755
  } else if (this.failedRepliesMap.has(message.id)) {
525
756
  this.failedRepliesMap.delete(message.id);
526
757
  }
527
758
 
528
- this.state.next((current) => ({
529
- ...current,
530
- replies: addToMessageList(current.replies, formattedMessage, timestampChanged),
531
- }));
759
+ // The reply messagePaginator is the reply list source.
760
+ this.messagePaginator.ingestItem(formattedMessage);
532
761
  };
533
762
 
763
+ // todo: can be removed with the next breaking change and use MessagePaginator only
534
764
  public updateParentMessageLocally = ({ message }: { message: MessageResponse }) => {
535
765
  if (message.id !== this.id) {
536
766
  throw new Error('Message does not belong to this thread');
537
767
  }
538
768
 
539
- this.state.next((current) => {
540
- const formattedMessage = formatMessage(message);
541
-
542
- return {
543
- ...current,
544
- deletedAt: formattedMessage.deleted_at,
545
- parentMessage: formattedMessage,
546
- replyCount: message.reply_count ?? current.replyCount,
547
- };
548
- });
769
+ // The parent's content lives in the client-global message store; `state.parentMessage` (and the
770
+ // fields derived from it) is a projection kept in sync by `subscribeParentMessageFromStore`.
771
+ // Writing the store fans the change out to every collection holding this id and reflects it here.
772
+ if (this.client.messageStore.has(message.id)) {
773
+ this.client.messageStore.upsert(formatMessage(message));
774
+ }
549
775
  };
550
776
 
777
+ // todo: can be removed with the next breaking change and use MessagePaginator only
551
778
  public updateParentMessageOrReplyLocally = (message: MessageResponse) => {
552
779
  if (message.parent_id === this.id) {
553
780
  this.upsertReplyLocally({ message });
@@ -558,85 +785,187 @@ export class Thread extends WithSubscriptions {
558
785
  }
559
786
  };
560
787
 
561
- public markAsRead = async ({ force = false }: { force?: boolean } = {}) => {
562
- if (this.ownUnreadCount === 0 && !force) {
563
- return null;
564
- }
565
-
566
- return await this.client.messageDeliveryReporter.markRead(this);
567
- };
568
-
569
- private throttledMarkAsRead = throttle(
570
- () => this.markAsRead(),
571
- MARK_AS_READ_THROTTLE_TIMEOUT,
572
- { trailing: true },
573
- );
574
-
575
- public queryReplies = ({
576
- limit = DEFAULT_PAGE_LIMIT,
577
- sort = DEFAULT_SORT,
578
- ...otherOptions
579
- }: QueryRepliesOptions = {}) =>
580
- this.channel.getReplies(this.id, { limit, ...otherOptions }, sort);
788
+ /**
789
+ * Sends a message with optimistic local state update.
790
+ */
791
+ async sendMessageWithLocalUpdate({
792
+ localMessage,
793
+ message,
794
+ options,
795
+ sendMessageRequestFn,
796
+ }: SendMessageWithStateUpdateParams): Promise<void> {
797
+ await this.messageOperations.send(
798
+ {
799
+ localMessage,
800
+ message,
801
+ options,
802
+ },
803
+ sendMessageRequestFn,
804
+ );
805
+ }
581
806
 
582
- public loadNextPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) =>
583
- this.loadPage(limit);
807
+ /**
808
+ * Retry sending a failed message.
809
+ */
810
+ async retrySendMessageWithLocalUpdate(
811
+ params: Omit<SendMessageWithStateUpdateParams, 'message'>,
812
+ ) {
813
+ await this.messageOperations.retry(
814
+ {
815
+ localMessage: { ...params.localMessage, type: 'regular' },
816
+ options: params.options,
817
+ },
818
+ params.sendMessageRequestFn,
819
+ );
820
+ }
584
821
 
585
- public loadPrevPage = ({ limit = DEFAULT_PAGE_LIMIT }: { limit?: number } = {}) =>
586
- this.loadPage(-limit);
822
+ /**
823
+ * Updates a message with optimistic local state update.
824
+ *
825
+ * The update flows through `messagePaginator`, which is the sole reply source.
826
+ */
827
+ async updateMessageWithLocalUpdate(params: UpdateMessageWithStateUpdateParams) {
828
+ await this.messageOperations.update(
829
+ {
830
+ localMessage: params.localMessage,
831
+ options: params.options,
832
+ },
833
+ params.updateMessageRequestFn,
834
+ );
835
+ }
587
836
 
588
- private loadPage = async (count: number) => {
589
- const { pagination } = this.state.getLatestValue();
590
- const [loadingKey, cursorKey, insertionMethodKey] =
591
- count > 0
592
- ? (['isLoadingNext', 'nextCursor', 'push'] as const)
593
- : (['isLoadingPrev', 'prevCursor', 'unshift'] as const);
837
+ /**
838
+ * Deletes a message with local state update.
839
+ */
840
+ async deleteMessageWithLocalUpdate(params: DeleteMessageWithStateUpdateParams) {
841
+ await this.messageOperations.delete(
842
+ {
843
+ localMessage: params.localMessage,
844
+ options: params.options,
845
+ },
846
+ params.deleteMessageRequestFn,
847
+ );
848
+ }
594
849
 
595
- if (pagination[loadingKey] || pagination[cursorKey] === null) return;
850
+ /**
851
+ * Adds a reaction to a reply with an optimistic local state update, mirroring
852
+ * {@link Channel.addReactionWithLocalUpdate}. The optimistic message is applied to THIS thread's
853
+ * paginator (so pure replies get optimism the channel paginator can't give); the request routes
854
+ * through the parent channel since reactions are channel-level.
855
+ */
856
+ async addReactionWithLocalUpdate({
857
+ messageId,
858
+ reaction,
859
+ options,
860
+ }: {
861
+ messageId: string;
862
+ reaction: ReactionRequest;
863
+ options?: Pick<SendReactionRequest, 'enforce_unique' | 'skip_push'>;
864
+ }) {
865
+ const client = this.channel.getClient();
866
+ const undo = applyReactionLocally(client, {
867
+ enforceUnique: options?.enforce_unique ?? false,
868
+ messageId,
869
+ reaction,
870
+ });
596
871
 
597
- const queryOptions = { [count > 0 ? 'id_gt' : 'id_lt']: pagination[cursorKey] };
598
- const limit = Math.abs(count);
872
+ try {
873
+ const response = await this.channel.sendReaction({
874
+ id: messageId,
875
+ reaction,
876
+ ...options,
877
+ });
878
+ // reconcile the server copy only if we still hold it — a bare upsert of an unheld id would
879
+ // orphan it (the store's refcount GC only reclaims held ids).
880
+ if (response?.message && client.messageStore.has(response.message.id)) {
881
+ client.messageStore.upsert(formatMessage(response.message));
882
+ }
883
+ } catch (error) {
884
+ if (undo && (!client.offlineDb || !isEphemeral(error as Error))) {
885
+ undo();
886
+ }
887
+ throw error;
888
+ }
889
+ }
599
890
 
600
- this.state.partialNext({ pagination: { ...pagination, [loadingKey]: true } });
891
+ /**
892
+ * Removes the current user's reaction from a reply with an optimistic local state update,
893
+ * mirroring {@link Thread.addReactionWithLocalUpdate}.
894
+ */
895
+ async deleteReactionWithLocalUpdate({
896
+ messageId,
897
+ type,
898
+ }: {
899
+ messageId: string;
900
+ type: string;
901
+ }) {
902
+ const client = this.channel.getClient();
903
+ const undo = applyReactionLocally(client, {
904
+ messageId,
905
+ reaction: { type },
906
+ removed: true,
907
+ });
601
908
 
602
909
  try {
603
- const data = await this.queryReplies({ ...queryOptions, limit });
604
- const replies = data.messages.map(formatMessage);
605
- const maybeNextCursor = replies.at(count > 0 ? -1 : 0)?.id ?? null;
910
+ const response = await this.channel.deleteReaction({ id: messageId, type });
911
+ // reconcile the server copy only if we still hold it — a bare upsert of an unheld id would
912
+ // orphan it (the store's refcount GC only reclaims held ids).
913
+ if (response?.message && client.messageStore.has(response.message.id)) {
914
+ client.messageStore.upsert(formatMessage(response.message));
915
+ }
916
+ } catch (error) {
917
+ if (undo && (!client.offlineDb || !isEphemeral(error as Error))) {
918
+ undo();
919
+ }
920
+ throw error;
921
+ }
922
+ }
606
923
 
607
- this.state.next((current) => {
608
- let nextReplies = current.replies;
924
+ public markRead = async ({ force = false }: { force?: boolean } = {}) => {
925
+ if (this.ownUnreadCount === 0 && !force) {
926
+ return null;
927
+ }
609
928
 
610
- // prevent re-creating array if there's nothing to add to the current one
611
- if (replies.length > 0) {
612
- nextReplies = [...current.replies];
613
- nextReplies[insertionMethodKey](...replies);
614
- }
929
+ return await this.client.messageDeliveryReporter.markRead(this);
930
+ };
615
931
 
616
- return {
617
- ...current,
618
- replies: nextReplies,
619
- pagination: {
620
- ...current.pagination,
621
- [cursorKey]: data.messages.length < limit ? null : maybeNextCursor,
622
- [loadingKey]: false,
623
- },
624
- };
625
- });
626
- } catch (error) {
627
- this.client.logger('error', (error as Error).message);
628
- this.state.next((current) => ({
629
- ...current,
630
- pagination: {
631
- ...current.pagination,
632
- [loadingKey]: false,
633
- },
634
- }));
635
- }
932
+ private throttledMarkRead = () => {
933
+ this.client.messageDeliveryReporter.throttledMarkRead(this);
636
934
  };
935
+
936
+ /**
937
+ * @deprecated Use `thread.markRead` instead.
938
+ */
939
+ public markAsRead = ({ force = false }: { force?: boolean } = {}) =>
940
+ this.markRead({ force });
637
941
  }
638
942
 
639
- const formatReadState = (read: ReadResponse[]): ThreadReadState =>
943
+ type MessageThreadParticipant = NonNullable<
944
+ MessageResponse['thread_participants']
945
+ >[number];
946
+ type ThreadParticipant = NonNullable<ThreadStateResponse['thread_participants']>[number];
947
+
948
+ const normalizeThreadParticipants = (
949
+ participants: MessageResponse['thread_participants'] | undefined,
950
+ channelCid: string,
951
+ ): ThreadStateResponse['thread_participants'] | undefined => {
952
+ if (!participants) return undefined;
953
+
954
+ const now = new Date();
955
+
956
+ return participants.map(
957
+ (participant: MessageThreadParticipant) =>
958
+ ({
959
+ channel_cid: channelCid,
960
+ created_at: now,
961
+ last_read_at: now,
962
+ user: participant as UserResponse,
963
+ user_id: participant.id,
964
+ }) as ThreadParticipant,
965
+ );
966
+ };
967
+
968
+ const formatReadState = (read: ReadStateResponse[]): ThreadReadState =>
640
969
  read.reduce<ThreadReadState>((state, userRead) => {
641
970
  state[userRead.user.id] = {
642
971
  user: userRead.user,
@@ -647,21 +976,16 @@ const formatReadState = (read: ReadResponse[]): ThreadReadState =>
647
976
  return state;
648
977
  }, {});
649
978
 
650
- const repliesPaginationFromInitialThread = (
651
- thread: ThreadResponse,
652
- ): ThreadRepliesPagination => {
653
- const latestRepliesContainsAllReplies =
654
- thread.latest_replies.length === thread.reply_count;
655
-
656
- return {
657
- nextCursor: null,
658
- prevCursor: latestRepliesContainsAllReplies
659
- ? null
660
- : (thread.latest_replies.at(0)?.id ?? null),
661
- isLoadingNext: false,
662
- isLoadingPrev: false,
663
- };
664
- };
979
+ const getPlaceholderReadResponse = (currentUserId?: string): ReadStateResponse[] =>
980
+ currentUserId
981
+ ? [
982
+ {
983
+ user: { id: currentUserId } as UserResponse,
984
+ unread_messages: 0,
985
+ last_read: new Date(),
986
+ },
987
+ ]
988
+ : [];
665
989
 
666
990
  const ownUnreadCountSelector =
667
991
  (currentUserId: string | undefined) => (state: ThreadState) =>