mirror of
https://github.com/usebruno/bruno.git
synced 2026-06-27 22:54:07 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user