@pmate/chat-sdk 0.12.0-next.27 → 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.
- package/lib/runtime/chatSession.d.ts +9 -0
- package/lib/runtime/chatSession.js +33 -8
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +43 -0
- package/lib/runtime/createChat.js +10 -0
- package/lib/runtime/index.d.ts +2 -0
- package/lib/runtime/index.js +2 -0
- package/lib/runtime/messageTimeline.d.ts +33 -0
- package/lib/runtime/messageTimeline.js +98 -0
- package/lib/runtime/readIndex.js +2 -1
- package/lib/runtime/readingContent.d.ts +2 -0
- package/lib/runtime/readingContent.js +9 -0
- package/package.json +1 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type TimelineItem } from "./messageTimeline.js";
|
|
2
|
+
import { type ReadingCandidate, type MessageReadingState } from "./conversationReading.js";
|
|
1
3
|
import type { ConversationReadMessage } from "./conversationRead.js";
|
|
2
4
|
import { type ReadCountsResult } from "./readCounts.js";
|
|
3
5
|
import type { OnlineReachEvent } from "./reach.js";
|
|
@@ -16,21 +18,25 @@ export type ChatSessionOptions<T extends StoredMessage> = {
|
|
|
16
18
|
activeThreadId?: string;
|
|
17
19
|
reading: ReadingClientOptions;
|
|
18
20
|
decode: (message: ChatRuntimeMessage) => T | undefined;
|
|
21
|
+
readingCandidate?: (message: T) => ReadingCandidate | undefined;
|
|
19
22
|
onError: (error: unknown) => void;
|
|
20
23
|
};
|
|
21
24
|
export type ChatThreadSnapshot<T> = {
|
|
22
25
|
messages: readonly T[];
|
|
26
|
+
timeline: readonly TimelineItem<T>[];
|
|
23
27
|
outgoing: ReturnType<DurableChatSender["snapshot"]>;
|
|
24
28
|
hasEarlier: boolean;
|
|
25
29
|
resetVersion: number;
|
|
26
30
|
complete: boolean;
|
|
27
31
|
readCounts?: ReadCountsResult;
|
|
28
32
|
unread: ReturnType<typeof unreadInConversation> | undefined;
|
|
33
|
+
messageReading: Readonly<Record<string, MessageReadingState>>;
|
|
29
34
|
};
|
|
30
35
|
export type ChatSummarySnapshot<T> = {
|
|
31
36
|
threads: readonly {
|
|
32
37
|
threadId: string;
|
|
33
38
|
lastMessage?: T;
|
|
39
|
+
lastItem?: TimelineItem<T>;
|
|
34
40
|
unread: ChatThreadSnapshot<T>["unread"];
|
|
35
41
|
outgoingCount: number;
|
|
36
42
|
}[];
|
|
@@ -54,6 +60,8 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
54
60
|
private sender;
|
|
55
61
|
private listeners;
|
|
56
62
|
private snapshots;
|
|
63
|
+
private timelineDecoded;
|
|
64
|
+
private decodeTimeline;
|
|
57
65
|
private historyEdges;
|
|
58
66
|
private earlierReads;
|
|
59
67
|
private off;
|
|
@@ -125,6 +133,7 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
125
133
|
private watchCounts;
|
|
126
134
|
getSnapshot(): ChatSummarySnapshot<T>;
|
|
127
135
|
getSnapshot(threadId: string): ChatThreadSnapshot<T>;
|
|
136
|
+
private buildThreadSnapshot;
|
|
128
137
|
subscribe(listener: () => void): () => void;
|
|
129
138
|
subscribe(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
|
|
130
139
|
private publishAll;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { projectMessageTimeline } from "./messageTimeline.js";
|
|
2
|
+
import { projectConversationReading } from "./conversationReading.js";
|
|
1
3
|
import { ReadCountsClient } from "./readCounts.js";
|
|
2
4
|
import { WindowedTasks } from "./coalescer.js";
|
|
3
5
|
import { MessageReadersClient } from "./messageReaders.js";
|
|
@@ -22,12 +24,15 @@ export class ChatSession {
|
|
|
22
24
|
sender;
|
|
23
25
|
listeners = new Map();
|
|
24
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); };
|
|
25
30
|
historyEdges = new Map();
|
|
26
31
|
earlierReads = new Map();
|
|
27
32
|
off = [];
|
|
28
33
|
globalListeners = new Set();
|
|
29
34
|
globalSnapshot;
|
|
30
|
-
closedThread = { messages: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined };
|
|
35
|
+
closedThread = { messages: [], timeline: [], outgoing: [], hasEarlier: false, resetVersion: 0, complete: false, unread: undefined, messageReading: {} };
|
|
31
36
|
closedSummary = { threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" };
|
|
32
37
|
connectionState = "closed";
|
|
33
38
|
catchUp;
|
|
@@ -428,13 +433,26 @@ export class ChatSession {
|
|
|
428
433
|
}
|
|
429
434
|
let snapshot = this.snapshots.get(threadId);
|
|
430
435
|
if (!snapshot) {
|
|
431
|
-
|
|
432
|
-
const state = this.getReadingSnapshot();
|
|
433
|
-
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: state?.threads.some(thread => (thread.conversationId ?? thread.id) === threadId) ? unreadInConversation(state, threadId) : undefined };
|
|
436
|
+
snapshot = this.buildThreadSnapshot(threadId);
|
|
434
437
|
this.snapshots.set(threadId, snapshot);
|
|
435
438
|
}
|
|
436
439
|
return snapshot;
|
|
437
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
|
+
}
|
|
438
456
|
subscribe(target, listener, fields) {
|
|
439
457
|
if (this.closed || this.withdrawn)
|
|
440
458
|
return () => { };
|
|
@@ -483,7 +501,7 @@ export class ChatSession {
|
|
|
483
501
|
ids.add(outgoing.threadId);
|
|
484
502
|
const threads = [...ids].sort().map(threadId => {
|
|
485
503
|
const snapshot = this.getSnapshot(threadId);
|
|
486
|
-
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 };
|
|
487
505
|
});
|
|
488
506
|
const state = this.getReadingSnapshot();
|
|
489
507
|
const known = Boolean(state && state.syncCursor !== "0" && threads.every(thread => thread.unread !== undefined));
|
|
@@ -493,7 +511,11 @@ export class ChatSession {
|
|
|
493
511
|
if (this.closed || this.withdrawn)
|
|
494
512
|
return;
|
|
495
513
|
const next = this.buildGlobal();
|
|
496
|
-
|
|
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
|
+
}))
|
|
497
519
|
return;
|
|
498
520
|
this.globalSnapshot = next;
|
|
499
521
|
this.globalListeners.forEach(listener => listener());
|
|
@@ -501,8 +523,9 @@ export class ChatSession {
|
|
|
501
523
|
publish(threadId, notifyGlobal = true) {
|
|
502
524
|
const previous = this.snapshots.get(threadId);
|
|
503
525
|
this.snapshots.delete(threadId);
|
|
504
|
-
const next = this.
|
|
505
|
-
|
|
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)) {
|
|
506
529
|
this.snapshots.set(threadId, previous);
|
|
507
530
|
return;
|
|
508
531
|
}
|
|
@@ -528,6 +551,7 @@ export class ChatSession {
|
|
|
528
551
|
this.counts?.watch(undefined, []);
|
|
529
552
|
this.countSnapshots.clear();
|
|
530
553
|
this.snapshots.clear();
|
|
554
|
+
this.timelineDecoded = new WeakMap();
|
|
531
555
|
this.historyEdges.clear();
|
|
532
556
|
this.earlierReads.clear();
|
|
533
557
|
this.globalSnapshot = undefined;
|
|
@@ -593,6 +617,7 @@ export class ChatSession {
|
|
|
593
617
|
this.globalListeners.clear();
|
|
594
618
|
this.readingListeners.clear();
|
|
595
619
|
this.snapshots.clear();
|
|
620
|
+
this.timelineDecoded = new WeakMap();
|
|
596
621
|
this.countSnapshots.clear();
|
|
597
622
|
this.historyEdges.clear();
|
|
598
623
|
this.earlierReads.clear();
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { unreadInConversation, type ReadIndexState } from "./readIndex.js";
|
|
2
|
+
export type ReadingCandidate = {
|
|
3
|
+
id: string;
|
|
4
|
+
threadId: string;
|
|
5
|
+
from?: string;
|
|
6
|
+
mine?: boolean;
|
|
7
|
+
recalled?: boolean;
|
|
8
|
+
unread?: boolean;
|
|
9
|
+
attentionEligible?: boolean;
|
|
10
|
+
readPolicy?: "visible" | "explicit";
|
|
11
|
+
};
|
|
12
|
+
export type MessageReadingState = {
|
|
13
|
+
seq?: number;
|
|
14
|
+
read: boolean;
|
|
15
|
+
readPolicy: "visible" | "explicit";
|
|
16
|
+
};
|
|
17
|
+
export type ConversationReadingView = {
|
|
18
|
+
messageReading: Readonly<Record<string, MessageReadingState>>;
|
|
19
|
+
unread: ReturnType<typeof unreadInConversation> | undefined;
|
|
20
|
+
badgeCount: number | undefined;
|
|
21
|
+
};
|
|
22
|
+
/** One projection for indexed authority and observations awaiting indexing. A
|
|
23
|
+
* directory badge is optional compatibility metadata, never proof of coverage. */
|
|
24
|
+
export declare function projectConversationReading(state: ReadIndexState | undefined, conversation: {
|
|
25
|
+
id: string;
|
|
26
|
+
kind: "dm" | "group";
|
|
27
|
+
badgeCount?: number;
|
|
28
|
+
}, candidates?: readonly ReadingCandidate[]): ConversationReadingView;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { sourcesInConversation, unreadInConversation } from "./readIndex.js";
|
|
2
|
+
/** One projection for indexed authority and observations awaiting indexing. A
|
|
3
|
+
* directory badge is optional compatibility metadata, never proof of coverage. */
|
|
4
|
+
export function projectConversationReading(state, conversation, candidates = []) {
|
|
5
|
+
const sources = state ? sourcesInConversation(state, conversation.id) : [];
|
|
6
|
+
const sourceIds = new Set([conversation.id, ...sources.map(source => source.id)]);
|
|
7
|
+
const indexed = new Set(), messageReading = Object.create(null);
|
|
8
|
+
for (const source of sources)
|
|
9
|
+
for (const message of source.messages) {
|
|
10
|
+
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) };
|
|
12
|
+
}
|
|
13
|
+
const observed = new Map();
|
|
14
|
+
for (const intent of state?.observedReadIntents ?? [])
|
|
15
|
+
if (sourceIds.has(intent.threadId)) {
|
|
16
|
+
if (!observed.has(intent.messageId))
|
|
17
|
+
observed.set(intent.messageId, new Set());
|
|
18
|
+
observed.get(intent.messageId).add(intent.readPolicy);
|
|
19
|
+
}
|
|
20
|
+
const unique = new Map(candidates.filter(message => message.threadId === conversation.id).map(message => [message.id, message]));
|
|
21
|
+
let pendingMessages = 0, pendingMentions = 0, observedBadges = 0;
|
|
22
|
+
for (const message of unique.values()) {
|
|
23
|
+
const incoming = !message.mine && message.from !== state?.profileId && !message.recalled;
|
|
24
|
+
const attention = conversation.kind === "dm" || message.attentionEligible === true;
|
|
25
|
+
const policy = message.readPolicy ?? "visible";
|
|
26
|
+
const acknowledged = observed.get(message.id)?.has(policy) === true;
|
|
27
|
+
if (!indexed.has(message.id) && acknowledged)
|
|
28
|
+
messageReading[message.id] = { read: true, readPolicy: policy };
|
|
29
|
+
if (!incoming)
|
|
30
|
+
continue;
|
|
31
|
+
if (acknowledged && attention)
|
|
32
|
+
observedBadges++;
|
|
33
|
+
if (indexed.has(message.id) || acknowledged || message.unread !== true)
|
|
34
|
+
continue;
|
|
35
|
+
pendingMessages++;
|
|
36
|
+
if (message.attentionEligible === true)
|
|
37
|
+
pendingMentions++;
|
|
38
|
+
}
|
|
39
|
+
const authoritative = sources.length && state ? unreadInConversation(state, conversation.id) : undefined;
|
|
40
|
+
const unread = authoritative ? { unreadMessageCount: authoritative.unreadMessageCount + pendingMessages, unreadMentionCount: authoritative.unreadMentionCount + pendingMentions, badgeCount: authoritative.badgeCount + (conversation.kind === "group" ? pendingMentions : pendingMessages) } : undefined;
|
|
41
|
+
const pendingBadge = conversation.kind === "group" ? pendingMentions : pendingMessages;
|
|
42
|
+
return { messageReading, unread, badgeCount: unread?.badgeCount ?? (conversation.badgeCount === undefined ? undefined : Math.max(Math.max(0, conversation.badgeCount - observedBadges), pendingBadge)) };
|
|
43
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isReadingControl } from "./readingContent.js";
|
|
1
2
|
import { ChatSession } from "./chatSession.js";
|
|
2
3
|
import { SocialReadClient } from "./socialReadClient.js";
|
|
3
4
|
import { chatTeamId } from "./teamScope.js";
|
|
@@ -21,6 +22,15 @@ export function createChat(options) {
|
|
|
21
22
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: options.fetch, onError },
|
|
22
23
|
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: options.fetch,
|
|
23
24
|
session: { token, identity: { accountId, app: appId, profileId } }, onNotification: options.onNotification, onReach: options.onReach },
|
|
25
|
+
readingCandidate: message => {
|
|
26
|
+
if (isReadingControl(message.body, appId))
|
|
27
|
+
return undefined;
|
|
28
|
+
const body = message.body;
|
|
29
|
+
if (!body || !["message", "quote"].includes(String(body.kind)) && !(body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action))))
|
|
30
|
+
return undefined;
|
|
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) };
|
|
33
|
+
},
|
|
24
34
|
decode: message => decodeIncoming(message, { appId, profileId, teamId }), onError,
|
|
25
35
|
});
|
|
26
36
|
}
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -30,3 +30,5 @@ export * from "./chatSession.js";
|
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
32
|
export * from "./conversationRead.js";
|
|
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
|
@@ -30,3 +30,5 @@ export * from "./chatSession.js";
|
|
|
30
30
|
export * from "./messageReaders.js";
|
|
31
31
|
export * from "./createChat.js";
|
|
32
32
|
export * from "./conversationRead.js";
|
|
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/lib/runtime/readIndex.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Dexie } from "dexie";
|
|
2
|
+
import { isReadingControl } from "./readingContent.js";
|
|
2
3
|
import { isValidChatTeamId } from "./thread.js";
|
|
3
4
|
export class ReadSyncGap extends Error {
|
|
4
5
|
cursor;
|
|
@@ -158,7 +159,7 @@ export function unreadInThread(state, threadId) {
|
|
|
158
159
|
if (!thread)
|
|
159
160
|
throw new Error("UNKNOWN_READ_THREAD");
|
|
160
161
|
const explicit = new Set(thread.explicitReadMessageIds);
|
|
161
|
-
const messages = thread.messages.filter(message => !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
162
|
+
const messages = thread.messages.filter(message => !message.recalled && !isReadingControl(message.payload, state.appId) && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
162
163
|
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.readThroughSeq));
|
|
163
164
|
const unreadMessageCount = messages.length;
|
|
164
165
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Social control envelopes do not contribute to message unread counts. */
|
|
2
|
+
export function isReadingControl(payload, appId) {
|
|
3
|
+
if (!payload || typeof payload !== "object")
|
|
4
|
+
return false;
|
|
5
|
+
const value = payload;
|
|
6
|
+
if (value.body !== undefined)
|
|
7
|
+
return isReadingControl(value.body, appId);
|
|
8
|
+
return value.kind === "message" && value.content?.appId === appId && (value.content.reaction !== undefined || value.content.recall !== undefined);
|
|
9
|
+
}
|
package/package.json
CHANGED