@pmate/chat-sdk 0.12.0-next.5 → 0.12.0-next.50

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.
Files changed (52) hide show
  1. package/README.md +34 -1
  2. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  3. package/lib/runtime/batchedReadIndex.js +50 -4
  4. package/lib/runtime/cachedMessage.d.ts +12 -0
  5. package/lib/runtime/cachedMessage.js +18 -0
  6. package/lib/runtime/chatClient.d.ts +1 -0
  7. package/lib/runtime/chatClient.js +1 -1
  8. package/lib/runtime/chatSession.d.ts +77 -13
  9. package/lib/runtime/chatSession.js +483 -42
  10. package/lib/runtime/connectivity.d.ts +4 -0
  11. package/lib/runtime/connectivity.js +8 -0
  12. package/lib/runtime/conversationRead.d.ts +13 -0
  13. package/lib/runtime/conversationRead.js +34 -0
  14. package/lib/runtime/conversationReading.d.ts +28 -0
  15. package/lib/runtime/conversationReading.js +47 -0
  16. package/lib/runtime/createChat.d.ts +5 -5
  17. package/lib/runtime/createChat.js +16 -17
  18. package/lib/runtime/durableSender.d.ts +54 -14
  19. package/lib/runtime/durableSender.js +354 -44
  20. package/lib/runtime/index.d.ts +4 -0
  21. package/lib/runtime/index.js +4 -0
  22. package/lib/runtime/messageClient.d.ts +32 -8
  23. package/lib/runtime/messageClient.js +415 -75
  24. package/lib/runtime/messageControls.d.ts +19 -0
  25. package/lib/runtime/messageControls.js +44 -0
  26. package/lib/runtime/messageLaunch.d.ts +42 -0
  27. package/lib/runtime/messageLaunch.js +133 -0
  28. package/lib/runtime/messageQuotes.d.ts +3 -0
  29. package/lib/runtime/messageQuotes.js +17 -0
  30. package/lib/runtime/messageReaders.d.ts +12 -1
  31. package/lib/runtime/messageReaders.js +67 -3
  32. package/lib/runtime/messageStore.d.ts +40 -2
  33. package/lib/runtime/messageStore.js +218 -10
  34. package/lib/runtime/messageTimeline.d.ts +50 -0
  35. package/lib/runtime/messageTimeline.js +120 -0
  36. package/lib/runtime/readCounts.d.ts +15 -1
  37. package/lib/runtime/readCounts.js +96 -8
  38. package/lib/runtime/readIndex.d.ts +55 -6
  39. package/lib/runtime/readIndex.js +363 -14
  40. package/lib/runtime/readSync.d.ts +1 -0
  41. package/lib/runtime/readSync.js +25 -8
  42. package/lib/runtime/readingClient.d.ts +19 -0
  43. package/lib/runtime/readingClient.js +131 -19
  44. package/lib/runtime/readingContent.d.ts +5 -0
  45. package/lib/runtime/readingContent.js +23 -0
  46. package/lib/runtime/recordCache.d.ts +9 -0
  47. package/lib/runtime/recordCache.js +62 -5
  48. package/lib/runtime/responseCache.d.ts +44 -0
  49. package/lib/runtime/responseCache.js +173 -0
  50. package/lib/runtime/voiceUpload.d.ts +2 -0
  51. package/lib/runtime/voiceUpload.js +20 -0
  52. package/package.json +6 -1
@@ -1,3 +1,7 @@
1
+ import { projectMessageTimeline } from "./messageTimeline.js";
2
+ import { projectConversationReading } from "./conversationReading.js";
3
+ import { ReadCountsClient } from "./readCounts.js";
4
+ import { WindowedTasks } from "./coalescer.js";
1
5
  import { MessageReadersClient } from "./messageReaders.js";
2
6
  import { MessageClient } from "./messageClient.js";
3
7
  import { DurableChatSender } from "./durableSender.js";
