@pmate/chat-sdk 0.12.0-next.7 → 0.12.0-next.71

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 (105) hide show
  1. package/README.md +170 -81
  2. package/lib/advanced.d.ts +10 -0
  3. package/lib/advanced.js +10 -0
  4. package/lib/chat.d.ts +5 -0
  5. package/lib/chat.js +11 -0
  6. package/lib/chatContract.d.ts +148 -0
  7. package/lib/chatContract.js +1 -0
  8. package/lib/chatError.d.ts +11 -0
  9. package/lib/chatError.js +13 -0
  10. package/lib/chatTypes.d.ts +121 -0
  11. package/lib/chatTypes.js +1 -0
  12. package/lib/commands.d.ts +20 -0
  13. package/lib/commands.js +12 -0
  14. package/lib/components/ChatInputDesktop.js +5 -4
  15. package/lib/components/ChatInputMobile.js +5 -4
  16. package/lib/components/CommandMenu.d.ts +15 -0
  17. package/lib/components/CommandMenu.js +10 -0
  18. package/lib/credentials.d.ts +26 -0
  19. package/lib/credentials.js +110 -0
  20. package/lib/fileMetadata.d.ts +9 -0
  21. package/lib/fileMetadata.js +18 -0
  22. package/lib/framework.d.ts +11 -0
  23. package/lib/framework.js +12 -0
  24. package/lib/history.d.ts +4 -0
  25. package/lib/history.js +8 -0
  26. package/lib/hooks/useChatSnapshot.d.ts +4 -0
  27. package/lib/hooks/useChatSnapshot.js +9 -0
  28. package/lib/hooks/useComposerSuggestions.d.ts +50 -0
  29. package/lib/hooks/useComposerSuggestions.js +70 -0
  30. package/lib/index.d.ts +5 -0
  31. package/lib/index.js +5 -0
  32. package/lib/internal/chatEngine.d.ts +131 -0
  33. package/lib/internal/chatEngine.js +280 -0
  34. package/lib/internal/chatRegistry.d.ts +4 -0
  35. package/lib/internal/chatRegistry.js +4 -0
  36. package/lib/internal/createPublicChat.d.ts +4 -0
  37. package/lib/internal/createPublicChat.js +52 -0
  38. package/lib/messageView.d.ts +6 -0
  39. package/lib/messageView.js +103 -0
  40. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  41. package/lib/runtime/batchedReadIndex.js +50 -4
  42. package/lib/runtime/cachedMessage.d.ts +12 -0
  43. package/lib/runtime/cachedMessage.js +18 -0
  44. package/lib/runtime/chatClient.d.ts +2 -0
  45. package/lib/runtime/chatClient.js +8 -3
  46. package/lib/runtime/chatError.d.ts +3 -0
  47. package/lib/runtime/chatError.js +15 -0
  48. package/lib/runtime/chatSession.d.ts +127 -13
  49. package/lib/runtime/chatSession.js +727 -40
  50. package/lib/runtime/connectivity.d.ts +4 -0
  51. package/lib/runtime/connectivity.js +8 -0
  52. package/lib/runtime/conversationDirectory.d.ts +37 -0
  53. package/lib/runtime/conversationDirectory.js +116 -0
  54. package/lib/runtime/conversationRead.d.ts +13 -0
  55. package/lib/runtime/conversationRead.js +34 -0
  56. package/lib/runtime/conversationReading.d.ts +28 -0
  57. package/lib/runtime/conversationReading.js +47 -0
  58. package/lib/runtime/createChat.d.ts +11 -7
  59. package/lib/runtime/createChat.js +46 -23
  60. package/lib/runtime/durableSender.d.ts +97 -14
  61. package/lib/runtime/durableSender.js +786 -71
  62. package/lib/runtime/groupClient.d.ts +76 -0
  63. package/lib/runtime/groupClient.js +325 -0
  64. package/lib/runtime/index.d.ts +7 -0
  65. package/lib/runtime/index.js +5 -0
  66. package/lib/runtime/messageClient.d.ts +35 -8
  67. package/lib/runtime/messageClient.js +364 -66
  68. package/lib/runtime/messageControls.d.ts +19 -0
  69. package/lib/runtime/messageControls.js +44 -0
  70. package/lib/runtime/messageLaunch.d.ts +44 -0
  71. package/lib/runtime/messageLaunch.js +137 -0
  72. package/lib/runtime/messageQuotes.d.ts +3 -0
  73. package/lib/runtime/messageQuotes.js +17 -0
  74. package/lib/runtime/messageReaders.d.ts +12 -1
  75. package/lib/runtime/messageReaders.js +67 -3
  76. package/lib/runtime/messageStore.d.ts +40 -2
  77. package/lib/runtime/messageStore.js +227 -11
  78. package/lib/runtime/messageTimeline.d.ts +50 -0
  79. package/lib/runtime/messageTimeline.js +120 -0
  80. package/lib/runtime/outboxSchema.d.ts +5 -0
  81. package/lib/runtime/outboxSchema.js +59 -0
  82. package/lib/runtime/outgoingContent.d.ts +89 -0
  83. package/lib/runtime/outgoingContent.js +113 -0
  84. package/lib/runtime/readBatchJournal.d.ts +35 -0
  85. package/lib/runtime/readBatchJournal.js +210 -0
  86. package/lib/runtime/readCounts.d.ts +15 -1
  87. package/lib/runtime/readCounts.js +97 -9
  88. package/lib/runtime/readIndex.d.ts +68 -7
  89. package/lib/runtime/readIndex.js +460 -22
  90. package/lib/runtime/readSchema.d.ts +2 -0
  91. package/lib/runtime/readSchema.js +58 -0
  92. package/lib/runtime/readSync.d.ts +1 -0
  93. package/lib/runtime/readSync.js +26 -9
  94. package/lib/runtime/readingClient.d.ts +19 -0
  95. package/lib/runtime/readingClient.js +131 -19
  96. package/lib/runtime/readingContent.d.ts +5 -0
  97. package/lib/runtime/readingContent.js +23 -0
  98. package/lib/runtime/responseCache.d.ts +59 -0
  99. package/lib/runtime/responseCache.js +232 -0
  100. package/lib/runtime/socialReadClient.d.ts +4 -2
  101. package/lib/runtime/socialReadClient.js +12 -8
  102. package/lib/runtime/voiceUpload.d.ts +2 -0
  103. package/lib/runtime/voiceUpload.js +27 -0
  104. package/lib/types/chatInput.d.ts +3 -0
  105. package/package.json +27 -2
