@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"): Promise<import("./readIndex.js").ReadIndexState>;
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
- return this.readers.getReaders({ ...page, threadId, messageId }, signal);
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 notify = listener;
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;
@@ -8,6 +8,7 @@ export declare class IndexedDbResponseCache<T> {
8
8
  private pending;
9
9
  private tasks;
10
10
  private running?;
11
+ private invalidations;
11
12
  private closed;
12
13
  private closing?;
13
14
  constructor(scope: string, options?: {
@@ -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
- async invalidate() {
44
+ invalidate() {
44
45
  if (this.closed)
45
- throw new Error("RESPONSE_CACHE_CLOSED");
46
- await this.db.transaction("rw", this.rows, this.epochs, async () => {
47
- const epoch = ((await this.epochs.get(this.scope))?.epoch ?? 0) + 1;
48
- await this.epochs.put({ scope: this.scope, epoch });
49
- await this.rows.where("scope").equals(this.scope).delete();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.13",
3
+ "version": "0.12.0-next.15",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",