deepspace 0.7.0 → 0.8.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/CHANGELOG.md +30 -0
- package/README.md +22 -8
- package/dist/cli.js +10306 -5256
- package/dist/cli.js.map +1 -1
- package/dist/feature-installer.js +638 -0
- package/dist/feature-installer.js.map +1 -0
- package/dist/index.d.ts +107 -117
- package/dist/index.js +610 -779
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +403 -167
- package/dist/server.js +498 -367
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +115 -0
- package/dist/testing.js +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +397 -160
- package/dist/worker.js +506 -367
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -0
- package/features/ai-chat/src/AiChatPage.tsx +2 -3
- package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
- package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
- package/features/ai-chat/src/ChatPanel.tsx +158 -862
- package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
- package/features/canvas/src/CanvasListPage.tsx +1 -1
- package/features/docs/feature.json +4 -8
- package/features/docs/src/DocsEditorPage.tsx +33 -326
- package/features/docs/src/DocsListPage.tsx +33 -29
- package/features/docs/src/InviteDialog.tsx +11 -40
- package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
- package/features/docs/src/docs-library-types.ts +28 -3
- package/features/docs/src/docs-ui.css +1 -4
- package/features/docs/src/use-docs-presence-access.ts +330 -0
- package/features/file-attachments/feature.json +2 -2
- package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
- package/features/file-attachments/src/useFilePreview.ts +54 -68
- package/features/file-manager/src/FileManagerPage.tsx +1 -1
- package/features/integration-test/src/integration.spec.ts +1 -1
- package/features/items/feature.json +1 -5
- package/features/items/src/ItemsPage.tsx +207 -135
- package/features/landing/feature.json +3 -0
- package/features/landing/src/LandingPage.tsx +4 -18
- package/features/leaderboard/feature.json +1 -5
- package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
- package/features/messaging/feature.json +0 -4
- package/features/messaging/src/ChatMultiPage.tsx +1 -3
- package/features/messaging/src/ChatPage.tsx +1 -3
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
- package/features/search-bar/feature.json +8 -3
- package/features/search-bar/src/SearchOverlay.tsx +108 -252
- package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
- package/features/search-bar/src/search-model.ts +223 -0
- package/features/tasks/src/TasksPage.tsx +3 -3
- package/features/topbar/src/Topbar.tsx +0 -17
- package/package.json +1 -2
- package/features/docs/src/docs-favorites.ts +0 -18
- package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
- package/features/file-attachments/src/attachments-constants.ts +0 -9
- package/features/items/src/items-constants.ts +0 -10
- package/features/leaderboard/src/leaderboard-constants.ts +0 -21
- package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
- 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 {
|
|
29
|
-
|
|
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>>(
|
|
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<
|
|
100
|
+
const { records: documents, status } = useQuery<DocsDocumentFields>('documents', {
|
|
97
101
|
orderBy: 'createdAt',
|
|
98
102
|
orderDir: 'desc',
|
|
99
103
|
})
|
|
100
|
-
const { create, remove, put } = useMutations<
|
|
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<
|
|
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<
|
|
140
|
+
(d: RecordData<DocsDocumentFields>) =>
|
|
137
141
|
Boolean(
|
|
138
142
|
user?.id &&
|
|
139
|
-
|
|
140
|
-
|
|
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<
|
|
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
|
-
|
|
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<
|
|
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<
|
|
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(
|
|
85
|
-
|
|
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 =
|
|
104
|
-
const prevEditors =
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|