Snap notifications integration (#14605)
* begin controller implentation * add NotificationController * create selectors and actions * update actions tu use forceUpdateMetamaskState * Basic notification UI * fix typo and remove console.log * lint * more css * add notifications scroll * add translations and fix some css * Fix rebase and edit colors * add flask tags * add flask tag to routes component * add missing flask tags * add tests * fix tests * store notification expiration delay in constant * address requested changes * rename to unreadNotificationsCount * add missing flask tagfeature/default_network_editable
parent
95c230127c
commit
b599035a12
@ -0,0 +1,6 @@ |
||||
///: BEGIN:ONLY_INCLUDE_IN(flask)
|
||||
|
||||
// The time after which a notification should be deleted.
|
||||
export const NOTIFICATIONS_EXPIRATION_DELAY = 10000; |
||||
|
||||
///: END:ONLY_INCLUDE_IN
|
@ -0,0 +1 @@ |
||||
export { default } from './notifications'; |
@ -0,0 +1,114 @@ |
||||
.notifications { |
||||
position: relative; |
||||
display: flex; |
||||
flex-direction: column; |
||||
background-color: var(--color-background-default); |
||||
|
||||
&__header { |
||||
display: flex; |
||||
flex-direction: row; |
||||
justify-content: space-between; |
||||
width: 100%; |
||||
padding: 65px 24px 15px 24px; |
||||
border-bottom: 1px solid var(--color-border-muted); |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
padding: 10px 20px; |
||||
} |
||||
|
||||
&__title-container { |
||||
display: flex; |
||||
flex-flow: row; |
||||
align-items: center; |
||||
flex: 0 0 auto; |
||||
|
||||
&__title { |
||||
@include H3; |
||||
|
||||
margin-left: 26px; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
@include H6; |
||||
|
||||
margin-left: 16px; |
||||
font-weight: bold; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&_button { |
||||
width: auto; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
font-size: 0.75rem; |
||||
padding: 3.5px 1rem; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__container { |
||||
display: flex; |
||||
overflow: auto; |
||||
flex-direction: column; |
||||
flex: 1 1 auto; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
height: 100%; |
||||
} |
||||
|
||||
&__text { |
||||
@include H3; |
||||
|
||||
color: var(--color-text-muted); |
||||
text-align: center; |
||||
|
||||
@media screen and (max-width: $break-small) { |
||||
@include H6; |
||||
} |
||||
} |
||||
} |
||||
|
||||
.empty { |
||||
justify-content: center; |
||||
align-items: center; |
||||
} |
||||
|
||||
&__item { |
||||
display: flex; |
||||
flex-direction: row; |
||||
align-items: center; |
||||
border-bottom: 1px solid var(--color-border-muted); |
||||
padding: 16px; |
||||
cursor: pointer; |
||||
|
||||
&:hover { |
||||
background-color: var(--color-background-alternative); |
||||
} |
||||
|
||||
&__unread-dot { |
||||
content: ' '; |
||||
align-self: flex-start; |
||||
margin-top: 6px; |
||||
width: 8px; |
||||
height: 8px; |
||||
background-color: transparent; |
||||
border-radius: 50%; |
||||
} |
||||
|
||||
.unread { |
||||
background-color: var(--color-primary-default); |
||||
} |
||||
|
||||
&__details { |
||||
display: flex; |
||||
flex-direction: column; |
||||
margin-left: 12px; |
||||
|
||||
&__infos { |
||||
color: var(--color-text-muted); |
||||
font-size: 12px; |
||||
margin-top: 6px; |
||||
} |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,100 @@ |
||||
import React from 'react'; |
||||
|
||||
import { renderWithProvider } from '../../../test/lib/render-helpers'; |
||||
import configureStore from '../../store/store'; |
||||
import Notifications, { NotificationItem } from './notifications'; |
||||
|
||||
describe('Notifications', () => { |
||||
const render = (params) => { |
||||
const store = configureStore({ |
||||
...params, |
||||
}); |
||||
|
||||
return renderWithProvider(<Notifications />, store); |
||||
}; |
||||
|
||||
it('can render a list of notifications', () => { |
||||
const mockStore = { |
||||
metamask: { |
||||
notifications: { |
||||
test: { |
||||
id: 'test', |
||||
origin: 'test', |
||||
createdDate: 1652967897732, |
||||
readDate: null, |
||||
message: 'foo', |
||||
}, |
||||
test2: { |
||||
id: 'test2', |
||||
origin: 'test', |
||||
createdDate: 1652967897732, |
||||
readDate: null, |
||||
message: 'bar', |
||||
}, |
||||
}, |
||||
snaps: { |
||||
test: { |
||||
enabled: true, |
||||
id: 'test', |
||||
manifest: { |
||||
proposedName: 'Notification Example Snap', |
||||
description: 'A notification example snap.', |
||||
}, |
||||
}, |
||||
}, |
||||
}, |
||||
}; |
||||
|
||||
const { getByText } = render(mockStore); |
||||
|
||||
expect( |
||||
getByText(mockStore.metamask.notifications.test.message), |
||||
).toBeDefined(); |
||||
|
||||
expect( |
||||
getByText(mockStore.metamask.notifications.test2.message), |
||||
).toBeDefined(); |
||||
}); |
||||
|
||||
it('can render an empty list of notifications', () => { |
||||
const mockStore = { |
||||
metamask: { |
||||
notifications: {}, |
||||
snaps: {}, |
||||
}, |
||||
}; |
||||
|
||||
const { getByText } = render(mockStore); |
||||
|
||||
expect(getByText('Nothing to see here.')).toBeDefined(); |
||||
}); |
||||
}); |
||||
|
||||
describe('NotificationItem', () => { |
||||
const render = (props) => renderWithProvider(<NotificationItem {...props} />); |
||||
it('can render notification item', () => { |
||||
const props = { |
||||
notification: { |
||||
id: 'test', |
||||
origin: 'test', |
||||
createdDate: 1652967897732, |
||||
readDate: null, |
||||
message: 'Hello, http://localhost:8086!', |
||||
}, |
||||
snaps: [ |
||||
{ |
||||
id: 'test', |
||||
tabMessage: () => 'test snap name', |
||||
descriptionMessage: () => 'test description', |
||||
sectionMessage: () => 'test section Message', |
||||
route: '/test', |
||||
icon: 'test', |
||||
}, |
||||
], |
||||
onItemClick: jest.fn(), |
||||
}; |
||||
const { getByText } = render(props); |
||||
|
||||
expect(getByText(props.notification.message)).toBeDefined(); |
||||
}); |
||||
}); |
@ -0,0 +1,141 @@ |
||||
import React, { useEffect } from 'react'; |
||||
import PropTypes from 'prop-types'; |
||||
import classnames from 'classnames'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import { useHistory } from 'react-router-dom'; |
||||
import { formatDate } from '../../helpers/utils/util'; |
||||
import { |
||||
getNotifications, |
||||
getSnapsRouteObjects, |
||||
getUnreadNotifications, |
||||
} from '../../selectors'; |
||||
import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; |
||||
import { |
||||
deleteExpiredNotifications, |
||||
markNotificationsAsRead, |
||||
} from '../../store/actions'; |
||||
import IconCaretLeft from '../../components/ui/icon/icon-caret-left'; |
||||
import Button from '../../components/ui/button'; |
||||
import { useI18nContext } from '../../hooks/useI18nContext'; |
||||
|
||||
export function NotificationItem({ notification, snaps, onItemClick }) { |
||||
const { message, origin, createdDate, readDate } = notification; |
||||
const history = useHistory(); |
||||
const t = useI18nContext(); |
||||
|
||||
const snap = snaps.find(({ id: snapId }) => { |
||||
return snapId === origin; |
||||
}); |
||||
|
||||
const handleNameClick = (e) => { |
||||
e.stopPropagation(); |
||||
history.push(snap.route); |
||||
}; |
||||
|
||||
const handleItemClick = () => onItemClick(notification); |
||||
|
||||
return ( |
||||
<div className="notifications__item" onClick={handleItemClick}> |
||||
<div |
||||
className={classnames( |
||||
'notifications__item__unread-dot', |
||||
!readDate && 'unread', |
||||
)} |
||||
/> |
||||
<div className="notifications__item__details"> |
||||
<p className="notifications__item__details__message">{message}</p> |
||||
<p className="notifications__item__details__infos"> |
||||
{t('notificationsInfos', [ |
||||
formatDate(createdDate, "LLLL d',' yyyy 'at' t"), |
||||
<Button type="inline" onClick={handleNameClick} key="button"> |
||||
{snap.tabMessage()} |
||||
</Button>, |
||||
])} |
||||
</p> |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export default function Notifications() { |
||||
const history = useHistory(); |
||||
const dispatch = useDispatch(); |
||||
const t = useI18nContext(); |
||||
const notifications = useSelector(getNotifications); |
||||
const snapsRouteObject = useSelector(getSnapsRouteObjects); |
||||
const unreadNotifications = useSelector(getUnreadNotifications); |
||||
|
||||
const markAllAsRead = () => { |
||||
const unreadNotificationIds = unreadNotifications.map(({ id }) => id); |
||||
|
||||
dispatch(markNotificationsAsRead(unreadNotificationIds)); |
||||
}; |
||||
|
||||
const markAsRead = (notificationToMark) => { |
||||
if (!notificationToMark.readDate) { |
||||
dispatch(markNotificationsAsRead([notificationToMark.id])); |
||||
} |
||||
}; |
||||
|
||||
useEffect(() => { |
||||
return () => dispatch(deleteExpiredNotifications()); |
||||
}, [dispatch]); |
||||
|
||||
return ( |
||||
<div className="main-container notifications"> |
||||
<div className="notifications__header"> |
||||
<div className="notifications__header__title-container"> |
||||
<IconCaretLeft |
||||
className="notifications__header__title-container__back-button" |
||||
color="var(--color-text-default)" |
||||
size={23} |
||||
onClick={() => history.push(DEFAULT_ROUTE)} |
||||
/> |
||||
<div className="notifications__header__title-container__title"> |
||||
{t('notificationsHeader')} |
||||
</div> |
||||
</div> |
||||
<Button |
||||
type="secondary" |
||||
className="notifications__header_button" |
||||
onClick={markAllAsRead} |
||||
> |
||||
{t('notificationsMarkAllAsRead')} |
||||
</Button> |
||||
</div> |
||||
<div |
||||
className={classnames( |
||||
'notifications__container', |
||||
notifications.length === 0 && 'empty', |
||||
)} |
||||
> |
||||
{notifications.length > 0 ? ( |
||||
notifications.map((notification, id) => ( |
||||
<NotificationItem |
||||
notification={notification} |
||||
snaps={snapsRouteObject} |
||||
key={id} |
||||
onItemClick={markAsRead} |
||||
/> |
||||
)) |
||||
) : ( |
||||
<div className="notifications__container__text"> |
||||
{t('notificationsEmptyText')} |
||||
</div> |
||||
)} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
NotificationItem.propTypes = { |
||||
notification: { |
||||
id: PropTypes.string.isRequired, |
||||
message: PropTypes.string.isRequired, |
||||
origin: PropTypes.string.isRequired, |
||||
createdDate: PropTypes.number.isRequired, |
||||
readDate: PropTypes.number.isRequired, |
||||
}, |
||||
snaps: PropTypes.array.isRequired, |
||||
onItemClick: PropTypes.func.isRequired, |
||||
}; |
Loading…
Reference in new issue