@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
|
@@ -0,0 +1,824 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
useEffect,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react';
|
|
11
|
+
import type { PresenceScroll, PresenceUser } from '../provider';
|
|
12
|
+
|
|
13
|
+
const USER_COLORS = [
|
|
14
|
+
'#ef4444',
|
|
15
|
+
'#3b82f6',
|
|
16
|
+
'#22c55e',
|
|
17
|
+
'#f59e0b',
|
|
18
|
+
'#14b8a6',
|
|
19
|
+
'#f97316',
|
|
20
|
+
'#ec4899',
|
|
21
|
+
'#84cc16',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
function hashColor(userId: string): string {
|
|
25
|
+
let hash = 0;
|
|
26
|
+
for (let i = 0; i < userId.length; i++) {
|
|
27
|
+
hash = (hash << 5) - hash + userId.charCodeAt(i);
|
|
28
|
+
hash |= 0;
|
|
29
|
+
}
|
|
30
|
+
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function displayUser(userId: string): string {
|
|
34
|
+
return userId.length > 10 ? userId.slice(0, 8) : userId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clampDepth(depth: number): number {
|
|
38
|
+
return Math.max(0, Math.min(1, depth));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatLastActivity(lastActivity?: string): string {
|
|
42
|
+
if (!lastActivity) return 'unknown activity';
|
|
43
|
+
const ts = Date.parse(lastActivity);
|
|
44
|
+
if (Number.isNaN(ts)) return lastActivity;
|
|
45
|
+
const deltaMs = Date.now() - ts;
|
|
46
|
+
const deltaSec = Math.max(0, Math.floor(deltaMs / 1000));
|
|
47
|
+
if (deltaSec < 10) return 'just now';
|
|
48
|
+
if (deltaSec < 60) return `${deltaSec}s ago`;
|
|
49
|
+
const deltaMin = Math.floor(deltaSec / 60);
|
|
50
|
+
if (deltaMin < 60) return `${deltaMin}m ago`;
|
|
51
|
+
const deltaHour = Math.floor(deltaMin / 60);
|
|
52
|
+
if (deltaHour < 24) return `${deltaHour}h ago`;
|
|
53
|
+
const deltaDay = Math.floor(deltaHour / 24);
|
|
54
|
+
return `${deltaDay}d ago`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function panelStyle(base?: CSSProperties): CSSProperties {
|
|
58
|
+
return {
|
|
59
|
+
border: '1px solid #e5e7eb',
|
|
60
|
+
borderRadius: 10,
|
|
61
|
+
padding: 12,
|
|
62
|
+
background: '#ffffff',
|
|
63
|
+
...base,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PresenceCursorLayerProps {
|
|
68
|
+
presence: PresenceUser[];
|
|
69
|
+
localUserId?: string;
|
|
70
|
+
width?: number | string;
|
|
71
|
+
height?: number | string;
|
|
72
|
+
className?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function PresenceCursorLayer({
|
|
76
|
+
presence,
|
|
77
|
+
localUserId,
|
|
78
|
+
width = '100%',
|
|
79
|
+
height = 320,
|
|
80
|
+
className,
|
|
81
|
+
}: PresenceCursorLayerProps) {
|
|
82
|
+
const users = presence.filter((user) => user.userId !== localUserId && user.cursor);
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className={className}
|
|
87
|
+
style={panelStyle({
|
|
88
|
+
position: 'relative',
|
|
89
|
+
width,
|
|
90
|
+
height,
|
|
91
|
+
overflow: 'hidden',
|
|
92
|
+
})}
|
|
93
|
+
>
|
|
94
|
+
{users.map((user) => {
|
|
95
|
+
if (!user.cursor) return null;
|
|
96
|
+
const color = hashColor(user.userId);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div
|
|
100
|
+
key={user.userId}
|
|
101
|
+
style={{
|
|
102
|
+
position: 'absolute',
|
|
103
|
+
left: user.cursor.x,
|
|
104
|
+
top: user.cursor.y,
|
|
105
|
+
transform: 'translate(-50%, -50%)',
|
|
106
|
+
pointerEvents: 'none',
|
|
107
|
+
}}
|
|
108
|
+
>
|
|
109
|
+
<div
|
|
110
|
+
style={{
|
|
111
|
+
width: 12,
|
|
112
|
+
height: 12,
|
|
113
|
+
borderRadius: '50%',
|
|
114
|
+
border: '2px solid #ffffff',
|
|
115
|
+
background: color,
|
|
116
|
+
boxShadow: '0 1px 6px rgba(0,0,0,0.2)',
|
|
117
|
+
}}
|
|
118
|
+
/>
|
|
119
|
+
<div
|
|
120
|
+
style={{
|
|
121
|
+
marginTop: 4,
|
|
122
|
+
fontSize: 11,
|
|
123
|
+
color: '#111827',
|
|
124
|
+
fontWeight: 600,
|
|
125
|
+
padding: '2px 6px',
|
|
126
|
+
borderRadius: 999,
|
|
127
|
+
background: '#ffffff',
|
|
128
|
+
border: `1px solid ${color}`,
|
|
129
|
+
whiteSpace: 'nowrap',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{displayUser(user.userId)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
})}
|
|
137
|
+
{users.length === 0 && (
|
|
138
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No remote cursors</div>
|
|
139
|
+
)}
|
|
140
|
+
</div>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface PresenceFocusListProps {
|
|
145
|
+
presence: PresenceUser[];
|
|
146
|
+
localUserId?: string;
|
|
147
|
+
maxItems?: number;
|
|
148
|
+
className?: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function PresenceFocusList({
|
|
152
|
+
presence,
|
|
153
|
+
localUserId,
|
|
154
|
+
maxItems = 8,
|
|
155
|
+
className,
|
|
156
|
+
}: PresenceFocusListProps) {
|
|
157
|
+
const focused = presence
|
|
158
|
+
.filter((user) => user.userId !== localUserId && user.focusNode)
|
|
159
|
+
.slice(0, maxItems);
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className={className} style={panelStyle()}>
|
|
163
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
164
|
+
Focus Nodes
|
|
165
|
+
</div>
|
|
166
|
+
{focused.length === 0 ? (
|
|
167
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No remote focus</div>
|
|
168
|
+
) : (
|
|
169
|
+
<div style={{ display: 'grid', gap: 6 }}>
|
|
170
|
+
{focused.map((user) => (
|
|
171
|
+
<div
|
|
172
|
+
key={user.userId}
|
|
173
|
+
style={{
|
|
174
|
+
border: `1px solid ${hashColor(user.userId)}44`,
|
|
175
|
+
borderRadius: 8,
|
|
176
|
+
padding: '6px 8px',
|
|
177
|
+
fontSize: 12,
|
|
178
|
+
}}
|
|
179
|
+
>
|
|
180
|
+
<span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>{' '}
|
|
181
|
+
<span style={{ color: '#6b7280' }}>focused</span>{' '}
|
|
182
|
+
<code style={{ color: '#111827' }}>{user.focusNode}</code>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
</div>
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface PresenceTypingListProps {
|
|
192
|
+
presence: PresenceUser[];
|
|
193
|
+
localUserId?: string;
|
|
194
|
+
className?: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function PresenceTypingList({
|
|
198
|
+
presence,
|
|
199
|
+
localUserId,
|
|
200
|
+
className,
|
|
201
|
+
}: PresenceTypingListProps) {
|
|
202
|
+
const typing = presence.filter(
|
|
203
|
+
(user) => user.userId !== localUserId && user.typing?.isTyping,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<div className={className} style={panelStyle()}>
|
|
208
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
209
|
+
Typing
|
|
210
|
+
</div>
|
|
211
|
+
{typing.length === 0 ? (
|
|
212
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No one is typing</div>
|
|
213
|
+
) : (
|
|
214
|
+
<div style={{ display: 'grid', gap: 6 }}>
|
|
215
|
+
{typing.map((user) => (
|
|
216
|
+
<div
|
|
217
|
+
key={user.userId}
|
|
218
|
+
style={{
|
|
219
|
+
display: 'flex',
|
|
220
|
+
alignItems: 'center',
|
|
221
|
+
gap: 8,
|
|
222
|
+
fontSize: 12,
|
|
223
|
+
borderRadius: 8,
|
|
224
|
+
padding: '6px 8px',
|
|
225
|
+
background: '#f9fafb',
|
|
226
|
+
}}
|
|
227
|
+
>
|
|
228
|
+
<div
|
|
229
|
+
style={{
|
|
230
|
+
width: 8,
|
|
231
|
+
height: 8,
|
|
232
|
+
borderRadius: '50%',
|
|
233
|
+
background: hashColor(user.userId),
|
|
234
|
+
}}
|
|
235
|
+
/>
|
|
236
|
+
<span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
|
|
237
|
+
<span style={{ color: '#6b7280' }}>
|
|
238
|
+
{user.typing?.field ? `typing in ${user.typing.field}` : 'typing'}
|
|
239
|
+
</span>
|
|
240
|
+
{user.typing?.isComposing ? (
|
|
241
|
+
<span style={{ color: '#92400e' }}>(composing)</span>
|
|
242
|
+
) : null}
|
|
243
|
+
</div>
|
|
244
|
+
))}
|
|
245
|
+
</div>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export interface PresenceSelectionListProps {
|
|
252
|
+
presence: PresenceUser[];
|
|
253
|
+
localUserId?: string;
|
|
254
|
+
className?: string;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function PresenceSelectionList({
|
|
258
|
+
presence,
|
|
259
|
+
localUserId,
|
|
260
|
+
className,
|
|
261
|
+
}: PresenceSelectionListProps) {
|
|
262
|
+
const selections = presence.filter(
|
|
263
|
+
(user) => user.userId !== localUserId && user.selection,
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div className={className} style={panelStyle()}>
|
|
268
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
269
|
+
Selections
|
|
270
|
+
</div>
|
|
271
|
+
{selections.length === 0 ? (
|
|
272
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No active selections</div>
|
|
273
|
+
) : (
|
|
274
|
+
<div style={{ display: 'grid', gap: 6 }}>
|
|
275
|
+
{selections.map((user) => (
|
|
276
|
+
<div
|
|
277
|
+
key={user.userId}
|
|
278
|
+
style={{
|
|
279
|
+
borderLeft: `4px solid ${hashColor(user.userId)}`,
|
|
280
|
+
paddingLeft: 8,
|
|
281
|
+
fontSize: 12,
|
|
282
|
+
}}
|
|
283
|
+
>
|
|
284
|
+
<div style={{ fontWeight: 600 }}>{displayUser(user.userId)}</div>
|
|
285
|
+
<div style={{ color: '#6b7280' }}>
|
|
286
|
+
{user.selection?.path ?? 'document'}: {user.selection?.start} -{' '}
|
|
287
|
+
{user.selection?.end}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
))}
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export interface PresenceScrollBarProps {
|
|
298
|
+
presence: PresenceUser[];
|
|
299
|
+
localUserId?: string;
|
|
300
|
+
height?: number;
|
|
301
|
+
className?: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function PresenceScrollBar({
|
|
305
|
+
presence,
|
|
306
|
+
localUserId,
|
|
307
|
+
height = 220,
|
|
308
|
+
className,
|
|
309
|
+
}: PresenceScrollBarProps) {
|
|
310
|
+
const users = presence.filter((user) => user.scroll);
|
|
311
|
+
|
|
312
|
+
return (
|
|
313
|
+
<div className={className} style={panelStyle()}>
|
|
314
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
315
|
+
Scroll Presence
|
|
316
|
+
</div>
|
|
317
|
+
<div style={{ display: 'flex', gap: 12 }}>
|
|
318
|
+
<div
|
|
319
|
+
style={{
|
|
320
|
+
position: 'relative',
|
|
321
|
+
width: 12,
|
|
322
|
+
height,
|
|
323
|
+
borderRadius: 999,
|
|
324
|
+
background: '#e5e7eb',
|
|
325
|
+
overflow: 'hidden',
|
|
326
|
+
}}
|
|
327
|
+
>
|
|
328
|
+
{users.map((user) => {
|
|
329
|
+
const depth = clampDepth(user.scroll?.depth ?? 0);
|
|
330
|
+
const color = hashColor(user.userId);
|
|
331
|
+
const isLocal = user.userId === localUserId;
|
|
332
|
+
return (
|
|
333
|
+
<div
|
|
334
|
+
key={user.userId}
|
|
335
|
+
title={`${displayUser(user.userId)}: ${Math.round(depth * 100)}%`}
|
|
336
|
+
style={{
|
|
337
|
+
position: 'absolute',
|
|
338
|
+
left: isLocal ? 0 : 1,
|
|
339
|
+
right: isLocal ? 0 : 1,
|
|
340
|
+
height: isLocal ? 4 : 3,
|
|
341
|
+
top: `calc(${depth * 100}% - ${isLocal ? 2 : 1.5}px)`,
|
|
342
|
+
borderRadius: 999,
|
|
343
|
+
background: color,
|
|
344
|
+
}}
|
|
345
|
+
/>
|
|
346
|
+
);
|
|
347
|
+
})}
|
|
348
|
+
</div>
|
|
349
|
+
<div style={{ display: 'grid', gap: 6, fontSize: 12, flex: 1 }}>
|
|
350
|
+
{users.length === 0 ? (
|
|
351
|
+
<div style={{ color: '#6b7280' }}>No scroll telemetry yet</div>
|
|
352
|
+
) : (
|
|
353
|
+
users.map((user) => {
|
|
354
|
+
const depth = clampDepth(user.scroll?.depth ?? 0);
|
|
355
|
+
return (
|
|
356
|
+
<div key={user.userId} style={{ display: 'flex', gap: 8 }}>
|
|
357
|
+
<span style={{ fontWeight: 600, minWidth: 68 }}>
|
|
358
|
+
{displayUser(user.userId)}
|
|
359
|
+
</span>
|
|
360
|
+
<span style={{ color: '#6b7280' }}>
|
|
361
|
+
{Math.round(depth * 100)}%
|
|
362
|
+
</span>
|
|
363
|
+
</div>
|
|
364
|
+
);
|
|
365
|
+
})
|
|
366
|
+
)}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</div>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export interface PresenceViewportListProps {
|
|
374
|
+
presence: PresenceUser[];
|
|
375
|
+
localUserId?: string;
|
|
376
|
+
className?: string;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function PresenceViewportList({
|
|
380
|
+
presence,
|
|
381
|
+
localUserId,
|
|
382
|
+
className,
|
|
383
|
+
}: PresenceViewportListProps) {
|
|
384
|
+
const users = presence.filter(
|
|
385
|
+
(user) => user.userId !== localUserId && user.viewport,
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<div className={className} style={panelStyle()}>
|
|
390
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
391
|
+
Viewports
|
|
392
|
+
</div>
|
|
393
|
+
{users.length === 0 ? (
|
|
394
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No viewport data</div>
|
|
395
|
+
) : (
|
|
396
|
+
<div style={{ display: 'grid', gap: 8 }}>
|
|
397
|
+
{users.map((user) => {
|
|
398
|
+
const viewport = user.viewport;
|
|
399
|
+
if (!viewport) return null;
|
|
400
|
+
const ratio = viewport.width / Math.max(1, viewport.height);
|
|
401
|
+
return (
|
|
402
|
+
<div key={user.userId} style={{ fontSize: 12 }}>
|
|
403
|
+
<div style={{ marginBottom: 4 }}>
|
|
404
|
+
<span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>{' '}
|
|
405
|
+
<span style={{ color: '#6b7280' }}>
|
|
406
|
+
{viewport.width}x{viewport.height}
|
|
407
|
+
</span>
|
|
408
|
+
</div>
|
|
409
|
+
<div
|
|
410
|
+
style={{
|
|
411
|
+
height: 6,
|
|
412
|
+
borderRadius: 999,
|
|
413
|
+
background: '#e5e7eb',
|
|
414
|
+
overflow: 'hidden',
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
<div
|
|
418
|
+
style={{
|
|
419
|
+
width: `${Math.min(100, Math.max(10, ratio * 40))}%`,
|
|
420
|
+
height: '100%',
|
|
421
|
+
borderRadius: 999,
|
|
422
|
+
background: hashColor(user.userId),
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
})}
|
|
429
|
+
</div>
|
|
430
|
+
)}
|
|
431
|
+
</div>
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export interface PresenceInputStateListProps {
|
|
436
|
+
presence: PresenceUser[];
|
|
437
|
+
localUserId?: string;
|
|
438
|
+
className?: string;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
export function PresenceInputStateList({
|
|
442
|
+
presence,
|
|
443
|
+
localUserId,
|
|
444
|
+
className,
|
|
445
|
+
}: PresenceInputStateListProps) {
|
|
446
|
+
const users = presence.filter(
|
|
447
|
+
(user) => user.userId !== localUserId && user.inputState,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
return (
|
|
451
|
+
<div className={className} style={panelStyle()}>
|
|
452
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
453
|
+
Input States
|
|
454
|
+
</div>
|
|
455
|
+
{users.length === 0 ? (
|
|
456
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No active input state</div>
|
|
457
|
+
) : (
|
|
458
|
+
<div style={{ display: 'grid', gap: 6 }}>
|
|
459
|
+
{users.map((user) => {
|
|
460
|
+
const state = user.inputState;
|
|
461
|
+
if (!state) return null;
|
|
462
|
+
return (
|
|
463
|
+
<div
|
|
464
|
+
key={user.userId}
|
|
465
|
+
style={{
|
|
466
|
+
display: 'flex',
|
|
467
|
+
flexWrap: 'wrap',
|
|
468
|
+
gap: 8,
|
|
469
|
+
fontSize: 12,
|
|
470
|
+
padding: '6px 8px',
|
|
471
|
+
borderRadius: 8,
|
|
472
|
+
background: '#f9fafb',
|
|
473
|
+
}}
|
|
474
|
+
>
|
|
475
|
+
<span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
|
|
476
|
+
<span style={{ color: '#6b7280' }}>{state.field}</span>
|
|
477
|
+
<span>{state.hasFocus ? 'focused' : 'blurred'}</span>
|
|
478
|
+
{state.selectionStart !== undefined && state.selectionEnd !== undefined ? (
|
|
479
|
+
<span style={{ color: '#6b7280' }}>
|
|
480
|
+
caret {state.selectionStart}-{state.selectionEnd}
|
|
481
|
+
</span>
|
|
482
|
+
) : null}
|
|
483
|
+
{state.valueLength !== undefined ? (
|
|
484
|
+
<span style={{ color: '#6b7280' }}>len {state.valueLength}</span>
|
|
485
|
+
) : null}
|
|
486
|
+
</div>
|
|
487
|
+
);
|
|
488
|
+
})}
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export interface PresenceEmotionListProps {
|
|
496
|
+
presence: PresenceUser[];
|
|
497
|
+
localUserId?: string;
|
|
498
|
+
className?: string;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function PresenceEmotionList({
|
|
502
|
+
presence,
|
|
503
|
+
localUserId,
|
|
504
|
+
className,
|
|
505
|
+
}: PresenceEmotionListProps) {
|
|
506
|
+
const users = presence.filter(
|
|
507
|
+
(user) => user.userId !== localUserId && user.emotion,
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
return (
|
|
511
|
+
<div className={className} style={panelStyle()}>
|
|
512
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
513
|
+
Emotion Channel
|
|
514
|
+
</div>
|
|
515
|
+
{users.length === 0 ? (
|
|
516
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No emotion signal</div>
|
|
517
|
+
) : (
|
|
518
|
+
<div style={{ display: 'grid', gap: 8 }}>
|
|
519
|
+
{users.map((user) => {
|
|
520
|
+
const emotion = user.emotion;
|
|
521
|
+
if (!emotion) return null;
|
|
522
|
+
const intensity = Math.max(
|
|
523
|
+
0,
|
|
524
|
+
Math.min(1, emotion.intensity ?? emotion.confidence ?? 0),
|
|
525
|
+
);
|
|
526
|
+
return (
|
|
527
|
+
<div key={user.userId} style={{ fontSize: 12 }}>
|
|
528
|
+
<div style={{ marginBottom: 4 }}>
|
|
529
|
+
<span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>{' '}
|
|
530
|
+
<span style={{ color: '#6b7280' }}>
|
|
531
|
+
{emotion.primary ?? 'unspecified'}
|
|
532
|
+
</span>
|
|
533
|
+
</div>
|
|
534
|
+
<div
|
|
535
|
+
style={{
|
|
536
|
+
height: 6,
|
|
537
|
+
borderRadius: 999,
|
|
538
|
+
background: '#e5e7eb',
|
|
539
|
+
overflow: 'hidden',
|
|
540
|
+
}}
|
|
541
|
+
>
|
|
542
|
+
<div
|
|
543
|
+
style={{
|
|
544
|
+
width: `${Math.round(intensity * 100)}%`,
|
|
545
|
+
height: '100%',
|
|
546
|
+
borderRadius: 999,
|
|
547
|
+
background: hashColor(user.userId),
|
|
548
|
+
}}
|
|
549
|
+
/>
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
})}
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
</div>
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
export interface PresenceEditingListProps {
|
|
561
|
+
presence: PresenceUser[];
|
|
562
|
+
localUserId?: string;
|
|
563
|
+
className?: string;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function PresenceEditingList({
|
|
567
|
+
presence,
|
|
568
|
+
localUserId,
|
|
569
|
+
className,
|
|
570
|
+
}: PresenceEditingListProps) {
|
|
571
|
+
const users = presence.filter((user) => user.userId !== localUserId && user.editing);
|
|
572
|
+
|
|
573
|
+
return (
|
|
574
|
+
<div className={className} style={panelStyle()}>
|
|
575
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
576
|
+
Editing Targets
|
|
577
|
+
</div>
|
|
578
|
+
{users.length === 0 ? (
|
|
579
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No active edit targets</div>
|
|
580
|
+
) : (
|
|
581
|
+
<div style={{ display: 'grid', gap: 6 }}>
|
|
582
|
+
{users.map((user) => (
|
|
583
|
+
<div
|
|
584
|
+
key={user.userId}
|
|
585
|
+
style={{
|
|
586
|
+
display: 'flex',
|
|
587
|
+
gap: 8,
|
|
588
|
+
alignItems: 'center',
|
|
589
|
+
fontSize: 12,
|
|
590
|
+
}}
|
|
591
|
+
>
|
|
592
|
+
<span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
|
|
593
|
+
<code style={{ color: '#6b7280' }}>{user.editing}</code>
|
|
594
|
+
</div>
|
|
595
|
+
))}
|
|
596
|
+
</div>
|
|
597
|
+
)}
|
|
598
|
+
</div>
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export interface PresenceStatusListProps {
|
|
603
|
+
presence: PresenceUser[];
|
|
604
|
+
localUserId?: string;
|
|
605
|
+
className?: string;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
export function PresenceStatusList({
|
|
609
|
+
presence,
|
|
610
|
+
localUserId,
|
|
611
|
+
className,
|
|
612
|
+
}: PresenceStatusListProps) {
|
|
613
|
+
const users = presence.filter((user) => user.userId !== localUserId);
|
|
614
|
+
|
|
615
|
+
return (
|
|
616
|
+
<div className={className} style={panelStyle()}>
|
|
617
|
+
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 8 }}>
|
|
618
|
+
Status
|
|
619
|
+
</div>
|
|
620
|
+
{users.length === 0 ? (
|
|
621
|
+
<div style={{ color: '#6b7280', fontSize: 13 }}>No collaborators online</div>
|
|
622
|
+
) : (
|
|
623
|
+
<div style={{ display: 'grid', gap: 6 }}>
|
|
624
|
+
{users.map((user) => {
|
|
625
|
+
const color =
|
|
626
|
+
user.status === 'online'
|
|
627
|
+
? '#10b981'
|
|
628
|
+
: user.status === 'away'
|
|
629
|
+
? '#f59e0b'
|
|
630
|
+
: '#9ca3af';
|
|
631
|
+
return (
|
|
632
|
+
<div
|
|
633
|
+
key={user.userId}
|
|
634
|
+
style={{
|
|
635
|
+
display: 'flex',
|
|
636
|
+
alignItems: 'center',
|
|
637
|
+
gap: 8,
|
|
638
|
+
fontSize: 12,
|
|
639
|
+
}}
|
|
640
|
+
>
|
|
641
|
+
<span
|
|
642
|
+
style={{
|
|
643
|
+
width: 8,
|
|
644
|
+
height: 8,
|
|
645
|
+
borderRadius: '50%',
|
|
646
|
+
background: color,
|
|
647
|
+
}}
|
|
648
|
+
/>
|
|
649
|
+
<span style={{ fontWeight: 600 }}>{displayUser(user.userId)}</span>
|
|
650
|
+
<span style={{ color: '#6b7280' }}>
|
|
651
|
+
{user.role} {user.status}
|
|
652
|
+
</span>
|
|
653
|
+
<span style={{ color: '#9ca3af' }}>
|
|
654
|
+
{formatLastActivity(user.lastActivity)}
|
|
655
|
+
</span>
|
|
656
|
+
</div>
|
|
657
|
+
);
|
|
658
|
+
})}
|
|
659
|
+
</div>
|
|
660
|
+
)}
|
|
661
|
+
</div>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export interface PresenceElementsPanelProps {
|
|
666
|
+
presence: PresenceUser[];
|
|
667
|
+
localUserId?: string;
|
|
668
|
+
className?: string;
|
|
669
|
+
showCursorLayer?: boolean;
|
|
670
|
+
cursorLayerHeight?: number | string;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export function PresenceElementsPanel({
|
|
674
|
+
presence,
|
|
675
|
+
localUserId,
|
|
676
|
+
className,
|
|
677
|
+
showCursorLayer = true,
|
|
678
|
+
cursorLayerHeight = 220,
|
|
679
|
+
}: PresenceElementsPanelProps) {
|
|
680
|
+
return (
|
|
681
|
+
<div className={className} style={{ display: 'grid', gap: 10 }}>
|
|
682
|
+
{showCursorLayer ? (
|
|
683
|
+
<PresenceCursorLayer
|
|
684
|
+
presence={presence}
|
|
685
|
+
localUserId={localUserId}
|
|
686
|
+
height={cursorLayerHeight}
|
|
687
|
+
/>
|
|
688
|
+
) : null}
|
|
689
|
+
<PresenceStatusList presence={presence} localUserId={localUserId} />
|
|
690
|
+
<PresenceEditingList presence={presence} localUserId={localUserId} />
|
|
691
|
+
<PresenceTypingList presence={presence} localUserId={localUserId} />
|
|
692
|
+
<PresenceFocusList presence={presence} localUserId={localUserId} />
|
|
693
|
+
<PresenceSelectionList presence={presence} localUserId={localUserId} />
|
|
694
|
+
<PresenceScrollBar presence={presence} localUserId={localUserId} />
|
|
695
|
+
<PresenceViewportList presence={presence} localUserId={localUserId} />
|
|
696
|
+
<PresenceInputStateList presence={presence} localUserId={localUserId} />
|
|
697
|
+
<PresenceEmotionList presence={presence} localUserId={localUserId} />
|
|
698
|
+
</div>
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
export interface CollaborativePresenceScrollContainerProps {
|
|
703
|
+
children: ReactNode;
|
|
704
|
+
presence: PresenceUser[];
|
|
705
|
+
localUserId?: string;
|
|
706
|
+
height?: number | string;
|
|
707
|
+
className?: string;
|
|
708
|
+
style?: CSSProperties;
|
|
709
|
+
onScrollStateChange?: (scroll: PresenceScroll) => void;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function CollaborativePresenceScrollContainer({
|
|
713
|
+
children,
|
|
714
|
+
presence,
|
|
715
|
+
localUserId,
|
|
716
|
+
height = 320,
|
|
717
|
+
className,
|
|
718
|
+
style,
|
|
719
|
+
onScrollStateChange,
|
|
720
|
+
}: CollaborativePresenceScrollContainerProps) {
|
|
721
|
+
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
722
|
+
const [localDepth, setLocalDepth] = useState(0);
|
|
723
|
+
|
|
724
|
+
const markers = useMemo(
|
|
725
|
+
() => presence.filter((user) => user.scroll),
|
|
726
|
+
[presence],
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
useEffect(() => {
|
|
730
|
+
const element = containerRef.current;
|
|
731
|
+
if (!element) return;
|
|
732
|
+
|
|
733
|
+
const update = () => {
|
|
734
|
+
const denominator = Math.max(1, element.scrollHeight - element.clientHeight);
|
|
735
|
+
const depth = clampDepth(element.scrollTop / denominator);
|
|
736
|
+
setLocalDepth(depth);
|
|
737
|
+
onScrollStateChange?.({
|
|
738
|
+
depth,
|
|
739
|
+
y: element.scrollTop,
|
|
740
|
+
viewportHeight: element.clientHeight,
|
|
741
|
+
documentHeight: element.scrollHeight,
|
|
742
|
+
});
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
update();
|
|
746
|
+
element.addEventListener('scroll', update, { passive: true });
|
|
747
|
+
return () => {
|
|
748
|
+
element.removeEventListener('scroll', update);
|
|
749
|
+
};
|
|
750
|
+
}, [onScrollStateChange]);
|
|
751
|
+
|
|
752
|
+
return (
|
|
753
|
+
<div
|
|
754
|
+
className={className}
|
|
755
|
+
style={{
|
|
756
|
+
...panelStyle({
|
|
757
|
+
position: 'relative',
|
|
758
|
+
height,
|
|
759
|
+
overflow: 'hidden',
|
|
760
|
+
padding: 0,
|
|
761
|
+
}),
|
|
762
|
+
...style,
|
|
763
|
+
}}
|
|
764
|
+
>
|
|
765
|
+
<div
|
|
766
|
+
ref={containerRef}
|
|
767
|
+
style={{
|
|
768
|
+
height: '100%',
|
|
769
|
+
overflowY: 'auto',
|
|
770
|
+
scrollbarWidth: 'none',
|
|
771
|
+
msOverflowStyle: 'none',
|
|
772
|
+
paddingRight: 22,
|
|
773
|
+
padding: 12,
|
|
774
|
+
}}
|
|
775
|
+
>
|
|
776
|
+
{children}
|
|
777
|
+
</div>
|
|
778
|
+
|
|
779
|
+
<div
|
|
780
|
+
style={{
|
|
781
|
+
position: 'absolute',
|
|
782
|
+
top: 10,
|
|
783
|
+
bottom: 10,
|
|
784
|
+
right: 6,
|
|
785
|
+
width: 10,
|
|
786
|
+
borderRadius: 999,
|
|
787
|
+
background: '#e5e7eb',
|
|
788
|
+
}}
|
|
789
|
+
>
|
|
790
|
+
<div
|
|
791
|
+
style={{
|
|
792
|
+
position: 'absolute',
|
|
793
|
+
left: 0,
|
|
794
|
+
right: 0,
|
|
795
|
+
height: 4,
|
|
796
|
+
top: `calc(${localDepth * 100}% - 2px)`,
|
|
797
|
+
borderRadius: 999,
|
|
798
|
+
background: '#111827',
|
|
799
|
+
}}
|
|
800
|
+
title={localUserId ? `${displayUser(localUserId)} (you)` : 'you'}
|
|
801
|
+
/>
|
|
802
|
+
|
|
803
|
+
{markers.map((user) => {
|
|
804
|
+
const depth = clampDepth(user.scroll?.depth ?? 0);
|
|
805
|
+
return (
|
|
806
|
+
<div
|
|
807
|
+
key={user.userId}
|
|
808
|
+
title={`${displayUser(user.userId)}: ${Math.round(depth * 100)}%`}
|
|
809
|
+
style={{
|
|
810
|
+
position: 'absolute',
|
|
811
|
+
left: 1,
|
|
812
|
+
right: 1,
|
|
813
|
+
height: 3,
|
|
814
|
+
borderRadius: 999,
|
|
815
|
+
top: `calc(${depth * 100}% - 1.5px)`,
|
|
816
|
+
background: hashColor(user.userId),
|
|
817
|
+
}}
|
|
818
|
+
/>
|
|
819
|
+
);
|
|
820
|
+
})}
|
|
821
|
+
</div>
|
|
822
|
+
</div>
|
|
823
|
+
);
|
|
824
|
+
}
|