@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
@@ -0,0 +1,284 @@
1
+ import { createMessagePresenter } from "../messageView.js";
2
+ import { createChat as createSession } from "../runtime/createChat.js";
3
+ import { asChatError } from "../runtime/chatError.js";
4
+ import { ChatError } from "../chatError.js";
5
+ export { ChatError } from "../chatError.js";
6
+ const emptyMessages = Object.freeze([]);
7
+ /** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
8
+ export class ChatEngine {
9
+ advanced;
10
+ options;
11
+ groups = new WeakMap();
12
+ group(value) {
13
+ if (!value)
14
+ return undefined;
15
+ let projected = this.groups.get(value);
16
+ if (!projected) {
17
+ const { versionNumber: _, ...visible } = value;
18
+ projected = visible;
19
+ this.groups.set(value, projected);
20
+ }
21
+ return projected;
22
+ }
23
+ present = createMessagePresenter();
24
+ redactedQuotes = new WeakMap();
25
+ messageLists = new WeakMap();
26
+ messages(items) {
27
+ let values = this.messageLists.get(items);
28
+ if (!values) {
29
+ const recalled = new Set(items.filter(item => item.recalled).map(item => item.id));
30
+ values = items.map(item => {
31
+ const value = this.present(item);
32
+ if (!value.quote?.messageId || !recalled.has(value.quote.messageId))
33
+ return value;
34
+ let hidden = this.redactedQuotes.get(value);
35
+ if (!hidden) {
36
+ hidden = { ...value, quote: { ...value.quote, content: "", recalled: true } };
37
+ this.redactedQuotes.set(value, hidden);
38
+ }
39
+ return hidden;
40
+ });
41
+ this.messageLists.set(items, values);
42
+ }
43
+ return values;
44
+ }
45
+ views = new Map();
46
+ summary;
47
+ targets = new Map();
48
+ errors = new Map();
49
+ listeners = new Map();
50
+ closed = false;
51
+ constructor(advanced, options) {
52
+ this.advanced = advanced;
53
+ this.options = options;
54
+ }
55
+ async operation(run) { try {
56
+ return await run();
57
+ }
58
+ catch (error) {
59
+ throw asChatError(error);
60
+ } }
61
+ async deny(threadId, failure) {
62
+ this.errors.set(threadId, failure);
63
+ this.targets.delete(threadId);
64
+ const retiring = this.advanced.revokeThread(threadId);
65
+ this.notify(threadId);
66
+ try {
67
+ await retiring;
68
+ }
69
+ catch (error) {
70
+ failure.cause = new AggregateError([failure.cause, error], "Access withdrawn; cache cleanup incomplete");
71
+ }
72
+ }
73
+ threadOperation(threadId, run) {
74
+ return this.operation(async () => {
75
+ const denied = threadId ? this.errors.get(threadId) : undefined;
76
+ if (denied?.code === "access-denied")
77
+ throw denied;
78
+ try {
79
+ return await run();
80
+ }
81
+ catch (error) {
82
+ const failure = threadId ? await this.advanced.conversationFailure(error, threadId) : asChatError(error);
83
+ if (threadId && failure.code === "access-denied" && failure.threadId)
84
+ await this.deny(threadId, failure);
85
+ throw failure;
86
+ }
87
+ });
88
+ }
89
+ start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
90
+ /** Resolve a scoped ID locally, registering its target when needed. No remote group is created. */
91
+ resolveThreadId(target) { return this.getConversation(target); }
92
+ /** A trusted recent window. Source selection and synchronization belong to the SDK. */
93
+ getThread(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId), options); }
94
+ loadEarlier(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId, { earlier: true }), options); }
95
+ refreshThread(threadId, options = {}) { return this.readForCaller(() => this.getMessages(threadId, { refresh: true }), options); }
96
+ readForCaller(run, { signal }) {
97
+ if (signal?.aborted)
98
+ return Promise.reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError"));
99
+ if (!signal)
100
+ return run();
101
+ // A caller's cancellation never aborts another subscriber's shared read.
102
+ return new Promise((resolve, reject) => {
103
+ const abort = () => { signal.removeEventListener("abort", abort); reject(signal.reason ?? new DOMException("The operation was aborted", "AbortError")); };
104
+ signal.addEventListener("abort", abort, { once: true });
105
+ run().then(value => { signal.removeEventListener("abort", abort); resolve(value); }, error => { signal.removeEventListener("abort", abort); reject(error); });
106
+ });
107
+ }
108
+ threadSubscribers = new Map();
109
+ openQueue = new Set();
110
+ opening = 0;
111
+ drainOpens() {
112
+ while (!this.closed && this.opening < 4 && this.openQueue.size) {
113
+ const id = this.openQueue.values().next().value;
114
+ this.openQueue.delete(id);
115
+ if (!this.threadSubscribers.has(id))
116
+ continue;
117
+ this.opening++;
118
+ void this.getThread(id).catch(() => { }).finally(() => { this.opening--; this.drainOpens(); });
119
+ }
120
+ }
121
+ /** Listen first, then open the local window; concurrent consumers share the initial read. */
122
+ subscribeThread(threadId, listener, options = {}) {
123
+ const off = this.subscribe(threadId, listener, options), count = this.threadSubscribers.get(threadId) ?? 0;
124
+ this.threadSubscribers.set(threadId, count + 1);
125
+ if (!count) {
126
+ this.openQueue.add(threadId);
127
+ this.drainOpens();
128
+ }
129
+ let disposed = false;
130
+ return () => {
131
+ if (disposed)
132
+ return;
133
+ disposed = true;
134
+ off();
135
+ const remaining = (this.threadSubscribers.get(threadId) ?? 1) - 1;
136
+ if (remaining)
137
+ this.threadSubscribers.set(threadId, remaining);
138
+ else {
139
+ this.threadSubscribers.delete(threadId);
140
+ this.openQueue.delete(threadId);
141
+ }
142
+ };
143
+ }
144
+ /** @deprecated Use resolveThreadId. Retained only for the migration window. */
145
+ getConversation(target) {
146
+ return this.operation(async () => {
147
+ const id = await this.advanced.getConversation(target);
148
+ const value = target.groupId !== undefined ? { kind: "group", targetId: target.groupId } : { kind: "dm", targetId: target.peerId };
149
+ this.targets.delete(id);
150
+ this.targets.set(id, value);
151
+ if (this.targets.size > 256)
152
+ this.targets.delete(this.targets.keys().next().value);
153
+ this.notify(id);
154
+ return id;
155
+ });
156
+ }
157
+ getSnapshot(threadId) {
158
+ if (threadId === undefined) {
159
+ const source = this.advanced.getSnapshot(), previous = this.summary;
160
+ const threads = source.threads.filter(thread => this.errors.get(thread.threadId)?.code !== "access-denied").map(thread => ({ threadId: thread.threadId, lastMessage: this.messages(this.advanced.getSnapshot(thread.threadId).timeline).at(-1), group: this.group(thread.group), unread: thread.unread, outgoingCount: thread.outgoingCount }));
161
+ const unreadMessageCount = source.unreadMessageCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.unreadMessageCount ?? 0), 0);
162
+ const badgeCount = source.badgeCount === undefined ? undefined : threads.reduce((sum, thread) => sum + (thread.unread?.badgeCount ?? 0), 0);
163
+ if (previous && previous.connection === source.connection && previous.badgeCount === badgeCount && previous.unreadMessageCount === unreadMessageCount && previous.threads.length === threads.length && threads.every((thread, i) => { const old = previous.threads[i]; return thread.threadId === old.threadId && thread.lastMessage === old.lastMessage && thread.group === old.group && thread.unread === old.unread && thread.outgoingCount === old.outgoingCount; }))
164
+ return previous;
165
+ return this.summary = { threads, connection: source.connection, badgeCount, unreadMessageCount };
166
+ }
167
+ const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
168
+ const error = this.closed ? undefined : this.errors.get(threadId), denied = error?.code === "access-denied";
169
+ const target = this.closed || !this.advanced.active || denied ? undefined : this.advanced.getConversationTarget(threadId) ?? this.targets.get(threadId);
170
+ const conversation = previous?.conversation?.kind === target?.kind && previous?.conversation?.targetId === target?.targetId ? previous?.conversation : target ? { kind: target.kind, targetId: target.targetId } : undefined;
171
+ // Partial migrated rows never masquerade as a trusted recent window.
172
+ const next = { threadId, conversation, messages: !denied && source.complete ? this.messages(source.timeline) : emptyMessages, ready: !denied && source.complete, hasEarlier: !denied && source.hasEarlier, unread: denied ? undefined : source.unread, group: denied ? undefined : this.group(source.group), readCounts: denied ? undefined : source.readCounts, error, status: !denied && source.complete ? "ready" : error ? "error" : "pending" };
173
+ if (previous && Object.keys(next).every(key => next[key] === previous[key]))
174
+ return previous;
175
+ this.views.set(threadId, next);
176
+ return next;
177
+ }
178
+ async getMessages(threadId, query = {}) {
179
+ try {
180
+ const denied = this.errors.get(threadId);
181
+ if (denied?.code === "access-denied")
182
+ throw denied;
183
+ if (Object.keys(query).some(key => !["earlier", "all", "refresh"].includes(key)))
184
+ throw new ChatError("invalid-input", "Unsupported message query");
185
+ const page = await this.advanced.getMessages(threadId, query);
186
+ if (!page.complete)
187
+ throw new ChatError("unavailable", "Messages are not ready; retry when connected");
188
+ this.errors.delete(threadId);
189
+ this.notify(threadId);
190
+ return this.getSnapshot(threadId);
191
+ }
192
+ catch (error) {
193
+ const failure = await this.advanced.conversationFailure(error, threadId);
194
+ if (!this.closed) {
195
+ if (failure.code === "access-denied" && failure.threadId)
196
+ await this.deny(threadId, failure);
197
+ else {
198
+ this.errors.set(threadId, failure);
199
+ this.notify(threadId);
200
+ }
201
+ }
202
+ throw failure;
203
+ }
204
+ }
205
+ sendMessage(threadId, content, options = {}) {
206
+ return this.threadOperation(threadId, async () => {
207
+ if (content && ("groupCreate" in content || "groupUpdate" in content || "groupDissolve" in content))
208
+ throw new ChatError("invalid-input", "Use group lifecycle methods for group changes");
209
+ const result = await this.advanced.sendMessage(threadId, content, options);
210
+ return { id: result.hash, timestamp: result.t };
211
+ });
212
+ }
213
+ retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
214
+ subscribe(target, callback, options = {}) {
215
+ const global = typeof target === "function", key = global ? "" : target;
216
+ const config = global ? callback : options;
217
+ const read = () => global ? this.getSnapshot() : this.getSnapshot(key);
218
+ let previous = read();
219
+ const emit = () => {
220
+ const value = read();
221
+ const fields = config?.fields;
222
+ const changed = fields ? fields.some(field => previous[field] !== value[field]) : value !== previous;
223
+ previous = value;
224
+ if (changed) {
225
+ try {
226
+ (global ? target : callback)(value);
227
+ }
228
+ catch (error) {
229
+ try {
230
+ this.options.onError(error);
231
+ }
232
+ catch { /* A consumer cannot break other consumers or cache publication. */ }
233
+ }
234
+ }
235
+ };
236
+ let listeners = this.listeners.get(key);
237
+ if (!listeners) {
238
+ listeners = new Set();
239
+ this.listeners.set(key, listeners);
240
+ }
241
+ listeners.add(emit);
242
+ const off = global ? this.advanced.subscribeChanges(emit) : this.advanced.subscribeChanges(key, emit, this.sessionFields(options.fields));
243
+ const dispose = () => { off(); listeners.delete(emit); if (!listeners.size)
244
+ this.listeners.delete(key); };
245
+ if (config?.emitCurrent !== false) {
246
+ try {
247
+ (global ? target : callback)(previous);
248
+ }
249
+ catch (error) {
250
+ dispose();
251
+ throw error;
252
+ }
253
+ }
254
+ return dispose;
255
+ }
256
+ sessionFields(fields) {
257
+ return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" || field === "status" ? ["complete"] : field === "conversation" ? ["messages", "group"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
258
+ }
259
+ notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); this.listeners.get("")?.forEach(listener => listener()); }
260
+ observeMessages(threadId, ids, event) { return this.threadOperation(threadId, async () => { await this.advanced.observeMessages(threadId, ids, event); }); }
261
+ markRead(threadId) { return this.threadOperation(threadId, async () => { await this.advanced.markRead(threadId); }); }
262
+ getReaders(threadId, messageId, page = {}, signal) {
263
+ return this.threadOperation(threadId, async () => {
264
+ if (Object.keys(page).some(key => !["afterProfileId", "revision"].includes(key)))
265
+ throw new ChatError("invalid-input", "Unsupported reader query");
266
+ return this.advanced.getReaders(threadId, messageId, page, signal);
267
+ });
268
+ }
269
+ watchReaders(threadId, ids, receiver) { return this.threadOperation(threadId, () => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
270
+ handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
271
+ getVoiceAttachment(threadId, messageId) { return this.threadOperation(threadId, () => this.advanced.getVoiceAttachment(threadId, messageId)); }
272
+ getGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.getGroup(threadId, options))); }
273
+ createGroup(input) {
274
+ return this.operation(async () => {
275
+ const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
276
+ const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
277
+ return { ...this.group(group), threadId };
278
+ });
279
+ }
280
+ updateGroup(threadId, changes) { return this.threadOperation(threadId, async () => this.group(await this.advanced.updateGroup(threadId, changes))); }
281
+ dissolveGroup(threadId, options = {}) { return this.threadOperation(threadId, async () => this.group(await this.advanced.dissolveGroup(threadId, options))); }
282
+ revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
283
+ close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.targets.clear(); this.listeners.clear(); this.threadSubscribers.clear(); this.openQueue.clear(); this.views.clear(); this.summary = undefined; this.present = createMessagePresenter(); this.messageLists = new WeakMap(); this.groups = new WeakMap(); this.redactedQuotes = new WeakMap(); }); }
284
+ }
@@ -0,0 +1,4 @@
1
+ import type { Chat } from "../chatContract.js";
2
+ import type { ChatEngine } from "./chatEngine.js";
3
+ export declare function registerChat(chat: Chat, engine: ChatEngine): void;
4
+ export declare function chatEngine(chat: Chat): ChatEngine;
@@ -0,0 +1,4 @@
1
+ const engines = new WeakMap();
2
+ export function registerChat(chat, engine) { engines.set(chat, engine); }
3
+ export function chatEngine(chat) { const engine = engines.get(chat); if (!engine)
4
+ throw new TypeError("Unknown Chat client"); return engine; }
@@ -0,0 +1,4 @@
1
+ import type { Chat } from "../chatContract.js";
2
+ import { type CreateChatOptions } from "../runtime/createChat.js";
3
+ /** Build only the public capabilities; no aliases or engine fields on the returned object. */
4
+ export declare function createPublicChat(options: CreateChatOptions): Chat;
@@ -0,0 +1,52 @@
1
+ import { waitForCaller } from "../credentials.js";
2
+ import { ChatEngine } from "./chatEngine.js";
3
+ import { registerChat } from "./chatRegistry.js";
4
+ import { createChat as createSession } from "../runtime/createChat.js";
5
+ import { asChatError } from "../runtime/chatError.js";
6
+ import { ChatError } from "../chatError.js";
7
+ /** Build only the public capabilities; no aliases or engine fields on the returned object. */
8
+ export function createPublicChat(options) {
9
+ const configured = { ...options, onError: (error) => options.onError?.(asChatError(error)) };
10
+ let session;
11
+ try {
12
+ session = createSession(configured);
13
+ }
14
+ catch (error) {
15
+ throw asChatError(error);
16
+ }
17
+ const engine = new ChatEngine(session, configured);
18
+ const keys = (value, allowed) => { if (Object.keys(value).some(key => !allowed.includes(key)))
19
+ throw new ChatError("invalid-input", "Unsupported operation options"); };
20
+ const read = (run, options = {}, cleanup) => {
21
+ try {
22
+ keys(options, ["signal"]);
23
+ options.signal?.throwIfAborted();
24
+ return waitForCaller(run(), options.signal, cleanup);
25
+ }
26
+ catch (error) {
27
+ return Promise.reject(error);
28
+ }
29
+ };
30
+ const chat = Object.freeze({
31
+ start: () => engine.start(), close: () => engine.close(),
32
+ resolveThreadId: (target, options = {}) => read(() => engine.resolveThreadId(target), options),
33
+ 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
+ subscribeThread: (id, listener, options = {}) => { keys(options, ["fields"]); return engine.subscribeThread(id, listener, options); }, subscribeChat: (listener, options = {}) => { keys(options, ["fields"]); return engine.subscribe(listener, options); },
35
+ sendMessage: (id, content, options) => engine.sendMessage(id, content, options), retryMessage: id => engine.retryMessage(id),
36
+ observeMessages: (id, ids, event) => engine.observeMessages(id, ids, event), markRead: id => engine.markRead(id), watchReaders: (id, ids, receiver) => engine.watchReaders(id, ids, receiver),
37
+ getReaders: (id, messageId, options = {}) => {
38
+ try {
39
+ keys(options, ["afterProfileId", "revision", "signal"]);
40
+ }
41
+ catch (error) {
42
+ return Promise.reject(error);
43
+ }
44
+ return read(() => engine.getReaders(id, messageId, { afterProfileId: options.afterProfileId, revision: options.revision }), { signal: options.signal });
45
+ },
46
+ getVoiceAttachment: (id, messageId, options = {}) => read(() => engine.getVoiceAttachment(id, messageId), options, lease => lease.release()),
47
+ getGroup: (id, options = {}) => read(() => engine.getGroup(id), options), refreshGroup: (id, options = {}) => read(() => engine.getGroup(id, { refresh: true }), options),
48
+ createGroup: input => engine.createGroup(input), updateGroup: (id, changes) => engine.updateGroup(id, changes), dissolveGroup: (id, options) => engine.dissolveGroup(id, options),
49
+ });
50
+ registerChat(chat, engine);
51
+ return chat;
52
+ }
@@ -0,0 +1,6 @@
1
+ import type { TimelineItem } from "./runtime/messageTimeline.js";
2
+ import type { CachedChatMessage } from "./runtime/cachedMessage.js";
3
+ import type { ChatMessage } from "./chatTypes.js";
4
+ export type { ChatMessage } from "./chatTypes.js";
5
+ /** Decode a body once; reading and delivery projections reuse its normalized content. */
6
+ export declare function createMessagePresenter(): (item: TimelineItem<CachedChatMessage>) => ChatMessage;
@@ -0,0 +1,103 @@
1
+ import { parseChatFile } from "./fileMetadata.js";
2
+ const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
3
+ const text = (value) => typeof value === "string" ? value : "";
4
+ function media(value) {
5
+ if (typeof value !== "string" || !value)
6
+ return undefined;
7
+ try {
8
+ const url = new URL(value, "https://chat.invalid");
9
+ return ["https:", "http:"].includes(url.protocol) && !url.username && !url.password ? value : undefined;
10
+ }
11
+ catch {
12
+ return undefined;
13
+ }
14
+ }
15
+ function quote(value) {
16
+ const record = object(value);
17
+ if (typeof record.senderName !== "string" || typeof record.content !== "string" || record.content.length > 6000)
18
+ return;
19
+ return { senderName: record.senderName.slice(0, 100), content: record.content, ...(typeof record.messageId === "string" ? { messageId: record.messageId } : {}) };
20
+ }
21
+ function legacyDuration(value) {
22
+ if (typeof value !== "string" || !/^\d+:\d{2}$/.test(value))
23
+ return 0;
24
+ const [minutes, seconds] = value.split(":").map(Number);
25
+ return (minutes * 60 + seconds) * 1000;
26
+ }
27
+ function content(message) {
28
+ const body = object(message.body), outer = object(body.content);
29
+ const envelope = body.kind === "quote" && outer.content !== undefined ? outer : body;
30
+ const payload = object(envelope.content);
31
+ const legacy = object(message.legacyDisplay);
32
+ const validLegacy = legacy.id === message.id && legacy.threadId === message.threadId && legacy.orgId === message.teamId && legacy.from === message.from && typeof legacy.content === "string";
33
+ const hint = validLegacy ? legacy : {};
34
+ const file = parseChatFile(payload.file === undefined ? hint.file : payload.file);
35
+ const contentKind = envelope.contentKind, voice = object(payload.voice);
36
+ const voiceHint = hint.kind === "voice";
37
+ const voiceUrl = media(voice.url) ?? (voiceHint ? media(hint.audioUrl) : undefined) ?? "";
38
+ const durationMs = typeof voice.durationMs === "number" && Number.isFinite(voice.durationMs) ? voice.durationMs : voiceHint ? legacyDuration(hint.duration) : 0;
39
+ const hasVoice = voiceHint || Object.keys(voice).length > 0 && (voiceUrl.length > 0 || durationMs > 0) || contentKind === "voice";
40
+ const imageUrl = media(payload.url) ?? (hint.kind === "image" ? media(hint.imageUrl) : undefined);
41
+ const messageQuote = quote(payload.quote) ?? quote(hint.quote);
42
+ const rawText = text(payload.text) || text(object(body.payload).message);
43
+ const result = { kind: "text", text: validLegacy ? text(hint.content) : rawText, senderName: text(hint.senderName) || undefined, senderAvatar: media(hint.senderAvatar), timeLabel: text(hint.createdAt) || undefined,
44
+ recipientCount: message.threadKind === "dm" ? 1 : validLegacy ? typeof hint.recipientCount === "number" ? hint.recipientCount : undefined : Array.isArray(message.recipients) ? new Set(message.recipients.filter(id => id !== message.from)).size : undefined };
45
+ if (validLegacy && ["emoji", "system", "attachment", "quote"].includes(String(hint.kind)))
46
+ result.kind = hint.kind;
47
+ if (body.kind === "contact") {
48
+ result.kind = ["friendRequest", "requestFriend"].includes(String(body.action)) ? "invitation" : "system";
49
+ if (result.kind === "system")
50
+ result.system = { type: "contact-updated" };
51
+ }
52
+ else if (body.kind === "group") {
53
+ result.kind = "system";
54
+ result.system = { type: body.action === "groupCreate" ? "group-created" : body.action === "groupDissolve" ? "group-dissolved" : "group-updated", title: text(object(body.payload).title) || undefined };
55
+ }
56
+ else if (file) {
57
+ result.kind = "attachment";
58
+ result.file = file;
59
+ }
60
+ else if (hasVoice) {
61
+ result.kind = "voice";
62
+ result.voice = { url: voiceUrl, durationMs, ...(text(voice.transcript) || text(hint.transcript) ? { transcript: text(voice.transcript) || text(hint.transcript) } : {}) };
63
+ }
64
+ else if ((contentKind === "image" || hint.kind === "image") && imageUrl) {
65
+ result.kind = "image";
66
+ result.image = { url: imageUrl, name: text(payload.name) || text(hint.imageAlt) || undefined };
67
+ }
68
+ else if (contentKind === "emoji") {
69
+ result.kind = "emoji";
70
+ result.text = text(payload.code) || result.text;
71
+ }
72
+ if (messageQuote) {
73
+ result.quote = messageQuote;
74
+ if (result.kind === "text")
75
+ result.kind = "quote";
76
+ }
77
+ if (result.kind === "attachment")
78
+ result.attachment = { name: text(hint.attachmentName) || undefined, size: text(hint.attachmentSize) || undefined };
79
+ if (payload.format !== undefined)
80
+ result.richContent = payload;
81
+ else if (hint.richContent !== undefined)
82
+ result.richContent = hint.richContent;
83
+ if (Array.isArray(payload.mentions))
84
+ result.mentions = payload.mentions;
85
+ return result;
86
+ }
87
+ /** Decode a body once; reading and delivery projections reuse its normalized content. */
88
+ export function createMessagePresenter() {
89
+ const bodies = new WeakMap(), items = new WeakMap();
90
+ return (item) => {
91
+ const previous = items.get(item);
92
+ if (previous)
93
+ return previous;
94
+ let value = { kind: "system", text: "" };
95
+ if (!item.recalled && item.message) {
96
+ value = bodies.get(item.message) ?? content(item.message);
97
+ bodies.set(item.message, value);
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 };
100
+ items.set(item, result);
101
+ return result;
102
+ };
103
+ }
@@ -1,6 +1,8 @@
1
- import { type ReadIndexPersistence, type ReadIndexState } from "./readIndex.js";
1
+ import { type ReadIndexPersistence, type ReadIndexState, type ReadIndexTransform, type ReadObservationScope } from "./readIndex.js";
2
2
  type Change = {
3
- transform: (state: ReadIndexState) => ReadIndexState;
3
+ weight: number;
4
+ observation?: ReadObservationScope;
5
+ transform: ReadIndexTransform;
4
6
  resolve: (state: ReadIndexState) => void;
5
7
  reject: (error: unknown) => void;
6
8
  };
