@pmate/chat-sdk 0.12.0-next.27 → 0.12.0-next.29

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 { type TimelineItem } from "./messageTimeline.js";
2
+ import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
1
3
  import type { ConversationReadMessage } from "./conversationRead.js";
2
4
  import { type ReadCountsResult } from "./readCounts.js";
3
5
  import type { OnlineReachEvent } from "./reach.js";
@@ -16,21 +18,25 @@ export type ChatSessionOptions<T extends StoredMessage> = {
16
18
  activeThreadId?: string;
17
19
  reading: ReadingClientOptions;
18
20
  decode: (message: ChatRuntimeMessage) => T | undefined;
21
+ readingCandidate?: (message: T) => ReadingCandidate | undefined;
19
22
  onError: (error: unknown) => void;
20
23
  };
21
24
  export type ChatThreadSnapshot<T> = {
22
25
  messages: readonly T[];
26
+ timeline: readonly TimelineItem<T>[];
23
27
  outgoing: ReturnType<DurableChatSender["snapshot"]>;
24
28
  hasEarlier: boolean;
25
29
  resetVersion: number;
26
30
  complete: boolean;
27
31
  readCounts?: ReadCountsResult;
28
32
  unread: ReturnType<typeof unreadInConversation> | undefined;
33
+ messageReading: Readonly<Record<string, MessageReadingState>>;
29
34
  };
