@pmate/chat-sdk 0.12.0-next.81 → 0.12.0-next.82
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/README.md +31 -0
- package/lib/chatContract.d.ts +7 -0
- package/lib/chatTypes.d.ts +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/internal/createPublicChat.d.ts +1 -1
- package/lib/internal/createPublicChat.js +12 -2
- package/lib/messageView.js +1 -1
- package/lib/runtime/chatSession.d.ts +11 -0
- package/lib/runtime/chatSession.js +18 -4
- package/lib/runtime/conversationRead.d.ts +2 -1
- package/lib/runtime/conversationRead.js +5 -5
- package/lib/runtime/conversationReading.d.ts +2 -0
- package/lib/runtime/conversationReading.js +3 -3
- package/lib/runtime/createChat.d.ts +1 -0
- package/lib/runtime/createChat.js +10 -3
- package/lib/runtime/index.d.ts +1 -0
- package/lib/runtime/index.js +1 -0
- package/lib/runtime/messageDiscussionStore.d.ts +63 -0
- package/lib/runtime/messageDiscussionStore.js +147 -0
- package/lib/runtime/messageStore.d.ts +8 -0
- package/lib/runtime/messageStore.js +16 -0
- package/lib/runtime/messageThread.d.ts +9 -0
- package/lib/runtime/messageThread.js +17 -0
- package/lib/runtime/messageTimeline.d.ts +1 -0
- package/lib/runtime/messageTimeline.js +2 -1
- package/lib/runtime/outgoingContent.d.ts +2 -1
- package/lib/runtime/outgoingContent.js +4 -1
- package/lib/runtime/readIndex.d.ts +6 -2
- package/lib/runtime/readIndex.js +40 -25
- package/lib/runtime/readSync.js +4 -3
- package/lib/runtime/readingClient.d.ts +1 -1
- package/lib/runtime/readingClient.js +2 -2
- package/lib/runtime/socialReadClient.d.ts +20 -0
- package/lib/runtime/socialReadClient.js +24 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -307,3 +307,34 @@ Host applications know which commands are available in a conversation. The share
|
|
|
307
307
|
The same prop is supported by `ChatInputMobile`. Names omit the leading slash. Arrow keys navigate, Enter/Tab inserts a command and a space, Escape closes; touch selection edits the draft only. Command arguments and existing member mentions are preserved. IME composition, URLs and slashes in ordinary body text do not trigger command execution. Omit `commands` for unsupported conversations.
|
|
308
308
|
|
|
309
309
|
Host-owned textareas can use `useComposerSuggestions({ value, onChange, mentions, commands, disabled })`, spread `inputProps`, render `MentionMenu` with `menuProps` and `CommandMenu` with `commandMenuProps`, and send `getMentions()` with the untrimmed value.
|
|
310
|
+
|
|
311
|
+
## Message Threads
|
|
312
|
+
|
|
313
|
+
### Background
|
|
314
|
+
|
|
315
|
+
A conversation ID identifies a DM or group. A Message Thread is a single-level
|
|
316
|
+
subdiscussion rooted in one ordinary message. Quoting a message alone does not
|
|
317
|
+
create a Message Thread. Explicit `threadRootId` is the authoritative boundary.
|
|
318
|
+
|
|
319
|
+
The main timeline loads main messages, while conversation unread still includes
|
|
320
|
+
unread replies once. Reading main history leaves replies unread. Each discussion
|
|
321
|
+
has its own visible-read cursor; voice still requires playback. Local discussion
|
|
322
|
+
windows store message IDs and pagination metadata, sharing canonical message
|
|
323
|
+
bodies, control proofs and permission tombstones across all windows.
|
|
324
|
+
|
|
325
|
+
```ts
|
|
326
|
+
const discussion = chat.openMessageThread(conversationId, rootMessageId)
|
|
327
|
+
const off = discussion.subscribeThread(conversationId, renderReplies)
|
|
328
|
+
await discussion.start() // reuses the parent connection
|
|
329
|
+
await discussion.sendMessage(conversationId, {text: "Follow up"})
|
|
330
|
+
// threadRootId is inherited by text, media, recalls and reactions.
|
|
331
|
+
await discussion.markRead(conversationId) // discussion text only
|
|
332
|
+
const summaries = await chat.getMessageThreads(conversationId, visibleRootIds)
|
|
333
|
+
off()
|
|
334
|
+
await discussion.close()
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
A discussion cannot open another discussion. Thread-internal quoted replies keep
|
|
338
|
+
the same root. Close each discussion when its view closes; closing the parent
|
|
339
|
+
also closes its discussions. Agents working on a platform supporting Message
|
|
340
|
+
Threads should put their replies in the incoming Message Thread.
|
package/lib/chatContract.d.ts
CHANGED
|
@@ -114,12 +114,19 @@ type ContentChoices = {
|
|
|
114
114
|
};
|
|
115
115
|
};
|
|
116
116
|
export type OutgoingContent = {
|
|
117
|
+
threadRootId?: string;
|
|
118
|
+
} & {
|
|
117
119
|
[K in keyof ContentChoices]: ContentChoices[K] & {
|
|
118
120
|
[P in Exclude<keyof ContentChoices, K>]?: never;
|
|
119
121
|
};
|
|
120
122
|
}[keyof ContentChoices];
|
|
121
123
|
/** Default public client. Protocol and synchronous store internals are separate entry points. */
|
|
122
124
|
export interface Chat {
|
|
125
|
+
openMessageThread(threadId: string, threadRootId: string): Chat;
|
|
126
|
+
getMessageThreads(threadId: string, rootMessageIds: readonly string[], options?: ReadOptions): Promise<{
|
|
127
|
+
revision: number;
|
|
128
|
+
items: import("./runtime/socialReadClient.js").MessageThreadSummary[];
|
|
129
|
+
}>;
|
|
123
130
|
start(): Promise<void>;
|
|
124
131
|
close(): Promise<void>;
|
|
125
132
|
resolveThreadId(target: ConversationTarget, options?: ReadOptions): Promise<string>;
|
package/lib/chatTypes.d.ts
CHANGED
package/lib/index.d.ts
CHANGED
package/lib/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { Chat } from "../chatContract.js";
|
|
2
2
|
import { type CreateChatOptions } from "../runtime/createChat.js";
|
|
3
3
|
/** Build only the public capabilities; no aliases or engine fields on the returned object. */
|
|
4
|
-
export declare function createPublicChat(options: CreateChatOptions): Chat;
|
|
4
|
+
export declare function createPublicChat(options: CreateChatOptions, onClosed?: () => void): Chat;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validMessageThreadRoot } from "../runtime/messageThread.js";
|
|
1
2
|
import { waitForCaller } from "../credentials.js";
|
|
2
3
|
import { ChatEngine } from "./chatEngine.js";
|
|
3
4
|
import { registerChat } from "./chatRegistry.js";
|
|
@@ -5,7 +6,7 @@ import { createChat as createSession } from "../runtime/createChat.js";
|
|
|
5
6
|
import { asChatError } from "../runtime/chatError.js";
|
|
6
7
|
import { ChatError } from "../chatError.js";
|
|
7
8
|
/** Build only the public capabilities; no aliases or engine fields on the returned object. */
|
|
8
|
-
export function createPublicChat(options) {
|
|
9
|
+
export function createPublicChat(options, onClosed) {
|
|
9
10
|
const configured = { ...options, onError: (error) => options.onError?.(asChatError(error)) };
|
|
10
11
|
let session;
|
|
11
12
|
try {
|
|
@@ -27,8 +28,17 @@ export function createPublicChat(options) {
|
|
|
27
28
|
return Promise.reject(error);
|
|
28
29
|
}
|
|
29
30
|
};
|
|
31
|
+
const discussions = new Set();
|
|
30
32
|
const chat = Object.freeze({
|
|
31
|
-
|
|
33
|
+
openMessageThread: (threadId, threadRootId) => {
|
|
34
|
+
if (configured.threadRootId !== undefined || !validMessageThreadRoot(threadRootId))
|
|
35
|
+
throw new ChatError("invalid-input", "Nested Message Threads are not supported");
|
|
36
|
+
const child = createPublicChat({ ...configured, threadId, threadRootId, connection: session.sharedTransport() }, () => discussions.delete(child));
|
|
37
|
+
discussions.add(child);
|
|
38
|
+
return child;
|
|
39
|
+
},
|
|
40
|
+
getMessageThreads: (id, roots, options = {}) => read(() => session.getMessageThreads(id, roots, options.signal), options),
|
|
41
|
+
start: () => engine.start(), close: async () => { await Promise.all([...discussions].map(child => child.close())); discussions.clear(); await engine.close(); onClosed?.(); },
|
|
32
42
|
resolveThreadId: (target, options = {}) => read(() => engine.resolveThreadId(target), options),
|
|
33
43
|
getThread: (id, options) => read(() => engine.getThread(id), options), loadEarlier: (id, options) => read(() => engine.loadEarlier(id), options), refreshThread: (id, options) => read(() => engine.refreshThread(id), options),
|
|
34
44
|
subscribeThread: (id, listener, options = {}) => { keys(options, ["fields"]); return engine.subscribeThread(id, listener, options); }, subscribeChat: (listener, options = {}) => { keys(options, ["fields"]); return engine.subscribe(listener, options); },
|
package/lib/messageView.js
CHANGED
|
@@ -96,7 +96,7 @@ export function createMessagePresenter() {
|
|
|
96
96
|
value = bodies.get(item.message) ?? content(item.message);
|
|
97
97
|
bodies.set(item.message, value);
|
|
98
98
|
}
|
|
99
|
-
const result = { id: item.id, threadId: item.threadId, timestamp: item.timestamp, from: item.from, mine: item.mine, status: item.status, recalled: item.recalled, unread: item.unread, attentionEligible: item.attentionEligible, reading: item.reading, reactions: item.reactions, ...value };
|
|
99
|
+
const result = { id: item.id, threadId: item.threadId, ...(item.threadRootId === undefined ? {} : { threadRootId: item.threadRootId }), timestamp: item.timestamp, from: item.from, mine: item.mine, status: item.status, recalled: item.recalled, unread: item.unread, attentionEligible: item.attentionEligible, reading: item.reading, reactions: item.reactions, ...value };
|
|
100
100
|
items.set(item, result);
|
|
101
101
|
return result;
|
|
102
102
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MessageThreadScope } from "./messageThread.js";
|
|
1
2
|
import { type ConversationTarget } from "./conversationDirectory.js";
|
|
2
3
|
import { type ChatGroup } from "./groupClient.js";
|
|
3
4
|
import type { SocialGroup } from "./socialReadClient.js";
|
|
@@ -16,6 +17,11 @@ import { unreadInConversation } from "./readIndex.js";
|
|
|
16
17
|
import type { StoredMessage } from "./messageStore.js";
|
|
17
18
|
import type { ChatRuntimeMessage } from "./protocol.js";
|
|
18
19
|
export type ChatSessionOptions<T extends StoredMessage> = {
|
|
20
|
+
discussion?: MessageThreadScope;
|
|
21
|
+
messageThreads?: (threadId: string, roots: readonly string[], signal?: AbortSignal) => Promise<{
|
|
22
|
+
items: import("./socialReadClient.js").MessageThreadSummary[];
|
|
23
|
+
revision: number;
|
|
24
|
+
}>;
|
|
19
25
|
onDispose?: () => void;
|
|
20
26
|
messages: MessageClientOptions<T>;
|
|
21
27
|
connection: ChatClientOptions;
|
|
@@ -161,6 +167,11 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
161
167
|
sendMessage(threadId: string, input: SendMessageInput, options?: {
|
|
162
168
|
waitFor?: "accepted" | "delivered";
|
|
163
169
|
}): Promise<ChatRuntimeMessage>;
|
|
170
|
+
sharedTransport(): ChatClient;
|
|
171
|
+
getMessageThreads(threadId: string, roots: readonly string[], signal?: AbortSignal): Promise<{
|
|
172
|
+
items: import("./socialReadClient.js").MessageThreadSummary[];
|
|
173
|
+
revision: number;
|
|
174
|
+
}>;
|
|
164
175
|
private sendWire;
|
|
165
176
|
getConversation(target: ConversationTarget): Promise<string>;
|
|
166
177
|
private sendContent;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { messageThreadRoot } from "./messageThread.js";
|
|
1
2
|
import { asChatError } from "./chatError.js";
|
|
2
3
|
import { scopedGroupDenial, isScopedGroupDenial } from "./groupDenial.js";
|
|
3
4
|
import { threadChatError } from "./chatError.js";
|
|
@@ -285,9 +286,9 @@ export class ChatSession {
|
|
|
285
286
|
if (item.recalled || !item.message)
|
|
286
287
|
return [];
|
|
287
288
|
const candidate = this.options.readingCandidate?.(item.message);
|
|
288
|
-
return candidate ? [{ id: item.id, threadId, from: item.from, mine: item.mine, recalled: item.recalled, readPolicy: candidate.readPolicy }] : [];
|
|
289
|
+
return candidate ? [{ id: item.id, threadId, threadRootId: item.threadRootId, from: item.from, mine: item.mine, recalled: item.recalled, readPolicy: candidate.readPolicy }] : [];
|
|
289
290
|
});
|
|
290
|
-
return this.readingOperation(() => this.reading.markRead(threadId, cached));
|
|
291
|
+
return this.readingOperation(() => this.reading.markRead(threadId, cached, this.options.discussion?.threadRootId));
|
|
291
292
|
}
|
|
292
293
|
updateReading(transform) { return this.readingOperation(() => this.reading.update(transform)); }
|
|
293
294
|
assertOpen() { if (this.withdrawn)
|
|
@@ -445,6 +446,13 @@ export class ChatSession {
|
|
|
445
446
|
return this.sendWire(thread, input, options.waitFor === "delivered");
|
|
446
447
|
return this.sendContent(thread, input, options.waitFor === "delivered");
|
|
447
448
|
}
|
|
449
|
+
sharedTransport() { this.assertOpen(); return this.connection; }
|
|
450
|
+
async getMessageThreads(threadId, roots, signal) {
|
|
451
|
+
this.assertOpen();
|
|
452
|
+
if (!this.options.messageThreads)
|
|
453
|
+
throw new Error("MESSAGE_THREADS_UNAVAILABLE");
|
|
454
|
+
return this.options.messageThreads(threadId, roots, signal);
|
|
455
|
+
}
|
|
448
456
|
sendWire(threadId, wire, delivered) {
|
|
449
457
|
return (delivered ? this.sender.send(threadId, wire) : this.sender.enqueue(threadId, wire)).then(next => { if (this.withdrawn)
|
|
450
458
|
throw new Error("CHAT_ACCESS_REVOKED"); return next; });
|
|
@@ -463,6 +471,11 @@ export class ChatSession {
|
|
|
463
471
|
}
|
|
464
472
|
}
|
|
465
473
|
async sendContent(target, input, delivered) {
|
|
474
|
+
if (this.options.discussion?.threadRootId !== undefined) {
|
|
475
|
+
if (input.threadRootId !== undefined && input.threadRootId !== this.options.discussion.threadRootId)
|
|
476
|
+
throw new Error("MESSAGE_THREAD_SCOPE_MISMATCH");
|
|
477
|
+
input = { ...input, threadRootId: this.options.discussion.threadRootId };
|
|
478
|
+
}
|
|
466
479
|
let thread = typeof target === "string" ? await this.resolveConversation(target) : target;
|
|
467
480
|
validateConversation(this.options.messages.scope, thread);
|
|
468
481
|
await this.conversations.put(thread);
|
|
@@ -736,7 +749,8 @@ export class ChatSession {
|
|
|
736
749
|
buildThreadSnapshot(threadId, previous) {
|
|
737
750
|
const source = this.messages.getSnapshot(threadId), raw = source?.messages ?? [];
|
|
738
751
|
const messages = previous && previous.messages.length === raw.length && previous.messages.every((message, index) => message === raw[index]) ? previous.messages : raw;
|
|
739
|
-
const
|
|
752
|
+
const allOutgoing = this.sender.snapshot(threadId);
|
|
753
|
+
const outgoing = this.options.discussion ? allOutgoing.filter(record => messageThreadRoot(record.message.body) === this.options.discussion.threadRootId) : allOutgoing;
|
|
740
754
|
const sameSource = previous && previous.messages === messages && previous.outgoing.length === outgoing.length && previous.outgoing.every((record, index) => record === outgoing[index]);
|
|
741
755
|
const candidates = new Map(messages.flatMap(message => { const candidate = this.options.readingCandidate?.(message); return candidate ? [[candidate.id, candidate]] : []; }));
|
|
742
756
|
const semanticReading = previous?.messageReading ?? {};
|
|
@@ -746,7 +760,7 @@ export class ChatSession {
|
|
|
746
760
|
candidates.set(item.id, { ...candidates.get(item.id), id: item.id, threadId, from: item.from, mine: item.mine, recalled: true });
|
|
747
761
|
const state = this.getReadingSnapshot();
|
|
748
762
|
const kind = state?.threads.find(thread => (thread.conversationId ?? thread.id) === threadId)?.kind ?? "dm";
|
|
749
|
-
const view = projectConversationReading(state, { id: threadId, kind }, [...candidates.values()]);
|
|
763
|
+
const view = projectConversationReading(state, { id: threadId, kind, threadRootId: this.options.discussion?.threadRootId }, [...candidates.values()]);
|
|
750
764
|
const unread = previous && JSON.stringify(previous.unread) === JSON.stringify(view.unread) ? previous.unread : view.unread;
|
|
751
765
|
const messageReading = previous && JSON.stringify(previous.messageReading) === JSON.stringify(view.messageReading) ? previous.messageReading : view.messageReading;
|
|
752
766
|
const timeline = messageReading === semanticReading ? semantics : projectMessageTimeline({ appId: this.options.messages.scope.appId, profileId: this.options.messages.scope.profileId, threadId, messages, outgoing, candidates, decode: this.decodeTimeline, reading: messageReading, previous: previous?.timeline });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReadIndexState } from "./readIndex.js";
|
|
2
2
|
/** Optional observations whose index metadata may still be in flight. */
|
|
3
3
|
export type ConversationReadMessage = {
|
|
4
|
+
threadRootId?: string | null;
|
|
4
5
|
id: string;
|
|
5
6
|
threadId: string;
|
|
6
7
|
mine?: boolean;
|
|
@@ -10,4 +11,4 @@ export type ConversationReadMessage = {
|
|
|
10
11
|
kind?: string;
|
|
11
12
|
};
|
|
12
13
|
/** Explicitly mark ordinary text history read; unheard audio stays unread. */
|
|
13
|
-
export declare function recordConversationRead(state: ReadIndexState, conversationId: string, messages?: readonly ConversationReadMessage[], operationId?: () => `${string}-${string}-${string}-${string}-${string}
|
|
14
|
+
export declare function recordConversationRead(state: ReadIndexState, conversationId: string, messages?: readonly ConversationReadMessage[], operationId?: () => `${string}-${string}-${string}-${string}-${string}`, threadRootId?: string): ReadIndexState;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { recordReadIntent, sourcesInConversation } from "./readIndex.js";
|
|
1
|
+
import { readingCursor, recordReadIntent, sourcesInConversation } from "./readIndex.js";
|
|
2
2
|
/** Explicitly mark ordinary text history read; unheard audio stays unread. */
|
|
3
|
-
export function recordConversationRead(state, conversationId, messages = [], operationId = () => crypto.randomUUID()) {
|
|
3
|
+
export function recordConversationRead(state, conversationId, messages = [], operationId = () => crypto.randomUUID(), threadRootId) {
|
|
4
4
|
if (!conversationId)
|
|
5
5
|
throw new Error("INVALID_READING_THREAD");
|
|
6
6
|
const sources = sourcesInConversation(state, conversationId);
|
|
@@ -11,9 +11,9 @@ export function recordConversationRead(state, conversationId, messages = [], ope
|
|
|
11
11
|
for (const source of sources) {
|
|
12
12
|
const incoming = source.messages.filter(message => {
|
|
13
13
|
indexedIds.add(message.id);
|
|
14
|
-
return !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId);
|
|
14
|
+
return (message.threadRootId ?? undefined) === threadRootId && !message.recalled && message.from !== state.profileId && message.recipients.includes(state.profileId);
|
|
15
15
|
});
|
|
16
|
-
const latest = incoming.filter(message => message.readPolicy === "visible" && message.seq > source
|
|
16
|
+
const latest = incoming.filter(message => message.readPolicy === "visible" && message.seq > readingCursor(source, threadRootId))
|
|
17
17
|
.reduce((last, message) => !last || message.seq > last.seq ? message : last, undefined);
|
|
18
18
|
if (latest)
|
|
19
19
|
next = recordReadIntent(next, { operationId: operationId(), threadId: source.id, messageId: latest.id, readPolicy: "visible" });
|
|
@@ -22,7 +22,7 @@ export function recordConversationRead(state, conversationId, messages = [], ope
|
|
|
22
22
|
// observations so leaving the page or reloading cannot resurrect the badge.
|
|
23
23
|
const observed = new Set(next.observedReadIntents?.filter(intent => intent.threadId === conversationId).map(intent => intent.messageId));
|
|
24
24
|
for (const message of messages) {
|
|
25
|
-
if (message.threadId !== conversationId || message.mine || message.from === state.profileId || message.recalled || indexedIds.has(message.id) || observed.has(message.id))
|
|
25
|
+
if ((message.threadRootId ?? undefined) !== threadRootId || message.threadId !== conversationId || message.mine || message.from === state.profileId || message.recalled || indexedIds.has(message.id) || observed.has(message.id))
|
|
26
26
|
continue;
|
|
27
27
|
const readPolicy = message.readPolicy ?? (message.kind === "voice" ? "explicit" : "visible");
|
|
28
28
|
if (message.kind === "voice" || readPolicy === "explicit")
|
|
@@ -2,6 +2,7 @@ import { unreadInConversation, type ReadIndexState } from "./readIndex.js";
|
|
|
2
2
|
export type ReadingCandidate = {
|
|
3
3
|
id: string;
|
|
4
4
|
threadId: string;
|
|
5
|
+
threadRootId?: string;
|
|
5
6
|
from?: string;
|
|
6
7
|
mine?: boolean;
|
|
7
8
|
recalled?: boolean;
|
|
@@ -25,4 +26,5 @@ export declare function projectConversationReading(state: ReadIndexState | undef
|
|
|
25
26
|
id: string;
|
|
26
27
|
kind: "dm" | "group";
|
|
27
28
|
badgeCount?: number;
|
|
29
|
+
threadRootId?: string;
|
|
28
30
|
}, candidates?: readonly ReadingCandidate[]): ConversationReadingView;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sourcesInConversation, unreadInConversation } from "./readIndex.js";
|
|
1
|
+
import { readingCursor, 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 = []) {
|
|
@@ -13,7 +13,7 @@ export function projectConversationReading(state, conversation, candidates = [])
|
|
|
13
13
|
for (const source of sources)
|
|
14
14
|
for (const message of source.messages) {
|
|
15
15
|
indexed.add(message.id);
|
|
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.
|
|
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 <= readingCursor(source, message.threadRootId)) };
|
|
17
17
|
}
|
|
18
18
|
const observed = new Map();
|
|
19
19
|
for (const intent of state?.observedReadIntents ?? [])
|
|
@@ -40,7 +40,7 @@ export function projectConversationReading(state, conversation, candidates = [])
|
|
|
40
40
|
if (message.attentionEligible === true)
|
|
41
41
|
pendingMentions++;
|
|
42
42
|
}
|
|
43
|
-
const authoritative = sources.length && state ? unreadInConversation(state, conversation.id, hidden) : undefined;
|
|
43
|
+
const authoritative = sources.length && state ? unreadInConversation(state, conversation.id, hidden, conversation.threadRootId) : undefined;
|
|
44
44
|
const unread = authoritative ? { unreadMessageCount: authoritative.unreadMessageCount + pendingMessages, unreadMentionCount: authoritative.unreadMentionCount + pendingMentions, badgeCount: authoritative.badgeCount + (conversation.kind === "group" ? pendingMentions : pendingMessages) } : undefined;
|
|
45
45
|
const pendingBadge = conversation.kind === "group" ? pendingMentions : pendingMessages;
|
|
46
46
|
return { messageReading, unread, badgeCount: unread?.badgeCount ?? (conversation.badgeCount === undefined ? undefined : Math.max(Math.max(0, conversation.badgeCount - observedBadges), pendingBadge)) };
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { IndexedDbDiscussionStore } from "./messageDiscussionStore.js";
|
|
2
|
+
import { mergeStoredMessage } from "./messageStore.js";
|
|
3
|
+
import { messageThreadRoot } from "./messageThread.js";
|
|
1
4
|
import { ChatCredentials } from "../credentials.js";
|
|
2
5
|
import { voiceUploader } from "./voiceUpload.js";
|
|
3
6
|
import { isOffline } from "./connectivity.js";
|
|
@@ -9,6 +12,8 @@ import { decodeCachedChatMessage } from "./cachedMessage.js";
|
|
|
9
12
|
/** Standard browser entry: storage, paging, realtime and reading share one identity. */
|
|
10
13
|
export function createChatSession(options) {
|
|
11
14
|
const { appId, accountId, profileId, teamId, onError } = options;
|
|
15
|
+
if (options.threadRootId !== undefined && !options.threadId)
|
|
16
|
+
throw new Error("MESSAGE_THREAD_CONVERSATION_REQUIRED");
|
|
12
17
|
const token = options.token ?? "";
|
|
13
18
|
if (!token && !options.getToken)
|
|
14
19
|
throw new Error("INVALID_CHAT_CREDENTIALS");
|
|
@@ -20,11 +25,13 @@ export function createChatSession(options) {
|
|
|
20
25
|
const fetcher = credentials?.fetch ?? options.fetch;
|
|
21
26
|
const reader = new SocialReadClient({ appId, profileId, token, hubHttpUrl: http, fetch: fetcher });
|
|
22
27
|
const page = async (threadId, signal, cursor = {}) => {
|
|
23
|
-
const result = await reader.messagePage({ threadId, teamId, limit: 100, ...cursor }, signal);
|
|
28
|
+
const result = await reader.messagePage({ threadId, teamId, limit: 100, ...(options.threadRootId === undefined ? { mainOnly: true } : { threadRootId: options.threadRootId }), ...cursor }, signal);
|
|
24
29
|
return { ...result, messages: result.messages.map(message => ({ ...message, id: message.messageId, timestamp: message.serverCreatedAt })) };
|
|
25
30
|
};
|
|
26
31
|
return new ChatSession({
|
|
27
32
|
sharedConnection: options.connection,
|
|
33
|
+
discussion: { threadRootId: options.threadRootId },
|
|
34
|
+
messageThreads: (threadId, roots, signal) => reader.messageThreads({ threadId, teamId, rootMessageIds: roots }, signal),
|
|
28
35
|
onDispose: () => credentials?.close(),
|
|
29
36
|
uploadVoice: voiceUploader(profileId, options.uploadEndpoint, fetcher),
|
|
30
37
|
reconcileOutgoing: async (record, signal) => {
|
|
@@ -41,7 +48,7 @@ export function createChatSession(options) {
|
|
|
41
48
|
},
|
|
42
49
|
loadGroup: (groupId, signal) => reader.group({ groupId, teamId }, signal),
|
|
43
50
|
activeThreadId: options.threadId,
|
|
44
|
-
messages: { scope: { appId, accountId, profileId, teamId }, transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
51
|
+
messages: { scope: { appId, accountId, profileId, teamId }, storage: new IndexedDbDiscussionStore({ appId, accountId, profileId, teamId }, mergeStoredMessage, { threadRootId: options.threadRootId }), transport: { authorizeScope: async (signal) => { await reader.contacts(teamId, signal); }, load: (thread, signal) => page(thread, signal), changes: (thread, after, signal) => page(thread, signal, { after }), older: (thread, before, signal) => page(thread, signal, { before }) }, onError },
|
|
45
52
|
reading: { appId, profileId, sessionToken: token, hubHttpUrl: http, fetch: fetcher, onError },
|
|
46
53
|
connection: { appId, profileId, teamId, hubUrl: ws, clientInstanceId: options.clientInstanceId, fetchImpl: fetcher,
|
|
47
54
|
getToken: credentials?.getToken,
|
|
@@ -52,7 +59,7 @@ export function createChatSession(options) {
|
|
|
52
59
|
const body = message.body;
|
|
53
60
|
if (!body || !["message", "quote"].includes(String(body.kind)) && !(body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action))))
|
|
54
61
|
return undefined;
|
|
55
|
-
return { id: message.id, threadId: message.threadId, from: message.from, mine: message.from === profileId, recalled: message.recalled, unread: message.from !== profileId,
|
|
62
|
+
return { id: message.id, threadId: message.threadId, threadRootId: messageThreadRoot(message.body), from: message.from, mine: message.from === profileId, recalled: message.recalled, unread: message.from !== profileId,
|
|
56
63
|
readPolicy: message.readPolicy === "explicit" || message.legacyDisplay?.kind === "voice" || messageRequiresPlayback(body) ? "explicit" : "visible", attentionEligible: messageMentionsProfile(body, profileId) };
|
|
57
64
|
},
|
|
58
65
|
decode: message => decodeCachedChatMessage(message, { appId, profileId, teamId }), onError,
|
package/lib/runtime/index.d.ts
CHANGED
package/lib/runtime/index.js
CHANGED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { IndexedDbMessageStore, type MessageScope, type MessageCoverage, type StoredMessage } from "./messageStore.js";
|
|
2
|
+
/** Discussion windows contain IDs only. The existing canonical store owns every
|
|
3
|
+
* body, control proof, permission tombstone and conversation reset epoch.
|
|
4
|
+
* Source writes precede pointer checkpoints: an interrupted commit can require
|
|
5
|
+
* replay, but can never claim body coverage that was not durably accepted. */
|
|
6
|
+
export declare class IndexedDbDiscussionStore<T extends StoredMessage> extends IndexedDbMessageStore<T> {
|
|
7
|
+
private pointerDb;
|
|
8
|
+
private membership;
|
|
9
|
+
private discussionWindows;
|
|
10
|
+
private initialized;
|
|
11
|
+
private migrations;
|
|
12
|
+
private owner;
|
|
13
|
+
private root;
|
|
14
|
+
constructor(scope: MessageScope, merge: (previous: T | undefined, incoming: T) => T, options?: {
|
|
15
|
+
indexedDB?: IDBFactory;
|
|
16
|
+
name?: string;
|
|
17
|
+
threadRootId?: string;
|
|
18
|
+
});
|
|
19
|
+
private initializePointers;
|
|
20
|
+
private source;
|
|
21
|
+
private pointerKey;
|
|
22
|
+
private window;
|
|
23
|
+
read(thread: string, limit?: number, before?: {
|
|
24
|
+
timestamp: number;
|
|
25
|
+
id: string;
|
|
26
|
+
}): Promise<{
|
|
27
|
+
messages: T[];
|
|
28
|
+
hasEarlier: boolean;
|
|
29
|
+
coverage: MessageCoverage | undefined;
|
|
30
|
+
}>;
|
|
31
|
+
readFallback(thread: string, limit?: number, before?: {
|
|
32
|
+
timestamp: number;
|
|
33
|
+
id: string;
|
|
34
|
+
}): Promise<{
|
|
35
|
+
messages: T[];
|
|
36
|
+
hasEarlier: boolean;
|
|
37
|
+
coverage: MessageCoverage | undefined;
|
|
38
|
+
}>;
|
|
39
|
+
commit(thread: string, messages: T[], coverage?: MessageCoverage, guard?: {
|
|
40
|
+
version: number;
|
|
41
|
+
authoritative?: boolean;
|
|
42
|
+
scopeVersion?: number;
|
|
43
|
+
}): Promise<number>;
|
|
44
|
+
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
45
|
+
windows(): Promise<{
|
|
46
|
+
scope: string;
|
|
47
|
+
checkpoint?: string;
|
|
48
|
+
complete: boolean;
|
|
49
|
+
nextBefore?: string | null;
|
|
50
|
+
syncedAt?: number;
|
|
51
|
+
resetVersion?: number;
|
|
52
|
+
revoked?: boolean;
|
|
53
|
+
scopeVersion?: number;
|
|
54
|
+
owner: string;
|
|
55
|
+
thread: string;
|
|
56
|
+
root: string;
|
|
57
|
+
sourceVersion: number;
|
|
58
|
+
}[]>;
|
|
59
|
+
resetThread(thread: string, revoked?: boolean, scopeVersion?: number): Promise<number>;
|
|
60
|
+
removeThread(thread: string): Promise<void>;
|
|
61
|
+
clear(): Promise<void>;
|
|
62
|
+
close(): void;
|
|
63
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Dexie } from "dexie";
|
|
2
|
+
import { IndexedDbMessageStore, messageScopeKey } from "./messageStore.js";
|
|
3
|
+
import { messageThreadRoot, validMessageThreadRoot } from "./messageThread.js";
|
|
4
|
+
/** Discussion windows contain IDs only. The existing canonical store owns every
|
|
5
|
+
* body, control proof, permission tombstone and conversation reset epoch.
|
|
6
|
+
* Source writes precede pointer checkpoints: an interrupted commit can require
|
|
7
|
+
* replay, but can never claim body coverage that was not durably accepted. */
|
|
8
|
+
export class IndexedDbDiscussionStore extends IndexedDbMessageStore {
|
|
9
|
+
pointerDb;
|
|
10
|
+
membership;
|
|
11
|
+
discussionWindows;
|
|
12
|
+
initialized;
|
|
13
|
+
migrations = new Map();
|
|
14
|
+
owner;
|
|
15
|
+
root;
|
|
16
|
+
constructor(scope, merge, options = {}) {
|
|
17
|
+
super(scope, merge, options);
|
|
18
|
+
if (options.threadRootId !== undefined && !validMessageThreadRoot(options.threadRootId))
|
|
19
|
+
throw new Error("INVALID_MESSAGE_THREAD_ROOT");
|
|
20
|
+
this.owner = messageScopeKey(scope);
|
|
21
|
+
this.root = options.threadRootId ?? "";
|
|
22
|
+
this.pointerDb = new Dexie(`${options.name ?? "pmate-chat-messages"}:discussions`, { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
|
|
23
|
+
this.pointerDb.version(1).stores({ membership: "[owner+thread+root+id],[owner+thread+root+time+id],[owner+thread],owner", windows: "[owner+thread+root],[owner+thread],owner", initialized: "[owner+thread],owner" });
|
|
24
|
+
this.initialized = this.pointerDb.table("initialized");
|
|
25
|
+
this.membership = this.pointerDb.table("membership");
|
|
26
|
+
this.discussionWindows = this.pointerDb.table("windows");
|
|
27
|
+
this.pointerDb.on("versionchange", () => { this.pointerDb.close({ disableAutoOpen: false }); return false; });
|
|
28
|
+
}
|
|
29
|
+
async initializePointers(thread) {
|
|
30
|
+
const running = this.migrations.get(thread);
|
|
31
|
+
if (running)
|
|
32
|
+
return running;
|
|
33
|
+
const task = (async () => {
|
|
34
|
+
const source = await this.source(thread);
|
|
35
|
+
if (source?.revoked)
|
|
36
|
+
return;
|
|
37
|
+
const version = source?.resetVersion ?? 0, scopeVersion = source?.scopeVersion ?? 0;
|
|
38
|
+
const known = await this.initialized.get([this.owner, thread]);
|
|
39
|
+
if (known?.version === version && known.scopeVersion === scopeVersion)
|
|
40
|
+
return;
|
|
41
|
+
let before;
|
|
42
|
+
for (;;) {
|
|
43
|
+
const page = await super.readFallback(thread, 100, before);
|
|
44
|
+
if (page.coverage?.revoked || (page.coverage?.resetVersion ?? 0) !== version || (page.coverage?.scopeVersion ?? 0) !== scopeVersion)
|
|
45
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
46
|
+
const rows = page.messages.filter(message => {
|
|
47
|
+
const body = message.body;
|
|
48
|
+
return !body?.content?.reaction && !body?.content?.recall;
|
|
49
|
+
}).map(message => ({ owner: this.owner, thread, root: messageThreadRoot(message.body) ?? "", id: message.id, time: message.timestamp }));
|
|
50
|
+
if (rows.length)
|
|
51
|
+
await this.membership.bulkPut(rows);
|
|
52
|
+
if (!page.hasEarlier || !page.messages.length)
|
|
53
|
+
break;
|
|
54
|
+
const first = page.messages[0];
|
|
55
|
+
before = { timestamp: first.timestamp, id: first.id };
|
|
56
|
+
}
|
|
57
|
+
const current = await this.source(thread);
|
|
58
|
+
if (current?.revoked || (current?.resetVersion ?? 0) !== version || (current?.scopeVersion ?? 0) !== scopeVersion)
|
|
59
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
60
|
+
await this.initialized.put({ owner: this.owner, thread, version, scopeVersion });
|
|
61
|
+
})().finally(() => this.migrations.delete(thread));
|
|
62
|
+
this.migrations.set(thread, task);
|
|
63
|
+
return task;
|
|
64
|
+
}
|
|
65
|
+
async source(thread) { return (await super.read(thread, 1)).coverage; }
|
|
66
|
+
pointerKey(thread) { return [this.owner, thread, this.root]; }
|
|
67
|
+
async window(thread, source) {
|
|
68
|
+
if (source?.revoked)
|
|
69
|
+
return source;
|
|
70
|
+
const window = await this.discussionWindows.get(this.pointerKey(thread));
|
|
71
|
+
if (!window || window.sourceVersion !== (source?.resetVersion ?? 0) || (window.scopeVersion ?? 0) !== (source?.scopeVersion ?? 0))
|
|
72
|
+
return { ...source, complete: false, checkpoint: undefined, nextBefore: undefined };
|
|
73
|
+
return { ...window, resetVersion: source?.resetVersion ?? 0, scopeVersion: source?.scopeVersion ?? 0 };
|
|
74
|
+
}
|
|
75
|
+
async read(thread, limit = 100, before) {
|
|
76
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 1000)
|
|
77
|
+
throw new Error("INVALID_MESSAGE_LIMIT");
|
|
78
|
+
await this.initializePointers(thread);
|
|
79
|
+
const source = await this.source(thread), coverage = await this.window(thread, source);
|
|
80
|
+
if (coverage?.revoked)
|
|
81
|
+
return { messages: [], hasEarlier: false, coverage };
|
|
82
|
+
const prefix = [this.owner, thread, this.root];
|
|
83
|
+
const members = await this.membership.where("[owner+thread+root+time+id]").between([...prefix, Dexie.minKey, Dexie.minKey], [...prefix, before?.timestamp ?? Dexie.maxKey, before?.id ?? Dexie.maxKey], true, !before).reverse().limit(limit + 1).toArray();
|
|
84
|
+
const ids = members.slice(0, limit).reverse().map(row => row.id), messages = [];
|
|
85
|
+
let legacyFallback = false;
|
|
86
|
+
for (let offset = 0; offset < ids.length; offset += 100) {
|
|
87
|
+
const selected = await super.readSelectedFallback(thread, ids.slice(offset, offset + 100));
|
|
88
|
+
if (selected.coverage?.revoked || (selected.coverage?.resetVersion ?? 0) !== (source?.resetVersion ?? 0) || (selected.coverage?.scopeVersion ?? 0) !== (source?.scopeVersion ?? 0))
|
|
89
|
+
return { messages: [], hasEarlier: false, coverage: { ...selected.coverage, complete: false } };
|
|
90
|
+
messages.push(...selected.messages);
|
|
91
|
+
legacyFallback ||= selected.legacyFallback;
|
|
92
|
+
}
|
|
93
|
+
return { messages, hasEarlier: members.length > limit, coverage: !legacyFallback && messages.length === Math.min(members.length, limit) ? coverage : { ...coverage, complete: false, checkpoint: undefined } };
|
|
94
|
+
}
|
|
95
|
+
async readFallback(thread, limit = 100, before) { return this.read(thread, limit, before); }
|
|
96
|
+
async commit(thread, messages, coverage, guard) {
|
|
97
|
+
const changed = await super.commit(thread, messages, guard?.authoritative ? { complete: false, resetVersion: guard.version, revoked: false } : undefined, guard);
|
|
98
|
+
const source = await this.source(thread);
|
|
99
|
+
if (source?.revoked || guard && ((source?.resetVersion ?? 0) !== guard.version || (source?.scopeVersion ?? 0) !== (guard.scopeVersion ?? 0)))
|
|
100
|
+
throw new Error("MESSAGE_PROJECTION_CHANGED");
|
|
101
|
+
const rows = messages.filter(message => {
|
|
102
|
+
const body = message.body;
|
|
103
|
+
return !body?.content?.reaction && !body?.content?.recall;
|
|
104
|
+
}).map(message => ({ owner: this.owner, thread, root: messageThreadRoot(message.body) ?? "", id: message.id, time: message.timestamp }));
|
|
105
|
+
await this.pointerDb.transaction("rw", this.membership, this.discussionWindows, async () => {
|
|
106
|
+
if (rows.length)
|
|
107
|
+
await this.membership.bulkPut(rows);
|
|
108
|
+
if (coverage)
|
|
109
|
+
await this.discussionWindows.put({ ...coverage, owner: this.owner, thread, root: this.root, sourceVersion: source?.resetVersion ?? 0, scopeVersion: source?.scopeVersion ?? 0 });
|
|
110
|
+
});
|
|
111
|
+
return changed;
|
|
112
|
+
}
|
|
113
|
+
async importLegacy(thread, messages) {
|
|
114
|
+
const changed = await super.importLegacy(thread, messages);
|
|
115
|
+
const rows = messages.map(message => ({ owner: this.owner, thread, root: messageThreadRoot(message.body) ?? "", id: message.id, time: message.timestamp }));
|
|
116
|
+
if (rows.length)
|
|
117
|
+
await this.membership.bulkPut(rows);
|
|
118
|
+
return changed;
|
|
119
|
+
}
|
|
120
|
+
async windows() {
|
|
121
|
+
const access = await this.scopeAccess();
|
|
122
|
+
if (access.revoked)
|
|
123
|
+
return [];
|
|
124
|
+
return (await this.discussionWindows.where("owner").equals(this.owner).toArray()).filter(row => row.root === this.root).map(row => ({ ...row, scope: this.owner }));
|
|
125
|
+
}
|
|
126
|
+
async resetThread(thread, revoked = false, scopeVersion = 0) {
|
|
127
|
+
const version = await super.resetThread(thread, revoked, scopeVersion);
|
|
128
|
+
await this.membership.where("[owner+thread]").equals([this.owner, thread]).delete();
|
|
129
|
+
// All discussion windows notice the canonical epoch change on their next read.
|
|
130
|
+
return version;
|
|
131
|
+
}
|
|
132
|
+
async removeThread(thread) {
|
|
133
|
+
await super.removeThread(thread);
|
|
134
|
+
await this.pointerDb.transaction("rw", this.membership, this.discussionWindows, async () => {
|
|
135
|
+
await this.membership.where("[owner+thread]").equals([this.owner, thread]).delete();
|
|
136
|
+
await this.discussionWindows.where("[owner+thread]").equals([this.owner, thread]).delete();
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
async clear() {
|
|
140
|
+
await super.clear();
|
|
141
|
+
await this.pointerDb.transaction("rw", this.membership, this.discussionWindows, async () => {
|
|
142
|
+
await this.membership.where("owner").equals(this.owner).delete();
|
|
143
|
+
await this.discussionWindows.where("owner").equals(this.owner).delete();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
close() { super.close(); this.pointerDb.close(); }
|
|
147
|
+
}
|
|
@@ -64,6 +64,14 @@ export declare class IndexedDbMessageStore<T extends StoredMessage> {
|
|
|
64
64
|
hasEarlier: boolean;
|
|
65
65
|
coverage: MessageCoverage | undefined;
|
|
66
66
|
}>;
|
|
67
|
+
/** Exact fallback for SDK-owned discussion pointers. Legacy rows remain
|
|
68
|
+
* display-only and never establish authoritative delivery or history coverage. */
|
|
69
|
+
readSelectedFallback(thread: string, ids: readonly string[]): Promise<{
|
|
70
|
+
legacyFallback: boolean;
|
|
71
|
+
messages: T[];
|
|
72
|
+
hasEarlier: boolean;
|
|
73
|
+
coverage: MessageCoverage | undefined;
|
|
74
|
+
}>;
|
|
67
75
|
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
68
76
|
importLegacy(thread: string, messages: T[]): Promise<number>;
|
|
69
77
|
/** Bounded fallback only; callers must report an incomplete window. */
|
|
@@ -74,6 +74,22 @@ export class IndexedDbMessageStore {
|
|
|
74
74
|
});
|
|
75
75
|
return this.finishPage(thread, page);
|
|
76
76
|
}
|
|
77
|
+
/** Exact fallback for SDK-owned discussion pointers. Legacy rows remain
|
|
78
|
+
* display-only and never establish authoritative delivery or history coverage. */
|
|
79
|
+
async readSelectedFallback(thread, ids) {
|
|
80
|
+
if (ids.length > 100 || ids.some(id => !id || id.length > 256))
|
|
81
|
+
throw new Error("INVALID_MESSAGE_SELECTION");
|
|
82
|
+
const page = await this.transact("r", [this.rows, this.legacy, this.coverage, this.accessRows, this.controls], async () => {
|
|
83
|
+
const access = await this.scopeAccess(), coverage = this.scopedCoverage(await this.coverage.get([this.key, thread]), access);
|
|
84
|
+
if (access.revoked || coverage?.revoked)
|
|
85
|
+
return { messages: [], hasEarlier: false, coverage: { ...coverage, complete: false, revoked: true, scopeVersion: access.version }, legacyFallback: false };
|
|
86
|
+
const keys = [...new Set(ids)].map(id => [this.key, thread, id]);
|
|
87
|
+
const canonical = await this.rows.bulkGet(keys), legacy = await this.legacy.bulkGet(keys);
|
|
88
|
+
const fallback = canonical.some((row, index) => !row && legacy[index]);
|
|
89
|
+
return { ...await this.controlPage({ messages: canonical.flatMap((row, index) => row ? [row.value] : legacy[index] ? [legacy[index].value] : []), hasEarlier: false, coverage: fallback ? { ...coverage, complete: false } : coverage }), legacyFallback: fallback };
|
|
90
|
+
});
|
|
91
|
+
return { ...await this.finishPage(thread, page), legacyFallback: page.legacyFallback };
|
|
92
|
+
}
|
|
77
93
|
/** Migration records never claim coverage or overwrite authoritative rows. */
|
|
78
94
|
async importLegacy(thread, messages) {
|
|
79
95
|
if (messages.length > 128)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Conversation IDs identify rooms; Message Thread roots identify discussions
|
|
2
|
+
* within a room. Quoted replies never implicitly create discussion membership. */
|
|
3
|
+
export type MessageThreadScope = {
|
|
4
|
+
threadRootId?: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function validMessageThreadRoot(value: unknown): value is string;
|
|
7
|
+
export declare function messageThreadRoot(body: unknown): string | undefined;
|
|
8
|
+
/** Never use this discussion key as an individual message/action dedup key. */
|
|
9
|
+
export declare function messageDiscussionKey(conversationId: string, scope?: MessageThreadScope): string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function validMessageThreadRoot(value) {
|
|
2
|
+
return typeof value === "string" && new TextEncoder().encode(value).byteLength > 0
|
|
3
|
+
&& new TextEncoder().encode(value).byteLength <= 256 && !/[\u0000-\u001f\u007f-\u009f]/u.test(value);
|
|
4
|
+
}
|
|
5
|
+
export function messageThreadRoot(body) {
|
|
6
|
+
if (!body || typeof body !== "object")
|
|
7
|
+
return;
|
|
8
|
+
const content = body.content;
|
|
9
|
+
if (!content || typeof content !== "object")
|
|
10
|
+
return;
|
|
11
|
+
const root = content.threadRootId;
|
|
12
|
+
return validMessageThreadRoot(root) ? root : undefined;
|
|
13
|
+
}
|
|
14
|
+
/** Never use this discussion key as an individual message/action dedup key. */
|
|
15
|
+
export function messageDiscussionKey(conversationId, scope = {}) {
|
|
16
|
+
return JSON.stringify([conversationId, scope.threadRootId ?? null]);
|
|
17
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { messageThreadRoot } from "./messageThread.js";
|
|
1
2
|
import { isReadingControl } from "./readingContent.js";
|
|
2
3
|
function content(message, appId) {
|
|
3
4
|
const body = message.body;
|
|
@@ -107,7 +108,7 @@ export function projectMessageTimeline(input) {
|
|
|
107
108
|
const mine = envelope.from === input.profileId, reading = input.reading[message.id];
|
|
108
109
|
const withdrawn = Boolean(tombstones.has(message.id) || message.recalled || envelope.legacyDisplay?.recalled || envelope.from && recalled.get(message.id)?.has(envelope.from));
|
|
109
110
|
const candidate = input.candidates?.get(message.id);
|
|
110
|
-
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, ...(candidate ? { unread: !mine && !withdrawn && (reading ? !reading.read : candidate.unread), attentionEligible: candidate.attentionEligible } : {}),
|
|
111
|
+
const item = { id: message.id, threadId: message.threadId, ...(messageThreadRoot(envelope.body) === undefined ? {} : { threadRootId: messageThreadRoot(envelope.body) }), timestamp: message.timestamp, from: envelope.from, mine, status: !mine && reading ? (reading.read ? "read" : status === "read" ? "sent" : status) : status, recalled: withdrawn, ...(candidate ? { unread: !mine && !withdrawn && (reading ? !reading.read : candidate.unread), attentionEligible: candidate.attentionEligible } : {}),
|
|
111
112
|
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) }; }) };
|
|
112
113
|
const old = previous.get(item.id);
|
|
113
114
|
const sameReading = old?.reading === item.reading || old?.reading?.seq === item.reading?.seq && old?.reading?.read === item.reading?.read && old?.reading?.readPolicy === item.reading?.readPolicy;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type MessageThreadScope } from "./messageThread.js";
|
|
1
2
|
import { type ChatFile } from "../fileMetadata.js";
|
|
2
3
|
import type { ChatMention } from "../mentions/model.js";
|
|
3
4
|
import type { MessageScope } from "./messageStore.js";
|
|
@@ -79,7 +80,7 @@ type ContentChoices = {
|
|
|
79
80
|
};
|
|
80
81
|
};
|
|
81
82
|
};
|
|
82
|
-
export type OutgoingContent = {
|
|
83
|
+
export type OutgoingContent = MessageThreadScope & {
|
|
83
84
|
[K in keyof ContentChoices]: ContentChoices[K] & {
|
|
84
85
|
[P in Exclude<keyof ContentChoices, K>]?: never;
|
|
85
86
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validMessageThreadRoot } from "./messageThread.js";
|
|
1
2
|
import { parseChatFile } from "../fileMetadata.js";
|
|
2
3
|
import { dmThreadId, groupThreadId } from "./thread.js";
|
|
3
4
|
const contentKeys = ["text", "file", "image", "voice", "recall", "reaction", "invitation", "groupCreate", "groupUpdate", "groupDissolve"];
|
|
@@ -39,7 +40,9 @@ export function outgoingContent(scope, thread, input) {
|
|
|
39
40
|
validateConversation(scope, thread);
|
|
40
41
|
if (!input || typeof input !== "object" || contentKeys.filter(key => Object.hasOwn(input, key)).length !== 1)
|
|
41
42
|
throw new Error("INVALID_MESSAGE_CONTENT");
|
|
42
|
-
|
|
43
|
+
if (input.threadRootId !== undefined && (!validMessageThreadRoot(input.threadRootId) || input.invitation || input.groupCreate || input.groupUpdate || input.groupDissolve))
|
|
44
|
+
throw new Error("INVALID_MESSAGE_THREAD_ROOT");
|
|
45
|
+
const identity = { appId: scope.appId, teamId: scope.teamId, ...(input.threadRootId === undefined ? {} : { threadRootId: input.threadRootId }) };
|
|
43
46
|
if (input.invitation) {
|
|
44
47
|
if (typeof input.invitation !== "object" || Array.isArray(input.invitation) || thread.kind !== "dm" || input.invitation.message !== undefined && typeof input.invitation.message !== "string")
|
|
45
48
|
throw new Error("INVALID_MESSAGE_INVITATION");
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export type IndexedReadMessage = {
|
|
3
3
|
id: string;
|
|
4
4
|
seq: number;
|
|
5
|
+
threadRootId?: string | null;
|
|
5
6
|
from: string;
|
|
6
7
|
recipients: string[];
|
|
7
8
|
mentions: string[];
|
|
@@ -18,6 +19,7 @@ export declare function readingMetadata<T extends {
|
|
|
18
19
|
threads: IndexedReadThread[];
|
|
19
20
|
}>(state: T): T;
|
|
20
21
|
export type ReadingOperation = {
|
|
22
|
+
threadRootId?: string;
|
|
21
23
|
operationId: string;
|
|
22
24
|
threadId: string;
|
|
23
25
|
readThroughSeq: number;
|
|
@@ -33,6 +35,7 @@ export type IndexedReadThread = {
|
|
|
33
35
|
kind: "dm" | "group";
|
|
34
36
|
messages: IndexedReadMessage[];
|
|
35
37
|
readThroughSeq: number;
|
|
38
|
+
messageThreadReadThrough?: Record<string, number> | null;
|
|
36
39
|
explicitReadMessageIds: string[];
|
|
37
40
|
};
|
|
38
41
|
export type ObservedReadIntent = {
|
|
@@ -65,6 +68,7 @@ export declare class ReadSyncGap extends Error {
|
|
|
65
68
|
constructor(cursor: string);
|
|
66
69
|
}
|
|
67
70
|
export declare function createReadIndex(appId: string, profileId: string): ReadIndexState;
|
|
71
|
+
export declare function readingCursor(thread: Pick<IndexedReadThread, "readThroughSeq" | "messageThreadReadThrough">, root?: string | null): number;
|
|
68
72
|
/** A snapshot is authoritative for server state, never for unsent local operations. */
|
|
69
73
|
export declare function installReadSnapshot(current: ReadIndexState, snapshot: ReadSnapshot): ReadIndexState;
|
|
70
74
|
/** Cursor positions are opaque; only exact predecessor matching proves continuity. */
|
|
@@ -80,7 +84,7 @@ export declare function coalesceReadOperations(current: ReadIndexState): (Readin
|
|
|
80
84
|
operationIds: string[];
|
|
81
85
|
})[];
|
|
82
86
|
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
83
|
-
export declare function unreadInThread(state: ReadIndexState, threadId: string, hidden?: ReadonlyMap<string, ReadonlySet<string
|
|
87
|
+
export declare function unreadInThread(state: ReadIndexState, threadId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>, threadRootId?: string): {
|
|
84
88
|
unreadMessageCount: number;
|
|
85
89
|
unreadMentionCount: number;
|
|
86
90
|
badgeCount: number;
|
|
@@ -158,7 +162,7 @@ export declare class IndexedDbReadIndex implements ReadIndexPersistence {
|
|
|
158
162
|
}
|
|
159
163
|
export declare function sourcesInConversation(state: ReadIndexState, conversationId: string): IndexedReadThread[];
|
|
160
164
|
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
161
|
-
export declare function unreadInConversation(state: ReadIndexState, conversationId: string, hidden?: ReadonlyMap<string, ReadonlySet<string
|
|
165
|
+
export declare function unreadInConversation(state: ReadIndexState, conversationId: string, hidden?: ReadonlyMap<string, ReadonlySet<string>>, threadRootId?: string): {
|
|
162
166
|
unreadMessageCount: number;
|
|
163
167
|
unreadMentionCount: number;
|
|
164
168
|
badgeCount: number;
|
package/lib/runtime/readIndex.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validMessageThreadRoot } from "./messageThread.js";
|
|
1
2
|
import { prepareReadSchema } from "./readSchema.js";
|
|
2
3
|
import { ReadBatchJournal, lockReadOwner } from "./readBatchJournal.js";
|
|
3
4
|
import { Dexie } from "dexie";
|
|
@@ -31,6 +32,17 @@ export class ReadSyncGap extends Error {
|
|
|
31
32
|
export function createReadIndex(appId, profileId) {
|
|
32
33
|
return { schemaVersion: 1, appId, profileId, syncCursor: "0", threads: [], pending: [] };
|
|
33
34
|
}
|
|
35
|
+
export function readingCursor(thread, root) {
|
|
36
|
+
return root == null ? thread.readThroughSeq : Object.hasOwn(thread.messageThreadReadThrough ?? {}, root) ? thread.messageThreadReadThrough[root] : 0;
|
|
37
|
+
}
|
|
38
|
+
function mergeDiscussionCursors(a, b) {
|
|
39
|
+
if (!a.messageThreadReadThrough && !b.messageThreadReadThrough)
|
|
40
|
+
return {};
|
|
41
|
+
const entries = new Map(Object.entries(a.messageThreadReadThrough ?? {}));
|
|
42
|
+
for (const [root, seq] of Object.entries(b.messageThreadReadThrough ?? {}))
|
|
43
|
+
entries.set(root, Math.max(entries.get(root) ?? 0, seq));
|
|
44
|
+
return { messageThreadReadThrough: Object.fromEntries(entries) };
|
|
45
|
+
}
|
|
34
46
|
function validateThread(thread) {
|
|
35
47
|
if (!thread.id || !["dm", "group"].includes(thread.kind) || !Number.isSafeInteger(thread.readThroughSeq) || thread.readThroughSeq < 0)
|
|
36
48
|
throw new Error("INVALID_READ_THREAD");
|
|
@@ -38,9 +50,11 @@ function validateThread(thread) {
|
|
|
38
50
|
throw new Error("INVALID_READ_TEAM");
|
|
39
51
|
if (thread.conversationId !== undefined && (!thread.conversationId || !thread.teamId))
|
|
40
52
|
throw new Error("INVALID_READ_CONVERSATION");
|
|
53
|
+
if (Object.entries(thread.messageThreadReadThrough ?? {}).some(([root, seq]) => !validMessageThreadRoot(root) || !Number.isSafeInteger(seq) || seq < 0))
|
|
54
|
+
throw new Error("INVALID_MESSAGE_THREAD_READING");
|
|
41
55
|
const ids = new Set(), positions = new Set();
|
|
42
56
|
for (const message of thread.messages) {
|
|
43
|
-
if (!message.id || !Number.isSafeInteger(message.seq) || message.seq < 1 || ids.has(message.id) || positions.has(message.seq)
|
|
57
|
+
if (message.threadRootId != null && !validMessageThreadRoot(message.threadRootId) || !message.id || !Number.isSafeInteger(message.seq) || message.seq < 1 || ids.has(message.id) || positions.has(message.seq)
|
|
44
58
|
|| !["visible", "explicit"].includes(message.readPolicy))
|
|
45
59
|
throw new Error("INVALID_READ_MESSAGE");
|
|
46
60
|
ids.add(message.id);
|
|
@@ -52,11 +66,11 @@ function cloneThread(thread) {
|
|
|
52
66
|
return structuredClone({ ...thread, messages: [...thread.messages].sort((a, b) => a.seq - b.seq), explicitReadMessageIds: [...new Set(thread.explicitReadMessageIds)] });
|
|
53
67
|
}
|
|
54
68
|
function applyOperation(thread, operation) {
|
|
55
|
-
return { ...thread, readThroughSeq: Math.max(thread.readThroughSeq, operation.readThroughSeq),
|
|
69
|
+
return { ...thread, ...(operation.threadRootId === undefined ? { readThroughSeq: Math.max(thread.readThroughSeq, operation.readThroughSeq) } : { messageThreadReadThrough: { ...thread.messageThreadReadThrough, [operation.threadRootId]: Math.max(readingCursor(thread, operation.threadRootId), operation.readThroughSeq) } }),
|
|
56
70
|
explicitReadMessageIds: [...new Set([...thread.explicitReadMessageIds, ...operation.explicitReadMessageIds])] };
|
|
57
71
|
}
|
|
58
72
|
function covered(thread, operation) {
|
|
59
|
-
return thread.
|
|
73
|
+
return readingCursor(thread, operation.threadRootId) >= operation.readThroughSeq
|
|
60
74
|
&& operation.explicitReadMessageIds.every(id => thread.explicitReadMessageIds.includes(id));
|
|
61
75
|
}
|
|
62
76
|
/** A snapshot is authoritative for server state, never for unsent local operations. */
|
|
@@ -80,7 +94,7 @@ export function installReadSnapshot(current, snapshot) {
|
|
|
80
94
|
for (const previous of current.threads) {
|
|
81
95
|
const thread = byId.get(previous.id);
|
|
82
96
|
if (thread)
|
|
83
|
-
byId.set(thread.id, { ...thread, readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
|
|
97
|
+
byId.set(thread.id, { ...thread, ...mergeDiscussionCursors(thread, previous), readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
|
|
84
98
|
explicitReadMessageIds: [...new Set([...thread.explicitReadMessageIds, ...previous.explicitReadMessageIds])] });
|
|
85
99
|
}
|
|
86
100
|
return resolveObservedReads({ ...snapshot, threads: [...byId.values()], pending, observedReadIntents: current.observedReadIntents ?? [] });
|
|
@@ -96,7 +110,7 @@ export function applyReadChange(current, change) {
|
|
|
96
110
|
const incoming = cloneThread(change.thread);
|
|
97
111
|
const pending = current.pending.filter(operation => operation.threadId !== incoming.id || !covered(incoming, operation));
|
|
98
112
|
const previous = current.threads.find(thread => thread.id === incoming.id);
|
|
99
|
-
const merged = previous ? { ...incoming, readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
|
|
113
|
+
const merged = previous ? { ...incoming, ...mergeDiscussionCursors(incoming, previous), readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
|
|
100
114
|
explicitReadMessageIds: [...new Set([...incoming.explicitReadMessageIds, ...previous.explicitReadMessageIds])] } : incoming;
|
|
101
115
|
const projected = pending.filter(operation => operation.threadId === incoming.id).reduce(applyOperation, merged);
|
|
102
116
|
return resolveObservedReads({ ...current, syncCursor: change.syncCursor, pending, threads: [...current.threads.filter(t => t.id !== incoming.id), projected] });
|
|
@@ -140,9 +154,9 @@ export function recordVisibleRead(current, threadId, messageId, operationId) {
|
|
|
140
154
|
const message = thread?.messages.find(m => m.id === messageId);
|
|
141
155
|
if (!thread || !message || message.readPolicy !== "visible")
|
|
142
156
|
throw new Error("INVALID_VISIBLE_READ");
|
|
143
|
-
if (message.seq <= thread.
|
|
157
|
+
if (message.seq <= readingCursor(thread, message.threadRootId))
|
|
144
158
|
return current;
|
|
145
|
-
return enqueue(current, { operationId, threadId: thread.id, readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
|
|
159
|
+
return enqueue(current, { operationId, threadId: thread.id, ...(message.threadRootId == null ? {} : { threadRootId: message.threadRootId }), readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
|
|
146
160
|
}
|
|
147
161
|
export function recordExplicitRead(current, threadId, messageId, operationId) {
|
|
148
162
|
const thread = readSource(current, threadId, messageId);
|
|
@@ -151,7 +165,7 @@ export function recordExplicitRead(current, threadId, messageId, operationId) {
|
|
|
151
165
|
throw new Error("INVALID_EXPLICIT_READ");
|
|
152
166
|
if (thread.explicitReadMessageIds.includes(messageId))
|
|
153
167
|
return current;
|
|
154
|
-
return enqueue(current, { operationId, threadId: thread.id, readThroughSeq: 0, explicitReadMessageIds: [messageId] });
|
|
168
|
+
return enqueue(current, { operationId, threadId: thread.id, ...(message.threadRootId == null ? {} : { threadRootId: message.threadRootId }), readThroughSeq: 0, explicitReadMessageIds: [messageId] });
|
|
155
169
|
}
|
|
156
170
|
function enqueue(current, operation) {
|
|
157
171
|
if (!operation.operationId || current.pending.some(p => p.operationId === operation.operationId))
|
|
@@ -162,9 +176,10 @@ function enqueue(current, operation) {
|
|
|
162
176
|
export function coalesceReadOperations(current) {
|
|
163
177
|
const batches = new Map();
|
|
164
178
|
for (const operation of current.pending) {
|
|
165
|
-
const
|
|
179
|
+
const key = JSON.stringify([operation.threadId, operation.threadRootId ?? null]);
|
|
180
|
+
const batch = batches.get(key);
|
|
166
181
|
if (!batch) {
|
|
167
|
-
batches.set(
|
|
182
|
+
batches.set(key, { ...structuredClone(operation), operationIds: [operation.operationId] });
|
|
168
183
|
continue;
|
|
169
184
|
}
|
|
170
185
|
batch.operationIds.push(operation.operationId);
|
|
@@ -177,13 +192,13 @@ export function coalesceReadOperations(current) {
|
|
|
177
192
|
return [...batches.values()];
|
|
178
193
|
}
|
|
179
194
|
/** Counts are over the indexed message coverage, not an estimate of missing history. */
|
|
180
|
-
export function unreadInThread(state, threadId, hidden) {
|
|
195
|
+
export function unreadInThread(state, threadId, hidden, threadRootId) {
|
|
181
196
|
const thread = state.threads.find(t => t.id === threadId);
|
|
182
197
|
if (!thread)
|
|
183
198
|
throw new Error("UNKNOWN_READ_THREAD");
|
|
184
199
|
const explicit = new Set(thread.explicitReadMessageIds);
|
|
185
|
-
const messages = thread.messages.filter(message => !message.recalled && !hidden?.get(message.id)?.has(message.from) && !(message.readingControl || isReadingControl(message.payload, state.appId)) && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
186
|
-
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > thread.
|
|
200
|
+
const messages = thread.messages.filter(message => (threadRootId === undefined || message.threadRootId === threadRootId) && !message.recalled && !hidden?.get(message.id)?.has(message.from) && !(message.readingControl || isReadingControl(message.payload, state.appId)) && message.from !== state.profileId && message.recipients.includes(state.profileId)
|
|
201
|
+
&& (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > readingCursor(thread, message.threadRootId)));
|
|
187
202
|
const unreadMessageCount = messages.length;
|
|
188
203
|
const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
|
|
189
204
|
return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
|
|
@@ -358,7 +373,7 @@ export class IndexedDbReadIndex {
|
|
|
358
373
|
throw new Error("READ_STORAGE_INCOMPLETE");
|
|
359
374
|
return message.value;
|
|
360
375
|
});
|
|
361
|
-
return { ...row.value, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds, messages };
|
|
376
|
+
return { ...row.value, readThroughSeq: position.readThroughSeq, messageThreadReadThrough: position.messageThreadReadThrough, explicitReadMessageIds: position.explicitReadMessageIds, messages };
|
|
362
377
|
}) };
|
|
363
378
|
this.normalizedStates.add(result);
|
|
364
379
|
if ((threadOrder ?? []).every(id => byId.get(id)?.messageCount !== undefined && !byId.get(id).messageIds.length && [...(byThread.get(id)?.values() ?? [])].every(row => Number.isSafeInteger(row.ordinal))))
|
|
@@ -532,7 +547,7 @@ export class IndexedDbReadIndex {
|
|
|
532
547
|
if (message)
|
|
533
548
|
messages.push(previous);
|
|
534
549
|
}
|
|
535
|
-
partialThreads.push({ ...source.cached, messages, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds });
|
|
550
|
+
partialThreads.push({ ...source.cached, messages, readThroughSeq: position.readThroughSeq, messageThreadReadThrough: position.messageThreadReadThrough, explicitReadMessageIds: position.explicitReadMessageIds });
|
|
536
551
|
}
|
|
537
552
|
const partial = { schemaVersion: 1, appId, profileId, syncCursor: header.syncCursor, pending: header.pending, observedReadIntents: header.observedReadIntents, threads: partialThreads };
|
|
538
553
|
const result = transform(partial, (await this.versions.get(key))?.teams ?? {});
|
|
@@ -543,8 +558,8 @@ export class IndexedDbReadIndex {
|
|
|
543
558
|
const writes = [];
|
|
544
559
|
for (let i = 0; i < result.threads.length; i++) {
|
|
545
560
|
const thread = result.threads[i], before = positions[i];
|
|
546
|
-
if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
|
|
547
|
-
writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, explicitReadMessageIds: thread.explicitReadMessageIds });
|
|
561
|
+
if (thread.readThroughSeq !== before.readThroughSeq || JSON.stringify(thread.messageThreadReadThrough) !== JSON.stringify(before.messageThreadReadThrough) || JSON.stringify(thread.explicitReadMessageIds) !== JSON.stringify(before.explicitReadMessageIds))
|
|
562
|
+
writes.push({ owner, id: thread.id, readThroughSeq: thread.readThroughSeq, messageThreadReadThrough: thread.messageThreadReadThrough, explicitReadMessageIds: thread.explicitReadMessageIds });
|
|
548
563
|
}
|
|
549
564
|
if (writes.length > 126)
|
|
550
565
|
return undefined;
|
|
@@ -557,10 +572,10 @@ export class IndexedDbReadIndex {
|
|
|
557
572
|
let changed = false;
|
|
558
573
|
const threads = base.threads.map(thread => {
|
|
559
574
|
const position = byId.get(thread.id);
|
|
560
|
-
if (!position || thread.readThroughSeq === position.readThroughSeq && JSON.stringify(thread.explicitReadMessageIds) === JSON.stringify(position.explicitReadMessageIds))
|
|
575
|
+
if (!position || thread.readThroughSeq === position.readThroughSeq && JSON.stringify(thread.messageThreadReadThrough) === JSON.stringify(position.messageThreadReadThrough) && JSON.stringify(thread.explicitReadMessageIds) === JSON.stringify(position.explicitReadMessageIds))
|
|
561
576
|
return thread;
|
|
562
577
|
changed = true;
|
|
563
|
-
return { ...thread, readThroughSeq: position.readThroughSeq, explicitReadMessageIds: position.explicitReadMessageIds };
|
|
578
|
+
return { ...thread, readThroughSeq: position.readThroughSeq, messageThreadReadThrough: position.messageThreadReadThrough, explicitReadMessageIds: position.explicitReadMessageIds };
|
|
564
579
|
});
|
|
565
580
|
if (!changed && JSON.stringify(base.pending) === JSON.stringify(result.pending) && JSON.stringify(base.observedReadIntents) === JSON.stringify(result.observedReadIntents))
|
|
566
581
|
return base;
|
|
@@ -622,11 +637,11 @@ export class IndexedDbReadIndex {
|
|
|
622
637
|
const old = oldThreads.get(thread.id);
|
|
623
638
|
if (old === thread && modern)
|
|
624
639
|
continue;
|
|
625
|
-
const { messages, readThroughSeq, explicitReadMessageIds, ...value } = thread;
|
|
626
|
-
if (!modern || !old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
627
|
-
yield { table: "readingPositions", key: [owner, thread.id], value: { owner, id: thread.id, readThroughSeq, explicitReadMessageIds } };
|
|
640
|
+
const { messages, readThroughSeq, messageThreadReadThrough, explicitReadMessageIds, ...value } = thread;
|
|
641
|
+
if (!modern || !old || old.readThroughSeq !== readThroughSeq || JSON.stringify(old.messageThreadReadThrough) !== JSON.stringify(messageThreadReadThrough) || JSON.stringify(old.explicitReadMessageIds) !== JSON.stringify(explicitReadMessageIds))
|
|
642
|
+
yield { table: "readingPositions", key: [owner, thread.id], value: { owner, id: thread.id, readThroughSeq, messageThreadReadThrough, explicitReadMessageIds } };
|
|
628
643
|
const metadata = { owner, id: thread.id, value, messageIds: [], messageCount: messages.length };
|
|
629
|
-
const oldMetadata = old ? { owner, id: old.id, value: (({ messages: _m, readThroughSeq: _r, explicitReadMessageIds: _e, ...rest }) => rest)(old), messageIds: modern ? [] : old.messages.map(message => message.id), ...(modern ? { messageCount: old.messages.length } : {}) } : undefined;
|
|
644
|
+
const oldMetadata = old ? { owner, id: old.id, value: (({ messages: _m, readThroughSeq: _r, messageThreadReadThrough: _mt, explicitReadMessageIds: _e, ...rest }) => rest)(old), messageIds: modern ? [] : old.messages.map(message => message.id), ...(modern ? { messageCount: old.messages.length } : {}) } : undefined;
|
|
630
645
|
if (JSON.stringify(metadata) !== JSON.stringify(oldMetadata))
|
|
631
646
|
yield { table: "readingThreads", key: [owner, thread.id], value: metadata };
|
|
632
647
|
if (old?.messages === messages && modern)
|
|
@@ -669,14 +684,14 @@ function readSource(state, conversationId, messageId) {
|
|
|
669
684
|
return sources[0];
|
|
670
685
|
}
|
|
671
686
|
/** Aggregate logical history while each original source retains its own read cursor. */
|
|
672
|
-
export function unreadInConversation(state, conversationId, hidden) {
|
|
687
|
+
export function unreadInConversation(state, conversationId, hidden, threadRootId) {
|
|
673
688
|
const sources = sourcesInConversation(state, conversationId);
|
|
674
689
|
if (!sources.length)
|
|
675
690
|
throw new Error("UNKNOWN_READ_CONVERSATION");
|
|
676
691
|
if (new Set(sources.map(source => source.teamId)).size > 1)
|
|
677
692
|
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
678
693
|
return sources.reduce((total, source) => {
|
|
679
|
-
const unread = unreadInThread(state, source.id, hidden);
|
|
694
|
+
const unread = unreadInThread(state, source.id, hidden, threadRootId);
|
|
680
695
|
return { unreadMessageCount: total.unreadMessageCount + unread.unreadMessageCount, unreadMentionCount: total.unreadMentionCount + unread.unreadMentionCount, badgeCount: total.badgeCount + unread.badgeCount };
|
|
681
696
|
}, { unreadMessageCount: 0, unreadMentionCount: 0, badgeCount: 0 });
|
|
682
697
|
}
|
package/lib/runtime/readSync.js
CHANGED
|
@@ -63,11 +63,12 @@ export class ReadSyncClient {
|
|
|
63
63
|
for (const operation of current.pending) {
|
|
64
64
|
if (operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0)
|
|
65
65
|
continue;
|
|
66
|
-
const
|
|
66
|
+
const key = JSON.stringify([operation.threadId, operation.threadRootId ?? null]);
|
|
67
|
+
const previous = latest.get(key);
|
|
67
68
|
if (!previous || operation.readThroughSeq > previous.readThroughSeq)
|
|
68
|
-
latest.set(
|
|
69
|
+
latest.set(key, operation);
|
|
69
70
|
}
|
|
70
|
-
const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(operation.threadId) === operation);
|
|
71
|
+
const outgoing = current.pending.filter(operation => operation.explicitReadMessageIds.length || operation.readThroughSeq <= 0 || latest.get(JSON.stringify([operation.threadId, operation.threadRootId ?? null])) === operation);
|
|
71
72
|
for (const operation of outgoing) {
|
|
72
73
|
if (persistence.loadVersions)
|
|
73
74
|
assertReadIndexVersions(versions, await persistence.loadVersions(appId, profileId));
|
|
@@ -47,7 +47,7 @@ export declare class ReadingClient {
|
|
|
47
47
|
private updateScoped;
|
|
48
48
|
prepareTeam(teamId: string, version: number): Promise<void>;
|
|
49
49
|
private assertThreadTeam;
|
|
50
|
-
markRead(threadId: string, messages?: readonly ConversationReadMessage[]): Promise<ReadIndexState>;
|
|
50
|
+
markRead(threadId: string, messages?: readonly ConversationReadMessage[], threadRootId?: string): Promise<ReadIndexState>;
|
|
51
51
|
recordVisible(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
52
52
|
recordExplicit(threadId: string, messageIds: string[]): Promise<ReadIndexState>;
|
|
53
53
|
private record;
|
|
@@ -155,8 +155,8 @@ export class ReadingClient {
|
|
|
155
155
|
if (this.options.teamId !== undefined && state.threads.some(thread => (thread.id === threadId || thread.conversationId === threadId) && thread.teamId !== undefined && thread.teamId !== this.options.teamId))
|
|
156
156
|
throw new Error("READ_CONVERSATION_TEAM_MISMATCH");
|
|
157
157
|
}
|
|
158
|
-
markRead(threadId, messages = []) {
|
|
159
|
-
return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return recordConversationRead(state, threadId, messages); }, this.options.teamId);
|
|
158
|
+
markRead(threadId, messages = [], threadRootId) {
|
|
159
|
+
return this.updateScoped(state => { this.assertThreadTeam(state, threadId); return recordConversationRead(state, threadId, messages, undefined, threadRootId); }, this.options.teamId);
|
|
160
160
|
}
|
|
161
161
|
recordVisible(threadId, messageIds) { return this.record(threadId, messageIds, "visible"); }
|
|
162
162
|
recordExplicit(threadId, messageIds) { return this.record(threadId, messageIds, "explicit"); }
|
|
@@ -13,6 +13,14 @@ export type SocialMessagePage = {
|
|
|
13
13
|
hasMoreChanges: boolean;
|
|
14
14
|
resetRequired: boolean;
|
|
15
15
|
};
|
|
16
|
+
export type MessageThreadSummary = {
|
|
17
|
+
rootMessageId: string;
|
|
18
|
+
replyCount: number;
|
|
19
|
+
unreadCount: number;
|
|
20
|
+
lastReplyId: string | null;
|
|
21
|
+
lastReplyAt: number | null;
|
|
22
|
+
participantProfileIds: string[];
|
|
23
|
+
};
|
|
16
24
|
export type SocialGroup = {
|
|
17
25
|
appId: string;
|
|
18
26
|
groupId: string;
|
|
@@ -62,7 +70,19 @@ export declare class SocialReadClient {
|
|
|
62
70
|
before?: string;
|
|
63
71
|
after?: string;
|
|
64
72
|
messageId?: string;
|
|
73
|
+
threadRootId?: string;
|
|
74
|
+
mainOnly?: boolean;
|
|
65
75
|
}, signal?: AbortSignal): Promise<SocialMessagePage>;
|
|
76
|
+
messageThreads(input: {
|
|
77
|
+
threadId: string;
|
|
78
|
+
teamId?: string;
|
|
79
|
+
rootMessageIds: readonly string[];
|
|
80
|
+
}, signal?: AbortSignal): Promise<{
|
|
81
|
+
appId: string;
|
|
82
|
+
threadId: string;
|
|
83
|
+
revision: number;
|
|
84
|
+
items: MessageThreadSummary[];
|
|
85
|
+
}>;
|
|
66
86
|
group(input: {
|
|
67
87
|
groupId: string;
|
|
68
88
|
teamId?: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { messageThreadRoot, validMessageThreadRoot } from "./messageThread.js";
|
|
1
2
|
export class SocialReadError extends Error {
|
|
2
3
|
status;
|
|
3
4
|
constructor(status, message) {
|
|
@@ -26,13 +27,15 @@ export class SocialReadClient {
|
|
|
26
27
|
}
|
|
27
28
|
async messagePage(input, signal) {
|
|
28
29
|
this.requireTeam(input.teamId);
|
|
30
|
+
if (input.threadRootId !== undefined && !validMessageThreadRoot(input.threadRootId) || input.threadRootId !== undefined && input.mainOnly || input.messageId !== undefined && (input.threadRootId !== undefined || input.mainOnly))
|
|
31
|
+
throw new SocialReadError(400, "INVALID_MESSAGE_THREAD_SELECTOR");
|
|
29
32
|
const limit = input.limit ?? (input.messageId === undefined ? 100 : 1);
|
|
30
33
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500 || (input.before !== undefined && input.after !== undefined))
|
|
31
34
|
throw new SocialReadError(400, "INVALID_MESSAGE_PAGE");
|
|
32
35
|
if (input.messageId !== undefined && (!input.messageId || input.messageId.length > 256 || /[\u0000-\u001f\u007f]/.test(input.messageId) || limit !== 1 || input.before !== undefined || input.after !== undefined))
|
|
33
36
|
throw new SocialReadError(400, "INVALID_MESSAGE_LOOKUP");
|
|
34
37
|
const value = await this.request("messages", { threadId: input.threadId, ...this.teamQuery(input.teamId), limit: String(limit),
|
|
35
|
-
...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }), ...(input.messageId === undefined ? {} : { messageId: input.messageId }) }, signal);
|
|
38
|
+
...(input.threadRootId === undefined ? {} : { threadRootId: input.threadRootId }), ...(input.mainOnly === undefined ? {} : { mainOnly: String(input.mainOnly) }), ...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }), ...(input.messageId === undefined ? {} : { messageId: input.messageId }) }, signal);
|
|
36
39
|
const cursor = (token) => typeof token === "string" && /^mp1:\d+:[0-9a-f]{64}$/.test(token);
|
|
37
40
|
if (!value || !Array.isArray(value.messages) || value.messages.length > limit || !cursor(value.checkpoint)
|
|
38
41
|
|| (value.nextBefore !== null && !cursor(value.nextBefore)) || typeof value.hasMoreChanges !== "boolean" || typeof value.resetRequired !== "boolean"
|
|
@@ -41,6 +44,26 @@ export class SocialReadClient {
|
|
|
41
44
|
throw new SocialReadError(502, "INVALID_MESSAGE_PAGE_RESPONSE");
|
|
42
45
|
if (input.messageId !== undefined && (value.messages.some(row => row.messageId !== input.messageId) || value.nextBefore !== null || value.hasMoreChanges || value.resetRequired))
|
|
43
46
|
throw new SocialReadError(502, "INVALID_MESSAGE_LOOKUP_RESPONSE");
|
|
47
|
+
if (value.messages.some(row => {
|
|
48
|
+
const root = messageThreadRoot(row.body);
|
|
49
|
+
const content = row.body?.content;
|
|
50
|
+
return input.mainOnly && root !== undefined || input.threadRootId !== undefined && root !== input.threadRootId && !content?.reaction && !content?.recall;
|
|
51
|
+
}))
|
|
52
|
+
throw new SocialReadError(502, "INVALID_MESSAGE_THREAD_PAGE_RESPONSE");
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
async messageThreads(input, signal) {
|
|
56
|
+
this.requireTeam(input.teamId);
|
|
57
|
+
if (!input.rootMessageIds.length || input.rootMessageIds.length > 100 || !input.rootMessageIds.every(validMessageThreadRoot))
|
|
58
|
+
throw new SocialReadError(400, "INVALID_MESSAGE_THREAD_ROOTS");
|
|
59
|
+
const value = await this.request("message-threads", { threadId: input.threadId, ...this.teamQuery(input.teamId), rootMessageIds: JSON.stringify(input.rootMessageIds) }, signal);
|
|
60
|
+
const roots = new Set(input.rootMessageIds), seen = new Set();
|
|
61
|
+
if (!value || value.appId !== this.options.appId || value.threadId !== input.threadId || !Number.isSafeInteger(value.revision) || value.revision < 0 || !Array.isArray(value.items) || value.items.length !== roots.size || value.items.some(item => {
|
|
62
|
+
const invalid = !roots.has(item.rootMessageId) || seen.has(item.rootMessageId) || !Number.isSafeInteger(item.replyCount) || item.replyCount < 0 || !Number.isSafeInteger(item.unreadCount) || item.unreadCount < 0 || item.unreadCount > item.replyCount || !Array.isArray(item.participantProfileIds);
|
|
63
|
+
seen.add(item.rootMessageId);
|
|
64
|
+
return invalid;
|
|
65
|
+
}))
|
|
66
|
+
throw new SocialReadError(502, "INVALID_MESSAGE_THREAD_SUMMARY_RESPONSE");
|
|
44
67
|
return value;
|
|
45
68
|
}
|
|
46
69
|
group(input, signal) {
|
package/package.json
CHANGED