@pmate/chat-sdk 0.12.0-next.28 → 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,4 @@
1
+ import { type TimelineItem } from "./messageTimeline.js";
1
2
  import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
2
3
  import type { ConversationReadMessage } from "./conversationRead.js";
3
4
  import { type ReadCountsResult } from "./readCounts.js";
@@ -22,6 +23,7 @@ export type ChatSessionOptions<T extends StoredMessage> = {
22
23
  };
23
24
  export type ChatThreadSnapshot<T> = {
24
25
  messages: readonly T[];
26
+ timeline: readonly TimelineItem<T>[];
25
27
  outgoing: ReturnType<DurableChatSender["snapshot"]>;
26
28
  hasEarlier: boolean;
27
29
  resetVersion: number;
@@ -34,6 +36,7 @@ export type ChatSummarySnapshot<T> = {
34
36
  threads: readonly {
35
37
  threadId: string;
36
38
  lastMessage?: T;
39
+ lastItem?: TimelineItem<T>;
37
40
  unread: ChatThreadSnapshot<T>["unread"];
38
41
  outgoingCount: number;
39
42
  }[];
@@ -57,6 +60,8 @@ export declare class ChatSession<T extends StoredMessage> {
57
60
  private sender;
58
61
  private listeners;
59
62
  private snapshots;
63
+ private timelineDecoded;
64
+ private decodeTimeline;
60
65
  private historyEdges;
61
66
  private earlierReads;
62
67
  private off;
@@ -128,6 +133,7 @@ export declare class ChatSession<T extends StoredMessage> {
128
133
  private watchCounts;
129
134
  getSnapshot(): ChatSummarySnapshot<T>;
130
135
  getSnapshot(threadId: string): ChatThreadSnapshot<T>;
136
+ private buildThreadSnapshot;
131
137
  subscribe(listener: () => void): () => void;
132
138
  subscribe(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
133
139
  private publishAll;
@@ -1,3 +1,4 @@
1
+ import { projectMessageTimeline } from "./messageTimeline.js";
1
2
  import { projectConversationReading } from "./conversationReading.js";
2
3
  import { ReadCountsClient } from "./readCounts.js";
3
4
  import { WindowedTasks } from "./coalescer.js";
@@ -23,12 +24,15 @@ export class ChatSession {
23
24
  sender;
24
25
  listeners = new Map();
25
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); };
26
30
  historyEdges = new Map();
27
31
  earlierReads = new Map();
28
32
  off = [];
29
33
  globalListeners = new Set();
30
34
  globalSnapshot;
31
- closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
35
+ closedThread = { messages: [], timeline: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
32
36
  closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
33
37
  connectionState = "closed";
34
38
  catchUp;
@@ -429,17 +433,26 @@ export class ChatSession {
429
433
  }
430
434
  let snapshot = this.snapshots.get(threadId);
431
435
  if (!snapshot) {
432
- const messages = this.messages.getSnapshot(threadId);
433
- const state = this.getReadingSnapshot();
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 };
436
+ snapshot = this.buildThreadSnapshot(threadId);
439
437
  this.snapshots.set(threadId, snapshot);
440
438
  }
441
439
  return snapshot;
442
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
+ }
443
456
  subscribe(target, listener, fields) {
444
457
  if (this.closed || this.withdrawn)
445
458
  return () => { };
@@ -488,7 +501,7 @@ export class ChatSession {
488
501
  ids.add(outgoing.threadId);
489
502
  const threads = [...ids].sort().map(threadId => {
490
503
  const snapshot = this.getSnapshot(threadId);
491
- 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 };
492
505
  });
493
506
  const state = this.getReadingSnapshot();
494
507
  const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
@@ -498,7 +511,11 @@ export class ChatSession {
498
511
  if (this.closed || this.withdrawn)
499
512
  return;
500
513
  const next = this.buildGlobal();
501
- 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
+ }))
502
519
  return;
503
520
  this.globalSnapshot = next;
504
521
  this.globalListeners.forEach(listener => listener());
@@ -506,8 +523,9 @@ export class ChatSession {
506
523
  publish(threadId, notifyGlobal = true) {
507
524
  const previous = this.snapshots.get(threadId);
508
525
  this.snapshots.delete(threadId);
509
- const next = this.getSnapshot(threadId);
510
- 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)) {
511
529
  this.snapshots.set(threadId, previous);
512
530
  return;
513
531
  }
@@ -533,6 +551,7 @@ export class ChatSession {
533
551
  this.counts?.watch(undefined, []);
534
552
  this.countSnapshots.clear();
535
553
  this.snapshots.clear();
554
+ this.timelineDecoded = new WeakMap();
536
555
  this.historyEdges.clear();
537
556
  this.earlierReads.clear();
538
557
  this.globalSnapshot = undefined;
@@ -598,6 +617,7 @@ export class ChatSession {
598
617
  this.globalListeners.clear();
599
618
  this.readingListeners.clear();
600
619
  this.snapshots.clear();
620
+ this.timelineDecoded = new WeakMap();
601
621
  this.countSnapshots.clear();
602
622
  this.historyEdges.clear();
603
623
  this.earlierReads.clear();
@@ -31,3 +31,4 @@ export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
32
  export * from "./conversationRead.js";
33
33
  export { projectConversationReading, type ReadingCandidate, type MessageReadingState, type ConversationReadingView } from "./conversationReading.js";
34
+ export { projectMessageTimeline, type TimelineItem, type TimelineReaction } from "./messageTimeline.js";
@@ -31,3 +31,4 @@ export * from "./messageReaders.js";
31
31
  export * from "./createChat.js";
32
32
  export * from "./conversationRead.js";
33
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.28",
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",