@pmate/chat-sdk 0.12.0-next.60 → 0.12.0-next.62
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 +101 -140
- package/lib/chat.d.ts +101 -0
- package/lib/chat.js +124 -0
- package/lib/hooks/useChatSnapshot.d.ts +4 -0
- package/lib/hooks/useChatSnapshot.js +8 -0
- package/lib/index.d.ts +2 -0
- package/lib/index.js +2 -0
- package/lib/runtime/chatError.d.ts +12 -0
- package/lib/runtime/chatError.js +26 -0
- package/lib/runtime/chatSession.d.ts +17 -4
- package/lib/runtime/chatSession.js +83 -12
- package/lib/runtime/conversationDirectory.d.ts +34 -0
- package/lib/runtime/conversationDirectory.js +114 -0
- package/lib/runtime/index.d.ts +1 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -128,133 +128,114 @@ The handler leaves text-only pastes and events already handled by the host untou
|
|
|
128
128
|
|
|
129
129
|
Only send on explicit submission: block send while `pending`, deliver each ready attachment's `url`, and remove it after delivery succeeds. Keep unsuccessful drafts to allow retry without reuploading. For `ChatInputDesktop`, use `hasPendingContent` and `sendDisabled` with host-owned drafts. Unmount or key the draft owner when switching accounts, Teams or conversations.
|
|
130
130
|
|
|
131
|
-
## Browser
|
|
131
|
+
## Browser messages
|
|
132
132
|
|
|
133
133
|
### Background
|
|
134
134
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
135
|
+
App code should render messages and report actual user actions. IndexedDB,
|
|
136
|
+
trusted recent windows, synchronization, recipients, retries and read rules belong
|
|
137
|
+
to the SDK. The default API presents one semantic message list, including delivery,
|
|
138
|
+
recalls and reactions. It never presents a partial migration as a ready window.
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
```ts
|
|
141
|
+
import {createChat, ChatError} from "@pmate/chat-sdk/chat"
|
|
142
|
+
const chat = createChat({appId, accountId, profileId, teamId, token, onError})
|
|
143
|
+
await chat.start() // also handles an optional shared calls/presence connection
|
|
144
|
+
|
|
145
|
+
// Existing conversations already have a threadId. For a new conversation:
|
|
146
|
+
const threadId = await chat.getConversation({peerId}) // or {groupId}
|
|
147
|
+
try {
|
|
148
|
+
await chat.getMessages(threadId)
|
|
149
|
+
openThread(threadId) // cold failure leaves the usable conversation list in place
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (error instanceof ChatError && error.retryable) showRetry()
|
|
152
|
+
}
|
|
153
|
+
const off = chat.subscribe(threadId, state => render(state.messages))
|
|
154
|
+
await chat.sendMessage(threadId, {text: "Hello"})
|
|
155
|
+
await chat.getMessages(threadId, {earlier: true})
|
|
156
|
+
off()
|
|
157
|
+
await chat.close()
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`getMessages` returns the same stable snapshot as `getSnapshot` and `subscribe`.
|
|
161
|
+
It resolves with a trusted recent window, or rejects with `ChatError`; Apps do not
|
|
162
|
+
check coverage or select local/remote sources. `{earlier:true}` extends the SDK's
|
|
163
|
+
current window without timestamps, limits or cursors; `{all:true}` traverses
|
|
164
|
+
accessible history and rejects if the scan is incomplete. A failed history scan
|
|
165
|
+
can leave the already trusted recent window usable. Cache hits do not repeat
|
|
166
|
+
history requests. Reconnection and browser wake reconcile newer changes.
|
|
167
|
+
|
|
168
|
+
A thread snapshot contains `threadId`, one renderable `messages` list, `ready`,
|
|
169
|
+
`hasEarlier`, `unread`, `group`, `readCounts` and optional `error`. `ready` helps
|
|
170
|
+
route entry and startup before the first read; it is not a cache-source indicator.
|
|
171
|
+
`getSnapshot()` and `subscribe(listener)` provide conversation summaries, aggregate
|
|
172
|
+
unread counts and connection state. Unknown unread counts remain `undefined`.
|
|
173
|
+
Subscription always emits the current snapshot first, including with
|
|
174
|
+
`{fields:["messages","unread"]}`, then meaningful changes only. Object references
|
|
175
|
+
remain stable for unchanged results. Framework adapters can use
|
|
176
|
+
`{emitCurrent:false}`; React can import `useChatSnapshot(chat, threadId)` from
|
|
177
|
+
`@pmate/chat-sdk`. Establishing a subscription never starts a read or marks it read.
|
|
178
|
+
|
|
179
|
+
Sending accepts text/mentions/quotes, images, voice, recalls, reactions and
|
|
180
|
+
invitations through the same typed content union. The SDK resolves the target
|
|
181
|
+
and group roster, expands @all and assigns reaction revisions. The default
|
|
182
|
+
Promise resolves after durable acceptance and returns `{id,timestamp}`; outgoing
|
|
183
|
+
status is part of the same message list. `{waitFor:"delivered"}` waits for the
|
|
184
|
+
server acknowledgement. `retryMessage(id)` retries a failed exact wire.
|
|
141
185
|
|
|
142
186
|
```ts
|
|
143
|
-
|
|
144
|
-
await chat.
|
|
145
|
-
|
|
146
|
-
// SDK owns the page size and history boundary; hosts need not construct a cursor.
|
|
147
|
-
const earlier = await chat.getMessages(threadId, { earlier: true })
|
|
148
|
-
// After an authoritative mutation (e.g. history assignment), await reconciliation.
|
|
149
|
-
// Concurrent refreshes share transport; errors do not masquerade as fresh cache.
|
|
150
|
-
const reconciled = await chat.getMessages(threadId, { refresh: true })
|
|
151
|
-
// Explicit full-history queries reuse cached pages and internally rebase expired cursors.
|
|
152
|
-
const history = await chat.getMessages(threadId, { all: true })
|
|
153
|
-
const off = chat.subscribe(threadId, () => render(chat.getSnapshot(threadId)))
|
|
154
|
-
const offSummary = chat.subscribe(() => renderSummary(chat.getSnapshot()))
|
|
187
|
+
await chat.sendMessage(threadId, {voice:{blob,durationMs}})
|
|
188
|
+
await chat.sendMessage(threadId, {reaction:{messageId,emojiId,active:true}})
|
|
189
|
+
await chat.sendMessage(threadId, {recall:{messageId}}, {waitFor:"delivered"})
|
|
155
190
|
await chat.observeMessages(threadId, visibleIds, "visible")
|
|
156
|
-
|
|
157
|
-
await chat.
|
|
158
|
-
const conversation = { id: threadId, kind: "dm" as const, targetId: peerId, teamId }
|
|
159
|
-
await chat.sendMessage(conversation, { text: "Hello" })
|
|
160
|
-
await chat.sendMessage(conversation, { image: { url: uploadedUrl, name: "photo.png" } })
|
|
161
|
-
await chat.sendMessage(conversation, { voice: { blob: recording, durationMs: 1200 } })
|
|
191
|
+
await chat.observeMessages(threadId, [voiceId], "played") // actual playing event
|
|
192
|
+
await chat.markRead(threadId) // ordinary text only; unheard voice stays unread
|
|
162
193
|
const readers = await chat.getReaders(threadId, messageId)
|
|
163
|
-
off(); offSummary()
|
|
164
|
-
// Ordinary navigation/shutdown preserves offline data.
|
|
165
|
-
await chat.close()
|
|
166
194
|
```
|
|
167
195
|
|
|
196
|
+
Voice media is acquired on demand through `getVoiceAttachment(threadId,messageId)`.
|
|
197
|
+
Use its `url` and call `release()` when playback/transcription stops. Acquisition,
|
|
198
|
+
clicking play, a fulfilled play Promise and transcription do not count as playing.
|
|
199
|
+
The SDK persists observations locally, batches writes and synchronization, and
|
|
200
|
+
preserves offline intents. It does not promise all media binaries are offline.
|
|
201
|
+
|
|
168
202
|
```ts
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
await chat.revokeAccess()
|
|
203
|
+
const group = await chat.getGroup(threadId)
|
|
204
|
+
const created = await chat.createGroup({title, members}) // optional groupId
|
|
205
|
+
await chat.updateGroup(created.threadId, {title: "New title"})
|
|
173
206
|
```
|
|
174
207
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
The
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
subscribes per conversation, and accepts incremental event batches. Its transport
|
|
207
|
-
supplies authoritative `load`, `changes` and `older` pages. `nextBefore` tracks
|
|
208
|
-
uncached older history independently of the latest synchronization checkpoint.
|
|
209
|
-
A projection reset removes obsolete rows before installing a new window.
|
|
210
|
-
`SocialReadClient.messagePage` implements the authenticated Hub page protocol;
|
|
211
|
-
request cancellation is supported through its optional second signal argument.
|
|
212
|
-
|
|
213
|
-
`ReadingClient` owns persisted visible/explicit read intents, windowed network
|
|
214
|
-
synchronization and retry. A host reports actual visibility with `recordVisible`
|
|
215
|
-
or explicit actions with `recordExplicit`; loading or subscribing never marks
|
|
216
|
-
messages read. `getSnapshot` plus `subscribe` provide stable reading snapshots.
|
|
217
|
-
`BatchedReadIndex` resolves queued writes only after their transaction commits.
|
|
218
|
-
|
|
219
|
-
`IndexedDbRecordCache` is a version-aware control-event index. Call and await
|
|
220
|
-
`open` before using it. An optional legacy reader runs during initialization only;
|
|
221
|
-
legacy cleanup happens after durable migration. Normal reads use memory and writes
|
|
222
|
-
are merged into bounded IndexedDB transactions. Cache errors must be surfaced by
|
|
223
|
-
the host, and pending operations are not reported as saved before their promise
|
|
224
|
-
resolves.
|
|
225
|
-
Control-event batches are bounded by both 128 records and 1 MiB per transaction;
|
|
226
|
-
the queue includes in-flight writes and is limited to 4096 records / 8 MiB.
|
|
227
|
-
`transactionBytes` and `queueBytes` can lower these limits. Oversized operations
|
|
228
|
-
fail before changing the memory snapshot. Repeating an in-flight write waits for
|
|
229
|
-
that physical write, including its failure, rather than acknowledging it early.
|
|
230
|
-
|
|
231
|
-
`DurableChatSender` saves the exact message created by `ChatClient.prepareChat`
|
|
232
|
-
before transmitting it. `retry(hash)` uses `sendPrepared` with the original hash
|
|
233
|
-
and payload. Failed sends require an explicit retry; interrupted sends can resume
|
|
234
|
-
when the connection returns. Call `confirm(hashes)` only after authoritative
|
|
235
|
-
messages have been durably merged. Confirmation tombstones prevent another tab's
|
|
236
|
-
late transport failure from recreating an outgoing message.
|
|
237
|
-
|
|
238
|
-
Custom primitive integrations must initialize them with the same authenticated
|
|
239
|
-
scope, dispose them on identity changes, and wire their transport to an authorized
|
|
240
|
-
server. Standard browser integrations should use the facade instead. The Merry migration and production validation
|
|
241
|
-
are tracked in the unified message layer proposal.
|
|
242
|
-
|
|
243
|
-
When the browser reports offline, scheduled message restoration and reading
|
|
244
|
-
refreshes wait for the normal reconnect/wake path. Cached reads remain available;
|
|
245
|
-
unknown receipt counts stay unknown and an uncached reader detail page rejects
|
|
246
|
-
without attempting transport. A network failure arriving after the browser went
|
|
247
|
-
offline does not turn background synchronization into an operation error.
|
|
248
|
-
Authorization failures still revoke the corresponding cached data.
|
|
249
|
-
|
|
250
|
-
Reading entry points check the message scope even before `initialize()` finishes.
|
|
251
|
-
Concurrent observations share the permission lookup and batch their local writes.
|
|
252
|
-
Visibility/playback operations reject another known team's conversation; a
|
|
253
|
-
permission change in another team does not discard this team's observations.
|
|
254
|
-
Ordinary shutdown waits for accepted local reading writes, while access withdrawal
|
|
255
|
-
rejects late results. Retired asynchronous reading entries reject through promises;
|
|
256
|
-
refresh/subscription teardown remains safe.
|
|
257
|
-
|
|
208
|
+
Group reads restore IndexedDB metadata before background reconciliation. Mutations
|
|
209
|
+
wait for confirmed delivery and a causal server version. SDK-owned requests share
|
|
210
|
+
bounded queues and time windows; Apps do not poll. `watchReaders(threadId,ids,receiver)`
|
|
211
|
+
selects receipt counts for the current viewport; clear with `watchReaders(undefined,[])`.
|
|
212
|
+
|
|
213
|
+
`ChatError` exposes `code`, `retryable`, optional HTTP `status`, and the original
|
|
214
|
+
`cause`. Recovery categories are `unavailable`, `access-denied`,
|
|
215
|
+
`storage-unavailable`, `invalid-input`, `conflict`, `closed`, and `busy`.
|
|
216
|
+
Permission failures never turn into trusted offline data. Dispose and recreate
|
|
217
|
+
the SDK when account, profile, team or token changes.
|
|
218
|
+
|
|
219
|
+
### Advanced integration and migration
|
|
220
|
+
|
|
221
|
+
`chat.advanced` exposes the underlying `ChatSession` for one-time legacy imports,
|
|
222
|
+
custom transports and diagnostics. Its `messages` are raw records, `timeline` is
|
|
223
|
+
the semantic projection, and `complete/resetVersion` describe coverage internals.
|
|
224
|
+
The older `createChat` export from `@pmate/chat-sdk/runtime` retains this low-level
|
|
225
|
+
contract for compatibility. New Apps should import from `@pmate/chat-sdk/chat`
|
|
226
|
+
or the package root. Do not mix the two snapshot contracts.
|
|
227
|
+
|
|
228
|
+
For migration, replace `subscribe(id, () => render(getSnapshot(id).timeline))`
|
|
229
|
+
with `subscribe(id, state => render(state.messages))`; replace conversation
|
|
230
|
+
objects and App-supplied recipients with `threadId`; await `getMessages` before
|
|
231
|
+
opening a cold thread instead of checking `page.complete`. Replace `initialize()`
|
|
232
|
+
with `start()`; a shared connection is initialized without another connect call.
|
|
233
|
+
Raw wire sends are available only through `advanced.sendMessage`. Group changes
|
|
234
|
+
use `createGroup/updateGroup`; UI never assigns reaction versions or recipients.
|
|
235
|
+
|
|
236
|
+
The advanced storage and transport classes remain exported from `/runtime`.
|
|
237
|
+
They must share one authenticated scope and must not bypass scope authority.
|
|
238
|
+
Normal UI code should not maintain another message cache or outbox.
|
|
258
239
|
|
|
259
240
|
### Reading storage recovery
|
|
260
241
|
|
|
@@ -282,29 +263,9 @@ Pending voice snapshots contain `voiceAttachment` metadata, without loading audi
|
|
|
282
263
|
The outbox format upgrade renames the old canonical store without copying binary data in the schema transaction, then atomically separates inline payloads in bounded, resumable transactions. An already-open older application must reload; unsupported senders reject the new format. Original wire/hash, failed status and retry semantics are retained.
|
|
283
264
|
|
|
284
265
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
await chat.sendMessage(group, {groupCreate: {title, members}})
|
|
292
|
-
await chat.sendMessage(group, {groupUpdate: {expectedRevision, addMembers}})
|
|
293
|
-
```
|
|
294
|
-
|
|
295
|
-
These calls retain durable-acceptance semantics. Recall and reaction validate the target against the current scoped timeline or canonical local cache; the App does not supply protocol bodies or reaction versions. Reaction counters and exact wires commit in one IndexedDB transaction, including across documents. Validation ordering does not wait for each preceding commit, so a same-key burst still reaches the bounded acceptance batches. Pass `{waitFor:"delivered"}` as the third argument when an action requires server confirmation; the default still resolves after durable acceptance.
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
Group metadata uses the same identity, cache and subscriptions:
|
|
299
|
-
|
|
300
|
-
```ts
|
|
301
|
-
const group = await chat.getGroup(conversation)
|
|
302
|
-
const off = chat.subscribe(conversation.id, () => {
|
|
303
|
-
renderGroup(chat.getSnapshot(conversation.id).group)
|
|
304
|
-
}, ["group"])
|
|
305
|
-
await chat.createGroup(conversation, {title, members})
|
|
306
|
-
await chat.updateGroup(conversation, {title: "New title"})
|
|
307
|
-
off()
|
|
308
|
-
```
|
|
309
|
-
|
|
310
|
-
Reads restore trusted IndexedDB metadata before background revalidation. Creation and updates wait for confirmed delivery and a causal server version, including newer concurrent revisions; Apps do not poll for an exact message hash. Group events and browser recovery share a 200ms window. At most four metadata requests run concurrently, with 128 queued requests, a five-second request timeout, and at most five read-after-write attempts. Per-scope persistent metadata is capped at 64 groups / 1MiB, with 128KiB records, 32-record write batches, conditional cross-document writes and bounded pruning. Cached metadata does not bypass scope withdrawal or server membership checks. Binary media remains on demand.
|
|
266
|
+
SDK group metadata uses a 200ms merge window, four concurrent requests, a bounded
|
|
267
|
+
128-request queue, five-second timeout and at most five read-after-write attempts.
|
|
268
|
+
The persistent group cache is capped at 64 groups / 1MiB, with 128KiB records,
|
|
269
|
+
32-record write batches, conditional writes and bounded pruning. Conversation
|
|
270
|
+
identities are stored separately without rosters; repeated registrations share
|
|
271
|
+
one write and concurrent lookups use bulk reads. Scope withdrawal retires both.
|
package/lib/chat.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { type CreateChatOptions } from "./runtime/createChat.js";
|
|
2
|
+
import type { ChatSession, ChatSummarySnapshot as SessionSummary, ChatThreadSnapshot as SessionThread } from "./runtime/chatSession.js";
|
|
3
|
+
import type { CachedChatMessage } from "./runtime/cachedMessage.js";
|
|
4
|
+
import type { TimelineItem } from "./runtime/messageTimeline.js";
|
|
5
|
+
import type { ConversationTarget } from "./runtime/conversationDirectory.js";
|
|
6
|
+
import type { OutgoingContent } from "./runtime/outgoingContent.js";
|
|
7
|
+
import { ChatError } from "./runtime/chatError.js";
|
|
8
|
+
export { ChatError } from "./runtime/chatError.js";
|
|
9
|
+
export type { ChatErrorCode } from "./runtime/chatError.js";
|
|
10
|
+
export type { CreateChatOptions, ConversationTarget, OutgoingContent };
|
|
11
|
+
export type { ChatGroup } from "./runtime/groupClient.js";
|
|
12
|
+
export type ChatMessage = TimelineItem<CachedChatMessage>;
|
|
13
|
+
export type ChatThreadSnapshot = {
|
|
14
|
+
threadId: string;
|
|
15
|
+
messages: readonly ChatMessage[];
|
|
16
|
+
ready: boolean;
|
|
17
|
+
hasEarlier: boolean;
|
|
18
|
+
unread: SessionThread<CachedChatMessage>["unread"];
|
|
19
|
+
group: SessionThread<CachedChatMessage>["group"];
|
|
20
|
+
readCounts: SessionThread<CachedChatMessage>["readCounts"];
|
|
21
|
+
error?: ChatError;
|
|
22
|
+
};
|
|
23
|
+
export type ChatSnapshot = {
|
|
24
|
+
threads: readonly {
|
|
25
|
+
threadId: string;
|
|
26
|
+
lastMessage?: ChatMessage;
|
|
27
|
+
group: SessionThread<CachedChatMessage>["group"];
|
|
28
|
+
unread: ChatThreadSnapshot["unread"];
|
|
29
|
+
outgoingCount: number;
|
|
30
|
+
}[];
|
|
31
|
+
unreadMessageCount: number | undefined;
|
|
32
|
+
badgeCount: number | undefined;
|
|
33
|
+
connection: SessionSummary<CachedChatMessage>["connection"];
|
|
34
|
+
};
|
|
35
|
+
export type MessageQuery = {
|
|
36
|
+
earlier?: boolean;
|
|
37
|
+
all?: boolean;
|
|
38
|
+
refresh?: boolean;
|
|
39
|
+
};
|
|
40
|
+
export type SendOptions = {
|
|
41
|
+
waitFor?: "accepted" | "delivered";
|
|
42
|
+
};
|
|
43
|
+
export type SentMessage = {
|
|
44
|
+
id: string;
|
|
45
|
+
timestamp: number;
|
|
46
|
+
};
|
|
47
|
+
type SubscribeOptions<T> = {
|
|
48
|
+
fields?: readonly (keyof T)[];
|
|
49
|
+
emitCurrent?: boolean;
|
|
50
|
+
};
|
|
51
|
+
/** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
|
|
52
|
+
export declare class Chat {
|
|
53
|
+
readonly advanced: ChatSession<CachedChatMessage>;
|
|
54
|
+
private options;
|
|
55
|
+
private views;
|
|
56
|
+
private summary?;
|
|
57
|
+
private errors;
|
|
58
|
+
private listeners;
|
|
59
|
+
private closed;
|
|
60
|
+
constructor(advanced: ChatSession<CachedChatMessage>, options: CreateChatOptions);
|
|
61
|
+
private operation;
|
|
62
|
+
start(): Promise<void>;
|
|
63
|
+
getConversation(target: ConversationTarget): Promise<string>;
|
|
64
|
+
getSnapshot(): ChatSnapshot;
|
|
65
|
+
getSnapshot(threadId: string): ChatThreadSnapshot;
|
|
66
|
+
getMessages(threadId: string, query?: MessageQuery): Promise<ChatThreadSnapshot>;
|
|
67
|
+
sendMessage(threadId: string, content: OutgoingContent, options?: SendOptions): Promise<SentMessage>;
|
|
68
|
+
retryMessage(messageId: string): Promise<SentMessage>;
|
|
69
|
+
subscribe(listener: (snapshot: ChatSnapshot) => void, options?: SubscribeOptions<ChatSnapshot>): () => void;
|
|
70
|
+
subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot) => void, options?: SubscribeOptions<ChatThreadSnapshot>): () => void;
|
|
71
|
+
private sessionFields;
|
|
72
|
+
private notify;
|
|
73
|
+
observeMessages(threadId: string, ids: string[], event: "visible" | "played"): Promise<import("./runtime/readIndex.js").ReadIndexState>;
|
|
74
|
+
markRead(threadId: string): Promise<import("./runtime/readIndex.js").ReadIndexState>;
|
|
75
|
+
getReaders(threadId: string, messageId: string, page?: Parameters<ChatSession<CachedChatMessage>["getReaders"]>[2], signal?: AbortSignal): Promise<import("./runtime/messageReaders.js").MessageReadersPage>;
|
|
76
|
+
watchReaders(threadId: string | undefined, ids: string[], receiver?: string): Promise<void>;
|
|
77
|
+
handleReach(event: Parameters<ChatSession<CachedChatMessage>["handleReach"]>[0]): Promise<"handled" | undefined>;
|
|
78
|
+
getVoiceAttachment(threadId: string, messageId: string): Promise<import("./runtime/durableSender.js").VoiceAttachmentLease>;
|
|
79
|
+
getGroup(threadId: string, options?: {
|
|
80
|
+
refresh?: boolean;
|
|
81
|
+
}): Promise<import("./chat.js").ChatGroup>;
|
|
82
|
+
createGroup(input: {
|
|
83
|
+
groupId?: string;
|
|
84
|
+
title: string;
|
|
85
|
+
members: readonly string[];
|
|
86
|
+
}): Promise<{
|
|
87
|
+
threadId: string;
|
|
88
|
+
groupId: string;
|
|
89
|
+
teamId: string;
|
|
90
|
+
ownerId: string;
|
|
91
|
+
title: string;
|
|
92
|
+
avatar?: string;
|
|
93
|
+
memberIds: readonly string[];
|
|
94
|
+
revision: string;
|
|
95
|
+
versionNumber: number;
|
|
96
|
+
}>;
|
|
97
|
+
updateGroup(threadId: string, changes: Parameters<ChatSession<CachedChatMessage>["updateGroup"]>[1]): Promise<import("./chat.js").ChatGroup>;
|
|
98
|
+
revokeAccess(): Promise<void>;
|
|
99
|
+
close(): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
export declare function createChat(options: CreateChatOptions): Chat;
|
package/lib/chat.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createChat as createSession } from "./runtime/createChat.js";
|
|
2
|
+
import { asChatError, ChatError } from "./runtime/chatError.js";
|
|
3
|
+
export { ChatError } from "./runtime/chatError.js";
|
|
4
|
+
const emptyMessages = Object.freeze([]);
|
|
5
|
+
/** App-facing API. Cache coverage, raw protocol and synchronization stay in advanced. */
|
|
6
|
+
export class Chat {
|
|
7
|
+
advanced;
|
|
8
|
+
options;
|
|
9
|
+
views = new Map();
|
|
10
|
+
summary;
|
|
11
|
+
errors = new Map();
|
|
12
|
+
listeners = new Map();
|
|
13
|
+
closed = false;
|
|
14
|
+
constructor(advanced, options) {
|
|
15
|
+
this.advanced = advanced;
|
|
16
|
+
this.options = options;
|
|
17
|
+
}
|
|
18
|
+
async operation(run) { try {
|
|
19
|
+
return await run();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
throw asChatError(error);
|
|
23
|
+
} }
|
|
24
|
+
start() { return this.operation(() => this.options.connection ? this.advanced.initialize() : this.advanced.start()); }
|
|
25
|
+
getConversation(target) { return this.operation(() => this.advanced.getConversation(target)); }
|
|
26
|
+
getSnapshot(threadId) {
|
|
27
|
+
if (threadId === undefined) {
|
|
28
|
+
const source = this.advanced.getSnapshot(), previous = this.summary;
|
|
29
|
+
const threads = source.threads.map(thread => ({ threadId: thread.threadId, lastMessage: thread.lastItem, group: thread.group, unread: thread.unread, outgoingCount: thread.outgoingCount }));
|
|
30
|
+
if (previous && previous.connection === source.connection && previous.badgeCount === source.badgeCount && previous.unreadMessageCount === source.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; }))
|
|
31
|
+
return previous;
|
|
32
|
+
return this.summary = { threads, connection: source.connection, badgeCount: source.badgeCount, unreadMessageCount: source.unreadMessageCount };
|
|
33
|
+
}
|
|
34
|
+
const source = this.advanced.getSnapshot(threadId), previous = this.views.get(threadId);
|
|
35
|
+
// Partial migrated rows never masquerade as a trusted recent window.
|
|
36
|
+
const next = { threadId, messages: source.complete ? source.timeline : emptyMessages, ready: source.complete, hasEarlier: source.hasEarlier, unread: source.unread, group: source.group, readCounts: source.readCounts, error: this.closed ? undefined : this.errors.get(threadId) };
|
|
37
|
+
if (previous && Object.keys(next).every(key => next[key] === previous[key]))
|
|
38
|
+
return previous;
|
|
39
|
+
this.views.set(threadId, next);
|
|
40
|
+
return next;
|
|
41
|
+
}
|
|
42
|
+
async getMessages(threadId, query = {}) {
|
|
43
|
+
try {
|
|
44
|
+
if (Object.keys(query).some(key => !["earlier", "all", "refresh"].includes(key)))
|
|
45
|
+
throw new ChatError("invalid-input", "Unsupported message query");
|
|
46
|
+
const page = await this.advanced.getMessages(threadId, query);
|
|
47
|
+
if (!page.complete)
|
|
48
|
+
throw new ChatError("unavailable", "Messages are not ready; retry when connected");
|
|
49
|
+
this.errors.delete(threadId);
|
|
50
|
+
this.notify(threadId);
|
|
51
|
+
return this.getSnapshot(threadId);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const failure = asChatError(error);
|
|
55
|
+
if (!this.closed) {
|
|
56
|
+
this.errors.set(threadId, failure);
|
|
57
|
+
this.notify(threadId);
|
|
58
|
+
}
|
|
59
|
+
throw failure;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
sendMessage(threadId, content, options = {}) {
|
|
63
|
+
return this.operation(async () => { const result = await this.advanced.sendMessage(threadId, content, options); return { id: result.hash, timestamp: result.t }; });
|
|
64
|
+
}
|
|
65
|
+
retryMessage(messageId) { return this.operation(async () => { const result = await this.advanced.retryMessage(messageId); return { id: result.hash, timestamp: result.t }; }); }
|
|
66
|
+
subscribe(target, callback, options = {}) {
|
|
67
|
+
const global = typeof target === "function", key = global ? "" : target;
|
|
68
|
+
const config = global ? callback : options;
|
|
69
|
+
const read = () => global ? this.getSnapshot() : this.getSnapshot(key);
|
|
70
|
+
let previous = read();
|
|
71
|
+
const emit = () => {
|
|
72
|
+
const value = read();
|
|
73
|
+
const fields = config?.fields;
|
|
74
|
+
const changed = fields ? fields.some(field => previous[field] !== value[field]) : value !== previous;
|
|
75
|
+
previous = value;
|
|
76
|
+
if (changed)
|
|
77
|
+
(global ? target : callback)(value);
|
|
78
|
+
};
|
|
79
|
+
let listeners = this.listeners.get(key);
|
|
80
|
+
if (!listeners) {
|
|
81
|
+
listeners = new Set();
|
|
82
|
+
this.listeners.set(key, listeners);
|
|
83
|
+
}
|
|
84
|
+
listeners.add(emit);
|
|
85
|
+
const off = global ? this.advanced.subscribeChanges(emit) : this.advanced.subscribeChanges(key, emit, this.sessionFields(options.fields));
|
|
86
|
+
const dispose = () => { off(); listeners.delete(emit); if (!listeners.size)
|
|
87
|
+
this.listeners.delete(key); };
|
|
88
|
+
if (config?.emitCurrent !== false) {
|
|
89
|
+
try {
|
|
90
|
+
(global ? target : callback)(previous);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
dispose();
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return dispose;
|
|
98
|
+
}
|
|
99
|
+
sessionFields(fields) {
|
|
100
|
+
return fields ? [...new Set(fields.flatMap(field => field === "messages" ? ["timeline", "complete"] : field === "ready" ? ["complete"] : field === "error" || field === "threadId" ? [] : [field]))] : undefined;
|
|
101
|
+
}
|
|
102
|
+
notify(threadId) { this.listeners.get(threadId)?.forEach(listener => listener()); }
|
|
103
|
+
observeMessages(threadId, ids, event) { return this.operation(() => this.advanced.observeMessages(threadId, ids, event)); }
|
|
104
|
+
markRead(threadId) { return this.operation(() => this.advanced.markRead(threadId)); }
|
|
105
|
+
getReaders(threadId, messageId, page = {}, signal) { return this.operation(() => this.advanced.getReaders(threadId, messageId, page, signal)); }
|
|
106
|
+
watchReaders(threadId, ids, receiver) { return this.operation(() => this.advanced.observeMessages(threadId, ids, "receipts", receiver)); }
|
|
107
|
+
handleReach(event) { return this.operation(() => this.advanced.handleReach(event)); }
|
|
108
|
+
getVoiceAttachment(threadId, messageId) { return this.operation(() => this.advanced.getVoiceAttachment(threadId, messageId)); }
|
|
109
|
+
getGroup(threadId, options = {}) { return this.operation(() => this.advanced.getGroup(threadId, options)); }
|
|
110
|
+
createGroup(input) {
|
|
111
|
+
return this.operation(async () => {
|
|
112
|
+
const groupId = input.groupId ?? crypto.randomUUID(), threadId = await this.getConversation({ groupId });
|
|
113
|
+
const group = await this.advanced.createGroup({ id: threadId, kind: "group", targetId: groupId, teamId: this.options.teamId }, { title: input.title, members: input.members });
|
|
114
|
+
return { ...group, threadId };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
updateGroup(threadId, changes) { return this.operation(() => this.advanced.updateGroup(threadId, changes)); }
|
|
118
|
+
revokeAccess() { return this.operation(() => this.advanced.revokeAccess()); }
|
|
119
|
+
close() { this.closed = true; return this.operation(() => this.advanced.close()).finally(() => { this.errors.clear(); this.listeners.clear(); this.views.clear(); this.summary = undefined; }); }
|
|
120
|
+
}
|
|
121
|
+
export function createChat(options) {
|
|
122
|
+
const configured = { ...options, onError: (error) => options.onError(asChatError(error)) };
|
|
123
|
+
return new Chat(createSession(configured), configured);
|
|
124
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Chat, ChatSnapshot, ChatThreadSnapshot } from "../chat.js";
|
|
2
|
+
/** React consumes the same stable view as callback subscribers. No App-side cache. */
|
|
3
|
+
export declare function useChatSnapshot(chat: Chat | undefined): ChatSnapshot;
|
|
4
|
+
export declare function useChatSnapshot(chat: Chat | undefined, threadId: string): ChatThreadSnapshot;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
2
|
+
const emptySummary = Object.freeze({ threads: [], unreadMessageCount: undefined, badgeCount: undefined, connection: "closed" });
|
|
3
|
+
const emptyThread = Object.freeze({ threadId: "", messages: [], ready: false, hasEarlier: false, unread: undefined, group: undefined, readCounts: undefined });
|
|
4
|
+
export function useChatSnapshot(chat, threadId) {
|
|
5
|
+
const subscribe = useCallback((notify) => !chat ? () => { } : threadId === undefined ? chat.subscribe(notify, { emitCurrent: false }) : chat.subscribe(threadId, notify, { emitCurrent: false }), [chat, threadId]);
|
|
6
|
+
const snapshot = useCallback(() => threadId === undefined ? chat?.getSnapshot() ?? emptySummary : chat?.getSnapshot(threadId) ?? emptyThread, [chat, threadId]);
|
|
7
|
+
return useSyncExternalStore(subscribe, snapshot, () => threadId === undefined ? emptySummary : emptyThread);
|
|
8
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -41,3 +41,5 @@ export * from "./runtime/socialReadClient";
|
|
|
41
41
|
export * from "./ux/imagePaste";
|
|
42
42
|
export * from "./hooks/useImageAttachments";
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
|
+
export { createChat, Chat, ChatError, type ChatThreadSnapshot, type ChatMessage as MessageView, type ChatErrorCode, type ConversationTarget, type OutgoingContent, type CreateChatOptions, type ChatSnapshot, type SentMessage, type MessageQuery } from "./chat.js";
|
|
45
|
+
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
package/lib/index.js
CHANGED
|
@@ -41,3 +41,5 @@ export * from "./runtime/socialReadClient";
|
|
|
41
41
|
export * from "./ux/imagePaste";
|
|
42
42
|
export * from "./hooks/useImageAttachments";
|
|
43
43
|
export * from "./components/ImageAttachmentTray";
|
|
44
|
+
export { createChat, Chat, ChatError } from "./chat.js";
|
|
45
|
+
export { useChatSnapshot } from "./hooks/useChatSnapshot.js";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type ChatErrorCode = "unavailable" | "access-denied" | "storage-unavailable" | "invalid-input" | "conflict" | "closed" | "busy";
|
|
2
|
+
/** Stable recovery categories. The original failure remains available as cause. */
|
|
3
|
+
export declare class ChatError extends Error {
|
|
4
|
+
readonly code: ChatErrorCode;
|
|
5
|
+
readonly retryable: boolean;
|
|
6
|
+
readonly status?: number;
|
|
7
|
+
constructor(code: ChatErrorCode, message: string, options?: {
|
|
8
|
+
cause?: unknown;
|
|
9
|
+
status?: number;
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
export declare function asChatError(error: unknown): ChatError;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Stable recovery categories. The original failure remains available as cause. */
|
|
2
|
+
export class ChatError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
retryable;
|
|
5
|
+
status;
|
|
6
|
+
constructor(code, message, options = {}) {
|
|
7
|
+
super(message, { cause: options.cause });
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = "ChatError";
|
|
10
|
+
this.status = options.status;
|
|
11
|
+
this.retryable = ["unavailable", "storage-unavailable", "busy"].includes(code);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function asChatError(error) {
|
|
15
|
+
if (error instanceof ChatError)
|
|
16
|
+
return error;
|
|
17
|
+
const value = error;
|
|
18
|
+
const status = value?.status, message = value?.message ?? String(error);
|
|
19
|
+
const code = status === 401 || status === 403 || /ACCESS_REVOKED|ACCESS_DENIED|SCOPE_CHANGED/.test(message) ? "access-denied"
|
|
20
|
+
: /CLOSED/.test(message) ? "closed"
|
|
21
|
+
: status === 409 || /REVISION_CONFLICT|PROJECTION_CHANGED/.test(message) ? "conflict"
|
|
22
|
+
: /BACKPRESSURE|TOO_LARGE/.test(message) ? "busy"
|
|
23
|
+
: status === 400 || /INVALID_|CONVERSATION_MISMATCH|NOT_OWNED/.test(message) ? "invalid-input"
|
|
24
|
+
: /QuotaExceeded|DatabaseClosed|UnknownError|AbortError/.test(value?.name ?? "") || /STORAGE_|DATABASE_|CACHE_WRITE/.test(message) ? "storage-unavailable" : "unavailable";
|
|
25
|
+
return new ChatError(code, message, { cause: error, status });
|
|
26
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ConversationTarget } from "./conversationDirectory.js";
|
|
1
2
|
import { type ChatGroup } from "./groupClient.js";
|
|
2
3
|
import type { SocialGroup } from "./socialReadClient.js";
|
|
3
4
|
import { type ChatConversation, type OutgoingContent } from "./outgoingContent.js";
|
|
@@ -55,6 +56,7 @@ export type ChatSummarySnapshot<T> = {
|
|
|
55
56
|
/** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
|
|
56
57
|
export declare class ChatSession<T extends StoredMessage> {
|
|
57
58
|
private options;
|
|
59
|
+
private conversations;
|
|
58
60
|
private messages;
|
|
59
61
|
private connection;
|
|
60
62
|
private reading;
|
|
@@ -132,26 +134,33 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
132
134
|
private readEarlier;
|
|
133
135
|
private readAll;
|
|
134
136
|
/** Reads trusted local group metadata immediately; SDK refreshes it in the background. */
|
|
135
|
-
getGroup(thread: ChatConversation, options?: {
|
|
137
|
+
getGroup(thread: ChatConversation | string, options?: {
|
|
136
138
|
refresh?: boolean;
|
|
137
139
|
}): Promise<ChatGroup>;
|
|
138
140
|
createGroup(thread: ChatConversation, content: {
|
|
139
141
|
title: string;
|
|
140
142
|
members: readonly string[];
|
|
141
143
|
}): Promise<ChatGroup>;
|
|
142
|
-
updateGroup(
|
|
144
|
+
updateGroup(target: ChatConversation | string, changes: {
|
|
143
145
|
expectedRevision?: string;
|
|
144
146
|
addMembers?: readonly string[];
|
|
145
147
|
removeMembers?: readonly string[];
|
|
146
148
|
title?: string;
|
|
147
149
|
avatar?: string;
|
|
148
150
|
}): Promise<ChatGroup>;
|
|
151
|
+
sendMessage(threadId: string, input: OutgoingContent, options?: {
|
|
152
|
+
waitFor?: "accepted" | "delivered";
|
|
153
|
+
}): Promise<ChatRuntimeMessage>;
|
|
149
154
|
sendMessage(thread: ChatConversation, input: OutgoingContent, options?: {
|
|
150
155
|
waitFor?: "accepted" | "delivered";
|
|
151
156
|
}): Promise<ChatRuntimeMessage>;
|
|
152
157
|
sendMessage(threadId: string, input: SendMessageInput, options?: {
|
|
153
158
|
waitFor?: "accepted" | "delivered";
|
|
154
159
|
}): Promise<ChatRuntimeMessage>;
|
|
160
|
+
private sendWire;
|
|
161
|
+
getConversation(target: ConversationTarget): Promise<string>;
|
|
162
|
+
private sendContent;
|
|
163
|
+
private resolveConversation;
|
|
155
164
|
private sendControl;
|
|
156
165
|
getReaders(threadId: string, messageId: string, page?: Omit<MessageReadersQuery, "threadId" | "messageId">, signal?: AbortSignal): Promise<import("./messageReaders.js").MessageReadersPage>;
|
|
157
166
|
/** Resolve voice media uniformly; pending local bytes are loaded only on demand. */
|
|
@@ -168,8 +177,12 @@ export declare class ChatSession<T extends StoredMessage> {
|
|
|
168
177
|
getSnapshot(): ChatSummarySnapshot<T>;
|
|
169
178
|
getSnapshot(threadId: string): ChatThreadSnapshot<T>;
|
|
170
179
|
private buildThreadSnapshot;
|
|
171
|
-
|
|
172
|
-
subscribe(
|
|
180
|
+
/** Delivers the current stable snapshot immediately, then only meaningful changes. */
|
|
181
|
+
subscribe(listener: (snapshot: ChatSummarySnapshot<T>) => void): () => void;
|
|
182
|
+
subscribe(threadId: string, listener: (snapshot: ChatThreadSnapshot<T>) => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
|
|
183
|
+
/** Change-only bridge for useSyncExternalStore and other framework adapters. */
|
|
184
|
+
subscribeChanges(listener: () => void): () => void;
|
|
185
|
+
subscribeChanges(threadId: string, listener: () => void, fields?: readonly (keyof ChatThreadSnapshot<T>)[]): () => void;
|
|
173
186
|
private publishAll;
|
|
174
187
|
private buildGlobal;
|
|
175
188
|
private publishGlobal;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { ConversationDirectory } from "./conversationDirectory.js";
|
|
1
2
|
import { groupThreadId } from "./thread.js";
|
|
2
3
|
import { GroupClient } from "./groupClient.js";
|
|
3
4
|
import { isReadingControl } from "./readingContent.js";
|
|
4
|
-
import { outgoingContent } from "./outgoingContent.js";
|
|
5
|
+
import { outgoingContent, validateConversation } from "./outgoingContent.js";
|
|
5
6
|
import { projectMessageTimeline } from "./messageTimeline.js";
|
|
6
7
|
import { projectConversationReading } from "./conversationReading.js";
|
|
7
8
|
import { ReadCountsClient } from "./readCounts.js";
|
|
@@ -15,6 +16,7 @@ import { unreadInConversation } from "./readIndex.js";
|
|
|
15
16
|
/** Owns all browser message clients. Hosts subscribe to stable thread snapshots. */
|
|
16
17
|
export class ChatSession {
|
|
17
18
|
options;
|
|
19
|
+
conversations;
|
|
18
20
|
messages;
|
|
19
21
|
connection;
|
|
20
22
|
reading;
|
|
@@ -68,6 +70,7 @@ export class ChatSession {
|
|
|
68
70
|
throw new Error("CHAT_SESSION_SCOPE_MISMATCH");
|
|
69
71
|
}
|
|
70
72
|
this.messages = new MessageClient(options.messages);
|
|
73
|
+
this.conversations = new ConversationDirectory(scope, () => this.messages.scopeAccess());
|
|
71
74
|
this.connection = options.sharedConnection ?? new ChatClient({ ...options.connection, onReach: async (event) => {
|
|
72
75
|
const handled = await this.handleReach(event);
|
|
73
76
|
if (handled)
|
|
@@ -379,11 +382,11 @@ export class ChatSession {
|
|
|
379
382
|
throw new Error("MESSAGE_SYNC_RESET_REQUIRED");
|
|
380
383
|
}
|
|
381
384
|
/** Reads trusted local group metadata immediately; SDK refreshes it in the background. */
|
|
382
|
-
getGroup(thread, options = {}) {
|
|
385
|
+
async getGroup(thread, options = {}) {
|
|
383
386
|
this.assertOpen();
|
|
384
387
|
if (!this.groups)
|
|
385
388
|
throw new Error("GROUP_METADATA_UNAVAILABLE");
|
|
386
|
-
return this.groups.get(thread, options);
|
|
389
|
+
return this.groups.get(typeof thread === "string" ? await this.resolveConversation(thread) : thread, options);
|
|
387
390
|
}
|
|
388
391
|
async createGroup(thread, content) {
|
|
389
392
|
this.assertOpen();
|
|
@@ -392,7 +395,8 @@ export class ChatSession {
|
|
|
392
395
|
await this.sendMessage(thread, { groupCreate: content }, { waitFor: "delivered" });
|
|
393
396
|
return this.groups.after(thread, 1);
|
|
394
397
|
}
|
|
395
|
-
async updateGroup(
|
|
398
|
+
async updateGroup(target, changes) {
|
|
399
|
+
const thread = typeof target === "string" ? await this.resolveConversation(target) : target;
|
|
396
400
|
const previous = await this.getGroup(thread);
|
|
397
401
|
if (previous.versionNumber >= Number.MAX_SAFE_INTEGER)
|
|
398
402
|
throw new Error("INVALID_GROUP_VERSION");
|
|
@@ -403,14 +407,54 @@ export class ChatSession {
|
|
|
403
407
|
this.assertOpen();
|
|
404
408
|
if (options.waitFor !== undefined && !["accepted", "delivered"].includes(options.waitFor))
|
|
405
409
|
throw new Error("INVALID_MESSAGE_WAIT");
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
410
|
+
if (typeof thread === "string" && Object.hasOwn(input, "body"))
|
|
411
|
+
return this.sendWire(thread, input, options.waitFor === "delivered");
|
|
412
|
+
return this.sendContent(thread, input, options.waitFor === "delivered");
|
|
413
|
+
}
|
|
414
|
+
sendWire(threadId, wire, delivered) {
|
|
411
415
|
return (delivered ? this.sender.send(threadId, wire) : this.sender.enqueue(threadId, wire)).then(next => { if (this.withdrawn)
|
|
412
416
|
throw new Error("CHAT_ACCESS_REVOKED"); return next; });
|
|
413
417
|
}
|
|
418
|
+
getConversation(target) { this.assertOpen(); return this.conversations.create(target); }
|
|
419
|
+
async sendContent(target, input, delivered) {
|
|
420
|
+
let thread = typeof target === "string" ? await this.resolveConversation(target) : target;
|
|
421
|
+
validateConversation(this.options.messages.scope, thread);
|
|
422
|
+
await this.conversations.put(thread);
|
|
423
|
+
this.assertOpen();
|
|
424
|
+
// Recipients are SDK-owned metadata, never an App-supplied roster.
|
|
425
|
+
if (thread.kind === "group" && !input.groupCreate && !input.groupUpdate) {
|
|
426
|
+
const group = await this.getGroup(thread);
|
|
427
|
+
this.assertOpen();
|
|
428
|
+
thread = { ...thread, memberIds: group.memberIds };
|
|
429
|
+
}
|
|
430
|
+
const wire = outgoingContent(this.options.messages.scope, thread, input);
|
|
431
|
+
if (input.recall || input.reaction)
|
|
432
|
+
return this.sendControl({ ...thread, memberIds: wire.recipients }, wire, delivered);
|
|
433
|
+
return this.sendWire(thread.id, wire, delivered);
|
|
434
|
+
}
|
|
435
|
+
async resolveConversation(threadId) {
|
|
436
|
+
const known = await this.conversations.get(threadId);
|
|
437
|
+
if (known)
|
|
438
|
+
return known;
|
|
439
|
+
const scope = this.options.messages.scope;
|
|
440
|
+
const group = this.getSnapshot(threadId).group;
|
|
441
|
+
if (group)
|
|
442
|
+
return { id: threadId, kind: "group", targetId: group.groupId, teamId: scope.teamId };
|
|
443
|
+
for (const value of this.getSnapshot(threadId).messages) {
|
|
444
|
+
const row = value;
|
|
445
|
+
const kind = row.threadKind ?? row.body?.threadKind;
|
|
446
|
+
if ((kind !== "group" && kind !== "dm") || !row.to || !row.from)
|
|
447
|
+
continue;
|
|
448
|
+
const thread = { id: threadId, kind, targetId: kind === "group" || row.from === scope.profileId ? row.to : row.from, teamId: scope.teamId };
|
|
449
|
+
// A stored row is only a hint; validate the canonical scoped identifier.
|
|
450
|
+
try {
|
|
451
|
+
validateConversation(scope, thread);
|
|
452
|
+
return thread;
|
|
453
|
+
}
|
|
454
|
+
catch { }
|
|
455
|
+
}
|
|
456
|
+
throw new Error("MESSAGE_CONVERSATION_UNKNOWN");
|
|
457
|
+
}
|
|
414
458
|
sendControl(thread, wire, delivered = false) {
|
|
415
459
|
const content = wire.body.content;
|
|
416
460
|
const recall = content.recall, reaction = content.reaction;
|
|
@@ -643,6 +687,33 @@ export class ChatSession {
|
|
|
643
687
|
return { group: this.groups?.snapshot(threadId).data, timeline, messages, hasEarlier: source?.hasEarlier ?? false, resetVersion: source?.resetVersion ?? 0, complete: source?.complete ?? false, outgoing, readCounts: this.countSnapshots.get(threadId), unread, messageReading };
|
|
644
688
|
}
|
|
645
689
|
subscribe(target, listener, fields) {
|
|
690
|
+
if (typeof target === "function") {
|
|
691
|
+
const notify = () => target(this.getSnapshot());
|
|
692
|
+
const off = this.subscribeChanges(notify);
|
|
693
|
+
try {
|
|
694
|
+
notify();
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
off();
|
|
698
|
+
throw error;
|
|
699
|
+
}
|
|
700
|
+
return off;
|
|
701
|
+
}
|
|
702
|
+
const notify = () => listener(this.getSnapshot(target));
|
|
703
|
+
const off = this.subscribeChanges(target, notify, fields);
|
|
704
|
+
// The deprecated field-array form retains change-only behavior during migration.
|
|
705
|
+
if (!fields) {
|
|
706
|
+
try {
|
|
707
|
+
notify();
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
off();
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
return off;
|
|
715
|
+
}
|
|
716
|
+
subscribeChanges(target, listener, fields) {
|
|
646
717
|
if (this.closed || this.withdrawn)
|
|
647
718
|
return () => { };
|
|
648
719
|
if (typeof target === "function") {
|
|
@@ -666,7 +737,7 @@ export class ChatSession {
|
|
|
666
737
|
this.listeners.set(threadId, listeners);
|
|
667
738
|
}
|
|
668
739
|
listeners.add(notify);
|
|
669
|
-
const off = this.messages.subscribe(threadId, () => { }), unpin = this.groups?.pin(threadId);
|
|
740
|
+
const off = this.messages.subscribe(threadId, () => { }), unpin = (!selected || selected.includes("group")) ? this.groups?.pin(threadId) : undefined;
|
|
670
741
|
return () => {
|
|
671
742
|
off();
|
|
672
743
|
unpin?.();
|
|
@@ -771,7 +842,7 @@ export class ChatSession {
|
|
|
771
842
|
const results = await Promise.allSettled([
|
|
772
843
|
this.sender.revoke(version),
|
|
773
844
|
this.reading.revokeTeam(this.options.messages.scope.teamId, version, knownThreads),
|
|
774
|
-
this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version),
|
|
845
|
+
this.readers.retireScope(version), this.counts?.retireScope(version), this.groups?.retire(version), this.conversations.retire(version),
|
|
775
846
|
]);
|
|
776
847
|
for (const result of results)
|
|
777
848
|
if (result.status === "rejected")
|
|
@@ -799,7 +870,7 @@ export class ChatSession {
|
|
|
799
870
|
const readingBarrier = Promise.allSettled([...this.acceptedReading, ...this.acceptedControls]);
|
|
800
871
|
const readingClosing = readingBarrier.then(() => this.reading.close());
|
|
801
872
|
try {
|
|
802
|
-
const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), groupsClosing, senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, groupsClosing, readingBarrier]))]);
|
|
873
|
+
const results = await Promise.allSettled([this.counts?.close(), ...this.countClosings, this.readers.close(), this.conversations.close(), groupsClosing, senderClosing, readingClosing, this.messages.close(Promise.allSettled([senderClosing, groupsClosing, readingBarrier]))]);
|
|
803
874
|
const failed = results.find((result) => result.status === "rejected");
|
|
804
875
|
if (failed)
|
|
805
876
|
throw failed.reason;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ResponseCacheAccess } from "./responseCache.js";
|
|
2
|
+
import { type MessageScope } from "./messageStore.js";
|
|
3
|
+
import { type ChatConversation } from "./outgoingContent.js";
|
|
4
|
+
export type ConversationTarget = {
|
|
5
|
+
peerId: string;
|
|
6
|
+
groupId?: never;
|
|
7
|
+
} | {
|
|
8
|
+
groupId: string;
|
|
9
|
+
peerId?: never;
|
|
10
|
+
};
|
|
11
|
+
/** Canonical target identities only; rosters always belong to GroupClient. */
|
|
12
|
+
export declare class ConversationDirectory {
|
|
13
|
+
private scope;
|
|
14
|
+
private access;
|
|
15
|
+
private cache;
|
|
16
|
+
private memory;
|
|
17
|
+
private pending;
|
|
18
|
+
private queue;
|
|
19
|
+
private writes;
|
|
20
|
+
private version;
|
|
21
|
+
private scheduled;
|
|
22
|
+
private closed;
|
|
23
|
+
private retired;
|
|
24
|
+
constructor(scope: MessageScope, access: ResponseCacheAccess);
|
|
25
|
+
private assertOpen;
|
|
26
|
+
private remember;
|
|
27
|
+
create(target: ConversationTarget): Promise<string>;
|
|
28
|
+
put(thread: ChatConversation): Promise<ChatConversation>;
|
|
29
|
+
private persist;
|
|
30
|
+
get(id: string): Promise<ChatConversation | undefined>;
|
|
31
|
+
private flush;
|
|
32
|
+
retire(version: number): Promise<void>;
|
|
33
|
+
close(): Promise<void>;
|
|
34
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { IndexedDbResponseCache } from "./responseCache.js";
|
|
2
|
+
import { messageScopeKey } from "./messageStore.js";
|
|
3
|
+
import { validateConversation } from "./outgoingContent.js";
|
|
4
|
+
import { dmThreadId, groupThreadId } from "./thread.js";
|
|
5
|
+
/** Canonical target identities only; rosters always belong to GroupClient. */
|
|
6
|
+
export class ConversationDirectory {
|
|
7
|
+
scope;
|
|
8
|
+
access;
|
|
9
|
+
cache;
|
|
10
|
+
memory = new Map();
|
|
11
|
+
pending = new Map();
|
|
12
|
+
queue = [];
|
|
13
|
+
writes = new Map();
|
|
14
|
+
version;
|
|
15
|
+
scheduled = false;
|
|
16
|
+
closed = false;
|
|
17
|
+
retired = false;
|
|
18
|
+
constructor(scope, access) {
|
|
19
|
+
this.scope = scope;
|
|
20
|
+
this.access = access;
|
|
21
|
+
this.cache = new IndexedDbResponseCache(`conversations:${messageScopeKey(scope)}`, { access, entries: 256, recordBytes: 4096 });
|
|
22
|
+
}
|
|
23
|
+
assertOpen() { if (this.retired)
|
|
24
|
+
throw new Error("CHAT_ACCESS_REVOKED"); if (this.closed)
|
|
25
|
+
throw new Error("CHAT_SESSION_CLOSED"); }
|
|
26
|
+
remember(thread) {
|
|
27
|
+
this.memory.delete(thread.id);
|
|
28
|
+
this.memory.set(thread.id, thread);
|
|
29
|
+
if (this.memory.size > 256)
|
|
30
|
+
this.memory.delete(this.memory.keys().next().value);
|
|
31
|
+
return thread;
|
|
32
|
+
}
|
|
33
|
+
async create(target) {
|
|
34
|
+
const kind = target.groupId !== undefined ? "group" : "dm", targetId = target.groupId ?? target.peerId;
|
|
35
|
+
if (target.groupId !== undefined && target.peerId !== undefined)
|
|
36
|
+
throw new Error("INVALID_MESSAGE_CONVERSATION");
|
|
37
|
+
const id = kind === "group" ? groupThreadId(this.scope.appId, targetId, this.scope.teamId) : dmThreadId(this.scope.appId, this.scope.profileId, targetId, this.scope.teamId);
|
|
38
|
+
await this.put({ id, kind, targetId, teamId: this.scope.teamId });
|
|
39
|
+
return id;
|
|
40
|
+
}
|
|
41
|
+
put(thread) {
|
|
42
|
+
this.assertOpen();
|
|
43
|
+
validateConversation(this.scope, thread);
|
|
44
|
+
const existing = this.writes.get(thread.id);
|
|
45
|
+
if (existing)
|
|
46
|
+
return existing;
|
|
47
|
+
if (this.writes.size >= 128)
|
|
48
|
+
return Promise.reject(new Error("CONVERSATION_BACKPRESSURE"));
|
|
49
|
+
const task = this.persist(thread).finally(() => this.writes.delete(thread.id));
|
|
50
|
+
this.writes.set(thread.id, task);
|
|
51
|
+
return task;
|
|
52
|
+
}
|
|
53
|
+
async persist(thread) {
|
|
54
|
+
this.assertOpen();
|
|
55
|
+
validateConversation(this.scope, thread);
|
|
56
|
+
const value = { id: thread.id, kind: thread.kind, targetId: thread.targetId, teamId: this.scope.teamId };
|
|
57
|
+
const previous = await this.get(thread.id);
|
|
58
|
+
if (previous) {
|
|
59
|
+
if (previous.kind !== value.kind || previous.targetId !== value.targetId)
|
|
60
|
+
throw new Error("MESSAGE_CONVERSATION_MISMATCH");
|
|
61
|
+
return previous;
|
|
62
|
+
}
|
|
63
|
+
const row = await this.cache.read(thread.id);
|
|
64
|
+
await this.cache.write(thread.id, value, row.epoch);
|
|
65
|
+
this.assertOpen();
|
|
66
|
+
return this.remember(value);
|
|
67
|
+
}
|
|
68
|
+
async get(id) {
|
|
69
|
+
this.assertOpen();
|
|
70
|
+
const authority = await this.access();
|
|
71
|
+
this.assertOpen();
|
|
72
|
+
if (!authority.allowed)
|
|
73
|
+
throw new Error("CHAT_ACCESS_REVOKED");
|
|
74
|
+
if (this.version !== undefined && this.version !== authority.version)
|
|
75
|
+
this.memory.clear();
|
|
76
|
+
this.version = authority.version;
|
|
77
|
+
const known = this.memory.get(id);
|
|
78
|
+
if (known)
|
|
79
|
+
return this.remember(known);
|
|
80
|
+
const existing = this.pending.get(id);
|
|
81
|
+
if (existing)
|
|
82
|
+
return existing;
|
|
83
|
+
if (this.pending.size >= 128)
|
|
84
|
+
throw new Error("CONVERSATION_BACKPRESSURE");
|
|
85
|
+
const task = new Promise((resolve, reject) => this.queue.push({ id, resolve, reject })).finally(() => this.pending.delete(id));
|
|
86
|
+
this.pending.set(id, task);
|
|
87
|
+
if (!this.scheduled) {
|
|
88
|
+
this.scheduled = true;
|
|
89
|
+
queueMicrotask(() => { this.scheduled = false; void this.flush(); });
|
|
90
|
+
}
|
|
91
|
+
return task;
|
|
92
|
+
}
|
|
93
|
+
async flush() {
|
|
94
|
+
const batch = this.queue.splice(0, 128);
|
|
95
|
+
try {
|
|
96
|
+
const rows = await this.cache.readMany(batch.map(item => item.id));
|
|
97
|
+
this.assertOpen();
|
|
98
|
+
for (const item of batch) {
|
|
99
|
+
const value = rows.get(item.id)?.cached?.value;
|
|
100
|
+
if (value) {
|
|
101
|
+
if (value.id !== item.id)
|
|
102
|
+
throw new Error("MESSAGE_CONVERSATION_MISMATCH");
|
|
103
|
+
validateConversation(this.scope, value);
|
|
104
|
+
}
|
|
105
|
+
item.resolve(value ? this.remember(value) : undefined);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
batch.forEach(item => item.reject(error));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
retire(version) { this.retired = true; this.memory.clear(); return this.cache.retire(version); }
|
|
113
|
+
async close() { this.closed = true; this.memory.clear(); await Promise.allSettled([...this.pending.values(), ...this.writes.values()]); await this.cache.close(); }
|
|
114
|
+
}
|
package/lib/runtime/index.d.ts
CHANGED
|
@@ -35,3 +35,4 @@ export { projectMessageTimeline, type TimelineItem, type TimelineReaction, type
|
|
|
35
35
|
export * from "./messageLaunch.js";
|
|
36
36
|
export * from "./outgoingContent.js";
|
|
37
37
|
export type { ChatGroup } from "./groupClient.js";
|
|
38
|
+
export type { ConversationTarget } from "./conversationDirectory.js";
|
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.62",
|
|
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",
|
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
"types": "./lib/runtime/index.d.ts",
|
|
29
29
|
"import": "./lib/runtime/index.js",
|
|
30
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"
|
|
31
36
|
}
|
|
32
37
|
},
|
|
33
38
|
"publishConfig": {
|