stream-chat 9.21.0 → 9.22.1

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.
@@ -31,6 +31,38 @@ type ChannelReadStatus = Record<
31
31
  }
32
32
  >;
33
33
 
34
+ const messageSetBounds = (
35
+ a: LocalMessage[] | MessageResponse[],
36
+ b: LocalMessage[] | MessageResponse[],
37
+ ) => ({
38
+ newestMessageA: new Date(a[0]?.created_at ?? 0),
39
+ oldestMessageA: new Date(a.slice(-1)[0]?.created_at ?? 0),
40
+ newestMessageB: new Date(b[0]?.created_at ?? 0),
41
+ oldestMessageB: new Date(b.slice(-1)[0]?.created_at ?? 0),
42
+ });
43
+
44
+ const aContainsOrEqualsB = (a: LocalMessage[], b: LocalMessage[]) => {
45
+ const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } =
46
+ messageSetBounds(a, b);
47
+ return newestMessageA >= newestMessageB && oldestMessageB >= oldestMessageA;
48
+ };
49
+
50
+ const aOverlapsB = (a: LocalMessage[], b: LocalMessage[]) => {
51
+ const { newestMessageA, newestMessageB, oldestMessageA, oldestMessageB } =
52
+ messageSetBounds(a, b);
53
+ return (
54
+ oldestMessageA < oldestMessageB &&
55
+ oldestMessageB < newestMessageA &&
56
+ newestMessageA < newestMessageB
57
+ );
58
+ };
59
+
60
+ const messageSetsOverlapByTimestamp = (a: LocalMessage[], b: LocalMessage[]) =>
61
+ aContainsOrEqualsB(a, b) ||
62
+ aContainsOrEqualsB(b, a) ||
63
+ aOverlapsB(a, b) ||
64
+ aOverlapsB(b, a);
65
+
34
66
  /**
35
67
  * ChannelState - A container class for the channel state.
36
68
  */
@@ -867,6 +899,41 @@ export class ChannelState {
867
899
  return this.messageSets[messageSetIndex].messages.find((m) => m.id === messageId);
868
900
  }
869
901
 
