@pmate/chat-sdk 0.12.0-next.31 → 0.12.0-next.33

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.
@@ -203,7 +203,16 @@ export class ChatSession {
203
203
  }
204
204
  synchronizeReading(cursor, profileId) { return this.readingOperation(() => this.reading.synchronize(cursor, profileId), false); }
205
205
  /** Explicit text-history acknowledgement; unheard audio stays unread. */
206
- markRead(threadId, messages = []) { return this.readingOperation(() => this.reading.markRead(threadId, messages)); }
206
+ async markRead(threadId, messages) {
207
+ this.assertOpen();
208
+ const cached = messages ?? this.getSnapshot(threadId).timeline.flatMap(item => {
209
+ if (item.recalled || !item.message)
210
+ return [];
211
+ const candidate = this.options.readingCandidate?.(item.message);
212
+ return candidate ? [{ id: item.id, threadId, from: item.from, mine: item.mine, recalled: item.recalled, readPolicy: candidate.readPolicy }] : [];
213
+ });
214
+ return this.readingOperation(() => this.reading.markRead(threadId, cached));
215
+ }
207
216
  updateReading(transform) { return this.readingOperation(() => this.reading.update(transform)); }
208
217
  assertOpen() { if (this.withdrawn)
209
218
  throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
@@ -439,19 +448,23 @@ export class ChatSession {
439
448
  return snapshot;
440
449
  }
441
450
  buildThreadSnapshot(threadId, previous) {
442
- const messages = this.messages.getSnapshot(threadId);
451
+ const source = this.messages.getSnapshot(threadId), raw = source?.messages ?? [];
452
+ const messages = previous && previous.messages.length === raw.length && previous.messages.every((message, index) => message === raw[index]) ? previous.messages : raw;
453
+ const outgoing = this.sender.snapshot(threadId);
454
+ const sameSource = previous && previous.messages === messages && previous.outgoing.length === outgoing.length && previous.outgoing.every((record, index) => record === outgoing[index]);
455
+ const semanticReading = previous?.messageReading ?? {};
456
+ const semantics = sameSource ? previous.timeline : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, decode: this.decodeTimeline, reading: semanticReading, previous: previous?.timeline });
457
+ const candidates = new Map(messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [[candidate.id, candidate]] : []; }));
458
+ for (const item of semantics)
459
+ if (item.recalled)
460
+ candidates.set(item.id, { ...candidates.get(item.id), id: item.id, threadId, from: item.from, mine: item.mine, recalled: true });
443
461
  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);
462
+ const kind = state?.threads.find(thread => (thread.conversationId ?? thread.id) === threadId)?.kind ?? "dm";
463
+ const view = projectConversationReading(state, { id: threadId, kind }, [...candidates.values()]);
448
464
  const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
449
465
  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 };
466
+ const timeline = messageReading === semanticReading ? semantics : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, decode: this.decodeTimeline, reading: messageReading, previous: previous?.timeline });
467
+ return { timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
455
468
  }
456
469
  subscribe(target, listener, fields) {
457
470
  if (this.closed || this.withdrawn)
@@ -2,13 +2,18 @@ import { sourcesInConversation, unreadInConversation } from "./readIndex.js";
2
2
  /** One projection for indexed authority and observations awaiting indexing. A
3
3
  * directory badge is optional compatibility metadata, never proof of coverage. */
4
4
  export function projectConversationReading(state, conversation, candidates = []) {
5
+ const unique = new Map(candidates.filter(message => message.threadId === conversation.id).map(message => [message.id, message]));
6
+ const hidden = new Map();
7
+ for (const message of unique.values())
8
+ if (message.recalled && message.from)
9
+ hidden.set(message.id, new Set([message.from]));
5
10
  const sources = state ? sourcesInConversation(state, conversation.id) : [];
6
11
  const sourceIds = new Set([conversation.id, ...sources.map(source => source.id)]);
7
12
  const indexed = new Set(), messageReading = Object.create(null);
8
13
  for (const source of sources)
9
14
  for (const message of source.messages) {
10
15
  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) };
16
+ messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: message.recalled || hidden.get(message.id)?.has(message.from) === true || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= source.readThroughSeq) };
12
17
  }
13
18
  const observed = new Map();
14
19
  for (const intent of state?.observedReadIntents ?? [])
@@ -17,7 +22,6 @@ export function projectConversationReading(state, conversation, candidates = [])
17
22
  observed.set(intent.messageId, new Set());
18
23
  observed.get(intent.messageId).add(intent.readPolicy);
19
24
  }
20
- const unique = new Map(candidates.filter(message => message.threadId === conversation.id).map(message => [message.id, message]));
21
25
  let pendingMessages = 0, pendingMentions = 0, observedBadges = 0;
