@pmate/chat-sdk 0.12.0-next.2 → 0.12.0-next.20

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.
@@ -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,397 @@ 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
+ connectionState = "closed";
31
+ catchUp;
32
+ readingStarted = false;
18
33
  closed = false;
19
34
  starting;
35
+ initializing;
20
36
  closing;
21
37
  constructor(options) {
22
38
  this.options = options;
23
39
  const scope = options.messages.scope;
24
40
  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
41
  throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
42
+ if (options.sharedConnection) {
43
+ const shared = options.sharedConnection.identityScope();
44
+ if (shared.appId !== scope.appId || shared.profileId !== scope.profileId || (shared.accountId !== undefined && shared.accountId !== scope.accountId) || (shared.teamId !== undefined && shared.teamId !== scope.teamId))
45
+ throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
46
+ }
26
47
  this.messages = new MessageClient(options.messages);
27
- this.connection = new ChatClient(options.connection);
28
- this.reading = new ReadingClient(options.reading);
29
- this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
48
+ this.connection = options.sharedConnection ?? new ChatClient({ ...options.connection, onReach: async (event) => {
49
+ const handled = await this.handleReach(event);
50
+ if (handled)
51
+ return handled;
52
+ if (options.connection.onReach)
53
+ return options.connection.onReach(event);
54
+ throw new Error("REACH_HANDLER_UNAVAILABLE");
55
+ } });
56
+ this.reading = new ReadingClient({ ...options.reading, storageScope: JSON.stringify([scope.accountId]), migrateLegacy: true });
57
+ this.readers = new MessageReadersClient({ appId: scope.appId, profileId: scope.profileId, token: options.reading.sessionToken, scope, onError: options.onError, hubHttpUrl: options.reading.hubHttpUrl, fetch: options.reading.fetch });
30
58
  this.sender = new DurableChatSender(this.connection, scope, options.onError);
59
+ this.catchUp = new WindowedTasks(1000, error => { if (!this.closed)
60
+ options.onError(error); });
61
+ this.countTasks = new WindowedTasks(50, error => { if (!this.closed)
62
+ options.onError(error); });
63
+ if (typeof window !== "undefined") {
64
+ const wake = () => this.wakeCounts();
65
+ window.addEventListener("online", wake);
66
+ window.addEventListener("focus", wake);
67
+ this.off.push(() => { window.removeEventListener("online", wake); window.removeEventListener("focus", wake); });
68
+ }
69
+ this.off.push(this.messages.subscribeAll(thread => this.publish(thread)));
31
70
  this.off.push(this.sender.subscribe(() => this.publishAll()), this.reading.subscribe(() => this.publishAll()));
32
71
  this.off.push(this.connection.watchMessages(message => {
33
72
  try {
34
73
  const decoded = options.decode(message);
35
74
  if (decoded)
36
- void this.messages.receive(decoded.threadId, [decoded]).then(() => { this.publish(decoded.threadId); return this.sender.confirm([message.hash]); }).catch(options.onError);
75
+ void this.messages.receive(decoded.threadId, [decoded]).then(async () => {
76
+ if (!this.closed && this.readingStarted)
77
+ this.reading.refresh();
78
+ await this.sender.confirm([message.hash]);
79
+ }).catch(error => { if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
80
+ this.messages.invalidate(decoded.threadId);
81
+ else if (!this.closed)
82
+ options.onError(error); });
37
83
  }
38
84
  catch (error) {
39
85
  options.onError(error);
40
86
  }
41
87
  }));
42
88
  this.off.push(this.connection.watchConnection(state => {
89
+ this.connectionState = state;
90
+ this.publishGlobal();
43
91
  if (state === "connected") {
44
- for (const thread of this.listeners.keys())
45
- this.messages.invalidate(thread);
46
- this.reading.refresh();
92
+ const since = Date.now();
93
+ this.catchUp.schedule("scope", () => this.messages.restore(since, options.activeThreadId));
94
+ if (this.readingStarted)
95
+ this.reading.refresh();
96
+ this.wakeCounts();
47
97
  }
48
98
  }));
49
99
  }
