@pmate/chat-sdk 0.12.0-next.84 → 0.12.0-next.86

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.
@@ -156,8 +156,16 @@ export class ChatSession {
156
156
  this.groups?.hint({ id: groupThreadId(scope.appId, message.to, scope.teamId), kind: "group", targetId: message.to, teamId: scope.teamId }, Number.isSafeInteger(body.payload.versionNumber) ? body.payload.versionNumber : undefined);
157
157
  }
158
158
  const decoded = options.decode(message);
159
+ // A peer can react before history returns our own ACKed source message.
160
+ // Discussion windows store controls against canonical target IDs; retain
161
+ // the known sent local target in the same atomic receive batch so the
162
+ // reaction is visible without waiting for another network history read.
163
+ const control = message.body?.content;
164
+ const targetId = control?.reaction?.messageId ?? control?.recall?.messageId;
165
+ const outgoing = decoded && targetId ? this.sender.snapshot(decoded.threadId).find(record => record.hash === targetId && record.status === 'sent' && record.message.from === scope.profileId) : undefined;
166
+ const localTarget = outgoing && options.decode(outgoing.message);
159
167
  if (decoded)
160
- void this.messages.receive(decoded.threadId, [decoded]).then(async () => {
168
+ void this.messages.receive(decoded.threadId, localTarget ? [localTarget, decoded] : [decoded]).then(async () => {
161
169
  if (!this.closed && this.readingStarted)
162
170
  this.reading.refresh();
163
171
  if (message.from === scope.profileId)
@@ -13,7 +13,7 @@ export function recordConversationRead(state, conversationId, messages = [], ope
13
13
  indexedIds.add(message.id);
14
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 > readingCursor(source, threadRootId))
16
+ const latest = incoming.filter(message => message.readPolicy === "visible" && !source.preservedReadMessageIds?.includes(message.id) && 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" });
@@ -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 <= readingCursor(source, message.threadRootId)) };
16
+ messageReading[message.id] = { seq: message.seq, readPolicy: message.readPolicy, read: source.preservedReadMessageIds?.includes(message.id) === true || 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 ?? [])
@@ -27,6 +27,8 @@ export type ReadingOperation = {
27
27
  explicitReadMessageIds: string[];
28
28
  };
29
29
  export type IndexedReadThread = {
30
+ /** Read facts retained when server-side thread organization moves a message. */
31
+ preservedReadMessageIds?: string[];
30
32
  /** Durable source ID: reading positions must remain attached to this ID. */
31
33
  id: string;
32
34
  teamId?: string;
@@ -50,6 +50,8 @@ function validateThread(thread) {
50
50
  throw new Error("INVALID_READ_TEAM");
51
51
  if (thread.conversationId !== undefined && (!thread.conversationId || !thread.teamId))
52
52
  throw new Error("INVALID_READ_CONVERSATION");
53
+ if (thread.preservedReadMessageIds !== undefined && (!Array.isArray(thread.preservedReadMessageIds) || thread.preservedReadMessageIds.some(id => typeof id !== "string" || !id)))
54
+ throw new Error("INVALID_PRESERVED_READ_MESSAGES");
53
55
  if (Object.entries(thread.messageThreadReadThrough ?? {}).some(([root, seq]) => !validMessageThreadRoot(root) || !Number.isSafeInteger(seq) || seq < 0))
54
56
  throw new Error("INVALID_MESSAGE_THREAD_READING");
55
57
  const ids = new Set(), positions = new Set();
@@ -94,7 +96,7 @@ export function installReadSnapshot(current, snapshot) {
94
96
  for (const previous of current.threads) {
95
97
  const thread = byId.get(previous.id);
96
98
  if (thread)
97
- byId.set(thread.id, { ...thread, ...mergeDiscussionCursors(thread, previous), readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
99
+ byId.set(thread.id, { ...thread, ...mergeDiscussionCursors(thread, previous), preservedReadMessageIds: [...new Set([...(thread.preservedReadMessageIds ?? []), ...(previous.preservedReadMessageIds ?? [])])], readThroughSeq: Math.max(thread.readThroughSeq, previous.readThroughSeq),
98
100
  explicitReadMessageIds: [...new Set([...thread.explicitReadMessageIds, ...previous.explicitReadMessageIds])] });
99
101
  }
100
102
  return resolveObservedReads({ ...snapshot, threads: [...byId.values()], pending, observedReadIntents: current.observedReadIntents ?? [] });
@@ -110,7 +112,7 @@ export function applyReadChange(current, change) {
110
112
  const incoming = cloneThread(change.thread);
111
113
  const pending = current.pending.filter(operation => operation.threadId !== incoming.id || !covered(incoming, operation));
112
114
  const previous = current.threads.find(thread => thread.id === incoming.id);
113
- const merged = previous ? { ...incoming, ...mergeDiscussionCursors(incoming, previous), readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
115
+ const merged = previous ? { ...incoming, ...mergeDiscussionCursors(incoming, previous), preservedReadMessageIds: [...new Set([...(incoming.preservedReadMessageIds ?? []), ...(previous.preservedReadMessageIds ?? [])])], readThroughSeq: Math.max(incoming.readThroughSeq, previous.readThroughSeq),
114
116
  explicitReadMessageIds: [...new Set([...incoming.explicitReadMessageIds, ...previous.explicitReadMessageIds])] } : incoming;
115
117
  const projected = pending.filter(operation => operation.threadId === incoming.id).reduce(applyOperation, merged);
116
118
  return resolveObservedReads({ ...current, syncCursor: change.syncCursor, pending, threads: [...current.threads.filter(t => t.id !== incoming.id), projected] });
@@ -154,7 +156,7 @@ export function recordVisibleRead(current, threadId, messageId, operationId) {
154
156
  const message = thread?.messages.find(m => m.id === messageId);
155
157
  if (!thread || !message || message.readPolicy !== "visible")
156
158
  throw new Error("INVALID_VISIBLE_READ");
157
- if (message.seq <= readingCursor(thread, message.threadRootId))
159
+ if (thread.preservedReadMessageIds?.includes(message.id) || message.seq <= readingCursor(thread, message.threadRootId))
158
160
  return current;
159
161
  return enqueue(current, { operationId, threadId: thread.id, ...(message.threadRootId == null ? {} : { threadRootId: message.threadRootId }), readThroughSeq: message.seq, readThroughMessageId: message.id, explicitReadMessageIds: [] });
160
162
  }
@@ -198,7 +200,7 @@ export function unreadInThread(state, threadId, hidden, threadRootId) {
198
200
  throw new Error("UNKNOWN_READ_THREAD");
199
201
  const explicit = new Set(thread.explicitReadMessageIds);
200
202
  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)));
203
+ && !thread.preservedReadMessageIds?.includes(message.id) && (message.readPolicy === "explicit" ? !explicit.has(message.id) : message.seq > readingCursor(thread, message.threadRootId)));
202
204
  const unreadMessageCount = messages.length;
203
205
  const unreadMentionCount = messages.filter(message => message.mentions.includes(state.profileId)).length;
204
206
  return { unreadMessageCount, unreadMentionCount, badgeCount: thread.kind === "group" ? unreadMentionCount : unreadMessageCount };
@@ -14,6 +14,7 @@ export type SocialMessagePage = {
14
14
  resetRequired: boolean;
15
15
  };
16
16
  export type MessageThreadSummary = {
17
+ ownerAgentId?: string;
17
18
  rootMessageId: string;
18
19
  replyCount: number;
19
20
  unreadCount: number;
@@ -59,7 +59,7 @@ export class SocialReadClient {
59
59
  const value = await this.request("message-threads", { threadId: input.threadId, ...this.teamQuery(input.teamId), rootMessageIds: JSON.stringify(input.rootMessageIds) }, signal);
60
60
  const roots = new Set(input.rootMessageIds), seen = new Set();
61
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);
62
+ const invalid = item.ownerAgentId !== undefined && (typeof item.ownerAgentId !== "string" || !/^0x[a-f0-9]{64}$/.test(item.ownerAgentId)) || !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
63
  seen.add(item.rootMessageId);
64
64
  return invalid;
65
65
  }))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.84",
3
+ "version": "0.12.0-next.86",
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",
@@ -58,6 +58,15 @@
58
58
  "publishConfig": {
59
59
  "access": "public"
60
60
  },
61
+ "scripts": {
62
+ "clean": "rimraf lib",
63
+ "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
64
+ "prepack": "pnpm run build",
65
+ "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
66
+ "test": "vitest run",
67
+ "storybook": "storybook dev -p 6009",
68
+ "build-storybook": "storybook build --output-dir dist"
69
+ },
61
70
  "dependencies": {
62
71
  "@emoji-mart/data": "^1.2.1",
63
72
  "@emoji-mart/react": "^1.1.1",
@@ -73,24 +82,16 @@
73
82
  "devDependencies": {
74
83
  "@storybook/addon-docs": "^9.0.17",
75
84
  "@storybook/react-vite": "9.0.17",
76
- "@tailwindcss/vite": "^4.1.3",
77
- "@types/react": "^19.2.7",
78
- "@types/react-dom": "^19.2.3",
85
+ "@tailwindcss/vite": "catalog:",
86
+ "@types/react": "catalog:",
87
+ "@types/react-dom": "catalog:",
79
88
  "fake-indexeddb": "^6.2.5",
80
- "react": "^19.1.1",
81
- "react-dom": "^19.1.1",
89
+ "react": "catalog:",
90
+ "react-dom": "catalog:",
82
91
  "rimraf": "^6.1.2",
83
92
  "storybook": "^9.0.17",
84
- "tailwindcss": "^4.0.14",
93
+ "tailwindcss": "catalog:",
85
94
  "typescript": "5.9.2",
86
95
  "vitest": "^4.0.17"
87
- },
88
- "scripts": {
89
- "clean": "rimraf lib",
90
- "build": "pnpm run clean && tsc -p tsconfig.build.json && cp src/voice/recorder-processor.js lib/voice/recorder-processor.js",
91
- "npm:publish": "pnpm run build && npm publish --access public --registry https://registry.npmjs.org/",
92
- "test": "vitest run",
93
- "storybook": "storybook dev -p 6009",
94
- "build-storybook": "storybook build --output-dir dist"
95
96
  }
96
- }
97
+ }