@tuturuuu/ui 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +48 -0
- package/package.json +8 -8
- package/src/components/ui/currency-input.test.tsx +43 -0
- package/src/components/ui/currency-input.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-default-role-card.tsx +60 -35
- package/src/components/ui/custom/workspace-access/workspace-access-member-row.tsx +176 -167
- package/src/components/ui/custom/workspace-access/workspace-access-members.tsx +16 -10
- package/src/components/ui/custom/workspace-access/workspace-access-page-header.tsx +75 -36
- package/src/components/ui/custom/workspace-access/workspace-access-page.tsx +39 -42
- package/src/components/ui/custom/workspace-access/workspace-access-people-filters.tsx +1 -1
- package/src/components/ui/custom/workspace-access/workspace-access-roles.tsx +113 -91
- package/src/components/ui/custom/workspace-access/workspace-access-tabs-toolbar.tsx +73 -32
- package/src/components/ui/finance/transactions/form-types.ts +2 -0
- package/src/components/ui/finance/transactions/transaction-card.tsx +21 -9
- package/src/components/ui/money-input.test.tsx +64 -0
- package/src/components/ui/money-input.tsx +63 -0
- package/src/components/ui/storefront/cart-summary.tsx +114 -29
- package/src/components/ui/storefront/checkout-overlay.tsx +27 -0
- package/src/components/ui/storefront/hero-panel.tsx +2 -8
- package/src/components/ui/storefront/image-panel.tsx +6 -0
- package/src/components/ui/storefront/index.ts +11 -0
- package/src/components/ui/storefront/listing-card.tsx +84 -22
- package/src/components/ui/storefront/product-detail.tsx +289 -0
- package/src/components/ui/storefront/product-dialog.tsx +72 -0
- package/src/components/ui/storefront/storefront-surface.test.tsx +124 -1
- package/src/components/ui/storefront/storefront-surface.tsx +333 -133
- package/src/components/ui/storefront/types.ts +23 -1
- package/src/components/ui/storefront/utils.ts +111 -27
- package/src/components/ui/text-editor/__tests__/content-migration.test.ts +32 -0
- package/src/components/ui/text-editor/__tests__/image-extension.test.ts +69 -1
- package/src/components/ui/text-editor/__tests__/video-extension.test.ts +47 -0
- package/src/components/ui/text-editor/content-migration.ts +41 -18
- package/src/components/ui/text-editor/extensions.ts +1 -1
- package/src/components/ui/text-editor/image-extension.ts +40 -18
- package/src/components/ui/text-editor/video-extension.ts +11 -2
- package/src/components/ui/tu-do/boards/__tests__/workspace-projects-client-page.test.tsx +70 -1
- package/src/components/ui/tu-do/boards/boardId/board-column-external-retry.test.tsx +127 -0
- package/src/components/ui/tu-do/boards/boardId/board-column.tsx +1 -3
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/task-drag-cache.ts +13 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.test.ts +63 -0
- package/src/components/ui/tu-do/boards/boardId/kanban/dnd/use-kanban-dnd.ts +46 -8
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.test.tsx +13 -2
- package/src/components/ui/tu-do/boards/boardId/kanban/rendering/kanban-columns.tsx +3 -1
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.test.tsx +164 -0
- package/src/components/ui/tu-do/boards/boardId/task-board-server-page.tsx +56 -2
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-display.ts +9 -0
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-grid.tsx +8 -16
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-task-row.tsx +5 -25
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.test.ts +36 -1
- package/src/components/ui/tu-do/boards/boardId/timeline/timeline-utils.ts +51 -2
- package/src/components/ui/tu-do/boards/workspace-projects-client-page.tsx +13 -3
- package/src/components/ui/tu-do/shared/__tests__/board-views.test.tsx +34 -1
- package/src/components/ui/tu-do/shared/board-header.tsx +39 -0
- package/src/components/ui/tu-do/shared/board-views.tsx +9 -7
- package/src/components/ui/tu-do/shared/cursor-overlay-multi-wrapper.tsx +53 -12
- package/src/components/ui/tu-do/shared/task-dialog-presentation.test.ts +53 -0
- package/src/components/ui/tu-do/shared/task-dialog-presentation.ts +19 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.test.tsx +57 -0
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/compact-task-create-popover.tsx +136 -111
- package/src/components/ui/tu-do/shared/task-edit-dialog/components/task-description-editor.tsx +3 -1
- package/src/components/ui/tu-do/shared/task-edit-dialog.tsx +42 -14
- package/src/hooks/__tests__/useBoardRealtime.test.tsx +2 -2
- package/src/hooks/__tests__/useCursorTracking.test.tsx +212 -0
- package/src/hooks/useBoardRealtime.ts +6 -3
- package/src/hooks/useBoardRealtime.types.ts +11 -0
- package/src/hooks/useCursorTracking.ts +91 -27
- package/src/hooks/useTaskUserRealtime.ts +5 -3
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vitest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { act, renderHook, waitFor } from '@testing-library/react';
|
|
6
|
+
import { createClient } from '@tuturuuu/supabase/next/client';
|
|
7
|
+
import type { RefObject } from 'react';
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { PRIVATE_TASK_REALTIME_CHANNEL_CONFIG } from '../useBoardRealtime.types';
|
|
10
|
+
import { useCursorTracking } from '../useCursorTracking';
|
|
11
|
+
|
|
12
|
+
type BroadcastListener = (message: {
|
|
13
|
+
payload: Record<string, unknown>;
|
|
14
|
+
}) => void;
|
|
15
|
+
|
|
16
|
+
type MockSupabaseClient = {
|
|
17
|
+
channel: ReturnType<typeof vi.fn>;
|
|
18
|
+
removeChannel: ReturnType<typeof vi.fn>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type MockCreateClientFn = {
|
|
22
|
+
(): MockSupabaseClient;
|
|
23
|
+
mockReturnValue: (value: MockSupabaseClient) => void;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
vi.mock('@tuturuuu/supabase/next/client', () => ({
|
|
27
|
+
createClient: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('@tuturuuu/utils/constants', () => ({
|
|
31
|
+
DEV_MODE: false,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
function createContainerRef(): RefObject<HTMLDivElement | null> {
|
|
35
|
+
const element = document.createElement('div');
|
|
36
|
+
element.getBoundingClientRect = () =>
|
|
37
|
+
({
|
|
38
|
+
bottom: 100,
|
|
39
|
+
height: 100,
|
|
40
|
+
left: 0,
|
|
41
|
+
right: 100,
|
|
42
|
+
top: 0,
|
|
43
|
+
width: 100,
|
|
44
|
+
x: 0,
|
|
45
|
+
y: 0,
|
|
46
|
+
}) as DOMRect;
|
|
47
|
+
document.body.append(element);
|
|
48
|
+
return { current: element };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('useCursorTracking', () => {
|
|
52
|
+
let broadcastListeners: Map<string, BroadcastListener>;
|
|
53
|
+
let mockChannel: {
|
|
54
|
+
on: ReturnType<typeof vi.fn>;
|
|
55
|
+
send: ReturnType<typeof vi.fn>;
|
|
56
|
+
subscribe: ReturnType<typeof vi.fn>;
|
|
57
|
+
};
|
|
58
|
+
let mockRemoveChannel: ReturnType<typeof vi.fn>;
|
|
59
|
+
let requestAnimationFrameMock: ReturnType<typeof vi.fn>;
|
|
60
|
+
let animationFrameCallbacks: FrameRequestCallback[];
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
broadcastListeners = new Map();
|
|
64
|
+
animationFrameCallbacks = [];
|
|
65
|
+
|
|
66
|
+
requestAnimationFrameMock = vi.fn((callback: FrameRequestCallback) => {
|
|
67
|
+
animationFrameCallbacks.push(callback);
|
|
68
|
+
return animationFrameCallbacks.length;
|
|
69
|
+
});
|
|
70
|
+
vi.stubGlobal('requestAnimationFrame', requestAnimationFrameMock);
|
|
71
|
+
vi.stubGlobal('cancelAnimationFrame', vi.fn());
|
|
72
|
+
|
|
73
|
+
mockChannel = {
|
|
74
|
+
on: vi.fn(
|
|
75
|
+
(
|
|
76
|
+
type: string,
|
|
77
|
+
config: { event?: string },
|
|
78
|
+
callback: BroadcastListener
|
|
79
|
+
) => {
|
|
80
|
+
if (type === 'broadcast' && config.event) {
|
|
81
|
+
broadcastListeners.set(config.event, callback);
|
|
82
|
+
}
|
|
83
|
+
return mockChannel;
|
|
84
|
+
}
|
|
85
|
+
),
|
|
86
|
+
send: vi.fn(),
|
|
87
|
+
subscribe: vi.fn(() => mockChannel),
|
|
88
|
+
};
|
|
89
|
+
mockRemoveChannel = vi.fn();
|
|
90
|
+
|
|
91
|
+
const mockCreateClient = createClient as unknown as MockCreateClientFn;
|
|
92
|
+
mockCreateClient.mockReturnValue({
|
|
93
|
+
channel: vi.fn(() => mockChannel),
|
|
94
|
+
removeChannel: mockRemoveChannel,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
afterEach(() => {
|
|
99
|
+
document.body.replaceChildren();
|
|
100
|
+
vi.unstubAllGlobals();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('subscribes to cursor channels with private realtime authorization', () => {
|
|
104
|
+
const containerRef = createContainerRef();
|
|
105
|
+
|
|
106
|
+
renderHook(() =>
|
|
107
|
+
useCursorTracking('board-realtime-board-1', containerRef, {
|
|
108
|
+
display_name: 'Current User',
|
|
109
|
+
id: 'user-current',
|
|
110
|
+
})
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
const supabaseInstance = (createClient as unknown as MockCreateClientFn)();
|
|
114
|
+
expect(supabaseInstance.channel).toHaveBeenCalledWith(
|
|
115
|
+
'board-realtime-board-1',
|
|
116
|
+
PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('broadcasts cursor payloads without private profile fields', async () => {
|
|
121
|
+
const containerRef = createContainerRef();
|
|
122
|
+
|
|
123
|
+
renderHook(() =>
|
|
124
|
+
useCursorTracking(
|
|
125
|
+
'board-realtime-board-1',
|
|
126
|
+
containerRef,
|
|
127
|
+
{
|
|
128
|
+
avatar_url: 'https://example.com/avatar.png',
|
|
129
|
+
display_name: 'Current User',
|
|
130
|
+
email: 'current@example.com',
|
|
131
|
+
id: 'user-current',
|
|
132
|
+
},
|
|
133
|
+
{ cursorScope: { boardId: 'board-1', type: 'board' } }
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
act(() => {
|
|
138
|
+
containerRef.current?.dispatchEvent(
|
|
139
|
+
new MouseEvent('mousemove', { clientX: 80, clientY: 40 })
|
|
140
|
+
);
|
|
141
|
+
animationFrameCallbacks.shift()?.(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await waitFor(() => expect(mockChannel.send).toHaveBeenCalled());
|
|
145
|
+
expect(mockChannel.send).toHaveBeenCalledWith({
|
|
146
|
+
event: 'cursor-move',
|
|
147
|
+
payload: {
|
|
148
|
+
metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
|
|
149
|
+
user: {
|
|
150
|
+
avatar_url: 'https://example.com/avatar.png',
|
|
151
|
+
display_name: 'Current User',
|
|
152
|
+
id: 'user-current',
|
|
153
|
+
},
|
|
154
|
+
x: expect.any(Number),
|
|
155
|
+
y: expect.any(Number),
|
|
156
|
+
},
|
|
157
|
+
type: 'broadcast',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('accepts only well-formed cursor payloads and strips private fields', () => {
|
|
162
|
+
const containerRef = createContainerRef();
|
|
163
|
+
const { result } = renderHook(() =>
|
|
164
|
+
useCursorTracking('board-realtime-board-1', containerRef, {
|
|
165
|
+
display_name: 'Current User',
|
|
166
|
+
id: 'user-current',
|
|
167
|
+
})
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const listener = broadcastListeners.get('cursor-move');
|
|
171
|
+
expect(listener).toBeDefined();
|
|
172
|
+
|
|
173
|
+
act(() => {
|
|
174
|
+
listener?.({
|
|
175
|
+
payload: {
|
|
176
|
+
user: { email: 'bad@example.com' },
|
|
177
|
+
x: 12,
|
|
178
|
+
y: 18,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
expect(result.current.cursors.size).toBe(0);
|
|
183
|
+
|
|
184
|
+
act(() => {
|
|
185
|
+
listener?.({
|
|
186
|
+
payload: {
|
|
187
|
+
metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
|
|
188
|
+
user: {
|
|
189
|
+
avatar_url: 'https://example.com/other.png',
|
|
190
|
+
display_name: 'Other User',
|
|
191
|
+
email: 'other@example.com',
|
|
192
|
+
id: 'user-other',
|
|
193
|
+
},
|
|
194
|
+
x: 12,
|
|
195
|
+
y: 18,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
expect(result.current.cursors.get('user-other')).toEqual({
|
|
201
|
+
lastUpdatedAt: expect.any(Number),
|
|
202
|
+
metadata: { cursorScope: { boardId: 'board-1', type: 'board' } },
|
|
203
|
+
user: {
|
|
204
|
+
avatar_url: 'https://example.com/other.png',
|
|
205
|
+
display_name: 'Other User',
|
|
206
|
+
id: 'user-other',
|
|
207
|
+
},
|
|
208
|
+
x: 12,
|
|
209
|
+
y: 18,
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
});
|
|
@@ -10,8 +10,10 @@ import { toast } from './use-toast';
|
|
|
10
10
|
import {
|
|
11
11
|
type BoardRealtimePayload,
|
|
12
12
|
createRealtimeClientId,
|
|
13
|
+
getBoardRealtimeChannelName,
|
|
13
14
|
isBoardRealtimeEnvelope,
|
|
14
15
|
LOCAL_BROADCAST_CHANNEL_PREFIX,
|
|
16
|
+
PRIVATE_TASK_REALTIME_CHANNEL_CONFIG,
|
|
15
17
|
type RealtimeChannel,
|
|
16
18
|
SEEN_REALTIME_EVENT_LIMIT,
|
|
17
19
|
} from './useBoardRealtime.types';
|
|
@@ -205,9 +207,10 @@ export function useBoardRealtime(
|
|
|
205
207
|
channelRef.current = null;
|
|
206
208
|
}
|
|
207
209
|
|
|
208
|
-
const channel = supabase.channel(
|
|
209
|
-
|
|
210
|
-
|
|
210
|
+
const channel = supabase.channel(
|
|
211
|
+
getBoardRealtimeChannelName(boardId),
|
|
212
|
+
PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
|
|
213
|
+
);
|
|
211
214
|
channelRef.current = channel;
|
|
212
215
|
|
|
213
216
|
channel
|
|
@@ -14,8 +14,19 @@ type BoardRealtimeEnvelope = {
|
|
|
14
14
|
payload: BoardRealtimePayload;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
|
+
export const BOARD_REALTIME_CHANNEL_PREFIX = 'board-realtime';
|
|
17
18
|
export const LOCAL_BROADCAST_CHANNEL_PREFIX = 'tuturuuu:board-realtime';
|
|
18
19
|
export const SEEN_REALTIME_EVENT_LIMIT = 500;
|
|
20
|
+
export const PRIVATE_TASK_REALTIME_CHANNEL_CONFIG = {
|
|
21
|
+
config: {
|
|
22
|
+
broadcast: { self: false },
|
|
23
|
+
private: true,
|
|
24
|
+
},
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export function getBoardRealtimeChannelName(boardId: string) {
|
|
28
|
+
return `${BOARD_REALTIME_CHANNEL_PREFIX}-${boardId}`;
|
|
29
|
+
}
|
|
19
30
|
|
|
20
31
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
21
32
|
typeof value === 'object' && value !== null;
|
|
@@ -5,15 +5,79 @@ import { DEV_MODE } from '@tuturuuu/utils/constants';
|
|
|
5
5
|
import type { RefObject } from 'react';
|
|
6
6
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
7
7
|
import { usePageVisibility } from './use-page-visibility';
|
|
8
|
+
import { PRIVATE_TASK_REALTIME_CHANNEL_CONFIG } from './useBoardRealtime.types';
|
|
9
|
+
|
|
10
|
+
type CursorUser = Pick<User, 'avatar_url' | 'display_name' | 'id'> & {
|
|
11
|
+
id: string;
|
|
12
|
+
};
|
|
8
13
|
|
|
9
14
|
export interface CursorPosition {
|
|
10
15
|
x: number;
|
|
11
16
|
y: number;
|
|
12
|
-
user?:
|
|
17
|
+
user?: CursorUser;
|
|
13
18
|
metadata?: { [key: string]: any };
|
|
14
19
|
lastUpdatedAt: number;
|
|
15
20
|
}
|
|
16
21
|
|
|
22
|
+
type ParsedCursorPosition = CursorPosition & { user: CursorUser };
|
|
23
|
+
|
|
24
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
25
|
+
return typeof value === 'object' && value !== null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sanitizeCursorUser(user: User | undefined): CursorUser | null {
|
|
29
|
+
if (!user?.id) return null;
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
avatar_url:
|
|
33
|
+
typeof user.avatar_url === 'string' && user.avatar_url.length > 0
|
|
34
|
+
? user.avatar_url
|
|
35
|
+
: null,
|
|
36
|
+
display_name:
|
|
37
|
+
typeof user.display_name === 'string' && user.display_name.length > 0
|
|
38
|
+
? user.display_name
|
|
39
|
+
: null,
|
|
40
|
+
id: user.id,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function sanitizeCursorMetadata(metadata: { [key: string]: any } | undefined) {
|
|
45
|
+
if (metadata == null || !isRecord(metadata) || Array.isArray(metadata)) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return metadata;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseCursorMovePayload(payload: unknown): ParsedCursorPosition | null {
|
|
53
|
+
if (!isRecord(payload)) return null;
|
|
54
|
+
|
|
55
|
+
const { metadata, user, x, y } = payload;
|
|
56
|
+
if (typeof x !== 'number' || !Number.isFinite(x)) return null;
|
|
57
|
+
if (typeof y !== 'number' || !Number.isFinite(y)) return null;
|
|
58
|
+
if (!isRecord(user) || typeof user.id !== 'string' || user.id.length === 0) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
lastUpdatedAt: Date.now(),
|
|
64
|
+
metadata: sanitizeCursorMetadata(metadata as { [key: string]: any }),
|
|
65
|
+
user: {
|
|
66
|
+
avatar_url:
|
|
67
|
+
typeof user.avatar_url === 'string' && user.avatar_url.length > 0
|
|
68
|
+
? user.avatar_url
|
|
69
|
+
: null,
|
|
70
|
+
display_name:
|
|
71
|
+
typeof user.display_name === 'string' && user.display_name.length > 0
|
|
72
|
+
? user.display_name
|
|
73
|
+
: null,
|
|
74
|
+
id: user.id,
|
|
75
|
+
},
|
|
76
|
+
x,
|
|
77
|
+
y,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
17
81
|
export function useCursorTracking(
|
|
18
82
|
channelName: string,
|
|
19
83
|
containerRef?: RefObject<HTMLElement | null>,
|
|
@@ -59,6 +123,8 @@ export function useCursorTracking(
|
|
|
59
123
|
metadata?: { [key: string]: any }
|
|
60
124
|
) => {
|
|
61
125
|
if (!channelRef.current) return;
|
|
126
|
+
const cursorUser = sanitizeCursorUser(user);
|
|
127
|
+
if (cursorUser == null) return;
|
|
62
128
|
// Check error count instead of state to avoid dependency
|
|
63
129
|
if (errorCountRef.current >= MAX_ERROR_COUNT) return;
|
|
64
130
|
|
|
@@ -73,7 +139,12 @@ export function useCursorTracking(
|
|
|
73
139
|
await channelRef.current?.send({
|
|
74
140
|
type: 'broadcast',
|
|
75
141
|
event: 'cursor-move',
|
|
76
|
-
payload: {
|
|
142
|
+
payload: {
|
|
143
|
+
metadata: sanitizeCursorMetadata(metadata),
|
|
144
|
+
user: cursorUser,
|
|
145
|
+
x,
|
|
146
|
+
y,
|
|
147
|
+
},
|
|
77
148
|
});
|
|
78
149
|
|
|
79
150
|
lastBroadcastTimeRef.current = Date.now();
|
|
@@ -90,7 +161,12 @@ export function useCursorTracking(
|
|
|
90
161
|
await channelRef.current.send({
|
|
91
162
|
type: 'broadcast',
|
|
92
163
|
event: 'cursor-move',
|
|
93
|
-
payload: {
|
|
164
|
+
payload: {
|
|
165
|
+
metadata: sanitizeCursorMetadata(metadata),
|
|
166
|
+
user: cursorUser,
|
|
167
|
+
x,
|
|
168
|
+
y,
|
|
169
|
+
},
|
|
94
170
|
});
|
|
95
171
|
|
|
96
172
|
lastBroadcastTimeRef.current = now;
|
|
@@ -207,38 +283,25 @@ export function useCursorTracking(
|
|
|
207
283
|
channelRef.current = null;
|
|
208
284
|
}
|
|
209
285
|
|
|
210
|
-
const channel = supabase.channel(
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
},
|
|
215
|
-
},
|
|
216
|
-
});
|
|
286
|
+
const channel = supabase.channel(
|
|
287
|
+
channelName,
|
|
288
|
+
PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
|
|
289
|
+
);
|
|
217
290
|
|
|
218
291
|
channelRef.current = channel;
|
|
219
292
|
// Listen for cursor movements from other users
|
|
220
293
|
channel
|
|
221
294
|
.on('broadcast', { event: 'cursor-move' }, (payload) => {
|
|
222
295
|
try {
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
y,
|
|
226
|
-
user: broadcastUser,
|
|
227
|
-
metadata: broadcastMetadata,
|
|
228
|
-
} = payload.payload;
|
|
296
|
+
const cursor = parseCursorMovePayload(payload.payload);
|
|
297
|
+
if (cursor == null) return;
|
|
229
298
|
|
|
230
299
|
// Ignore own broadcasts (extra safety)
|
|
231
|
-
if (
|
|
300
|
+
if (cursor.user?.id === user?.id) return;
|
|
232
301
|
|
|
233
302
|
setCursors((prev) => {
|
|
234
303
|
const updated = new Map(prev);
|
|
235
|
-
updated.set(
|
|
236
|
-
x,
|
|
237
|
-
y,
|
|
238
|
-
user: broadcastUser,
|
|
239
|
-
metadata: broadcastMetadata,
|
|
240
|
-
lastUpdatedAt: Date.now(),
|
|
241
|
-
});
|
|
304
|
+
updated.set(cursor.user.id, cursor);
|
|
242
305
|
return updated;
|
|
243
306
|
});
|
|
244
307
|
} catch (err) {
|
|
@@ -289,16 +352,17 @@ export function useCursorTracking(
|
|
|
289
352
|
|
|
290
353
|
// Broadcast cursor removal before unsubscribing (best effort)
|
|
291
354
|
// This helps other users see the cursor disappear immediately
|
|
292
|
-
|
|
355
|
+
const cursorUser = sanitizeCursorUser(user);
|
|
356
|
+
if (channelRef.current && cursorUser != null) {
|
|
293
357
|
try {
|
|
294
358
|
channelRef.current.send({
|
|
295
359
|
type: 'broadcast',
|
|
296
360
|
event: 'cursor-move',
|
|
297
361
|
payload: {
|
|
362
|
+
metadata: sanitizeCursorMetadata(metadata),
|
|
363
|
+
user: cursorUser,
|
|
298
364
|
x: -1000,
|
|
299
365
|
y: -1000,
|
|
300
|
-
user,
|
|
301
|
-
metadata,
|
|
302
366
|
},
|
|
303
367
|
});
|
|
304
368
|
} catch (err) {
|
|
@@ -8,6 +8,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
|
|
8
8
|
import {
|
|
9
9
|
type BoardRealtimePayload,
|
|
10
10
|
createRealtimeClientId,
|
|
11
|
+
PRIVATE_TASK_REALTIME_CHANNEL_CONFIG,
|
|
11
12
|
type RealtimeChannel,
|
|
12
13
|
SEEN_REALTIME_EVENT_LIMIT,
|
|
13
14
|
} from './useBoardRealtime.types';
|
|
@@ -255,9 +256,10 @@ export function useTaskUserRealtime(userId: string | null | undefined) {
|
|
|
255
256
|
return;
|
|
256
257
|
}
|
|
257
258
|
|
|
258
|
-
const channel = supabase.channel(
|
|
259
|
-
|
|
260
|
-
|
|
259
|
+
const channel = supabase.channel(
|
|
260
|
+
getTaskUserRealtimeChannelName(userId),
|
|
261
|
+
PRIVATE_TASK_REALTIME_CHANNEL_CONFIG
|
|
262
|
+
);
|
|
261
263
|
channelRef.current = channel;
|
|
262
264
|
|
|
263
265
|
channel
|