@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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
510
|
-
|
|
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();
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -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";
|
package/lib/runtime/index.js
CHANGED
|
@@ -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