50
100
  start() {
51
101
  this.assertOpen();
52
102
  return this.starting ??= (async () => {
53
- await this.sender.initialize();
54
- await this.reading.start();
103
+ await this.initialize();
55
104
  this.assertOpen();
56
105
  await this.connection.connect();
57
106
  })().catch(error => { this.starting = undefined; throw error; });
58
107
  }
108
+ /** Initializes local state and background reading sync without opening a
109
+ * second transport when the host already owns calls/presence. */
110
+ initialize() {
111
+ this.assertOpen();
112
+ return this.initializing ??= (async () => {
113
+ await this.sender.initialize();
114
+ this.assertOpen();
115
+ this.readingStarted = true;
116
+ await this.reading.start();
117
+ this.assertOpen();
118
+ })().catch(error => { this.initializing = undefined; throw error; });
119
+ }
120
+ /** One-time migration only: never establishes trusted history coverage. */
121
+ importLegacyMessages(threadId, messages) { this.assertOpen(); return this.messages.importLegacy(threadId, messages); }
122
+ getReadingSnapshot() { return this.reading.getSnapshot(); }
123
+ subscribeReading(listener) { this.assertOpen(); return this.reading.subscribe(listener); }
124
+ refreshReading() { this.assertOpen(); this.reading.refresh(); }
125
+ synchronizeReading(cursor, profileId) { this.assertOpen(); return this.reading.synchronize(cursor, profileId); }
126
+ /** Explicit text-history acknowledgement; unheard audio stays unread. */
127
+ markRead(threadId, messages = []) { this.assertOpen(); return this.reading.markRead(threadId, messages); }
128
+ updateReading(transform) { this.assertOpen(); return this.reading.update(transform); }
59
129
  assertOpen() { if (this.closed)
60
130
  throw new Error("CHAT_SESSION_CLOSED"); }
61
131
  async getMessages(threadId, options = {}) {
62
132
  this.assertOpen();
133
+ if (options.earlier) {
134
+ if (options.before || options.all || options.limit !== undefined || options.refresh)
135
+ throw new Error("INVALID_MESSAGE_QUERY");
136
+ return this.readEarlier(threadId);
137
+ }
138
+ if (options.all && (options.before || options.limit !== undefined))
139
+ throw new Error("INVALID_MESSAGE_QUERY");
140
+ // Explicit authoritative changes (for example history assignment) can invalidate
141
+ // even a recently cached empty window. Keep this on the existing read surface.
142
+ if (options.refresh)
143
+ await this.messages.read(threadId, options.limit ?? 100, true);
144
+ if (options.all) {
145
+ return this.readAll(threadId);
146
+ }
63
147
  const page = await (options.before ? this.messages.earlier(threadId, options.before, options.limit) : this.messages.read(threadId, options.limit));
64
- if (!options.before)
65
- this.publish(threadId);
148
+ await this.sender.confirm(page.messages.map(message => message.id));
149
+ const edge = this.historyEdges.get(threadId);
150
+ if (!options.before && page.complete && (!edge || edge.version !== page.resetVersion))
151
+ this.rememberHistoryEdge(threadId, page);
66
152
  return page;
67
153
  }
