@pmate/chat-sdk 0.12.0-next.30 → 0.12.0-next.32
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.
- package/lib/runtime/chatSession.js +14 -10
- package/lib/runtime/conversationReading.js +7 -3
- package/lib/runtime/index.d.ts +1 -1
- package/lib/runtime/messageTimeline.d.ts +14 -0
- package/lib/runtime/messageTimeline.js +13 -3
- package/lib/runtime/readIndex.d.ts +2 -2
- package/lib/runtime/readIndex.js +4 -4
- package/package.json +1 -1
|
@@ -439,19 +439,23 @@ export class ChatSession {
|
|
|
439
439
|
return snapshot;
|
|
440
440
|
}
|
|
441
441
|
buildThreadSnapshot(threadId, previous) {
|
|
442
|
-
const
|
|
442
|
+
const source = this.messages.getSnapshot(threadId), raw = source?.messages ?? [];
|
|
443
|
+
const messages = previous && previous.messages.length === raw.length && previous.messages.every((message, index) => message === raw[index]) ? previous.messages : raw;
|
|
444
|
+
const outgoing = this.sender.snapshot(threadId);
|
|
445
|
+
const sameSource = previous && previous.messages === messages && previous.outgoing.length === outgoing.length && previous.outgoing.every((record, index) => record === outgoing[index]);
|
|
446
|
+
const semanticReading = previous?.messageReading ?? {};
|
|
447
|
+
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 });
|
|
448
|
+
const candidates = new Map(messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [[candidate.id, candidate]] : []; }));
|
|
449
|
+
for (const item of semantics)
|
|
450
|
+
if (item.recalled)
|
|
451
|
+
candidates.set(item.id, { ...candidates.get(item.id), id: item.id, threadId, from: item.from, mine: item.mine, recalled: true });
|
|
443
452
|
const state = this.getReadingSnapshot();
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
const kind = sources[0]?.kind ?? "dm";
|
|
447
|
-
const view = projectConversationReading(state, { id: threadId, kind }, candidates);
|
|
453
|
+
const kind = state?.threads.find(thread => (thread.conversationId ?? thread.id) === threadId)?.kind ?? "dm";
|
|
454
|
+
const view = projectConversationReading(state, { id: threadId, kind }, [...candidates.values()]);
|
|
448
455
|
const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
|
|
449
456
|
const messageReading = previous && JSON.stringify(previous.messageReading) === JSON.stringify(view.messageReading) ? previous.messageReading : view.messageReading;
|
|
450
|
-
const
|
|
451
|
-
|
|
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 };
|
|
457
|
+
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 });
|
|
458
|
+
return { timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
|
|
455
459
|
}
|
|
456
460
|
subscribe(target, listener, fields) {
|
|
457
461
|
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)) };
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -31,4 +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";
|
|
34
|
+
export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type TimelineReactionEvent } from "./messageTimeline.js";
|
|
@@ -2,6 +2,9 @@ import type { StoredMessage } from "./messageStore.js";
|
|
|
2
2
|
import type { OutgoingMessage } from "./durableSender.js";
|
|
3
3
|
import type { ChatRuntimeMessage } from "./protocol.js";
|
|
4
4
|
import type { MessageReadingState } from "./conversationReading.js";
|
|
5
|
+
export type TimelineReactionEvent = Reaction & {
|
|
6
|
+
threadId: string;
|
|
7
|
+
};
|
|
5
8
|
export type TimelineReaction = {
|
|
6
9
|
emojiId: string;
|
|
7
10
|
userIds: string[];
|
|
@@ -18,6 +21,16 @@ export type TimelineItem<T> = {
|
|
|
18
21
|
message: T | undefined;
|
|
19
22
|
reading?: MessageReadingState;
|
|
20
23
|
reactions: readonly TimelineReaction[];
|
|
24
|
+
reactionEvents: readonly TimelineReactionEvent[];
|
|
25
|
+
};
|
|
26
|
+
type Reaction = {
|
|
27
|
+
id: string;
|
|
28
|
+
messageId: string;
|
|
29
|
+
from: string;
|
|
30
|
+
emojiId: string;
|
|
31
|
+
active: boolean;
|
|
32
|
+
revision: number;
|
|
33
|
+
timestamp: number;
|
|
21
34
|
};
|
|
22
35
|
/** Derives one display stream without copying bodies or creating another cache.
|
|
23
36
|
* Outgoing rows retain their wire identity; indexed rows take precedence. */
|
|
@@ -31,3 +44,4 @@ export declare function projectMessageTimeline<T extends StoredMessage>(input: {
|
|
|
31
44
|
reading: Readonly<Record<string, MessageReadingState>>;
|
|
32
45
|
previous?: readonly TimelineItem<T>[];
|
|
33
46
|
}): readonly TimelineItem<T>[];
|
|
47
|
+
export {};
|
|
@@ -17,6 +17,7 @@ export function projectMessageTimeline(input) {
|
|
|
17
17
|
const app = body?.content?.appId ?? body?.payload?.appId;
|
|
18
18
|
return message.threadId === input.threadId && (value.appId === undefined || value.appId === input.appId) && (app === undefined || app === input.appId);
|
|
19
19
|
};
|
|
20
|
+
const tombstones = new Set(input.messages.filter(message => scoped(message) && (message.recalled || message.legacyDisplay?.recalled)).map(message => message.id));
|
|
20
21
|
const records = new Map();
|
|
21
22
|
for (const pending of input.outgoing) {
|
|
22
23
|
if (pending.threadId !== input.threadId || pending.confirmed)
|
|
@@ -74,6 +75,14 @@ export function projectMessageTimeline(input) {
|
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
}
|
|
78
|
+
const groupedEvents = new Map();
|
|
79
|
+
for (const event of reactions.values()) {
|
|
80
|
+
const values = groupedEvents.get(event.messageId) ?? [];
|
|
81
|
+
values.push({ ...event, threadId: input.threadId });
|
|
82
|
+
groupedEvents.set(event.messageId, values);
|
|
83
|
+
}
|
|
84
|
+
for (const values of groupedEvents.values())
|
|
85
|
+
values.sort((a, b) => a.from < b.from ? -1 : a.from > b.from ? 1 : a.emojiId < b.emojiId ? -1 : a.emojiId > b.emojiId ? 1 : 0);
|
|
77
86
|
const grouped = new Map();
|
|
78
87
|
for (const event of reactions.values())
|
|
79
88
|
if (event.active) {
|
|
@@ -91,13 +100,14 @@ export function projectMessageTimeline(input) {
|
|
|
91
100
|
if (isReadingControl(envelope.body, input.appId))
|
|
92
101
|
continue;
|
|
93
102
|
const mine = envelope.from === input.profileId, reading = input.reading[message.id];
|
|
94
|
-
const withdrawn = Boolean(message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
|
|
103
|
+
const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
|
|
95
104
|
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,
|
|
96
|
-
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) }; }) };
|
|
105
|
+
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) }; }) };
|
|
97
106
|
const old = previous.get(item.id);
|
|
98
107
|
const sameReading = old?.reading === item.reading || old?.reading?.seq === item.reading?.seq && old?.reading?.read === item.reading?.read && old?.reading?.readPolicy === item.reading?.readPolicy;
|
|
99
108
|
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]); });
|
|
100
|
-
|
|
109
|
+
const sameEvents = old?.reactionEvents.length === item.reactionEvents.length && old.reactionEvents.every((event, index) => { const next = item.reactionEvents[index]; return event.id === next.id && event.from === next.from && event.emojiId === next.emojiId && event.active === next.active && event.revision === next.revision && event.timestamp === next.timestamp && event.threadId === next.threadId && event.messageId === next.messageId; });
|
|
110
|
+
result.push(old && sameEvents && 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);
|
|
101
111
|
}
|
|
102
112
|
result.sort((a, b) => a.timestamp - b.timestamp || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
103
113
|
return input.previous?.length === result.length && result.every((item, index) => item === input.previous[index]) ? input.previous : result;
|
|
@@ -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;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -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
|
}
|
package/package.json
CHANGED