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.
- package/dist/cjs/index.browser.js +661 -38
- package/dist/cjs/index.browser.js.map +4 -4
- package/dist/cjs/index.node.js +656 -37
- package/dist/cjs/index.node.js.map +4 -4
- package/dist/esm/index.mjs +664 -41
- package/dist/esm/index.mjs.map +4 -4
- package/dist/types/channel.d.ts +11 -2
- package/dist/types/channel_state.d.ts +6 -0
- package/dist/types/client.d.ts +17 -5
- package/dist/types/events.d.ts +4 -4
- package/dist/types/index.d.ts +1 -0
- package/dist/types/messageDelivery/MessageDeliveryReporter.d.ts +74 -0
- package/dist/types/messageDelivery/MessageReceiptsTracker.d.ts +121 -0
- package/dist/types/messageDelivery/index.d.ts +2 -0
- package/dist/types/types.d.ts +11 -4
- package/dist/types/utils.d.ts +2 -1
- package/package.json +1 -1
- package/src/channel.ts +73 -7
- package/src/channel_state.ts +147 -14
- package/src/client.ts +60 -14
- package/src/events.ts +4 -6
- package/src/index.ts +1 -0
- package/src/messageDelivery/MessageDeliveryReporter.ts +259 -0
- package/src/messageDelivery/MessageReceiptsTracker.ts +417 -0
- package/src/messageDelivery/index.ts +2 -0
- package/src/thread.ts +1 -1
- package/src/types.ts +13 -4
- package/src/utils.ts +2 -1
package/src/channel_state.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
908
|
-
targetMessageSetIndex =
|
|
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
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
931
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
3154
|
+
return this._deleteMessage(messageID, options);
|
|
3132
3155
|
}
|
|
3133
3156
|
|
|
3134
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4710
|
-
|
|
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
|
@@ -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
|
+
}
|