mirror of
https://github.com/usebruno/bruno.git
synced 2026-07-09 06:55:03 +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:
@@ -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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user