@@ -11,15 +15,38 @@ export class ChatSession {
11
15
  connection;
12
16
  reading;
13
17
  readers;
18
+ counts;
19
+ countsReceiver;
20
+ countsThread;
21
+ countSnapshots = new Map();
22
+ countClosings = new Set();
23
+ countTasks;
14
24
  sender;
15
25
  listeners = new Map();
16
26
  snapshots = new Map();
27
+ timelineDecoded = new WeakMap();
28
+ decodeTimeline = (message) => { if (!this.timelineDecoded.has(message))
29
+ this.timelineDecoded.set(message, this.options.decode(message)); return this.timelineDecoded.get(message); };
30
+ historyEdges = new Map();
31
+ earlierReads = new Map();
17
32
  off = [];
18
33
  globalListeners = new Set();
19
34
  globalSnapshot;
35
+ closedThread = { messages: [], timeline: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
36
+ closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
20
37
  connectionState = "closed";
38
+ catchUp;
39
+ readingAllowed = false;
40
+ readingListeners = new Set();
41
+ acceptedReading = new Set();
42
+ readingAccess;
43
+ readingStarted = false;
44
+ readingStarting;
45
+ withdrawn = false;
46
+ revoking;
21
47
  closed = false;
22
48
  starting;
49
+ initializing;
23
50
  closing;
24
51
  constructor(options) {
25
52
  this.options = options;
@@ -32,16 +59,56 @@ export class ChatSession {
32
59
  throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
33
60
  }
34
61
  this.messages = new MessageClient(options.messages);
35
- this.connection = options.sharedConnection ?? new ChatClient(options.connection);
36
- this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
37
- this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
38
- this.sender = new DurableChatSender(this.connection, scope, options.onError);
39
- this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
62
+ this.connection = options.sharedConnection ?? new ChatClient({ ...options.connection, onReach: async (event) => {
63
+ const handled = await this.handleReach(event);
64
+ if (handled)
65
+ return handled;
66
+ if (options.connection.onReach)
67
+ return options.connection.onReach(event);
68
+ throw new Error("REACH_HANDLER_UNAVAILABLE");
69
+ } });
70
+ this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
71
+ this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, access: () => this.messages.scopeAccess(), onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
72
+ this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice });
73
+ this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
74
+ options.onError(error); });
75
+ this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
76
+ options.onError(error); });
77
+ if (typeof window !== "undefined") {
78
+ const wake = () => {
79
+ if (typeof document !== "undefined" && document.hidden)
80
+ return;
81
+ this.wakeCounts();
82
+ this.scheduleCatchUp();
83
+ };
84
+ for (const type of ["online", "focus", "pageshow"])
85
+ window.addEventListener(type, wake);
86
+ if (typeof document !== "undefined")
87
+ document.addEventListener("visibilitychange", wake);
88
+ this.off.push(() => {
89
+ for (const type of ["online", "focus", "pageshow"])
90
+ window.removeEventListener(type, wake);
91
+ if (typeof document !== "undefined")
92
+ document.removeEventListener("visibilitychange", wake);
93
+ });
94
+ }
95
+ this.off.push(this.messages.subscribeAccessRevoked(() => { if (!this.withdrawn && !this.closed)
96
+ void this.revokeAccess().catch(error => options.onError(error)); }));
97
+ this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
98
+ this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => { this.publishAll(); if (this.readingAllowed)
99
+ this.readingListeners.forEach(listener => listener()); }));
40
100
  this.off.push(this.connection.watchMessages(message => {
41
101
  try {
42
102
  const decoded = options.decode(message);
43
103
  if (decoded)
44
- void this.messages.receive(decoded.threadId, [decoded]).then(() => { this.publish(decoded.threadId); return this.sender.confirm([message.hash]); }).catch(options.onError);
104
+ void this.messages.receive(decoded.threadId, [decoded]).then(async () => {
105
+ if (!this.closed && this.readingStarted)
106
+ this.reading.refresh();
107
+ await this.sender.confirm([message.hash]);
108
+ }).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
109
+ this.messages.invalidate(decoded.threadId);
110
+ else if (!this.closed)
111
+ options.onError(error); });
45
112
  }
46
113
  catch (error) {
47
114
  options.onError(error);
@@ -51,36 +118,178 @@ export class ChatSession {
51
118
  this.connectionState = state;
52
119
  this.publishGlobal();
53
120
  if (state === "connected") {
54
- for (const thread of this.listeners.keys())
55
- this.messages.invalidate(thread);
56
- this.reading.refresh();
121
+ this.scheduleCatchUp();
122
+ if (this.readingStarted)
123
+ this.reading.refresh();
124
+ this.wakeCounts();
57
125
  }
58
126
  }));
59
127
  }
