@pmate/chat-sdk 0.12.0-next.4 → 0.12.0-next.41

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