@tuturuuu/ui 0.37.1 → 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 (64) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +10 -7
  3. package/src/components/ui/custom/__tests__/workspace-picker-content.test.tsx +267 -0
  4. package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +24 -38
  5. package/src/components/ui/custom/education-todo.test.tsx +131 -0
  6. package/src/components/ui/custom/education-todo.tsx +191 -0
  7. package/src/components/ui/custom/hidden-workspaces-settings.tsx +49 -0
  8. package/src/components/ui/custom/mobile-header-height.ts +16 -0
  9. package/src/components/ui/custom/satellite-notification-polling.test.tsx +61 -0
  10. package/src/components/ui/custom/satellite-notification-popover.tsx +7 -3
  11. package/src/components/ui/custom/structure.tsx +37 -5
  12. package/src/components/ui/custom/user-filters.tsx +2 -1
  13. package/src/components/ui/custom/visible-workspace-filter.tsx +41 -0
  14. package/src/components/ui/custom/workspace-picker-content.tsx +356 -0
  15. package/src/components/ui/custom/workspace-select-helpers.ts +11 -0
  16. package/src/components/ui/custom/workspace-select-invitations.tsx +3 -0
  17. package/src/components/ui/custom/workspace-select.tsx +95 -359
  18. package/src/components/ui/legacy/calendar/calendar-draft-recovery.ts +34 -0
  19. package/src/components/ui/legacy/calendar/event-modal.tsx +77 -137
  20. package/src/components/ui/legacy/calendar/event-save-payload.test.ts +40 -0
  21. package/src/components/ui/legacy/calendar/event-save-payload.ts +79 -0
  22. package/src/components/ui/legacy/calendar/event-source-selection.test.ts +95 -0
  23. package/src/components/ui/legacy/calendar/event-source-selection.ts +78 -0
  24. package/src/components/ui/legacy/calendar/provider-color-picker.test.tsx +147 -0
  25. package/src/components/ui/legacy/calendar/provider-color-picker.tsx +115 -0
  26. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +35 -1
  27. package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +23 -11
  28. package/src/components/ui/legacy/calendar/settings/calendar-sync-dashboard.tsx +4 -23
  29. package/src/components/ui/legacy/calendar/settings/sync-logs-table.tsx +3 -6
  30. package/src/components/ui/legacy/calendar/use-calendar-draft-save.test.tsx +119 -0
  31. package/src/components/ui/legacy/calendar/use-calendar-draft-save.tsx +141 -0
  32. package/src/components/ui/popover.tsx +3 -1
  33. package/src/components/ui/resizable.tsx +2 -1
  34. package/src/components/ui/text-editor/__tests__/collaboration-carets.test.ts +142 -0
  35. package/src/components/ui/text-editor/__tests__/markdown-document.test.ts +50 -0
  36. package/src/components/ui/text-editor/clipboard-serialization.ts +10 -0
  37. package/src/components/ui/text-editor/collaboration-carets.ts +56 -0
  38. package/src/components/ui/text-editor/editor-readonly-transition.test.tsx +2 -0
  39. package/src/components/ui/text-editor/editor.tsx +8 -3
  40. package/src/components/ui/text-editor/extensions.ts +8 -2
  41. package/src/components/ui/text-editor/markdown-paste-extension.ts +1 -1
  42. package/src/components/ui/text-editor/markdown.ts +11 -0
  43. package/src/components/ui/text-editor/tool-bar.tsx +43 -44
  44. package/src/hooks/__tests__/collaboration-awareness.test.ts +106 -0
  45. package/src/hooks/__tests__/supabase-provider.test.ts +9 -0
  46. package/src/hooks/__tests__/use-calendar-readonly.test.tsx +12 -2
  47. package/src/hooks/__tests__/use-notifications-subscription.test.tsx +31 -1
  48. package/src/hooks/__tests__/use-visible-workspaces.test.tsx +76 -0
  49. package/src/hooks/__tests__/use-workspace-presence.test.tsx +3 -3
  50. package/src/hooks/__tests__/use-workspace-visibility.test.tsx +155 -0
  51. package/src/hooks/__tests__/use-yjs-collaboration.test.tsx +108 -0
  52. package/src/hooks/__tests__/workspace-actor-lifetime.test.tsx +103 -0
  53. package/src/hooks/calendar-creation-request.test.ts +62 -0
  54. package/src/hooks/calendar-creation-request.ts +34 -0
  55. package/src/hooks/calendar-event-write-payload.ts +24 -0
  56. package/src/hooks/notification-subscription.ts +5 -2
  57. package/src/hooks/supabase-provider.ts +38 -72
  58. package/src/hooks/use-calendar.tsx +21 -45
  59. package/src/hooks/use-exchange-rates.ts +4 -15
  60. package/src/hooks/use-visible-workspaces.ts +57 -0
  61. package/src/hooks/use-workspace-presence.ts +9 -9
  62. package/src/hooks/use-workspace-user.ts +15 -3
  63. package/src/hooks/use-workspace-visibility.ts +234 -0
  64. package/src/hooks/use-yjs-collaboration.ts +45 -27
