diff --git a/public/locales/en.json b/public/locales/en.json index 343eaabed..4ac9b6c3a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -383,5 +383,18 @@ "title": "ENS constitution book now available", "description": "A printed copy of the ENS constitution and its signers is now available in hardcover and ultra-limited edition of 50" } + }, + "epns": { + "link": "Wallet Notif (EPNS)", + "modal": { + "subheader": "You can choose to opt-in or opt-out of ENS channel on EPNS to get notified when your domain is near expiry.", + "optInButton": "OPT-IN", + "optOutButton": "OPT-OUT", + "platforms": "View Supported Platforms", + "alreadyOpted": "You are Opted-In!", + "apiError": "Mayday! Mayday! Can the devs do something? They have been notified, please try again a little later!", + "unsupportedNetwork": "Wallet notifications are only supported on the Main net!", + "switchNetwork": "Switch Network" + } } } \ No newline at end of file diff --git a/src/components/Calendar/Dropdown.js b/src/components/Calendar/Dropdown.js index ce6f3f689..00a64db2c 100644 --- a/src/components/Calendar/Dropdown.js +++ b/src/components/Calendar/Dropdown.js @@ -24,7 +24,7 @@ function Dropdown(props, ref) { const allChildren = prependChildren.concat(children).concat(appendChildren) return ( -
+
{allChildren}
) diff --git a/src/components/EPNS/EPNSLink.js b/src/components/EPNS/EPNSLink.js new file mode 100644 index 000000000..1f3cb42b4 --- /dev/null +++ b/src/components/EPNS/EPNSLink.js @@ -0,0 +1,21 @@ +import React from 'react' + +const EPNSLink = ({ children, onClick = null }) => { + async function handleClick(e) { + e.preventDefault() + + if (onClick) { + onClick(e) + } + } + + return ( + <> + + {children} + + + ) +} + +export default EPNSLink diff --git a/src/components/EPNS/EPNSNotificationModal.js b/src/components/EPNS/EPNSNotificationModal.js new file mode 100644 index 000000000..1d2760c18 --- /dev/null +++ b/src/components/EPNS/EPNSNotificationModal.js @@ -0,0 +1,233 @@ +import styled from '@emotion/styled' +import mq from 'mediaQuery' +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { + default as Button, + getButtonDefaultStyles, + getButtonStyles +} from '../Forms/Button' +import Loader from '../Loader' +import { useWeb3Data } from './useWeb3' +import { selectConfig, isUserSubscribed, optIn, optOut } from './EPNSUtil' +import OnSubscribedModal from './EPNSOnSubscribeModal' + +const LoadingComponent = styled(Loader)` + display: inline-flex; + align-items: center; + margin: 0 10px; + vertical-align: middle; +` + +const FormComponent = styled('form')` + display: grid; + grid-gap: 10px; + font-weight: 200; +` + +const FormLabel = styled('label')` + display: block; + margin-top: 20px; +` + +const FormWarning = styled('div')` + color: #f5a623; + margin-bottom: 10px; +` + +const FormError = styled(FormLabel)` + color: red; + font-size: 0.8rem; +` + +const FormContent = styled('div')` + margin-top: 15px; + margin-bottom: 0; +` + +const FormText = styled('div')` + font-size: 1rem; + margin-bottom: 45px; +` + +const FormActions = styled('div')` + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 15px; + width: 100%; + + ${mq.small` + gap: 0px; + `} +` + +const buttonStyles = ` + position: relative; +` + +const SubActionWrapper = styled('div')` + display: flex; + gap: 15px; + flex-wrap: wrap; + width: 100%; + + ${mq.small` + width: fit-content; + `} +` + +const ResponsiveButton = styled(Button)` + width: 100%; + ${mq.small` + width: auto; + `} +` + +const CancelComponent = styled(ResponsiveButton)` + ${getButtonDefaultStyles()} + ${getButtonStyles({ type: 'hollow' })} + ${buttonStyles} + ` + +const EPNSNotificationModal = ({ address, onCancel }) => { + const { t } = useTranslation() + + const { signer, networkId } = useWeb3Data() + const [epnsConfig, setEpnsConfig] = useState({}) + const [isChannelSubscribed, setIsChannelSubscribed] = useState() + const [isLoading, setIsLoading] = useState() + const [isError, setError] = useState() + const [networkSupported, setNetworkSupported] = useState() // epns api only support kovan + const [isModalOpen, setIsModalOpen] = useState() + + const disableButton = !networkSupported + const buttonType = disableButton ? 'disabled' : 'primary' + + const handleSubmit = e => { + e.preventDefault() + } + + // opt into the ENS channel + async function callOptIn() { + setIsLoading(true) + await optIn(signer, epnsConfig.CHANNEL_ADDRESS, networkId, address, { + baseApiUrl: epnsConfig.API_BASE_URL, + onSuccess: () => { + setIsChannelSubscribed(true) + setIsLoading(false) + setIsModalOpen(true) + }, + onError: _ => { + setError(true) + setIsLoading(false) + } + }) + } + + // opt out of the ENS channel + async function callOptOut() { + setIsLoading(true) + await optOut(signer, epnsConfig.CHANNEL_ADDRESS, networkId, address, { + baseApiUrl: epnsConfig.API_BASE_URL, + onSuccess: () => { + setIsChannelSubscribed(false) + setIsLoading(false) + }, + onError: _ => { + setError(true) + setIsLoading(false) + } + }) + } + + const toggleSubscription = () => { + if (!isChannelSubscribed) { + callOptIn() + } else { + callOptOut() + } + } + + const toggleModal = () => { + setIsModalOpen(value => !value) + } + // fetch and update of a user is already subscribed to the ens channel + async function fetchAndUpdateSubscription() { + try { + setIsLoading(true) + const status = await isUserSubscribed( + address, + epnsConfig.CHANNEL_ADDRESS, + epnsConfig.API_BASE_URL + ) + setIsChannelSubscribed(status) + } catch (err) { + setError(true) + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + // determine the config + if (networkId) { + setEpnsConfig(selectConfig(networkId)) + } + }, [networkId]) + + useEffect(() => { + // fetch the channel details if we have config + const isSupprtedNW = Object.keys(epnsConfig).length > 0 + if (isSupprtedNW) { + fetchAndUpdateSubscription() + } + setNetworkSupported(isSupprtedNW) + }, [epnsConfig]) + + return ( + + + {t('epns.modal.subheader')} + {!networkSupported ? ( + {t('epns.modal.unsupportedNetwork')} + ) : null} + {isError ? {t('epns.modal.apiError')} : null} + + + + {t('c.cancel')} + + + {t('epns.modal.platforms')} + + {isLoading ? ( + + ) : isChannelSubscribed ? ( + + {t('epns.modal.optOutButton')} + + ) : ( + + {t('epns.modal.optInButton')} + + )} + + + + {/* OnSubscribeModal */} + {isModalOpen ? : null} + + ) +} + +export default EPNSNotificationModal diff --git a/src/components/EPNS/EPNSOnSubscribeModal.js b/src/components/EPNS/EPNSOnSubscribeModal.js new file mode 100644 index 000000000..d515ec8fb --- /dev/null +++ b/src/components/EPNS/EPNSOnSubscribeModal.js @@ -0,0 +1,176 @@ +import React from 'react' +// import * as ReactUse from "react-use"; +import styled from '@emotion/styled' +import { LINKS, CLOSE_ICON } from './EPNSUtil' +import { useOnClickOutside } from 'components/hooks' + +const OnSubscribedModal = ({ onClose }) => { + const modalRef = React.useRef(null) + // dummy function to help navigate to another page + const goto = url => { + window.open(url, '_blank') + } + + // ReactUse.useClickAway(modalRef, onClose); + useOnClickOutside([modalRef], () => onClose()) + + return ( + + + + + + Recieve + Notifications + +

+ Recieve notifications from EPNS via the following platforms. +

+
+ + + {LINKS.map((oneLink, idx) => ( + goto(oneLink.link)} key={idx}> + + {oneLink.text} + + ))} + +
+
+ ) +} + +const ItemLink = styled.div` + width: 260px; + height: 62px; + padding-left: 22px; + + background: #fafafa; + border: 0.2px solid rgba(0, 0, 0, 0.16); + box-sizing: border-box; + border-radius: 5px; + font-size: 0.75em; + text-transform: uppercase; + display: flex; + align-items: center; + gap: 1.3125em; + + cursor: pointer; + transition: 300ms; + + &:hover { + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); + } +` +const CustomHeaderTwo = styled.h2` + margin-top: 0; + margin-bottom: 1em; + color: rgb(0, 0, 0); + font-weight: 600; + font-size: 1.5625em; + letter-spacing: 0.1em; + text-transform: uppercase; + padding: 0px; + font-family: inherit; + text-align: inherit; +` + +const Item = styled.div` + display: flex; + flex-direction: column; + text-transform: capitalise; + + &.modal__heading { + margin-bottom: 3.3125rem; + } + + &.modal__content { + display: grid; + grid-template-columns: 50% 50%; + grid-row-gap: 3.3125em; + } +` + +const CustomSpan = styled.span` + flex: initial; + align-self: auto; + color: rgb(0, 0, 0); + background: transparent; + font-weight: 400; + font-size: inherit; + text-transform: inherit; + margin: 0px; + padding: 0px; + letter-spacing: inherit; + text-align: initial; + position: initial; + inset: auto; + z-index: auto; +` + +const StyledSpan = styled(CustomSpan)` + background: rgb(226, 8, 128); + color: #fff; + font-weight: 600; + padding: 3px 8px; +` + +const H3 = styled.h3` + color: rgb(0 0 0 / 0.5); + font-weight: 300; + font-size: 1em; + text-transform: uppercase; + margin: -15px 0px 20px 0px; + padding: 0px; + letter-spacing: 0.1em; + font-family: 'Source Sans Pro', Helvetica, sans-serif; + text-align: inherit; + max-width: initial; +` + +const Overlay = styled.div` + top: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.85); + height: 100%; + width: 100%; + z-index: 1000; + position: fixed; + display: flex; + justify-content: center; + align-items: center; + overflow-y: scroll; +` + +const Modal = styled.div` + padding: 3.875em; + background: white; + text-align: left; + border: 1px solid rgba(0, 0, 0, 0.16); + box-sizing: border-box; + box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); + border-radius: 15px; + position: relative; + + & > img { + position: absolute; + right: 40px; + top: 40px; + cursor: pointer; + } + + @media (max-width: 1000px) { + width: max(70vw, 350px); + padding: 2em; + .modal__content { + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 10px !important; + } + } +` + +export default OnSubscribedModal diff --git a/src/components/EPNS/EPNSUtil.js b/src/components/EPNS/EPNSUtil.js new file mode 100644 index 000000000..c8224cdb4 --- /dev/null +++ b/src/components/EPNS/EPNSUtil.js @@ -0,0 +1,242 @@ +const EPNS_CONFIG = { + // MAIN_NET - prod + 1: { + CHANNEL_ADDRESS: '0x983110309620D911731Ac0932219af06091b6744', // ENS address + API_BASE_URL: 'https://backend-prod.epns.io/apis', + EPNS_COMMUNICATOR_CONTRACT: '0xb3971BCef2D791bc4027BbfedFb47319A4AAaaAa' + } +} + +const signingConstants = { + // The several types of actions and their corresponding types + // which we can take, when it comes to signing messages + ACTION_TYPES: { + // the type to be used for the subscribe action to a channel + subscribe: { + Subscribe: [ + { name: 'channel', type: 'address' }, + { name: 'subscriber', type: 'address' }, + { name: 'action', type: 'string' } + ] + }, + // the type to be used for the unsubscribe action to a channel + unsubscribe: { + Unsubscribe: [ + { name: 'channel', type: 'address' }, + { name: 'unsubscriber', type: 'address' }, + { name: 'action', type: 'string' } + ] + } + } +} + +function getDomainInformation(chainId, verifyingContractAddress) { + return { + name: 'EPNS COMM V1', + chainId: chainId, + verifyingContract: + verifyingContractAddress || + EPNS_CONFIG[chainId].EPNS_COMMUNICATOR_CONTRACT + } +} + +function getSubscriptionMessage(channelAddress, userAddress, action) { + return { + channel: channelAddress, + [action === 'Unsubscribe' ? 'unsubscriber' : 'subscriber']: userAddress, + action: action + } +} + +async function fetchPOST(url, body = {}, options = {}) { + return fetch(url, { + method: 'POST', + headers: { + 'Content-type': 'application/json; charset=UTF-8' + }, + ...options, + body: body + }) + .then(async response => { + const isJson = response.headers + .get('content-type') + ?.includes('application/json') + const data = isJson ? await response.json() : null + + // check for error response + if (!response.ok) { + // get error message from body or default to response status + const error = (data && data.message) || response.status + return Promise.reject(error) + } + + return Promise.resolve(data) + }) + .catch(error => { + console.error('EPNS API Fetch error: ', error) + throw error + }) +} + +async function optIn( + signer, + channelAddress, + chainId, + userAddress, + { + baseApiUrl = EPNS_CONFIG[chainId].API_BASE_URL, + verifyingContractAddress = EPNS_CONFIG[chainId].EPNS_COMMUNICATOR_CONTRACT, + onSuccess = () => 'success', + onError = () => 'error' + } = {} +) { + try { + // get domain information + const domainInformation = getDomainInformation( + chainId, + verifyingContractAddress + ) + // get type information + const typeInformation = signingConstants.ACTION_TYPES['subscribe'] + // get message + const messageInformation = getSubscriptionMessage( + channelAddress, + userAddress, + 'Subscribe' + ) + // sign message + const signature = await signer._signTypedData( + domainInformation, + typeInformation, + messageInformation + ) + + const postBody = JSON.stringify({ + signature, + message: messageInformation, + op: 'write', + chainId, + contractAddress: verifyingContractAddress + }) + + await fetchPOST(`${baseApiUrl}/channels/subscribe_offchain`, postBody) + + onSuccess() + return { status: 'success', message: 'succesfully opted into channel' } + } catch (err) { + onError(err) + return { status: 'error', message: err.message } + } +} + +async function optOut( + signer, + channelAddress, + chainId, + userAddress, + { + baseApiUrl = EPNS_CONFIG[chainId].API_BASE_URL, + verifyingContractAddress = EPNS_CONFIG[chainId].EPNS_COMMUNICATOR_CONTRACT, + onSuccess = () => 'success', + onError = () => 'error' + } = {} +) { + try { + // get domain information + const domainInformation = getDomainInformation( + chainId, + verifyingContractAddress + ) + // get type information + const typeInformation = signingConstants.ACTION_TYPES['unsubscribe'] + + // get message + const messageInformation = getSubscriptionMessage( + channelAddress, + userAddress, + 'Unsubscribe' + ) + + // sign message + const signature = await signer._signTypedData( + domainInformation, + typeInformation, + messageInformation + ) + + const postBody = JSON.stringify({ + signature, + message: messageInformation, + op: 'write', + chainId, + contractAddress: verifyingContractAddress + }) + + await fetchPOST(`${baseApiUrl}/channels/unsubscribe_offchain`, postBody) + + onSuccess() + return { status: 'success', message: 'succesfully opted out of channel' } + } catch (err) { + onError(err) + return { status: 'error', message: err.message } + } +} + +function selectConfig(networkId) { + return EPNS_CONFIG[networkId] || {} +} + +/** + * Function to obtain all the addresses subscribed to a channel + * @param channelAddress the address of the channel + * @param userAddress + */ +async function getSubscribers(channelAddress, baseApiUrl) { + try { + const postBody = JSON.stringify({ channel: channelAddress, op: 'read' }) + const response = await fetchPOST( + `${baseApiUrl}/channels/get_subscribers`, + postBody + ) + + return response.subscribers || [] + } catch (err) { + throw err + } +} + +async function isUserSubscribed(userAddress, channelAddress, baseApiUrl) { + const channelSubscribers = await getSubscribers(channelAddress, baseApiUrl) + + return channelSubscribers + .map(a => a.toLowerCase()) + .includes(userAddress.toLowerCase()) +} + +const LINKS = [ + { + text: 'EPNS Browser Extension', + link: + 'https://chrome.google.com/webstore/detail/epns-protocol-alpha/lbdcbpaldalgiieffakjhiccoeebchmg', + img: 'https://backend-kovan.epns.io/assets/googlechromeicon.png' + }, + { + text: 'EPNS App (iOS)', + link: 'https://apps.apple.com/app/ethereum-push-service-epns/id1528614910', + img: 'https://backend-kovan.epns.io/assets/apple.png' + }, + { + text: 'EPNS App (Android)', + link: 'https://play.google.com/store/apps/details?id=io.epns.epns', + img: 'https://backend-kovan.epns.io/assets/playstorecolor@3x.png' + }, + { + text: 'Visit our dApp', + link: 'https://app.epns.io/', + img: 'https://backend-kovan.epns.io/assets/dappcolor@3x.png' + } +] + +const CLOSE_ICON = 'https://backend-kovan.epns.io/assets/cross.png' + +export { optIn, optOut, selectConfig, isUserSubscribed, LINKS, CLOSE_ICON } diff --git a/src/components/EPNS/index.js b/src/components/EPNS/index.js new file mode 100644 index 000000000..d3111da37 --- /dev/null +++ b/src/components/EPNS/index.js @@ -0,0 +1,5 @@ +import EPNSLink from './EPNSLink' +import EPNSNotificationModal from './EPNSNotificationModal' +import { useWeb3Data } from './useWeb3' + +export { EPNSLink, EPNSNotificationModal, useWeb3Data } diff --git a/src/components/EPNS/useWeb3.js b/src/components/EPNS/useWeb3.js new file mode 100644 index 000000000..7b21665dd --- /dev/null +++ b/src/components/EPNS/useWeb3.js @@ -0,0 +1,26 @@ +import { useEffect, useState } from 'react' +import { getSigner, getNetworkId } from '@ensdomains/ui' + +export function useWeb3Data() { + const [signer, setSigner] = useState(null) + const [networkId, setNetworkId] = useState(null) + const isEpnsSupportedNetwork = [1, 42].includes(networkId) + + useEffect(() => { + async function init() { + const _signer = await getSigner() + const _networkId = await getNetworkId() + + setSigner(_signer) + setNetworkId(_networkId) + } + + init() + }, []) + + return { + signer, + networkId, + isEpnsSupportedNetwork + } +} diff --git a/src/components/ExpiryNotification/EmailNotifyLink.js b/src/components/ExpiryNotification/EmailNotifyLink.js index 8c32a65df..60f97a27c 100644 --- a/src/components/ExpiryNotification/EmailNotifyLink.js +++ b/src/components/ExpiryNotification/EmailNotifyLink.js @@ -1,9 +1,5 @@ import React, { useContext, useState } from 'react' -import GlobalState from '../../globalState' - -import Modal from '../Modal/Modal' - -import ExpiryNotificationModal from './ExpiryNotificationModal' +import styled from '@emotion/styled/macro' // If react-add-to-calendar-hoc is replaced, it may be useful // to switch to a button element for a11y purposes. diff --git a/src/components/ExpiryNotification/ExpiryNotifyDropdown.js b/src/components/ExpiryNotification/ExpiryNotifyDropdown.js index 710bd0a05..37e9cd6f6 100644 --- a/src/components/ExpiryNotification/ExpiryNotifyDropdown.js +++ b/src/components/ExpiryNotification/ExpiryNotifyDropdown.js @@ -7,17 +7,22 @@ import EmailNotifyLink from './EmailNotifyLink' import Modal from '../Modal/Modal' import ExpiryNotificationModal from './ExpiryNotificationModal' import { useOnClickOutside } from 'components/hooks' +import { EPNSLink, EPNSNotificationModal, useWeb3Data } from '../EPNS' const ExpiryNotifyDropdownContainer = styled('div')` position: relative; ` +const customDropdownStyles = { minWidth: 162, gap: '10px' } + export default function ExpiryNotifyDropdown({ address }) { const dropdownRef = createRef() const togglerRef = createRef() const [showDropdown, setShowDropdown] = useState(false) const [showModal, setShowModal] = useState(false) + const [optionSelected, setOptionSelected] = useState(false) const { t } = useTranslation() + const { isEpnsSupportedNetwork } = useWeb3Data() useOnClickOutside([dropdownRef, togglerRef], () => setShowDropdown(false)) @@ -26,12 +31,20 @@ export default function ExpiryNotifyDropdown({ address }) { } const handleEmailNotifyClick = () => { + setOptionSelected('email') setShowModal(true) setShowDropdown(false) } const handleCloseModal = () => { setShowModal(false) + setOptionSelected('') + } + + const handleEPNSNotifyClick = () => { + setOptionSelected('epns') + setShowModal(true) + setShowDropdown(false) } return ( @@ -40,7 +53,7 @@ export default function ExpiryNotifyDropdown({ address }) { {t('expiryNotification.reminder')} {showDropdown && ( - + {t('c.email')} + + {isEpnsSupportedNetwork && ( + + {t('epns.link')} + + )} )} - {showModal && ( + {showModal && optionSelected === 'email' && ( )} + + {isEpnsSupportedNetwork && showModal && optionSelected === 'epns' && ( + + + + )} ) }