@tuturuuu/ui 0.37.2 → 0.38.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +9 -6
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
  4. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  5. package/src/components/ui/custom/education-todo.tsx +191 -0
  6. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  7. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  8. package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
  9. package/src/components/ui/custom/workspace-select.tsx +12 -4
  10. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  11. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  12. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  13. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  14. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  15. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  16. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  17. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  18. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  19. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  20. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  21. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  22. package/src/components/ui/popover.tsx +3 -1
  23. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  24. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  25. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  26. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  27. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  28. package/src/components/ui/text-editor/editor.tsx +8 -3
  29. package/src/components/ui/text-editor/extensions.ts +8 -2
  30. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  31. package/src/components/ui/text-editor/markdown.ts +11 -0
  32. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  33. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  34. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  35. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  36. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  37. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  38. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  39. package/src/hooks/calendar-creation-request.test.ts +62 -0
  40. package/src/hooks/calendar-creation-request.ts +34 -0
  41. package/src/hooks/calendar-event-write-payload.ts +24 -0
  42. package/src/hooks/notification-subscription.ts +5 -2
  43. package/src/hooks/supabase-provider.ts +38 -72
  44. package/src/hooks/use-calendar.tsx +21 -45
  45. package/src/hooks/use-exchange-rates.ts +4 -15
  46. package/src/hooks/use-workspace-presence.ts +9 -9
  47. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -37,7 +37,7 @@ export interface YjsCollaborationResult {
37
37
  }
38
38
 
