@pmate/chat-sdk 0.12.0-next.8 → 0.12.0-next.80

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 (111) hide show
  1. package/README.md +170 -83
  2. package/lib/advanced.d.ts +10 -0
  3. package/lib/advanced.js +10 -0
  4. package/lib/chat.d.ts +5 -0
  5. package/lib/chat.js +11 -0
  6. package/lib/chatContract.d.ts +151 -0
  7. package/lib/chatContract.js +1 -0
  8. package/lib/chatError.d.ts +14 -0
  9. package/lib/chatError.js +16 -0
  10. package/lib/chatTypes.d.ts +127 -0
  11. package/lib/chatTypes.js +1 -0
  12. package/lib/commands.d.ts +20 -0
  13. package/lib/commands.js +12 -0
  14. package/lib/components/ChatInputDesktop.js +5 -4
  15. package/lib/components/ChatInputMobile.js +5 -4
  16. package/lib/components/CommandMenu.d.ts +15 -0
  17. package/lib/components/CommandMenu.js +10 -0
  18. package/lib/credentials.d.ts +26 -0
  19. package/lib/credentials.js +110 -0
  20. package/lib/fileMetadata.d.ts +9 -0
  21. package/lib/fileMetadata.js +18 -0
  22. package/lib/framework.d.ts +11 -0
  23. package/lib/framework.js +12 -0
  24. package/lib/history.d.ts +4 -0
  25. package/lib/history.js +8 -0
  26. package/lib/hooks/useChatSnapshot.d.ts +4 -0
  27. package/lib/hooks/useChatSnapshot.js +9 -0
  28. package/lib/hooks/useComposerSuggestions.d.ts +50 -0
  29. package/lib/hooks/useComposerSuggestions.js +70 -0
  30. package/lib/index.d.ts +5 -0
  31. package/lib/index.js +5 -0
  32. package/lib/internal/chatEngine.d.ts +130 -0
  33. package/lib/internal/chatEngine.js +284 -0
  34. package/lib/internal/chatRegistry.d.ts +4 -0
  35. package/lib/internal/chatRegistry.js +4 -0
  36. package/lib/internal/createPublicChat.d.ts +4 -0
  37. package/lib/internal/createPublicChat.js +52 -0
  38. package/lib/messageView.d.ts +6 -0
  39. package/lib/messageView.js +103 -0
  40. package/lib/runtime/batchedReadIndex.d.ts +16 -3
  41. package/lib/runtime/batchedReadIndex.js +50 -4
  42. package/lib/runtime/cachedMessage.d.ts +12 -0
  43. package/lib/runtime/cachedMessage.js +18 -0
  44. package/lib/runtime/chatClient.d.ts +7 -0
  45. package/lib/runtime/chatClient.js +14 -3
  46. package/lib/runtime/chatError.d.ts +5 -0
  47. package/lib/runtime/chatError.js +20 -0
  48. package/lib/runtime/chatSession.d.ts +128 -13
  49. package/lib/runtime/chatSession.js +759 -48
  50. package/lib/runtime/connectivity.d.ts +4 -0
  51. package/lib/runtime/connectivity.js +8 -0
  52. package/lib/runtime/conversationDirectory.d.ts +37 -0
  53. package/lib/runtime/conversationDirectory.js +116 -0
  54. package/lib/runtime/conversationRead.d.ts +13 -0
  55. package/lib/runtime/conversationRead.js +34 -0
  56. package/lib/runtime/conversationReading.d.ts +28 -0
  57. package/lib/runtime/conversationReading.js +47 -0
  58. package/lib/runtime/createChat.d.ts +11 -7
  59. package/lib/runtime/createChat.js +46 -23
  60. package/lib/runtime/durableSender.d.ts +97 -14
  61. package/lib/runtime/durableSender.js +786 -71
  62. package/lib/runtime/groupClient.d.ts +91 -0
  63. package/lib/runtime/groupClient.js +380 -0
  64. package/lib/runtime/groupDenial.d.ts +2 -0
  65. package/lib/runtime/groupDenial.js +4 -0
  66. package/lib/runtime/index.d.ts +7 -0
  67. package/lib/runtime/index.js +5 -0
  68. package/lib/runtime/indexedDbRecovery.d.ts +4 -0
  69. package/lib/runtime/indexedDbRecovery.js +27 -0
  70. package/lib/runtime/messageClient.d.ts +35 -8
  71. package/lib/runtime/messageClient.js +364 -66
  72. package/lib/runtime/messageControls.d.ts +19 -0
  73. package/lib/runtime/messageControls.js +44 -0
  74. package/lib/runtime/messageLaunch.d.ts +44 -0
  75. package/lib/runtime/messageLaunch.js +137 -0
  76. package/lib/runtime/messageQuotes.d.ts +3 -0
  77. package/lib/runtime/messageQuotes.js +17 -0
  78. package/lib/runtime/messageReaders.d.ts +5 -1
  79. package/lib/runtime/messageReaders.js +16 -5
  80. package/lib/runtime/messageStore.d.ts +43 -2
  81. package/lib/runtime/messageStore.js +248 -14
  82. package/lib/runtime/messageTimeline.d.ts +50 -0
  83. package/lib/runtime/messageTimeline.js +120 -0
  84. package/lib/runtime/outboxSchema.d.ts +5 -0
  85. package/lib/runtime/outboxSchema.js +59 -0
  86. package/lib/runtime/outgoingContent.d.ts +94 -0
  87. package/lib/runtime/outgoingContent.js +118 -0
  88. package/lib/runtime/readBatchJournal.d.ts +35 -0
  89. package/lib/runtime/readBatchJournal.js +210 -0
  90. package/lib/runtime/readCounts.d.ts +15 -1
  91. package/lib/runtime/readCounts.js +97 -9
  92. package/lib/runtime/readIndex.d.ts +68 -7
  93. package/lib/runtime/readIndex.js +460 -22
  94. package/lib/runtime/readSchema.d.ts +2 -0
  95. package/lib/runtime/readSchema.js +58 -0
  96. package/lib/runtime/readSync.d.ts +1 -0
  97. package/lib/runtime/readSync.js +26 -9
  98. package/lib/runtime/readingClient.d.ts +19 -0
  99. package/lib/runtime/readingClient.js +131 -19
  100. package/lib/runtime/readingContent.d.ts +5 -0
  101. package/lib/runtime/readingContent.js +23 -0
  102. package/lib/runtime/responseCache.d.ts +40 -5
  103. package/lib/runtime/responseCache.js +182 -44
  104. package/lib/runtime/socialReadClient.d.ts +9 -4
  105. package/lib/runtime/socialReadClient.js +12 -8
  106. package/lib/runtime/voiceUpload.d.ts +2 -0
  107. package/lib/runtime/voiceUpload.js +27 -0
  108. package/lib/snapshot.d.ts +36 -0
  109. package/lib/snapshot.js +50 -0
  110. package/lib/types/chatInput.d.ts +3 -0
  111. package/package.json +32 -2