154
+ rememberHistoryEdge(threadId, page) {
155
+ const first = page.messages[0];
156
+ this.historyEdges.set(threadId, { before: first ? { timestamp: first.timestamp, id: first.id } : undefined, version: page.resetVersion, hasEarlier: page.hasEarlier });
157
+ }
158
+ readEarlier(threadId) {
159
+ const running = this.earlierReads.get(threadId);
160
+ if (running)
161
+ return running;
162
+ const task = (async () => {
163
+ const recent = await this.getMessages(threadId);
164
+ if (!recent.complete)
165
+ throw new Error("MESSAGE_WINDOW_INCOMPLETE");
166
+ const edge = this.historyEdges.get(threadId);
167
+ if (!edge.hasEarlier)
168
+ return { ...recent, messages: [], hasEarlier: false };
169
+ if (!edge.before)
170
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
171
+ const page = await this.getMessages(threadId, { before: edge.before });
172
+ this.assertOpen();
173
+ this.rememberHistoryEdge(threadId, page);
174
+ return page;
175
+ })().finally(() => { if (this.earlierReads.get(threadId) === task)
176
+ this.earlierReads.delete(threadId); });
177
+ this.earlierReads.set(threadId, task);
178
+ return task;
179
+ }
180
+ async readAll(threadId) {
181
+ for (let attempt = 0; attempt < 3; attempt++) {
182
+ const pages = [];
183
+ let version = 0;
184
+ try {
185
+ let page = await this.getMessages(threadId);
186
+ version = page.resetVersion;
187
+ pages.push(page.messages);
188
+ if (!page.complete)
189
+ return { ...page, hasEarlier: true };
190
+ let restart = false;
191
+ while (page.hasEarlier) {
192
+ const first = page.messages[0];
193
+ if (!first)
194
+ throw new Error("MESSAGE_SYNC_NO_PROGRESS");
195
+ page = await this.getMessages(threadId, { before: { timestamp: first.timestamp, id: first.id } });
196
+ if (page.resetVersion !== version) {
197
+ restart = true;
198
+ break;
199
+ }
200
+ pages.push(page.messages);
201
+ if (!page.complete)
202
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
203
+ }
204
+ if (restart)
205
+ continue;
206
+ return { messages: pages.reverse().flat(), hasEarlier: false, complete: true, resetVersion: version };
207
+ }
208
+ catch (error) {
209
+ if ([401, 403].includes(error?.status ?? 0))
210
+ throw error;
211
+ if (String(error).includes("MESSAGE_PROJECTION_CHANGED"))
212
+ continue;
213
+ if (pages.length)
214
+ return { messages: pages.reverse().flat(), hasEarlier: true, complete: false, resetVersion: version };
215
+ throw error;
216
+ }
217
+ }
218
+ throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
219
+ }
68
220
  sendMessage(threadId, input) {
69
221
  this.assertOpen();
70
222
  return this.sender.enqueue(threadId, input);
71
223
  }
