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.
- package/CHANGELOG.md +37 -0
- package/README.md +22 -8
- package/dist/cli.js +9812 -4674
- 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 +2 -3
- 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
|
@@ -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
|
|
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/
|
|
18
|
-
"dest": "src/pages/docs/
|
|
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/
|
|
22
|
-
"dest": "src/pages/docs/
|
|
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
|
|
35
|
-
import { 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<
|
|
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<
|
|
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
|
-
} =
|
|
302
|
+
} = useYjsRoom(docId ?? 'noop', 'content')
|
|
386
303
|
|
|
387
|
-
const
|
|
304
|
+
const presenceAccess = useDocsPresenceAccess({
|
|
305
|
+
docId,
|
|
306
|
+
document: selectedDoc,
|
|
307
|
+
user,
|
|
308
|
+
yjsCanWrite: canWrite,
|
|
309
|
+
writeAuthResolved,
|
|
310
|
+
})
|
|
388
311
|
const {
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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)
|