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