@pmate/chat-sdk 0.12.0-next.26 → 0.12.0-next.28

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,4 @@
1
+ import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
1
2
  import type { ConversationReadMessage } from "./conversationRead.js";
2
3
  import { type ReadCountsResult } from "./readCounts.js";
3
4
  import type { OnlineReachEvent } from "./reach.js";
@@ -16,6 +17,7 @@ export type ChatSessionOptions<T extends StoredMessage> = {
16
17
  activeThreadId?: string;
17
18
  reading: ReadingClientOptions;
18
19
  decode: (message: ChatRuntimeMessage) => T | undefined;
20
+ readingCandidate?: (message: T) => ReadingCandidate | undefined;
19
21
  onError: (error: unknown) => void;
20
22
  };
21
23
  export type ChatThreadSnapshot<T> = {
@@ -26,6 +28,7 @@ export type ChatThreadSnapshot<T> = {
26
28
  complete: boolean;
27
29
  readCounts?: ReadCountsResult;
28
30
  unread: ReturnType<typeof unreadInConversation> | undefined;
31
+ messageReading: Readonly<Record<string, MessageReadingState>>;
29
32
  };
30
33
  export type ChatSummarySnapshot<T> = {
31
34
  threads: readonly {
@@ -1,3 +1,4 @@
1
+ import { projectConversationReading } from "./conversationReading.js";
1
2
  import { ReadCountsClient } from "./readCounts.js";
2
3
  import { WindowedTasks } from "./coalescer.js";
3
4
  import { MessageReadersClient } from "./messageReaders.js";
@@ -27,7 +28,7 @@ export class ChatSession {
27
28
  off = [];
28
29
  globalListeners = new Set();
29
30
  globalSnapshot;
30
- closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined };
31
+ closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
31
32
  closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
32
33
  connectionState = "closed";
33
34
  catchUp;
@@ -430,7 +431,11 @@ export class ChatSession {
430
431
  if (!snapshot) {
431
432
  const messages = this.messages.getSnapshot(threadId);
432
433
  const state = this.getReadingSnapshot();
433
- 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 };
434
+ const candidates = messages?.messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [candidate] : []; }) ?? [];
435
+ const sources = state?.threads.filter(thread => (thread.conversationId ?? thread.id) === threadId) ?? [];
436
+ const kind = sources[0]?.kind ?? "dm";
437
+ const view = projectConversationReading(state, { id: threadId, kind }, candidates);
438
+ 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: view.unread, messageReading: view.messageReading };
434
439
  this.snapshots.set(threadId, snapshot);
435
440
  }
436
441
  return snapshot;
