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
@@ -25,18 +25,14 @@ import {
25
25
  Pencil,
26
26
  } from 'lucide-react'
27
27
  import { LibrarySidebar, readSidebarCollapsed, writeSidebarCollapsed } from './LibrarySidebar'
28
- import { getFavorites, saveFavorites } from './docs-favorites'
29
- import type { DocFolderFields, LibraryNavSelection } from './docs-library-types'
28
+ import {
29
+ parseDocsIdList,
30
+ type DocFolderFields,
31
+ type DocsDocumentFields,
32
+ type LibraryNavSelection,
33
+ } from './docs-library-types'
30
34
  import './docs-ui.css'
31
35
 
32
- interface DocumentFields {
33
- title: string
34
- ownerId: string
35
- collaborators?: string
36
- editors?: string
37
- folderId?: string
38
- }
39
-
40
36
  type SortOption = 'lastEdited' | 'titleAZ' | 'titleZA'
41
37
  type ViewMode = 'grid' | 'list'
42
38
 
@@ -47,6 +43,24 @@ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
47
43
  ]
48
44
 
49
45
  const UNTITLED = 'Untitled Document'
46
+ const FAVORITES_KEY = 'deepspace-docs-favorites'
47
+
48
+ function readFavorites(): Set<string> {
49
+ try {
50
+ const stored = localStorage.getItem(FAVORITES_KEY)
51
+ return new Set(parseDocsIdList(stored ?? undefined))
52
+ } catch {
53
+ return new Set()
54
+ }
55
+ }
56
+
57
+ function writeFavorites(favorites: Set<string>): void {
58
+ try {
59
+ localStorage.setItem(FAVORITES_KEY, JSON.stringify([...favorites]))
60
+ } catch {
61
+ // Favorites are local convenience state; storage denial must not break UI state.
62
+ }
63
+ }
50
64
 
51
65
  function greetingForTime(): string {
52
66
  const h = new Date().getHours()
@@ -63,16 +77,6 @@ function formatDate(iso: string): string {
63
77
  })
64
78
  }
65
79
 
