@checkstack/notification-frontend 0.2.35 → 0.3.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.
@@ -1,28 +1,31 @@
1
- import { useState, useCallback } from "react";
1
+ import { useState, useCallback, type ReactNode } from "react";
2
2
  import { Link } from "react-router-dom";
3
3
  import { Bell, CheckCheck } from "lucide-react";
4
4
  import {
5
5
  Badge,
6
- DropdownMenu,
7
- DropdownMenuContent,
8
- DropdownMenuItem,
9
- DropdownMenuTrigger,
10
- DropdownMenuSeparator,
6
+ Popover,
7
+ PopoverContent,
8
+ PopoverTrigger,
9
+ Sheet,
10
+ SheetContent,
11
+ SheetTrigger,
12
+ SheetHeader,
13
+ SheetTitle,
11
14
  Button,
12
15
  stripMarkdown,
13
16
  useToast,
17
+ useIsMobile,
14
18
  } from "@checkstack/ui";
15
19
  import { useApi, usePluginClient } from "@checkstack/frontend-api";
16
- import { useSignal } from "@checkstack/signal-frontend";
17
20
  import { resolveRoute } from "@checkstack/common";
18
- import type { Notification } from "@checkstack/notification-common";
19
21
  import {
20
22
  NotificationApi,
21
- NOTIFICATION_RECEIVED,
22
- NOTIFICATION_COUNT_CHANGED,
23
- NOTIFICATION_READ,
24
23
  notificationRoutes,
25
24
  } from "@checkstack/notification-common";
25
+ import { NotificationSubjects } from "./NotificationSubjects";
26
+ import { groupByCollapseKey, type CollapsedNotification } from "./collapse";
27
+ import { CollapsedGroupTimeline } from "./CollapsedGroupTimeline";
28
+ import { ChevronDown, ChevronUp } from "lucide-react";
26
29
  import { authApiRef } from "@checkstack/auth-frontend/api";
27
30
 
