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 ? (
+
+
+
+
+
+
+ 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);
+ }
+};