feat(#BRU-31): notifications feature draft (#1730)

* feat(#BRU-31): notifications feature
* feat(#BRU-31): date correction
This commit is contained in:
lohit 2024-03-11 17:48:52 +05:30 committed by GitHub
parent 1fca217046
commit b0f4491cd2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 574 additions and 5 deletions

View File

@ -1,9 +1,13 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import StyledWrapper from './StyledWrapper'; import StyledWrapper from './StyledWrapper';
const ModalHeader = ({ title, handleCancel }) => ( const ModalHeader = ({ title, handleCancel, headerContentComponent }) => (
<div className="bruno-modal-header"> <div className="bruno-modal-header">
{title ? <div className="bruno-modal-header-title">{title}</div> : null} {headerContentComponent ? (
headerContentComponent
) : (
<>{title ? <div className="bruno-modal-header-title">{title}</div> : null}</>
)}
{handleCancel ? ( {handleCancel ? (
<div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}> <div className="close cursor-pointer" onClick={handleCancel ? () => handleCancel() : null}>
× ×
@ -54,6 +58,7 @@ const ModalFooter = ({
const Modal = ({ const Modal = ({
size, size,
title, title,
headerContentComponent,
confirmText, confirmText,
cancelText, cancelText,
handleCancel, handleCancel,
@ -99,7 +104,11 @@ const Modal = ({
return ( return (
<StyledWrapper className={classes} onClick={onClick ? (e) => onClick(e) : null}> <StyledWrapper className={classes} onClick={onClick ? (e) => onClick(e) : null}>
<div className={`bruno-modal-card modal-${size}`}> <div className={`bruno-modal-card modal-${size}`}>
<ModalHeader title={title} handleCancel={() => closeModal({ type: 'icon' })} /> <ModalHeader
title={title}
handleCancel={() => closeModal({ type: 'icon' })}
headerContentComponent={headerContentComponent}
/>
<ModalContent>{children}</ModalContent> <ModalContent>{children}</ModalContent>
<ModalFooter <ModalFooter
confirmText={confirmText} confirmText={confirmText}

View File

@ -0,0 +1,91 @@
import styled from 'styled-components';
const StyledWrapper = styled.div`
.notifications-modal {
margin-inline: -1rem;
margin-block: -1.5rem;
background-color: ${(props) => props.theme.notifications.settings.bg};
}
.notification-count {
position: absolute;
right: -10px;
top: -15px;
z-index: 10;
margin-right: 0.5rem;
background-color: ${(props) => props.theme.notifications.bell.count};
border-radius: 50%;
padding: 2px 1px;
min-width: 20px;
display: flex;
justify-content: center;
font-size: 10px;
}
.bell {
animation: fade-and-pulse 1s ease-in-out 1s forwards;
}
ul {
background-color: ${(props) => props.theme.notifications.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.notifications.settings.sidebar.borderRight};
min-height: 400px;
height: 100%;
max-height: 85vh;
overflow-y: auto;
}
li {
min-width: 150px;
min-height: 5rem;
display: block;
position: relative;
cursor: pointer;
padding: 8px 10px;
border-left: solid 2px transparent;
border-bottom: solid 1px ${(props) => props.theme.notifications.settings.item.borderBottom};
font-weight: 600;
&:hover {
background-color: ${(props) => props.theme.notifications.settings.item.hoverBg};
}
}
.active {
font-weight: normal;
background-color: ${(props) => props.theme.notifications.settings.item.active.bg} !important;
border-left: solid 2px ${(props) => props.theme.notifications.settings.item.border};
&:hover {
background-color: ${(props) => props.theme.notifications.settings.item.active.hoverBg} !important;
}
}
.read {
opacity: 0.7;
font-weight: normal;
background-color: ${(props) => props.theme.notifications.settings.item.read.bg} !important;
&:hover {
background-color: ${(props) => props.theme.notifications.settings.item.read.hoverBg} !important;
}
}
.notification-title {
// text ellipses 2 lines
// white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.notification-date {
color: ${(props) => props.theme.notifications.settings.item.date.color} !important;
}
.pagination {
background-color: ${(props) => props.theme.notifications.settings.sidebar.bg};
border-right: solid 1px ${(props) => props.theme.notifications.settings.sidebar.borderRight};
}
`;
export default StyledWrapper;

View File

@ -0,0 +1,192 @@
import { IconBell } from '@tabler/icons';
import { useState } from 'react';
import StyledWrapper from './StyleWrapper';
import Modal from 'components/Modal/index';
import { useEffect } from 'react';
import {
fetchNotifications,
markMultipleNotificationsAsRead,
markNotificationAsRead
} from 'providers/ReduxStore/slices/app';
import { useDispatch, useSelector } from 'react-redux';
import { humanizeDate, relativeDate } from 'utils/common/index';
const Notifications = () => {
const dispatch = useDispatch();
const notificationsById = useSelector((state) => state.app.notifications);
const notifications = [...notificationsById].reverse();
const [showNotificationsModal, toggleNotificationsModal] = useState(false);
const [selectedNotification, setSelectedNotification] = useState(null);
const [pageSize, setPageSize] = useState(5);
const [pageNumber, setPageNumber] = useState(1);
const notificationsStartIndex = (pageNumber - 1) * pageSize;
const notificationsEndIndex = pageNumber * pageSize;
const totalPages = Math.ceil(notifications.length / pageSize);
useEffect(() => {
dispatch(fetchNotifications());
}, []);
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 unreadNotifications = notifications.filter((notification) => !notification.read);
const modalHeaderContentComponent = (
<div className="flex flex-row gap-8">
<div>NOTIFICATIONS</div>
{unreadNotifications.length > 0 && (
<>
<div className="normal-case font-normal">
{unreadNotifications.length} <i>unread notifications</i>
</div>
<button
className={`select-none ${1 == 2 ? 'opacity-50' : 'text-link cursor-pointer hover:underline'}`}
onClick={() => {
let allNotificationIds = notifications.map((notification) => notification.id);
dispatch(markMultipleNotificationsAsRead({ notificationIds: allNotificationIds }));
}}
>
{'Mark all as read'}
</button>
</>
)}
</div>
);
return (
<StyledWrapper>
<div
className="relative"
onClick={() => {
dispatch(fetchNotifications());
toggleNotificationsModal(true);
}}
>
<IconBell
size={18}
strokeWidth={1.5}
className={`mr-2 hover:text-gray-700 ${unreadNotifications?.length > 0 ? 'bell' : ''}`}
/>
{unreadNotifications.length > 0 && (
<div className="notification-count text-xs">{unreadNotifications.length}</div>
)}
</div>
{showNotificationsModal && (
<Modal
size="lg"
title="Notifications"
confirmText={'Close'}
handleConfirm={() => {
toggleNotificationsModal(false);
}}
handleCancel={() => {
toggleNotificationsModal(false);
}}
hideFooter={true}
headerContentComponent={modalHeaderContentComponent}
>
<div className="notifications-modal">
{notifications?.length > 0 ? (
<div className="grid grid-cols-4 flex flex-row text-sm">
<div className="col-span-1 flex flex-col">
<ul
className="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
className={`p-4 flex flex-col gap-2 ${
selectedNotification?.id == notification?.id ? 'active' : notification?.read ? 'read' : ''
}`}
onClick={handleNotificationItemClick(notification)}
>
<div className="notification-title w-full">{notification?.title}</div>
{/* human readable relative date */}
<div className="notification-date w-full flex justify-start font-normal text-xs py-2">
{relativeDate(notification?.date)}
</div>
</li>
))}
</ul>
<div className="w-full pagination flex flex-row gap-4 justify-center py-4 items-center">
<button
className={`pl-2 pr-2 py-3 select-none ${
pageNumber <= 1 ? 'opacity-50' : 'text-link cursor-pointer hover:underline'
}`}
onClick={handlePrev}
>
{'Previous'}
</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 gap-2">
<div className="w-full text-lg flex flex-wrap h-fit">{selectedNotification?.title}</div>
<div className="w-full notification-date">{humanizeDate(selectedNotification?.date)}</div>
<div
className="flex w-full flex-col flex-wrap h-fit"
dangerouslySetInnerHTML={{ __html: selectedNotification?.description }}
></div>
</div>
</div>
) : (
<div className="opacity-50 italic text-xs p-12 flex justify-center">No Notifications</div>
)}
</div>
</Modal>
)}
</StyledWrapper>
);
};
export default Notifications;

View File

@ -11,6 +11,7 @@ import { useSelector, useDispatch } from 'react-redux';
import { IconSettings, IconCookie, IconHeart } from '@tabler/icons'; import { IconSettings, IconCookie, IconHeart } from '@tabler/icons';
import { updateLeftSidebarWidth, updateIsDragging, showPreferences } from 'providers/ReduxStore/slices/app'; import { updateLeftSidebarWidth, updateIsDragging, showPreferences } from 'providers/ReduxStore/slices/app';
import { useTheme } from 'providers/Theme'; import { useTheme } from 'providers/Theme';
import Notifications from 'components/Notifications/index';
const MIN_LEFT_SIDEBAR_WIDTH = 221; const MIN_LEFT_SIDEBAR_WIDTH = 221;
const MAX_LEFT_SIDEBAR_WIDTH = 600; const MAX_LEFT_SIDEBAR_WIDTH = 600;
@ -112,6 +113,7 @@ const Sidebar = () => {
className="mr-2 hover:text-gray-700" className="mr-2 hover:text-gray-700"
onClick={() => setGoldenEditonOpen(true)} onClick={() => setGoldenEditonOpen(true)}
/> />
<Notifications />
</div> </div>
<div className="pl-1" style={{ position: 'relative', top: '3px' }}> <div className="pl-1" style={{ position: 'relative', top: '3px' }}>
{/* This will get moved to home page */} {/* This will get moved to home page */}

View File

@ -141,6 +141,18 @@ const GlobalStyle = createGlobalStyle`
} }
} }
@keyframes fade-and-pulse {
0% {
scale: 1;
}
20% {
scale: 1.5;
}
100% {
scale: 1;
}
}
@keyframes rotateClockwise { @keyframes rotateClockwise {
0% { 0% {
transform: scaleY(-1) rotate(0deg); transform: scaleY(-1) rotate(0deg);

View File

@ -1,6 +1,8 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
import filter from 'lodash/filter'; import filter from 'lodash/filter';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { getReadNotificationIds, setReadNotificationsIds } from 'utils/common/notifications';
import { getAppInstallDate } from 'utils/common/platform';
const initialState = { const initialState = {
isDragging: false, isDragging: false,
@ -24,7 +26,10 @@ const initialState = {
} }
}, },
cookies: [], cookies: [],
taskQueue: [] taskQueue: [],
notifications: [],
fetchingNotifications: false,
readNotificationIds: getReadNotificationIds() || []
}; };
export const appSlice = createSlice({ export const appSlice = createSlice({
@ -69,6 +74,70 @@ export const appSlice = createSlice({
}, },
removeAllTasksFromQueue: (state) => { removeAllTasksFromQueue: (state) => {
state.taskQueue = []; state.taskQueue = [];
},
fetchingNotifications: (state, action) => {
state.fetchingNotifications = action.payload.fetching;
},
updateNotifications: (state, action) => {
let notifications = action.payload.notifications || [];
let readNotificationIds = state.readNotificationIds;
// App installed date
let appInstalledOnDate = getAppInstallDate();
// date 5 days before
let dateFiveDaysBefore = new Date();
dateFiveDaysBefore.setDate(dateFiveDaysBefore.getDate() - 5);
// check if app was installed in the last 5 days
if (appInstalledOnDate > dateFiveDaysBefore) {
// filter out notifications that were sent before the app was installed
notifications = notifications.filter(
(notification) => new Date(notification.date) > new Date(appInstalledOnDate)
);
} else {
// filter out notifications that sent within the last 5 days
notifications = notifications.filter(
(notification) => new Date(notification.date) > new Date(dateFiveDaysBefore)
);
}
state.notifications = notifications.map((notification) => {
return {
...notification,
read: readNotificationIds.includes(notification.id)
};
});
},
markNotificationAsRead: (state, action) => {
let readNotificationIds = state.readNotificationIds;
readNotificationIds.push(action.payload.notificationId);
state.readNotificationIds = readNotificationIds;
// set the read notification ids in the localstorage
setReadNotificationsIds(readNotificationIds);
state.notifications = state.notifications.map((notification) => {
return {
...notification,
read: readNotificationIds.includes(notification.id)
};
});
},
markMultipleNotificationsAsRead: (state, action) => {
let readNotificationIds = state.readNotificationIds;
readNotificationIds.push(...action.payload.notificationIds);
state.readNotificationIds = readNotificationIds;
// set the read notification ids in the localstorage
setReadNotificationsIds(readNotificationIds);
state.notifications = state.notifications.map((notification) => {
return {
...notification,
read: readNotificationIds.includes(notification.id)
};
});
} }
} }
}); });
@ -86,7 +155,12 @@ export const {
updateCookies, updateCookies,
insertTaskIntoQueue, insertTaskIntoQueue,
removeTaskFromQueue, removeTaskFromQueue,
removeAllTasksFromQueue removeAllTasksFromQueue,
updateNotifications,
fetchingNotifications,
mergeNotifications,
markNotificationAsRead,
markMultipleNotificationsAsRead
} = appSlice.actions; } = appSlice.actions;
export const savePreferences = (preferences) => (dispatch, getState) => { export const savePreferences = (preferences) => (dispatch, getState) => {
@ -114,6 +188,26 @@ export const deleteCookiesForDomain = (domain) => (dispatch, getState) => {
}); });
}; };
export const fetchNotifications = () => (dispatch, getState) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
dispatch(fetchingNotifications({ fetching: true }));
ipcRenderer
.invoke('renderer:fetch-notifications')
.then((notifications) => {
dispatch(updateNotifications({ notifications }));
dispatch(fetchingNotifications({ fetching: false }));
})
.then(resolve)
.catch((err) => {
toast.error('An error occurred while fetching notifications');
dispatch(fetchingNotifications({ fetching: false }));
console.error(err);
resolve();
});
});
};
export const completeQuitFlow = () => (dispatch, getState) => { export const completeQuitFlow = () => (dispatch, getState) => {
const { ipcRenderer } = window; const { ipcRenderer } = window;
return ipcRenderer.invoke('main:complete-quit-flow'); return ipcRenderer.invoke('main:complete-quit-flow');

View File

@ -136,6 +136,37 @@ const darkTheme = {
} }
}, },
notifications: {
bg: '#3D3D3D',
settings: {
bg: '#3D3D3D',
sidebar: {
bg: '#3D3D3D',
borderRight: '#4f4f4f'
},
item: {
border: '#569cd6',
hoverBg: 'transparent',
borderBottom: '#4f4f4f99',
active: {
bg: '#4f4f4f',
hoverBg: '#4f4f4f'
},
read: {
bg: '#4f4f4f55',
hoverBg: '#4f4f4f'
},
date: {
color: '#ccc9'
}
},
gridBorder: '#4f4f4f'
},
bell: {
count: '#cc7b1b55'
}
},
modal: { modal: {
title: { title: {
color: '#ccc', color: '#ccc',

View File

@ -140,6 +140,40 @@ const lightTheme = {
} }
}, },
notifications: {
bg: '#efefef',
settings: {
bg: 'white',
sidebar: {
bg: '#eaeaea',
borderRight: 'transparent'
},
item: {
border: '#546de5',
borderBottom: '#4f4f4f44',
hoverBg: '#e4e4e4',
active: {
bg: '#dcdcdc',
hoverBg: '#dcdcdc'
},
read: {
bg: '#dcdcdc55',
hoverBg: '#dcdcdc'
},
date: {
color: '#5f5f5f'
}
},
gridBorder: '#f4f4f4'
},
sidebar: {
bg: '#eaeaea'
},
bell: {
count: '#cc7b1b55'
}
},
modal: { modal: {
title: { title: {
color: 'rgb(86 86 86)', color: 'rgb(86 86 86)',

View File

@ -120,3 +120,42 @@ export const startsWith = (str, search) => {
export const pluralizeWord = (word, count) => { export const pluralizeWord = (word, count) => {
return count === 1 ? word : `${word}s`; return count === 1 ? word : `${word}s`;
}; };
export const relativeDate = (dateString) => {
const date = new Date(dateString);
const currentDate = new Date();
const difference = currentDate - date;
const secondsDifference = Math.floor(difference / 1000);
const minutesDifference = Math.floor(secondsDifference / 60);
const hoursDifference = Math.floor(minutesDifference / 60);
const daysDifference = Math.floor(hoursDifference / 24);
const weeksDifference = Math.floor(daysDifference / 7);
const monthsDifference = Math.floor(daysDifference / 30);
if (secondsDifference < 60) {
return 'Few seconds ago';
} else if (minutesDifference < 60) {
return `${minutesDifference} minute${minutesDifference > 1 ? 's' : ''} ago`;
} else if (hoursDifference < 24) {
return `${hoursDifference} hour${hoursDifference > 1 ? 's' : ''} ago`;
} else if (daysDifference < 7) {
return `${daysDifference} day${daysDifference > 1 ? 's' : ''} ago`;
} else if (weeksDifference < 4) {
return `${weeksDifference} week${weeksDifference > 1 ? 's' : ''} ago`;
} else {
return `${monthsDifference} month${monthsDifference > 1 ? 's' : ''} ago`;
}
};
export const humanizeDate = (dateString) => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
});
};

View File

@ -0,0 +1,19 @@
import toast from 'react-hot-toast';
export const getReadNotificationIds = () => {
try {
let readNotificationIdsString = window.localStorage.getItem('bruno.notifications.read');
let readNotificationIds = readNotificationIdsString ? JSON.parse(readNotificationIdsString) : [];
return readNotificationIds;
} catch (err) {
toast.error('An error occurred while fetching read notifications');
}
};
export const setReadNotificationsIds = (val) => {
try {
window.localStorage.setItem('bruno.notifications.read', JSON.stringify(val));
} catch (err) {
toast.error('An error occurred while setting read notifications');
}
};

View File

@ -46,3 +46,15 @@ export const isMacOS = () => {
}; };
export const PATH_SEPARATOR = isWindowsOS() ? '\\' : '/'; export const PATH_SEPARATOR = isWindowsOS() ? '\\' : '/';
export const getAppInstallDate = () => {
let dateString = localStorage.getItem('bruno.installedOn');
if (!dateString) {
dateString = new Date().toISOString();
localStorage.setItem('bruno.installedOn', dateString);
}
const date = new Date(dateString);
return date;
};

View File

@ -0,0 +1 @@
BRUNO_INFO_ENDPOINT = http://localhost:8081

View File

@ -12,6 +12,7 @@ const registerCollectionsIpc = require('./ipc/collection');
const registerPreferencesIpc = require('./ipc/preferences'); const registerPreferencesIpc = require('./ipc/preferences');
const Watcher = require('./app/watcher'); const Watcher = require('./app/watcher');
const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window'); const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window');
const registerNotificationsIpc = require('./ipc/notifications');
const lastOpenedCollections = new LastOpenedCollections(); const lastOpenedCollections = new LastOpenedCollections();
@ -120,6 +121,7 @@ app.on('ready', async () => {
registerNetworkIpc(mainWindow); registerNetworkIpc(mainWindow);
registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections); registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections);
registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections); registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections);
registerNotificationsIpc(mainWindow, watcher);
}); });
// Quit the app once all windows are closed // Quit the app once all windows are closed

View File

@ -0,0 +1,31 @@
require('dotenv').config();
const { ipcMain } = require('electron');
const fetch = require('node-fetch');
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);
}
});
};
module.exports = registerNotificationsIpc;
const fetchNotifications = async (props) => {
try {
const { lastNotificationId } = props || {};
let url = process.env.BRUNO_INFO_ENDPOINT;
if (!url) {
return Promise.reject('Invalid notifications endpoint', error);
}
if (lastNotificationId) url += `?lastNotificationId=${lastNotificationId}`;
const data = await fetch(url).then((res) => res.json());
return data?.notifications || [];
} catch (error) {
return Promise.reject('Error while fetching notifications!', error);
}
};