deepspace 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +9812 -4674
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +107 -117
  8. package/dist/index.js +610 -779
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +115 -0
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +397 -160
  17. package/dist/worker.js +506 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +158 -862
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +2 -3
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. package/scripts/add-feature.cjs +0 -807
@@ -0,0 +1,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 onClose={onClose}>
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 onClose={onClose}>
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 onClose={onClose}>
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,6 +1,6 @@
1
1
  {
2
2
  "name": "deepspace",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "engines": {
5
5
  "node": ">=22.15.0"
6
6
  },
@@ -33,7 +33,6 @@
33
33
  "files": [
34
34
  "dist",
35
35
  "features",
36
- "scripts",
37
36
  "CHANGELOG.md"
38
37
  ],
39
38
  "scripts": {
@@ -48,7 +47,7 @@
48
47
  "better-auth": "1.6.23",
49
48
  "citty": "^0.2.0",
50
49
  "cross-spawn": "^7.0.6",
51
- "esbuild": "^0.25.0",
50
+ "esbuild": "^0.27.0",
52
51
  "hono": "^4.6.0",
53
52
  "jose": "^6.1.0",
54
53
  "smol-toml": "^1.6.1",
@@ -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,10 +0,0 @@
1
- /**
2
- * Items Feature - Constants
3
- */
4
-
5
- export const ITEM_STATUS = {
6
- ACTIVE: 'active',
7
- ARCHIVED: 'archived',
8
- } as const
9
-
10
- export type ItemStatus = typeof ITEM_STATUS[keyof typeof ITEM_STATUS]
@@ -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
- }
@@ -1,2 +0,0 @@
1
- /** Shared emoji list used by the hover toolbar and mobile action sheet. */
2
- export const EMOJI_LIST = ['\u{1F44D}', '\u{1F44E}', '\u{1F604}', '\u{1F389}', '\u2764\uFE0F', '\u{1F680}', '\u{1F440}', '\u{1F525}', '\u{1F4AF}', '\u{1F914}', '\u{1F622}', '\u{1F64F}']