@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.
- package/README.md +170 -83
- package/lib/advanced.d.ts +10 -0
- package/lib/advanced.js +10 -0
- package/lib/chat.d.ts +5 -0
- package/lib/chat.js +11 -0
- package/lib/chatContract.d.ts +151 -0
- package/lib/chatContract.js +1 -0
- package/lib/chatError.d.ts +14 -0
- package/lib/chatError.js +16 -0
- package/lib/chatTypes.d.ts +127 -0
- package/lib/chatTypes.js +1 -0
- package/lib/commands.d.ts +20 -0
- package/lib/commands.js +12 -0
- package/lib/components/ChatInputDesktop.js +5 -4
- package/lib/components/ChatInputMobile.js +5 -4
- package/lib/components/CommandMenu.d.ts +15 -0
- package/lib/components/CommandMenu.js +10 -0
- package/lib/credentials.d.ts +26 -0
- package/lib/credentials.js +110 -0
- package/lib/fileMetadata.d.ts +9 -0
- package/lib/fileMetadata.js +18 -0
- package/lib/framework.d.ts +11 -0
- package/lib/framework.js +12 -0
- package/lib/history.d.ts +4 -0
- package/lib/history.js +8 -0
- package/lib/hooks/useChatSnapshot.d.ts +4 -0
- package/lib/hooks/useChatSnapshot.js +9 -0
- package/lib/hooks/useComposerSuggestions.d.ts +50 -0
- package/lib/hooks/useComposerSuggestions.js +70 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +5 -0
- package/lib/internal/chatEngine.d.ts +130 -0
- package/lib/internal/chatEngine.js +284 -0
- package/lib/internal/chatRegistry.d.ts +4 -0
- package/lib/internal/chatRegistry.js +4 -0
- package/lib/internal/createPublicChat.d.ts +4 -0
- package/lib/internal/createPublicChat.js +52 -0
- package/lib/messageView.d.ts +6 -0
- package/lib/messageView.js +103 -0
- package/lib/runtime/batchedReadIndex.d.ts +16 -3
- package/lib/runtime/batchedReadIndex.js +50 -4
- package/lib/runtime/cachedMessage.d.ts +12 -0
- package/lib/runtime/cachedMessage.js +18 -0
- package/lib/runtime/chatClient.d.ts +7 -0
- package/lib/runtime/chatClient.js +14 -3
- package/lib/runtime/chatError.d.ts +5 -0
- package/lib/runtime/chatError.js +20 -0
- package/lib/runtime/chatSession.d.ts +128 -13
- package/lib/runtime/chatSession.js +759 -48
- package/lib/runtime/connectivity.d.ts +4 -0
- package/lib/runtime/connectivity.js +8 -0
- package/lib/runtime/conversationDirectory.d.ts +37 -0
- package/lib/runtime/conversationDirectory.js +116 -0
- package/lib/runtime/conversationRead.d.ts +13 -0
- package/lib/runtime/conversationRead.js +34 -0
- package/lib/runtime/conversationReading.d.ts +28 -0
- package/lib/runtime/conversationReading.js +47 -0
- package/lib/runtime/createChat.d.ts +11 -7
- package/lib/runtime/createChat.js +46 -23
- package/lib/runtime/durableSender.d.ts +97 -14
- package/lib/runtime/durableSender.js +786 -71
- package/lib/runtime/groupClient.d.ts +91 -0
- package/lib/runtime/groupClient.js +380 -0
- package/lib/runtime/groupDenial.d.ts +2 -0
- package/lib/runtime/groupDenial.js +4 -0
- package/lib/runtime/index.d.ts +7 -0
- package/lib/runtime/index.js +5 -0
- package/lib/runtime/indexedDbRecovery.d.ts +4 -0
- package/lib/runtime/indexedDbRecovery.js +27 -0
- package/lib/runtime/messageClient.d.ts +35 -8
- package/lib/runtime/messageClient.js +364 -66
- package/lib/runtime/messageControls.d.ts +19 -0
- package/lib/runtime/messageControls.js +44 -0
- package/lib/runtime/messageLaunch.d.ts +44 -0
- package/lib/runtime/messageLaunch.js +137 -0
- package/lib/runtime/messageQuotes.d.ts +3 -0
- package/lib/runtime/messageQuotes.js +17 -0
- package/lib/runtime/messageReaders.d.ts +5 -1
- package/lib/runtime/messageReaders.js +16 -5
- package/lib/runtime/messageStore.d.ts +43 -2
- package/lib/runtime/messageStore.js +248 -14
- package/lib/runtime/messageTimeline.d.ts +50 -0
- package/lib/runtime/messageTimeline.js +120 -0
- package/lib/runtime/outboxSchema.d.ts +5 -0
- package/lib/runtime/outboxSchema.js +59 -0
- package/lib/runtime/outgoingContent.d.ts +94 -0
- package/lib/runtime/outgoingContent.js +118 -0
- package/lib/runtime/readBatchJournal.d.ts +35 -0
- package/lib/runtime/readBatchJournal.js +210 -0
- package/lib/runtime/readCounts.d.ts +15 -1
- package/lib/runtime/readCounts.js +97 -9
- package/lib/runtime/readIndex.d.ts +68 -7
- package/lib/runtime/readIndex.js +460 -22
- package/lib/runtime/readSchema.d.ts +2 -0
- package/lib/runtime/readSchema.js +58 -0
- package/lib/runtime/readSync.d.ts +1 -0
- package/lib/runtime/readSync.js +26 -9
- package/lib/runtime/readingClient.d.ts +19 -0
- package/lib/runtime/readingClient.js +131 -19
- package/lib/runtime/readingContent.d.ts +5 -0
- package/lib/runtime/readingContent.js +23 -0
- package/lib/runtime/responseCache.d.ts +40 -5
- package/lib/runtime/responseCache.js +182 -44
- package/lib/runtime/socialReadClient.d.ts +9 -4
- package/lib/runtime/socialReadClient.js +12 -8
- package/lib/runtime/voiceUpload.d.ts +2 -0
- package/lib/runtime/voiceUpload.js +27 -0
- package/lib/snapshot.d.ts +36 -0
- package/lib/snapshot.js +50 -0
- package/lib/types/chatInput.d.ts +3 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
await this.
|
|
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
|
-
|
|
59
|
-
if (!batch.length)
|
|
152
|
+
if (!this.pending.length)
|
|
60
153
|
return;
|
|
61
154
|
const task = (async () => {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
if (this.
|
|
93
|
-
return;
|
|
227
|
+
close() {
|
|
228
|
+
if (this.closing)
|
|
229
|
+
return this.closing;
|
|
94
230
|
this.closed = true;
|
|
95
231
|
this.tasks.close();
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
22
|
-
title
|
|
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,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>;
|
package/lib/snapshot.js
ADDED
|
@@ -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
|
+
}
|
package/lib/types/chatInput.d.ts
CHANGED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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": {
|