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