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' && (
+
+
+
+ )}
)
}