@@ -1,3 +1,11 @@
1
+ import { ConversationDirectory } from "./conversationDirectory.js";
2
+ import { groupThreadId } from "./thread.js";
3
+ import { GroupClient } from "./groupClient.js";
4
+ import { isReadingControl } from "./readingContent.js";
5
+ import { outgoingContent, validateConversation } from "./outgoingContent.js";
6
+ import { projectMessageTimeline } from "./messageTimeline.js";
7
+ import { projectConversationReading } from "./conversationReading.js";
8
+ import { ReadCountsClient } from "./readCounts.js";
1
9
  import { WindowedTasks } from "./coalescer.js";
2
10
  import { MessageReadersClient } from "./messageReaders.js";
3
11
  import { MessageClient } from "./messageClient.js";
@@ -8,21 +16,48 @@ import { unreadInConversation } from "./readIndex.js";
8
16
  /** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
9
17
  export class ChatSession {
10
18
  options;
19
+ conversations;
11
20
  messages;
12
21
  connection;
13
22
  reading;
14
23
  readers;
24
+ groups;
25
+ counts;
26
+ countsReceiver;
27
+ countsThread;
28
+ countSnapshots = new Map();
29
+ countClosings = new Set();
30
+ countTasks;
31
+ controlTails = new Map();
32
+ acceptedControls = new Set();
33
+ controlCount = 0;
34
+ controlBytes = 0;
15
35
  sender;
16
36
  listeners = new Map();
17
37
  snapshots = new Map();
38
+ timelineDecoded = new WeakMap();
39
+ decodeTimeline = (message) => { if (!this.timelineDecoded.has(message))
40
+ this.timelineDecoded.set(message, this.options.decode(message)); return this.timelineDecoded.get(message); };
41
+ historyEdges = new Map();
42
+ earlierReads = new Map();
18
43
  off = [];
19
44
  globalListeners = new Set();
20
45
  globalSnapshot;
46
+ closedThread = { messages: [], timeline: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
47
+ closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
21
48
  connectionState = "closed";
22
49
  catchUp;
50
+ readingAllowed = false;
51
+ readingListeners = new Set();
52
+ acceptedReading = new Set();
53
+ readingAccess;
23
54
  readingStarted = false;
55
+ readingStarting;
56
+ withdrawn = false;
57
+ revoking;
24
58
  closed = false;
25
59
  starting;
60
+ initializing;
26
61
  closing;
27
62
  constructor(options) {
28
63
  this.options = options;
@@ -35,19 +70,78 @@ export class ChatSession {
35
70
  throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
36
71
  }
37
72
  this.messages = new MessageClient(options.messages);
38
- this.connection = options.sharedConnection ?? new ChatClient(options.connection);
39
- this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId, scope.teamId]) });
40
- this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
41
- this.sender = new DurableChatSender(this.connection, scope, options.onError);
73
+ this.conversations = new ConversationDirectory(scope, () => this.messages.scopeAccess());
74
+ this.connection = options.sharedConnection ?? new ChatClient({ ...options.connection, onReach: async (event) => {
75
+ const handled = await this.handleReach(event);
76
+ if (handled)
77
+ return handled;
78
+ if (options.connection.onReach)
79
+ return options.connection.onReach(event);
80
+ throw new Error("REACH_HANDLER_UNAVAILABLE");
81
+ } });
82
+ this.reading = new ReadingClient({ ...options.reading, teamId: scope.teamId, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true, metadataOnly: true });
83
+ 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 });
84
+ if (options.loadGroup) {
85
+ this.groups = new GroupClient({ scope, access: () => this.messages.scopeAccess(), load: options.loadGroup, onError: options.onError, onDenied: () => { void this.revokeAccess().catch(error => options.onError(error)); } });
86
+ this.off.push(this.groups.subscribe(thread => this.publish(thread)));
87
+ }
88
+ this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess(), uploadVoice: options.uploadVoice, reconcile: async (record, signal) => {
89
+ const cached = await this.messages.cachedMessage(record.threadId, record.message.hash);
90
+ if (cached && cached.from === scope.profileId && cached.to === record.message.to)
91
+ return true;
92
+ if (!options.reconcileOutgoing)
93
+ throw new Error("OUTBOX_RECONCILIATION_UNAVAILABLE");
94
+ try {
95
+ return await options.reconcileOutgoing(record, signal);
96
+ }
97
+ catch (error) {
98
+ if ([401, 403].includes(error?.status ?? 0))
99
+ await this.messages.revoke(record.threadId);
100
+ throw error;
101
+ }
102
+ } });
42
103
  this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
43
104
  options.onError(error); });
105
+ this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
106
+ options.onError(error); });
107
+ if (typeof window !== "undefined") {
108
+ const wake = () => {
109
+ if (typeof document !== "undefined" && document.hidden)
110
+ return;
111
+ this.wakeCounts();
112
+ this.groups?.wake();
113
+ this.scheduleCatchUp();
114
+ };
115
+ for (const type of ["online", "focus", "pageshow"])
116
+ window.addEventListener(type, wake);
117
+ if (typeof document !== "undefined")
118
+ document.addEventListener("visibilitychange", wake);
119
+ this.off.push(() => {
120
+ for (const type of ["online", "focus", "pageshow"])
121
+ window.removeEventListener(type, wake);
122
+ if (typeof document !== "undefined")
123
+ document.removeEventListener("visibilitychange", wake);
124
+ });
125
+ }
126
+ this.off.push(this.messages.subscribeAccessRevoked(() => { if (!this.withdrawn && !this.closed)
127
+ void this.revokeAccess().catch(error => options.onError(error)); }));
44
128
  this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
45
- this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
129
+ this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => { this.publishAll(); if (this.readingAllowed)
130
+ this.readingListeners.forEach(listener => listener()); }));
46
131
  this.off.push(this.connection.watchMessages(message => {
47
132
  try {
133
+ const body = message.body;
134
+ if (body?.kind === "group" && body.payload?.appId === scope.appId && body.payload.teamId === scope.teamId && body.payload.groupId === message.to) {
135
+ this.groups?.hint({ id: groupThreadId(scope.appId, message.to, scope.teamId), kind: "group", targetId: message.to, teamId: scope.teamId }, Number.isSafeInteger(body.payload.versionNumber) ? body.payload.versionNumber : undefined);
136
+ }
48
137
  const decoded = options.decode(message);
49
138
  if (decoded)
50
- void this.messages.receive(decoded.threadId, [decoded]).then(() => this.sender.confirm([message.hash])).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
139
+ void this.messages.receive(decoded.threadId, [decoded]).then(async () => {
140
+ if (!this.closed && this.readingStarted)
141
+ this.reading.refresh();
142
+ if (message.from === scope.profileId)
143
+ await this.sender.confirm([message.hash]);
144
+ }).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
51
145
  this.messages.invalidate(decoded.threadId);
52
146
  else if (!this.closed)
53
147
  options.onError(error); });
@@ -60,27 +154,132 @@ export class ChatSession {
60
154
  this.connectionState = state;
61
155
  this.publishGlobal();
62
156
  if (state === "connected") {
63
- const since = Date.now();
64
- this.catchUp.schedule("scope", () => this.messages.restore(since));
157
+ this.scheduleCatchUp();
65
158
  if (this.readingStarted)
66
159
  this.reading.refresh();
160
+ this.wakeCounts();
161
+ this.groups?.wake();
67
162
  }
68
163
  }));
69
164
  }
165
+ scheduleCatchUp() {
166
+ if (this.connectionState !== "connected" || this.closed || this.withdrawn)
167
+ return;
168
+ const since = Date.now();
169
+ this.catchUp.schedule("scope", async () => {
170
+ if (this.connectionState === "connected" && !this.closed && !this.withdrawn)
171
+ await this.messages.restore(since, this.options.activeThreadId);
172
+ });
173
+ }
70
174
  start() {
71
175
  this.assertOpen();
72
176
  return this.starting ??= (async () => {
73
- await this.sender.initialize();
74
- this.readingStarted = true;
75
- await this.reading.start();
177
+ await this.initialize();
76
178
  this.assertOpen();
77
179
  await this.connection.connect();
78
180
  })().catch(error => { this.starting = undefined; throw error; });
79
181
  }
80
- assertOpen() { if (this.closed)
182
+ /** Initializes local state and background reading sync without opening a
183
+ * second transport when the host already owns calls/presence. */
184
+ initialize() {
185
+ this.assertOpen();
186
+ return this.initializing ??= (async () => {
187
+ await this.sender.initialize();
188
+ this.assertOpen();
189
+ await this.startReading();
190
+ this.assertOpen();
191
+ })().catch(error => { this.initializing = undefined; throw error; });
192
+ }
193
+ startReading() {
194
+ if (this.readingStarted)
195
+ return Promise.resolve();
196
+ return this.readingStarting ??= (async () => {
197
+ const access = await this.messages.scopeAccess();
198
+ if (!access.allowed)
199
+ return;
200
+ this.assertOpen();
201
+ await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
202
+ this.setReadingAllowed(true);
203
+ await this.reading.start();
204
+ this.assertOpen();
205
+ this.readingStarted = true;
206
+ })().finally(() => { this.readingStarting = undefined; });
207
+ }
208
+ /** One-time migration only: never establishes trusted history coverage. */
209
+ importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
210
+ getReadingSnapshot() { return this.closed || this.withdrawn || !this.readingAllowed ? undefined : this.reading.getSnapshot(); }
211
+ subscribeReading(listener) { if (this.closed || this.withdrawn)
212
+ return () => { }; this.readingListeners.add(listener); return () => { this.readingListeners.delete(listener); }; }
213
+ setReadingAllowed(allowed) {
214
+ if (this.readingAllowed === allowed)
215
+ return;
216
+ const previous = this.getReadingSnapshot();
217
+ this.readingAllowed = allowed;
218
+ if (previous !== this.getReadingSnapshot())
219
+ this.readingListeners.forEach(listener => listener());
220
+ this.publishAll();
221
+ }
222
+ readingAuthority() {
223
+ return this.readingAccess ??= this.messages.scopeAccess().then(access => { if (!access.allowed)
224
+ this.setReadingAllowed(false); return access; }).finally(() => { this.readingAccess = undefined; });
225
+ }
226
+ async authorizeReading() {
227
+ const access = await this.readingAuthority();
228
+ if (this.withdrawn || !access.allowed)
229
+ throw new Error("CHAT_ACCESS_REVOKED");
230
+ await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
231
+ this.setReadingAllowed(true);
232
+ }
233
+ readingOperation(run, durable = true) {
234
+ if (this.closed || this.withdrawn)
235
+ return Promise.reject(new Error(this.withdrawn ? "CHAT_ACCESS_REVOKED" : "CHAT_SESSION_CLOSED"));
236
+ const task = (async () => {
237
+ await this.authorizeReading();
238
+ if (this.withdrawn)
239
+ throw new Error("CHAT_ACCESS_REVOKED");
240
+ if (!durable)
241
+ this.assertOpen();
242
+ const next = await run();
243
+ if (this.withdrawn || !(await this.readingAuthority()).allowed)
244
+ throw new Error("CHAT_ACCESS_REVOKED");
245
+ return next;
246
+ })();
247
+ if (durable) {
248
+ this.acceptedReading.add(task);
249
+ void task.finally(() => this.acceptedReading.delete(task)).catch(() => { });
250
+ }
251
+ return task;
252
+ }
253
+ refreshReading() {
254
+ if (this.closed || this.withdrawn)
255
+ return;
256
+ void this.authorizeReading().then(() => { if (!this.closed && !this.withdrawn)
257
+ this.reading.refresh(); }).catch(error => { if (!this.closed && !this.withdrawn)
258
+ this.options.onError(error); });
259
+ }
260
+ synchronizeReading(cursor, profileId) { return this.readingOperation(() => this.reading.synchronize(cursor, profileId), false); }
261
+ /** Explicit text-history acknowledgement; unheard audio stays unread. */
262
+ async markRead(threadId, messages) {
263
+ this.assertOpen();
264
+ const cached = messages ?? this.getSnapshot(threadId).timeline.flatMap(item => {
265
+ if (item.recalled || !item.message)
266
+ return [];
267
+ const candidate = this.options.readingCandidate?.(item.message);
268
+ return candidate ? [{ id: item.id, threadId, from: item.from, mine: item.mine, recalled: item.recalled, readPolicy: candidate.readPolicy }] : [];
269
+ });
270
+ return this.readingOperation(() => this.reading.markRead(threadId, cached));
271
+ }
272
+ updateReading(transform) { return this.readingOperation(() => this.reading.update(transform)); }
273
+ assertOpen() { if (this.withdrawn)
274
+ throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
81
275
  throw new Error("CHAT_SESSION_CLOSED"); }