128
+ scheduleCatchUp() {
129
+ if (this.connectionState !== "connected" || this.closed || this.withdrawn)
130
+ return;
131
+ const since = Date.now();
132
+ this.catchUp.schedule("scope", async () => {
133
+ if (this.connectionState === "connected" && !this.closed && !this.withdrawn)
134
+ await this.messages.restore(since, this.options.activeThreadId);
135
+ });
136
+ }
60
137
  start() {
61
138
  this.assertOpen();
62
139
  return this.starting ??= (async () => {
63
- await this.sender.initialize();
64
- await this.reading.start();
140
+ await this.initialize();
65
141
  this.assertOpen();
66
142
  await this.connection.connect();
67
143
  })().catch(error => { this.starting = undefined; throw error; });
68
144
  }
69
- assertOpen() { if (this.closed)
145
+ /** Initializes local state and background reading sync without opening a
146
+ * second transport when the host already owns calls/presence. */
147
+ initialize() {
148
+ this.assertOpen();
149
+ return this.initializing ??= (async () => {
150
+ await this.sender.initialize();
151
+ this.assertOpen();
152
+ await this.startReading();
153
+ this.assertOpen();
154
+ })().catch(error => { this.initializing = undefined; throw error; });
155
+ }
156
+ startReading() {
157
+ if (this.readingStarted)
158
+ return Promise.resolve();
159
+ return this.readingStarting ??= (async () => {
160
+ const access = await this.messages.scopeAccess();
161
+ if (!access.allowed)
162
+ return;
163
+ this.assertOpen();
164
+ await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
165
+ this.setReadingAllowed(true);
166
+ await this.reading.start();
167
+ this.assertOpen();
168
+ this.readingStarted = true;
169
+ })().finally(() => { this.readingStarting = undefined; });
170
+ }
171
+ /** One-time migration only: never establishes trusted history coverage. */
172
+ importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
173
+ getReadingSnapshot() { return this.closed || this.withdrawn || !this.readingAllowed ? undefined : this.reading.getSnapshot(); }
174
+ subscribeReading(listener) { if (this.closed || this.withdrawn)
175
+ return () => { }; this.readingListeners.add(listener); return () => { this.readingListeners.delete(listener); }; }
176
+ setReadingAllowed(allowed) {
177
+ if (this.readingAllowed === allowed)
178
+ return;
179
+ const previous = this.getReadingSnapshot();
180
+ this.readingAllowed = allowed;
181
+ if (previous !== this.getReadingSnapshot())
182
+ this.readingListeners.forEach(listener => listener());
183
+ this.publishAll();
184
+ }
185
+ readingAuthority() {
186
+ return this.readingAccess ??= this.messages.scopeAccess().then(access => { if (!access.allowed)
187
+ this.setReadingAllowed(false); return access; }).finally(() => { this.readingAccess = undefined; });
188
+ }
189
+ async authorizeReading() {
190
+ const access = await this.readingAuthority();
191
+ if (this.withdrawn || !access.allowed)
192
+ throw new Error("CHAT_ACCESS_REVOKED");
193
+ await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
194
+ this.setReadingAllowed(true);
195
+ }
196
+ readingOperation(run, durable = true) {
197
+ if (this.closed || this.withdrawn)
198
+ return Promise.reject(new Error(this.withdrawn ? "CHAT_ACCESS_REVOKED" : "CHAT_SESSION_CLOSED"));
199
+ const task = (async () => {
200
+ await this.authorizeReading();
201
+ if (this.withdrawn)
202
+ throw new Error("CHAT_ACCESS_REVOKED");
203
+ if (!durable)
204
+ this.assertOpen();
205
+ const next = await run();
206
+ if (this.withdrawn || !(await this.readingAuthority()).allowed)
207
+ throw new Error("CHAT_ACCESS_REVOKED");
208
+ return next;
209
+ })();
210
+ if (durable) {
211
+ this.acceptedReading.add(task);
212
+ void task.finally(() => this.acceptedReading.delete(task)).catch(() => { });
213
+ }
214
+ return task;
215
+ }
216
+ refreshReading() {
217
+ if (this.closed || this.withdrawn)
218
+ return;
219
+ void this.authorizeReading().then(() => { if (!this.closed && !this.withdrawn)
220
+ this.reading.refresh(); }).catch(error => { if (!this.closed && !this.withdrawn)
221
+ this.options.onError(error); });
222
+ }
223
+ synchronizeReading(cursor, profileId) { return this.readingOperation(() => this.reading.synchronize(cursor, profileId), false); }
224
+ /** Explicit text-history acknowledgement; unheard audio stays unread. */
225
+ async markRead(threadId, messages) {
226
+ this.assertOpen();
227
+ const cached = messages ?? this.getSnapshot(threadId).timeline.flatMap(item => {
228
+ if (item.recalled || !item.message)
229
+ return [];
230
+ const candidate = this.options.readingCandidate?.(item.message);
231
+ return candidate ? [{ id: item.id, threadId, from: item.from, mine: item.mine, recalled: item.recalled, readPolicy: candidate.readPolicy }] : [];
232
+ });
233
+ return this.readingOperation(() => this.reading.markRead(threadId, cached));
234
+ }
235
+ updateReading(transform) { return this.readingOperation(() => this.reading.update(transform)); }
236
+ assertOpen() { if (this.withdrawn)
237
+ throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
70
238
  throw new Error("CHAT_SESSION_CLOSED"); }
71
239
  async getMessages(threadId, options = {}) {
72
240
  this.assertOpen();
73
- if (options.all) {
74
- if (options.before || options.limit !== undefined)
241
+ if (options.earlier) {
242
+ if (options.before || options.all || options.limit !== undefined || options.refresh)
75
243
  throw new Error("INVALID_MESSAGE_QUERY");
244
+ return this.readEarlier(threadId);
245
+ }
246
+ if (options.all && (options.before || options.limit !== undefined))
247
+ throw new Error("INVALID_MESSAGE_QUERY");
248
+ // Explicit authoritative changes (for example history assignment) can invalidate
249
+ // even a recently cached empty window. Keep this on the existing read surface.
250
+ if (options.refresh)
251
+ await this.messages.read(threadId, options.limit ?? 100, true);
252
+ if (options.all) {
76
253
  return this.readAll(threadId);
77
254
  }
78
255
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
256
+ this.assertOpen();
79
257
  await this.sender.confirm(page.messages.map(message => message.id));
80
- if (!options.before)
81
- this.publish(threadId);
258
+ if (this.initializing && !this.readingStarted && page.complete)
259
+ await this.startReading();
260
+ this.assertOpen();
261
+ const edge = this.historyEdges.get(threadId);
262
+ if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
263
+ this.rememberHistoryEdge(threadId, page);
82
264
  return page;
83
265
  }
266
+ rememberHistoryEdge(threadId, page) {
267
+ const first = page.messages[0];
268
+ this.historyEdges.set(threadId, { before: first ? { timestamp: first.timestamp, id: first.id } : undefined, version: page.resetVersion, hasEarlier: page.hasEarlier });
269
+ }
270
+ readEarlier(threadId) {
271
+ const running = this.earlierReads.get(threadId);
272
+ if (running)
273
+ return running;
274
+ const task = (async () => {
275
+ const recent = await this.getMessages(threadId);
276
+ if (!recent.complete)
277
+ throw new Error("MESSAGE_WINDOW_INCOMPLETE");
278
+ const edge = this.historyEdges.get(threadId);
279
+ if (!edge.hasEarlier)
280
+ return { ...recent, messages: [], hasEarlier: false };
281
+ if (!edge.before)
282
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
283
+ const page = await this.messages.earlier(threadId, edge.before, 100, true);
284
+ await this.sender.confirm(page.messages.map(message => message.id));
285
+ this.assertOpen();
286
+ this.rememberHistoryEdge(threadId, page);
287
+ return page;
288
+ })().finally(() => { if (this.earlierReads.get(threadId) === task)
289
+ this.earlierReads.delete(threadId); });
290
+ this.earlierReads.set(threadId, task);
291
+ return task;
292
+ }
84
293
  async readAll(threadId) {
85
294
  for (let attempt = 0; attempt < 3; attempt++) {
86
295
  const pages = [];
@@ -102,12 +311,18 @@ export class ChatSession {
102
311
  break;
103
312
  }
104
313
  pages.push(page.messages);
314
+ if (!page.complete)
315
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
105
316
  }
106
317
  if (restart)
107
318
  continue;
108
- return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
319
+ const result = { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
320
+ await this.messages.expandWindow(threadId, result);
321
+ this.rememberHistoryEdge(threadId, result);
322
+ return result;
109
323
  }
110
324
  catch (error) {
325
+ this.assertOpen();
111
326
  if ([401, 403].includes(error?.status ?? 0))
112
327
  throw error;
113
328
  if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
@@ -121,18 +336,128 @@ export class ChatSession {
121
336
  }
122
337
  sendMessage(threadId, input) {
123
338
  this.assertOpen();
124
- return this.sender.enqueue(threadId, input);
339
+ return this.sender.enqueue(threadId, input).then(next => { if (this.withdrawn)
340
+ throw new Error("CHAT_ACCESS_REVOKED"); return next; });
125
341
  }
126
- getReaders(threadId, messageId, page = {}, signal) {
342
+ async getReaders(threadId, messageId, page = {}, signal) {
127
343
  this.assertOpen();
128
- return this.readers.getReaders({ ...page, threadId, messageId }, signal);
344
+ const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
345
+ this.assertOpen();
346
+ if (!result.resetRequired) {
347
+ const previous = this.countSnapshots.get(threadId);
348
+ if (!previous || previous.revision <= result.revision) {
349
+ this.publishCounts({ appId: result.appId, threadId, revision: result.revision, counts: { ...previous?.counts, [messageId]: result.total } });
350
+ this.counts?.changed(threadId, result.revision);
351
+ }
352
+ }
353
+ return result;
129
354
  }
130
355
  retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
131
- observeMessages(threadId, ids, event) {
356
+ observeMessages(threadId, ids, event, clientInstanceId) {
357
+ if (this.closed || this.withdrawn) {
358
+ if (event === "receipts" && !threadId)
359
+ return Promise.resolve();
360
+ return Promise.reject(new Error(this.withdrawn ? "CHAT_ACCESS_REVOKED" : "CHAT_SESSION_CLOSED"));
361
+ }
362
+ if (event === "receipts") {
363
+ this.watchCounts(threadId, ids, clientInstanceId);
364
+ return Promise.resolve();
365
+ }
366
+ if (!threadId)
367
+ throw new Error("INVALID_READING_THREAD");
368
+ return this.readingOperation(() => event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids));
369
+ }
370
+ /** Bridge transports can delegate social events without maintaining clients. */
371
+ async handleReach(event) {
132
372
  this.assertOpen();
133
- return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
373
+ if (!["social.readCountsChanged", "social.readStateChanged"].includes(event.eventType))
374
+ return undefined;
375
+ if (event.appId !== this.options.messages.scope.appId)
376
+ throw new Error("UNEXPECTED_READING_APPLICATION");
377
+ if (event.eventType === "social.readCountsChanged") {
378
+ if (typeof event.payload.threadId !== "string" || !Number.isSafeInteger(event.payload.revision) || Number(event.payload.revision) < 0)
379
+ throw new Error("INVALID_READ_COUNTS_EVENT");
380
+ this.counts?.changed(event.payload.threadId, Number(event.payload.revision));
381
+ }
382
+ else {
383
+ if (typeof event.payload.syncCursor !== "string" || !/^v1:\d+$/.test(event.payload.syncCursor) || typeof event.payload.profileId !== "string")
384
+ throw new Error("INVALID_READING_EVENT");
385
+ if (event.payload.profileId !== this.options.messages.scope.profileId)
386
+ throw new Error("Reading reach identity mismatch");
387
+ await this.initialize();
388
+ await this.synchronizeReading(event.payload.syncCursor, event.payload.profileId);
389
+ this.assertOpen();
390
+ }
391
+ return "handled";
392
+ }
393
+ wakeCounts() {
394
+ if (this.counts && this.countsThread && !this.closed)
395
+ this.countTasks.schedule("counts", async () => { this.counts?.reconnect(); });
396
+ }
397
+ publishCountSnapshot(thread) {
398
+ const previous = this.snapshots.get(thread);
399
+ if (!previous) {
400
+ this.publish(thread, false);
401
+ return;
402
+ }
403
+ // Receipt-only updates never serialize or rebuild message bodies.
404
+ this.snapshots.set(thread, { ...previous, readCounts: this.countSnapshots.get(thread) });
405
+ this.listeners.get(thread)?.forEach(listener => listener());
406
+ }
407
+ publishCounts(value) {
408
+ if (this.closed || this.withdrawn)
409
+ return;
410
+ const previous = this.countSnapshots.get(value.threadId);
411
+ if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
412
+ return;
413
+ this.countSnapshots.delete(value.threadId);
414
+ this.countSnapshots.set(value.threadId, value);
415
+ if (this.countSnapshots.size > 128) {
416
+ const oldest = this.countSnapshots.keys().next().value;
417
+ this.countSnapshots.delete(oldest);
418
+ this.publishCountSnapshot(oldest);
419
+ }
420
+ this.publishCountSnapshot(value.threadId);
421
+ }
422
+ watchCounts(threadId, ids, receiver = this.connection.identityScope().clientInstanceId) {
423
+ if (!threadId || !ids.length) {
424
+ this.countsThread = undefined;
425
+ this.counts?.watch(undefined, []);
426
+ return;
427
+ }
428
+ if (!receiver || receiver.length > 512 || /[\x00-\x1f]/.test(receiver))
429
+ throw new Error("INVALID_READ_COUNTS_RECEIVER");
430
+ if (!this.counts || this.countsReceiver !== receiver) {
431
+ if (this.counts) {
432
+ const closing = this.counts.close().finally(() => this.countClosings.delete(closing));
433
+ this.countClosings.add(closing);
434
+ }
435
+ const scope = this.options.messages.scope;
436
+ const client = new ReadCountsClient({ appId: scope.appId, profileId: scope.profileId, scope, access: () => this.messages.scopeAccess(), token: this.options.reading.sessionToken, clientInstanceId: receiver, hubHttpUrl: this.options.reading.hubHttpUrl, fetch: this.options.reading.fetch,
437
+ onChange: value => { if (!this.closed && this.counts === client)
438
+ this.publishCounts(value); },
439
+ onError: error => {
440
+ if (this.closed || this.counts !== client)
441
+ return;
442
+ if (/READ_COUNTS_HTTP_(401|403)|RESPONSE_CACHE_(ACCESS_REVOKED|SCOPE_CHANGED|INVALIDATED)/.test(String(error))) {
443
+ const threads = [...this.countSnapshots.keys()];
444
+ this.countSnapshots.clear();
445
+ threads.forEach(thread => this.publishCountSnapshot(thread));
446
+ }
447
+ this.options.onError(error);
448
+ } });
449
+ this.counts = client;
450
+ this.countsReceiver = receiver;
451
+ }
452
+ this.countsThread = threadId;
453
+ this.counts.watch(threadId, ids);
134
454
  }
135
455
  getSnapshot(threadId) {
456
+ if (this.closed || this.withdrawn) {
457
+ if (threadId === undefined)
458
+ return this.closedSummary;
459
+ return this.closedThread;
460
+ }
136
461
  if (threadId === undefined) {
137
462
  if (!this.globalSnapshot)
138
463
  this.globalSnapshot = this.buildGlobal();
@@ -140,28 +465,55 @@ export class ChatSession {
140
465
  }
141
466
  let snapshot = this.snapshots.get(threadId);
142
467
  if (!snapshot) {
143
- const messages = this.messages.getSnapshot(threadId);
144
- const state = this.reading.getSnapshot();
145
- 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) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
468
+ snapshot = this.buildThreadSnapshot(threadId);
146
469
  this.snapshots.set(threadId, snapshot);
147
470
  }
148
471
  return snapshot;
149
472
  }
150
- subscribe(target, listener) {
151
- this.assertOpen();
473
+ buildThreadSnapshot(threadId, previous) {
474
+ const source = this.messages.getSnapshot(threadId), raw = source?.messages ?? [];
475
+ const messages = previous && previous.messages.length === raw.length && previous.messages.every((message, index) => message === raw[index]) ? previous.messages : raw;
476
+ const outgoing = this.sender.snapshot(threadId);
477
+ const sameSource = previous && previous.messages === messages && previous.outgoing.length === outgoing.length && previous.outgoing.every((record, index) => record === outgoing[index]);
478
+ const candidates = new Map(messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [[candidate.id, candidate]] : []; }));
479
+ const semanticReading = previous?.messageReading ?? {};
480
+ const semantics = sameSource ? previous.timeline : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, candidates, decode: this.decodeTimeline, reading: semanticReading, previous: previous?.timeline });
481
+ for (const item of semantics)
482
+ if (item.recalled)
483
+ candidates.set(item.id, { ...candidates.get(item.id), id: item.id, threadId, from: item.from, mine: item.mine, recalled: true });
484
+ const state = this.getReadingSnapshot();
485
+ const kind = state?.threads.find(thread => (thread.conversationId ?? thread.id) === threadId)?.kind ?? "dm";
486
+ const view = projectConversationReading(state, { id: threadId, kind }, [...candidates.values()]);
487
+ const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
488
+ const messageReading = previous && JSON.stringify(previous.messageReading) === JSON.stringify(view.messageReading) ? previous.messageReading : view.messageReading;
489
+ const timeline = messageReading === semanticReading ? semantics : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, candidates, decode: this.decodeTimeline, reading: messageReading, previous: previous?.timeline });
490
+ return { timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
491
+ }
492
+ subscribe(target, listener, fields) {
493
+ if (this.closed || this.withdrawn)
494
+ return () => { };
152
495
  if (typeof target === "function") {
153
496
  this.globalListeners.add(target);
154
497
  return () => { this.globalListeners.delete(target); };
155
498
  }
156
499
  const threadId = target;
157
- const notify = listener;
500
+ const selected = fields ? [...fields] : undefined;
501
+ let values = selected?.map(field => this.getSnapshot(threadId)[field]);
502
+ const equal = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]));
503
+ const notify = selected ? () => {
504
+ const next = selected.map(field => this.getSnapshot(threadId)[field]);
505
+ const changed = next.some((value, index) => !equal(value, values[index]));
506
+ values = next;
507
+ if (changed)
508
+ listener();
509
+ } : listener;
158
510
  let listeners = this.listeners.get(threadId);
