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/dist/cjs/index.browser.js +674 -42
- package/dist/cjs/index.browser.js.map +4 -4
- package/dist/cjs/index.node.js +676 -42
- package/dist/cjs/index.node.js.map +4 -4
- package/dist/esm/index.mjs +674 -42
- package/dist/esm/index.mjs.map +4 -4
- package/dist/types/channel.d.ts +11 -2
- package/dist/types/channel_state.d.ts +8 -0
- package/dist/types/client.d.ts +22 -3
- package/dist/types/events.d.ts +5 -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 +31 -4
- package/dist/types/utils.d.ts +2 -1
- package/package.json +1 -1
- package/src/channel.ts +77 -7
- package/src/channel_state.ts +149 -14
- package/src/client.ts +73 -8
- package/src/events.ts +5 -6
- package/src/index.ts +1 -0
- package/src/messageComposer/messageComposer.ts +3 -6
- package/src/messageComposer/textComposer.ts +9 -3
- 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 +36 -5
- package/src/utils.ts +2 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import type { ReadResponse, UserResponse } from '../types';
|
|
2
|
+
|
|
3
|
+
type UserId = string;
|
|
4
|
+
type MessageId = string;
|
|
5
|
+
export type MsgRef = { timestampMs: number; msgId: MessageId };
|
|
6
|
+
export type OwnMessageReceiptsTrackerMessageLocator = (
|
|
7
|
+
timestampMs: number,
|
|
8
|
+
) => MsgRef | null;
|
|
9
|
+
export type UserProgress = {
|
|
10
|
+
user: UserResponse;
|
|
11
|
+
lastReadRef: MsgRef; // MIN_REF if none
|
|
12
|
+
lastDeliveredRef: MsgRef; // MIN_REF if none; always >= readRef
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// ---------- ordering utilities ----------
|
|
16
|
+
|
|
17
|
+
const MIN_REF: MsgRef = { timestampMs: Number.NEGATIVE_INFINITY, msgId: '' } as const;
|
|
18
|
+
|
|
19
|
+
const compareRefsAsc = (a: MsgRef, b: MsgRef) =>
|
|
20
|
+
a.timestampMs !== b.timestampMs ? a.timestampMs - b.timestampMs : 0;
|
|
21
|
+
|
|
22
|
+
const findIndex = <T>(arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => {
|
|
23
|
+
let lo = 0,
|
|
24
|
+
hi = arr.length;
|
|
25
|
+
while (lo < hi) {
|
|
26
|
+
const mid = (lo + hi) >>> 1;
|
|
27
|
+
if (compareRefsAsc(keyOf(arr[mid]), target) >= 0) hi = mid;
|
|
28
|
+
else lo = mid + 1;
|
|
29
|
+
}
|
|
30
|
+
return lo;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* For insertion after the last equal item. E.g. array [a] exists and b is being inserted -> we want [a,b], not [b,a].
|
|
35
|
+
* @param arr
|
|
36
|
+
* @param target
|
|
37
|
+
* @param keyOf
|
|
38
|
+
*/
|
|
39
|
+
const findUpperIndex = <T>(arr: T[], target: MsgRef, keyOf: (x: T) => MsgRef): number => {
|
|
40
|
+
let lo = 0,
|
|
41
|
+
hi = arr.length;
|
|
42
|
+
while (lo < hi) {
|
|
43
|
+
const mid = (lo + hi) >>> 1;
|
|
44
|
+
if (compareRefsAsc(keyOf(arr[mid]), target) > 0) hi = mid;
|
|
45
|
+
else lo = mid + 1;
|
|
46
|
+
}
|
|
47
|
+
return lo;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const insertByKey = (
|
|
51
|
+
arr: UserProgress[],
|
|
52
|
+
item: UserProgress,
|
|
53
|
+
keyOf: (x: UserProgress) => MsgRef,
|
|
54
|
+
) => arr.splice(findUpperIndex(arr, keyOf(item), keyOf), 0, item);
|
|
55
|
+
|
|
56
|
+
const removeByOldKey = (
|
|
57
|
+
arr: UserProgress[],
|
|
58
|
+
item: UserProgress,
|
|
59
|
+
oldKey: MsgRef,
|
|
60
|
+
keyOf: (x: UserProgress) => MsgRef,
|
|
61
|
+
) => {
|
|
62
|
+
// Find the plateau for oldKey, scan to match by user id
|
|
63
|
+
let i = findIndex(arr, oldKey, keyOf);
|
|
64
|
+
while (i < arr.length && compareRefsAsc(keyOf(arr[i]), oldKey) === 0) {
|
|
65
|
+
if (arr[i].user.id === item.user.id) {
|
|
66
|
+
arr.splice(i, 1);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type OwnMessageReceiptsTrackerOptions = {
|
|
74
|
+
locateMessage: OwnMessageReceiptsTrackerMessageLocator;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* MessageReceiptsTracker
|
|
79
|
+
* --------------------------------
|
|
80
|
+
* Tracks **other participants’** delivery/read progress toward **own (outgoing) messages**
|
|
81
|
+
* within a **single timeline** (one channel/thread).
|
|
82
|
+
*
|
|
83
|
+
* How it works
|
|
84
|
+
* ------------
|
|
85
|
+
* - Each user has a compact progress record:
|
|
86
|
+
* - `lastReadRef`: latest message they have **read**
|
|
87
|
+
* - `lastDeliveredRef`: latest message they have **received** (always `>= lastReadRef`)
|
|
88
|
+
* - Internally keeps two arrays sorted **ascending by timestamp**:
|
|
89
|
+
* - `readSorted` (by `lastReadRef`)
|
|
90
|
+
* - `deliveredSorted` (by `lastDeliveredRef`)
|
|
91
|
+
* - Queries like “who read message M?” become a **binary search + suffix slice**.
|
|
92
|
+
*
|
|
93
|
+
* Construction
|
|
94
|
+
* ------------
|
|
95
|
+
* `new MessageReceiptsTracker({locateMessage})`
|
|
96
|
+
* - `locateMessage(timestamp) => MsgRef | null` must resolve a message ref representation - `{ timestamp, msgId }`.
|
|
97
|
+
* - If `locateMessage` returns `null`, the event is ignored (message unknown locally).
|
|
98
|
+
*
|
|
99
|
+
* Event ingestion
|
|
100
|
+
* ---------------
|
|
101
|
+
* - `ingestInitial(rows: ReadResponse[])`: Builds initial state from server snapshot.
|
|
102
|
+
* If a user’s `last_read` is ahead of `last_delivered_at`, the tracker enforces
|
|
103
|
+
* the invariant `lastDeliveredRef >= lastReadRef`.
|
|
104
|
+
* - `onMessageRead(user, readAtISO)`:
|
|
105
|
+
* Advances the user’s read; also bumps delivered to match if needed.
|
|
106
|
+
* - `onMessageDelivered(user, deliveredAtISO)`:
|
|
107
|
+
* Advances the user’s delivered to `max(currentRead, deliveredAt)`.
|
|
108
|
+
*
|
|
109
|
+
* Queries
|
|
110
|
+
* -------
|
|
111
|
+
* - `readersForMessage(msgRef) : UserResponse[]` → users with `lastReadRef >= msgRef`
|
|
112
|
+
* - `deliveredForMessage(msgRef) : UserResponse[]` → users with `lastDeliveredRef >= msgRef`
|
|
113
|
+
* - `deliveredNotReadForMessage(msgRef): UserResponse[]` → delivered but `lastReadRef < msgRef`
|
|
114
|
+
* - `usersWhoseLastReadIs : UserResponse[]` → users for whom `msgRef` is their *last read* (exact match)
|
|
115
|
+
* - `usersWhoseLastDeliveredIs : UserResponse[]` → users for whom `msgRef` is their *last delivered* (exact match)
|
|
116
|
+
* - `groupUsersByLastReadMessage : Record<MsgId, UserResponse[]> → mapping of messages to their readers
|
|
117
|
+
* - `groupUsersByLastDeliveredMessage : Record<MsgId, UserResponse[]> → mapping of messages to their receivers
|
|
118
|
+
* - `hasUserRead(msgRef, userId) : boolean`
|
|
119
|
+
* - `hasUserDelivered(msgRef, userId) : boolean`
|
|
120
|
+
*
|
|
121
|
+
* Complexity
|
|
122
|
+
* ----------
|
|
123
|
+
* - Update on read/delivered: **O(log U)** (binary search + one splice) per event, where U is count of users stored by tracker.
|
|
124
|
+
* - Query lists: **O(log U + K)** where `K` is the number of returned users (suffix length).
|
|
125
|
+
* - Memory: **O(U)** - tracker’s memory grows linearly with the number of users in the channel/thread and does not depend on the number of messages.
|
|
126
|
+
*
|
|
127
|
+
* Scope & notes
|
|
128
|
+
* -------------
|
|
129
|
+
* - One tracker instance is **scoped to a single timeline**. Instantiate per channel/thread.
|
|
130
|
+
* - Ordering is by **ascending timestamp**; ties are kept stable by inserting at the end of the
|
|
131
|
+
* equal-timestamp plateau (upper-bound insertion), preserving intuitive arrival order.
|
|
132
|
+
* - This tracker models **others’ progress toward own messages**;
|
|
133
|
+
*/
|
|
134
|
+
export class MessageReceiptsTracker {
|
|
135
|
+
private byUser = new Map<UserId, UserProgress>();
|
|
136
|
+
private readSorted: UserProgress[] = []; // asc by lastReadRef
|
|
137
|
+
private deliveredSorted: UserProgress[] = []; // asc by lastDeliveredRef
|
|
138
|
+
private locateMessage: OwnMessageReceiptsTrackerMessageLocator;
|
|
139
|
+
|
|
140
|
+
constructor({ locateMessage }: OwnMessageReceiptsTrackerOptions) {
|
|
141
|
+
this.locateMessage = locateMessage;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Build initial state from server snapshots (single pass + sort). */
|
|
145
|
+
ingestInitial(responses: ReadResponse[]) {
|
|
146
|
+
this.byUser.clear();
|
|
147
|
+
this.readSorted = [];
|
|
148
|
+
this.deliveredSorted = [];
|
|
149
|
+
for (const r of responses) {
|
|
150
|
+
const lastReadTimestamp = r.last_read ? new Date(r.last_read).getTime() : null;
|
|
151
|
+
const lastDeliveredTimestamp = r.last_delivered_at
|
|
152
|
+
? new Date(r.last_delivered_at).getTime()
|
|
153
|
+
: null;
|
|
154
|
+
const lastReadRef = lastReadTimestamp
|
|
155
|
+
? (this.locateMessage(lastReadTimestamp) ?? MIN_REF)
|
|
156
|
+
: MIN_REF;
|
|
157
|
+
let lastDeliveredRef = lastDeliveredTimestamp
|
|
158
|
+
? (this.locateMessage(lastDeliveredTimestamp) ?? MIN_REF)
|
|
159
|
+
: MIN_REF;
|
|
160
|
+
const isReadAfterDelivered = compareRefsAsc(lastDeliveredRef, lastReadRef) < 0;
|
|
161
|
+
if (isReadAfterDelivered) lastDeliveredRef = lastReadRef;
|
|
162
|
+
|
|
163
|
+
const userProgress: UserProgress = { user: r.user, lastReadRef, lastDeliveredRef };
|
|
164
|
+
this.byUser.set(r.user.id, userProgress);
|
|
165
|
+
this.readSorted.splice(
|
|
166
|
+
findIndex(this.readSorted, lastReadRef, (up) => up.lastReadRef),
|
|
167
|
+
0,
|
|
168
|
+
userProgress,
|
|
169
|
+
);
|
|
170
|
+
this.deliveredSorted.splice(
|
|
171
|
+
findIndex(this.deliveredSorted, lastDeliveredRef, (up) => up.lastDeliveredRef),
|
|
172
|
+
0,
|
|
173
|
+
userProgress,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** message.delivered — user device confirmed delivery up to and including messageId. */
|
|
179
|
+
onMessageDelivered({
|
|
180
|
+
user,
|
|
181
|
+
deliveredAt,
|
|
182
|
+
lastDeliveredMessageId,
|
|
183
|
+
}: {
|
|
184
|
+
user: UserResponse;
|
|
185
|
+
deliveredAt: string;
|
|
186
|
+
lastDeliveredMessageId?: string;
|
|
187
|
+
}) {
|
|
188
|
+
const timestampMs = new Date(deliveredAt).getTime();
|
|
189
|
+
const msgRef = lastDeliveredMessageId
|
|
190
|
+
? { timestampMs, msgId: lastDeliveredMessageId }
|
|
191
|
+
: this.locateMessage(new Date(deliveredAt).getTime());
|
|
192
|
+
if (!msgRef) return;
|
|
193
|
+
const userProgress = this.ensureUser(user);
|
|
194
|
+
|
|
195
|
+
const newDelivered =
|
|
196
|
+
compareRefsAsc(msgRef, userProgress.lastReadRef) < 0
|
|
197
|
+
? userProgress.lastReadRef
|
|
198
|
+
: msgRef; // max(read, loc)
|
|
199
|
+
// newly announced delivered is older than or equal what is already registered
|
|
200
|
+
if (compareRefsAsc(newDelivered, userProgress.lastDeliveredRef) <= 0) return;
|
|
201
|
+
|
|
202
|
+
removeByOldKey(
|
|
203
|
+
this.deliveredSorted,
|
|
204
|
+
userProgress,
|
|
205
|
+
userProgress.lastDeliveredRef,
|
|
206
|
+
(x) => x.lastDeliveredRef,
|
|
207
|
+
);
|
|
208
|
+
userProgress.lastDeliveredRef = newDelivered;
|
|
209
|
+
insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** message.read — user read up to and including messageId. */
|
|
213
|
+
onMessageRead({
|
|
214
|
+
user,
|
|
215
|
+
readAt,
|
|
216
|
+
lastReadMessageId,
|
|
217
|
+
}: {
|
|
218
|
+
user: UserResponse;
|
|
219
|
+
readAt: string;
|
|
220
|
+
lastReadMessageId?: string;
|
|
221
|
+
}) {
|
|
222
|
+
const timestampMs = new Date(readAt).getTime();
|
|
223
|
+
const msgRef = lastReadMessageId
|
|
224
|
+
? { timestampMs, msgId: lastReadMessageId }
|
|
225
|
+
: this.locateMessage(timestampMs);
|
|
226
|
+
if (!msgRef) return;
|
|
227
|
+
const userProgress = this.ensureUser(user);
|
|
228
|
+
// newly announced read message is older than or equal the already recorded last read message
|
|
229
|
+
if (compareRefsAsc(msgRef, userProgress.lastReadRef) <= 0) return;
|
|
230
|
+
|
|
231
|
+
// move in readSorted
|
|
232
|
+
removeByOldKey(
|
|
233
|
+
this.readSorted,
|
|
234
|
+
userProgress,
|
|
235
|
+
userProgress.lastReadRef,
|
|
236
|
+
(x) => x.lastReadRef,
|
|
237
|
+
);
|
|
238
|
+
userProgress.lastReadRef = msgRef;
|
|
239
|
+
insertByKey(this.readSorted, userProgress, (x) => x.lastReadRef);
|
|
240
|
+
|
|
241
|
+
// keep delivered >= read
|
|
242
|
+
if (compareRefsAsc(userProgress.lastDeliveredRef, userProgress.lastReadRef) < 0) {
|
|
243
|
+
removeByOldKey(
|
|
244
|
+
this.deliveredSorted,
|
|
245
|
+
userProgress,
|
|
246
|
+
userProgress.lastDeliveredRef,
|
|
247
|
+
(x) => x.lastDeliveredRef,
|
|
248
|
+
);
|
|
249
|
+
userProgress.lastDeliveredRef = userProgress.lastReadRef;
|
|
250
|
+
insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** notification.mark_unread — user marked messages unread starting at `first_unread_message_id`.
|
|
255
|
+
* Sets lastReadRef to the event’s last_read_* values. Delivery never moves backward.
|
|
256
|
+
* The event is sent only to the user that triggered the action (own user), so we will never adjust read ref
|
|
257
|
+
* for other users - we will not see changes in the UI for other users. However, this implementation does not
|
|
258
|
+
* take into consideration this fact and is ready to handle the mark-unread event for any user.
|
|
259
|
+
*/
|
|
260
|
+
onNotificationMarkUnread({
|
|
261
|
+
user,
|
|
262
|
+
lastReadAt,
|
|
263
|
+
lastReadMessageId,
|
|
264
|
+
}: {
|
|
265
|
+
user: UserResponse;
|
|
266
|
+
lastReadAt?: string;
|
|
267
|
+
lastReadMessageId?: string;
|
|
268
|
+
}) {
|
|
269
|
+
const userProgress = this.ensureUser(user);
|
|
270
|
+
|
|
271
|
+
const newReadRef: MsgRef = lastReadAt
|
|
272
|
+
? { timestampMs: new Date(lastReadAt).getTime(), msgId: lastReadMessageId ?? '' }
|
|
273
|
+
: { ...MIN_REF };
|
|
274
|
+
|
|
275
|
+
// If no change, exit early.
|
|
276
|
+
if (
|
|
277
|
+
compareRefsAsc(newReadRef, userProgress.lastReadRef) === 0 &&
|
|
278
|
+
newReadRef.msgId === userProgress.lastReadRef.msgId
|
|
279
|
+
) {
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
removeByOldKey(
|
|
284
|
+
this.readSorted,
|
|
285
|
+
userProgress,
|
|
286
|
+
userProgress.lastReadRef,
|
|
287
|
+
(x) => x.lastReadRef,
|
|
288
|
+
);
|
|
289
|
+
userProgress.lastReadRef = newReadRef;
|
|
290
|
+
insertByKey(this.readSorted, userProgress, (x) => x.lastReadRef);
|
|
291
|
+
|
|
292
|
+
// Maintain invariant delivered >= read.
|
|
293
|
+
if (compareRefsAsc(userProgress.lastDeliveredRef, userProgress.lastReadRef) < 0) {
|
|
294
|
+
removeByOldKey(
|
|
295
|
+
this.deliveredSorted,
|
|
296
|
+
userProgress,
|
|
297
|
+
userProgress.lastDeliveredRef,
|
|
298
|
+
(x) => x.lastDeliveredRef,
|
|
299
|
+
);
|
|
300
|
+
userProgress.lastDeliveredRef = userProgress.lastReadRef;
|
|
301
|
+
insertByKey(this.deliveredSorted, userProgress, (x) => x.lastDeliveredRef);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** All users who READ this message. */
|
|
306
|
+
readersForMessage(msgRef: MsgRef): UserResponse[] {
|
|
307
|
+
const index = findIndex(this.readSorted, msgRef, ({ lastReadRef }) => lastReadRef);
|
|
308
|
+
return this.readSorted.slice(index).map((x) => x.user);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** All users who have it DELIVERED (includes readers). */
|
|
312
|
+
deliveredForMessage(msgRef: MsgRef): UserResponse[] {
|
|
313
|
+
const pos = findIndex(
|
|
314
|
+
this.deliveredSorted,
|
|
315
|
+
msgRef,
|
|
316
|
+
({ lastDeliveredRef }) => lastDeliveredRef,
|
|
317
|
+
);
|
|
318
|
+
return this.deliveredSorted.slice(pos).map((x) => x.user);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Users who delivered but have NOT read. */
|
|
322
|
+
deliveredNotReadForMessage(msgRef: MsgRef): UserResponse[] {
|
|
323
|
+
const pos = findIndex(
|
|
324
|
+
this.deliveredSorted,
|
|
325
|
+
msgRef,
|
|
326
|
+
({ lastDeliveredRef }) => lastDeliveredRef,
|
|
327
|
+
);
|
|
328
|
+
const usersDeliveredNotRead: UserResponse[] = [];
|
|
329
|
+
for (let i = pos; i < this.deliveredSorted.length; i++) {
|
|
330
|
+
const userProgress = this.deliveredSorted[i];
|
|
331
|
+
if (compareRefsAsc(userProgress.lastReadRef, msgRef) < 0)
|
|
332
|
+
usersDeliveredNotRead.push(userProgress.user);
|
|
333
|
+
}
|
|
334
|
+
return usersDeliveredNotRead;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Users for whom `msgRef` is their *last read* (exact match). */
|
|
338
|
+
usersWhoseLastReadIs(msgRef: MsgRef): UserResponse[] {
|
|
339
|
+
if (!msgRef.msgId) return [];
|
|
340
|
+
const start = findIndex(this.readSorted, msgRef, (x) => x.lastReadRef);
|
|
341
|
+
const end = findUpperIndex(this.readSorted, msgRef, (x) => x.lastReadRef);
|
|
342
|
+
const users: UserResponse[] = [];
|
|
343
|
+
for (let i = start; i < end; i++) {
|
|
344
|
+
const up = this.readSorted[i];
|
|
345
|
+
if (up.lastReadRef.msgId === msgRef.msgId) users.push(up.user);
|
|
346
|
+
}
|
|
347
|
+
return users;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Users for whom `msgRef` is their *last delivered* (exact match). */
|
|
351
|
+
usersWhoseLastDeliveredIs(msgRef: MsgRef): UserResponse[] {
|
|
352
|
+
if (!msgRef.msgId) return [];
|
|
353
|
+
const start = findIndex(this.deliveredSorted, msgRef, (x) => x.lastDeliveredRef);
|
|
354
|
+
const end = findUpperIndex(this.deliveredSorted, msgRef, (x) => x.lastDeliveredRef);
|
|
355
|
+
const users: UserResponse[] = [];
|
|
356
|
+
for (let i = start; i < end; i++) {
|
|
357
|
+
const up = this.deliveredSorted[i];
|
|
358
|
+
if (up.lastDeliveredRef.msgId === msgRef.msgId) users.push(up.user);
|
|
359
|
+
}
|
|
360
|
+
return users;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---- queries: per-user status ----
|
|
364
|
+
|
|
365
|
+
hasUserRead(msgRef: MsgRef, userId: string): boolean {
|
|
366
|
+
const up = this.byUser.get(userId);
|
|
367
|
+
return !!up && compareRefsAsc(up.lastReadRef, msgRef) >= 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
hasUserDelivered(msgRef: MsgRef, userId: string): boolean {
|
|
371
|
+
const up = this.byUser.get(userId);
|
|
372
|
+
return !!up && compareRefsAsc(up.lastDeliveredRef, msgRef) >= 0;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
getUserProgress(userId: string): UserProgress | null {
|
|
376
|
+
const userProgress = this.byUser.get(userId);
|
|
377
|
+
if (!userProgress) return null;
|
|
378
|
+
return userProgress;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
groupUsersByLastReadMessage(): Record<MessageId, UserResponse[]> {
|
|
382
|
+
return Array.from(this.byUser.values()).reduce<Record<MessageId, UserResponse[]>>(
|
|
383
|
+
(acc, userProgress) => {
|
|
384
|
+
const msgId = userProgress.lastReadRef.msgId;
|
|
385
|
+
if (!msgId) return acc;
|
|
386
|
+
if (!acc[msgId]) acc[msgId] = [];
|
|
387
|
+
acc[msgId].push(userProgress.user);
|
|
388
|
+
return acc;
|
|
389
|
+
},
|
|
390
|
+
{},
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
groupUsersByLastDeliveredMessage(): Record<MessageId, UserResponse[]> {
|
|
395
|
+
return Array.from(this.byUser.values()).reduce<Record<MessageId, UserResponse[]>>(
|
|
396
|
+
(acc, userProgress) => {
|
|
397
|
+
const msgId = userProgress.lastDeliveredRef.msgId;
|
|
398
|
+
if (!msgId) return acc;
|
|
399
|
+
if (!acc[msgId]) acc[msgId] = [];
|
|
400
|
+
acc[msgId].push(userProgress.user);
|
|
401
|
+
return acc;
|
|
402
|
+
},
|
|
403
|
+
{},
|
|
404
|
+
);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private ensureUser(user: UserResponse): UserProgress {
|
|
408
|
+
let up = this.byUser.get(user.id);
|
|
409
|
+
if (!up) {
|
|
410
|
+
up = { user, lastReadRef: MIN_REF, lastDeliveredRef: MIN_REF };
|
|
411
|
+
this.byUser.set(user.id, up);
|
|
412
|
+
insertByKey(this.readSorted, up, (x) => x.lastReadRef);
|
|
413
|
+
insertByKey(this.deliveredSorted, up, (x) => x.lastDeliveredRef);
|
|
414
|
+
}
|
|
415
|
+
return up;
|
|
416
|
+
}
|
|
417
|
+
}
|
package/src/thread.ts
CHANGED
|
@@ -534,7 +534,7 @@ export class Thread extends WithSubscriptions {
|
|
|
534
534
|
return null;
|
|
535
535
|
}
|
|
536
536
|
|
|
537
|
-
return await this.
|
|
537
|
+
return await this.client.messageDeliveryReporter.markRead(this);
|
|
538
538
|
};
|
|
539
539
|
|
|
540
540
|
private throttledMarkAsRead = throttle(
|