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