Whats new popup (#10583)
* Add 'What's New' notification popup * Move selectors from shared/notifications into ui/ directory * Use keys for localized message in whats new notifications objects, to ensure notifications will be translated. * Remove unused swaps intro popup locale messages * Fix keys of whats new notification locales * Remove notifications messages and descriptions from comment in shared/notifications * Move notifcationActionFunctions to shared/notifications and make it stateless * Get notification data from constants instead of state in whats-new-popup * Code cleanup * Fix build quote reference to swapsEthToken, broken during rebase * Rename notificationFilters to notificationToExclude to clarify its purpose * Documentation for getSortedNotificationsToShow * Move notification action functions from shared/ to whats-new-popup.js * Stop setting swapsWelcomeMessageHasBeenShown to state in app-state controller * Update e2e tests for whats new popup changes * Updating migration files * Addressing feedback part 1 * Addressing feedback part 2 * Remove unnecessary div in whats-new-popup * Change getNotificationsToExclude to getNotificationsToInclude for use in the getSortedNotificationsToShow selector * Delete intro-popup directory and test files * Lint fix * Add notifiction state to address-entry fixture * Use two separate functions for rendering first and subsequent notifications in the whats-new-popup * Ensure that string literals are passed to t for whats new popup text * Update import-ui fixtures to include notificaiton controller state * Remove unnecessary, accidental change confirm-approve * Remove swaps notification in favour of mobile swaps as first notifcation and TBD 3rd notification * Update whats-new-popup to use intersection observer api to detect if notification has been seen * Add notifications to send-edit and threebox e2e test fixtures * Update ui/app/selectors/selectors.js Co-authored-by: Mark Stacey <markjstacey@gmail.com> * Update ui/app/selectors/selectors.js Co-authored-by: Mark Stacey <markjstacey@gmail.com> * Clean up locale code for whats-new-popup notifications * Disconnect observers in whats-new-popup when their callback is first called * Add test case for migration 58 for when the AppStateController does not exist * Rename popover components containerRef to popoverWrapRef * Fix messages.json * Update notification messages and images * Rename popoverWrapRef -> popoverRef in whats-new-popup and popover.component * Only create one observer, and only after images have loaded, in whats-new-popup * Set width and height on whats-new-popup image, instead of setting state on img load * Update ui/app/components/app/whats-new-popup/whats-new-popup.js Co-authored-by: Mark Stacey <markjstacey@gmail.com> * Code clean up in whats new popup re: notification rendering and action functions * Code cleanup in render notification functions of whats-new-popup * Update ui/app/components/app/whats-new-popup/whats-new-popup.js Co-authored-by: Mark Stacey <markjstacey@gmail.com> * lint fix * Update and localize notification dates * Clean up date code in shred/notifications/index.js Co-authored-by: ryanml <ryanlanese@gmail.com> Co-authored-by: Mark Stacey <markjstacey@gmail.com>feature/default_network_editable
parent
b6d8291dfc
commit
b73f543b23
After Width: | Height: | Size: 11 KiB |
@ -0,0 +1,23 @@ |
||||
import { cloneDeep } from 'lodash'; |
||||
|
||||
const version = 58; |
||||
|
||||
/** |
||||
* Deletes the swapsWelcomeMessageHasBeenShown property from state |
||||
*/ |
||||
export default { |
||||
version, |
||||
async migrate(originalVersionedData) { |
||||
const versionedData = cloneDeep(originalVersionedData); |
||||
versionedData.meta.version = version; |
||||
const state = versionedData.data; |
||||
versionedData.data = transformState(state); |
||||
return versionedData; |
||||
}, |
||||
}; |
||||
|
||||
function transformState(state) { |
||||
delete state.AppStateController?.swapsWelcomeMessageHasBeenShown; |
||||
|
||||
return state; |
||||
} |
@ -0,0 +1,46 @@ |
||||
import { strict as assert } from 'assert'; |
||||
import migration58 from './058'; |
||||
|
||||
describe('migration #58', function () { |
||||
it('should update the version metadata', async function () { |
||||
const oldStorage = { |
||||
meta: { |
||||
version: 57, |
||||
}, |
||||
data: {}, |
||||
}; |
||||
|
||||
const newStorage = await migration58.migrate(oldStorage); |
||||
assert.deepEqual(newStorage.meta, { |
||||
version: 58, |
||||
}); |
||||
}); |
||||
|
||||
describe('deleting swapsWelcomeMessageHasBeenShown', function () { |
||||
it('should delete the swapsWelcomeMessageHasBeenShown property', async function () { |
||||
const oldStorage = { |
||||
meta: {}, |
||||
data: { |
||||
AppStateController: { |
||||
swapsWelcomeMessageHasBeenShown: false, |
||||
bar: 'baz', |
||||
}, |
||||
foo: 'bar', |
||||
}, |
||||
}; |
||||
const newStorage = await migration58.migrate(oldStorage); |
||||
assert.deepEqual(newStorage.data.AppStateController, { bar: 'baz' }); |
||||
}); |
||||
|
||||
it('should not modify state if the AppStateController does not exist', async function () { |
||||
const oldStorage = { |
||||
meta: {}, |
||||
data: { |
||||
foo: 'bar', |
||||
}, |
||||
}; |
||||
const newStorage = await migration58.migrate(oldStorage); |
||||
assert.deepEqual(newStorage.data, oldStorage.data); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,51 @@ |
||||
// Messages and descriptions for these locale keys are in app/_locales/en/messages.json
|
||||
export const UI_NOTIFICATIONS = { |
||||
1: { |
||||
id: 1, |
||||
date: '2021-03-17', |
||||
image: { |
||||
src: 'images/mobile-link-qr.svg', |
||||
height: '270px', |
||||
width: '270px', |
||||
}, |
||||
}, |
||||
2: { |
||||
id: 2, |
||||
date: '2020-08-31', |
||||
}, |
||||
3: { |
||||
id: 3, |
||||
date: '2021-03-8', |
||||
}, |
||||
}; |
||||
|
||||
export const getTranslatedUINoficiations = (t, locale) => { |
||||
return { |
||||
1: { |
||||
...UI_NOTIFICATIONS[1], |
||||
title: t('notifications1Title'), |
||||
description: t('notifications1Description'), |
||||
date: new Intl.DateTimeFormat(locale).format( |
||||
new Date(UI_NOTIFICATIONS[1].date), |
||||
), |
||||
}, |
||||
2: { |
||||
...UI_NOTIFICATIONS[2], |
||||
title: t('notifications2Title'), |
||||
description: t('notifications2Description'), |
||||
actionText: t('notifications2ActionText'), |
||||
date: new Intl.DateTimeFormat(locale).format( |
||||
new Date(UI_NOTIFICATIONS[2].date), |
||||
), |
||||
}, |
||||
3: { |
||||
...UI_NOTIFICATIONS[3], |
||||
title: t('notifications3Title'), |
||||
description: t('notifications3Description'), |
||||
actionText: t('notifications3ActionText'), |
||||
date: new Intl.DateTimeFormat(locale).format( |
||||
new Date(UI_NOTIFICATIONS[3].date), |
||||
), |
||||
}, |
||||
}; |
||||
}; |
@ -0,0 +1 @@ |
||||
export { default } from './whats-new-popup'; |
@ -0,0 +1,51 @@ |
||||
.whats-new-popup { |
||||
&__notifications { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
} |
||||
|
||||
&__notification, |
||||
&__first-notification { |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: left; |
||||
margin: 0 24px 24px 24px; |
||||
border-bottom: 1px solid $Grey-100; |
||||
} |
||||
|
||||
&__notification-image { |
||||
margin-bottom: 16px; |
||||
} |
||||
|
||||
&__description-and-date { |
||||
margin-bottom: 16px; |
||||
} |
||||
|
||||
&__notification-date { |
||||
color: $Grey-500; |
||||
} |
||||
|
||||
&__button { |
||||
margin-right: auto; |
||||
} |
||||
|
||||
&__button, |
||||
&__link { |
||||
margin-bottom: 24px; |
||||
} |
||||
|
||||
&__link { |
||||
@include H6; |
||||
|
||||
color: $Blue-500; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
&__notification-title { |
||||
@include H4; |
||||
|
||||
font-weight: bold; |
||||
margin-bottom: 8px; |
||||
} |
||||
} |
@ -0,0 +1,179 @@ |
||||
import React, { useContext, useMemo, useRef, useState, useEffect } from 'react'; |
||||
import { useSelector } from 'react-redux'; |
||||
import PropTypes from 'prop-types'; |
||||
import classnames from 'classnames'; |
||||
import { getCurrentLocale } from '../../../ducks/metamask/metamask'; |
||||
import { I18nContext } from '../../../contexts/i18n'; |
||||
import { useEqualityCheck } from '../../../hooks/useEqualityCheck'; |
||||
import Button from '../../ui/button'; |
||||
import Popover from '../../ui/popover'; |
||||
import { updateViewedNotifications } from '../../../store/actions'; |
||||
import { getTranslatedUINoficiations } from '../../../../../shared/notifications'; |
||||
import { getSortedNotificationsToShow } from '../../../selectors'; |
||||
|
||||
function getActionFunctionById(id) { |
||||
const actionFunctions = { |
||||
2: () => { |
||||
global.platform.openTab({ |
||||
url: |
||||
'https://survey.alchemer.com/s3/6173069/MetaMask-Extension-NPS-January-2021', |
||||
}); |
||||
}, |
||||
3: () => { |
||||
global.platform.openTab({ |
||||
url: 'https://community.metamask.io/t/about-the-security-category/72', |
||||
}); |
||||
}, |
||||
}; |
||||
|
||||
return actionFunctions[id]; |
||||
} |
||||
|
||||
const renderFirstNotification = (notification, idRefMap) => { |
||||
const { id, date, title, description, image, actionText } = notification; |
||||
const actionFunction = getActionFunctionById(id); |
||||
return ( |
||||
<div |
||||
className={classnames( |
||||
'whats-new-popup__notification whats-new-popup__first-notification', |
||||
)} |
||||
key={`whats-new-popop-notificatiion-${id}`} |
||||
ref={idRefMap[id]} |
||||
> |
||||
{image && ( |
||||
<img |
||||
className="whats-new-popup__notification-image" |
||||
src={image.src} |
||||
height={image.height} |
||||
width={image.width} |
||||
/> |
||||
)} |
||||
<div className="whats-new-popup__notification-title">{title}</div> |
||||
<div className="whats-new-popup__description-and-date"> |
||||
<div className="whats-new-popup__notification-description"> |
||||
{description} |
||||
</div> |
||||
<div className="whats-new-popup__notification-date">{date}</div> |
||||
</div> |
||||
{actionText && ( |
||||
<Button |
||||
type="secondary" |
||||
className="whats-new-popup__button" |
||||
rounded |
||||
onClick={actionFunction} |
||||
> |
||||
{actionText} |
||||
</Button> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const renderSubsequentNotification = (notification, idRefMap) => { |
||||
const { id, date, title, description, actionText } = notification; |
||||
|
||||
const actionFunction = getActionFunctionById(id); |
||||
return ( |
||||
<div |
||||
className={classnames('whats-new-popup__notification')} |
||||
key={`whats-new-popop-notificatiion-${id}`} |
||||
ref={idRefMap[id]} |
||||
> |
||||
<div className="whats-new-popup__notification-title">{title}</div> |
||||
<div className="whats-new-popup__description-and-date"> |
||||
<div className="whats-new-popup__notification-description"> |
||||
{description} |
||||
</div> |
||||
<div className="whats-new-popup__notification-date">{date}</div> |
||||
</div> |
||||
{actionText && ( |
||||
<div className="whats-new-popup__link" onClick={actionFunction}> |
||||
{actionText} |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export default function WhatsNewPopup({ onClose }) { |
||||
const t = useContext(I18nContext); |
||||
|
||||
const notifications = useSelector(getSortedNotificationsToShow); |
||||
const locale = useSelector(getCurrentLocale); |
||||
|
||||
const [seenNotifications, setSeenNotifications] = useState({}); |
||||
|
||||
const popoverRef = useRef(); |
||||
|
||||
const memoizedNotifications = useEqualityCheck(notifications); |
||||
const idRefMap = useMemo( |
||||
() => |
||||
memoizedNotifications.reduce( |
||||
(_idRefMap, notification) => ({ |
||||
..._idRefMap, |
||||
[notification.id]: React.createRef(), |
||||
}), |
||||
{}, |
||||
), |
||||
[memoizedNotifications], |
||||
); |
||||
|
||||
useEffect(() => { |
||||
const observer = new window.IntersectionObserver( |
||||
(entries, _observer) => { |
||||
entries.forEach((entry) => { |
||||
if (entry.isIntersecting) { |
||||
const [id, ref] = Object.entries(idRefMap).find(([_, _ref]) => |
||||
_ref.current.isSameNode(entry.target), |
||||
); |
||||
|
||||
setSeenNotifications((_seenNotifications) => ({ |
||||
..._seenNotifications, |
||||
[id]: true, |
||||
})); |
||||
|
||||
_observer.unobserve(ref.current); |
||||
} |
||||
}); |
||||
}, |
||||
{ |
||||
root: popoverRef.current, |
||||
threshold: 1.0, |
||||
}, |
||||
); |
||||
|
||||
Object.values(idRefMap).forEach((ref) => { |
||||
observer.observe(ref.current); |
||||
}); |
||||
|
||||
return () => { |
||||
observer.disconnect(); |
||||
}; |
||||
}, [idRefMap, setSeenNotifications]); |
||||
|
||||
return ( |
||||
<Popover |
||||
className="whats-new-popup__popover" |
||||
title={t('whatsNew')} |
||||
onClose={() => { |
||||
updateViewedNotifications(seenNotifications); |
||||
onClose(); |
||||
}} |
||||
popoverRef={popoverRef} |
||||
mediumHeight |
||||
> |
||||
<div className="whats-new-popup__notifications"> |
||||
{notifications.map(({ id }, index) => { |
||||
const notification = getTranslatedUINoficiations(t, locale)[id]; |
||||
return index === 0 |
||||
? renderFirstNotification(notification, idRefMap) |
||||
: renderSubsequentNotification(notification, idRefMap); |
||||
})} |
||||
</div> |
||||
</Popover> |
||||
); |
||||
} |
||||
|
||||
WhatsNewPopup.propTypes = { |
||||
onClose: PropTypes.func.isRequired, |
||||
}; |
@ -1,9 +0,0 @@ |
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP |
||||
|
||||
exports[`IntroPopup renders the component with initial props 1`] = ` |
||||
<div> |
||||
<div |
||||
class="intro-popup" |
||||
/> |
||||
</div> |
||||
`; |
@ -1 +0,0 @@ |
||||
export { default } from './intro-popup'; |
@ -1,71 +0,0 @@ |
||||
.intro-popup { |
||||
&__liquidity-sources-label { |
||||
@include H7; |
||||
|
||||
font-weight: bold; |
||||
margin-bottom: 6px; |
||||
color: $Black-100; |
||||
|
||||
@media screen and (min-width: 576px) { |
||||
@include H6; |
||||
} |
||||
} |
||||
|
||||
&__learn-more-header { |
||||
@include H4; |
||||
|
||||
font-weight: bold; |
||||
margin-bottom: 12px; |
||||
margin-top: 16px; |
||||
} |
||||
|
||||
&__learn-more-link { |
||||
@include H6; |
||||
|
||||
color: $Blue-500; |
||||
margin-bottom: 8px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
&__content { |
||||
margin-left: 24px; |
||||
|
||||
> img { |
||||
width: 96%; |
||||
margin-left: -9px; |
||||
} |
||||
} |
||||
|
||||
&__footer { |
||||
border-top: none; |
||||
} |
||||
|
||||
&__button { |
||||
border-radius: 100px; |
||||
height: 44px; |
||||
} |
||||
|
||||
&__source-logo-container { |
||||
width: 276px; |
||||
display: flex; |
||||
justify-content: center; |
||||
align-items: center; |
||||
padding: 20px 16px; |
||||
background: $Grey-000; |
||||
border-radius: 8px; |
||||
|
||||
@media screen and (min-width: 576px) { |
||||
width: 412px; |
||||
|
||||
img { |
||||
width: 364px; |
||||
} |
||||
} |
||||
} |
||||
|
||||
&__popover { |
||||
@media screen and (min-width: 576px) { |
||||
width: 460px; |
||||
} |
||||
} |
||||
} |
@ -1,108 +0,0 @@ |
||||
import React, { useContext } from 'react'; |
||||
import { useDispatch, useSelector } from 'react-redux'; |
||||
import { useHistory } from 'react-router-dom'; |
||||
import PropTypes from 'prop-types'; |
||||
import { setSwapsFromToken } from '../../../ducks/swaps/swaps'; |
||||
import { I18nContext } from '../../../contexts/i18n'; |
||||
import { BUILD_QUOTE_ROUTE } from '../../../helpers/constants/routes'; |
||||
import { useNewMetricEvent } from '../../../hooks/useMetricEvent'; |
||||
import { getSwapsDefaultToken } from '../../../selectors'; |
||||
import Button from '../../../components/ui/button'; |
||||
import Popover from '../../../components/ui/popover'; |
||||
|
||||
export default function IntroPopup({ onClose }) { |
||||
const dispatch = useDispatch(useDispatch); |
||||
const history = useHistory(); |
||||
const t = useContext(I18nContext); |
||||
|
||||
const swapsDefaultToken = useSelector(getSwapsDefaultToken); |
||||
const enteredSwapsEvent = useNewMetricEvent({ |
||||
event: 'Swaps Opened', |
||||
properties: { |
||||
source: 'Intro popup', |
||||
active_currency: swapsDefaultToken.symbol, |
||||
}, |
||||
category: 'swaps', |
||||
}); |
||||
const blogPostVisitedEvent = useNewMetricEvent({ |
||||
event: 'Blog Post Visited ', |
||||
category: 'swaps', |
||||
}); |
||||
const contractAuditVisitedEvent = useNewMetricEvent({ |
||||
event: 'Contract Audit Visited', |
||||
category: 'swaps', |
||||
}); |
||||
const productOverviewDismissedEvent = useNewMetricEvent({ |
||||
event: 'Product Overview Dismissed', |
||||
category: 'swaps', |
||||
}); |
||||
|
||||
return ( |
||||
<div className="intro-popup"> |
||||
<Popover |
||||
className="intro-popup__popover" |
||||
title={t('swapIntroPopupTitle')} |
||||
subtitle={t('swapIntroPopupSubTitle')} |
||||
onClose={() => { |
||||
productOverviewDismissedEvent(); |
||||
onClose(); |
||||
}} |
||||
footerClassName="intro-popup__footer" |
||||
footer={ |
||||
<Button |
||||
type="confirm" |
||||
className="intro-popup__button" |
||||
onClick={() => { |
||||
onClose(); |
||||
enteredSwapsEvent(); |
||||
dispatch(setSwapsFromToken(swapsDefaultToken)); |
||||
history.push(BUILD_QUOTE_ROUTE); |
||||
}} |
||||
> |
||||
{t('swapStartSwapping')} |
||||
</Button> |
||||
} |
||||
> |
||||
<div className="intro-popup__content"> |
||||
<div className="intro-popup__liquidity-sources-label"> |
||||
{t('swapIntroLiquiditySourcesLabel')} |
||||
</div> |
||||
<div className="intro-popup__source-logo-container"> |
||||
<img src="images/source-logos-all.svg" alt="" /> |
||||
</div> |
||||
<div className="intro-popup__learn-more-header"> |
||||
{t('swapIntroLearnMoreHeader')} |
||||
</div> |
||||
<div |
||||
className="intro-popup__learn-more-link" |
||||
onClick={() => { |
||||
global.platform.openTab({ |
||||
url: |
||||
'https://medium.com/metamask/introducing-metamask-swaps-84318c643785', |
||||
}); |
||||
blogPostVisitedEvent(); |
||||
}} |
||||
> |
||||
{t('swapIntroLearnMoreLink')} |
||||
</div> |
||||
<div |
||||
className="intro-popup__learn-more-link" |
||||
onClick={() => { |
||||
global.platform.openTab({ |
||||
url: |
||||
'https://diligence.consensys.net/audits/private/lsjipyllnw2/', |
||||
}); |
||||
contractAuditVisitedEvent(); |
||||
}} |
||||
> |
||||
{t('swapLearnMoreContractsAuditReview')} |
||||
</div> |
||||
</div> |
||||
</Popover> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
IntroPopup.propTypes = { |
||||
onClose: PropTypes.func.isRequired, |
||||
}; |
@ -1,24 +0,0 @@ |
||||
import React from 'react'; |
||||
import configureMockStore from 'redux-mock-store'; |
||||
|
||||
import { |
||||
renderWithProvider, |
||||
createSwapsMockStore, |
||||
} from '../../../../../test/jest'; |
||||
import IntroPopup from '.'; |
||||
|
||||
const createProps = (customProps = {}) => { |
||||
return { |
||||
onClose: jest.fn(), |
||||
...customProps, |
||||
}; |
||||
}; |
||||
|
||||
describe('IntroPopup', () => { |
||||
it('renders the component with initial props', () => { |
||||
const store = configureMockStore()(createSwapsMockStore()); |
||||
const props = createProps(); |
||||
const { container } = renderWithProvider(<IntroPopup {...props} />, store); |
||||
expect(container).toMatchSnapshot(); |
||||
}); |
||||
}); |
Loading…
Reference in new issue