@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.
- package/CHANGELOG.md +28 -0
- package/package.json +9 -6
- package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +86 -0
- package/src/components/ui/custom/education-todo.test.tsx +131 -0
- package/src/components/ui/custom/education-todo.tsx +191 -0
- package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
- package/src/components/ui/custom/workspace-picker-content.tsx +93 -30
- package/src/components/ui/custom/workspace-select.tsx +12 -4
- package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
- package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
- package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
- package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
- package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
- package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
- package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
- package/src/components/ui/popover.tsx +3 -1
- package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
- package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
- package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
- package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
- package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
- package/src/components/ui/text-editor/editor.tsx +8 -3
- package/src/components/ui/text-editor/extensions.ts +8 -2
- package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
- package/src/components/ui/text-editor/markdown.ts +11 -0
- package/src/components/ui/text-editor/tool-bar.tsx +43 -44
- package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
- package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
- package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
- package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
- package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
- package/src/hooks/calendar-creation-request.test.ts +62 -0
- package/src/hooks/calendar-creation-request.ts +34 -0
- package/src/hooks/calendar-event-write-payload.ts +24 -0
- package/src/hooks/notification-subscription.ts +5 -2
- package/src/hooks/supabase-provider.ts +38 -72
- package/src/hooks/use-calendar.tsx +21 -45
- package/src/hooks/use-exchange-rates.ts +4 -15
- package/src/hooks/use-workspace-presence.ts +9 -9
- package/src/hooks/use-yjs-collaboration.ts +45 -27
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createRealtimeClient,
|
|
3
|
+
type RealtimeChannel,
|
|
4
|
+
} from '@tuturuuu/internal-api/realtime';
|
|
1
5
|
import type { SupabaseClient } from '@tuturuuu/supabase/next/client';
|
|
2
|
-
import type { RealtimeChannel } from '@tuturuuu/supabase/next/realtime';
|
|
3
6
|
import debug from 'debug';
|
|
4
7
|
import { EventEmitter } from 'eventemitter3';
|
|
5
8
|
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
@@ -22,12 +25,15 @@ export interface SupabaseProviderConfig {
|
|
|
22
25
|
broadcastDebounceMs?: number; // Debounce time for broadcasting updates (0 = immediate, default: 0)
|
|
23
26
|
loadState?: () => Promise<number[] | null>;
|
|
24
27
|
saveState?: (state: number[]) => Promise<boolean | undefined>;
|
|
28
|
+
ownsDocument?: boolean;
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export default class SupabaseProvider extends EventEmitter {
|
|
28
32
|
public awareness: awarenessProtocol.Awareness;
|
|
29
33
|
public connected = false;
|
|
30
34
|
private channel: RealtimeChannel | null = null;
|
|
35
|
+
private realtime = createRealtimeClient();
|
|
36
|
+
private pendingAwarenessClients = new Set<number>();
|
|
31
37
|
|
|
32
38
|
private _synced: boolean = false;
|
|
33
39
|
private resyncInterval: NodeJS.Timeout | undefined;
|
|
@@ -71,13 +77,11 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
71
77
|
return;
|
|
72
78
|
}
|
|
73
79
|
|
|
74
|
-
// Only broadcast and save if connected and not destroyed
|
|
75
80
|
if (!this.connected || this.destroyed) {
|
|
76
81
|
this.logger('skipping broadcast - not connected or destroyed');
|
|
77
82
|
return;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
// Validate update size (avoid broadcasting empty or corrupted updates)
|
|
81
85
|
if (!update || update.length === 0) {
|
|
82
86
|
this.logger('skipping broadcast - empty update');
|
|
83
87
|
return;
|
|
@@ -91,8 +95,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
91
95
|
);
|
|
92
96
|
|
|
93
97
|
if (this.broadcastDebounceMs > 0) {
|
|
94
|
-
// Debounced broadcast for free tier — merge rapid incremental edits so
|
|
95
|
-
// peers do not miss insertion structs that later updates depend on.
|
|
96
98
|
this.pendingBroadcastUpdate = this.pendingBroadcastUpdate
|
|
97
99
|
? Y.mergeUpdates([this.pendingBroadcastUpdate, update])
|
|
98
100
|
: update;
|
|
@@ -110,7 +112,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
110
112
|
}
|
|
111
113
|
}, this.broadcastDebounceMs);
|
|
112
114
|
} else {
|
|
113
|
-
// Immediate broadcast for paid tier
|
|
114
115
|
this.emit('message', update);
|
|
115
116
|
}
|
|
116
117
|
|
|
@@ -118,12 +119,10 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
debouncedSave() {
|
|
121
|
-
// Clear any pending save
|
|
122
122
|
if (this.saveTimeout) {
|
|
123
123
|
clearTimeout(this.saveTimeout);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// Schedule a new save after the debounce period
|
|
127
126
|
this.saveTimeout = setTimeout(() => {
|
|
128
127
|
this.saveTimeout = undefined;
|
|
129
128
|
this.logger('debounce period elapsed, saving to database');
|
|
@@ -131,12 +130,7 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
131
130
|
}, this.saveDebounceMs);
|
|
132
131
|
}
|
|
133
132
|
|
|
134
|
-
/**
|
|
135
|
-
* Immediately save any pending changes without waiting for debounce period.
|
|
136
|
-
* Also flushes any pending broadcast.
|
|
137
|
-
*/
|
|
138
133
|
async flushSave() {
|
|
139
|
-
// Flush pending broadcast first
|
|
140
134
|
if (this.broadcastDebounceTimeout) {
|
|
141
135
|
clearTimeout(this.broadcastDebounceTimeout);
|
|
142
136
|
this.broadcastDebounceTimeout = undefined;
|
|
@@ -156,24 +150,30 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
156
150
|
}
|
|
157
151
|
}
|
|
158
152
|
|
|
159
|
-
onAwarenessUpdate(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (this.
|
|
168
|
-
|
|
169
|
-
|
|
153
|
+
onAwarenessUpdate(
|
|
154
|
+
{
|
|
155
|
+
added,
|
|
156
|
+
updated,
|
|
157
|
+
removed,
|
|
158
|
+
}: { added: number[]; updated: number[]; removed: number[] },
|
|
159
|
+
origin: unknown
|
|
160
|
+
) {
|
|
161
|
+
if (origin === this || this.destroyed) return;
|
|
162
|
+
for (const id of [...added, ...updated, ...removed])
|
|
163
|
+
this.pendingAwarenessClients.add(id);
|
|
164
|
+
if (this.awarenessDebounceTimeout) return;
|
|
170
165
|
this.awarenessDebounceTimeout = setTimeout(() => {
|
|
171
|
-
this.
|
|
166
|
+
this.awarenessDebounceTimeout = undefined;
|
|
167
|
+
const clients = [...this.pendingAwarenessClients];
|
|
168
|
+
this.pendingAwarenessClients.clear();
|
|
169
|
+
this.emit(
|
|
170
|
+
'awareness',
|
|
171
|
+
awarenessProtocol.encodeAwarenessUpdate(this.awareness, clients)
|
|
172
|
+
);
|
|
172
173
|
}, 150);
|
|
173
174
|
}
|
|
174
175
|
|
|
175
176
|
removeSelfFromAwarenessOnUnload() {
|
|
176
|
-
// Flush any pending saves before unload
|
|
177
177
|
this.flushSave();
|
|
178
178
|
awarenessProtocol.removeAwarenessStates(
|
|
179
179
|
this.awareness,
|
|
@@ -184,7 +184,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
184
184
|
|
|
185
185
|
async save() {
|
|
186
186
|
try {
|
|
187
|
-
// Don't save if not connected or destroyed
|
|
188
187
|
if (!this.connected || this.destroyed) {
|
|
189
188
|
this.logger('skipping save - not connected or destroyed');
|
|
190
189
|
return false;
|
|
@@ -192,13 +191,11 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
192
191
|
|
|
193
192
|
const content = Array.from(Y.encodeStateAsUpdate(this.doc));
|
|
194
193
|
|
|
195
|
-
// Skip save if content is empty or too small (likely invalid)
|
|
196
194
|
if (!content || content.length === 0) {
|
|
197
195
|
this.logger('skipping save - empty content');
|
|
198
196
|
return false;
|
|
199
197
|
}
|
|
200
198
|
|
|
201
|
-
// Skip save if content is suspiciously small (might be corrupted)
|
|
202
199
|
if (content.length < 10) {
|
|
203
200
|
this.logger('skipping save - content too small, possibly corrupted');
|
|
204
201
|
return false;
|
|
@@ -229,7 +226,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
229
226
|
if (error) {
|
|
230
227
|
this.logger(`save failed with status ${status}:`, error);
|
|
231
228
|
|
|
232
|
-
// Handle 422 errors - task was deleted, validation failed, or RLS rejected
|
|
233
229
|
if (status === 422) {
|
|
234
230
|
console.warn(
|
|
235
231
|
`Failed to save Yjs state (422 - Unprocessable Entity):`,
|
|
@@ -241,15 +237,12 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
241
237
|
}
|
|
242
238
|
);
|
|
243
239
|
|
|
244
|
-
// Mark as not synced so UI shows warning
|
|
245
240
|
this.synced = false;
|
|
246
241
|
|
|
247
|
-
// Stop trying to save this document
|
|
248
242
|
this.logger('stopping future saves due to 422 error');
|
|
249
243
|
return false;
|
|
250
244
|
}
|
|
251
245
|
|
|
252
|
-
// Handle 404 errors - task doesn't exist
|
|
253
246
|
if (status === 404) {
|
|
254
247
|
console.warn(`Task ${this.config.id} not found, stopping saves`);
|
|
255
248
|
this.synced = false;
|
|
@@ -278,7 +271,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
278
271
|
private async onConnect() {
|
|
279
272
|
this.logger('connected');
|
|
280
273
|
|
|
281
|
-
// Reset reconnect attempts on successful connection
|
|
282
274
|
this.reconnectAttempts = 0;
|
|
283
275
|
if (this.reconnectTimeout) {
|
|
284
276
|
clearTimeout(this.reconnectTimeout);
|
|
@@ -312,7 +304,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
312
304
|
this.logger('setting connected flag to true');
|
|
313
305
|
this.isOnline(true);
|
|
314
306
|
|
|
315
|
-
// Mark as synced after loading initial data
|
|
316
307
|
this.synced = true;
|
|
317
308
|
|
|
318
309
|
this.emit('status', [{ status: 'connected' }]);
|
|
@@ -331,9 +322,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
331
322
|
this.version++;
|
|
332
323
|
Y.applyUpdate(this.doc, update, origin);
|
|
333
324
|
} catch (error) {
|
|
334
|
-
// Handle DOM reconciliation errors during Yjs sync
|
|
335
|
-
// This happens when ProseMirror/Tiptap tries to manipulate DOM nodes
|
|
336
|
-
// that React has already cleaned up during reconnection scenarios
|
|
337
325
|
if (
|
|
338
326
|
error instanceof DOMException &&
|
|
339
327
|
error.name === 'NotFoundError' &&
|
|
@@ -343,33 +331,31 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
343
331
|
'DOM reconciliation error during Yjs update - this can happen after AFK reconnection'
|
|
344
332
|
);
|
|
345
333
|
console.warn(
|
|
346
|
-
'[
|
|
334
|
+
'[Cloudflare collaboration] DOM reconciliation error during Yjs sync. ' +
|
|
347
335
|
'This is usually harmless and occurs when the editor reconnects after being idle.'
|
|
348
336
|
);
|
|
349
|
-
// Emit event so the component can handle recovery if needed
|
|
350
337
|
this.emit('dom-error', error);
|
|
351
338
|
return;
|
|
352
339
|
}
|
|
353
|
-
// Re-throw other errors
|
|
354
340
|
throw error;
|
|
355
341
|
}
|
|
356
342
|
}
|
|
357
343
|
|
|
358
344
|
private disconnect() {
|
|
359
345
|
if (this.channel) {
|
|
360
|
-
this.
|
|
346
|
+
this.realtime.removeChannel(this.channel);
|
|
361
347
|
this.channel = null;
|
|
362
348
|
}
|
|
363
349
|
}
|
|
364
350
|
|
|
365
351
|
private connect() {
|
|
366
|
-
this.channel = this.
|
|
352
|
+
this.channel = this.realtime.channel(this.config.channel);
|
|
367
353
|
if (this.channel) {
|
|
368
354
|
this.channel
|
|
369
|
-
.on('broadcast', { event: 'message' }, ({ payload }) => {
|
|
355
|
+
.on<number[]>('broadcast', { event: 'message' }, ({ payload }) => {
|
|
370
356
|
this.onMessage(Uint8Array.from(payload), this);
|
|
371
357
|
})
|
|
372
|
-
.on('broadcast', { event: 'awareness' }, ({ payload }) => {
|
|
358
|
+
.on<number[]>('broadcast', { event: 'awareness' }, ({ payload }) => {
|
|
373
359
|
this.onAwareness(Uint8Array.from(payload));
|
|
374
360
|
})
|
|
375
361
|
.subscribe((status, err) => {
|
|
@@ -420,7 +406,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
420
406
|
this.config = config || {};
|
|
421
407
|
this.id = doc.clientID;
|
|
422
408
|
|
|
423
|
-
// Initialize debounce times
|
|
424
409
|
(this as any).saveDebounceMs = this.config.saveDebounceMs ?? 1000;
|
|
425
410
|
(this as any).broadcastDebounceMs = this.config.broadcastDebounceMs ?? 0;
|
|
426
411
|
|
|
@@ -429,11 +414,10 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
429
414
|
this.on('disconnect', this.onDisconnect);
|
|
430
415
|
|
|
431
416
|
this.logger = debug(`y-${doc.clientID}`);
|
|
432
|
-
// turn on debug logging to the console
|
|
433
417
|
this.logger.enabled = true;
|
|
434
418
|
|
|
435
419
|
this.logger('constructor initializing');
|
|
436
|
-
this.logger('connecting to
|
|
420
|
+
this.logger('connecting to Cloudflare realtime', doc.guid);
|
|
437
421
|
this.logger(`save debounce set to ${this.saveDebounceMs}ms`);
|
|
438
422
|
|
|
439
423
|
if (
|
|
@@ -448,19 +432,16 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
448
432
|
`setting resync interval to every ${interval / 1000} seconds`
|
|
449
433
|
);
|
|
450
434
|
this.resyncInterval = setInterval(() => {
|
|
451
|
-
// Only resync if connected and not destroyed
|
|
452
435
|
if (!this.connected || this.destroyed) {
|
|
453
436
|
this.logger('skipping resync - not connected or destroyed');
|
|
454
437
|
return;
|
|
455
438
|
}
|
|
456
439
|
|
|
457
|
-
// Skip resync when page is hidden (no one is watching)
|
|
458
440
|
if (!isPageVisible()) {
|
|
459
441
|
this.logger('skipping resync - page not visible');
|
|
460
442
|
return;
|
|
461
443
|
}
|
|
462
444
|
|
|
463
|
-
// Skip resync if no local changes since last resync
|
|
464
445
|
if (!this._dirty) {
|
|
465
446
|
this.logger('skipping resync - no local changes');
|
|
466
447
|
return;
|
|
@@ -471,7 +452,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
471
452
|
this.logger('resyncing (resync interval elapsed)');
|
|
472
453
|
const update = Y.encodeStateAsUpdate(this.doc);
|
|
473
454
|
|
|
474
|
-
// Validate update before broadcasting
|
|
475
455
|
if (!update || update.length === 0) {
|
|
476
456
|
this.logger('skipping resync - empty update');
|
|
477
457
|
return;
|
|
@@ -497,9 +477,7 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
497
477
|
process.on('exit', this.processExitHandler);
|
|
498
478
|
}
|
|
499
479
|
this.on('awareness', (update) => {
|
|
500
|
-
// Only broadcast if connected and channel exists
|
|
501
480
|
if (this.connected && this.channel && !this.destroyed) {
|
|
502
|
-
// Validate update before broadcasting
|
|
503
481
|
if (update && update.length > 0) {
|
|
504
482
|
this.channel.send({
|
|
505
483
|
type: 'broadcast',
|
|
@@ -510,9 +488,7 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
510
488
|
}
|
|
511
489
|
});
|
|
512
490
|
this.on('message', (update) => {
|
|
513
|
-
// Only broadcast if connected and channel exists
|
|
514
491
|
if (this.connected && this.channel && !this.destroyed) {
|
|
515
|
-
// Validate update before broadcasting
|
|
516
492
|
if (update && update.length > 0) {
|
|
517
493
|
this.channel.send({
|
|
518
494
|
type: 'broadcast',
|
|
@@ -525,7 +501,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
525
501
|
|
|
526
502
|
this.connect();
|
|
527
503
|
|
|
528
|
-
// Reconnect when user returns from AFK (hidden → visible)
|
|
529
504
|
if (typeof document !== 'undefined') {
|
|
530
505
|
this._handleVisibilityChange = () => {
|
|
531
506
|
if (this.destroyed) return;
|
|
@@ -535,7 +510,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
535
510
|
this.resetAndReconnect();
|
|
536
511
|
}
|
|
537
512
|
|
|
538
|
-
// Pause reconnect timer when page goes hidden to conserve attempts
|
|
539
513
|
if (document.visibilityState === 'hidden' && this.reconnectTimeout) {
|
|
540
514
|
this.logger('page hidden during reconnect backoff — pausing timer');
|
|
541
515
|
clearTimeout(this.reconnectTimeout);
|
|
@@ -550,7 +524,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
550
524
|
|
|
551
525
|
this.awareness.on('update', this.boundAwarenessUpdate);
|
|
552
526
|
|
|
553
|
-
// Listen to document updates and broadcast them to peers
|
|
554
527
|
this.doc.on('update', this.boundDocumentUpdate);
|
|
555
528
|
}
|
|
556
529
|
|
|
@@ -583,7 +556,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
583
556
|
return;
|
|
584
557
|
}
|
|
585
558
|
|
|
586
|
-
// Exponential backoff with jitter
|
|
587
559
|
const delay = Math.min(
|
|
588
560
|
this.reconnectDelay * 2 ** this.reconnectAttempts,
|
|
589
561
|
this.maxReconnectDelay
|
|
@@ -600,19 +572,12 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
600
572
|
this.reconnectAttempts++;
|
|
601
573
|
this.logger(`reconnect attempt ${this.reconnectAttempts}`);
|
|
602
574
|
|
|
603
|
-
// Disconnect old channel if it exists
|
|
604
575
|
this.disconnect();
|
|
605
576
|
|
|
606
|
-
// Try to reconnect
|
|
607
577
|
this.connect();
|
|
608
578
|
}, actualDelay);
|
|
609
579
|
}
|
|
610
580
|
|
|
611
|
-
/**
|
|
612
|
-
* Reset backoff state and force a fresh connection cycle.
|
|
613
|
-
* Used when the user returns from AFK (visibilitychange) or when
|
|
614
|
-
* reconnect-failed fires while the page is still active.
|
|
615
|
-
*/
|
|
616
581
|
public resetAndReconnect() {
|
|
617
582
|
if (this.destroyed || this.connected) return;
|
|
618
583
|
|
|
@@ -638,8 +603,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
638
603
|
this.emit('status', [{ status: 'disconnected' }]);
|
|
639
604
|
}
|
|
640
605
|
|
|
641
|
-
// update awareness (keep all users except local)
|
|
642
|
-
// FIXME? compare to broadcast channel behavior
|
|
643
606
|
const states = Array.from(this.awareness.getStates().keys()).filter(
|
|
644
607
|
(client) => client !== this.doc.clientID
|
|
645
608
|
);
|
|
@@ -674,7 +637,9 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
674
637
|
}
|
|
675
638
|
|
|
676
639
|
public destroy() {
|
|
640
|
+
if (this.destroyed) return;
|
|
677
641
|
this.logger('destroying');
|
|
642
|
+
const pendingSave = this.flushSave();
|
|
678
643
|
this.destroyed = true;
|
|
679
644
|
|
|
680
645
|
// Clear reconnect timeout
|
|
@@ -696,9 +661,6 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
696
661
|
}
|
|
697
662
|
this.pendingBroadcastUpdate = undefined;
|
|
698
663
|
|
|
699
|
-
// Save any pending changes before destroying
|
|
700
|
-
this.flushSave();
|
|
701
|
-
|
|
702
664
|
// Remove visibility change listener
|
|
703
665
|
if (this._handleVisibilityChange && typeof document !== 'undefined') {
|
|
704
666
|
document.removeEventListener(
|
|
@@ -721,5 +683,9 @@ export default class SupabaseProvider extends EventEmitter {
|
|
|
721
683
|
this.doc.off('update', this.boundDocumentUpdate);
|
|
722
684
|
|
|
723
685
|
if (this.channel) this.disconnect();
|
|
686
|
+
if (this.config.ownsDocument) {
|
|
687
|
+
this.awareness.destroy();
|
|
688
|
+
void pendingSave.finally(() => this.doc.destroy());
|
|
689
|
+
}
|
|
724
690
|
}
|
|
725
691
|
}
|
|
@@ -3,7 +3,6 @@ import {
|
|
|
3
3
|
deleteWorkspaceCalendarEvent,
|
|
4
4
|
updateWorkspaceCalendarEvent,
|
|
5
5
|
type WorkspaceCalendarEventCreatePayload,
|
|
6
|
-
type WorkspaceCalendarEventUpdatePayload,
|
|
7
6
|
} from '@tuturuuu/internal-api';
|
|
8
7
|
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
9
8
|
import type {
|
|
@@ -28,8 +27,13 @@ import {
|
|
|
28
27
|
useState,
|
|
29
28
|
} from 'react';
|
|
30
29
|
import { getTaskApiUrl } from '../lib/tasks-app-url';
|
|
30
|
+
import {
|
|
31
|
+
createCalendarCreationRequests,
|
|
32
|
+
createOptimisticEventId,
|
|
33
|
+
} from './calendar-creation-request';
|
|
31
34
|
import { roundToNearest15Minutes } from './calendar-date-utils';
|
|
32
35
|
import { createCalendarEventLookup } from './calendar-event-lookup';
|
|
36
|
+
import { calendarEventUpdatePayload } from './calendar-event-write-payload';
|
|
33
37
|
import { useCalendarSync } from './use-calendar-sync';
|
|
34
38
|
import { useOpenInitialCalendarEvent } from './use-open-initial-calendar-event';
|
|
35
39
|
|
|
@@ -39,17 +43,6 @@ type TaskDragData = {
|
|
|
39
43
|
totalDuration?: number;
|
|
40
44
|
};
|
|
41
45
|
|
|
42
|
-
const createOptimisticEventId = () => {
|
|
43
|
-
if (
|
|
44
|
-
typeof crypto !== 'undefined' &&
|
|
45
|
-
typeof crypto.randomUUID === 'function'
|
|
46
|
-
) {
|
|
47
|
-
return `optimistic-${crypto.randomUUID()}`;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
return `optimistic-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
51
|
-
};
|
|
52
|
-
|
|
53
46
|
const noStoreFetchOptions = {
|
|
54
47
|
fetch: (input: RequestInfo | URL, init?: RequestInit) =>
|
|
55
48
|
fetch(input, { ...init, cache: 'no-store' }),
|
|
@@ -101,7 +94,8 @@ const CalendarContext = createContext<{
|
|
|
101
94
|
getGoogleEvents: () => CalendarEvent[];
|
|
102
95
|
getEventLevel: (eventId: string) => number;
|
|
103
96
|
addEvent: (
|
|
104
|
-
event: Omit<CalendarEvent, 'id'
|
|
97
|
+
event: Omit<CalendarEvent, 'id'>,
|
|
98
|
+
options?: { requestId?: string }
|
|
105
99
|
) => Promise<CalendarEvent | undefined>;
|
|
106
100
|
addEmptyEvent: (date: Date, isAllDay?: boolean) => CalendarEvent;
|
|
107
101
|
addEmptyEventWithDuration: (startDate: Date, endDate: Date) => CalendarEvent;
|
|
@@ -265,11 +259,7 @@ export type CalendarEventAdapter = {
|
|
|
265
259
|
) => Promise<CalendarEvent | undefined> | CalendarEvent | undefined;
|
|
266
260
|
};
|
|
267
261
|
|
|
268
|
-
/**
|
|
269
|
-
* Syncs task total_duration after a calendar event is resized or moved.
|
|
270
|
-
* - Uses canonical workspace calendar events as the source of truth
|
|
271
|
-
* - If total scheduled time exceeds task's total_duration, auto-increase it
|
|
272
|
-
*/
|
|
262
|
+
/** Increase task duration when its canonical calendar schedule exceeds it. */
|
|
273
263
|
async function syncTaskDurationAfterEventChange(
|
|
274
264
|
supabase: any,
|
|
275
265
|
eventId: string,
|
|
@@ -442,6 +432,7 @@ export const CalendarProvider = ({
|
|
|
442
432
|
const [activeEventId, setActiveEventId] = useState<string | null>(null);
|
|
443
433
|
const [previewEventId, setPreviewEventId] = useState<string | null>(null);
|
|
444
434
|
const [isModalHidden, setModalHidden] = useState(false);
|
|
435
|
+
const creationRequests = useRef(createCalendarCreationRequests());
|
|
445
436
|
const [pendingNewEvent, setPendingNewEvent] =
|
|
446
437
|
useState<Partial<CalendarEvent> | null>(null);
|
|
447
438
|
const [defaultNewEventTab, setDefaultNewEventTab] = useState<'manual' | 'ai'>(
|
|
@@ -583,13 +574,15 @@ export const CalendarProvider = ({
|
|
|
583
574
|
|
|
584
575
|
// CRUD operations with Supabase
|
|
585
576
|
const addEvent = useCallback(
|
|
586
|
-
async (
|
|
577
|
+
async (
|
|
578
|
+
event: Omit<CalendarEvent, 'id'>,
|
|
579
|
+
options?: { requestId?: string }
|
|
580
|
+
) => {
|
|
587
581
|
if (readOnly) {
|
|
588
582
|
console.warn('Calendar is in read-only mode');
|
|
589
583
|
return undefined;
|
|
590
584
|
}
|
|
591
585
|
|
|
592
|
-
// Round start and end times to nearest 15-minute interval
|
|
593
586
|
const startDate = roundToNearest15Minutes(new Date(event.start_at));
|
|
594
587
|
const endDate = roundToNearest15Minutes(new Date(event.end_at));
|
|
595
588
|
|
|
@@ -619,7 +612,13 @@ export const CalendarProvider = ({
|
|
|
619
612
|
locked: true,
|
|
620
613
|
task_id: (event as CalendarEvent & { task_id?: string | null }).task_id,
|
|
621
614
|
source: event.source,
|
|
615
|
+
...(event.providerColor && { providerColor: event.providerColor }),
|
|
622
616
|
};
|
|
617
|
+
if (event.providerColor) delete payload.color;
|
|
618
|
+
payload.requestId = creationRequests.current.forAttempt(
|
|
619
|
+
{ wsId: ws.id, ...payload },
|
|
620
|
+
options?.requestId
|
|
621
|
+
);
|
|
623
622
|
const optimisticId = createOptimisticEventId();
|
|
624
623
|
const optimisticEvent = {
|
|
625
624
|
...event,
|
|
@@ -670,10 +669,7 @@ export const CalendarProvider = ({
|
|
|
670
669
|
|
|
671
670
|
const addEmptyEvent = useCallback(
|
|
672
671
|
(date: Date, isAllDay?: boolean) => {
|
|
673
|
-
//
|
|
674
|
-
// This ensures all-day events are created at midnight in the user's timezone rather than UTC
|
|
675
|
-
// The workaround is necessary because dayjs timezone handling for all-day events can be inconsistent
|
|
676
|
-
// across different browsers and timezone configurations
|
|
672
|
+
// Keep all-day boundaries in the user timezone.
|
|
677
673
|
const selectedDate = dayjs(date);
|
|
678
674
|
|
|
679
675
|
let start_at: string;
|
|
@@ -701,10 +697,8 @@ export const CalendarProvider = ({
|
|
|
701
697
|
end_at = endTime.toISOString();
|
|
702
698
|
}
|
|
703
699
|
|
|
704
|
-
// Use default color
|
|
705
700
|
const defaultColor = 'BLUE';
|
|
706
701
|
|
|
707
|
-
// Create a new event with default values
|
|
708
702
|
const newEvent: CalendarEvent = {
|
|
709
703
|
id: 'new',
|
|
710
704
|
title: '',
|
|
@@ -736,14 +730,11 @@ export const CalendarProvider = ({
|
|
|
736
730
|
|
|
737
731
|
const addEmptyEventWithDuration = useCallback(
|
|
738
732
|
(startDate: Date, endDate: Date) => {
|
|
739
|
-
// Round start and end times to nearest 15-minute interval
|
|
740
733
|
const roundedStartDate = roundToNearest15Minutes(startDate);
|
|
741
734
|
const roundedEndDate = roundToNearest15Minutes(endDate);
|
|
742
735
|
|
|
743
|
-
// Use default color
|
|
744
736
|
const defaultColor = 'BLUE';
|
|
745
737
|
|
|
746
|
-
// Create a new event with default values
|
|
747
738
|
const newEvent: CalendarEvent = {
|
|
748
739
|
id: 'new',
|
|
749
740
|
title: '',
|
|
@@ -832,22 +823,7 @@ export const CalendarProvider = ({
|
|
|
832
823
|
|
|
833
824
|
try {
|
|
834
825
|
// Clean up the update data to ensure no undefined values and exclude system fields
|
|
835
|
-
const cleanUpdateData
|
|
836
|
-
...(updateData.title !== undefined && { title: updateData.title }),
|
|
837
|
-
...(updateData.description !== undefined && {
|
|
838
|
-
description: updateData.description,
|
|
839
|
-
}),
|
|
840
|
-
...(updateData.start_at !== undefined && {
|
|
841
|
-
start_at: updateData.start_at,
|
|
842
|
-
}),
|
|
843
|
-
...(updateData.end_at !== undefined && { end_at: updateData.end_at }),
|
|
844
|
-
...(updateData.color !== undefined && { color: updateData.color }),
|
|
845
|
-
...(updateData.location !== undefined && {
|
|
846
|
-
location: updateData.location,
|
|
847
|
-
}),
|
|
848
|
-
...(updateData.locked !== undefined && { locked: updateData.locked }),
|
|
849
|
-
...(updateData.source !== undefined && { source: updateData.source }),
|
|
850
|
-
};
|
|
826
|
+
const cleanUpdateData = calendarEventUpdatePayload(updateData);
|
|
851
827
|
|
|
852
828
|
// ws is guaranteed to be defined here (validated above at line 732)
|
|
853
829
|
const wsId = ws!.id;
|
|
@@ -1,23 +1,12 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useQuery } from '@tanstack/react-query';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
interface ExchangeRatesResponse {
|
|
7
|
-
data: ExchangeRate[];
|
|
8
|
-
date: string | null;
|
|
9
|
-
}
|
|
4
|
+
import { getExchangeRates } from '@tuturuuu/internal-api/finance-exchange-rates';
|
|
10
5
|
|
|
11
6
|
export function useExchangeRates() {
|
|
12
|
-
return useQuery
|
|
7
|
+
return useQuery({
|
|
13
8
|
queryKey: ['exchange-rates', 'latest'],
|
|
14
|
-
queryFn:
|
|
15
|
-
|
|
16
|
-
cache: 'no-store',
|
|
17
|
-
});
|
|
18
|
-
if (!res.ok) throw new Error('Failed to fetch exchange rates');
|
|
19
|
-
return res.json();
|
|
20
|
-
},
|
|
21
|
-
staleTime: 60 * 60 * 1000, // 1 hour — rates update daily
|
|
9
|
+
queryFn: () => getExchangeRates(),
|
|
10
|
+
staleTime: 60 * 60 * 1000,
|
|
22
11
|
});
|
|
23
12
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import type { RealtimePresenceState } from '@tuturuuu/internal-api/realtime';
|
|
4
|
+
import { createRealtimeClient } from '@tuturuuu/internal-api/realtime';
|
|
3
5
|
import { getCurrentUserProfile } from '@tuturuuu/internal-api/users';
|
|
4
|
-
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
5
|
-
import type { RealtimePresenceState } from '@tuturuuu/supabase/next/realtime';
|
|
6
6
|
import type { User } from '@tuturuuu/types/primitives/User';
|
|
7
7
|
import { DEV_MODE } from '@tuturuuu/utils/constants';
|
|
8
8
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
@@ -149,14 +149,14 @@ export function useWorkspacePresence({
|
|
|
149
149
|
if (setupPromiseRef.current) return setupPromiseRef.current;
|
|
150
150
|
|
|
151
151
|
const promise = (async (): Promise<boolean> => {
|
|
152
|
-
const
|
|
152
|
+
const realtime = createRealtimeClient();
|
|
153
153
|
|
|
154
154
|
try {
|
|
155
155
|
if (isCleanedUpRef.current) return false;
|
|
156
156
|
|
|
157
157
|
const {
|
|
158
158
|
data: { user },
|
|
159
|
-
} = await
|
|
159
|
+
} = await realtime.auth.getUser();
|
|
160
160
|
|
|
161
161
|
if (!user?.id || isCleanedUpRef.current) return false;
|
|
162
162
|
|
|
@@ -184,12 +184,12 @@ export function useWorkspacePresence({
|
|
|
184
184
|
|
|
185
185
|
// Clean up any stale channel
|
|
186
186
|
if (channelRef.current) {
|
|
187
|
-
await
|
|
187
|
+
await realtime.removeChannel(channelRef.current);
|
|
188
188
|
channelRef.current = null;
|
|
189
189
|
lastTrackSignatureRef.current = null;
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
const channel =
|
|
192
|
+
const channel = realtime.channel(channelKey, {
|
|
193
193
|
config: {
|
|
194
194
|
presence: {
|
|
195
195
|
key: user.id,
|
|
@@ -238,7 +238,7 @@ export function useWorkspacePresence({
|
|
|
238
238
|
setupPromiseRef.current = null;
|
|
239
239
|
lastTrackSignatureRef.current = null;
|
|
240
240
|
if (deadCh) {
|
|
241
|
-
|
|
241
|
+
realtime.removeChannel(deadCh).catch(() => {});
|
|
242
242
|
}
|
|
243
243
|
if (
|
|
244
244
|
retryCountRef.current < MAX_RETRIES &&
|
|
@@ -269,7 +269,7 @@ export function useWorkspacePresence({
|
|
|
269
269
|
setupPromiseRef.current = null;
|
|
270
270
|
lastTrackSignatureRef.current = null;
|
|
271
271
|
if (deadCh) {
|
|
272
|
-
|
|
272
|
+
realtime.removeChannel(deadCh).catch(() => {});
|
|
273
273
|
}
|
|
274
274
|
// Attempt reconnection
|
|
275
275
|
if (retryCountRef.current < MAX_RETRIES) {
|
|
@@ -320,7 +320,7 @@ export function useWorkspacePresence({
|
|
|
320
320
|
setupPromiseRef.current = null;
|
|
321
321
|
if (channelRef.current) {
|
|
322
322
|
channelRef.current.untrack?.().catch(() => {});
|
|
323
|
-
|
|
323
|
+
createRealtimeClient().removeChannel(channelRef.current);
|
|
324
324
|
channelRef.current = null;
|
|
325
325
|
lastTrackSignatureRef.current = null;
|
|
326
326
|
}
|