stream-chat 9.20.3 → 9.22.0

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.
package/src/channel.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { ChannelState } from './channel_state';
2
+ import { MessageComposer } from './messageComposer';
3
+ import { MessageReceiptsTracker } from './messageDelivery';
2
4
  import {
3
5
  generateChannelTempCid,
4
6
  logChatPromiseExecution,
@@ -74,7 +76,6 @@ import type {
74
76
  } from './types';
75
77
  import type { Role } from './permissions';
76
78
  import type { CustomChannelData } from './custom_types';
77
- import { MessageComposer } from './messageComposer';
78
79
 
79
80
  /**
80
81
  * Channel - The Channel class manages it's own state.
@@ -110,6 +111,7 @@ export class Channel {
110
111
  disconnected: boolean;
111
112
  push_preferences?: PushPreference;
112
113
  public readonly messageComposer: MessageComposer;
114
+ public readonly messageReceiptsTracker: MessageReceiptsTracker;
113
115
 
114
116
  /**
115
117
  * constructor - Create a channel
@@ -158,6 +160,13 @@ export class Channel {
158
160
  client: this._client,
159
161
  compositionContext: this,
160
162
  });
163
+
164
+ this.messageReceiptsTracker = new MessageReceiptsTracker({
165
+ locateMessage: (timestampMs) => {
166
+ const msg = this.state.findMessageByTimestamp(timestampMs);
167
+ return msg && { timestampMs, msgId: msg.id };
168
+ },
169
+ });
161
170
  }
162
171
 
163
172
  /**
@@ -1131,16 +1140,26 @@ export class Channel {
1131
1140
  }
1132
1141
 
1133
1142
  /**
1134
- * markRead - Send the mark read event for this user, only works if the `read_events` setting is enabled
1143
+ * 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.
1135
1144
  *
1136
1145
  * @param {MarkReadOptions} data
1137
1146
  * @return {Promise<EventAPIResponse | null>} Description
1138
1147
  */
1139
1148
  async markRead(data: MarkReadOptions = {}) {
1149
+ return await this.getClient().messageDeliveryReporter.markRead(this, data);
1150
+ }
1151
+
1152
+ /**
1153
+ * markReadRequest - Send the mark read event for this user, only works if the `read_events` setting is enabled
1154
+ *
1155
+ * @param {MarkReadOptions} data
1156
+ * @return {Promise<EventAPIResponse | null>} Description
1157
+ */
1158
+ async markAsReadRequest(data: MarkReadOptions = {}) {
1140
1159
  this._checkInitialized();
1141
1160
 
1142
1161
  if (!this.getConfig()?.read_events && !this.getClient()._isUsingServerAuth()) {
1143
- return Promise.resolve(null);
1162
+ return null;
1144
1163
  }
1145
1164
 
1146
1165
  return await this.getClient().post<EventAPIResponse>(this._channelURL() + '/read', {
@@ -1554,6 +1573,7 @@ export class Channel {
1554
1573
  { method: 'upsertChannels' },
1555
1574
  );
1556
1575
 
1576
+ this.getClient().syncDeliveredCandidates([this]);
1557
1577
  return state;
1558
1578
  }
1559
1579
 
@@ -1874,18 +1894,50 @@ export class Channel {
1874
1894
  break;
1875
1895
  case 'message.read':
1876
1896
  if (event.user?.id && event.created_at) {
1897
+ const previousReadState = channelState.read[event.user.id];
1877
1898
  channelState.read[event.user.id] = {
1899
+ // in case we already have delivery information
1900
+ ...previousReadState,
1878
1901
  last_read: new Date(event.created_at),
1879
1902
  last_read_message_id: event.last_read_message_id,
1880
1903
  user: event.user,
1881
1904
  unread_messages: 0,
1882
1905
  };
1906
+ this.messageReceiptsTracker.onMessageRead({
1907
+ user: event.user,
1908
+ readAt: event.created_at,
1909
+ lastReadMessageId: event.last_read_message_id,
1910
+ });
1911
+ const client = this.getClient();
1883
1912
 
1884
- if (event.user?.id === this.getClient().user?.id) {
1913
+ const isOwnEvent = event.user?.id === client.user?.id;
1914
+
1915
+ if (isOwnEvent) {
1885
1916
  channelState.unreadCount = 0;
1917
+ client.syncDeliveredCandidates([this]);
1886
1918
  }
1887
1919
  }
1888
1920
  break;
1921
+ case 'message.delivered':
1922
+ // todo: update also on thread
1923
+ if (event.user?.id && event.created_at) {
1924
+ const previousReadState = channelState.read[event.user.id];
1925
+ channelState.read[event.user.id] = {
1926
+ ...previousReadState,
1927
+ last_delivered_at: event.last_delivered_at
1928
+ ? new Date(event.last_delivered_at)
1929
+ : undefined,
1930
+ last_delivered_message_id: event.last_delivered_message_id,
1931
+ user: event.user,
1932
+ };
1933
+
1934
+ this.messageReceiptsTracker.onMessageDelivered({
1935
+ user: event.user,
1936
+ deliveredAt: event.created_at,
1937
+ lastDeliveredMessageId: event.last_delivered_message_id,
1938
+ });
1939
+ }
1940
+ break;
1889
1941
  case 'user.watching.start':
1890
1942
  case 'user.updated':
1891
1943
  if (event.user?.id) {
@@ -1921,8 +1973,9 @@ export class Channel {
1921
1973
  break;
1922
1974
  case 'message.new':
1923
1975
  if (event.message) {
1976
+ const client = this.getClient();
1924
1977
  /* if message belongs to current user, always assume timestamp is changed to filter it out and add again to avoid duplication */
1925
- const ownMessage = event.user?.id === this.getClient().user?.id;
1978
+ const ownMessage = event.user?.id === client.user?.id;
1926
1979
  const isThreadMessage =
1927
1980
  event.message.parent_id && !event.message.show_in_channel;
1928
1981
 
@@ -1947,6 +2000,8 @@ export class Channel {
1947
2000
  last_read: new Date(event.created_at as string),
1948
2001
  user: event.user,
1949
2002
  unread_messages: 0,
2003
+ last_delivered_at: new Date(event.created_at as string),
2004
+ last_delivered_message_id: event.message.id,
1950
2005
  };
1951
2006
  } else {
1952
2007
  channelState.read[userId].unread_messages += 1;
@@ -1957,6 +2012,8 @@ export class Channel {
1957
2012
  if (this._countMessageAsUnread(event.message)) {
1958
2013
  channelState.unreadCount = channelState.unreadCount + 1;
1959
2014
  }
2015
+
2016
+ client.syncDeliveredCandidates([this]);
1960
2017
  }
1961
2018
  break;
1962
2019
  case 'message.updated':
@@ -2057,11 +2114,13 @@ export class Channel {
2057
2114
  break;
2058
2115
  case 'notification.mark_unread': {
2059
2116
  const ownMessage = event.user?.id === this.getClient().user?.id;
2060
- if (!(ownMessage && event.user)) break;
2117
+ if (!ownMessage || !event.user) break;
2061
2118
 
2062
2119
  const unreadCount = event.unread_messages ?? 0;
2063
-
2120
+ const currentState = channelState.read[event.user.id];
2064
2121
  channelState.read[event.user.id] = {
2122
+ // keep the message delivery info
2123
+ ...currentState,
2065
2124
  first_unread_message_id: event.first_unread_message_id,
2066
2125
  last_read: new Date(event.last_read_at as string),
2067
2126
  last_read_message_id: event.last_read_message_id,
@@ -2070,6 +2129,11 @@ export class Channel {
2070
2129
  };
2071
2130
 
2072
2131
  channelState.unreadCount = unreadCount;
2132
+ this.messageReceiptsTracker.onNotificationMarkUnread({
2133
+ user: event.user,
2134
+ lastReadAt: event.last_read_at,
2135
+ lastReadMessageId: event.last_read_message_id,
2136
+ });
2073
2137
  break;
2074
2138
  }
2075
2139
  case 'channel.updated':
@@ -2272,6 +2336,10 @@ export class Channel {
2272
2336
  if (state.read) {
2273
2337
  for (const read of state.read) {
2274
2338
  this.state.read[read.user.id] = {
2339
+ last_delivered_at: read.last_delivered_at
2340
+ ? new Date(read.last_delivered_at)
2341
+ : undefined,
2342
+ last_delivered_message_id: read.last_delivered_message_id,
2275
2343
  last_read: new Date(read.last_read),
2276
2344
  last_read_message_id: read.last_read_message_id,
2277
2345
  unread_messages: read.unread_messages ?? 0,
@@ -2282,6 +2350,8 @@ export class Channel {
2282
2350
  this.state.unreadCount = this.state.read[read.user.id].unread_messages;
2283
2351
  }
2284
2352
  }
2353
+
2354
+ this.messageReceiptsTracker.ingestInitial(state.read);
2285
2355
  }
2286
2356
 
2287
2357
  return {
@@ -26,9 +26,43 @@ type ChannelReadStatus = Record<
26
26
  user: UserResponse;
27
27
  first_unread_message_id?: string;
28
28
  last_read_message_id?: string;
29
+ last_delivered_at?: Date;
30
+ last_delivered_message_id?: string;
29
31
  }
30
32
  >;
31
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
+
32
66
  /**
33
67
  * ChannelState - A container class for the channel state.
34
68
  */
@@ -865,6 +899,41 @@ export class ChannelState {
865
899
  return this.messageSets[messageSetIndex].messages.find((m) => m.id === messageId);
866
900
  }
867
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
+
868
937
  private switchToMessageSet(index: number) {
869
938
  const currentMessages = this.messageSets.find((s) => s.isCurrent);
870
939
  if (!currentMessages) {
@@ -887,6 +956,26 @@ export class ChannelState {
887
956
  );
888
957
  }
889
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
+
890
979
  private findTargetMessageSet(
891
980
  newMessages: (MessageResponse | LocalMessage)[],
892
981
  addIfDoesNotExist = true,
@@ -894,39 +983,85 @@ export class ChannelState {
894
983
  ) {
895
984
  let messagesToAdd: (MessageResponse | LocalMessage)[] = newMessages;
896
985
  let targetMessageSetIndex!: number;
986
+ if (newMessages.length === 0)
987
+ return { targetMessageSetIndex: 0, messagesToAdd: newMessages };
897
988
  if (addIfDoesNotExist) {
898
- const overlappingMessageSetIndices = this.messageSets
989
+ const overlappingMessageSetIndicesByMsgIds = this.messageSets
899
990
  .map((_, i) => i)
900
991
  .filter((i) =>
901
992
  this.areMessageSetsOverlap(this.messageSets[i].messages, newMessages),
902
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
+ );
903
1002
  switch (messageSetToAddToIfDoesNotExist) {
904
1003
  case 'new':
905
- if (overlappingMessageSetIndices.length > 0) {
906
- targetMessageSetIndex = overlappingMessageSetIndices[0];
1004
+ if (overlappingMessageSetIndicesByMsgIds.length > 0) {
1005
+ targetMessageSetIndex = overlappingMessageSetIndicesByMsgIds[0];
1006
+ } else if (overlappingMessageSetIndicesByTimestamp.length > 0) {
1007
+ targetMessageSetIndex = overlappingMessageSetIndicesByTimestamp[0];
907
1008
  // No new message set is created if newMessages only contains thread replies
908
1009
  } else if (newMessages.some((m) => !m.parent_id)) {
909
- this.messageSets.push({
910
- messages: [],
911
- isCurrent: false,
912
- isLatest: false,
913
- pagination: DEFAULT_MESSAGE_SET_PAGINATION,
914
- });
915
- 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
+ }
916
1038
  }
917
1039
  break;
918
1040
  case 'current':
919
- 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
+ }
920
1047
  break;
921
1048
  case 'latest':
922
- 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
+ }
923
1055
  break;
924
1056
  default:
925
1057
  targetMessageSetIndex = -1;
926
1058
  }
927
1059
  // when merging the target set will be the first one from the overlapping message sets
928
- const mergeTargetMessageSetIndex = overlappingMessageSetIndices.splice(0, 1)[0];
929
- const mergeSourceMessageSetIndices = [...overlappingMessageSetIndices];
1060
+ const mergeTargetMessageSetIndex = overlappingMessageSetIndicesByMsgIds.splice(
1061
+ 0,
1062
+ 1,
1063
+ )[0];
1064
+ const mergeSourceMessageSetIndices = [...overlappingMessageSetIndicesByMsgIds];
930
1065
  if (
931
1066
  mergeTargetMessageSetIndex !== undefined &&
932
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,
@@ -87,6 +88,7 @@ import type {
87
88
  DraftSort,
88
89
  EndpointName,
89
90
  Event,
91
+ EventAPIResponse,
90
92
  EventHandler,
91
93
  ExportChannelOptions,
92
94
  ExportChannelRequest,
@@ -124,6 +126,7 @@ import type {
124
126
  LocalMessage,
125
127
  Logger,
126
128
  MarkChannelsReadOptions,
129
+ MarkDeliveredOptions,
127
130
  MessageFilters,
128
131
  MessageFlagsFilters,
129
132
  MessageFlagsPaginationOptions,
@@ -238,6 +241,7 @@ import type {
238
241
  QueryChannelsRequestType,
239
242
  } from './channel_manager';
240
243
  import { ChannelManager } from './channel_manager';
244
+ import { MessageDeliveryReporter } from './messageDelivery';
241
245
  import { NotificationManager } from './notifications';
242
246
  import { ReminderManager } from './reminders';
243
247
  import { StateStore } from './store';
@@ -270,7 +274,7 @@ export type MessageComposerSetupState = {
270
274
 
271
275
  export class StreamChat {
272
276
  private static _instance?: unknown | StreamChat; // type is undefined|StreamChat, unknown is due to TS limitations with statics
273
-
277
+ messageDeliveryReporter: MessageDeliveryReporter;
274
278
  _user?: OwnUserResponse | UserResponse;
275
279
  appSettingsPromise?: Promise<AppSettingsAPIResponse>;
276
280
  activeChannels: {
@@ -501,6 +505,7 @@ export class StreamChat {
501
505
  this.threads = new ThreadManager({ client: this });
502
506
  this.polls = new PollManager({ client: this });
503
507
  this.reminders = new ReminderManager({ client: this });
508
+ this.messageDeliveryReporter = new MessageDeliveryReporter({ client: this });
504
509
  }
505
510
 
506
511
  /**
@@ -2008,7 +2013,7 @@ export class StreamChat {
2008
2013
 
2009
2014
  channels.push(c);
2010
2015
  }
2011
-
2016
+ this.syncDeliveredCandidates(channels);
2012
2017
  return channels;
2013
2018
  }
2014
2019
 
@@ -3101,10 +3106,30 @@ export class StreamChat {
3101
3106
  );
3102
3107
  }
3103
3108
 
3104
- 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
+
3105
3130
  try {
3106
3131
  if (this.offlineDb) {
3107
- if (hardDelete) {
3132
+ if (options.hardDelete) {
3108
3133
  await this.offlineDb.hardDeleteMessage({ id: messageID });
3109
3134
  } else {
3110
3135
  await this.offlineDb.softDeleteMessage({ id: messageID });
@@ -3113,7 +3138,7 @@ export class StreamChat {
3113
3138
  {
3114
3139
  task: {
3115
3140
  messageId: messageID,
3116
- payload: [messageID, hardDelete],
3141
+ payload: [messageID, options],
3117
3142
  type: 'delete-message',
3118
3143
  },
3119
3144
  },
@@ -3126,18 +3151,40 @@ export class StreamChat {
3126
3151
  });
3127
3152
  }
3128
3153
 
3129
- return this._deleteMessage(messageID, hardDelete);
3154
+ return this._deleteMessage(messageID, options);
3130
3155
  }
3131
3156
 
3132
- 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
+
3133
3170
  let params = {};
3134
3171
  if (hardDelete) {
3135
3172
  params = { hard: true };
3136
3173
  }
3137
- 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 }>(
3138
3178
  this.baseURL + `/messages/${encodeURIComponent(messageID)}`,
3139
3179
  params,
3140
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;
3141
3188
  }
3142
3189
 
3143
3190
  /**
@@ -4697,4 +4744,22 @@ export class StreamChat {
4697
4744
  deleteImage(url: string) {
4698
4745
  return this.delete<APIResponse>(`${this.baseURL}/uploads/image`, { url });
4699
4746
  }
4747
+
4748
+ /**
4749
+ * Send the mark delivered event for this user
4750
+ *
4751
+ * @param {MarkDeliveredOptions} data
4752
+ * @return {Promise<EventAPIResponse | void>} Description
4753
+ */
4754
+ async markChannelsDelivered(data: MarkDeliveredOptions) {
4755
+ if (!data?.latest_delivered_messages?.length) return;
4756
+ return await this.post<EventAPIResponse>(
4757
+ this.baseURL + '/channels/delivered',
4758
+ data ?? {},
4759
+ );
4760
+ }
4761
+
4762
+ syncDeliveredCandidates(collections: Channel[]) {
4763
+ this.messageDeliveryReporter.syncDeliveredCandidates(collections);
4764
+ }
4700
4765
  }
package/src/events.ts CHANGED
@@ -21,6 +21,7 @@ export const EVENT_MAP = {
21
21
  'message.undeleted': true,
22
22
  'notification.added_to_channel': true,
23
23
  'notification.channel_deleted': true,
24
+ 'message.delivered': true,
24
25
  'notification.channel_mutes_updated': true,
25
26
  'notification.channel_truncated': true,
26
27
  'notification.invite_accepted': true,
@@ -30,6 +31,7 @@ export const EVENT_MAP = {
30
31
  'notification.mark_unread': true,
31
32
  'notification.message_new': true,
32
33
  'notification.mutes_updated': true,
34
+ 'notification.reminder_due': true,
33
35
  'notification.removed_from_channel': true,
34
36
  'notification.thread_message_new': true,
35
37
  'poll.closed': true,
@@ -40,6 +42,9 @@ export const EVENT_MAP = {
40
42
  'reaction.deleted': true,
41
43
  'reaction.new': true,
42
44
  'reaction.updated': true,
45
+ 'reminder.created': true,
46
+ 'reminder.deleted': true,
47
+ 'reminder.updated': true,
43
48
  'thread.updated': true,
44
49
  'typing.start': true,
45
50
  'typing.stop': true,
@@ -66,10 +71,4 @@ export const EVENT_MAP = {
66
71
  'capabilities.changed': true,
67
72
  'live_location_sharing.started': true,
68
73
  'live_location_sharing.stopped': true,
69
-
70
- // Reminder events
71
- 'reminder.created': true,
72
- 'reminder.updated': true,
73
- 'reminder.deleted': true,
74
- 'notification.reminder_due': true,
75
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';
@@ -812,12 +812,9 @@ export class MessageComposer extends WithSubscriptions {
812
812
 
813
813
  this.initState({ composition: draft });
814
814
  } catch (error) {
815
- this.client.notifications.add({
816
- message: 'Failed to get the draft',
817
- origin: {
818
- emitter: 'MessageComposer',
819
- context: { composer: this },
820
- },
815
+ this.client.logger('error', `messageComposer:getDraft`, {
816
+ tags: ['channel', 'messageComposer'],
817
+ error,
821
818
  });
822
819
  }
823
820
  };
@@ -203,13 +203,19 @@ export class TextComposer {
203
203
  selection?: TextSelection;
204
204
  }) => {
205
205
  if (!this.enabled) return;
206
-
206
+ /**
207
+ * Windows inserts \r\n; macOS inserts \n.
208
+ * The caret can fall inside a CRLF pair during repeated pastes on Windows.
209
+ * That corrupts newline alignment (a\nb\na\nba\nb\n\n).
210
+ * Normalize the text to prevent it.
211
+ */
212
+ const normalizedText = text.replace(/\r\n/g, '\n');
207
213
  const finalSelection: TextSelection = selection ?? this.selection;
208
214
  const { maxLengthOnEdit } = this.composer.config.text ?? {};
209
215
  const currentText = this.text;
210
216
  const textBeforeTrim = [
211
217
  currentText.slice(0, finalSelection.start),
212
- text,
218
+ normalizedText,
213
219
  currentText.slice(finalSelection.end),
214
220
  ].join('');
215
221
 
@@ -217,7 +223,7 @@ export class TextComposer {
217
223
  0,
218
224
  typeof maxLengthOnEdit === 'number' ? maxLengthOnEdit : textBeforeTrim.length,
219
225
  );
220
- const expectedCursorPosition = finalSelection.start + text.length;
226
+ const expectedCursorPosition = finalSelection.start + normalizedText.length;
221
227
  const cursorPosition = Math.min(expectedCursorPosition, finalText.length);
222
228
 
223
229
  await this.handleChange({