@abraca/dabra 1.9.1 → 2.0.1
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/dist/abracadabra-provider.cjs +12728 -9142
- package/dist/abracadabra-provider.cjs.map +1 -1
- package/dist/abracadabra-provider.esm.js +12746 -9210
- package/dist/abracadabra-provider.esm.js.map +1 -1
- package/dist/index.d.ts +1510 -118
- package/package.json +1 -1
- package/src/AbracadabraBaseProvider.ts +70 -2
- package/src/AbracadabraClient.ts +572 -66
- package/src/AbracadabraProvider.ts +22 -7
- package/src/AbracadabraWS.ts +1 -1
- package/src/ChatClient.ts +193 -113
- package/src/ContentManager.ts +80 -12
- package/src/CryptoIdentityKeystore.ts +3 -3
- package/src/DocConverters.ts +161 -6
- package/src/DocKeyManager.ts +60 -12
- package/src/DocTypes.ts +10 -0
- package/src/DocumentManager.ts +62 -85
- package/src/EncryptedChatClient.ts +173 -0
- package/src/EncryptedY.ts +2 -2
- package/src/IdentityDoc.ts +25 -0
- package/src/MnemonicKeyDerivation.ts +4 -4
- package/src/NotificationsClient.ts +120 -98
- package/src/OutgoingMessages/SubdocMessage.ts +2 -2
- package/src/RpcClient.ts +659 -0
- package/src/TreeManager.ts +61 -17
- package/src/TreeTimestamps.ts +28 -25
- package/src/index.ts +71 -1
- package/src/messageRecord.ts +121 -0
- package/src/types.ts +235 -16
- package/src/webrtc/AbracadabraWebRTC.ts +2 -2
- package/src/webrtc/DataChannelRouter.ts +2 -2
- package/src/webrtc/E2EEChannel.ts +3 -3
- package/src/webrtc/FileTransferChannel.ts +9 -2
|
@@ -73,6 +73,15 @@ export interface AbracadabraProviderConfiguration
|
|
|
73
73
|
* sharing one local store. Used for the identity doc.
|
|
74
74
|
*/
|
|
75
75
|
serverAgnostic?: boolean;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Maximum number of simultaneously cached child providers before LRU
|
|
79
|
+
* eviction reclaims the least-recently-used unpinned ones. Default 20.
|
|
80
|
+
* Apps that legitimately need more docs alive (e.g. background-sync of
|
|
81
|
+
* a working set) should raise this; pin individual children with
|
|
82
|
+
* `pinChild()` to make them eviction-immune regardless of cap.
|
|
83
|
+
*/
|
|
84
|
+
maxChildren?: number;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
/** Validate that a string is a UUID acceptable by the server's DocId parser. */
|
|
@@ -114,8 +123,9 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
114
123
|
private childAccessTimes = new Map<string, number>();
|
|
115
124
|
/** Pinned children that must not be evicted (e.g. actively viewed docs) */
|
|
116
125
|
private pinnedChildren = new Set<string>();
|
|
117
|
-
/**
|
|
118
|
-
private static readonly
|
|
126
|
+
/** Default cap on simultaneously cached child providers; configurable per-instance via `maxChildren`. */
|
|
127
|
+
private static readonly DEFAULT_MAX_CHILDREN = 20;
|
|
128
|
+
private readonly maxChildren: number;
|
|
119
129
|
|
|
120
130
|
private abracadabraConfig: AbracadabraProviderConfiguration;
|
|
121
131
|
|
|
@@ -143,8 +153,13 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
143
153
|
const client = configuration.client ?? null;
|
|
144
154
|
|
|
145
155
|
if (client) {
|
|
146
|
-
|
|
147
|
-
|
|
156
|
+
// `url` is no longer part of the base provider config — the URL
|
|
157
|
+
// rides on `websocketProvider`. Keep the legacy field assignment
|
|
158
|
+
// for downstream consumers that read it off the merged object,
|
|
159
|
+
// but cast through `any` since `resolved` doesn't declare it.
|
|
160
|
+
const r = resolved as { url?: string; websocketProvider?: AbracadabraWS };
|
|
161
|
+
if (!r.url && !r.websocketProvider) {
|
|
162
|
+
r.url = client.wsUrl;
|
|
148
163
|
}
|
|
149
164
|
if (resolved.token === undefined && !configuration.cryptoIdentity) {
|
|
150
165
|
resolved.token = () => client.token ?? "";
|
|
@@ -155,6 +170,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
155
170
|
this._client = client;
|
|
156
171
|
this.abracadabraConfig = configuration;
|
|
157
172
|
this.subdocLoading = configuration.subdocLoading ?? "lazy";
|
|
173
|
+
this.maxChildren = configuration.maxChildren ?? AbracadabraProvider.DEFAULT_MAX_CHILDREN;
|
|
158
174
|
|
|
159
175
|
const serverOrigin = configuration.serverAgnostic
|
|
160
176
|
? undefined
|
|
@@ -560,9 +576,8 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
560
576
|
* at or below MAX_CHILDREN.
|
|
561
577
|
*/
|
|
562
578
|
private evictLRU() {
|
|
563
|
-
if (this.childProviders.size <=
|
|
579
|
+
if (this.childProviders.size <= this.maxChildren) return;
|
|
564
580
|
|
|
565
|
-
// Build a list of evictable children sorted by last access (oldest first)
|
|
566
581
|
const evictable: Array<{ id: string; accessTime: number }> = [];
|
|
567
582
|
for (const [id] of this.childProviders) {
|
|
568
583
|
if (this.pinnedChildren.has(id)) continue;
|
|
@@ -570,7 +585,7 @@ export class AbracadabraProvider extends AbracadabraBaseProvider {
|
|
|
570
585
|
}
|
|
571
586
|
evictable.sort((a, b) => a.accessTime - b.accessTime);
|
|
572
587
|
|
|
573
|
-
let toEvict = this.childProviders.size -
|
|
588
|
+
let toEvict = this.childProviders.size - this.maxChildren;
|
|
574
589
|
for (const entry of evictable) {
|
|
575
590
|
if (toEvict <= 0) break;
|
|
576
591
|
this.unloadChild(entry.id);
|
package/src/AbracadabraWS.ts
CHANGED
|
@@ -542,7 +542,7 @@ export class AbracadabraWS extends EventEmitter {
|
|
|
542
542
|
// Detect server-side rate-limit close (code 4429).
|
|
543
543
|
// `event` may be a CloseEvent (browser) with `.code`, or a raw number (ws/Node.js).
|
|
544
544
|
// `event` may be a CloseEvent (browser) with `.code`, or a raw number (ws/Node.js).
|
|
545
|
-
const isRateLimited = (event as any)?.code === 4429 || event === 4429;
|
|
545
|
+
const isRateLimited = (event as any)?.code === 4429 || (event as unknown) === 4429;
|
|
546
546
|
this.emit("disconnect", { event });
|
|
547
547
|
if (isRateLimited) {
|
|
548
548
|
this.emit("rateLimited");
|
package/src/ChatClient.ts
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import EventEmitter from "./EventEmitter.ts";
|
|
2
2
|
import type {
|
|
3
|
-
ChatChannel,
|
|
4
|
-
ChatMessage,
|
|
5
|
-
ChatReadCursor,
|
|
6
|
-
ChatReadReceipt,
|
|
7
3
|
ChatTypingEvent,
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
SendChatMessageInput as _LegacySendInput,
|
|
5
|
+
GetChatHistoryInput as _LegacyHistoryInput,
|
|
10
6
|
} from "./types.ts";
|
|
11
7
|
|
|
8
|
+
// Legacy alias re-exports kept for type-import compat with consumers that
|
|
9
|
+
// import these names from `@abraca/dabra`. The old shapes still describe
|
|
10
|
+
// the wire the dashboard's own reactive layer reads/writes; this client
|
|
11
|
+
// itself no longer uses them as method inputs.
|
|
12
|
+
export type _ReExportedTypes = _LegacySendInput | _LegacyHistoryInput;
|
|
13
|
+
|
|
12
14
|
/**
|
|
13
15
|
* Minimal provider surface ChatClient needs. Matches `AbracadabraBaseProvider`.
|
|
14
|
-
* Kept as an interface so consumers can pass any compatible transport.
|
|
15
16
|
*/
|
|
16
17
|
export interface ChatClientTransport {
|
|
17
18
|
sendStateless(payload: string): void;
|
|
@@ -30,38 +31,114 @@ type PendingResolver<T> = {
|
|
|
30
31
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
|
-
*
|
|
34
|
+
* Send a chat message into a channel doc.
|
|
35
|
+
*
|
|
36
|
+
* `channel_doc_id` is the UUID of the channel/dm doc — `documents.id` for
|
|
37
|
+
* a row with `kind = "channel"` or `kind = "dm"`. Group channels under a
|
|
38
|
+
* Space pass the channel doc's id; DMs pass the DM doc's id (callers
|
|
39
|
+
* resolve "the DM doc between user A and user B" via their own path —
|
|
40
|
+
* typically `client.listChildren(serverRootId)` filtered by `kind === "dm"`
|
|
41
|
+
* with both pubkeys in `permissions`).
|
|
42
|
+
*/
|
|
43
|
+
export interface SendMessageInput {
|
|
44
|
+
channel_doc_id: string;
|
|
45
|
+
content: string;
|
|
46
|
+
/** Cleartext recipient pubkeys for server-side mention fan-out. */
|
|
47
|
+
mentions?: string[];
|
|
48
|
+
/** Message id this is a reply to. */
|
|
49
|
+
reply_to?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Optional Ed25519 signature over the canonical envelope, base64url. When
|
|
52
|
+
* server config `[messages].require_client_sig = true`, sends without a
|
|
53
|
+
* sig are rejected. Default-config servers accept unsigned sends — the
|
|
54
|
+
* threat model relies on JWT-bound session auth + the server's own
|
|
55
|
+
* `server_sig` over `(msg_id, period_id, ts, sig)`.
|
|
56
|
+
*/
|
|
57
|
+
sig?: string;
|
|
58
|
+
/** Client-side timestamp (ms) for skew-tolerance check. Optional. */
|
|
59
|
+
ts_hint?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SendMessageResult {
|
|
63
|
+
message_id: string;
|
|
64
|
+
period_id: string;
|
|
65
|
+
ts: number;
|
|
66
|
+
server_sig: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EditMessageInput {
|
|
70
|
+
channel_doc_id: string;
|
|
71
|
+
message_id: string;
|
|
72
|
+
content: string;
|
|
73
|
+
sig?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DeleteMessageInput {
|
|
77
|
+
channel_doc_id: string;
|
|
78
|
+
message_id: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface MarkReadInput {
|
|
82
|
+
channel_doc_id: string;
|
|
83
|
+
period_id: string;
|
|
84
|
+
message_id: string;
|
|
85
|
+
/** Unix milliseconds. Defaults to server time. */
|
|
86
|
+
ts?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Thin façade over the `messages:*` stateless protocol.
|
|
34
91
|
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
92
|
+
* The unified messages model stores chat history as Y.Array entries on
|
|
93
|
+
* `kind = "channel-period"` child docs of channel/dm docs (and notifications
|
|
94
|
+
* as entries on a per-user `kind = "inbox"` doc). Reading history, listing
|
|
95
|
+
* channels, observing real-time messages, and tracking read cursors are all
|
|
96
|
+
* Y.Doc-shaped operations that the SDK consumer performs via
|
|
97
|
+
* `AbracadabraClient` + `AbracadabraProvider` directly — not through this
|
|
98
|
+
* façade. ChatClient handles only the *write-and-validate* path (sends,
|
|
99
|
+
* edits, deletes, typing, mark-read), where the server is the sole authority
|
|
100
|
+
* over the channel-period Y.Array.
|
|
37
101
|
*
|
|
38
102
|
* Events emitted:
|
|
39
|
-
* - `
|
|
40
|
-
*
|
|
41
|
-
*
|
|
103
|
+
* - `typing` → ChatTypingEvent (typing broadcast on the channel doc)
|
|
104
|
+
*
|
|
105
|
+
* For incoming-message observation, the consumer opens an
|
|
106
|
+
* `AbracadabraProvider` on the active period doc and observes its
|
|
107
|
+
* `messages` Y.Array directly. The dashboard's `useChat` composable does
|
|
108
|
+
* this; the integration tests do this; external consumers should mirror
|
|
109
|
+
* the pattern.
|
|
42
110
|
*/
|
|
43
111
|
export class ChatClient extends EventEmitter {
|
|
44
112
|
private readonly provider: ChatClientTransport;
|
|
45
113
|
private readonly responseTimeoutMs: number;
|
|
46
114
|
|
|
47
|
-
// FIFO of promises waiting on a
|
|
48
|
-
// replies on the same socket, one-per-request, so a simple queue per type
|
|
49
|
-
// matches observed behavior.
|
|
115
|
+
// FIFO of promises waiting on a typed `messages:ok` reply (keyed by source).
|
|
50
116
|
private readonly pending: Map<string, PendingResolver<any>[]> = new Map();
|
|
51
117
|
|
|
52
118
|
private readonly boundOnStateless: (data: { payload: string }) => void;
|
|
119
|
+
private readonly boundOnServerError: (data: {
|
|
120
|
+
source: string;
|
|
121
|
+
code: string;
|
|
122
|
+
message: string;
|
|
123
|
+
meta?: unknown;
|
|
124
|
+
}) => void;
|
|
53
125
|
|
|
54
126
|
constructor(provider: ChatClientTransport, options?: { responseTimeoutMs?: number }) {
|
|
55
127
|
super();
|
|
56
128
|
this.provider = provider;
|
|
57
129
|
this.responseTimeoutMs = options?.responseTimeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
58
130
|
this.boundOnStateless = (data) => this.handleStateless(data.payload);
|
|
131
|
+
this.boundOnServerError = (data) => this.handleServerError(data);
|
|
59
132
|
this.provider.on("stateless", this.boundOnStateless);
|
|
133
|
+
// BaseProvider intercepts `{type:"error",source,code}` frames and emits
|
|
134
|
+
// them as `serverError`, not `stateless` — so request/response code paths
|
|
135
|
+
// must listen here to learn about rejections.
|
|
136
|
+
this.provider.on("serverError", this.boundOnServerError);
|
|
60
137
|
}
|
|
61
138
|
|
|
62
|
-
/** Stop listening for chat messages. Does not disconnect the underlying provider. */
|
|
63
139
|
destroy(): void {
|
|
64
140
|
this.provider.off("stateless", this.boundOnStateless);
|
|
141
|
+
this.provider.off("serverError", this.boundOnServerError);
|
|
65
142
|
for (const queue of this.pending.values()) {
|
|
66
143
|
for (const p of queue) {
|
|
67
144
|
clearTimeout(p.timer);
|
|
@@ -74,107 +151,121 @@ export class ChatClient extends EventEmitter {
|
|
|
74
151
|
|
|
75
152
|
// ── Outgoing requests ──────────────────────────────────────────────────────
|
|
76
153
|
|
|
77
|
-
/** Send a chat message
|
|
78
|
-
|
|
154
|
+
/** Send a chat message. Resolves with the server-assigned id, ts, and signatures. */
|
|
155
|
+
send(input: SendMessageInput): Promise<SendMessageResult> {
|
|
156
|
+
const promise = this.enqueue<SendMessageResult>("messages:send");
|
|
79
157
|
this.provider.sendStateless(
|
|
80
158
|
JSON.stringify({
|
|
81
|
-
type: "
|
|
82
|
-
|
|
159
|
+
type: "messages:send",
|
|
160
|
+
channel_doc_id: input.channel_doc_id,
|
|
83
161
|
content: input.content,
|
|
84
|
-
|
|
162
|
+
mentions: input.mentions ?? [],
|
|
163
|
+
...(input.reply_to !== undefined ? { reply_to: input.reply_to } : {}),
|
|
164
|
+
...(input.sig !== undefined ? { sig: input.sig } : {}),
|
|
165
|
+
...(input.ts_hint !== undefined ? { ts_hint: input.ts_hint } : {}),
|
|
85
166
|
}),
|
|
86
167
|
);
|
|
168
|
+
return promise;
|
|
87
169
|
}
|
|
88
170
|
|
|
89
|
-
/**
|
|
90
|
-
|
|
91
|
-
const promise = this.enqueue<
|
|
171
|
+
/** Edit a previously-sent message. Append-only — server records an `edit` entry. */
|
|
172
|
+
edit(input: EditMessageInput): Promise<void> {
|
|
173
|
+
const promise = this.enqueue<void>("messages:edit");
|
|
92
174
|
this.provider.sendStateless(
|
|
93
175
|
JSON.stringify({
|
|
94
|
-
type: "
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
176
|
+
type: "messages:edit",
|
|
177
|
+
channel_doc_id: input.channel_doc_id,
|
|
178
|
+
message_id: input.message_id,
|
|
179
|
+
content: input.content,
|
|
180
|
+
...(input.sig !== undefined ? { sig: input.sig } : {}),
|
|
98
181
|
}),
|
|
99
182
|
);
|
|
100
183
|
return promise;
|
|
101
184
|
}
|
|
102
185
|
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
-
this.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
186
|
+
/** Delete a message. Append-only — server records a `tombstone` entry. */
|
|
187
|
+
delete(input: DeleteMessageInput): Promise<void> {
|
|
188
|
+
const promise = this.enqueue<void>("messages:delete");
|
|
189
|
+
this.provider.sendStateless(
|
|
190
|
+
JSON.stringify({
|
|
191
|
+
type: "messages:delete",
|
|
192
|
+
channel_doc_id: input.channel_doc_id,
|
|
193
|
+
message_id: input.message_id,
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
112
196
|
return promise;
|
|
113
197
|
}
|
|
114
198
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
199
|
+
/** Broadcast a typing indicator on a channel. Fire-and-forget. */
|
|
200
|
+
typing(channel_doc_id: string): void {
|
|
117
201
|
this.provider.sendStateless(
|
|
118
|
-
JSON.stringify({ type: "
|
|
202
|
+
JSON.stringify({ type: "messages:typing", channel_doc_id }),
|
|
119
203
|
);
|
|
120
204
|
}
|
|
121
205
|
|
|
122
|
-
/**
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
206
|
+
/** Mark a (period, message) position as read for the calling user. Fire-and-forget. */
|
|
207
|
+
markRead(input: MarkReadInput): void {
|
|
208
|
+
this.provider.sendStateless(
|
|
209
|
+
JSON.stringify({
|
|
210
|
+
type: "messages:mark_read",
|
|
211
|
+
channel_doc_id: input.channel_doc_id,
|
|
212
|
+
period_id: input.period_id,
|
|
213
|
+
message_id: input.message_id,
|
|
214
|
+
...(input.ts !== undefined ? { ts: input.ts } : {}),
|
|
215
|
+
}),
|
|
126
216
|
);
|
|
127
|
-
this.provider.sendStateless(JSON.stringify({ type: "chat:read_cursors", channel }));
|
|
128
|
-
return promise;
|
|
129
217
|
}
|
|
130
218
|
|
|
131
|
-
// ── Typed event subscription helpers
|
|
132
|
-
|
|
133
|
-
onMessage(fn: (m: ChatMessage) => void): this {
|
|
134
|
-
return this.on("message", fn) as this;
|
|
135
|
-
}
|
|
219
|
+
// ── Typed event subscription helpers ──────────────────────────────────────
|
|
136
220
|
|
|
221
|
+
/** Observe typing broadcasts. */
|
|
137
222
|
onTyping(fn: (e: ChatTypingEvent) => void): this {
|
|
138
223
|
return this.on("typing", fn) as this;
|
|
139
224
|
}
|
|
140
225
|
|
|
141
|
-
onReadReceipt(fn: (e: ChatReadReceipt) => void): this {
|
|
142
|
-
return this.on("readReceipt", fn) as this;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
226
|
// ── Internals ─────────────────────────────────────────────────────────────
|
|
146
227
|
|
|
147
|
-
private enqueue<T>(
|
|
228
|
+
private enqueue<T>(source: string): Promise<T> {
|
|
148
229
|
return new Promise<T>((resolve, reject) => {
|
|
149
230
|
const timer = setTimeout(() => {
|
|
150
|
-
this.removePending(
|
|
151
|
-
reject(new Error(`ChatClient: timeout waiting for ${
|
|
231
|
+
this.removePending(source, entry);
|
|
232
|
+
reject(new Error(`ChatClient: timeout waiting for ${source} ack`));
|
|
152
233
|
}, this.responseTimeoutMs);
|
|
153
234
|
const entry: PendingResolver<T> = { resolve, reject, timer };
|
|
154
|
-
const queue = this.pending.get(
|
|
235
|
+
const queue = this.pending.get(source) ?? [];
|
|
155
236
|
queue.push(entry);
|
|
156
|
-
this.pending.set(
|
|
237
|
+
this.pending.set(source, queue);
|
|
157
238
|
});
|
|
158
239
|
}
|
|
159
240
|
|
|
160
|
-
private removePending(
|
|
161
|
-
const queue = this.pending.get(
|
|
241
|
+
private removePending(source: string, entry: PendingResolver<any>): void {
|
|
242
|
+
const queue = this.pending.get(source);
|
|
162
243
|
if (!queue) return;
|
|
163
244
|
const idx = queue.indexOf(entry);
|
|
164
245
|
if (idx >= 0) queue.splice(idx, 1);
|
|
165
|
-
if (queue.length === 0) this.pending.delete(
|
|
246
|
+
if (queue.length === 0) this.pending.delete(source);
|
|
166
247
|
}
|
|
167
248
|
|
|
168
|
-
private resolveNext<T>(
|
|
169
|
-
const queue = this.pending.get(
|
|
249
|
+
private resolveNext<T>(source: string, value: T): boolean {
|
|
250
|
+
const queue = this.pending.get(source);
|
|
170
251
|
if (!queue || queue.length === 0) return false;
|
|
171
252
|
const next = queue.shift()!;
|
|
172
|
-
if (queue.length === 0) this.pending.delete(
|
|
253
|
+
if (queue.length === 0) this.pending.delete(source);
|
|
173
254
|
clearTimeout(next.timer);
|
|
174
255
|
next.resolve(value);
|
|
175
256
|
return true;
|
|
176
257
|
}
|
|
177
258
|
|
|
259
|
+
private rejectNext(source: string, err: Error): boolean {
|
|
260
|
+
const queue = this.pending.get(source);
|
|
261
|
+
if (!queue || queue.length === 0) return false;
|
|
262
|
+
const next = queue.shift()!;
|
|
263
|
+
if (queue.length === 0) this.pending.delete(source);
|
|
264
|
+
clearTimeout(next.timer);
|
|
265
|
+
next.reject(err);
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
|
|
178
269
|
private handleStateless(payload: string): void {
|
|
179
270
|
let parsed: any;
|
|
180
271
|
try {
|
|
@@ -183,52 +274,41 @@ export class ChatClient extends EventEmitter {
|
|
|
183
274
|
return;
|
|
184
275
|
}
|
|
185
276
|
const type: unknown = parsed?.type;
|
|
186
|
-
if (typeof type !== "string"
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
break;
|
|
194
|
-
}
|
|
195
|
-
case "chat:history": {
|
|
196
|
-
this.resolveNext("chat:history", {
|
|
197
|
-
channel: parsed.channel,
|
|
198
|
-
messages: parsed.messages ?? [],
|
|
199
|
-
});
|
|
200
|
-
break;
|
|
201
|
-
}
|
|
202
|
-
case "chat:channels": {
|
|
203
|
-
this.resolveNext("chat:channels", { channels: parsed.channels ?? [] });
|
|
204
|
-
break;
|
|
205
|
-
}
|
|
206
|
-
case "chat:typing": {
|
|
207
|
-
this.emit("typing", {
|
|
208
|
-
channel: parsed.channel,
|
|
209
|
-
sender_id: parsed.sender_id,
|
|
210
|
-
sender_name: parsed.sender_name ?? null,
|
|
211
|
-
} as ChatTypingEvent);
|
|
212
|
-
break;
|
|
213
|
-
}
|
|
214
|
-
case "chat:read_receipt": {
|
|
215
|
-
this.emit("readReceipt", {
|
|
216
|
-
channel: parsed.channel,
|
|
217
|
-
user_id: parsed.user_id,
|
|
218
|
-
last_read_at: parsed.last_read_at,
|
|
219
|
-
} as ChatReadReceipt);
|
|
220
|
-
break;
|
|
221
|
-
}
|
|
222
|
-
case "chat:read_cursors": {
|
|
223
|
-
this.resolveNext("chat:read_cursors", {
|
|
224
|
-
channel: parsed.channel,
|
|
225
|
-
cursors: parsed.cursors ?? [],
|
|
226
|
-
});
|
|
227
|
-
break;
|
|
228
|
-
}
|
|
229
|
-
default:
|
|
230
|
-
// Unknown chat:* subtype — ignore for forward compat.
|
|
231
|
-
break;
|
|
277
|
+
if (typeof type !== "string") return;
|
|
278
|
+
|
|
279
|
+
// Server emits `messages:ok` acks with a `source` matching the originating
|
|
280
|
+
// request type. Use that to resolve the matching pending promise.
|
|
281
|
+
if (type === "messages:ok" && typeof parsed.source === "string") {
|
|
282
|
+
this.resolveNext(parsed.source, parsed.meta);
|
|
283
|
+
return;
|
|
232
284
|
}
|
|
285
|
+
if (type === "messages:typing") {
|
|
286
|
+
// The server includes channel_doc_id + user_id; the legacy
|
|
287
|
+
// ChatTypingEvent shape has `channel`, so emit it as the new doc id —
|
|
288
|
+
// consumers that want the old `group:<id>` form can prefix locally.
|
|
289
|
+
this.emit("typing", {
|
|
290
|
+
channel: parsed.channel_doc_id,
|
|
291
|
+
sender_id: parsed.user_id,
|
|
292
|
+
sender_name: null,
|
|
293
|
+
} as ChatTypingEvent);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private handleServerError(data: {
|
|
299
|
+
source: string;
|
|
300
|
+
code: string;
|
|
301
|
+
message: string;
|
|
302
|
+
meta?: unknown;
|
|
303
|
+
}): void {
|
|
304
|
+
if (typeof data.source !== "string" || !data.source.startsWith("messages:")) return;
|
|
305
|
+
if (data.source === "messages:inbox_fetch" || data.source === "messages:inbox_mark_read") {
|
|
306
|
+
// Inbox traffic belongs to NotificationsClient — let it handle its own errors.
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
this.rejectNext(
|
|
310
|
+
data.source,
|
|
311
|
+
new Error(`${data.code ?? "error"}: ${data.message ?? "messages request failed"}`),
|
|
312
|
+
);
|
|
233
313
|
}
|
|
234
314
|
}
|
package/src/ContentManager.ts
CHANGED
|
@@ -10,7 +10,19 @@ import {
|
|
|
10
10
|
yjsToMarkdown,
|
|
11
11
|
populateYDocFromMarkdown,
|
|
12
12
|
parseFrontmatter,
|
|
13
|
+
buildHeadingElement,
|
|
14
|
+
buildParagraphElement,
|
|
15
|
+
buildBulletListElement,
|
|
16
|
+
buildOrderedListElement,
|
|
17
|
+
buildTaskListElement,
|
|
18
|
+
buildCodeBlockElement,
|
|
19
|
+
buildBlockquoteElement,
|
|
20
|
+
buildHorizontalRuleElement,
|
|
21
|
+
buildBlocksFromMarkdown,
|
|
22
|
+
readBlocksFromFragment,
|
|
23
|
+
type DocumentBlock,
|
|
13
24
|
} from "./DocConverters.ts";
|
|
25
|
+
export type { DocumentBlock } from "./DocConverters.ts";
|
|
14
26
|
import type { DocumentManager } from "./DocumentManager.ts";
|
|
15
27
|
|
|
16
28
|
export interface DocumentContent {
|
|
@@ -46,11 +58,12 @@ export class ContentManager {
|
|
|
46
58
|
let label = title;
|
|
47
59
|
let type: string | undefined;
|
|
48
60
|
let meta: PageMeta | undefined;
|
|
49
|
-
|
|
61
|
+
const childrenWithOrder: Array<{
|
|
50
62
|
id: string;
|
|
51
63
|
label: string;
|
|
52
64
|
type?: string;
|
|
53
65
|
meta?: PageMeta;
|
|
66
|
+
order: number;
|
|
54
67
|
}> = [];
|
|
55
68
|
|
|
56
69
|
if (treeMap) {
|
|
@@ -62,23 +75,28 @@ export class ContentManager {
|
|
|
62
75
|
meta = entry.meta as PageMeta | undefined;
|
|
63
76
|
}
|
|
64
77
|
// Collect immediate children sorted by order
|
|
65
|
-
treeMap.forEach((
|
|
66
|
-
|
|
67
|
-
|
|
78
|
+
treeMap.forEach((rawChild: unknown, id: string) => {
|
|
79
|
+
const value = toPlain(rawChild) as Record<string, unknown>;
|
|
80
|
+
if (value && value.parentId === docId) {
|
|
81
|
+
childrenWithOrder.push({
|
|
68
82
|
id,
|
|
69
|
-
label: value.label || "Untitled",
|
|
70
|
-
type: value.type,
|
|
71
|
-
meta: value.meta,
|
|
83
|
+
label: (value.label as string) || "Untitled",
|
|
84
|
+
type: value.type as string | undefined,
|
|
85
|
+
meta: value.meta as PageMeta | undefined,
|
|
86
|
+
order: (value.order as number) ?? 0,
|
|
72
87
|
});
|
|
73
88
|
}
|
|
74
89
|
});
|
|
75
|
-
|
|
76
|
-
const orderA = treeMap.get(a.id)?.order ?? 0;
|
|
77
|
-
const orderB = treeMap.get(b.id)?.order ?? 0;
|
|
78
|
-
return orderA - orderB;
|
|
79
|
-
});
|
|
90
|
+
childrenWithOrder.sort((a, b) => a.order - b.order);
|
|
80
91
|
}
|
|
81
92
|
|
|
93
|
+
const children = childrenWithOrder.map(({ id, label, type, meta }) => ({
|
|
94
|
+
id,
|
|
95
|
+
label,
|
|
96
|
+
type,
|
|
97
|
+
meta,
|
|
98
|
+
}));
|
|
99
|
+
|
|
82
100
|
return { label, type, meta, title, markdown, children };
|
|
83
101
|
}
|
|
84
102
|
|
|
@@ -141,6 +159,56 @@ export class ContentManager {
|
|
|
141
159
|
populateYDocFromMarkdown(fragment, contentToWrite, fallbackTitle);
|
|
142
160
|
}
|
|
143
161
|
|
|
162
|
+
private async _appendElements(docId: string, els: Y.XmlElement[]): Promise<void> {
|
|
163
|
+
const provider = await this.dm.getChildProvider(docId);
|
|
164
|
+
const doc = provider.document;
|
|
165
|
+
const fragment = doc.getXmlFragment("default");
|
|
166
|
+
doc.transact(() => {
|
|
167
|
+
fragment.insert(fragment.length, els);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async appendHeading(docId: string, text: string, opts?: { level?: 1|2|3|4|5|6 }): Promise<void> {
|
|
172
|
+
return this._appendElements(docId, [buildHeadingElement(text, opts?.level)]);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async appendParagraph(docId: string, text: string): Promise<void> {
|
|
176
|
+
return this._appendElements(docId, [buildParagraphElement(text)]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async appendBulletList(docId: string, items: string[]): Promise<void> {
|
|
180
|
+
return this._appendElements(docId, [buildBulletListElement(items)]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async appendOrderedList(docId: string, items: string[]): Promise<void> {
|
|
184
|
+
return this._appendElements(docId, [buildOrderedListElement(items)]);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async appendTaskList(docId: string, items: Array<{ text: string; checked?: boolean }>): Promise<void> {
|
|
188
|
+
return this._appendElements(docId, [buildTaskListElement(items)]);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async appendCodeBlock(docId: string, code: string, opts?: { language?: string }): Promise<void> {
|
|
192
|
+
return this._appendElements(docId, [buildCodeBlockElement(code, opts?.language)]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async appendBlockquote(docId: string, text: string): Promise<void> {
|
|
196
|
+
return this._appendElements(docId, [buildBlockquoteElement(text)]);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async appendHorizontalRule(docId: string): Promise<void> {
|
|
200
|
+
return this._appendElements(docId, [buildHorizontalRuleElement()]);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async appendMarkdown(docId: string, markdown: string): Promise<void> {
|
|
204
|
+
return this._appendElements(docId, buildBlocksFromMarkdown(markdown));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async getBlocks(docId: string): Promise<DocumentBlock[]> {
|
|
208
|
+
const provider = await this.dm.getChildProvider(docId);
|
|
209
|
+
return readBlocksFromFragment(provider.document.getXmlFragment("default"));
|
|
210
|
+
}
|
|
211
|
+
|
|
144
212
|
/**
|
|
145
213
|
* Get the raw Y.XmlFragment for a document (the 'default' fragment
|
|
146
214
|
* that TipTap uses for document content).
|
|
@@ -357,7 +357,7 @@ export class CryptoIdentityKeystore {
|
|
|
357
357
|
} else {
|
|
358
358
|
const all = await dbGetAll(db);
|
|
359
359
|
if (all.length > 0) {
|
|
360
|
-
await dbPut(db, all[0].key, { ...all[0].value, username });
|
|
360
|
+
await dbPut(db, String(all[0].key), { ...all[0].value, username });
|
|
361
361
|
}
|
|
362
362
|
}
|
|
363
363
|
} finally {
|
|
@@ -557,7 +557,7 @@ export class CryptoIdentityKeystore {
|
|
|
557
557
|
challenge: crypto.getRandomValues(new Uint8Array(32)),
|
|
558
558
|
rp: { id: rpId, name: rpName },
|
|
559
559
|
user: {
|
|
560
|
-
id: fromBase64url(kp.publicKeyB64),
|
|
560
|
+
id: fromBase64url(kp.publicKeyB64) as BufferSource,
|
|
561
561
|
name: kp.publicKeyB64.slice(0, 16),
|
|
562
562
|
displayName: kp.publicKeyB64.slice(0, 16),
|
|
563
563
|
},
|
|
@@ -663,7 +663,7 @@ export class CryptoIdentityKeystore {
|
|
|
663
663
|
|
|
664
664
|
if (credentialIdHint) {
|
|
665
665
|
allowCredentials.push({
|
|
666
|
-
id: fromBase64url(credentialIdHint),
|
|
666
|
+
id: fromBase64url(credentialIdHint) as BufferSource,
|
|
667
667
|
type: "public-key",
|
|
668
668
|
});
|
|
669
669
|
} else {
|