@pmate/chat-sdk 0.12.0-next.13 → 0.12.0-next.14

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,7 +96,13 @@ 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 publishCounts;
105
+ private watchCounts;
91
106
  getSnapshot(): ChatSummarySnapshot<T>;
92
107
  getSnapshot(threadId: string): ChatThreadSnapshot<T>;
93
108
  subscribe(listener: () => void): () => void;
@@ -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,105 @@ 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) {
208
+ this.assertOpen();
209
+ const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
192
210
  this.assertOpen();
193
- return this.readers.getReaders({ ...page, threadId, messageId }, signal);
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
+ publishCounts(value) {
261
+ const previous = this.countSnapshots.get(value.threadId);
262
+ if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
263
+ return;
264
+ this.countSnapshots.delete(value.threadId);
265
+ this.countSnapshots.set(value.threadId, value);
266
+ if (this.countSnapshots.size > 128) {
267
+ const oldest = this.countSnapshots.keys().next().value;
268
+ this.countSnapshots.delete(oldest);
269
+ this.publish(oldest, false);
270
+ }
271
+ this.publish(value.threadId, false);
272
+ }
273
+ watchCounts(threadId, ids, receiver = this.connection.identityScope().clientInstanceId) {
274
+ if (!threadId || !ids.length) {
275
+ this.countsThread = undefined;
276
+ this.counts?.watch(undefined, []);
277
+ return;
278
+ }
279
+ if (!receiver || receiver.length > 512 || /[\x00-\x1f]/.test(receiver))
280
+ throw new Error("INVALID_READ_COUNTS_RECEIVER");
281
+ if (!this.counts || this.countsReceiver !== receiver) {
282
+ if (this.counts) {
283
+ const closing = this.counts.close().finally(() => this.countClosings.delete(closing));
284
+ this.countClosings.add(closing);
285
+ }
286
+ const scope = this.options.messages.scope;
287
+ 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,
288
+ onChange: value => { if (!this.closed && this.counts === client)
289
+ this.publishCounts(value); },
290
+ onError: error => {
291
+ if (this.closed || this.counts !== client)
292
+ return;
293
+ if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
294
+ const threads = [...this.countSnapshots.keys()];
295
+ this.countSnapshots.clear();
296
+ threads.forEach(thread => this.publish(thread, false));
297
+ }
298
+ this.options.onError(error);
299
+ } });
300
+ this.counts = client;
301
+ this.countsReceiver = receiver;
302
+ }
303
+ this.countsThread = threadId;
304
+ this.counts.watch(threadId, ids);
305
+ }
200
306
  getSnapshot(threadId) {
201
307
  if (threadId === undefined) {
202
308
  if (!this.globalSnapshot)
@@ -207,7 +313,7 @@ export class ChatSession {
207
313
  if (!snapshot) {
208
314
  const messages = this.messages.getSnapshot(threadId);
209
315
  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 };
316
+ 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
317
  this.snapshots.set(threadId, snapshot);
212
318
  }
213
319
  return snapshot;
@@ -277,13 +383,15 @@ export class ChatSession {
277
383
  return this.closing ??= (async () => {
278
384
  this.closed = true;
279
385
  this.catchUp.close();
386
+ this.countTasks.close();
280
387
  this.off.splice(0).forEach(off => off());
281
388
  if (!this.options.sharedConnection)
282
389
  this.connection.close();
283
- await Promise.all([this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
390
+ await Promise.all([this.counts?.close(), ...this.countClosings, this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
284
391
  this.listeners.clear();
285
392
  this.globalListeners.clear();
286
393
  this.snapshots.clear();
394
+ this.countSnapshots.clear();
287
395
  this.historyEdges.clear();
288
396
  this.earlierReads.clear();
289
397
  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.14",
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",