@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.21
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 +62 -3
- package/lib/runtime/batchedReadIndex.d.ts +11 -2
- package/lib/runtime/batchedReadIndex.js +21 -1
- package/lib/runtime/chatClient.d.ts +8 -0
- package/lib/runtime/chatClient.js +8 -2
- package/lib/runtime/chatSession.d.ts +79 -7
- package/lib/runtime/chatSession.js +439 -30
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/createChat.d.ts +26 -0
- package/lib/runtime/createChat.js +42 -0
- package/lib/runtime/durableSender.d.ts +28 -4
- package/lib/runtime/durableSender.js +158 -35
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/messageClient.d.ts +39 -6
- package/lib/runtime/messageClient.js +448 -69
- package/lib/runtime/messageReaders.d.ts +12 -1
- package/lib/runtime/messageReaders.js +64 -3
- package/lib/runtime/messageStore.d.ts +37 -2
- package/lib/runtime/messageStore.js +150 -10
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +85 -7
- package/lib/runtime/readIndex.d.ts +26 -3
- package/lib/runtime/readIndex.js +75 -6
- package/lib/runtime/readSync.js +12 -4
- package/lib/runtime/readingClient.d.ts +15 -0
- package/lib/runtime/readingClient.js +83 -12
- 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,5 @@
|
|
|
1
|
+
import { ReadCountsClient } from "./readCounts.js";
|
|
2
|
+
import { WindowedTasks } from "./coalescer.js";
|
|
1
3
|
import { MessageReadersClient } from "./messageReaders.js";
|
|
2
4
|
import { MessageClient } from "./messageClient.js";
|
|
3
5
|
import { DurableChatSender } from "./durableSender.js";
|
|
@@ -11,102 +13,432 @@ export class ChatSession {
|
|
|
11
13
|
connection;
|
|
12
14
|
reading;
|
|
13
15
|
readers;
|
|
16
|
+
counts;
|
|
17
|
+
countsReceiver;
|
|
18
|
+
countsThread;
|
|
19
|
+
countSnapshots = new Map();
|
|
20
|
+
countClosings = new Set();
|
|
21
|
+
countTasks;
|
|
14
22
|
sender;
|
|
15
23
|
listeners = new Map();
|
|
16
24
|
snapshots = new Map();
|
|
25
|
+
historyEdges = new Map();
|
|
26
|
+
earlierReads = new Map();
|
|
17
27
|
off = [];
|
|
28
|
+
globalListeners = new Set();
|
|
29
|
+
globalSnapshot;
|
|
30
|
+
closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined };
|
|
31
|
+
closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
|
|
32
|
+
connectionState = "closed";
|
|
33
|
+
catchUp;
|
|
34
|
+
readingStarted = false;
|
|
35
|
+
readingStarting;
|
|
36
|
+
withdrawn = false;
|
|
37
|
+
revoking;
|
|
18
38
|
closed = false;
|
|
19
39
|
starting;
|
|
40
|
+
initializing;
|
|
20
41
|
closing;
|
|
21
42
|
constructor(options) {
|
|
22
43
|
this.options = options;
|
|
23
44
|
const scope = options.messages.scope;
|
|
24
45
|
if (options.connection.appId !== scope.appId || options.reading.appId !== scope.appId || options.reading.profileId !== scope.profileId || options.connection.profileId !== scope.profileId || options.connection.teamId !== scope.teamId)
|
|
25
46
|
throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
|
|
47
|
+
if (options.sharedConnection) {
|
|
48
|
+
const shared = options.sharedConnection.identityScope();
|
|
49
|
+
if (shared.appId !== scope.appId || shared.profileId !== scope.profileId || (shared.accountId !== undefined && shared.accountId !== scope.accountId) || (shared.teamId !== undefined && shared.teamId !== scope.teamId))
|
|
50
|
+
throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
|
|
51
|
+
}
|
|
26
52
|
this.messages = new MessageClient(options.messages);
|
|
27
|
-
this.connection = new ChatClient(options.connection)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
this.connection = options.sharedConnection ?? new ChatClient({ ...options.connection, onReach: async (event) => {
|
|
54
|
+
const handled = await this.handleReach(event);
|
|
55
|
+
if (handled)
|
|
56
|
+
return handled;
|
|
57
|
+
if (options.connection.onReach)
|
|
58
|
+
return options.connection.onReach(event);
|
|
59
|
+
throw new Error("REACH_HANDLER_UNAVAILABLE");
|
|
60
|
+
} });
|
|
61
|
+
this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
|
|
62
|
+
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 });
|
|
63
|
+
this.sender = new DurableChatSender(this.connection, scope, options.onError, { access: () => this.messages.scopeAccess() });
|
|
64
|
+
this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
|
|
65
|
+
options.onError(error); });
|
|
66
|
+
this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
|
|
67
|
+
options.onError(error); });
|
|
68
|
+
if (typeof window !== "undefined") {
|
|
69
|
+
const wake = () => this.wakeCounts();
|
|
70
|
+
window.addEventListener("online", wake);
|
|
71
|
+
window.addEventListener("focus", wake);
|
|
72
|
+
this.off.push(() => { window.removeEventListener("online", wake); window.removeEventListener("focus", wake); });
|
|
73
|
+
}
|
|
74
|
+
this.off.push(this.messages.subscribeAccessRevoked(() => { if (!this.withdrawn && !this.closed)
|
|
75
|
+
void this.revokeAccess().catch(error => options.onError(error)); }));
|
|
76
|
+
this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
|
|
31
77
|
this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
|
|
32
78
|
this.off.push(this.connection.watchMessages(message => {
|
|
33
79
|
try {
|
|
34
80
|
const decoded = options.decode(message);
|
|
35
81
|
if (decoded)
|
|
36
|
-
void this.messages.receive(decoded.threadId, [decoded]).then(() => {
|
|
82
|
+
void this.messages.receive(decoded.threadId, [decoded]).then(async () => {
|
|
83
|
+
if (!this.closed && this.readingStarted)
|
|
84
|
+
this.reading.refresh();
|
|
85
|
+
await this.sender.confirm([message.hash]);
|
|
86
|
+
}).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
87
|
+
this.messages.invalidate(decoded.threadId);
|
|
88
|
+
else if (!this.closed)
|
|
89
|
+
options.onError(error); });
|
|
37
90
|
}
|
|
38
91
|
catch (error) {
|
|
39
92
|
options.onError(error);
|
|
40
93
|
}
|
|
41
94
|
}));
|
|
42
95
|
this.off.push(this.connection.watchConnection(state => {
|
|
96
|
+
this.connectionState = state;
|
|
97
|
+
this.publishGlobal();
|
|
43
98
|
if (state === "connected") {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.
|
|
99
|
+
const since = Date.now();
|
|
100
|
+
this.catchUp.schedule("scope", () => this.messages.restore(since, options.activeThreadId));
|
|
101
|
+
if (this.readingStarted)
|
|
102
|
+
this.reading.refresh();
|
|
103
|
+
this.wakeCounts();
|
|
47
104
|
}
|
|
48
105
|
}));
|
|
49
106
|
}
|
|
50
107
|
start() {
|
|
51
108
|
this.assertOpen();
|
|
52
109
|
return this.starting ??= (async () => {
|
|
53
|
-
await this.
|
|
54
|
-
await this.reading.start();
|
|
110
|
+
await this.initialize();
|
|
55
111
|
this.assertOpen();
|
|
56
112
|
await this.connection.connect();
|
|
57
113
|
})().catch(error => { this.starting = undefined; throw error; });
|
|
58
114
|
}
|
|
59
|
-
|
|
115
|
+
/** Initializes local state and background reading sync without opening a
|
|
116
|
+
* second transport when the host already owns calls/presence. */
|
|
117
|
+
initialize() {
|
|
118
|
+
this.assertOpen();
|
|
119
|
+
return this.initializing ??= (async () => {
|
|
120
|
+
await this.sender.initialize();
|
|
121
|
+
this.assertOpen();
|
|
122
|
+
await this.startReading();
|
|
123
|
+
this.assertOpen();
|
|
124
|
+
})().catch(error => { this.initializing = undefined; throw error; });
|
|
125
|
+
}
|
|
126
|
+
startReading() {
|
|
127
|
+
if (this.readingStarted)
|
|
128
|
+
return Promise.resolve();
|
|
129
|
+
return this.readingStarting ??= (async () => {
|
|
130
|
+
const access = await this.messages.scopeAccess();
|
|
131
|
+
if (!access.allowed)
|
|
132
|
+
return;
|
|
133
|
+
this.assertOpen();
|
|
134
|
+
await this.reading.prepareTeam(this.options.messages.scope.teamId, access.version);
|
|
135
|
+
await this.reading.start();
|
|
136
|
+
this.assertOpen();
|
|
137
|
+
this.readingStarted = true;
|
|
138
|
+
})().finally(() => { this.readingStarting = undefined; });
|
|
139
|
+
}
|
|
140
|
+
/** One-time migration only: never establishes trusted history coverage. */
|
|
141
|
+
importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
|
|
142
|
+
getReadingSnapshot() { return this.closed || this.withdrawn ? undefined : this.reading.getSnapshot(); }
|
|
143
|
+
subscribeReading(listener) { this.assertOpen(); return this.reading.subscribe(listener); }
|
|
144
|
+
refreshReading() { this.assertOpen(); this.reading.refresh(); }
|
|
145
|
+
synchronizeReading(cursor, profileId) { this.assertOpen(); return this.reading.synchronize(cursor, profileId); }
|
|
146
|
+
/** Explicit text-history acknowledgement; unheard audio stays unread. */
|
|
147
|
+
markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages); }
|
|
148
|
+
updateReading(transform) { this.assertOpen(); return this.reading.update(transform); }
|
|
149
|
+
assertOpen() { if (this.withdrawn)
|
|
150
|
+
throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
|
|
60
151
|
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
61
152
|
async getMessages(threadId, options = {}) {
|
|
62
153
|
this.assertOpen();
|
|
154
|
+
if (options.earlier) {
|
|
155
|
+
if (options.before || options.all || options.limit !== undefined || options.refresh)
|
|
156
|
+
throw new Error("INVALID_MESSAGE_QUERY");
|
|
157
|
+
return this.readEarlier(threadId);
|
|
158
|
+
}
|
|
159
|
+
if (options.all && (options.before || options.limit !== undefined))
|
|
160
|
+
throw new Error("INVALID_MESSAGE_QUERY");
|
|
161
|
+
// Explicit authoritative changes (for example history assignment) can invalidate
|
|
162
|
+
// even a recently cached empty window. Keep this on the existing read surface.
|
|
163
|
+
if (options.refresh)
|
|
164
|
+
await this.messages.read(threadId, options.limit ?? 100, true);
|
|
165
|
+
if (options.all) {
|
|
166
|
+
return this.readAll(threadId);
|
|
167
|
+
}
|
|
63
168
|
const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
|
|
64
|
-
|
|
65
|
-
|
|
169
|
+
this.assertOpen();
|
|
170
|
+
await this.sender.confirm(page.messages.map(message => message.id));
|
|
171
|
+
if (this.initializing && !this.readingStarted && page.complete)
|
|
172
|
+
await this.startReading();
|
|
173
|
+
const edge = this.historyEdges.get(threadId);
|
|
174
|
+
if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
|
|
175
|
+
this.rememberHistoryEdge(threadId, page);
|
|
66
176
|
return page;
|
|
67
177
|
}
|
|
178
|
+
rememberHistoryEdge(threadId, page) {
|
|
179
|
+
const first = page.messages[0];
|
|
180
|
+
this.historyEdges.set(threadId, { before: first ? { timestamp: first.timestamp, id: first.id } : undefined, version: page.resetVersion, hasEarlier: page.hasEarlier });
|
|
181
|
+
}
|
|
182
|
+
readEarlier(threadId) {
|
|
183
|
+
const running = this.earlierReads.get(threadId);
|
|
184
|
+
if (running)
|
|
185
|
+
return running;
|
|
186
|
+
const task = (async () => {
|
|
187
|
+
const recent = await this.getMessages(threadId);
|
|
188
|
+
if (!recent.complete)
|
|
189
|
+
throw new Error("MESSAGE_WINDOW_INCOMPLETE");
|
|
190
|
+
const edge = this.historyEdges.get(threadId);
|
|
191
|
+
if (!edge.hasEarlier)
|
|
192
|
+
return { ...recent, messages: [], hasEarlier: false };
|
|
193
|
+
if (!edge.before)
|
|
194
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
195
|
+
const page = await this.getMessages(threadId, { before: edge.before });
|
|
196
|
+
this.assertOpen();
|
|
197
|
+
this.rememberHistoryEdge(threadId, page);
|
|
198
|
+
return page;
|
|
199
|
+
})().finally(() => { if (this.earlierReads.get(threadId) === task)
|
|
200
|
+
this.earlierReads.delete(threadId); });
|
|
201
|
+
this.earlierReads.set(threadId, task);
|
|
202
|
+
return task;
|
|
203
|
+
}
|
|
204
|
+
async readAll(threadId) {
|
|
205
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
206
|
+
const pages = [];
|
|
207
|
+
let version = 0;
|
|
208
|
+
try {
|
|
209
|
+
let page = await this.getMessages(threadId);
|
|
210
|
+
version = page.resetVersion;
|
|
211
|
+
pages.push(page.messages);
|
|
212
|
+
if (!page.complete)
|
|
213
|
+
return { ...page, hasEarlier: true };
|
|
214
|
+
let restart = false;
|
|
215
|
+
while (page.hasEarlier) {
|
|
216
|
+
const first = page.messages[0];
|
|
217
|
+
if (!first)
|
|
218
|
+
throw new Error("MESSAGE_SYNC_NO_PROGRESS");
|
|
219
|
+
page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
|
|
220
|
+
if (page.resetVersion !== version) {
|
|
221
|
+
restart = true;
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
pages.push(page.messages);
|
|
225
|
+
if (!page.complete)
|
|
226
|
+
return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
|
|
227
|
+
}
|
|
228
|
+
if (restart)
|
|
229
|
+
continue;
|
|
230
|
+
return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
if ([401, 403].includes(error?.status ?? 0))
|
|
234
|
+
throw error;
|
|
235
|
+
if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
|
|
236
|
+
continue;
|
|
237
|
+
if (pages.length)
|
|
238
|
+
return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
|
|
239
|
+
throw error;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
243
|
+
}
|
|
68
244
|
sendMessage(threadId, input) {
|
|
69
245
|
this.assertOpen();
|
|
70
246
|
return this.sender.enqueue(threadId, input);
|
|
71
247
|
}
|
|
72
|
-
getReaders(threadId, messageId, page = {}, signal) {
|
|
248
|
+
async getReaders(threadId, messageId, page = {}, signal) {
|
|
73
249
|
this.assertOpen();
|
|
74
|
-
|
|
250
|
+
const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
|
|
251
|
+
this.assertOpen();
|
|
252
|
+
if (!result.resetRequired) {
|
|
253
|
+
const previous = this.countSnapshots.get(threadId);
|
|
254
|
+
if (!previous || previous.revision <= result.revision) {
|
|
255
|
+
this.publishCounts({ appId: result.appId, threadId, revision: result.revision, counts: { ...previous?.counts, [messageId]: result.total } });
|
|
256
|
+
this.counts?.changed(threadId, result.revision);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return result;
|
|
75
260
|
}
|
|
76
261
|
retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
|
|
77
|
-
observeMessages(threadId, ids, event) {
|
|
262
|
+
observeMessages(threadId, ids, event, clientInstanceId) {
|
|
263
|
+
if (this.closed && event === "receipts" && !threadId)
|
|
264
|
+
return Promise.resolve();
|
|
78
265
|
this.assertOpen();
|
|
266
|
+
if (event === "receipts") {
|
|
267
|
+
this.watchCounts(threadId, ids, clientInstanceId);
|
|
268
|
+
return Promise.resolve();
|
|
269
|
+
}
|
|
270
|
+
if (!threadId)
|
|
271
|
+
throw new Error("INVALID_READING_THREAD");
|
|
79
272
|
return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
|
|
80
273
|
}
|
|
274
|
+
/** Bridge transports can delegate social events without maintaining clients. */
|
|
275
|
+
async handleReach(event) {
|
|
276
|
+
this.assertOpen();
|
|
277
|
+
if (!["social.readCountsChanged", "social.readStateChanged"].includes(event.eventType))
|
|
278
|
+
return undefined;
|
|
279
|
+
if (event.appId !== this.options.messages.scope.appId)
|
|
280
|
+
throw new Error("UNEXPECTED_READING_APPLICATION");
|
|
281
|
+
if (event.eventType === "social.readCountsChanged") {
|
|
282
|
+
if (typeof event.payload.threadId !== "string" || !Number.isSafeInteger(event.payload.revision) || Number(event.payload.revision) < 0)
|
|
283
|
+
throw new Error("INVALID_READ_COUNTS_EVENT");
|
|
284
|
+
this.counts?.changed(event.payload.threadId, Number(event.payload.revision));
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
if (typeof event.payload.syncCursor !== "string" || !/^v1:\d+$/.test(event.payload.syncCursor) || typeof event.payload.profileId !== "string")
|
|
288
|
+
throw new Error("INVALID_READING_EVENT");
|
|
289
|
+
if (event.payload.profileId !== this.options.messages.scope.profileId)
|
|
290
|
+
throw new Error("Reading reach identity mismatch");
|
|
291
|
+
await this.initialize();
|
|
292
|
+
await this.reading.synchronize(event.payload.syncCursor, event.payload.profileId);
|
|
293
|
+
this.assertOpen();
|
|
294
|
+
}
|
|
295
|
+
return "handled";
|
|
296
|
+
}
|
|
297
|
+
wakeCounts() {
|
|
298
|
+
if (this.counts && this.countsThread && !this.closed)
|
|
299
|
+
this.countTasks.schedule("counts", async () => { this.counts?.reconnect(); });
|
|
300
|
+
}
|
|
301
|
+
publishCountSnapshot(thread) {
|
|
302
|
+
const previous = this.snapshots.get(thread);
|
|
303
|
+
if (!previous) {
|
|
304
|
+
this.publish(thread, false);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// Receipt-only updates never serialize or rebuild message bodies.
|
|
308
|
+
this.snapshots.set(thread, { ...previous, readCounts: this.countSnapshots.get(thread) });
|
|
309
|
+
this.listeners.get(thread)?.forEach(listener => listener());
|
|
310
|
+
}
|
|
311
|
+
publishCounts(value) {
|
|
312
|
+
if (this.closed || this.withdrawn)
|
|
313
|
+
return;
|
|
314
|
+
const previous = this.countSnapshots.get(value.threadId);
|
|
315
|
+
if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
|
|
316
|
+
return;
|
|
317
|
+
this.countSnapshots.delete(value.threadId);
|
|
318
|
+
this.countSnapshots.set(value.threadId, value);
|
|
319
|
+
if (this.countSnapshots.size > 128) {
|
|
320
|
+
const oldest = this.countSnapshots.keys().next().value;
|
|
321
|
+
this.countSnapshots.delete(oldest);
|
|
322
|
+
this.publishCountSnapshot(oldest);
|
|
323
|
+
}
|
|
324
|
+
this.publishCountSnapshot(value.threadId);
|
|
325
|
+
}
|
|
326
|
+
watchCounts(threadId, ids, receiver = this.connection.identityScope().clientInstanceId) {
|
|
327
|
+
if (!threadId || !ids.length) {
|
|
328
|
+
this.countsThread = undefined;
|
|
329
|
+
this.counts?.watch(undefined, []);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (!receiver || receiver.length > 512 || /[\x00-\x1f]/.test(receiver))
|
|
333
|
+
throw new Error("INVALID_READ_COUNTS_RECEIVER");
|
|
334
|
+
if (!this.counts || this.countsReceiver !== receiver) {
|
|
335
|
+
if (this.counts) {
|
|
336
|
+
const closing = this.counts.close().finally(() => this.countClosings.delete(closing));
|
|
337
|
+
this.countClosings.add(closing);
|
|
338
|
+
}
|
|
339
|
+
const scope = this.options.messages.scope;
|
|
340
|
+
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,
|
|
341
|
+
onChange: value => { if (!this.closed && this.counts === client)
|
|
342
|
+
this.publishCounts(value); },
|
|
343
|
+
onError: error => {
|
|
344
|
+
if (this.closed || this.counts !== client)
|
|
345
|
+
return;
|
|
346
|
+
if (/READ_COUNTS_HTTP_(401|403)|RESPONSE_CACHE_(ACCESS_REVOKED|SCOPE_CHANGED|INVALIDATED)/.test(String(error))) {
|
|
347
|
+
const threads = [...this.countSnapshots.keys()];
|
|
348
|
+
this.countSnapshots.clear();
|
|
349
|
+
threads.forEach(thread => this.publishCountSnapshot(thread));
|
|
350
|
+
}
|
|
351
|
+
this.options.onError(error);
|
|
352
|
+
} });
|
|
353
|
+
this.counts = client;
|
|
354
|
+
this.countsReceiver = receiver;
|
|
355
|
+
}
|
|
356
|
+
this.countsThread = threadId;
|
|
357
|
+
this.counts.watch(threadId, ids);
|
|
358
|
+
}
|
|
81
359
|
getSnapshot(threadId) {
|
|
360
|
+
if (this.closed || this.withdrawn) {
|
|
361
|
+
if (threadId === undefined)
|
|
362
|
+
return this.closedSummary;
|
|
363
|
+
return this.closedThread;
|
|
364
|
+
}
|
|
365
|
+
if (threadId === undefined) {
|
|
366
|
+
if (!this.globalSnapshot)
|
|
367
|
+
this.globalSnapshot = this.buildGlobal();
|
|
368
|
+
return this.globalSnapshot;
|
|
369
|
+
}
|
|
82
370
|
let snapshot = this.snapshots.get(threadId);
|
|
83
371
|
if (!snapshot) {
|
|
84
372
|
const messages = this.messages.getSnapshot(threadId);
|
|
85
373
|
const state = this.reading.getSnapshot();
|
|
86
|
-
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) :
|
|
374
|
+
snapshot = { messages: messages?.messages ?? [], hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing: this.sender.snapshot(threadId), readCounts: this.countSnapshots.get(threadId), unread: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : undefined };
|
|
87
375
|
this.snapshots.set(threadId, snapshot);
|
|
88
376
|
}
|
|
89
377
|
return snapshot;
|
|
90
378
|
}
|
|
91
|
-
subscribe(
|
|
379
|
+
subscribe(target, listener, fields) {
|
|
92
380
|
this.assertOpen();
|
|
381
|
+
if (typeof target === "function") {
|
|
382
|
+
this.globalListeners.add(target);
|
|
383
|
+
return () => { this.globalListeners.delete(target); };
|
|
384
|
+
}
|
|
385
|
+
const threadId = target;
|
|
386
|
+
const selected = fields ? [...fields] : undefined;
|
|
387
|
+
let values = selected?.map(field => this.getSnapshot(threadId)[field]);
|
|
388
|
+
const equal = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]));
|
|
389
|
+
const notify = selected ? () => {
|
|
390
|
+
const next = selected.map(field => this.getSnapshot(threadId)[field]);
|
|
391
|
+
const changed = next.some((value, index) => !equal(value, values[index]));
|
|
392
|
+
values = next;
|
|
393
|
+
if (changed)
|
|
394
|
+
listener();
|
|
395
|
+
} : listener;
|
|
93
396
|
let listeners = this.listeners.get(threadId);
|
|
94
397
|
if (!listeners) {
|
|
95
398
|
listeners = new Set();
|
|
96
399
|
this.listeners.set(threadId, listeners);
|
|
97
400
|
}
|
|
98
|
-
listeners.add(
|
|
99
|
-
const off = this.messages.subscribe(threadId, () =>
|
|
401
|
+
listeners.add(notify);
|
|
402
|
+
const off = this.messages.subscribe(threadId, () => { });
|
|
100
403
|
return () => {
|
|
101
404
|
off();
|
|
102
|
-
listeners.delete(
|
|
405
|
+
listeners.delete(notify);
|
|
103
406
|
if (!listeners.size)
|
|
104
407
|
this.listeners.delete(threadId);
|
|
105
408
|
};
|
|
106
409
|
}
|
|
107
|
-
publishAll() {
|
|
108
|
-
this.
|
|
109
|
-
|
|
410
|
+
publishAll() {
|
|
411
|
+
if (this.closed || this.withdrawn)
|
|
412
|
+
return;
|
|
413
|
+
for (const threadId of [...this.snapshots.keys()])
|
|
414
|
+
this.publish(threadId, false);
|
|
415
|
+
this.publishGlobal();
|
|
416
|
+
}
|
|
417
|
+
buildGlobal() {
|
|
418
|
+
const ids = new Set(this.snapshots.keys());
|
|
419
|
+
for (const thread of this.reading.getSnapshot()?.threads ?? [])
|
|
420
|
+
if (thread.teamId === this.options.messages.scope.teamId)
|
|
421
|
+
ids.add(thread.conversationId ?? thread.id);
|
|
422
|
+
for (const outgoing of this.sender.snapshot())
|
|
423
|
+
ids.add(outgoing.threadId);
|
|
424
|
+
const threads = [...ids].sort().map(threadId => {
|
|
425
|
+
const snapshot = this.getSnapshot(threadId);
|
|
426
|
+
return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
|
|
427
|
+
});
|
|
428
|
+
const state = this.reading.getSnapshot();
|
|
429
|
+
const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
|
|
430
|
+
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 };
|
|
431
|
+
}
|
|
432
|
+
publishGlobal() {
|
|
433
|
+
if (this.closed || this.withdrawn)
|
|
434
|
+
return;
|
|
435
|
+
const next = this.buildGlobal();
|
|
436
|
+
if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
|
|
437
|
+
return;
|
|
438
|
+
this.globalSnapshot = next;
|
|
439
|
+
this.globalListeners.forEach(listener => listener());
|
|
440
|
+
}
|
|
441
|
+
publish(threadId, notifyGlobal = true) {
|
|
110
442
|
const previous = this.snapshots.get(threadId);
|
|
111
443
|
this.snapshots.delete(threadId);
|
|
112
444
|
const next = this.getSnapshot(threadId);
|
|
@@ -115,16 +447,93 @@ export class ChatSession {
|
|
|
115
447
|
return;
|
|
116
448
|
}
|
|
117
449
|
this.listeners.get(threadId)?.forEach(listener => listener());
|
|
450
|
+
if (notifyGlobal)
|
|
451
|
+
this.publishGlobal();
|
|
118
452
|
}
|
|
119
|
-
|
|
453
|
+
/** Permanently retire this identity/team session. UI withdrawal is immediate;
|
|
454
|
+
* completion means all durable stores have crossed the same authority epoch. */
|
|
455
|
+
revokeAccess() {
|
|
456
|
+
if (this.revoking)
|
|
457
|
+
return this.revoking;
|
|
458
|
+
if (this.closed)
|
|
459
|
+
return Promise.reject(new Error("CHAT_SESSION_CLOSED"));
|
|
460
|
+
this.withdrawn = true;
|
|
461
|
+
const knownThreads = [...new Set([...this.snapshots.keys(), ...this.historyEdges.keys()])];
|
|
462
|
+
this.catchUp.close();
|
|
463
|
+
this.countTasks.close();
|
|
464
|
+
this.off.splice(0).forEach(off => off());
|
|
465
|
+
this.sender.withdraw();
|
|
466
|
+
this.reading.withdraw();
|
|
467
|
+
this.readers.withdraw();
|
|
468
|
+
this.counts?.watch(undefined, []);
|
|
469
|
+
this.countSnapshots.clear();
|
|
470
|
+
this.snapshots.clear();
|
|
471
|
+
this.historyEdges.clear();
|
|
472
|
+
this.earlierReads.clear();
|
|
473
|
+
this.globalSnapshot = undefined;
|
|
474
|
+
this.listeners.forEach(listeners => listeners.forEach(listener => listener()));
|
|
475
|
+
this.globalListeners.forEach(listener => listener());
|
|
476
|
+
if (!this.options.sharedConnection)
|
|
477
|
+
this.connection.close();
|
|
478
|
+
return this.revoking = Promise.resolve().then(async () => {
|
|
479
|
+
const failures = [];
|
|
480
|
+
let version;
|
|
481
|
+
try {
|
|
482
|
+
version = await this.messages.revokeScope();
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
failures.push(error);
|
|
486
|
+
version = 1;
|
|
487
|
+
try {
|
|
488
|
+
version = Math.max(1, (await this.messages.scopeAccess()).version);
|
|
489
|
+
}
|
|
490
|
+
catch (failure) {
|
|
491
|
+
failures.push(failure);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const results = await Promise.allSettled([
|
|
495
|
+
this.sender.revoke(version),
|
|
496
|
+
this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
|
|
497
|
+
this.readers.retireScope(version), this.counts?.retireScope(version),
|
|
498
|
+
]);
|
|
499
|
+
for (const result of results)
|
|
500
|
+
if (result.status === "rejected")
|
|
501
|
+
failures.push(result.reason);
|
|
502
|
+
try {
|
|
503
|
+
await this.dispose();
|
|
504
|
+
}
|
|
505
|
+
catch (error) {
|
|
506
|
+
failures.push(error);
|
|
507
|
+
}
|
|
508
|
+
if (failures.length)
|
|
509
|
+
throw new AggregateError(failures, "CHAT_ACCESS_CLEANUP_FAILED");
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
close() { return this.revoking ?? this.dispose(); }
|
|
513
|
+
dispose() {
|
|
120
514
|
return this.closing ??= (async () => {
|
|
121
515
|
this.closed = true;
|
|
516
|
+
this.catchUp.close();
|
|
517
|
+
this.countTasks.close();
|
|
122
518
|
this.off.splice(0).forEach(off => off());
|
|
123
|
-
this.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
519
|
+
if (!this.options.sharedConnection)
|
|
520
|
+
this.connection.close();
|
|
521
|
+
const senderClosing = this.sender.close();
|
|
522
|
+
try {
|
|
523
|
+
const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), senderClosing, this.reading.close(), this.messages.close(senderClosing)]);
|
|
524
|
+
const failed = results.find((result) => result.status === "rejected");
|
|
525
|
+
if (failed)
|
|
526
|
+
throw failed.reason;
|
|
527
|
+
}
|
|
528
|
+
finally {
|
|
529
|
+
this.listeners.clear();
|
|
530
|
+
this.globalListeners.clear();
|
|
531
|
+
this.snapshots.clear();
|
|
532
|
+
this.countSnapshots.clear();
|
|
533
|
+
this.historyEdges.clear();
|
|
534
|
+
this.earlierReads.clear();
|
|
535
|
+
this.globalSnapshot = undefined;
|
|
536
|
+
}
|
|
128
537
|
})();
|
|
129
538
|
}
|
|
130
539
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type ReadIndexState } from "./readIndex.js";
|
|
2
|
+
/** Optional observations whose index metadata may still be in flight. */
|
|
3
|
+
export type ConversationReadMessage = {
|
|
4
|
+
id: string;
|
|
5
|
+
threadId: string;
|
|
6
|
+
mine?: boolean;
|
|
7
|
+
from?: string;
|
|
8
|
+
recalled?: boolean;
|
|
9
|
+
readPolicy?: "visible" | "explicit";
|
|
10
|
+
kind?: string;
|
|
11
|
+
};
|
|
12
|
+
/** Explicitly mark ordinary text history read; unheard audio stays unread. */
|
|
13
|
+
export declare function recordConversationRead(state: ReadIndexState, conversationId: string, messages?: readonly ConversationReadMessage[], operationId?: () => `${string}-${string}-${string}-${string}-${string}`): ReadIndexState;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { recordReadIntent, sourcesInConversation } from "./readIndex.js";
|
|
2
|
+
/** Explicitly mark ordinary text history read; unheard audio stays unread. */
|
|
3
|
+
export function recordConversationRead(state, conversationId, messages = [], operationId = () => crypto.randomUUID()) {
|
|
4
|
+
if (!conversationId)
|
|
5
|
+
throw new Error("INVALID_READING_THREAD");
|
|
6
|
+
const sources = sourcesInConversation(state, conversationId);
|
|
7
|
+
if (new Set(sources.map(source => source.teamId)).size > 1)
|
|
8
|
+
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
9
|
+
let next = state;
|
|
10
|
+
const indexedIds = new Set();
|
|
11
|
+
for (const source of sources) {
|
|
12
|
+
const incoming = source.messages.filter(message => {
|
|
13
|
+
indexedIds.add(message.id);
|
|
14
|
+
return !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId);
|
|
15
|
+
});
|
|
16
|
+
const latest = incoming.filter(message => message.readPolicy === "visible" && message.seq > source.readThroughSeq)
|
|
17
|
+
.reduce((last, message) => !last || message.seq > last.seq ? message : last, undefined);
|
|
18
|
+
if (latest)
|
|
19
|
+
next = recordReadIntent(next, { operationId: operationId(), threadId: source.id, messageId: latest.id, readPolicy: "visible" });
|
|
20
|
+
}
|
|
21
|
+
// Live/history messages can precede their read-index metadata. Persist their
|
|
22
|
+
// observations so leaving the page or reloading cannot resurrect the badge.
|
|
23
|
+
const observed = new Set(next.observedReadIntents?.filter(intent => intent.threadId === conversationId).map(intent => intent.messageId));
|
|
24
|
+
for (const message of messages) {
|
|
25
|
+
if (message.threadId !== conversationId || message.mine || message.from === state.profileId || message.recalled || indexedIds.has(message.id) || observed.has(message.id))
|
|
26
|
+
continue;
|
|
27
|
+
const readPolicy = message.readPolicy ?? (message.kind === "voice" ? "explicit" : "visible");
|
|
28
|
+
if (message.kind === "voice" || readPolicy === "explicit")
|
|
29
|
+
continue;
|
|
30
|
+
next = recordReadIntent(next, { operationId: operationId(), threadId: conversationId, messageId: message.id, readPolicy });
|
|
31
|
+
observed.add(message.id);
|
|
32
|
+
}
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { ChatSession } from "./chatSession.js";
|
|
2
|
+
import type { ChatReaderSocialMessage } from "./readerClient.js";
|
|
3
|
+
import type { ChatClient, ChatClientOptions } from "./chatClient.js";
|
|
4
|
+
export type CachedChatMessage = ChatReaderSocialMessage & {
|
|
5
|
+
id: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
};
|
|
8
|
+
export type CreateChatOptions = {
|
|
9
|
+
appId: string;
|
|
10
|
+
accountId: string;
|
|
11
|
+
profileId: string;
|
|
12
|
+
teamId: string;
|
|
13
|
+
token: string;
|
|
14
|
+
/** Limit reconnect reconciliation for a dedicated conversation document. */
|
|
15
|
+
threadId?: string;
|
|
16
|
+
endpoint?: string;
|
|
17
|
+
clientInstanceId?: string;
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
/** Reuse an existing transport for call/presence integrations; its owner closes it. */
|
|
20
|
+
connection?: ChatClient;
|
|
21
|
+
onError: (error: unknown) => void;
|
|
22
|
+
onNotification?: ChatClientOptions["onNotification"];
|
|
23
|
+
onReach?: ChatClientOptions["onReach"];
|
|
24
|
+
};
|
|
25
|
+
/** Standard browser entry: storage, paging, realtime and reading share one identity. */
|
|
26
|
+
export declare function createChat(options: CreateChatOptions): ChatSession<CachedChatMessage>;
|