|
| 1 | +import React, { useEffect, useMemo, useState } from "react"; |
| 2 | +import { NavLink } from "react-router-dom"; |
| 3 | +import ReactMarkdown from "react-markdown"; |
| 4 | +import { api, useApi } from "api"; |
| 5 | +import { Notification, Priority } from "@dappnode/types"; |
| 6 | +import { dappmanagerAliases, externalUrlProps } from "params"; |
| 7 | +import { resolveDappnodeUrl } from "utils/resolveDappnodeUrl"; |
| 8 | +import { X, ChevronDown, ChevronUp } from "lucide-react"; |
| 9 | +import { AlertDescription } from "components/primitives/alert"; |
| 10 | +import { Button } from "components/primitives/button"; |
| 11 | +import { Collapsible, CollapsibleContent } from "components/primitives/collapsible"; |
| 12 | +import { cn } from "lib/utils"; |
| 13 | + |
| 14 | +const NUM_BANNERS_SHOWN = 3; |
| 15 | + |
| 16 | +/** Priority-based background + text color classes */ |
| 17 | +const priorityStyles: Record<Priority, string> = { |
| 18 | + [Priority.low]: "tw:bg-muted tw:text-muted-foreground", |
| 19 | + [Priority.medium]: "tw:bg-primary/10 tw:text-primary", |
| 20 | + [Priority.high]: "tw:bg-caution/15 tw:text-caution-foreground tw:dark:text-caution tw:dark:bg-caution/20", |
| 21 | + [Priority.critical]: "tw:bg-destructive/10 tw:text-destructive tw:dark:bg-destructive/20" |
| 22 | +}; |
| 23 | + |
| 24 | +/** Map Priority → CTA button variant */ |
| 25 | +const priorityButtonVariant: Record<Priority, "default" | "outline" | "destructive"> = { |
| 26 | + [Priority.low]: "default", |
| 27 | + [Priority.medium]: "default", |
| 28 | + [Priority.high]: "outline", |
| 29 | + [Priority.critical]: "destructive" |
| 30 | +}; |
| 31 | + |
| 32 | +/** |
| 33 | + * Filters notifications: |
| 34 | + * 1. Removes entries with errors |
| 35 | + * 2. Deduplicates by correlationId, keeping the most recent |
| 36 | + * 3. Keeps only triggered (not resolved) and unseen |
| 37 | + * 4. Sorts by priority (critical → low) |
| 38 | + */ |
| 39 | +function filterNotifications(notifications: Notification[]): Notification[] { |
| 40 | + const priorityOrder = [Priority.critical, Priority.high, Priority.medium, Priority.low]; |
| 41 | + const map = new Map<string, Notification>(); |
| 42 | + |
| 43 | + notifications |
| 44 | + .filter((n) => !n.errors) |
| 45 | + .forEach((n) => { |
| 46 | + const existing = map.get(n.correlationId); |
| 47 | + if (!existing || new Date(n.timestamp) > new Date(existing.timestamp)) { |
| 48 | + map.set(n.correlationId, n); |
| 49 | + } |
| 50 | + }); |
| 51 | + |
| 52 | + return Array.from(map.values()) |
| 53 | + .filter((n) => n.status === "triggered") |
| 54 | + .filter((n) => !n.seen) |
| 55 | + .sort((a, b) => priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority)); |
| 56 | +} |
| 57 | + |
| 58 | +/* ── Main banner list ───────────────────────────────────────────────── */ |
| 59 | + |
| 60 | +export function BannerNotifications() { |
| 61 | + const [notifications, setNotifications] = useState<Notification[]>([]); |
| 62 | + |
| 63 | + const oneMonthAgoTimestamp = useMemo(() => { |
| 64 | + const now = new Date(); |
| 65 | + now.setMonth(now.getMonth() - 1); |
| 66 | + return Math.floor(now.getTime() / 1000); |
| 67 | + }, []); |
| 68 | + |
| 69 | + const notificationsCall = useApi.notificationsGetBanner({ timestamp: oneMonthAgoTimestamp }); |
| 70 | + |
| 71 | + useEffect(() => { |
| 72 | + if (notificationsCall.data) { |
| 73 | + setNotifications(filterNotifications(notificationsCall.data)); |
| 74 | + } |
| 75 | + }, [notificationsCall.data]); |
| 76 | + |
| 77 | + // Revalidate every minute |
| 78 | + useEffect(() => { |
| 79 | + const interval = setInterval(() => notificationsCall.revalidate(), 60 * 1000); |
| 80 | + return () => clearInterval(interval); |
| 81 | + }, []); |
| 82 | + |
| 83 | + if (!notifications.length) return null; |
| 84 | + |
| 85 | + return ( |
| 86 | + <div className="tw:flex tw:flex-col tw:gap-card tw:px-page-x tw:pt-page-y"> |
| 87 | + {notifications.slice(0, NUM_BANNERS_SHOWN).map((n) => ( |
| 88 | + <BannerNotificationCard |
| 89 | + key={n.id} |
| 90 | + notification={n} |
| 91 | + onClose={() => setNotifications((prev) => prev.filter((x) => x.id !== n.id))} |
| 92 | + /> |
| 93 | + ))} |
| 94 | + </div> |
| 95 | + ); |
| 96 | +} |
| 97 | + |
| 98 | +/* ── Single banner card ─────────────────────────────────────────────── */ |
| 99 | + |
| 100 | +function BannerNotificationCard({ notification, onClose }: { notification: Notification; onClose: () => void }) { |
| 101 | + const defaultOpen = notification.priority === Priority.critical; |
| 102 | + const [open, setOpen] = useState(defaultOpen); |
| 103 | + |
| 104 | + const handleClose = () => { |
| 105 | + api.notificationSetSeenByCorrelationID({ correlationId: notification.correlationId }); |
| 106 | + onClose(); |
| 107 | + }; |
| 108 | + |
| 109 | + const isExternalUrl = |
| 110 | + notification.callToAction && !dappmanagerAliases.some((alias) => notification.callToAction!.url.includes(alias)); |
| 111 | + |
| 112 | + const buttonVariant = priorityButtonVariant[notification.priority]; |
| 113 | + |
| 114 | + return ( |
| 115 | + <Collapsible open={open} onOpenChange={setOpen}> |
| 116 | + <div |
| 117 | + className={cn( |
| 118 | + "tw:relative tw:rounded-lg tw:border tw:px-3 tw:py-2.5 tw:pr-10 tw:text-sm tw:shadow-sm tw:cursor-pointer", |
| 119 | + priorityStyles[notification.priority] |
| 120 | + )} |
| 121 | + onClick={() => setOpen((o) => !o)} |
| 122 | + > |
| 123 | + {/* Close button (top-right) */} |
| 124 | + <div className="tw:absolute tw:top-2 tw:right-2"> |
| 125 | + <Button |
| 126 | + variant="ghost" |
| 127 | + size="icon-xs" |
| 128 | + onClick={(e) => { |
| 129 | + e.stopPropagation(); |
| 130 | + handleClose(); |
| 131 | + }} |
| 132 | + aria-label="Dismiss notification" |
| 133 | + > |
| 134 | + <X /> |
| 135 | + </Button> |
| 136 | + </div> |
| 137 | + |
| 138 | + {/* Title row */} |
| 139 | + <div className="tw:flex tw:items-center tw:gap-1.5 tw:font-medium"> |
| 140 | + {notification.title} |
| 141 | + {open ? <ChevronUp className="tw:size-3.5" /> : <ChevronDown className="tw:size-3.5" />} |
| 142 | + </div> |
| 143 | + |
| 144 | + {/* Collapsible body */} |
| 145 | + <CollapsibleContent> |
| 146 | + <AlertDescription className="tw:mt-1.5"> |
| 147 | + <ReactMarkdown |
| 148 | + children={notification.body} |
| 149 | + components={{ a: ({ ...props }) => <a target="_blank" rel="noopener noreferrer" {...props} /> }} |
| 150 | + /> |
| 151 | + |
| 152 | + {notification.callToAction && ( |
| 153 | + <NavLink |
| 154 | + to={resolveDappnodeUrl(notification.callToAction.url, window.location)} |
| 155 | + {...(isExternalUrl ? externalUrlProps : {})} |
| 156 | + className="tw:inline-block tw:mt-2" |
| 157 | + > |
| 158 | + <Button variant={buttonVariant} size="sm"> |
| 159 | + {notification.callToAction.title} |
| 160 | + </Button> |
| 161 | + </NavLink> |
| 162 | + )} |
| 163 | + </AlertDescription> |
| 164 | + </CollapsibleContent> |
| 165 | + </div> |
| 166 | + </Collapsible> |
| 167 | + ); |
| 168 | +} |
0 commit comments