diff --git a/src/assets/icons/alert.svg b/src/assets/icons/alert.svg index 9af2be1607..40ce621da1 100644 --- a/src/assets/icons/alert.svg +++ b/src/assets/icons/alert.svg @@ -1,6 +1,5 @@ - - - - - + + + + diff --git a/src/assets/icons/check.svg b/src/assets/icons/check.svg index e644005002..1b9d532e6f 100644 --- a/src/assets/icons/check.svg +++ b/src/assets/icons/check.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/src/assets/icons/error.svg b/src/assets/icons/error.svg index d8fce121ac..719fe010c3 100644 --- a/src/assets/icons/error.svg +++ b/src/assets/icons/error.svg @@ -1,6 +1,4 @@ - - - - - + + + diff --git a/src/assets/icons/info.svg b/src/assets/icons/info.svg index 9f7502e4bb..f32151cf0a 100644 --- a/src/assets/icons/info.svg +++ b/src/assets/icons/info.svg @@ -1,7 +1,5 @@ - - - - - - + + + + diff --git a/src/components/AppLayout/Header/components/Layout.tsx b/src/components/AppLayout/Header/components/Layout.tsx index 6a0ade48b1..865d35fa0b 100644 --- a/src/components/AppLayout/Header/components/Layout.tsx +++ b/src/components/AppLayout/Header/components/Layout.tsx @@ -20,6 +20,7 @@ import { shouldSwitchWalletChain } from 'src/logic/wallets/store/selectors' import { useSelector } from 'react-redux' import { OVERVIEW_EVENTS } from 'src/utils/events/overview' import Track from 'src/components/Track' +import Notifications from 'src/components/AppLayout/Header/components/Notifications' const styles = () => ({ root: { @@ -90,7 +91,8 @@ const WalletPopup = ({ anchorEl, providerDetails, classes, open, onClose }) => { } const Layout = ({ classes, providerDetails, providerInfo }) => { - const { clickAway, open, toggle } = useStateHandler() + const { clickAway: clickAwayNotifications, open: openNotifications, toggle: toggleNotifications } = useStateHandler() + const { clickAway: clickAwayWallet, open: openWallet, toggle: toggleWallet } = useStateHandler() const { clickAway: clickAwayNetworks, open: openNetworks, toggle: toggleNetworks } = useStateHandler() const isWrongChain = useSelector(shouldSwitchWalletChain) @@ -113,25 +115,28 @@ const Layout = ({ classes, providerDetails, providerInfo }) => { )} + + + providerRef.current && ( ) } /> - + ) diff --git a/src/components/AppLayout/Header/components/Notifications/NotificationItem.tsx b/src/components/AppLayout/Header/components/Notifications/NotificationItem.tsx new file mode 100644 index 0000000000..9f4354837c --- /dev/null +++ b/src/components/AppLayout/Header/components/Notifications/NotificationItem.tsx @@ -0,0 +1,61 @@ +import { ReactElement } from 'react' +import styled from 'styled-components' +import { OptionsObject } from 'notistack' + +import { NotificationsState } from 'src/logic/notifications/store/notifications' +import { black500 } from 'src/theme/variables' +import { formatTime } from 'src/utils/date' +import AlertIcon from 'src/assets/icons/alert.svg' +import CheckIcon from 'src/assets/icons/check.svg' +import ErrorIcon from 'src/assets/icons/error.svg' +import InfoIcon from 'src/assets/icons/info.svg' +import { UnreadNotificationBadge, NotificationSubtitle } from 'src/components/AppLayout/Header/components/Notifications' + +const notificationIcon = { + error: ErrorIcon, + info: InfoIcon, + success: CheckIcon, + warning: AlertIcon, +} + +const getNotificationIcon = (variant: OptionsObject['variant'] = 'info'): string => notificationIcon[variant] + +const NoficationItem = ({ read, options, message, timestamp }: NotificationsState[number]): ReactElement => ( + + + + +
+ {message} +
+ {formatTime(timestamp)} +
+ +) + +const Notification = styled.div` + box-sizing: border-box; + display: flex; + align-items: center; + > * { + padding: 8px; + } +` + +const NotificationMessage = styled.p` + all: unset; + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: ${black500}; +` + +export default NoficationItem diff --git a/src/components/AppLayout/Header/components/Notifications/NotificationList.tsx b/src/components/AppLayout/Header/components/Notifications/NotificationList.tsx new file mode 100644 index 0000000000..0d18bb8e95 --- /dev/null +++ b/src/components/AppLayout/Header/components/Notifications/NotificationList.tsx @@ -0,0 +1,41 @@ +import { ReactElement } from 'react' +import styled from 'styled-components' + +import { NotificationsState } from 'src/logic/notifications/store/notifications' +import { NOTIFICATION_LIMIT } from 'src/components/AppLayout/Header/components/Notifications' +import { StyledScrollableBar } from 'src/routes/safe/components/Transactions/TxList/styled' +import { black300 } from 'src/theme/variables' +import NotificationItem from 'src/components/AppLayout/Header/components/Notifications/NotificationItem' + +const NotificationList = ({ notifications }: { notifications: NotificationsState }): ReactElement => { + if (!notifications.length) { + return No notifications + } + + return ( + NOTIFICATION_LIMIT}> + System updates + {notifications.map((notification) => ( + + ))} + + ) +} + +const ScrollContainer = styled(StyledScrollableBar)<{ $showScrollbar: boolean }>` + height: ${({ $showScrollbar: $scroll }) => ($scroll ? '500px' : 'auto')}; + overflow-x: hidden; + overflow-y: auto; + width: 100%; +` + +const NotificationType = styled.h4` + all: unset; + display: block; + font-weight: 400; + font-size: 14px; + color: ${black300}; + margin-bottom: 12px; +` + +export default NotificationList diff --git a/src/components/AppLayout/Header/components/Notifications/__tests__/index.test.ts b/src/components/AppLayout/Header/components/Notifications/__tests__/index.test.ts new file mode 100644 index 0000000000..9bee9879d6 --- /dev/null +++ b/src/components/AppLayout/Header/components/Notifications/__tests__/index.test.ts @@ -0,0 +1,103 @@ +import { NotificationsState } from 'src/logic/notifications/store/notifications' +import { getSortedNotifications } from '../' + +const UNREAD_ACTION_NOTIFICATION = { + read: false, + action: true, +} as NotificationsState[number] + +const UNREAD_NOTIFICATION = { + read: false, + action: false, +} as NotificationsState[number] + +const READ_ACTION_NOTIFICATION = { + read: true, + action: true, +} as NotificationsState[number] + +const READ_NOTIFICATION = { + read: true, + action: false, +} as NotificationsState[number] + +describe('Notifications', () => { + describe('getSortedNotifications', () => { + it("should't sort correctly ordered notifications", () => { + const notifications = [ + { ...UNREAD_ACTION_NOTIFICATION, timestamp: 6 }, + { ...UNREAD_ACTION_NOTIFICATION, timestamp: 5 }, + { ...UNREAD_NOTIFICATION, timestamp: 4 }, + { ...UNREAD_NOTIFICATION, timestamp: 3 }, + { ...READ_ACTION_NOTIFICATION, timestamp: 2 }, + { ...READ_NOTIFICATION, timestamp: 1 }, + ] + expect(getSortedNotifications(notifications)).toEqual(notifications) + }) + + it('should sort the read notifications chronologically regardless of action', () => { + const notifications = [ + { ...READ_NOTIFICATION, timestamp: 1 }, + { ...READ_NOTIFICATION, timestamp: 2 }, + { ...READ_ACTION_NOTIFICATION, timestamp: 3 }, + { ...READ_NOTIFICATION, timestamp: 4 }, + ] + + const sortedNotifications = [ + { ...READ_NOTIFICATION, timestamp: 4 }, + { ...READ_ACTION_NOTIFICATION, timestamp: 3 }, + { ...READ_NOTIFICATION, timestamp: 2 }, + { ...READ_NOTIFICATION, timestamp: 1 }, + ] + + expect(getSortedNotifications(notifications)).toEqual(sortedNotifications) + }) + + it('should sort unread actionable notifications to the top', () => { + const notifications = [ + { ...READ_ACTION_NOTIFICATION, timestamp: 3 }, + { ...READ_NOTIFICATION, timestamp: 2 }, + { ...UNREAD_ACTION_NOTIFICATION, timestamp: 1 }, + ] + + const sortedNotifications = [ + { ...UNREAD_ACTION_NOTIFICATION, timestamp: 1 }, + { ...READ_ACTION_NOTIFICATION, timestamp: 3 }, + { ...READ_NOTIFICATION, timestamp: 2 }, + ] + expect(getSortedNotifications(notifications)).toEqual(sortedNotifications) + }) + it('should sort unread notifications to the top', () => { + const notifications = [ + { ...READ_ACTION_NOTIFICATION, timestamp: 3 }, + { ...READ_NOTIFICATION, timestamp: 3 }, + { ...UNREAD_NOTIFICATION, timestamp: 1 }, + ] + + const sortedNotifications = [ + { ...UNREAD_NOTIFICATION, timestamp: 1 }, + { ...READ_ACTION_NOTIFICATION, timestamp: 3 }, + { ...READ_NOTIFICATION, timestamp: 3 }, + ] + + expect(getSortedNotifications(notifications)).toEqual(sortedNotifications) + }) + it('should sort actionable notifications to the top, followed by unread notifications', () => { + const notifications = [ + { ...READ_ACTION_NOTIFICATION, timestamp: 4 }, + { ...READ_NOTIFICATION, timestamp: 3 }, + { ...UNREAD_NOTIFICATION, timestamp: 2 }, + { ...UNREAD_ACTION_NOTIFICATION, timestamp: 1 }, + ] + + const sortedNotifications = [ + { ...UNREAD_ACTION_NOTIFICATION, timestamp: 1 }, + { ...UNREAD_NOTIFICATION, timestamp: 2 }, + { ...READ_ACTION_NOTIFICATION, timestamp: 4 }, + { ...READ_NOTIFICATION, timestamp: 3 }, + ] + + expect(getSortedNotifications(notifications)).toEqual(sortedNotifications) + }) + }) +}) diff --git a/src/components/AppLayout/Header/components/Notifications/index.tsx b/src/components/AppLayout/Header/components/Notifications/index.tsx new file mode 100644 index 0000000000..62f1050e03 --- /dev/null +++ b/src/components/AppLayout/Header/components/Notifications/index.tsx @@ -0,0 +1,204 @@ +import { ReactElement, useMemo, useRef, useState } from 'react' +import { useDispatch, useSelector } from 'react-redux' +import { IconButton, Badge, ClickAwayListener, Paper, Popper } from '@material-ui/core' +import NotificationsNoneIcon from '@material-ui/icons/NotificationsNone' +import styled from 'styled-components' + +import { ReturnValue as Props } from 'src/logic/hooks/useStateHandler' +import { + deleteAllNotifications, + NotificationsState, + readNotification, + selectNotifications, +} from 'src/logic/notifications/store/notifications' +import { black300, border, primary200, primary400, sm } from 'src/theme/variables' +import ExpandMoreIcon from '@material-ui/icons/ExpandMore' +import ExpandLessIcon from '@material-ui/icons/ExpandLess' +import NotificationList from 'src/components/AppLayout/Header/components/Notifications/NotificationList' + +export const NOTIFICATION_LIMIT = 4 + +export const getSortedNotifications = (notifications: NotificationsState): NotificationsState => { + const chronologicalNotifications = notifications.sort((a, b) => b.timestamp - a.timestamp) + + const unreadActionNotifications = chronologicalNotifications.filter(({ read, action }) => !read && action) + const unreadNotifications = chronologicalNotifications.filter(({ read, action }) => !read && !action) + const readNotifications = chronologicalNotifications.filter(({ read }) => read) + + return [...unreadActionNotifications, ...unreadNotifications, ...readNotifications] +} + +const Notifications = ({ open, toggle, clickAway }: Props): ReactElement => { + const dispatch = useDispatch() + const notificationsRef = useRef(null) + const [showAll, setShowAll] = useState(false) + + const notifications = useSelector(selectNotifications) + const sortedNotifications = useMemo(() => getSortedNotifications(notifications), [notifications]) + + const canExpand = notifications.length > NOTIFICATION_LIMIT + + const notificationsToShow = + canExpand && showAll ? sortedNotifications : sortedNotifications.slice(0, NOTIFICATION_LIMIT) + + const unreadCount = useMemo( + () => notifications.filter(({ read, dismissed }) => !read && dismissed).length, + [notifications], + ) + const hasUnread = unreadCount > 0 + + const handleClickBell = () => { + if (open) { + notificationsToShow.forEach(({ read, options }) => { + if (read) return + dispatch(readNotification({ key: options.key })) + }) + setShowAll(false) + } + toggle() + } + + const handleClickAway = () => { + clickAway() + setShowAll(false) + } + + return ( + + + + + + + + + + +
+ Notifications + {hasUnread && {unreadCount}} +
+ dispatch(deleteAllNotifications())}>Clear All +
+ + {canExpand && ( +
+ setShowAll((prev) => !prev)} disableRipple> + {showAll ? : } + + + {showAll ? 'Hide' : `${notifications.length - NOTIFICATION_LIMIT} other notifications`} + +
+ )} +
+
+
+
+ ) +} + +const Wrapper = styled.div` + height: 100%; +` + +const BellIconButton = styled(IconButton)` + width: 44px; + height: 100%; + border-radius: 0; + &:hover { + background: none; + } +` + +export const UnreadNotificationBadge = styled(Badge)` + .MuiBadge-badge { + background-color: ${primary400}; + } +` + +const NotificationsPopper = styled(Paper)` + box-sizing: border-box; + border-radius: ${sm}; + box-shadow: 0 0 10px 0 rgba(33, 48, 77, 0.1); + width: 438px; + padding: 30px 23px; +` + +const NotificationsHeader = styled.div` + height: 30px; + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: 16px; +` + +const NotificationsTitle = styled.h4` + display: inline; + font-weight: 700; + font-size: 20px; + line-height: 26px; +` + +const UnreadCount = styled.span` + display: inline-block; + background: ${primary200}; + border-radius: 6px; + margin-left: 9px; + color: ${primary400}; + text-align: center; + width: 18px; + height: 18px; +` + +const ClearAllButton = styled.button` + all: unset; + cursor: pointer; + font-style: normal; + font-weight: 700; + font-size: 16px; + color: ${primary400}; +` + +export const NotificationSubtitle = styled.span` + font-weight: 400; + font-size: 16px; + line-height: 24px; + color: ${black300}; +` + +const ExpandIconButton = styled(IconButton)` + box-sizing: border-box; + background-color: ${border}; + width: 20px; + height: 20px; + margin-left: 10px; + margin-right: 18px; + padding: 0; + > * { + color: ${black300}; + } +` + +export default Notifications diff --git a/src/logic/hooks/useNotifier.tsx b/src/logic/hooks/useNotifier.tsx index 6f3c501c8a..5036f94b01 100644 --- a/src/logic/hooks/useNotifier.tsx +++ b/src/logic/hooks/useNotifier.tsx @@ -15,28 +15,40 @@ const useNotifier = (): void => { useEffect(() => { for (const notification of notifications) { - // Unspecified keys are automatically generated in `enqueueSnackbar` thunk - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const key = notification.options!.key! + const { key } = notification.options + // Dismiss notification via Notistack if (notification.dismissed) { closeSnackbar(key) continue } + // Do nothing if notification is already on screen if (onScreenKeys.includes(key)) { continue } + // `onExited` runs after a notification unmounts meaning that already + // closed notifications would 'close' again, marking them as unread + let wasClosed = false + + // Display notification with Notistack enqueueSnackbar(notification.message, { ...notification.options, onExited: () => { // Cleanup store/cache when notification has unmounted - dispatch(closeNotification({ key })) + if (!wasClosed) { + dispatch(closeNotification({ key, read: false })) + } onScreenKeys = onScreenKeys.filter((onScreenKey) => onScreenKey !== key) }, action: ( - dispatch(closeNotification({ key }))}> + { + dispatch(closeNotification({ key })) + wasClosed = true + }} + > ), diff --git a/src/logic/notifications/notificationTypes.ts b/src/logic/notifications/notificationTypes.ts index 2911a4bd60..73044c7460 100644 --- a/src/logic/notifications/notificationTypes.ts +++ b/src/logic/notifications/notificationTypes.ts @@ -13,7 +13,7 @@ const longDuration = 10000 export type Notification = { message: SnackbarMessage options?: OptionsObject - dismissed?: boolean + action?: unknown // Will specify the type when actions are added } enum NOTIFICATION_IDS { diff --git a/src/logic/notifications/store/notifications.ts b/src/logic/notifications/store/notifications.ts index ec71ba0d4a..45f10d3d1d 100644 --- a/src/logic/notifications/store/notifications.ts +++ b/src/logic/notifications/store/notifications.ts @@ -1,4 +1,4 @@ -import { SnackbarKey } from 'notistack' +import { OptionsObject, SnackbarKey } from 'notistack' import { AnyAction } from 'redux' import { Action, createAction, handleActions } from 'redux-actions' import { ThunkAction } from 'redux-thunk' @@ -10,27 +10,48 @@ export const NOTIFICATIONS_REDUCER_ID = 'notifications' enum NOTIFICATION_ACTIONS { SHOW = 'notifications/show', + READ = 'notifications/read', CLOSE = 'notifications/close', CLOSE_ALL = 'notifications/closeAll', DELETE = 'notifications/delete', DELETE_ALL = 'notifications/deleteAll', } -export type NotificationsState = Notification[] +type WithRequiredProperty = Type & { + [Property in Key]-?: Type[Property] +} + +// `showNotification` generates `options.key` if none is provided +type KeyedNotification = Notification & { options: WithRequiredProperty } + +export type NotificationsState = (KeyedNotification & { + timestamp: number + dismissed: boolean + read: boolean +})[] +type ShowPayload = KeyedNotification type KeyPayload = { key: SnackbarKey } +type ClosePayload = KeyPayload & { read?: boolean } -const notificationsReducer = handleActions( +type Payloads = ShowPayload | ClosePayload | KeyPayload + +const notificationsReducer = handleActions( { - [NOTIFICATION_ACTIONS.SHOW]: (state, { payload }: Action) => { - // getNotificationsFromTxType can return `null` notifications - return payload ? [...state, payload] : state + [NOTIFICATION_ACTIONS.SHOW]: (state, { payload }: Action) => { + return [...state, { ...payload, read: false, dismissed: false, timestamp: new Date().getTime() }] }, - [NOTIFICATION_ACTIONS.CLOSE]: (state, { payload }: Action) => { + [NOTIFICATION_ACTIONS.READ]: (state, action: Action) => { return state.map((notification) => { - return notification.options?.key === payload.key ? { ...notification, dismissed: true } : notification + return notification.options?.key === action.payload.key ? { ...notification, read: true } : notification }) }, + [NOTIFICATION_ACTIONS.CLOSE]: (state, action: Action) => { + return state.map((notification) => { + return notification.options?.key === action.payload.key ? { ...notification, dismissed: true } : notification + }) + }, + [NOTIFICATION_ACTIONS.CLOSE_ALL]: (state) => { return state.map((notification) => ({ ...notification, dismissed: true })) }, @@ -45,24 +66,31 @@ const notificationsReducer = handleActions, + payload: Notification, ): ThunkAction => { return (dispatch): SnackbarKey => { - const action = createAction(NOTIFICATION_ACTIONS.SHOW) + const action = createAction(NOTIFICATION_ACTIONS.SHOW) - const key = payload.options?.key || Math.random().toString(32).slice(2) + // Generate/append random key in case key was dispatched before and is `read` + const key = `${payload.options?.key || ''}${Math.random().toString(32).slice(2)}` - dispatch( - action({ - ...payload, - options: { ...payload.options, key }, - }), - ) + dispatch(action({ ...payload, options: { ...payload.options, key } })) return key } } -export const closeNotification = createAction(NOTIFICATION_ACTIONS.CLOSE) +export const readNotification = createAction(NOTIFICATION_ACTIONS.READ) +export const closeNotification = (payload: ClosePayload): ThunkAction => { + return (dispatch): void => { + const { read = true } = payload + if (read) { + dispatch(readNotification(payload)) + } + + const action = createAction(NOTIFICATION_ACTIONS.CLOSE) + dispatch(action(payload)) + } +} export const closeAllNotifications = createAction(NOTIFICATION_ACTIONS.CLOSE_ALL) export const deleteNotification = createAction(NOTIFICATION_ACTIONS.DELETE) export const deleteAllNotifications = createAction(NOTIFICATION_ACTIONS.DELETE_ALL) diff --git a/src/logic/notifications/txNotificationBuilder.tsx b/src/logic/notifications/txNotificationBuilder.tsx index 4dc613b633..e2e8789cbb 100644 --- a/src/logic/notifications/txNotificationBuilder.tsx +++ b/src/logic/notifications/txNotificationBuilder.tsx @@ -163,7 +163,7 @@ export const createTxNotifications = ( const beforeExecutionKey = dispatch(showNotification(txNotifications.beforeExecution)) return { - closePending: () => dispatch(closeNotification({ key: beforeExecutionKey })), + closePending: () => dispatch(closeNotification({ key: beforeExecutionKey, read: false })), showOnRejection: () => dispatch(showNotification(txNotifications.afterRejection)), showOnError: (err: Error & { code: number }, customErrorMessage?: string) => { const msg = isTxPendingError(err) diff --git a/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx b/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx index 441e8be41f..46648b307e 100644 --- a/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx +++ b/src/routes/safe/components/Balances/SendModal/screens/ReviewSendFundsTx/index.tsx @@ -39,7 +39,7 @@ import useSafeAddress from 'src/logic/currentSession/hooks/useSafeAddress' import { createSendParams } from 'src/logic/safe/transactions/gas' import { SpendingLimitModalWrapper } from 'src/routes/safe/components/Transactions/helpers/SpendingLimitModalWrapper' import { getNotificationsFromTxType } from 'src/logic/notifications' -import { showNotification } from 'src/logic/notifications/store/notifications' +import { closeNotification, showNotification } from 'src/logic/notifications/store/notifications' const useStyles = makeStyles(styles) @@ -111,9 +111,11 @@ const ReviewSendFundsTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactE const spendingLimit = getSpendingLimitContract(spendingLimitModuleAddress) const notification = getNotificationsFromTxType(TX_NOTIFICATION_TYPES.SPENDING_LIMIT_TX) + trackEvent(MODALS_EVENTS.USE_SPENDING_LIMIT) + + let beforeExecutionKey = '' try { - trackEvent(MODALS_EVENTS.USE_SPENDING_LIMIT) - dispatch(showNotification(notification.beforeExecution)) + beforeExecutionKey = dispatch(showNotification(notification.beforeExecution)) as unknown as string const allowanceTransferTx = await spendingLimit.methods.executeAllowanceTransfer( safeAddress, @@ -126,6 +128,8 @@ const ReviewSendFundsTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactE EMPTY_DATA, ) + dispatch(closeNotification({ key: beforeExecutionKey, read: false })) + const sendParams = createSendParams(tx.tokenSpendingLimit.delegate, txParameters) await allowanceTransferTx.send(sendParams).on('transactionHash', () => { @@ -134,6 +138,7 @@ const ReviewSendFundsTx = ({ onClose, onPrev, tx }: ReviewTxProps): React.ReactE }) } catch (err) { logError(Errors._801, err.message) + dispatch(closeNotification({ key: beforeExecutionKey, read: false })) dispatch(showNotification(notification.afterRejection)) } onClose()