@@ -1,52 +1,146 @@
1
+ import { recoverableTransactions } from "./indexedDbRecovery.js";
1
2
  import { Dexie } from "dexie";
2
3
  import { WindowedTasks } from "./coalescer.js";
4
+ export function isResponseCacheAuthorityError(error) {
5
+ return /RESPONSE_CACHE_(ACCESS_REVOKED|SCOPE_CHANGED|INVALIDATED)/.test(String(error));
6
+ }
7
+ export async function assertResponseCacheAccess(access, expectedVersion) {
8
+ if (!access)
9
+ return undefined;
10
+ const current = await access();
11
+ if (!current.allowed)
12
+ throw new Error("RESPONSE_CACHE_ACCESS_REVOKED");
13
+ if (expectedVersion !== undefined && current.version !== expectedVersion)
14
+ throw new Error("RESPONSE_CACHE_SCOPE_CHANGED");
15
+ return current.version;
16
+ }
3
17
  /** Small response windows, isolated by identity. Epochs fence late writes after
4
18
  * access withdrawal. No localStorage or synchronous writes on the read path. */
5
19
  export class IndexedDbResponseCache {
6
20
  scope;
21
+ options;
22
+ transact;
7
23
  db;
8
24
  rows;
9
25
  epochs;
10
26
  pending = [];
11
27
  tasks = new WindowedTasks(16, () => { });
12
28
  running;
29
+ invalidations = new Set();
13
30
  closed = false;
31
+ closing;
14
32
  constructor(scope, options = {}) {
15
33
  this.scope = scope;
34
+ this.options = options;
35
+ for (const [value, max] of [[options.recordBytes ?? 65536, 1048576], [options.entries ?? 256, 256], [options.batchRecords ?? 128, 128]])
36
+ if (!Number.isSafeInteger(value) || value < 1 || value > max)
37
+ throw new Error("INVALID_RESPONSE_CACHE_LIMIT");
16
38
  this.db = new Dexie(options.name ?? "pmate-chat-response-windows", { indexedDB: options.indexedDB ?? globalThis.indexedDB, IDBKeyRange: globalThis.IDBKeyRange });
39
+ this.transact = recoverableTransactions(this.db, () => this.closed);
17
40
  this.db.version(1).stores({ windows: "[scope+key],scope", epochs: "scope" });
18
41
  this.rows = this.db.table("windows");
19
42
  this.epochs = this.db.table("epochs");
20
43
  }
44
+ /** Resolve shared message-scope authority before opening our transaction.
45
+ * A newer authority version retires response windows exactly once; delayed
46
+ * callers can never roll the authority backwards or delete recovered data. */
47
+ async synchronizeAccess() {
48
+ if (!this.options.access)
49
+ return;
50
+ const access = await this.options.access();
51
+ const stored = await this.transact("r", this.epochs, () => this.epochs.get(this.scope));
52
+ const storedVersion = stored?.accessVersion ?? 0;
53
+ if (access.version < storedVersion)
54
+ throw new Error("RESPONSE_CACHE_SCOPE_CHANGED");
55
+ // Ordinary cache reads must not take an IndexedDB write lock. Only an
56
+ // authority transition needs the atomic epoch bump and range deletion.
57
+ if (access.version > storedVersion)
58
+ await this.transact("rw", this.rows, this.epochs, async () => {
59
+ const current = await this.epochs.get(this.scope);
60
+ const version = current?.accessVersion ?? 0;
61
+ if (access.version < version)
62
+ throw new Error("RESPONSE_CACHE_SCOPE_CHANGED");
63
+ if (access.version > version) {
64
+ await this.epochs.put({ scope: this.scope, epoch: (current?.epoch ?? 0) + 1, accessVersion: access.version });
65
+ await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
66
+ }
67
+ });
68
+ if (!access.allowed)
69
+ throw new Error("RESPONSE_CACHE_ACCESS_REVOKED");
70
+ }
21
71
  async read(key) {
22
72
  if (this.closed)
23
73
  throw new Error("RESPONSE_CACHE_CLOSED");
24
- return this.db.transaction("r", this.rows, this.epochs, async () => {
74
+ await this.synchronizeAccess();
75
+ return this.transact("r", this.rows, this.epochs, async () => {
25
76
  const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
26
77
  const row = await this.rows.get([this.scope, key]);
27
78
  return { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined };
28
79
  });
29
80
  }
30
- write(key, value, epoch) {
81
+ /** Key-only callers can share one bounded metadata read transaction. */
82
+ async readMany(keys) {
83
+ if (this.closed)
84
+ throw new Error("RESPONSE_CACHE_CLOSED");
85
+ if (keys.length > 128)
86
+ throw new Error("RESPONSE_CACHE_SELECTION_TOO_LARGE");
87
+ await this.synchronizeAccess();
88
+ return this.transact("r", this.rows, this.epochs, async () => {
89
+ const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0, unique = [...new Set(keys)];
90
+ const rows = await this.rows.bulkGet(unique.map(key => [this.scope, key]));
91
+ return new Map(unique.map((key, index) => { const row = rows[index]; return [key, { epoch, cached: row?.epoch === epoch ? { value: row.value, time: row.time } : undefined }]; }));
92
+ });
93
+ }
94
+ write(key, value, epoch, expectedRevision) {
31
95
  if (this.closed)
32
96
  return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
97
+ if (expectedRevision !== undefined && !this.options.revision)
98
+ return Promise.reject(new Error("RESPONSE_CACHE_REVISION_UNAVAILABLE"));
33
99
  const copy = structuredClone(value), bytes = new TextEncoder().encode(JSON.stringify(copy)).byteLength;
34
100
  // Count in-flight data as well as queued data; requests cannot create an
35
101
  // unbounded backlog while IndexedDB is blocked by another tab.
36
- if (bytes > 65536 || this.pending.length >= 128 || this.pending.reduce((n, w) => n + w.row.bytes, 0) + bytes > 1048576)
102
+ if (bytes > (this.options.recordBytes ?? 65536) || this.pending.length >= 128 || this.pending.reduce((n, w) => n + w.row.bytes, 0) + bytes > 1048576)
37
103
  return Promise.reject(new Error("RESPONSE_CACHE_BACKPRESSURE"));
38
- const promise = new Promise((resolve, reject) => this.pending.push({ row: { scope: this.scope, key, epoch, time: Date.now(), bytes, value: copy }, resolve, reject }));
104
+ const promise = new Promise((resolve, reject) => this.pending.push({ row: { scope: this.scope, key, epoch, time: Date.now(), bytes, value: copy }, expectedRevision, resolve, reject }));
39
105
  this.tasks.schedule("write", () => this.flush());
40
106
  return promise;
41
107
  }
42
- async invalidate() {
108
+ /** Forget one response without invalidating unrelated cached conversations. */
109
+ async remove(key) {
110
+ await this.flush();
111
+ await this.synchronizeAccess();
112
+ await this.transact("rw", this.rows, () => this.rows.delete([this.scope, key]));
113
+ }
114
+ /** Idempotent shared-scope retirement: an old cleanup cannot erase windows
115
+ * created after the same authority version was already recovered. */
116
+ retire(version) {
43
117
  if (this.closed)
44
- throw new Error("RESPONSE_CACHE_CLOSED");
45
- await this.db.transaction("rw", this.rows, this.epochs, async () => {
46
- const epoch = ((await this.epochs.get(this.scope))?.epoch ?? 0) + 1;
47
- await this.epochs.put({ scope: this.scope, epoch });
48
- await this.rows.where("scope").equals(this.scope).delete();
118
+ return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
119
+ if (!Number.isSafeInteger(version) || version < 1)
120
+ return Promise.reject(new Error("INVALID_RESPONSE_SCOPE_VERSION"));
121
+ const task = this.transact("rw", this.rows, this.epochs, async () => {
122
+ const current = await this.epochs.get(this.scope);
123
+ if ((current?.accessVersion ?? 0) >= version)
124
+ return;
125
+ await this.epochs.put({ scope: this.scope, epoch: (current?.epoch ?? 0) + 1, accessVersion: version });
126
+ await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
49
127
  });
128
+ this.invalidations.add(task);
129
+ return task.finally(() => this.invalidations.delete(task));
130
+ }
131
+ invalidate() {
132
+ if (this.closed)
133
+ return Promise.reject(new Error("RESPONSE_CACHE_CLOSED"));
134
+ const task = (async () => {
135
+ await this.transact("rw", this.rows, this.epochs, async () => {
136
+ const current = await this.epochs.get(this.scope);
137
+ const epoch = (current?.epoch ?? 0) + 1;
138
+ await this.epochs.put({ scope: this.scope, epoch, accessVersion: current?.accessVersion });
139
+ await this.rows.where("[scope+key]").between([this.scope, Dexie.minKey], [this.scope, Dexie.maxKey], true, true).delete();
140
+ });
141
+ })();
142
+ this.invalidations.add(task);
143
+ return task.finally(() => this.invalidations.delete(task));
50
144
  }
51
145
  async flush() {
52
146
  if (this.running) {
@@ -55,49 +149,93 @@ export class IndexedDbResponseCache {
55
149
  return this.flush();
56
150
  return;
57
151
  }
58
- const batch = [...this.pending];
59
- if (!batch.length)
152
+ if (!this.pending.length)
60
153
  return;
61
154
  const task = (async () => {
62
- try {
63
- const accepted = await this.db.transaction("rw", this.rows, this.epochs, async () => {
64
- const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
65
- const valid = batch.filter(write => write.row.epoch === epoch);
66
- const unique = new Map(valid.map(write => [write.row.key, write.row]));
67
- await this.rows.bulkPut([...unique.values()]);
68
- const stored = await this.rows.where("scope").equals(this.scope).toArray();
69
- stored.sort((a, b) => b.time - a.time || a.key.localeCompare(b.key));
70
- let bytes = 0;
71
- const evicted = [];
72
- stored.forEach((row, index) => { bytes += row.bytes; if (index >= 256 || bytes > 1048576)
73
- evicted.push([this.scope, row.key]); });
74
- await this.rows.bulkDelete(evicted);
75
- return new Set(valid);
76
- });
77
- batch.forEach(write => accepted.has(write) ? write.resolve() : write.reject(new Error("RESPONSE_CACHE_INVALIDATED")));
78
- }
79
- catch (error) {
80
- batch.forEach(write => write.reject(error));
81
- throw error;
82
- }
83
- finally {
84
- this.pending.splice(0, batch.length);
155
+ let failed = false, failure;
156
+ // Each batch commits and resolves independently. A configured smaller
157
+ // batch still drains all accepted requests, including during close.
158
+ while (this.pending.length) {
159
+ const batch = this.pending.slice(0, this.options.batchRecords ?? 128);
160
+ try {
161
+ await this.synchronizeAccess();
162
+ const outcome = await this.transact("rw", this.rows, this.epochs, async () => {
163
+ const epoch = (await this.epochs.get(this.scope))?.epoch ?? 0;
164
+ const valid = batch.filter(write => write.row.epoch === epoch);
165
+ const conditional = valid.filter(write => write.expectedRevision !== undefined);
166
+ const keys = [...new Set(conditional.map(write => write.row.key))];
167
+ const current = conditional.length ? await this.rows.bulkGet(keys.map(key => [this.scope, key])) : [];
168
+ const projected = new Map(keys.map((key, index) => [key, current[index]?.epoch === epoch ? current[index] : undefined]));
169
+ const accepted = new Set(), changed = new Set(), unique = new Map();
170
+ for (const write of valid) {
171
+ if (write.expectedRevision !== undefined) {
172
+ const previous = projected.get(write.row.key);
173
+ const revision = previous ? this.options.revision(previous.value) : null;
174
+ if (revision !== write.expectedRevision) {
175
+ // Another document may have already committed this exact
176
+ // revision; it is an idempotent success, never an overwrite.
177
+ if (revision === this.options.revision(write.row.value) && JSON.stringify(previous.value) === JSON.stringify(write.row.value))
178
+ accepted.add(write);
179
+ else
180
+ changed.add(write);
181
+ continue;
182
+ }
183
+ }
184
+ unique.set(write.row.key, write.row);
185
+ projected.set(write.row.key, write.row);
186
+ accepted.add(write);
187
+ }
188
+ if (unique.size) {
189
+ await this.rows.bulkPut([...unique.values()]);
190
+ if (!this.options.retainAll) {
191
+ const stored = await this.rows.where("scope").equals(this.scope).toArray();
192
+ stored.sort((a, b) => b.time - a.time || a.key.localeCompare(b.key));
193
+ let bytes = 0;
194
+ const evicted = [];
195
+ stored.forEach((row, index) => { bytes += row.bytes; if (index >= (this.options.entries ?? 256) || bytes > 1048576)
196
+ evicted.push([this.scope, row.key]); });
197
+ if (evicted.length)
198
+ await this.rows.bulkDelete(evicted);
199
+ }
200
+ }
201
+ return { accepted, changed };
202
+ });
203
+ for (const write of batch) {
204
+ if (outcome.accepted.has(write))
205
+ write.resolve();
206
+ else
207
+ write.reject(new Error(outcome.changed.has(write) ? "RESPONSE_CACHE_CHANGED" : "RESPONSE_CACHE_INVALIDATED"));
208
+ }
209
+ }
210
+ catch (error) {
211
+ batch.forEach(write => write.reject(error));
212
+ if (!failed)
213
+ failure = error;
214
+ failed = true;
215
+ }
216
+ finally {
217
+ this.pending.splice(0, batch.length);
218
+ }
85
219
  }
220
+ if (failed)
221
+ throw failure;
86
222
  })().finally(() => { if (this.running === task)
87
223
  this.running = undefined; });
88
224
  this.running = task;
89
225
  return task;
90
226
  }
91
- async close() {
92
- if (this.closed)
93
- return;
227
+ close() {
228
+ if (this.closing)
229
+ return this.closing;
94
230
  this.closed = true;
95
231
  this.tasks.close();
96
- try {
97
- await this.flush();
98
- }
99
- finally {
100
- this.db.close();
101
- }
232
+ return this.closing = (async () => {
233
+ try {
234
+ await Promise.all([this.flush(), ...this.invalidations]);
235
+ }
236
+ finally {
237
+ this.db.close();
238
+ }
239
+ })();
102
240
  }
103
241
  }
@@ -18,10 +18,14 @@ export type SocialGroup = {
18
18
  groupId: string;
19
19
  teamId?: string | null;
20
20
  ownerId: string;
21
- members: string[];
22
- title: string;
21
+ members?: readonly string[];
22
+ title?: string;
23
23
  avatar?: string | null;
24
24
  revision: string;
25
+ versionNumber?: number;
26
+ status?: "active" | "dissolved";
27
+ dissolvedBy?: string;
28
+ dissolvedAt?: number;
25
29
  };
26
30
  export type SocialRelationship = {
27
31
  appId: string;
@@ -45,7 +49,7 @@ export declare class SocialReadClient {
45
49
  private readonly options;
46
50
  constructor(options: SocialReadClientOptions);
47
51
  /** Without teamId includes current Teams and unassigned contact metadata for migration. */
48
- contacts(teamId?: string): Promise<ChatReaderContact[]>;
52
+ contacts(teamId?: string, signal?: AbortSignal): Promise<ChatReaderContact[]>;
49
53
  unread(teamId?: string): Promise<ChatReaderUnread[]>;
50
54
  messages(input: {
51
55
  threadId: string;
@@ -57,11 +61,12 @@ export declare class SocialReadClient {
57
61
  limit?: number;
58
62
  before?: string;
59
63
  after?: string;
64
+ messageId?: string;
60
65
  }, signal?: AbortSignal): Promise<SocialMessagePage>;
61
66
  group(input: {
62
67
  groupId: string;
63
68
  teamId?: string;
64
- }): Promise<SocialGroup>;
69
+ }, signal?: AbortSignal): Promise<SocialGroup>;
65
70
  relationship(input: {
66
71
  peerId: string;
67
72
  teamId?: string;
@@ -14,8 +14,8 @@ export class SocialReadClient {
14
14
  options;
15
15
  constructor(options) { this.options = { ...options }; }
16
16
  /** Without teamId includes current Teams and unassigned contact metadata for migration. */
17
- contacts(teamId) {
18
- return this.list("contacts", this.teamQuery(teamId));
17
+ contacts(teamId, signal) {
18
+ return this.list("contacts", this.teamQuery(teamId), signal);
19
19
  }
20
20
  unread(teamId) {
21
21
  return this.list("unread", this.teamQuery(teamId));
@@ -26,22 +26,26 @@ export class SocialReadClient {
26
26
  }
27
27
  async messagePage(input, signal) {
28
28
  this.requireTeam(input.teamId);
29
- const limit = input.limit ?? 100;
29
+ const limit = input.limit ?? (input.messageId === undefined ? 100 : 1);
30
30
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 500 || (input.before !== undefined && input.after !== undefined))
31
31
  throw new SocialReadError(400, "INVALID_MESSAGE_PAGE");
32
+ 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
+ throw new SocialReadError(400, "INVALID_MESSAGE_LOOKUP");
32
34
  const value = await this.request("messages", { threadId: input.threadId, ...this.teamQuery(input.teamId), limit: String(limit),
33
- ...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }) }, signal);
35
+ ...(input.before === undefined ? {} : { before: input.before }), ...(input.after === undefined ? {} : { after: input.after }), ...(input.messageId === undefined ? {} : { messageId: input.messageId }) }, signal);
34
36
  const cursor = (token) => typeof token === "string" && /^mp1:\d+:[0-9a-f]{64}$/.test(token);
35
37
  if (!value || !Array.isArray(value.messages) || value.messages.length > limit || !cursor(value.checkpoint)
36
38
  || (value.nextBefore !== null && !cursor(value.nextBefore)) || typeof value.hasMoreChanges !== "boolean" || typeof value.resetRequired !== "boolean"
37
39
  || value.messages.some(row => row.appId !== this.options.appId || row.threadId !== input.threadId || row.teamId !== input.teamId || !row.messageId || !Number.isFinite(row.serverCreatedAt))
38
40
  || (value.resetRequired && (value.messages.length || value.hasMoreChanges || value.nextBefore !== null)))
39
41
  throw new SocialReadError(502, "INVALID_MESSAGE_PAGE_RESPONSE");
42
+ if (input.messageId !== undefined && (value.messages.some(row => row.messageId !== input.messageId) || value.nextBefore !== null || value.hasMoreChanges || value.resetRequired))
43
+ throw new SocialReadError(502, "INVALID_MESSAGE_LOOKUP_RESPONSE");
40
44
  return value;
41
45
  }
42
- group(input) {
46
+ group(input, signal) {
43
47
  this.requireTeam(input.teamId);
44
- return this.request("group", { groupId: input.groupId, ...this.teamQuery(input.teamId) });
48
+ return this.request("group", { groupId: input.groupId, ...this.teamQuery(input.teamId) }, signal);
45
49
  }
46
50
  relationship(input) {
47
51
  this.requireTeam(input.teamId);
@@ -54,8 +58,8 @@ export class SocialReadClient {
54
58
  if (this.options.appId === "pmate:merry" && !teamId)
55
59
  throw new SocialReadError(400, "TEAM_ID_REQUIRED");
56
60
  }
57
- async list(action, query) {
58
- const value = await this.request(action, query);
61
+ async list(action, query, signal) {
62
+ const value = await this.request(action, query, signal);
59
63
  if (!Array.isArray(value))
60
64
  throw new SocialReadError(502, "INVALID_SOCIAL_READ_RESPONSE");
61
65
  return value;
@@ -0,0 +1,2 @@
1
+ /** The SDK owns upload I/O; hosts configure only their service endpoint. */
2
+ export declare function voiceUploader(profileId: string, endpoint?: string, fetcher?: typeof fetch): (blob: Blob, signal: AbortSignal) => Promise<string>;
@@ -0,0 +1,27 @@
1
+ import { isOffline } from "./connectivity.js";
2
+ /** The SDK owns upload I/O; hosts configure only their service endpoint. */
3
+ export function voiceUploader(profileId, endpoint = "https://upload-fc.pmate.chat", fetcher = globalThis.fetch) {
4
+ return async (blob, signal) => {
5
+ if (signal.aborted)
6
+ throw new DOMException("Upload aborted", "AbortError");
7
+ if (isOffline())
8
+ throw new Error("VOICE_UPLOAD_OFFLINE");
9
+ const bytes = new Uint8Array(await blob.arrayBuffer());
10
+ if (signal.aborted)
11
+ throw new DOMException("Upload aborted", "AbortError");
12
+ let binary = "";
13
+ for (let offset = 0; offset < bytes.length; offset += 32768)
14
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
15
+ const extension = blob.type.includes("mp4") ? "m4a" : blob.type.includes("mpeg") ? "mp3" : "webm";
16
+ const type = extension === "m4a" ? "audio/mp4" : extension === "mp3" ? "audio/mpeg" : "audio/webm";
17
+ if (isOffline())
18
+ throw new Error("VOICE_UPLOAD_OFFLINE");
19
+ const response = await fetcher(`${endpoint.replace(/\/+$/, "")}/file`, { method: "POST", signal, headers: { "Content-Type": "application/json", "x-file-content-type": type }, body: JSON.stringify({ user: profileId, base64: btoa(binary), filename: `voice-${Date.now()}.${extension}` }) });
20
+ if (!response.ok)
21
+ throw new Error("VOICE_UPLOAD_FAILED");
22
+ const result = await response.json();
23
+ if (typeof result.data !== "string" || !result.data)
24
+ throw new Error("VOICE_UPLOAD_FAILED");
25
+ return result.data;
26
+ };
27
+ }
@@ -0,0 +1,36 @@
1
+ import { type SocialGroup } from "./runtime/socialReadClient.js";
2
+ import type { ChatMessage, UnreadState } from "./chatTypes.js";
3
+ export type ConversationSnapshotOptions = {
4
+ appId: string;
5
+ accountId: string;
6
+ profileId: string;
7
+ teamId: string;
8
+ threadId: string;
9
+ token: string;
10
+ hubHttpUrl?: string;
11
+ limit?: number;
12
+ fetch?: typeof fetch;
13
+ signal?: AbortSignal;
14
+ };
15
+ export type ConversationSnapshot = Readonly<{
16
+ appId: string;
17
+ accountId: string;
18
+ profileId: string;
19
+ teamId: string;
20
+ threadId: string;
21
+ generatedAt: number;
22
+ contact: Readonly<{
23
+ peerId: string;
24
+ type: "group" | "user";
25
+ }>;
26
+ group: SocialGroup | null;
27
+ messages: readonly ChatMessage[];
28
+ hasEarlier: boolean;
29
+ unread: UnreadState;
30
+ checkpoint: string;
31
+ readingCursor: string;
32
+ serverTiming: string | null;
33
+ }>;
34
+ /** One network first-screen read. This does not open a socket, write a body cache,
35
+ * install a partial reading index, or mark anything read. Live SDK state takes over later. */
36
+ export declare function fetchConversationSnapshot(options: ConversationSnapshotOptions): Promise<ConversationSnapshot>;
@@ -0,0 +1,50 @@
1
+ import { SocialReadError } from "./runtime/socialReadClient.js";
2
+ import { projectConversationReading } from "./runtime/conversationReading.js";
3
+ import { projectMessageTimeline } from "./runtime/messageTimeline.js";
4
+ import { createMessagePresenter } from "./messageView.js";
5
+ const invalid = () => new SocialReadError(502, "INVALID_THREAD_SNAPSHOT_RESPONSE");
6
+ /** One network first-screen read. This does not open a socket, write a body cache,
7
+ * install a partial reading index, or mark anything read. Live SDK state takes over later. */
8
+ export async function fetchConversationSnapshot(options) {
9
+ const limit = options.limit ?? 30;
10
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100)
11
+ throw new SocialReadError(400, "INVALID_THREAD_SNAPSHOT_LIMIT");
12
+ const query = new URLSearchParams({ appId: options.appId, profileId: options.profileId, teamId: options.teamId, threadId: options.threadId, limit: String(limit) });
13
+ const response = await (options.fetch ?? fetch)(`${(options.hubHttpUrl ?? "https://hub.pmate.chat").replace(/\/+$/, "")}/api/social/thread-snapshot?${query}`, {
14
+ headers: { authorization: `Bearer ${options.token}`, accept: "application/json" }, cache: "no-store", redirect: "error",
15
+ signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000),
16
+ });
17
+ const envelope = await response.json().catch(() => null);
18
+ if (!response.ok || envelope?.success !== true)
19
+ throw new SocialReadError(response.status, envelope?.message ?? "THREAD_SNAPSHOT_FAILED");
20
+ const value = envelope.data;
21
+ if (!value || value.schemaVersion !== 1 || ["appId", "accountId", "profileId", "teamId", "threadId"].some(key => value[key] !== options[key]) || !Number.isFinite(value.generatedAt))
22
+ throw invalid();
23
+ const cursor = (value) => typeof value === "string" && /^mp1:\d+:[0-9a-f]{64}$/.test(value);
24
+ const page = value.page, reading = value.reading;
25
+ if (!page || !Array.isArray(page.messages) || page.messages.length > limit || !cursor(page.checkpoint) || (page.nextBefore !== null && !cursor(page.nextBefore)) || typeof page.hasMoreChanges !== "boolean" || page.hasMoreChanges || page.resetRequired !== false)
26
+ throw invalid();
27
+ if (page.messages.some(row => row.appId !== options.appId || row.teamId !== options.teamId || row.threadId !== options.threadId || !row.messageId || !Number.isFinite(row.serverCreatedAt)))
28
+ throw invalid();
29
+ if (!value.contact || !["group", "user"].includes(value.contact.type) || !value.contact.peerId)
30
+ throw invalid();
31
+ if (value.contact.type === "group" && (!value.group || value.group.appId !== options.appId || value.group.teamId !== options.teamId || value.group.groupId !== value.contact.peerId || !["active", "dissolved"].includes(value.group.status ?? "")))
32
+ throw invalid();
33
+ if (!reading || reading.schemaVersion !== 1 || reading.appId !== options.appId || reading.profileId !== options.profileId || reading.scope !== "thread-window" || typeof reading.syncCursor !== "string" || !Array.isArray(reading.threads) || reading.threads.length > 1 || reading.threads.some(thread => thread.id !== options.threadId || thread.teamId !== options.teamId || !Array.isArray(thread.messages) || !Array.isArray(thread.explicitReadMessageIds) || !Number.isSafeInteger(thread.readThroughSeq)))
34
+ throw invalid();
35
+ const summary = reading.summary;
36
+ if (!summary || [summary.unreadMessageCount, summary.unreadMentionCount, summary.badgeCount].some(count => !Number.isSafeInteger(count) || count < 0))
37
+ throw invalid();
38
+ const ids = new Set(page.messages.map(row => row.messageId));
39
+ if (reading.threads.some(thread => thread.messages.some(fact => !ids.has(fact.id) || !Number.isSafeInteger(fact.seq) || !Array.isArray(fact.recipients) || !Array.isArray(fact.mentions) || !["visible", "explicit"].includes(fact.readPolicy))))
40
+ throw invalid();
41
+ const facts = new Map(reading.threads.flatMap(thread => thread.messages).map(fact => [fact.id, fact]));
42
+ const messages = page.messages.map(row => ({ ...row, id: row.messageId, timestamp: row.serverCreatedAt, ...(facts.get(row.messageId)?.recalled ? { recalled: true } : {}) }));
43
+ const kind = value.contact.type === "group" ? "group" : "dm";
44
+ const state = { ...reading, pending: [] };
45
+ const projected = projectConversationReading(state, { id: options.threadId, kind });
46
+ const present = createMessagePresenter();
47
+ const timeline = projectMessageTimeline({ appId: options.appId, profileId: options.profileId, threadId: options.threadId, messages, outgoing: [], decode: () => undefined, reading: projected.messageReading });
48
+ 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") };
50
+ }
@@ -1,3 +1,4 @@
1
+ import type { ChatCommandConfig } from "../commands";
1
2
  import type { MentionConfig, ChatMention } from "../mentions/model";
2
3
  import type { ClipboardEventHandler, ComponentType, DragEventHandler, ReactNode } from "react";
3
4
  import type { ChatAttachment } from "./attachment";
@@ -121,6 +122,8 @@ export type ChatImageConfig = {
121
122
  uploadImage?: (file: File) => Promise<ChatImageUploadResult>;
122
123
  };
123
124
  export type ChatInputBaseProps = {
125
+ /** Host-provided slash commands; omit to disable command suggestions. Selection never sends. */
126
+ commands?: ChatCommandConfig;
124
127
  /** Group members supplied by the host; omit for ordinary text input. */
125
128
  mentions?: MentionConfig;
126
129
  /** Use a taller composer with actions in a bottom toolbar. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pmate/chat-sdk",
3
- "version": "0.12.0-next.8",
4
- "description": "Cross-platform PMate chat input components, voice input primitives, and ASR helpers",
3
+ "version": "0.12.0-next.80",
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",
7
7
  "types": "./lib/index.d.ts",
@@ -19,10 +19,40 @@
19
19
  "import": "./lib/index.js",
20
20
  "default": "./lib/index.js"
21
21
  },
22
+ "./bootstrap": {
23
+ "types": "./lib/runtime/messageLaunch.d.ts",
24
+ "import": "./lib/runtime/messageLaunch.js",
25
+ "default": "./lib/runtime/messageLaunch.js"
26
+ },
22
27
  "./runtime": {
23
28
  "types": "./lib/runtime/index.d.ts",
24
29
  "import": "./lib/runtime/index.js",
25
30
  "default": "./lib/runtime/index.js"
31
+ },
32
+ "./chat": {
33
+ "types": "./lib/chat.d.ts",
34
+ "import": "./lib/chat.js",
35
+ "default": "./lib/chat.js"
36
+ },
37
+ "./advanced": {
38
+ "types": "./lib/advanced.d.ts",
39
+ "import": "./lib/advanced.js",
40
+ "default": "./lib/advanced.js"
41
+ },
42
+ "./framework": {
43
+ "types": "./lib/framework.d.ts",
44
+ "import": "./lib/framework.js",
45
+ "default": "./lib/framework.js"
46
+ },
47
+ "./history": {
48
+ "types": "./lib/history.d.ts",
49
+ "import": "./lib/history.js",
50
+ "default": "./lib/history.js"
51
+ },
52
+ "./snapshot": {
53
+ "types": "./lib/snapshot.d.ts",
54
+ "import": "./lib/snapshot.js",
55
+ "default": "./lib/snapshot.js"
26
56
  }
27
57
  },
28
58
  "publishConfig": {