@pmate/chat-sdk 0.12.0-next.27 → 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;
@@ -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.27",
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",