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