39
39
  /**
40
- * Hook for managing Yjs collaboration with Supabase Realtime.
40
+ * Hook for managing Yjs collaboration with Cloudflare realtime.
41
41
  *
42
42
  * Uses deferred cleanup so that React StrictMode's double-invoke cycle
43
43
  * (mount → cleanup → remount) reuses the existing SupabaseProvider instead
@@ -72,17 +72,27 @@ export function useYjsCollaboration(
72
72
  // Ref that event listeners check — survives StrictMode cleanup/remount
73
73
  const mountedRef = useRef(false);
74
74
 
75
- // Create Yjs document and awareness (stable references)
76
- const doc = useMemo(() => (enabled ? new Y.Doc() : null), [enabled]);
77
-
78
- const awareness = useMemo(
79
- () => (enabled && doc ? new Awareness(doc) : null),
80
- [enabled, doc]
75
+ const hasUser = !!user;
76
+ // Create the document without starting awareness timers during render.
77
+ const documentScope = JSON.stringify([
78
+ channel,
79
+ tableName,
80
+ columnName,
81
+ id,
82
+ user?.id,
83
+ ]);
84
+ const doc = useMemo(
85
+ () => (enabled && hasUser ? new Y.Doc({ guid: documentScope }) : null),
86
+ [enabled, hasUser, documentScope]
81
87
  );
82
88
 
89
+ const awareness =
90
+ providerState && providerState.id === doc?.clientID
91
+ ? providerState.awareness
92
+ : null;
93
+
83
94
  // Stabilize callback and user refs — these should never cause provider
84
95
  // destruction/recreation.
85
- const hasUser = !!user;
86
96
  const userRef = useRef(user);
87
97
  userRef.current = user;
88
98
  const onSyncRef = useRef(onSync);
@@ -94,19 +104,22 @@ export function useYjsCollaboration(
94
104
 
95
105
  // Provider lifecycle: create/destroy based on channel config
96
106
  useEffect(() => {
97
- if (!enabled || !doc || !awareness || !hasUser) return;
107
+ if (!enabled || !doc || !hasUser) return;
98
108
 
99
109
  mountedRef.current = true;
100
110
 
101
111
  // ── StrictMode reuse path ───────────────────────────────────────
102
112
  // If a pending deferred destruction exists, cancel it and reuse
103
- // the existing provider. This prevents the Supabase Realtime
104
- // "leave then immediate re-join" race that causes a 10s timeout.
113
+ // the existing provider only when its document identity still matches.
105
114
  if (destroyTimerRef.current) {
106
115
  clearTimeout(destroyTimerRef.current);
107
116
  destroyTimerRef.current = null;
108
117
 
109
- if (providerRef.current && !providerRef.current.destroyed) {
118
+ if (
119
+ providerRef.current &&
120
+ !providerRef.current.destroyed &&
121
+ providerRef.current.id === doc.clientID
122
+ ) {
110
123
  console.log('♻️ Reusing existing SupabaseProvider (StrictMode)');
111
124
  return () => {
112
125
  mountedRef.current = false;
@@ -133,6 +146,7 @@ export function useYjsCollaboration(
133
146
  }
134
147
 
135
148
  const supabase = createClient();
149
+ const awareness = new Awareness(doc);
136
150
 
137
151
  // Set initial awareness state
138
152
  const currentUser = userRef.current;
@@ -152,6 +166,7 @@ export function useYjsCollaboration(
152
166
  tableName: tableName,
153
167
  columnName: columnName,
154
168
  awareness,
169
+ ownsDocument: true,
155
170
  resyncInterval: 30000,
156
171
  saveDebounceMs: saveDebounceMs ?? 300,
157
172
  loadState: loadDocumentState,
@@ -165,25 +180,25 @@ export function useYjsCollaboration(
165
180
  // Listen to provider events — use mountedRef so listeners stay valid
166
181
  // across StrictMode cleanup/remount without re-registration.
167
182
  provider.on('status', ([{ status }]) => {
168
- if (!mountedRef.current) return;
183
+ if (!mountedRef.current || providerRef.current !== provider) return;
169
184
  console.log('📡 Provider status:', status);
170
185
  setConnected(status === 'connected');
171
186
  });
172
187
 
173
188
  provider.on('synced', ([syncState]) => {
174
- if (!mountedRef.current) return;
189
+ if (!mountedRef.current || providerRef.current !== provider) return;
175
190
  console.log('🔄 Provider synced:', syncState);
176
191
  setSynced(syncState);
177
192
  onSyncRef.current?.(syncState);
178
193
  });
179
194
 
180
195
  provider.on('sync', ([syncState]) => {
181
- if (!mountedRef.current) return;
196
+ if (!mountedRef.current || providerRef.current !== provider) return;
182
197
  console.log('🔄 Provider sync event:', syncState);
183
198
  });
184
199
 
185
200
  provider.on('save', (version) => {
186
- if (!mountedRef.current) return;
201
+ if (!mountedRef.current || providerRef.current !== provider) return;
187
202
  console.log('💾 Document saved to database, version:', version);
188
203
  onSaveRef.current?.(version);
189
204
  });
@@ -196,7 +211,7 @@ export function useYjsCollaboration(
196
211
  channel: string;
197
212
  status: string;
198
213
  }) => {
199
- if (!mountedRef.current) return;
214
+ if (!mountedRef.current || providerRef.current !== provider) return;
200
215
  console.error('❌ Provider error:', {
201
216
  message: errorInfo.message,
202
217
  channel: errorInfo.channel,
@@ -207,19 +222,19 @@ export function useYjsCollaboration(
207
222
  );
208
223
 
209
224
  provider.on('connect', () => {
210
- if (!mountedRef.current) return;
225
+ if (!mountedRef.current || providerRef.current !== provider) return;
211
226
  console.log('✅ Provider connected');
212
227
  });
213
228
 
214
229
  provider.on('disconnect', () => {
215
- if (!mountedRef.current) return;
230
+ if (!mountedRef.current || providerRef.current !== provider) return;
216
231
  console.log('🔌 Provider disconnected');
217
232
  setConnected(false);
218
233
  setSynced(false);
219
234
  });
220
235
 
221
236
  provider.on('dom-error', (error: DOMException) => {
222
- if (!mountedRef.current) return;
237
+ if (!mountedRef.current || providerRef.current !== provider) return;
223
238
  console.warn(
224
239
  '⚠️ DOM reconciliation error handled gracefully:',
225
240
  error.message
@@ -227,7 +242,7 @@ export function useYjsCollaboration(
227
242
  });
228
243
 
229
244
  provider.on('reconnect-failed', () => {
230
- if (!mountedRef.current) return;
245
+ if (!mountedRef.current || providerRef.current !== provider) return;
231
246
  // If page is visible (user is active but network dropped), restart
232
247
  // the backoff cycle after a short delay to avoid hammering
233
248
  if (
@@ -240,8 +255,8 @@ export function useYjsCollaboration(
240
255
  setTimeout(() => {
241
256
  if (
242
257
  mountedRef.current &&
243
- providerRef.current &&
244
- !providerRef.current.destroyed
258
+ providerRef.current === provider &&
259
+ !provider.destroyed
245
260
  ) {
246
261
  providerRef.current.resetAndReconnect();
247
262
  }
@@ -269,7 +284,6 @@ export function useYjsCollaboration(
269
284
  columnName,
270
285
  hasUser,
271
286
  doc,
272
- awareness,
273
287
  enabled,
274
288
  broadcastDebounceMs,
275
289
  saveDebounceMs,
@@ -290,8 +304,12 @@ export function useYjsCollaboration(
290
304
  return {
291
305
  doc,
292
306
  awareness,
293
- provider: providerState,
294
- synced,
295
- connected,
307
+ provider:
308
+ providerState && providerState.id === doc?.clientID
309
+ ? providerState
310
+ : null,
311
+ synced: !!providerState && providerState.id === doc?.clientID && synced,
312
+ connected:
313
+ !!providerState && providerState.id === doc?.clientID && connected,
296
314
  };
297
315
  }