22
26
  for (const message of unique.values()) {
23
27
  const incoming = !message.mine && message.from !== state?.profileId && !message.recalled;
@@ -36,7 +40,7 @@ export function projectConversationReading(state, conversation, candidates = [])
36
40
  if (message.attentionEligible === true)
37
41
  pendingMentions++;
38
42
  }
39
- const authoritative = sources.length && state ? unreadInConversation(state, conversation.id) : undefined;
43
+ const authoritative = sources.length && state ? unreadInConversation(state, conversation.id, hidden) : undefined;
40
44
  const unread = authoritative ? { unreadMessageCount: authoritative.unreadMessageCount + pendingMessages, unreadMentionCount: authoritative.unreadMentionCount + pendingMentions, badgeCount: authoritative.badgeCount + (conversation.kind === "group" ? pendingMentions : pendingMessages) } : undefined;
41
45
  const pendingBadge = conversation.kind === "group" ? pendingMentions : pendingMessages;
42
46
  return { messageReading, unread, badgeCount: unread?.badgeCount ?? (conversation.badgeCount === undefined ? undefined : Math.max(Math.max(0, conversation.badgeCount - observedBadges), pendingBadge)) };
@@ -4,6 +4,7 @@ import type { ChatClient, ChatClientOptions } from "./chatClient.js";
4
4
  export type CachedChatMessage = ChatReaderSocialMessage & {
5
5
  id: string;
6
6
  timestamp: number;
7
+ readPolicy?: "visible" | "explicit";
7
8
  };
8
9
  export type CreateChatOptions = {
9
10
  appId: string;
@@ -1,4 +1,4 @@
1
- import { isReadingControl } from "./readingContent.js";
1
+ import { isReadingControl, messageRequiresPlayback, messageMentionsProfile } from "./readingContent.js";
2
2
  import { ChatSession } from "./chatSession.js";
3
3
  import { SocialReadClient } from "./socialReadClient.js";
4
4
  import { chatTeamId } from "./teamScope.js";
@@ -29,7 +29,7 @@ export function createChat(options) {
29
29
  if (!body || !["message", "quote"].includes(String(body.kind)) && !(body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action))))
30
30
  return undefined;
31
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) };
32
+ readPolicy: message.readPolicy === "explicit" || message.legacyDisplay?.kind === "voice" || messageRequiresPlayback(body) ? "explicit" : "visible", attentionEligible: messageMentionsProfile(body, profileId) };
33
33
  },
34
34
  decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
35
35
  });
@@ -3,6 +3,11 @@ function content(message, appId) {
3
3
  const body = message.body;
4
4
  return body?.kind === "message" && body.content?.appId === appId ? body : undefined;
5
5
  }