@@ -13,9 +15,20 @@ export declare class BatchedReadIndex implements ReadIndexPersistence {
13
15
  private queueLimit;
14
16
  private queues;
15
17
  private closed;
18
+ private sourceCounts;
16
19
  constructor(storage: ReadIndexPersistence, windowMs?: number, batchSize?: number, queueLimit?: number);
17
20
  load(appId: string, profileId: string): Promise<ReadIndexState | undefined>;
18
- update(appId: string, profileId: string, transform: Change["transform"]): Promise<ReadIndexState>;
21
+ loadVersioned(appId: string, profileId: string): Promise<{
22
+ state: ReadIndexState | undefined;
23
+ versions: import("./readIndex.js").ReadIndexVersions;
24
+ } | {
25
+ state: ReadIndexState | undefined;
26
+ versions: {};
27
+ }>;
28
+ loadVersions(appId: string, profileId: string): Promise<{}>;
29
+ invalidateTeam(appId: string, profileId: string, teamId: string, version: number, knownThreadIds?: readonly string[]): Promise<ReadIndexState>;
30
+ private weight;
31
+ update(appId: string, profileId: string, transform: Change["transform"], observation?: ReadObservationScope): Promise<ReadIndexState>;
19
32
  private drain;
20
33
  flush(appId: string, profileId: string): Promise<void>;
21
34
  close(): Promise<void>;
@@ -8,6 +8,7 @@ export class BatchedReadIndex {
8
8
  queueLimit;
9
9
  queues = new Map();
10
10
  closed = false;
11
+ sourceCounts = new WeakMap();
11
12
  constructor(storage, windowMs = 16, batchSize = 128, queueLimit = 4096) {
12
13
  this.storage = storage;
13
14
  this.windowMs = windowMs;
@@ -22,7 +23,42 @@ export class BatchedReadIndex {
22
23
  throw new Error("READ_STORAGE_CLOSED");
23
24
  return this.storage.load(appId, profileId);
24
25
  }
25
- update(appId, profileId, transform) {
26
+ async loadVersioned(appId, profileId) {
27
+ await this.flush(appId, profileId);
28
+ if (this.closed)
29
+ throw new Error("READ_STORAGE_CLOSED");
30
+ return this.storage.loadVersioned ? this.storage.loadVersioned(appId, profileId) : { state: await this.storage.load(appId, profileId), versions: {} };
31
+ }
32
+ async loadVersions(appId, profileId) {
33
+ if (this.closed)
34
+ throw new Error("READ_STORAGE_CLOSED");
35
+ return this.storage.loadVersions ? this.storage.loadVersions(appId, profileId) : {};
36
+ }
37
+ async invalidateTeam(appId, profileId, teamId, version, knownThreadIds) {
38
+ if (this.closed)
39
+ throw new Error("READ_STORAGE_CLOSED");
40
+ if (!this.storage.invalidateTeam)
41
+ throw new Error("READ_SCOPE_INVALIDATION_UNAVAILABLE");
42
+ // Do not flush a blocked old HTTP transform first. The durable epoch is the
43
+ // barrier; any queued transform sees it inside its eventual transaction.
44
+ return this.storage.invalidateTeam(appId, profileId, teamId, version, knownThreadIds);
45
+ }
46
+ weight(observation) {
47
+ if (!observation)
48
+ return 1;
49
+ let counts = this.sourceCounts.get(observation.base);
50
+ if (!counts) {
51
+ counts = new Map();
52
+ for (const thread of observation.base.threads) {
53
+ counts.set(thread.id, (counts.get(thread.id) ?? 0) + 1);
54
+ if (thread.conversationId && thread.conversationId !== thread.id)
55
+ counts.set(thread.conversationId, (counts.get(thread.conversationId) ?? 0) + 1);
56
+ }
57
+ this.sourceCounts.set(observation.base, counts);
58
+ }
59
+ return observation.targets.reduce((sum, target) => sum + (counts.get(target.threadId) ?? 1), 0);
60
+ }
61
+ update(appId, profileId, transform, observation) {
26
62
  if (this.closed)
27
63
  return Promise.reject(new Error("READ_STORAGE_CLOSED"));
28
64
  const key = JSON.stringify([appId, profileId]);
@@ -33,7 +69,7 @@ export class BatchedReadIndex {
33
69
  }
34
70
  if (queue.pending.length >= this.queueLimit)
35
71
  return Promise.reject(new Error("READ_STORAGE_BACKPRESSURE"));
36
- const promise = new Promise((resolve, reject) => queue.pending.push({ transform, resolve, reject }));
72
+ const promise = new Promise((resolve, reject) => queue.pending.push({ transform, observation, weight: this.weight(observation), resolve, reject }));
37
73
  if (!queue.running && queue.timer === undefined)
38
74
  queue.timer = setTimeout(() => { queue.timer = undefined; void this.drain(key, queue); }, this.windowMs);
39
75
  return promise;
@@ -43,12 +79,22 @@ export class BatchedReadIndex {
43
79
  return queue.running;
44
80
  clearTimeout(queue.timer);
45
81
  queue.timer = undefined;
46
- const batch = queue.pending.splice(0, this.batchSize);
82
+ const batch = [];
83
+ let weight = 0;
84
+ while (queue.pending.length && batch.length < this.batchSize) {
85
+ const nextWeight = queue.pending[0].weight;
86
+ if (batch.length && weight + nextWeight > 128)
87
+ break;
88
+ weight += nextWeight;
89
+ batch.push(queue.pending.shift());
90
+ }
47
91
  if (!batch.length)
48
92
  return;
49
93
  const run = (async () => {
50
94
  try {
51
- const next = await this.storage.update(queue.appId, queue.profileId, state => batch.reduce((current, change) => change.transform(current), state));
95
+ const first = batch[0].observation;
96
+ const observation = first && batch.every(change => change.observation?.base === first.base) ? { base: first.base, targets: batch.flatMap(change => change.observation.targets) } : undefined;
97
+ const next = await this.storage.update(queue.appId, queue.profileId, (state, versions) => batch.reduce((current, change) => change.transform(current, versions), state), observation);
52
98
  batch.forEach(change => change.resolve(next));
53
99
  }
54
100
  catch (error) {
@@ -0,0 +1,12 @@
1
+ import type { ChatReaderSocialMessage } from "./readerClient.js";
2
+ import type { ChatRuntimeMessage } from "./protocol.js";
3
+ export type CachedChatMessage = ChatReaderSocialMessage & {
4
+ id: string;
5
+ timestamp: number;
6
+ readPolicy?: "visible" | "explicit";
7
+ };
8
+ export declare function decodeCachedChatMessage(message: ChatRuntimeMessage, scope: {
9
+ appId: string;
10
+ profileId: string;
11
+ teamId: string;
12
+ }): CachedChatMessage | undefined;
@@ -0,0 +1,18 @@
1
+ import { chatTeamId } from "./teamScope.js";
2
+ import { dmThreadId, groupThreadId } from "./thread.js";
3
+ export function decodeCachedChatMessage(message, scope) {
4
+ if (message.opcode !== 1 || !message.body || typeof message.body !== "object")
5
+ return;
6
+ const body = message.body;
7
+ const invitation = body.kind === "contact" && ["friendRequest", "requestFriend"].includes(String(body.action));
8
+ if ((!invitation && body.kind !== "message") || (invitation ? body.payload?.appId : body.content?.appId) !== scope.appId || chatTeamId(body) !== scope.teamId)
9
+ return;
10
+ const threadKind = invitation ? "dm" : body.threadKind;
11
+ if (threadKind !== "dm" && threadKind !== "group")
12
+ return;
13
+ if (threadKind === "dm" && message.from !== scope.profileId && message.to !== scope.profileId)
14
+ return;
15
+ const threadId = threadKind === "dm" ? dmThreadId(scope.appId, message.from, message.to, scope.teamId) : groupThreadId(scope.appId, message.to, scope.teamId);
16
+ return { appId: scope.appId, teamId: scope.teamId, threadId, threadKind, messageId: message.hash, id: message.hash,
17
+ timestamp: message.t, serverCreatedAt: message.t, clientCreatedAt: message.t, from: message.from, to: message.to, body: message.body, recipients: message.recipients };
18
+ }
@@ -16,6 +16,7 @@ export type ChatClientOptions = {
16
16
  onNotification?: OnlineNotificationHandler;
17
17
  profileId?: string;
18
18
  botKey?: PmateBotKeyFile | string | unknown;
19
+ getToken?: () => Promise<string>;
19
20
  session?: BotLoginSession;
20
21
  hubUrl?: string;
21
22
  authBaseUrl?: string;
@@ -62,6 +63,7 @@ export declare class ChatClient {
62
63
  profileId: string | undefined;
63
64
  accountId: string | undefined;
64
65
  teamId: string | undefined;
66
+ clientInstanceId: string;
65
67
  };
66
68
  watchConnection(handler: (state: ChatConnectionState) => void): () => void;
67
69
  private setConnection;
@@ -121,6 +123,11 @@ export declare class ChatClient {
121
123
  title?: string;
122
124
  avatar?: string;
123
125
  }): Promise<ChatRuntimeMessage>;
126
+ dissolveGroup(input: {
127
+ groupId: string;
128
+ teamId?: string;
129
+ expectedRevision: string;
130
+ }): Promise<ChatRuntimeMessage>;
124
131
  sendGroup(input: {
125
132
  groupId: string;
126
133
  teamId?: string;