902
+ findMessageByTimestamp(
903
+ timestampMs: number,
904
+ parentMessageId?: string,
905
+ exactTsMatch: boolean = false,
906
+ ): LocalMessage | null {
907
+ if (
908
+ (parentMessageId && !this.threads[parentMessageId]) ||
909
+ this.messageSets.length === 0
910
+ )
911
+ return null;
912
+ const setIndex = this.findMessageSetByOldestTimestamp(timestampMs);
913
+ const targetMsgSet = this.messageSets[setIndex]?.messages;
914
+ if (!targetMsgSet?.length) return null;
915
+ const firstMsgTimestamp = targetMsgSet[0].created_at.getTime();
916
+ const lastMsgTimestamp = targetMsgSet.slice(-1)[0].created_at.getTime();
917
+ const isOutOfBound =
918
+ timestampMs < firstMsgTimestamp || lastMsgTimestamp < timestampMs;
919
+ if (isOutOfBound && exactTsMatch) return null;
920
+
921
+ let msgIndex = 0,
922
+ hi = targetMsgSet.length - 1;
923
+ while (msgIndex < hi) {
924
+ const mid = (msgIndex + hi) >>> 1;
925
+ if (timestampMs <= targetMsgSet[mid].created_at.getTime()) hi = mid;
926
+ else msgIndex = mid + 1;
927
+ }
928
+
929
+ const foundMessage = targetMsgSet[msgIndex];
930
+ return !exactTsMatch
931
+ ? foundMessage
932
+ : foundMessage.created_at.getTime() === timestampMs
933
+ ? foundMessage
934
+ : null;
935
+ }
936
+
870
937
  private switchToMessageSet(index: number) {
871
938
  const currentMessages = this.messageSets.find((s) => s.isCurrent);
872
939
  if (!currentMessages) {
@@ -889,6 +956,26 @@ export class ChannelState {
889
956
  );
890
957
  }
891
958
 
959
+ /**
960
+ * Identifies the set index into which a message set would pertain if its first item's creation date corresponded to oldestTimestampMs.
961
+ * @param oldestTimestampMs
962
+ */
963
+ private findMessageSetByOldestTimestamp = (oldestTimestampMs: number): number => {
964
+ let lo = 0,
965
+ hi = this.messageSets.length;
966
+ while (lo < hi) {
967
+ const mid = (lo + hi) >>> 1;
968
+ const msgSet = this.messageSets[mid];
969
+ // should not happen
970
+ if (msgSet.messages.length === 0) return -1;
971
+
972
+ const oldestMessageTimestampInSet = msgSet.messages[0].created_at.getTime();
973
+ if (oldestMessageTimestampInSet <= oldestTimestampMs) hi = mid;
974
+ else lo = mid + 1;
975
+ }
976
+ return lo;
977
+ };
978
+
892
979
  private findTargetMessageSet(
893
980
  newMessages: (MessageResponse | LocalMessage)[],
894
981
  addIfDoesNotExist = true,
@@ -896,39 +983,85 @@ export class ChannelState {
896
983
  ) {
897
984
  let messagesToAdd: (MessageResponse | LocalMessage)[] = newMessages;
898
985
  let targetMessageSetIndex!: number;
986
+ if (newMessages.length === 0)
987
+ return { targetMessageSetIndex: 0, messagesToAdd: newMessages };
899
988
  if (addIfDoesNotExist) {
900
- const overlappingMessageSetIndices = this.messageSets
989
+ const overlappingMessageSetIndicesByMsgIds = this.messageSets
901
990
  .map((_, i) => i)
902
991
  .filter((i) =>
903
992
  this.areMessageSetsOverlap(this.messageSets[i].messages, newMessages),
904
993
  );
994
+ const overlappingMessageSetIndicesByTimestamp = this.messageSets
995
+ .map((_, i) => i)
996
+ .filter((i) =>
997
+ messageSetsOverlapByTimestamp(
998
+ this.messageSets[i].messages,
999
+ newMessages.map(formatMessage),
1000
+ ),
1001
+ );
905
1002
  switch (messageSetToAddToIfDoesNotExist) {
906
1003
  case 'new':
907
- if (overlappingMessageSetIndices.length > 0) {
908
- targetMessageSetIndex = overlappingMessageSetIndices[0];
1004
+ if (overlappingMessageSetIndicesByMsgIds.length > 0) {
1005
+ targetMessageSetIndex = overlappingMessageSetIndicesByMsgIds[0];
1006
+ } else if (overlappingMessageSetIndicesByTimestamp.length > 0) {
1007
+ targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0];
909
1008
  // No new message set is created if newMessages only contains thread replies
910
1009
  } else if (newMessages.some((m) => !m.parent_id)) {
911
- this.messageSets.push({
912
- messages: [],
913
- isCurrent: false,
914
- isLatest: false,
915
- pagination: DEFAULT_MESSAGE_SET_PAGINATION,
916
- });
917
- targetMessageSetIndex = this.messageSets.length - 1;
1010
+ // find the index to insert the set
1011
+ const setIngestIndex = this.findMessageSetByOldestTimestamp(
1012
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
1013
+ new Date(newMessages[0].created_at!).getTime(),
1014
+ );
1015
+ if (setIngestIndex === -1) {
1016
+ this.messageSets.push({
1017
+ messages: [],
1018
+ isCurrent: false,
1019
+ isLatest: false,
1020
+ pagination: DEFAULT_MESSAGE_SET_PAGINATION,
1021
+ });
1022
+ targetMessageSetIndex = this.messageSets.length - 1;
1023
+ } else {
1024
+ const isLatest = setIngestIndex === 0;
1025
+ this.messageSets.splice(setIngestIndex, 0, {
1026
+ messages: [],
1027
+ isCurrent: false,
1028
+ isLatest,
1029
+ pagination: DEFAULT_MESSAGE_SET_PAGINATION, // fixme: it is problematic decide about pagination without having data
1030
+ });
1031
+ if (isLatest) {
1032
+ this.messageSets.slice(1).forEach((set) => {
1033
+ set.isLatest = false;
1034
+ });
1035
+ }
1036
+ targetMessageSetIndex = setIngestIndex;
1037
+ }
918
1038
  }
919
1039
  break;
920
1040
  case 'current':
921
- targetMessageSetIndex = this.messageSets.findIndex((s) => s.isCurrent);
1041
+ // determine if there is another set to which it would match taken into consideration the timestamp
1042
+ if (overlappingMessageSetIndicesByTimestamp.length > 0) {
1043
+ targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0];
1044
+ } else {
1045
+ targetMessageSetIndex = this.messageSets.findIndex((s) => s.isCurrent);
1046
+ }
922
1047
  break;
923
1048
  case 'latest':
924
- targetMessageSetIndex = this.messageSets.findIndex((s) => s.isLatest);
1049
+ // determine if there is another set to which it would match taken into consideration the timestamp
1050
+ if (overlappingMessageSetIndicesByTimestamp.length > 0) {
1051
+ targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0];
1052
+ } else {
1053
+ targetMessageSetIndex = this.messageSets.findIndex((s) => s.isLatest);
1054
+ }
925
1055
  break;
926
1056
  default:
927
1057
  targetMessageSetIndex = -1;
928
1058
  }
929
1059
  // when merging the target set will be the first one from the overlapping message sets
930
- const mergeTargetMessageSetIndex = overlappingMessageSetIndices.splice(0, 1)[0];
931
- const mergeSourceMessageSetIndices = [...overlappingMessageSetIndices];
1060
+ const mergeTargetMessageSetIndex = overlappingMessageSetIndicesByMsgIds.splice(
1061
+ 0,
1062
+ 1,
1063
+ )[0];
1064
+ const mergeSourceMessageSetIndices = [...overlappingMessageSetIndicesByMsgIds];
932
1065
  if (
933
1066
  mergeTargetMessageSetIndex !== undefined &&
934
1067
  mergeTargetMessageSetIndex !== targetMessageSetIndex
package/src/client.ts CHANGED
@@ -80,6 +80,7 @@ import type {
80
80
  DeactivateUsersOptions,
81
81
  DeleteChannelsResponse,
82
82
  DeleteCommandResponse,
83
+ DeleteMessageOptions,
83
84
  DeleteUserOptions,
84
85
  Device,
85
86
  DeviceIdentifier,
@@ -240,6 +241,7 @@ import type {
240
241
  QueryChannelsRequestType,
241
242
  } from './channel_manager';
242
243
  import { ChannelManager } from './channel_manager';
244
+ import { MessageDeliveryReporter } from './messageDelivery';
243
245
  import { NotificationManager } from './notifications';
244
246
  import { ReminderManager } from './reminders';
245
247
  import { StateStore } from './store';
@@ -272,7 +274,7 @@ export type MessageComposerSetupState = {
272
274
 
273
275
  export class StreamChat {
274
276
  private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics
275
-
277
+ messageDeliveryReporter: MessageDeliveryReporter;
276
278
  _user?: OwnUserResponse | UserResponse;
277
279
  appSettingsPromise?: Promise<AppSettingsAPIResponse>;
278
280
  activeChannels: {
@@ -503,6 +505,7 @@ export class StreamChat {
503
505
  this.threads = new ThreadManager({ client: this });
504
506
  this.polls = new PollManager({ client: this });
505
507
  this.reminders = new ReminderManager({ client: this });
508
+ this.messageDeliveryReporter = new MessageDeliveryReporter({ client: this });
506
509
  }
507
510
 
508
511
  /**
@@ -2010,7 +2013,7 @@ export class StreamChat {
2010
2013
 
2011
2014
  channels.push(c);
2012
2015
  }
2013
-
2016
+ this.syncDeliveredCandidates(channels);
2014
2017
  return channels;
2015
2018
  }
2016
2019
 
@@ -3103,10 +3106,30 @@ export class StreamChat {
3103
3106
  );
3104
3107
  }
3105
3108
 
3106
- async deleteMessage(messageID: string, hardDelete?: boolean) {
3109
+ /**
3110
+ * deleteMessage - Delete a message
3111
+ *
3112
+ * @param {string} messageID The id of the message to delete
3113
+ * @param {boolean | DeleteMessageOptions | undefined} [optionsOrHardDelete]
3114
+ * @return {Promise<APIResponse & { message: MessageResponse }>} The API response
3115
+ */
3116
+ // fixme: remove the signature with optionsOrHardDelete boolean with the next major release
3117
+ async deleteMessage(
3118
+ messageID: string,
3119
+ optionsOrHardDelete?: DeleteMessageOptions | boolean,
3120
+ ): Promise<APIResponse & { message: MessageResponse }> {
3121
+ let options: DeleteMessageOptions = {};
3122
+ if (typeof optionsOrHardDelete === 'boolean') {
3123
+ options = optionsOrHardDelete ? { hardDelete: true } : {};
3124
+ } else if (optionsOrHardDelete?.deleteForMe) {
3125
+ options = { deleteForMe: true };
3126
+ } else if (optionsOrHardDelete?.hardDelete) {
3127
+ options = { hardDelete: true };
3128
+ }
3129
+
3107
3130
  try {
3108
3131
  if (this.offlineDb) {
3109
- if (hardDelete) {
3132
+ if (options.hardDelete) {
3110
3133
  await this.offlineDb.hardDeleteMessage({ id: messageID });
3111
3134
  } else {
3112
3135
  await this.offlineDb.softDeleteMessage({ id: messageID });
@@ -3115,7 +3138,7 @@ export class StreamChat {
3115
3138
  {
3116
3139
  task: {
3117
3140
  messageId: messageID,
3118
- payload: [messageID, hardDelete],
3141
+ payload: [messageID, options],
3119
3142
  type: 'delete-message',
3120
3143
  },
3121
3144
  },
@@ -3128,18 +3151,40 @@ export class StreamChat {
3128
3151
  });
3129
3152
  }
3130
3153
 
3131
- return this._deleteMessage(messageID, hardDelete);
3154
+ return this._deleteMessage(messageID, options);
3132
3155
  }
3133
3156
 
3134
- async _deleteMessage(messageID: string, hardDelete?: boolean) {
3157
+ // fixme: remove the signature with optionsOrHardDelete boolean with the next major release
3158
+ async _deleteMessage(
3159
+ messageID: string,
3160
+ optionsOrHardDelete?: DeleteMessageOptions | boolean,
3161
+ ): Promise<APIResponse & { message: MessageResponse }> {
3162
+ // this is a API call method, we do not route hardDelete: true and deleteForMe: true to deleteForMe: true
3163
+ // and expect to receive error response from the server
3164
+ const { deleteForMe, hardDelete } = (
3165
+ typeof optionsOrHardDelete === 'boolean'
3166
+ ? { hardDelete: optionsOrHardDelete }
3167
+ : (optionsOrHardDelete ?? {})
3168
+ ) as DeleteMessageOptions;
3169
+
3135
3170
  let params = {};
3136
3171
  if (hardDelete) {
3137
3172
  params = { hard: true };
3138
3173
  }
3139
- return await this.delete<APIResponse & { message: MessageResponse }>(
3174
+ if (deleteForMe) {
3175
+ params = { ...params, delete_for_me: true };
3176
+ }
3177
+ const result = await this.delete<APIResponse & { message: MessageResponse }>(
3140
3178
  this.baseURL + `/messages/${encodeURIComponent(messageID)}`,
3141
3179
  params,
3142
3180
  );
3181
+
3182
+ // necessary to populate the below values as the server does not return the message in the response as deleted
3183
+ if (deleteForMe) {
3184
+ result.message.deleted_for_me = true;
3185
+ result.message.type = 'deleted';
3186
+ }
3187
+ return result;
3143
3188
  }
3144
3189
 
3145
3190
  /**
@@ -4701,19 +4746,20 @@ export class StreamChat {
4701
4746
  }
4702
4747
 
4703
4748
  /**
4704
- * Send the mark delivered event for this user, only works if the `delivery_receipts` setting is enabled
4749
+ * Send the mark delivered event for this user
4705
4750
  *
4706
4751
  * @param {MarkDeliveredOptions} data
4707
4752
  * @return {Promise<EventAPIResponse | void>} Description
4708
4753
  */
4709
- async markChannelsDelivered(data?: MarkDeliveredOptions) {
4710
- const deliveryReceiptsEnabled =
4711
- this.user?.privacy_settings?.delivery_receipts?.enabled;
4712
- if (!deliveryReceiptsEnabled) return;
4713
-
4754
+ async markChannelsDelivered(data: MarkDeliveredOptions) {
4755
+ if (!data?.latest_delivered_messages?.length) return;
4714
4756
  return await this.post<EventAPIResponse>(
4715
4757
  this.baseURL + '/channels/delivered',
4716
4758
  data ?? {},
4717
4759
  );
4718
4760
  }
4761
+
4762
+ syncDeliveredCandidates(collections: Channel[]) {
4763
+ this.messageDeliveryReporter.syncDeliveredCandidates(collections);
4764
+ }
4719
4765
  }
package/src/events.ts CHANGED
@@ -31,6 +31,7 @@ export const EVENT_MAP = {
31
31
  'notification.mark_unread': true,
32
32
  'notification.message_new': true,
33
33
  'notification.mutes_updated': true,
34
+ 'notification.reminder_due': true,
34
35
  'notification.removed_from_channel': true,
35
36
  'notification.thread_message_new': true,
36
37
  'poll.closed': true,
@@ -41,6 +42,9 @@ export const EVENT_MAP = {
41
42
  'reaction.deleted': true,
42
43
  'reaction.new': true,
43
44
  'reaction.updated': true,
45
+ 'reminder.created': true,
46
+ 'reminder.deleted': true,
47
+ 'reminder.updated': true,
44
48
  'thread.updated': true,
45
49
  'typing.start': true,
46
50
  'typing.stop': true,
@@ -67,10 +71,4 @@ export const EVENT_MAP = {
67
71
  'capabilities.changed': true,
68
72
  'live_location_sharing.started': true,
69
73
  'live_location_sharing.stopped': true,
70
-
71
- // Reminder events
72
- 'reminder.created': true,
73
- 'reminder.updated': true,
74
- 'reminder.deleted': true,
75
- 'notification.reminder_due': true,
76
74
  };
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from './connection';
8
8
  export * from './events';
9
9
  export * from './insights';
10
10
  export * from './messageComposer';
11
+ export * from './messageDelivery';
11
12
  export * from './middleware';
12
13
  export * from './moderation';
13
14
  export * from './notifications';
@@ -0,0 +1,259 @@
1
+ import type { StreamChat } from '../client';
2
+ import { Channel } from '../channel';
3
+ import type { ThreadUserReadState } from '../thread';
4
+ import { Thread } from '../thread';
5
+ import type {
6
+ EventAPIResponse,
7
+ LocalMessage,
8
+ MarkDeliveredOptions,
9
+ MarkReadOptions,
10
+ } from '../types';
11
+ import { throttle } from '../utils';
12
+
13
+ const MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD = 100 as const;
14
+ const MARK_AS_DELIVERED_BUFFER_TIMEOUT = 1000 as const;
15
+ const MARK_AS_READ_THROTTLE_TIMEOUT = 1000 as const;
16
+
17
+ const isChannel = (item: Channel | Thread): item is Channel => item instanceof Channel;
18
+ const isThread = (item: Channel | Thread): item is Thread => item instanceof Thread;
19
+
20
+ type MessageId = string;
21
+ type ChannelThreadCompositeId = string;
22
+
23
+ export type AnnounceDeliveryOptions = Omit<
24
+ MarkDeliveredOptions,
25
+ 'latest_delivered_messages'
26
+ >;
27
+
28
+ export type MessageDeliveryReporterOptions = {
29
+ client: StreamChat;
30
+ };
31
+
32
+ export class MessageDeliveryReporter {
33
+ protected client: StreamChat;
34
+
35
+ protected deliveryReportCandidates: Map<ChannelThreadCompositeId, MessageId> =
36
+ new Map();
37
+ protected nextDeliveryReportCandidates: Map<ChannelThreadCompositeId, MessageId> =
38
+ new Map();
39
+
40
+ protected markDeliveredRequestPromise: Promise<EventAPIResponse | void> | null = null;
41
+ protected markDeliveredTimeout: ReturnType<typeof setTimeout> | null = null;
42
+
43
+ constructor({ client }: MessageDeliveryReporterOptions) {
44
+ this.client = client;
45
+ }
46
+
47
+ private get markDeliveredRequestInFlight() {
48
+ return this.markDeliveredRequestPromise !== null;
49
+ }
50
+ private get hasTimer() {
51
+ return this.markDeliveredTimeout !== null;
52
+ }
53
+ private get hasDeliveryCandidates() {
54
+ return this.deliveryReportCandidates.size > 0;
55
+ }
56
+
57
+ /**
58
+ * Build latest_delivered_messages payload from an arbitrary buffer (deliveryReportCandidates / nextDeliveryReportCandidates)
59
+ */
60
+ private confirmationsFrom(map: Map<ChannelThreadCompositeId, MessageId>) {
61
+ return Array.from(map.entries()).map(([key, messageId]) => {
62
+ const [type, id, parent_id] = key.split(':');
63
+ return parent_id
64
+ ? { cid: `${type}:${id}`, id: messageId, parent_id }
65
+ : { cid: key, id: messageId };
66
+ });
67
+ }
68
+
69
+ private confirmationsFromDeliveryReportCandidates() {
70
+ const entries = Array.from(this.deliveryReportCandidates);
71
+ const sendBuffer = new Map(entries.slice(0, MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD));
72
+ this.deliveryReportCandidates = new Map(
73
+ entries.slice(MAX_DELIVERED_MESSAGE_COUNT_IN_PAYLOAD),
74
+ );
75
+
76
+ return { latest_delivered_messages: this.confirmationsFrom(sendBuffer), sendBuffer };
77
+ }
78
+
79
+ /**
80
+ * Generate candidate key for storing in the candidates buffer
81
+ * @param collection
82
+ * @private
83
+ */
84
+ private candidateKeyFor(
85
+ collection: Channel | Thread,
86
+ ): ChannelThreadCompositeId | undefined {
87
+ if (isChannel(collection)) return collection.cid;
88
+ if (isThread(collection)) return `${collection.channel.cid}:${collection.id}`;
89
+ }
90
+
91
+ /**
92
+ * Retrieve the reference to the latest message in the state that is nor read neither reported as delivered
93
+ * @param collection
94
+ */
95
+ private getNextDeliveryReportCandidate = (
96
+ collection: Channel | Thread,
97
+ ): { key: ChannelThreadCompositeId; id: MessageId | null } | undefined => {
98
+ const ownUserId = this.client.user?.id;
99
+ if (!ownUserId) return;
100
+
101
+ let latestMessages: LocalMessage[] = [];
102
+ let lastDeliveredAt: Date | undefined;
103
+ let lastReadAt: Date | undefined;
104
+ let key: string | undefined = undefined;
105
+
106
+ if (isChannel(collection)) {
107
+ latestMessages = collection.state.latestMessages;
108
+ const ownReadState = collection.state.read[ownUserId] ?? {};
109
+ lastReadAt = ownReadState?.last_read;
110
+ lastDeliveredAt = ownReadState?.last_delivered_at;
111
+ key = collection.cid;
112
+ } else if (isThread(collection)) {
113
+ latestMessages = collection.state.getLatestValue().replies;
114
+ const ownReadState =
115
+ collection.state.getLatestValue().read[ownUserId] ?? ({} as ThreadUserReadState);
116
+ lastReadAt = ownReadState?.lastReadAt;
117
+ // @ts-expect-error lastDeliveredAt is not defined yet on ThreadUserReadState
118
+ lastDeliveredAt = ownReadState?.lastDeliveredAt;
119
+ key = `${collection.channel.cid}:${collection.id}`;
120
+ // todo: remove return statement once marking messages as delivered in thread is supported
121
+ return;
122
+ } else {
123
+ return;
124
+ }
125
+
126
+ if (!key) return;
127
+
128
+ const [latestMessage] = latestMessages.slice(-1);
129
+
130
+ const wholeCollectionIsRead =
131
+ !latestMessage || lastReadAt >= latestMessage.created_at;
132
+ if (wholeCollectionIsRead) return { key, id: null };
133
+ const wholeCollectionIsMarkedDelivered =
134
+ !latestMessage || (lastDeliveredAt ?? 0) >= latestMessage.created_at;
135
+ if (wholeCollectionIsMarkedDelivered) return { key, id: null };
136
+
137
+ return { key, id: latestMessage.id || null };
138
+ };
139
+
140
+ /**
141
+ * Updates the delivery candidates buffer with the latest delivery candidates
142
+ * @param collection
143
+ */
144
+ private trackDeliveredCandidate(collection: Channel | Thread) {
145
+ if (isChannel(collection) && !collection.getConfig()?.read_events) return;
146
+ if (isThread(collection) && !collection.channel.getConfig()?.read_events) return;
147
+ const candidate = this.getNextDeliveryReportCandidate(collection);
148
+ if (!candidate?.key) return;
149
+ const buffer = this.markDeliveredRequestInFlight
150
+ ? this.nextDeliveryReportCandidates
151
+ : this.deliveryReportCandidates;
152
+ if (candidate.id === null) buffer.delete(candidate.key);
153
+ else buffer.set(candidate.key, candidate.id);
154
+ }
155
+
156
+ /**
157
+ * Removes candidate from the delivery report buffer
158
+ * @param collection
159
+ * @private
160
+ */
161
+ private removeCandidateFor(collection: Channel | Thread) {
162
+ const candidateKey = this.candidateKeyFor(collection);
163
+ if (!candidateKey) return;
164
+ this.deliveryReportCandidates.delete(candidateKey);
165
+ this.nextDeliveryReportCandidates.delete(candidateKey);
166
+ }
167
+
168
+ /**
169
+ * Records the latest message delivered for Channel or Thread instances and schedules the next report
170
+ * if not already scheduled and candidates exist.
171
+ * Should be used for WS handling (message.new) as well as for ingesting HTTP channel query results.
172
+ * @param collections
173
+ */
174
+ public syncDeliveredCandidates(collections: (Channel | Thread)[]) {
175
+ if (this.client.user?.privacy_settings?.delivery_receipts?.enabled === false) return;
176
+ for (const c of collections) this.trackDeliveredCandidate(c);
177
+ this.announceDeliveryBuffered();
178
+ }
179
+
180
+ /**
181
+ * Fires delivery announcement request followed by immediate delivery candidate buffer reset.
182
+ * @param options
183
+ */
184
+ public announceDelivery = (options?: AnnounceDeliveryOptions) => {
185
+ if (this.markDeliveredRequestInFlight || !this.hasDeliveryCandidates) return;
186
+
187
+ const { latest_delivered_messages, sendBuffer } =
188
+ this.confirmationsFromDeliveryReportCandidates();
189
+ if (!latest_delivered_messages.length) return;
190
+
191
+ const payload = { ...options, latest_delivered_messages };
192
+
193
+ const postFlightReconcile = () => {
194
+ this.markDeliveredRequestPromise = null;
195
+
196
+ // promote anything that arrived during request
197
+ for (const [k, v] of this.nextDeliveryReportCandidates.entries()) {
198
+ this.deliveryReportCandidates.set(k, v);
199
+ }
200
+ this.nextDeliveryReportCandidates = new Map();
201
+
202
+ // checks internally whether there are candidates to announce
203
+ this.announceDeliveryBuffered(options);
204
+ };
205
+
206
+ const handleError = () => {
207
+ // repopulate relevant candidates for the next report
208
+ for (const [k, v] of Object.entries(sendBuffer)) {
209
+ if (!this.deliveryReportCandidates.has(k)) {
210
+ this.deliveryReportCandidates.set(k, v);
211
+ }
212
+ }
213
+ postFlightReconcile();
214
+ };
215
+
216
+ this.markDeliveredRequestPromise = this.client
217
+ .markChannelsDelivered(payload)
218
+ .then(postFlightReconcile, handleError);
219
+ };
220
+
221
+ public announceDeliveryBuffered = (options?: AnnounceDeliveryOptions) => {
222
+ if (this.hasTimer || this.markDeliveredRequestInFlight || !this.hasDeliveryCandidates)
223
+ return;
224
+ this.markDeliveredTimeout = setTimeout(() => {
225
+ this.markDeliveredTimeout = null;
226
+ this.announceDelivery(options);
227
+ }, MARK_AS_DELIVERED_BUFFER_TIMEOUT);
228
+ };
229
+
230
+ /**
231
+ * Delegates the mark-read call to the Channel or Thread instance
232
+ * @param collection
233
+ * @param options
234
+ */
235
+ public markRead = async (collection: Channel | Thread, options?: MarkReadOptions) => {
236
+ let result: EventAPIResponse | null = null;
237
+ if (isChannel(collection)) {
238
+ result = await collection.markAsReadRequest(options);
239
+ } else if (isThread(collection)) {
240
+ result = await collection.channel.markAsReadRequest({
241
+ ...options,
242
+ thread_id: collection.id,
243
+ });
244
+ }
245
+
246
+ this.removeCandidateFor(collection);
247
+ return result;
248
+ };
249
+
250
+ /**
251
+ * Throttles the MessageDeliveryReporter.markRead call
252
+ * @param collection
253
+ * @param options
254
+ */
255
+ public throttledMarkRead = throttle(this.markRead, MARK_AS_READ_THROTTLE_TIMEOUT, {
256
+ leading: false,
257
+ trailing: true,
258
+ });
259
+ }