feat: redesign notification modal (#8140)

* fix: test expect

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: maintain state for read and cleared notification ids

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* feat: revamp Notifications

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: break things into components and use events

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: icon + more padding

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* chore: use classnames

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* chore: remove redundancy

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* chore: make it pixel accurate

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: remove redundant useMemo

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: colors of notification modal

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: use color paletter + fix badge color

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: ensure semantics for notification icon

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: handle keyboard navigation for drawer items

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: colors, no notification view, etc

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: don't crash on color of badge that is invalid

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix: use hex color for type of notification
Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

* fix

* Apply suggestions from code review

Co-authored-by: Sid <siddharth@usebruno.com>

* fix: use parseToRgb instead of custom isHexColor check + add unit tests

Co-authored-by: Prateek Sunal
<41370460+prateekmedia@users.noreply.github.com>

* fix: pointer events getting swallowed by iframe, causing resize issue

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>

---------

Co-authored-by: Prateek Sunal <41370460+prateekmedia@users.noreply.github.com>
Co-authored-by: naman-bruno <naman@usebruno.com>
Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
prateek-bruno
2026-06-18 21:28:47 +05:30
committed by GitHub
parent a7efed674e
commit 6711ccdda2
29 changed files with 866 additions and 419 deletions

View File

@@ -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.
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null} data-testid="modal-close-button">
×
<IconX size={16} strokeWidth={1.5} />
</div>
) : null}
</div>
);
const ModalContent = ({ children }) => <div className="bruno-modal-content px-4 py-4">{children}</div>;
const ModalContent = ({ children, noPadding }) => (
<div className={`bruno-modal-content ${noPadding ? '' : 'px-4 py-4'}`}>{children}</div>
);
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}
/>
<ModalContent>{children}</ModalContent>
<ModalContent noPadding={noPadding}>{children}</ModalContent>
<ModalFooter
confirmText={confirmText}
cancelText={cancelText}

View File

@@ -0,0 +1,95 @@
import DOMPurify from 'dompurify';
import { parseToRgb, rgba } from 'polished';
import { useTheme } from 'providers/Theme';
import { humanizeDate } from 'utils/common';
// color may be any CSS color (hex, rgb, hsl): solid text on a 15% tinted bg.
// Falls back to the theme's purple when the supplied color can't be parsed.
export const getBadgeStyle = (color, theme) => {
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 `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base target="_blank" />
<style>
html, body { margin: 0; padding: 0; background: ${theme.notifications.bg}; }
body {
padding: 8px 12px;
font-family: Inter, sans-serif;
font-size: 12px;
line-height: 20px;
font-weight: 500;
color: ${theme.colors.text.muted};
word-break: break-word;
}
p { margin: 0 0 0.75rem 0; }
a { color: ${theme.textLink}; text-decoration: underline; }
h1, h2, h3, h4, h5, h6 { font-size: 13px; font-weight: 600; margin: 0 0 0.5rem 0; color: ${theme.text}; }
ul { padding-left: 1.25rem; margin: 0 0 0.75rem 0; }
img { max-width: 100%; }
</style>
</head>
<body>${body}</body>
</html>`;
};
if (!notification) {
return (
<div className="notif-detail">
<div className="notif-empty">Select a notification to read more.</div>
</div>
);
}
return (
<div className="notif-detail">
<div className="notif-detail-header">
<div className="notif-detail-meta">
{notification.type && (
<span className="notif-type-badge" style={getBadgeStyle(notification.color, theme)}>
{notification.type}
</span>
)}
<span className="notif-detail-date">{humanizeDate(notification.date)}</span>
</div>
<div className="notif-detail-title">{notification.title}</div>
</div>
<iframe
key={notification.id}
className="notif-detail-body"
title="Notification details"
sandbox="allow-popups"
srcDoc={buildDescriptionDocument(notification.description)}
/>
</div>
);
};
export default NotificationDetail;

View File

@@ -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));
});
});

View File

