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

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