@pmate/chat-sdk 0.12.0-next.3 → 0.12.0-next.30

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