28
31
  export const NotificationBell = () => {
@@ -30,25 +33,33 @@ export const NotificationBell = () => {
30
33
  const { data: session, isPending: isAuthLoading } = authApi.useSession();
31
34
  const notificationClient = usePluginClient(NotificationApi);
32
35
  const toast = useToast();
36
+ const isMobile = useIsMobile();
33
37
 
34
38
  const [isOpen, setIsOpen] = useState(false);
39
+ const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
40
+ () => new Set(),
41
+ );
35
42
 
36
- // State for real-time updates
37
- const [signalUnreadCount, setSignalUnreadCount] = useState<
38
- number | undefined
39
- >();
40
- const [signalNotifications, setSignalNotifications] = useState<
41
- Notification[] | undefined
42
- >();
43
+ const toggleExpanded = useCallback((key: string) => {
44
+ setExpandedGroups((prev) => {
45
+ const next = new Set(prev);
46
+ if (next.has(key)) {
47
+ next.delete(key);
48
+ } else {
49
+ next.add(key);
50
+ }
51
+ return next;
52
+ });
53
+ }, []);
43
54
 
44
- // Fetch unread count via useQuery
55
+ // Realtime updates arrive via SignalAutoInvalidator on `[["notification"]]`,
56
+ // so both queries stay fresh without per-component signal handlers.
45
57
  const { data: unreadData, isLoading: unreadLoading } =
46
58
  notificationClient.getUnreadCount.useQuery(undefined, {
47
59
  enabled: !!session,
48
60
  staleTime: 30_000,
49
61
  });
50
62
 
51
- // Fetch recent notifications via useQuery
52
63
  const { data: notificationsData, isLoading: notificationsLoading } =
53
64
  notificationClient.getNotifications.useQuery(
54
65
  { limit: 5, offset: 0, unreadOnly: true },
@@ -58,80 +69,14 @@ export const NotificationBell = () => {
58
69
  },
59
70
  );
60
71
 
61
- // Mark all as read mutation
62
72
  const markAsReadMutation = notificationClient.markAsRead.useMutation();
63
73
 
64
- // Use signal data if available, otherwise use query data
65
- const unreadCount = signalUnreadCount ?? unreadData?.count ?? 0;
66
- const recentNotifications =
67
- signalNotifications ?? notificationsData?.notifications ?? [];
68
-
69
- // ==========================================================================
70
- // REALTIME SIGNAL SUBSCRIPTIONS (replaces polling)
71
- // ==========================================================================
72
-
73
- // Handle new notification received
74
- useSignal(
75
- NOTIFICATION_RECEIVED,
76
- useCallback((payload) => {
77
- // Increment unread count
78
- setSignalUnreadCount((prev) => (prev ?? 0) + 1);
79
-
80
- // Add to recent notifications if dropdown is open
81
- setSignalNotifications((prev) => [
82
- {
83
- id: payload.id,
84
- title: payload.title,
85
- body: payload.body,
86
- importance: payload.importance,
87
- userId: "", // Not needed for display
88
- isRead: false,
89
- createdAt: new Date(),
90
- },
91
- ...(prev ?? []).slice(0, 4), // Keep only 5 items
92
- ]);
93
- }, []),
94
- );
95
-
96
- // Handle count changes from other sources
97
- useSignal(
98
- NOTIFICATION_COUNT_CHANGED,
99
- useCallback((payload) => {
100
- setSignalUnreadCount(payload.unreadCount);
101
- }, []),
102
- );
103
-
104
- // Handle notification marked as read
105
- useSignal(
106
- NOTIFICATION_READ,
107
- useCallback(
108
- (payload) => {
109
- if (payload.notificationId) {
110
- // Single notification marked as read - remove from list
111
- setSignalNotifications((prev) =>
112
- (prev ?? []).filter((n) => n.id !== payload.notificationId),
113
- );
114
- // Use unreadData?.count as fallback when signalUnreadCount hasn't been set yet
115
- setSignalUnreadCount((prev) =>
116
- Math.max(0, (prev ?? unreadData?.count ?? 1) - 1),
117
- );
118
- } else {
119
- // All marked as read - clear the list
120
- setSignalNotifications([]);
121
- setSignalUnreadCount(0);
122
- }
123
- },
124
- [unreadData?.count],
125
- ),
126
- );
127
-
128
- // ==========================================================================
74
+ const unreadCount = unreadData?.count ?? 0;
75
+ const recentNotifications = notificationsData?.notifications ?? [];
129
76
 
130
77
  const handleMarkAllAsRead = async () => {
131
78
  try {
132
79
  await markAsReadMutation.mutateAsync({});
133
- setSignalUnreadCount(0);
134
- setSignalNotifications([]);
135
80
  } catch {
136
81
  toast.error("Failed to mark all as read");
137
82
  }
@@ -141,7 +86,6 @@ export const NotificationBell = () => {
141
86
  setIsOpen(false);
142
87
  }, []);
143
88
 
144
- // Hide notification bell for unauthenticated users
145
89
  if (isAuthLoading || !session) {
146
90
  return;
147
91
  }
@@ -156,119 +100,174 @@ export const NotificationBell = () => {
156
100
  );
157
101
  }
158
102
 