@@ -0,0 +1,28 @@
1
+ import { unreadInConversation, type ReadIndexState } from "./readIndex.js";
2
+ export type ReadingCandidate = {
3
+ id: string;
4
+ threadId: string;
5
+ from?: string;
6
+ mine?: boolean;
7
+ recalled?: boolean;
8
+ unread?: boolean;
9
+ attentionEligible?: boolean;
10
+ readPolicy?: "visible" | "explicit";
11
+ };
12
+ export type MessageReadingState = {
13
+ seq?: number;
14
+ read: boolean;
15
+ readPolicy: "visible" | "explicit";
16
+ };
17
+ export type ConversationReadingView = {
18
+ messageReading: Readonly<Record<string, MessageReadingState>>;
19
+ unread: ReturnType<typeof unreadInConversation> | undefined;
20
+ badgeCount: number | undefined;
21
+ };
22
+ /** One projection for indexed authority and observations awaiting indexing. A
23
+ * directory badge is optional compatibility metadata, never proof of coverage. */
24
+ export declare function projectConversationReading(state: ReadIndexState | undefined, conversation: {
25
+ id: string;
26
+ kind: "dm" | "group";
27
+ badgeCount?: number;
28
+ }, candidates?: readonly ReadingCandidate[]): ConversationReadingView;
@@ -0,0 +1,43 @@
1
+ import { sourcesInConversation, unreadInConversation } from "./readIndex.js";
2
+ /** One projection for indexed authority and observations awaiting indexing. A
3
+ * directory badge is optional compatibility metadata, never proof of coverage. */
4
+ export function projectConversationReading(state, conversation, candidates = []) {
5
+ const sources = state ? sourcesInConversation(state, conversation.id) : [];
6
+ const sourceIds = new Set([conversation.id, ...sources.map(source => source.id)]);
7
+ const indexed = new Set(), messageReading = Object.create(null);
8
+ for (const source of sources)
9
+ for (const message of source.messages) {
10
+ indexed.add(message.id);
11
+ messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: message.recalled || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= source.readThroughSeq) };
12
+ }
13
+ const observed = new Map();
14
+ for (const intent of state?.observedReadIntents ?? [])
15
+ if (sourceIds.has(intent.threadId)) {
16
+ if (!observed.has(intent.messageId))
17
+ observed.set(intent.messageId, new Set());
18
+ observed.get(intent.messageId).add(intent.readPolicy);
19
+ }
20
+ const unique = new Map(candidates.filter(message => message.threadId === conversation.id).map(message => [message.id, message]));
21
+ let pendingMessages = 0, pendingMentions = 0, observedBadges = 0;
22
+ for (const message of unique.values()) {
23
+ const incoming = !message.mine && message.from !== state?.profileId && !message.recalled;
24
+ const attention = conversation.kind === "dm" || message.attentionEligible === true;
25
+ const policy = message.readPolicy ?? "visible";
26
+ const acknowledged = observed.get(message.id)?.has(policy) === true;
27
+ if (!indexed.has(message.id) && acknowledged)
28
+ messageReading[message.id] = { read: true, readPolicy: policy };
29
+ if (!incoming)
30
+ continue;
31
+ if (acknowledged && attention)
32
+ observedBadges++;
33
+ if (indexed.has(message.id) || acknowledged || message.unread !== true)
34
+ continue;
35
+ pendingMessages++;
36
+ if (message.attentionEligible === true)
37
+ pendingMentions++;
38
+ }
39
+ const authoritative = sources.length && state ? unreadInConversation(state, conversation.id) : undefined;
40
+ const unread = authoritative ? { unreadMessageCount: authoritative.unreadMessageCount + pendingMessages, unreadMentionCount: authoritative.unreadMentionCount + pendingMentions, badgeCount: authoritative.badgeCount + (conversation.kind === "group" ? pendingMentions : pendingMessages) } : undefined;
41
+ const pendingBadge = conversation.kind === "group" ? pendingMentions : pendingMessages;
42
+ return { messageReading, unread, badgeCount: unread?.badgeCount ?? (conversation.badgeCount === undefined ? undefined : Math.max(Math.max(0, conversation.badgeCount - observedBadges), pendingBadge)) };
43
+ }
@@ -1,3 +1,4 @@
1
+ import { isReadingControl } from "./readingContent.js";
1
2
  import { ChatSession } from "./chatSession.js";
2
3
  import { SocialReadClient } from "./socialReadClient.js";
3
4
  import { chatTeamId } from "./teamScope.js";
@@ -21,6 +22,15 @@ export function createChat(options) {
21
22
  reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
22
23
  connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
23
24
  session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
25
+ readingCandidate: message => {
26
+ if (isReadingControl(message.body, appId))
27
+ return undefined;
28
+ const body = message.body;
29
+ if (!body || !["message", "quote"].includes(String(body.kind)) && !(body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action))))
30
+ return undefined;
31
+ return { id: message.id, threadId: message.threadId, from: message.from, mine: message.from === profileId, recalled: message.recalled, unread: message.from !== profileId,
32
+ readPolicy: body?.contentKind === "voice" ? "explicit" : "visible", attentionEligible: Array.isArray(body.content?.mentions) && body.content.mentions.some(mention => mention?.id === profileId) };
33
+ },
24
34
  decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
25
35
  });
26
36
  }
@@ -30,3 +30,4 @@ export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
32
  export * from "./conversationRead.js";