@@ -0,0 +1,35 @@
import classnames from 'classnames';
import { relativeDate } from 'utils/common';
const NotificationList = ({ items, selectedId, onSelect }) => {
return (
<ul className="notif-list">
{items.map((notification) => {
const isActive = selectedId === notification.id;
const isUnread = !notification.read;
return (
<li
key={notification.id}
className={classnames('notif-list-item', { active: isActive, unread: isUnread })}
role="button"
tabIndex={0}
onClick={() => onSelect(notification)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
onSelect(notification);
}
}}
>
<div className={classnames('notif-item-title', { unread: isUnread })}>{notification.title}</div>
<div className="notif-item-date">{relativeDate(notification.date)}</div>
</li>
);
})}
{items.length === 0 && <li className="notif-list-empty">No notifications to show.</li>}
</ul>
);
};
export default NotificationList;

View File

@@ -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 = (
<span className="notif-menu-trigger" aria-label="Notifications menu">
<IconDotsVertical size={16} strokeWidth={1.5} />
</span>
);
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 (
<div className="notif-tabs">
<div className="notif-tab-group">
<button
type="button"
className={classnames('notif-tab', { active: activeTab === TABS.ALL })}
onClick={() => onTabChange(TABS.ALL)}
>
All
</button>
<button
type="button"
className={classnames('notif-tab', { active: activeTab === TABS.UNREAD })}
onClick={() => onTabChange(TABS.UNREAD)}
>
Unread
{unreadCount > 0 && <span className="notif-tab-badge">{unreadCount}</span>}
</button>
</div>
<Dropdown icon={menuIcon} placement="bottom-end" onCreate={onDropdownCreate}>
<div
className={classnames('dropdown-item', { disabled: unreadCount === 0 })}
onClick={() => {
if (unreadCount === 0) return;
hideDropdown();
onMarkAllRead();
}}
>
Mark all as read
</div>
<div
className="dropdown-item"
onClick={() => {
hideDropdown();
onClearAll();
}}
>
Clear all
</div>
</Dropdown>
</div>
);
};
export default NotificationTabs;

View File

@@ -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;

View File

