mirror of
https://github.com/usebruno/bruno.git
synced 2024-11-25 01:14:23 +01:00
feat(#BRU-31): notifications feature draft (#1730)
* feat(#BRU-31): notifications feature * feat(#BRU-31): date correction
This commit is contained in:
parent
1fca217046
commit
b0f4491cd2
@ -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}
|
||||||
|
@ -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;
|
192
packages/bruno-app/src/components/Notifications/index.js
Normal file
192
packages/bruno-app/src/components/Notifications/index.js
Normal 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;
|
@ -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 */}
|
||||||
|
@ -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);
|
||||||
|
@ -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');
|
||||||
|
@ -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',
|
||||||
|
@ -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)',
|
||||||
|
@ -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'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
19
packages/bruno-app/src/utils/common/notifications.js
Normal file
19
packages/bruno-app/src/utils/common/notifications.js
Normal 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');
|
||||||
|
}
|
||||||
|
};
|
@ -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;
|
||||||
|
};
|
||||||
|
1
packages/bruno-electron/.env.sample
Normal file
1
packages/bruno-electron/.env.sample
Normal file
@ -0,0 +1 @@
|
|||||||
|
BRUNO_INFO_ENDPOINT = http://localhost:8081
|
@ -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
|
||||||
|
31
packages/bruno-electron/src/ipc/notifications.js
Normal file
31
packages/bruno-electron/src/ipc/notifications.js
Normal 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);
|
||||||
|
}
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user