@xnetjs/react 0.0.2
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/LICENSE +21 -0
- package/README.md +327 -0
- package/dist/chunk-CWHCGYDW.js +2238 -0
- package/dist/index.d.ts +2782 -0
- package/dist/index.js +4798 -0
- package/dist/instrumentation-Cn94kn8-.d.ts +42 -0
- package/dist/internal.d.ts +31 -0
- package/dist/internal.js +10 -0
- package/package.json +61 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4798 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InstrumentationContext,
|
|
3
|
+
METABRIDGE_ORIGIN,
|
|
4
|
+
METABRIDGE_SEED_ORIGIN,
|
|
5
|
+
NodeStoreSyncProvider,
|
|
6
|
+
PluginRegistryContext,
|
|
7
|
+
SecurityProvider,
|
|
8
|
+
TelemetryContext,
|
|
9
|
+
XNetContext,
|
|
10
|
+
XNetProvider,
|
|
11
|
+
createConnectionManager,
|
|
12
|
+
createMetaBridge,
|
|
13
|
+
createNodePool,
|
|
14
|
+
createOfflineQueue,
|
|
15
|
+
createRegistry,
|
|
16
|
+
createSyncManager,
|
|
17
|
+
downloadBackup,
|
|
18
|
+
uploadBackup,
|
|
19
|
+
useCommand,
|
|
20
|
+
useCommands,
|
|
21
|
+
useContributions,
|
|
22
|
+
useDataBridge,
|
|
23
|
+
useEditorExtensions,
|
|
24
|
+
useEditorExtensionsSafe,
|
|
25
|
+
useInstrumentation,
|
|
26
|
+
useNodeStore,
|
|
27
|
+
usePluginRegistry,
|
|
28
|
+
usePluginRegistryOptional,
|
|
29
|
+
usePlugins,
|
|
30
|
+
useSecurityContext,
|
|
31
|
+
useSecurityContextOptional,
|
|
32
|
+
useSidebarItems,
|
|
33
|
+
useSlashCommands,
|
|
34
|
+
useTelemetryReporter,
|
|
35
|
+
useView,
|
|
36
|
+
useViews,
|
|
37
|
+
useXNet
|
|
38
|
+
} from "./chunk-CWHCGYDW.js";
|
|
39
|
+
|
|
40
|
+
// src/hooks/useQuery.ts
|
|
41
|
+
import { useSyncExternalStore, useMemo, useCallback, useEffect, useRef } from "react";
|
|
42
|
+
|
|
43
|
+
// src/utils/flattenNode.ts
|
|
44
|
+
function flattenNode(node, options) {
|
|
45
|
+
const { properties, timestamps, deletedAt, documentContent, _unknown, _schemaVersion, ...base } = node;
|
|
46
|
+
const migrationInfo = options?.migrationInfo ?? node._migrationInfo;
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
...properties,
|
|
50
|
+
// Include version compatibility fields if present
|
|
51
|
+
...options?.unknownSchema && { _unknownSchema: true },
|
|
52
|
+
..._unknown && Object.keys(_unknown).length > 0 && { _unknown },
|
|
53
|
+
..._schemaVersion && { _schemaVersion },
|
|
54
|
+
// Include migration fields if present
|
|
55
|
+
...migrationInfo && {
|
|
56
|
+
_migratedFrom: migrationInfo.from,
|
|
57
|
+
_migrationInfo: migrationInfo
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function flattenNodes(nodes, options) {
|
|
62
|
+
return nodes.map((node) => flattenNode(node, options));
|
|
63
|
+
}
|
|
64
|
+
function flattenUnknownSchemaNode(node) {
|
|
65
|
+
return flattenNode(node, { unknownSchema: true });
|
|
66
|
+
}
|
|
67
|
+
function flattenNodesWithSchemaCheck(nodes, isSchemaKnown) {
|
|
68
|
+
return nodes.map((node) => flattenNode(node, { unknownSchema: !isSchemaKnown(node.schemaId) }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// src/hooks/useQuery.ts
|
|
72
|
+
function useQuery(schema, idOrFilter) {
|
|
73
|
+
const bridge = useDataBridge();
|
|
74
|
+
const instrumentation = useInstrumentation();
|
|
75
|
+
const telemetry = useTelemetryReporter();
|
|
76
|
+
const schemaId = schema._schemaId;
|
|
77
|
+
const queryStartRef = useRef(Date.now());
|
|
78
|
+
const isSingleQuery = typeof idOrFilter === "string";
|
|
79
|
+
const filter = typeof idOrFilter === "object" ? idOrFilter : {};
|
|
80
|
+
const nodeId = isSingleQuery ? idOrFilter : null;
|
|
81
|
+
const whereKey = useMemo(() => JSON.stringify(filter.where), [filter.where]);
|
|
82
|
+
const orderByKey = useMemo(() => JSON.stringify(filter.orderBy), [filter.orderBy]);
|
|
83
|
+
const options = useMemo(() => {
|
|
84
|
+
if (isSingleQuery && nodeId) {
|
|
85
|
+
return { nodeId };
|
|
86
|
+
}
|
|
87
|
+
return {
|
|
88
|
+
where: filter.where,
|
|
89
|
+
includeDeleted: filter.includeDeleted,
|
|
90
|
+
orderBy: filter.orderBy,
|
|
91
|
+
limit: filter.limit,
|
|
92
|
+
offset: filter.offset
|
|
93
|
+
};
|
|
94
|
+
}, [
|
|
95
|
+
isSingleQuery,
|
|
96
|
+
nodeId,
|
|
97
|
+
whereKey,
|
|
98
|
+
filter.includeDeleted,
|
|
99
|
+
orderByKey,
|
|
100
|
+
filter.limit,
|
|
101
|
+
filter.offset
|
|
102
|
+
]);
|
|
103
|
+
const subscription = useMemo(() => {
|
|
104
|
+
if (!bridge) {
|
|
105
|
+
return {
|
|
106
|
+
getSnapshot: () => null,
|
|
107
|
+
subscribe: () => () => {
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
return bridge.query(schema, options);
|
|
112
|
+
}, [bridge, schema, options]);
|
|
113
|
+
const reloadCountRef = useRef(0);
|
|
114
|
+
const reload = useCallback(() => {
|
|
115
|
+
reloadCountRef.current++;
|
|
116
|
+
}, []);
|
|
117
|
+
const rawData = useSyncExternalStore(
|
|
118
|
+
subscription.subscribe,
|
|
119
|
+
subscription.getSnapshot,
|
|
120
|
+
subscription.getSnapshot
|
|
121
|
+
// Server snapshot (same as client for now)
|
|
122
|
+
);
|
|
123
|
+
const { data, migrationWarnings } = useMemo(() => {
|
|
124
|
+
if (rawData === null) {
|
|
125
|
+
return {
|
|
126
|
+
data: isSingleQuery ? null : [],
|
|
127
|
+
migrationWarnings: []
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
const warnings = [];
|
|
131
|
+
if (isSingleQuery) {
|
|
132
|
+
const node = rawData[0] ?? null;
|
|
133
|
+
if (node) {
|
|
134
|
+
const flat = flattenNode(node);
|
|
135
|
+
if (flat._migrationInfo && !flat._migrationInfo.lossless) {
|
|
136
|
+
warnings.push({
|
|
137
|
+
nodeId: flat.id,
|
|
138
|
+
from: flat._migrationInfo.from,
|
|
139
|
+
to: flat._migrationInfo.to,
|
|
140
|
+
warnings: flat._migrationInfo.warnings
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return { data: flat, migrationWarnings: warnings };
|
|
144
|
+
}
|
|
145
|
+
return { data: null, migrationWarnings: warnings };
|
|
146
|
+
}
|
|
147
|
+
const flattened = flattenNodes(rawData);
|
|
148
|
+
for (const flat of flattened) {
|
|
149
|
+
if (flat._migrationInfo && !flat._migrationInfo.lossless) {
|
|
150
|
+
warnings.push({
|
|
151
|
+
nodeId: flat.id,
|
|
152
|
+
from: flat._migrationInfo.from,
|
|
153
|
+
to: flat._migrationInfo.to,
|
|
154
|
+
warnings: flat._migrationInfo.warnings
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return { data: flattened, migrationWarnings: warnings };
|
|
159
|
+
}, [rawData, isSingleQuery]);
|
|
160
|
+
const loading = rawData === null;
|
|
161
|
+
const queryIdRef = useRef(
|
|
162
|
+
`useQuery-${schemaId}-${nodeId || "list"}-${Math.random().toString(36).slice(2, 8)}`
|
|
163
|
+
);
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!instrumentation?.queryTracker) return;
|
|
166
|
+
const mode = isSingleQuery ? "single" : filter.where ? "filtered" : "list";
|
|
167
|
+
const queryId = queryIdRef.current;
|
|
168
|
+
instrumentation.queryTracker.register(queryId, {
|
|
169
|
+
type: "useQuery",
|
|
170
|
+
schemaId,
|
|
171
|
+
mode,
|
|
172
|
+
filter: filter.where,
|
|
173
|
+
nodeId: nodeId || void 0
|
|
174
|
+
});
|
|
175
|
+
return () => {
|
|
176
|
+
instrumentation.queryTracker.unregister(queryId);
|
|
177
|
+
};
|
|
178
|
+
}, [instrumentation, schemaId, isSingleQuery, nodeId, filter.where]);
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!instrumentation?.queryTracker || loading) return;
|
|
181
|
+
const count = isSingleQuery ? data ? 1 : 0 : Array.isArray(data) ? data.length : 0;
|
|
182
|
+
instrumentation.queryTracker.recordUpdate(queryIdRef.current, count, 0);
|
|
183
|
+
}, [data, instrumentation, isSingleQuery, loading]);
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!telemetry) return;
|
|
186
|
+
telemetry.reportUsage("react.useQuery", 1);
|
|
187
|
+
return () => {
|
|
188
|
+
telemetry.reportUsage("react.useQuery.unmount", 1);
|
|
189
|
+
};
|
|
190
|
+
}, [telemetry]);
|
|
191
|
+
const hasReportedTimingRef = useRef(false);
|
|
192
|
+
useEffect(() => {
|
|
193
|
+
if (!telemetry || loading || hasReportedTimingRef.current) return;
|
|
194
|
+
hasReportedTimingRef.current = true;
|
|
195
|
+
const elapsed = Date.now() - queryStartRef.current;
|
|
196
|
+
telemetry.reportPerformance("react.useQuery", elapsed);
|
|
197
|
+
if (elapsed < 5) {
|
|
198
|
+
telemetry.reportUsage("react.useQuery.cache_hit", 1);
|
|
199
|
+
} else {
|
|
200
|
+
telemetry.reportUsage("react.useQuery.cache_miss", 1);
|
|
201
|
+
}
|
|
202
|
+
}, [loading, telemetry]);
|
|
203
|
+
return {
|
|
204
|
+
data,
|
|
205
|
+
loading,
|
|
206
|
+
error: null,
|
|
207
|
+
// Errors are handled by the bridge
|
|
208
|
+
reload,
|
|
209
|
+
migrationWarnings
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// src/hooks/useMutate.ts
|
|
214
|
+
import { isTempId } from "@xnetjs/data";
|
|
215
|
+
import { useCallback as useCallback2, useState, useRef as useRef2 } from "react";
|
|
216
|
+
function hasTempIdInValue(value) {
|
|
217
|
+
if (isTempId(value)) return true;
|
|
218
|
+
if (Array.isArray(value)) {
|
|
219
|
+
return value.some((item) => hasTempIdInValue(item));
|
|
220
|
+
}
|
|
221
|
+
if (value && typeof value === "object") {
|
|
222
|
+
return Object.values(value).some((item) => hasTempIdInValue(item));
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
function hasTempIdsInOps(ops) {
|
|
227
|
+
return ops.some((op) => {
|
|
228
|
+
switch (op.type) {
|
|
229
|
+
case "create":
|
|
230
|
+
return Boolean(op.id && isTempId(op.id) || hasTempIdInValue(op.data));
|
|
231
|
+
case "update":
|
|
232
|
+
return isTempId(op.id) || hasTempIdInValue(op.data);
|
|
233
|
+
case "delete":
|
|
234
|
+
case "restore":
|
|
235
|
+
return isTempId(op.id);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
function toTransactionOperations(ops) {
|
|
240
|
+
return ops.map((op) => {
|
|
241
|
+
switch (op.type) {
|
|
242
|
+
case "create":
|
|
243
|
+
return {
|
|
244
|
+
type: "create",
|
|
245
|
+
options: {
|
|
246
|
+
id: op.id,
|
|
247
|
+
schemaId: op.schema._schemaId,
|
|
248
|
+
properties: op.data
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
case "update":
|
|
252
|
+
return {
|
|
253
|
+
type: "update",
|
|
254
|
+
nodeId: op.id,
|
|
255
|
+
options: {
|
|
256
|
+
properties: op.data
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
case "delete":
|
|
260
|
+
return {
|
|
261
|
+
type: "delete",
|
|
262
|
+
nodeId: op.id
|
|
263
|
+
};
|
|
264
|
+
case "restore":
|
|
265
|
+
return {
|
|
266
|
+
type: "restore",
|
|
267
|
+
nodeId: op.id
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
function useMutate() {
|
|
273
|
+
const bridge = useDataBridge();
|
|
274
|
+
const telemetry = useTelemetryReporter();
|
|
275
|
+
const [pendingCount, setPendingCount] = useState(0);
|
|
276
|
+
const pendingRef = useRef2(0);
|
|
277
|
+
const withPending = useCallback2(async (fn) => {
|
|
278
|
+
pendingRef.current++;
|
|
279
|
+
setPendingCount(pendingRef.current);
|
|
280
|
+
try {
|
|
281
|
+
return await fn();
|
|
282
|
+
} finally {
|
|
283
|
+
pendingRef.current--;
|
|
284
|
+
setPendingCount(pendingRef.current);
|
|
285
|
+
}
|
|
286
|
+
}, []);
|
|
287
|
+
const create = useCallback2(
|
|
288
|
+
async (schema, data, id) => {
|
|
289
|
+
if (!bridge) return null;
|
|
290
|
+
const start = telemetry ? Date.now() : 0;
|
|
291
|
+
try {
|
|
292
|
+
const result = await withPending(async () => {
|
|
293
|
+
const node = await bridge.create(schema, data, id);
|
|
294
|
+
return flattenNode(node);
|
|
295
|
+
});
|
|
296
|
+
telemetry?.reportPerformance("react.useMutate.create", Date.now() - start);
|
|
297
|
+
telemetry?.reportUsage("react.useMutate.create.success", 1);
|
|
298
|
+
return result;
|
|
299
|
+
} catch (err) {
|
|
300
|
+
telemetry?.reportUsage("react.useMutate.create.failure", 1);
|
|
301
|
+
telemetry?.reportCrash(err instanceof Error ? err : new Error(String(err)), {
|
|
302
|
+
codeNamespace: "react.useMutate.create"
|
|
303
|
+
});
|
|
304
|
+
throw err;
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
[bridge, telemetry, withPending]
|
|
308
|
+
);
|
|
309
|
+
const update = useCallback2(
|
|
310
|
+
async (_schema, id, data) => {
|
|
311
|
+
if (!bridge) return null;
|
|
312
|
+
const start = telemetry ? Date.now() : 0;
|
|
313
|
+
try {
|
|
314
|
+
const result = await withPending(async () => {
|
|
315
|
+
const node = await bridge.update(id, data);
|
|
316
|
+
return flattenNode(node);
|
|
317
|
+
});
|
|
318
|
+
telemetry?.reportPerformance("react.useMutate.update", Date.now() - start);
|
|
319
|
+
telemetry?.reportUsage("react.useMutate.update.success", 1);
|
|
320
|
+
return result;
|
|
321
|
+
} catch (err) {
|
|
322
|
+
telemetry?.reportUsage("react.useMutate.update.failure", 1);
|
|
323
|
+
telemetry?.reportCrash(err instanceof Error ? err : new Error(String(err)), {
|
|
324
|
+
codeNamespace: "react.useMutate.update",
|
|
325
|
+
nodeId: id
|
|
326
|
+
});
|
|
327
|
+
throw err;
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
[bridge, telemetry, withPending]
|
|
331
|
+
);
|
|
332
|
+
const remove = useCallback2(
|
|
333
|
+
async (id) => {
|
|
334
|
+
if (!bridge) return;
|
|
335
|
+
const start = telemetry ? Date.now() : 0;
|
|
336
|
+
try {
|
|
337
|
+
await withPending(async () => {
|
|
338
|
+
await bridge.delete(id);
|
|
339
|
+
});
|
|
340
|
+
telemetry?.reportPerformance("react.useMutate.delete", Date.now() - start);
|
|
341
|
+
telemetry?.reportUsage("react.useMutate.delete.success", 1);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
telemetry?.reportUsage("react.useMutate.delete.failure", 1);
|
|
344
|
+
telemetry?.reportCrash(err instanceof Error ? err : new Error(String(err)), {
|
|
345
|
+
codeNamespace: "react.useMutate.delete",
|
|
346
|
+
nodeId: id
|
|
347
|
+
});
|
|
348
|
+
throw err;
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
[bridge, telemetry, withPending]
|
|
352
|
+
);
|
|
353
|
+
const restore = useCallback2(
|
|
354
|
+
async (id) => {
|
|
355
|
+
if (!bridge) return null;
|
|
356
|
+
const start = telemetry ? Date.now() : 0;
|
|
357
|
+
try {
|
|
358
|
+
const result = await withPending(async () => {
|
|
359
|
+
const node = await bridge.restore(id);
|
|
360
|
+
return flattenNode(node);
|
|
361
|
+
});
|
|
362
|
+
telemetry?.reportPerformance("react.useMutate.restore", Date.now() - start);
|
|
363
|
+
telemetry?.reportUsage("react.useMutate.restore.success", 1);
|
|
364
|
+
return result;
|
|
365
|
+
} catch (err) {
|
|
366
|
+
telemetry?.reportUsage("react.useMutate.restore.failure", 1);
|
|
367
|
+
telemetry?.reportCrash(err instanceof Error ? err : new Error(String(err)), {
|
|
368
|
+
codeNamespace: "react.useMutate.restore",
|
|
369
|
+
nodeId: id
|
|
370
|
+
});
|
|
371
|
+
throw err;
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
[bridge, telemetry, withPending]
|
|
375
|
+
);
|
|
376
|
+
const mutate = useCallback2(
|
|
377
|
+
async (ops) => {
|
|
378
|
+
if (!bridge || ops.length === 0) return null;
|
|
379
|
+
const start = telemetry ? Date.now() : 0;
|
|
380
|
+
try {
|
|
381
|
+
const result = await withPending(async () => {
|
|
382
|
+
const nodeStore = bridge.nodeStore;
|
|
383
|
+
const canUseStoreTransactions = nodeStore && typeof nodeStore.transaction === "function";
|
|
384
|
+
const usesTempIds = hasTempIdsInOps(ops);
|
|
385
|
+
if (usesTempIds && !canUseStoreTransactions) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
"Temp IDs in useMutate.mutate() require a transaction-capable bridge (NodeStore.transaction). Current bridge executes operations sequentially and cannot resolve temp IDs."
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (canUseStoreTransactions) {
|
|
391
|
+
const tx = await nodeStore.transaction(toTransactionOperations(ops));
|
|
392
|
+
return {
|
|
393
|
+
results: tx.results,
|
|
394
|
+
batchId: tx.batchId,
|
|
395
|
+
tempIds: tx.tempIds
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const results = [];
|
|
399
|
+
for (const op of ops) {
|
|
400
|
+
switch (op.type) {
|
|
401
|
+
case "create": {
|
|
402
|
+
const node = await bridge.create(
|
|
403
|
+
op.schema,
|
|
404
|
+
op.data,
|
|
405
|
+
op.id
|
|
406
|
+
);
|
|
407
|
+
results.push(node);
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
case "update": {
|
|
411
|
+
const node = await bridge.update(op.id, op.data);
|
|
412
|
+
results.push(node);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case "delete": {
|
|
416
|
+
await bridge.delete(op.id);
|
|
417
|
+
results.push(null);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "restore": {
|
|
421
|
+
const node = await bridge.restore(op.id);
|
|
422
|
+
results.push(node);
|
|
423
|
+
break;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return { results };
|
|
428
|
+
});
|
|
429
|
+
telemetry?.reportPerformance("react.useMutate.transaction", Date.now() - start);
|
|
430
|
+
telemetry?.reportUsage("react.useMutate.transaction.success", 1);
|
|
431
|
+
return result;
|
|
432
|
+
} catch (err) {
|
|
433
|
+
telemetry?.reportUsage("react.useMutate.transaction.failure", 1);
|
|
434
|
+
telemetry?.reportCrash(err instanceof Error ? err : new Error(String(err)), {
|
|
435
|
+
codeNamespace: "react.useMutate.transaction",
|
|
436
|
+
opCount: ops.length
|
|
437
|
+
});
|
|
438
|
+
throw err;
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
[bridge, telemetry, withPending]
|
|
442
|
+
);
|
|
443
|
+
return {
|
|
444
|
+
create,
|
|
445
|
+
update,
|
|
446
|
+
remove,
|
|
447
|
+
restore,
|
|
448
|
+
mutate,
|
|
449
|
+
isPending: pendingCount > 0,
|
|
450
|
+
pendingCount
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// src/hooks/useNode.ts
|
|
455
|
+
import { useState as useState2, useEffect as useEffect2, useCallback as useCallback3, useRef as useRef3, useMemo as useMemo2 } from "react";
|
|
456
|
+
import * as Y2 from "yjs";
|
|
457
|
+
|
|
458
|
+
// src/sync/WebSocketSyncProvider.ts
|
|
459
|
+
import {
|
|
460
|
+
Awareness,
|
|
461
|
+
applyAwarenessUpdate,
|
|
462
|
+
encodeAwarenessUpdate,
|
|
463
|
+
removeAwarenessStates
|
|
464
|
+
} from "y-protocols/awareness";
|
|
465
|
+
import * as Y from "yjs";
|
|
466
|
+
function log(provider, ...args) {
|
|
467
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("xnet:sync:debug") === "true") {
|
|
468
|
+
console.log(`[WSSyncProvider:${provider.room}]`, ...args);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
function toBase64(data) {
|
|
472
|
+
if (typeof Buffer !== "undefined") {
|
|
473
|
+
return Buffer.from(data).toString("base64");
|
|
474
|
+
}
|
|
475
|
+
let binary = "";
|
|
476
|
+
for (let i = 0; i < data.length; i++) {
|
|
477
|
+
binary += String.fromCharCode(data[i]);
|
|
478
|
+
}
|
|
479
|
+
return btoa(binary);
|
|
480
|
+
}
|
|
481
|
+
function fromBase64(str) {
|
|
482
|
+
if (typeof Buffer !== "undefined") {
|
|
483
|
+
return new Uint8Array(Buffer.from(str, "base64"));
|
|
484
|
+
}
|
|
485
|
+
const binary = atob(str);
|
|
486
|
+
const bytes = new Uint8Array(binary.length);
|
|
487
|
+
for (let i = 0; i < binary.length; i++) {
|
|
488
|
+
bytes[i] = binary.charCodeAt(i);
|
|
489
|
+
}
|
|
490
|
+
return bytes;
|
|
491
|
+
}
|
|
492
|
+
var WebSocketSyncProvider = class {
|
|
493
|
+
doc;
|
|
494
|
+
room;
|
|
495
|
+
url;
|
|
496
|
+
awareness;
|
|
497
|
+
ws = null;
|
|
498
|
+
reconnectDelay;
|
|
499
|
+
maxReconnectAttempts;
|
|
500
|
+
reconnectAttempts = 0;
|
|
501
|
+
reconnectTimer = null;
|
|
502
|
+
destroyed = false;
|
|
503
|
+
connected = false;
|
|
504
|
+
synced = false;
|
|
505
|
+
peerId;
|
|
506
|
+
remotePeerIds = /* @__PURE__ */ new Set();
|
|
507
|
+
eventHandlers = /* @__PURE__ */ new Map();
|
|
508
|
+
constructor(doc, options) {
|
|
509
|
+
this.doc = doc;
|
|
510
|
+
this.room = options.room;
|
|
511
|
+
this.url = options.url;
|
|
512
|
+
this.reconnectDelay = options.reconnectDelay ?? 2e3;
|
|
513
|
+
this.maxReconnectAttempts = options.maxReconnectAttempts ?? Infinity;
|
|
514
|
+
this.peerId = Math.random().toString(36).slice(2, 10);
|
|
515
|
+
this.awareness = new Awareness(doc);
|
|
516
|
+
this.doc.on("update", this._onDocUpdate);
|
|
517
|
+
this.awareness.on("update", this._onAwarenessUpdate);
|
|
518
|
+
this._connect();
|
|
519
|
+
}
|
|
520
|
+
get isConnected() {
|
|
521
|
+
return this.connected;
|
|
522
|
+
}
|
|
523
|
+
get isSynced() {
|
|
524
|
+
return this.synced;
|
|
525
|
+
}
|
|
526
|
+
/** Helper to get XML fragment length for debug logging */
|
|
527
|
+
_getFragmentLength() {
|
|
528
|
+
try {
|
|
529
|
+
const fragment = this.doc.getXmlFragment("default");
|
|
530
|
+
return fragment?.length ?? 0;
|
|
531
|
+
} catch {
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
on(event, handler) {
|
|
536
|
+
if (!this.eventHandlers.has(event)) {
|
|
537
|
+
this.eventHandlers.set(event, /* @__PURE__ */ new Set());
|
|
538
|
+
}
|
|
539
|
+
this.eventHandlers.get(event).add(handler);
|
|
540
|
+
}
|
|
541
|
+
off(event, handler) {
|
|
542
|
+
this.eventHandlers.get(event)?.delete(handler);
|
|
543
|
+
}
|
|
544
|
+
emit(event, data) {
|
|
545
|
+
this.eventHandlers.get(event)?.forEach((handler) => handler(data));
|
|
546
|
+
}
|
|
547
|
+
destroy() {
|
|
548
|
+
this.destroyed = true;
|
|
549
|
+
this.doc.off("update", this._onDocUpdate);
|
|
550
|
+
this.awareness.off("update", this._onAwarenessUpdate);
|
|
551
|
+
removeAwarenessStates(this.awareness, [this.doc.clientID], this);
|
|
552
|
+
if (this.reconnectTimer) {
|
|
553
|
+
clearTimeout(this.reconnectTimer);
|
|
554
|
+
this.reconnectTimer = null;
|
|
555
|
+
}
|
|
556
|
+
if (this.ws) {
|
|
557
|
+
this._send({ type: "unsubscribe", topics: [this.room] });
|
|
558
|
+
this.ws.close();
|
|
559
|
+
this.ws = null;
|
|
560
|
+
}
|
|
561
|
+
this.connected = false;
|
|
562
|
+
this.emit("status", { connected: false });
|
|
563
|
+
this.eventHandlers.clear();
|
|
564
|
+
}
|
|
565
|
+
_connect() {
|
|
566
|
+
if (this.destroyed) return;
|
|
567
|
+
try {
|
|
568
|
+
this.ws = new WebSocket(this.url);
|
|
569
|
+
this.ws.onopen = () => {
|
|
570
|
+
log(this, "WebSocket connected to", this.url);
|
|
571
|
+
this.connected = true;
|
|
572
|
+
this.reconnectAttempts = 0;
|
|
573
|
+
this.emit("status", { connected: true });
|
|
574
|
+
log(this, "Subscribing to room:", this.room);
|
|
575
|
+
this._send({ type: "subscribe", topics: [this.room] });
|
|
576
|
+
const sv = Y.encodeStateVector(this.doc);
|
|
577
|
+
log(this, "Sending sync-step1, state vector size:", sv.length);
|
|
578
|
+
this._publish({
|
|
579
|
+
type: "sync-step1",
|
|
580
|
+
from: this.peerId,
|
|
581
|
+
sv: toBase64(sv)
|
|
582
|
+
});
|
|
583
|
+
const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [this.doc.clientID]);
|
|
584
|
+
log(this, "Sending initial awareness update");
|
|
585
|
+
this._publish({
|
|
586
|
+
type: "awareness",
|
|
587
|
+
from: this.peerId,
|
|
588
|
+
update: toBase64(awarenessUpdate)
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
this.ws.onmessage = (event) => {
|
|
592
|
+
try {
|
|
593
|
+
const msg = JSON.parse(event.data);
|
|
594
|
+
if (msg.type === "publish" && msg.topic === this.room) {
|
|
595
|
+
this._handleSyncMessage(msg.data);
|
|
596
|
+
} else if (msg.type === "pong") {
|
|
597
|
+
}
|
|
598
|
+
} catch {
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
this.ws.onclose = (event) => {
|
|
602
|
+
log(this, "WebSocket closed, code:", event.code, "reason:", event.reason || "(none)");
|
|
603
|
+
this.connected = false;
|
|
604
|
+
this.synced = false;
|
|
605
|
+
this.remotePeerIds.clear();
|
|
606
|
+
this.emit("peers", { count: 0 });
|
|
607
|
+
this.emit("status", { connected: false });
|
|
608
|
+
this._scheduleReconnect();
|
|
609
|
+
};
|
|
610
|
+
this.ws.onerror = (event) => {
|
|
611
|
+
log(this, "WebSocket error:", event);
|
|
612
|
+
};
|
|
613
|
+
} catch {
|
|
614
|
+
this._scheduleReconnect();
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
_scheduleReconnect() {
|
|
618
|
+
if (this.destroyed) return;
|
|
619
|
+
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
|
620
|
+
this.reconnectAttempts++;
|
|
621
|
+
this.reconnectTimer = setTimeout(() => {
|
|
622
|
+
this._connect();
|
|
623
|
+
}, this.reconnectDelay);
|
|
624
|
+
}
|
|
625
|
+
_send(msg) {
|
|
626
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
627
|
+
this.ws.send(JSON.stringify(msg));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
_publish(data) {
|
|
631
|
+
this._send({
|
|
632
|
+
type: "publish",
|
|
633
|
+
topic: this.room,
|
|
634
|
+
data
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
/** Handle incoming sync messages from other peers */
|
|
638
|
+
_handleSyncMessage(data) {
|
|
639
|
+
if (!data || data.from === this.peerId) return;
|
|
640
|
+
if (data.from && typeof data.from === "string") {
|
|
641
|
+
const hadPeer = this.remotePeerIds.has(data.from);
|
|
642
|
+
this.remotePeerIds.add(data.from);
|
|
643
|
+
if (!hadPeer) {
|
|
644
|
+
this.emit("peers", { count: this.remotePeerIds.size });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
log(this, "Received message type:", data.type, "from:", data.from);
|
|
648
|
+
switch (data.type) {
|
|
649
|
+
case "sync-step1": {
|
|
650
|
+
const remoteSV = fromBase64(data.sv);
|
|
651
|
+
const diff = Y.encodeStateAsUpdate(this.doc, remoteSV);
|
|
652
|
+
log(
|
|
653
|
+
this,
|
|
654
|
+
"Received sync-step1 from peer, their SV size:",
|
|
655
|
+
remoteSV.length,
|
|
656
|
+
"our diff size:",
|
|
657
|
+
diff.length
|
|
658
|
+
);
|
|
659
|
+
log(this, "Sending sync-step2 to peer:", data.from, "update size:", diff.length);
|
|
660
|
+
this._publish({
|
|
661
|
+
type: "sync-step2",
|
|
662
|
+
from: this.peerId,
|
|
663
|
+
to: data.from,
|
|
664
|
+
update: toBase64(diff)
|
|
665
|
+
});
|
|
666
|
+
if (!this.synced) {
|
|
667
|
+
const sv = Y.encodeStateVector(this.doc);
|
|
668
|
+
log(this, "Sending our sync-step1 in response, SV size:", sv.length);
|
|
669
|
+
this._publish({
|
|
670
|
+
type: "sync-step1",
|
|
671
|
+
from: this.peerId,
|
|
672
|
+
sv: toBase64(sv)
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
const awarenessUpdate = encodeAwarenessUpdate(this.awareness, [this.doc.clientID]);
|
|
676
|
+
this._publish({
|
|
677
|
+
type: "awareness",
|
|
678
|
+
from: this.peerId,
|
|
679
|
+
update: toBase64(awarenessUpdate)
|
|
680
|
+
});
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
case "sync-step2": {
|
|
684
|
+
if (data.to && data.to !== this.peerId) {
|
|
685
|
+
log(this, "Ignoring sync-step2 addressed to different peer:", data.to);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const update = fromBase64(data.update);
|
|
689
|
+
log(
|
|
690
|
+
this,
|
|
691
|
+
"Received sync-step2, applying update size:",
|
|
692
|
+
update.length,
|
|
693
|
+
"doc content before:",
|
|
694
|
+
this.doc.getMap("meta").size,
|
|
695
|
+
"keys"
|
|
696
|
+
);
|
|
697
|
+
Y.applyUpdate(this.doc, update, this);
|
|
698
|
+
log(
|
|
699
|
+
this,
|
|
700
|
+
"After applying update, doc content:",
|
|
701
|
+
this.doc.getMap("meta").size,
|
|
702
|
+
"keys, XML fragment length:",
|
|
703
|
+
this._getFragmentLength()
|
|
704
|
+
);
|
|
705
|
+
if (!this.synced) {
|
|
706
|
+
this.synced = true;
|
|
707
|
+
log(this, "Sync complete! Emitting synced event");
|
|
708
|
+
this.emit("synced", { synced: true });
|
|
709
|
+
}
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
case "sync-update": {
|
|
713
|
+
const update = fromBase64(data.update);
|
|
714
|
+
log(this, "Received sync-update, size:", update.length);
|
|
715
|
+
Y.applyUpdate(this.doc, update, this);
|
|
716
|
+
break;
|
|
717
|
+
}
|
|
718
|
+
case "awareness": {
|
|
719
|
+
const update = fromBase64(data.update);
|
|
720
|
+
log(this, "Received awareness update");
|
|
721
|
+
applyAwarenessUpdate(this.awareness, update, this);
|
|
722
|
+
break;
|
|
723
|
+
}
|
|
724
|
+
case "awareness-snapshot": {
|
|
725
|
+
const users = Array.isArray(data.users) ? data.users : [];
|
|
726
|
+
this.emit("awareness-snapshot", users);
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
/** Broadcast local doc updates to peers */
|
|
732
|
+
_onDocUpdate = (update, origin) => {
|
|
733
|
+
if (origin === this) return;
|
|
734
|
+
if (this.connected) {
|
|
735
|
+
this._publish({
|
|
736
|
+
type: "sync-update",
|
|
737
|
+
from: this.peerId,
|
|
738
|
+
update: toBase64(update)
|
|
739
|
+
});
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
/** Broadcast local awareness changes to peers */
|
|
743
|
+
_onAwarenessUpdate = ({ added, updated, removed }, origin) => {
|
|
744
|
+
if (origin === this) return;
|
|
745
|
+
const changedClients = [...added, ...updated, ...removed];
|
|
746
|
+
if (changedClients.length === 0) return;
|
|
747
|
+
if (this.connected) {
|
|
748
|
+
const update = encodeAwarenessUpdate(this.awareness, changedClients);
|
|
749
|
+
this._publish({
|
|
750
|
+
type: "awareness",
|
|
751
|
+
from: this.peerId,
|
|
752
|
+
update: toBase64(update)
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// src/hooks/useSyncManager.ts
|
|
759
|
+
import { useContext } from "react";
|
|
760
|
+
function useSyncManager() {
|
|
761
|
+
const context = useContext(XNetContext);
|
|
762
|
+
return context?.syncManager ?? null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// src/hooks/useNode.ts
|
|
766
|
+
function log2(...args) {
|
|
767
|
+
if (typeof localStorage !== "undefined" && localStorage.getItem("xnet:sync:debug") === "true") {
|
|
768
|
+
console.log("[useNode]", ...args);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
var DEFAULT_SIGNALING_SERVERS = ["ws://localhost:4444"];
|
|
772
|
+
var DEFAULT_PERSIST_DEBOUNCE = 1e3;
|
|
773
|
+
function generateColor(seed) {
|
|
774
|
+
let hash = 0;
|
|
775
|
+
for (let i = 0; i < seed.length; i++) {
|
|
776
|
+
hash = seed.charCodeAt(i) + ((hash << 5) - hash);
|
|
777
|
+
}
|
|
778
|
+
const hue = Math.abs(hash % 360);
|
|
779
|
+
const s = 0.7;
|
|
780
|
+
const l = 0.5;
|
|
781
|
+
const c = (1 - Math.abs(2 * l - 1)) * s;
|
|
782
|
+
const x = c * (1 - Math.abs(hue / 60 % 2 - 1));
|
|
783
|
+
const m = l - c / 2;
|
|
784
|
+
let r = 0, g = 0, b = 0;
|
|
785
|
+
if (hue < 60) {
|
|
786
|
+
r = c;
|
|
787
|
+
g = x;
|
|
788
|
+
b = 0;
|
|
789
|
+
} else if (hue < 120) {
|
|
790
|
+
r = x;
|
|
791
|
+
g = c;
|
|
792
|
+
b = 0;
|
|
793
|
+
} else if (hue < 180) {
|
|
794
|
+
r = 0;
|
|
795
|
+
g = c;
|
|
796
|
+
b = x;
|
|
797
|
+
} else if (hue < 240) {
|
|
798
|
+
r = 0;
|
|
799
|
+
g = x;
|
|
800
|
+
b = c;
|
|
801
|
+
} else if (hue < 300) {
|
|
802
|
+
r = x;
|
|
803
|
+
g = 0;
|
|
804
|
+
b = c;
|
|
805
|
+
} else {
|
|
806
|
+
r = c;
|
|
807
|
+
g = 0;
|
|
808
|
+
b = x;
|
|
809
|
+
}
|
|
810
|
+
const toHex = (v) => Math.round((v + m) * 255).toString(16).padStart(2, "0");
|
|
811
|
+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
|
812
|
+
}
|
|
813
|
+
var pendingFlushes = /* @__PURE__ */ new Map();
|
|
814
|
+
function useNode(schema, id, options = {}) {
|
|
815
|
+
const {
|
|
816
|
+
signalingServers = DEFAULT_SIGNALING_SERVERS,
|
|
817
|
+
disableSync = false,
|
|
818
|
+
persistDebounce = DEFAULT_PERSIST_DEBOUNCE,
|
|
819
|
+
createIfMissing,
|
|
820
|
+
did
|
|
821
|
+
} = options;
|
|
822
|
+
const createIfMissingRef = useRef3(createIfMissing);
|
|
823
|
+
createIfMissingRef.current = createIfMissing;
|
|
824
|
+
const { store, isReady } = useNodeStore();
|
|
825
|
+
const syncManager = useSyncManager();
|
|
826
|
+
const bridge = useDataBridge();
|
|
827
|
+
const instrumentation = useInstrumentation();
|
|
828
|
+
const schemaId = schema._schemaId;
|
|
829
|
+
const hasDocument = schema.schema.document === "yjs";
|
|
830
|
+
const usingBridgeRef = useRef3(false);
|
|
831
|
+
const [data, setData] = useState2(null);
|
|
832
|
+
const [doc, setDoc] = useState2(null);
|
|
833
|
+
const [loading, setLoading] = useState2(true);
|
|
834
|
+
const [error, setError] = useState2(null);
|
|
835
|
+
const [isDirty, setIsDirty] = useState2(false);
|
|
836
|
+
const [lastSavedAt, setLastSavedAt] = useState2(null);
|
|
837
|
+
const [syncStatus, setSyncStatus] = useState2("offline");
|
|
838
|
+
const [syncError, setSyncError] = useState2(null);
|
|
839
|
+
const [peerCount, setPeerCount] = useState2(0);
|
|
840
|
+
const [wasCreated, setWasCreated] = useState2(false);
|
|
841
|
+
const [livePresence, setLivePresence] = useState2([]);
|
|
842
|
+
const [snapshotPresence, setSnapshotPresence] = useState2([]);
|
|
843
|
+
const [awareness, setAwareness] = useState2(null);
|
|
844
|
+
const presence = useMemo2(() => {
|
|
845
|
+
if (livePresence.length === 0 && snapshotPresence.length === 0) return [];
|
|
846
|
+
const liveByDid = new Map(livePresence.map((user) => [user.did, user]));
|
|
847
|
+
const snapshotByDid = new Map(snapshotPresence.map((user) => [user.did, user]));
|
|
848
|
+
const mergedLive = livePresence.map((user) => ({
|
|
849
|
+
...snapshotByDid.get(user.did),
|
|
850
|
+
...user,
|
|
851
|
+
isStale: false
|
|
852
|
+
}));
|
|
853
|
+
const mergedSnapshot = snapshotPresence.filter((user) => !liveByDid.has(user.did));
|
|
854
|
+
return [...mergedLive, ...mergedSnapshot];
|
|
855
|
+
}, [livePresence, snapshotPresence]);
|
|
856
|
+
const providerRef = useRef3(null);
|
|
857
|
+
const saveTimeoutRef = useRef3(null);
|
|
858
|
+
const docRef = useRef3(null);
|
|
859
|
+
const creatingRef = useRef3(false);
|
|
860
|
+
const storeRef = useRef3(store);
|
|
861
|
+
storeRef.current = store;
|
|
862
|
+
const dataRef = useRef3(null);
|
|
863
|
+
dataRef.current = data;
|
|
864
|
+
const queryIdRef = useRef3(
|
|
865
|
+
`useNode-${schemaId}-${id || "null"}-${Math.random().toString(36).slice(2, 8)}`
|
|
866
|
+
);
|
|
867
|
+
useEffect2(() => {
|
|
868
|
+
if (!instrumentation?.queryTracker || !id) return;
|
|
869
|
+
instrumentation.queryTracker.register(queryIdRef.current, {
|
|
870
|
+
type: "useNode",
|
|
871
|
+
schemaId,
|
|
872
|
+
mode: "document",
|
|
873
|
+
nodeId: id
|
|
874
|
+
});
|
|
875
|
+
return () => {
|
|
876
|
+
instrumentation.queryTracker.unregister(queryIdRef.current);
|
|
877
|
+
};
|
|
878
|
+
}, [instrumentation, schemaId, id]);
|
|
879
|
+
const load = useCallback3(async () => {
|
|
880
|
+
if (!store || !isReady || !id) {
|
|
881
|
+
setData(null);
|
|
882
|
+
setDoc(null);
|
|
883
|
+
setLoading(false);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (hasDocument && !disableSync && !syncManager) {
|
|
887
|
+
setLoading(true);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
setLoading(true);
|
|
891
|
+
setError(null);
|
|
892
|
+
setWasCreated(false);
|
|
893
|
+
setSyncError(null);
|
|
894
|
+
try {
|
|
895
|
+
const pendingFlush = pendingFlushes.get(id);
|
|
896
|
+
if (pendingFlush) {
|
|
897
|
+
await pendingFlush;
|
|
898
|
+
}
|
|
899
|
+
let node = await store.get(id);
|
|
900
|
+
let justCreated = false;
|
|
901
|
+
if (!node && createIfMissingRef.current && !creatingRef.current) {
|
|
902
|
+
creatingRef.current = true;
|
|
903
|
+
try {
|
|
904
|
+
node = await store.create({
|
|
905
|
+
id,
|
|
906
|
+
schemaId,
|
|
907
|
+
properties: createIfMissingRef.current
|
|
908
|
+
});
|
|
909
|
+
justCreated = true;
|
|
910
|
+
setWasCreated(true);
|
|
911
|
+
log2("Node auto-created:", id);
|
|
912
|
+
} finally {
|
|
913
|
+
creatingRef.current = false;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
if (node?.deleted && createIfMissingRef.current) {
|
|
917
|
+
node = await store.restore(id);
|
|
918
|
+
}
|
|
919
|
+
if (!node || node.schemaId !== schemaId) {
|
|
920
|
+
setData(null);
|
|
921
|
+
setDoc(null);
|
|
922
|
+
setLoading(false);
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
setData(flattenNode(node));
|
|
926
|
+
if (hasDocument) {
|
|
927
|
+
if (docRef.current && docRef.current.guid === id) {
|
|
928
|
+
setDoc(docRef.current);
|
|
929
|
+
setLastSavedAt(node.updatedAt);
|
|
930
|
+
} else {
|
|
931
|
+
if (docRef.current) {
|
|
932
|
+
instrumentation?.yDocRegistry.unregister(docRef.current.guid);
|
|
933
|
+
if (!usingBridgeRef.current) {
|
|
934
|
+
if (providerRef.current) {
|
|
935
|
+
providerRef.current.destroy();
|
|
936
|
+
providerRef.current = null;
|
|
937
|
+
}
|
|
938
|
+
docRef.current.destroy();
|
|
939
|
+
} else if (docRef.current.guid) {
|
|
940
|
+
syncManager?.release(docRef.current.guid);
|
|
941
|
+
}
|
|
942
|
+
docRef.current = null;
|
|
943
|
+
setDoc(null);
|
|
944
|
+
usingBridgeRef.current = false;
|
|
945
|
+
}
|
|
946
|
+
let ydoc;
|
|
947
|
+
let acquiredAwareness = null;
|
|
948
|
+
if (bridge?.acquireDoc && !disableSync) {
|
|
949
|
+
const acquired = await bridge.acquireDoc(id);
|
|
950
|
+
ydoc = acquired.doc;
|
|
951
|
+
acquiredAwareness = acquired.awareness;
|
|
952
|
+
usingBridgeRef.current = true;
|
|
953
|
+
const storedContent = await store.getDocumentContent(id);
|
|
954
|
+
if (storedContent && storedContent.length > 0) {
|
|
955
|
+
Y2.applyUpdate(ydoc, storedContent, "storage");
|
|
956
|
+
}
|
|
957
|
+
if (syncManager) {
|
|
958
|
+
syncManager.track(id, schemaId);
|
|
959
|
+
}
|
|
960
|
+
} else if (syncManager && !disableSync) {
|
|
961
|
+
ydoc = await syncManager.acquire(id);
|
|
962
|
+
usingBridgeRef.current = true;
|
|
963
|
+
const storedContent = await store.getDocumentContent(id);
|
|
964
|
+
if (storedContent && storedContent.length > 0) {
|
|
965
|
+
Y2.applyUpdate(ydoc, storedContent, "storage");
|
|
966
|
+
}
|
|
967
|
+
syncManager.track(id, schemaId);
|
|
968
|
+
} else {
|
|
969
|
+
log2("Using fallback WebSocketSyncProvider path for:", id);
|
|
970
|
+
ydoc = new Y2.Doc({ guid: id, gc: false });
|
|
971
|
+
usingBridgeRef.current = false;
|
|
972
|
+
const storedContent = await store.getDocumentContent(id);
|
|
973
|
+
if (storedContent && storedContent.length > 0) {
|
|
974
|
+
Y2.applyUpdate(ydoc, storedContent);
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (acquiredAwareness) {
|
|
978
|
+
setAwareness(acquiredAwareness);
|
|
979
|
+
}
|
|
980
|
+
docRef.current = ydoc;
|
|
981
|
+
const metaMap = ydoc.getMap("meta");
|
|
982
|
+
if (metaMap.size === 0 && node.properties) {
|
|
983
|
+
const entries = Object.entries(node.properties);
|
|
984
|
+
const hasContent = entries.some(([, v]) => v !== "" && v !== null && v !== void 0);
|
|
985
|
+
if (hasContent || !justCreated) {
|
|
986
|
+
ydoc.transact(() => {
|
|
987
|
+
metaMap.set("_schemaId", schemaId);
|
|
988
|
+
for (const [key, value] of entries) {
|
|
989
|
+
if (!justCreated || value !== "" && value !== null && value !== void 0) {
|
|
990
|
+
metaMap.set(key, value);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
}, "local");
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
instrumentation?.yDocRegistry.register(id, ydoc);
|
|
997
|
+
setDoc(ydoc);
|
|
998
|
+
setLastSavedAt(node.updatedAt);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
} catch (err) {
|
|
1002
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1003
|
+
} finally {
|
|
1004
|
+
setLoading(false);
|
|
1005
|
+
}
|
|
1006
|
+
}, [store, isReady, id, schemaId, hasDocument, syncManager, disableSync]);
|
|
1007
|
+
const save = useCallback3(async () => {
|
|
1008
|
+
if (!store || !id || !docRef.current) return;
|
|
1009
|
+
saveTimeoutRef.current = null;
|
|
1010
|
+
try {
|
|
1011
|
+
const content = Y2.encodeStateAsUpdate(docRef.current);
|
|
1012
|
+
await store.setDocumentContent(id, content);
|
|
1013
|
+
setIsDirty(false);
|
|
1014
|
+
setLastSavedAt(Date.now());
|
|
1015
|
+
} catch (err) {
|
|
1016
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1017
|
+
}
|
|
1018
|
+
}, [store, id]);
|
|
1019
|
+
const saveRef = useRef3(save);
|
|
1020
|
+
saveRef.current = save;
|
|
1021
|
+
const scheduleSave = useCallback3(() => {
|
|
1022
|
+
setIsDirty(true);
|
|
1023
|
+
if (saveTimeoutRef.current) {
|
|
1024
|
+
clearTimeout(saveTimeoutRef.current);
|
|
1025
|
+
}
|
|
1026
|
+
saveTimeoutRef.current = setTimeout(() => {
|
|
1027
|
+
saveRef.current();
|
|
1028
|
+
}, persistDebounce);
|
|
1029
|
+
}, [persistDebounce]);
|
|
1030
|
+
const update = useCallback3(
|
|
1031
|
+
async (properties) => {
|
|
1032
|
+
if (!store || !isReady || !id) return;
|
|
1033
|
+
try {
|
|
1034
|
+
const node = await store.update(id, {
|
|
1035
|
+
properties
|
|
1036
|
+
});
|
|
1037
|
+
setData(flattenNode(node));
|
|
1038
|
+
if (docRef.current) {
|
|
1039
|
+
const metaMap = docRef.current.getMap("meta");
|
|
1040
|
+
docRef.current.transact(() => {
|
|
1041
|
+
metaMap.set("_schemaId", schemaId);
|
|
1042
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
1043
|
+
metaMap.set(key, value);
|
|
1044
|
+
}
|
|
1045
|
+
}, "local");
|
|
1046
|
+
}
|
|
1047
|
+
} catch (err) {
|
|
1048
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1049
|
+
}
|
|
1050
|
+
},
|
|
1051
|
+
[store, isReady, id]
|
|
1052
|
+
);
|
|
1053
|
+
const remove = useCallback3(async () => {
|
|
1054
|
+
if (!store || !isReady || !id) return;
|
|
1055
|
+
try {
|
|
1056
|
+
await store.delete(id);
|
|
1057
|
+
setData(null);
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1060
|
+
}
|
|
1061
|
+
}, [store, isReady, id]);
|
|
1062
|
+
useEffect2(() => {
|
|
1063
|
+
load();
|
|
1064
|
+
}, [load]);
|
|
1065
|
+
useEffect2(() => {
|
|
1066
|
+
if (!doc || !id || !hasDocument) return;
|
|
1067
|
+
if (doc.guid !== id) {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (usingBridgeRef.current && syncManager) {
|
|
1071
|
+
let receivedSyncData = false;
|
|
1072
|
+
let syncTimeoutId = null;
|
|
1073
|
+
const updateHandler2 = (_update, origin) => {
|
|
1074
|
+
if (origin === "remote") {
|
|
1075
|
+
receivedSyncData = true;
|
|
1076
|
+
if (syncTimeoutId) {
|
|
1077
|
+
clearTimeout(syncTimeoutId);
|
|
1078
|
+
syncTimeoutId = null;
|
|
1079
|
+
}
|
|
1080
|
+
setSyncError(null);
|
|
1081
|
+
}
|
|
1082
|
+
scheduleSave();
|
|
1083
|
+
};
|
|
1084
|
+
doc.on("update", updateHandler2);
|
|
1085
|
+
const metaMap = doc.getMap("meta");
|
|
1086
|
+
const applyMetaToNodeStore = () => {
|
|
1087
|
+
if (metaMap.size === 0 || !storeRef.current || !id) return;
|
|
1088
|
+
const remoteProps = {};
|
|
1089
|
+
metaMap.forEach((value, key) => {
|
|
1090
|
+
if (key.startsWith("_")) return;
|
|
1091
|
+
remoteProps[key] = value;
|
|
1092
|
+
});
|
|
1093
|
+
if (Object.keys(remoteProps).length === 0) return;
|
|
1094
|
+
const currentData = dataRef.current;
|
|
1095
|
+
if (currentData) {
|
|
1096
|
+
let hasChanges = false;
|
|
1097
|
+
for (const [key, value] of Object.entries(remoteProps)) {
|
|
1098
|
+
if (currentData[key] !== value) {
|
|
1099
|
+
hasChanges = true;
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
if (!hasChanges) return;
|
|
1104
|
+
}
|
|
1105
|
+
storeRef.current.update(id, { properties: remoteProps }).then((node) => {
|
|
1106
|
+
if (node) setData(flattenNode(node));
|
|
1107
|
+
}).catch((err) => {
|
|
1108
|
+
console.warn("[useNode] Failed to apply remote meta to NodeStore:", err);
|
|
1109
|
+
});
|
|
1110
|
+
};
|
|
1111
|
+
const metaObserver = (event) => {
|
|
1112
|
+
const origin = event.transaction.origin;
|
|
1113
|
+
if (origin === null || origin === "local" || origin === "storage" || origin === METABRIDGE_ORIGIN || origin === METABRIDGE_SEED_ORIGIN) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
applyMetaToNodeStore();
|
|
1117
|
+
};
|
|
1118
|
+
metaMap.observe(metaObserver);
|
|
1119
|
+
if (metaMap.size > 0) {
|
|
1120
|
+
applyMetaToNodeStore();
|
|
1121
|
+
}
|
|
1122
|
+
const statusUnsub = syncManager.on("status", (status) => {
|
|
1123
|
+
setSyncStatus(
|
|
1124
|
+
status === "connected" ? "connected" : status === "connecting" ? "connecting" : "offline"
|
|
1125
|
+
);
|
|
1126
|
+
});
|
|
1127
|
+
setSyncStatus(
|
|
1128
|
+
syncManager.status === "connected" ? "connected" : syncManager.status === "connecting" ? "connecting" : "offline"
|
|
1129
|
+
);
|
|
1130
|
+
const awareness2 = syncManager.getAwareness(id);
|
|
1131
|
+
setAwareness(awareness2);
|
|
1132
|
+
if (awareness2 && did) {
|
|
1133
|
+
awareness2.setLocalStateField("user", {
|
|
1134
|
+
did,
|
|
1135
|
+
name: `${did.slice(8, 16)}...`,
|
|
1136
|
+
color: generateColor(did)
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
let awarenessCleanup = null;
|
|
1140
|
+
if (awareness2) {
|
|
1141
|
+
const awarenessHandler = () => {
|
|
1142
|
+
const states = awareness2.getStates();
|
|
1143
|
+
const nextPresence = [];
|
|
1144
|
+
states.forEach((state, clientId) => {
|
|
1145
|
+
if (clientId === awareness2.clientID) return;
|
|
1146
|
+
const user = state.user;
|
|
1147
|
+
if (user?.did) {
|
|
1148
|
+
nextPresence.push({
|
|
1149
|
+
did: user.did,
|
|
1150
|
+
name: user.name,
|
|
1151
|
+
color: user.color || generateColor(user.did),
|
|
1152
|
+
isStale: false
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
setLivePresence(nextPresence);
|
|
1157
|
+
};
|
|
1158
|
+
awareness2.on("change", awarenessHandler);
|
|
1159
|
+
awarenessHandler();
|
|
1160
|
+
awarenessCleanup = () => awareness2.off("change", awarenessHandler);
|
|
1161
|
+
}
|
|
1162
|
+
const snapshotUnsub = syncManager.onAwarenessSnapshot(id, (users) => {
|
|
1163
|
+
const mapped = users.map((user) => ({
|
|
1164
|
+
did: user.did,
|
|
1165
|
+
name: user.state.user?.name,
|
|
1166
|
+
color: user.state.user?.color ?? generateColor(user.did),
|
|
1167
|
+
lastSeen: user.lastSeen,
|
|
1168
|
+
isStale: user.isStale
|
|
1169
|
+
}));
|
|
1170
|
+
setSnapshotPresence(mapped);
|
|
1171
|
+
});
|
|
1172
|
+
if (wasCreated) {
|
|
1173
|
+
syncTimeoutId = setTimeout(() => {
|
|
1174
|
+
const fragment = doc.getXmlFragment("default");
|
|
1175
|
+
const metaMap2 = doc.getMap("meta");
|
|
1176
|
+
const hasContent = (fragment?.length ?? 0) > 0 || metaMap2.size > 1;
|
|
1177
|
+
if (!receivedSyncData && !hasContent) {
|
|
1178
|
+
const errorMsg = "Sync timeout: No content received from peers. The shared document may not exist or peers may be offline.";
|
|
1179
|
+
log2("Sync timeout - no content received for:", id);
|
|
1180
|
+
setSyncStatus("error");
|
|
1181
|
+
setSyncError(errorMsg);
|
|
1182
|
+
}
|
|
1183
|
+
}, 1e4);
|
|
1184
|
+
}
|
|
1185
|
+
return () => {
|
|
1186
|
+
if (syncTimeoutId) {
|
|
1187
|
+
clearTimeout(syncTimeoutId);
|
|
1188
|
+
}
|
|
1189
|
+
doc.off("update", updateHandler2);
|
|
1190
|
+
metaMap.unobserve(metaObserver);
|
|
1191
|
+
statusUnsub();
|
|
1192
|
+
if (awarenessCleanup) awarenessCleanup();
|
|
1193
|
+
snapshotUnsub();
|
|
1194
|
+
setAwareness(null);
|
|
1195
|
+
setSyncStatus("offline");
|
|
1196
|
+
setSyncError(null);
|
|
1197
|
+
setPeerCount(0);
|
|
1198
|
+
setLivePresence([]);
|
|
1199
|
+
setSnapshotPresence([]);
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
const updateHandler = (_update, _origin) => {
|
|
1203
|
+
scheduleSave();
|
|
1204
|
+
};
|
|
1205
|
+
doc.on("update", updateHandler);
|
|
1206
|
+
if (!disableSync && signalingServers.length > 0) {
|
|
1207
|
+
setSyncStatus("connecting");
|
|
1208
|
+
const provider = new WebSocketSyncProvider(doc, {
|
|
1209
|
+
url: signalingServers[0],
|
|
1210
|
+
room: `xnet-doc-${id}`
|
|
1211
|
+
});
|
|
1212
|
+
providerRef.current = provider;
|
|
1213
|
+
let receivedSyncData = false;
|
|
1214
|
+
let syncTimeoutId = null;
|
|
1215
|
+
const statusHandler = (event) => {
|
|
1216
|
+
const { connected } = event;
|
|
1217
|
+
setSyncStatus(connected ? "connected" : "connecting");
|
|
1218
|
+
};
|
|
1219
|
+
provider.on("status", statusHandler);
|
|
1220
|
+
const metaMap = doc.getMap("meta");
|
|
1221
|
+
const applyMetaToNodeStore = () => {
|
|
1222
|
+
if (metaMap.size === 0) return;
|
|
1223
|
+
const props = {};
|
|
1224
|
+
metaMap.forEach((value, key) => {
|
|
1225
|
+
if (key.startsWith("_")) return;
|
|
1226
|
+
props[key] = value;
|
|
1227
|
+
});
|
|
1228
|
+
if (Object.keys(props).length > 0 && storeRef.current && id) {
|
|
1229
|
+
storeRef.current.update(id, { properties: props }).then((node) => {
|
|
1230
|
+
if (node) setData(flattenNode(node));
|
|
1231
|
+
}).catch((err) => {
|
|
1232
|
+
console.warn("[useDocument] Failed to apply remote meta to NodeStore:", err);
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
const metaObserver = (event) => {
|
|
1237
|
+
const origin = event.transaction.origin;
|
|
1238
|
+
if (origin === null || origin === "local" || origin === "storage" || origin === METABRIDGE_ORIGIN || origin === METABRIDGE_SEED_ORIGIN) {
|
|
1239
|
+
return;
|
|
1240
|
+
}
|
|
1241
|
+
applyMetaToNodeStore();
|
|
1242
|
+
};
|
|
1243
|
+
metaMap.observe(metaObserver);
|
|
1244
|
+
const syncedHandler = (event) => {
|
|
1245
|
+
const { synced } = event;
|
|
1246
|
+
if (synced) {
|
|
1247
|
+
receivedSyncData = true;
|
|
1248
|
+
if (syncTimeoutId) {
|
|
1249
|
+
clearTimeout(syncTimeoutId);
|
|
1250
|
+
syncTimeoutId = null;
|
|
1251
|
+
}
|
|
1252
|
+
setSyncError(null);
|
|
1253
|
+
applyMetaToNodeStore();
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
provider.on("synced", syncedHandler);
|
|
1257
|
+
if (wasCreated) {
|
|
1258
|
+
syncTimeoutId = setTimeout(() => {
|
|
1259
|
+
const fragment = doc.getXmlFragment("default");
|
|
1260
|
+
const metaMap2 = doc.getMap("meta");
|
|
1261
|
+
const hasContent = (fragment?.length ?? 0) > 0 || metaMap2.size > 1;
|
|
1262
|
+
if (!receivedSyncData && !hasContent) {
|
|
1263
|
+
const errorMsg = "Sync timeout: No content received from peers. The shared document may not exist or peers may be offline.";
|
|
1264
|
+
log2("Sync timeout - no content received for:", id);
|
|
1265
|
+
setSyncStatus("error");
|
|
1266
|
+
setSyncError(errorMsg);
|
|
1267
|
+
}
|
|
1268
|
+
}, 1e4);
|
|
1269
|
+
}
|
|
1270
|
+
const peersHandler = (event) => {
|
|
1271
|
+
const { count } = event;
|
|
1272
|
+
setPeerCount(count);
|
|
1273
|
+
};
|
|
1274
|
+
provider.on("peers", peersHandler);
|
|
1275
|
+
const { awareness: providerAwareness } = provider;
|
|
1276
|
+
setAwareness(providerAwareness);
|
|
1277
|
+
if (did) {
|
|
1278
|
+
providerAwareness.setLocalStateField("user", {
|
|
1279
|
+
did,
|
|
1280
|
+
name: `${did.slice(8, 16)}...`,
|
|
1281
|
+
color: generateColor(did)
|
|
1282
|
+
});
|
|
1283
|
+
}
|
|
1284
|
+
const awarenessHandler = () => {
|
|
1285
|
+
const states = providerAwareness.getStates();
|
|
1286
|
+
const nextPresence = [];
|
|
1287
|
+
states.forEach((state, clientId) => {
|
|
1288
|
+
if (clientId === providerAwareness.clientID) return;
|
|
1289
|
+
const user = state.user;
|
|
1290
|
+
if (user?.did) {
|
|
1291
|
+
nextPresence.push({
|
|
1292
|
+
did: user.did,
|
|
1293
|
+
name: user.name,
|
|
1294
|
+
color: user.color || generateColor(user.did),
|
|
1295
|
+
isStale: false
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
});
|
|
1299
|
+
setLivePresence(nextPresence);
|
|
1300
|
+
};
|
|
1301
|
+
providerAwareness.on("change", awarenessHandler);
|
|
1302
|
+
const snapshotHandler = (users) => {
|
|
1303
|
+
if (!Array.isArray(users)) return;
|
|
1304
|
+
const mapped = users.filter(
|
|
1305
|
+
(user) => Boolean(
|
|
1306
|
+
user && typeof user === "object" && typeof user.did === "string"
|
|
1307
|
+
)
|
|
1308
|
+
).map(
|
|
1309
|
+
(user) => ({
|
|
1310
|
+
did: user.did,
|
|
1311
|
+
name: user.state.user?.name,
|
|
1312
|
+
color: user.state.user?.color ?? generateColor(user.did),
|
|
1313
|
+
lastSeen: user.lastSeen,
|
|
1314
|
+
isStale: user.isStale
|
|
1315
|
+
})
|
|
1316
|
+
);
|
|
1317
|
+
setSnapshotPresence(mapped);
|
|
1318
|
+
};
|
|
1319
|
+
provider.on("awareness-snapshot", snapshotHandler);
|
|
1320
|
+
return () => {
|
|
1321
|
+
if (syncTimeoutId) {
|
|
1322
|
+
clearTimeout(syncTimeoutId);
|
|
1323
|
+
}
|
|
1324
|
+
doc.off("update", updateHandler);
|
|
1325
|
+
metaMap.unobserve(metaObserver);
|
|
1326
|
+
providerAwareness.off("change", awarenessHandler);
|
|
1327
|
+
provider.off("awareness-snapshot", snapshotHandler);
|
|
1328
|
+
provider.off("synced", syncedHandler);
|
|
1329
|
+
provider.off("status", statusHandler);
|
|
1330
|
+
provider.off("peers", peersHandler);
|
|
1331
|
+
provider.destroy();
|
|
1332
|
+
providerRef.current = null;
|
|
1333
|
+
setAwareness(null);
|
|
1334
|
+
setSyncStatus("offline");
|
|
1335
|
+
setSyncError(null);
|
|
1336
|
+
setPeerCount(0);
|
|
1337
|
+
setLivePresence([]);
|
|
1338
|
+
setSnapshotPresence([]);
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
return () => {
|
|
1342
|
+
doc.off("update", updateHandler);
|
|
1343
|
+
};
|
|
1344
|
+
}, [
|
|
1345
|
+
doc,
|
|
1346
|
+
id,
|
|
1347
|
+
hasDocument,
|
|
1348
|
+
disableSync,
|
|
1349
|
+
signalingServers,
|
|
1350
|
+
scheduleSave,
|
|
1351
|
+
did,
|
|
1352
|
+
syncManager,
|
|
1353
|
+
wasCreated
|
|
1354
|
+
]);
|
|
1355
|
+
useEffect2(() => {
|
|
1356
|
+
return () => {
|
|
1357
|
+
if (saveTimeoutRef.current) {
|
|
1358
|
+
clearTimeout(saveTimeoutRef.current);
|
|
1359
|
+
saveTimeoutRef.current = null;
|
|
1360
|
+
}
|
|
1361
|
+
if (docRef.current && store && id) {
|
|
1362
|
+
const content = Y2.encodeStateAsUpdate(docRef.current);
|
|
1363
|
+
const flushPromise = store.setDocumentContent(id, content).catch(() => {
|
|
1364
|
+
}).finally(() => {
|
|
1365
|
+
pendingFlushes.delete(id);
|
|
1366
|
+
});
|
|
1367
|
+
pendingFlushes.set(id, flushPromise);
|
|
1368
|
+
}
|
|
1369
|
+
if (usingBridgeRef.current && id) {
|
|
1370
|
+
if (bridge?.releaseDoc) {
|
|
1371
|
+
bridge.releaseDoc(id);
|
|
1372
|
+
} else if (syncManager) {
|
|
1373
|
+
syncManager.release(id);
|
|
1374
|
+
}
|
|
1375
|
+
usingBridgeRef.current = false;
|
|
1376
|
+
}
|
|
1377
|
+
};
|
|
1378
|
+
}, [store, id, syncManager, bridge]);
|
|
1379
|
+
useEffect2(() => {
|
|
1380
|
+
if (!store || !id || !hasDocument) return;
|
|
1381
|
+
const handleBeforeUnload = () => {
|
|
1382
|
+
if (saveTimeoutRef.current) {
|
|
1383
|
+
clearTimeout(saveTimeoutRef.current);
|
|
1384
|
+
saveTimeoutRef.current = null;
|
|
1385
|
+
}
|
|
1386
|
+
if (docRef.current) {
|
|
1387
|
+
try {
|
|
1388
|
+
const content = Y2.encodeStateAsUpdate(docRef.current);
|
|
1389
|
+
store.setDocumentContent(id, content).catch(() => {
|
|
1390
|
+
});
|
|
1391
|
+
} catch {
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
};
|
|
1395
|
+
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
1396
|
+
return () => {
|
|
1397
|
+
window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
1398
|
+
};
|
|
1399
|
+
}, [store, id, hasDocument]);
|
|
1400
|
+
useEffect2(() => {
|
|
1401
|
+
if (!store || !id) return;
|
|
1402
|
+
const unsubscribe = store.subscribe((event) => {
|
|
1403
|
+
if (event.change.payload.nodeId !== id) return;
|
|
1404
|
+
if (event.node && event.node.schemaId === schemaId) {
|
|
1405
|
+
setData(flattenNode(event.node));
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
return unsubscribe;
|
|
1409
|
+
}, [store, id, schemaId]);
|
|
1410
|
+
useEffect2(() => {
|
|
1411
|
+
if (!instrumentation?.queryTracker || !id || loading) return;
|
|
1412
|
+
instrumentation.queryTracker.recordUpdate(queryIdRef.current, data ? 1 : 0, 0);
|
|
1413
|
+
}, [data, instrumentation, id, loading]);
|
|
1414
|
+
return {
|
|
1415
|
+
// Data
|
|
1416
|
+
data,
|
|
1417
|
+
doc,
|
|
1418
|
+
// Mutations
|
|
1419
|
+
update,
|
|
1420
|
+
remove,
|
|
1421
|
+
// State
|
|
1422
|
+
loading,
|
|
1423
|
+
error,
|
|
1424
|
+
isDirty,
|
|
1425
|
+
lastSavedAt,
|
|
1426
|
+
wasCreated,
|
|
1427
|
+
// Sync
|
|
1428
|
+
syncStatus,
|
|
1429
|
+
syncError,
|
|
1430
|
+
peerCount,
|
|
1431
|
+
// Presence
|
|
1432
|
+
presence,
|
|
1433
|
+
awareness,
|
|
1434
|
+
// Actions
|
|
1435
|
+
save,
|
|
1436
|
+
reload: load
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// src/hooks/useDatabaseDoc.ts
|
|
1441
|
+
import {
|
|
1442
|
+
getColumns,
|
|
1443
|
+
getColumn,
|
|
1444
|
+
createColumn as createColumnOp,
|
|
1445
|
+
updateColumn as updateColumnOp,
|
|
1446
|
+
deleteColumn as deleteColumnOp,
|
|
1447
|
+
reorderColumn as reorderColumnOp,
|
|
1448
|
+
duplicateColumn as duplicateColumnOp,
|
|
1449
|
+
getViews,
|
|
1450
|
+
getView,
|
|
1451
|
+
createView as createViewOp,
|
|
1452
|
+
updateView as updateViewOp,
|
|
1453
|
+
deleteView as deleteViewOp,
|
|
1454
|
+
duplicateView as duplicateViewOp,
|
|
1455
|
+
initializeDatabaseDoc,
|
|
1456
|
+
isDatabaseDocInitialized
|
|
1457
|
+
} from "@xnetjs/data";
|
|
1458
|
+
import { useState as useState3, useEffect as useEffect3, useCallback as useCallback4, useRef as useRef4 } from "react";
|
|
1459
|
+
import * as Y3 from "yjs";
|
|
1460
|
+
function useDatabaseDoc(databaseId) {
|
|
1461
|
+
const { store, isReady } = useNodeStore();
|
|
1462
|
+
const [doc, setDoc] = useState3(null);
|
|
1463
|
+
const [columns, setColumns] = useState3([]);
|
|
1464
|
+
const [views, setViews] = useState3([]);
|
|
1465
|
+
const [loading, setLoading] = useState3(true);
|
|
1466
|
+
const [error, setError] = useState3(null);
|
|
1467
|
+
const docRef = useRef4(null);
|
|
1468
|
+
docRef.current = doc;
|
|
1469
|
+
useEffect3(() => {
|
|
1470
|
+
if (!store || !isReady || !databaseId) {
|
|
1471
|
+
setDoc(null);
|
|
1472
|
+
setColumns([]);
|
|
1473
|
+
setViews([]);
|
|
1474
|
+
setLoading(false);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
let mounted = true;
|
|
1478
|
+
const loadDoc = async () => {
|
|
1479
|
+
try {
|
|
1480
|
+
setLoading(true);
|
|
1481
|
+
setError(null);
|
|
1482
|
+
const ydoc = new Y3.Doc({ guid: databaseId, gc: false });
|
|
1483
|
+
const storedContent = await store.getDocumentContent(databaseId);
|
|
1484
|
+
if (storedContent && storedContent.length > 0) {
|
|
1485
|
+
Y3.applyUpdate(ydoc, storedContent);
|
|
1486
|
+
}
|
|
1487
|
+
if (!mounted) {
|
|
1488
|
+
ydoc.destroy();
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (!isDatabaseDocInitialized(ydoc)) {
|
|
1492
|
+
initializeDatabaseDoc(ydoc);
|
|
1493
|
+
}
|
|
1494
|
+
docRef.current = ydoc;
|
|
1495
|
+
setDoc(ydoc);
|
|
1496
|
+
setColumns(getColumns(ydoc));
|
|
1497
|
+
setViews(getViews(ydoc));
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
if (!mounted) return;
|
|
1500
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1501
|
+
} finally {
|
|
1502
|
+
if (mounted) setLoading(false);
|
|
1503
|
+
}
|
|
1504
|
+
};
|
|
1505
|
+
loadDoc();
|
|
1506
|
+
return () => {
|
|
1507
|
+
mounted = false;
|
|
1508
|
+
if (docRef.current && store) {
|
|
1509
|
+
const content = Y3.encodeStateAsUpdate(docRef.current);
|
|
1510
|
+
store.setDocumentContent(databaseId, content).catch(() => {
|
|
1511
|
+
});
|
|
1512
|
+
docRef.current.destroy();
|
|
1513
|
+
docRef.current = null;
|
|
1514
|
+
}
|
|
1515
|
+
};
|
|
1516
|
+
}, [store, isReady, databaseId]);
|
|
1517
|
+
useEffect3(() => {
|
|
1518
|
+
if (!doc) return;
|
|
1519
|
+
const columnsArray = doc.getArray("columns");
|
|
1520
|
+
const updateColumns = () => {
|
|
1521
|
+
setColumns(getColumns(doc));
|
|
1522
|
+
};
|
|
1523
|
+
columnsArray.observeDeep(updateColumns);
|
|
1524
|
+
return () => {
|
|
1525
|
+
columnsArray.unobserveDeep(updateColumns);
|
|
1526
|
+
};
|
|
1527
|
+
}, [doc]);
|
|
1528
|
+
useEffect3(() => {
|
|
1529
|
+
if (!doc) return;
|
|
1530
|
+
const viewsMap = doc.getMap("views");
|
|
1531
|
+
const updateViews = () => {
|
|
1532
|
+
setViews(getViews(doc));
|
|
1533
|
+
};
|
|
1534
|
+
viewsMap.observeDeep(updateViews);
|
|
1535
|
+
return () => {
|
|
1536
|
+
viewsMap.unobserveDeep(updateViews);
|
|
1537
|
+
};
|
|
1538
|
+
}, [doc]);
|
|
1539
|
+
useEffect3(() => {
|
|
1540
|
+
if (!doc || !store) return;
|
|
1541
|
+
let saveTimeout = null;
|
|
1542
|
+
const handleUpdate = () => {
|
|
1543
|
+
if (saveTimeout) {
|
|
1544
|
+
clearTimeout(saveTimeout);
|
|
1545
|
+
}
|
|
1546
|
+
saveTimeout = setTimeout(() => {
|
|
1547
|
+
if (docRef.current && store) {
|
|
1548
|
+
const content = Y3.encodeStateAsUpdate(docRef.current);
|
|
1549
|
+
store.setDocumentContent(databaseId, content).catch(() => {
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}, 500);
|
|
1553
|
+
};
|
|
1554
|
+
doc.on("update", handleUpdate);
|
|
1555
|
+
return () => {
|
|
1556
|
+
doc.off("update", handleUpdate);
|
|
1557
|
+
if (saveTimeout) {
|
|
1558
|
+
clearTimeout(saveTimeout);
|
|
1559
|
+
}
|
|
1560
|
+
};
|
|
1561
|
+
}, [doc, store, databaseId]);
|
|
1562
|
+
const handleCreateColumn = useCallback4(
|
|
1563
|
+
(definition) => {
|
|
1564
|
+
if (!docRef.current) return null;
|
|
1565
|
+
return createColumnOp(docRef.current, definition);
|
|
1566
|
+
},
|
|
1567
|
+
[]
|
|
1568
|
+
);
|
|
1569
|
+
const handleUpdateColumn = useCallback4(
|
|
1570
|
+
(columnId, updates) => {
|
|
1571
|
+
if (!docRef.current) return;
|
|
1572
|
+
updateColumnOp(docRef.current, columnId, updates);
|
|
1573
|
+
},
|
|
1574
|
+
[]
|
|
1575
|
+
);
|
|
1576
|
+
const handleDeleteColumn = useCallback4((columnId) => {
|
|
1577
|
+
if (!docRef.current) return;
|
|
1578
|
+
deleteColumnOp(docRef.current, columnId);
|
|
1579
|
+
}, []);
|
|
1580
|
+
const handleReorderColumn = useCallback4((columnId, newIndex) => {
|
|
1581
|
+
if (!docRef.current) return;
|
|
1582
|
+
reorderColumnOp(docRef.current, columnId, newIndex);
|
|
1583
|
+
}, []);
|
|
1584
|
+
const handleDuplicateColumn = useCallback4((columnId, newName) => {
|
|
1585
|
+
if (!docRef.current) return null;
|
|
1586
|
+
return duplicateColumnOp(docRef.current, columnId, newName);
|
|
1587
|
+
}, []);
|
|
1588
|
+
const handleGetColumn = useCallback4((columnId) => {
|
|
1589
|
+
if (!docRef.current) return null;
|
|
1590
|
+
return getColumn(docRef.current, columnId);
|
|
1591
|
+
}, []);
|
|
1592
|
+
const handleCreateView = useCallback4((config) => {
|
|
1593
|
+
if (!docRef.current) return null;
|
|
1594
|
+
return createViewOp(docRef.current, config);
|
|
1595
|
+
}, []);
|
|
1596
|
+
const handleUpdateView = useCallback4(
|
|
1597
|
+
(viewId, updates) => {
|
|
1598
|
+
if (!docRef.current) return;
|
|
1599
|
+
updateViewOp(docRef.current, viewId, updates);
|
|
1600
|
+
},
|
|
1601
|
+
[]
|
|
1602
|
+
);
|
|
1603
|
+
const handleDeleteView = useCallback4((viewId) => {
|
|
1604
|
+
if (!docRef.current) return;
|
|
1605
|
+
deleteViewOp(docRef.current, viewId);
|
|
1606
|
+
}, []);
|
|
1607
|
+
const handleDuplicateView = useCallback4((viewId, newName) => {
|
|
1608
|
+
if (!docRef.current) return null;
|
|
1609
|
+
return duplicateViewOp(docRef.current, viewId, newName);
|
|
1610
|
+
}, []);
|
|
1611
|
+
const handleGetView = useCallback4((viewId) => {
|
|
1612
|
+
if (!docRef.current) return null;
|
|
1613
|
+
return getView(docRef.current, viewId);
|
|
1614
|
+
}, []);
|
|
1615
|
+
return {
|
|
1616
|
+
columns,
|
|
1617
|
+
views,
|
|
1618
|
+
doc,
|
|
1619
|
+
loading,
|
|
1620
|
+
error,
|
|
1621
|
+
// Column operations
|
|
1622
|
+
createColumn: handleCreateColumn,
|
|
1623
|
+
updateColumn: handleUpdateColumn,
|
|
1624
|
+
deleteColumn: handleDeleteColumn,
|
|
1625
|
+
reorderColumn: handleReorderColumn,
|
|
1626
|
+
duplicateColumn: handleDuplicateColumn,
|
|
1627
|
+
getColumn: handleGetColumn,
|
|
1628
|
+
// View operations
|
|
1629
|
+
createView: handleCreateView,
|
|
1630
|
+
updateView: handleUpdateView,
|
|
1631
|
+
deleteView: handleDeleteView,
|
|
1632
|
+
duplicateView: handleDuplicateView,
|
|
1633
|
+
getView: handleGetView
|
|
1634
|
+
};
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/hooks/useDatabase.ts
|
|
1638
|
+
import {
|
|
1639
|
+
queryRows,
|
|
1640
|
+
createRow as createRowOp,
|
|
1641
|
+
updateCells,
|
|
1642
|
+
deleteRow as deleteRowOp,
|
|
1643
|
+
moveRow,
|
|
1644
|
+
fromCellProperties,
|
|
1645
|
+
filterRows,
|
|
1646
|
+
sortRows
|
|
1647
|
+
} from "@xnetjs/data";
|
|
1648
|
+
import { useState as useState4, useEffect as useEffect4, useCallback as useCallback5, useMemo as useMemo3, useRef as useRef5 } from "react";
|
|
1649
|
+
function useDatabase(databaseId, options = {}) {
|
|
1650
|
+
const { store, isReady } = useNodeStore();
|
|
1651
|
+
const { columns, views } = useDatabaseDoc(databaseId);
|
|
1652
|
+
const [rows, setRows] = useState4([]);
|
|
1653
|
+
const [total, setTotal] = useState4(0);
|
|
1654
|
+
const [cursor, setCursor] = useState4();
|
|
1655
|
+
const [hasMore, setHasMore] = useState4(false);
|
|
1656
|
+
const [loading, setLoading] = useState4(true);
|
|
1657
|
+
const [loadingMore, setLoadingMore] = useState4(false);
|
|
1658
|
+
const [error, setError] = useState4(null);
|
|
1659
|
+
const [activeViewId, setActiveViewId] = useState4(options.view);
|
|
1660
|
+
const { pageSize = 50, filters, sorts, search } = options;
|
|
1661
|
+
const storeRef = useRef5(store);
|
|
1662
|
+
storeRef.current = store;
|
|
1663
|
+
const activeView = useMemo3(() => {
|
|
1664
|
+
if (activeViewId) {
|
|
1665
|
+
return views.find((v) => v.id === activeViewId) ?? null;
|
|
1666
|
+
}
|
|
1667
|
+
return views[0] ?? null;
|
|
1668
|
+
}, [views, activeViewId]);
|
|
1669
|
+
const effectiveFilters = filters ?? activeView?.filters ?? null;
|
|
1670
|
+
const effectiveSorts = sorts ?? activeView?.sorts ?? [];
|
|
1671
|
+
const fetchRows = useCallback5(
|
|
1672
|
+
async (reset = true) => {
|
|
1673
|
+
if (!store || !isReady) return;
|
|
1674
|
+
try {
|
|
1675
|
+
if (reset) {
|
|
1676
|
+
setLoading(true);
|
|
1677
|
+
setCursor(void 0);
|
|
1678
|
+
} else {
|
|
1679
|
+
setLoadingMore(true);
|
|
1680
|
+
}
|
|
1681
|
+
const result = await queryRows(store, databaseId, {
|
|
1682
|
+
limit: pageSize * 10,
|
|
1683
|
+
// Fetch more for client-side filtering
|
|
1684
|
+
cursor: reset ? void 0 : cursor
|
|
1685
|
+
});
|
|
1686
|
+
let parsedRows = result.rows.map((node) => nodeToRow(node, columns));
|
|
1687
|
+
if (effectiveFilters && effectiveFilters.conditions.length > 0) {
|
|
1688
|
+
parsedRows = filterRows(parsedRows, columns, effectiveFilters);
|
|
1689
|
+
}
|
|
1690
|
+
if (effectiveSorts.length > 0) {
|
|
1691
|
+
parsedRows = sortRows(parsedRows, columns, effectiveSorts);
|
|
1692
|
+
}
|
|
1693
|
+
parsedRows = parsedRows.slice(0, pageSize);
|
|
1694
|
+
if (reset) {
|
|
1695
|
+
setRows(parsedRows);
|
|
1696
|
+
} else {
|
|
1697
|
+
setRows((prev) => [...prev, ...parsedRows]);
|
|
1698
|
+
}
|
|
1699
|
+
setTotal(parsedRows.length);
|
|
1700
|
+
setCursor(result.cursor);
|
|
1701
|
+
setHasMore(result.hasMore);
|
|
1702
|
+
setError(null);
|
|
1703
|
+
} catch (err) {
|
|
1704
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1705
|
+
} finally {
|
|
1706
|
+
setLoading(false);
|
|
1707
|
+
setLoadingMore(false);
|
|
1708
|
+
}
|
|
1709
|
+
},
|
|
1710
|
+
[store, isReady, databaseId, pageSize, cursor, columns]
|
|
1711
|
+
);
|
|
1712
|
+
useEffect4(() => {
|
|
1713
|
+
if (columns.length > 0) {
|
|
1714
|
+
fetchRows(true);
|
|
1715
|
+
}
|
|
1716
|
+
}, [databaseId, columns.length]);
|
|
1717
|
+
useEffect4(() => {
|
|
1718
|
+
if (columns.length > 0) {
|
|
1719
|
+
fetchRows(true);
|
|
1720
|
+
}
|
|
1721
|
+
}, [effectiveFilters, effectiveSorts, search]);
|
|
1722
|
+
useEffect4(() => {
|
|
1723
|
+
if (!store) return;
|
|
1724
|
+
const unsubscribe = store.subscribe((event) => {
|
|
1725
|
+
const node = event.node;
|
|
1726
|
+
if (node?.schemaId === "xnet://xnet.fyi/DatabaseRow") {
|
|
1727
|
+
const dbId = node.properties.database;
|
|
1728
|
+
if (dbId === databaseId) {
|
|
1729
|
+
fetchRows(true);
|
|
1730
|
+
}
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
return unsubscribe;
|
|
1734
|
+
}, [store, databaseId, fetchRows]);
|
|
1735
|
+
const loadMore = useCallback5(async () => {
|
|
1736
|
+
if (!hasMore || loadingMore) return;
|
|
1737
|
+
await fetchRows(false);
|
|
1738
|
+
}, [hasMore, loadingMore, fetchRows]);
|
|
1739
|
+
const handleCreateRow = useCallback5(
|
|
1740
|
+
async (values) => {
|
|
1741
|
+
if (!storeRef.current) throw new Error("Store not ready");
|
|
1742
|
+
const lastRow = rows[rows.length - 1];
|
|
1743
|
+
const rowId = await createRowOp(storeRef.current, {
|
|
1744
|
+
databaseId,
|
|
1745
|
+
cells: values ?? {},
|
|
1746
|
+
after: lastRow?.sortKey
|
|
1747
|
+
});
|
|
1748
|
+
return rowId;
|
|
1749
|
+
},
|
|
1750
|
+
[databaseId, rows]
|
|
1751
|
+
);
|
|
1752
|
+
const handleUpdateRow = useCallback5(
|
|
1753
|
+
async (rowId, values) => {
|
|
1754
|
+
if (!storeRef.current) throw new Error("Store not ready");
|
|
1755
|
+
await updateCells(storeRef.current, rowId, values);
|
|
1756
|
+
},
|
|
1757
|
+
[]
|
|
1758
|
+
);
|
|
1759
|
+
const handleDeleteRow = useCallback5(async (rowId) => {
|
|
1760
|
+
if (!storeRef.current) throw new Error("Store not ready");
|
|
1761
|
+
await deleteRowOp(storeRef.current, rowId);
|
|
1762
|
+
}, []);
|
|
1763
|
+
const handleReorderRow = useCallback5(
|
|
1764
|
+
async (rowId, before, after) => {
|
|
1765
|
+
if (!storeRef.current) throw new Error("Store not ready");
|
|
1766
|
+
await moveRow(storeRef.current, rowId, { before, after });
|
|
1767
|
+
},
|
|
1768
|
+
[]
|
|
1769
|
+
);
|
|
1770
|
+
const handleDeleteRows = useCallback5(async (rowIds) => {
|
|
1771
|
+
if (!storeRef.current) throw new Error("Store not ready");
|
|
1772
|
+
await Promise.all(rowIds.map((id) => deleteRowOp(storeRef.current, id)));
|
|
1773
|
+
}, []);
|
|
1774
|
+
return {
|
|
1775
|
+
columns,
|
|
1776
|
+
views,
|
|
1777
|
+
rows,
|
|
1778
|
+
total,
|
|
1779
|
+
hasMore,
|
|
1780
|
+
loadMore,
|
|
1781
|
+
activeView,
|
|
1782
|
+
setActiveView: setActiveViewId,
|
|
1783
|
+
createRow: handleCreateRow,
|
|
1784
|
+
updateRow: handleUpdateRow,
|
|
1785
|
+
deleteRow: handleDeleteRow,
|
|
1786
|
+
reorderRow: handleReorderRow,
|
|
1787
|
+
deleteRows: handleDeleteRows,
|
|
1788
|
+
loading,
|
|
1789
|
+
loadingMore,
|
|
1790
|
+
error,
|
|
1791
|
+
refetch: () => fetchRows(true)
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
function nodeToRow(node, _columns) {
|
|
1795
|
+
const cells = fromCellProperties(node.properties);
|
|
1796
|
+
return {
|
|
1797
|
+
id: node.id,
|
|
1798
|
+
sortKey: node.properties.sortKey,
|
|
1799
|
+
cells,
|
|
1800
|
+
createdAt: node.createdAt,
|
|
1801
|
+
createdBy: node.createdBy
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
// src/hooks/useDatabaseRow.ts
|
|
1806
|
+
import { getRow, updateCells as updateCells2, deleteRow as deleteRowOp2 } from "@xnetjs/data";
|
|
1807
|
+
import { useState as useState5, useEffect as useEffect5, useCallback as useCallback6, useRef as useRef6 } from "react";
|
|
1808
|
+
import * as Y4 from "yjs";
|
|
1809
|
+
function useDatabaseRow(rowId) {
|
|
1810
|
+
const { store, isReady } = useNodeStore();
|
|
1811
|
+
const [row, setRow] = useState5(null);
|
|
1812
|
+
const [doc, setDoc] = useState5(null);
|
|
1813
|
+
const [loading, setLoading] = useState5(true);
|
|
1814
|
+
const [error, setError] = useState5(null);
|
|
1815
|
+
const optimisticRef = useRef6({});
|
|
1816
|
+
const storeRef = useRef6(store);
|
|
1817
|
+
storeRef.current = store;
|
|
1818
|
+
useEffect5(() => {
|
|
1819
|
+
if (!store || !isReady || !rowId) {
|
|
1820
|
+
setRow(null);
|
|
1821
|
+
setDoc(null);
|
|
1822
|
+
setLoading(false);
|
|
1823
|
+
return;
|
|
1824
|
+
}
|
|
1825
|
+
let mounted = true;
|
|
1826
|
+
const load = async () => {
|
|
1827
|
+
try {
|
|
1828
|
+
setLoading(true);
|
|
1829
|
+
const node = await getRow(store, rowId);
|
|
1830
|
+
if (!mounted) return;
|
|
1831
|
+
if (node) {
|
|
1832
|
+
setRow({
|
|
1833
|
+
id: node.id,
|
|
1834
|
+
databaseId: node.properties.database,
|
|
1835
|
+
sortKey: node.properties.sortKey,
|
|
1836
|
+
cells: node.cells,
|
|
1837
|
+
createdAt: node.createdAt,
|
|
1838
|
+
createdBy: node.createdBy
|
|
1839
|
+
});
|
|
1840
|
+
const storedContent = await store.getDocumentContent(rowId);
|
|
1841
|
+
if (storedContent && storedContent.length > 0) {
|
|
1842
|
+
const ydoc = new Y4.Doc({ guid: rowId, gc: false });
|
|
1843
|
+
Y4.applyUpdate(ydoc, storedContent);
|
|
1844
|
+
setDoc(ydoc);
|
|
1845
|
+
}
|
|
1846
|
+
} else {
|
|
1847
|
+
setRow(null);
|
|
1848
|
+
setDoc(null);
|
|
1849
|
+
}
|
|
1850
|
+
setError(null);
|
|
1851
|
+
} catch (err) {
|
|
1852
|
+
if (!mounted) return;
|
|
1853
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1854
|
+
} finally {
|
|
1855
|
+
if (mounted) setLoading(false);
|
|
1856
|
+
}
|
|
1857
|
+
};
|
|
1858
|
+
load();
|
|
1859
|
+
const unsubscribe = store.subscribe((event) => {
|
|
1860
|
+
if (event.change.payload.nodeId === rowId) {
|
|
1861
|
+
load();
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1864
|
+
return () => {
|
|
1865
|
+
mounted = false;
|
|
1866
|
+
unsubscribe();
|
|
1867
|
+
};
|
|
1868
|
+
}, [store, isReady, rowId]);
|
|
1869
|
+
const handleUpdate = useCallback6(
|
|
1870
|
+
async (values) => {
|
|
1871
|
+
if (!storeRef.current) throw new Error("Store not ready");
|
|
1872
|
+
optimisticRef.current = { ...optimisticRef.current, ...values };
|
|
1873
|
+
setRow(
|
|
1874
|
+
(prev) => prev ? {
|
|
1875
|
+
...prev,
|
|
1876
|
+
cells: { ...prev.cells, ...values }
|
|
1877
|
+
} : null
|
|
1878
|
+
);
|
|
1879
|
+
try {
|
|
1880
|
+
await updateCells2(storeRef.current, rowId, values);
|
|
1881
|
+
for (const key of Object.keys(values)) {
|
|
1882
|
+
delete optimisticRef.current[key];
|
|
1883
|
+
}
|
|
1884
|
+
} catch (err) {
|
|
1885
|
+
setRow((prev) => {
|
|
1886
|
+
if (!prev) return null;
|
|
1887
|
+
const reverted = { ...prev.cells };
|
|
1888
|
+
for (const key of Object.keys(values)) {
|
|
1889
|
+
delete reverted[key];
|
|
1890
|
+
}
|
|
1891
|
+
return { ...prev, cells: reverted };
|
|
1892
|
+
});
|
|
1893
|
+
throw err;
|
|
1894
|
+
}
|
|
1895
|
+
},
|
|
1896
|
+
[rowId]
|
|
1897
|
+
);
|
|
1898
|
+
const handleDelete = useCallback6(async () => {
|
|
1899
|
+
if (!storeRef.current) throw new Error("Store not ready");
|
|
1900
|
+
await deleteRowOp2(storeRef.current, rowId);
|
|
1901
|
+
}, [rowId]);
|
|
1902
|
+
return {
|
|
1903
|
+
row,
|
|
1904
|
+
doc,
|
|
1905
|
+
update: handleUpdate,
|
|
1906
|
+
delete: handleDelete,
|
|
1907
|
+
loading,
|
|
1908
|
+
error
|
|
1909
|
+
};
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// src/hooks/useCell.ts
|
|
1913
|
+
import { updateCell, cellKey } from "@xnetjs/data";
|
|
1914
|
+
import { useState as useState6, useEffect as useEffect6, useCallback as useCallback7, useRef as useRef7 } from "react";
|
|
1915
|
+
function useCell(rowId, columnId, options = {}) {
|
|
1916
|
+
const { store, isReady } = useNodeStore();
|
|
1917
|
+
const { debounce: debounceMs = 300 } = options;
|
|
1918
|
+
const [value, setValue] = useState6(null);
|
|
1919
|
+
const [saving, setSaving] = useState6(false);
|
|
1920
|
+
const [error, setError] = useState6(null);
|
|
1921
|
+
const debounceRef = useRef7();
|
|
1922
|
+
const storeRef = useRef7(store);
|
|
1923
|
+
storeRef.current = store;
|
|
1924
|
+
useEffect6(() => {
|
|
1925
|
+
if (!store || !isReady || !rowId) return;
|
|
1926
|
+
store.get(rowId).then((node) => {
|
|
1927
|
+
if (node) {
|
|
1928
|
+
const key = cellKey(columnId);
|
|
1929
|
+
setValue(node.properties[key] ?? null);
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
}, [store, isReady, rowId, columnId]);
|
|
1933
|
+
useEffect6(() => {
|
|
1934
|
+
if (!store || !rowId) return;
|
|
1935
|
+
const unsubscribe = store.subscribe((event) => {
|
|
1936
|
+
if (event.change.payload.nodeId === rowId && event.node) {
|
|
1937
|
+
const key = cellKey(columnId);
|
|
1938
|
+
setValue(event.node.properties[key] ?? null);
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
return unsubscribe;
|
|
1942
|
+
}, [store, rowId, columnId]);
|
|
1943
|
+
const handleSetValue = useCallback7(
|
|
1944
|
+
(newValue) => {
|
|
1945
|
+
setValue(newValue);
|
|
1946
|
+
setError(null);
|
|
1947
|
+
if (debounceRef.current) {
|
|
1948
|
+
clearTimeout(debounceRef.current);
|
|
1949
|
+
}
|
|
1950
|
+
debounceRef.current = setTimeout(async () => {
|
|
1951
|
+
if (!storeRef.current) return;
|
|
1952
|
+
try {
|
|
1953
|
+
setSaving(true);
|
|
1954
|
+
await updateCell(storeRef.current, rowId, columnId, newValue);
|
|
1955
|
+
} catch (err) {
|
|
1956
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
1957
|
+
} finally {
|
|
1958
|
+
setSaving(false);
|
|
1959
|
+
}
|
|
1960
|
+
}, debounceMs);
|
|
1961
|
+
},
|
|
1962
|
+
[rowId, columnId, debounceMs]
|
|
1963
|
+
);
|
|
1964
|
+
const clear = useCallback7(() => {
|
|
1965
|
+
handleSetValue(null);
|
|
1966
|
+
}, [handleSetValue]);
|
|
1967
|
+
useEffect6(() => {
|
|
1968
|
+
return () => {
|
|
1969
|
+
if (debounceRef.current) {
|
|
1970
|
+
clearTimeout(debounceRef.current);
|
|
1971
|
+
}
|
|
1972
|
+
};
|
|
1973
|
+
}, []);
|
|
1974
|
+
return {
|
|
1975
|
+
value,
|
|
1976
|
+
setValue: handleSetValue,
|
|
1977
|
+
clear,
|
|
1978
|
+
saving,
|
|
1979
|
+
error
|
|
1980
|
+
};
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// src/hooks/useRelatedRows.ts
|
|
1984
|
+
import { fromCellProperties as fromCellProperties2 } from "@xnetjs/data";
|
|
1985
|
+
import { useState as useState7, useEffect as useEffect7, useMemo as useMemo4 } from "react";
|
|
1986
|
+
function useRelatedRows(rowIds) {
|
|
1987
|
+
const { store, isReady } = useNodeStore();
|
|
1988
|
+
const [rows, setRows] = useState7([]);
|
|
1989
|
+
const [loading, setLoading] = useState7(true);
|
|
1990
|
+
const [error, setError] = useState7(null);
|
|
1991
|
+
const idsKey = useMemo4(() => rowIds.join(","), [rowIds]);
|
|
1992
|
+
useEffect7(() => {
|
|
1993
|
+
if (!store || !isReady) return;
|
|
1994
|
+
if (rowIds.length === 0) {
|
|
1995
|
+
setRows([]);
|
|
1996
|
+
setLoading(false);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
let cancelled = false;
|
|
2000
|
+
const fetchRows = async () => {
|
|
2001
|
+
try {
|
|
2002
|
+
setLoading(true);
|
|
2003
|
+
const results = await Promise.all(
|
|
2004
|
+
rowIds.map(async (id) => {
|
|
2005
|
+
try {
|
|
2006
|
+
return await store.get(id);
|
|
2007
|
+
} catch {
|
|
2008
|
+
return null;
|
|
2009
|
+
}
|
|
2010
|
+
})
|
|
2011
|
+
);
|
|
2012
|
+
if (!cancelled) {
|
|
2013
|
+
const loadedRows = results.filter((node) => node !== null).map((node) => nodeToRow2(node));
|
|
2014
|
+
setRows(loadedRows);
|
|
2015
|
+
setError(null);
|
|
2016
|
+
}
|
|
2017
|
+
} catch (err) {
|
|
2018
|
+
if (!cancelled) {
|
|
2019
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2020
|
+
}
|
|
2021
|
+
} finally {
|
|
2022
|
+
if (!cancelled) {
|
|
2023
|
+
setLoading(false);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
fetchRows();
|
|
2028
|
+
return () => {
|
|
2029
|
+
cancelled = true;
|
|
2030
|
+
};
|
|
2031
|
+
}, [store, isReady, idsKey, rowIds]);
|
|
2032
|
+
return { rows, loading, error };
|
|
2033
|
+
}
|
|
2034
|
+
function nodeToRow2(node) {
|
|
2035
|
+
const cells = fromCellProperties2(node.properties);
|
|
2036
|
+
return {
|
|
2037
|
+
id: node.id,
|
|
2038
|
+
sortKey: node.properties.sortKey ?? "",
|
|
2039
|
+
cells,
|
|
2040
|
+
createdAt: node.createdAt,
|
|
2041
|
+
createdBy: node.createdBy
|
|
2042
|
+
};
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// src/hooks/useReverseRelations.ts
|
|
2046
|
+
import { fromCellProperties as fromCellProperties3, getColumns as getColumns2, isDatabaseDocInitialized as isDatabaseDocInitialized2 } from "@xnetjs/data";
|
|
2047
|
+
import { useState as useState8, useEffect as useEffect8 } from "react";
|
|
2048
|
+
import * as Y5 from "yjs";
|
|
2049
|
+
function useReverseRelations(rowId, databaseId) {
|
|
2050
|
+
const { store, isReady } = useNodeStore();
|
|
2051
|
+
const [relations, setRelations] = useState8([]);
|
|
2052
|
+
const [loading, setLoading] = useState8(true);
|
|
2053
|
+
const [error, setError] = useState8(null);
|
|
2054
|
+
const [refetchKey, setRefetchKey] = useState8(0);
|
|
2055
|
+
useEffect8(() => {
|
|
2056
|
+
if (!store || !isReady || !rowId || !databaseId) {
|
|
2057
|
+
setLoading(false);
|
|
2058
|
+
return;
|
|
2059
|
+
}
|
|
2060
|
+
let cancelled = false;
|
|
2061
|
+
const findReverseRelations = async () => {
|
|
2062
|
+
try {
|
|
2063
|
+
setLoading(true);
|
|
2064
|
+
const allDatabases = await store.list({
|
|
2065
|
+
schemaId: "xnet://xnet.fyi/Database"
|
|
2066
|
+
});
|
|
2067
|
+
const reverseRelations = [];
|
|
2068
|
+
for (const db of allDatabases) {
|
|
2069
|
+
if (cancelled) break;
|
|
2070
|
+
try {
|
|
2071
|
+
const docContent = await store.getDocumentContent(db.id);
|
|
2072
|
+
if (!docContent || docContent.length === 0) continue;
|
|
2073
|
+
const doc = new Y5.Doc({ guid: db.id });
|
|
2074
|
+
Y5.applyUpdate(doc, docContent);
|
|
2075
|
+
if (!isDatabaseDocInitialized2(doc)) {
|
|
2076
|
+
doc.destroy();
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
const columns = getColumns2(doc);
|
|
2080
|
+
const relationColumns = columns.filter(
|
|
2081
|
+
(col) => col.type === "relation" && col.config?.targetDatabase === databaseId
|
|
2082
|
+
);
|
|
2083
|
+
doc.destroy();
|
|
2084
|
+
if (relationColumns.length === 0) continue;
|
|
2085
|
+
for (const col of relationColumns) {
|
|
2086
|
+
if (cancelled) break;
|
|
2087
|
+
const allRows = await store.list({
|
|
2088
|
+
schemaId: "xnet://xnet.fyi/DatabaseRow"
|
|
2089
|
+
});
|
|
2090
|
+
const rows = allRows.filter((row) => row.properties.database === db.id);
|
|
2091
|
+
for (const node of rows) {
|
|
2092
|
+
const cellKey2 = `cell_${col.id}`;
|
|
2093
|
+
const cellValue = node.properties[cellKey2];
|
|
2094
|
+
if (Array.isArray(cellValue) && cellValue.includes(rowId)) {
|
|
2095
|
+
reverseRelations.push({
|
|
2096
|
+
row: nodeToRow3(node),
|
|
2097
|
+
column: col,
|
|
2098
|
+
sourceDatabaseId: db.id,
|
|
2099
|
+
sourceDatabaseTitle: db.properties.title ?? "Untitled"
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
} catch {
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
2108
|
+
if (!cancelled) {
|
|
2109
|
+
setRelations(reverseRelations);
|
|
2110
|
+
setError(null);
|
|
2111
|
+
}
|
|
2112
|
+
} catch (err) {
|
|
2113
|
+
if (!cancelled) {
|
|
2114
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2115
|
+
}
|
|
2116
|
+
} finally {
|
|
2117
|
+
if (!cancelled) {
|
|
2118
|
+
setLoading(false);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
findReverseRelations();
|
|
2123
|
+
return () => {
|
|
2124
|
+
cancelled = true;
|
|
2125
|
+
};
|
|
2126
|
+
}, [store, isReady, rowId, databaseId, refetchKey]);
|
|
2127
|
+
const refetch = () => setRefetchKey((k) => k + 1);
|
|
2128
|
+
return { relations, loading, error, refetch };
|
|
2129
|
+
}
|
|
2130
|
+
function nodeToRow3(node) {
|
|
2131
|
+
const cells = fromCellProperties3(node.properties);
|
|
2132
|
+
return {
|
|
2133
|
+
id: node.id,
|
|
2134
|
+
sortKey: node.properties.sortKey ?? "",
|
|
2135
|
+
cells,
|
|
2136
|
+
createdAt: node.createdAt,
|
|
2137
|
+
createdBy: node.createdBy
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// src/hooks/useDatabaseSchema.ts
|
|
2142
|
+
import { extractSchemaFromDoc } from "@xnetjs/data";
|
|
2143
|
+
import { useState as useState9, useEffect as useEffect9, useMemo as useMemo5, useRef as useRef8 } from "react";
|
|
2144
|
+
import * as Y6 from "yjs";
|
|
2145
|
+
function useDatabaseSchema(databaseId) {
|
|
2146
|
+
const { store, isReady } = useNodeStore();
|
|
2147
|
+
const [schema, setSchema] = useState9(null);
|
|
2148
|
+
const [metadata, setMetadata] = useState9(null);
|
|
2149
|
+
const [schemaIRI, setSchemaIRI] = useState9(null);
|
|
2150
|
+
const [loading, setLoading] = useState9(true);
|
|
2151
|
+
const [error, setError] = useState9(null);
|
|
2152
|
+
const [refreshKey, setRefreshKey] = useState9(0);
|
|
2153
|
+
const docRef = useRef8(null);
|
|
2154
|
+
useEffect9(() => {
|
|
2155
|
+
if (!store || !isReady || !databaseId) {
|
|
2156
|
+
setSchema(null);
|
|
2157
|
+
setMetadata(null);
|
|
2158
|
+
setSchemaIRI(null);
|
|
2159
|
+
setLoading(false);
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
let mounted = true;
|
|
2163
|
+
const loadSchema = async () => {
|
|
2164
|
+
try {
|
|
2165
|
+
setLoading(true);
|
|
2166
|
+
setError(null);
|
|
2167
|
+
const ydoc = new Y6.Doc({ guid: databaseId, gc: false });
|
|
2168
|
+
const storedContent = await store.getDocumentContent(databaseId);
|
|
2169
|
+
if (storedContent && storedContent.length > 0) {
|
|
2170
|
+
Y6.applyUpdate(ydoc, storedContent);
|
|
2171
|
+
}
|
|
2172
|
+
if (!mounted) {
|
|
2173
|
+
ydoc.destroy();
|
|
2174
|
+
return;
|
|
2175
|
+
}
|
|
2176
|
+
docRef.current = ydoc;
|
|
2177
|
+
const extractedSchema = extractSchemaFromDoc(databaseId, ydoc);
|
|
2178
|
+
if (!extractedSchema) {
|
|
2179
|
+
setSchema(null);
|
|
2180
|
+
setMetadata(null);
|
|
2181
|
+
setSchemaIRI(null);
|
|
2182
|
+
return;
|
|
2183
|
+
}
|
|
2184
|
+
const dataMap = ydoc.getMap("data");
|
|
2185
|
+
const meta = dataMap.get("schema");
|
|
2186
|
+
if (!mounted) return;
|
|
2187
|
+
setSchema(extractedSchema);
|
|
2188
|
+
setMetadata(meta ?? null);
|
|
2189
|
+
setSchemaIRI(extractedSchema["@id"]);
|
|
2190
|
+
} catch (err) {
|
|
2191
|
+
if (!mounted) return;
|
|
2192
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2193
|
+
} finally {
|
|
2194
|
+
if (mounted) setLoading(false);
|
|
2195
|
+
}
|
|
2196
|
+
};
|
|
2197
|
+
void loadSchema();
|
|
2198
|
+
return () => {
|
|
2199
|
+
mounted = false;
|
|
2200
|
+
if (docRef.current) {
|
|
2201
|
+
docRef.current.destroy();
|
|
2202
|
+
docRef.current = null;
|
|
2203
|
+
}
|
|
2204
|
+
};
|
|
2205
|
+
}, [store, isReady, databaseId, refreshKey]);
|
|
2206
|
+
useEffect9(() => {
|
|
2207
|
+
const doc = docRef.current;
|
|
2208
|
+
if (!doc || !databaseId) return;
|
|
2209
|
+
const dataMap = doc.getMap("data");
|
|
2210
|
+
const handleSchemaChange = () => {
|
|
2211
|
+
const meta = dataMap.get("schema");
|
|
2212
|
+
if (!meta) return;
|
|
2213
|
+
const extractedSchema = extractSchemaFromDoc(databaseId, doc);
|
|
2214
|
+
if (extractedSchema) {
|
|
2215
|
+
setSchema(extractedSchema);
|
|
2216
|
+
setMetadata(meta);
|
|
2217
|
+
setSchemaIRI(extractedSchema["@id"]);
|
|
2218
|
+
}
|
|
2219
|
+
};
|
|
2220
|
+
dataMap.observe(handleSchemaChange);
|
|
2221
|
+
return () => {
|
|
2222
|
+
dataMap.unobserve(handleSchemaChange);
|
|
2223
|
+
};
|
|
2224
|
+
}, [databaseId, schema]);
|
|
2225
|
+
const refresh = useMemo5(
|
|
2226
|
+
() => () => {
|
|
2227
|
+
setRefreshKey((k) => k + 1);
|
|
2228
|
+
},
|
|
2229
|
+
[]
|
|
2230
|
+
);
|
|
2231
|
+
return useMemo5(
|
|
2232
|
+
() => ({
|
|
2233
|
+
schema,
|
|
2234
|
+
metadata,
|
|
2235
|
+
schemaIRI,
|
|
2236
|
+
loading,
|
|
2237
|
+
error,
|
|
2238
|
+
refresh
|
|
2239
|
+
}),
|
|
2240
|
+
[schema, metadata, schemaIRI, loading, error, refresh]
|
|
2241
|
+
);
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
// src/hooks/useComments.ts
|
|
2245
|
+
import { CommentSchema } from "@xnetjs/data";
|
|
2246
|
+
import { useState as useState10, useEffect as useEffect10, useMemo as useMemo6, useCallback as useCallback8, useRef as useRef9 } from "react";
|
|
2247
|
+
function useComments({ nodeId, anchorType }) {
|
|
2248
|
+
const { store, isReady } = useNodeStore();
|
|
2249
|
+
const [comments, setComments] = useState10([]);
|
|
2250
|
+
const [loading, setLoading] = useState10(true);
|
|
2251
|
+
const [error, setError] = useState10(null);
|
|
2252
|
+
const queryRef = useRef9({ nodeId, anchorType });
|
|
2253
|
+
queryRef.current = { nodeId, anchorType };
|
|
2254
|
+
const loadComments = useCallback8(async () => {
|
|
2255
|
+
if (!store || !isReady) return;
|
|
2256
|
+
try {
|
|
2257
|
+
setLoading(true);
|
|
2258
|
+
setError(null);
|
|
2259
|
+
const nodes = await store.list({
|
|
2260
|
+
schemaId: CommentSchema._schemaId
|
|
2261
|
+
});
|
|
2262
|
+
const filtered = nodes.filter((n) => {
|
|
2263
|
+
if (n.properties.target !== queryRef.current.nodeId) return false;
|
|
2264
|
+
if (queryRef.current.anchorType && n.properties.anchorType !== queryRef.current.anchorType && !n.properties.inReplyTo) {
|
|
2265
|
+
return false;
|
|
2266
|
+
}
|
|
2267
|
+
return true;
|
|
2268
|
+
});
|
|
2269
|
+
const commentNodes = filtered.map((n) => nodeToComment(n));
|
|
2270
|
+
setComments(commentNodes);
|
|
2271
|
+
} catch (err) {
|
|
2272
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2273
|
+
} finally {
|
|
2274
|
+
setLoading(false);
|
|
2275
|
+
}
|
|
2276
|
+
}, [store, isReady]);
|
|
2277
|
+
useEffect10(() => {
|
|
2278
|
+
loadComments();
|
|
2279
|
+
}, [loadComments]);
|
|
2280
|
+
useEffect10(() => {
|
|
2281
|
+
if (!store || !isReady) return;
|
|
2282
|
+
const handleChange = (event) => {
|
|
2283
|
+
if (event.node?.schemaId === CommentSchema._schemaId) {
|
|
2284
|
+
if (event.node.properties.target === nodeId) {
|
|
2285
|
+
loadComments();
|
|
2286
|
+
}
|
|
2287
|
+
} else if (event.change?.payload?.schemaId === CommentSchema._schemaId) {
|
|
2288
|
+
loadComments();
|
|
2289
|
+
}
|
|
2290
|
+
};
|
|
2291
|
+
const unsubscribe = store.subscribe(handleChange);
|
|
2292
|
+
return () => unsubscribe();
|
|
2293
|
+
}, [store, isReady, loadComments, nodeId]);
|
|
2294
|
+
const threads = useMemo6(() => {
|
|
2295
|
+
const threadMap = /* @__PURE__ */ new Map();
|
|
2296
|
+
for (const comment of comments) {
|
|
2297
|
+
if (!comment.properties.inReplyTo) {
|
|
2298
|
+
threadMap.set(comment.id, { root: comment, replies: [] });
|
|
2299
|
+
}
|
|
2300
|
+
}
|
|
2301
|
+
for (const comment of comments) {
|
|
2302
|
+
const rootId = comment.properties.inReplyTo;
|
|
2303
|
+
if (rootId) {
|
|
2304
|
+
const thread = threadMap.get(rootId);
|
|
2305
|
+
if (thread) {
|
|
2306
|
+
thread.replies.push(comment);
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
}
|
|
2310
|
+
for (const thread of threadMap.values()) {
|
|
2311
|
+
thread.replies.sort((a, b) => a.lamportTime - b.lamportTime);
|
|
2312
|
+
}
|
|
2313
|
+
return Array.from(threadMap.values());
|
|
2314
|
+
}, [comments]);
|
|
2315
|
+
const unresolvedCount = useMemo6(() => {
|
|
2316
|
+
return threads.filter((t) => !t.root.properties.resolved).length;
|
|
2317
|
+
}, [threads]);
|
|
2318
|
+
const addComment = useCallback8(
|
|
2319
|
+
async (options) => {
|
|
2320
|
+
if (!store || !isReady) return null;
|
|
2321
|
+
try {
|
|
2322
|
+
const node = await store.create({
|
|
2323
|
+
schemaId: CommentSchema._schemaId,
|
|
2324
|
+
properties: {
|
|
2325
|
+
target: nodeId,
|
|
2326
|
+
targetSchema: options.targetSchema,
|
|
2327
|
+
anchorType: options.anchorType,
|
|
2328
|
+
anchorData: options.anchorData,
|
|
2329
|
+
content: options.content,
|
|
2330
|
+
resolved: false,
|
|
2331
|
+
edited: false
|
|
2332
|
+
}
|
|
2333
|
+
});
|
|
2334
|
+
return node.id;
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2337
|
+
return null;
|
|
2338
|
+
}
|
|
2339
|
+
},
|
|
2340
|
+
[store, isReady, nodeId]
|
|
2341
|
+
);
|
|
2342
|
+
const replyTo = useCallback8(
|
|
2343
|
+
async (rootCommentId, content, context) => {
|
|
2344
|
+
if (!store || !isReady) return null;
|
|
2345
|
+
const root = threads.find((t) => t.root.id === rootCommentId)?.root;
|
|
2346
|
+
if (!root) {
|
|
2347
|
+
setError(new Error("Thread root not found"));
|
|
2348
|
+
return null;
|
|
2349
|
+
}
|
|
2350
|
+
try {
|
|
2351
|
+
const node = await store.create({
|
|
2352
|
+
schemaId: CommentSchema._schemaId,
|
|
2353
|
+
properties: {
|
|
2354
|
+
target: nodeId,
|
|
2355
|
+
targetSchema: root.properties.targetSchema,
|
|
2356
|
+
inReplyTo: rootCommentId,
|
|
2357
|
+
// Always points to root (flat threading)
|
|
2358
|
+
anchorType: "node",
|
|
2359
|
+
// Replies don't need positional anchors
|
|
2360
|
+
anchorData: "{}",
|
|
2361
|
+
content,
|
|
2362
|
+
replyToUser: context?.replyToUser,
|
|
2363
|
+
replyToCommentId: context?.replyToCommentId,
|
|
2364
|
+
resolved: false,
|
|
2365
|
+
edited: false
|
|
2366
|
+
}
|
|
2367
|
+
});
|
|
2368
|
+
return node.id;
|
|
2369
|
+
} catch (err) {
|
|
2370
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2371
|
+
return null;
|
|
2372
|
+
}
|
|
2373
|
+
},
|
|
2374
|
+
[store, isReady, nodeId, threads]
|
|
2375
|
+
);
|
|
2376
|
+
const resolveThread = useCallback8(
|
|
2377
|
+
async (rootCommentId) => {
|
|
2378
|
+
if (!store || !isReady) return;
|
|
2379
|
+
try {
|
|
2380
|
+
await store.update(rootCommentId, {
|
|
2381
|
+
properties: {
|
|
2382
|
+
resolved: true,
|
|
2383
|
+
resolvedAt: Date.now()
|
|
2384
|
+
}
|
|
2385
|
+
});
|
|
2386
|
+
} catch (err) {
|
|
2387
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2388
|
+
}
|
|
2389
|
+
},
|
|
2390
|
+
[store, isReady]
|
|
2391
|
+
);
|
|
2392
|
+
const reopenThread = useCallback8(
|
|
2393
|
+
async (rootCommentId) => {
|
|
2394
|
+
if (!store || !isReady) return;
|
|
2395
|
+
try {
|
|
2396
|
+
await store.update(rootCommentId, {
|
|
2397
|
+
properties: {
|
|
2398
|
+
resolved: false,
|
|
2399
|
+
resolvedBy: null,
|
|
2400
|
+
resolvedAt: null
|
|
2401
|
+
}
|
|
2402
|
+
});
|
|
2403
|
+
} catch (err) {
|
|
2404
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2405
|
+
}
|
|
2406
|
+
},
|
|
2407
|
+
[store, isReady]
|
|
2408
|
+
);
|
|
2409
|
+
const deleteComment = useCallback8(
|
|
2410
|
+
async (commentId) => {
|
|
2411
|
+
if (!store || !isReady) return;
|
|
2412
|
+
try {
|
|
2413
|
+
const thread = threads.find((t) => t.root.id === commentId);
|
|
2414
|
+
const isRoot = thread !== void 0;
|
|
2415
|
+
if (isRoot) {
|
|
2416
|
+
if (thread.replies.length > 0) {
|
|
2417
|
+
await store.update(commentId, {
|
|
2418
|
+
properties: { content: "[deleted]" }
|
|
2419
|
+
});
|
|
2420
|
+
} else {
|
|
2421
|
+
await store.delete(commentId);
|
|
2422
|
+
}
|
|
2423
|
+
} else {
|
|
2424
|
+
await store.delete(commentId);
|
|
2425
|
+
}
|
|
2426
|
+
} catch (err) {
|
|
2427
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2428
|
+
}
|
|
2429
|
+
},
|
|
2430
|
+
[store, isReady, threads]
|
|
2431
|
+
);
|
|
2432
|
+
const deleteThread = useCallback8(
|
|
2433
|
+
async (rootCommentId) => {
|
|
2434
|
+
if (!store || !isReady) return;
|
|
2435
|
+
const thread = threads.find((t) => t.root.id === rootCommentId);
|
|
2436
|
+
if (!thread) return;
|
|
2437
|
+
try {
|
|
2438
|
+
for (const reply of thread.replies) {
|
|
2439
|
+
await store.delete(reply.id);
|
|
2440
|
+
}
|
|
2441
|
+
await store.delete(rootCommentId);
|
|
2442
|
+
} catch (err) {
|
|
2443
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2444
|
+
}
|
|
2445
|
+
},
|
|
2446
|
+
[store, isReady, threads]
|
|
2447
|
+
);
|
|
2448
|
+
const editComment = useCallback8(
|
|
2449
|
+
async (commentId, content) => {
|
|
2450
|
+
if (!store || !isReady) return;
|
|
2451
|
+
try {
|
|
2452
|
+
await store.update(commentId, {
|
|
2453
|
+
properties: {
|
|
2454
|
+
content,
|
|
2455
|
+
edited: true,
|
|
2456
|
+
editedAt: Date.now()
|
|
2457
|
+
}
|
|
2458
|
+
});
|
|
2459
|
+
} catch (err) {
|
|
2460
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2461
|
+
}
|
|
2462
|
+
},
|
|
2463
|
+
[store, isReady]
|
|
2464
|
+
);
|
|
2465
|
+
return {
|
|
2466
|
+
comments,
|
|
2467
|
+
threads,
|
|
2468
|
+
count: comments.length,
|
|
2469
|
+
unresolvedCount,
|
|
2470
|
+
loading,
|
|
2471
|
+
error,
|
|
2472
|
+
addComment,
|
|
2473
|
+
replyTo,
|
|
2474
|
+
resolveThread,
|
|
2475
|
+
reopenThread,
|
|
2476
|
+
deleteComment,
|
|
2477
|
+
deleteThread,
|
|
2478
|
+
editComment,
|
|
2479
|
+
reload: loadComments
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
function nodeToComment(node) {
|
|
2483
|
+
const firstTimestamp = Object.values(node.timestamps)[0];
|
|
2484
|
+
const lamportTime = firstTimestamp?.lamport?.time ?? 0;
|
|
2485
|
+
const wallTime = firstTimestamp?.wallTime ?? node.createdAt;
|
|
2486
|
+
return {
|
|
2487
|
+
id: node.id,
|
|
2488
|
+
schemaId: node.schemaId,
|
|
2489
|
+
createdAt: node.createdAt,
|
|
2490
|
+
lamportTime,
|
|
2491
|
+
wallTime,
|
|
2492
|
+
properties: {
|
|
2493
|
+
target: node.properties.target,
|
|
2494
|
+
targetSchema: node.properties.targetSchema,
|
|
2495
|
+
inReplyTo: node.properties.inReplyTo,
|
|
2496
|
+
anchorType: node.properties.anchorType,
|
|
2497
|
+
anchorData: node.properties.anchorData,
|
|
2498
|
+
content: node.properties.content,
|
|
2499
|
+
attachments: node.properties.attachments,
|
|
2500
|
+
replyToUser: node.properties.replyToUser,
|
|
2501
|
+
replyToCommentId: node.properties.replyToCommentId,
|
|
2502
|
+
resolved: node.properties.resolved ?? false,
|
|
2503
|
+
resolvedBy: node.properties.resolvedBy,
|
|
2504
|
+
resolvedAt: node.properties.resolvedAt,
|
|
2505
|
+
edited: node.properties.edited ?? false,
|
|
2506
|
+
editedAt: node.properties.editedAt,
|
|
2507
|
+
createdBy: node.createdBy
|
|
2508
|
+
}
|
|
2509
|
+
};
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
// src/hooks/useCommentCount.ts
|
|
2513
|
+
import { useMemo as useMemo7 } from "react";
|
|
2514
|
+
function useCommentCount(nodeId) {
|
|
2515
|
+
const { unresolvedCount } = useComments({ nodeId });
|
|
2516
|
+
return unresolvedCount;
|
|
2517
|
+
}
|
|
2518
|
+
function useCommentCounts(nodeId) {
|
|
2519
|
+
const { threads } = useComments({ nodeId });
|
|
2520
|
+
return useMemo7(() => {
|
|
2521
|
+
const resolved = threads.filter((t) => t.root.properties.resolved).length;
|
|
2522
|
+
const unresolved = threads.length - resolved;
|
|
2523
|
+
return {
|
|
2524
|
+
total: threads.length,
|
|
2525
|
+
unresolved,
|
|
2526
|
+
resolved
|
|
2527
|
+
};
|
|
2528
|
+
}, [threads]);
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
// src/hooks/useHistory.ts
|
|
2532
|
+
import {
|
|
2533
|
+
HistoryEngine,
|
|
2534
|
+
SnapshotCache,
|
|
2535
|
+
MemorySnapshotStorage
|
|
2536
|
+
} from "@xnetjs/history";
|
|
2537
|
+
import { useState as useState11, useEffect as useEffect11, useCallback as useCallback9, useRef as useRef10 } from "react";
|
|
2538
|
+
function useHistory(nodeId) {
|
|
2539
|
+
const { store, isReady } = useNodeStore();
|
|
2540
|
+
const [timeline, setTimeline] = useState11([]);
|
|
2541
|
+
const [loading, setLoading] = useState11(false);
|
|
2542
|
+
const [error, setError] = useState11(null);
|
|
2543
|
+
const engineRef = useRef10(null);
|
|
2544
|
+
const getEngine = useCallback9(() => {
|
|
2545
|
+
if (!store) return null;
|
|
2546
|
+
const storage = store.getStorageAdapter();
|
|
2547
|
+
if (!storage) return null;
|
|
2548
|
+
if (!engineRef.current || engineRef.current.storage !== storage) {
|
|
2549
|
+
const snapshotStorage = new MemorySnapshotStorage();
|
|
2550
|
+
const snapshots = new SnapshotCache(snapshotStorage, { interval: 50 });
|
|
2551
|
+
engineRef.current = { engine: new HistoryEngine(storage, snapshots), storage };
|
|
2552
|
+
}
|
|
2553
|
+
return engineRef.current.engine;
|
|
2554
|
+
}, [store]);
|
|
2555
|
+
const loadTimeline = useCallback9(async () => {
|
|
2556
|
+
if (!nodeId || !isReady) return;
|
|
2557
|
+
const engine = getEngine();
|
|
2558
|
+
if (!engine) return;
|
|
2559
|
+
setLoading(true);
|
|
2560
|
+
setError(null);
|
|
2561
|
+
try {
|
|
2562
|
+
const entries = await engine.getTimeline(nodeId);
|
|
2563
|
+
setTimeline(entries);
|
|
2564
|
+
} catch (err) {
|
|
2565
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2566
|
+
} finally {
|
|
2567
|
+
setLoading(false);
|
|
2568
|
+
}
|
|
2569
|
+
}, [nodeId, isReady, getEngine]);
|
|
2570
|
+
useEffect11(() => {
|
|
2571
|
+
loadTimeline();
|
|
2572
|
+
}, [loadTimeline]);
|
|
2573
|
+
useEffect11(() => {
|
|
2574
|
+
if (!store || !nodeId) return;
|
|
2575
|
+
const unsub = store.subscribe((event) => {
|
|
2576
|
+
if (event.change.payload.nodeId === nodeId) {
|
|
2577
|
+
loadTimeline();
|
|
2578
|
+
}
|
|
2579
|
+
});
|
|
2580
|
+
return unsub;
|
|
2581
|
+
}, [store, nodeId, loadTimeline]);
|
|
2582
|
+
const materializeAt = useCallback9(
|
|
2583
|
+
async (target) => {
|
|
2584
|
+
if (!nodeId) return null;
|
|
2585
|
+
const engine = getEngine();
|
|
2586
|
+
if (!engine) return null;
|
|
2587
|
+
try {
|
|
2588
|
+
return await engine.materializeAt(nodeId, target);
|
|
2589
|
+
} catch (err) {
|
|
2590
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2591
|
+
return null;
|
|
2592
|
+
}
|
|
2593
|
+
},
|
|
2594
|
+
[nodeId, getEngine]
|
|
2595
|
+
);
|
|
2596
|
+
const diff = useCallback9(
|
|
2597
|
+
async (from, to) => {
|
|
2598
|
+
if (!nodeId) return [];
|
|
2599
|
+
const engine = getEngine();
|
|
2600
|
+
if (!engine) return [];
|
|
2601
|
+
try {
|
|
2602
|
+
return await engine.diff(nodeId, from, to);
|
|
2603
|
+
} catch (err) {
|
|
2604
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2605
|
+
return [];
|
|
2606
|
+
}
|
|
2607
|
+
},
|
|
2608
|
+
[nodeId, getEngine]
|
|
2609
|
+
);
|
|
2610
|
+
const createRevertPayload = useCallback9(
|
|
2611
|
+
async (target) => {
|
|
2612
|
+
if (!nodeId || !store) return null;
|
|
2613
|
+
const engine = getEngine();
|
|
2614
|
+
if (!engine) return null;
|
|
2615
|
+
try {
|
|
2616
|
+
const current = await store.get(nodeId);
|
|
2617
|
+
if (!current) return null;
|
|
2618
|
+
return await engine.createRevertPayload(nodeId, target, current);
|
|
2619
|
+
} catch (err) {
|
|
2620
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2621
|
+
return null;
|
|
2622
|
+
}
|
|
2623
|
+
},
|
|
2624
|
+
[nodeId, store, getEngine]
|
|
2625
|
+
);
|
|
2626
|
+
return {
|
|
2627
|
+
timeline,
|
|
2628
|
+
changeCount: timeline.length,
|
|
2629
|
+
materializeAt,
|
|
2630
|
+
diff,
|
|
2631
|
+
createRevertPayload,
|
|
2632
|
+
loading,
|
|
2633
|
+
error,
|
|
2634
|
+
reload: loadTimeline
|
|
2635
|
+
};
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
// src/hooks/useUndo.ts
|
|
2639
|
+
import { UndoManager } from "@xnetjs/history";
|
|
2640
|
+
import { useState as useState12, useEffect as useEffect12, useCallback as useCallback10, useRef as useRef11 } from "react";
|
|
2641
|
+
function useUndo(nodeId, opts) {
|
|
2642
|
+
const { store } = useNodeStore();
|
|
2643
|
+
const [undoCount, setUndoCount] = useState12(0);
|
|
2644
|
+
const [redoCount, setRedoCount] = useState12(0);
|
|
2645
|
+
const managerRef = useRef11(null);
|
|
2646
|
+
useEffect12(() => {
|
|
2647
|
+
if (!store) return;
|
|
2648
|
+
const manager = new UndoManager(store, opts.localDID, opts.options);
|
|
2649
|
+
manager.start();
|
|
2650
|
+
managerRef.current = manager;
|
|
2651
|
+
return () => {
|
|
2652
|
+
manager.stop();
|
|
2653
|
+
managerRef.current = null;
|
|
2654
|
+
};
|
|
2655
|
+
}, [store, opts.localDID]);
|
|
2656
|
+
const syncCounts = useCallback10(() => {
|
|
2657
|
+
if (!managerRef.current || !nodeId) {
|
|
2658
|
+
setUndoCount(0);
|
|
2659
|
+
setRedoCount(0);
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
setUndoCount(managerRef.current.getUndoCount(nodeId));
|
|
2663
|
+
setRedoCount(managerRef.current.getRedoCount(nodeId));
|
|
2664
|
+
}, [nodeId]);
|
|
2665
|
+
useEffect12(() => {
|
|
2666
|
+
syncCounts();
|
|
2667
|
+
}, [syncCounts]);
|
|
2668
|
+
useEffect12(() => {
|
|
2669
|
+
if (!store || !nodeId) return;
|
|
2670
|
+
const unsub = store.subscribe((event) => {
|
|
2671
|
+
if (event.change.payload.nodeId === nodeId) {
|
|
2672
|
+
syncCounts();
|
|
2673
|
+
}
|
|
2674
|
+
});
|
|
2675
|
+
return unsub;
|
|
2676
|
+
}, [store, nodeId, syncCounts]);
|
|
2677
|
+
const undo = useCallback10(async () => {
|
|
2678
|
+
if (!managerRef.current || !nodeId) return false;
|
|
2679
|
+
const result = await managerRef.current.undo(nodeId);
|
|
2680
|
+
syncCounts();
|
|
2681
|
+
return result;
|
|
2682
|
+
}, [nodeId, syncCounts]);
|
|
2683
|
+
const redo = useCallback10(async () => {
|
|
2684
|
+
if (!managerRef.current || !nodeId) return false;
|
|
2685
|
+
const result = await managerRef.current.redo(nodeId);
|
|
2686
|
+
syncCounts();
|
|
2687
|
+
return result;
|
|
2688
|
+
}, [nodeId, syncCounts]);
|
|
2689
|
+
const undoBatch = useCallback10(
|
|
2690
|
+
async (batchId) => {
|
|
2691
|
+
if (!managerRef.current) return false;
|
|
2692
|
+
const result = await managerRef.current.undoBatch(batchId);
|
|
2693
|
+
syncCounts();
|
|
2694
|
+
return result;
|
|
2695
|
+
},
|
|
2696
|
+
[syncCounts]
|
|
2697
|
+
);
|
|
2698
|
+
const clear = useCallback10(() => {
|
|
2699
|
+
if (!managerRef.current || !nodeId) return;
|
|
2700
|
+
managerRef.current.clear(nodeId);
|
|
2701
|
+
syncCounts();
|
|
2702
|
+
}, [nodeId, syncCounts]);
|
|
2703
|
+
return {
|
|
2704
|
+
undo,
|
|
2705
|
+
redo,
|
|
2706
|
+
undoBatch,
|
|
2707
|
+
canUndo: undoCount > 0,
|
|
2708
|
+
canRedo: redoCount > 0,
|
|
2709
|
+
undoCount,
|
|
2710
|
+
redoCount,
|
|
2711
|
+
clear
|
|
2712
|
+
};
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
// src/hooks/useAudit.ts
|
|
2716
|
+
import { AuditIndex } from "@xnetjs/history";
|
|
2717
|
+
import { useState as useState13, useEffect as useEffect13, useCallback as useCallback11, useRef as useRef12 } from "react";
|
|
2718
|
+
function useAudit(nodeId, options) {
|
|
2719
|
+
const { store, isReady } = useNodeStore();
|
|
2720
|
+
const [entries, setEntries] = useState13([]);
|
|
2721
|
+
const [activity, setActivity] = useState13(null);
|
|
2722
|
+
const [loading, setLoading] = useState13(false);
|
|
2723
|
+
const [error, setError] = useState13(null);
|
|
2724
|
+
const auditRef = useRef12(null);
|
|
2725
|
+
const getAudit = useCallback11(() => {
|
|
2726
|
+
if (!store) return null;
|
|
2727
|
+
const storage = store.getStorageAdapter();
|
|
2728
|
+
if (!storage) return null;
|
|
2729
|
+
if (!auditRef.current || auditRef.current.storage !== storage) {
|
|
2730
|
+
auditRef.current = { audit: new AuditIndex(storage), storage };
|
|
2731
|
+
}
|
|
2732
|
+
return auditRef.current.audit;
|
|
2733
|
+
}, [store]);
|
|
2734
|
+
const load = useCallback11(async () => {
|
|
2735
|
+
if (!nodeId || !isReady) return;
|
|
2736
|
+
const audit = getAudit();
|
|
2737
|
+
if (!audit) return;
|
|
2738
|
+
setLoading(true);
|
|
2739
|
+
setError(null);
|
|
2740
|
+
try {
|
|
2741
|
+
const query = {
|
|
2742
|
+
nodeId,
|
|
2743
|
+
order: options?.order ?? "desc",
|
|
2744
|
+
limit: options?.limit ?? 200
|
|
2745
|
+
};
|
|
2746
|
+
if (options?.operations) query.operations = options.operations;
|
|
2747
|
+
if (options?.author) query.author = options.author;
|
|
2748
|
+
if (options?.timeRange) {
|
|
2749
|
+
query.fromWallTime = options.timeRange[0];
|
|
2750
|
+
query.toWallTime = options.timeRange[1];
|
|
2751
|
+
}
|
|
2752
|
+
const [auditEntries, activitySummary] = await Promise.all([
|
|
2753
|
+
audit.query(query),
|
|
2754
|
+
audit.getNodeActivity(nodeId)
|
|
2755
|
+
]);
|
|
2756
|
+
setEntries(auditEntries);
|
|
2757
|
+
setActivity(activitySummary);
|
|
2758
|
+
} catch (err) {
|
|
2759
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2760
|
+
} finally {
|
|
2761
|
+
setLoading(false);
|
|
2762
|
+
}
|
|
2763
|
+
}, [
|
|
2764
|
+
nodeId,
|
|
2765
|
+
isReady,
|
|
2766
|
+
getAudit,
|
|
2767
|
+
options?.operations,
|
|
2768
|
+
options?.author,
|
|
2769
|
+
options?.timeRange,
|
|
2770
|
+
options?.limit,
|
|
2771
|
+
options?.order
|
|
2772
|
+
]);
|
|
2773
|
+
useEffect13(() => {
|
|
2774
|
+
load();
|
|
2775
|
+
}, [load]);
|
|
2776
|
+
useEffect13(() => {
|
|
2777
|
+
if (!store || !nodeId) return;
|
|
2778
|
+
const unsub = store.subscribe((event) => {
|
|
2779
|
+
if (event.change.payload.nodeId === nodeId) {
|
|
2780
|
+
load();
|
|
2781
|
+
}
|
|
2782
|
+
});
|
|
2783
|
+
return unsub;
|
|
2784
|
+
}, [store, nodeId, load]);
|
|
2785
|
+
return { entries, activity, loading, error, reload: load };
|
|
2786
|
+
}
|
|
2787
|
+
|
|
2788
|
+
// src/hooks/useDiff.ts
|
|
2789
|
+
import {
|
|
2790
|
+
HistoryEngine as HistoryEngine2,
|
|
2791
|
+
SnapshotCache as SnapshotCache2,
|
|
2792
|
+
MemorySnapshotStorage as MemorySnapshotStorage2,
|
|
2793
|
+
DiffEngine
|
|
2794
|
+
} from "@xnetjs/history";
|
|
2795
|
+
import { useState as useState14, useCallback as useCallback12, useRef as useRef13 } from "react";
|
|
2796
|
+
function useDiff(nodeId) {
|
|
2797
|
+
const { store } = useNodeStore();
|
|
2798
|
+
const [result, setResult] = useState14(null);
|
|
2799
|
+
const [loading, setLoading] = useState14(false);
|
|
2800
|
+
const [error, setError] = useState14(null);
|
|
2801
|
+
const engineRef = useRef13(null);
|
|
2802
|
+
const getEngine = useCallback12(() => {
|
|
2803
|
+
if (!store) return null;
|
|
2804
|
+
const storage = store.getStorageAdapter();
|
|
2805
|
+
if (!storage) return null;
|
|
2806
|
+
if (!engineRef.current || engineRef.current.storage !== storage) {
|
|
2807
|
+
const snapshotStorage = new MemorySnapshotStorage2();
|
|
2808
|
+
const snapshots = new SnapshotCache2(snapshotStorage, { interval: 50 });
|
|
2809
|
+
const history = new HistoryEngine2(storage, snapshots);
|
|
2810
|
+
engineRef.current = { diff: new DiffEngine(history), storage };
|
|
2811
|
+
}
|
|
2812
|
+
return engineRef.current.diff;
|
|
2813
|
+
}, [store]);
|
|
2814
|
+
const diff = useCallback12(
|
|
2815
|
+
async (from, to) => {
|
|
2816
|
+
if (!nodeId) return;
|
|
2817
|
+
const engine = getEngine();
|
|
2818
|
+
if (!engine) return;
|
|
2819
|
+
setLoading(true);
|
|
2820
|
+
setError(null);
|
|
2821
|
+
try {
|
|
2822
|
+
const diffResult = await engine.diffNode(nodeId, from, to);
|
|
2823
|
+
setResult(diffResult);
|
|
2824
|
+
} catch (err) {
|
|
2825
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2826
|
+
} finally {
|
|
2827
|
+
setLoading(false);
|
|
2828
|
+
}
|
|
2829
|
+
},
|
|
2830
|
+
[nodeId, getEngine]
|
|
2831
|
+
);
|
|
2832
|
+
const diffFromCurrent = useCallback12(
|
|
2833
|
+
async (changesAgo) => {
|
|
2834
|
+
if (!nodeId) return;
|
|
2835
|
+
const engine = getEngine();
|
|
2836
|
+
if (!engine) return;
|
|
2837
|
+
setLoading(true);
|
|
2838
|
+
setError(null);
|
|
2839
|
+
try {
|
|
2840
|
+
const diffResult = await engine.diffFromCurrent(nodeId, changesAgo);
|
|
2841
|
+
setResult(diffResult);
|
|
2842
|
+
} catch (err) {
|
|
2843
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2844
|
+
} finally {
|
|
2845
|
+
setLoading(false);
|
|
2846
|
+
}
|
|
2847
|
+
},
|
|
2848
|
+
[nodeId, getEngine]
|
|
2849
|
+
);
|
|
2850
|
+
return { diff, diffFromCurrent, result, loading, error };
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
// src/hooks/useBlame.ts
|
|
2854
|
+
import { BlameEngine } from "@xnetjs/history";
|
|
2855
|
+
import { useState as useState15, useEffect as useEffect14, useCallback as useCallback13, useRef as useRef14 } from "react";
|
|
2856
|
+
function useBlame(nodeId) {
|
|
2857
|
+
const { store, isReady } = useNodeStore();
|
|
2858
|
+
const [blame, setBlame] = useState15([]);
|
|
2859
|
+
const [loading, setLoading] = useState15(false);
|
|
2860
|
+
const [error, setError] = useState15(null);
|
|
2861
|
+
const engineRef = useRef14(null);
|
|
2862
|
+
const getEngine = useCallback13(() => {
|
|
2863
|
+
if (!store) return null;
|
|
2864
|
+
const storage = store.getStorageAdapter();
|
|
2865
|
+
if (!storage) return null;
|
|
2866
|
+
if (!engineRef.current || engineRef.current.storage !== storage) {
|
|
2867
|
+
engineRef.current = { engine: new BlameEngine(storage), storage };
|
|
2868
|
+
}
|
|
2869
|
+
return engineRef.current.engine;
|
|
2870
|
+
}, [store]);
|
|
2871
|
+
const load = useCallback13(async () => {
|
|
2872
|
+
if (!nodeId || !isReady) return;
|
|
2873
|
+
const engine = getEngine();
|
|
2874
|
+
if (!engine) return;
|
|
2875
|
+
setLoading(true);
|
|
2876
|
+
setError(null);
|
|
2877
|
+
try {
|
|
2878
|
+
const info = await engine.getBlame(nodeId);
|
|
2879
|
+
setBlame(info);
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2882
|
+
} finally {
|
|
2883
|
+
setLoading(false);
|
|
2884
|
+
}
|
|
2885
|
+
}, [nodeId, isReady, getEngine]);
|
|
2886
|
+
useEffect14(() => {
|
|
2887
|
+
load();
|
|
2888
|
+
}, [load]);
|
|
2889
|
+
useEffect14(() => {
|
|
2890
|
+
if (!store || !nodeId) return;
|
|
2891
|
+
const unsub = store.subscribe((event) => {
|
|
2892
|
+
if (event.change.payload.nodeId === nodeId) {
|
|
2893
|
+
load();
|
|
2894
|
+
}
|
|
2895
|
+
});
|
|
2896
|
+
return unsub;
|
|
2897
|
+
}, [store, nodeId, load]);
|
|
2898
|
+
return { blame, loading, error, reload: load };
|
|
2899
|
+
}
|
|
2900
|
+
|
|
2901
|
+
// src/hooks/useVerification.ts
|
|
2902
|
+
import {
|
|
2903
|
+
VerificationEngine
|
|
2904
|
+
} from "@xnetjs/history";
|
|
2905
|
+
import { useState as useState16, useCallback as useCallback14, useRef as useRef15 } from "react";
|
|
2906
|
+
function useVerification(nodeId) {
|
|
2907
|
+
const { store } = useNodeStore();
|
|
2908
|
+
const [result, setResult] = useState16(null);
|
|
2909
|
+
const [loading, setLoading] = useState16(false);
|
|
2910
|
+
const [progress, setProgress] = useState16(0);
|
|
2911
|
+
const [error, setError] = useState16(null);
|
|
2912
|
+
const engineRef = useRef15(null);
|
|
2913
|
+
const getEngine = useCallback14(() => {
|
|
2914
|
+
if (!store) return null;
|
|
2915
|
+
const storage = store.getStorageAdapter();
|
|
2916
|
+
if (!storage) return null;
|
|
2917
|
+
if (!engineRef.current || engineRef.current.storage !== storage) {
|
|
2918
|
+
engineRef.current = { engine: new VerificationEngine(storage), storage };
|
|
2919
|
+
}
|
|
2920
|
+
return engineRef.current.engine;
|
|
2921
|
+
}, [store]);
|
|
2922
|
+
const verify = useCallback14(
|
|
2923
|
+
async (options) => {
|
|
2924
|
+
if (!nodeId) return;
|
|
2925
|
+
const engine = getEngine();
|
|
2926
|
+
if (!engine) return;
|
|
2927
|
+
setLoading(true);
|
|
2928
|
+
setProgress(0);
|
|
2929
|
+
setError(null);
|
|
2930
|
+
try {
|
|
2931
|
+
const verifyResult = await engine.verifyNodeHistory(nodeId, {
|
|
2932
|
+
...options,
|
|
2933
|
+
onProgress: (p) => {
|
|
2934
|
+
setProgress(p);
|
|
2935
|
+
options?.onProgress?.(p);
|
|
2936
|
+
}
|
|
2937
|
+
});
|
|
2938
|
+
setResult(verifyResult);
|
|
2939
|
+
} catch (err) {
|
|
2940
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2941
|
+
} finally {
|
|
2942
|
+
setLoading(false);
|
|
2943
|
+
setProgress(1);
|
|
2944
|
+
}
|
|
2945
|
+
},
|
|
2946
|
+
[nodeId, getEngine]
|
|
2947
|
+
);
|
|
2948
|
+
const quickCheck = useCallback14(async () => {
|
|
2949
|
+
if (!nodeId) return null;
|
|
2950
|
+
const engine = getEngine();
|
|
2951
|
+
if (!engine) return null;
|
|
2952
|
+
try {
|
|
2953
|
+
return await engine.quickCheck(nodeId);
|
|
2954
|
+
} catch (err) {
|
|
2955
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
2956
|
+
return null;
|
|
2957
|
+
}
|
|
2958
|
+
}, [nodeId, getEngine]);
|
|
2959
|
+
return { verify, quickCheck, result, loading, progress, error };
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2962
|
+
// src/hooks/useHubStatus.ts
|
|
2963
|
+
import { useContext as useContext2 } from "react";
|
|
2964
|
+
function useHubStatus() {
|
|
2965
|
+
const context = useContext2(XNetContext);
|
|
2966
|
+
return context?.hubStatus ?? "disconnected";
|
|
2967
|
+
}
|
|
2968
|
+
|
|
2969
|
+
// src/hooks/useCan.ts
|
|
2970
|
+
import { useEffect as useEffect15, useRef as useRef16, useState as useState17 } from "react";
|
|
2971
|
+
var GRANT_SCHEMA_ID = "xnet://xnet.fyi/Grant";
|
|
2972
|
+
var INITIAL_STATE = {
|
|
2973
|
+
canRead: false,
|
|
2974
|
+
canWrite: false,
|
|
2975
|
+
canDelete: false,
|
|
2976
|
+
canShare: false,
|
|
2977
|
+
loading: true,
|
|
2978
|
+
error: null,
|
|
2979
|
+
isFresh: false,
|
|
2980
|
+
evaluatedAt: 0
|
|
2981
|
+
};
|
|
2982
|
+
function useCan(nodeId) {
|
|
2983
|
+
const { store, isReady } = useNodeStore();
|
|
2984
|
+
const [state, setState] = useState17(INITIAL_STATE);
|
|
2985
|
+
const requestRef = useRef16(0);
|
|
2986
|
+
useEffect15(() => {
|
|
2987
|
+
if (!store || !isReady) {
|
|
2988
|
+
setState((prev) => ({ ...prev, loading: true }));
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2991
|
+
if (!store.auth) {
|
|
2992
|
+
setState({
|
|
2993
|
+
...INITIAL_STATE,
|
|
2994
|
+
loading: false,
|
|
2995
|
+
error: new Error("Authorization API is not configured on this NodeStore")
|
|
2996
|
+
});
|
|
2997
|
+
return;
|
|
2998
|
+
}
|
|
2999
|
+
const run = async () => {
|
|
3000
|
+
const requestId = ++requestRef.current;
|
|
3001
|
+
setState((prev) => ({ ...prev, loading: true, error: null }));
|
|
3002
|
+
try {
|
|
3003
|
+
const [read, write, del, share] = await Promise.all([
|
|
3004
|
+
store.auth.can({ action: "read", nodeId }),
|
|
3005
|
+
store.auth.can({ action: "write", nodeId }),
|
|
3006
|
+
store.auth.can({ action: "delete", nodeId }),
|
|
3007
|
+
store.auth.can({ action: "share", nodeId })
|
|
3008
|
+
]);
|
|
3009
|
+
if (requestRef.current !== requestId) return;
|
|
3010
|
+
setState({
|
|
3011
|
+
canRead: read.allowed,
|
|
3012
|
+
canWrite: write.allowed,
|
|
3013
|
+
canDelete: del.allowed,
|
|
3014
|
+
canShare: share.allowed,
|
|
3015
|
+
loading: false,
|
|
3016
|
+
error: null,
|
|
3017
|
+
isFresh: !read.cached,
|
|
3018
|
+
evaluatedAt: read.evaluatedAt
|
|
3019
|
+
});
|
|
3020
|
+
} catch (error) {
|
|
3021
|
+
if (requestRef.current !== requestId) return;
|
|
3022
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
3023
|
+
setState((prev) => ({ ...prev, loading: false, error: normalized }));
|
|
3024
|
+
}
|
|
3025
|
+
};
|
|
3026
|
+
void run();
|
|
3027
|
+
const unsubscribe = store.subscribe((event) => {
|
|
3028
|
+
const typedEvent = event;
|
|
3029
|
+
const eventNodeId = typedEvent.change?.payload?.nodeId;
|
|
3030
|
+
if (eventNodeId === nodeId) {
|
|
3031
|
+
void run();
|
|
3032
|
+
return;
|
|
3033
|
+
}
|
|
3034
|
+
const isGrantUpdate = typedEvent.node?.schemaId === GRANT_SCHEMA_ID;
|
|
3035
|
+
const grantResource = typedEvent.node?.properties?.resource;
|
|
3036
|
+
if (isGrantUpdate && grantResource === nodeId) {
|
|
3037
|
+
void run();
|
|
3038
|
+
}
|
|
3039
|
+
});
|
|
3040
|
+
return () => {
|
|
3041
|
+
unsubscribe();
|
|
3042
|
+
};
|
|
3043
|
+
}, [isReady, nodeId, store]);
|
|
3044
|
+
return state;
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
// src/hooks/useCanEdit.ts
|
|
3048
|
+
import { useEffect as useEffect16, useRef as useRef17, useState as useState18 } from "react";
|
|
3049
|
+
var GRANT_SCHEMA_ID2 = "xnet://xnet.fyi/Grant";
|
|
3050
|
+
var INITIAL_STATE2 = {
|
|
3051
|
+
canEdit: false,
|
|
3052
|
+
canView: false,
|
|
3053
|
+
loading: true,
|
|
3054
|
+
error: null,
|
|
3055
|
+
roles: []
|
|
3056
|
+
};
|
|
3057
|
+
function useCanEdit(nodeId) {
|
|
3058
|
+
const { store, isReady } = useNodeStore();
|
|
3059
|
+
const [state, setState] = useState18(INITIAL_STATE2);
|
|
3060
|
+
const requestRef = useRef17(0);
|
|
3061
|
+
useEffect16(() => {
|
|
3062
|
+
if (!store || !isReady) {
|
|
3063
|
+
setState((prev) => ({ ...prev, loading: true }));
|
|
3064
|
+
return;
|
|
3065
|
+
}
|
|
3066
|
+
if (!store.auth) {
|
|
3067
|
+
setState({
|
|
3068
|
+
...INITIAL_STATE2,
|
|
3069
|
+
loading: false,
|
|
3070
|
+
error: new Error("Authorization API is not configured on this NodeStore")
|
|
3071
|
+
});
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
3074
|
+
const run = async () => {
|
|
3075
|
+
const requestId = ++requestRef.current;
|
|
3076
|
+
setState((prev) => ({ ...prev, loading: true, error: null }));
|
|
3077
|
+
try {
|
|
3078
|
+
const [read, write] = await Promise.all([
|
|
3079
|
+
store.auth.can({ action: "read", nodeId }),
|
|
3080
|
+
store.auth.can({ action: "write", nodeId })
|
|
3081
|
+
]);
|
|
3082
|
+
if (requestRef.current !== requestId) return;
|
|
3083
|
+
const roleSet = /* @__PURE__ */ new Set([...read.roles, ...write.roles]);
|
|
3084
|
+
setState({
|
|
3085
|
+
canEdit: write.allowed,
|
|
3086
|
+
canView: read.allowed && !write.allowed,
|
|
3087
|
+
loading: false,
|
|
3088
|
+
error: null,
|
|
3089
|
+
roles: Array.from(roleSet)
|
|
3090
|
+
});
|
|
3091
|
+
} catch (error) {
|
|
3092
|
+
if (requestRef.current !== requestId) return;
|
|
3093
|
+
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
3094
|
+
setState((prev) => ({ ...prev, loading: false, error: normalized }));
|
|
3095
|
+
}
|
|
3096
|
+
};
|
|
3097
|
+
void run();
|
|
3098
|
+
const unsubscribe = store.subscribe((event) => {
|
|
3099
|
+
const typedEvent = event;
|
|
3100
|
+
const eventNodeId = typedEvent.change?.payload?.nodeId;
|
|
3101
|
+
if (eventNodeId === nodeId) {
|
|
3102
|
+
void run();
|
|
3103
|
+
return;
|
|
3104
|
+
}
|
|
3105
|
+
const isGrantUpdate = typedEvent.node?.schemaId === GRANT_SCHEMA_ID2;
|
|
3106
|
+
const grantResource = typedEvent.node?.properties?.resource;
|
|
3107
|
+
if (isGrantUpdate && grantResource === nodeId) {
|
|
3108
|
+
void run();
|
|
3109
|
+
}
|
|
3110
|
+
});
|
|
3111
|
+
return () => {
|
|
3112
|
+
unsubscribe();
|
|
3113
|
+
};
|
|
3114
|
+
}, [isReady, nodeId, store]);
|
|
3115
|
+
return state;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
// src/hooks/useGrants.ts
|
|
3119
|
+
import { useCallback as useCallback15, useEffect as useEffect17, useState as useState19 } from "react";
|
|
3120
|
+
var GRANT_SCHEMA_ID3 = "xnet://xnet.fyi/Grant";
|
|
3121
|
+
function useGrants(nodeId) {
|
|
3122
|
+
const { store, isReady } = useNodeStore();
|
|
3123
|
+
const [grants, setGrants] = useState19([]);
|
|
3124
|
+
const [loading, setLoading] = useState19(true);
|
|
3125
|
+
const [error, setError] = useState19(null);
|
|
3126
|
+
const load = useCallback15(async () => {
|
|
3127
|
+
if (!store || !store.auth || !isReady) return;
|
|
3128
|
+
setLoading(true);
|
|
3129
|
+
setError(null);
|
|
3130
|
+
try {
|
|
3131
|
+
const next = await store.auth.listGrants({ nodeId });
|
|
3132
|
+
setGrants(next);
|
|
3133
|
+
setLoading(false);
|
|
3134
|
+
} catch (err) {
|
|
3135
|
+
const normalized = err instanceof Error ? err : new Error(String(err));
|
|
3136
|
+
setError(normalized);
|
|
3137
|
+
setLoading(false);
|
|
3138
|
+
}
|
|
3139
|
+
}, [isReady, nodeId, store]);
|
|
3140
|
+
useEffect17(() => {
|
|
3141
|
+
if (!store || !isReady) {
|
|
3142
|
+
setLoading(true);
|
|
3143
|
+
return;
|
|
3144
|
+
}
|
|
3145
|
+
if (!store.auth) {
|
|
3146
|
+
setLoading(false);
|
|
3147
|
+
setError(new Error("Authorization API is not configured on this NodeStore"));
|
|
3148
|
+
return;
|
|
3149
|
+
}
|
|
3150
|
+
void load();
|
|
3151
|
+
const unsubscribe = store.subscribe((event) => {
|
|
3152
|
+
const typedEvent = event;
|
|
3153
|
+
const isGrantUpdate = typedEvent.node?.schemaId === GRANT_SCHEMA_ID3;
|
|
3154
|
+
const resource = typedEvent.node?.properties?.resource;
|
|
3155
|
+
if (isGrantUpdate && resource === nodeId) {
|
|
3156
|
+
void load();
|
|
3157
|
+
}
|
|
3158
|
+
});
|
|
3159
|
+
return () => {
|
|
3160
|
+
unsubscribe();
|
|
3161
|
+
};
|
|
3162
|
+
}, [isReady, load, nodeId, store]);
|
|
3163
|
+
const grant = useCallback15(
|
|
3164
|
+
async (input) => {
|
|
3165
|
+
if (!store?.auth) {
|
|
3166
|
+
throw new Error("Authorization API is not configured on this NodeStore");
|
|
3167
|
+
}
|
|
3168
|
+
const created = await store.auth.grant({ ...input, resource: input.resource ?? nodeId });
|
|
3169
|
+
await load();
|
|
3170
|
+
return created;
|
|
3171
|
+
},
|
|
3172
|
+
[load, nodeId, store]
|
|
3173
|
+
);
|
|
3174
|
+
const revoke = useCallback15(
|
|
3175
|
+
async (grantId) => {
|
|
3176
|
+
if (!store?.auth) {
|
|
3177
|
+
throw new Error("Authorization API is not configured on this NodeStore");
|
|
3178
|
+
}
|
|
3179
|
+
await store.auth.revoke({ grantId });
|
|
3180
|
+
await load();
|
|
3181
|
+
},
|
|
3182
|
+
[load, store]
|
|
3183
|
+
);
|
|
3184
|
+
return { grants, loading, error, grant, revoke };
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
// src/hooks/useBackup.ts
|
|
3188
|
+
import { useCallback as useCallback16, useContext as useContext3, useMemo as useMemo8, useState as useState20 } from "react";
|
|
3189
|
+
function useBackup() {
|
|
3190
|
+
const context = useContext3(XNetContext);
|
|
3191
|
+
const [uploading, setUploading] = useState20(false);
|
|
3192
|
+
const backupConfig = useMemo8(() => {
|
|
3193
|
+
if (!context) return null;
|
|
3194
|
+
if (!context.hubUrl || !context.encryptionKey) return null;
|
|
3195
|
+
return {
|
|
3196
|
+
hubUrl: context.hubUrl,
|
|
3197
|
+
encryptionKey: context.encryptionKey,
|
|
3198
|
+
getAuthToken: context.getHubAuthToken
|
|
3199
|
+
};
|
|
3200
|
+
}, [context]);
|
|
3201
|
+
const upload = useCallback16(
|
|
3202
|
+
async (docId, plaintext) => {
|
|
3203
|
+
if (!backupConfig) {
|
|
3204
|
+
throw new Error("Hub backup is not configured");
|
|
3205
|
+
}
|
|
3206
|
+
setUploading(true);
|
|
3207
|
+
try {
|
|
3208
|
+
await uploadBackup(backupConfig, docId, plaintext);
|
|
3209
|
+
} finally {
|
|
3210
|
+
setUploading(false);
|
|
3211
|
+
}
|
|
3212
|
+
},
|
|
3213
|
+
[backupConfig]
|
|
3214
|
+
);
|
|
3215
|
+
const download = useCallback16(
|
|
3216
|
+
async (docId) => {
|
|
3217
|
+
if (!backupConfig) return null;
|
|
3218
|
+
return downloadBackup(backupConfig, docId);
|
|
3219
|
+
},
|
|
3220
|
+
[backupConfig]
|
|
3221
|
+
);
|
|
3222
|
+
return {
|
|
3223
|
+
upload,
|
|
3224
|
+
download,
|
|
3225
|
+
uploading
|
|
3226
|
+
};
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
// src/hooks/useFileUpload.ts
|
|
3230
|
+
import { hashHex } from "@xnetjs/crypto";
|
|
3231
|
+
import { useCallback as useCallback17, useContext as useContext4, useState as useState21 } from "react";
|
|
3232
|
+
var toHttpUrl = (hubUrl) => {
|
|
3233
|
+
try {
|
|
3234
|
+
const url = new URL(hubUrl);
|
|
3235
|
+
if (url.protocol === "ws:") url.protocol = "http:";
|
|
3236
|
+
if (url.protocol === "wss:") url.protocol = "https:";
|
|
3237
|
+
return url.toString().replace(/\/$/, "");
|
|
3238
|
+
} catch {
|
|
3239
|
+
return hubUrl;
|
|
3240
|
+
}
|
|
3241
|
+
};
|
|
3242
|
+
function useFileUpload() {
|
|
3243
|
+
const context = useContext4(XNetContext);
|
|
3244
|
+
const [uploading, setUploading] = useState21(false);
|
|
3245
|
+
const [progress, setProgress] = useState21(0);
|
|
3246
|
+
const upload = useCallback17(
|
|
3247
|
+
async (file) => {
|
|
3248
|
+
if (!context?.hubUrl) throw new Error("Hub URL not configured");
|
|
3249
|
+
setUploading(true);
|
|
3250
|
+
setProgress(0);
|
|
3251
|
+
try {
|
|
3252
|
+
const buffer = new Uint8Array(await file.arrayBuffer());
|
|
3253
|
+
setProgress(0.3);
|
|
3254
|
+
const cid = `cid:blake3:${hashHex(buffer)}`;
|
|
3255
|
+
setProgress(0.5);
|
|
3256
|
+
const token = context.getHubAuthToken ? await context.getHubAuthToken() : "";
|
|
3257
|
+
const httpUrl = toHttpUrl(context.hubUrl);
|
|
3258
|
+
const res = await fetch(`${httpUrl}/files/${cid}`, {
|
|
3259
|
+
method: "PUT",
|
|
3260
|
+
headers: {
|
|
3261
|
+
...token ? { Authorization: `Bearer ${token}` } : {},
|
|
3262
|
+
"Content-Type": file.type || "application/octet-stream",
|
|
3263
|
+
"X-File-Name": file.name
|
|
3264
|
+
},
|
|
3265
|
+
body: buffer
|
|
3266
|
+
});
|
|
3267
|
+
setProgress(0.9);
|
|
3268
|
+
if (!res.ok) {
|
|
3269
|
+
let message = `Upload failed: ${res.status}`;
|
|
3270
|
+
try {
|
|
3271
|
+
const err = await res.json();
|
|
3272
|
+
if (err?.error) message = err.error;
|
|
3273
|
+
} catch {
|
|
3274
|
+
}
|
|
3275
|
+
throw new Error(message);
|
|
3276
|
+
}
|
|
3277
|
+
setProgress(1);
|
|
3278
|
+
return {
|
|
3279
|
+
cid,
|
|
3280
|
+
name: file.name,
|
|
3281
|
+
mimeType: file.type || "application/octet-stream",
|
|
3282
|
+
size: file.size
|
|
3283
|
+
};
|
|
3284
|
+
} finally {
|
|
3285
|
+
setUploading(false);
|
|
3286
|
+
}
|
|
3287
|
+
},
|
|
3288
|
+
[context]
|
|
3289
|
+
);
|
|
3290
|
+
return { upload, uploading, progress };
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// src/hooks/useHubSearch.ts
|
|
3294
|
+
import { useCallback as useCallback18, useContext as useContext5, useEffect as useEffect18, useMemo as useMemo9, useRef as useRef18, useState as useState22 } from "react";
|
|
3295
|
+
var createRequestId = () => {
|
|
3296
|
+
const cryptoObj = globalThis.crypto;
|
|
3297
|
+
if (cryptoObj && "randomUUID" in cryptoObj) {
|
|
3298
|
+
return cryptoObj.randomUUID();
|
|
3299
|
+
}
|
|
3300
|
+
return Math.random().toString(36).slice(2);
|
|
3301
|
+
};
|
|
3302
|
+
function useHubSearch() {
|
|
3303
|
+
const context = useContext5(XNetContext);
|
|
3304
|
+
const connection = context?.hubConnection ?? context?.syncManager?.connection ?? null;
|
|
3305
|
+
const [results, setResults] = useState22([]);
|
|
3306
|
+
const [loading, setLoading] = useState22(false);
|
|
3307
|
+
const [error, setError] = useState22(null);
|
|
3308
|
+
const pendingRef = useRef18(/* @__PURE__ */ new Map());
|
|
3309
|
+
useEffect18(() => {
|
|
3310
|
+
if (!connection) return;
|
|
3311
|
+
const unsubscribe = connection.onMessage((message) => {
|
|
3312
|
+
const msg = message;
|
|
3313
|
+
if (!msg.type || !msg.id) return;
|
|
3314
|
+
if (msg.type === "query-response") {
|
|
3315
|
+
const pending = pendingRef.current.get(msg.id);
|
|
3316
|
+
if (pending) {
|
|
3317
|
+
pendingRef.current.delete(msg.id);
|
|
3318
|
+
clearTimeout(pending.timeoutId);
|
|
3319
|
+
pending.resolve(msg.results ?? []);
|
|
3320
|
+
}
|
|
3321
|
+
}
|
|
3322
|
+
if (msg.type === "query-error") {
|
|
3323
|
+
const pending = pendingRef.current.get(msg.id);
|
|
3324
|
+
if (pending) {
|
|
3325
|
+
pendingRef.current.delete(msg.id);
|
|
3326
|
+
clearTimeout(pending.timeoutId);
|
|
3327
|
+
pending.reject(new Error(msg.error ?? "Query failed"));
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
});
|
|
3331
|
+
return () => {
|
|
3332
|
+
unsubscribe();
|
|
3333
|
+
for (const pending of pendingRef.current.values()) {
|
|
3334
|
+
clearTimeout(pending.timeoutId);
|
|
3335
|
+
pending.reject(new Error("Query cancelled"));
|
|
3336
|
+
}
|
|
3337
|
+
pendingRef.current.clear();
|
|
3338
|
+
};
|
|
3339
|
+
}, [connection]);
|
|
3340
|
+
const search = useCallback18(
|
|
3341
|
+
async (query, options) => {
|
|
3342
|
+
if (!connection || connection.status !== "connected") {
|
|
3343
|
+
setError(new Error("Hub connection not available"));
|
|
3344
|
+
setResults([]);
|
|
3345
|
+
return [];
|
|
3346
|
+
}
|
|
3347
|
+
setLoading(true);
|
|
3348
|
+
setError(null);
|
|
3349
|
+
const id = createRequestId();
|
|
3350
|
+
const promise = new Promise((resolve, reject) => {
|
|
3351
|
+
const timeoutId = setTimeout(() => {
|
|
3352
|
+
pendingRef.current.delete(id);
|
|
3353
|
+
reject(new Error("Query timeout"));
|
|
3354
|
+
}, 5e3);
|
|
3355
|
+
pendingRef.current.set(id, { resolve, reject, timeoutId });
|
|
3356
|
+
});
|
|
3357
|
+
connection.sendRaw({
|
|
3358
|
+
type: "query-request",
|
|
3359
|
+
id,
|
|
3360
|
+
query,
|
|
3361
|
+
filters: options?.schemaIri ? { schemaIri: options.schemaIri } : void 0,
|
|
3362
|
+
limit: options?.limit,
|
|
3363
|
+
offset: options?.offset
|
|
3364
|
+
});
|
|
3365
|
+
try {
|
|
3366
|
+
const response = await promise;
|
|
3367
|
+
setResults(response);
|
|
3368
|
+
return response;
|
|
3369
|
+
} catch (err) {
|
|
3370
|
+
const errorValue = err instanceof Error ? err : new Error("Search failed");
|
|
3371
|
+
setError(errorValue);
|
|
3372
|
+
setResults([]);
|
|
3373
|
+
return [];
|
|
3374
|
+
} finally {
|
|
3375
|
+
setLoading(false);
|
|
3376
|
+
}
|
|
3377
|
+
},
|
|
3378
|
+
[connection]
|
|
3379
|
+
);
|
|
3380
|
+
return useMemo9(
|
|
3381
|
+
() => ({
|
|
3382
|
+
search,
|
|
3383
|
+
results,
|
|
3384
|
+
loading,
|
|
3385
|
+
error
|
|
3386
|
+
}),
|
|
3387
|
+
[search, results, loading, error]
|
|
3388
|
+
);
|
|
3389
|
+
}
|
|
3390
|
+
|
|
3391
|
+
// src/hooks/useRemoteSchema.ts
|
|
3392
|
+
import { useContext as useContext6, useEffect as useEffect19, useMemo as useMemo10, useState as useState23 } from "react";
|
|
3393
|
+
var toHubHttpUrl = (hubUrl) => {
|
|
3394
|
+
if (hubUrl.startsWith("http://") || hubUrl.startsWith("https://")) return hubUrl;
|
|
3395
|
+
return hubUrl.replace("wss://", "https://").replace("ws://", "http://");
|
|
3396
|
+
};
|
|
3397
|
+
var useRemoteSchema = (iri) => {
|
|
3398
|
+
const context = useContext6(XNetContext);
|
|
3399
|
+
const hubUrl = context?.hubUrl ?? null;
|
|
3400
|
+
const getHubAuthToken = context?.getHubAuthToken;
|
|
3401
|
+
const [schema, setSchema] = useState23(null);
|
|
3402
|
+
const [loading, setLoading] = useState23(false);
|
|
3403
|
+
const [error, setError] = useState23(null);
|
|
3404
|
+
useEffect19(() => {
|
|
3405
|
+
if (!iri || !hubUrl) {
|
|
3406
|
+
setSchema(null);
|
|
3407
|
+
setLoading(false);
|
|
3408
|
+
return;
|
|
3409
|
+
}
|
|
3410
|
+
let cancelled = false;
|
|
3411
|
+
const controller = new AbortController();
|
|
3412
|
+
const fetchSchema = async () => {
|
|
3413
|
+
setLoading(true);
|
|
3414
|
+
setError(null);
|
|
3415
|
+
try {
|
|
3416
|
+
const hubHttpUrl = toHubHttpUrl(hubUrl);
|
|
3417
|
+
const encodedIri = encodeURIComponent(iri);
|
|
3418
|
+
const token = getHubAuthToken ? await getHubAuthToken() : "";
|
|
3419
|
+
const headers = token ? { Authorization: `Bearer ${token}` } : void 0;
|
|
3420
|
+
const response = await fetch(`${hubHttpUrl}/schemas/resolve/${encodedIri}`, {
|
|
3421
|
+
headers,
|
|
3422
|
+
signal: controller.signal
|
|
3423
|
+
});
|
|
3424
|
+
if (!response.ok) {
|
|
3425
|
+
if (response.status === 404) {
|
|
3426
|
+
if (!cancelled) setSchema(null);
|
|
3427
|
+
return;
|
|
3428
|
+
}
|
|
3429
|
+
throw new Error(`Failed to resolve schema: ${response.status}`);
|
|
3430
|
+
}
|
|
3431
|
+
const data = await response.json();
|
|
3432
|
+
if (!cancelled) setSchema(data);
|
|
3433
|
+
} catch (err) {
|
|
3434
|
+
if (cancelled) return;
|
|
3435
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
3436
|
+
const errorValue = err instanceof Error ? err : new Error("Schema lookup failed");
|
|
3437
|
+
setError(errorValue);
|
|
3438
|
+
} finally {
|
|
3439
|
+
if (!cancelled) setLoading(false);
|
|
3440
|
+
}
|
|
3441
|
+
};
|
|
3442
|
+
void fetchSchema();
|
|
3443
|
+
return () => {
|
|
3444
|
+
cancelled = true;
|
|
3445
|
+
controller.abort();
|
|
3446
|
+
};
|
|
3447
|
+
}, [getHubAuthToken, hubUrl, iri]);
|
|
3448
|
+
return useMemo10(
|
|
3449
|
+
() => ({
|
|
3450
|
+
schema,
|
|
3451
|
+
loading,
|
|
3452
|
+
error
|
|
3453
|
+
}),
|
|
3454
|
+
[schema, loading, error]
|
|
3455
|
+
);
|
|
3456
|
+
};
|
|
3457
|
+
|
|
3458
|
+
// src/hooks/usePeerDiscovery.ts
|
|
3459
|
+
import { useCallback as useCallback19, useContext as useContext7, useMemo as useMemo11, useState as useState24 } from "react";
|
|
3460
|
+
var toHubHttpUrl2 = (hubUrl) => hubUrl.replace("wss://", "https://").replace("ws://", "http://");
|
|
3461
|
+
var usePeerDiscovery = () => {
|
|
3462
|
+
const context = useContext7(XNetContext);
|
|
3463
|
+
const hubUrl = context?.hubUrl ?? null;
|
|
3464
|
+
const [peers, setPeers] = useState24([]);
|
|
3465
|
+
const [loading, setLoading] = useState24(false);
|
|
3466
|
+
const refresh = useCallback19(async () => {
|
|
3467
|
+
if (!hubUrl) return;
|
|
3468
|
+
setLoading(true);
|
|
3469
|
+
try {
|
|
3470
|
+
const hubHttpUrl = toHubHttpUrl2(hubUrl);
|
|
3471
|
+
const res = await fetch(`${hubHttpUrl}/dids?limit=50`);
|
|
3472
|
+
if (!res.ok) return;
|
|
3473
|
+
const { peers: records } = await res.json();
|
|
3474
|
+
const now = Date.now();
|
|
3475
|
+
setPeers(
|
|
3476
|
+
records.map((record) => ({
|
|
3477
|
+
did: record.did,
|
|
3478
|
+
displayName: record.displayName,
|
|
3479
|
+
endpoints: record.endpoints,
|
|
3480
|
+
lastSeen: record.lastSeen,
|
|
3481
|
+
isOnline: now - record.lastSeen < 5 * 60 * 1e3
|
|
3482
|
+
}))
|
|
3483
|
+
);
|
|
3484
|
+
} finally {
|
|
3485
|
+
setLoading(false);
|
|
3486
|
+
}
|
|
3487
|
+
}, [hubUrl]);
|
|
3488
|
+
const resolve = useCallback19(
|
|
3489
|
+
async (did) => {
|
|
3490
|
+
if (!hubUrl) return null;
|
|
3491
|
+
const hubHttpUrl = toHubHttpUrl2(hubUrl);
|
|
3492
|
+
const res = await fetch(`${hubHttpUrl}/dids/${encodeURIComponent(did)}`);
|
|
3493
|
+
if (!res.ok) return null;
|
|
3494
|
+
const record = await res.json();
|
|
3495
|
+
return {
|
|
3496
|
+
did: record.did,
|
|
3497
|
+
displayName: record.displayName,
|
|
3498
|
+
endpoints: record.endpoints,
|
|
3499
|
+
lastSeen: record.lastSeen,
|
|
3500
|
+
isOnline: Date.now() - record.lastSeen < 5 * 60 * 1e3
|
|
3501
|
+
};
|
|
3502
|
+
},
|
|
3503
|
+
[hubUrl]
|
|
3504
|
+
);
|
|
3505
|
+
return useMemo11(
|
|
3506
|
+
() => ({
|
|
3507
|
+
peers,
|
|
3508
|
+
resolve,
|
|
3509
|
+
refresh,
|
|
3510
|
+
loading
|
|
3511
|
+
}),
|
|
3512
|
+
[peers, resolve, refresh, loading]
|
|
3513
|
+
);
|
|
3514
|
+
};
|
|
3515
|
+
|
|
3516
|
+
// src/components/HubStatusIndicator.tsx
|
|
3517
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3518
|
+
var STATUS_CONFIG = {
|
|
3519
|
+
disconnected: { color: "var(--muted)", label: "Offline" },
|
|
3520
|
+
connecting: { color: "var(--warning)", label: "Connecting..." },
|
|
3521
|
+
connected: { color: "var(--success)", label: "Synced to hub" },
|
|
3522
|
+
error: { color: "var(--destructive)", label: "Connection error" }
|
|
3523
|
+
};
|
|
3524
|
+
function HubStatusIndicator() {
|
|
3525
|
+
const status = useHubStatus();
|
|
3526
|
+
const config = STATUS_CONFIG[status];
|
|
3527
|
+
return /* @__PURE__ */ jsxs(
|
|
3528
|
+
"div",
|
|
3529
|
+
{
|
|
3530
|
+
style: { display: "flex", alignItems: "center", gap: "6px", fontSize: "12px" },
|
|
3531
|
+
title: config.label,
|
|
3532
|
+
children: [
|
|
3533
|
+
/* @__PURE__ */ jsx(
|
|
3534
|
+
"span",
|
|
3535
|
+
{
|
|
3536
|
+
style: {
|
|
3537
|
+
width: "8px",
|
|
3538
|
+
height: "8px",
|
|
3539
|
+
borderRadius: "50%",
|
|
3540
|
+
backgroundColor: config.color,
|
|
3541
|
+
display: "inline-block",
|
|
3542
|
+
animation: status === "connecting" ? "pulse 1.5s infinite" : void 0
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
),
|
|
3546
|
+
/* @__PURE__ */ jsx("span", { style: { color: "var(--muted-foreground)" }, children: config.label })
|
|
3547
|
+
]
|
|
3548
|
+
}
|
|
3549
|
+
);
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
// src/components/ErrorBoundary.tsx
|
|
3553
|
+
import { Component } from "react";
|
|
3554
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
3555
|
+
var ErrorBoundary = class extends Component {
|
|
3556
|
+
state = { hasError: false, error: null };
|
|
3557
|
+
static getDerivedStateFromError(error) {
|
|
3558
|
+
return { hasError: true, error };
|
|
3559
|
+
}
|
|
3560
|
+
componentDidCatch(error, errorInfo) {
|
|
3561
|
+
console.error("[ErrorBoundary]", error, errorInfo);
|
|
3562
|
+
this.props.onError?.(error, errorInfo);
|
|
3563
|
+
}
|
|
3564
|
+
handleReset = () => {
|
|
3565
|
+
this.setState({ hasError: false, error: null });
|
|
3566
|
+
};
|
|
3567
|
+
componentDidUpdate(prevProps) {
|
|
3568
|
+
if (this.state.hasError && this.props.resetKey !== void 0 && prevProps.resetKey !== this.props.resetKey) {
|
|
3569
|
+
this.handleReset();
|
|
3570
|
+
}
|
|
3571
|
+
}
|
|
3572
|
+
render() {
|
|
3573
|
+
if (!this.state.hasError) {
|
|
3574
|
+
return this.props.children;
|
|
3575
|
+
}
|
|
3576
|
+
if (this.props.fallback) {
|
|
3577
|
+
if (typeof this.props.fallback === "function") {
|
|
3578
|
+
return this.props.fallback({
|
|
3579
|
+
error: this.state.error ?? new Error("Unknown error"),
|
|
3580
|
+
reset: this.handleReset
|
|
3581
|
+
});
|
|
3582
|
+
}
|
|
3583
|
+
return this.props.fallback;
|
|
3584
|
+
}
|
|
3585
|
+
return /* @__PURE__ */ jsxs2(
|
|
3586
|
+
"div",
|
|
3587
|
+
{
|
|
3588
|
+
role: "alert",
|
|
3589
|
+
style: {
|
|
3590
|
+
padding: "2rem",
|
|
3591
|
+
textAlign: "center",
|
|
3592
|
+
maxWidth: "480px",
|
|
3593
|
+
margin: "4rem auto"
|
|
3594
|
+
},
|
|
3595
|
+
children: [
|
|
3596
|
+
/* @__PURE__ */ jsx2("h2", { style: { fontSize: "1.25rem", fontWeight: 600, marginBottom: "0.5rem" }, children: "Something went wrong" }),
|
|
3597
|
+
/* @__PURE__ */ jsx2("p", { style: { color: "#666", marginBottom: "1rem", fontSize: "0.875rem" }, children: this.state.error?.message ?? "An unexpected error occurred." }),
|
|
3598
|
+
/* @__PURE__ */ jsx2(
|
|
3599
|
+
"button",
|
|
3600
|
+
{
|
|
3601
|
+
onClick: this.handleReset,
|
|
3602
|
+
style: {
|
|
3603
|
+
padding: "0.5rem 1rem",
|
|
3604
|
+
borderRadius: "6px",
|
|
3605
|
+
border: "1px solid #ccc",
|
|
3606
|
+
background: "#fff",
|
|
3607
|
+
cursor: "pointer",
|
|
3608
|
+
fontSize: "0.875rem"
|
|
3609
|
+
},
|
|
3610
|
+
children: "Try again"
|
|
3611
|
+
}
|
|
3612
|
+
)
|
|
3613
|
+
]
|
|
3614
|
+
}
|
|
3615
|
+
);
|
|
3616
|
+
}
|
|
3617
|
+
};
|
|
3618
|
+
|
|
3619
|
+
// src/components/Skeleton.tsx
|
|
3620
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
3621
|
+
var stylesInjected = false;
|
|
3622
|
+
function ensureStyles() {
|
|
3623
|
+
if (stylesInjected || typeof document === "undefined") return;
|
|
3624
|
+
const id = "xnet-skeleton-styles";
|
|
3625
|
+
if (document.getElementById(id)) {
|
|
3626
|
+
stylesInjected = true;
|
|
3627
|
+
return;
|
|
3628
|
+
}
|
|
3629
|
+
const el = document.createElement("style");
|
|
3630
|
+
el.id = id;
|
|
3631
|
+
el.textContent = `
|
|
3632
|
+
@keyframes xnet-skeleton-pulse {
|
|
3633
|
+
0% { background-position: 200% 0; }
|
|
3634
|
+
100% { background-position: -200% 0; }
|
|
3635
|
+
}
|
|
3636
|
+
`;
|
|
3637
|
+
document.head.appendChild(el);
|
|
3638
|
+
stylesInjected = true;
|
|
3639
|
+
}
|
|
3640
|
+
function Skeleton({
|
|
3641
|
+
width = "100%",
|
|
3642
|
+
height = "1em",
|
|
3643
|
+
borderRadius = "4px",
|
|
3644
|
+
style,
|
|
3645
|
+
lines = 1,
|
|
3646
|
+
gap = "0.5rem"
|
|
3647
|
+
}) {
|
|
3648
|
+
ensureStyles();
|
|
3649
|
+
const baseStyle = {
|
|
3650
|
+
display: "block",
|
|
3651
|
+
width,
|
|
3652
|
+
height,
|
|
3653
|
+
borderRadius,
|
|
3654
|
+
background: "linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%)",
|
|
3655
|
+
backgroundSize: "200% 100%",
|
|
3656
|
+
animation: "xnet-skeleton-pulse 1.5s ease-in-out infinite",
|
|
3657
|
+
...style
|
|
3658
|
+
};
|
|
3659
|
+
if (lines <= 1) {
|
|
3660
|
+
return /* @__PURE__ */ jsx3("span", { "aria-hidden": "true", style: baseStyle });
|
|
3661
|
+
}
|
|
3662
|
+
return /* @__PURE__ */ jsx3("div", { "aria-hidden": "true", style: { display: "flex", flexDirection: "column", gap }, children: Array.from({ length: lines }, (_, i) => /* @__PURE__ */ jsx3(
|
|
3663
|
+
"span",
|
|
3664
|
+
{
|
|
3665
|
+
style: {
|
|
3666
|
+
...baseStyle,
|
|
3667
|
+
// Last line is shorter for visual realism
|
|
3668
|
+
width: i === lines - 1 ? "60%" : width
|
|
3669
|
+
}
|
|
3670
|
+
},
|
|
3671
|
+
i
|
|
3672
|
+
)) });
|
|
3673
|
+
}
|
|
3674
|
+
function injectSkeletonStyles() {
|
|
3675
|
+
if (typeof document === "undefined") return;
|
|
3676
|
+
const id = "xnet-skeleton-styles";
|
|
3677
|
+
if (document.getElementById(id)) return;
|
|
3678
|
+
const style = document.createElement("style");
|
|
3679
|
+
style.id = id;
|
|
3680
|
+
style.textContent = `
|
|
3681
|
+
@keyframes xnet-skeleton-pulse {
|
|
3682
|
+
0% { background-position: 200% 0; }
|
|
3683
|
+
100% { background-position: -200% 0; }
|
|
3684
|
+
}
|
|
3685
|
+
`;
|
|
3686
|
+
document.head.appendChild(style);
|
|
3687
|
+
}
|
|
3688
|
+
|
|
3689
|
+
// src/components/DemoBanner.tsx
|
|
3690
|
+
import { useState as useState25 } from "react";
|
|
3691
|
+
import { jsx as jsx4, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
3692
|
+
var STORAGE_KEY = "xnet:demo-banner-dismissed";
|
|
3693
|
+
function DemoBanner({ evictionHours, onDismiss }) {
|
|
3694
|
+
const [dismissed, setDismissed] = useState25(() => {
|
|
3695
|
+
if (typeof window === "undefined") return false;
|
|
3696
|
+
return localStorage.getItem(STORAGE_KEY) === "true";
|
|
3697
|
+
});
|
|
3698
|
+
if (dismissed) return null;
|
|
3699
|
+
const handleDismiss = () => {
|
|
3700
|
+
localStorage.setItem(STORAGE_KEY, "true");
|
|
3701
|
+
setDismissed(true);
|
|
3702
|
+
onDismiss?.();
|
|
3703
|
+
};
|
|
3704
|
+
return /* @__PURE__ */ jsxs3("div", { className: "fixed top-0 left-0 right-0 z-50 flex items-center justify-between px-4 py-2 bg-amber-100 dark:bg-amber-900/50 border-b border-amber-300 dark:border-amber-700 text-amber-900 dark:text-amber-100 text-sm", children: [
|
|
3705
|
+
/* @__PURE__ */ jsxs3("div", { className: "flex items-center gap-2", children: [
|
|
3706
|
+
/* @__PURE__ */ jsxs3("span", { children: [
|
|
3707
|
+
"Demo mode \u2014 data is saved locally; encrypted backups expire after ",
|
|
3708
|
+
evictionHours,
|
|
3709
|
+
"h of inactivity."
|
|
3710
|
+
] }),
|
|
3711
|
+
/* @__PURE__ */ jsx4(
|
|
3712
|
+
"a",
|
|
3713
|
+
{
|
|
3714
|
+
href: "https://xnet.fyi/download",
|
|
3715
|
+
className: "ml-2 px-2 py-0.5 bg-amber-500 hover:bg-amber-600 text-white rounded text-xs font-medium transition-colors",
|
|
3716
|
+
children: "Download desktop app"
|
|
3717
|
+
}
|
|
3718
|
+
)
|
|
3719
|
+
] }),
|
|
3720
|
+
/* @__PURE__ */ jsx4(
|
|
3721
|
+
"button",
|
|
3722
|
+
{
|
|
3723
|
+
onClick: handleDismiss,
|
|
3724
|
+
className: "p-1 hover:bg-amber-200 dark:hover:bg-amber-800 rounded transition-colors",
|
|
3725
|
+
"aria-label": "Dismiss banner",
|
|
3726
|
+
children: /* @__PURE__ */ jsx4("svg", { className: "w-4 h-4", fill: "none", stroke: "currentColor", viewBox: "0 0 24 24", children: /* @__PURE__ */ jsx4(
|
|
3727
|
+
"path",
|
|
3728
|
+
{
|
|
3729
|
+
strokeLinecap: "round",
|
|
3730
|
+
strokeLinejoin: "round",
|
|
3731
|
+
strokeWidth: 2,
|
|
3732
|
+
d: "M6 18L18 6M6 6l12 12"
|
|
3733
|
+
}
|
|
3734
|
+
) })
|
|
3735
|
+
}
|
|
3736
|
+
)
|
|
3737
|
+
] });
|
|
3738
|
+
}
|
|
3739
|
+
|
|
3740
|
+
// src/components/DemoQuotaIndicator.tsx
|
|
3741
|
+
import { jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
3742
|
+
function formatBytes(bytes) {
|
|
3743
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
3744
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
3745
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
3746
|
+
}
|
|
3747
|
+
function DemoQuotaIndicator({ usedBytes, limitBytes }) {
|
|
3748
|
+
const percentage = Math.round(usedBytes / limitBytes * 100);
|
|
3749
|
+
const isWarning = percentage >= 80;
|
|
3750
|
+
const isCritical = percentage >= 95;
|
|
3751
|
+
return /* @__PURE__ */ jsxs4("div", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
|
|
3752
|
+
/* @__PURE__ */ jsx5("div", { className: "w-16 h-1.5 bg-border rounded-full overflow-hidden", children: /* @__PURE__ */ jsx5(
|
|
3753
|
+
"div",
|
|
3754
|
+
{
|
|
3755
|
+
className: `h-full transition-all ${isCritical ? "bg-red-500" : isWarning ? "bg-amber-500" : "bg-primary"}`,
|
|
3756
|
+
style: { width: `${Math.min(percentage, 100)}%` }
|
|
3757
|
+
}
|
|
3758
|
+
) }),
|
|
3759
|
+
/* @__PURE__ */ jsxs4("span", { className: isCritical ? "text-red-500" : isWarning ? "text-amber-500" : "", children: [
|
|
3760
|
+
formatBytes(usedBytes),
|
|
3761
|
+
" / ",
|
|
3762
|
+
formatBytes(limitBytes)
|
|
3763
|
+
] })
|
|
3764
|
+
] });
|
|
3765
|
+
}
|
|
3766
|
+
|
|
3767
|
+
// src/components/DemoDataExpiredScreen.tsx
|
|
3768
|
+
import { jsx as jsx6, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
3769
|
+
function DemoDataExpiredScreen() {
|
|
3770
|
+
return /* @__PURE__ */ jsxs5("div", { className: "flex flex-col items-center justify-center min-h-screen p-6 text-center bg-background", children: [
|
|
3771
|
+
/* @__PURE__ */ jsx6("div", { className: "text-6xl mb-6", children: "\u{1F550}" }),
|
|
3772
|
+
/* @__PURE__ */ jsx6("h1", { className: "text-2xl font-bold mb-3 text-foreground", children: "Your demo data has expired" }),
|
|
3773
|
+
/* @__PURE__ */ jsx6("p", { className: "text-muted-foreground max-w-md mb-6", children: "Your local data is still saved in this browser, but encrypted hub backups are removed after 24 hours of inactivity. Cross-device sync requires an active hub connection." }),
|
|
3774
|
+
/* @__PURE__ */ jsxs5("div", { className: "flex gap-3", children: [
|
|
3775
|
+
/* @__PURE__ */ jsx6(
|
|
3776
|
+
"button",
|
|
3777
|
+
{
|
|
3778
|
+
onClick: () => window.location.reload(),
|
|
3779
|
+
className: "px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors",
|
|
3780
|
+
children: "Start Fresh"
|
|
3781
|
+
}
|
|
3782
|
+
),
|
|
3783
|
+
/* @__PURE__ */ jsx6(
|
|
3784
|
+
"a",
|
|
3785
|
+
{
|
|
3786
|
+
href: "https://xnet.fyi/download",
|
|
3787
|
+
className: "px-4 py-2 border border-border rounded-md hover:bg-accent transition-colors text-foreground no-underline",
|
|
3788
|
+
children: "Download Desktop App"
|
|
3789
|
+
}
|
|
3790
|
+
)
|
|
3791
|
+
] }),
|
|
3792
|
+
/* @__PURE__ */ jsx6("p", { className: "mt-8 text-sm text-muted-foreground", children: "The desktop app stores data permanently on your device." })
|
|
3793
|
+
] });
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
// src/hooks/useDemoMode.ts
|
|
3797
|
+
import { useState as useState26, useEffect as useEffect20 } from "react";
|
|
3798
|
+
function useDemoMode() {
|
|
3799
|
+
const { syncManager } = useXNet();
|
|
3800
|
+
const [state, setState] = useState26({ isDemo: false });
|
|
3801
|
+
useEffect20(() => {
|
|
3802
|
+
if (!syncManager?.connection) return;
|
|
3803
|
+
const connection = syncManager.connection;
|
|
3804
|
+
const unsubscribe = connection.onMessage((message) => {
|
|
3805
|
+
if (message.type !== "handshake") return;
|
|
3806
|
+
const isDemo = message.isDemo === true;
|
|
3807
|
+
if (!isDemo) {
|
|
3808
|
+
setState({ isDemo: false });
|
|
3809
|
+
return;
|
|
3810
|
+
}
|
|
3811
|
+
const demoLimits = message.demoLimits;
|
|
3812
|
+
setState({
|
|
3813
|
+
isDemo: true,
|
|
3814
|
+
limits: demoLimits ? {
|
|
3815
|
+
quotaBytes: demoLimits.quotaBytes,
|
|
3816
|
+
maxDocs: demoLimits.maxDocs,
|
|
3817
|
+
evictionHours: Math.round(demoLimits.evictionTtlMs / 36e5)
|
|
3818
|
+
} : void 0
|
|
3819
|
+
});
|
|
3820
|
+
});
|
|
3821
|
+
return unsubscribe;
|
|
3822
|
+
}, [syncManager]);
|
|
3823
|
+
return state;
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
// src/components/OfflineIndicator.tsx
|
|
3827
|
+
import { useState as useState27, useEffect as useEffect21 } from "react";
|
|
3828
|
+
import { jsx as jsx7, jsxs as jsxs6 } from "react/jsx-runtime";
|
|
3829
|
+
function useIsOffline() {
|
|
3830
|
+
const [offline, setOffline] = useState27(
|
|
3831
|
+
typeof navigator !== "undefined" ? !navigator.onLine : false
|
|
3832
|
+
);
|
|
3833
|
+
useEffect21(() => {
|
|
3834
|
+
const goOffline = () => setOffline(true);
|
|
3835
|
+
const goOnline = () => setOffline(false);
|
|
3836
|
+
window.addEventListener("offline", goOffline);
|
|
3837
|
+
window.addEventListener("online", goOnline);
|
|
3838
|
+
return () => {
|
|
3839
|
+
window.removeEventListener("offline", goOffline);
|
|
3840
|
+
window.removeEventListener("online", goOnline);
|
|
3841
|
+
};
|
|
3842
|
+
}, []);
|
|
3843
|
+
return offline;
|
|
3844
|
+
}
|
|
3845
|
+
function OfflineIndicator({
|
|
3846
|
+
message = "You are offline. Changes will sync when reconnected.",
|
|
3847
|
+
className,
|
|
3848
|
+
position = "bottom"
|
|
3849
|
+
}) {
|
|
3850
|
+
const offline = useIsOffline();
|
|
3851
|
+
if (!offline) return null;
|
|
3852
|
+
const positionStyle = position === "top" ? { top: 0 } : { bottom: 0 };
|
|
3853
|
+
return /* @__PURE__ */ jsxs6(
|
|
3854
|
+
"div",
|
|
3855
|
+
{
|
|
3856
|
+
role: "status",
|
|
3857
|
+
"aria-live": "polite",
|
|
3858
|
+
className,
|
|
3859
|
+
style: {
|
|
3860
|
+
position: "fixed",
|
|
3861
|
+
left: 0,
|
|
3862
|
+
right: 0,
|
|
3863
|
+
zIndex: 9999,
|
|
3864
|
+
display: "flex",
|
|
3865
|
+
alignItems: "center",
|
|
3866
|
+
justifyContent: "center",
|
|
3867
|
+
gap: "0.5rem",
|
|
3868
|
+
padding: "0.5rem 1rem",
|
|
3869
|
+
background: "#fbbf24",
|
|
3870
|
+
color: "#78350f",
|
|
3871
|
+
fontSize: "0.875rem",
|
|
3872
|
+
fontWeight: 500,
|
|
3873
|
+
...positionStyle
|
|
3874
|
+
},
|
|
3875
|
+
children: [
|
|
3876
|
+
/* @__PURE__ */ jsxs6(
|
|
3877
|
+
"svg",
|
|
3878
|
+
{
|
|
3879
|
+
width: "16",
|
|
3880
|
+
height: "16",
|
|
3881
|
+
viewBox: "0 0 24 24",
|
|
3882
|
+
fill: "none",
|
|
3883
|
+
stroke: "currentColor",
|
|
3884
|
+
strokeWidth: "2",
|
|
3885
|
+
strokeLinecap: "round",
|
|
3886
|
+
strokeLinejoin: "round",
|
|
3887
|
+
children: [
|
|
3888
|
+
/* @__PURE__ */ jsx7("line", { x1: "1", y1: "1", x2: "23", y2: "23" }),
|
|
3889
|
+
/* @__PURE__ */ jsx7("path", { d: "M16.72 11.06A10.94 10.94 0 0 1 19 12.55" }),
|
|
3890
|
+
/* @__PURE__ */ jsx7("path", { d: "M5 12.55a10.94 10.94 0 0 1 5.17-2.39" }),
|
|
3891
|
+
/* @__PURE__ */ jsx7("path", { d: "M10.71 5.05A16 16 0 0 1 22.56 9" }),
|
|
3892
|
+
/* @__PURE__ */ jsx7("path", { d: "M1.42 9a15.91 15.91 0 0 1 4.7-2.88" }),
|
|
3893
|
+
/* @__PURE__ */ jsx7("path", { d: "M8.53 16.11a6 6 0 0 1 6.95 0" }),
|
|
3894
|
+
/* @__PURE__ */ jsx7("line", { x1: "12", y1: "20", x2: "12.01", y2: "20" })
|
|
3895
|
+
]
|
|
3896
|
+
}
|
|
3897
|
+
),
|
|
3898
|
+
/* @__PURE__ */ jsx7("span", { children: message })
|
|
3899
|
+
]
|
|
3900
|
+
}
|
|
3901
|
+
);
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
// src/hooks/useIdentity.ts
|
|
3905
|
+
function useIdentity() {
|
|
3906
|
+
const { identity, authorDID } = useXNet();
|
|
3907
|
+
return {
|
|
3908
|
+
identity: identity ?? null,
|
|
3909
|
+
isAuthenticated: !!identity || !!authorDID,
|
|
3910
|
+
did: identity?.did ?? authorDID ?? null
|
|
3911
|
+
};
|
|
3912
|
+
}
|
|
3913
|
+
|
|
3914
|
+
// src/onboarding/machine.ts
|
|
3915
|
+
var TRANSITIONS = {
|
|
3916
|
+
welcome: {
|
|
3917
|
+
AUTHENTICATE: "authenticating",
|
|
3918
|
+
CREATE_NEW: "authenticating",
|
|
3919
|
+
IMPORT_EXISTING: "import-identity",
|
|
3920
|
+
BROWSER_UNSUPPORTED: "unsupported-browser"
|
|
3921
|
+
},
|
|
3922
|
+
authenticating: {
|
|
3923
|
+
PASSKEY_SUCCESS: "connecting-hub",
|
|
3924
|
+
PASSKEY_FAILED: "auth-error"
|
|
3925
|
+
},
|
|
3926
|
+
"auth-error": {
|
|
3927
|
+
RETRY_AUTH: "authenticating",
|
|
3928
|
+
BACK_TO_WELCOME: "welcome"
|
|
3929
|
+
},
|
|
3930
|
+
"import-identity": {
|
|
3931
|
+
SCAN_QR: "qr-scan",
|
|
3932
|
+
ENTER_PHRASE: "recovery-phrase",
|
|
3933
|
+
BACK_TO_WELCOME: "welcome"
|
|
3934
|
+
},
|
|
3935
|
+
"qr-scan": {
|
|
3936
|
+
IDENTITY_IMPORTED: "connecting-hub",
|
|
3937
|
+
BACK_TO_WELCOME: "welcome"
|
|
3938
|
+
},
|
|
3939
|
+
"recovery-phrase": {
|
|
3940
|
+
IDENTITY_IMPORTED: "connecting-hub",
|
|
3941
|
+
BACK_TO_WELCOME: "welcome"
|
|
3942
|
+
},
|
|
3943
|
+
"connecting-hub": {
|
|
3944
|
+
HUB_CONNECTED: "ready",
|
|
3945
|
+
HUB_FAILED: "ready"
|
|
3946
|
+
// Continue anyway — hub is optional
|
|
3947
|
+
},
|
|
3948
|
+
ready: {
|
|
3949
|
+
CREATE_FIRST_PAGE: "complete"
|
|
3950
|
+
}
|
|
3951
|
+
// 'unsupported-browser' and 'complete' have no transitions (terminal)
|
|
3952
|
+
};
|
|
3953
|
+
function onboardingReducer(current, event) {
|
|
3954
|
+
const transitions = TRANSITIONS[current.state];
|
|
3955
|
+
const nextState = transitions?.[event.type];
|
|
3956
|
+
if (!nextState) {
|
|
3957
|
+
return current;
|
|
3958
|
+
}
|
|
3959
|
+
const nextContext = { ...current.context };
|
|
3960
|
+
switch (event.type) {
|
|
3961
|
+
case "PASSKEY_SUCCESS":
|
|
3962
|
+
nextContext.identity = event.identity;
|
|
3963
|
+
nextContext.keyBundle = event.keyBundle;
|
|
3964
|
+
nextContext.error = null;
|
|
3965
|
+
break;
|
|
3966
|
+
case "PASSKEY_FAILED":
|
|
3967
|
+
nextContext.error = event.error;
|
|
3968
|
+
break;
|
|
3969
|
+
case "IDENTITY_IMPORTED":
|
|
3970
|
+
nextContext.identity = event.identity;
|
|
3971
|
+
nextContext.keyBundle = event.keyBundle;
|
|
3972
|
+
nextContext.error = null;
|
|
3973
|
+
break;
|
|
3974
|
+
case "HUB_FAILED":
|
|
3975
|
+
nextContext.error = event.error;
|
|
3976
|
+
break;
|
|
3977
|
+
case "HUB_CONNECTED":
|
|
3978
|
+
nextContext.error = null;
|
|
3979
|
+
break;
|
|
3980
|
+
case "RETRY_AUTH":
|
|
3981
|
+
case "AUTHENTICATE":
|
|
3982
|
+
case "CREATE_NEW":
|
|
3983
|
+
nextContext.error = null;
|
|
3984
|
+
break;
|
|
3985
|
+
}
|
|
3986
|
+
return { state: nextState, context: nextContext };
|
|
3987
|
+
}
|
|
3988
|
+
function createInitialState(hubUrl) {
|
|
3989
|
+
return {
|
|
3990
|
+
state: "welcome",
|
|
3991
|
+
context: {
|
|
3992
|
+
identity: null,
|
|
3993
|
+
keyBundle: null,
|
|
3994
|
+
hubUrl: hubUrl ?? null,
|
|
3995
|
+
error: null,
|
|
3996
|
+
isDemo: false
|
|
3997
|
+
}
|
|
3998
|
+
};
|
|
3999
|
+
}
|
|
4000
|
+
|
|
4001
|
+
// src/onboarding/OnboardingProvider.tsx
|
|
4002
|
+
import { detectPasskeySupport, createIdentityManager, isTestBypassEnabled } from "@xnetjs/identity";
|
|
4003
|
+
import {
|
|
4004
|
+
createContext,
|
|
4005
|
+
useContext as useContext8,
|
|
4006
|
+
useReducer,
|
|
4007
|
+
useCallback as useCallback20,
|
|
4008
|
+
useEffect as useEffect22,
|
|
4009
|
+
useRef as useRef19,
|
|
4010
|
+
useMemo as useMemo12
|
|
4011
|
+
} from "react";
|
|
4012
|
+
import { jsx as jsx8 } from "react/jsx-runtime";
|
|
4013
|
+
var OnboardingCtx = createContext(null);
|
|
4014
|
+
function OnboardingProvider({
|
|
4015
|
+
children,
|
|
4016
|
+
defaultHubUrl = "wss://hub.xnet.fyi",
|
|
4017
|
+
onComplete
|
|
4018
|
+
}) {
|
|
4019
|
+
const [{ state, context }, dispatch] = useReducer(
|
|
4020
|
+
onboardingReducer,
|
|
4021
|
+
createInitialState(defaultHubUrl)
|
|
4022
|
+
);
|
|
4023
|
+
const manager = useMemo12(() => createIdentityManager(), []);
|
|
4024
|
+
useEffect22(() => {
|
|
4025
|
+
let cancelled = false;
|
|
4026
|
+
manager.preflight().catch(() => {
|
|
4027
|
+
});
|
|
4028
|
+
detectPasskeySupport().then((support) => {
|
|
4029
|
+
if (isTestBypassEnabled()) {
|
|
4030
|
+
return;
|
|
4031
|
+
}
|
|
4032
|
+
if (!cancelled && (!support.webauthn || !support.platform)) {
|
|
4033
|
+
dispatch({ type: "BROWSER_UNSUPPORTED" });
|
|
4034
|
+
}
|
|
4035
|
+
}).catch(() => {
|
|
4036
|
+
});
|
|
4037
|
+
return () => {
|
|
4038
|
+
cancelled = true;
|
|
4039
|
+
};
|
|
4040
|
+
}, [manager]);
|
|
4041
|
+
useEffect22(() => {
|
|
4042
|
+
if (state === "complete" && context.identity && context.keyBundle && onComplete) {
|
|
4043
|
+
onComplete(context.identity, context.keyBundle);
|
|
4044
|
+
}
|
|
4045
|
+
}, [state, context.identity, context.keyBundle, onComplete]);
|
|
4046
|
+
const authInFlight = useRef19(false);
|
|
4047
|
+
const send = useCallback20(
|
|
4048
|
+
(event) => {
|
|
4049
|
+
if ((event.type === "AUTHENTICATE" || event.type === "CREATE_NEW") && state === "welcome" || event.type === "RETRY_AUTH" && state === "auth-error") {
|
|
4050
|
+
if (authInFlight.current) return;
|
|
4051
|
+
authInFlight.current = true;
|
|
4052
|
+
manager.create().then((keyBundle) => {
|
|
4053
|
+
dispatch({
|
|
4054
|
+
type: "PASSKEY_SUCCESS",
|
|
4055
|
+
identity: keyBundle.identity,
|
|
4056
|
+
keyBundle
|
|
4057
|
+
});
|
|
4058
|
+
}).catch((err) => {
|
|
4059
|
+
dispatch({
|
|
4060
|
+
type: "PASSKEY_FAILED",
|
|
4061
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
4062
|
+
});
|
|
4063
|
+
}).finally(() => {
|
|
4064
|
+
authInFlight.current = false;
|
|
4065
|
+
});
|
|
4066
|
+
}
|
|
4067
|
+
dispatch(event);
|
|
4068
|
+
},
|
|
4069
|
+
[state, manager]
|
|
4070
|
+
);
|
|
4071
|
+
return /* @__PURE__ */ jsx8(OnboardingCtx.Provider, { value: { state, context, send }, children });
|
|
4072
|
+
}
|
|
4073
|
+
function useOnboarding() {
|
|
4074
|
+
const ctx = useContext8(OnboardingCtx);
|
|
4075
|
+
if (!ctx) {
|
|
4076
|
+
throw new Error("useOnboarding must be used within <OnboardingProvider>");
|
|
4077
|
+
}
|
|
4078
|
+
return ctx;
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
// src/onboarding/helpers.ts
|
|
4082
|
+
function getPlatformAuthName() {
|
|
4083
|
+
if (typeof navigator === "undefined") return "Biometric authentication";
|
|
4084
|
+
const ua = navigator.userAgent.toLowerCase();
|
|
4085
|
+
if (ua.includes("mac") || ua.includes("iphone") || ua.includes("ipad")) {
|
|
4086
|
+
return "Touch ID";
|
|
4087
|
+
}
|
|
4088
|
+
if (ua.includes("windows")) {
|
|
4089
|
+
return "Windows Hello";
|
|
4090
|
+
}
|
|
4091
|
+
if (ua.includes("android")) {
|
|
4092
|
+
return "Fingerprint";
|
|
4093
|
+
}
|
|
4094
|
+
return "Biometric authentication";
|
|
4095
|
+
}
|
|
4096
|
+
function truncateDid(did, headLen = 16, tailLen = 4) {
|
|
4097
|
+
if (did.length <= headLen + tailLen + 3) return did;
|
|
4098
|
+
return `${did.slice(0, headLen)}...${did.slice(-tailLen)}`;
|
|
4099
|
+
}
|
|
4100
|
+
async function copyToClipboard(text) {
|
|
4101
|
+
if (typeof navigator === "undefined" || !navigator.clipboard) {
|
|
4102
|
+
return false;
|
|
4103
|
+
}
|
|
4104
|
+
try {
|
|
4105
|
+
await navigator.clipboard.writeText(text);
|
|
4106
|
+
return true;
|
|
4107
|
+
} catch {
|
|
4108
|
+
return false;
|
|
4109
|
+
}
|
|
4110
|
+
}
|
|
4111
|
+
|
|
4112
|
+
// src/onboarding/screens/AuthenticatingScreen.tsx
|
|
4113
|
+
import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
|
|
4114
|
+
function AuthenticatingScreen() {
|
|
4115
|
+
return /* @__PURE__ */ jsxs7("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4116
|
+
/* @__PURE__ */ jsx9("div", { className: "w-12 h-12 mb-6 border-4 border-primary border-t-transparent rounded-full animate-spin" }),
|
|
4117
|
+
/* @__PURE__ */ jsxs7("h1", { className: "text-2xl font-semibold mb-2", children: [
|
|
4118
|
+
"Waiting for ",
|
|
4119
|
+
getPlatformAuthName()
|
|
4120
|
+
] }),
|
|
4121
|
+
/* @__PURE__ */ jsx9("p", { className: "text-muted-foreground text-center", children: "Complete the biometric prompt to continue..." })
|
|
4122
|
+
] });
|
|
4123
|
+
}
|
|
4124
|
+
|
|
4125
|
+
// src/onboarding/screens/AuthErrorScreen.tsx
|
|
4126
|
+
import { jsx as jsx10, jsxs as jsxs8 } from "react/jsx-runtime";
|
|
4127
|
+
function AuthErrorScreen() {
|
|
4128
|
+
const { send, context } = useOnboarding();
|
|
4129
|
+
return /* @__PURE__ */ jsxs8("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4130
|
+
/* @__PURE__ */ jsx10("div", { className: "text-5xl mb-4", children: "!" }),
|
|
4131
|
+
/* @__PURE__ */ jsx10("h1", { className: "text-2xl font-semibold mb-2", children: "Authentication failed" }),
|
|
4132
|
+
/* @__PURE__ */ jsxs8("p", { className: "text-muted-foreground mb-2", children: [
|
|
4133
|
+
"Could not set up ",
|
|
4134
|
+
getPlatformAuthName(),
|
|
4135
|
+
"."
|
|
4136
|
+
] }),
|
|
4137
|
+
context.error && /* @__PURE__ */ jsx10("p", { className: "text-destructive text-sm mb-4 max-w-md text-center", children: context.error.message }),
|
|
4138
|
+
/* @__PURE__ */ jsx10(
|
|
4139
|
+
"button",
|
|
4140
|
+
{
|
|
4141
|
+
className: "px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors mb-3",
|
|
4142
|
+
onClick: () => send({ type: "RETRY_AUTH" }),
|
|
4143
|
+
children: "Try again"
|
|
4144
|
+
}
|
|
4145
|
+
),
|
|
4146
|
+
/* @__PURE__ */ jsx10(
|
|
4147
|
+
"button",
|
|
4148
|
+
{
|
|
4149
|
+
className: "text-sm text-muted-foreground hover:text-foreground transition-colors mb-6",
|
|
4150
|
+
onClick: () => send({ type: "BACK_TO_WELCOME" }),
|
|
4151
|
+
children: "Back to welcome"
|
|
4152
|
+
}
|
|
4153
|
+
),
|
|
4154
|
+
/* @__PURE__ */ jsx10("p", { className: "text-xs text-muted-foreground max-w-sm text-center", children: "Make sure your browser supports passkeys (Chrome 116+, Safari 18+, Edge 116+)." })
|
|
4155
|
+
] });
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
// src/onboarding/screens/HubConnectScreen.tsx
|
|
4159
|
+
import { useEffect as useEffect23 } from "react";
|
|
4160
|
+
import { jsx as jsx11, jsxs as jsxs9 } from "react/jsx-runtime";
|
|
4161
|
+
function HubConnectScreen({ connectToHub }) {
|
|
4162
|
+
const { send, context } = useOnboarding();
|
|
4163
|
+
useEffect23(() => {
|
|
4164
|
+
let cancelled = false;
|
|
4165
|
+
if (connectToHub) {
|
|
4166
|
+
connectToHub().then(() => {
|
|
4167
|
+
if (!cancelled) send({ type: "HUB_CONNECTED" });
|
|
4168
|
+
}).catch((err) => {
|
|
4169
|
+
if (!cancelled) {
|
|
4170
|
+
send({
|
|
4171
|
+
type: "HUB_FAILED",
|
|
4172
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
4173
|
+
});
|
|
4174
|
+
}
|
|
4175
|
+
});
|
|
4176
|
+
} else {
|
|
4177
|
+
send({ type: "HUB_CONNECTED" });
|
|
4178
|
+
}
|
|
4179
|
+
return () => {
|
|
4180
|
+
cancelled = true;
|
|
4181
|
+
};
|
|
4182
|
+
}, []);
|
|
4183
|
+
return /* @__PURE__ */ jsxs9("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4184
|
+
/* @__PURE__ */ jsx11("div", { className: "w-12 h-12 mb-6 border-4 border-primary border-t-transparent rounded-full animate-spin" }),
|
|
4185
|
+
/* @__PURE__ */ jsx11("h1", { className: "text-2xl font-semibold mb-2", children: "Connecting to sync server" }),
|
|
4186
|
+
/* @__PURE__ */ jsx11("p", { className: "text-muted-foreground mb-4", children: "Setting up secure connection..." }),
|
|
4187
|
+
context.hubUrl && /* @__PURE__ */ jsx11("p", { className: "text-xs text-muted-foreground font-mono", children: context.hubUrl })
|
|
4188
|
+
] });
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
// src/onboarding/screens/ImportIdentityScreen.tsx
|
|
4192
|
+
import { jsx as jsx12, jsxs as jsxs10 } from "react/jsx-runtime";
|
|
4193
|
+
function ImportIdentityScreen() {
|
|
4194
|
+
const { send } = useOnboarding();
|
|
4195
|
+
return /* @__PURE__ */ jsxs10("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4196
|
+
/* @__PURE__ */ jsx12("h1", { className: "text-2xl font-semibold mb-2", children: "Import your identity" }),
|
|
4197
|
+
/* @__PURE__ */ jsx12("p", { className: "text-muted-foreground text-center mb-8 max-w-md", children: "Bring your existing xNet identity to this device." }),
|
|
4198
|
+
/* @__PURE__ */ jsx12(
|
|
4199
|
+
"button",
|
|
4200
|
+
{
|
|
4201
|
+
className: "px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors mb-3 w-64",
|
|
4202
|
+
onClick: () => send({ type: "SCAN_QR" }),
|
|
4203
|
+
children: "Scan from another device"
|
|
4204
|
+
}
|
|
4205
|
+
),
|
|
4206
|
+
/* @__PURE__ */ jsx12(
|
|
4207
|
+
"button",
|
|
4208
|
+
{
|
|
4209
|
+
className: "text-sm text-muted-foreground hover:text-foreground transition-colors mb-6",
|
|
4210
|
+
onClick: () => send({ type: "ENTER_PHRASE" }),
|
|
4211
|
+
children: "Enter recovery phrase"
|
|
4212
|
+
}
|
|
4213
|
+
),
|
|
4214
|
+
/* @__PURE__ */ jsx12(
|
|
4215
|
+
"button",
|
|
4216
|
+
{
|
|
4217
|
+
className: "text-sm text-muted-foreground hover:text-foreground transition-colors",
|
|
4218
|
+
onClick: () => send({ type: "BACK_TO_WELCOME" }),
|
|
4219
|
+
children: "Back"
|
|
4220
|
+
}
|
|
4221
|
+
)
|
|
4222
|
+
] });
|
|
4223
|
+
}
|
|
4224
|
+
|
|
4225
|
+
// src/onboarding/screens/ReadyScreen.tsx
|
|
4226
|
+
import { useState as useState28, useEffect as useEffect24, useRef as useRef20 } from "react";
|
|
4227
|
+
import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
|
|
4228
|
+
function ReadyScreen() {
|
|
4229
|
+
const { send, context } = useOnboarding();
|
|
4230
|
+
const [copied, setCopied] = useState28(false);
|
|
4231
|
+
const timerRef = useRef20(null);
|
|
4232
|
+
useEffect24(() => {
|
|
4233
|
+
return () => {
|
|
4234
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
4235
|
+
};
|
|
4236
|
+
}, []);
|
|
4237
|
+
const handleCopy = async () => {
|
|
4238
|
+
if (context.identity?.did) {
|
|
4239
|
+
const ok = await copyToClipboard(context.identity.did);
|
|
4240
|
+
if (ok) {
|
|
4241
|
+
setCopied(true);
|
|
4242
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
4243
|
+
timerRef.current = setTimeout(() => setCopied(false), 2e3);
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
};
|
|
4247
|
+
return /* @__PURE__ */ jsxs11("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4248
|
+
/* @__PURE__ */ jsx13("div", { className: "text-5xl mb-4", children: "\u2713" }),
|
|
4249
|
+
/* @__PURE__ */ jsx13("h1", { className: "text-2xl font-semibold mb-6", children: "You're all set!" }),
|
|
4250
|
+
context.identity && /* @__PURE__ */ jsxs11("div", { className: "flex flex-col items-center mb-4", children: [
|
|
4251
|
+
/* @__PURE__ */ jsx13("label", { className: "text-xs text-muted-foreground mb-1", children: "Your identity" }),
|
|
4252
|
+
/* @__PURE__ */ jsxs11("div", { className: "flex items-center gap-2", children: [
|
|
4253
|
+
/* @__PURE__ */ jsx13("code", { className: "text-sm font-mono bg-muted px-3 py-1 rounded", children: truncateDid(context.identity.did) }),
|
|
4254
|
+
/* @__PURE__ */ jsx13(
|
|
4255
|
+
"button",
|
|
4256
|
+
{
|
|
4257
|
+
className: "text-xs text-primary hover:text-primary/80 transition-colors",
|
|
4258
|
+
onClick: handleCopy,
|
|
4259
|
+
title: "Copy full DID",
|
|
4260
|
+
children: copied ? "Copied!" : "Copy"
|
|
4261
|
+
}
|
|
4262
|
+
)
|
|
4263
|
+
] })
|
|
4264
|
+
] }),
|
|
4265
|
+
context.hubUrl && /* @__PURE__ */ jsxs11("div", { className: "flex flex-col items-center mb-4", children: [
|
|
4266
|
+
/* @__PURE__ */ jsx13("label", { className: "text-xs text-muted-foreground mb-1", children: "Connected to" }),
|
|
4267
|
+
/* @__PURE__ */ jsx13("span", { className: "text-sm font-mono", children: context.hubUrl })
|
|
4268
|
+
] }),
|
|
4269
|
+
context.isDemo && /* @__PURE__ */ jsx13("div", { className: "px-4 py-2 bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100 rounded-lg text-sm mb-6", children: "Demo mode \u2014 data is saved locally; encrypted backups expire after 24h of inactivity." }),
|
|
4270
|
+
/* @__PURE__ */ jsx13(
|
|
4271
|
+
"button",
|
|
4272
|
+
{
|
|
4273
|
+
className: "px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors",
|
|
4274
|
+
onClick: () => send({ type: "CREATE_FIRST_PAGE" }),
|
|
4275
|
+
children: "Create your first page"
|
|
4276
|
+
}
|
|
4277
|
+
)
|
|
4278
|
+
] });
|
|
4279
|
+
}
|
|
4280
|
+
|
|
4281
|
+
// src/onboarding/screens/UnsupportedBrowserScreen.tsx
|
|
4282
|
+
import { jsx as jsx14, jsxs as jsxs12 } from "react/jsx-runtime";
|
|
4283
|
+
function UnsupportedBrowserScreen() {
|
|
4284
|
+
return /* @__PURE__ */ jsxs12("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4285
|
+
/* @__PURE__ */ jsx14("div", { className: "text-5xl mb-4", children: ":(" }),
|
|
4286
|
+
/* @__PURE__ */ jsx14("h1", { className: "text-2xl font-semibold mb-2", children: "Browser not supported" }),
|
|
4287
|
+
/* @__PURE__ */ jsxs12("p", { className: "text-muted-foreground text-center mb-6 max-w-md", children: [
|
|
4288
|
+
"xNet requires ",
|
|
4289
|
+
getPlatformAuthName(),
|
|
4290
|
+
" which isn't available in this browser."
|
|
4291
|
+
] }),
|
|
4292
|
+
/* @__PURE__ */ jsx14(
|
|
4293
|
+
"a",
|
|
4294
|
+
{
|
|
4295
|
+
href: "/download",
|
|
4296
|
+
className: "px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 transition-colors mb-6 no-underline",
|
|
4297
|
+
children: "Download Desktop App"
|
|
4298
|
+
}
|
|
4299
|
+
),
|
|
4300
|
+
/* @__PURE__ */ jsx14("p", { className: "text-xs text-muted-foreground", children: "Supported browsers: Chrome 116+, Safari 18+, Edge 116+" })
|
|
4301
|
+
] });
|
|
4302
|
+
}
|
|
4303
|
+
|
|
4304
|
+
// src/onboarding/screens/WelcomeScreen.tsx
|
|
4305
|
+
import { jsx as jsx15, jsxs as jsxs13 } from "react/jsx-runtime";
|
|
4306
|
+
function WelcomeScreen() {
|
|
4307
|
+
const { send } = useOnboarding();
|
|
4308
|
+
return /* @__PURE__ */ jsxs13("div", { className: "flex flex-col items-center justify-center min-h-screen bg-gradient-to-b from-background to-muted/30 text-foreground p-6", children: [
|
|
4309
|
+
/* @__PURE__ */ jsx15("div", { className: "w-16 h-16 mb-8 rounded-2xl bg-primary/10 flex items-center justify-center", children: /* @__PURE__ */ jsxs13(
|
|
4310
|
+
"svg",
|
|
4311
|
+
{
|
|
4312
|
+
className: "w-8 h-8 text-primary",
|
|
4313
|
+
viewBox: "0 0 24 24",
|
|
4314
|
+
fill: "none",
|
|
4315
|
+
stroke: "currentColor",
|
|
4316
|
+
strokeWidth: "2",
|
|
4317
|
+
strokeLinecap: "round",
|
|
4318
|
+
strokeLinejoin: "round",
|
|
4319
|
+
children: [
|
|
4320
|
+
/* @__PURE__ */ jsx15("path", { d: "M12 2L2 7l10 5 10-5-10-5z" }),
|
|
4321
|
+
/* @__PURE__ */ jsx15("path", { d: "M2 17l10 5 10-5" }),
|
|
4322
|
+
/* @__PURE__ */ jsx15("path", { d: "M2 12l10 5 10-5" })
|
|
4323
|
+
]
|
|
4324
|
+
}
|
|
4325
|
+
) }),
|
|
4326
|
+
/* @__PURE__ */ jsx15("h1", { className: "text-4xl font-bold mb-3 tracking-tight", children: "Welcome to xNet" }),
|
|
4327
|
+
/* @__PURE__ */ jsx15("p", { className: "text-muted-foreground text-center mb-10 max-w-sm text-lg leading-relaxed", children: "Your private workspace that syncs everywhere and belongs to you." }),
|
|
4328
|
+
/* @__PURE__ */ jsx15(
|
|
4329
|
+
"button",
|
|
4330
|
+
{
|
|
4331
|
+
className: "group px-8 py-4 bg-primary text-primary-foreground rounded-xl font-semibold text-lg shadow-lg shadow-primary/25 hover:shadow-xl hover:shadow-primary/30 hover:scale-[1.02] active:scale-[0.98] transition-all duration-200 mb-4",
|
|
4332
|
+
onClick: () => send({ type: "AUTHENTICATE" }),
|
|
4333
|
+
children: /* @__PURE__ */ jsxs13("span", { className: "flex items-center gap-2", children: [
|
|
4334
|
+
getPlatformAuthName() === "Touch ID" && /* @__PURE__ */ jsx15("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx15("path", { d: "M17.81 4.47c-.08 0-.16-.02-.23-.06C15.66 3.42 14 3 12.01 3c-1.98 0-3.86.47-5.57 1.41-.24.13-.54.04-.68-.2-.13-.24-.04-.55.2-.68C7.82 2.52 9.86 2 12.01 2c2.13 0 3.99.47 6.03 1.52.25.13.34.43.21.67-.09.18-.26.28-.44.28zM3.5 9.72c-.1 0-.2-.03-.29-.09-.23-.16-.28-.47-.12-.7.99-1.4 2.25-2.5 3.75-3.27C9.98 4.04 14 4.03 17.15 5.65c1.5.77 2.76 1.86 3.75 3.25.16.22.11.54-.12.7-.23.16-.54.11-.7-.12-.9-1.26-2.04-2.25-3.39-2.94-2.87-1.47-6.54-1.47-9.4.01-1.36.7-2.5 1.7-3.4 2.96-.08.14-.23.21-.39.21zm6.25 12.07c-.13 0-.26-.05-.35-.15-.87-.87-1.34-1.43-2.01-2.64-.69-1.23-1.05-2.73-1.05-4.34 0-2.97 2.54-5.39 5.66-5.39s5.66 2.42 5.66 5.39c0 .28-.22.5-.5.5s-.5-.22-.5-.5c0-2.42-2.09-4.39-4.66-4.39-2.57 0-4.66 1.97-4.66 4.39 0 1.44.32 2.77.93 3.85.64 1.15 1.08 1.64 1.85 2.42.19.2.19.51 0 .71-.11.1-.24.15-.37.15zm7.17-1.85c-1.19 0-2.24-.3-3.1-.89-1.49-1.01-2.38-2.65-2.38-4.39 0-.28.22-.5.5-.5s.5.22.5.5c0 1.41.72 2.74 1.94 3.56.71.48 1.54.71 2.54.71.24 0 .64-.03 1.04-.1.27-.05.53.13.58.41.05.27-.13.53-.41.58-.57.11-1.07.12-1.21.12zM14.91 22c-.04 0-.09-.01-.13-.02-1.59-.44-2.63-1.03-3.72-2.1-1.4-1.39-2.17-3.24-2.17-5.22 0-1.62 1.38-2.94 3.08-2.94 1.7 0 3.08 1.32 3.08 2.94 0 1.07.93 1.94 2.08 1.94s2.08-.87 2.08-1.94c0-3.77-3.25-6.83-7.25-6.83-2.84 0-5.44 1.58-6.61 4.03-.39.81-.59 1.76-.59 2.8 0 .78.07 2.01.67 3.61.1.26-.03.55-.29.64-.26.1-.55-.04-.64-.29-.49-1.31-.73-2.61-.73-3.96 0-1.2.23-2.29.68-3.24 1.33-2.79 4.28-4.6 7.51-4.6 4.55 0 8.25 3.51 8.25 7.83 0 1.62-1.38 2.94-3.08 2.94s-3.08-1.32-3.08-2.94c0-1.07-.93-1.94-2.08-1.94s-2.08.87-2.08 1.94c0 1.71.66 3.31 1.87 4.51.95.94 1.86 1.46 3.27 1.85.27.07.42.35.35.61-.05.23-.26.38-.48.38z" }) }),
|
|
4335
|
+
getPlatformAuthName() === "Face ID" && /* @__PURE__ */ jsx15("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx15("path", { d: "M9 11.75c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25 1.25-.56 1.25-1.25-.56-1.25-1.25-1.25zm6 0c-.69 0-1.25.56-1.25 1.25s.56 1.25 1.25 1.25 1.25-.56 1.25-1.25-.56-1.25-1.25-1.25zM12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8 0-.29.02-.58.05-.86 2.36-1.05 4.23-2.98 5.21-5.37C11.07 8.33 14.05 10 17.42 10c.78 0 1.53-.09 2.25-.26.21.71.33 1.47.33 2.26 0 4.41-3.59 8-8 8z" }) }),
|
|
4336
|
+
getPlatformAuthName() === "Windows Hello" && /* @__PURE__ */ jsx15("svg", { className: "w-5 h-5", viewBox: "0 0 24 24", fill: "currentColor", children: /* @__PURE__ */ jsx15("path", { d: "M3 5v14h18V5H3zm16 12H5V7h14v10z" }) }),
|
|
4337
|
+
!["Touch ID", "Face ID", "Windows Hello"].includes(getPlatformAuthName()) && /* @__PURE__ */ jsxs13(
|
|
4338
|
+
"svg",
|
|
4339
|
+
{
|
|
4340
|
+
className: "w-5 h-5",
|
|
4341
|
+
viewBox: "0 0 24 24",
|
|
4342
|
+
fill: "none",
|
|
4343
|
+
stroke: "currentColor",
|
|
4344
|
+
strokeWidth: "2",
|
|
4345
|
+
children: [
|
|
4346
|
+
/* @__PURE__ */ jsx15("rect", { x: "3", y: "11", width: "18", height: "11", rx: "2", ry: "2" }),
|
|
4347
|
+
/* @__PURE__ */ jsx15("path", { d: "M7 11V7a5 5 0 0 1 10 0v4" })
|
|
4348
|
+
]
|
|
4349
|
+
}
|
|
4350
|
+
),
|
|
4351
|
+
"Get started with ",
|
|
4352
|
+
getPlatformAuthName()
|
|
4353
|
+
] })
|
|
4354
|
+
}
|
|
4355
|
+
),
|
|
4356
|
+
/* @__PURE__ */ jsx15("p", { className: "text-xs text-muted-foreground/70 text-center max-w-xs mt-2 mb-8", children: "Creates a secure passkey on your device. No passwords needed." }),
|
|
4357
|
+
/* @__PURE__ */ jsx15(
|
|
4358
|
+
"button",
|
|
4359
|
+
{
|
|
4360
|
+
className: "text-sm text-muted-foreground hover:text-foreground transition-colors underline-offset-4 hover:underline",
|
|
4361
|
+
onClick: () => send({ type: "IMPORT_EXISTING" }),
|
|
4362
|
+
children: "I already have an identity"
|
|
4363
|
+
}
|
|
4364
|
+
)
|
|
4365
|
+
] });
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
// src/onboarding/OnboardingFlow.tsx
|
|
4369
|
+
import { Fragment, jsx as jsx16, jsxs as jsxs14 } from "react/jsx-runtime";
|
|
4370
|
+
function OnboardingFlow({ connectToHub, children }) {
|
|
4371
|
+
const { state } = useOnboarding();
|
|
4372
|
+
switch (state) {
|
|
4373
|
+
case "welcome":
|
|
4374
|
+
return /* @__PURE__ */ jsx16(WelcomeScreen, {});
|
|
4375
|
+
case "authenticating":
|
|
4376
|
+
return /* @__PURE__ */ jsx16(AuthenticatingScreen, {});
|
|
4377
|
+
case "auth-error":
|
|
4378
|
+
return /* @__PURE__ */ jsx16(AuthErrorScreen, {});
|
|
4379
|
+
case "unsupported-browser":
|
|
4380
|
+
return /* @__PURE__ */ jsx16(UnsupportedBrowserScreen, {});
|
|
4381
|
+
case "import-identity":
|
|
4382
|
+
return /* @__PURE__ */ jsx16(ImportIdentityScreen, {});
|
|
4383
|
+
case "qr-scan":
|
|
4384
|
+
return /* @__PURE__ */ jsxs14("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4385
|
+
/* @__PURE__ */ jsx16("h1", { className: "text-2xl font-semibold mb-2", children: "QR Scan" }),
|
|
4386
|
+
/* @__PURE__ */ jsx16("p", { className: "text-muted-foreground", children: "Coming soon \u2014 scan a QR code from another device." })
|
|
4387
|
+
] });
|
|
4388
|
+
case "recovery-phrase":
|
|
4389
|
+
return /* @__PURE__ */ jsxs14("div", { className: "flex flex-col items-center justify-center min-h-screen bg-background text-foreground p-6", children: [
|
|
4390
|
+
/* @__PURE__ */ jsx16("h1", { className: "text-2xl font-semibold mb-2", children: "Recovery Phrase" }),
|
|
4391
|
+
/* @__PURE__ */ jsx16("p", { className: "text-muted-foreground", children: "Coming soon \u2014 enter your recovery phrase." })
|
|
4392
|
+
] });
|
|
4393
|
+
case "connecting-hub":
|
|
4394
|
+
return /* @__PURE__ */ jsx16(HubConnectScreen, { connectToHub });
|
|
4395
|
+
case "ready":
|
|
4396
|
+
return /* @__PURE__ */ jsx16(ReadyScreen, {});
|
|
4397
|
+
case "complete":
|
|
4398
|
+
return /* @__PURE__ */ jsx16(Fragment, { children });
|
|
4399
|
+
default:
|
|
4400
|
+
return /* @__PURE__ */ jsx16(WelcomeScreen, {});
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
|
|
4404
|
+
// src/onboarding/screens/SmartWelcome.tsx
|
|
4405
|
+
import { discoverExistingPasskey } from "@xnetjs/identity";
|
|
4406
|
+
import { useEffect as useEffect25, useState as useState29 } from "react";
|
|
4407
|
+
import { jsx as jsx17, jsxs as jsxs15 } from "react/jsx-runtime";
|
|
4408
|
+
function SmartWelcome() {
|
|
4409
|
+
const { send } = useOnboarding();
|
|
4410
|
+
const [checking, setChecking] = useState29(true);
|
|
4411
|
+
const [hasExisting, setHasExisting] = useState29(false);
|
|
4412
|
+
useEffect25(() => {
|
|
4413
|
+
let cancelled = false;
|
|
4414
|
+
discoverExistingPasskey().then((passkey) => {
|
|
4415
|
+
if (!cancelled) {
|
|
4416
|
+
setHasExisting(passkey !== null);
|
|
4417
|
+
setChecking(false);
|
|
4418
|
+
}
|
|
4419
|
+
}).catch(() => {
|
|
4420
|
+
if (!cancelled) {
|
|
4421
|
+
setChecking(false);
|
|
4422
|
+
}
|
|
4423
|
+
});
|
|
4424
|
+
return () => {
|
|
4425
|
+
cancelled = true;
|
|
4426
|
+
};
|
|
4427
|
+
}, []);
|
|
4428
|
+
if (checking) {
|
|
4429
|
+
return /* @__PURE__ */ jsx17("div", { className: "onboarding-screen", children: /* @__PURE__ */ jsx17("p", { className: "spinner-text", children: "Checking for existing identity..." }) });
|
|
4430
|
+
}
|
|
4431
|
+
if (hasExisting) {
|
|
4432
|
+
const authName = getPlatformAuthName();
|
|
4433
|
+
return /* @__PURE__ */ jsxs15("div", { className: "onboarding-screen welcome-back", children: [
|
|
4434
|
+
/* @__PURE__ */ jsx17("h1", { children: "Welcome back!" }),
|
|
4435
|
+
/* @__PURE__ */ jsxs15("p", { className: "subtitle", children: [
|
|
4436
|
+
"We found your xNet identity. Use ",
|
|
4437
|
+
authName,
|
|
4438
|
+
" to sign in."
|
|
4439
|
+
] }),
|
|
4440
|
+
/* @__PURE__ */ jsxs15("button", { className: "primary-button", onClick: () => send({ type: "AUTHENTICATE" }), children: [
|
|
4441
|
+
"Sign in with ",
|
|
4442
|
+
authName
|
|
4443
|
+
] }),
|
|
4444
|
+
/* @__PURE__ */ jsx17("button", { className: "text-button", onClick: () => send({ type: "CREATE_NEW" }), children: "Create a new identity instead" })
|
|
4445
|
+
] });
|
|
4446
|
+
}
|
|
4447
|
+
return /* @__PURE__ */ jsx17(WelcomeScreen, {});
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
// src/onboarding/screens/SyncProgressOverlay.tsx
|
|
4451
|
+
import { useEffect as useEffect26 } from "react";
|
|
4452
|
+
import { Fragment as Fragment2, jsx as jsx18, jsxs as jsxs16 } from "react/jsx-runtime";
|
|
4453
|
+
function formatBytes2(bytes) {
|
|
4454
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
4455
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
4456
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
4457
|
+
}
|
|
4458
|
+
function SyncProgressOverlay({
|
|
4459
|
+
progress,
|
|
4460
|
+
onComplete
|
|
4461
|
+
}) {
|
|
4462
|
+
useEffect26(() => {
|
|
4463
|
+
if (progress.phase === "complete") {
|
|
4464
|
+
const timer = setTimeout(onComplete, 1500);
|
|
4465
|
+
return () => clearTimeout(timer);
|
|
4466
|
+
}
|
|
4467
|
+
}, [progress.phase, onComplete]);
|
|
4468
|
+
return /* @__PURE__ */ jsx18("div", { className: "sync-progress-overlay", children: /* @__PURE__ */ jsxs16("div", { className: "sync-card", children: [
|
|
4469
|
+
progress.phase === "connecting" && /* @__PURE__ */ jsxs16(Fragment2, { children: [
|
|
4470
|
+
/* @__PURE__ */ jsx18("h2", { children: "Connecting to server..." }),
|
|
4471
|
+
/* @__PURE__ */ jsx18("p", { className: "spinner-text", children: "Please wait" })
|
|
4472
|
+
] }),
|
|
4473
|
+
progress.phase === "syncing" && /* @__PURE__ */ jsxs16(Fragment2, { children: [
|
|
4474
|
+
/* @__PURE__ */ jsx18("h2", { children: "Syncing your data" }),
|
|
4475
|
+
/* @__PURE__ */ jsx18("div", { className: "progress-bar", children: /* @__PURE__ */ jsx18(
|
|
4476
|
+
"div",
|
|
4477
|
+
{
|
|
4478
|
+
className: "progress-fill",
|
|
4479
|
+
style: {
|
|
4480
|
+
width: `${progress.roomsSynced / Math.max(progress.roomsTotal, 1) * 100}%`
|
|
4481
|
+
}
|
|
4482
|
+
}
|
|
4483
|
+
) }),
|
|
4484
|
+
/* @__PURE__ */ jsxs16("p", { className: "progress-text", children: [
|
|
4485
|
+
progress.roomsSynced,
|
|
4486
|
+
" of ",
|
|
4487
|
+
progress.roomsTotal,
|
|
4488
|
+
" items"
|
|
4489
|
+
] }),
|
|
4490
|
+
/* @__PURE__ */ jsxs16("p", { className: "bytes-text", children: [
|
|
4491
|
+
formatBytes2(progress.bytesReceived),
|
|
4492
|
+
" received"
|
|
4493
|
+
] })
|
|
4494
|
+
] }),
|
|
4495
|
+
progress.phase === "complete" && /* @__PURE__ */ jsxs16(Fragment2, { children: [
|
|
4496
|
+
/* @__PURE__ */ jsx18("h2", { children: "All synced!" }),
|
|
4497
|
+
/* @__PURE__ */ jsxs16("p", { children: [
|
|
4498
|
+
progress.roomsTotal,
|
|
4499
|
+
" items synchronized"
|
|
4500
|
+
] })
|
|
4501
|
+
] }),
|
|
4502
|
+
progress.phase === "error" && /* @__PURE__ */ jsxs16(Fragment2, { children: [
|
|
4503
|
+
/* @__PURE__ */ jsx18("h2", { children: "Sync issue" }),
|
|
4504
|
+
/* @__PURE__ */ jsx18("p", { children: "Some data may not be up to date." }),
|
|
4505
|
+
progress.error && /* @__PURE__ */ jsx18("p", { className: "error-detail", children: progress.error.message }),
|
|
4506
|
+
/* @__PURE__ */ jsx18("button", { className: "primary-button", onClick: onComplete, children: "Continue anyway" })
|
|
4507
|
+
] })
|
|
4508
|
+
] }) });
|
|
4509
|
+
}
|
|
4510
|
+
|
|
4511
|
+
// src/onboarding/templates.ts
|
|
4512
|
+
var QUICK_START_TEMPLATES = [
|
|
4513
|
+
{
|
|
4514
|
+
id: "blank-page",
|
|
4515
|
+
name: "Blank Page",
|
|
4516
|
+
description: "Start from scratch",
|
|
4517
|
+
icon: "file"
|
|
4518
|
+
},
|
|
4519
|
+
{
|
|
4520
|
+
id: "meeting-notes",
|
|
4521
|
+
name: "Meeting Notes",
|
|
4522
|
+
description: "Template for taking meeting notes",
|
|
4523
|
+
icon: "users"
|
|
4524
|
+
},
|
|
4525
|
+
{
|
|
4526
|
+
id: "project-tracker",
|
|
4527
|
+
name: "Project Tracker",
|
|
4528
|
+
description: "Database for tracking tasks",
|
|
4529
|
+
icon: "kanban"
|
|
4530
|
+
},
|
|
4531
|
+
{
|
|
4532
|
+
id: "canvas",
|
|
4533
|
+
name: "Whiteboard",
|
|
4534
|
+
description: "Infinite canvas for visual thinking",
|
|
4535
|
+
icon: "pen-tool"
|
|
4536
|
+
}
|
|
4537
|
+
];
|
|
4538
|
+
|
|
4539
|
+
// src/sync/InitialSyncManager.ts
|
|
4540
|
+
function createInitialSyncManager() {
|
|
4541
|
+
let progress = {
|
|
4542
|
+
phase: "connecting",
|
|
4543
|
+
roomsTotal: 0,
|
|
4544
|
+
roomsSynced: 0,
|
|
4545
|
+
bytesReceived: 0
|
|
4546
|
+
};
|
|
4547
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
4548
|
+
let syncedRoomIds = /* @__PURE__ */ new Set();
|
|
4549
|
+
function notify() {
|
|
4550
|
+
const snapshot = { ...progress };
|
|
4551
|
+
for (const listener of listeners) {
|
|
4552
|
+
listener(snapshot);
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
return {
|
|
4556
|
+
onProgress(listener) {
|
|
4557
|
+
listeners.add(listener);
|
|
4558
|
+
listener({ ...progress });
|
|
4559
|
+
return () => {
|
|
4560
|
+
listeners.delete(listener);
|
|
4561
|
+
};
|
|
4562
|
+
},
|
|
4563
|
+
handleMessage(msg) {
|
|
4564
|
+
switch (msg.type) {
|
|
4565
|
+
case "initial-sync":
|
|
4566
|
+
progress.phase = "syncing";
|
|
4567
|
+
if (msg.update) {
|
|
4568
|
+
progress.bytesReceived += msg.update.byteLength;
|
|
4569
|
+
}
|
|
4570
|
+
if (msg.room) {
|
|
4571
|
+
syncedRoomIds.add(msg.room);
|
|
4572
|
+
progress.roomsSynced = syncedRoomIds.size;
|
|
4573
|
+
}
|
|
4574
|
+
if (msg.roomCount != null) {
|
|
4575
|
+
progress.roomsTotal = msg.roomCount;
|
|
4576
|
+
}
|
|
4577
|
+
notify();
|
|
4578
|
+
break;
|
|
4579
|
+
case "node-changes":
|
|
4580
|
+
if (msg.changes) {
|
|
4581
|
+
progress.bytesReceived += JSON.stringify(msg.changes).length;
|
|
4582
|
+
}
|
|
4583
|
+
notify();
|
|
4584
|
+
break;
|
|
4585
|
+
case "initial-sync-complete":
|
|
4586
|
+
progress.phase = "complete";
|
|
4587
|
+
progress.roomsTotal = msg.roomCount ?? syncedRoomIds.size;
|
|
4588
|
+
progress.roomsSynced = syncedRoomIds.size;
|
|
4589
|
+
notify();
|
|
4590
|
+
break;
|
|
4591
|
+
}
|
|
4592
|
+
},
|
|
4593
|
+
getProgress() {
|
|
4594
|
+
return { ...progress };
|
|
4595
|
+
},
|
|
4596
|
+
start() {
|
|
4597
|
+
syncedRoomIds = /* @__PURE__ */ new Set();
|
|
4598
|
+
progress = {
|
|
4599
|
+
phase: "connecting",
|
|
4600
|
+
roomsTotal: 0,
|
|
4601
|
+
roomsSynced: 0,
|
|
4602
|
+
bytesReceived: 0
|
|
4603
|
+
};
|
|
4604
|
+
notify();
|
|
4605
|
+
},
|
|
4606
|
+
setError(error) {
|
|
4607
|
+
progress.phase = "error";
|
|
4608
|
+
progress.error = error;
|
|
4609
|
+
notify();
|
|
4610
|
+
},
|
|
4611
|
+
reset() {
|
|
4612
|
+
syncedRoomIds = /* @__PURE__ */ new Set();
|
|
4613
|
+
progress = {
|
|
4614
|
+
phase: "connecting",
|
|
4615
|
+
roomsTotal: 0,
|
|
4616
|
+
roomsSynced: 0,
|
|
4617
|
+
bytesReceived: 0
|
|
4618
|
+
};
|
|
4619
|
+
listeners.clear();
|
|
4620
|
+
}
|
|
4621
|
+
};
|
|
4622
|
+
}
|
|
4623
|
+
|
|
4624
|
+
// src/hooks/useSecurity.ts
|
|
4625
|
+
import {
|
|
4626
|
+
hybridSign,
|
|
4627
|
+
hybridVerify
|
|
4628
|
+
} from "@xnetjs/crypto";
|
|
4629
|
+
import { parseDID } from "@xnetjs/identity";
|
|
4630
|
+
import { useCallback as useCallback21, useMemo as useMemo13 } from "react";
|
|
4631
|
+
function useSecurity(options = {}) {
|
|
4632
|
+
const context = useSecurityContext();
|
|
4633
|
+
const effectiveLevel = options.level ?? context.level;
|
|
4634
|
+
const hasPQKeys = useMemo13(
|
|
4635
|
+
() => context.keyBundle?.pqSigningKey !== void 0,
|
|
4636
|
+
[context.keyBundle]
|
|
4637
|
+
);
|
|
4638
|
+
const hasKeyBundle = useMemo13(() => context.keyBundle !== void 0, [context.keyBundle]);
|
|
4639
|
+
const maxLevel = useMemo13(() => hasPQKeys ? 2 : 0, [hasPQKeys]);
|
|
4640
|
+
const canSignAt = useCallback21(
|
|
4641
|
+
(level) => {
|
|
4642
|
+
if (!context.keyBundle) return false;
|
|
4643
|
+
if (level === 0) return true;
|
|
4644
|
+
return hasPQKeys;
|
|
4645
|
+
},
|
|
4646
|
+
[context.keyBundle, hasPQKeys]
|
|
4647
|
+
);
|
|
4648
|
+
const sign = useCallback21(
|
|
4649
|
+
(data) => {
|
|
4650
|
+
if (!context.keyBundle) {
|
|
4651
|
+
throw new Error(
|
|
4652
|
+
"No key bundle available. Ensure keyBundle is provided to SecurityProvider."
|
|
4653
|
+
);
|
|
4654
|
+
}
|
|
4655
|
+
if (!canSignAt(effectiveLevel)) {
|
|
4656
|
+
throw new Error(
|
|
4657
|
+
`Cannot sign at Level ${effectiveLevel}: PQ keys required. Current key bundle only supports Level 0.`
|
|
4658
|
+
);
|
|
4659
|
+
}
|
|
4660
|
+
return hybridSign(
|
|
4661
|
+
data,
|
|
4662
|
+
{
|
|
4663
|
+
ed25519: context.keyBundle.signingKey,
|
|
4664
|
+
mlDsa: context.keyBundle.pqSigningKey
|
|
4665
|
+
},
|
|
4666
|
+
effectiveLevel
|
|
4667
|
+
);
|
|
4668
|
+
},
|
|
4669
|
+
[context.keyBundle, effectiveLevel, canSignAt]
|
|
4670
|
+
);
|
|
4671
|
+
const verify = useCallback21(
|
|
4672
|
+
async (data, signature, did) => {
|
|
4673
|
+
const ed25519PublicKey = parseDID(did);
|
|
4674
|
+
const pqPublicKey = await context.registry.lookup(did);
|
|
4675
|
+
return hybridVerify(
|
|
4676
|
+
data,
|
|
4677
|
+
signature,
|
|
4678
|
+
{
|
|
4679
|
+
ed25519: ed25519PublicKey,
|
|
4680
|
+
mlDsa: pqPublicKey ?? void 0
|
|
4681
|
+
},
|
|
4682
|
+
{
|
|
4683
|
+
minLevel: context.minVerificationLevel,
|
|
4684
|
+
policy: context.verificationPolicy
|
|
4685
|
+
}
|
|
4686
|
+
);
|
|
4687
|
+
},
|
|
4688
|
+
[context.registry, context.minVerificationLevel, context.verificationPolicy]
|
|
4689
|
+
);
|
|
4690
|
+
return {
|
|
4691
|
+
level: effectiveLevel,
|
|
4692
|
+
hasPQKeys,
|
|
4693
|
+
maxLevel,
|
|
4694
|
+
sign,
|
|
4695
|
+
verify,
|
|
4696
|
+
setLevel: context.setLevel,
|
|
4697
|
+
canSignAt,
|
|
4698
|
+
hasKeyBundle
|
|
4699
|
+
};
|
|
4700
|
+
}
|
|
4701
|
+
export {
|
|
4702
|
+
AuthErrorScreen,
|
|
4703
|
+
AuthenticatingScreen,
|
|
4704
|
+
DemoBanner,
|
|
4705
|
+
DemoDataExpiredScreen,
|
|
4706
|
+
DemoQuotaIndicator,
|
|
4707
|
+
ErrorBoundary,
|
|
4708
|
+
HubConnectScreen,
|
|
4709
|
+
HubStatusIndicator,
|
|
4710
|
+
ImportIdentityScreen,
|
|
4711
|
+
InstrumentationContext,
|
|
4712
|
+
METABRIDGE_ORIGIN,
|
|
4713
|
+
METABRIDGE_SEED_ORIGIN,
|
|
4714
|
+
NodeStoreSyncProvider,
|
|
4715
|
+
OfflineIndicator,
|
|
4716
|
+
OnboardingFlow,
|
|
4717
|
+
OnboardingProvider,
|
|
4718
|
+
PluginRegistryContext,
|
|
4719
|
+
QUICK_START_TEMPLATES,
|
|
4720
|
+
ReadyScreen,
|
|
4721
|
+
SecurityProvider,
|
|
4722
|
+
Skeleton,
|
|
4723
|
+
SmartWelcome,
|
|
4724
|
+
SyncProgressOverlay,
|
|
4725
|
+
TelemetryContext,
|
|
4726
|
+
UnsupportedBrowserScreen,
|
|
4727
|
+
WebSocketSyncProvider,
|
|
4728
|
+
WelcomeScreen,
|
|
4729
|
+
XNetProvider,
|
|
4730
|
+
copyToClipboard,
|
|
4731
|
+
createConnectionManager,
|
|
4732
|
+
createInitialState,
|
|
4733
|
+
createInitialSyncManager,
|
|
4734
|
+
createMetaBridge,
|
|
4735
|
+
createNodePool,
|
|
4736
|
+
createOfflineQueue,
|
|
4737
|
+
createRegistry,
|
|
4738
|
+
createSyncManager,
|
|
4739
|
+
flattenNode,
|
|
4740
|
+
flattenNodes,
|
|
4741
|
+
flattenNodesWithSchemaCheck,
|
|
4742
|
+
flattenUnknownSchemaNode,
|
|
4743
|
+
getPlatformAuthName,
|
|
4744
|
+
injectSkeletonStyles,
|
|
4745
|
+
onboardingReducer,
|
|
4746
|
+
truncateDid,
|
|
4747
|
+
useAudit,
|
|
4748
|
+
useBackup,
|
|
4749
|
+
useBlame,
|
|
4750
|
+
useCan,
|
|
4751
|
+
useCanEdit,
|
|
4752
|
+
useCell,
|
|
4753
|
+
useCommand,
|
|
4754
|
+
useCommands,
|
|
4755
|
+
useCommentCount,
|
|
4756
|
+
useCommentCounts,
|
|
4757
|
+
useComments,
|
|
4758
|
+
useContributions,
|
|
4759
|
+
useDatabase,
|
|
4760
|
+
useDatabaseDoc,
|
|
4761
|
+
useDatabaseRow,
|
|
4762
|
+
useDatabaseSchema,
|
|
4763
|
+
useDemoMode,
|
|
4764
|
+
useDiff,
|
|
4765
|
+
useEditorExtensions,
|
|
4766
|
+
useEditorExtensionsSafe,
|
|
4767
|
+
useFileUpload,
|
|
4768
|
+
useGrants,
|
|
4769
|
+
useHistory,
|
|
4770
|
+
useHubSearch,
|
|
4771
|
+
useHubStatus,
|
|
4772
|
+
useIdentity,
|
|
4773
|
+
useInstrumentation,
|
|
4774
|
+
useIsOffline,
|
|
4775
|
+
useMutate,
|
|
4776
|
+
useNode,
|
|
4777
|
+
useOnboarding,
|
|
4778
|
+
usePeerDiscovery,
|
|
4779
|
+
usePluginRegistry,
|
|
4780
|
+
usePluginRegistryOptional,
|
|
4781
|
+
usePlugins,
|
|
4782
|
+
useQuery,
|
|
4783
|
+
useRelatedRows,
|
|
4784
|
+
useRemoteSchema,
|
|
4785
|
+
useReverseRelations,
|
|
4786
|
+
useSecurity,
|
|
4787
|
+
useSecurityContext,
|
|
4788
|
+
useSecurityContextOptional,
|
|
4789
|
+
useSidebarItems,
|
|
4790
|
+
useSlashCommands,
|
|
4791
|
+
useSyncManager,
|
|
4792
|
+
useTelemetryReporter,
|
|
4793
|
+
useUndo,
|
|
4794
|
+
useVerification,
|
|
4795
|
+
useView,
|
|
4796
|
+
useViews,
|
|
4797
|
+
useXNet
|
|
4798
|
+
};
|