@pmate/chat-sdk 0.12.0-next.80 → 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.
Files changed (37) hide show
  1. package/README.md +31 -0
  2. package/lib/chatContract.d.ts +7 -0
  3. package/lib/chatTypes.d.ts +1 -0
  4. package/lib/index.d.ts +1 -0
  5. package/lib/index.js +1 -0
  6. package/lib/internal/createPublicChat.d.ts +1 -1
  7. package/lib/internal/createPublicChat.js +12 -2
  8. package/lib/messageView.js +1 -1
  9. package/lib/runtime/chatSession.d.ts +11 -0
  10. package/lib/runtime/chatSession.js +18 -4
  11. package/lib/runtime/conversationRead.d.ts +2 -1
  12. package/lib/runtime/conversationRead.js +5 -5
  13. package/lib/runtime/conversationReading.d.ts +2 -0
  14. package/lib/runtime/conversationReading.js +3 -3
  15. package/lib/runtime/createChat.d.ts +1 -0
  16. package/lib/runtime/createChat.js +10 -3
  17. package/lib/runtime/index.d.ts +1 -0
  18. package/lib/runtime/index.js +1 -0
  19. package/lib/runtime/messageDiscussionStore.d.ts +63 -0
  20. package/lib/runtime/messageDiscussionStore.js +147 -0
  21. package/lib/runtime/messageStore.d.ts +8 -0
  22. package/lib/runtime/messageStore.js +16 -0
  23. package/lib/runtime/messageThread.d.ts +9 -0
  24. package/lib/runtime/messageThread.js +17 -0
  25. package/lib/runtime/messageTimeline.d.ts +1 -0
  26. package/lib/runtime/messageTimeline.js +2 -1
  27. package/lib/runtime/outgoingContent.d.ts +2 -1
  28. package/lib/runtime/outgoingContent.js +4 -1
  29. package/lib/runtime/readIndex.d.ts +6 -2
  30. package/lib/runtime/readIndex.js +40 -25
  31. package/lib/runtime/readSync.js +4 -3
  32. package/lib/runtime/readingClient.d.ts +1 -1
  33. package/lib/runtime/readingClient.js +2 -2
  34. package/lib/runtime/socialReadClient.d.ts +20 -0
  35. package/lib/runtime/socialReadClient.js +24 -1
  36. package/lib/snapshot.js +3 -1
  37. 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.
@@ -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>;
@@ -6,6 +6,7 @@ import type { ChatMention } from "./mentions/model.js";
6
6
  export type ChatMessage = Readonly<{
7
7
  id: string;
8
8
  threadId: string;
9
+ threadRootId?: string;
9
10
  timestamp: number;
10
11
  from?: string;
11
12
  mine: boolean;
package/lib/index.d.ts CHANGED
@@ -46,3 +46,4 @@ export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
46
46
  export * from "./commands";
47
47
  export * from "./components/CommandMenu";
48
48
  export * from "./hooks/useComposerSuggestions";
49
+ export * from "./runtime/messageThread.js";
package/lib/index.js CHANGED
@@ -46,3 +46,4 @@ export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
46
46
  export * from "./commands";
47
47
  export * from "./components/CommandMenu";
48
48
  export * from "./hooks/useComposerSuggestions";
49
+ export * from "./runtime/messageThread.js";
@@ -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
- start: () => engine.start(), close: () => engine.close(),
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); },
@@ -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 outgoing = this.sender.snapshot(threadId);
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}`): ReadIndexState;
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.readThroughSeq)
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.readThroughSeq) };
16
+ messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: message.recalled || hidden.get(message.id)?.has(message.from) === true || message.from === state.profileId || !message.recipients.includes(state.profileId) || (message.readPolicy === "explicit" ? source.explicitReadMessageIds.includes(message.id) : message.seq <= 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)) };
@@ -11,6 +11,7 @@ export type CreateChatOptions = {
11
11
  token?: string;
12
12
  getToken?: TokenProvider;
13
13
  /** Limit reconnect reconciliation for a dedicated conversation document. */
14
+ threadRootId?: string;
14
15
  threadId?: string;
15
16
  uploadEndpoint?: string;
16
17
  endpoint?: string;
@@ -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,
@@ -36,3 +36,4 @@ export * from "./messageLaunch.js";
36
36
  export * from "./outgoingContent.js";
37
37
  export type { ChatGroup } from "./groupClient.js";
38
38
  export type { ConversationTarget } from "./conversationDirectory.js";
39
+ export * from "./messageThread.js";
@@ -34,3 +34,4 @@ export { projectConversationReading } from "./conversationReading.js";
34
34
  export { projectMessageTimeline } from "./messageTimeline.js";
35
35
  export * from "./messageLaunch.js";
36
36
  export * from "./outgoingContent.js";
37
+ export * from "./messageThread.js";
@@ -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
+ }
@@ -13,6 +13,7 @@ export type TimelineReaction = {
13
13
  export type TimelineItem<T> = {
14
14
  id: string;
15
15
  threadId: string;
16
+ threadRootId?: string;
16
17
  timestamp: number;
17
18
  from?: string;
18
19
  mine: boolean;
@@ -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
- const identity = { appId: scope.appId, teamId: scope.teamId };
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;
@@ -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.readThroughSeq >= operation.readThroughSeq
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.readThroughSeq)
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 batch = batches.get(operation.threadId);
179
+ const key = JSON.stringify([operation.threadId, operation.threadRootId ?? null]);
180
+ const batch = batches.get(key);
166
181
  if (!batch) {
167
- batches.set(operation.threadId, { ...structuredClone(operation), operationIds: [operation.operationId] });
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.readThroughSeq));
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
  }
@@ -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 previous = latest.get(operation.threadId);
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(operation.threadId, operation);
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/lib/snapshot.js CHANGED
@@ -45,6 +45,8 @@ export async function fetchConversationSnapshot(options) {
45
45
  const projected = projectConversationReading(state, { id: options.threadId, kind });
46
46
  const present = createMessagePresenter();
47
47
  const timeline = projectMessageTimeline({ appId: options.appId, profileId: options.profileId, threadId: options.threadId, messages, outgoing: [], decode: () => undefined, reading: projected.messageReading });
48
+ const recalled = new Set(timeline.filter(item => item.recalled).map(item => item.id));
49
+ const displayed = timeline.map(present).map(message => message.quote?.messageId && recalled.has(message.quote.messageId) ? { ...message, quote: { ...message.quote, content: "", recalled: true } } : message);
48
50
  return { appId: value.appId, accountId: value.accountId, profileId: value.profileId, teamId: value.teamId, threadId: value.threadId, generatedAt: value.generatedAt, contact: value.contact, group: value.group,
49
- messages: timeline.map(present), hasEarlier: page.nextBefore !== null, unread: summary, checkpoint: page.checkpoint, readingCursor: reading.syncCursor, serverTiming: response.headers.get("server-timing") };
51
+ messages: displayed, hasEarlier: page.nextBefore !== null, unread: summary, checkpoint: page.checkpoint, readingCursor: reading.syncCursor, serverTiming: response.headers.get("server-timing") };
50
52
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.80",
3
+ "version": "0.12.0-next.82",
4
4
  "description": "Local-first chat SDK, message subscriptions, reading state, and shared chat UI primitives",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",