@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuturuuu/ui",
3
- "version": "0.25.0",
3
+ "version": "0.25.2",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -115,14 +115,10 @@ describe('mergeWorkspaceSelectWorkspaces', () => {
115
115
  });
116
116
 
117
117
  it('keeps workspace fallback images outside Radix AvatarImage context', () => {
118
- const workspaceSelectSource = readFileSync(
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
- className="group relative hidden flex-none transition-all md:flex"
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
- const url = `/api/workspaces/${targetWsId}/${
481
- accept ? 'accept-invite' : 'decline-invite'
482
- }`;
483
-
484
- const res = await fetch(url, { method: 'POST' });
485
-
486
- if (res.ok) {
487
- const updateRes = await fetch(
488
- `/api/v1/notifications/${notification.id}/metadata`,
489
- {
490
- method: 'PATCH',
491
- headers: { 'Content-Type': 'application/json' },
492
- body: JSON.stringify({
493
- action_taken: accept ? 'accepted' : 'declined',
494
- action_timestamp: new Date().toISOString(),
495
- }),
496
- }
497
- );
498
-
499
- if (updateRes.ok) {
500
- await Promise.all([
501
- queryClient.invalidateQueries({
502
- queryKey: ['workspaces'],
503
- refetchType: 'active',
504
- }),
505
- queryClient.invalidateQueries({
506
- queryKey: ['notifications'],
507
- refetchType: 'active',
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">Joined</span>
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
- Declined
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
- Decline
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
- Accept
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: ['workspace-select-current-workspace', resolvedWorkspaceId],
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
- const getWorkspaceLandingPath = (nextSlug: string) => {
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 = workspaces.length > 0;
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(wsId?: string, options?: { enabled?: boolean }) {
326
+ export function useUnreadCount(
327
+ wsId?: string,
328
+ options?: { cacheScope?: string; enabled?: boolean }
329
+ ) {
321
330
  return useQuery({
322
- queryKey: ['notifications', 'unread-count', wsId || 'all'],
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(