72
- getReaders(threadId, messageId, page = {}, signal) {
224
+ async getReaders(threadId, messageId, page = {}, signal) {
73
225
  this.assertOpen();
74
- return this.readers.getReaders({ ...page, threadId, messageId }, signal);
226
+ const result = await this.readers.getReaders({ ...page, threadId, messageId }, signal);
227
+ this.assertOpen();
228
+ if (!result.resetRequired) {
229
+ const previous = this.countSnapshots.get(threadId);
230
+ if (!previous || previous.revision <= result.revision) {
231
+ this.publishCounts({ appId: result.appId, threadId, revision: result.revision, counts: { ...previous?.counts, [messageId]: result.total } });
232
+ this.counts?.changed(threadId, result.revision);
233
+ }
234
+ }
235
+ return result;
75
236
  }
76
237
  retryMessage(messageId) { this.assertOpen(); return this.sender.retry(messageId); }
77
- observeMessages(threadId, ids, event) {
238
+ observeMessages(threadId, ids, event, clientInstanceId) {
239
+ if (this.closed && event === "receipts" && !threadId)
240
+ return Promise.resolve();
78
241
  this.assertOpen();
242
+ if (event === "receipts") {
243
+ this.watchCounts(threadId, ids, clientInstanceId);
244
+ return Promise.resolve();
245
+ }
246
+ if (!threadId)
247
+ throw new Error("INVALID_READING_THREAD");
79
248
  return event === "played" ? this.reading.recordExplicit(threadId, ids) : this.reading.recordVisible(threadId, ids);
80
249
  }
250
+ /** Bridge transports can delegate social events without maintaining clients. */
251
+ async handleReach(event) {
252
+ this.assertOpen();
253
+ if (!["social.readCountsChanged", "social.readStateChanged"].includes(event.eventType))
254
+ return undefined;
255
+ if (event.appId !== this.options.messages.scope.appId)
256
+ throw new Error("UNEXPECTED_READING_APPLICATION");
257
+ if (event.eventType === "social.readCountsChanged") {
258
+ if (typeof event.payload.threadId !== "string" || !Number.isSafeInteger(event.payload.revision) || Number(event.payload.revision) < 0)
259
+ throw new Error("INVALID_READ_COUNTS_EVENT");
260
+ this.counts?.changed(event.payload.threadId, Number(event.payload.revision));
261
+ }
262
+ else {
263
+ if (typeof event.payload.syncCursor !== "string" || !/^v1:\d+$/.test(event.payload.syncCursor) || typeof event.payload.profileId !== "string")
264
+ throw new Error("INVALID_READING_EVENT");
265
+ if (event.payload.profileId !== this.options.messages.scope.profileId)
266
+ throw new Error("Reading reach identity mismatch");
267
+ await this.initialize();
268
+ await this.reading.synchronize(event.payload.syncCursor, event.payload.profileId);
269
+ this.assertOpen();
270
+ }
271
+ return "handled";
272
+ }
273
+ wakeCounts() {
274
+ if (this.counts && this.countsThread && !this.closed)
275
+ this.countTasks.schedule("counts", async () => { this.counts?.reconnect(); });
276
+ }
277
+ publishCountSnapshot(thread) {
278
+ const previous = this.snapshots.get(thread);
279
+ if (!previous) {
280
+ this.publish(thread, false);
281
+ return;
282
+ }
283
+ // Receipt-only updates never serialize or rebuild message bodies.
284
+ this.snapshots.set(thread, { ...previous, readCounts: this.countSnapshots.get(thread) });
285
+ this.listeners.get(thread)?.forEach(listener => listener());
286
+ }
287
+ publishCounts(value) {
288
+ const previous = this.countSnapshots.get(value.threadId);
289
+ if ((previous && previous.revision > value.revision) || JSON.stringify(previous) === JSON.stringify(value))
290
+ return;
291
+ this.countSnapshots.delete(value.threadId);
292
+ this.countSnapshots.set(value.threadId, value);
293
+ if (this.countSnapshots.size > 128) {
294
+ const oldest = this.countSnapshots.keys().next().value;
295
+ this.countSnapshots.delete(oldest);
296
+ this.publishCountSnapshot(oldest);
297
+ }
298
+ this.publishCountSnapshot(value.threadId);
299
+ }
300
+ watchCounts(threadId, ids, receiver = this.connection.identityScope().clientInstanceId) {
301
+ if (!threadId || !ids.length) {
302
+ this.countsThread = undefined;
303
+ this.counts?.watch(undefined, []);
304
+ return;
305
+ }
306
+ if (!receiver || receiver.length > 512 || /[\x00-\x1f]/.test(receiver))
307
+ throw new Error("INVALID_READ_COUNTS_RECEIVER");
308
+ if (!this.counts || this.countsReceiver !== receiver) {
309
+ if (this.counts) {
310
+ const closing = this.counts.close().finally(() => this.countClosings.delete(closing));
311
+ this.countClosings.add(closing);
312
+ }
313
+ const scope = this.options.messages.scope;
314
+ const client = new ReadCountsClient({ appId: scope.appId, profileId: scope.profileId, scope, token: this.options.reading.sessionToken, clientInstanceId: receiver, hubHttpUrl: this.options.reading.hubHttpUrl, fetch: this.options.reading.fetch,
315
+ onChange: value => { if (!this.closed && this.counts === client)
316
+ this.publishCounts(value); },
317
+ onError: error => {
318
+ if (this.closed || this.counts !== client)
319
+ return;
320
+ if (/READ_COUNTS_HTTP_(401|403)/.test(String(error))) {
321
+ const threads = [...this.countSnapshots.keys()];
322
+ this.countSnapshots.clear();
323
+ threads.forEach(thread => this.publishCountSnapshot(thread));
324
+ }
325
+ this.options.onError(error);
326
+ } });
327
+ this.counts = client;
328
+ this.countsReceiver = receiver;
329
+ }
330
+ this.countsThread = threadId;
331
+ this.counts.watch(threadId, ids);
332
+ }
81
333
  getSnapshot(threadId) {
334
+ if (threadId === undefined) {
335
+ if (!this.globalSnapshot)
336
+ this.globalSnapshot = this.buildGlobal();
337
+ return this.globalSnapshot;
338
+ }
82
339
  let snapshot = this.snapshots.get(threadId);
83
340
  if (!snapshot) {
84
341
  const messages = this.messages.getSnapshot(threadId);
85
342
  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) : { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 } };
343
+ 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
344
  this.snapshots.set(threadId, snapshot);
88
345
  }
89
346
  return snapshot;
90
347
  }
