@pmate/chat-sdk 0.12.0-next.32 → 0.12.0-next.34

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)
@@ -252,7 +261,8 @@ export class ChatSession {
252
261
  return { ...recent, messages: [], hasEarlier: false };
253
262
  if (!edge.before)
254
263
  throw new Error("MESSAGE_SYNC_NO_PROGRESS");
255
- const page = await this.getMessages(threadId, { before: edge.before });
264
+ const page = await this.messages.earlier(threadId, edge.before, 100, true);
265
+ await this.sender.confirm(page.messages.map(message => message.id));
256
266
  this.assertOpen();
257
267
  this.rememberHistoryEdge(threadId, page);
258
268
  return page;
@@ -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
  });
@@ -36,6 +36,7 @@ export declare class MessageClient<T extends StoredMessage> {
36
36
  private options;
37
37
  private storage;
38
38
  private watches;
39
+ private windowEdges;
39
40
  private accessListeners;
40
41
  private allListeners;
41
42
  private reads;
@@ -93,7 +94,7 @@ export declare class MessageClient<T extends StoredMessage> {
93
94
  earlier(thread: string, before: {
94
95
  timestamp: number;
95
96
  id: string;
96
- }, limit?: number): Promise<MessageSnapshot<T>>;
97
+ }, limit?: number, expand?: boolean): Promise<MessageSnapshot<T>>;
97
98
  private fillWindow;
98
99
  private denyView;
99
100
  private denyScope;
@@ -6,6 +6,7 @@ export class MessageClient {
6
6
  options;
7
7
  storage;
8
8
  watches = new Map();
9
+ windowEdges = new Map();
9
10
  accessListeners = new Set();
10
11
  allListeners = new Set();
11
12
  reads = new Map();
@@ -252,7 +253,27 @@ export class MessageClient {
252
253
  async reload(thread, fallback = false) {
253
254
  await this.ensureScope();
254
255
  const limit = 100;
255
- const page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
256
+ let page = await (fallback ? this.storage.readFallback(thread, limit) : this.storage.read(thread, limit));
257
+ const edge = this.windowEdges.get(thread);
258
+ if (edge && edge.version !== (page.coverage?.resetVersion ?? 0))
259
+ this.windowEdges.delete(thread);
260
+ if (edge && edge.version === (page.coverage?.resetVersion ?? 0)) {
261
+ const pages = [page.messages];
262
+ const beforeEdge = (message) => message.timestamp < edge.timestamp || message.timestamp === edge.timestamp && message.id < edge.id;
263
+ let oldest = page.messages[0];
264
+ while (oldest && !beforeEdge(oldest) && (oldest.timestamp !== edge.timestamp || oldest.id !== edge.id) && page.hasEarlier) {
265
+ const next = await (fallback ? this.storage.readFallback(thread, limit, { timestamp: oldest.timestamp, id: oldest.id }) : this.storage.read(thread, limit, { timestamp: oldest.timestamp, id: oldest.id }));
266
+ if ((next.coverage?.resetVersion ?? 0) !== edge.version || (next.coverage?.scopeVersion ?? 0) !== (page.coverage?.scopeVersion ?? 0)) {
267
+ this.windowEdges.delete(thread);
268
+ return this.reload(thread, fallback);
269
+ }
270
+ pages.push(next.messages);
271
+ page = next;
272
+ oldest = next.messages[0];
273
+ }
274
+ const messages = pages.reverse().flat();
275
+ page = { ...page, messages: messages.filter(message => !beforeEdge(message)), hasEarlier: page.hasEarlier || messages.some(beforeEdge) };
276
+ }
256
277
  if (this.closed)
257
278
  throw new Error("MESSAGE_CLIENT_CLOSED");
258
279
  if ((page.coverage?.scopeVersion ?? 0) !== this.scopeVersion) {
@@ -464,7 +485,7 @@ export class MessageClient {
464
485
  this.syncing.set(thread, task);
465
486
  return task;
466
487
  }
467
- async earlier(thread, before, limit = 100) {
488
+ async earlier(thread, before, limit = 100, expand = false) {
468
489
  await this.ensureScope();
469
490
  if (this.scopeDenied)
470
491
  throw new Error("MESSAGE_ACCESS_REVOKED");
@@ -478,7 +499,14 @@ export class MessageClient {
478
499
  throw new Error("MESSAGE_OFFLINE_UNAVAILABLE");
479
500
  await this.synchronize(thread);
480
501
  }
481
- return await this.fillWindow(thread, limit, before);
502
+ const page = await this.fillWindow(thread, limit, before);
503
+ if (expand && page.complete && page.messages.length) {
504
+ const first = page.messages[0], old = this.windowEdges.get(thread);
505
+ if (!old || old.version !== page.resetVersion || first.timestamp < old.timestamp || first.timestamp === old.timestamp && first.id < old.id)
506
+ this.windowEdges.set(thread, { timestamp: first.timestamp, id: first.id, version: page.resetVersion });
507
+ await this.reload(thread);
508
+ }
509
+ return page;
482
510
  }
483
511
  catch (error) {
484
512
  if (String(error).includes("MESSAGE_SCOPE_CHANGED")) {
@@ -538,6 +566,7 @@ export class MessageClient {
538
566
  return { messages: page.messages, hasEarlier: page.hasEarlier || Boolean(page.coverage?.nextBefore), resetVersion: page.coverage?.resetVersion ?? 0, complete: page.coverage?.complete ?? false };
539
567
  }
540
568
  denyView(thread, version) {
569
+ this.windowEdges.delete(thread);
541
570
  this.revoked.add(thread);
542
571
  const watch = this.watches.get(thread);
543
572
  const snapshot = { messages: [], hasEarlier: false, complete: false, resetVersion: Math.max(watch?.snapshot.resetVersion ?? 0, version ?? (watch?.snapshot.resetVersion ?? 0) + 1) };
@@ -619,6 +648,7 @@ export class MessageClient {
619
648
  this.abort.abort();
620
649
  this.channel?.close();
621
650
  this.watches.clear();
651
+ this.windowEdges.clear();
622
652
  this.allListeners.clear();
623
653
  this.accessListeners.clear();
624
654
  this.storage.close();
@@ -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;
@@ -42,9 +47,9 @@ export function projectMessageTimeline(input) {
42
47
  reactions.set(key, event);
43
48
  };
44
49
  for (const { message, status } of records.values()) {
45
- if (status === "failed" && isReadingControl(message.body, input.appId))
50
+ if (status === "failed" && isControl(message, input.appId))
46
51
  continue;
47
- 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;
48
53
  if (typeof target === "string" && target.length > 0 && target.length <= 256 && envelope.from) {
49
54
  if (!recalled.has(target))
50
55
  recalled.set(target, new Set());
@@ -97,7 +102,7 @@ export function projectMessageTimeline(input) {
97
102
  const result = [];
98
103
  for (const { message, status } of records.values()) {
99
104
  const envelope = message;
100
- if (isReadingControl(envelope.body, input.appId))
105
+ if (isControl(envelope, input.appId))
101
106
  continue;
102
107
  const mine = envelope.from === input.profileId, reading = input.reading[message.id];
103
108
  const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
@@ -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.32",
3
+ "version": "0.12.0-next.34",
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",