@pmate/chat-sdk 0.12.0-next.13 → 0.12.0-next.15
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.
|
@@ -62,6 +62,7 @@ export declare class ChatClient {
|
|
|
62
62
|
profileId: string | undefined;
|
|
63
63
|
accountId: string | undefined;
|
|
64
64
|
teamId: string | undefined;
|
|
65
|
+
clientInstanceId: string;
|
|
65
66
|
};
|
|
66
67
|
watchConnection(handler: (state: ChatConnectionState) => void): () => void;
|
|
67
68
|
private setConnection;
|
|
@@ -123,7 +123,7 @@ export class ChatClient {
|
|
|
123
123
|
/** Non-secret identity used to validate a shared browser transport. */
|
|
124
124
|
identityScope() {
|
|
125
125
|
const identity = this.session?.identity ?? this.session?.session?.identity;
|
|
126
|
-
return { appId: this.options.appId, profileId: this.profileIdValue ?? this.options.profileId, accountId: identity?.accountId, teamId: this.options.teamId };
|
|
126
|
+
return { appId: this.options.appId, profileId: this.profileIdValue ?? this.options.profileId, accountId: identity?.accountId, teamId: this.options.teamId, clientInstanceId: this.instanceId };
|
|
127
127
|
}
|
|
128
128
|
watchConnection(handler) {
|
|
129
129
|
this.connectionHandlers.add(handler);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type ReadCountsResult } from "./readCounts.js";
|
|
2
|
+
import type { OnlineReachEvent } from "./reach.js";
|
|
1
3
|
import { type MessageReadersQuery } from "./messageReaders.js";
|
|
2
4
|
import { type MessageClientOptions, type MessageSnapshot } from "./messageClient.js";
|
|
3
5
|
import { DurableChatSender } from "./durableSender.js";
|
|
@@ -21,6 +23,7 @@ export type ChatThreadSnapshot<T> = {
|
|
|
21
23
|
hasEarlier: boolean;
|
|
22
24
|
resetVersion: number;
|
|
23
25
|
complete: boolean;
|
|
26
|
+
readCounts?: ReadCountsResult;
|
|
24
27
|
unread: ReturnType<typeof unreadInConversation> | undefined;
|
|
25
28
|
};
|
|
26
29
|
export type ChatSummarySnapshot<T> = {
|
|
@@ -41,6 +44,12 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
41
44
|
private connection;
|
|
42
45
|
private reading;
|
|
43
46
|
private readers;
|
|
47
|
+
private counts?;
|
|
48
|
+
private countsReceiver?;
|
|
49
|
+
private countsThread?;
|
|
50
|
+
private countSnapshots;
|
|
51
|
+
private countClosings;
|
|
52
|
+
private countTasks;
|
|
44
53
|
private sender;
|
|
45
54
|
private listeners;
|
|
46
55
|
private snapshots;
|
|
@@ -87,11 +96,18 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
87
96
|
}): Promise<ChatRuntimeMessage>;
|
|
88
97
|
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
89
98
|
retryMessage(messageId: string): Promise<ChatRuntimeMessage>;
|
|
90
|
-
observeMessages(threadId: string, ids: string[], event: "visible" | "played"):
|
|
99
|
+
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): ReturnType<ReadingClient["recordVisible"]>;
|
|
100
|
+
observeMessages(threadId: string | undefined, ids: string[], event: "receipts", clientInstanceId?: string): Promise<void>;
|
|
101
|
+
/** Bridge transports can delegate social events without maintaining clients. */
|
|
102
|
+
handleReach(event: OnlineReachEvent): Promise<"handled" | undefined>;
|
|
103
|
+
private wakeCounts;
|
|
104
|
+
private publishCountSnapshot;
|
|
105
|
+
private publishCounts;
|
|
106
|
+
private watchCounts;
|
|
91
107
|
getSnapshot(): ChatSummarySnapshot<T>;
|
|
92
108
|
getSnapshot(threadId: string): ChatThreadSnapshot<T>;
|
|
93
109
|
subscribe(listener: () => void): () => void;
|
|
94
|
-
subscribe(threadId: string, listener: () => void): () => void;
|
|
110
|
+
subscribe(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
|
|
95
111
|
private publishAll;
|
|
96
112
|
private buildGlobal;
|
|
97
113
|
private publishGlobal;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ReadCountsClient } from "./readCounts.js";
|
|
1
2
|
import { WindowedTasks } from "./coalescer.js";
|
|
2
3
|
import { MessageReadersClient } from "./messageReaders.js";
|
|
3
4
|
import { MessageClient } from "./messageClient.js";
|
|
@@ -12,6 +13,12 @@ export class ChatSession {
|
|
|
12
13
|
connection;
|
|
13
14
|
reading;
|
|
14
15
|
readers;
|
|
16
|
+
counts;
|
|
17
|
+
countsReceiver;
|
|
18
|
+
countsThread;
|
|
19
|
+
countSnapshots = new Map();
|
|
20
|
+
countClosings = new Set();
|
|
21
|
+
countTasks;
|
|
15
22
|
sender;
|
|
16
23
|
listeners = new Map();
|
|
17
24
|
snapshots = new Map();
|
|
@@ -44,6 +51,14 @@ export class ChatSession {
|
|
|
44
51
|
this.sender = new DurableChatSender(this.connection, scope, options.onError);
|
|
45
52
|
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
46
53
|
options.onError(error); });
|
|
54
|
+
this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
|
|
55
|
+
options.onError(error); });
|
|
56
|
+
if (typeof window !== "undefined") {
|
|
57
|
+
const wake = () => this.wakeCounts();
|
|
58
|
+
window.addEventListener("online", wake);
|
|
59
|
+
window.addEventListener("focus", wake);
|
|
60
|
+
this.off.push(() => { window.removeEventListener("online", wake); window.removeEventListener("focus", wake); });
|
|
61
|
+
}
|
|
47
62
|
this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
|
|
48
63
|
this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
|
|
49
64
|
this.off.push(this.connection.watchMessages(message => {
|
|
@@ -67,6 +82,7 @@ export class ChatSession {
|
|
|
67
82
|
this.catchUp.schedule("scope", () => this.messages.restore(since, options.activeThreadId));
|
|
68
83
|
if (this.readingStarted)
|
|
69
84
|
this.reading.refresh();
|
|
85
|
+
this.wakeCounts();
|
|
70
86
|
}
|
|
71
87
|
}));
|
|
72
88
|
}
|
|
@@ -188,15 +204,115 @@ export class ChatSession {
|
|
|
188
204
|
this.assertOpen();
|
|
189
205
|
return this.sender.enqueue(threadId, input);
|
|
190
206
|
}
|
|
191
|
-
getReaders(threadId, messageId, page = {}, signal) {
|
|
207
|
+
async getReaders(threadId, messageId, page = {}, signal) {
|
|
192
208
|
this.assertOpen();
|
|
193
|
-
|
|
209
|
+
const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
|
|
210
|
+
this.assertOpen();
|
|
211
|
+
if (!result.resetRequired) {
|
|
212
|
+
const previous = this.countSnapshots.get(threadId);
|
|
213
|
+
if (!previous || previous.revision <= result.revision) {
|
|
214
|
+
this.publishCounts({ appId: result.appId, threadId, revision: result.revision, counts: { ...previous?.counts, [messageId]: result.total } });
|
|
215
|
+
this.counts?.changed(threadId, result.revision);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return result;
|
|
194
219
|
}
|
|
195
220
|
retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
|
|
196
|
-
observeMessages(threadId, ids, event) {
|
|
221
|
+
observeMessages(threadId, ids, event, clientInstanceId) {
|
|
222
|
+
if (this.closed && event === "receipts" && !threadId)
|
|
223
|
+
return Promise.resolve();
|
|
197
224
|
this.assertOpen();
|
|
225
|
+
if (event === "receipts") {
|
|
226
|
+
this.watchCounts(threadId, ids, clientInstanceId);
|
|
227
|
+
return Promise.resolve();
|
|
228
|
+
}
|
|
229
|
+
if (!threadId)
|
|
230
|
+
throw new Error("INVALID_READING_THREAD");
|
|
198
231
|
return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
|
|
199
232
|
}
|
|
233
|
+
/** Bridge transports can delegate social events without maintaining clients. */
|
|
234
|
+
async handleReach(event) {
|
|
235
|
+
this.assertOpen();
|
|
236
|
+
if (!["social.readCountsChanged", "social.readStateChanged"].includes(event.eventType))
|
|
237
|
+
return undefined;
|
|
238
|
+
if (event.appId !== this.options.messages.scope.appId)
|
|
239
|
+
throw new Error("UNEXPECTED_READING_APPLICATION");
|
|
240
|
+
if (event.eventType === "social.readCountsChanged") {
|
|
241
|
+
if (typeof event.payload.threadId !== "string" || !Number.isSafeInteger(event.payload.revision) || Number(event.payload.revision) < 0)
|
|
242
|
+
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
243
|
+
this.counts?.changed(event.payload.threadId, Number(event.payload.revision));
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
if (typeof event.payload.syncCursor !== "string" || !/^v1:\d+$/.test(event.payload.syncCursor) || typeof event.payload.profileId !== "string")
|
|
247
|
+
throw new Error("INVALID_READING_EVENT");
|
|
248
|
+
if (event.payload.profileId !== this.options.messages.scope.profileId)
|
|
249
|
+
throw new Error("Reading reach identity mismatch");
|
|
250
|
+
await this.initialize();
|
|
251
|
+
await this.reading.synchronize(event.payload.syncCursor, event.payload.profileId);
|
|
252
|
+
this.assertOpen();
|
|
253
|
+
}
|
|
254
|
+
return "handled";
|
|
255
|
+
}
|
|
256
|
+
wakeCounts() {
|
|
257
|
+
if (this.counts && this.countsThread && !this.closed)
|
|
258
|
+
this.countTasks.schedule("counts", async () => { this.counts?.reconnect(); });
|
|
259
|
+
}
|
|
260
|
+
publishCountSnapshot(thread) {
|
|
261
|
+
const previous = this.snapshots.get(thread);
|
|
262
|
+
if (!previous) {
|
|
263
|
+
this.publish(thread, false);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// Receipt-only updates never serialize or rebuild message bodies.
|
|
267
|
+
this.snapshots.set(thread, { ...previous, readCounts: this.countSnapshots.get(thread) });
|
|
268
|
+
this.listeners.get(thread)?.forEach(listener => listener());
|
|
269
|
+
}
|
|
270
|
+
publishCounts(value) {
|
|
271
|
+
const previous = this.countSnapshots.get(value.threadId);
|
|
272
|
+
if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
|
|
273
|
+
return;
|
|
274
|
+
this.countSnapshots.delete(value.threadId);
|
|
275
|
+
this.countSnapshots.set(value.threadId, value);
|
|
276
|
+
if (this.countSnapshots.size > 128) {
|
|
277
|
+
const oldest = this.countSnapshots.keys().next().value;
|
|
278
|
+
this.countSnapshots.delete(oldest);
|
|
279
|
+
this.publishCountSnapshot(oldest);
|
|
280
|
+
}
|
|
281
|
+
this.publishCountSnapshot(value.threadId);
|
|
282
|
+
}
|
|
283
|
+
watchCounts(threadId, ids, receiver = this.connection.identityScope().clientInstanceId) {
|
|
284
|
+
if (!threadId || !ids.length) {
|
|
285
|
+
this.countsThread = undefined;
|
|
286
|
+
this.counts?.watch(undefined, []);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (!receiver || receiver.length > 512 || /[\x00-\x1f]/.test(receiver))
|
|
290
|
+
throw new Error("INVALID_READ_COUNTS_RECEIVER");
|
|
291
|
+
if (!this.counts || this.countsReceiver !== receiver) {
|
|
292
|
+
if (this.counts) {
|
|
293
|
+
const closing = this.counts.close().finally(() => this.countClosings.delete(closing));
|
|
294
|
+
this.countClosings.add(closing);
|
|
295
|
+
}
|
|
296
|
+
const scope = this.options.messages.scope;
|
|
297
|
+
const client = new ReadCountsClient({ appId: scope.appId, profileId: scope.profileId, scope, token: this.options.reading.sessionToken, clientInstanceId: receiver, hubHttpUrl: this.options.reading.hubHttpUrl, fetch: this.options.reading.fetch,
|
|
298
|
+
onChange: value => { if (!this.closed && this.counts === client)
|
|
299
|
+
this.publishCounts(value); },
|
|
300
|
+
onError: error => {
|
|
301
|
+
if (this.closed || this.counts !== client)
|
|
302
|
+
return;
|
|
303
|
+
if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
|
|
304
|
+
const threads = [...this.countSnapshots.keys()];
|
|
305
|
+
this.countSnapshots.clear();
|
|
306
|
+
threads.forEach(thread => this.publishCountSnapshot(thread));
|
|
307
|
+
}
|
|
308
|
+
this.options.onError(error);
|
|
309
|
+
} });
|
|
310
|
+
this.counts = client;
|
|
311
|
+
this.countsReceiver = receiver;
|
|
312
|
+
}
|
|
313
|
+
this.countsThread = threadId;
|
|
314
|
+
this.counts.watch(threadId, ids);
|
|
315
|
+
}
|
|
200
316
|
getSnapshot(threadId) {
|
|
201
317
|
if (threadId === undefined) {
|
|
202
318
|
if (!this.globalSnapshot)
|
|
@@ -207,19 +323,28 @@ export class ChatSession {
|
|
|
207
323
|
if (!snapshot) {
|
|
208
324
|
const messages = this.messages.getSnapshot(threadId);
|
|
209
325
|
const state = this.reading.getSnapshot();
|
|
210
|
-
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : undefined };
|
|
326
|
+
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), readCounts: this.countSnapshots.get(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : undefined };
|
|
211
327
|
this.snapshots.set(threadId, snapshot);
|
|
212
328
|
}
|
|
213
329
|
return snapshot;
|
|
214
330
|
}
|
|
215
|
-
subscribe(target, listener) {
|
|
331
|
+
subscribe(target, listener, fields) {
|
|
216
332
|
this.assertOpen();
|
|
217
333
|
if (typeof target === "function") {
|
|
218
334
|
this.globalListeners.add(target);
|
|
219
335
|
return () => { this.globalListeners.delete(target); };
|
|
220
336
|
}
|
|
221
337
|
const threadId = target;
|
|
222
|
-
const
|
|
338
|
+
const selected = fields ? [...fields] : undefined;
|
|
339
|
+
let values = selected?.map(field => this.getSnapshot(threadId)[field]);
|
|
340
|
+
const equal = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]));
|
|
341
|
+
const notify = selected ? () => {
|
|
342
|
+
const next = selected.map(field => this.getSnapshot(threadId)[field]);
|
|
343
|
+
const changed = next.some((value, index) => !equal(value, values[index]));
|
|
344
|
+
values = next;
|
|
345
|
+
if (changed)
|
|
346
|
+
listener();
|
|
347
|
+
} : listener;
|
|
223
348
|
let listeners = this.listeners.get(threadId);
|
|
224
349
|
if (!listeners) {
|
|
225
350
|
listeners = new Set();
|
|
@@ -277,13 +402,15 @@ export class ChatSession {
|
|
|
277
402
|
return this.closing ??= (async () => {
|
|
278
403
|
this.closed = true;
|
|
279
404
|
this.catchUp.close();
|
|
405
|
+
this.countTasks.close();
|
|
280
406
|
this.off.splice(0).forEach(off => off());
|
|
281
407
|
if (!this.options.sharedConnection)
|
|
282
408
|
this.connection.close();
|
|
283
|
-
await Promise.all([this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
|
|
409
|
+
await Promise.all([this.counts?.close(), ...this.countClosings, this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
|
|
284
410
|
this.listeners.clear();
|
|
285
411
|
this.globalListeners.clear();
|
|
286
412
|
this.snapshots.clear();
|
|
413
|
+
this.countSnapshots.clear();
|
|
287
414
|
this.historyEdges.clear();
|
|
288
415
|
this.earlierReads.clear();
|
|
289
416
|
this.globalSnapshot = undefined;
|
|
@@ -10,6 +10,7 @@ export class IndexedDbResponseCache {
|
|
|
10
10
|
pending = [];
|
|
11
11
|
tasks = new WindowedTasks(16, () => { });
|
|
12
12
|
running;
|
|
13
|
+
invalidations = new Set();
|
|
13
14
|
closed = false;
|
|
14
15
|
closing;
|
|
15
16
|
constructor(scope, options = {}) {
|
|
@@ -40,14 +41,18 @@ export class IndexedDbResponseCache {
|
|
|
40
41
|
this.tasks.schedule("write", () => this.flush());
|
|
41
42
|
return promise;
|
|
42
43
|
}
|
|
43
|
-
|
|
44
|
+
invalidate() {
|
|
44
45
|
if (this.closed)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
|
|
47
|
+
const task = (async () => {
|
|
48
|
+
await this.db.transaction("rw", this.rows, this.epochs, async () => {
|
|
49
|
+
const epoch = ((await this.epochs.get(this.scope))?.epoch ?? 0) + 1;
|
|
50
|
+
await this.epochs.put({ scope: this.scope, epoch });
|
|
51
|
+
await this.rows.where("scope").equals(this.scope).delete();
|
|
52
|
+
});
|
|
53
|
+
})();
|
|
54
|
+
this.invalidations.add(task);
|
|
55
|
+
return task.finally(() => this.invalidations.delete(task));
|
|
51
56
|
}
|
|
52
57
|
async flush() {
|
|
53
58
|
if (this.running) {
|
|
@@ -96,7 +101,7 @@ export class IndexedDbResponseCache {
|
|
|
96
101
|
this.tasks.close();
|
|
97
102
|
return this.closing = (async () => {
|
|
98
103
|
try {
|
|
99
|
-
await this.flush();
|
|
104
|
+
await Promise.all([this.flush(), ...this.invalidations]);
|
|
100
105
|
}
|
|
101
106
|
finally {
|
|
102
107
|
this.db.close();
|
package/package.json
CHANGED