159
- return (
160
- <DropdownMenu>
161
- <DropdownMenuTrigger
103
+ const trigger = (
104
+ <Button variant="ghost" size="icon" className="relative group">
105
+ <Bell className="h-5 w-5 transition-transform group-hover:scale-110" />
106
+ {unreadCount > 0 && (
107
+ <span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center">
108
+ <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
109
+ <Badge
110
+ variant="destructive"
111
+ className="relative h-5 min-w-[20px] flex items-center justify-center p-0 text-xs font-bold"
112
+ >
113
+ {unreadCount > 99 ? "99+" : unreadCount}
114
+ </Badge>
115
+ </span>
116
+ )}
117
+ </Button>
118
+ );
119
+
120
+ const headerActions =
121
+ unreadCount > 0 ? (
122
+ <Button
123
+ variant="ghost"
124
+ size="sm"
125
+ className="h-7 text-xs"
162
126
  onClick={() => {
163
- setIsOpen(!isOpen);
127
+ void handleMarkAllAsRead();
164
128
  }}
165
129
  >
166
- <Button variant="ghost" size="icon" className="relative group">
167
- <Bell className="h-5 w-5 transition-transform group-hover:scale-110" />
168
- {unreadCount > 0 && (
169
- <span className="absolute -top-1 -right-1 flex h-5 min-w-[20px] items-center justify-center">
170
- <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-destructive opacity-75" />
130
+ <CheckCheck className="h-3 w-3 mr-1" />
131
+ Mark all read
132
+ </Button>
133
+ ) : undefined;
134
+
135
+ const renderItem = (group: CollapsedNotification): ReactNode => {
136
+ const notification = group.representative;
137
+ const isExpanded = expandedGroups.has(group.key);
138
+ return (
139
+ <div
140
+ key={group.key}
141
+ className={`flex flex-col items-start gap-1 px-3 py-2 hover:bg-accent transition-colors ${
142
+ notification.importance === "critical"
143
+ ? "border-l-2 border-l-destructive"
144
+ : notification.importance === "warning"
145
+ ? "border-l-2 border-l-warning"
146
+ : ""
147
+ }`}
148
+ >
149
+ <div className="flex items-center gap-2 w-full">
150
+ <div
151
+ className={`font-medium text-sm flex-1 truncate ${
152
+ notification.importance === "critical"
153
+ ? "text-destructive"
154
+ : notification.importance === "warning"
155
+ ? "text-warning"
156
+ : "text-foreground"
157
+ }`}
158
+ >
159
+ {notification.title}
160
+ </div>
161
+ {group.collapsed && (
162
+ <button
163
+ type="button"
164
+ className="inline-flex items-center gap-0.5 shrink-0"
165
+ onClick={(e: React.MouseEvent) => {
166
+ e.stopPropagation();
167
+ toggleExpanded(group.key);
168
+ }}
169
+ aria-label={
170
+ isExpanded
171
+ ? "Collapse update history"
172
+ : "Show update history"
173
+ }
174
+ >
171
175
  <Badge
172
- variant="destructive"
173
- className="relative h-5 min-w-[20px] flex items-center justify-center p-0 text-xs font-bold"
176
+ variant="secondary"
177
+ className="text-[10px] h-5 cursor-pointer hover:bg-accent"
174
178
  >
175
- {unreadCount > 99 ? "99+" : unreadCount}
179
+ +{group.count - 1} updates
180
+ {isExpanded ? (
181
+ <ChevronUp className="ml-0.5 h-3 w-3" />
182
+ ) : (
183
+ <ChevronDown className="ml-0.5 h-3 w-3" />
184
+ )}
176
185
  </Badge>
177
- </span>
186
+ </button>
178
187
  )}
179
- </Button>
180
- </DropdownMenuTrigger>
181
- <DropdownMenuContent
182
- isOpen={isOpen}
183
- onClose={handleClose}
184
- className="w-80"
185
- >
186
- {/* Header */}
187
- <div className="flex items-center justify-between px-3 py-2 border-b border-border">
188
- <span className="font-semibold text-sm">Notifications</span>
189
- {unreadCount > 0 && (
190
- <Button
191
- variant="ghost"
192
- size="sm"
193
- className="h-6 text-xs"
194
- onClick={() => {
195
- void handleMarkAllAsRead();
188
+ </div>
189
+ <div className="text-xs text-muted-foreground line-clamp-2">
190
+ {stripMarkdown(notification.body)}
191
+ </div>
192
+ {notification.subjects && notification.subjects.length > 0 && (
193
+ <NotificationSubjects subjects={notification.subjects} />
194
+ )}
195
+ {notification.action && (
196
+ <div className="flex gap-2 mt-1">
197
+ <Link
198
+ to={notification.action.url}
199
+ className="text-xs text-primary hover:underline"
200
+ onClick={(e: React.MouseEvent) => {
201
+ e.stopPropagation();
196
202
  }}
197
203
  >
198
- <CheckCheck className="h-3 w-3 mr-1" />
199
- Mark all read
200
- </Button>
201
- )}
202
- </div>
204
+ {notification.action.label}
205
+ </Link>
206
+ </div>
207
+ )}
208
+ {group.collapsed && isExpanded && (
209
+ <CollapsedGroupTimeline notifications={group.notifications} />
210
+ )}
211
+ </div>
212
+ );
213
+ };
203
214
 
204
- {/* Notification List */}
205
- <div className="max-h-[400px] overflow-y-auto">
206
- {recentNotifications.length === 0 ? (
207
- <div className="px-3 py-6 text-center text-sm text-muted-foreground">
208
- No unread notifications
209
- </div>
210
- ) : (
211
- <>
212
- {recentNotifications.map((notification) => (
213
- <DropdownMenuItem
214
- key={notification.id}
215
- className={`flex flex-col items-start gap-1 px-3 py-2 cursor-pointer ${
216
- notification.importance === "critical"
217
- ? "border-l-2 border-l-destructive"
218
- : notification.importance === "warning"
219
- ? "border-l-2 border-l-warning"
220
- : ""
221
- }`}
222
- >
223
- <div
224
- className={`font-medium text-sm ${
225
- notification.importance === "critical"
226
- ? "text-destructive"
227
- : notification.importance === "warning"
228
- ? "text-warning"
229
- : "text-foreground"
230
- }`}
231
- >
232
- {notification.title}
233
- </div>
234
- <div className="text-xs text-muted-foreground line-clamp-2">
235
- {stripMarkdown(notification.body)}
236
- </div>
237
- {notification.action && (
238
- <div className="flex gap-2 mt-1">
239
- <Link
240
- to={notification.action.url}
241
- className="text-xs text-primary hover:underline"
242
- onClick={(e: React.MouseEvent) => {
243
- e.stopPropagation();
244
- }}
245
- >
246
- {notification.action.label}
247
- </Link>
248
- </div>
249
- )}
250
- </DropdownMenuItem>
251
- ))}
252
- </>
253
- )}
254
- </div>
215
+ const collapsedGroups = groupByCollapseKey(recentNotifications);
216
+
217
+ const list =
218
+ collapsedGroups.length === 0 ? (
219
+ <div className="px-3 py-6 text-center text-sm text-muted-foreground">
220
+ No unread notifications
221
+ </div>
222
+ ) : (
223
+ <>{collapsedGroups.map((g) => renderItem(g))}</>
224
+ );
255
225
 
256
- <DropdownMenuSeparator />
226
+ const footer = (
227
+ <Link
228
+ to={resolveRoute(notificationRoutes.routes.home)}
229
+ className="block w-full text-center text-sm text-primary hover:bg-accent transition-colors px-3 py-2"
230
+ onClick={handleClose}
231
+ >
232
+ View all notifications
233
+ </Link>
234
+ );
257
235
 
258
- {/* Footer */}
259
- <DropdownMenuItem
260
- onClick={() => {
261
- handleClose();
262
- }}
236
+ if (isMobile) {
237
+ return (
238
+ <Sheet open={isOpen} onOpenChange={setIsOpen}>
239
+ <SheetTrigger asChild>{trigger}</SheetTrigger>
240
+ <SheetContent
241
+ size="full"
242
+ className="flex flex-col p-0"
243
+ onCloseAutoFocus={(e) => e.preventDefault()}
263
244
  >
264
- <Link
265
- to={resolveRoute(notificationRoutes.routes.home)}
266
- className="w-full text-center text-sm text-primary"
267
- >
268
- View all notifications
269
- </Link>
270
- </DropdownMenuItem>
271
- </DropdownMenuContent>
272
- </DropdownMenu>
245
+ <SheetHeader className="flex flex-row items-center justify-between gap-2 px-4 py-3 border-b border-border space-y-0">
246
+ <SheetTitle className="text-base">Notifications</SheetTitle>
247
+ <div className="pr-8">{headerActions}</div>
248
+ </SheetHeader>
249
+ <div className="flex-1 overflow-y-auto">{list}</div>
250
+ <div className="border-t border-border">{footer}</div>
251
+ </SheetContent>
252
+ </Sheet>
253
+ );
254
+ }
255
+
256
+ return (
257
+ <Popover open={isOpen} onOpenChange={setIsOpen}>
258
+ <PopoverTrigger asChild>{trigger}</PopoverTrigger>
259
+ <PopoverContent
260
+ className="w-80 p-0"
261
+ align="end"
262
+ onCloseAutoFocus={(e) => e.preventDefault()}
263
+ >
264
+ <div className="flex items-center justify-between px-3 py-2 border-b border-border">
265
+ <span className="font-semibold text-sm">Notifications</span>
266
+ {headerActions}
267
+ </div>
268
+ <div className="max-h-[400px] overflow-y-auto">{list}</div>
269
+ <div className="border-t border-border">{footer}</div>
270
+ </PopoverContent>
271
+ </Popover>
273
272
  );
274
273
  };
@@ -0,0 +1,154 @@
1
+ import { useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import {
4
+ Popover,
5
+ PopoverContent,
6
+ PopoverTrigger,
7
+ Button,
8
+ usePerformance,
9
+ } from "@checkstack/ui";
10
+ import type { NotificationSubject } from "@checkstack/notification-common";
11
+ import { getSubjectKindRenderer } from "./SubjectKindRegistry";
12
+
13
+ const STATUS_DOT_COLOR: Record<
14
+ NonNullable<NotificationSubject["status"]>,
15
+ string
16
+ > = {
17
+ healthy: "bg-emerald-500",
18
+ degraded: "bg-amber-500",
19
+ unhealthy: "bg-rose-500",
20
+ unknown: "bg-zinc-400",
21
+ };
22
+
23
+ interface NotificationSubjectsProps {
24
+ subjects: NotificationSubject[];
25
+ /** Inline cap before collapsing the rest into a "+N" overflow popover. */
26
+ maxVisible?: number;
27
+ /** Optional callback fired when a chip's link is clicked. */
28
+ onSubjectClick?: () => void;
29
+ }
30
+
31
+ /**
32
+ * Compact chip list of affected entities. Each chip shows the kind icon (via
33
+ * the kind registry) and the subject name; clicking links to the subject's
34
+ * url when present. Status maps to a small colored dot.
35
+ *
36
+ * On low-power devices, transitions are disabled.
37
+ */
38
+ export function NotificationSubjects({
39
+ subjects,
40
+ maxVisible = 3,
41
+ onSubjectClick,
42
+ }: NotificationSubjectsProps) {
43
+ const [overflowOpen, setOverflowOpen] = useState(false);
44
+ const { isLowPower } = usePerformance();
45
+
46
+ if (subjects.length === 0) return <></>;
47
+
48
+ const visible = subjects.slice(0, maxVisible);
49
+ const overflow = subjects.slice(maxVisible);
50
+
51
+ return (
52
+ <div className="flex flex-wrap items-center gap-1.5 mt-2">
53
+ {visible.map((subject) => (
54
+ <SubjectChip
55
+ key={`${subject.kind}:${subject.id}`}
56
+ subject={subject}
57
+ isLowPower={isLowPower}
58
+ onClick={onSubjectClick}
59
+ />
60
+ ))}
61
+ {overflow.length > 0 && (
62
+ <Popover open={overflowOpen} onOpenChange={setOverflowOpen}>
63
+ <PopoverTrigger asChild>
64
+ <Button
65
+ type="button"
66
+ variant="outline"
67
+ size="sm"
68
+ className="h-6 px-2 text-xs"
69
+ >
70
+ +{overflow.length} more
71
+ </Button>
72
+ </PopoverTrigger>
73
+ <PopoverContent
74
+ className="w-72 p-2"
75
+ align="start"
76
+ onCloseAutoFocus={(e) => {
77
+ e.preventDefault();
78
+ }}
79
+ >
80
+ <div className="flex flex-col gap-1">
81
+ {overflow.map((subject) => (
82
+ <SubjectChip
83
+ key={`${subject.kind}:${subject.id}`}
84
+ subject={subject}
85
+ isLowPower={isLowPower}
86
+ onClick={() => {
87
+ setOverflowOpen(false);
88
+ onSubjectClick?.();
89
+ }}
90
+ fullWidth
91
+ />
92
+ ))}
93
+ </div>
94
+ </PopoverContent>
95
+ </Popover>
96
+ )}
97
+ </div>
98
+ );
99
+ }
100
+
101
+ interface SubjectChipProps {
102
+ subject: NotificationSubject;
103
+ isLowPower: boolean;
104
+ onClick?: () => void;
105
+ fullWidth?: boolean;
106
+ }
107
+
108
+ function SubjectChip({
109
+ subject,
110
+ isLowPower,
111
+ onClick,
112
+ fullWidth,
113
+ }: SubjectChipProps) {
114
+ const renderer = getSubjectKindRenderer(subject.kind);
115
+ const Icon = renderer.icon;
116
+ const dotColor = subject.status
117
+ ? STATUS_DOT_COLOR[subject.status]
118
+ : undefined;
119
+
120
+ const baseClass = [
121
+ "inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md",
122
+ "border border-border bg-card text-foreground",
123
+ "text-xs font-medium max-w-[14rem] truncate",
124
+ isLowPower ? "" : "transition-colors hover:bg-accent",
125
+ fullWidth ? "w-full" : "",
126
+ ]
127
+ .filter(Boolean)
128
+ .join(" ");
129
+
130
+ const content = (
131
+ <>
132
+ <Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
133
+ {dotColor && (
134
+ <span
135
+ className={`inline-block h-2 w-2 rounded-full shrink-0 ${dotColor}`}
136
+ aria-label={subject.status ?? undefined}
137
+ />
138
+ )}
139
+ <span className="truncate" title={`${renderer.label}: ${subject.name}`}>
140
+ {subject.name}
141
+ </span>
142
+ </>
143
+ );
144
+
145
+ if (subject.url) {
146
+ return (
147
+ <Link to={subject.url} className={baseClass} onClick={onClick}>
148
+ {content}
149
+ </Link>
150
+ );
151
+ }
152
+
153
+ return <span className={baseClass}>{content}</span>;
154
+ }