@tuturuuu/ui 0.30.2 → 0.31.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 +41 -0
- package/biome.json +1 -1
- package/package.json +95 -49
- package/src/components/ui/calendar-app/calendar-client-page.test.tsx +57 -0
- package/src/components/ui/calendar-app/calendar-client-page.tsx +13 -4
- package/src/components/ui/calendar-app/calendar-page-shell.tsx +7 -1
- package/src/components/ui/calendar-app/components/calendar-connections-compact.tsx +47 -39
- package/src/components/ui/calendar-app/components/calendar-connections-settings-content.tsx +14 -0
- package/src/components/ui/calendar-app/components/calendar-connections-unified.tsx +4 -0
- package/src/components/ui/calendar-app/components/calendar-sync-attention-button.tsx +47 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.test.tsx +112 -0
- package/src/components/ui/calendar-app/components/calendar-sync-recovery.tsx +206 -0
- package/src/components/ui/calendar-app/components/calendar-types.ts +1 -1
- package/src/components/ui/calendar-app/components/use-calendar-connections-manager.ts +86 -90
- package/src/components/ui/custom/language-dropdown-item.tsx +6 -14
- package/src/components/ui/custom/nav-link.tsx +7 -7
- package/src/components/ui/custom/nav.tsx +15 -63
- package/src/components/ui/custom/notification-card.tsx +361 -0
- package/src/components/ui/custom/notification-list.tsx +178 -0
- package/src/components/ui/custom/notification-popover-client.tsx +12 -780
- package/src/components/ui/custom/notification-runtime.tsx +23 -0
- package/src/components/ui/custom/onboarding-settings-panel.tsx +8 -55
- package/src/components/ui/custom/satellite-account-switcher-menu.tsx +212 -0
- package/src/components/ui/custom/satellite-brand.tsx +122 -0
- package/src/components/ui/custom/satellite-content.tsx +148 -0
- package/src/components/ui/custom/satellite-footer-actions.tsx +350 -0
- package/src/components/ui/custom/satellite-language-item.tsx +26 -0
- package/src/components/ui/custom/satellite-navigation.tsx +86 -0
- package/src/components/ui/custom/satellite-notification-popover.tsx +272 -0
- package/src/components/ui/custom/satellite-onboarding-settings-panel.tsx +80 -0
- package/src/components/ui/custom/satellite-settings-dialog-shell.tsx +621 -0
- package/src/components/ui/custom/satellite-shell-utils.ts +163 -0
- package/src/components/ui/custom/satellite-shell.test.tsx +169 -0
- package/src/components/ui/custom/satellite-shell.tsx +50 -0
- package/src/components/ui/custom/satellite-theme-dropdown-items.tsx +92 -0
- package/src/components/ui/custom/satellite-user-menu-items.tsx +190 -0
- package/src/components/ui/custom/satellite-user-menu.tsx +93 -0
- package/src/components/ui/custom/satellite-workspace-section.tsx +46 -0
- package/src/components/ui/custom/settings/keyboard-shortcuts-settings.tsx +16 -0
- package/src/components/ui/custom/settings-dialog-shell.tsx +21 -600
- package/src/components/ui/custom/sidebar-context.tsx +5 -14
- package/src/components/ui/custom/sidebar-footer-actions.tsx +19 -322
- package/src/components/ui/custom/structure.tsx +14 -0
- package/src/components/ui/custom/system-language-dropdown-item.tsx +7 -14
- package/src/components/ui/custom/theme-dropdown-items.tsx +2 -85
- package/src/components/ui/custom/use-satellite-shell.tsx +165 -0
- package/src/components/ui/legacy/calendar/agenda-view.tsx +139 -47
- package/src/components/ui/legacy/calendar/calendar-content.test.tsx +116 -0
- package/src/components/ui/legacy/calendar/calendar-content.tsx +38 -74
- package/src/components/ui/legacy/calendar/calendar-event-tone.ts +20 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.test.ts +27 -0
- package/src/components/ui/legacy/calendar/calendar-meeting-link.ts +34 -0
- package/src/components/ui/legacy/calendar/calendar-period.ts +28 -0
- package/src/components/ui/legacy/calendar/calendar-shortcuts.test.tsx +107 -0
- package/src/components/ui/legacy/calendar/calendar-views.test.tsx +169 -0
- package/src/components/ui/legacy/calendar/event-modal-header.tsx +52 -0
- package/src/components/ui/legacy/calendar/event-modal.tsx +107 -156
- package/src/components/ui/legacy/calendar/month-calendar.tsx +242 -933
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.test.ts +25 -0
- package/src/components/ui/legacy/calendar/save-calendar-event-drafts.ts +32 -0
- package/src/components/ui/legacy/calendar/smart-calendar.tsx +3 -0
- package/src/components/ui/legacy/calendar/use-calendar-view-shortcuts.ts +54 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.test.tsx +59 -0
- package/src/components/ui/legacy/calendar/use-event-draft-session.ts +20 -0
- package/src/components/ui/legacy/calendar/year-calendar.tsx +34 -16
- package/src/components/ui/pwa-status.test.tsx +53 -0
- package/src/components/ui/pwa-status.tsx +113 -0
- package/src/components/ui/report-problem-dialog-content.tsx +350 -0
- package/src/components/ui/report-problem-dialog.tsx +6 -975
- package/src/components/ui/report-problem-model.ts +133 -0
- package/src/components/ui/report-problem-mutation.ts +122 -0
- package/src/components/ui/save-notification.test.tsx +36 -0
- package/src/components/ui/save-notification.tsx +17 -0
- package/src/components/ui/use-report-problem.ts +488 -0
- package/src/declarations.d.ts +2 -0
- package/src/hooks/__tests__/use-calendar-readonly.test.tsx +46 -4
- package/src/hooks/__tests__/use-calendar-sync.test.tsx +24 -0
- package/src/hooks/calendar-date-utils.ts +11 -0
- package/src/hooks/calendar-event-lookup.test.ts +77 -0
- package/src/hooks/calendar-event-lookup.ts +47 -0
- package/src/hooks/calendar-range-cache.ts +52 -0
- package/src/hooks/pwa-install-prompt.ts +44 -0
- package/src/hooks/use-calendar-sync.tsx +23 -100
- package/src/hooks/use-calendar.tsx +21 -77
- package/src/hooks/use-notifications.ts +9 -24
- package/src/hooks/use-open-initial-calendar-event.ts +27 -0
- package/src/hooks/use-settings-dialog-shortcut.test.tsx +41 -0
- package/src/hooks/use-settings-dialog-shortcut.ts +12 -14
- package/src/hooks/use-workspace-presence.ts +0 -18
- package/src/lib/tasks-app-url.test.ts +25 -0
- package/src/lib/tasks-app-url.ts +8 -0
|
@@ -1,789 +1,21 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
|
|
3
|
-
import { useQueryClient } from '@tanstack/react-query';
|
|
4
|
-
import {
|
|
5
|
-
Archive,
|
|
6
|
-
AtSign,
|
|
7
|
-
Bell,
|
|
8
|
-
Calendar,
|
|
9
|
-
Check,
|
|
10
|
-
CheckCircle2,
|
|
11
|
-
ChevronRight,
|
|
12
|
-
ClipboardList,
|
|
13
|
-
Edit3,
|
|
14
|
-
Eye,
|
|
15
|
-
EyeOff,
|
|
16
|
-
Inbox,
|
|
17
|
-
Loader2,
|
|
18
|
-
Mail,
|
|
19
|
-
RotateCcw,
|
|
20
|
-
Shield,
|
|
21
|
-
UserPlus,
|
|
22
|
-
X,
|
|
23
|
-
XCircle,
|
|
24
|
-
} from '@tuturuuu/icons';
|
|
25
|
-
import { updateNotificationMetadata } from '@tuturuuu/internal-api';
|
|
26
|
-
import {
|
|
27
|
-
acceptWorkspaceInvite,
|
|
28
|
-
declineWorkspaceInvite,
|
|
29
|
-
} from '@tuturuuu/internal-api/workspaces';
|
|
30
|
-
import { Button } from '@tuturuuu/ui/button';
|
|
31
|
-
import {
|
|
32
|
-
dedupeNotifications,
|
|
33
|
-
type Notification,
|
|
34
|
-
useInfiniteNotifications,
|
|
35
|
-
useMarkAllAsRead,
|
|
36
|
-
useNotificationSubscription,
|
|
37
|
-
useUnreadCount,
|
|
38
|
-
useUpdateNotification,
|
|
39
|
-
} from '@tuturuuu/ui/hooks/use-notifications';
|
|
40
|
-
import {
|
|
41
|
-
dispatchRequestOpenTask,
|
|
42
|
-
waitForTaskOpenResult,
|
|
43
|
-
} from '@tuturuuu/ui/lib/task-open-events';
|
|
44
|
-
import { Popover, PopoverContent } from '@tuturuuu/ui/popover';
|
|
45
|
-
import { toast } from '@tuturuuu/ui/sonner';
|
|
46
|
-
import { cn } from '@tuturuuu/utils/format';
|
|
47
|
-
import dayjs from 'dayjs';
|
|
48
|
-
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
49
2
|
import Link from 'next/link';
|
|
50
3
|
import { useParams, usePathname, useRouter } from 'next/navigation';
|
|
51
|
-
import {
|
|
52
|
-
import {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
interface NotificationPopoverClientProps {
|
|
59
|
-
userId?: string;
|
|
60
|
-
noNotificationsText: string;
|
|
61
|
-
notificationsText: string;
|
|
62
|
-
viewAllText: string;
|
|
63
|
-
markAsReadText: string;
|
|
64
|
-
markAsUnreadText: string;
|
|
65
|
-
inboxText?: string;
|
|
66
|
-
archiveText?: string;
|
|
67
|
-
archiveAllText?: string;
|
|
68
|
-
emptyArchiveText?: string;
|
|
69
|
-
loadingMoreText?: string;
|
|
70
|
-
retryText?: string;
|
|
71
|
-
acceptText?: string;
|
|
72
|
-
declineText?: string;
|
|
73
|
-
acceptedText?: string;
|
|
74
|
-
declinedText?: string;
|
|
75
|
-
/** Base URL for external redirect (e.g. 'https://tuturuuu.com'). When set, "View All" links to {webAppUrl}/{wsId}/notifications. */
|
|
76
|
-
webAppUrl?: string;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// Workspace identifier validation regex
|
|
80
|
-
const UUID_REGEX =
|
|
81
|
-
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/;
|
|
82
|
-
const SPECIAL_WORKSPACE_SLUGS = new Set(['personal', 'internal']);
|
|
83
|
-
|
|
84
|
-
function isValidWorkspaceIdentifier(value: unknown): value is string {
|
|
85
|
-
return (
|
|
86
|
-
typeof value === 'string' &&
|
|
87
|
-
(UUID_REGEX.test(value) || SPECIAL_WORKSPACE_SLUGS.has(value))
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function isValidWorkspaceFilterId(value: unknown): value is string {
|
|
92
|
-
return typeof value === 'string' && UUID_REGEX.test(value);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export default function NotificationPopoverClient({
|
|
96
|
-
userId,
|
|
97
|
-
noNotificationsText,
|
|
98
|
-
notificationsText,
|
|
99
|
-
viewAllText,
|
|
100
|
-
markAsReadText,
|
|
101
|
-
markAsUnreadText,
|
|
102
|
-
inboxText = 'Inbox',
|
|
103
|
-
archiveText = 'Archive',
|
|
104
|
-
archiveAllText = 'Archive all',
|
|
105
|
-
emptyArchiveText = 'No archived notifications yet.',
|
|
106
|
-
loadingMoreText = 'Loading more...',
|
|
107
|
-
retryText = 'Retry',
|
|
108
|
-
acceptText = 'Accept',
|
|
109
|
-
declineText = 'Decline',
|
|
110
|
-
acceptedText = 'Joined',
|
|
111
|
-
declinedText = 'Declined',
|
|
112
|
-
webAppUrl,
|
|
113
|
-
}: NotificationPopoverClientProps) {
|
|
114
|
-
const [open, setOpen] = useState(false);
|
|
115
|
-
const [activeTab, setActiveTab] = useState<TabType>('inbox');
|
|
116
|
-
const params = useParams();
|
|
117
|
-
const queryClient = useQueryClient();
|
|
118
|
-
|
|
119
|
-
const workspaceIdParam =
|
|
120
|
-
typeof params.wsId === 'string' ? params.wsId : undefined;
|
|
121
|
-
const wsIdForFiltering = isValidWorkspaceFilterId(workspaceIdParam)
|
|
122
|
-
? workspaceIdParam
|
|
123
|
-
: undefined;
|
|
124
|
-
const wsIdForTaskContext = isValidWorkspaceIdentifier(workspaceIdParam)
|
|
125
|
-
? workspaceIdParam
|
|
126
|
-
: undefined;
|
|
127
|
-
|
|
128
|
-
// Accurate unread count from dedicated endpoint
|
|
129
|
-
const { data: unreadCount = 0 } = useUnreadCount(wsIdForFiltering, {
|
|
130
|
-
cacheScope: userId,
|
|
131
|
-
enabled: Boolean(userId),
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
// Infinite scroll for inbox (unread) and archive (read)
|
|
135
|
-
const inboxQuery = useInfiniteNotifications({
|
|
136
|
-
cacheScope: userId,
|
|
137
|
-
wsId: wsIdForFiltering,
|
|
138
|
-
unreadOnly: true,
|
|
139
|
-
pageSize: 15,
|
|
140
|
-
enabled: Boolean(userId) && open && activeTab === 'inbox',
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
const archiveQuery = useInfiniteNotifications({
|
|
144
|
-
cacheScope: userId,
|
|
145
|
-
wsId: wsIdForFiltering,
|
|
146
|
-
readOnly: true,
|
|
147
|
-
pageSize: 15,
|
|
148
|
-
enabled: Boolean(userId) && open && activeTab === 'archive',
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
const markAllAsRead = useMarkAllAsRead();
|
|
152
|
-
const updateNotification = useUpdateNotification();
|
|
153
|
-
|
|
154
|
-
// Subscribe to realtime updates
|
|
155
|
-
useNotificationSubscription(wsIdForFiltering || '', userId || '');
|
|
156
|
-
|
|
157
|
-
const activeQuery = activeTab === 'inbox' ? inboxQuery : archiveQuery;
|
|
158
|
-
const allNotifications = dedupeNotifications(
|
|
159
|
-
activeQuery.data?.pages.flatMap((p) => p.notifications) ?? []
|
|
160
|
-
);
|
|
161
|
-
const hasNotifications = allNotifications.length > 0;
|
|
162
|
-
|
|
163
|
-
// When webAppUrl is set, link to the external web app's notifications page
|
|
164
|
-
const baseUrl = webAppUrl ?? '';
|
|
165
|
-
const notificationsPageUrl = params.wsId
|
|
166
|
-
? `${baseUrl}/${params.wsId}/notifications`
|
|
167
|
-
: `${baseUrl}/notifications`;
|
|
168
|
-
|
|
169
|
-
const handleMarkAsRead = async (id: string, isUnread: boolean) => {
|
|
170
|
-
try {
|
|
171
|
-
await updateNotification.mutateAsync({ id, read: isUnread });
|
|
172
|
-
} catch (error) {
|
|
173
|
-
console.error('Failed to update notification:', error);
|
|
174
|
-
toast.error('Failed to update notification');
|
|
175
|
-
}
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
const handleArchiveAll = async () => {
|
|
179
|
-
try {
|
|
180
|
-
await markAllAsRead.mutateAsync(wsIdForFiltering);
|
|
181
|
-
toast.success('All notifications archived');
|
|
182
|
-
} catch (error) {
|
|
183
|
-
console.error('Failed to archive all:', error);
|
|
184
|
-
toast.error('Failed to archive notifications');
|
|
185
|
-
}
|
|
186
|
-
};
|
|
187
|
-
|
|
188
|
-
if (!userId) {
|
|
189
|
-
return null;
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
return (
|
|
193
|
-
<Popover open={open} onOpenChange={setOpen}>
|
|
194
|
-
<NotificationPopoverTriggerButton
|
|
195
|
-
notificationsText={notificationsText}
|
|
196
|
-
unreadCount={unreadCount}
|
|
197
|
-
/>
|
|
198
|
-
<PopoverContent
|
|
199
|
-
className="w-100 overflow-hidden rounded-xl border p-0 shadow-xl"
|
|
200
|
-
align="end"
|
|
201
|
-
>
|
|
202
|
-
{/* Header */}
|
|
203
|
-
<div className="flex items-center justify-between border-b bg-background px-4 py-3">
|
|
204
|
-
<h3 className="font-semibold text-base">{notificationsText}</h3>
|
|
205
|
-
<div className="flex items-center gap-2">
|
|
206
|
-
{unreadCount > 0 && (
|
|
207
|
-
<Button
|
|
208
|
-
variant="ghost"
|
|
209
|
-
size="sm"
|
|
210
|
-
className="h-7 gap-1 text-foreground/60 text-xs hover:text-foreground"
|
|
211
|
-
onClick={handleArchiveAll}
|
|
212
|
-
disabled={markAllAsRead.isPending}
|
|
213
|
-
>
|
|
214
|
-
{markAllAsRead.isPending ? (
|
|
215
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
216
|
-
) : (
|
|
217
|
-
<Archive className="h-3 w-3" />
|
|
218
|
-
)}
|
|
219
|
-
{archiveAllText}
|
|
220
|
-
</Button>
|
|
221
|
-
)}
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
|
|
225
|
-
{/* Tabs */}
|
|
226
|
-
<div className="flex border-b bg-background">
|
|
227
|
-
<button
|
|
228
|
-
type="button"
|
|
229
|
-
onClick={() => setActiveTab('inbox')}
|
|
230
|
-
className={cn(
|
|
231
|
-
'flex flex-1 items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors',
|
|
232
|
-
activeTab === 'inbox'
|
|
233
|
-
? 'border-primary border-b-2 text-primary'
|
|
234
|
-
: 'text-foreground/50 hover:text-foreground/80'
|
|
235
|
-
)}
|
|
236
|
-
>
|
|
237
|
-
<Inbox className="h-3.5 w-3.5" />
|
|
238
|
-
{inboxText}
|
|
239
|
-
{unreadCount > 0 && (
|
|
240
|
-
<span className="rounded-full bg-dynamic-red/10 px-1.5 py-0.5 font-semibold text-[10px] text-dynamic-red leading-none">
|
|
241
|
-
{unreadCount > 99 ? '99+' : unreadCount}
|
|
242
|
-
</span>
|
|
243
|
-
)}
|
|
244
|
-
</button>
|
|
245
|
-
<button
|
|
246
|
-
type="button"
|
|
247
|
-
onClick={() => setActiveTab('archive')}
|
|
248
|
-
className={cn(
|
|
249
|
-
'flex flex-1 items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors',
|
|
250
|
-
activeTab === 'archive'
|
|
251
|
-
? 'border-primary border-b-2 text-primary'
|
|
252
|
-
: 'text-foreground/50 hover:text-foreground/80'
|
|
253
|
-
)}
|
|
254
|
-
>
|
|
255
|
-
<Archive className="h-3.5 w-3.5" />
|
|
256
|
-
{archiveText}
|
|
257
|
-
</button>
|
|
258
|
-
</div>
|
|
259
|
-
|
|
260
|
-
{/* Notification List with Infinite Scroll — key forces remount on tab switch to reset scroll */}
|
|
261
|
-
<NotificationList
|
|
262
|
-
key={activeTab}
|
|
263
|
-
query={activeQuery}
|
|
264
|
-
notifications={allNotifications}
|
|
265
|
-
hasNotifications={hasNotifications}
|
|
266
|
-
activeTab={activeTab}
|
|
267
|
-
wsId={wsIdForTaskContext}
|
|
268
|
-
noNotificationsText={noNotificationsText}
|
|
269
|
-
emptyArchiveText={emptyArchiveText}
|
|
270
|
-
loadingMoreText={loadingMoreText}
|
|
271
|
-
retryText={retryText}
|
|
272
|
-
acceptText={acceptText}
|
|
273
|
-
declineText={declineText}
|
|
274
|
-
acceptedText={acceptedText}
|
|
275
|
-
declinedText={declinedText}
|
|
276
|
-
markAsReadText={markAsReadText}
|
|
277
|
-
markAsUnreadText={markAsUnreadText}
|
|
278
|
-
onMarkAsRead={handleMarkAsRead}
|
|
279
|
-
queryClient={queryClient}
|
|
280
|
-
onActionComplete={() => setOpen(false)}
|
|
281
|
-
/>
|
|
282
|
-
|
|
283
|
-
{/* Footer with View All */}
|
|
284
|
-
<div className="border-t bg-background">
|
|
285
|
-
<Link
|
|
286
|
-
href={notificationsPageUrl}
|
|
287
|
-
onClick={() => setOpen(false)}
|
|
288
|
-
className="flex items-center justify-center gap-1.5 px-4 py-2.5 font-medium text-sm transition-colors hover:rounded-b-xl hover:bg-foreground/5"
|
|
289
|
-
>
|
|
290
|
-
{viewAllText}
|
|
291
|
-
<ChevronRight className="h-4 w-4" />
|
|
292
|
-
</Link>
|
|
293
|
-
</div>
|
|
294
|
-
</PopoverContent>
|
|
295
|
-
</Popover>
|
|
296
|
-
);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
function NotificationList({
|
|
300
|
-
query,
|
|
301
|
-
notifications,
|
|
302
|
-
hasNotifications,
|
|
303
|
-
activeTab,
|
|
304
|
-
wsId,
|
|
305
|
-
noNotificationsText,
|
|
306
|
-
emptyArchiveText,
|
|
307
|
-
loadingMoreText,
|
|
308
|
-
retryText,
|
|
309
|
-
acceptText,
|
|
310
|
-
declineText,
|
|
311
|
-
acceptedText,
|
|
312
|
-
declinedText,
|
|
313
|
-
markAsReadText,
|
|
314
|
-
markAsUnreadText,
|
|
315
|
-
onMarkAsRead,
|
|
316
|
-
queryClient,
|
|
317
|
-
onActionComplete,
|
|
318
|
-
}: {
|
|
319
|
-
query: ReturnType<typeof useInfiniteNotifications>;
|
|
320
|
-
notifications: Notification[];
|
|
321
|
-
hasNotifications: boolean;
|
|
322
|
-
activeTab: TabType;
|
|
323
|
-
wsId?: string;
|
|
324
|
-
noNotificationsText: string;
|
|
325
|
-
emptyArchiveText: string;
|
|
326
|
-
loadingMoreText: string;
|
|
327
|
-
retryText: string;
|
|
328
|
-
acceptText: string;
|
|
329
|
-
declineText: string;
|
|
330
|
-
acceptedText: string;
|
|
331
|
-
declinedText: string;
|
|
332
|
-
markAsReadText: string;
|
|
333
|
-
markAsUnreadText: string;
|
|
334
|
-
onMarkAsRead: (id: string, isUnread: boolean) => void;
|
|
335
|
-
queryClient: any;
|
|
336
|
-
onActionComplete: () => void;
|
|
337
|
-
}) {
|
|
338
|
-
const scrollRef = useRef<HTMLDivElement>(null);
|
|
339
|
-
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
340
|
-
|
|
341
|
-
// Infinite scroll via IntersectionObserver
|
|
342
|
-
const handleIntersect = useCallback(
|
|
343
|
-
(entries: IntersectionObserverEntry[]) => {
|
|
344
|
-
const entry = entries[0];
|
|
345
|
-
if (
|
|
346
|
-
entry?.isIntersecting &&
|
|
347
|
-
query.hasNextPage &&
|
|
348
|
-
!query.isFetchingNextPage
|
|
349
|
-
) {
|
|
350
|
-
query.fetchNextPage();
|
|
351
|
-
}
|
|
352
|
-
},
|
|
353
|
-
[query]
|
|
354
|
-
);
|
|
355
|
-
|
|
356
|
-
useEffect(() => {
|
|
357
|
-
const sentinel = sentinelRef.current;
|
|
358
|
-
if (!sentinel) return;
|
|
359
|
-
|
|
360
|
-
const observer = new IntersectionObserver(handleIntersect, {
|
|
361
|
-
root: scrollRef.current,
|
|
362
|
-
rootMargin: '100px',
|
|
363
|
-
});
|
|
364
|
-
|
|
365
|
-
observer.observe(sentinel);
|
|
366
|
-
return () => observer.disconnect();
|
|
367
|
-
}, [handleIntersect]);
|
|
368
|
-
|
|
369
|
-
const emptyText =
|
|
370
|
-
activeTab === 'inbox' ? noNotificationsText : emptyArchiveText;
|
|
371
|
-
|
|
372
|
-
if (query.error) {
|
|
373
|
-
return (
|
|
374
|
-
<div className="flex h-90 flex-col items-center justify-center text-center">
|
|
375
|
-
<Bell className="mb-3 h-10 w-10 text-dynamic-red/30" />
|
|
376
|
-
<p className="text-foreground/60 text-sm">
|
|
377
|
-
Failed to load notifications
|
|
378
|
-
</p>
|
|
379
|
-
<p className="mt-1 text-foreground/40 text-xs">
|
|
380
|
-
{query.error instanceof Error ? query.error.message : 'Unknown error'}
|
|
381
|
-
</p>
|
|
382
|
-
<Button
|
|
383
|
-
className="mt-3"
|
|
384
|
-
disabled={query.isFetching}
|
|
385
|
-
onClick={() => query.refetch()}
|
|
386
|
-
size="sm"
|
|
387
|
-
type="button"
|
|
388
|
-
variant="outline"
|
|
389
|
-
>
|
|
390
|
-
{query.isFetching ? (
|
|
391
|
-
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
|
392
|
-
) : (
|
|
393
|
-
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
|
394
|
-
)}
|
|
395
|
-
{retryText}
|
|
396
|
-
</Button>
|
|
397
|
-
</div>
|
|
398
|
-
);
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
if (query.isLoading) {
|
|
402
|
-
return (
|
|
403
|
-
<div className="space-y-2 p-2">
|
|
404
|
-
{[...Array(3)].map((_, i) => (
|
|
405
|
-
<div key={i} className="rounded-xl border bg-foreground/2 p-3">
|
|
406
|
-
<div className="flex items-start gap-3">
|
|
407
|
-
<div className="h-8 w-8 flex-none animate-pulse rounded-full bg-foreground/10" />
|
|
408
|
-
<div className="min-w-0 flex-1 space-y-2">
|
|
409
|
-
<div className="h-3 w-24 animate-pulse rounded bg-foreground/10" />
|
|
410
|
-
<div className="h-4 w-full animate-pulse rounded bg-foreground/10" />
|
|
411
|
-
<div className="h-3 w-3/4 animate-pulse rounded bg-foreground/10" />
|
|
412
|
-
</div>
|
|
413
|
-
</div>
|
|
414
|
-
</div>
|
|
415
|
-
))}
|
|
416
|
-
</div>
|
|
417
|
-
);
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (!hasNotifications) {
|
|
421
|
-
return (
|
|
422
|
-
<div className="flex h-90 flex-col items-center justify-center text-center">
|
|
423
|
-
{activeTab === 'inbox' ? (
|
|
424
|
-
<Inbox className="mb-3 h-10 w-10 text-foreground/20" />
|
|
425
|
-
) : (
|
|
426
|
-
<Archive className="mb-3 h-10 w-10 text-foreground/20" />
|
|
427
|
-
)}
|
|
428
|
-
<p className="text-foreground/60 text-sm">{emptyText}</p>
|
|
429
|
-
</div>
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
return (
|
|
434
|
-
<div ref={scrollRef} className="max-h-90 overflow-y-auto">
|
|
435
|
-
<div className="space-y-1.5 p-2">
|
|
436
|
-
{notifications.map((notification) => (
|
|
437
|
-
<NotificationCard
|
|
438
|
-
key={notification.id}
|
|
439
|
-
notification={notification}
|
|
440
|
-
wsId={wsId}
|
|
441
|
-
onMarkAsRead={onMarkAsRead}
|
|
442
|
-
markAsReadText={markAsReadText}
|
|
443
|
-
markAsUnreadText={markAsUnreadText}
|
|
444
|
-
queryClient={queryClient}
|
|
445
|
-
onActionComplete={onActionComplete}
|
|
446
|
-
acceptText={acceptText}
|
|
447
|
-
declineText={declineText}
|
|
448
|
-
acceptedText={acceptedText}
|
|
449
|
-
declinedText={declinedText}
|
|
450
|
-
/>
|
|
451
|
-
))}
|
|
452
|
-
|
|
453
|
-
{/* Sentinel for infinite scroll */}
|
|
454
|
-
<div ref={sentinelRef} className="h-1" />
|
|
455
|
-
|
|
456
|
-
{query.isFetchingNextPage && (
|
|
457
|
-
<div className="flex items-center justify-center gap-2 py-3 text-foreground/40 text-xs">
|
|
458
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
459
|
-
{loadingMoreText}
|
|
460
|
-
</div>
|
|
461
|
-
)}
|
|
462
|
-
</div>
|
|
463
|
-
</div>
|
|
464
|
-
);
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
interface NotificationCardProps {
|
|
468
|
-
notification: Notification;
|
|
469
|
-
wsId?: string;
|
|
470
|
-
onMarkAsRead: (id: string, isUnread: boolean) => void;
|
|
471
|
-
markAsReadText: string;
|
|
472
|
-
markAsUnreadText: string;
|
|
473
|
-
queryClient: any;
|
|
474
|
-
onActionComplete?: () => void;
|
|
475
|
-
acceptText: string;
|
|
476
|
-
declineText: string;
|
|
477
|
-
acceptedText: string;
|
|
478
|
-
declinedText: string;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function getWorkspaceInviteWorkspaceId(notification: Notification) {
|
|
482
|
-
const candidates = [
|
|
483
|
-
notification.data?.workspace_id,
|
|
484
|
-
notification.entity_id,
|
|
485
|
-
notification.ws_id,
|
|
486
|
-
];
|
|
487
|
-
|
|
488
|
-
return (
|
|
489
|
-
candidates.find(
|
|
490
|
-
(candidate): candidate is string =>
|
|
491
|
-
typeof candidate === 'string' && candidate.length > 0
|
|
492
|
-
) ?? null
|
|
493
|
-
);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
function NotificationCard({
|
|
497
|
-
notification,
|
|
498
|
-
wsId,
|
|
499
|
-
onMarkAsRead,
|
|
500
|
-
markAsReadText,
|
|
501
|
-
markAsUnreadText,
|
|
502
|
-
queryClient,
|
|
503
|
-
onActionComplete,
|
|
504
|
-
acceptText,
|
|
505
|
-
declineText,
|
|
506
|
-
acceptedText,
|
|
507
|
-
declinedText,
|
|
508
|
-
}: NotificationCardProps) {
|
|
509
|
-
const isUnread = !notification.read_at;
|
|
510
|
-
const [processingAction, setProcessingAction] = useState<string | null>(null);
|
|
511
|
-
const router = useRouter();
|
|
4
|
+
import { NotificationRuntime } from './notification-runtime';
|
|
5
|
+
import SatelliteNotificationPopover, {
|
|
6
|
+
type NotificationPopoverClientProps,
|
|
7
|
+
} from './satellite-notification-popover';
|
|
8
|
+
export default function NotificationPopoverClient(
|
|
9
|
+
props: NotificationPopoverClientProps
|
|
10
|
+
) {
|
|
512
11
|
const params = useParams();
|
|
513
12
|
const pathname = usePathname();
|
|
514
|
-
|
|
515
|
-
const handleAction = async (actionType: string, payload: any) => {
|
|
516
|
-
setProcessingAction(actionType);
|
|
517
|
-
|
|
518
|
-
try {
|
|
519
|
-
switch (actionType) {
|
|
520
|
-
case 'WORKSPACE_INVITE_ACCEPT':
|
|
521
|
-
case 'WORKSPACE_INVITE_DECLINE': {
|
|
522
|
-
const accept = actionType === 'WORKSPACE_INVITE_ACCEPT';
|
|
523
|
-
const targetWsId = payload.wsId;
|
|
524
|
-
if (!targetWsId) {
|
|
525
|
-
toast.error('Failed to process invite');
|
|
526
|
-
break;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
await (accept
|
|
530
|
-
? acceptWorkspaceInvite(targetWsId)
|
|
531
|
-
: declineWorkspaceInvite(targetWsId));
|
|
532
|
-
await updateNotificationMetadata(notification.id, {
|
|
533
|
-
action_taken: accept ? 'accepted' : 'declined',
|
|
534
|
-
action_timestamp: new Date().toISOString(),
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
await Promise.all([
|
|
538
|
-
queryClient.invalidateQueries({
|
|
539
|
-
queryKey: ['workspaces'],
|
|
540
|
-
refetchType: 'active',
|
|
541
|
-
}),
|
|
542
|
-
queryClient.invalidateQueries({
|
|
543
|
-
queryKey: ['notifications'],
|
|
544
|
-
refetchType: 'active',
|
|
545
|
-
}),
|
|
546
|
-
queryClient.refetchQueries({
|
|
547
|
-
queryKey: ['notifications'],
|
|
548
|
-
type: 'active',
|
|
549
|
-
}),
|
|
550
|
-
]);
|
|
551
|
-
|
|
552
|
-
toast.success(accept ? acceptedText : declinedText);
|
|
553
|
-
|
|
554
|
-
onMarkAsRead(notification.id, true);
|
|
555
|
-
router.refresh();
|
|
556
|
-
onActionComplete?.();
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
|
-
default:
|
|
560
|
-
break;
|
|
561
|
-
}
|
|
562
|
-
} catch (error) {
|
|
563
|
-
console.error('Action error:', error);
|
|
564
|
-
toast.error('An error occurred');
|
|
565
|
-
} finally {
|
|
566
|
-
setProcessingAction(null);
|
|
567
|
-
}
|
|
568
|
-
};
|
|
569
|
-
|
|
570
|
-
const notificationWsId = notification.ws_id || wsId;
|
|
571
|
-
const isTaskEntityNotification =
|
|
572
|
-
notification.entity_type === 'task' && !!notification.entity_id;
|
|
573
|
-
const entityLink =
|
|
574
|
-
notification.entity_type === 'time_tracking_request' &&
|
|
575
|
-
notification.entity_id &&
|
|
576
|
-
notificationWsId
|
|
577
|
-
? `/${notificationWsId}/time-tracker/requests`
|
|
578
|
-
: null;
|
|
579
|
-
|
|
13
|
+
const router = useRouter();
|
|
580
14
|
return (
|
|
581
|
-
<
|
|
582
|
-
|
|
583
|
-
'group relative rounded-xl border p-3 transition-all hover:border-foreground/20 hover:shadow-sm',
|
|
584
|
-
isUnread
|
|
585
|
-
? 'border-dynamic-blue/40 bg-dynamic-blue/5'
|
|
586
|
-
: 'bg-foreground/2 hover:bg-foreground/4'
|
|
587
|
-
)}
|
|
15
|
+
<NotificationRuntime
|
|
16
|
+
value={{ params, pathname, router, Link, realtime: true }}
|
|
588
17
|
>
|
|
589
|
-
<
|
|
590
|
-
|
|
591
|
-
<div
|
|
592
|
-
className={cn(
|
|
593
|
-
'flex h-8 w-8 flex-none items-center justify-center rounded-full transition-colors',
|
|
594
|
-
isUnread
|
|
595
|
-
? 'bg-dynamic-blue/20 text-dynamic-blue'
|
|
596
|
-
: 'bg-foreground/10 text-foreground/60'
|
|
597
|
-
)}
|
|
598
|
-
>
|
|
599
|
-
{getNotificationIcon(notification.type)}
|
|
600
|
-
</div>
|
|
601
|
-
|
|
602
|
-
{/* Content */}
|
|
603
|
-
<div className="min-w-0 flex-1">
|
|
604
|
-
<div className="mb-1 font-medium text-sm leading-snug">
|
|
605
|
-
{notification.title}
|
|
606
|
-
</div>
|
|
607
|
-
|
|
608
|
-
{notification.description && (
|
|
609
|
-
<div className="mb-1.5 line-clamp-2 text-foreground/70 text-xs leading-relaxed">
|
|
610
|
-
{notification.description}
|
|
611
|
-
</div>
|
|
612
|
-
)}
|
|
613
|
-
|
|
614
|
-
<div className="mb-2 text-foreground/40 text-xs">
|
|
615
|
-
{dayjs(notification.created_at).fromNow()}
|
|
616
|
-
</div>
|
|
617
|
-
|
|
618
|
-
{/* Action buttons or status */}
|
|
619
|
-
{notification.type === 'workspace_invite' &&
|
|
620
|
-
notification.data?.action_taken ? (
|
|
621
|
-
<div className="mt-2 inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
|
|
622
|
-
{notification.data.action_taken === 'accepted' ? (
|
|
623
|
-
<>
|
|
624
|
-
<CheckCircle2 className="h-3 w-3 text-dynamic-green" />
|
|
625
|
-
<span className="font-medium text-dynamic-green">
|
|
626
|
-
{acceptedText}
|
|
627
|
-
</span>
|
|
628
|
-
</>
|
|
629
|
-
) : (
|
|
630
|
-
<>
|
|
631
|
-
<XCircle className="h-3 w-3 text-foreground/40" />
|
|
632
|
-
<span className="font-medium text-foreground/60">
|
|
633
|
-
{declinedText}
|
|
634
|
-
</span>
|
|
635
|
-
</>
|
|
636
|
-
)}
|
|
637
|
-
</div>
|
|
638
|
-
) : notification.type === 'workspace_invite' &&
|
|
639
|
-
!notification.data?.action_taken ? (
|
|
640
|
-
<div className="mt-2 flex items-center gap-1.5">
|
|
641
|
-
<Button
|
|
642
|
-
variant="outline"
|
|
643
|
-
size="sm"
|
|
644
|
-
onClick={() =>
|
|
645
|
-
handleAction('WORKSPACE_INVITE_DECLINE', {
|
|
646
|
-
wsId: getWorkspaceInviteWorkspaceId(notification),
|
|
647
|
-
})
|
|
648
|
-
}
|
|
649
|
-
disabled={!!processingAction}
|
|
650
|
-
className="h-7 gap-1 text-xs"
|
|
651
|
-
>
|
|
652
|
-
{processingAction === 'WORKSPACE_INVITE_DECLINE' ? (
|
|
653
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
654
|
-
) : (
|
|
655
|
-
<X className="h-3 w-3" />
|
|
656
|
-
)}
|
|
657
|
-
{declineText}
|
|
658
|
-
</Button>
|
|
659
|
-
<Button
|
|
660
|
-
size="sm"
|
|
661
|
-
onClick={() =>
|
|
662
|
-
handleAction('WORKSPACE_INVITE_ACCEPT', {
|
|
663
|
-
wsId: getWorkspaceInviteWorkspaceId(notification),
|
|
664
|
-
})
|
|
665
|
-
}
|
|
666
|
-
disabled={!!processingAction}
|
|
667
|
-
className="h-7 gap-1 text-xs"
|
|
668
|
-
>
|
|
669
|
-
{processingAction === 'WORKSPACE_INVITE_ACCEPT' ? (
|
|
670
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
671
|
-
) : (
|
|
672
|
-
<Check className="h-3 w-3" />
|
|
673
|
-
)}
|
|
674
|
-
{acceptText}
|
|
675
|
-
</Button>
|
|
676
|
-
</div>
|
|
677
|
-
) : isTaskEntityNotification ? (
|
|
678
|
-
<Button
|
|
679
|
-
variant="link"
|
|
680
|
-
size="sm"
|
|
681
|
-
className="h-auto p-0 text-xs hover:text-dynamic-blue"
|
|
682
|
-
disabled={!!processingAction}
|
|
683
|
-
onClick={async () => {
|
|
684
|
-
if (!notification.entity_id) return;
|
|
685
|
-
setProcessingAction('OPEN_TASK');
|
|
686
|
-
try {
|
|
687
|
-
const { handled, requestId } = dispatchRequestOpenTask({
|
|
688
|
-
taskId: notification.entity_id,
|
|
689
|
-
wsId: notificationWsId,
|
|
690
|
-
});
|
|
691
|
-
|
|
692
|
-
const opened = handled
|
|
693
|
-
? await waitForTaskOpenResult(requestId, 6000)
|
|
694
|
-
: false;
|
|
695
|
-
|
|
696
|
-
if (!opened && notificationWsId) {
|
|
697
|
-
const locale =
|
|
698
|
-
typeof params?.locale === 'string' ? params.locale : null;
|
|
699
|
-
const targetWorkspacePath = locale
|
|
700
|
-
? `/${locale}/${notificationWsId}`
|
|
701
|
-
: `/${notificationWsId}`;
|
|
702
|
-
const isAlreadyInTargetWorkspace =
|
|
703
|
-
pathname === targetWorkspacePath ||
|
|
704
|
-
pathname.startsWith(`${targetWorkspacePath}/`);
|
|
705
|
-
|
|
706
|
-
const openTaskParam = `openTaskId=${encodeURIComponent(notification.entity_id)}`;
|
|
707
|
-
const fallbackUrl = isAlreadyInTargetWorkspace
|
|
708
|
-
? `${pathname}?${openTaskParam}`
|
|
709
|
-
: `${targetWorkspacePath}?${openTaskParam}`;
|
|
710
|
-
|
|
711
|
-
router.push(fallbackUrl);
|
|
712
|
-
}
|
|
713
|
-
onActionComplete?.();
|
|
714
|
-
} finally {
|
|
715
|
-
setProcessingAction(null);
|
|
716
|
-
}
|
|
717
|
-
}}
|
|
718
|
-
>
|
|
719
|
-
{processingAction === 'OPEN_TASK' ? (
|
|
720
|
-
<>
|
|
721
|
-
<Loader2 className="h-3 w-3 animate-spin" />
|
|
722
|
-
Opening...
|
|
723
|
-
</>
|
|
724
|
-
) : (
|
|
725
|
-
'View details →'
|
|
726
|
-
)}
|
|
727
|
-
</Button>
|
|
728
|
-
) : entityLink ? (
|
|
729
|
-
<Link href={entityLink} onClick={onActionComplete}>
|
|
730
|
-
<Button
|
|
731
|
-
variant="link"
|
|
732
|
-
size="sm"
|
|
733
|
-
className="h-auto p-0 text-xs hover:text-dynamic-blue"
|
|
734
|
-
>
|
|
735
|
-
View details →
|
|
736
|
-
</Button>
|
|
737
|
-
</Link>
|
|
738
|
-
) : null}
|
|
739
|
-
</div>
|
|
740
|
-
|
|
741
|
-
{/* Mark as read/unread button */}
|
|
742
|
-
<Button
|
|
743
|
-
variant="ghost"
|
|
744
|
-
size="icon"
|
|
745
|
-
className={cn(
|
|
746
|
-
'h-7 w-7 flex-none transition-opacity hover:bg-foreground/10',
|
|
747
|
-
'opacity-0 group-hover:opacity-100'
|
|
748
|
-
)}
|
|
749
|
-
onClick={() => onMarkAsRead(notification.id, isUnread)}
|
|
750
|
-
title={isUnread ? markAsReadText : markAsUnreadText}
|
|
751
|
-
>
|
|
752
|
-
{isUnread ? (
|
|
753
|
-
<EyeOff className="h-4 w-4" />
|
|
754
|
-
) : (
|
|
755
|
-
<Eye className="h-4 w-4" />
|
|
756
|
-
)}
|
|
757
|
-
</Button>
|
|
758
|
-
</div>
|
|
759
|
-
</div>
|
|
18
|
+
<SatelliteNotificationPopover {...props} />
|
|
19
|
+
</NotificationRuntime>
|
|
760
20
|
);
|
|
761
21
|
}
|
|
762
|
-
|
|
763
|
-
function getNotificationIcon(type: string) {
|
|
764
|
-
const iconClassName = 'h-4 w-4';
|
|
765
|
-
|
|
766
|
-
switch (type) {
|
|
767
|
-
case 'task_assigned':
|
|
768
|
-
return <ClipboardList className={iconClassName} />;
|
|
769
|
-
case 'task_updated':
|
|
770
|
-
return <Edit3 className={iconClassName} />;
|
|
771
|
-
case 'task_completed':
|
|
772
|
-
return <CheckCircle2 className={iconClassName} />;
|
|
773
|
-
case 'task_reopened':
|
|
774
|
-
return <RotateCcw className={iconClassName} />;
|
|
775
|
-
case 'task_mention':
|
|
776
|
-
return <AtSign className={iconClassName} />;
|
|
777
|
-
case 'task_due_date_changed':
|
|
778
|
-
case 'task_start_date_changed':
|
|
779
|
-
return <Calendar className={iconClassName} />;
|
|
780
|
-
case 'workspace_invite':
|
|
781
|
-
return <Mail className={iconClassName} />;
|
|
782
|
-
case 'account_update':
|
|
783
|
-
return <UserPlus className={iconClassName} />;
|
|
784
|
-
case 'security_alert':
|
|
785
|
-
return <Shield className={iconClassName} />;
|
|
786
|
-
default:
|
|
787
|
-
return <Bell className={iconClassName} />;
|
|
788
|
-
}
|
|
789
|
-
}
|