66
- function parseIdList(raw: string | undefined): string[] {
67
- if (!raw) return []
68
- try {
69
- const parsed = JSON.parse(raw)
70
- return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
71
- } catch {
72
- return []
73
- }
74
- }
75
-
76
80
  export default function DocsListPage() {
77
81
  const { user } = useUser()
78
82
  const navigate = useNavigate()
@@ -83,7 +87,7 @@ export default function DocsListPage() {
83
87
  const [sortBy, setSortBy] = useState<SortOption>('lastEdited')
84
88
  const [viewMode, setViewMode] = useState<ViewMode>('grid')
85
89
  const [sortOpen, setSortOpen] = useState(false)
86
- const [favorites, setFavorites] = useState<Set<string>>(() => getFavorites())
90
+ const [favorites, setFavorites] = useState<Set<string>>(readFavorites)
87
91
  const [libraryNav, setLibraryNav] = useState<LibraryNavSelection>({ kind: 'all' })
88
92
  const [sidebarCollapsed, setSidebarCollapsed] = useState(readSidebarCollapsed)
89
93
  const [renamingId, setRenamingId] = useState<string | null>(null)
@@ -93,11 +97,11 @@ export default function DocsListPage() {
93
97
  const [moveMenuId, setMoveMenuId] = useState<string | null>(null)
94
98
  const [actionsMenuId, setActionsMenuId] = useState<string | null>(null)
95
99
 
96
- const { records: documents, status } = useQuery<DocumentFields>('documents', {
100
+ const { records: documents, status } = useQuery<DocsDocumentFields>('documents', {
97
101
  orderBy: 'createdAt',
98
102
  orderDir: 'desc',
99
103
  })
100
- const { create, remove, put } = useMutations<DocumentFields>('documents')
104
+ const { create, remove, put } = useMutations<DocsDocumentFields>('documents')
101
105
 
102
106
  const { records: folderRecords } = useQuery<DocFolderFields>('doc_folders', {
103
107
  orderBy: 'createdAt',
@@ -128,16 +132,16 @@ export default function DocsListPage() {
128
132
  const displayFirstName = user?.name?.trim().split(/\s+/)[0] ?? 'there'
129
133
 
130
134
  const isOwnedDocument = useCallback(
131
- (d: RecordData<DocumentFields>) => d.data.ownerId === user?.id,
135
+ (d: RecordData<DocsDocumentFields>) => d.data.ownerId === user?.id,
132
136
  [user?.id],
133
137
  )
134
138
 
135
139
  const isSharedWithCurrentUser = useCallback(
136
- (d: RecordData<DocumentFields>) =>
140
+ (d: RecordData<DocsDocumentFields>) =>
137
141
  Boolean(
138
142
  user?.id &&
139
- d.data.ownerId !== user.id &&
140
- parseIdList(d.data.collaborators).includes(user.id),
143
+ d.data.ownerId !== user.id &&
144
+ parseDocsIdList(d.data.collaborators).includes(user.id),
141
145
  ),
142
146
  [user?.id],
143
147
  )
@@ -160,7 +164,7 @@ export default function DocsListPage() {
160
164
  }, [libraryNav, sortedFolders])
161
165
 
162
166
  const matchesNav = useCallback(
163
- (d: RecordData<DocumentFields>) => {
167
+ (d: RecordData<DocsDocumentFields>) => {
164
168
  const fid = d.data.folderId ?? ''
165
169
  if (libraryNav.kind === 'all') return isOwnedDocument(d)
166
170
  if (libraryNav.kind === 'shared') return isSharedWithCurrentUser(d)
@@ -205,7 +209,7 @@ export default function DocsListPage() {
205
209
  const next = new Set(prev)
206
210
  if (next.has(contentId)) next.delete(contentId)
207
211
  else next.add(contentId)
208
- saveFavorites(next)
212
+ writeFavorites(next)
209
213
  return next
210
214
  })
211
215
  }, [])
@@ -3,14 +3,7 @@ import type { RecordData } from 'deepspace'
3
3
  import { useMutations, useQuery, useUser } from 'deepspace'
4
4
  import { Mail, ShieldCheck, UserMinus, Users } from 'lucide-react'
5
5
  import { Modal, useToast } from '@/components/ui'
6
-
7
- export interface InviteDialogDocumentFields {
8
- title: string
9
- ownerId: string
10
- collaborators?: string
11
- editors?: string
12
- folderId?: string
13
- }
6
+ import { parseDocsIdList, type DocsDocumentFields, type InviteAclDiff } from './docs-library-types'
14
7
 
15
8
  interface UserFields {
16
9
  email?: string
@@ -20,25 +13,10 @@ interface UserFields {
20
13
 
21
14
  type InviteRole = 'viewer' | 'editor'
22
15
 
23
- /**
24
- * Diff that resulted from an InviteDialog save. The editor page rebroadcasts
25
- * this over the doc's presence channel so the affected peer gets the change
26
- * even when the docs schema's `read: 'collaborator'` rule prevents the
27
- * `documents` record update from reaching a now-removed user.
28
- */
29
- export interface InviteAclDiff {
30
- /** Users dropped from `collaborators` entirely. */
31
- removedUserIds: string[]
32
- /** Users who lost the editor role but remain collaborators (now viewers). */
33
- demotedUserIds: string[]
34
- /** Existing collaborators who gained the editor role. */
35
- promotedUserIds: string[]
36
- }
37
-
38
16
  interface InviteDialogProps {
39
17
  open: boolean
40
18
  onOpenChange: (open: boolean) => void
41
- doc: RecordData<InviteDialogDocumentFields>
19
+ doc: RecordData<DocsDocumentFields>
42
20
  isOwner: boolean
43
21
  /**
44
22
  * Called after a save mutation that changed the ACL. The parent uses it to
@@ -49,16 +27,6 @@ interface InviteDialogProps {
49
27
  onAclChange?: (diff: InviteAclDiff) => void
50
28
  }
51
29
 
52
- function parseIds(raw: string | undefined): string[] {
53
- if (!raw) return []
54
- try {
55
- const parsed = JSON.parse(raw)
56
- return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
57
- } catch {
58
- return []
59
- }
60
- }
61
-
62
30
  function uniqueIds(ids: string[]): string[] {
63
31
  return [...new Set(ids.filter(Boolean))]
64
32
  }
@@ -75,14 +43,17 @@ function initialsFor(name: string): string {
75
43
  export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }: InviteDialogProps) {
76
44
  const { user } = useUser()
77
45
  const { records: users } = useQuery<UserFields>('users')
78
- const { put } = useMutations<InviteDialogDocumentFields>('documents')
46
+ const { put } = useMutations<DocsDocumentFields>('documents')
79
47
  const toast = useToast()
80
48
  const [email, setEmail] = useState('')
81
49
  const [role, setRole] = useState<InviteRole>('editor')
82
50
  const [saving, setSaving] = useState(false)
83
51
 
84
- const collaborators = useMemo(() => parseIds(doc.data.collaborators), [doc.data.collaborators])
85
- const editors = useMemo(() => parseIds(doc.data.editors), [doc.data.editors])
52
+ const collaborators = useMemo(
53
+ () => parseDocsIdList(doc.data.collaborators),
54
+ [doc.data.collaborators],
55
+ )
56
+ const editors = useMemo(() => parseDocsIdList(doc.data.editors), [doc.data.editors])
86
57
 
87
58
  const ownerRecord = useMemo(
88
59
  () => users.find((u) => u.recordId === doc.data.ownerId),
@@ -100,8 +71,8 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
100
71
  if (!isOwner) return null
101
72
 
102
73
  const saveAccess = async (nextCollaborators: string[], nextEditors: string[]) => {
103
- const prevCollaborators = parseIds(doc.data.collaborators)
104
- const prevEditors = parseIds(doc.data.editors)
74
+ const prevCollaborators = parseDocsIdList(doc.data.collaborators)
75
+ const prevEditors = parseDocsIdList(doc.data.editors)
105
76
  const nextCollabList = uniqueIds(nextCollaborators)
106
77
  const nextCollabSet = new Set(nextCollabList)
107
78
  const nextEditorList = uniqueIds(nextEditors).filter((id) => nextCollabSet.has(id))
@@ -181,7 +152,7 @@ export function InviteDialog({ open, onOpenChange, doc, isOwner, onAclChange }:
181
152
 
182
153
  return (
183
154
  <Modal open={open} onClose={() => onOpenChange(false)} size="lg" className="docs-feature-scope">
184
- <Modal.Header onClose={() => onOpenChange(false)}>
155
+ <Modal.Header>
185
156
  <Modal.Title>Share document</Modal.Title>
186
157
  <Modal.Description>
187
158
  Invite DeepSpace users by the email address on their account.
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ deriveDocsAccessChange,
4
+ detectDocsAclEvent,
5
+ isDocsOwner,
6
+ resolveDocsAccessRole,
7
+ } from '../use-docs-presence-access'
8
+
9
+ describe('document access policy', () => {
10
+ const document = {
11
+ data: {
12
+ ownerId: 'owner',
13
+ collaborators: JSON.stringify(['editor', 'viewer']),
14
+ editors: JSON.stringify(['editor']),
15
+ },
16
+ }
17
+
18
+ it('resolves owner, editor, viewer, and no-access roles from the document ACL', () => {
19
+ expect(resolveDocsAccessRole(document, 'owner')).toBe('owner')
20
+ expect(resolveDocsAccessRole(document, 'editor')).toBe('editor')
21
+ expect(resolveDocsAccessRole(document, 'viewer')).toBe('viewer')
22
+ expect(resolveDocsAccessRole(document, 'outsider')).toBe('none')
23
+ expect(resolveDocsAccessRole(document, null)).toBe('none')
24
+ })
25
+
26
+ it('does not expose owner controls while both the document and user are loading', () => {
27
+ expect(isDocsOwner(undefined, undefined)).toBe(false)
28
+ expect(isDocsOwner(document, undefined)).toBe(false)
29
+ expect(isDocsOwner(undefined, 'owner')).toBe(false)
30
+ expect(isDocsOwner(document, 'owner')).toBe(true)
31
+ })
32
+
33
+ it('treats malformed ACL fields as empty lists', () => {
34
+ const malformed = {
35
+ data: { ownerId: 'owner', collaborators: '{broken', editors: JSON.stringify('editor') },
36
+ }
37
+ expect(resolveDocsAccessRole(malformed, 'editor')).toBe('none')
38
+ })
39
+
40
+ it('derives only actionable transitions from the session baseline', () => {
41
+ expect(deriveDocsAccessChange('editor', 'viewer', null)).toBe('downgrade')
42
+ expect(deriveDocsAccessChange('viewer', 'editor', null)).toBe('upgrade')
43
+ expect(deriveDocsAccessChange('viewer', 'none', null)).toBe('revoked')
44
+ expect(deriveDocsAccessChange('owner', 'owner', null)).toBeNull()
45
+ expect(deriveDocsAccessChange(null, 'viewer', { kind: 'downgrade', at: 20 })).toBe('downgrade')
46
+ expect(deriveDocsAccessChange('editor', 'viewer', { kind: 'revoked', at: 20 })).toBe('revoked')
47
+ })
48
+
49
+ it('accepts only a fresh ACL signal addressed to the current user', () => {
50
+ const state = {
51
+ aclSignal: {
52
+ at: 20,
53
+ removed: ['removed-user'],
54
+ demoted: ['demoted-user'],
55
+ promoted: ['promoted-user'],
56
+ },
57
+ }
58
+
59
+ expect(detectDocsAclEvent(state, 'removed-user', 10)).toEqual({ kind: 'revoked', at: 20 })
60
+ expect(detectDocsAclEvent(state, 'demoted-user', 10)).toEqual({
61
+ kind: 'downgrade',
62
+ at: 20,
63
+ })
64
+ expect(detectDocsAclEvent(state, 'promoted-user', 10)).toEqual({
65
+ kind: 'upgrade',
66
+ at: 20,
67
+ })
68
+ expect(detectDocsAclEvent(state, 'other-user', 10)).toBeNull()
69
+ expect(detectDocsAclEvent(state, 'removed-user', 20)).toBeNull()
70
+ })
71
+ })
@@ -1,15 +1,40 @@
1
- /**
2
- * Library navigation + folder types for docs UI (aligned with docs2).
3
- */
1
+ /** Shared document, ACL, folder, and navigation model for the Docs feature. */
2
+
3
+ export interface DocsDocumentFields {
4
+ title: string
5
+ ownerId: string
6
+ collaborators?: string
7
+ editors?: string
8
+ folderId?: string
9
+ }
4
10
 
5
11
  export interface DocFolderFields {
6
12
  name: string
7
13
  ownerId: string
8
14
  }
9
15
 
16
+ export interface InviteAclDiff {
17
+ /** Users dropped from `collaborators` entirely. */
18
+ removedUserIds: string[]
19
+ /** Users who lost the editor role but remain collaborators (now viewers). */
20
+ demotedUserIds: string[]
21
+ /** Existing collaborators who gained the editor role. */
22
+ promotedUserIds: string[]
23
+ }
24
+
10
25
  export type LibraryNavSelection =
11
26
  | { kind: 'all' }
12
27
  | { kind: 'shared' }
13
28
  | { kind: 'favorites' }
14
29
  | { kind: 'uncategorized' }
15
30
  | { kind: 'folder'; folderId: string }
31
+
32
+ export function parseDocsIdList(raw: string | undefined): string[] {
33
+ if (!raw) return []
34
+ try {
35
+ const parsed: unknown = JSON.parse(raw)
36
+ return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
37
+ } catch {
38
+ return []
39
+ }
40
+ }
@@ -18,10 +18,7 @@
18
18
  --docs-el-accent: var(--color-primary, var(--theme-accent, var(--ui-accent, #3b82f6)));
19
19
  --docs-el-line: var(--color-border, var(--theme-border, rgba(15, 23, 42, 0.12)));
20
20
 
21
- --docs-el-selection: var(
22
- --color-primary-muted,
23
- color-mix(in srgb, var(--docs-el-accent) 22%, transparent)
24
- );
21
+ --docs-el-selection: color-mix(in srgb, var(--docs-el-accent) 22%, transparent);
25
22
 
26
23
  --docs-editor-desk: var(
27
24
  --color-muted,
@@ -0,0 +1,330 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
2
+ import { usePresenceRoom, type PresencePeerClient, type UsePresenceRoomResult } from 'deepspace'
3
+ import type { Editor } from '@tiptap/react'
4
+ import type { DocsPresenceParticipant } from './DocsPresence'
5
+ import { parseDocsIdList, type DocsDocumentFields, type InviteAclDiff } from './docs-library-types'
6
+
7
+ const TYPING_IDLE_MS = 1600
8
+ const TYPING_STALE_MS = 5000
9
+ /** Re-broadcast presence so clients who connect later still see existing viewers. */
10
+ const PRESENCE_HEARTBEAT_MS = 25_000
11
+
12
+ export type DocsAccessRole = 'owner' | 'editor' | 'viewer' | 'none'
13
+ export type AccessChangeKind = 'downgrade' | 'upgrade' | 'revoked'
14
+
15
+ interface DocsAccessDocument {
16
+ data: Pick<DocsDocumentFields, 'ownerId' | 'collaborators' | 'editors'>
17
+ }
18
+
19
+ interface DocsPresenceUser {
20
+ id: string
21
+ name?: string | null
22
+ email?: string | null
23
+ imageUrl?: string | null
24
+ }
25
+
26
+ interface DetectedAclEvent {
27
+ kind: AccessChangeKind
28
+ at: number
29
+ }
30
+
31
+ export function isDocsOwner(
32
+ document: DocsAccessDocument | null | undefined,
33
+ userId: string | null | undefined,
34
+ ): boolean {
35
+ return Boolean(document && userId && document.data.ownerId === userId)
36
+ }
37
+
38
+ export function resolveDocsAccessRole(
39
+ document: DocsAccessDocument | null | undefined,
40
+ userId: string | null | undefined,
41
+ ): DocsAccessRole {
42
+ if (!userId || !document) return 'none'
43
+ if (isDocsOwner(document, userId)) return 'owner'
44
+ if (parseDocsIdList(document.data.editors).includes(userId)) return 'editor'
45
+ if (parseDocsIdList(document.data.collaborators).includes(userId)) return 'viewer'
46
+ return 'none'
47
+ }
48
+
49
+ export function deriveDocsAccessChange(
50
+ initialRole: DocsAccessRole | null,
51
+ effectiveRole: DocsAccessRole,
52
+ detectedEvent: DetectedAclEvent | null,
53
+ ): AccessChangeKind | null {
54
+ // A presence-derived revoke is the only signal guaranteed to reach a peer
55
+ // whose document subscription was removed, so it takes precedence.
56
+ if (detectedEvent?.kind === 'revoked') return 'revoked'
57
+
58
+ if (initialRole && initialRole !== 'owner') {
59
+ if (effectiveRole === 'none') return 'revoked'
60
+ if (initialRole === 'editor' && effectiveRole === 'viewer') return 'downgrade'
61
+ if (initialRole === 'viewer' && effectiveRole === 'editor') return 'upgrade'
62
+ }
63
+
64
+ return detectedEvent?.kind ?? null
65
+ }
66
+
67
+ export function detectDocsAclEvent(
68
+ state: Record<string, unknown>,
69
+ userId: string,
70
+ sessionStartedAt: number,
71
+ ): DetectedAclEvent | null {
72
+ const rawSignal = state.aclSignal
73
+ if (!rawSignal || typeof rawSignal !== 'object') return null
74
+
75
+ const signal = rawSignal as {
76
+ at?: unknown
77
+ removed?: unknown
78
+ demoted?: unknown
79
+ promoted?: unknown
80
+ }
81
+ if (typeof signal.at !== 'number' || signal.at <= sessionStartedAt) return null
82
+
83
+ const includesUser = (list: unknown): boolean =>
84
+ Array.isArray(list) && list.some((id) => id === userId)
85
+
86
+ if (includesUser(signal.removed)) return { kind: 'revoked', at: signal.at }
87
+ if (includesUser(signal.demoted)) return { kind: 'downgrade', at: signal.at }
88
+ if (includesUser(signal.promoted)) return { kind: 'upgrade', at: signal.at }
89
+ return null
90
+ }
91
+
92
+ function peerToDocsParticipant(peer: PresencePeerClient): DocsPresenceParticipant {
93
+ const state = peer.state
94
+ const mode: DocsPresenceParticipant['mode'] = state.mode === 'view' ? 'view' : 'edit'
95
+ const lastTypedAt = typeof state.lastTypedAt === 'number' ? state.lastTypedAt : undefined
96
+ const participant: DocsPresenceParticipant = {
97
+ clientId: 0,
98
+ userId: peer.userId,
99
+ name: peer.userName?.trim() || peer.userEmail?.trim() || 'Guest',
100
+ mode,
101
+ typing: state.typing === true,
102
+ isSelf: false,
103
+ }
104
+ if (peer.userEmail) participant.email = peer.userEmail
105
+ if (peer.userImageUrl) participant.imageUrl = peer.userImageUrl
106
+ if (lastTypedAt != null) participant.lastTypedAt = lastTypedAt
107
+ return participant
108
+ }
109
+
110
+ function sortDocsPresenceParticipants(
111
+ left: DocsPresenceParticipant,
112
+ right: DocsPresenceParticipant,
113
+ ): number {
114
+ if (left.isSelf !== right.isSelf) return left.isSelf ? -1 : 1
115
+ if (left.mode !== right.mode) return left.mode === 'edit' ? -1 : 1
116
+ return left.name.localeCompare(right.name)
117
+ }
118
+
119
+ function buildDocsPresence(
120
+ peers: PresencePeerClient[],
121
+ self: DocsPresenceUser | null | undefined,
122
+ editMode: boolean,
123
+ ): DocsPresenceParticipant[] {
124
+ if (!self) {
125
+ return [...peers.map(peerToDocsParticipant)].sort(sortDocsPresenceParticipants)
126
+ }
127
+
128
+ const selfRow: DocsPresenceParticipant = {
129
+ clientId: 0,
130
+ userId: self.id,
131
+ name: self.name?.trim() || self.email?.trim() || 'You',
132
+ mode: editMode ? 'edit' : 'view',
133
+ isSelf: true,
134
+ }
135
+ if (self.email) selfRow.email = self.email
136
+ if (self.imageUrl) selfRow.imageUrl = self.imageUrl
137
+
138
+ const others = peers.filter((peer) => peer.userId !== self.id).map(peerToDocsParticipant)
139
+ return [selfRow, ...others].sort(sortDocsPresenceParticipants)
140
+ }
141
+
142
+ interface UseDocsPresenceAccessOptions {
143
+ docId: string | undefined
144
+ document: DocsAccessDocument | null | undefined
145
+ user: DocsPresenceUser | null | undefined
146
+ yjsCanWrite: boolean
147
+ writeAuthResolved: boolean
148
+ }
149
+
150
+ interface DocsPresenceAccessResult {
151
+ isOwner: boolean
152
+ effectiveRole: DocsAccessRole
153
+ effectiveCanWrite: boolean
154
+ showReadOnlyDocUx: boolean
155
+ accessChangeKind: AccessChangeKind | null
156
+ handleAclChange: (diff: InviteAclDiff) => void
157
+ presenceAsEditMode: boolean
158
+ presenceRoom: UsePresenceRoomResult
159
+ }
160
+
161
+ export function useDocsPresenceAccess({
162
+ docId,
163
+ document,
164
+ user,
165
+ yjsCanWrite,
166
+ writeAuthResolved,
167
+ }: UseDocsPresenceAccessOptions): DocsPresenceAccessResult {
168
+ const presenceRoom = usePresenceRoom(docId ? `doc:${docId}` : '_')
169
+ const { peers, updateState } = presenceRoom
170
+ const isOwner = isDocsOwner(document, user?.id)
171
+ const effectiveRole = resolveDocsAccessRole(document, user?.id)
172
+ const policyCanEdit = effectiveRole === 'owner' || effectiveRole === 'editor'
173
+
174
+ /** The first concrete role is the baseline for live ACL transitions. */
175
+ const [initialRole, setInitialRole] = useState<DocsAccessRole | null>(null)
176
+ const sessionStartedAtRef = useRef<number | null>(null)
177
+ useEffect(() => {
178
+ setInitialRole(null)
179
+ sessionStartedAtRef.current = null
180
+ }, [docId])
181
+ useEffect(() => {
182
+ if (initialRole || !document || !user || effectiveRole === 'none') return
183
+ setInitialRole(effectiveRole)
184
+ sessionStartedAtRef.current = Date.now()
185
+ }, [document, effectiveRole, initialRole, user])
186
+
187
+ /** Presence reaches peers after document-read access has been revoked. */
188
+ const [detectedAclEvent, setDetectedAclEvent] = useState<DetectedAclEvent | null>(null)
189
+ useEffect(() => {
190
+ setDetectedAclEvent(null)
191
+ }, [docId])
192
+ useEffect(() => {
193
+ if (!user || !document || detectedAclEvent) return
194
+ const sessionStartedAt = sessionStartedAtRef.current
195
+ if (sessionStartedAt == null || user.id === document.data.ownerId) return
196
+
197
+ const ownerPeer = peers.find((peer) => peer.userId === document.data.ownerId)
198
+ if (!ownerPeer) return
199
+ const nextEvent = detectDocsAclEvent(ownerPeer.state, user.id, sessionStartedAt)
200
+ if (nextEvent) setDetectedAclEvent(nextEvent)
201
+ }, [detectedAclEvent, document, peers, user])
202
+
203
+ const accessChangeKind = useMemo(
204
+ () => deriveDocsAccessChange(initialRole, effectiveRole, detectedAclEvent),
205
+ [detectedAclEvent, effectiveRole, initialRole],
206
+ )
207
+ const accessLocked = accessChangeKind !== null
208
+ const writesLockedByAcl = accessLocked && accessChangeKind !== 'upgrade'
209
+ const effectiveCanWrite = yjsCanWrite && policyCanEdit && !writesLockedByAcl
210
+ const showReadOnlyDocUx =
211
+ effectiveRole === 'viewer' || (policyCanEdit && writeAuthResolved && !yjsCanWrite)
212
+ const presenceAsEditMode =
213
+ policyCanEdit && !(writeAuthResolved && !yjsCanWrite) && !writesLockedByAcl
214
+
215
+ const handleAclChange = useCallback(
216
+ (diff: InviteAclDiff) => {
217
+ if (!isOwner) return
218
+ updateState({
219
+ aclSignal: {
220
+ at: Date.now(),
221
+ removed: diff.removedUserIds,
222
+ demoted: diff.demotedUserIds,
223
+ promoted: diff.promotedUserIds,
224
+ },
225
+ })
226
+ },
227
+ [isOwner, updateState],
228
+ )
229
+
230
+ return {
231
+ isOwner,
232
+ effectiveRole,
233
+ effectiveCanWrite,
234
+ showReadOnlyDocUx,
235
+ accessChangeKind,
236
+ handleAclChange,
237
+ presenceAsEditMode,
238
+ presenceRoom,
239
+ }
240
+ }
241
+
242
+ interface UseDocsEditorPresenceOptions {
243
+ editor: Editor | null
244
+ document: DocsAccessDocument | null | undefined
245
+ user: DocsPresenceUser | null | undefined
246
+ synced: boolean
247
+ access: DocsPresenceAccessResult
248
+ }
249
+
250
+ export function useDocsEditorPresence({
251
+ editor,
252
+ document,
253
+ user,
254
+ synced,
255
+ access,
256
+ }: UseDocsEditorPresenceOptions): {
257
+ participants: DocsPresenceParticipant[]
258
+ typingNames: string[]
259
+ } {
260
+ const { effectiveCanWrite, presenceAsEditMode, presenceRoom } = access
261
+ const { peers, connected, updateState } = presenceRoom
262
+ const typingRef = useRef(false)
263
+ const typingTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
264
+
265
+ const participants = useMemo(
266
+ () => buildDocsPresence(peers, user, presenceAsEditMode),
267
+ [peers, presenceAsEditMode, user],
268
+ )
269
+
270
+ const publishPresence = useCallback(
271
+ (typing: boolean) => {
272
+ if (!document || !user || !synced) return
273
+ updateState({
274
+ mode: presenceAsEditMode ? 'edit' : 'view',
275
+ typing,
276
+ ...(typing ? { lastTypedAt: Date.now() } : {}),
277
+ })
278
+ },
279
+ [document, presenceAsEditMode, synced, updateState, user],
280
+ )
281
+
282
+ useEffect(() => {
283
+ if (!editor) return
284
+ const onUpdate = () => {
285
+ if (!effectiveCanWrite) return
286
+ typingRef.current = true
287
+ publishPresence(true)
288
+ if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
289
+ typingTimeoutRef.current = setTimeout(() => {
290
+ typingRef.current = false
291
+ publishPresence(false)
292
+ }, TYPING_IDLE_MS)
293
+ }
294
+ editor.on('update', onUpdate)
295
+ return () => {
296
+ editor.off('update', onUpdate)
297
+ if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
298
+ }
299
+ }, [editor, effectiveCanWrite, publishPresence])
300
+
301
+ useEffect(() => {
302
+ publishPresence(typingRef.current)
303
+ }, [connected, publishPresence])
304
+
305
+ useEffect(() => {
306
+ if (!synced || !document || !user) return
307
+ const id = window.setInterval(() => publishPresence(typingRef.current), PRESENCE_HEARTBEAT_MS)
308
+ return () => clearInterval(id)
309
+ }, [document, publishPresence, synced, user])
310
+
311
+ const typingNames = useMemo(() => {
312
+ const names: string[] = []
313
+ const seen = new Set<string>()
314
+ for (const participant of participants) {
315
+ if (participant.isSelf || !participant.typing) continue
316
+ if (
317
+ participant.lastTypedAt != null &&
318
+ Date.now() - participant.lastTypedAt >= TYPING_STALE_MS
319
+ ) {
320
+ continue
321
+ }
322
+ if (seen.has(participant.name)) continue
323
+ seen.add(participant.name)
324
+ names.push(participant.name)
325
+ }
326
+ return names
327
+ }, [participants])
328
+
329
+ return { participants, typingNames }
330
+ }