diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js
index 9ddab746b..a70814e8f 100644
--- a/packages/bruno-app/src/components/Modal/index.js
+++ b/packages/bruno-app/src/components/Modal/index.js
@@ -1,4 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
+import { IconX } from '@tabler/icons';
import StyledWrapper from './StyledWrapper';
import useFocusTrap from 'hooks/useFocusTrap';
import Button from 'ui/Button';
@@ -12,13 +13,15 @@ const ModalHeader = ({ title, handleCancel, customHeader, hideClose }) => (
{handleCancel && !hideClose ? (
// TODO: Remove data-test-id and use data-testid instead across the codebase.
handleCancel() : null} data-testid="modal-close-button">
- ×
+
) : null}
);
-const ModalContent = ({ children }) => {children}
;
+const ModalContent = ({ children, noPadding }) => (
+ {children}
+);
const ModalFooter = ({
confirmText,
@@ -84,7 +87,8 @@ const Modal = ({
onClick,
closeModalFadeTimeout = 500,
dataTestId,
- confirmButtonColor = 'primary'
+ confirmButtonColor = 'primary',
+ noPadding
}) => {
const modalRef = useRef(null);
const [isClosing, setIsClosing] = useState(false);
@@ -148,7 +152,7 @@ const Modal = ({
handleCancel={() => closeModal({ type: 'icon' })}
customHeader={customHeader}
/>
- {children}
+ {children}
{
+ let badgeColor = theme.colors.text.purple;
+ try {
+ parseToRgb(color);
+ badgeColor = color;
+ } catch {
+ // invalid color; keep the fallback
+ }
+ return {
+ backgroundColor: rgba(badgeColor, 0.15),
+ color: badgeColor
+ };
+};
+
+const getSanitizedDescription = (description) => {
+ return DOMPurify.sanitize(description || '', {
+ ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'br', 'strong', 'em'],
+ ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
+ });
+};
+
+const NotificationDetail = ({ notification }) => {
+ const { theme } = useTheme();
+
+ // Rendered in a sandboxed iframe (no allow-scripts); theme CSS is inlined
+ // since the iframe doesn't inherit app styles.
+ const buildDescriptionDocument = (description) => {
+ const body = getSanitizedDescription(description);
+ return `
+
+
+
+
+
+
+ ${body}
+`;
+ };
+
+ if (!notification) {
+ return (
+
+
Select a notification to read more.
+
+ );
+ }
+
+ return (
+
+
+
+ {notification.type && (
+
+ {notification.type}
+
+ )}
+ {humanizeDate(notification.date)}
+
+
{notification.title}
+
+
+
+ );
+};
+
+export default NotificationDetail;
diff --git a/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationDetail.spec.js b/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationDetail.spec.js
new file mode 100644
index 000000000..793596fdd
--- /dev/null
+++ b/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationDetail.spec.js
@@ -0,0 +1,40 @@
+import { rgba } from 'polished';
+import { getBadgeStyle } from './NotificationDetail';
+
+describe('getBadgeStyle', () => {
+ const theme = { colors: { text: { purple: '#8e44ad' } } };
+
+ it('uses a valid hex color for both text and tinted background', () => {
+ const style = getBadgeStyle('#ff0000', theme);
+ expect(style).toEqual({
+ backgroundColor: rgba('#ff0000', 0.15),
+ color: '#ff0000'
+ });
+ });
+
+ it('accepts rgb color strings', () => {
+ const style = getBadgeStyle('rgb(0, 128, 255)', theme);
+ expect(style.color).toBe('rgb(0, 128, 255)');
+ expect(style.backgroundColor).toBe(rgba('rgb(0, 128, 255)', 0.15));
+ });
+
+ it('accepts hsl color strings', () => {
+ const style = getBadgeStyle('hsl(210, 100%, 50%)', theme);
+ expect(style.color).toBe('hsl(210, 100%, 50%)');
+ expect(style.backgroundColor).toBe(rgba('hsl(210, 100%, 50%)', 0.15));
+ });
+
+ it('falls back to the theme purple for an unparseable color', () => {
+ const style = getBadgeStyle('not-a-color', theme);
+ expect(style).toEqual({
+ backgroundColor: rgba(theme.colors.text.purple, 0.15),
+ color: theme.colors.text.purple
+ });
+ });
+
+ it('falls back to the theme purple when color is undefined', () => {
+ const style = getBadgeStyle(undefined, theme);
+ expect(style.color).toBe(theme.colors.text.purple);
+ expect(style.backgroundColor).toBe(rgba(theme.colors.text.purple, 0.15));
+ });
+});
diff --git a/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationList.js b/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationList.js
new file mode 100644
index 000000000..62f7ed343
--- /dev/null
+++ b/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationList.js
@@ -0,0 +1,35 @@
+import classnames from 'classnames';
+import { relativeDate } from 'utils/common';
+
+const NotificationList = ({ items, selectedId, onSelect }) => {
+ return (
+
+ );
+};
+
+export default NotificationList;
diff --git a/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationTabs.js b/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationTabs.js
new file mode 100644
index 000000000..7ff79205a
--- /dev/null
+++ b/packages/bruno-app/src/components/Notifications/NotificationsModal/NotificationTabs.js
@@ -0,0 +1,74 @@
+import classnames from 'classnames';
+import { IconDotsVertical } from '@tabler/icons';
+import { useEffect, useRef } from 'react';
+import Dropdown from 'components/Dropdown';
+import { TABS } from '../hooks/useNotifications';
+
+const menuIcon = (
+
+
+
+);
+
+const NotificationTabs = ({ activeTab, unreadCount, onTabChange, onMarkAllRead, onClearAll }) => {
+ const dropdownTippyRef = useRef(null);
+ const onDropdownCreate = (ref) => (dropdownTippyRef.current = ref);
+ const hideDropdown = () => dropdownTippyRef.current?.hide();
+
+ // Clicks inside the detail iframe don't bubble to the parent document, so
+ // tippy's outside-click dismissal never fires. Closing on iframe focus covers it.
+ useEffect(() => {
+ const onWindowBlur = () => {
+ if (document.activeElement?.tagName === 'IFRAME') {
+ hideDropdown();
+ }
+ };
+ window.addEventListener('blur', onWindowBlur);
+ return () => window.removeEventListener('blur', onWindowBlur);
+ }, []);
+
+ return (
+
+
+
+
+
+
+ {
+ if (unreadCount === 0) return;
+ hideDropdown();
+ onMarkAllRead();
+ }}
+ >
+ Mark all as read
+
+ {
+ hideDropdown();
+ onClearAll();
+ }}
+ >
+ Clear all
+
+
+
+ );
+};
+
+export default NotificationTabs;
diff --git a/packages/bruno-app/src/components/Notifications/NotificationsModal/StyledWrapper.js b/packages/bruno-app/src/components/Notifications/NotificationsModal/StyledWrapper.js
new file mode 100644
index 000000000..69b28d5c9
--- /dev/null
+++ b/packages/bruno-app/src/components/Notifications/NotificationsModal/StyledWrapper.js
@@ -0,0 +1,267 @@
+import styled from 'styled-components';
+import { rgba } from 'polished';
+
+const StyledWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ width: 800px;
+ height: 520px;
+ max-width: 100%;
+ max-height: 70vh;
+ overflow: hidden;
+ background-color: ${(props) => props.theme.notifications.bg};
+
+ /* While dragging, stop the detail iframe from swallowing mousemove events,
+ which would otherwise freeze the resize until the cursor re-enters the handle. */
+ &.dragging .notif-detail-body {
+ pointer-events: none;
+ }
+
+ .notif-sidebar {
+ flex: 0 0 auto;
+ display: flex;
+ flex-direction: column;
+ background-color: ${(props) => props.theme.notifications.list.bg};
+ }
+
+ .notif-resize-handle {
+ flex: 0 0 1px;
+ cursor: col-resize;
+ background: ${(props) => props.theme.notifications.list.borderBottom};
+ position: relative;
+ user-select: none;
+ transition: background-color 0.15s ease;
+
+ /* widen the hit target without bloating the visible line */
+ &::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: -3px;
+ right: -3px;
+ }
+
+ &:hover,
+ &.dragging {
+ background: ${(props) => props.theme.colors.text.yellow};
+ }
+ }
+
+ .notif-tabs {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 7px 12px;
+ gap: 6px;
+ border-bottom: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
+ }
+
+ .notif-tab-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .notif-tab {
+ height: 24px;
+ padding: 4px 8px;
+ border-radius: 6px;
+ border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
+ font-size: 12px;
+ line-height: 20px;
+ font-weight: 400;
+ color: ${(props) => props.theme.text};
+ background: transparent;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+
+ &.active {
+ background-color: ${(props) => props.theme.brand};
+ color: ${(props) => props.theme.background.base};
+ font-weight: 500;
+
+ .notif-tab-badge {
+ background-color: ${(props) => props.theme.background.base};
+ color: ${(props) => props.theme.brand};
+ border-color: ${(props) => props.theme.background.base};
+ }
+ }
+ }
+
+ .notif-tab-badge {
+ min-width: 16px;
+ height: 16px;
+ padding: 0 4px;
+ border-radius: 999px;
+ border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
+ background-color: ${(props) => rgba(props.theme.brand, 0.1)};
+ color: ${(props) => props.theme.brand};
+ font-size: 11px;
+ line-height: 14px;
+ font-weight: 500;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .notif-menu-trigger {
+ cursor: pointer;
+ color: ${(props) => props.theme.colors.text.muted};
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px;
+ border-radius: 4px;
+
+ &:hover {
+ background-color: ${(props) => props.theme.notifications.list.hoverBg};
+ }
+ }
+
+ .notif-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ overflow-y: auto;
+ flex: 1;
+ min-height: 0;
+ background-color: ${(props) => props.theme.notifications.list.bg};
+ }
+
+ .notif-list-empty {
+ padding: 16px 12px;
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 12px;
+ font-style: italic;
+ text-align: center;
+ }
+
+ .notif-list-item {
+ position: relative;
+ padding: 8px 12px;
+ cursor: pointer;
+ border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+
+ &:hover {
+ background-color: ${(props) => props.theme.notifications.list.hoverBg};
+ }
+
+ &.unread {
+ background-color: ${(props) => props.theme.notifications.list.active.bg};
+
+ &:hover {
+ background-color: ${(props) => props.theme.notifications.list.hoverBg};
+ }
+ }
+
+ &.active {
+ &::before {
+ content: '';
+ position: absolute;
+ left: 0;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background-color: ${(props) => props.theme.colors.text.yellow};
+ }
+ }
+ }
+
+ .notif-item-title {
+ color: ${(props) => props.theme.text};
+ font-size: 13px;
+ line-height: 20px;
+ font-weight: 400;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+
+ &.unread {
+ font-weight: 600;
+ }
+ }
+
+ .notif-item-date,
+ .notif-detail-date {
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 12px;
+ line-height: 20px;
+ font-weight: 500;
+ }
+
+ .notif-detail {
+ flex: 1;
+ min-width: 0;
+ padding: 6px 6px 0 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .notif-detail-header {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 0 12px;
+ }
+
+ .notif-detail-meta {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ margin-top: 1px;
+ min-height: 24px;
+ }
+
+ .notif-type-badge {
+ height: 24px;
+ padding: 4px 8px;
+ border-radius: 6px;
+ border: 1px solid ${(props) => props.theme.notifications.list.borderBottom};
+ font-size: 12px;
+ line-height: 20px;
+ font-weight: 400;
+ display: inline-flex;
+ align-items: center;
+ }
+
+ .notif-detail-title {
+ color: ${(props) => props.theme.text};
+ font-size: 13px;
+ line-height: 20px;
+ font-weight: 600;
+ }
+
+ .notif-detail-body {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ border: none;
+ background: transparent;
+ }
+
+ .notif-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ color: ${(props) => props.theme.colors.text.muted};
+ font-size: 13px;
+ }
+
+ .notif-empty-text {
+ font-style: italic;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Notifications/NotificationsModal/index.js b/packages/bruno-app/src/components/Notifications/NotificationsModal/index.js
new file mode 100644
index 000000000..e14b62345
--- /dev/null
+++ b/packages/bruno-app/src/components/Notifications/NotificationsModal/index.js
@@ -0,0 +1,89 @@
+import { useRef } from 'react';
+import classnames from 'classnames';
+import { useDragResize } from 'hooks/useDragResize';
+import { usePersistedState } from 'hooks/usePersistedState';
+import Modal from 'components/Modal/index';
+import Portal from 'components/Portal';
+import StyledWrapper from './StyledWrapper';
+import NotificationTabs from './NotificationTabs';
+import NotificationList from './NotificationList';
+import NotificationDetail from './NotificationDetail';
+
+const DEFAULT_SIDEBAR_WIDTH = 260;
+const SIDEBAR_MIN = 200;
+// Reserved for the detail pane; caps the sidebar at ~420px in the 800px modal.
+const DETAIL_MIN = 380;
+
+const NotificationsModal = ({ notifications, onClose }) => {
+ const {
+ visibleNotifications,
+ listed,
+ unreadCount,
+ activeTab,
+ selectedNotification,
+ onTabChange,
+ onSelect,
+ onMarkAllRead,
+ onClearAll
+ } = notifications;
+
+ const containerRef = useRef(null);
+ const [sidebarWidth, setSidebarWidth] = usePersistedState({
+ key: 'notification-sidebar',
+ default: DEFAULT_SIDEBAR_WIDTH
+ });
+ const { dragging, dragWidth, dragbarProps } = useDragResize({
+ containerRef,
+ width: sidebarWidth,
+ onWidthChange: (w) => setSidebarWidth(w ?? DEFAULT_SIDEBAR_WIDTH),
+ minLeft: SIDEBAR_MIN,
+ minRight: DETAIL_MIN
+ });
+ const effectiveWidth = dragging ? dragWidth : sidebarWidth;
+ const isEmpty = visibleNotifications.length === 0;
+
+ return (
+
+
+
+
+
+
+
+
+ {isEmpty ? (
+
+
You are all caught up!
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+export default NotificationsModal;
diff --git a/packages/bruno-app/src/components/Notifications/StyleWrapper.js b/packages/bruno-app/src/components/Notifications/StyleWrapper.js
deleted file mode 100644
index ba42b5892..000000000
--- a/packages/bruno-app/src/components/Notifications/StyleWrapper.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import styled from 'styled-components';
-
-const StyledWrapper = styled.div`
- .notifications-modal {
- margin-inline: -1rem;
- margin-block: -1.5rem;
- background-color: ${(props) => props.theme.notifications.bg};
- }
-
- .notification-count {
- display: flex;
- color: white;
- position: absolute;
- top: -0.625rem;
- right: -0.5rem;
- margin-right: 0.5rem;
- justify-content: center;
- font-size: 0.625rem;
- border-radius: 50%;
- background-color: ${(props) => props.theme.colors.text.yellow};
- border: solid 2px ${(props) => props.theme.sidebar.bg};
- min-width: 1.25rem;
- }
-
- button.mark-as-read {
- font-weight: 400 !important;
- }
-
- ul.notifications {
- background-color: ${(props) => props.theme.notifications.list.bg};
- border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
- min-height: 400px;
- height: 100%;
- max-height: 85vh;
- overflow-y: auto;
-
- li {
- min-width: 150px;
- cursor: pointer;
- padding: 0.5rem 0.625rem;
- border-left: solid 2px transparent;
- color: ${(props) => props.theme.textLink};
- border-bottom: solid 1px ${(props) => props.theme.notifications.list.borderBottom};
- &:hover {
- background-color: ${(props) => props.theme.notifications.list.hoverBg};
- }
-
- &.active {
- color: ${(props) => props.theme.text} !important;
- background-color: ${(props) => props.theme.notifications.list.active.bg} !important;
- border-left: solid 2px ${(props) => props.theme.notifications.list.active.border};
- &:hover {
- background-color: ${(props) => props.theme.notifications.list.active.hoverBg} !important;
- }
- }
-
- &.read {
- color: ${(props) => props.theme.text} !important;
- }
-
- .notification-date {
- font-size: ${(props) => props.theme.font.size.xs};
- }
- }
- }
-
- .notification-title {
- overflow: hidden;
- text-overflow: ellipsis;
- display: -webkit-box;
- -webkit-line-clamp: 2;
- -webkit-box-orient: vertical;
- }
-
- .notification-date {
- color: ${(props) => props.theme.colors.text.muted};
- }
-
- .pagination {
- background-color: ${(props) => props.theme.notifications.list.bg};
- border-right: solid 1px ${(props) => props.theme.notifications.list.borderRight};
- }
-`;
-
-export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Notifications/StyledWrapper.js b/packages/bruno-app/src/components/Notifications/StyledWrapper.js
new file mode 100644
index 000000000..d7996e383
--- /dev/null
+++ b/packages/bruno-app/src/components/Notifications/StyledWrapper.js
@@ -0,0 +1,32 @@
+import styled from 'styled-components';
+
+const StyledWrapper = styled.button`
+ position: relative;
+ cursor: pointer;
+ background: none;
+ border: none;
+ padding: 0;
+
+ .notification-count {
+ position: absolute;
+ top: -4px;
+ right: -6px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 14px;
+ height: 14px;
+ padding: 0 3px;
+ color: ${(props) => props.theme.background.base};
+ font-size: 9px;
+ font-weight: 600;
+ line-height: 1;
+ border-radius: 999px;
+ background-color: ${(props) => props.theme.brand};
+ border: 1.5px solid ${(props) => props.theme.sidebar.bg};
+ box-sizing: border-box;
+ pointer-events: none;
+ }
+`;
+
+export default StyledWrapper;
diff --git a/packages/bruno-app/src/components/Notifications/hooks/useNotifications.js b/packages/bruno-app/src/components/Notifications/hooks/useNotifications.js
new file mode 100644
index 000000000..876f50983
--- /dev/null
+++ b/packages/bruno-app/src/components/Notifications/hooks/useNotifications.js
@@ -0,0 +1,100 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ clearAllNotifications,
+ markAllNotificationsAsRead,
+ markNotificationAsRead
+} from 'providers/ReduxStore/slices/notifications';
+
+export const TABS = { ALL: 'all', UNREAD: 'unread' };
+
+const useNotifications = () => {
+ const dispatch = useDispatch();
+ const notifications = useSelector((state) => state.notifications.notifications);
+ const clearedIds = useSelector((state) => state.notifications.clearedNotificationIds);
+
+ const [isOpen, setIsOpen] = useState(false);
+ const [selectedNotification, setSelectedNotification] = useState(null);
+ const [activeTab, setActiveTab] = useState(TABS.ALL);
+ const [pinnedUnreadIds, setPinnedUnreadIds] = useState(null);
+
+ const visibleNotifications = useMemo(
+ () => notifications.filter((n) => !clearedIds?.includes(n.id)),
+ [notifications, clearedIds]
+ );
+ const unreadCount = visibleNotifications.filter((n) => !n.read).length;
+ // Pin the Unread set on tab entry so reading items doesn't make them vanish.
+ const listed = useMemo(() => {
+ if (activeTab !== TABS.UNREAD) return visibleNotifications;
+ if (!pinnedUnreadIds) return visibleNotifications.filter((n) => !n.read);
+ return visibleNotifications.filter((n) => pinnedUnreadIds.has(n.id));
+ }, [activeTab, visibleNotifications, pinnedUnreadIds]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ if (selectedNotification && listed.find((n) => n.id === selectedNotification.id)) return;
+ const first = listed[0];
+ if (!first) {
+ setSelectedNotification(null);
+ return;
+ }
+ setSelectedNotification(first);
+ if (!first.read) {
+ dispatch(markNotificationAsRead({ notificationId: first.id }));
+ }
+ }, [listed, selectedNotification, isOpen]);
+
+ const onTabChange = (tab) => {
+ if (tab === TABS.UNREAD) {
+ const ids = visibleNotifications.filter((n) => !n.read).map((n) => n.id);
+ setPinnedUnreadIds(new Set(ids));
+ } else {
+ setPinnedUnreadIds(null);
+ }
+ setActiveTab(tab);
+ };
+
+ const onSelect = (notification) => {
+ setSelectedNotification(notification);
+ if (!notification.read) {
+ dispatch(markNotificationAsRead({ notificationId: notification.id }));
+ }
+ };
+
+ const onMarkAllRead = () => {
+ dispatch(markAllNotificationsAsRead());
+ if (activeTab === TABS.UNREAD) {
+ setPinnedUnreadIds(null);
+ }
+ };
+ const onClearAll = () => dispatch(clearAllNotifications());
+
+ const open = () => {
+ window.ipcRenderer?.send('renderer:notifications-opened');
+ setIsOpen(true);
+ };
+
+ const close = () => {
+ setIsOpen(false);
+ setSelectedNotification(null);
+ setActiveTab(TABS.ALL);
+ setPinnedUnreadIds(null);
+ };
+
+ return {
+ isOpen,
+ visibleNotifications,
+ listed,
+ unreadCount,
+ activeTab,
+ selectedNotification,
+ open,
+ close,
+ onTabChange,
+ onSelect,
+ onMarkAllRead,
+ onClearAll
+ };
+};
+
+export default useNotifications;
diff --git a/packages/bruno-app/src/components/Notifications/index.js b/packages/bruno-app/src/components/Notifications/index.js
index 5d571b3d2..c1e07cdf5 100644
--- a/packages/bruno-app/src/components/Notifications/index.js
+++ b/packages/bruno-app/src/components/Notifications/index.js
@@ -1,214 +1,24 @@
import { IconBell } from '@tabler/icons';
-import { useState } from 'react';
-import StyledWrapper from './StyleWrapper';
-import Modal from 'components/Modal/index';
-import Portal from 'components/Portal';
-import { useEffect } from 'react';
-import { useApp } from 'providers/App';
-import {
- fetchNotifications,
- markAllNotificationsAsRead,
- markNotificationAsRead
-} from 'providers/ReduxStore/slices/notifications';
-import { useDispatch, useSelector } from 'react-redux';
-import { humanizeDate, relativeDate } from 'utils/common';
import ToolHint from 'components/ToolHint';
-import DOMPurify from 'dompurify';
-
-const PAGE_SIZE = 5;
+import StyledWrapper from './StyledWrapper';
+import NotificationsModal from './NotificationsModal';
+import useNotifications from './hooks/useNotifications';
const Notifications = () => {
- const dispatch = useDispatch();
- const { version } = useApp();
- const notifications = useSelector((state) => state.notifications.notifications);
-
- const [showNotificationsModal, setShowNotificationsModal] = useState(false);
- const [selectedNotification, setSelectedNotification] = useState(null);
- const [pageNumber, setPageNumber] = useState(1);
-
- const notificationsStartIndex = (pageNumber - 1) * PAGE_SIZE;
- const notificationsEndIndex = pageNumber * PAGE_SIZE;
- const totalPages = Math.ceil(notifications.length / PAGE_SIZE);
- const unreadNotifications = notifications.filter((notification) => !notification.read);
-
- useEffect(() => {
- dispatch(fetchNotifications({
- currentVersion: version
- }));
- }, []);
-
- useEffect(() => {
- reset();
- }, [showNotificationsModal]);
-
- useEffect(() => {
- if (!selectedNotification && notifications?.length > 0 && showNotificationsModal) {
- let firstNotification = notifications[0];
- setSelectedNotification(firstNotification);
- dispatch(markNotificationAsRead({ notificationId: firstNotification?.id }));
- }
- }, [notifications, selectedNotification, showNotificationsModal]);
-
- const reset = () => {
- setSelectedNotification(null);
- setPageNumber(1);
- };
-
- const handlePrev = (e) => {
- if (pageNumber - 1 < 1) return;
- setPageNumber(pageNumber - 1);
- };
-
- const handleNext = (e) => {
- if (pageNumber + 1 > totalPages) return;
- setPageNumber(pageNumber + 1);
- };
-
- const handleNotificationItemClick = (notification) => (e) => {
- e.preventDefault();
- setSelectedNotification(notification);
- dispatch(markNotificationAsRead({ notificationId: notification?.id }));
- };
-
- const getSanitizedDescription = (description) => {
- return DOMPurify.sanitize(encodeURIComponent(description), {
- ALLOWED_TAGS: ['a', 'ul', 'img', 'li', 'div', 'span', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'],
- ALLOWED_ATTR: ['href', 'style', 'target', 'src', 'alt']
- });
- };
-
- const modalCustomHeader = (
-
-
NOTIFICATIONS
- {unreadNotifications.length > 0 && (
- <>
-
- {unreadNotifications.length} unread notifications
-
-
- >
- )}
-
- );
+ const notifications = useNotifications();
+ const { isOpen, unreadCount, open, close } = notifications;
return (
-
- {
- dispatch(fetchNotifications({
- currentVersion: version
- }));
- setShowNotificationsModal(true);
- }}
- aria-label="Check all Notifications"
- >
+ <>
+
- 0 ? 'bell' : ''}`}
- />
- {unreadNotifications.length > 0 && (
- {unreadNotifications.length}
- )}
+
+ {unreadCount > 0 && {unreadCount}}
-
+
- {showNotificationsModal && (
-
- {
- setShowNotificationsModal(false);
- }}
- handleCancel={() => {
- setShowNotificationsModal(false);
- }}
- hideFooter={true}
- customHeader={modalCustomHeader}
- disableCloseOnOutsideClick={true}
- disableEscapeKey={true}
- >
-
- {notifications?.length > 0 ? (
-
-
-
-
-
-
- Page
-
- {pageNumber}
-
- of
-
- {totalPages}
-
-
-
-
-
-
-
{selectedNotification?.title}
-
- {humanizeDate(selectedNotification?.date)}
-
-
-
-
- ) : (
-
You are all caught up!
- )}
-
-
-
- )}
-
+ {isOpen && }
+ >
);
};
diff --git a/packages/bruno-app/src/providers/App/useIpcEvents.js b/packages/bruno-app/src/providers/App/useIpcEvents.js
index d91729405..2b527262f 100644
--- a/packages/bruno-app/src/providers/App/useIpcEvents.js
+++ b/packages/bruno-app/src/providers/App/useIpcEvents.js
@@ -38,6 +38,7 @@ import { isElectron } from 'utils/common/platform';
import { globalEnvironmentsUpdateEvent, updateGlobalEnvironments } from 'providers/ReduxStore/slices/global-environments';
import { collectionAddOauth2CredentialsByUrl, collectionClearOauth2CredentialsByCredentialsId, updateCollectionLoadingState } from 'providers/ReduxStore/slices/collections/index';
import { addLog } from 'providers/ReduxStore/slices/logs';
+import { loadNotifications } from 'providers/ReduxStore/slices/notifications';
import { updateSystemResources } from 'providers/ReduxStore/slices/performance';
import { apiSpecAddFileEvent, apiSpecChangeFileEvent } from 'providers/ReduxStore/slices/apiSpec';
@@ -343,6 +344,10 @@ const useIpcEvents = () => {
dispatch(setGitVersion(val));
});
+ const removeLoadNotificationsListener = ipcRenderer.on('main:load-notifications', (notifications) => {
+ dispatch(loadNotifications(notifications));
+ });
+
return () => {
removeCollectionTreeUpdateListener();
removeApiSpecTreeUpdateListener();
@@ -376,6 +381,7 @@ const useIpcEvents = () => {
removePersistentEnvVariablesUpdateListener();
removeSystemResourcesListener();
gitVersionListener();
+ removeLoadNotificationsListener();
};
}, [isElectron]);
};
diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js b/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js
index c03db6d6a..23e2ddc56 100644
--- a/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js
+++ b/packages/bruno-app/src/providers/ReduxStore/slices/notifications.js
@@ -2,6 +2,8 @@ import toast from 'react-hot-toast';
import { createSlice } from '@reduxjs/toolkit';
import { getAppInstallDate } from 'utils/common/platform';
import semver from 'semver';
+import { version } from '../../../../package.json';
+
const getReadNotificationIds = () => {
try {
let readNotificationIdsString = window.localStorage.getItem('bruno.notifications.read');
@@ -21,10 +23,27 @@ const setReadNotificationsIds = (val) => {
}
};
+const getClearedNotificationIds = () => {
+ try {
+ let raw = window.localStorage.getItem('bruno.notifications.cleared');
+ return raw ? JSON.parse(raw) : [];
+ } catch (err) {
+ return [];
+ }
+};
+
+const setClearedNotificationIds = (val) => {
+ try {
+ window.localStorage.setItem('bruno.notifications.cleared', JSON.stringify(val));
+ } catch (err) {
+ // ignore
+ }
+};
+
const initialState = {
- loading: false,
notifications: [],
- readNotificationIds: getReadNotificationIds() || []
+ readNotificationIds: getReadNotificationIds() || [],
+ clearedNotificationIds: getClearedNotificationIds() || []
};
export const filterNotificationsByVersion = (notifications, currentVersion) => {
@@ -51,9 +70,6 @@ export const notificationSlice = createSlice({
name: 'notifications',
initialState,
reducers: {
- setFetchingStatus: (state, action) => {
- state.loading = action.payload.fetching;
- },
setNotifications: (state, action) => {
let notifications = action.payload.notifications || [];
let readNotificationIds = state.readNotificationIds;
@@ -99,31 +115,25 @@ export const notificationSlice = createSlice({
state.notifications.forEach((notification) => {
notification.read = true;
});
+ },
+ clearAllNotifications: (state) => {
+ const ids = state.notifications.map((n) => n.id);
+ const merged = Array.from(new Set([...(state.clearedNotificationIds || []), ...ids]));
+ state.clearedNotificationIds = merged;
+ setClearedNotificationIds(merged);
}
}
});
-export const { setNotifications, setFetchingStatus, markNotificationAsRead, markAllNotificationsAsRead }
- = notificationSlice.actions;
+export const {
+ setNotifications,
+ markNotificationAsRead,
+ markAllNotificationsAsRead,
+ clearAllNotifications
+} = notificationSlice.actions;
-export const fetchNotifications = ({ currentVersion }) => (dispatch, getState) => {
- return new Promise((resolve) => {
- const { ipcRenderer } = window;
- dispatch(setFetchingStatus(true));
- ipcRenderer
- .invoke('renderer:fetch-notifications')
- .then((notifications) => {
- notifications = filterNotificationsByVersion(notifications, currentVersion);
- dispatch(setNotifications({ notifications }));
- dispatch(setFetchingStatus(false));
- resolve(notifications);
- })
- .catch((err) => {
- dispatch(setFetchingStatus(false));
- console.error(err);
- resolve([]);
- });
- });
+export const loadNotifications = (notifications) => (dispatch) => {
+ dispatch(setNotifications({ notifications: filterNotificationsByVersion(notifications, version) }));
};
export default notificationSlice.reducer;
diff --git a/packages/bruno-app/src/themes/dark/catppuccin-frappe.js b/packages/bruno-app/src/themes/dark/catppuccin-frappe.js
index 331f67dff..b57680f55 100644
--- a/packages/bruno-app/src/themes/dark/catppuccin-frappe.js
+++ b/packages/bruno-app/src/themes/dark/catppuccin-frappe.js
@@ -271,15 +271,13 @@ const catppuccinFrappeTheme = {
},
notifications: {
- bg: colors.SURFACE0,
+ bg: colors.BASE,
list: {
- bg: colors.SURFACE0,
- borderRight: colors.SURFACE2,
+ bg: colors.BASE,
borderBottom: colors.SURFACE1,
hoverBg: colors.SURFACE1,
active: {
- border: colors.BLUE,
- bg: colors.SURFACE2,
+ bg: colors.SURFACE0,
hoverBg: colors.SURFACE2
}
}
diff --git a/packages/bruno-app/src/themes/dark/catppuccin-macchiato.js b/packages/bruno-app/src/themes/dark/catppuccin-macchiato.js
index 79b52808d..c5863ec20 100644
--- a/packages/bruno-app/src/themes/dark/catppuccin-macchiato.js
+++ b/packages/bruno-app/src/themes/dark/catppuccin-macchiato.js
@@ -271,15 +271,13 @@ const catppuccinMacchiatoTheme = {
},
notifications: {
- bg: colors.SURFACE0,
+ bg: colors.BASE,
list: {
- bg: colors.SURFACE0,
- borderRight: colors.SURFACE2,
+ bg: colors.BASE,
borderBottom: colors.SURFACE1,
hoverBg: colors.SURFACE1,
active: {
- border: colors.BLUE,
- bg: colors.SURFACE2,
+ bg: colors.SURFACE0,
hoverBg: colors.SURFACE2
}
}
diff --git a/packages/bruno-app/src/themes/dark/catppuccin-mocha.js b/packages/bruno-app/src/themes/dark/catppuccin-mocha.js
index 09136cca1..46fe43b2d 100644
--- a/packages/bruno-app/src/themes/dark/catppuccin-mocha.js
+++ b/packages/bruno-app/src/themes/dark/catppuccin-mocha.js
@@ -271,15 +271,13 @@ const catppuccinMochaTheme = {
},
notifications: {
- bg: colors.SURFACE0,
+ bg: colors.BASE,
list: {
- bg: colors.SURFACE0,
- borderRight: colors.SURFACE2,
+ bg: colors.BASE,
borderBottom: colors.SURFACE1,
hoverBg: colors.SURFACE1,
active: {
- border: colors.BLUE,
- bg: colors.SURFACE2,
+ bg: colors.SURFACE0,
hoverBg: colors.SURFACE2
}
}
diff --git a/packages/bruno-app/src/themes/dark/dark-monochrome.js b/packages/bruno-app/src/themes/dark/dark-monochrome.js
index b9136d29b..be292a499 100644
--- a/packages/bruno-app/src/themes/dark/dark-monochrome.js
+++ b/packages/bruno-app/src/themes/dark/dark-monochrome.js
@@ -258,16 +258,14 @@ const darkMonochromeTheme = {
},
notifications: {
- bg: colors.GRAY_3,
+ bg: colors.BG,
list: {
- bg: '3D3D3D',
- borderRight: '#4f4f4f',
+ bg: colors.BG,
borderBottom: '#545454',
- hoverBg: '#434343',
+ hoverBg: colors.GRAY_3,
active: {
- border: '#a3a3a3',
- bg: '#4f4f4f',
- hoverBg: '#4f4f4f'
+ bg: colors.GRAY_2,
+ hoverBg: colors.GRAY_4
}
}
},
diff --git a/packages/bruno-app/src/themes/dark/dark-pastel.js b/packages/bruno-app/src/themes/dark/dark-pastel.js
index 06790f357..29ba300bb 100644
--- a/packages/bruno-app/src/themes/dark/dark-pastel.js
+++ b/packages/bruno-app/src/themes/dark/dark-pastel.js
@@ -274,16 +274,14 @@ const darkPastelTheme = {
},
notifications: {
- bg: colors.GRAY_3,
+ bg: colors.BG,
list: {
- bg: colors.GRAY_2,
- borderRight: colors.GRAY_4,
+ bg: colors.BG,
borderBottom: colors.GRAY_4,
- hoverBg: colors.GRAY_3,
+ hoverBg: colors.GRAY_4,
active: {
- border: colors.BRAND,
- bg: colors.GRAY_4,
- hoverBg: colors.GRAY_4
+ bg: colors.GRAY_3,
+ hoverBg: colors.GRAY_5
}
}
},
diff --git a/packages/bruno-app/src/themes/dark/dark.js b/packages/bruno-app/src/themes/dark/dark.js
index 480768ae2..237d7d0cc 100644
--- a/packages/bruno-app/src/themes/dark/dark.js
+++ b/packages/bruno-app/src/themes/dark/dark.js
@@ -303,16 +303,14 @@ const darkTheme = {
},
notifications: {
- bg: colors.GRAY_3,
+ bg: palette.background.BASE,
list: {
- bg: '3D3D3D',
- borderRight: '#4f4f4f',
- borderBottom: '#545454',
- hoverBg: '#434343',
+ bg: palette.background.BASE,
+ borderBottom: palette.border.BORDER0,
+ hoverBg: colors.GRAY_3,
active: {
- border: '#569cd6',
- bg: '#4f4f4f',
- hoverBg: '#4f4f4f'
+ bg: palette.background.SURFACE0,
+ hoverBg: colors.GRAY_4
}
}
},
diff --git a/packages/bruno-app/src/themes/dark/nord.js b/packages/bruno-app/src/themes/dark/nord.js
index 371a1b040..83c512141 100644
--- a/packages/bruno-app/src/themes/dark/nord.js
+++ b/packages/bruno-app/src/themes/dark/nord.js
@@ -273,16 +273,14 @@ const nordTheme = {
},
notifications: {
- bg: colors.NORD2,
+ bg: colors.NORD0,
list: {
- bg: colors.NORD1,
- borderRight: colors.NORD3,
+ bg: colors.NORD0,
borderBottom: colors.NORD3,
- hoverBg: colors.NORD2,
+ hoverBg: colors.NORD3,
active: {
- border: colors.NORD8,
bg: colors.NORD2,
- hoverBg: colors.NORD2
+ hoverBg: '#5d6b83'
}
}
},
diff --git a/packages/bruno-app/src/themes/dark/vscode.js b/packages/bruno-app/src/themes/dark/vscode.js
index accfa70c9..8cc0af4c3 100644
--- a/packages/bruno-app/src/themes/dark/vscode.js
+++ b/packages/bruno-app/src/themes/dark/vscode.js
@@ -276,16 +276,14 @@ const vscodeDarkTheme = {
},
notifications: {
- bg: colors.GRAY_3,
+ bg: colors.EDITOR_BG,
list: {
- bg: colors.GRAY_2,
- borderRight: colors.BORDER,
+ bg: colors.EDITOR_BG,
borderBottom: colors.BORDER,
- hoverBg: colors.GRAY_3,
+ hoverBg: colors.GRAY_4,
active: {
- border: colors.BRAND,
bg: colors.GRAY_3,
- hoverBg: colors.GRAY_3
+ hoverBg: colors.GRAY_5
}
}
},
diff --git a/packages/bruno-app/src/themes/light/catppuccin-latte.js b/packages/bruno-app/src/themes/light/catppuccin-latte.js
index 681644d79..f1c3edb9b 100644
--- a/packages/bruno-app/src/themes/light/catppuccin-latte.js
+++ b/packages/bruno-app/src/themes/light/catppuccin-latte.js
@@ -271,14 +271,12 @@ const catppuccinLatteTheme = {
notifications: {
bg: colors.BASE,
list: {
- bg: colors.MANTLE,
- borderRight: colors.SURFACE1,
+ bg: colors.BASE,
borderBottom: colors.SURFACE1,
- hoverBg: colors.SURFACE0,
+ hoverBg: colors.SURFACE1,
active: {
- border: colors.BLUE,
- bg: colors.SURFACE1,
- hoverBg: colors.SURFACE1
+ bg: colors.SURFACE0,
+ hoverBg: colors.SURFACE2
}
}
},
diff --git a/packages/bruno-app/src/themes/light/light-monochrome.js b/packages/bruno-app/src/themes/light/light-monochrome.js
index d4a495544..353bc0977 100644
--- a/packages/bruno-app/src/themes/light/light-monochrome.js
+++ b/packages/bruno-app/src/themes/light/light-monochrome.js
@@ -257,16 +257,14 @@ const lightMonochromeTheme = {
},
notifications: {
- bg: 'white',
+ bg: colors.BACKGROUND,
list: {
- bg: '#eaeaea',
- borderRight: 'transparent',
+ bg: colors.BACKGROUND,
borderBottom: '#d3d3d3',
- hoverBg: '#e4e4e4',
+ hoverBg: colors.GRAY_4,
active: {
- border: '#525252',
- bg: '#dcdcdc',
- hoverBg: '#dcdcdc'
+ bg: colors.GRAY_3,
+ hoverBg: colors.GRAY_5
}
}
},
diff --git a/packages/bruno-app/src/themes/light/light-pastel.js b/packages/bruno-app/src/themes/light/light-pastel.js
index 4cecf81ce..2b824d434 100644
--- a/packages/bruno-app/src/themes/light/light-pastel.js
+++ b/packages/bruno-app/src/themes/light/light-pastel.js
@@ -271,16 +271,14 @@ const lightPastelTheme = {
},
notifications: {
- bg: colors.WHITE,
+ bg: colors.BACKGROUND,
list: {
- bg: colors.GRAY_2,
- borderRight: 'transparent',
+ bg: colors.BACKGROUND,
borderBottom: colors.GRAY_4,
- hoverBg: colors.GRAY_3,
+ hoverBg: colors.GRAY_4,
active: {
- border: colors.BRAND,
bg: colors.GRAY_3,
- hoverBg: colors.GRAY_3
+ hoverBg: colors.GRAY_5
}
}
},
diff --git a/packages/bruno-app/src/themes/light/light.js b/packages/bruno-app/src/themes/light/light.js
index 1ee38b9f8..60929cb34 100644
--- a/packages/bruno-app/src/themes/light/light.js
+++ b/packages/bruno-app/src/themes/light/light.js
@@ -295,13 +295,11 @@ const lightTheme = {
notifications: {
bg: palette.background.BASE,
list: {
- bg: palette.background.SURFACE0,
- borderRight: 'transparent',
- borderBottom: palette.border.BORDER2,
+ bg: palette.background.BASE,
+ borderBottom: palette.border.BORDER0,
hoverBg: palette.background.SURFACE1,
active: {
- border: palette.hues.BLUE,
- bg: palette.background.SURFACE1,
+ bg: palette.background.SURFACE0,
hoverBg: palette.background.SURFACE2
}
}
diff --git a/packages/bruno-app/src/themes/light/vscode.js b/packages/bruno-app/src/themes/light/vscode.js
index 84da83302..9c258d615 100644
--- a/packages/bruno-app/src/themes/light/vscode.js
+++ b/packages/bruno-app/src/themes/light/vscode.js
@@ -275,16 +275,14 @@ const vscodeLightTheme = {
},
notifications: {
- bg: colors.WHITE,
+ bg: colors.EDITOR_BG,
list: {
- bg: colors.GRAY_2,
- borderRight: 'transparent',
+ bg: colors.EDITOR_BG,
borderBottom: colors.BORDER,
- hoverBg: colors.GRAY_3,
+ hoverBg: colors.GRAY_4,
active: {
- border: colors.BRAND,
bg: colors.GRAY_3,
- hoverBg: colors.GRAY_3
+ hoverBg: colors.GRAY_5
}
}
},
diff --git a/packages/bruno-app/src/themes/schema/oss.js b/packages/bruno-app/src/themes/schema/oss.js
index 10d9e27a2..42351eec4 100644
--- a/packages/bruno-app/src/themes/schema/oss.js
+++ b/packages/bruno-app/src/themes/schema/oss.js
@@ -394,21 +394,19 @@ export const ossSchema = {
type: 'object',
properties: {
bg: { type: 'string' },
- borderRight: { type: 'string' },
borderBottom: { type: 'string' },
hoverBg: { type: 'string' },
active: {
type: 'object',
properties: {
- border: { type: 'string' },
bg: { type: 'string' },
hoverBg: { type: 'string' }
},
- required: ['border', 'bg', 'hoverBg'],
+ required: ['bg', 'hoverBg'],
additionalProperties: false
}
},
- required: ['bg', 'borderRight', 'borderBottom', 'hoverBg', 'active'],
+ required: ['bg', 'borderBottom', 'hoverBg', 'active'],
additionalProperties: false
}
},
diff --git a/packages/bruno-electron/src/ipc/notifications.js b/packages/bruno-electron/src/ipc/notifications.js
index c49e87fed..5a2edd0c3 100644
--- a/packages/bruno-electron/src/ipc/notifications.js
+++ b/packages/bruno-electron/src/ipc/notifications.js
@@ -2,26 +2,24 @@ require('dotenv').config();
const { ipcMain } = require('electron');
const fetch = require('node-fetch');
+const fetchNotifications = async () => {
+ const url = process.env.BRUNO_INFO_ENDPOINT || 'https://appinfo.usebruno.com';
+ const data = await fetch(url).then((res) => res.json());
+ return data?.notifications || [];
+};
+
+const pushNotifications = async (mainWindow) => {
+ try {
+ const notifications = await fetchNotifications();
+ mainWindow.webContents.send('main:load-notifications', notifications);
+ } catch (error) {
+ console.error('Error while fetching notifications!', error);
+ }
+};
+
const registerNotificationsIpc = (mainWindow, watcher) => {
- ipcMain.handle('renderer:fetch-notifications', async () => {
- try {
- const notifications = await fetchNotifications();
- return Promise.resolve(notifications);
- } catch (error) {
- return Promise.reject(error);
- }
- });
+ mainWindow.webContents.on('did-finish-load', () => pushNotifications(mainWindow));
+ ipcMain.on('renderer:notifications-opened', () => pushNotifications(mainWindow));
};
module.exports = registerNotificationsIpc;
-
-const fetchNotifications = async () => {
- try {
- let url = process.env.BRUNO_INFO_ENDPOINT || 'https://appinfo.usebruno.com';
- const data = await fetch(url).then((res) => res.json());
-
- return data?.notifications || [];
- } catch (error) {
- return Promise.reject('Error while fetching notifications!', error);
- }
-};
diff --git a/tests/footer/notifications/notifications.spec.js b/tests/footer/notifications/notifications.spec.js
index 51da3805b..1737e1327 100644
--- a/tests/footer/notifications/notifications.spec.js
+++ b/tests/footer/notifications/notifications.spec.js
@@ -14,7 +14,7 @@ test.describe('Notifications Modal', () => {
// Verify modal is visible and has the correct title
await expect(notificationsModal).toBeVisible();
- await expect(notificationsModal.locator('.bruno-modal-header-title')).toContainText('NOTIFICATIONS');
+ await expect(notificationsModal.locator('.bruno-modal-header-title')).toContainText('Notifications');
// Click the close button
await modalCloseButton.click();