@tuturuuu/ui 0.25.0 → 0.25.2
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/package.json +1 -1
- package/src/components/ui/custom/__tests__/workspace-select-helpers.test.ts +2 -6
- package/src/components/ui/custom/__tests__/workspace-select-invitations.test.tsx +141 -0
- package/src/components/ui/custom/notification-popover-client.tsx +97 -57
- package/src/components/ui/custom/workspace-select-icon.tsx +58 -0
- package/src/components/ui/custom/workspace-select-invitations.tsx +202 -0
- package/src/components/ui/custom/workspace-select.tsx +43 -61
- package/src/hooks/use-notifications.ts +16 -2
package/package.json
CHANGED
|
@@ -115,14 +115,10 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it('keeps workspace fallback images outside Radix AvatarImage context', () => {
|
|
118
|
-
const
|
|
119
|
-
join(process.cwd(), 'src/components/ui/custom/workspace-select.tsx'),
|
|
118
|
+
const workspaceIconSource = readFileSync(
|
|
119
|
+
join(process.cwd(), 'src/components/ui/custom/workspace-select-icon.tsx'),
|
|
120
120
|
'utf8'
|
|
121
121
|
);
|
|
122
|
-
const workspaceIconSource = workspaceSelectSource.slice(
|
|
123
|
-
workspaceSelectSource.indexOf('function WorkspaceIcon'),
|
|
124
|
-
workspaceSelectSource.indexOf('export function WorkspaceSelect')
|
|
125
|
-
);
|
|
126
122
|
|
|
127
123
|
expect(workspaceIconSource).toContain('<AvatarFallback');
|
|
128
124
|
expect(workspaceIconSource).toContain('<Image');
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
2
|
+
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { WorkspaceInvitationRecord } from '@tuturuuu/internal-api/workspaces';
|
|
4
|
+
import { NextIntlClientProvider } from 'next-intl';
|
|
5
|
+
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { Command, CommandInput, CommandList } from '../../command';
|
|
7
|
+
import {
|
|
8
|
+
useWorkspaceInvitations,
|
|
9
|
+
WorkspaceInvitationItems,
|
|
10
|
+
} from '../workspace-select-invitations';
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => ({
|
|
13
|
+
acceptWorkspaceInvite: vi.fn(),
|
|
14
|
+
declineWorkspaceInvite: vi.fn(),
|
|
15
|
+
listWorkspaceInvitations: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('@tuturuuu/internal-api/workspaces', () => ({
|
|
19
|
+
acceptWorkspaceInvite: mocks.acceptWorkspaceInvite,
|
|
20
|
+
declineWorkspaceInvite: mocks.declineWorkspaceInvite,
|
|
21
|
+
listWorkspaceInvitations: mocks.listWorkspaceInvitations,
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const invitation: WorkspaceInvitationRecord = {
|
|
25
|
+
createdAt: null,
|
|
26
|
+
matchedEmail: 'invitee@example.com',
|
|
27
|
+
source: 'email',
|
|
28
|
+
type: 'MEMBER',
|
|
29
|
+
workspace: {
|
|
30
|
+
avatar_url: null,
|
|
31
|
+
handle: 'acme',
|
|
32
|
+
id: 'workspace-1',
|
|
33
|
+
logo_url: null,
|
|
34
|
+
name: 'Acme',
|
|
35
|
+
personal: false,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const messages = {
|
|
40
|
+
common: {
|
|
41
|
+
guest_access: 'Guest',
|
|
42
|
+
members: 'Member',
|
|
43
|
+
retry: 'Retry',
|
|
44
|
+
},
|
|
45
|
+
'workspace-invitation': {
|
|
46
|
+
accept: 'Accept',
|
|
47
|
+
'accept-error': 'Could not accept',
|
|
48
|
+
'accept-success': 'Accepted',
|
|
49
|
+
'decline-error': 'Could not decline',
|
|
50
|
+
'decline-success': 'Declined',
|
|
51
|
+
'direct-invite': 'Direct invitation',
|
|
52
|
+
'email-invite': 'Email invitation',
|
|
53
|
+
'list-eyebrow': 'Pending invitations',
|
|
54
|
+
reject: 'Decline',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function Harness({
|
|
59
|
+
onAccepted = vi.fn(),
|
|
60
|
+
onDeclined = vi.fn(),
|
|
61
|
+
}: {
|
|
62
|
+
onAccepted?: (value: WorkspaceInvitationRecord) => void;
|
|
63
|
+
onDeclined?: () => void;
|
|
64
|
+
}) {
|
|
65
|
+
const controller = useWorkspaceInvitations({
|
|
66
|
+
cacheScope: 'user-1',
|
|
67
|
+
enabled: true,
|
|
68
|
+
onAccepted,
|
|
69
|
+
onDeclined,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<Command>
|
|
74
|
+
<CommandInput aria-label="Search" />
|
|
75
|
+
<CommandList>
|
|
76
|
+
<WorkspaceInvitationItems
|
|
77
|
+
controller={controller}
|
|
78
|
+
fallbackLogoUrl="/logo.svg"
|
|
79
|
+
/>
|
|
80
|
+
</CommandList>
|
|
81
|
+
</Command>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function renderHarness(props: Parameters<typeof Harness>[0] = {}) {
|
|
86
|
+
const queryClient = new QueryClient({
|
|
87
|
+
defaultOptions: { queries: { retry: false } },
|
|
88
|
+
});
|
|
89
|
+
return render(
|
|
90
|
+
<QueryClientProvider client={queryClient}>
|
|
91
|
+
<NextIntlClientProvider locale="en" messages={messages}>
|
|
92
|
+
<Harness {...props} />
|
|
93
|
+
</NextIntlClientProvider>
|
|
94
|
+
</QueryClientProvider>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
describe('workspace invitation picker items', () => {
|
|
99
|
+
beforeAll(() => {
|
|
100
|
+
globalThis.ResizeObserver = class ResizeObserver {
|
|
101
|
+
disconnect() {}
|
|
102
|
+
observe() {}
|
|
103
|
+
unobserve() {}
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.clearAllMocks();
|
|
109
|
+
mocks.listWorkspaceInvitations.mockResolvedValue({
|
|
110
|
+
invitations: [invitation],
|
|
111
|
+
});
|
|
112
|
+
mocks.acceptWorkspaceInvite.mockResolvedValue(undefined);
|
|
113
|
+
mocks.declineWorkspaceInvite.mockResolvedValue(undefined);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('accepts the highlighted invitation and reports the accepted workspace', async () => {
|
|
117
|
+
const onAccepted = vi.fn();
|
|
118
|
+
renderHarness({ onAccepted });
|
|
119
|
+
|
|
120
|
+
const option = await screen.findByRole('option', { name: /Acme/ });
|
|
121
|
+
fireEvent.click(option);
|
|
122
|
+
|
|
123
|
+
await waitFor(() =>
|
|
124
|
+
expect(mocks.acceptWorkspaceInvite).toHaveBeenCalledWith('workspace-1')
|
|
125
|
+
);
|
|
126
|
+
await waitFor(() => expect(onAccepted).toHaveBeenCalledWith(invitation));
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('declines in place without activating the accept option', async () => {
|
|
130
|
+
const onDeclined = vi.fn();
|
|
131
|
+
renderHarness({ onDeclined });
|
|
132
|
+
|
|
133
|
+
fireEvent.click(await screen.findByRole('button', { name: 'Decline' }));
|
|
134
|
+
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(mocks.declineWorkspaceInvite).toHaveBeenCalledWith('workspace-1')
|
|
137
|
+
);
|
|
138
|
+
expect(mocks.acceptWorkspaceInvite).not.toHaveBeenCalled();
|
|
139
|
+
await waitFor(() => expect(onDeclined).toHaveBeenCalledOnce());
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -22,6 +22,11 @@ import {
|
|
|
22
22
|
X,
|
|
23
23
|
XCircle,
|
|
24
24
|
} from '@tuturuuu/icons';
|
|
25
|
+
import { updateNotificationMetadata } from '@tuturuuu/internal-api';
|
|
26
|
+
import {
|
|
27
|
+
acceptWorkspaceInvite,
|
|
28
|
+
declineWorkspaceInvite,
|
|
29
|
+
} from '@tuturuuu/internal-api/workspaces';
|
|
25
30
|
import { Button } from '@tuturuuu/ui/button';
|
|
26
31
|
import {
|
|
27
32
|
dedupeNotifications,
|
|
@@ -61,6 +66,11 @@ interface NotificationPopoverClientProps {
|
|
|
61
66
|
archiveAllText?: string;
|
|
62
67
|
emptyArchiveText?: string;
|
|
63
68
|
loadingMoreText?: string;
|
|
69
|
+
retryText?: string;
|
|
70
|
+
acceptText?: string;
|
|
71
|
+
declineText?: string;
|
|
72
|
+
acceptedText?: string;
|
|
73
|
+
declinedText?: string;
|
|
64
74
|
/** Base URL for external redirect (e.g. 'https://tuturuuu.com'). When set, "View All" links to {webAppUrl}/{wsId}/notifications. */
|
|
65
75
|
webAppUrl?: string;
|
|
66
76
|
}
|
|
@@ -93,6 +103,11 @@ export default function NotificationPopoverClient({
|
|
|
93
103
|
archiveAllText = 'Archive all',
|
|
94
104
|
emptyArchiveText = 'No archived notifications yet.',
|
|
95
105
|
loadingMoreText = 'Loading more...',
|
|
106
|
+
retryText = 'Retry',
|
|
107
|
+
acceptText = 'Accept',
|
|
108
|
+
declineText = 'Decline',
|
|
109
|
+
acceptedText = 'Joined',
|
|
110
|
+
declinedText = 'Declined',
|
|
96
111
|
webAppUrl,
|
|
97
112
|
}: NotificationPopoverClientProps) {
|
|
98
113
|
const [open, setOpen] = useState(false);
|
|
@@ -111,11 +126,13 @@ export default function NotificationPopoverClient({
|
|
|
111
126
|
|
|
112
127
|
// Accurate unread count from dedicated endpoint
|
|
113
128
|
const { data: unreadCount = 0 } = useUnreadCount(wsIdForFiltering, {
|
|
129
|
+
cacheScope: userId,
|
|
114
130
|
enabled: Boolean(userId),
|
|
115
131
|
});
|
|
116
132
|
|
|
117
133
|
// Infinite scroll for inbox (unread) and archive (read)
|
|
118
134
|
const inboxQuery = useInfiniteNotifications({
|
|
135
|
+
cacheScope: userId,
|
|
119
136
|
wsId: wsIdForFiltering,
|
|
120
137
|
unreadOnly: true,
|
|
121
138
|
pageSize: 15,
|
|
@@ -123,6 +140,7 @@ export default function NotificationPopoverClient({
|
|
|
123
140
|
});
|
|
124
141
|
|
|
125
142
|
const archiveQuery = useInfiniteNotifications({
|
|
143
|
+
cacheScope: userId,
|
|
126
144
|
wsId: wsIdForFiltering,
|
|
127
145
|
readOnly: true,
|
|
128
146
|
pageSize: 15,
|
|
@@ -176,7 +194,9 @@ export default function NotificationPopoverClient({
|
|
|
176
194
|
<Button
|
|
177
195
|
variant="ghost"
|
|
178
196
|
size="icon"
|
|
179
|
-
|
|
197
|
+
aria-label={notificationsText}
|
|
198
|
+
title={notificationsText}
|
|
199
|
+
className="group relative flex size-10 flex-none transition-all"
|
|
180
200
|
>
|
|
181
201
|
<Bell className="h-6 w-6" />
|
|
182
202
|
{unreadCount > 0 && (
|
|
@@ -261,6 +281,11 @@ export default function NotificationPopoverClient({
|
|
|
261
281
|
noNotificationsText={noNotificationsText}
|
|
262
282
|
emptyArchiveText={emptyArchiveText}
|
|
263
283
|
loadingMoreText={loadingMoreText}
|
|
284
|
+
retryText={retryText}
|
|
285
|
+
acceptText={acceptText}
|
|
286
|
+
declineText={declineText}
|
|
287
|
+
acceptedText={acceptedText}
|
|
288
|
+
declinedText={declinedText}
|
|
264
289
|
markAsReadText={markAsReadText}
|
|
265
290
|
markAsUnreadText={markAsUnreadText}
|
|
266
291
|
onMarkAsRead={handleMarkAsRead}
|
|
@@ -293,6 +318,11 @@ function NotificationList({
|
|
|
293
318
|
noNotificationsText,
|
|
294
319
|
emptyArchiveText,
|
|
295
320
|
loadingMoreText,
|
|
321
|
+
retryText,
|
|
322
|
+
acceptText,
|
|
323
|
+
declineText,
|
|
324
|
+
acceptedText,
|
|
325
|
+
declinedText,
|
|
296
326
|
markAsReadText,
|
|
297
327
|
markAsUnreadText,
|
|
298
328
|
onMarkAsRead,
|
|
@@ -307,6 +337,11 @@ function NotificationList({
|
|
|
307
337
|
noNotificationsText: string;
|
|
308
338
|
emptyArchiveText: string;
|
|
309
339
|
loadingMoreText: string;
|
|
340
|
+
retryText: string;
|
|
341
|
+
acceptText: string;
|
|
342
|
+
declineText: string;
|
|
343
|
+
acceptedText: string;
|
|
344
|
+
declinedText: string;
|
|
310
345
|
markAsReadText: string;
|
|
311
346
|
markAsUnreadText: string;
|
|
312
347
|
onMarkAsRead: (id: string, isUnread: boolean) => void;
|
|
@@ -357,6 +392,21 @@ function NotificationList({
|
|
|
357
392
|
<p className="mt-1 text-foreground/40 text-xs">
|
|
358
393
|
{query.error instanceof Error ? query.error.message : 'Unknown error'}
|
|
359
394
|
</p>
|
|
395
|
+
<Button
|
|
396
|
+
className="mt-3"
|
|
397
|
+
disabled={query.isFetching}
|
|
398
|
+
onClick={() => query.refetch()}
|
|
399
|
+
size="sm"
|
|
400
|
+
type="button"
|
|
401
|
+
variant="outline"
|
|
402
|
+
>
|
|
403
|
+
{query.isFetching ? (
|
|
404
|
+
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
405
|
+
) : (
|
|
406
|
+
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
|
407
|
+
)}
|
|
408
|
+
{retryText}
|
|
409
|
+
</Button>
|
|
360
410
|
</div>
|
|
361
411
|
);
|
|
362
412
|
}
|
|
@@ -406,6 +456,10 @@ function NotificationList({
|
|
|
406
456
|
markAsUnreadText={markAsUnreadText}
|
|
407
457
|
queryClient={queryClient}
|
|
408
458
|
onActionComplete={onActionComplete}
|
|
459
|
+
acceptText={acceptText}
|
|
460
|
+
declineText={declineText}
|
|
461
|
+
acceptedText={acceptedText}
|
|
462
|
+
declinedText={declinedText}
|
|
409
463
|
/>
|
|
410
464
|
))}
|
|
411
465
|
|
|
@@ -431,6 +485,10 @@ interface NotificationCardProps {
|
|
|
431
485
|
markAsUnreadText: string;
|
|
432
486
|
queryClient: any;
|
|
433
487
|
onActionComplete?: () => void;
|
|
488
|
+
acceptText: string;
|
|
489
|
+
declineText: string;
|
|
490
|
+
acceptedText: string;
|
|
491
|
+
declinedText: string;
|
|
434
492
|
}
|
|
435
493
|
|
|
436
494
|
function getWorkspaceInviteWorkspaceId(notification: Notification) {
|
|
@@ -456,6 +514,10 @@ function NotificationCard({
|
|
|
456
514
|
markAsUnreadText,
|
|
457
515
|
queryClient,
|
|
458
516
|
onActionComplete,
|
|
517
|
+
acceptText,
|
|
518
|
+
declineText,
|
|
519
|
+
acceptedText,
|
|
520
|
+
declinedText,
|
|
459
521
|
}: NotificationCardProps) {
|
|
460
522
|
const isUnread = !notification.read_at;
|
|
461
523
|
const [processingAction, setProcessingAction] = useState<string | null>(null);
|
|
@@ -477,58 +539,34 @@ function NotificationCard({
|
|
|
477
539
|
break;
|
|
478
540
|
}
|
|
479
541
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}),
|
|
509
|
-
queryClient.refetchQueries({
|
|
510
|
-
queryKey: ['notifications'],
|
|
511
|
-
type: 'active',
|
|
512
|
-
}),
|
|
513
|
-
]);
|
|
514
|
-
|
|
515
|
-
toast.success(
|
|
516
|
-
accept
|
|
517
|
-
? 'Workspace invite accepted'
|
|
518
|
-
: 'Workspace invite declined'
|
|
519
|
-
);
|
|
520
|
-
|
|
521
|
-
onMarkAsRead(notification.id, true);
|
|
522
|
-
router.refresh();
|
|
523
|
-
onActionComplete?.();
|
|
524
|
-
} else {
|
|
525
|
-
toast.error('Failed to update notification');
|
|
526
|
-
}
|
|
527
|
-
} else {
|
|
528
|
-
const errorData = await res.json();
|
|
529
|
-
console.error('Failed to process invite:', errorData);
|
|
530
|
-
toast.error(errorData.error || 'Failed to process invite');
|
|
531
|
-
}
|
|
542
|
+
await (accept
|
|
543
|
+
? acceptWorkspaceInvite(targetWsId)
|
|
544
|
+
: declineWorkspaceInvite(targetWsId));
|
|
545
|
+
await updateNotificationMetadata(notification.id, {
|
|
546
|
+
action_taken: accept ? 'accepted' : 'declined',
|
|
547
|
+
action_timestamp: new Date().toISOString(),
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
await Promise.all([
|
|
551
|
+
queryClient.invalidateQueries({
|
|
552
|
+
queryKey: ['workspaces'],
|
|
553
|
+
refetchType: 'active',
|
|
554
|
+
}),
|
|
555
|
+
queryClient.invalidateQueries({
|
|
556
|
+
queryKey: ['notifications'],
|
|
557
|
+
refetchType: 'active',
|
|
558
|
+
}),
|
|
559
|
+
queryClient.refetchQueries({
|
|
560
|
+
queryKey: ['notifications'],
|
|
561
|
+
type: 'active',
|
|
562
|
+
}),
|
|
563
|
+
]);
|
|
564
|
+
|
|
565
|
+
toast.success(accept ? acceptedText : declinedText);
|
|
566
|
+
|
|
567
|
+
onMarkAsRead(notification.id, true);
|
|
568
|
+
router.refresh();
|
|
569
|
+
onActionComplete?.();
|
|
532
570
|
break;
|
|
533
571
|
}
|
|
534
572
|
default:
|
|
@@ -597,13 +635,15 @@ function NotificationCard({
|
|
|
597
635
|
{notification.data.action_taken === 'accepted' ? (
|
|
598
636
|
<>
|
|
599
637
|
<CheckCircle2 className="h-3 w-3 text-dynamic-green" />
|
|
600
|
-
<span className="font-medium text-dynamic-green">
|
|
638
|
+
<span className="font-medium text-dynamic-green">
|
|
639
|
+
{acceptedText}
|
|
640
|
+
</span>
|
|
601
641
|
</>
|
|
602
642
|
) : (
|
|
603
643
|
<>
|
|
604
644
|
<XCircle className="h-3 w-3 text-foreground/40" />
|
|
605
645
|
<span className="font-medium text-foreground/60">
|
|
606
|
-
|
|
646
|
+
{declinedText}
|
|
607
647
|
</span>
|
|
608
648
|
</>
|
|
609
649
|
)}
|
|
@@ -627,7 +667,7 @@ function NotificationCard({
|
|
|
627
667
|
) : (
|
|
628
668
|
<X className="h-3 w-3" />
|
|
629
669
|
)}
|
|
630
|
-
|
|
670
|
+
{declineText}
|
|
631
671
|
</Button>
|
|
632
672
|
<Button
|
|
633
673
|
size="sm"
|
|
@@ -644,7 +684,7 @@ function NotificationCard({
|
|
|
644
684
|
) : (
|
|
645
685
|
<Check className="h-3 w-3" />
|
|
646
686
|
)}
|
|
647
|
-
|
|
687
|
+
{acceptText}
|
|
648
688
|
</Button>
|
|
649
689
|
</div>
|
|
650
690
|
) : isTaskEntityNotification ? (
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { cn } from '@tuturuuu/utils/format';
|
|
2
|
+
import Image from 'next/image';
|
|
3
|
+
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
|
4
|
+
import { TUTURUUU_LOGO_URL } from './tuturuuu-logo';
|
|
5
|
+
import { resolveWorkspaceAvatarUrl } from './workspace-select-helpers';
|
|
6
|
+
|
|
7
|
+
export function WorkspaceIcon({
|
|
8
|
+
name,
|
|
9
|
+
avatarUrl,
|
|
10
|
+
className,
|
|
11
|
+
fallbackLogoUrl = TUTURUUU_LOGO_URL,
|
|
12
|
+
}: {
|
|
13
|
+
name?: string | null;
|
|
14
|
+
avatarUrl?: string | null;
|
|
15
|
+
className?: string;
|
|
16
|
+
fallbackLogoUrl?: string;
|
|
17
|
+
}) {
|
|
18
|
+
const resolvedAvatarUrl = resolveWorkspaceAvatarUrl(avatarUrl);
|
|
19
|
+
const shouldSkipFallbackOptimization = /^https?:\/\//u.test(fallbackLogoUrl);
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<Avatar
|
|
23
|
+
className={cn(
|
|
24
|
+
'h-5 max-h-5 min-h-5 w-5 min-w-5 max-w-5 flex-none overflow-hidden',
|
|
25
|
+
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm',
|
|
26
|
+
className
|
|
27
|
+
)}
|
|
28
|
+
>
|
|
29
|
+
<AvatarImage
|
|
30
|
+
alt={name || 'Workspace'}
|
|
31
|
+
className={cn(
|
|
32
|
+
'h-full w-full object-cover',
|
|
33
|
+
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
34
|
+
)}
|
|
35
|
+
src={
|
|
36
|
+
resolvedAvatarUrl ||
|
|
37
|
+
(name ? `https://avatar.vercel.sh/${name}.png` : undefined)
|
|
38
|
+
}
|
|
39
|
+
/>
|
|
40
|
+
<AvatarFallback
|
|
41
|
+
className={cn(
|
|
42
|
+
'h-full w-full text-xs',
|
|
43
|
+
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
<Image
|
|
47
|
+
alt=""
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
className="h-full w-full object-cover"
|
|
50
|
+
height={20}
|
|
51
|
+
src={fallbackLogoUrl}
|
|
52
|
+
unoptimized={shouldSkipFallbackOptimization}
|
|
53
|
+
width={20}
|
|
54
|
+
/>
|
|
55
|
+
</AvatarFallback>
|
|
56
|
+
</Avatar>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { Check, Loader2, Mail, RefreshCw, X } from '@tuturuuu/icons';
|
|
5
|
+
import type { WorkspaceInvitationRecord } from '@tuturuuu/internal-api/workspaces';
|
|
6
|
+
import {
|
|
7
|
+
acceptWorkspaceInvite,
|
|
8
|
+
declineWorkspaceInvite,
|
|
9
|
+
listWorkspaceInvitations,
|
|
10
|
+
} from '@tuturuuu/internal-api/workspaces';
|
|
11
|
+
import { useTranslations } from 'next-intl';
|
|
12
|
+
import { toast } from 'sonner';
|
|
13
|
+
import { Button } from '../button';
|
|
14
|
+
import { CommandGroup, CommandItem } from '../command';
|
|
15
|
+
import { WorkspaceIcon } from './workspace-select-icon';
|
|
16
|
+
|
|
17
|
+
export function useWorkspaceInvitations({
|
|
18
|
+
cacheScope,
|
|
19
|
+
enabled,
|
|
20
|
+
onAccepted,
|
|
21
|
+
onDeclined,
|
|
22
|
+
}: {
|
|
23
|
+
cacheScope?: string;
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
onAccepted: (invitation: WorkspaceInvitationRecord) => void;
|
|
26
|
+
onDeclined: () => void;
|
|
27
|
+
}) {
|
|
28
|
+
const queryClient = useQueryClient();
|
|
29
|
+
const t = useTranslations();
|
|
30
|
+
const query = useQuery({
|
|
31
|
+
queryKey: ['workspace-invitations', ...(cacheScope ? [cacheScope] : [])],
|
|
32
|
+
queryFn: async () => (await listWorkspaceInvitations()).invitations,
|
|
33
|
+
enabled,
|
|
34
|
+
retry: 1,
|
|
35
|
+
staleTime: 30_000,
|
|
36
|
+
});
|
|
37
|
+
const invitations = query.data ?? [];
|
|
38
|
+
const mutation = useMutation({
|
|
39
|
+
mutationFn: async ({
|
|
40
|
+
action,
|
|
41
|
+
invitation,
|
|
42
|
+
}: {
|
|
43
|
+
action: 'accept' | 'decline';
|
|
44
|
+
invitation: WorkspaceInvitationRecord;
|
|
45
|
+
}) => {
|
|
46
|
+
if (action === 'accept') {
|
|
47
|
+
await acceptWorkspaceInvite(invitation.workspace.id);
|
|
48
|
+
} else {
|
|
49
|
+
await declineWorkspaceInvite(invitation.workspace.id);
|
|
50
|
+
}
|
|
51
|
+
return { action, invitation };
|
|
52
|
+
},
|
|
53
|
+
onSuccess: async ({ action, invitation }) => {
|
|
54
|
+
await Promise.all([
|
|
55
|
+
queryClient.invalidateQueries({ queryKey: ['workspace-invitations'] }),
|
|
56
|
+
queryClient.invalidateQueries({ queryKey: ['workspaces'] }),
|
|
57
|
+
queryClient.invalidateQueries({ queryKey: ['user-workspaces'] }),
|
|
58
|
+
queryClient.invalidateQueries({ queryKey: ['workspace-user'] }),
|
|
59
|
+
queryClient.invalidateQueries({ queryKey: ['current-user'] }),
|
|
60
|
+
queryClient.invalidateQueries({ queryKey: ['user'] }),
|
|
61
|
+
queryClient.invalidateQueries({ queryKey: ['notifications'] }),
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
if (action === 'accept') {
|
|
65
|
+
toast.success(t('workspace-invitation.accept-success'));
|
|
66
|
+
onAccepted(invitation);
|
|
67
|
+
} else {
|
|
68
|
+
toast.success(t('workspace-invitation.decline-success'));
|
|
69
|
+
onDeclined();
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
onError: (error, { action }) => {
|
|
73
|
+
toast.error(
|
|
74
|
+
t(
|
|
75
|
+
action === 'accept'
|
|
76
|
+
? 'workspace-invitation.accept-error'
|
|
77
|
+
: 'workspace-invitation.decline-error'
|
|
78
|
+
),
|
|
79
|
+
{
|
|
80
|
+
description: error instanceof Error ? error.message : undefined,
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return { invitations, mutation, query };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function WorkspaceInvitationItems({
|
|
90
|
+
controller,
|
|
91
|
+
fallbackLogoUrl,
|
|
92
|
+
}: {
|
|
93
|
+
controller: ReturnType<typeof useWorkspaceInvitations>;
|
|
94
|
+
fallbackLogoUrl: string;
|
|
95
|
+
}) {
|
|
96
|
+
const t = useTranslations();
|
|
97
|
+
const { invitations, mutation, query } = controller;
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<>
|
|
101
|
+
{invitations.length > 0 && (
|
|
102
|
+
<CommandGroup
|
|
103
|
+
heading={`${t('workspace-invitation.list-eyebrow')} (${invitations.length})`}
|
|
104
|
+
>
|
|
105
|
+
{invitations.map((invitation) => {
|
|
106
|
+
const workspaceName =
|
|
107
|
+
invitation.workspace.name ||
|
|
108
|
+
invitation.workspace.handle ||
|
|
109
|
+
invitation.workspace.id;
|
|
110
|
+
const isPending =
|
|
111
|
+
mutation.isPending &&
|
|
112
|
+
mutation.variables?.invitation.workspace.id ===
|
|
113
|
+
invitation.workspace.id;
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className="flex items-stretch gap-1 [&:has([cmdk-item][hidden])]:hidden"
|
|
118
|
+
key={`${invitation.workspace.id}-${invitation.source}`}
|
|
119
|
+
>
|
|
120
|
+
<CommandItem
|
|
121
|
+
className="min-w-0 flex-1 gap-2"
|
|
122
|
+
disabled={isPending}
|
|
123
|
+
onSelect={() =>
|
|
124
|
+
mutation.mutate({ action: 'accept', invitation })
|
|
125
|
+
}
|
|
126
|
+
value={`${workspaceName} ${invitation.workspace.handle || ''} ${invitation.source} ${invitation.type}`}
|
|
127
|
+
>
|
|
128
|
+
<WorkspaceIcon
|
|
129
|
+
avatarUrl={
|
|
130
|
+
invitation.workspace.avatar_url ||
|
|
131
|
+
invitation.workspace.logo_url
|
|
132
|
+
}
|
|
133
|
+
fallbackLogoUrl={fallbackLogoUrl}
|
|
134
|
+
name={workspaceName}
|
|
135
|
+
/>
|
|
136
|
+
<div className="min-w-0 flex-1">
|
|
137
|
+
<div className="truncate text-xs">{workspaceName}</div>
|
|
138
|
+
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
139
|
+
<Mail className="size-3" />
|
|
140
|
+
{t(
|
|
141
|
+
`workspace-invitation.${
|
|
142
|
+
invitation.source === 'email'
|
|
143
|
+
? 'email-invite'
|
|
144
|
+
: 'direct-invite'
|
|
145
|
+
}`
|
|
146
|
+
)}
|
|
147
|
+
<span aria-hidden="true">·</span>
|
|
148
|
+
{invitation.type === 'GUEST'
|
|
149
|
+
? t('common.guest_access')
|
|
150
|
+
: t('common.members')}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
{isPending && mutation.variables?.action === 'accept' ? (
|
|
154
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
155
|
+
) : (
|
|
156
|
+
<Check className="size-3.5" />
|
|
157
|
+
)}
|
|
158
|
+
<span className="sr-only">
|
|
159
|
+
{t('workspace-invitation.accept')}
|
|
160
|
+
</span>
|
|
161
|
+
</CommandItem>
|
|
162
|
+
<Button
|
|
163
|
+
aria-label={t('workspace-invitation.reject')}
|
|
164
|
+
className="size-8 self-center"
|
|
165
|
+
disabled={isPending}
|
|
166
|
+
onClick={() =>
|
|
167
|
+
mutation.mutate({ action: 'decline', invitation })
|
|
168
|
+
}
|
|
169
|
+
size="icon"
|
|
170
|
+
title={t('workspace-invitation.reject')}
|
|
171
|
+
type="button"
|
|
172
|
+
variant="ghost"
|
|
173
|
+
>
|
|
174
|
+
{isPending && mutation.variables?.action === 'decline' ? (
|
|
175
|
+
<Loader2 className="size-3.5 animate-spin" />
|
|
176
|
+
) : (
|
|
177
|
+
<X className="size-3.5" />
|
|
178
|
+
)}
|
|
179
|
+
</Button>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
})}
|
|
183
|
+
</CommandGroup>
|
|
184
|
+
)}
|
|
185
|
+
{query.isError && (
|
|
186
|
+
<CommandGroup>
|
|
187
|
+
<CommandItem
|
|
188
|
+
onSelect={() => query.refetch()}
|
|
189
|
+
value="retry workspace invitations"
|
|
190
|
+
>
|
|
191
|
+
{query.isFetching ? (
|
|
192
|
+
<Loader2 className="size-4 animate-spin" />
|
|
193
|
+
) : (
|
|
194
|
+
<RefreshCw className="size-4" />
|
|
195
|
+
)}
|
|
196
|
+
{t('common.retry')}
|
|
197
|
+
</CommandItem>
|
|
198
|
+
</CommandGroup>
|
|
199
|
+
)}
|
|
200
|
+
</>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
import { cn } from '@tuturuuu/utils/format';
|
|
28
28
|
import { workspaceHandleSchema } from '@tuturuuu/utils/workspace-handle';
|
|
29
29
|
import { WORKSPACE_LIMIT_ERROR_CODE } from '@tuturuuu/utils/workspace-limits';
|
|
30
|
-
import Image from 'next/image';
|
|
31
30
|
import { usePathname, useRouter } from 'next/navigation';
|
|
32
31
|
import { useLocale, useTranslations } from 'next-intl';
|
|
33
32
|
import type { ReactNode } from 'react';
|
|
@@ -37,7 +36,6 @@ import { z } from 'zod';
|
|
|
37
36
|
import { useForm } from '../../../hooks/use-form';
|
|
38
37
|
import { useWorkspaceUser } from '../../../hooks/use-workspace-user';
|
|
39
38
|
import { zodResolver } from '../../../resolvers';
|
|
40
|
-
import { Avatar, AvatarFallback, AvatarImage } from '../avatar';
|
|
41
39
|
import { Badge } from '../badge';
|
|
42
40
|
import { Button } from '../button';
|
|
43
41
|
import {
|
|
@@ -74,6 +72,11 @@ import {
|
|
|
74
72
|
normalizeWorkspaceSwitchPath,
|
|
75
73
|
resolveWorkspaceAvatarUrl,
|
|
76
74
|
} from './workspace-select-helpers';
|
|
75
|
+
import { WorkspaceIcon } from './workspace-select-icon';
|
|
76
|
+
import {
|
|
77
|
+
useWorkspaceInvitations,
|
|
78
|
+
WorkspaceInvitationItems,
|
|
79
|
+
} from './workspace-select-invitations';
|
|
77
80
|
import { useOpenWorkspaceSelectWhenRevealed } from './workspace-select-reveal';
|
|
78
81
|
|
|
79
82
|
const FormSchema = z.object({
|
|
@@ -84,59 +87,6 @@ const JoinWorkspaceByHandleFormSchema = z.object({
|
|
|
84
87
|
handle: workspaceHandleSchema,
|
|
85
88
|
});
|
|
86
89
|
|
|
87
|
-
function WorkspaceIcon({
|
|
88
|
-
name,
|
|
89
|
-
avatarUrl,
|
|
90
|
-
className,
|
|
91
|
-
fallbackLogoUrl = TUTURUUU_LOGO_URL,
|
|
92
|
-
}: {
|
|
93
|
-
name?: string | null;
|
|
94
|
-
avatarUrl?: string | null;
|
|
95
|
-
className?: string;
|
|
96
|
-
fallbackLogoUrl?: string;
|
|
97
|
-
}) {
|
|
98
|
-
const resolvedAvatarUrl = resolveWorkspaceAvatarUrl(avatarUrl);
|
|
99
|
-
const shouldSkipFallbackOptimization = /^https?:\/\//u.test(fallbackLogoUrl);
|
|
100
|
-
|
|
101
|
-
return (
|
|
102
|
-
<Avatar
|
|
103
|
-
className={cn(
|
|
104
|
-
'h-5 max-h-5 min-h-5 w-5 min-w-5 max-w-5 flex-none overflow-hidden',
|
|
105
|
-
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm',
|
|
106
|
-
className
|
|
107
|
-
)}
|
|
108
|
-
>
|
|
109
|
-
<AvatarImage
|
|
110
|
-
src={
|
|
111
|
-
resolvedAvatarUrl ||
|
|
112
|
-
(name ? `https://avatar.vercel.sh/${name}.png` : undefined)
|
|
113
|
-
}
|
|
114
|
-
alt={name || 'Workspace'}
|
|
115
|
-
className={cn(
|
|
116
|
-
'h-full w-full object-cover',
|
|
117
|
-
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
118
|
-
)}
|
|
119
|
-
/>
|
|
120
|
-
<AvatarFallback
|
|
121
|
-
className={cn(
|
|
122
|
-
'h-full w-full text-xs',
|
|
123
|
-
resolvedAvatarUrl ? 'rounded-xs' : 'rounded-sm'
|
|
124
|
-
)}
|
|
125
|
-
>
|
|
126
|
-
<Image
|
|
127
|
-
alt=""
|
|
128
|
-
aria-hidden="true"
|
|
129
|
-
className="h-full w-full object-cover"
|
|
130
|
-
height={20}
|
|
131
|
-
src={fallbackLogoUrl}
|
|
132
|
-
unoptimized={shouldSkipFallbackOptimization}
|
|
133
|
-
width={20}
|
|
134
|
-
/>
|
|
135
|
-
</AvatarFallback>
|
|
136
|
-
</Avatar>
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
90
|
export function WorkspaceSelect({
|
|
141
91
|
wsId,
|
|
142
92
|
hideLeading,
|
|
@@ -152,6 +102,7 @@ export function WorkspaceSelect({
|
|
|
152
102
|
triggerClassName,
|
|
153
103
|
popoverModal = false,
|
|
154
104
|
platformWorkspaceSetupUrl,
|
|
105
|
+
cacheScope,
|
|
155
106
|
}: {
|
|
156
107
|
wsId: string;
|
|
157
108
|
hideLeading?: boolean;
|
|
@@ -172,6 +123,8 @@ export function WorkspaceSelect({
|
|
|
172
123
|
popoverModal?: boolean;
|
|
173
124
|
/** Platform origin used to prepare a newly created satellite workspace. */
|
|
174
125
|
platformWorkspaceSetupUrl?: string;
|
|
126
|
+
/** Authenticated identity used to isolate user-specific picker caches. */
|
|
127
|
+
cacheScope?: string;
|
|
175
128
|
}) {
|
|
176
129
|
const t = useTranslations();
|
|
177
130
|
const locale = useLocale();
|
|
@@ -184,7 +137,7 @@ export function WorkspaceSelect({
|
|
|
184
137
|
? resolveWorkspaceId(wsId)
|
|
185
138
|
: undefined;
|
|
186
139
|
const { data: listedWorkspaces } = useQuery({
|
|
187
|
-
queryKey: ['workspaces'],
|
|
140
|
+
queryKey: ['workspaces', ...(cacheScope ? [cacheScope] : [])],
|
|
188
141
|
queryFn: fetchWorkspaces,
|
|
189
142
|
enabled: !!wsId,
|
|
190
143
|
});
|
|
@@ -195,7 +148,11 @@ export function WorkspaceSelect({
|
|
|
195
148
|
)
|
|
196
149
|
);
|
|
197
150
|
const { data: currentWorkspaceFallback } = useQuery({
|
|
198
|
-
queryKey: [
|
|
151
|
+
queryKey: [
|
|
152
|
+
'workspace-select-current-workspace',
|
|
153
|
+
resolvedWorkspaceId,
|
|
154
|
+
...(cacheScope ? [cacheScope] : []),
|
|
155
|
+
],
|
|
199
156
|
queryFn: async () =>
|
|
200
157
|
(await getWorkspace(resolvedWorkspaceId!)) as InternalApiWorkspaceSummary,
|
|
201
158
|
enabled: Boolean(resolvedWorkspaceId && !hasListedCurrentWorkspace),
|
|
@@ -206,7 +163,6 @@ export function WorkspaceSelect({
|
|
|
206
163
|
currentWorkspaceFallback
|
|
207
164
|
);
|
|
208
165
|
const { data: currentUser } = useWorkspaceUser();
|
|
209
|
-
|
|
210
166
|
const defaultWorkspaceId = currentUser?.default_workspace_id || null;
|
|
211
167
|
|
|
212
168
|
const form = useForm({
|
|
@@ -228,6 +184,18 @@ export function WorkspaceSelect({
|
|
|
228
184
|
|
|
229
185
|
const [loading, setLoading] = useState(false);
|
|
230
186
|
const [joiningByHandle, setJoiningByHandle] = useState(false);
|
|
187
|
+
const invitationController = useWorkspaceInvitations({
|
|
188
|
+
cacheScope,
|
|
189
|
+
enabled: Boolean(wsId),
|
|
190
|
+
onAccepted: (invitation) => {
|
|
191
|
+
setOpen(false);
|
|
192
|
+
const slug = invitation.workspace.handle || invitation.workspace.id;
|
|
193
|
+
router.push(getWorkspaceLandingPath(slug));
|
|
194
|
+
router.refresh();
|
|
195
|
+
},
|
|
196
|
+
onDeclined: () => router.refresh(),
|
|
197
|
+
});
|
|
198
|
+
const invitations = invitationController.invitations;
|
|
231
199
|
|
|
232
200
|
const updateDefaultWorkspaceMutation = useMutation({
|
|
233
201
|
mutationFn: (workspaceId: string) =>
|
|
@@ -257,7 +225,7 @@ export function WorkspaceSelect({
|
|
|
257
225
|
},
|
|
258
226
|
});
|
|
259
227
|
|
|
260
|
-
|
|
228
|
+
function getWorkspaceLandingPath(nextSlug: string) {
|
|
261
229
|
if (resolveNextPathname) {
|
|
262
230
|
return resolveNextPathname({
|
|
263
231
|
currentPathname: pathname || `/${wsId}`,
|
|
@@ -268,7 +236,7 @@ export function WorkspaceSelect({
|
|
|
268
236
|
return customRedirectSuffix
|
|
269
237
|
? `/${nextSlug}/${customRedirectSuffix}`
|
|
270
238
|
: `/${nextSlug}`;
|
|
271
|
-
}
|
|
239
|
+
}
|
|
272
240
|
|
|
273
241
|
async function onSubmit(formData: z.infer<typeof FormSchema>) {
|
|
274
242
|
if (disableCreateNewWorkspace) return;
|
|
@@ -450,7 +418,8 @@ export function WorkspaceSelect({
|
|
|
450
418
|
}
|
|
451
419
|
};
|
|
452
420
|
|
|
453
|
-
const hasSelectableWorkspaces =
|
|
421
|
+
const hasSelectableWorkspaces =
|
|
422
|
+
workspaces.length > 0 || invitations.length > 0;
|
|
454
423
|
useOpenWorkspaceSelectWhenRevealed(hasSelectableWorkspaces, setOpen);
|
|
455
424
|
|
|
456
425
|
const workspace =
|
|
@@ -616,6 +585,15 @@ export function WorkspaceSelect({
|
|
|
616
585
|
</Badge>
|
|
617
586
|
)}
|
|
618
587
|
</div>
|
|
588
|
+
{invitations.length > 0 && (
|
|
589
|
+
<Badge
|
|
590
|
+
aria-label={`${invitations.length} ${t('workspace-invitation.list-eyebrow')}`}
|
|
591
|
+
className="h-5 min-w-5 justify-center px-1 text-[10px]"
|
|
592
|
+
variant="destructive"
|
|
593
|
+
>
|
|
594
|
+
{invitations.length > 99 ? '99+' : invitations.length}
|
|
595
|
+
</Badge>
|
|
596
|
+
)}
|
|
619
597
|
{hideLeading || (
|
|
620
598
|
<ChevronDown className="ml-1 h-4 w-4 shrink-0 opacity-50" />
|
|
621
599
|
)}
|
|
@@ -626,6 +604,10 @@ export function WorkspaceSelect({
|
|
|
626
604
|
<CommandInput autoFocus placeholder="Search workspace..." />
|
|
627
605
|
<CommandEmpty>No workspace found.</CommandEmpty>
|
|
628
606
|
<CommandList className="max-h-64">
|
|
607
|
+
<WorkspaceInvitationItems
|
|
608
|
+
controller={invitationController}
|
|
609
|
+
fallbackLogoUrl={fallbackLogoUrl}
|
|
610
|
+
/>
|
|
629
611
|
{groups.map((group) => (
|
|
630
612
|
<CommandGroup key={group.label} heading={group.label}>
|
|
631
613
|
{group.teams.map(
|
|
@@ -75,6 +75,7 @@ interface NotificationsPage {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
interface UseNotificationsOptions {
|
|
78
|
+
cacheScope?: string;
|
|
78
79
|
wsId?: string;
|
|
79
80
|
limit?: number;
|
|
80
81
|
offset?: number;
|
|
@@ -210,6 +211,7 @@ export function dedupeNotifications(
|
|
|
210
211
|
* @param wsId - If provided, filters to specific workspace. If omitted, fetches all notifications across all workspaces.
|
|
211
212
|
*/
|
|
212
213
|
export function useNotifications({
|
|
214
|
+
cacheScope,
|
|
213
215
|
wsId,
|
|
214
216
|
limit = 20,
|
|
215
217
|
offset = 0,
|
|
@@ -226,6 +228,7 @@ export function useNotifications({
|
|
|
226
228
|
unreadOnly,
|
|
227
229
|
readOnly,
|
|
228
230
|
type,
|
|
231
|
+
...(cacheScope ? [cacheScope] : []),
|
|
229
232
|
],
|
|
230
233
|
queryFn: async () => {
|
|
231
234
|
const params = new URLSearchParams({
|
|
@@ -263,12 +266,14 @@ export function useNotifications({
|
|
|
263
266
|
* Hook to fetch notifications with infinite scroll support
|
|
264
267
|
*/
|
|
265
268
|
export function useInfiniteNotifications({
|
|
269
|
+
cacheScope,
|
|
266
270
|
wsId,
|
|
267
271
|
unreadOnly = false,
|
|
268
272
|
readOnly = false,
|
|
269
273
|
pageSize = 20,
|
|
270
274
|
enabled = true,
|
|
271
275
|
}: {
|
|
276
|
+
cacheScope?: string;
|
|
272
277
|
wsId?: string;
|
|
273
278
|
unreadOnly?: boolean;
|
|
274
279
|
readOnly?: boolean;
|
|
@@ -282,6 +287,7 @@ export function useInfiniteNotifications({
|
|
|
282
287
|
wsId || 'all',
|
|
283
288
|
unreadOnly,
|
|
284
289
|
readOnly,
|
|
290
|
+
...(cacheScope ? [cacheScope] : []),
|
|
285
291
|
],
|
|
286
292
|
queryFn: async ({ pageParam = 0 }) => {
|
|
287
293
|
const params = new URLSearchParams({
|
|
@@ -317,9 +323,17 @@ export function useInfiniteNotifications({
|
|
|
317
323
|
* Hook to get unread notification count.
|
|
318
324
|
* If wsId is provided, scopes to that workspace. Otherwise returns total unread count.
|
|
319
325
|
*/
|
|
320
|
-
export function useUnreadCount(
|
|
326
|
+
export function useUnreadCount(
|
|
327
|
+
wsId?: string,
|
|
328
|
+
options?: { cacheScope?: string; enabled?: boolean }
|
|
329
|
+
) {
|
|
321
330
|
return useQuery({
|
|
322
|
-
queryKey: [
|
|
331
|
+
queryKey: [
|
|
332
|
+
'notifications',
|
|
333
|
+
'unread-count',
|
|
334
|
+
wsId || 'all',
|
|
335
|
+
...(options?.cacheScope ? [options.cacheScope] : []),
|
|
336
|
+
],
|
|
323
337
|
queryFn: async () => {
|
|
324
338
|
const params = wsId ? `?wsId=${wsId}` : '';
|
|
325
339
|
const response = await fetch(
|