@affectively/aeon-pages 1.3.0 → 1.4.0
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/package.json +2 -2
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/commands/dev.ts +1 -1
- package/packages/react/package.json +3 -3
- package/packages/react/src/components/PresenceKit.tsx +824 -0
- package/packages/react/src/hooks/useAeonNavigation.ts +9 -10
- package/packages/react/src/hooks/usePilotNavigation.ts +1 -1
- package/packages/react/src/hooks.ts +189 -7
- package/packages/react/src/index.ts +49 -0
- package/packages/react/src/provider.tsx +718 -134
- package/packages/runtime/package.json +1 -1
- package/packages/runtime/src/durable-object.ts +382 -15
- package/packages/runtime/src/index.ts +6 -0
- package/packages/runtime/src/offline/encrypted-queue.ts +1 -1
- package/packages/runtime/src/registry.ts +1 -1
- package/packages/runtime/src/router/types.ts +56 -0
- package/packages/runtime/src/service-worker-push.ts +7 -9
- package/packages/runtime/src/storage.test.ts +129 -0
- package/packages/runtime/src/storage.ts +180 -3
- package/packages/runtime/src/types.ts +95 -0
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Provides:
|
|
5
5
|
* - Real-time sync via Aeon SyncCoordinator
|
|
6
|
-
* - Presence tracking via
|
|
6
|
+
* - Presence tracking via WebSocket channel
|
|
7
7
|
* - Offline support via OfflineOperationQueue
|
|
8
8
|
* - Schema versioning via SchemaVersionManager
|
|
9
9
|
*/
|
|
@@ -17,14 +17,73 @@ import React, {
|
|
|
17
17
|
useRef,
|
|
18
18
|
type ReactNode,
|
|
19
19
|
} from 'react';
|
|
20
|
-
import {
|
|
21
|
-
|
|
20
|
+
import {
|
|
21
|
+
getSyncCoordinator,
|
|
22
|
+
getOfflineQueue,
|
|
23
|
+
} from '@affectively/aeon-pages-runtime';
|
|
22
24
|
|
|
23
25
|
// Types
|
|
26
|
+
export interface PresenceSelection {
|
|
27
|
+
start: number;
|
|
28
|
+
end: number;
|
|
29
|
+
direction?: 'forward' | 'backward' | 'none';
|
|
30
|
+
path?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PresenceTyping {
|
|
34
|
+
isTyping: boolean;
|
|
35
|
+
field?: string;
|
|
36
|
+
isComposing?: boolean;
|
|
37
|
+
startedAt?: string;
|
|
38
|
+
stoppedAt?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PresenceScroll {
|
|
42
|
+
depth: number;
|
|
43
|
+
y?: number;
|
|
44
|
+
viewportHeight?: number;
|
|
45
|
+
documentHeight?: number;
|
|
46
|
+
path?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface PresenceViewport {
|
|
50
|
+
width: number;
|
|
51
|
+
height: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface PresenceInputState {
|
|
55
|
+
field: string;
|
|
56
|
+
hasFocus: boolean;
|
|
57
|
+
valueLength?: number;
|
|
58
|
+
selectionStart?: number;
|
|
59
|
+
selectionEnd?: number;
|
|
60
|
+
isComposing?: boolean;
|
|
61
|
+
inputMode?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PresenceEmotion {
|
|
65
|
+
primary?: string;
|
|
66
|
+
secondary?: string;
|
|
67
|
+
confidence?: number;
|
|
68
|
+
intensity?: number;
|
|
69
|
+
valence?: number;
|
|
70
|
+
arousal?: number;
|
|
71
|
+
dominance?: number;
|
|
72
|
+
source?: 'self-report' | 'inferred' | 'sensor' | 'hybrid';
|
|
73
|
+
updatedAt?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
24
76
|
export interface PresenceUser {
|
|
25
77
|
userId: string;
|
|
26
78
|
role: 'user' | 'assistant' | 'monitor' | 'admin';
|
|
27
79
|
cursor?: { x: number; y: number };
|
|
80
|
+
focusNode?: string;
|
|
81
|
+
selection?: PresenceSelection;
|
|
82
|
+
typing?: PresenceTyping;
|
|
83
|
+
scroll?: PresenceScroll;
|
|
84
|
+
viewport?: PresenceViewport;
|
|
85
|
+
inputState?: PresenceInputState;
|
|
86
|
+
emotion?: PresenceEmotion;
|
|
28
87
|
editing?: string;
|
|
29
88
|
status: 'online' | 'away' | 'offline';
|
|
30
89
|
lastActivity: string;
|
|
@@ -51,12 +110,24 @@ export interface AeonPageContextValue {
|
|
|
51
110
|
// Presence
|
|
52
111
|
presence: PresenceUser[];
|
|
53
112
|
localUser: PresenceUser | null;
|
|
54
|
-
updateCursor: (position: { x: number; y: number }) => void;
|
|
113
|
+
updateCursor: (position: { x: number; y: number }, path?: string) => void;
|
|
55
114
|
updateEditing: (elementPath: string | null) => void;
|
|
115
|
+
updateFocusNode: (nodePath: string) => void;
|
|
116
|
+
updateSelection: (selection: PresenceSelection) => void;
|
|
117
|
+
updateTyping: (
|
|
118
|
+
isTyping: boolean,
|
|
119
|
+
field?: string,
|
|
120
|
+
isComposing?: boolean,
|
|
121
|
+
) => void;
|
|
122
|
+
updateScroll: (scroll: PresenceScroll) => void;
|
|
123
|
+
updateViewport: (viewport: PresenceViewport) => void;
|
|
124
|
+
updateInputState: (inputState: PresenceInputState) => void;
|
|
125
|
+
updateEmotionState: (emotion: PresenceEmotion) => void;
|
|
56
126
|
|
|
57
127
|
// Sync
|
|
58
128
|
sync: SyncState;
|
|
59
|
-
forcSync: () => Promise<void>;
|
|
129
|
+
forcSync: () => Promise<void>; // Kept for backwards compatibility
|
|
130
|
+
forceSync: () => Promise<void>;
|
|
60
131
|
|
|
61
132
|
// Versioning
|
|
62
133
|
version: VersionInfo;
|
|
@@ -79,6 +150,21 @@ export interface AeonPageProviderProps {
|
|
|
79
150
|
initialData?: Record<string, unknown>;
|
|
80
151
|
}
|
|
81
152
|
|
|
153
|
+
interface SocketMessage {
|
|
154
|
+
type: string;
|
|
155
|
+
payload?: unknown;
|
|
156
|
+
users?: unknown;
|
|
157
|
+
data?: unknown;
|
|
158
|
+
tree?: unknown;
|
|
159
|
+
version?: unknown;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface PresenceEnvelope {
|
|
163
|
+
action?: 'join' | 'leave' | 'update';
|
|
164
|
+
user?: PresenceUser;
|
|
165
|
+
userId?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
82
168
|
/**
|
|
83
169
|
* AeonPageProvider - Wraps a page with Aeon collaborative features
|
|
84
170
|
*/
|
|
@@ -107,20 +193,314 @@ export function AeonPageProvider({
|
|
|
107
193
|
const [data, setDataState] = useState<Record<string, unknown>>(initialData);
|
|
108
194
|
const [tree, setTree] = useState<unknown>(null);
|
|
109
195
|
|
|
110
|
-
// Refs for Aeon modules (
|
|
196
|
+
// Refs for Aeon modules (initialized on demand)
|
|
111
197
|
const syncCoordinatorRef = useRef<unknown>(null);
|
|
112
198
|
const presenceManagerRef = useRef<unknown>(null);
|
|
113
199
|
const offlineQueueRef = useRef<unknown>(null);
|
|
114
200
|
const versionManagerRef = useRef<unknown>(null);
|
|
115
201
|
const wsRef = useRef<WebSocket | null>(null);
|
|
116
202
|
|
|
203
|
+
const updatePresenceUser = useCallback(
|
|
204
|
+
(userId: string, patch: Partial<PresenceUser>) => {
|
|
205
|
+
const now = new Date().toISOString();
|
|
206
|
+
|
|
207
|
+
setPresence((prev) => {
|
|
208
|
+
const index = prev.findIndex((user) => user.userId === userId);
|
|
209
|
+
if (index < 0) {
|
|
210
|
+
return [
|
|
211
|
+
...prev,
|
|
212
|
+
{
|
|
213
|
+
userId,
|
|
214
|
+
role: 'user',
|
|
215
|
+
status: 'online',
|
|
216
|
+
lastActivity: now,
|
|
217
|
+
...patch,
|
|
218
|
+
},
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const next = [...prev];
|
|
223
|
+
next[index] = {
|
|
224
|
+
...next[index],
|
|
225
|
+
...patch,
|
|
226
|
+
lastActivity: patch.lastActivity ?? now,
|
|
227
|
+
};
|
|
228
|
+
return next;
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
setLocalUser((prev) => {
|
|
232
|
+
if (!prev || prev.userId !== userId) {
|
|
233
|
+
return prev;
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
...prev,
|
|
237
|
+
...patch,
|
|
238
|
+
lastActivity: patch.lastActivity ?? now,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
},
|
|
242
|
+
[],
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
const removePresenceUser = useCallback((userId: string) => {
|
|
246
|
+
setPresence((prev) => prev.filter((user) => user.userId !== userId));
|
|
247
|
+
}, []);
|
|
248
|
+
|
|
249
|
+
const setPresenceSnapshot = useCallback((users: PresenceUser[]) => {
|
|
250
|
+
setPresence(users);
|
|
251
|
+
}, []);
|
|
252
|
+
|
|
253
|
+
const sendSocketMessage = useCallback((message: Record<string, unknown>) => {
|
|
254
|
+
const ws = wsRef.current;
|
|
255
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
256
|
+
ws.send(JSON.stringify(message));
|
|
257
|
+
}
|
|
258
|
+
}, []);
|
|
259
|
+
|
|
260
|
+
const connectWebSocket = useCallback(
|
|
261
|
+
(
|
|
262
|
+
targetSessionId: string,
|
|
263
|
+
userId: string,
|
|
264
|
+
role: PresenceUser['role'] = 'user',
|
|
265
|
+
) => {
|
|
266
|
+
if (typeof window === 'undefined') return;
|
|
267
|
+
|
|
268
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
269
|
+
const params = new URLSearchParams({
|
|
270
|
+
session: targetSessionId,
|
|
271
|
+
userId,
|
|
272
|
+
role,
|
|
273
|
+
});
|
|
274
|
+
const wsUrl = `${protocol}//${window.location.host}/_aeon/ws?${params.toString()}`;
|
|
275
|
+
|
|
276
|
+
const ws = new WebSocket(wsUrl);
|
|
277
|
+
wsRef.current = ws;
|
|
278
|
+
|
|
279
|
+
ws.onopen = () => {
|
|
280
|
+
console.log('[aeon-provider] WebSocket connected');
|
|
281
|
+
sendSocketMessage({
|
|
282
|
+
type: 'presence',
|
|
283
|
+
payload: { status: 'online' },
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
ws.onmessage = (event) => {
|
|
288
|
+
try {
|
|
289
|
+
const message = JSON.parse(event.data as string) as SocketMessage;
|
|
290
|
+
handleSyncMessage(message);
|
|
291
|
+
} catch (error) {
|
|
292
|
+
console.error('[aeon-provider] Error parsing message:', error);
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
ws.onclose = () => {
|
|
297
|
+
console.log('[aeon-provider] WebSocket disconnected');
|
|
298
|
+
setTimeout(() => connectWebSocket(targetSessionId, userId, role), 1000);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
ws.onerror = (error) => {
|
|
302
|
+
console.error('[aeon-provider] WebSocket error:', error);
|
|
303
|
+
};
|
|
304
|
+
},
|
|
305
|
+
[sendSocketMessage],
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
const handleSyncMessage = useCallback(
|
|
309
|
+
(message: unknown) => {
|
|
310
|
+
if (!isRecord(message) || typeof message.type !== 'string') {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const msg = message as unknown as SocketMessage;
|
|
315
|
+
|
|
316
|
+
switch (msg.type) {
|
|
317
|
+
case 'init': {
|
|
318
|
+
if (!isRecord(msg.payload)) break;
|
|
319
|
+
|
|
320
|
+
const payload = msg.payload;
|
|
321
|
+
if (isRecord(payload.session)) {
|
|
322
|
+
const session = payload.session;
|
|
323
|
+
if ('tree' in session) {
|
|
324
|
+
setTree(session.tree);
|
|
325
|
+
}
|
|
326
|
+
if (isRecord(session.data)) {
|
|
327
|
+
setDataState((prev) => ({
|
|
328
|
+
...prev,
|
|
329
|
+
...(session.data as Record<string, unknown>),
|
|
330
|
+
}));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (Array.isArray(payload.presence)) {
|
|
335
|
+
setPresenceSnapshot(payload.presence as PresenceUser[]);
|
|
336
|
+
}
|
|
337
|
+
break;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
case 'presence-update': {
|
|
341
|
+
if (Array.isArray(msg.users)) {
|
|
342
|
+
setPresenceSnapshot(msg.users as PresenceUser[]);
|
|
343
|
+
break;
|
|
344
|
+
}
|
|
345
|
+
if (isRecord(msg.payload) && Array.isArray(msg.payload.users)) {
|
|
346
|
+
setPresenceSnapshot(msg.payload.users as PresenceUser[]);
|
|
347
|
+
}
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case 'presence': {
|
|
352
|
+
if (!isRecord(msg.payload)) break;
|
|
353
|
+
|
|
354
|
+
const payload = msg.payload as PresenceEnvelope;
|
|
355
|
+
if (
|
|
356
|
+
(payload.action === 'join' || payload.action === 'update') &&
|
|
357
|
+
payload.user
|
|
358
|
+
) {
|
|
359
|
+
const user = payload.user;
|
|
360
|
+
updatePresenceUser(user.userId, user);
|
|
361
|
+
} else if (
|
|
362
|
+
payload.action === 'leave' &&
|
|
363
|
+
typeof payload.userId === 'string'
|
|
364
|
+
) {
|
|
365
|
+
removePresenceUser(payload.userId);
|
|
366
|
+
}
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
case 'cursor': {
|
|
371
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
if (isRecord(msg.payload.cursor)) {
|
|
375
|
+
updatePresenceUser(msg.payload.userId, {
|
|
376
|
+
cursor: {
|
|
377
|
+
x: Number(msg.payload.cursor.x ?? 0),
|
|
378
|
+
y: Number(msg.payload.cursor.y ?? 0),
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
case 'typing': {
|
|
386
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
387
|
+
break;
|
|
388
|
+
}
|
|
389
|
+
const typing = toPresenceTyping(msg.payload.typing);
|
|
390
|
+
if (typing) {
|
|
391
|
+
updatePresenceUser(msg.payload.userId, {
|
|
392
|
+
typing,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
case 'focus': {
|
|
399
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
if (typeof msg.payload.focusNode === 'string') {
|
|
403
|
+
updatePresenceUser(msg.payload.userId, {
|
|
404
|
+
focusNode: msg.payload.focusNode,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
case 'selection': {
|
|
411
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
const selection = toPresenceSelection(msg.payload.selection);
|
|
415
|
+
if (selection) {
|
|
416
|
+
updatePresenceUser(msg.payload.userId, {
|
|
417
|
+
selection,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
break;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
case 'scroll': {
|
|
424
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
425
|
+
break;
|
|
426
|
+
}
|
|
427
|
+
const scroll = toPresenceScroll(msg.payload.scroll);
|
|
428
|
+
if (scroll) {
|
|
429
|
+
updatePresenceUser(msg.payload.userId, {
|
|
430
|
+
scroll,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
break;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
case 'viewport': {
|
|
437
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
const viewport = toPresenceViewport(msg.payload.viewport);
|
|
441
|
+
if (viewport) {
|
|
442
|
+
updatePresenceUser(msg.payload.userId, {
|
|
443
|
+
viewport,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
case 'input-state': {
|
|
450
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
const inputState = toPresenceInputState(msg.payload.inputState);
|
|
454
|
+
if (inputState) {
|
|
455
|
+
updatePresenceUser(msg.payload.userId, {
|
|
456
|
+
inputState,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
case 'emotion': {
|
|
463
|
+
if (!isRecord(msg.payload) || typeof msg.payload.userId !== 'string') {
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
const emotion = toPresenceEmotion(msg.payload.emotion);
|
|
467
|
+
if (emotion) {
|
|
468
|
+
updatePresenceUser(msg.payload.userId, {
|
|
469
|
+
emotion,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
case 'data-update':
|
|
476
|
+
if (isRecord(msg.data)) {
|
|
477
|
+
setDataState((prev) => ({
|
|
478
|
+
...prev,
|
|
479
|
+
...(msg.data as Record<string, unknown>),
|
|
480
|
+
}));
|
|
481
|
+
}
|
|
482
|
+
break;
|
|
483
|
+
|
|
484
|
+
case 'tree-update':
|
|
485
|
+
setTree(msg.tree);
|
|
486
|
+
break;
|
|
487
|
+
|
|
488
|
+
case 'version-info': {
|
|
489
|
+
const parsedVersion = toVersionInfo(msg.version);
|
|
490
|
+
if (parsedVersion) {
|
|
491
|
+
setVersion(parsedVersion);
|
|
492
|
+
}
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
},
|
|
497
|
+
[removePresenceUser, setPresenceSnapshot, updatePresenceUser],
|
|
498
|
+
);
|
|
499
|
+
|
|
117
500
|
// Initialize Aeon modules
|
|
118
501
|
useEffect(() => {
|
|
119
502
|
const initAeon = async () => {
|
|
120
503
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
504
|
// Initialize sync coordinator
|
|
125
505
|
syncCoordinatorRef.current = getSyncCoordinator();
|
|
126
506
|
|
|
@@ -133,15 +513,16 @@ export function AeonPageProvider({
|
|
|
133
513
|
|
|
134
514
|
// Set up local user
|
|
135
515
|
const userId = generateUserId();
|
|
136
|
-
|
|
516
|
+
const initialUser: PresenceUser = {
|
|
137
517
|
userId,
|
|
138
518
|
role: 'user',
|
|
139
519
|
status: 'online',
|
|
140
520
|
lastActivity: new Date().toISOString(),
|
|
141
|
-
}
|
|
521
|
+
};
|
|
522
|
+
setLocalUser(initialUser);
|
|
142
523
|
|
|
143
524
|
// Connect WebSocket for real-time sync
|
|
144
|
-
connectWebSocket(sessionId);
|
|
525
|
+
connectWebSocket(sessionId, initialUser.userId, initialUser.role);
|
|
145
526
|
} catch (error) {
|
|
146
527
|
console.warn('[aeon-provider] Aeon modules not available:', error);
|
|
147
528
|
}
|
|
@@ -153,7 +534,7 @@ export function AeonPageProvider({
|
|
|
153
534
|
return () => {
|
|
154
535
|
wsRef.current?.close();
|
|
155
536
|
};
|
|
156
|
-
}, [sessionId]);
|
|
537
|
+
}, [connectWebSocket, sessionId]);
|
|
157
538
|
|
|
158
539
|
// Online/offline detection
|
|
159
540
|
useEffect(() => {
|
|
@@ -161,7 +542,6 @@ export function AeonPageProvider({
|
|
|
161
542
|
|
|
162
543
|
const handleOnline = () => {
|
|
163
544
|
setSync((prev) => ({ ...prev, isOnline: true }));
|
|
164
|
-
// Flush offline queue
|
|
165
545
|
flushOfflineQueue();
|
|
166
546
|
};
|
|
167
547
|
|
|
@@ -178,66 +558,16 @@ export function AeonPageProvider({
|
|
|
178
558
|
};
|
|
179
559
|
}, []);
|
|
180
560
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
ws.onopen = () => {
|
|
192
|
-
console.log('[aeon-provider] WebSocket connected');
|
|
193
|
-
// Join session
|
|
194
|
-
ws.send(JSON.stringify({ type: 'join', sessionId }));
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
ws.onmessage = (event) => {
|
|
198
|
-
try {
|
|
199
|
-
const message = JSON.parse(event.data);
|
|
200
|
-
handleSyncMessage(message);
|
|
201
|
-
} catch (error) {
|
|
202
|
-
console.error('[aeon-provider] Error parsing message:', error);
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
ws.onclose = () => {
|
|
207
|
-
console.log('[aeon-provider] WebSocket disconnected');
|
|
208
|
-
// Reconnect after delay
|
|
209
|
-
setTimeout(() => connectWebSocket(sessionId), 1000);
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
ws.onerror = (error) => {
|
|
213
|
-
console.error('[aeon-provider] WebSocket error:', error);
|
|
214
|
-
};
|
|
215
|
-
}, []);
|
|
216
|
-
|
|
217
|
-
// Handle incoming sync messages
|
|
218
|
-
const handleSyncMessage = useCallback((message: unknown) => {
|
|
219
|
-
const msg = message as { type: string; [key: string]: unknown };
|
|
220
|
-
|
|
221
|
-
switch (msg.type) {
|
|
222
|
-
case 'presence-update':
|
|
223
|
-
setPresence(msg.users as PresenceUser[]);
|
|
224
|
-
break;
|
|
225
|
-
|
|
226
|
-
case 'data-update':
|
|
227
|
-
setDataState((prev) => ({
|
|
228
|
-
...prev,
|
|
229
|
-
...(msg.data as Record<string, unknown>),
|
|
230
|
-
}));
|
|
231
|
-
break;
|
|
232
|
-
|
|
233
|
-
case 'tree-update':
|
|
234
|
-
setTree(msg.tree);
|
|
235
|
-
break;
|
|
236
|
-
|
|
237
|
-
case 'version-info':
|
|
238
|
-
setVersion(msg.version as VersionInfo);
|
|
239
|
-
break;
|
|
240
|
-
}
|
|
561
|
+
const patchLocalUser = useCallback((patch: Partial<PresenceUser>) => {
|
|
562
|
+
const now = new Date().toISOString();
|
|
563
|
+
setLocalUser((prev) => {
|
|
564
|
+
if (!prev) return prev;
|
|
565
|
+
return {
|
|
566
|
+
...prev,
|
|
567
|
+
...patch,
|
|
568
|
+
lastActivity: now,
|
|
569
|
+
};
|
|
570
|
+
});
|
|
241
571
|
}, []);
|
|
242
572
|
|
|
243
573
|
// Flush offline queue when back online
|
|
@@ -261,56 +591,138 @@ export function AeonPageProvider({
|
|
|
261
591
|
}
|
|
262
592
|
}, []);
|
|
263
593
|
|
|
264
|
-
//
|
|
594
|
+
// Presence update helpers
|
|
265
595
|
const updateCursor = useCallback(
|
|
266
|
-
(position: { x: number; y: number }) => {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
// Send to WebSocket
|
|
280
|
-
wsRef.current?.send(
|
|
281
|
-
JSON.stringify({
|
|
282
|
-
type: 'cursor-update',
|
|
283
|
-
position,
|
|
284
|
-
}),
|
|
285
|
-
);
|
|
596
|
+
(position: { x: number; y: number }, path?: string) => {
|
|
597
|
+
patchLocalUser({
|
|
598
|
+
cursor: position,
|
|
599
|
+
focusNode: path,
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
sendSocketMessage({
|
|
603
|
+
type: 'cursor',
|
|
604
|
+
payload: {
|
|
605
|
+
...position,
|
|
606
|
+
path,
|
|
607
|
+
},
|
|
608
|
+
});
|
|
286
609
|
},
|
|
287
|
-
[
|
|
610
|
+
[patchLocalUser, sendSocketMessage],
|
|
288
611
|
);
|
|
289
612
|
|
|
290
|
-
// Update editing element
|
|
291
613
|
const updateEditing = useCallback(
|
|
292
614
|
(elementPath: string | null) => {
|
|
293
|
-
|
|
615
|
+
patchLocalUser({
|
|
616
|
+
editing: elementPath ?? undefined,
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
sendSocketMessage({
|
|
620
|
+
type: 'presence',
|
|
621
|
+
payload: {
|
|
622
|
+
status: localUser?.status ?? 'online',
|
|
623
|
+
editing: elementPath,
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
},
|
|
627
|
+
[localUser?.status, patchLocalUser, sendSocketMessage],
|
|
628
|
+
);
|
|
294
629
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
630
|
+
const updateFocusNode = useCallback(
|
|
631
|
+
(nodePath: string) => {
|
|
632
|
+
patchLocalUser({ focusNode: nodePath });
|
|
633
|
+
sendSocketMessage({
|
|
634
|
+
type: 'focus',
|
|
635
|
+
payload: { nodePath },
|
|
636
|
+
});
|
|
637
|
+
},
|
|
638
|
+
[patchLocalUser, sendSocketMessage],
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
const updateSelection = useCallback(
|
|
642
|
+
(selection: PresenceSelection) => {
|
|
643
|
+
patchLocalUser({ selection });
|
|
644
|
+
sendSocketMessage({
|
|
645
|
+
type: 'selection',
|
|
646
|
+
payload: selection,
|
|
647
|
+
});
|
|
648
|
+
},
|
|
649
|
+
[patchLocalUser, sendSocketMessage],
|
|
650
|
+
);
|
|
651
|
+
|
|
652
|
+
const updateTyping = useCallback(
|
|
653
|
+
(isTyping: boolean, field?: string, isComposing = false) => {
|
|
654
|
+
const now = new Date().toISOString();
|
|
655
|
+
patchLocalUser({
|
|
656
|
+
typing: {
|
|
657
|
+
isTyping,
|
|
658
|
+
field,
|
|
659
|
+
isComposing,
|
|
660
|
+
startedAt: isTyping ? now : undefined,
|
|
661
|
+
stoppedAt: isTyping ? undefined : now,
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
sendSocketMessage({
|
|
665
|
+
type: 'typing',
|
|
666
|
+
payload: {
|
|
667
|
+
isTyping,
|
|
668
|
+
field,
|
|
669
|
+
isComposing,
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
},
|
|
673
|
+
[patchLocalUser, sendSocketMessage],
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const updateScroll = useCallback(
|
|
677
|
+
(scroll: PresenceScroll) => {
|
|
678
|
+
const normalized: PresenceScroll = {
|
|
679
|
+
...scroll,
|
|
680
|
+
depth: Math.max(0, Math.min(1, scroll.depth)),
|
|
681
|
+
};
|
|
682
|
+
patchLocalUser({ scroll: normalized });
|
|
683
|
+
sendSocketMessage({
|
|
684
|
+
type: 'scroll',
|
|
685
|
+
payload: normalized,
|
|
686
|
+
});
|
|
687
|
+
},
|
|
688
|
+
[patchLocalUser, sendSocketMessage],
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
const updateViewport = useCallback(
|
|
692
|
+
(viewport: PresenceViewport) => {
|
|
693
|
+
patchLocalUser({ viewport });
|
|
694
|
+
sendSocketMessage({
|
|
695
|
+
type: 'viewport',
|
|
696
|
+
payload: viewport,
|
|
697
|
+
});
|
|
698
|
+
},
|
|
699
|
+
[patchLocalUser, sendSocketMessage],
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
const updateInputState = useCallback(
|
|
703
|
+
(inputState: PresenceInputState) => {
|
|
704
|
+
patchLocalUser({ inputState });
|
|
705
|
+
sendSocketMessage({
|
|
706
|
+
type: 'input-state',
|
|
707
|
+
payload: inputState,
|
|
708
|
+
});
|
|
709
|
+
},
|
|
710
|
+
[patchLocalUser, sendSocketMessage],
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
const updateEmotionState = useCallback(
|
|
714
|
+
(emotion: PresenceEmotion) => {
|
|
715
|
+
const enrichedEmotion: PresenceEmotion = {
|
|
716
|
+
...emotion,
|
|
717
|
+
updatedAt: new Date().toISOString(),
|
|
718
|
+
};
|
|
719
|
+
patchLocalUser({ emotion: enrichedEmotion });
|
|
720
|
+
sendSocketMessage({
|
|
721
|
+
type: 'emotion',
|
|
722
|
+
payload: emotion,
|
|
723
|
+
});
|
|
312
724
|
},
|
|
313
|
-
[
|
|
725
|
+
[patchLocalUser, sendSocketMessage],
|
|
314
726
|
);
|
|
315
727
|
|
|
316
728
|
// Force sync
|
|
@@ -353,13 +765,11 @@ export function AeonPageProvider({
|
|
|
353
765
|
|
|
354
766
|
// Queue for sync
|
|
355
767
|
if (sync.isOnline && wsRef.current) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}),
|
|
362
|
-
);
|
|
768
|
+
sendSocketMessage({
|
|
769
|
+
type: 'data-set',
|
|
770
|
+
key,
|
|
771
|
+
value,
|
|
772
|
+
});
|
|
363
773
|
} else {
|
|
364
774
|
// Queue offline
|
|
365
775
|
// @ts-expect-error - Aeon module method
|
|
@@ -374,20 +784,21 @@ export function AeonPageProvider({
|
|
|
374
784
|
}));
|
|
375
785
|
}
|
|
376
786
|
},
|
|
377
|
-
[sync.isOnline],
|
|
787
|
+
[sendSocketMessage, sync.isOnline],
|
|
378
788
|
);
|
|
379
789
|
|
|
380
790
|
// Update tree
|
|
381
|
-
const updateTree = useCallback(
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
791
|
+
const updateTree = useCallback(
|
|
792
|
+
(path: string, value: unknown) => {
|
|
793
|
+
// This applies a patch to the remote tree
|
|
794
|
+
sendSocketMessage({
|
|
385
795
|
type: 'tree-patch',
|
|
386
796
|
path,
|
|
387
797
|
value,
|
|
388
|
-
})
|
|
389
|
-
|
|
390
|
-
|
|
798
|
+
});
|
|
799
|
+
},
|
|
800
|
+
[sendSocketMessage],
|
|
801
|
+
);
|
|
391
802
|
|
|
392
803
|
// Context value
|
|
393
804
|
const contextValue: AeonPageContextValue = {
|
|
@@ -397,8 +808,16 @@ export function AeonPageProvider({
|
|
|
397
808
|
localUser,
|
|
398
809
|
updateCursor,
|
|
399
810
|
updateEditing,
|
|
811
|
+
updateFocusNode,
|
|
812
|
+
updateSelection,
|
|
813
|
+
updateTyping,
|
|
814
|
+
updateScroll,
|
|
815
|
+
updateViewport,
|
|
816
|
+
updateInputState,
|
|
817
|
+
updateEmotionState,
|
|
400
818
|
sync,
|
|
401
819
|
forcSync: forceSync,
|
|
820
|
+
forceSync,
|
|
402
821
|
version,
|
|
403
822
|
migrate,
|
|
404
823
|
data,
|
|
@@ -429,15 +848,39 @@ export function useAeonPage(): AeonPageContextValue {
|
|
|
429
848
|
* usePresence - Just the presence data
|
|
430
849
|
*/
|
|
431
850
|
export function usePresence() {
|
|
432
|
-
const {
|
|
433
|
-
|
|
851
|
+
const {
|
|
852
|
+
presence,
|
|
853
|
+
localUser,
|
|
854
|
+
updateCursor,
|
|
855
|
+
updateEditing,
|
|
856
|
+
updateFocusNode,
|
|
857
|
+
updateSelection,
|
|
858
|
+
updateTyping,
|
|
859
|
+
updateScroll,
|
|
860
|
+
updateViewport,
|
|
861
|
+
updateInputState,
|
|
862
|
+
updateEmotionState,
|
|
863
|
+
} = useAeonPage();
|
|
864
|
+
return {
|
|
865
|
+
presence,
|
|
866
|
+
localUser,
|
|
867
|
+
updateCursor,
|
|
868
|
+
updateEditing,
|
|
869
|
+
updateFocusNode,
|
|
870
|
+
updateSelection,
|
|
871
|
+
updateTyping,
|
|
872
|
+
updateScroll,
|
|
873
|
+
updateViewport,
|
|
874
|
+
updateInputState,
|
|
875
|
+
updateEmotionState,
|
|
876
|
+
};
|
|
434
877
|
}
|
|
435
878
|
|
|
436
879
|
/**
|
|
437
880
|
* useAeonSync - Just the sync state
|
|
438
881
|
*/
|
|
439
882
|
export function useAeonSync() {
|
|
440
|
-
const { sync,
|
|
883
|
+
const { sync, forceSync } = useAeonPage();
|
|
441
884
|
return { ...sync, forceSync };
|
|
442
885
|
}
|
|
443
886
|
|
|
@@ -464,4 +907,145 @@ function generateUserId(): string {
|
|
|
464
907
|
return `user-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
465
908
|
}
|
|
466
909
|
|
|
910
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
911
|
+
return typeof value === 'object' && value !== null;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function toPresenceSelection(value: unknown): PresenceSelection | null {
|
|
915
|
+
if (!isRecord(value)) return null;
|
|
916
|
+
if (typeof value.start !== 'number' || typeof value.end !== 'number') {
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
return {
|
|
920
|
+
start: value.start,
|
|
921
|
+
end: value.end,
|
|
922
|
+
direction:
|
|
923
|
+
value.direction === 'forward' ||
|
|
924
|
+
value.direction === 'backward' ||
|
|
925
|
+
value.direction === 'none'
|
|
926
|
+
? value.direction
|
|
927
|
+
: undefined,
|
|
928
|
+
path: typeof value.path === 'string' ? value.path : undefined,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function toPresenceTyping(value: unknown): PresenceTyping | null {
|
|
933
|
+
if (!isRecord(value) || typeof value.isTyping !== 'boolean') {
|
|
934
|
+
return null;
|
|
935
|
+
}
|
|
936
|
+
return {
|
|
937
|
+
isTyping: value.isTyping,
|
|
938
|
+
field: typeof value.field === 'string' ? value.field : undefined,
|
|
939
|
+
isComposing:
|
|
940
|
+
typeof value.isComposing === 'boolean' ? value.isComposing : undefined,
|
|
941
|
+
startedAt: typeof value.startedAt === 'string' ? value.startedAt : undefined,
|
|
942
|
+
stoppedAt: typeof value.stoppedAt === 'string' ? value.stoppedAt : undefined,
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function toPresenceScroll(value: unknown): PresenceScroll | null {
|
|
947
|
+
if (!isRecord(value) || typeof value.depth !== 'number') {
|
|
948
|
+
return null;
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
depth: value.depth,
|
|
952
|
+
y: typeof value.y === 'number' ? value.y : undefined,
|
|
953
|
+
viewportHeight:
|
|
954
|
+
typeof value.viewportHeight === 'number' ? value.viewportHeight : undefined,
|
|
955
|
+
documentHeight:
|
|
956
|
+
typeof value.documentHeight === 'number' ? value.documentHeight : undefined,
|
|
957
|
+
path: typeof value.path === 'string' ? value.path : undefined,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
function toPresenceViewport(value: unknown): PresenceViewport | null {
|
|
962
|
+
if (
|
|
963
|
+
!isRecord(value) ||
|
|
964
|
+
typeof value.width !== 'number' ||
|
|
965
|
+
typeof value.height !== 'number'
|
|
966
|
+
) {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
return {
|
|
970
|
+
width: value.width,
|
|
971
|
+
height: value.height,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function toPresenceInputState(value: unknown): PresenceInputState | null {
|
|
976
|
+
if (
|
|
977
|
+
!isRecord(value) ||
|
|
978
|
+
typeof value.field !== 'string' ||
|
|
979
|
+
typeof value.hasFocus !== 'boolean'
|
|
980
|
+
) {
|
|
981
|
+
return null;
|
|
982
|
+
}
|
|
983
|
+
return {
|
|
984
|
+
field: value.field,
|
|
985
|
+
hasFocus: value.hasFocus,
|
|
986
|
+
valueLength: typeof value.valueLength === 'number' ? value.valueLength : undefined,
|
|
987
|
+
selectionStart:
|
|
988
|
+
typeof value.selectionStart === 'number' ? value.selectionStart : undefined,
|
|
989
|
+
selectionEnd:
|
|
990
|
+
typeof value.selectionEnd === 'number' ? value.selectionEnd : undefined,
|
|
991
|
+
isComposing:
|
|
992
|
+
typeof value.isComposing === 'boolean' ? value.isComposing : undefined,
|
|
993
|
+
inputMode: typeof value.inputMode === 'string' ? value.inputMode : undefined,
|
|
994
|
+
};
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function toPresenceEmotion(value: unknown): PresenceEmotion | null {
|
|
998
|
+
if (!isRecord(value)) return null;
|
|
999
|
+
|
|
1000
|
+
const emotion: PresenceEmotion = {
|
|
1001
|
+
primary: typeof value.primary === 'string' ? value.primary : undefined,
|
|
1002
|
+
secondary: typeof value.secondary === 'string' ? value.secondary : undefined,
|
|
1003
|
+
confidence: typeof value.confidence === 'number' ? value.confidence : undefined,
|
|
1004
|
+
intensity: typeof value.intensity === 'number' ? value.intensity : undefined,
|
|
1005
|
+
valence: typeof value.valence === 'number' ? value.valence : undefined,
|
|
1006
|
+
arousal: typeof value.arousal === 'number' ? value.arousal : undefined,
|
|
1007
|
+
dominance: typeof value.dominance === 'number' ? value.dominance : undefined,
|
|
1008
|
+
source:
|
|
1009
|
+
value.source === 'self-report' ||
|
|
1010
|
+
value.source === 'inferred' ||
|
|
1011
|
+
value.source === 'sensor' ||
|
|
1012
|
+
value.source === 'hybrid'
|
|
1013
|
+
? value.source
|
|
1014
|
+
: undefined,
|
|
1015
|
+
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : undefined,
|
|
1016
|
+
};
|
|
1017
|
+
|
|
1018
|
+
if (
|
|
1019
|
+
!emotion.primary &&
|
|
1020
|
+
!emotion.secondary &&
|
|
1021
|
+
emotion.confidence === undefined &&
|
|
1022
|
+
emotion.intensity === undefined &&
|
|
1023
|
+
emotion.valence === undefined &&
|
|
1024
|
+
emotion.arousal === undefined &&
|
|
1025
|
+
emotion.dominance === undefined &&
|
|
1026
|
+
!emotion.source &&
|
|
1027
|
+
!emotion.updatedAt
|
|
1028
|
+
) {
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return emotion;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function toVersionInfo(value: unknown): VersionInfo | null {
|
|
1036
|
+
if (
|
|
1037
|
+
!isRecord(value) ||
|
|
1038
|
+
typeof value.current !== 'string' ||
|
|
1039
|
+
typeof value.latest !== 'string' ||
|
|
1040
|
+
typeof value.needsMigration !== 'boolean'
|
|
1041
|
+
) {
|
|
1042
|
+
return null;
|
|
1043
|
+
}
|
|
1044
|
+
return {
|
|
1045
|
+
current: value.current,
|
|
1046
|
+
latest: value.latest,
|
|
1047
|
+
needsMigration: value.needsMigration,
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
|
|
467
1051
|
export default AeonPageProvider;
|