30
35
  export type ChatSummarySnapshot<T> = {
31
36
  threads: readonly {
32
37
  threadId: string;
33
38
  lastMessage?: T;
39
+ lastItem?: TimelineItem<T>;
34
40
  unread: ChatThreadSnapshot<T>["unread"];
35
41
  outgoingCount: number;
36
42
  }[];
@@ -54,6 +60,8 @@ export declare class ChatSession<T extends StoredMessage> {
54
60
  private sender;
55
61
  private listeners;
56
62
  private snapshots;
63
+ private timelineDecoded;
64
+ private decodeTimeline;
57
65
  private historyEdges;
58
66
  private earlierReads;
59
67
  private off;
@@ -125,6 +133,7 @@ export declare class ChatSession<T extends StoredMessage> {
125
133
  private watchCounts;
126
134
  getSnapshot(): ChatSummarySnapshot<T>;
127
135
  getSnapshot(threadId: string): ChatThreadSnapshot<T>;
136
+ private buildThreadSnapshot;
128
137
  subscribe(listener: () => void): () => void;
129
138
  subscribe(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
130
139
  private publishAll;
@@ -1,3 +1,5 @@
1
+ import { projectMessageTimeline } from "./messageTimeline.js";
2
+ import { projectConversationReading } from "./conversationReading.js";
1
3
  import { ReadCountsClient } from "./readCounts.js";
2
4
  import { WindowedTasks } from "./coalescer.js";
3
5
  import { MessageReadersClient } from "./messageReaders.js";
@@ -22,12 +24,15 @@ export class ChatSession {
22
24
  sender;
23
25
  listeners = new Map();
24
26
  snapshots = new Map();
27
+ timelineDecoded = new WeakMap();
28
+ decodeTimeline = (message) => { if (!this.timelineDecoded.has(message))
29
+ this.timelineDecoded.set(message, this.options.decode(message)); return this.timelineDecoded.get(message); };
25
30
  historyEdges = new Map();
26
31
  earlierReads = new Map();
27
32
  off = [];
28
33
  globalListeners = new Set();
29
34
  globalSnapshot;
30
- closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined };
35
+ closedThread = { messages: [], timeline: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
31
36
  closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
32
37
  connectionState = "closed";
33
38
  catchUp;
@@ -428,13 +433,26 @@ export class ChatSession {
428
433
  }
429
434
  let snapshot = this.snapshots.get(threadId);
430
435
  if (!snapshot) {
431
- const messages = this.messages.getSnapshot(threadId);
432
- 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 };
436
+ snapshot = this.buildThreadSnapshot(threadId);
434
437
  this.snapshots.set(threadId, snapshot);
435
438
  }
436
439
  return snapshot;
437
440
  }
441
+ buildThreadSnapshot(threadId, previous) {
442
+ const messages = this.messages.getSnapshot(threadId);
443
+ const state = this.getReadingSnapshot();
444
+ const candidates = messages?.messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [candidate] : []; }) ?? [];
445
+ const sources = state?.threads.filter(thread => (thread.conversationId ?? thread.id) === threadId) ?? [];
446
+ const kind = sources[0]?.kind ?? "dm";
447
+ const view = projectConversationReading(state, { id: threadId, kind }, candidates);
448
+ const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
449
+ const messageReading = previous && JSON.stringify(previous.messageReading) === JSON.stringify(view.messageReading) ? previous.messageReading : view.messageReading;
450
+ const raw = messages?.messages ?? [];
451
+ const stableMessages = previous && previous.messages.length === raw.length && previous.messages.every((message, index) => message === raw[index]) ? previous.messages : raw;
452
+ const outgoing = this.sender.snapshot(threadId);
453
+ const timeline = projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages: stableMessages, outgoing, decode: this.decodeTimeline, reading: messageReading, previous: previous?.timeline });
454
+ return { timeline, messages: stableMessages, hasEarlier: messages?.hasEarlier ?? false, resetVersion: messages?.resetVersion ?? 0, complete: messages?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
455
+ }
438
456
  subscribe(target, listener, fields) {
439
457
  if (this.closed || this.withdrawn)
440
458
  return () => { };
@@ -483,7 +501,7 @@ export class ChatSession {
483
501
  ids.add(outgoing.threadId);
484
502
  const threads = [...ids].sort().map(threadId => {
485
503
  const snapshot = this.getSnapshot(threadId);
486
- return { threadId, lastMessage: snapshot.messages.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
504
+ return { threadId, lastMessage: snapshot.timeline.at(-1)?.message, lastItem: snapshot.timeline.at(-1), unread: snapshot.unread, outgoingCount: snapshot.outgoing.length };
487
505
  });
488
506
  const state = this.getReadingSnapshot();
489
507
  const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
@@ -493,7 +511,11 @@ export class ChatSession {
493
511
  if (this.closed || this.withdrawn)
494
512
  return;
495
513
  const next = this.buildGlobal();
496
- if (JSON.stringify(next) === JSON.stringify(this.globalSnapshot))
514
+ const previous = this.globalSnapshot;
515
+ 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) => {
516
+ const other = next.threads[index];
517
+ return thread.threadId === other.threadId && thread.lastMessage === other.lastMessage && thread.lastItem === other.lastItem && thread.outgoingCount === other.outgoingCount && JSON.stringify(thread.unread) === JSON.stringify(other.unread);
518
+ }))
497
519
  return;
498
520
  this.globalSnapshot = next;
499
521
  this.globalListeners.forEach(listener => listener());
@@ -501,8 +523,9 @@ export class ChatSession {
501
523
  publish(threadId, notifyGlobal = true) {
502
524
  const previous = this.snapshots.get(threadId);
503
525
  this.snapshots.delete(threadId);
504
- const next = this.getSnapshot(threadId);
505
- if (previous && JSON.stringify(previous) === JSON.stringify(next)) {
526
+ const next = this.buildThreadSnapshot(threadId, previous);
527
+ this.snapshots.set(threadId, next);
528
+ if (previous && 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)) {
506
529
  this.snapshots.set(threadId, previous);
507
530
  return;
508
531
  }
@@ -528,6 +551,7 @@ export class ChatSession {
528
551
  this.counts?.watch(undefined, []);
529
552
  this.countSnapshots.clear();
530
553
  this.snapshots.clear();
554
+ this.timelineDecoded = new WeakMap();
531
555
  this.historyEdges.clear();
532
556
  this.earlierReads.clear();
533
557
  this.globalSnapshot = undefined;
@@ -593,6 +617,7 @@ export class ChatSession {
593
617
  this.globalListeners.clear();
594
618
  this.readingListeners.clear();
595
619
  this.snapshots.clear();
620
+ this.timelineDecoded = new WeakMap();
596
621
  this.countSnapshots.clear();
597
622
  this.historyEdges.clear();
598
623
  this.earlierReads.clear();
@@ -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,5 @@ 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";
34
+ export { projectMessageTimeline, type TimelineItem, type TimelineReaction } from "./messageTimeline.js";
@@ -30,3 +30,5 @@ 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";
34
+ export { projectMessageTimeline } from "./messageTimeline.js";
@@ -0,0 +1,33 @@
1
+ import type { StoredMessage } from "./messageStore.js";
2
+ import type { OutgoingMessage } from "./durableSender.js";
3
+ import type { ChatRuntimeMessage } from "./protocol.js";
4
+ import type { MessageReadingState } from "./conversationReading.js";
5
+ export type TimelineReaction = {
6
+ emojiId: string;
7
+ userIds: string[];
8
+ mine: boolean;
9
+ };
10
+ export type TimelineItem<T> = {
11
+ id: string;
12
+ threadId: string;
13
+ timestamp: number;
14
+ from?: string;
15
+ mine: boolean;
16
+ status: "sending" | "sent" | "failed" | "read";
17
+ recalled: boolean;
18
+ message: T | undefined;
19
+ reading?: MessageReadingState;
20
+ reactions: readonly TimelineReaction[];
21
+ };
22
+ /** Derives one display stream without copying bodies or creating another cache.
23
+ * Outgoing rows retain their wire identity; indexed rows take precedence. */
24
+ export declare function projectMessageTimeline<T extends StoredMessage>(input: {
25
+ appId: string;
26
+ profileId: string;
27
+ threadId: string;
28
+ messages: readonly T[];
29
+ outgoing: readonly OutgoingMessage[];
30
+ decode: (message: ChatRuntimeMessage) => T | undefined;
31
+ reading: Readonly<Record<string, MessageReadingState>>;
32
+ previous?: readonly TimelineItem<T>[];
33
+ }): readonly TimelineItem<T>[];
@@ -0,0 +1,98 @@
1
+ import { isReadingControl } from "./readingContent.js";
2
+ function content(message, appId) {
3
+ const body = message.body;
4
+ return body?.kind === "message" && body.content?.appId === appId ? body : undefined;
5
+ }
6
+ function validReaction(value) {
7
+ if (!value || typeof value !== "object")
8
+ return false;
9
+ const event = value;
10
+ return [event.id, event.messageId, event.from, event.emojiId].every(field => typeof field === "string" && field.length > 0 && field.length <= 256) && typeof event.active === "boolean" && Number.isSafeInteger(event.revision) && event.revision >= 0 && Number.isFinite(event.timestamp);
11
+ }
12
+ /** Derives one display stream without copying bodies or creating another cache.
13
+ * Outgoing rows retain their wire identity; indexed rows take precedence. */
14
+ export function projectMessageTimeline(input) {
15
+ const scoped = (message) => {
16
+ const value = message, body = value.body;
17
+ const app = body?.content?.appId ?? body?.payload?.appId;
18
+ return message.threadId === input.threadId && (value.appId === undefined || value.appId === input.appId) && (app === undefined || app === input.appId);
19
+ };
20
+ const records = new Map();
21
+ for (const pending of input.outgoing) {
22
+ if (pending.threadId !== input.threadId || pending.confirmed)
23
+ continue;
24
+ const message = input.decode(pending.message);
25
+ if (message && scoped(message))
26
+ records.set(message.id, { message, status: pending.status });
27
+ }
28
+ for (const message of input.messages)
29
+ if (scoped(message))
30
+ records.set(message.id, { message, status: "sent" });
31
+ const recalled = new Map(), reactions = new Map();
32
+ const addReaction = (event) => {
33
+ const key = JSON.stringify([event.messageId, event.from, event.emojiId]), old = reactions.get(key);
34
+ if (!old || event.revision > old.revision || event.revision === old.revision && (event.timestamp > old.timestamp || event.timestamp === old.timestamp && event.id > old.id))
35
+ reactions.set(key, event);
36
+ };
37
+ for (const { message, status } of records.values()) {
38
+ if (status === "failed" && isReadingControl(message.body, input.appId))
39
+ continue;
40
+ const envelope = message, body = content(envelope, input.appId), target = body?.content?.recall?.messageId;
41
+ if (typeof target === "string" && target.length > 0 && target.length <= 256 && envelope.from) {
42
+ if (!recalled.has(target))
43
+ recalled.set(target, new Set());
44
+ recalled.get(target).add(envelope.from);
45
+ }
46
+ const reaction = body?.content?.reaction;
47
+ if (body?.contentKind === "text" && reaction?.version === 1 && typeof reaction.messageId === "string" && typeof reaction.emojiId === "string" && typeof reaction.active === "boolean" && typeof reaction.revision === "number" && reaction.revision >= 1 && envelope.from) {
48
+ const event = { id: message.id, messageId: reaction.messageId, from: envelope.from, emojiId: reaction.emojiId, active: reaction.active, revision: reaction.revision, timestamp: message.timestamp };
49
+ if (validReaction(event))
50
+ addReaction(event);
51
+ }
52
+ const events = envelope.reactionEvents ?? envelope.legacyDisplay?.reactionEvents;
53
+ if (Array.isArray(events))
54
+ for (const event of events)
55
+ if (validReaction(event) && event.messageId === message.id && (event.threadId === undefined || event.threadId === input.threadId))
56
+ addReaction(event);
57
+ // Old display-only imports may have aggregates but no event IDs. Treat them
58
+ // as revision-zero baselines; any durable control supersedes them.
59
+ const baseline = envelope.legacyDisplay?.reactions;
60
+ if (Array.isArray(baseline))
61
+ for (const value of baseline) {
62
+ if (typeof value?.emojiId !== "string" || !Array.isArray(value.userIds))
63
+ continue;
64
+ for (const from of value.userIds) {
65
+ const event = { id: `legacy:${message.id}`, messageId: message.id, from, emojiId: value.emojiId, active: true, revision: 0, timestamp: 0 };
66
+ if (validReaction(event))
67
+ addReaction(event);
68
+ }
69
+ }
70
+ }
71
+ const grouped = new Map();
72
+ for (const event of reactions.values())
73
+ if (event.active) {
74
+ if (!grouped.has(event.messageId))
75
+ grouped.set(event.messageId, new Map());
76
+ const emojis = grouped.get(event.messageId);
77
+ if (!emojis.has(event.emojiId))
78
+ emojis.set(event.emojiId, new Set());
79
+ emojis.get(event.emojiId).add(event.from);
80
+ }
81
+ const previous = new Map(input.previous?.map(item => [item.id, item]) ?? []);
82
+ const result = [];
83
+ for (const { message, status } of records.values()) {
84
+ const envelope = message;
85
+ if (isReadingControl(envelope.body, input.appId))
86
+ continue;
87
+ const mine = envelope.from === input.profileId, reading = input.reading[message.id];
88
+ const withdrawn = Boolean(message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
89
+ const item = { id: message.id, threadId: message.threadId, timestamp: message.timestamp, from: envelope.from, mine, status: !mine && reading?.read ? "read" : status, recalled: withdrawn,
90
+ message: withdrawn ? undefined : message, reading, reactions: withdrawn ? [] : [...(grouped.get(message.id) ?? [])].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([emojiId, users]) => { const userIds = [...users].sort(); return { emojiId, userIds, mine: userIds.includes(input.profileId) }; }) };
91
+ const old = previous.get(item.id);
92
+ const sameReading = old?.reading === item.reading || old?.reading?.seq === item.reading?.seq && old?.reading?.read === item.reading?.read && old?.reading?.readPolicy === item.reading?.readPolicy;
93
+ const sameReactions = old?.reactions.length === item.reactions.length && old.reactions.every((reaction, index) => { const next = item.reactions[index]; return reaction.emojiId === next.emojiId && reaction.mine === next.mine && reaction.userIds.length === next.userIds.length && reaction.userIds.every((user, i) => user === next.userIds[i]); });
94
+ result.push(old && old.message === item.message && old.timestamp === item.timestamp && old.from === item.from && old.mine === item.mine && old.status === item.status && old.recalled === item.recalled && sameReading && sameReactions ? old : item);
95
+ }
96
+ result.sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
97
+ return input.previous?.length === result.length && result.every((item, index) => item === input.previous[index]) ? input.previous : result;
98
+ }
@@ -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.29",
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",