@@ -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 (
<Portal>
<Modal
size="md"
title="Notifications"
confirmText="Close"
handleConfirm={onClose}
handleCancel={onClose}
hideFooter={true}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
noPadding={true}
>
<StyledWrapper className={classnames('notifications-modal', { dragging })} ref={containerRef}>
<div className="notif-sidebar" style={{ width: effectiveWidth, flexBasis: effectiveWidth }}>
<NotificationTabs
activeTab={activeTab}
unreadCount={unreadCount}
onTabChange={onTabChange}
onMarkAllRead={onMarkAllRead}
onClearAll={onClearAll}
/>
<NotificationList items={listed} selectedId={selectedNotification?.id} onSelect={onSelect} />
</div>
<div
className={classnames('notif-resize-handle', { dragging })}
{...dragbarProps}
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
/>
{isEmpty ? (
<div className="notif-empty">
<div className="notif-empty-text">You are all caught up!</div>
</div>
) : (
<NotificationDetail notification={selectedNotification} />
)}
</StyledWrapper>
</Modal>
</Portal>
);
};
export default NotificationsModal;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 = (
<div className="flex flex-row gap-8">
<div className="bruno-modal-header-title">NOTIFICATIONS</div>
{unreadNotifications.length > 0 && (
<>
<div className="normal-case font-normal">
{unreadNotifications.length} <span>unread notifications</span>
</div>
<button
className={`select-none ${1 == 2 ? 'opacity-50' : 'text-link mark-as-read cursor-pointer hover:underline'}`}
onClick={() => dispatch(markAllNotificationsAsRead())}
>
Mark all as read
</button>
</>
)}
</div>
);
const notifications = useNotifications();
const { isOpen, unreadCount, open, close } = notifications;
return (
<StyledWrapper>
<a
className="relative cursor-pointer"
onClick={() => {
dispatch(fetchNotifications({
currentVersion: version
}));
setShowNotificationsModal(true);
}}
aria-label="Check all Notifications"
>
<>
<StyledWrapper onClick={open} aria-label="Check all Notifications">
<ToolHint text="Notifications" toolhintId="Notifications" offset={8}>
<IconBell
size={16}
aria-hidden
strokeWidth={1.5}
className={`${unreadNotifications?.length > 0 ? 'bell' : ''}`}
/>
{unreadNotifications.length > 0 && (
<span className="notification-count text-xs">{unreadNotifications.length}</span>
)}
<IconBell size={16} aria-hidden strokeWidth={1.5} />
{unreadCount > 0 && <span className="notification-count">{unreadCount}</span>}
</ToolHint>
</a>
</StyledWrapper>
{showNotificationsModal && (
<Portal>
<Modal
size="lg"
title="Notifications"
confirmText="Close"
handleConfirm={() => {
setShowNotificationsModal(false);
}}
handleCancel={() => {
setShowNotificationsModal(false);
}}
hideFooter={true}
customHeader={modalCustomHeader}
disableCloseOnOutsideClick={true}
disableEscapeKey={true}
>
<div className="notifications-modal">
{notifications?.length > 0 ? (
<div className="grid grid-cols-4 flex flex-row">
<div className="col-span-1 flex flex-col">
<ul
className="notifications w-full flex flex-col h-[50vh] max-h-[50vh] overflow-y-auto"
style={{ maxHeight: '50vh', height: '46vh' }}
>
{notifications?.slice(notificationsStartIndex, notificationsEndIndex)?.map((notification) => (
<li
key={notification.id}
className={`p-4 flex flex-col justify-center ${
selectedNotification?.id == notification.id ? 'active' : notification.read ? 'read' : ''
}`}
onClick={handleNotificationItemClick(notification)}
>
<div className="notification-title w-full">{notification?.title}</div>
<div className="notification-date text-xs py-2">{relativeDate(notification?.date)}</div>
</li>
))}
</ul>
<div className="w-full pagination flex flex-row gap-4 justify-center p-2 items-center text-xs">
<button
className={`pl-2 pr-2 py-3 select-none ${
pageNumber <= 1 ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
}`}
onClick={handlePrev}
>
Prev
</button>
<div className="flex flex-row items-center justify-center gap-1">
Page
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
{pageNumber}
</div>
of
<div className="w-[20px] flex justify-center" style={{ width: '20px' }}>
{totalPages}
</div>
</div>
<button
className={`pl-2 pr-2 py-3 select-none ${
pageNumber == totalPages ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
}`}
onClick={handleNext}
>
Next
</button>
</div>
</div>
<div className="flex w-full col-span-3 p-4 flex-col">
<div className="w-full text-lg flex flex-wrap h-fit mb-1">{selectedNotification?.title}</div>
<div className="w-full notification-date text-xs mb-4">
{humanizeDate(selectedNotification?.date)}
</div>
<iframe
src={`data:text/html,${getSanitizedDescription(selectedNotification?.description)}`}
sandbox="allow-popups"
style={{ width: '100%', height: '100%' }}
>
</iframe>
</div>
</div>
) : (
<div className="opacity-50 italic text-xs p-12 flex justify-center">You are all caught up!</div>
)}
</div>
</Modal>
</Portal>
)}
</StyledWrapper>
{isOpen && <NotificationsModal notifications={notifications} onClose={close} />}
</>
);
};

View File

@@ -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]);
};

View File

@@ -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;

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}
},

View File

@@ -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
}
}
},

View File

@@ -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
}
}
},

View File

@@ -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'
}
}
},

View File

@@ -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
}
}
},

View File

@@ -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
}
}
},

View File

@@ -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
}
}
},

View File

@@ -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
}
}
},

View File

@@ -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
}
}

View File

@@ -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
}
}
},

View File

@@ -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
}
},

View File

@@ -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);
}
};

View File

@@ -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();