91
- subscribe(threadId, listener) {
348
+ subscribe(target, listener, fields) {
92
349
  this.assertOpen();
350
+ if (typeof target === "function") {
351
+ this.globalListeners.add(target);
352
+ return () => { this.globalListeners.delete(target); };
353
+ }
354
+ const threadId = target;
355
+ const selected = fields ? [...fields] : undefined;
356
+ let values = selected?.map(field => this.getSnapshot(threadId)[field]);
357
+ const equal = (a, b) => a === b || (Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((value, index) => value === b[index]));
358
+ const notify = selected ? () => {
359
+ const next = selected.map(field => this.getSnapshot(threadId)[field]);
360
+ const changed = next.some((value, index) => !equal(value, values[index]));
361
+ values = next;
362
+ if (changed)
363
+ listener();
364
+ } : listener;
93
365
  let listeners = this.listeners.get(threadId);
94
366
  if (!listeners) {
95
367
  listeners = new Set();
96
368
  this.listeners.set(threadId, listeners);
97
369
  }
98
- listeners.add(listener);
99
- const off = this.messages.subscribe(threadId, () => this.publish(threadId));
370
+ listeners.add(notify);
371
+ const off = this.messages.subscribe(threadId, () => { });
100
372
  return () => {
101
373
  off();
102
- listeners.delete(listener);
374
+ listeners.delete(notify);
103
375
  if (!listeners.size)
104
376
  this.listeners.delete(threadId);
105
377
  };
106
378
  }
107
- publishAll() { for (const threadId of [...this.snapshots.keys()])
108
- this.publish(threadId); }
109
- publish(threadId) {
379
+ publishAll() {
380
+ for (const threadId of [...this.snapshots.keys()])
381
+ this.publish(threadId, false);
382
+ this.publishGlobal();
383
+ }
384
+ buildGlobal() {
385
+ const ids = new Set(this.snapshots.keys());
386
+ for (const thread of this.reading.getSnapshot()?.threads ?? [])
387
+ if (thread.teamId === this.options.messages.scope.teamId)
388
+ ids.add(thread.conversationId ?? thread.id);
389
+ for (const outgoing of this.sender.snapshot())
390
+ ids.add(outgoing.threadId);
391
+ const threads = [...ids].sort().map(threadId => {
392
+ const snapshot = this.getSnapshot(threadId);
393
+ return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
394
+ });
395
+ const state = this.reading.getSnapshot();
396
+ const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
397
+ 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 };
398
+ }
399
+ publishGlobal() {
400
+ const next = this.buildGlobal();
401
+ if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
402
+ return;
403
+ this.globalSnapshot = next;
404
+ this.globalListeners.forEach(listener => listener());
405
+ }
406
+ publish(threadId, notifyGlobal = true) {
110
407
  const previous = this.snapshots.get(threadId);
111
408
  this.snapshots.delete(threadId);
112
409
  const next = this.getSnapshot(threadId);
@@ -115,16 +412,25 @@ export class ChatSession {
115
412
  return;
116
413
  }
117
414
  this.listeners.get(threadId)?.forEach(listener => listener());
415
+ if (notifyGlobal)
416
+ this.publishGlobal();
118
417
  }
119
418
  close() {
120
419
  return this.closing ??= (async () => {
121
420
  this.closed = true;
421
+ this.catchUp.close();
422
+ this.countTasks.close();
122
423
  this.off.splice(0).forEach(off => off());
123
- this.connection.close();
124
- this.readers.close();
125
- await Promise.all([this.sender.close(), this.reading.close(), this.messages.close()]);
424
+ if (!this.options.sharedConnection)
425
+ this.connection.close();
426
+ await Promise.all([this.counts?.close(), ...this.countClosings, this.readers.close(), this.sender.close(), this.reading.close(), this.messages.close()]);
126
427
  this.listeners.clear();
428
+ this.globalListeners.clear();
127
429
  this.snapshots.clear();
430
+ this.countSnapshots.clear();
431
+ this.historyEdges.clear();
432
+ this.earlierReads.clear();
433
+ this.globalSnapshot = undefined;
128
434
  })();
129
435
  }
130
436
  }