82
276
  async getMessages(threadId, options = {}) {
83
277
  this.assertOpen();
278
+ if (options.earlier) {
279
+ if (options.before || options.all || options.limit !== undefined || options.refresh)
280
+ throw new Error("INVALID_MESSAGE_QUERY");
281
+ return this.readEarlier(threadId);
282
+ }
84
283
  if (options.all && (options.before || options.limit !== undefined))
85
284
  throw new Error("INVALID_MESSAGE_QUERY");
86
285
  // Explicit authoritative changes (for example history assignment) can invalidate
@@ -91,9 +290,53 @@ export class ChatSession {
91
290
  return this.readAll(threadId);
92
291
  }
93
292
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
94
- await this.sender.confirm(page.messages.map(message => message.id));
293
+ this.assertOpen();
294
+ await this.confirmMessages(page.messages);
295
+ if (this.initializing && !this.readingStarted && page.complete)
296
+ await this.startReading();
297
+ this.assertOpen();
298
+ const edge = this.historyEdges.get(threadId);
299
+ if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
300
+ this.rememberHistoryEdge(threadId, page);
95
301
  return page;
96
302
  }
303
+ async confirmMessages(messages) {
304
+ await this.sender.initialize();
305
+ // Only this profile can create its outgoing wire hashes. Keep the legacy
306
+ // fallback when a generic host has not supplied an author on stored rows.
307
+ const hashes = messages.filter(message => {
308
+ const from = message.from;
309
+ return typeof from !== "string" || !from || from === this.options.messages.scope.profileId;
310
+ }).map(message => message.id);
311
+ return hashes.length ? this.sender.confirm(hashes) : Promise.resolve();
312
+ }
313
+ rememberHistoryEdge(threadId, page) {
314
+ const first = page.messages[0];
315
+ this.historyEdges.set(threadId, { before: first ? { timestamp: first.timestamp, id: first.id } : undefined, version: page.resetVersion, hasEarlier: page.hasEarlier });
316
+ }
317
+ readEarlier(threadId) {
318
+ const running = this.earlierReads.get(threadId);
319
+ if (running)
320
+ return running;
321
+ const task = (async () => {
322
+ const recent = await this.getMessages(threadId);
323
+ if (!recent.complete)
324
+ throw new Error("MESSAGE_WINDOW_INCOMPLETE");
325
+ const edge = this.historyEdges.get(threadId);
326
+ if (!edge.hasEarlier)
327
+ return { ...recent, messages: [], hasEarlier: false };
328
+ if (!edge.before)
329
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
330
+ const page = await this.messages.earlier(threadId, edge.before, 100, true);
331
+ await this.confirmMessages(page.messages);
332
+ this.assertOpen();
333
+ this.rememberHistoryEdge(threadId, page);
334
+ return page;
335
+ })().finally(() => { if (this.earlierReads.get(threadId) === task)
336
+ this.earlierReads.delete(threadId); });
337
+ this.earlierReads.set(threadId, task);
338
+ return task;
339
+ }
97
340
  async readAll(threadId) {
98
341
  for (let attempt = 0; attempt < 3; attempt++) {
99
342
  const pages = [];
@@ -115,12 +358,18 @@ export class ChatSession {
115
358
  break;
116
359
  }
117
360
  pages.push(page.messages);
361
+ if (!page.complete)
362
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
118
363
  }
119
364
  if (restart)
120
365
  continue;
121
- return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
366
+ const result = { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
367
+ await this.messages.expandWindow(threadId, result);
368
+ this.rememberHistoryEdge(threadId, result);
369
+ return result;
122
370
  }
123
371
  catch (error) {
372
+ this.assertOpen();
124
373
  if ([401, 403].includes(error?.status ?? 0))
125
374
  throw error;
126
375
  if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
@@ -132,20 +381,310 @@ export class ChatSession {
132
381
  }
133
382
  throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
134
383
  }
135
- sendMessage(threadId, input) {
384
+ /** Reads trusted local group metadata immediately; SDK refreshes it in the background. */
385
+ async getGroup(thread, options = {}) {
386
+ this.assertOpen();
387
+ if (!this.groups)
388
+ throw new Error("GROUP_METADATA_UNAVAILABLE");
389
+ return this.groups.get(typeof thread === "string" ? await this.resolveConversation(thread) : thread, options);
390
+ }
391
+ async createGroup(thread, content) {
392
+ this.assertOpen();
393
+ if (!this.groups)
394
+ throw new Error("GROUP_METADATA_UNAVAILABLE");
395
+ await this.sendMessage(thread, { groupCreate: content }, { waitFor: "delivered" });
396
+ return this.groups.after(thread, 1);
397
+ }
398
+ async updateGroup(target, changes) {
399
+ const thread = typeof target === "string" ? await this.resolveConversation(target) : target;
400
+ const previous = await this.getGroup(thread);
401
+ if (previous.versionNumber >= Number.MAX_SAFE_INTEGER)
402
+ throw new Error("INVALID_GROUP_VERSION");
403
+ await this.sendMessage(thread, { groupUpdate: { ...changes, expectedRevision: changes.expectedRevision ?? previous.revision } }, { waitFor: "delivered" });
404
+ return this.groups.after(thread, previous.versionNumber + 1);
405
+ }
406
+ sendMessage(thread, input, options = {}) {
407
+ this.assertOpen();
408
+ if (options.waitFor !== undefined && !["accepted", "delivered"].includes(options.waitFor))
409
+ throw new Error("INVALID_MESSAGE_WAIT");
410
+ if (typeof thread === "string" && Object.hasOwn(input, "body"))
411
+ return this.sendWire(thread, input, options.waitFor === "delivered");
412
+ return this.sendContent(thread, input, options.waitFor === "delivered");
413
+ }
414
+ sendWire(threadId, wire, delivered) {
415
+ return (delivered ? this.sender.send(threadId, wire) : this.sender.enqueue(threadId, wire)).then(next => { if (this.withdrawn)
416
+ throw new Error("CHAT_ACCESS_REVOKED"); return next; });
417
+ }
418
+ async getConversation(target) {
419
+ this.assertOpen();
420
+ try {
421
+ return await this.conversations.create(target);
422
+ }
423
+ catch (error) {
424
+ if (error?.message !== "CHAT_ACCESS_REVOKED")
425
+ throw error;
426
+ await this.messages.authorizeScope();
427
+ this.assertOpen();
428
+ return this.conversations.create(target);
429
+ }
430
+ }
431
+ async sendContent(target, input, delivered) {
432
+ let thread = typeof target === "string" ? await this.resolveConversation(target) : target;
433
+ validateConversation(this.options.messages.scope, thread);
434
+ await this.conversations.put(thread);
435
+ this.assertOpen();
436
+ // Recipients are SDK-owned metadata, never an App-supplied roster.
437
+ if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate) {
438
+ const group = await this.getGroup(thread);
439
+ this.assertOpen();
440
+ thread = { ...thread, memberIds: group.memberIds };
441
+ }
442
+ const wire = outgoingContent(this.options.messages.scope, thread, input);
443
+ if (input.recall || input.reaction)
444
+ return this.sendControl({ ...thread, memberIds: wire.recipients }, wire, delivered);
445
+ return this.sendWire(thread.id, wire, delivered);
446
+ }
447
+ async resolveConversation(threadId) {
448
+ const known = await this.conversations.get(threadId);
449
+ if (known)
450
+ return known;
451
+ const inferred = this.getConversationTarget(threadId);
452
+ if (!inferred)
453
+ throw new Error("MESSAGE_CONVERSATION_UNKNOWN");
454
+ return inferred;
455
+ }
456
+ /** Validated target identity survives content redaction; no roster is exposed. */
457
+ getConversationTarget(threadId) {
458
+ const scope = this.options.messages.scope;
459
+ const snapshot = this.getSnapshot(threadId), group = snapshot.group;
460
+ if (group)
461
+ return { id: threadId, kind: "group", targetId: group.groupId, teamId: scope.teamId };
462
+ const infer = (row) => {
463
+ const kind = row.threadKind ?? row.body?.threadKind;
464
+ if ((kind !== "group" && kind !== "dm") || !row.to || !row.from)
465
+ return undefined;
466
+ const thread = { id: threadId, kind, targetId: kind === "group" || row.from === scope.profileId ? row.to : row.from, teamId: scope.teamId };
467
+ try {
468
+ validateConversation(scope, thread);
469
+ return thread;
470
+ }
471
+ catch {
472
+ return undefined;
473
+ }
474
+ };
475
+ for (const value of snapshot.messages) {
476
+ const thread = infer(value);
477
+ if (thread)
478
+ return thread;
479
+ }
480
+ // Older integrations may have a durable send but no registered target or indexed history yet.
481
+ for (const value of snapshot.outgoing) {
482
+ const thread = infer(value.message);
483
+ if (thread)
484
+ return thread;
485
+ }
486
+ return undefined;
487
+ }
488
+ sendControl(thread, wire, delivered = false) {
489
+ const content = wire.body.content;
490
+ const recall = content.recall, reaction = content.reaction;
491
+ const id = recall?.messageId ?? reaction.messageId, key = JSON.stringify([thread.id, id, reaction?.emojiId ?? "recall"]);
492
+ const bytes = JSON.stringify(wire).length * 3 + 1024;
493
+ if (this.controlCount >= 4096 || this.controlBytes + bytes > 64 * 1024 * 1024)
494
+ return Promise.reject(new Error("MESSAGE_CONTROL_BACKPRESSURE"));
495
+ this.controlCount++;
496
+ this.controlBytes += bytes;
497
+ const previous = this.controlTails.get(key) ?? Promise.resolve();
498
+ let delivery;
499
+ const validated = previous.catch(() => { }).then(async () => {
500
+ this.assertOpen();
501
+ const item = this.getSnapshot(thread.id).timeline.find(item => item.id === id);
502
+ const target = (item?.message ?? await this.messages.cachedMessage(thread.id, id));
503
+ this.assertOpen();
504
+ const scope = this.options.messages.scope, from = item?.from ?? target?.from, body = target?.body;
505
+ const wrongScope = target?.threadId !== thread.id || target.appId !== undefined && target.appId !== scope.appId || body?.content?.appId !== undefined && body.content.appId !== scope.appId || body?.threadKind !== undefined && body.threadKind !== thread.kind;
506
+ const unavailable = !target || !from || id.startsWith("local-") || target.recalled || item?.recalled || target.legacyDisplay?.kind === "system" || item?.status === "failed" || item?.status === "sending";
507
+ const ordinary = body && ["message", "quote"].includes(String(body.kind)) && !isReadingControl(body, scope.appId);
508
+ if (wrongScope || unavailable || !ordinary)
509
+ throw new Error("MESSAGE_CONTROL_TARGET_UNAVAILABLE");
510
+ if (recall && from !== scope.profileId)
511
+ throw new Error("MESSAGE_RECALL_NOT_OWNED");
512
+ if (thread.kind === "dm" && from !== scope.profileId && from !== thread.targetId || thread.kind === "group" && (!wire.recipients?.includes(scope.profileId) || !wire.recipients.includes(from)))
513
+ throw new Error("MESSAGE_CONTROL_TARGET_UNAVAILABLE");
514
+ if (reaction) {
515
+ let floor = 0;
516
+ const events = item?.reactionEvents ?? target.reactionEvents ?? target.legacyDisplay?.reactionEvents ?? [];
517
+ for (const event of events)
518
+ if (event.from === scope.profileId && event.messageId === id && event.emojiId === reaction.emojiId && Number.isSafeInteger(event.revision) && event.revision > floor)
519
+ floor = event.revision;
520
+ for (const record of this.sender.snapshot(thread.id)) {
521
+ const envelope = record.message.body, event = envelope?.content?.reaction;
522
+ if (record.message.from === scope.profileId && envelope?.content?.appId === scope.appId && event?.messageId === id && event.emojiId === reaction.emojiId && Number.isSafeInteger(event.revision) && event.revision > floor)
523
+ floor = event.revision;
524
+ }
525
+ wire = { ...wire, reactionRevisionFloor: floor };
526
+ }
527
+ delivery = delivered ? this.sender.send(thread.id, wire) : this.sender.enqueue(thread.id, wire);
528
+ void delivery.catch(() => { });
529
+ });
530
+ const tail = validated.finally(() => { if (this.controlTails.get(key) === tail)
531
+ this.controlTails.delete(key); });
532
+ const task = tail.then(() => delivery).then(message => { if (this.withdrawn)
533
+ throw new Error("CHAT_ACCESS_REVOKED"); return message; }).finally(() => { this.controlCount--; this.controlBytes -= bytes; this.acceptedControls.delete(task); });
534
+ this.controlTails.set(key, tail);
535
+ this.acceptedControls.add(task);
536
+ return task;
537
+ }
538
+ async getReaders(threadId, messageId, page = {}, signal) {
539
+ this.assertOpen();
540
+ const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
136
541
  this.assertOpen();
137
- return this.sender.enqueue(threadId, input);
542
+ if (!result.resetRequired) {
543
+ const previous = this.countSnapshots.get(threadId);
544
+ if (!previous || previous.revision <= result.revision) {
545
+ this.publishCounts({ appId: result.appId, threadId, revision: result.revision, counts: { ...previous?.counts, [messageId]: result.total } });
546
+ this.counts?.changed(threadId, result.revision);
547
+ }
548
+ }
549
+ return result;
138
550
  }
139
- getReaders(threadId, messageId, page = {}, signal) {
551
+ /** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
552
+ async getVoiceAttachment(threadId, messageId) {
140
553
  this.assertOpen();
141
- return this.readers.getReaders({ ...page, threadId, messageId }, signal);
554
+ const item = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
555
+ if (!item || item.recalled)
556
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
557
+ const local = await this.sender.acquireVoice(messageId, threadId);
558
+ try {
559
+ this.assertOpen();
560
+ }
561
+ catch (error) {
562
+ local?.release();
563
+ throw error;
564
+ }
565
+ const stillVisible = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
566
+ if (!stillVisible || stillVisible.recalled) {
567
+ local?.release();
568
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
569
+ }
570
+ if (local)
571
+ return local;
572
+ const current = this.getSnapshot(threadId).timeline.find(item => item.id === messageId);
573
+ const body = current?.message?.body;
574
+ const value = body?.content?.voice?.url;
575
+ if (!current || current.recalled || typeof value !== "string")
576
+ throw new Error("VOICE_ATTACHMENT_UNAVAILABLE");
577
+ const url = new URL(value);
578
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password)
579
+ throw new Error("INVALID_VOICE_ATTACHMENT_URL");
580
+ return { url: value, release: () => { } };
142
581
  }
143
582
  retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
144
- observeMessages(threadId, ids, event) {
583
+ observeMessages(threadId, ids, event, clientInstanceId) {
584
+ if (this.closed || this.withdrawn) {
585
+ if (event === "receipts" && !threadId)
586
+ return Promise.resolve();
587
+ return Promise.reject(new Error(this.withdrawn ? "CHAT_ACCESS_REVOKED" : "CHAT_SESSION_CLOSED"));
588
+ }
589
+ if (event === "receipts") {
590
+ this.watchCounts(threadId, ids, clientInstanceId);
591
+ return Promise.resolve();
592
+ }
593
+ if (!threadId)
594
+ throw new Error("INVALID_READING_THREAD");
595
+ return this.readingOperation(() => event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids));
596
+ }
597
+ /** Bridge transports can delegate social events without maintaining clients. */
598
+ async handleReach(event) {
145
599
  this.assertOpen();
146
- return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
600
+ if (!["social.readCountsChanged", "social.readStateChanged"].includes(event.eventType))
601
+ return undefined;
602
+ if (event.appId !== this.options.messages.scope.appId)
603
+ throw new Error("UNEXPECTED_READING_APPLICATION");
604
+ if (event.eventType === "social.readCountsChanged") {
605
+ if (typeof event.payload.threadId !== "string" || !Number.isSafeInteger(event.payload.revision) || Number(event.payload.revision) < 0)
606
+ throw new Error("INVALID_READ_COUNTS_EVENT");
607
+ this.counts?.changed(event.payload.threadId, Number(event.payload.revision));
608
+ }
609
+ else {
610
+ if (typeof event.payload.syncCursor !== "string" || !/^v1:\d+$/.test(event.payload.syncCursor) || typeof event.payload.profileId !== "string")
611
+ throw new Error("INVALID_READING_EVENT");
612
+ if (event.payload.profileId !== this.options.messages.scope.profileId)
613
+ throw new Error("Reading reach identity mismatch");
614
+ await this.initialize();
615
+ await this.synchronizeReading(event.payload.syncCursor, event.payload.profileId);
616
+ this.assertOpen();
617
+ }
618
+ return "handled";
619
+ }
620
+ wakeCounts() {
621
+ if (this.counts && this.countsThread && !this.closed)
622
+ this.countTasks.schedule("counts", async () => { this.counts?.reconnect(); });
623
+ }
624
+ publishCountSnapshot(thread) {
625
+ const previous = this.snapshots.get(thread);
626
+ if (!previous) {
627
+ this.publish(thread, false);
628
+ return;
629
+ }
630
+ // Receipt-only updates never serialize or rebuild message bodies.
631
+ this.snapshots.set(thread, { ...previous, readCounts: this.countSnapshots.get(thread) });
632
+ this.listeners.get(thread)?.forEach(listener => listener());
633
+ }
634
+ publishCounts(value) {
635
+ if (this.closed || this.withdrawn)
636
+ return;
637
+ const previous = this.countSnapshots.get(value.threadId);
638
+ if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
639
+ return;
640
+ this.countSnapshots.delete(value.threadId);
641
+ this.countSnapshots.set(value.threadId, value);
642
+ if (this.countSnapshots.size > 128) {
643
+ const oldest = this.countSnapshots.keys().next().value;
644
+ this.countSnapshots.delete(oldest);
645
+ this.publishCountSnapshot(oldest);
646
+ }
647
+ this.publishCountSnapshot(value.threadId);
648
+ }
649
+ watchCounts(threadId, ids, receiver = this.connection.identityScope().clientInstanceId) {
650
+ if (!threadId || !ids.length) {
651
+ this.countsThread = undefined;
652
+ this.counts?.watch(undefined, []);
653
+ return;
654
+ }
655
+ if (!receiver || receiver.length > 512 || /[\x00-\x1f]/.test(receiver))
656
+ throw new Error("INVALID_READ_COUNTS_RECEIVER");
657
+ if (!this.counts || this.countsReceiver !== receiver) {
658
+ if (this.counts) {
659
+ const closing = this.counts.close().finally(() => this.countClosings.delete(closing));
660
+ this.countClosings.add(closing);
661
+ }
662
+ const scope = this.options.messages.scope;
663
+ 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,
664
+ onChange: value => { if (!this.closed && this.counts === client)
665
+ this.publishCounts(value); },
666
+ onError: error => {
667
+ if (this.closed || this.counts !== client)
668
+ return;
669
+ if (/READ_COUNTS_HTTP_(401|403)|RESPONSE_CACHE_(ACCESS_REVOKED|SCOPE_CHANGED|INVALIDATED)/.test(String(error))) {
670
+ const threads = [...this.countSnapshots.keys()];
671
+ this.countSnapshots.clear();
672
+ threads.forEach(thread => this.publishCountSnapshot(thread));
673
+ }
674
+ this.options.onError(error);
675
+ } });
676
+ this.counts = client;
677
+ this.countsReceiver = receiver;
678
+ }
679
+ this.countsThread = threadId;
680
+ this.counts.watch(threadId, ids);
147
681
  }