@@ -46,9 +46,7 @@ import { ToolbarButton, ToolbarSeparator } from './toolbar-controls';
46
46
 
47
47
  type LinkEditorContext = 'bubble' | 'popover' | null;
48
48
 
49
- // ---------------------------------------------------------------------------
50
49
  // Main ToolBar (BubbleMenu)
51
- // ---------------------------------------------------------------------------
52
50
 
53
51
  interface ToolBarProps {
54
52
  editor: Editor | null;
@@ -59,12 +57,14 @@ interface ToolBarProps {
59
57
  onConvertToTask?: () => void | Promise<void>;
60
58
  /** When true, the BubbleMenu is hidden because the fixed toolbar is visible */
61
59
  fixedToolbarVisible?: boolean;
60
+ allowEmbeds?: boolean;
62
61
  toggleBlockLabel?: string;
63
62
  }
64
63
 
65
64
  export function ToolBar({
66
65
  editor,
67
66
  fixedToolbarVisible,
67
+ allowEmbeds = true,
68
68
  workspaceId,
69
69
  onImageUpload,
70
70
  onConvertToTask,
@@ -459,9 +459,7 @@ export function ToolBar({
459
459
  toast.success('YouTube video added');
460
460
  }, [editor, youtubeUrl]);
461
461
 
462
- // -----------------------------------------------------------------------
463
462
  // Render helpers
464
- // -----------------------------------------------------------------------
465
463
 
466
464
  /** Renders the core formatting buttons (used by both fixed and bubble toolbar) */
467
465
  const renderFormattingOptions = useCallback(
@@ -478,11 +476,9 @@ export function ToolBar({
478
476
  />
479
477
  ))}
480
478
 
481
- {/* Color controls */}
482
479
  <ToolbarSeparator />
483
480
  <TextEditorColorControls editor={editor} />
484
481
 
485
- {/* Link */}
486
482
  <ToolbarSeparator />
487
483
  <ToolbarButton
488
484
  id="link"
@@ -491,8 +487,7 @@ export function ToolBar({
491
487
  onClick={() => openLinkEditor(source)}
492
488
  />
493
489
 
494
- {/* Media uploads */}
495
- {workspaceId && onImageUpload && (
490
+ {onImageUpload && (
496
491
  <>
497
492
  <ToolbarSeparator />
498
493
  <ToolbarButton
@@ -508,30 +503,33 @@ export function ToolBar({
508
503
  onClick={triggerImageUpload}
509
504
  disabled={isUploadingImage}
510
505
  />
511
- <ToolbarButton
512
- id="video"
513
- icon={
514
- isUploadingVideo ? (
515
- <Loader2 className="size-4 animate-spin" />
516
- ) : (
517
- <FileVideo className="size-4" />
518
- )
519
- }
520
- pressed={false}
521
- onClick={triggerVideoUpload}
522
- disabled={isUploadingVideo}
523
- />
506
+ {workspaceId && (
507
+ <ToolbarButton
508
+ id="video"
509
+ icon={
510
+ isUploadingVideo ? (
511
+ <Loader2 className="size-4 animate-spin" />
512
+ ) : (
513
+ <FileVideo className="size-4" />
514
+ )
515
+ }
516
+ pressed={false}
517
+ onClick={triggerVideoUpload}
518
+ disabled={isUploadingVideo}
519
+ />
520
+ )}
524
521
  </>
525
522
  )}
526
523
 
527
- <ToolbarButton
528
- id="youtube"
529
- icon={<YoutubeIcon className="size-4" />}
530
- pressed={showYoutubeInput}
531
- onClick={() => setShowYoutubeInput(!showYoutubeInput)}
532
- />
524
+ {allowEmbeds && (
525
+ <ToolbarButton
526
+ id="youtube"
527
+ icon={<YoutubeIcon className="size-4" />}
528
+ pressed={showYoutubeInput}
529
+ onClick={() => setShowYoutubeInput(!showYoutubeInput)}
530
+ />
531
+ )}
533
532
 
534
- {/* Convert to task */}
535
533
  {onConvertToTask && (
536
534
  <>
537
535
  <ToolbarSeparator />
@@ -557,6 +555,7 @@ export function ToolBar({
557
555
  triggerVideoUpload,
558
556
  isUploadingVideo,
559
557
  showYoutubeInput,
558
+ allowEmbeds,
560
559
  onConvertToTask,
561
560
  toggleBlockLabel,
562
561
  ]
@@ -1133,8 +1132,7 @@ export function FixedToolbar({
1133
1132
  <ToolbarSeparator />
1134
1133
  <TextEditorColorControls editor={editor} />
1135
1134
 
1136
- {/* Media group */}
1137
- {workspaceId && onImageUpload && (
1135
+ {onImageUpload && (
1138
1136
  <>
1139
1137
  <ToolbarSeparator />
1140
1138
  <ToolbarButton
@@ -1150,23 +1148,24 @@ export function FixedToolbar({
1150
1148
  onClick={() => fileInputRef.current?.click()}
1151
1149
  disabled={isUploadingImage}
1152
1150
  />
1153
- <ToolbarButton
1154
- id="video"
1155
- icon={
1156
- isUploadingVideo ? (
1157
- <Loader2 className="size-4 animate-spin" />
1158
- ) : (
1159
- <FileVideo className="size-4" />
1160
- )
1161
- }
1162
- pressed={false}
1163
- onClick={() => videoInputRef.current?.click()}
1164
- disabled={isUploadingVideo}
1165
- />
1151
+ {workspaceId && (
1152
+ <ToolbarButton
1153
+ id="video"
1154
+ icon={
1155
+ isUploadingVideo ? (
1156
+ <Loader2 className="size-4 animate-spin" />
1157
+ ) : (
1158
+ <FileVideo className="size-4" />
1159
+ )
1160
+ }
1161
+ pressed={false}
1162
+ onClick={() => videoInputRef.current?.click()}
1163
+ disabled={isUploadingVideo}
1164
+ />
1165
+ )}
1166
1166
  </>
1167
1167
  )}
1168
1168
 
1169
- {/* Convert to task */}
1170
1169
  {onConvertToTask && (
1171
1170
  <>
1172
1171
  <ToolbarSeparator />
@@ -0,0 +1,106 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import * as awarenessProtocol from 'y-protocols/awareness';
3
+ import * as Y from 'yjs';
4
+ import SupabaseProvider from '../supabase-provider';
5
+
6
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
7
+ createRealtimeClient: () => {
8
+ const channel = {
9
+ on: () => channel,
10
+ subscribe: () => channel,
11
+ send: vi.fn(),
12
+ };
13
+ return { channel: () => channel, removeChannel: vi.fn() };
14
+ },
15
+ }));
16
+ afterEach(() => vi.useRealTimers());
17
+ describe('collaboration cursor batching', () => {
18
+ it('merges all changed client IDs and flushes during continuous updates', async () => {
19
+ vi.useFakeTimers();
20
+ const doc = new Y.Doc();
21
+ const provider = new SupabaseProvider(
22
+ doc,
23
+ {} as ConstructorParameters<typeof SupabaseProvider>[1],
24
+ {
25
+ channel: 'task-editor-fixture',
26
+ id: 'fixture',
27
+ tableName: 'tasks',
28
+ columnName: 'description_yjs_state',
29
+ loadState: async () => null,
30
+ saveState: async () => true,
31
+ resyncInterval: false,
32
+ }
33
+ );
34
+ const a = new Y.Doc();
35
+ const b = new Y.Doc();
36
+ const first = new awarenessProtocol.Awareness(a);
37
+ const second = new awarenessProtocol.Awareness(b);
38
+ first.setLocalState({ user: { id: 'a' }, cursor: 1 });
39
+ second.setLocalState({ user: { id: 'b' }, cursor: 2 });
40
+ const sent = vi.fn();
41
+ provider.on('awareness', sent);
42
+ awarenessProtocol.applyAwarenessUpdate(
43
+ provider.awareness,
44
+ awarenessProtocol.encodeAwarenessUpdate(first, [a.clientID]),
45
+ 'local-fixture'
46
+ );
47
+ await vi.advanceTimersByTimeAsync(100);
48
+ awarenessProtocol.applyAwarenessUpdate(
49
+ provider.awareness,
50
+ awarenessProtocol.encodeAwarenessUpdate(second, [b.clientID]),
51
+ 'local-fixture'
52
+ );
53
+ await vi.advanceTimersByTimeAsync(50);
54
+ expect(sent).toHaveBeenCalledOnce();
55
+ const receiverDoc = new Y.Doc();
56
+ const receiver = new awarenessProtocol.Awareness(receiverDoc);
57
+ awarenessProtocol.applyAwarenessUpdate(
58
+ receiver,
59
+ sent.mock.calls[0]![0],
60
+ 'fixture'
61
+ );
62
+ expect(receiver.getStates().get(a.clientID)).toMatchObject({ cursor: 1 });
63
+ expect(receiver.getStates().get(b.clientID)).toMatchObject({ cursor: 2 });
64
+ provider.destroy();
65
+ provider.awareness.destroy();
66
+ first.destroy();
67
+ second.destroy();
68
+ receiver.destroy();
69
+ for (const d of [doc, a, b, receiverDoc]) d.destroy();
70
+ });
71
+ it('does not rebroadcast peer awareness back to the room', async () => {
72
+ vi.useFakeTimers();
73
+ const doc = new Y.Doc();
74
+ const peerDoc = new Y.Doc();
75
+ const provider = new SupabaseProvider(
76
+ doc,
77
+ {} as ConstructorParameters<typeof SupabaseProvider>[1],
78
+ {
79
+ channel: 'task-editor-fixture',
80
+ id: 'fixture',
81
+ tableName: 'tasks',
82
+ columnName: 'description_yjs_state',
83
+ loadState: async () => null,
84
+ saveState: async () => true,
85
+ resyncInterval: false,
86
+ }
87
+ );
88
+ const peer = new awarenessProtocol.Awareness(peerDoc);
89
+ peer.setLocalState({ cursor: 4 });
90
+ const sent = vi.fn();
91
+ provider.on('awareness', sent);
92
+ provider.onAwareness(
93
+ awarenessProtocol.encodeAwarenessUpdate(peer, [peerDoc.clientID])
94
+ );
95
+ await vi.advanceTimersByTimeAsync(200);
96
+ expect(sent).not.toHaveBeenCalled();
97
+ expect(provider.awareness.getStates().get(peerDoc.clientID)).toMatchObject({
98
+ cursor: 4,
99
+ });
100
+ provider.destroy();
101
+ provider.awareness.destroy();
102
+ peer.destroy();
103
+ doc.destroy();
104
+ peerDoc.destroy();
105
+ });
106
+ });
@@ -5,6 +5,14 @@ import SupabaseProvider, {
5
5
  SUPABASE_PROVIDER_SYNC_ORIGIN,
6
6
  } from '../supabase-provider';
7
7
 
8
+ const realtimeMock = vi.hoisted(() => ({ channel: null as unknown }));
9
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
10
+ createRealtimeClient: () => ({
11
+ channel: () => realtimeMock.channel,
12
+ removeChannel: vi.fn(),
13
+ }),
14
+ }));
15
+
8
16
  function createRealtimeChannel() {
9
17
  let subscribeHandler:
10
18
  | ((status: string, err?: { message?: string }) => void)
@@ -22,6 +30,7 @@ function createRealtimeChannel() {
22
30
  },
23
31
  };
24
32
 
33
+ realtimeMock.channel = channel;
25
34
  return channel;
26
35
  }
27
36
 
@@ -294,10 +294,15 @@ describe('CalendarProvider Read-Only Mode', () => {
294
294
  await createPromise!;
295
295
  });
296
296
 
297
+ const optimisticId = calendarMockState.patchVisibleEvents.mock.calls.find(
298
+ ([, options]) => options?.status === 'creating'
299
+ )?.[0]?.[0]?.id;
300
+ expect(optimisticId).toMatch(/^optimistic[0-9a-f]{32}$/);
301
+
297
302
  expect(calendarMockState.patchVisibleEvents).toHaveBeenLastCalledWith(
298
303
  [serverEvent],
299
304
  expect.objectContaining({
300
- clearIds: [expect.stringMatching(/^optimistic-/)],
305
+ clearIds: [optimisticId],
301
306
  })
302
307
  );
303
308
  });
@@ -322,10 +327,15 @@ describe('CalendarProvider Read-Only Mode', () => {
322
327
  ).rejects.toThrow('create failed');
323
328
  });
324
329
 
330
+ const optimisticId = calendarMockState.patchVisibleEvents.mock.calls.find(
331
+ ([, options]) => options?.status === 'creating'
332
+ )?.[0]?.[0]?.id;
333
+ expect(optimisticId).toMatch(/^optimistic[0-9a-f]{32}$/);
334
+
325
335
  expect(calendarMockState.patchVisibleEvents).toHaveBeenLastCalledWith(
326
336
  [],
327
337
  expect.objectContaining({
328
- clearIds: [expect.stringMatching(/^optimistic-/)],
338
+ clearIds: [optimisticId],
329
339
  })
330
340
  );
331
341
  });
@@ -11,7 +11,7 @@ import {
11
11
  } from '../use-notifications';
12
12
 
13
13
  const createRealtimeClient = vi.hoisted(() => vi.fn());
14
- vi.mock('@tuturuuu/supabase/next/realtime-browser', () => ({
14
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
15
15
  createRealtimeClient,
16
16
  }));
17
17
 
@@ -31,6 +31,10 @@ describe('notification API refresh subscriptions', () => {
31
31
  configurable: true,
32
32
  value: 'visible',
33
33
  });
34
+ Object.defineProperty(navigator, 'onLine', {
35
+ configurable: true,
36
+ value: true,
37
+ });
34
38
  });
35
39
  afterEach(() => {
36
40
  __resetNotificationSubscriptionRegistryForTests();
@@ -51,6 +55,32 @@ describe('notification API refresh subscriptions', () => {
51
55
  client.clear();
52
56
  });
53
57
 
58
+ it('makes 30 shared refreshes per visible hour, pauses offline, and refreshes on reconnect', () => {
59
+ const client = new QueryClient();
60
+ const invalidate = vi.spyOn(client, 'invalidateQueries');
61
+ const hook = renderHook(() => useNotificationSubscription(null, 'user-1'), {
62
+ wrapper: wrapper(client),
63
+ });
64
+ act(() => vi.advanceTimersByTime(60 * 60_000));
65
+ expect(invalidate).toHaveBeenCalledTimes(30);
66
+ Object.defineProperty(navigator, 'onLine', {
67
+ configurable: true,
68
+ value: false,
69
+ });
70
+ act(() => vi.advanceTimersByTime(60 * 60_000));
71
+ expect(invalidate).toHaveBeenCalledTimes(30);
72
+ Object.defineProperty(navigator, 'onLine', {
73
+ configurable: true,
74
+ value: true,
75
+ });
76
+ act(() => window.dispatchEvent(new Event('online')));
77
+ expect(invalidate).toHaveBeenCalledTimes(31);
78
+ hook.unmount();
79
+ act(() => window.dispatchEvent(new Event('online')));
80
+ expect(invalidate).toHaveBeenCalledTimes(31);
81
+ client.clear();
82
+ });
83
+
54
84
  it('shares one refresh per user and query client without opening a denied realtime channel', () => {
55
85
  const client = new QueryClient();
56
86
  const invalidate = vi.spyOn(client, 'invalidateQueries');
@@ -0,0 +1,76 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { beforeEach, expect, it, vi } from 'vitest';
5
+ import { useVisibleWorkspaces } from '../use-visible-workspaces';
6
+ import { WorkspaceVisibilityProvider } from '../use-workspace-visibility';
7
+
8
+ const mocks = vi.hoisted(() => ({ list: vi.fn(), hidden: vi.fn() }));
9
+ vi.mock('@tuturuuu/internal-api/workspaces', () => ({
10
+ listWorkspaces: mocks.list,
11
+ }));
12
+ vi.mock('@tuturuuu/internal-api/users', () => ({
13
+ getCurrentUserHiddenWorkspaces: mocks.hidden,
14
+ updateCurrentUserHiddenWorkspace: vi.fn(),
15
+ }));
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ mocks.list.mockResolvedValue([{ id: 'visible' }, { id: 'hidden' }]);
19
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['hidden'] });
20
+ });
21
+ function fixture() {
22
+ const client = new QueryClient({
23
+ defaultOptions: { queries: { retry: false, retryDelay: 0 } },
24
+ });
25
+ const wrapper = ({ children }: { children: ReactNode }) => (
26
+ <QueryClientProvider client={client}>
27
+ <WorkspaceVisibilityProvider actorId="A">
28
+ {children}
29
+ </WorkspaceVisibilityProvider>
30
+ </QueryClientProvider>
31
+ );
32
+ return { client, wrapper };
33
+ }
34
+ it('retains an explicit zero bound and only exposes known visible choices', async () => {
35
+ const { client, wrapper } = fixture();
36
+ const { result } = renderHook(() => useVisibleWorkspaces(true, 0), {
37
+ wrapper,
38
+ });
39
+ await waitFor(() => expect(result.current.data).toEqual([{ id: 'visible' }]));
40
+ expect(mocks.list).toHaveBeenCalledWith({ limit: 0 });
41
+ expect(client.getQueryData(['workspace-ui-list', 'A', 0])).toHaveLength(2);
42
+ });
43
+ it('settled private discovery failure is an error rather than permanent loading', async () => {
44
+ mocks.hidden.mockRejectedValue(new Error('offline'));
45
+ const { client, wrapper } = fixture();
46
+ const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
47
+ await waitFor(() =>
48
+ expect(client.getQueryState(['workspace-hidden', 'A'])?.status).toBe(
49
+ 'error'
50
+ )
51
+ );
52
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
53
+ expect(result.current.isError).toBe(true);
54
+ expect(result.current.error?.message).toBe('offline');
55
+ expect(result.current.data).toBeUndefined();
56
+ });
57
+
58
+ it('distinguishes an absent personal workspace from one excluded by Hidden preferences', async () => {
59
+ mocks.list.mockResolvedValue([{ id: 'team', personal: false }]);
60
+ const { client, wrapper } = fixture();
61
+ const { result } = renderHook(() => useVisibleWorkspaces(), { wrapper });
62
+ await waitFor(() =>
63
+ expect(result.current.personalWorkspaceMissing).toBe(true)
64
+ );
65
+ mocks.list.mockResolvedValue([
66
+ { id: 'team', personal: false },
67
+ { id: 'personal', personal: true },
68
+ ]);
69
+ mocks.hidden.mockResolvedValue({ hiddenWorkspaceIds: ['personal'] });
70
+ await result.current.refetch();
71
+ await waitFor(() =>
72
+ expect(result.current.data).toEqual([{ id: 'team', personal: false }])
73
+ );
74
+ expect(result.current.personalWorkspaceMissing).toBe(false);
75
+ expect(client.getQueryData(['workspace-ui-list', 'A'])).toHaveLength(2);
76
+ });
@@ -3,7 +3,7 @@
3
3
  */
4
4
 
5
5
  import { act, renderHook, waitFor } from '@testing-library/react';
6
- import type { RealtimePresenceState } from '@tuturuuu/supabase/next/realtime';
6
+ import type { RealtimePresenceState } from '@tuturuuu/internal-api/realtime';
7
7
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
8
  import {
9
9
  type UseWorkspacePresenceResult,
@@ -16,8 +16,8 @@ const { createClientMock, getCurrentUserProfileMock } = vi.hoisted(() => ({
16
16
  getCurrentUserProfileMock: vi.fn(),
17
17
  }));
18
18
 
19
- vi.mock('@tuturuuu/supabase/next/client', () => ({
20
- createClient: createClientMock,
19
+ vi.mock('@tuturuuu/internal-api/realtime', () => ({
20
+ createRealtimeClient: createClientMock,
21
21
  }));
22
22
 
23
23
  vi.mock('@tuturuuu/internal-api/users', () => ({
@@ -0,0 +1,155 @@
1
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
2
+ import { act, renderHook, waitFor } from '@testing-library/react';
3
+ import type { ReactNode } from 'react';
4
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
5
+ import {
6
+ useWorkspaceVisibility,
7
+ WorkspaceVisibilityProvider,
8
+ } from '../use-workspace-visibility';
9
+
10
+ const mocks = vi.hoisted(() => ({ get: vi.fn(), put: vi.fn() }));
11
+ vi.mock('@tuturuuu/internal-api/users', () => ({
12
+ getCurrentUserHiddenWorkspaces: mocks.get,
13
+ updateCurrentUserHiddenWorkspace: mocks.put,
14
+ }));
15
+ function deferred<T>() {
16
+ let resolve!: (v: T) => void;
17
+ let reject!: (e: Error) => void;
18
+ const promise = new Promise<T>((a, b) => {
19
+ resolve = a;
20
+ reject = b;
21
+ });
22
+ return { promise, resolve, reject };
23
+ }
24
+ function setup(
25
+ actor = 'A',
26
+ client = new QueryClient({
27
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
28
+ })
29
+ ) {
30
+ const wrapper = ({ children }: { children: ReactNode }) => (
31
+ <QueryClientProvider client={client}>
32
+ <WorkspaceVisibilityProvider actorId={actor}>
33
+ {children}
34
+ </WorkspaceVisibilityProvider>
35
+ </QueryClientProvider>
36
+ );
37
+ return { ...renderHook(() => useWorkspaceVisibility(), { wrapper }), client };
38
+ }
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ mocks.get.mockResolvedValue({ hiddenWorkspaceIds: [] });
42
+ mocks.put.mockResolvedValue({});
43
+ });
44
+ describe('private owner-scoped workspace visibility', () => {
45
+ it('optimistically hides and rolls back only the failed workspace', async () => {
46
+ const write = deferred<object>();
47
+ mocks.put.mockReturnValue(write.promise);
48
+ const hook = setup();
49
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
50
+ let operation!: Promise<void>;
51
+ act(() => {
52
+ operation = hook.result.current.setHidden('ws-1', true);
53
+ });
54
+ await waitFor(() =>
55
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
56
+ );
57
+ await act(async () => {
58
+ write.reject(new Error('offline'));
59
+ await expect(operation).rejects.toThrow('offline');
60
+ });
61
+ expect(hook.result.current.hiddenIds).toEqual([]);
62
+ expect(hook.result.current.updateError).not.toBeNull();
63
+ expect(mocks.put).toHaveBeenCalledWith('ws-1', true, 'A');
64
+ await act(async () => {
65
+ await hook.result.current.refetch();
66
+ });
67
+ expect(hook.result.current.updateError).toBeNull();
68
+ });
69
+ it('a stale refresh cannot replace optimistic state', async () => {
70
+ const hook = setup();
71
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
72
+ const read = deferred<{ hiddenWorkspaceIds: string[] }>();
73
+ mocks.get
74
+ .mockImplementationOnce(() => read.promise)
75
+ .mockResolvedValue({ hiddenWorkspaceIds: ['ws-1'] });
76
+ act(() => {
77
+ void hook.result.current.refetch();
78
+ });
79
+ await act(async () => {
80
+ await hook.result.current.setHidden('ws-1', true);
81
+ });
82
+ await act(async () => {
83
+ read.resolve({ hiddenWorkspaceIds: [] });
84
+ });
85
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1']);
86
+ });
87
+ it('suppresses duplicate writes for the same pending workspace', async () => {
88
+ const write = deferred<object>();
89
+ mocks.put.mockReturnValue(write.promise);
90
+ const hook = setup();
91
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
92
+ let operation!: Promise<void>;
93
+ act(() => {
94
+ operation = hook.result.current.setHidden('ws-1', true);
95
+ });
96
+ await waitFor(() =>
97
+ expect(hook.result.current.pending.has('ws-1')).toBe(true)
98
+ );
99
+ await act(async () => {
100
+ await hook.result.current.setHidden('ws-1', true);
101
+ });
102
+ expect(mocks.put).toHaveBeenCalledTimes(1);
103
+ await act(async () => {
104
+ write.resolve({});
105
+ await operation;
106
+ });
107
+ });
108
+ it('removes private owner caches on logout and ignores late response', async () => {
109
+ const hook = setup();
110
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
111
+ const write = deferred<object>();
112
+ mocks.put.mockReturnValue(write.promise);
113
+ let operation!: Promise<void>;
114
+ act(() => {
115
+ operation = hook.result.current.setHidden('ws-1', true);
116
+ });
117
+ await waitFor(() =>
118
+ expect(hook.result.current.hiddenIds).toEqual(['ws-1'])
119
+ );
120
+ hook.unmount();
121
+ write.resolve({});
122
+ await operation;
123
+ expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
124
+ expect(hook.client.getQueryData(['workspace-hidden', 'B'])).toBeUndefined();
125
+ });
126
+ it('two actors use independent hidden cache keys', async () => {
127
+ mocks.get.mockImplementation(async (actor: string) => ({
128
+ hiddenWorkspaceIds: actor === 'A' ? ['ws-1'] : [],
129
+ }));
130
+ const client = new QueryClient({
131
+ defaultOptions: { queries: { retry: false } },
132
+ });
133
+ const a = setup('A', client);
134
+ const b = setup('B', client);
135
+ await waitFor(() => expect(a.result.current.hiddenIds).toEqual(['ws-1']));
136
+ await waitFor(() => expect(b.result.current.known).toBe(true));
137
+ expect(b.result.current.hiddenIds).toEqual([]);
138
+ });
139
+ });
140
+
141
+ it('settles a refetch safely after logout without returning private old-actor data', async () => {
142
+ const hook = setup();
143
+ await waitFor(() => expect(hook.result.current.known).toBe(true));
144
+ const read = deferred<{ hiddenWorkspaceIds: string[] }>();
145
+ mocks.get.mockReturnValue(read.promise);
146
+ let operation!: ReturnType<typeof hook.result.current.refetch>;
147
+ act(() => {
148
+ operation = hook.result.current.refetch();
149
+ });
150
+ await waitFor(() => expect(mocks.get).toHaveBeenCalledTimes(2));
151
+ hook.unmount();
152
+ read.resolve({ hiddenWorkspaceIds: ['old-private'] });
153
+ await expect(operation).resolves.toBeNull();
154
+ expect(hook.client.getQueryData(['workspace-hidden', 'A'])).toBeUndefined();
155
+ });