@@ -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>;
@@ -0,0 +1,42 @@
1
+ import { ChatSession } from "./chatSession.js";
2
+ import { SocialReadClient } from "./socialReadClient.js";
3
+ import { chatTeamId } from "./teamScope.js";
4
+ import { dmThreadId, groupThreadId, isValidChatTeamId } from "./thread.js";
5
+ /** Standard browser entry: storage, paging, realtime and reading share one identity. */
6
+ export function createChat(options) {
7
+ const { appId, accountId, profileId, teamId, token, onError } = options;
8
+ if (!isValidChatTeamId(teamId))
9
+ throw new Error("INVALID_CHAT_TEAM");
10
+ const endpoint = (options.endpoint ?? "https://hub.pmate.chat").replace(/\/+$/, "");
11
+ const http = endpoint.replace(/^ws/, "http"), ws = endpoint.replace(/^http/, "ws");
12
+ const reader = new SocialReadClient({ appId, profileId, token, hubHttpUrl: http, fetch: options.fetch });
13
+ const page = async (threadId, signal, cursor = {}) => {
14
+ const result = await reader.messagePage({ threadId, teamId, limit: 100, ...cursor }, signal);
15
+ return { ...result, messages: result.messages.map(message => ({ ...message, id: message.messageId, timestamp: message.serverCreatedAt })) };
16
+ };
17
+ return new ChatSession({
18
+ sharedConnection: options.connection,
19
+ activeThreadId: options.threadId,
20
+ messages: { scope: { appId, accountId, profileId, teamId }, transport: { load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
21
+ reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
22
+ connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
23
+ session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
24
+ decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
25
+ });
26
+ }
27
+ function decodeIncoming(message, scope) {
28
+ if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
29
+ return;
30
+ const body = message.body;
31
+ const invitation = body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action));
32
+ if ((!invitation && body.kind !== "message") || (invitation ? body.payload?.appId : body.content?.appId) !== scope.appId || chatTeamId(body) !== scope.teamId)
33
+ return;
34
+ const threadKind = invitation ? "dm" : body.threadKind;
35
+ if (threadKind !== "dm" && threadKind !== "group")
36
+ return;
37
+ if (threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
38
+ return;
39
+ const threadId = threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
40
+ return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind, messageId: message.hash, id: message.hash,
41
+ timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
42
+ }
@@ -143,10 +143,13 @@ export class DurableChatSender {
143
143
  /** Confirm only after the authoritative message has been durably merged.
144
144
  * A tombstone prevents another tab's late failure from recreating the outbox. */
145
145
  async confirm(hashes) {
146
+ hashes = [...new Set(hashes)];
147
+ if (!hashes.length)
148
+ return;
146
149
  await this.ready;
147
150
  await this.db.transaction("rw", this.outbox, async () => {
148
151
  const previous = await this.outbox.bulkGet(hashes.map(hash => [this.scope, hash]));
149
- const confirmed = previous.filter((record) => Boolean(record)).map(record => ({ ...record, status: "sent", confirmed: true }));
152
+ const confirmed = previous.filter((record) => Boolean(record && !record.confirmed)).map(record => ({ ...record, status: "sent", confirmed: true }));
150
153
  if (confirmed.length)
151
154
  await this.outbox.bulkPut(confirmed);
152
155
  });
@@ -28,3 +28,5 @@ export * from "./recordCache.js";
28
28
  export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
+ export * from "./createChat.js";
32
+ export * from "./conversationRead.js";
@@ -28,3 +28,5 @@ export * from "./recordCache.js";
28
28
  export * from "./durableSender.js";
29
29
  export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
+ export * from "./createChat.js";
32
+ export * from "./conversationRead.js";