148
682
  getSnapshot(threadId) {
683
+ if (this.closed || this.withdrawn) {
684
+ if (threadId === undefined)
685
+ return this.closedSummary;
686
+ return this.closedThread;
687
+ }
149
688
  if (threadId === undefined) {
150
689
  if (!this.globalSnapshot)
151
690
  this.globalSnapshot = this.buildGlobal();
@@ -153,56 +692,121 @@ export class ChatSession {
153
692
  }
154
693
  let snapshot = this.snapshots.get(threadId);
155
694
  if (!snapshot) {
156
- const messages = this.messages.getSnapshot(threadId);
157
- const state = this.reading.getSnapshot();
158
- 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 } };
695
+ snapshot = this.buildThreadSnapshot(threadId);
159
696
  this.snapshots.set(threadId, snapshot);
160
697
  }
161
698
  return snapshot;
162
699
  }
163
- subscribe(target, listener) {
164
- this.assertOpen();
700
+ buildThreadSnapshot(threadId, previous) {
701
+ const source = this.messages.getSnapshot(threadId), raw = source?.messages ?? [];
702
+ const messages = previous && previous.messages.length === raw.length && previous.messages.every((message, index) => message === raw[index]) ? previous.messages : raw;
703
+ const outgoing = this.sender.snapshot(threadId);
704
+ const sameSource = previous && previous.messages === messages && previous.outgoing.length === outgoing.length && previous.outgoing.every((record, index) => record === outgoing[index]);
705
+ const candidates = new Map(messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [[candidate.id, candidate]] : []; }));
706
+ const semanticReading = previous?.messageReading ?? {};
707
+ 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 });
708
+ for (const item of semantics)
709
+ if (item.recalled)
710
+ candidates.set(item.id, { ...candidates.get(item.id), id: item.id, threadId, from: item.from, mine: item.mine, recalled: true });
711
+ const state = this.getReadingSnapshot();
712
+ const kind = state?.threads.find(thread => (thread.conversationId ?? thread.id) === threadId)?.kind ?? "dm";
713
+ const view = projectConversationReading(state, { id: threadId, kind }, [...candidates.values()]);
714
+ const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
715
+ const messageReading = previous && JSON.stringify(previous.messageReading) === JSON.stringify(view.messageReading) ? previous.messageReading : view.messageReading;
716
+ 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 });
717
+ return { group: this.groups?.snapshot(threadId).data, timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
718
+ }
719
+ subscribe(target, listener, fields) {
720
+ if (typeof target === "function") {
721
+ const notify = () => target(this.getSnapshot());
722
+ const off = this.subscribeChanges(notify);
723
+ try {
724
+ notify();
725
+ }
726
+ catch (error) {
727
+ off();
728
+ throw error;
729
+ }
730
+ return off;
731
+ }
732
+ const notify = () => listener(this.getSnapshot(target));
733
+ const off = this.subscribeChanges(target, notify, fields);
734
+ // The deprecated field-array form retains change-only behavior during migration.
735
+ if (!fields) {
736
+ try {
737
+ notify();
738
+ }
739
+ catch (error) {
740
+ off();
741
+ throw error;
742
+ }
743
+ }
744
+ return off;
745
+ }
746
+ subscribeChanges(target, listener, fields) {
747
+ if (this.closed || this.withdrawn)
748
+ return () => { };
165
749
  if (typeof target === "function") {
166
750
  this.globalListeners.add(target);
167
751
  return () => { this.globalListeners.delete(target); };
168
752
  }
169
753
  const threadId = target;
170
- const notify = listener;
754
+ const selected = fields ? [...fields] : undefined;
755
+ let values = selected?.map(field => this.getSnapshot(threadId)[field]);
756
+ const equal = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]));
757
+ const notify = selected ? () => {
758
+ const next = selected.map(field => this.getSnapshot(threadId)[field]);
759
+ const changed = next.some((value, index) => !equal(value, values[index]));
760
+ values = next;
761
+ if (changed)
762
+ listener();
763
+ } : listener;
171
764
  let listeners = this.listeners.get(threadId);