6
+ function nativeRecall(message, appId) {
7
+ const body = message.body;
8
+ return message.appId === appId && body?.kind === "recall" && typeof body.targetMessageId === "string" && body.targetMessageId.length > 0 && body.targetMessageId.length <= 256 ? body.targetMessageId : undefined;
9
+ }
10
+ function isControl(message, appId) { return isReadingControl(message.body, appId) || nativeRecall(message, appId) !== undefined; }
6
11
  function validReaction(value) {
7
12
  if (!value || typeof value !== "object")
8
13
  return false;
@@ -17,6 +22,7 @@ export function projectMessageTimeline(input) {
17
22
  const app = body?.content?.appId ?? body?.payload?.appId;
18
23
  return message.threadId === input.threadId && (value.appId === undefined || value.appId === input.appId) && (app === undefined || app === input.appId);
19
24
  };
25
+ const tombstones = new Set(input.messages.filter(message => scoped(message) && (message.recalled || message.legacyDisplay?.recalled)).map(message => message.id));
20
26
  const records = new Map();
21
27
  for (const pending of input.outgoing) {
22
28
  if (pending.threadId !== input.threadId || pending.confirmed)
@@ -41,9 +47,9 @@ export function projectMessageTimeline(input) {
41
47
  reactions.set(key, event);
42
48
  };
43
49
  for (const { message, status } of records.values()) {
44
- if (status === "failed" && isReadingControl(message.body, input.appId))
50
+ if (status === "failed" && isControl(message, input.appId))
45
51
  continue;
46
- const envelope = message, body = content(envelope, input.appId), target = body?.content?.recall?.messageId;
52
+ const envelope = message, body = content(envelope, input.appId), target = nativeRecall(envelope, input.appId) ?? body?.content?.recall?.messageId;
47
53
  if (typeof target === "string" && target.length > 0 && target.length <= 256 && envelope.from) {
48
54
  if (!recalled.has(target))
49
55
  recalled.set(target, new Set());
@@ -96,10 +102,10 @@ export function projectMessageTimeline(input) {
96
102
  const result = [];
97
103
  for (const { message, status } of records.values()) {
98
104
  const envelope = message;
99
- if (isReadingControl(envelope.body, input.appId))
105
+ if (isControl(envelope, input.appId))
100
106
  continue;
101
107
  const mine = envelope.from === input.profileId, reading = input.reading[message.id];
102
- const withdrawn = Boolean(message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
108
+ const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
103
109
  const item = { id: message.id, threadId: message.threadId, timestamp: message.timestamp, from: envelope.from, mine, status: !mine && reading ? (reading.read ? "read" : status === "read" ? "sent" : status) : status, recalled: withdrawn,
104
110
  message: withdrawn ? undefined : message, reading, reactionEvents: withdrawn ? [] : groupedEvents.get(message.id) ?? [], 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) }; }) };
105
111
  const old = previous.get(item.id);
@@ -72,7 +72,7 @@ export declare function coalesceReadOperations(current: ReadIndexState): (Readin
72
72
  operationIds: string[];
73
73
  })[];
74
74
  /** Counts are over the indexed message coverage, not an estimate of missing history. */
75
- export declare function unreadInThread(state: ReadIndexState, threadId: string): {
75
+ export declare function unreadInThread(state: ReadIndexState, threadId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>): {
76
76
  unreadMessageCount: number;
77
77
  unreadMentionCount: number;
78
78
  badgeCount: number;
@@ -126,7 +126,7 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
126
126
  }
127
127
  export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
128
128
  /** Aggregate logical history while each original source retains its own read cursor. */
129
- export declare function unreadInConversation(state: ReadIndexState, conversationId: string): {
129
+ export declare function unreadInConversation(state: ReadIndexState, conversationId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>): {
130
130
  unreadMessageCount: number;
131
131
  unreadMentionCount: number;
132
132
  badgeCount: number;
@@ -154,12 +154,12 @@ export function coalesceReadOperations(current) {
154
154
  return [...batches.values()];
155
155
  }
156
156
  /** Counts are over the indexed message coverage, not an estimate of missing history. */
157
- export function unreadInThread(state, threadId) {
157
+ export function unreadInThread(state, threadId, hidden) {
158
158
  const thread = state.threads.find(t => t.id === threadId);
159
159
  if (!thread)
160
160
  throw new Error("UNKNOWN_READ_THREAD");
161
161
  const explicit = new Set(thread.explicitReadMessageIds);
162
- const messages = thread.messages.filter(message => !message.recalled && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
162
+ const messages = thread.messages.filter(message => !message.recalled && !hidden?.get(message.id)?.has(message.from) && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
163
163
  && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
164
164
  const unreadMessageCount = messages.length;
165
165
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
@@ -426,14 +426,14 @@ function readSource(state, conversationId, messageId) {
426
426
  return sources[0];
427
427
  }
428
428
  /** Aggregate logical history while each original source retains its own read cursor. */
429
- export function unreadInConversation(state, conversationId) {
429
+ export function unreadInConversation(state, conversationId, hidden) {
430
430
  const sources = sourcesInConversation(state, conversationId);
431
431
  if (!sources.length)
432
432
  throw new Error("UNKNOWN_READ_CONVERSATION");
433
433
  if (new Set(sources.map(source => source.teamId)).size > 1)
434
434
  throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
435
435
  return sources.reduce((total, source) => {
436
- const unread = unreadInThread(state, source.id);
436
+ const unread = unreadInThread(state, source.id, hidden);
437
437
  return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
438
438
  }, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
439
439
  }
@@ -1,2 +1,5 @@
1
1
  /** Social control envelopes do not contribute to message unread counts. */
2
2
  export declare function isReadingControl(payload: unknown, appId: string): boolean;
3
+ /** Mirrors the server's voice policy for ordinary and quoted wire envelopes. */
4
+ export declare function messageRequiresPlayback(body: unknown): boolean;
5
+ export declare function messageMentionsProfile(body: unknown, profileId: string): boolean;
@@ -7,3 +7,17 @@ export function isReadingControl(payload, appId) {
7
7
  return isReadingControl(value.body, appId);
8
8
  return value.kind === "message" && value.content?.appId === appId && (value.content.reaction !== undefined || value.content.recall !== undefined);
9
9
  }
10
+ /** Mirrors the server's voice policy for ordinary and quoted wire envelopes. */
11
+ export function messageRequiresPlayback(body) {
12
+ if (!body || typeof body !== "object")
13
+ return false;
14
+ const value = body;
15
+ return value.contentKind === "voice" || value.content?.contentKind === "voice" || [value.content?.voice?.url, value.content?.content?.voice?.url].some(url => typeof url === "string" && url.length > 0);
16
+ }
17
+ export function messageMentionsProfile(body, profileId) {
18
+ if (!body || typeof body !== "object")
19
+ return false;
20
+ const content = body.content;
21
+ const mentions = content?.mentions !== undefined ? content.mentions : content?.content?.mentions;
22
+ return Array.isArray(mentions) && mentions.some(mention => mention?.id === profileId);
23
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.31",
3
+ "version": "0.12.0-next.33",
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",