33
+ export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
@@ -30,3 +30,4 @@ export * from "./chatSession.js";
30
30
  export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
32
  export * from "./conversationRead.js";
33
+ export { projectConversationReading } from "./conversationReading.js";
@@ -1,4 +1,5 @@
1
1
  import { Dexie } from "dexie";
2
+ import { isReadingControl } from "./readingContent.js";
2
3
  import { isValidChatTeamId } from "./thread.js";
3
4
  export class ReadSyncGap extends Error {
4
5
  cursor;
@@ -158,7 +159,7 @@ export function unreadInThread(state, threadId) {
158
159
  if (!thread)
159
160
  throw new Error("UNKNOWN_READ_THREAD");
160
161
  const explicit = new Set(thread.explicitReadMessageIds);
161
- const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
162
+ const messages = thread.messages.filter(message => !message.recalled && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
162
163
  && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
163
164
  const unreadMessageCount = messages.length;
164
165
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
@@ -278,7 +279,7 @@ export class IndexedDbReadIndex {
278
279
  return this.hydrate(current, this.key(appId, profileId));
279
280
  if (current) {
280
281
  if (this.migrateLegacy)
281
- await this.persist(appId, profileId, undefined, current);
282
+ await this.persist(appId, profileId, undefined, current, true);
282
283
  return current;
283
284
  }
284
285
  if (!this.migrateLegacy || this.storageScope === undefined)
@@ -305,9 +306,11 @@ export class IndexedDbReadIndex {
305
306
  }
306
307
  /** Message bodies are immutable for local read observations: keep them out of
307
308
  * profile/cursor writes, and update only changed normalized rows on sync. */
308
- async persist(appId, profileId, previous, next) {
309
- if (previous && !this.normalizedStates.has(previous))
309
+ async persist(appId, profileId, previous, next, replaceLegacy = false) {
310
+ if (previous && !this.normalizedStates.has(previous)) {
310
311
  previous = undefined;
312
+ replaceLegacy = true;
313
+ }
311
314
  const owner = JSON.stringify(this.key(appId, profileId));
312
315
  const oldThreads = new Map(previous?.threads.map(thread => [thread.id, thread]) ?? []);
313
316
  const nextIds = new Set(next.threads.map(thread => thread.id));
@@ -352,6 +355,14 @@ export class IndexedDbReadIndex {
352
355
  await this.positionRows.delete([owner, id]);
353
356
  await this.messageRows.where("[owner+thread]").equals([owner, id]).delete();
354
357
  }
358
+ // A legacy document can replace the full profile after normalization. Its
359
+ // replacement is authoritative for the row set; discard unreachable bodies
360
+ // in the same transaction before installing the rebuilt normalized records.
361
+ if (replaceLegacy) {
362
+ await this.messageRows.where("owner").equals(owner).delete();
363
+ await this.threadRows.where("owner").equals(owner).delete();
364
+ await this.positionRows.where("owner").equals(owner).delete();
365
+ }
355
366
  // Bound each IndexedDB bulk request. A snapshot and its cursor still commit
356
367
  // atomically; bounding total snapshot transaction bytes is separate work.
357
368
  for (let offset = 0; offset < positionWrites.length; offset += 128)
@@ -0,0 +1,2 @@
1
+ /** Social control envelopes do not contribute to message unread counts. */
2
+ export declare function isReadingControl(payload: unknown, appId: string): boolean;
@@ -0,0 +1,9 @@
1
+ /** Social control envelopes do not contribute to message unread counts. */
2
+ export function isReadingControl(payload, appId) {
3
+ if (!payload || typeof payload !== "object")
4
+ return false;
5
+ const value = payload;
6
+ if (value.body !== undefined)
7
+ return isReadingControl(value.body, appId);
8
+ return value.kind === "message" && value.content?.appId === appId && (value.content.reaction !== undefined || value.content.recall !== undefined);
9
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.26",
3
+ "version": "0.12.0-next.28",
4
4
  "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",