deepspace 0.6.2 → 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 +44 -0
- package/README.md +22 -8
- package/dist/cli.js +10348 -5025
- 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 +229 -116
- package/dist/index.js +767 -788
- 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 +1 -1
- package/dist/testing.js +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +541 -159
- package/dist/worker.js +611 -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 +159 -825
- 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
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { readRecentSearches, rememberRecentItem, rememberRecentQuery } from '../search-model'
|
|
3
|
+
|
|
4
|
+
const storageKey = 'search-history-test'
|
|
5
|
+
let values: Map<string, string>
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
values = new Map()
|
|
9
|
+
vi.stubGlobal('window', {
|
|
10
|
+
localStorage: {
|
|
11
|
+
getItem: (key: string) => values.get(key) ?? null,
|
|
12
|
+
setItem: (key: string, value: string) => values.set(key, value),
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
18
|
+
|
|
19
|
+
describe('recent search history', () => {
|
|
20
|
+
it('stores only the stable ID for a selected result', () => {
|
|
21
|
+
expect(rememberRecentItem(storageKey, 'post-1', 5)).toEqual([
|
|
22
|
+
{ kind: 'item', itemId: 'post-1' },
|
|
23
|
+
])
|
|
24
|
+
expect(JSON.parse(values.get(storageKey) ?? 'null')).toEqual([
|
|
25
|
+
{ kind: 'item', itemId: 'post-1' },
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('ignores legacy result snapshots instead of replaying incomplete objects', () => {
|
|
30
|
+
values.set(storageKey, JSON.stringify([{ id: 'post-1', title: 'Old title' }]))
|
|
31
|
+
expect(readRecentSearches(storageKey, 5)).toEqual([])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('deduplicates submitted queries without changing their display casing', () => {
|
|
35
|
+
rememberRecentQuery(storageKey, 'Project Alpha', 5)
|
|
36
|
+
expect(rememberRecentQuery(storageKey, 'project alpha', 5)).toEqual([
|
|
37
|
+
{ kind: 'query', query: 'project alpha' },
|
|
38
|
+
])
|
|
39
|
+
})
|
|
40
|
+
})
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface HighlightedPart {
|
|
4
|
+
text: string
|
|
5
|
+
match: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface UseSearchIndexOptions<T> {
|
|
9
|
+
items: T[]
|
|
10
|
+
query: string
|
|
11
|
+
getText: (item: T) => Array<string | number | null | undefined>
|
|
12
|
+
limit?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useSearchIndex<T>({
|
|
16
|
+
items,
|
|
17
|
+
query,
|
|
18
|
+
getText,
|
|
19
|
+
limit = 20,
|
|
20
|
+
}: UseSearchIndexOptions<T>): T[] {
|
|
21
|
+
return useMemo(() => {
|
|
22
|
+
const resultLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : items.length
|
|
23
|
+
const normalizedQuery = normalize(query)
|
|
24
|
+
if (!normalizedQuery) return items.slice(0, resultLimit)
|
|
25
|
+
|
|
26
|
+
return items
|
|
27
|
+
.map((item, index) => {
|
|
28
|
+
const haystack = getText(item)
|
|
29
|
+
.filter((value): value is string | number => value !== null && value !== undefined)
|
|
30
|
+
.map(String)
|
|
31
|
+
.join(' ')
|
|
32
|
+
return { item, index, score: scoreText(haystack, normalizedQuery) }
|
|
33
|
+
})
|
|
34
|
+
.filter((entry) => entry.score > 0)
|
|
35
|
+
.sort((left, right) => right.score - left.score || left.index - right.index)
|
|
36
|
+
.slice(0, resultLimit)
|
|
37
|
+
.map((entry) => entry.item)
|
|
38
|
+
}, [getText, items, limit, query])
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getHighlightedParts(text: string, query: string): HighlightedPart[] {
|
|
42
|
+
const normalizedQuery = normalize(query)
|
|
43
|
+
if (!text || !normalizedQuery) return [{ text, match: false }]
|
|
44
|
+
|
|
45
|
+
const normalizedText = text.toLowerCase()
|
|
46
|
+
const parts: HighlightedPart[] = []
|
|
47
|
+
let cursor = 0
|
|
48
|
+
let index = normalizedText.indexOf(normalizedQuery)
|
|
49
|
+
|
|
50
|
+
while (index !== -1) {
|
|
51
|
+
if (index > cursor) parts.push({ text: text.slice(cursor, index), match: false })
|
|
52
|
+
parts.push({ text: text.slice(index, index + normalizedQuery.length), match: true })
|
|
53
|
+
cursor = index + normalizedQuery.length
|
|
54
|
+
index = normalizedText.indexOf(normalizedQuery, cursor)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (cursor < text.length) parts.push({ text: text.slice(cursor), match: false })
|
|
58
|
+
return parts.length > 0 ? parts : [{ text, match: false }]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface UseAsyncSearchOptions<T> {
|
|
62
|
+
query: string
|
|
63
|
+
search: (query: string) => Promise<T[]>
|
|
64
|
+
minLength?: number
|
|
65
|
+
delayMs?: number
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function useAsyncSearch<T>({
|
|
69
|
+
query,
|
|
70
|
+
search,
|
|
71
|
+
minLength = 2,
|
|
72
|
+
delayMs = 250,
|
|
73
|
+
}: UseAsyncSearchOptions<T>) {
|
|
74
|
+
const [items, setItems] = useState<T[]>([])
|
|
75
|
+
const [loading, setLoading] = useState(false)
|
|
76
|
+
const [error, setError] = useState<string | null>(null)
|
|
77
|
+
const requestId = useRef(0)
|
|
78
|
+
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
const trimmed = query.trim()
|
|
81
|
+
const currentRequest = ++requestId.current
|
|
82
|
+
const requiredLength = Number.isFinite(minLength) ? Math.max(0, Math.floor(minLength)) : 2
|
|
83
|
+
const waitMs = Number.isFinite(delayMs)
|
|
84
|
+
? Math.min(60_000, Math.max(0, Math.floor(delayMs)))
|
|
85
|
+
: 250
|
|
86
|
+
|
|
87
|
+
if (trimmed.length < requiredLength) {
|
|
88
|
+
setItems([])
|
|
89
|
+
setLoading(false)
|
|
90
|
+
setError(null)
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setLoading(true)
|
|
95
|
+
setError(null)
|
|
96
|
+
|
|
97
|
+
const timer = window.setTimeout(() => {
|
|
98
|
+
void search(trimmed)
|
|
99
|
+
.then((results) => {
|
|
100
|
+
if (requestId.current !== currentRequest) return
|
|
101
|
+
setItems(results)
|
|
102
|
+
setError(null)
|
|
103
|
+
})
|
|
104
|
+
.catch((searchError) => {
|
|
105
|
+
if (requestId.current !== currentRequest) return
|
|
106
|
+
setItems([])
|
|
107
|
+
setError(searchError instanceof Error ? searchError.message : 'Search failed')
|
|
108
|
+
})
|
|
109
|
+
.finally(() => {
|
|
110
|
+
if (requestId.current === currentRequest) setLoading(false)
|
|
111
|
+
})
|
|
112
|
+
}, waitMs)
|
|
113
|
+
|
|
114
|
+
return () => {
|
|
115
|
+
window.clearTimeout(timer)
|
|
116
|
+
if (requestId.current === currentRequest) requestId.current += 1
|
|
117
|
+
}
|
|
118
|
+
}, [delayMs, minLength, query, search])
|
|
119
|
+
|
|
120
|
+
return { items, loading, error }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type RecentSearchEntry = { kind: 'item'; itemId: string } | { kind: 'query'; query: string }
|
|
124
|
+
|
|
125
|
+
export function createRecentStorageKey(
|
|
126
|
+
title: string,
|
|
127
|
+
placeholder: string,
|
|
128
|
+
triggerLabel: string,
|
|
129
|
+
): string {
|
|
130
|
+
const scope =
|
|
131
|
+
[title, placeholder, triggerLabel].map((value) => value.trim().toLowerCase()).find(Boolean) ??
|
|
132
|
+
'search'
|
|
133
|
+
const normalizedScope = scope.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'search'
|
|
134
|
+
return `deepspace:search-bar:recent:${normalizedScope}`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function readRecentSearches(storageKey: string, maxItems: number): RecentSearchEntry[] {
|
|
138
|
+
try {
|
|
139
|
+
const parsed: unknown = JSON.parse(window.localStorage.getItem(storageKey) ?? '[]')
|
|
140
|
+
if (!Array.isArray(parsed)) return []
|
|
141
|
+
return parsed.filter(isRecentSearchEntry).slice(0, normalizeLimit(maxItems))
|
|
142
|
+
} catch {
|
|
143
|
+
return []
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function rememberRecentItem(
|
|
148
|
+
storageKey: string,
|
|
149
|
+
itemId: string,
|
|
150
|
+
maxItems: number,
|
|
151
|
+
): RecentSearchEntry[] {
|
|
152
|
+
if (!itemId) return readRecentSearches(storageKey, maxItems)
|
|
153
|
+
return rememberRecent(
|
|
154
|
+
storageKey,
|
|
155
|
+
{ kind: 'item', itemId },
|
|
156
|
+
(entry) => entry.kind === 'item' && entry.itemId === itemId,
|
|
157
|
+
maxItems,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function rememberRecentQuery(
|
|
162
|
+
storageKey: string,
|
|
163
|
+
rawQuery: string,
|
|
164
|
+
maxItems: number,
|
|
165
|
+
): RecentSearchEntry[] {
|
|
166
|
+
const query = rawQuery.trim()
|
|
167
|
+
const normalizedQuery = normalize(query)
|
|
168
|
+
if (!normalizedQuery) return readRecentSearches(storageKey, maxItems)
|
|
169
|
+
return rememberRecent(
|
|
170
|
+
storageKey,
|
|
171
|
+
{ kind: 'query', query },
|
|
172
|
+
(entry) => entry.kind === 'query' && normalize(entry.query) === normalizedQuery,
|
|
173
|
+
maxItems,
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function rememberRecent(
|
|
178
|
+
storageKey: string,
|
|
179
|
+
recent: RecentSearchEntry,
|
|
180
|
+
matches: (entry: RecentSearchEntry) => boolean,
|
|
181
|
+
maxItems: number,
|
|
182
|
+
): RecentSearchEntry[] {
|
|
183
|
+
const next = [
|
|
184
|
+
recent,
|
|
185
|
+
...readRecentSearches(storageKey, maxItems).filter((entry) => !matches(entry)),
|
|
186
|
+
].slice(0, normalizeLimit(maxItems))
|
|
187
|
+
|
|
188
|
+
try {
|
|
189
|
+
window.localStorage.setItem(storageKey, JSON.stringify(next))
|
|
190
|
+
} catch {
|
|
191
|
+
// Persistence is optional; search continues with the in-memory result.
|
|
192
|
+
}
|
|
193
|
+
return next
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isRecentSearchEntry(value: unknown): value is RecentSearchEntry {
|
|
197
|
+
if (!value || typeof value !== 'object') return false
|
|
198
|
+
const candidate = value as Partial<RecentSearchEntry>
|
|
199
|
+
if (candidate.kind === 'item') return typeof candidate.itemId === 'string' && !!candidate.itemId
|
|
200
|
+
return (
|
|
201
|
+
candidate.kind === 'query' && typeof candidate.query === 'string' && !!candidate.query.trim()
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeLimit(value: number): number {
|
|
206
|
+
return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function normalize(value: string): string {
|
|
210
|
+
return value.trim().toLowerCase()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function scoreText(value: string, query: string): number {
|
|
214
|
+
const text = normalize(value)
|
|
215
|
+
if (!text || !query) return 0
|
|
216
|
+
if (text === query) return 100
|
|
217
|
+
if (text.startsWith(query)) return 80
|
|
218
|
+
if (text.includes(` ${query}`)) return 60
|
|
219
|
+
if (text.includes(query)) return 40
|
|
220
|
+
|
|
221
|
+
const terms = query.split(/\s+/).filter(Boolean)
|
|
222
|
+
return terms.length > 1 && terms.every((term) => text.includes(term)) ? 20 : 0
|
|
223
|
+
}
|
|
@@ -81,7 +81,7 @@ function CreateChallengeModal({ isOpen, onClose, onCreate }: CreateChallengeModa
|
|
|
81
81
|
|
|
82
82
|
return (
|
|
83
83
|
<Modal open={isOpen} onClose={onClose} size="md">
|
|
84
|
-
<Modal.Header
|
|
84
|
+
<Modal.Header>
|
|
85
85
|
<Modal.Title>Create Challenge</Modal.Title>
|
|
86
86
|
</Modal.Header>
|
|
87
87
|
<Modal.Body>
|
|
@@ -163,7 +163,7 @@ function SubmitModal({ isOpen, onClose, onSubmit, challenge }: SubmitModalProps)
|
|
|
163
163
|
|
|
164
164
|
return (
|
|
165
165
|
<Modal open={isOpen} onClose={onClose} size="md">
|
|
166
|
-
<Modal.Header
|
|
166
|
+
<Modal.Header>
|
|
167
167
|
<Modal.Title>Submit Challenge</Modal.Title>
|
|
168
168
|
</Modal.Header>
|
|
169
169
|
<Modal.Body>
|
|
@@ -228,7 +228,7 @@ function GradeModal({ isOpen, onClose, onGrade, challenge }: GradeModalProps) {
|
|
|
228
228
|
|
|
229
229
|
return (
|
|
230
230
|
<Modal open={isOpen} onClose={onClose} size="md">
|
|
231
|
-
<Modal.Header
|
|
231
|
+
<Modal.Header>
|
|
232
232
|
<Modal.Title>Grade Submission</Modal.Title>
|
|
233
233
|
</Modal.Header>
|
|
234
234
|
<Modal.Body>
|
|
@@ -334,20 +334,3 @@ export default function Topbar({
|
|
|
334
334
|
</header>
|
|
335
335
|
)
|
|
336
336
|
}
|
|
337
|
-
|
|
338
|
-
// ============================================================================
|
|
339
|
-
// Example Logo Component
|
|
340
|
-
// ============================================================================
|
|
341
|
-
|
|
342
|
-
export function ExampleLogo({ collapsed = false }: { collapsed?: boolean }) {
|
|
343
|
-
return (
|
|
344
|
-
<div className="flex items-center gap-2">
|
|
345
|
-
<div className="w-8 h-8 bg-primary/20 rounded-lg flex items-center justify-center">
|
|
346
|
-
<svg className="w-5 h-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2">
|
|
347
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
348
|
-
</svg>
|
|
349
|
-
</div>
|
|
350
|
-
{!collapsed && <span className="text-lg font-semibold text-foreground">My App</span>}
|
|
351
|
-
</div>
|
|
352
|
-
)
|
|
353
|
-
}
|
package/package.json
CHANGED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pin favorites — browser localStorage (same pattern as docs2).
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
const FAVORITES_KEY = 'deepspace-docs-favorites'
|
|
6
|
-
|
|
7
|
-
export function getFavorites(): Set<string> {
|
|
8
|
-
try {
|
|
9
|
-
const stored = localStorage.getItem(FAVORITES_KEY)
|
|
10
|
-
return stored ? new Set(JSON.parse(stored) as string[]) : new Set()
|
|
11
|
-
} catch {
|
|
12
|
-
return new Set()
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function saveFavorites(favs: Set<string>): void {
|
|
17
|
-
localStorage.setItem(FAVORITES_KEY, JSON.stringify([...favs]))
|
|
18
|
-
}
|
|
@@ -1,289 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matches {@link import('deepspace').useYjsRoom} (YjsRoom DO via /ws/yjs/:id) but wires
|
|
3
|
-
* {@link Awareness} over the server's MSG_AWARENESS path. Published `useYjsRoom` builds may
|
|
4
|
-
* omit awareness relay; collaborator avatars/presence otherwise never populate.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
8
|
-
import * as Y from 'yjs'
|
|
9
|
-
import {
|
|
10
|
-
Awareness,
|
|
11
|
-
createDecoder,
|
|
12
|
-
createEncoder,
|
|
13
|
-
encodeAwarenessMessage,
|
|
14
|
-
getAuthToken,
|
|
15
|
-
handleAwarenessMessage,
|
|
16
|
-
MSG_AWARENESS,
|
|
17
|
-
MSG_SYNC,
|
|
18
|
-
MSG_SYNC_STEP1,
|
|
19
|
-
MSG_SYNC_STEP2,
|
|
20
|
-
MSG_SYNC_UPDATE,
|
|
21
|
-
readVarUint,
|
|
22
|
-
readVarUint8Array,
|
|
23
|
-
toUint8Array,
|
|
24
|
-
writeVarUint,
|
|
25
|
-
writeVarUint8Array,
|
|
26
|
-
} from 'deepspace'
|
|
27
|
-
|
|
28
|
-
const UPDATE_BATCH_DELAY_MS = 16
|
|
29
|
-
|
|
30
|
-
export interface UseYjsRoomWithAwarenessResult {
|
|
31
|
-
doc: Y.Doc
|
|
32
|
-
text: string
|
|
33
|
-
setText: (value: string) => void
|
|
34
|
-
synced: boolean
|
|
35
|
-
/** From the server's WebSocket `{ type:'auth', canWrite }`; false until confirmed. */
|
|
36
|
-
canWrite: boolean
|
|
37
|
-
/** Becomes true after the auth message is parsed (eliminates transient false before handshake). */
|
|
38
|
-
writeAuthResolved: boolean
|
|
39
|
-
awareness: Awareness
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function useYjsRoomWithAwareness(
|
|
43
|
-
docId: string,
|
|
44
|
-
fieldName: string,
|
|
45
|
-
): UseYjsRoomWithAwarenessResult {
|
|
46
|
-
const [syncedDocId, setSyncedDocId] = useState<string | null>(null)
|
|
47
|
-
const [canWrite, setCanWrite] = useState(false)
|
|
48
|
-
const [writeAuthResolved, setWriteAuthResolved] = useState(false)
|
|
49
|
-
const [text, setTextState] = useState('')
|
|
50
|
-
const [, setUpdateCount] = useState(0)
|
|
51
|
-
const synced = syncedDocId === docId
|
|
52
|
-
|
|
53
|
-
const { doc, awareness } = useMemo(() => {
|
|
54
|
-
const nextDoc = new Y.Doc()
|
|
55
|
-
return { doc: nextDoc, awareness: new Awareness(nextDoc) }
|
|
56
|
-
// docId is a deliberate semantic dep (not textually used): switching rooms
|
|
57
|
-
// MUST mint a fresh Y.Doc/Awareness so state from the old room never leaks
|
|
58
|
-
// into the new one. Removing it would reuse a stale doc across docIds.
|
|
59
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- docId forces a fresh Y.Doc per room; removing it reuses a stale doc across rooms
|
|
60
|
-
}, [docId])
|
|
61
|
-
|
|
62
|
-
const wsRef = useRef<WebSocket | null>(null)
|
|
63
|
-
const isLocalRef = useRef(false)
|
|
64
|
-
const applyingRemoteAwarenessRef = useRef(false)
|
|
65
|
-
const pendingUpdatesRef = useRef<Uint8Array[]>([])
|
|
66
|
-
const updateBatchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
67
|
-
|
|
68
|
-
const yText = useMemo(() => doc.getText(fieldName), [doc, fieldName])
|
|
69
|
-
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
return () => {
|
|
72
|
-
awareness.setLocalState(null)
|
|
73
|
-
doc.destroy()
|
|
74
|
-
}
|
|
75
|
-
}, [awareness, doc])
|
|
76
|
-
|
|
77
|
-
useLayoutEffect(() => {
|
|
78
|
-
setSyncedDocId(null)
|
|
79
|
-
setCanWrite(false)
|
|
80
|
-
setWriteAuthResolved(false)
|
|
81
|
-
setTextState('')
|
|
82
|
-
}, [docId])
|
|
83
|
-
|
|
84
|
-
useEffect(() => {
|
|
85
|
-
const observer = () => {
|
|
86
|
-
if (isLocalRef.current) return
|
|
87
|
-
setTextState(yText.toString())
|
|
88
|
-
}
|
|
89
|
-
yText.observe(observer)
|
|
90
|
-
return () => yText.unobserve(observer)
|
|
91
|
-
}, [yText])
|
|
92
|
-
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
let ws: WebSocket | null = null
|
|
95
|
-
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
96
|
-
let alive = true
|
|
97
|
-
|
|
98
|
-
const connect = async (isReconnect: boolean) => {
|
|
99
|
-
if (!alive) return
|
|
100
|
-
|
|
101
|
-
if (!isReconnect) {
|
|
102
|
-
doc.transact(() => {
|
|
103
|
-
if (yText.length > 0) yText.delete(0, yText.length)
|
|
104
|
-
})
|
|
105
|
-
setTextState('')
|
|
106
|
-
try {
|
|
107
|
-
awareness.setLocalState(null)
|
|
108
|
-
} catch {
|
|
109
|
-
/* ignore */
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const token = await getAuthToken()
|
|
114
|
-
if (!alive) return
|
|
115
|
-
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
116
|
-
const baseUrl = `${protocol}//${window.location.host}`
|
|
117
|
-
const url = new URL(`/ws/yjs/${encodeURIComponent(docId)}`, baseUrl)
|
|
118
|
-
if (token) url.searchParams.set('token', token)
|
|
119
|
-
ws = new WebSocket(url.toString())
|
|
120
|
-
ws.binaryType = 'arraybuffer'
|
|
121
|
-
wsRef.current = ws
|
|
122
|
-
|
|
123
|
-
ws.onopen = () => {
|
|
124
|
-
setSyncedDocId(null)
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
ws.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
|
128
|
-
if (typeof event.data === 'string') {
|
|
129
|
-
try {
|
|
130
|
-
const msg = JSON.parse(event.data) as { type?: string; canWrite?: boolean }
|
|
131
|
-
if (msg.type === 'auth') {
|
|
132
|
-
setWriteAuthResolved(true)
|
|
133
|
-
if (typeof msg.canWrite === 'boolean') setCanWrite(msg.canWrite)
|
|
134
|
-
}
|
|
135
|
-
} catch {
|
|
136
|
-
/* ignore */
|
|
137
|
-
}
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const data = new Uint8Array(event.data)
|
|
142
|
-
const decoder = createDecoder(data)
|
|
143
|
-
const messageType = readVarUint(decoder)
|
|
144
|
-
|
|
145
|
-
if (messageType === MSG_SYNC) {
|
|
146
|
-
const syncType = readVarUint(decoder)
|
|
147
|
-
const payload = readVarUint8Array(decoder)
|
|
148
|
-
switch (syncType) {
|
|
149
|
-
case MSG_SYNC_STEP1: {
|
|
150
|
-
const diff = Y.encodeStateAsUpdate(doc, payload)
|
|
151
|
-
const enc = createEncoder()
|
|
152
|
-
writeVarUint(enc, MSG_SYNC)
|
|
153
|
-
writeVarUint(enc, MSG_SYNC_STEP2)
|
|
154
|
-
writeVarUint8Array(enc, diff)
|
|
155
|
-
ws?.send(toUint8Array(enc).buffer)
|
|
156
|
-
setSyncedDocId(docId)
|
|
157
|
-
break
|
|
158
|
-
}
|
|
159
|
-
case MSG_SYNC_STEP2: {
|
|
160
|
-
Y.applyUpdate(doc, payload, 'server')
|
|
161
|
-
setTextState(yText.toString())
|
|
162
|
-
setSyncedDocId(docId)
|
|
163
|
-
break
|
|
164
|
-
}
|
|
165
|
-
case MSG_SYNC_UPDATE: {
|
|
166
|
-
Y.applyUpdate(doc, payload, 'server')
|
|
167
|
-
setTextState(yText.toString())
|
|
168
|
-
setUpdateCount((c) => c + 1)
|
|
169
|
-
break
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
} else if (messageType === MSG_AWARENESS) {
|
|
173
|
-
applyingRemoteAwarenessRef.current = true
|
|
174
|
-
try {
|
|
175
|
-
handleAwarenessMessage(awareness, data)
|
|
176
|
-
} catch {
|
|
177
|
-
/* ignore malformed */
|
|
178
|
-
} finally {
|
|
179
|
-
applyingRemoteAwarenessRef.current = false
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
ws.onclose = () => {
|
|
185
|
-
wsRef.current = null
|
|
186
|
-
setSyncedDocId(null)
|
|
187
|
-
if (alive) reconnectTimer = setTimeout(() => void connect(true), 1000)
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
ws.onerror = () => ws?.close()
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
void connect(false)
|
|
194
|
-
|
|
195
|
-
return () => {
|
|
196
|
-
alive = false
|
|
197
|
-
if (reconnectTimer) clearTimeout(reconnectTimer)
|
|
198
|
-
if (ws) {
|
|
199
|
-
ws.onclose = null
|
|
200
|
-
ws.onmessage = null
|
|
201
|
-
ws.onerror = null
|
|
202
|
-
ws.close()
|
|
203
|
-
}
|
|
204
|
-
wsRef.current = null
|
|
205
|
-
}
|
|
206
|
-
}, [doc, docId, yText, awareness])
|
|
207
|
-
|
|
208
|
-
useEffect(() => {
|
|
209
|
-
/**
|
|
210
|
-
* Tiptap fires a Yjs `update` for every keystroke. Sending each as its
|
|
211
|
-
* own WS frame floods the socket and visibly drops typing latency under
|
|
212
|
-
* load. We coalesce into a single MSG_SYNC_UPDATE per ~16 ms via
|
|
213
|
-
* Y.mergeUpdates — same behaviour Tiptap collab examples ship with.
|
|
214
|
-
*/
|
|
215
|
-
const flushPendingUpdates = () => {
|
|
216
|
-
updateBatchTimerRef.current = null
|
|
217
|
-
const updates = pendingUpdatesRef.current
|
|
218
|
-
pendingUpdatesRef.current = []
|
|
219
|
-
const socket = wsRef.current
|
|
220
|
-
if (!socket || socket.readyState !== WebSocket.OPEN || updates.length === 0) return
|
|
221
|
-
|
|
222
|
-
const enc = createEncoder()
|
|
223
|
-
writeVarUint(enc, MSG_SYNC)
|
|
224
|
-
writeVarUint(enc, MSG_SYNC_UPDATE)
|
|
225
|
-
writeVarUint8Array(enc, updates.length === 1 ? updates[0] : Y.mergeUpdates(updates))
|
|
226
|
-
socket.send(toUint8Array(enc).buffer)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const handler = (update: Uint8Array, origin: unknown) => {
|
|
230
|
-
if (origin === 'server') return
|
|
231
|
-
const socket = wsRef.current
|
|
232
|
-
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
|
233
|
-
pendingUpdatesRef.current.push(update)
|
|
234
|
-
if (!updateBatchTimerRef.current) {
|
|
235
|
-
updateBatchTimerRef.current = setTimeout(flushPendingUpdates, UPDATE_BATCH_DELAY_MS)
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
doc.on('update', handler)
|
|
239
|
-
return () => {
|
|
240
|
-
doc.off('update', handler)
|
|
241
|
-
if (updateBatchTimerRef.current) {
|
|
242
|
-
clearTimeout(updateBatchTimerRef.current)
|
|
243
|
-
flushPendingUpdates()
|
|
244
|
-
}
|
|
245
|
-
pendingUpdatesRef.current = []
|
|
246
|
-
}
|
|
247
|
-
}, [doc])
|
|
248
|
-
|
|
249
|
-
useEffect(() => {
|
|
250
|
-
const handleAwarenessUpdate = ({
|
|
251
|
-
added,
|
|
252
|
-
updated,
|
|
253
|
-
removed,
|
|
254
|
-
}: {
|
|
255
|
-
added: number[]
|
|
256
|
-
updated: number[]
|
|
257
|
-
removed: number[]
|
|
258
|
-
}) => {
|
|
259
|
-
if (applyingRemoteAwarenessRef.current) return
|
|
260
|
-
const changedClients = [...added, ...updated, ...removed]
|
|
261
|
-
if (changedClients.length === 0) return
|
|
262
|
-
const socket = wsRef.current
|
|
263
|
-
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
|
264
|
-
socket.send(encodeAwarenessMessage(awareness, changedClients).buffer)
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
awareness.on('update', handleAwarenessUpdate)
|
|
268
|
-
return () => {
|
|
269
|
-
awareness.off('update', handleAwarenessUpdate)
|
|
270
|
-
awareness.setLocalState(null)
|
|
271
|
-
}
|
|
272
|
-
}, [awareness])
|
|
273
|
-
|
|
274
|
-
const setText = useCallback(
|
|
275
|
-
(value: string) => {
|
|
276
|
-
setTextState(value)
|
|
277
|
-
if (!canWrite) return
|
|
278
|
-
isLocalRef.current = true
|
|
279
|
-
doc.transact(() => {
|
|
280
|
-
yText.delete(0, yText.length)
|
|
281
|
-
yText.insert(0, value)
|
|
282
|
-
})
|
|
283
|
-
isLocalRef.current = false
|
|
284
|
-
},
|
|
285
|
-
[doc, yText, canWrite],
|
|
286
|
-
)
|
|
287
|
-
|
|
288
|
-
return { doc, text, setText, synced, canWrite, writeAuthResolved, awareness }
|
|
289
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File Attachments Feature - Constants
|
|
3
|
-
*
|
|
4
|
-
* For file category detection, use getFileCategory from useMimeTypeDetection.ts.
|
|
5
|
-
* For human-readable sizes, import `formatFileSize` from 'deepspace'.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/** Max upload size (50MB) */
|
|
9
|
-
export const MAX_FILE_SIZE = 50 * 1024 * 1024
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Leaderboard Feature - Constants
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import type { BadgeProps } from '@/components/ui'
|
|
6
|
-
|
|
7
|
-
export type BadgeVariant = BadgeProps['variant']
|
|
8
|
-
|
|
9
|
-
export const LEADERBOARD_CATEGORY = {
|
|
10
|
-
GENERAL: 'general',
|
|
11
|
-
SPEED: 'speed',
|
|
12
|
-
ACCURACY: 'accuracy',
|
|
13
|
-
} as const
|
|
14
|
-
|
|
15
|
-
export type LeaderboardCategory = typeof LEADERBOARD_CATEGORY[keyof typeof LEADERBOARD_CATEGORY]
|
|
16
|
-
|
|
17
|
-
export const CATEGORY_CONFIG: Record<LeaderboardCategory, { title: string; color: BadgeVariant }> = {
|
|
18
|
-
[LEADERBOARD_CATEGORY.GENERAL]: { title: 'General', color: 'info' },
|
|
19
|
-
[LEADERBOARD_CATEGORY.SPEED]: { title: 'Speed', color: 'warning' },
|
|
20
|
-
[LEADERBOARD_CATEGORY.ACCURACY]: { title: 'Accuracy', color: 'success' },
|
|
21
|
-
}
|