deepspace 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +9812 -4674
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +107 -117
  8. package/dist/index.js +610 -779
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +115 -0
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +397 -160
  17. package/dist/worker.js +506 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +158 -862
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +2 -3
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. package/scripts/add-feature.cjs +0 -807
@@ -0,0 +1,163 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ reduceStreamMessages,
4
+ type InFlightMessage,
5
+ } from '../ChatPanel.stream'
6
+
7
+ function assistant(parts: InFlightMessage['parts'] = []): InFlightMessage {
8
+ return {
9
+ id: 'assistant-1',
10
+ role: 'assistant',
11
+ content: '',
12
+ parts,
13
+ forChatId: 'chat-1',
14
+ }
15
+ }
16
+
17
+ describe('reduceStreamMessages', () => {
18
+ it('coalesces adjacent text while preserving tool chronology', () => {
19
+ const afterText = reduceStreamMessages(
20
+ [assistant()],
21
+ { type: 'append-text', delta: 'Hello' },
22
+ 'assistant-1',
23
+ )
24
+ const afterTool = reduceStreamMessages(
25
+ afterText,
26
+ {
27
+ type: 'upsert-tool-call',
28
+ toolCallId: 'tool-1',
29
+ toolName: 'records.query',
30
+ input: { collection: 'tasks' },
31
+ },
32
+ 'assistant-1',
33
+ )
34
+ const result = reduceStreamMessages(
35
+ afterTool,
36
+ { type: 'append-text', delta: 'Done' },
37
+ 'assistant-1',
38
+ )
39
+
40
+ expect(result[0].content).toBe('HelloDone')
41
+ expect(result[0].parts).toEqual([
42
+ { type: 'text', text: 'Hello' },
43
+ {
44
+ type: 'tool-invocation',
45
+ toolCallId: 'tool-1',
46
+ toolInvocation: {
47
+ toolName: 'records.query',
48
+ state: 'call',
49
+ args: { collection: 'tasks' },
50
+ },
51
+ },
52
+ { type: 'text', text: 'Done' },
53
+ ])
54
+ })
55
+
56
+ it('upserts duplicate tool calls and finalizes their result', () => {
57
+ const started = reduceStreamMessages(
58
+ [assistant()],
59
+ {
60
+ type: 'upsert-tool-call',
61
+ toolCallId: 'tool-1',
62
+ toolName: 'records.query',
63
+ input: { collection: 'old' },
64
+ },
65
+ 'assistant-1',
66
+ )
67
+ const replaced = reduceStreamMessages(
68
+ started,
69
+ {
70
+ type: 'upsert-tool-call',
71
+ toolCallId: 'tool-1',
72
+ toolName: 'records.query',
73
+ input: { collection: 'tasks' },
74
+ },
75
+ 'assistant-1',
76
+ )
77
+ const result = reduceStreamMessages(
78
+ replaced,
79
+ {
80
+ type: 'finalize-tool-call',
81
+ toolCallId: 'tool-1',
82
+ result: { records: [] },
83
+ },
84
+ 'assistant-1',
85
+ )
86
+
87
+ expect(result[0].parts).toHaveLength(1)
88
+ expect(result[0].parts[0]).toMatchObject({
89
+ toolInvocation: {
90
+ state: 'result',
91
+ args: { collection: 'tasks' },
92
+ result: { records: [] },
93
+ },
94
+ })
95
+ })
96
+
97
+ it('surfaces input failures as completed failed tool rows', () => {
98
+ const result = reduceStreamMessages(
99
+ [assistant()],
100
+ {
101
+ type: 'fail-tool-input',
102
+ toolCallId: 'tool-1',
103
+ toolName: 'records.update',
104
+ input: { recordId: 42 },
105
+ errorText: 'recordId must be a string',
106
+ },
107
+ 'assistant-1',
108
+ )
109
+
110
+ expect(result[0].parts[0]).toMatchObject({
111
+ toolInvocation: {
112
+ state: 'result',
113
+ result: { success: false, error: 'recordId must be a string' },
114
+ },
115
+ })
116
+ })
117
+
118
+ it('drops an empty stopped assistant but preserves partial output', () => {
119
+ const empty = reduceStreamMessages(
120
+ [assistant()],
121
+ { type: 'abort' },
122
+ 'assistant-1',
123
+ )
124
+ const partial = reduceStreamMessages(
125
+ [assistant([{ type: 'text', text: 'Partial' }])],
126
+ { type: 'abort' },
127
+ 'assistant-1',
128
+ )
129
+
130
+ expect(empty).toEqual([])
131
+ expect(partial).toHaveLength(1)
132
+ })
133
+
134
+ it('drops an empty failed assistant without touching other overlays', () => {
135
+ const user: InFlightMessage = {
136
+ id: 'user-1',
137
+ role: 'user',
138
+ content: 'Hello',
139
+ parts: [],
140
+ forChatId: 'chat-1',
141
+ }
142
+ const result = reduceStreamMessages(
143
+ [user, assistant()],
144
+ { type: 'stream-error', errorText: 'rate limited' },
145
+ 'assistant-1',
146
+ )
147
+
148
+ expect(result).toEqual([user])
149
+ })
150
+
151
+ it('preserves partial assistant output when a later stream error arrives', () => {
152
+ const partial = assistant([{ type: 'text', text: 'Partial' }])
153
+ partial.content = 'Partial'
154
+
155
+ const result = reduceStreamMessages(
156
+ [partial],
157
+ { type: 'stream-error', errorText: 'connection lost' },
158
+ 'assistant-1',
159
+ )
160
+
161
+ expect(result).toEqual([partial])
162
+ })
163
+ })
@@ -47,7 +47,7 @@ function CreateCanvasModal({ isOpen, onClose, onCreate }: CreateCanvasModalProps
47
47
 
48
48
  return (
49
49
  <Modal open={isOpen} onClose={onClose} size="sm">
50
- <Modal.Header onClose={onClose}>
50
+ <Modal.Header>
51
51
  <Modal.Title>New Canvas</Modal.Title>
52
52
  </Modal.Header>
53
53
  <Modal.Body>
@@ -14,12 +14,12 @@
14
14
  "dest": "src/pages/docs/[docId].tsx"
15
15
  },
16
16
  {
17
- "src": "src/InviteDialog.tsx",
18
- "dest": "src/pages/docs/InviteDialog.tsx"
17
+ "src": "src/use-docs-presence-access.ts",
18
+ "dest": "src/pages/docs/use-docs-presence-access.ts"
19
19
  },
20
20
  {
21
- "src": "src/use-yjs-room-with-awareness.ts",
22
- "dest": "src/pages/docs/use-yjs-room-with-awareness.ts"
21
+ "src": "src/InviteDialog.tsx",
22
+ "dest": "src/pages/docs/InviteDialog.tsx"
23
23
  },
24
24
  {
25
25
  "src": "src/docs-ui.css",
@@ -33,10 +33,6 @@
33
33
  "src": "src/docs-library-types.ts",
34
34
  "dest": "src/pages/docs/docs-library-types.ts"
35
35
  },
36
- {
37
- "src": "src/docs-favorites.ts",
38
- "dest": "src/pages/docs/docs-favorites.ts"
39
- },
40
36
  {
41
37
  "src": "src/LibrarySidebar.tsx",
42
38
  "dest": "src/pages/docs/LibrarySidebar.tsx"
@@ -15,114 +15,33 @@
15
15
 
16
16
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
17
17
  import { useParams, useNavigate, useRouteError, isRouteErrorResponse } from 'react-router-dom'
18
- import {
19
- useUser,
20
- useQuery,
21
- useMutations,
22
- usePresenceRoom,
23
- getUserColor,
24
- type PresencePeerClient,
25
- } from 'deepspace'
18
+ import { getUserColor, useMutations, useQuery, useUser, useYjsRoom } from 'deepspace'
26
19
  import { ArrowLeft, AlertTriangle, List as ListIcon, RefreshCw, Share2 } from 'lucide-react'
27
20
  import { Badge } from '@/components/ui'
28
21
  import { type Editor, useEditorState } from '@tiptap/react'
29
- import { useYjsRoomWithAwareness } from './use-yjs-room-with-awareness'
30
22
  import { useDocEditor } from './editor/useDocEditor'
31
23
  import { DocEditorSurface, PAGE_HEIGHT_PX, PAGE_WIDTH_PX } from './editor/DocEditorSurface'
32
24
  import { DocsTiptapToolbar } from './editor/DocsTiptapToolbar'
33
25
  import { DocsOutlinePanel, DOCUMENT_OUTLINE_WIDTH_PX, type OutlineEntry } from './DocsOutlinePanel'
34
- import { DocsPresence, type DocsPresenceParticipant } from './DocsPresence'
35
- import { InviteDialog, type InviteAclDiff } from './InviteDialog'
26
+ import { DocsPresence } from './DocsPresence'
27
+ import { InviteDialog } from './InviteDialog'
28
+ import type { DocsDocumentFields } from './docs-library-types'
29
+ import {
30
+ useDocsEditorPresence,
31
+ useDocsPresenceAccess,
32
+ type AccessChangeKind,
33
+ } from './use-docs-presence-access'
36
34
  import './docs-ui.css'
37
35
 
38
- interface DocumentFields {
39
- title: string
40
- ownerId: string
41
- collaborators?: string
42
- editors?: string
43
- folderId?: string
44
- }
45
-
46
36
  const CANVAS_ZOOM_KEY = 'deepspace-docs-editor-canvas-zoom'
47
37
  const OUTLINE_OPEN_KEY = 'deepspace-docs-editor-outline-open'
48
38
  const KEYBOARD_ZOOM_STEP = 0.1
49
- const TYPING_IDLE_MS = 1600
50
- const TYPING_STALE_MS = 5000
51
- /** Re-broadcast presence so clients who connect later still see existing viewers. */
52
- const PRESENCE_HEARTBEAT_MS = 25_000
53
39
  const DOC_NOT_FOUND_GRACE_MS = 450
54
40
 
55
41
  function normalizeZoom(z: number): number {
56
42
  return Math.min(2, Math.max(0.5, Math.round(z * 1000) / 1000))
57
43
  }
58
44
 
59
- function parseIdList(raw: string | undefined): string[] {
60
- if (!raw) return []
61
- try {
62
- const parsed = JSON.parse(raw)
63
- return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
64
- } catch {
65
- return []
66
- }
67
- }
68
-
69
- function peerToDocsParticipant(p: PresencePeerClient): DocsPresenceParticipant {
70
- const state = p.state
71
- const mode: DocsPresenceParticipant['mode'] = state.mode === 'view' ? 'view' : 'edit'
72
- const lastTypedAt = typeof state.lastTypedAt === 'number' ? state.lastTypedAt : undefined
73
- const participant: DocsPresenceParticipant = {
74
- clientId: 0,
75
- userId: p.userId,
76
- name: p.userName?.trim() || p.userEmail?.trim() || 'Guest',
77
- mode,
78
- typing: state.typing === true,
79
- isSelf: false,
80
- }
81
- if (p.userEmail) participant.email = p.userEmail
82
- if (p.userImageUrl) participant.imageUrl = p.userImageUrl
83
- if (lastTypedAt != null) participant.lastTypedAt = lastTypedAt
84
- return participant
85
- }
86
-
87
- function sortDocsPresenceParticipants(
88
- a: DocsPresenceParticipant,
89
- b: DocsPresenceParticipant,
90
- ): number {
91
- if (a.isSelf !== b.isSelf) return a.isSelf ? -1 : 1
92
- if (a.mode !== b.mode) return a.mode === 'edit' ? -1 : 1
93
- return a.name.localeCompare(b.name)
94
- }
95
-
96
- interface DocsPresenceSelfUser {
97
- id: string
98
- name?: string | null
99
- email?: string | null
100
- imageUrl?: string | null
101
- }
102
-
103
- function buildDocsPresence(
104
- peers: PresencePeerClient[],
105
- self: DocsPresenceSelfUser | null | undefined,
106
- presenceAsEditMode: boolean,
107
- ): DocsPresenceParticipant[] {
108
- if (!self) {
109
- return [...peers.map(peerToDocsParticipant)].sort(sortDocsPresenceParticipants)
110
- }
111
-
112
- const selfRow: DocsPresenceParticipant = {
113
- clientId: 0,
114
- userId: self.id,
115
- name: self.name?.trim() || self.email?.trim() || 'You',
116
- mode: presenceAsEditMode ? 'edit' : 'view',
117
- isSelf: true,
118
- }
119
- if (self.email) selfRow.email = self.email
120
- if (self.imageUrl) selfRow.imageUrl = self.imageUrl
121
-
122
- const others = peers.filter((p) => p.userId !== self.id).map(peerToDocsParticipant)
123
- return [selfRow, ...others].sort(sortDocsPresenceParticipants)
124
- }
125
-
126
45
  function InlineTitle({
127
46
  title,
128
47
  canEdit,
@@ -190,8 +109,6 @@ function InlineTitle({
190
109
  )
191
110
  }
192
111
 
193
- type AccessChangeKind = 'downgrade' | 'upgrade' | 'revoked'
194
-
195
112
  /**
196
113
  * Full-screen overlay shown to the second user when the owner changes their
197
114
  * permissions mid-session. Three cases:
@@ -357,7 +274,7 @@ export default function DocsEditorPage() {
357
274
  const navigate = useNavigate()
358
275
  const { user } = useUser()
359
276
 
360
- const { records: documents, status } = useQuery<DocumentFields>('documents', {
277
+ const { records: documents, status } = useQuery<DocsDocumentFields>('documents', {
361
278
  orderBy: 'createdAt',
362
279
  orderDir: 'desc',
363
280
  })
@@ -367,7 +284,7 @@ export default function DocsEditorPage() {
367
284
  [documents, docId],
368
285
  )
369
286
 
370
- const { put } = useMutations<DocumentFields>('documents')
287
+ const { put } = useMutations<DocsDocumentFields>('documents')
371
288
 
372
289
  /**
373
290
  * `'content'` is kept as the field name only so legacy-doc migration can
@@ -382,18 +299,25 @@ export default function DocsEditorPage() {
382
299
  canWrite,
383
300
  writeAuthResolved,
384
301
  awareness,
385
- } = useYjsRoomWithAwareness(docId ?? 'noop', 'content')
302
+ } = useYjsRoom(docId ?? 'noop', 'content')
386
303
 
387
- const presenceScopeId = docId ? `doc:${docId}` : '_'
304
+ const presenceAccess = useDocsPresenceAccess({
305
+ docId,
306
+ document: selectedDoc,
307
+ user,
308
+ yjsCanWrite: canWrite,
309
+ writeAuthResolved,
310
+ })
388
311
  const {
389
- peers: presencePeers,
390
- connected: presenceConnected,
391
- updateState: updatePresenceState,
392
- } = usePresenceRoom(presenceScopeId)
312
+ isOwner,
313
+ effectiveRole,
314
+ effectiveCanWrite,
315
+ showReadOnlyDocUx,
316
+ accessChangeKind,
317
+ handleAclChange,
318
+ } = presenceAccess
393
319
 
394
320
  const [inviteOpen, setInviteOpen] = useState(false)
395
- const typingRef = useRef(false)
396
- const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
397
321
 
398
322
  const [canvasZoom, setCanvasZoom] = useState(() => {
399
323
  if (typeof window === 'undefined') return 1
@@ -451,101 +375,6 @@ export default function DocsEditorPage() {
451
375
  }
452
376
  }, [outlineOpen])
453
377
 
454
- // Permissions ----------------------------------------------------------------
455
- const isOwner = selectedDoc?.data.ownerId === user?.id
456
- const collaboratorIds = useMemo(
457
- () => parseIdList(selectedDoc?.data.collaborators),
458
- [selectedDoc?.data.collaborators],
459
- )
460
- const editorIds = useMemo(
461
- () => parseIdList(selectedDoc?.data.editors),
462
- [selectedDoc?.data.editors],
463
- )
464
- const effectiveRole: 'owner' | 'editor' | 'viewer' | 'none' = !user
465
- ? 'none'
466
- : isOwner
467
- ? 'owner'
468
- : editorIds.includes(user.id)
469
- ? 'editor'
470
- : collaboratorIds.includes(user.id)
471
- ? 'viewer'
472
- : 'none'
473
- const policyCanEdit = effectiveRole === 'owner' || effectiveRole === 'editor'
474
-
475
- /**
476
- * Snapshot of the *first* concrete role this peer saw for this `docId`,
477
- * plus the wall-clock instant when we captured it. We can't trust
478
- * `effectiveRole` alone to detect "the owner just removed me" because
479
- * role transitions (none → editor on initial fetch, editor → viewer on
480
- * demote, viewer → none on remove) all flow through the same `none`
481
- * value before/after `selectedDoc` resolves. The role tells us
482
- * downgrade/upgrade vs. still-loading; the timestamp lets us ignore
483
- * stale `aclSignal` presence updates from before we joined.
484
- */
485
- const [initialRole, setInitialRole] = useState<typeof effectiveRole | null>(null)
486
- const sessionStartedAtRef = useRef<number | null>(null)
487
- useEffect(() => {
488
- setInitialRole(null)
489
- sessionStartedAtRef.current = null
490
- }, [docId])
491
- useEffect(() => {
492
- if (initialRole) return
493
- if (!selectedDoc || !user) return
494
- if (effectiveRole === 'none') return
495
- setInitialRole(effectiveRole)
496
- sessionStartedAtRef.current = Date.now()
497
- }, [initialRole, selectedDoc, user, effectiveRole])
498
-
499
- /**
500
- * Latched permission-change event delivered over presence. The owner
501
- * publishes an `aclSignal` payload after every InviteDialog save; peers
502
- * watch the owner's presence state and freeze the first signal addressed
503
- * to them. This is the only mechanism that reaches a *removed* user,
504
- * since the docs schema's `read: 'collaborator'` rule means the
505
- * documents-record update with the new collaborators list is filtered
506
- * out of their RecordRoom subscription — the kicked client would
507
- * otherwise keep its stale role indefinitely and let the user keep
508
- * typing until they refresh.
509
- */
510
- type DetectedAclEvent = { kind: AccessChangeKind; at: number }
511
- const [detectedAclEvent, setDetectedAclEvent] = useState<DetectedAclEvent | null>(null)
512
- useEffect(() => {
513
- setDetectedAclEvent(null)
514
- }, [docId])
515
-
516
- const accessChangeKind: AccessChangeKind | null = useMemo(() => {
517
- // Latched presence-derived revoke always wins — it's the only signal
518
- // that reaches a peer whose record subscription was filtered out.
519
- if (detectedAclEvent?.kind === 'revoked') return 'revoked'
520
-
521
- // Local-record-derived transitions cover the cases where the
522
- // documents record still propagates (downgrade / upgrade). They
523
- // need an initial concrete role to compare against; without one
524
- // we fall through to whatever presence latched.
525
- if (initialRole && initialRole !== 'owner') {
526
- if (effectiveRole === 'none') return 'revoked'
527
- if (initialRole === 'editor' && effectiveRole === 'viewer') return 'downgrade'
528
- if (initialRole === 'viewer' && effectiveRole === 'editor') return 'upgrade'
529
- }
530
-
531
- return detectedAclEvent?.kind ?? null
532
- }, [initialRole, effectiveRole, detectedAclEvent])
533
- const accessLocked = accessChangeKind !== null
534
-
535
- /**
536
- * Once the owner revokes/downgrades, we hard-stop further writes from this
537
- * peer even if Yjs/server auth hasn't caught up yet. Combined with the
538
- * blocking overlay below, this prevents the "removed user can still type
539
- * until refresh" window described in the report.
540
- */
541
- const writesLockedByAcl = accessLocked && accessChangeKind !== 'upgrade'
542
- const effectiveCanWrite = canWrite && policyCanEdit && !writesLockedByAcl
543
- /** Collaborator viewers always see read-only chrome; editors/owners only after Yjs auth resolves and denies write. */
544
- const showReadOnlyDocUx =
545
- effectiveRole === 'viewer' || (policyCanEdit && writeAuthResolved && !canWrite)
546
- const presenceAsEditMode =
547
- policyCanEdit && !(writeAuthResolved && !canWrite) && !writesLockedByAcl
548
-
549
378
  // Tiptap editor --------------------------------------------------------------
550
379
  const userName = user?.name?.trim() || user?.email?.trim() || 'Guest'
551
380
  const userColor = useMemo(() => getUserColor(user?.id ?? 'anon'), [user?.id])
@@ -590,135 +419,13 @@ export default function DocsEditorPage() {
590
419
  })
591
420
  }, [editor, synced, effectiveCanWrite, doc, legacyText])
592
421
 
593
- // Presence -------------------------------------------------------------------
594
- const presenceParticipants = useMemo(
595
- () => buildDocsPresence(presencePeers, user ?? undefined, presenceAsEditMode),
596
- [presencePeers, user, presenceAsEditMode],
597
- )
598
-
599
- const publishPresence = useCallback(
600
- (typingFlag: boolean) => {
601
- if (!selectedDoc || !user || !synced) return
602
- updatePresenceState({
603
- mode: presenceAsEditMode ? 'edit' : 'view',
604
- typing: typingFlag,
605
- ...(typingFlag ? { lastTypedAt: Date.now() } : {}),
606
- })
607
- },
608
- [presenceAsEditMode, selectedDoc, synced, updatePresenceState, user],
609
- )
610
-
611
- /**
612
- * Permission-change fan-out via presence.
613
- *
614
- * The owner publishes a one-shot `aclSignal` payload after every
615
- * InviteDialog save. The PresenceRoom server merges incoming state into
616
- * each peer's record, so this field rides alongside the existing
617
- * `mode`/`typing`/`lastTypedAt` fields without clobbering them. We
618
- * intentionally route through presence rather than the documents record
619
- * because the docs schema's `read: 'collaborator'` rule prevents a
620
- * just-removed user from seeing the new record state — presence is the
621
- * only channel still wired to them at the moment of revocation.
622
- */
623
- const handleAclChange = useCallback(
624
- (diff: InviteAclDiff) => {
625
- if (!isOwner) return
626
- updatePresenceState({
627
- aclSignal: {
628
- at: Date.now(),
629
- removed: diff.removedUserIds,
630
- demoted: diff.demotedUserIds,
631
- promoted: diff.promotedUserIds,
632
- },
633
- })
634
- },
635
- [isOwner, updatePresenceState],
636
- )
637
-
638
- /**
639
- * Latch the first relevant `aclSignal` we see from the owner's presence
640
- * peer. We compare its `at` against `sessionStartedAtRef` so refreshing
641
- * into a doc whose owner already published a signal doesn't immediately
642
- * trip the overlay — only signals emitted *after* this session began
643
- * count. Once latched we keep the value: even if the owner disconnects
644
- * and their presence peer vanishes, the overlay must persist.
645
- */
646
- useEffect(() => {
647
- if (!user || !selectedDoc) return
648
- if (detectedAclEvent) return
649
- const start = sessionStartedAtRef.current
650
- if (start == null) return
651
- const ownerId = selectedDoc.data.ownerId
652
- if (user.id === ownerId) return
653
- const ownerPeer = presencePeers.find((p) => p.userId === ownerId)
654
- if (!ownerPeer) return
655
-
656
- const rawSignal = (ownerPeer.state as Record<string, unknown>).aclSignal
657
- if (!rawSignal || typeof rawSignal !== 'object') return
658
- const signal = rawSignal as {
659
- at?: unknown
660
- removed?: unknown
661
- demoted?: unknown
662
- promoted?: unknown
663
- }
664
- if (typeof signal.at !== 'number' || signal.at <= start) return
665
-
666
- const includes = (list: unknown): boolean =>
667
- Array.isArray(list) && list.some((id) => id === user.id)
668
-
669
- if (includes(signal.removed)) {
670
- setDetectedAclEvent({ kind: 'revoked', at: signal.at })
671
- } else if (includes(signal.demoted)) {
672
- setDetectedAclEvent({ kind: 'downgrade', at: signal.at })
673
- } else if (includes(signal.promoted)) {
674
- setDetectedAclEvent({ kind: 'upgrade', at: signal.at })
675
- }
676
- }, [presencePeers, user, selectedDoc, detectedAclEvent])
677
-
678
- // Typing flag — driven by Tiptap's `update` event (debounced idle reset).
679
- useEffect(() => {
680
- if (!editor) return
681
- const onUpdate = () => {
682
- if (!effectiveCanWrite) return
683
- typingRef.current = true
684
- publishPresence(true)
685
- if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
686
- typingTimeoutRef.current = setTimeout(() => {
687
- typingRef.current = false
688
- publishPresence(false)
689
- }, TYPING_IDLE_MS)
690
- }
691
- editor.on('update', onUpdate)
692
- return () => {
693
- editor.off('update', onUpdate)
694
- if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
695
- }
696
- }, [editor, effectiveCanWrite, publishPresence])
697
-
698
- // Initial presence + heartbeat so late joiners still see existing viewers.
699
- useEffect(() => {
700
- publishPresence(typingRef.current)
701
- }, [publishPresence, presenceConnected])
702
-
703
- useEffect(() => {
704
- if (!synced || !selectedDoc || !user) return
705
- const id = window.setInterval(() => publishPresence(typingRef.current), PRESENCE_HEARTBEAT_MS)
706
- return () => clearInterval(id)
707
- }, [synced, selectedDoc, user, publishPresence])
708
-
709
- // Typing names from presence (stale-aware).
710
- const typingNames = useMemo(() => {
711
- const names: string[] = []
712
- const seen = new Set<string>()
713
- for (const p of presenceParticipants) {
714
- if (p.isSelf || !p.typing) continue
715
- if (p.lastTypedAt != null && Date.now() - p.lastTypedAt >= TYPING_STALE_MS) continue
716
- if (seen.has(p.name)) continue
717
- seen.add(p.name)
718
- names.push(p.name)
719
- }
720
- return names
721
- }, [presenceParticipants])
422
+ const { participants: presenceParticipants, typingNames } = useDocsEditorPresence({
423
+ editor,
424
+ document: selectedDoc,
425
+ user,
426
+ synced,
427
+ access: presenceAccess,
428
+ })
722
429
 
723
430
  // Outline --------------------------------------------------------------------
724
431
  const outlineEntries = useOutlineEntries(editor)