@pmate/chat-sdk 0.12.0-next.8 → 0.12.0-next.81

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