172
765
  if (!listeners) {
173
766
  listeners = new Set();
174
767
  this.listeners.set(threadId, listeners);
175
768
  }
176
769
  listeners.add(notify);
177
- const off = this.messages.subscribe(threadId, () => { });
770
+ const off = this.messages.subscribe(threadId, () => { }), unpin = (!selected || selected.includes("group")) ? this.groups?.pin(threadId) : undefined;
178
771
  return () => {
179
772
  off();
773
+ unpin?.();
180
774
  listeners.delete(notify);
181
775
  if (!listeners.size)
182
776
  this.listeners.delete(threadId);
183
777
  };
184
778
  }
185
779
  publishAll() {
780
+ if (this.closed || this.withdrawn)
781
+ return;
186
782
  for (const threadId of [...this.snapshots.keys()])
187
783
  this.publish(threadId, false);
188
784
  this.publishGlobal();
189
785
  }
190
786
  buildGlobal() {
191
787
  const ids = new Set(this.snapshots.keys());
192
- for (const thread of this.reading.getSnapshot()?.threads ?? [])
788
+ for (const thread of this.getReadingSnapshot()?.threads ?? [])
193
789
  if (thread.teamId === this.options.messages.scope.teamId)
194
790
  ids.add(thread.conversationId ?? thread.id);
195
791
  for (const outgoing of this.sender.snapshot())
196
792
  ids.add(outgoing.threadId);
197
793
  const threads = [...ids].sort().map(threadId => {
198
794
  const snapshot = this.getSnapshot(threadId);
199
- return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
795
+ return { threadId, group: snapshot.group, lastMessage: snapshot.timeline.at(-1)?.message, lastItem: snapshot.timeline.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
200
796
  });
201
- 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 };
797
+ const state = this.getReadingSnapshot();
798
+ const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
799
+ 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 };
202
800
  }
