diff --git a/packages/bruno-app/src/components/Modal/index.js b/packages/bruno-app/src/components/Modal/index.js index 46cfef28b..9ad452fd8 100644 --- a/packages/bruno-app/src/components/Modal/index.js +++ b/packages/bruno-app/src/components/Modal/index.js @@ -1,9 +1,13 @@ import React, { useEffect, useState } from 'react'; import StyledWrapper from './StyledWrapper'; -const ModalHeader = ({ title, handleCancel }) => ( +const ModalHeader = ({ title, handleCancel, headerContentComponent }) => (
- {title ?
{title}
: null} + {headerContentComponent ? ( + headerContentComponent + ) : ( + <>{title ?
{title}
: null} + )} {handleCancel ? (
handleCancel() : null}> × @@ -54,6 +58,7 @@ const ModalFooter = ({ const Modal = ({ size, title, + headerContentComponent, confirmText, cancelText, handleCancel, @@ -99,7 +104,11 @@ const Modal = ({ return ( onClick(e) : null}>
- closeModal({ type: 'icon' })} /> + closeModal({ type: 'icon' })} + headerContentComponent={headerContentComponent} + /> {children} 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; diff --git a/packages/bruno-app/src/components/Notifications/index.js b/packages/bruno-app/src/components/Notifications/index.js new file mode 100644 index 000000000..6c6216f09 --- /dev/null +++ b/packages/bruno-app/src/components/Notifications/index.js @@ -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 = ( +
+
NOTIFICATIONS
+ {unreadNotifications.length > 0 && ( + <> +
+ {unreadNotifications.length} unread notifications +
+ + + )} +
+ ); + + return ( + +
{ + dispatch(fetchNotifications()); + toggleNotificationsModal(true); + }} + > + 0 ? 'bell' : ''}`} + /> + {unreadNotifications.length > 0 && ( +
{unreadNotifications.length}
+ )} +
+ {showNotificationsModal && ( + { + toggleNotificationsModal(false); + }} + handleCancel={() => { + toggleNotificationsModal(false); + }} + hideFooter={true} + headerContentComponent={modalHeaderContentComponent} + > +
+ {notifications?.length > 0 ? ( +
+
+
    + {notifications?.slice(notificationsStartIndex, notificationsEndIndex)?.map((notification) => ( +
  • +
    {notification?.title}
    + {/* human readable relative date */} +
    + {relativeDate(notification?.date)} +
    +
  • + ))} +
+
+ +
+ Page +
+ {pageNumber} +
+ of +
+ {totalPages} +
+
+ +
+
+
+
{selectedNotification?.title}
+
{humanizeDate(selectedNotification?.date)}
+
+
+
+ ) : ( +
No Notifications
+ )} +
+
+ )} +
+ ); +}; + +export default Notifications; diff --git a/packages/bruno-app/src/components/Sidebar/index.js b/packages/bruno-app/src/components/Sidebar/index.js index 1ac6509b8..0ed75df71 100644 --- a/packages/bruno-app/src/components/Sidebar/index.js +++ b/packages/bruno-app/src/components/Sidebar/index.js @@ -11,6 +11,7 @@ import { useSelector, useDispatch } from 'react-redux'; import { IconSettings, IconCookie, IconHeart } from '@tabler/icons'; import { updateLeftSidebarWidth, updateIsDragging, showPreferences } from 'providers/ReduxStore/slices/app'; import { useTheme } from 'providers/Theme'; +import Notifications from 'components/Notifications/index'; const MIN_LEFT_SIDEBAR_WIDTH = 221; const MAX_LEFT_SIDEBAR_WIDTH = 600; @@ -112,6 +113,7 @@ const Sidebar = () => { className="mr-2 hover:text-gray-700" onClick={() => setGoldenEditonOpen(true)} /> +
{/* This will get moved to home page */} diff --git a/packages/bruno-app/src/globalStyles.js b/packages/bruno-app/src/globalStyles.js index 1add03cfb..5e2efa423 100644 --- a/packages/bruno-app/src/globalStyles.js +++ b/packages/bruno-app/src/globalStyles.js @@ -141,6 +141,18 @@ const GlobalStyle = createGlobalStyle` } } + @keyframes fade-and-pulse { + 0% { + scale: 1; + } + 20% { + scale: 1.5; + } + 100% { + scale: 1; + } + } + @keyframes rotateClockwise { 0% { transform: scaleY(-1) rotate(0deg); diff --git a/packages/bruno-app/src/providers/ReduxStore/slices/app.js b/packages/bruno-app/src/providers/ReduxStore/slices/app.js index f4dd7393d..6c82690a7 100644 --- a/packages/bruno-app/src/providers/ReduxStore/slices/app.js +++ b/packages/bruno-app/src/providers/ReduxStore/slices/app.js @@ -1,6 +1,8 @@ import { createSlice } from '@reduxjs/toolkit'; import filter from 'lodash/filter'; import toast from 'react-hot-toast'; +import { getReadNotificationIds, setReadNotificationsIds } from 'utils/common/notifications'; +import { getAppInstallDate } from 'utils/common/platform'; const initialState = { isDragging: false, @@ -24,7 +26,10 @@ const initialState = { } }, cookies: [], - taskQueue: [] + taskQueue: [], + notifications: [], + fetchingNotifications: false, + readNotificationIds: getReadNotificationIds() || [] }; export const appSlice = createSlice({ @@ -69,6 +74,70 @@ export const appSlice = createSlice({ }, removeAllTasksFromQueue: (state) => { 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, insertTaskIntoQueue, removeTaskFromQueue, - removeAllTasksFromQueue + removeAllTasksFromQueue, + updateNotifications, + fetchingNotifications, + mergeNotifications, + markNotificationAsRead, + markMultipleNotificationsAsRead } = appSlice.actions; 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) => { const { ipcRenderer } = window; return ipcRenderer.invoke('main:complete-quit-flow'); diff --git a/packages/bruno-app/src/themes/dark.js b/packages/bruno-app/src/themes/dark.js index 079d66403..26be4e822 100644 --- a/packages/bruno-app/src/themes/dark.js +++ b/packages/bruno-app/src/themes/dark.js @@ -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: { title: { color: '#ccc', diff --git a/packages/bruno-app/src/themes/light.js b/packages/bruno-app/src/themes/light.js index 1a911a966..f4c10b097 100644 --- a/packages/bruno-app/src/themes/light.js +++ b/packages/bruno-app/src/themes/light.js @@ -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: { title: { color: 'rgb(86 86 86)', diff --git a/packages/bruno-app/src/utils/common/index.js b/packages/bruno-app/src/utils/common/index.js index 60afe9a0c..a9471dfd7 100644 --- a/packages/bruno-app/src/utils/common/index.js +++ b/packages/bruno-app/src/utils/common/index.js @@ -120,3 +120,42 @@ export const startsWith = (str, search) => { export const pluralizeWord = (word, count) => { 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' + }); +}; diff --git a/packages/bruno-app/src/utils/common/notifications.js b/packages/bruno-app/src/utils/common/notifications.js new file mode 100644 index 000000000..602a90558 --- /dev/null +++ b/packages/bruno-app/src/utils/common/notifications.js @@ -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'); + } +}; diff --git a/packages/bruno-app/src/utils/common/platform.js b/packages/bruno-app/src/utils/common/platform.js index 738a1fbed..ddfdb3a1f 100644 --- a/packages/bruno-app/src/utils/common/platform.js +++ b/packages/bruno-app/src/utils/common/platform.js @@ -46,3 +46,15 @@ export const isMacOS = () => { }; 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; +}; diff --git a/packages/bruno-electron/.env.sample b/packages/bruno-electron/.env.sample new file mode 100644 index 000000000..b75f94661 --- /dev/null +++ b/packages/bruno-electron/.env.sample @@ -0,0 +1 @@ +BRUNO_INFO_ENDPOINT = http://localhost:8081 \ No newline at end of file diff --git a/packages/bruno-electron/src/index.js b/packages/bruno-electron/src/index.js index 52afec0a8..678c07585 100644 --- a/packages/bruno-electron/src/index.js +++ b/packages/bruno-electron/src/index.js @@ -12,6 +12,7 @@ const registerCollectionsIpc = require('./ipc/collection'); const registerPreferencesIpc = require('./ipc/preferences'); const Watcher = require('./app/watcher'); const { loadWindowState, saveBounds, saveMaximized } = require('./utils/window'); +const registerNotificationsIpc = require('./ipc/notifications'); const lastOpenedCollections = new LastOpenedCollections(); @@ -120,6 +121,7 @@ app.on('ready', async () => { registerNetworkIpc(mainWindow); registerCollectionsIpc(mainWindow, watcher, lastOpenedCollections); registerPreferencesIpc(mainWindow, watcher, lastOpenedCollections); + registerNotificationsIpc(mainWindow, watcher); }); // Quit the app once all windows are closed diff --git a/packages/bruno-electron/src/ipc/notifications.js b/packages/bruno-electron/src/ipc/notifications.js new file mode 100644 index 000000000..10fdae5bf --- /dev/null +++ b/packages/bruno-electron/src/ipc/notifications.js @@ -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); + } +};