159
511
  if (!listeners) {
160
512
  listeners = new Set();
161
513
  this.listeners.set(threadId, listeners);
162
514
  }
163
515
  listeners.add(notify);
164
- const off = this.messages.subscribe(threadId, () => this.publish(threadId));
516
+ const off = this.messages.subscribe(threadId, () => { });
165
517
  return () => {
166
518
  off();
167
519
  listeners.delete(notify);
@@ -170,26 +522,36 @@ export class ChatSession {
170
522
  };
171
523
  }
172
524
  publishAll() {
525
+ if (this.closed || this.withdrawn)
526
+ return;
173
527
  for (const threadId of [...this.snapshots.keys()])
174
528
  this.publish(threadId, false);
175
529
  this.publishGlobal();
176
530
  }
177
531
  buildGlobal() {
178
532
  const ids = new Set(this.snapshots.keys());
179
- for (const thread of this.reading.getSnapshot()?.threads ?? [])
533
+ for (const thread of this.getReadingSnapshot()?.threads ?? [])
180
534
  if (thread.teamId === this.options.messages.scope.teamId)
181
535
  ids.add(thread.conversationId ?? thread.id);
182
536
  for (const outgoing of this.sender.snapshot())
183
537
  ids.add(outgoing.threadId);
184
538
  const threads = [...ids].sort().map(threadId => {
185
539
  const snapshot = this.getSnapshot(threadId);
186
- return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
540
+ return { threadId, lastMessage: snapshot.timeline.at(-1)?.message, lastItem: snapshot.timeline.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
187
541
  });
188
- return { threads, unreadMessageCount: threads.reduce((sum, t) => sum + t.unread.unreadMessageCount, 0), badgeCount: threads.reduce((sum, t) => sum + t.unread.badgeCount, 0), connection: this.connectionState };
542
+ const state = this.getReadingSnapshot();
543
+ const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
544
+ return { threads, unreadMessageCount: known ? threads.reduce((sum, t) => sum + (t.unread?.unreadMessageCount ?? 0), 0) : undefined, badgeCount: known ? threads.reduce((sum, t) => sum + (t.unread?.badgeCount ?? 0), 0) : undefined, connection: this.connectionState };
189
545
  }
190
546
  publishGlobal() {
547
+ if (this.closed || this.withdrawn)
548
+ return;
191
549
  const next = this.buildGlobal();
192
- if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
550
+ const previous = this.globalSnapshot;
551
+ if (previous && previous.connection === next.connection && previous.unreadMessageCount === next.unreadMessageCount && previous.badgeCount === next.badgeCount && previous.threads.length === next.threads.length && previous.threads.every((thread, index) => {
552
+ const other = next.threads[index];
553
+ return thread.threadId === other.threadId && thread.lastMessage === other.lastMessage && thread.lastItem === other.lastItem && thread.outgoingCount === other.outgoingCount && JSON.stringify(thread.unread) === JSON.stringify(other.unread);
554
+ }))
193
555
  return;
194
556
  this.globalSnapshot = next;
195
557
  this.globalListeners.forEach(listener => listener());
@@ -197,8 +559,9 @@ export class ChatSession {
197
559
  publish(threadId, notifyGlobal = true) {
198
560
  const previous = this.snapshots.get(threadId);
199
561
  this.snapshots.delete(threadId);
200
- const next = this.getSnapshot(threadId);
201
- if (previous && JSON.stringify(previous) === JSON.stringify(next)) {
562
+ const next = this.buildThreadSnapshot(threadId, previous);
563
+ this.snapshots.set(threadId, next);
564
+ if (previous && previous.messages === next.messages && previous.timeline === next.timeline && previous.outgoing.length === next.outgoing.length && previous.outgoing.every((record, index) => record === next.outgoing[index]) && previous.hasEarlier === next.hasEarlier && previous.resetVersion === next.resetVersion && previous.complete === next.complete && previous.readCounts === next.readCounts && JSON.stringify(previous.unread) === JSON.stringify(next.unread) && JSON.stringify(previous.messageReading) === JSON.stringify(next.messageReading)) {
202
565
  this.snapshots.set(threadId, previous);
203
566
  return;
204
567
  }
@@ -206,18 +569,96 @@ export class ChatSession {
206
569
  if (notifyGlobal)
207
570
  this.publishGlobal();
208
571
  }
209
- close() {
572
+ /** Permanently retire this identity/team session. UI withdrawal is immediate;
573
+ * completion means all durable stores have crossed the same authority epoch. */
574
+ revokeAccess() {
575
+ if (this.revoking)
576
+ return this.revoking;
577
+ if (this.closed)
578
+ return Promise.reject(new Error("CHAT_SESSION_CLOSED"));
579
+ this.withdrawn = true;
580
+ const knownThreads = [...new Set([...this.snapshots.keys(), ...this.historyEdges.keys()])];
581
+ this.catchUp.close();
582
+ this.countTasks.close();
583
+ this.off.splice(0).forEach(off => off());
584
+ this.sender.withdraw();
585
+ this.reading.withdraw();
586
+ this.readers.withdraw();
587
+ this.counts?.watch(undefined, []);
588
+ this.countSnapshots.clear();
589
+ this.snapshots.clear();
590
+ this.timelineDecoded = new WeakMap();
591
+ this.historyEdges.clear();
592
+ this.earlierReads.clear();
593
+ this.globalSnapshot = undefined;
594
+ this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
595
+ this.globalListeners.forEach(listener => listener());
596
+ this.readingListeners.forEach(listener => listener());
597
+ if (!this.options.sharedConnection)
598
+ this.connection.close();
599
+ return this.revoking = Promise.resolve().then(async () => {
600
+ const failures = [];
601
+ let version;
602
+ try {
603
+ version = await this.messages.revokeScope();
604
+ }
605
+ catch (error) {
606
+ failures.push(error);
607
+ version = 1;
608
+ try {
609
+ version = Math.max(1, (await this.messages.scopeAccess()).version);
610
+ }
611
+ catch (failure) {
612
+ failures.push(failure);
613
+ }
614
+ }
615
+ const results = await Promise.allSettled([
616
+ this.sender.revoke(version),
617
+ this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
618
+ this.readers.retireScope(version), this.counts?.retireScope(version),
619
+ ]);
620
+ for (const result of results)
621
+ if (result.status === "rejected")
622
+ failures.push(result.reason);
623
+ try {
624
+ await this.dispose();
625
+ }
626
+ catch (error) {
627
+ failures.push(error);
628
+ }
629
+ if (failures.length)
630
+ throw new AggregateError(failures, "CHAT_ACCESS_CLEANUP_FAILED");
631
+ });
632
+ }
633
+ close() { return this.revoking ?? this.dispose(); }
634
+ dispose() {
210
635
  return this.closing ??= (async () => {
211
636
  this.closed = true;
637
+ this.catchUp.close();
638
+ this.countTasks.close();
212
639
  this.off.splice(0).forEach(off => off());
213
640
  if (!this.options.sharedConnection)
214
641
  this.connection.close();
215
- this.readers.close();
216
- await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
217
- this.listeners.clear();
218
- this.globalListeners.clear();
219
- this.snapshots.clear();
220
- this.globalSnapshot = undefined;
642
+ const senderClosing = this.sender.close();
643
+ const readingBarrier = Promise.allSettled([...this.acceptedReading]);
644
+ const readingClosing = readingBarrier.then(() => this.reading.close());
645
+ try {
646
+ const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, readingBarrier]))]);
647
+ const failed = results.find((result) => result.status === "rejected");
648
+ if (failed)
649
+ throw failed.reason;
650
+ }
651
+ finally {
652
+ this.listeners.clear();
653
+ this.globalListeners.clear();
654
+ this.readingListeners.clear();
655
+ this.snapshots.clear();
656
+ this.timelineDecoded = new WeakMap();
657
+ this.countSnapshots.clear();
658
+ this.historyEdges.clear();
659
+ this.earlierReads.clear();
660
+ this.globalSnapshot = undefined;
661
+ }
221
662
  })();
222
663
  }
223
664
  }