203
801
  publishGlobal() {
802
+ if (this.closed || this.withdrawn)
803
+ return;
204
804
  const next = this.buildGlobal();
205
- if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
805
+ const previous = this.globalSnapshot;
806
+ 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) => {
807
+ const other = next.threads[index];
808
+ return thread.threadId === other.threadId && thread.group === other.group && thread.lastMessage === other.lastMessage && thread.lastItem === other.lastItem && thread.outgoingCount === other.outgoingCount && JSON.stringify(thread.unread) === JSON.stringify(other.unread);
809
+ }))
206
810
  return;
207
811
  this.globalSnapshot = next;
208
812
  this.globalListeners.forEach(listener => listener());
@@ -210,8 +814,9 @@ export class ChatSession {
210
814
  publish(threadId, notifyGlobal = true) {
211
815
  const previous = this.snapshots.get(threadId);
212
816
  this.snapshots.delete(threadId);
213
- const next = this.getSnapshot(threadId);
214
- if (previous && JSON.stringify(previous) === JSON.stringify(next)) {
817
+ const next = this.buildThreadSnapshot(threadId, previous);
818
+ this.snapshots.set(threadId, next);
819
+ if (previous && previous.group === next.group && 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)) {
215
820
  this.snapshots.set(threadId, previous);
216
821
  return;
217
822
  }
@@ -219,19 +824,101 @@ export class ChatSession {
219
824
  if (notifyGlobal)
220
825
  this.publishGlobal();
221
826
  }
222
- close() {
827
+ /** Permanently retire this identity/team session. UI withdrawal is immediate;
828
+ * completion means all durable stores have crossed the same authority epoch. */
829
+ revokeAccess() {
830
+ if (this.revoking)
831
+ return this.revoking;
832
+ if (this.closed)
833
+ return Promise.reject(new Error("CHAT_SESSION_CLOSED"));
834
+ this.withdrawn = true;
835
+ const knownThreads = [...new Set([...this.snapshots.keys(), ...this.historyEdges.keys()])];
836
+ this.catchUp.close();
837
+ this.countTasks.close();
838
+ this.off.splice(0).forEach(off => off());
839
+ this.sender.withdraw();
840
+ this.reading.withdraw();
841
+ this.readers.withdraw();
842
+ this.groups?.withdraw();
843
+ this.counts?.watch(undefined, []);
844
+ this.countSnapshots.clear();
845
+ this.snapshots.clear();
846
+ this.timelineDecoded = new WeakMap();
847
+ this.historyEdges.clear();
848
+ this.earlierReads.clear();
849
+ this.controlTails.clear();
850
+ this.globalSnapshot = undefined;
851
+ this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
852
+ this.globalListeners.forEach(listener => listener());
853
+ this.readingListeners.forEach(listener => listener());
854
+ if (!this.options.sharedConnection)
855
+ this.connection.close();
856
+ return this.revoking = Promise.resolve().then(async () => {
857
+ const failures = [];
858
+ let version;
859
+ try {
860
+ version = await this.messages.revokeScope();
861
+ }
862
+ catch (error) {
863
+ failures.push(error);
864
+ version = 1;
865
+ try {
866
+ version = Math.max(1, (await this.messages.scopeAccess()).version);
867
+ }
868
+ catch (failure) {
869
+ failures.push(failure);
870
+ }
871
+ }
872
+ const results = await Promise.allSettled([
873
+ this.sender.revoke(version),
874
+ this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
875
+ this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version), this.conversations.retire(version),
876
+ ]);
877
+ for (const result of results)
878
+ if (result.status === "rejected")
879
+ failures.push(result.reason);
880
+ try {
881
+ await this.dispose();
882
+ }
883
+ catch (error) {
884
+ failures.push(error);
885
+ }
886
+ if (failures.length)
887
+ throw new AggregateError(failures, "CHAT_ACCESS_CLEANUP_FAILED");
888
+ });
889
+ }
890
+ get active() { return !this.closed && !this.withdrawn; }
891
+ close() { return this.revoking ?? this.dispose(); }
892
+ dispose() {
223
893
  return this.closing ??= (async () => {
224
894
  this.closed = true;
225
895
  this.catchUp.close();
896
+ this.countTasks.close();
226
897
  this.off.splice(0).forEach(off => off());
227
898
  if (!this.options.sharedConnection)
228
899
  this.connection.close();
229
- this.readers.close();
230
- await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
231
- this.listeners.clear();
232
- this.globalListeners.clear();
233
- this.snapshots.clear();
234
- this.globalSnapshot = undefined;
900
+ const senderClosing = this.sender.close(), groupsClosing = this.groups?.close();
901
+ const readingBarrier = Promise.allSettled([...this.acceptedReading, ...this.acceptedControls]);
902
+ const readingClosing = readingBarrier.then(() => this.reading.close());
903
+ try {
904
+ const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), this.conversations.close(), groupsClosing, senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, groupsClosing, readingBarrier]))]);
905
+ const failed = results.find((result) => result.status === "rejected");
906
+ if (failed)
907
+ throw failed.reason;
908
+ }
909
+ finally {
910
+ this.options.onDispose?.();
911
+ this.listeners.clear();
912
+ this.globalListeners.clear();
913
+ this.readingListeners.clear();
914
+ this.snapshots.clear();
915
+ this.timelineDecoded = new WeakMap();
916
+ this.countSnapshots.clear();
917
+ this.historyEdges.clear();
918
+ this.earlierReads.clear();
919
+ this.controlTails.clear();
920
+ this.globalSnapshot = undefined;
921
+ }
235
922
  })();
236
923
  }
237
924
  }