diff --git a/src/App.tsx b/src/App.tsx index 5a6cdae7f6f..b739d65a7e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { useSelector } from 'react-redux' import { Routes } from 'Routes/Routes' import { ConsentBanner } from 'components/ConsentBanner' import { IconCircle } from 'components/IconCircle' +import { useBridgeClaimNotification } from 'hooks/useBridgeClaimNotification/useBridgeClaimNotification' import { useHasAppUpdated } from 'hooks/useHasAppUpdated/useHasAppUpdated' import { useModal } from 'hooks/useModal/useModal' import { selectShowConsentBanner, selectShowWelcomeModal } from 'state/slices/selectors' @@ -26,6 +27,8 @@ export const App = () => { const showConsentBanner = useSelector(selectShowConsentBanner) const { isOpen: isNativeOnboardOpen, open: openNativeOnboard } = useModal('nativeOnboard') + useBridgeClaimNotification() + const handleCtaClick = useCallback(() => window.location.reload(), []) useEffect(() => { diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 5e60e448742..5e738ccdeee 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -802,7 +802,9 @@ "pendingClaims": "Pending", "noPendingClaims": "No claims pending", "availableIn": "Available in %{time}", - "confirmAndClaim": "Confirm & Claim" + "confirmAndClaim": "Confirm & Claim", + "viewClaims": "View Claims", + "availableClaimsNotification": "You have bridge claims ready!" }, "trade": { "trade": "Trade", diff --git a/src/components/MultiHopTrade/MultiHopTrade.tsx b/src/components/MultiHopTrade/MultiHopTrade.tsx index 00944506809..5d84586edd9 100644 --- a/src/components/MultiHopTrade/MultiHopTrade.tsx +++ b/src/components/MultiHopTrade/MultiHopTrade.tsx @@ -1,6 +1,6 @@ import type { AssetId } from '@shapeshiftoss/caip' import { AnimatePresence } from 'framer-motion' -import { memo, useEffect, useMemo, useRef } from 'react' +import { memo, useEffect, useMemo, useRef, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import { MemoryRouter, Route, Switch, useLocation, useParams } from 'react-router-dom' import { selectAssetById } from 'state/slices/assetsSlice/selectors' @@ -10,6 +10,7 @@ import { useAppDispatch, useAppSelector } from 'state/store' import { MultiHopTradeConfirm } from './components/MultiHopTradeConfirm/MultiHopTradeConfirm' import { QuoteListRoute } from './components/QuoteList/QuoteListRoute' +import { Claim } from './components/TradeInput/components/Claim/Claim' import { TradeInput } from './components/TradeInput/TradeInput' import { VerifyAddresses } from './components/VerifyAddresses/VerifyAddresses' import { useGetTradeQuotes } from './hooks/useGetTradeQuotes/useGetTradeQuotes' @@ -20,6 +21,7 @@ const TradeRouteEntries = [ TradeRoutePaths.Confirm, TradeRoutePaths.VerifyAddresses, TradeRoutePaths.QuoteList, + TradeRoutePaths.Claim, ] export type TradeCardProps = { @@ -39,22 +41,34 @@ const GetTradeQuotes = () => { } export const MultiHopTrade = memo(({ defaultBuyAssetId, isCompact }: TradeCardProps) => { + const location = useLocation() const dispatch = useAppDispatch() const methods = useForm({ mode: 'onChange' }) const { assetSubId, chainId } = useParams() + const [initialIndex, setInitialIndex] = useState() const routeBuyAsset = useAppSelector(state => selectAssetById(state, `${chainId}/${assetSubId}`)) const defaultBuyAsset = useAppSelector(state => selectAssetById(state, defaultBuyAssetId ?? '')) + // Handle deep linked route from other pages + useEffect(() => { + if (initialIndex !== undefined) return + const incomingIndex = TradeRouteEntries.indexOf(location.pathname as TradeRoutePaths) + setInitialIndex(incomingIndex === -1 ? 0 : incomingIndex) + }, [initialIndex, location]) + useEffect(() => { dispatch(tradeInput.actions.clear()) if (routeBuyAsset) dispatch(tradeInput.actions.setBuyAsset(routeBuyAsset)) else if (defaultBuyAsset) dispatch(tradeInput.actions.setBuyAsset(defaultBuyAsset)) }, [defaultBuyAsset, defaultBuyAssetId, dispatch, routeBuyAsset]) + // Prevent default behavior overriding deep linked route + if (initialIndex === undefined) return null + return ( - + @@ -107,6 +121,9 @@ const TradeRoutes = memo(({ isCompact }: TradeRoutesProps) => { width={tradeInputRef.current?.offsetWidth ?? 'full'} /> + + + {/* Stop polling for quotes by unmounting the hook. This prevents trade execution getting */} diff --git a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx index 8d6803a5dd4..a7c9a897e6a 100644 --- a/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/TradeInput.tsx @@ -15,8 +15,8 @@ import { import { MessageOverlay } from 'components/MessageOverlay/MessageOverlay' import { getMixpanelEventData } from 'components/MultiHopTrade/helpers' import { useReceiveAddress } from 'components/MultiHopTrade/hooks/useReceiveAddress' -import { TradeSlideTransition } from 'components/MultiHopTrade/TradeSlideTransition' import { TradeInputTab, TradeRoutePaths } from 'components/MultiHopTrade/types' +import { SlideTransition } from 'components/SlideTransition' import { WalletActions } from 'context/WalletProvider/actions' import { useErrorHandler } from 'hooks/useErrorToast/useErrorToast' import { useWallet } from 'hooks/useWallet/useWallet' @@ -43,7 +43,6 @@ import { useAppDispatch, useAppSelector } from 'state/store' import { breakpoints } from 'theme/theme' import { useAccountIds } from '../../hooks/useAccountIds' -import { Claim } from './components/Claim/Claim' import { CollapsibleQuoteList } from './components/CollapsibleQuoteList' import { ConfirmSummary } from './components/ConfirmSummary' import { TradeInputBody } from './components/TradeInputBody' @@ -73,7 +72,6 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { const mixpanel = getMixPanel() const history = useHistory() const { showErrorToast } = useErrorHandler() - const [selectedTab, setSelectedTab] = useState(TradeInputTab.Trade) const [isConfirmationLoading, setIsConfirmationLoading] = useState(false) const [shouldShowWarningAcknowledgement, setShouldShowWarningAcknowledgement] = useState(false) const [shouldShowStreamingAcknowledgement, setShouldShowStreamingAcknowledgement] = @@ -210,6 +208,15 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { const handleFormSubmit = useMemo(() => handleSubmit(onSubmit), [handleSubmit, onSubmit]) + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + if (newTab === TradeInputTab.Claim) { + history.push(TradeRoutePaths.Claim) + } + }, + [history], + ) + // If the warning acknowledgement is shown, we need to handle the submit differently because we might want to show the streaming acknowledgement const handleWarningAcknowledgementSubmit = useCallback(() => { if (activeQuote?.isStreaming && isEstimatedExecutionTimeOverThreshold) @@ -249,84 +256,76 @@ export const TradeInput = ({ isCompact, tradeInputRef }: TradeInputProps) => { })() return ( - - - -
- - + +
+ + + - - - - - {selectedTab === TradeInputTab.Trade && ( - - - - - )} - {selectedTab === TradeInputTab.Claim && } - - - - - - - -
-
- - + + + + + + + + + + + +
+
+ + +
+
+
) } diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx index 8af0b56421d..a7ceca2a8f8 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/Claim.tsx @@ -1,53 +1,35 @@ +import { Card } from '@chakra-ui/react' import type { TxStatus } from '@shapeshiftoss/unchained-client' -import { AnimatePresence } from 'framer-motion' -import { lazy, Suspense, useCallback, useState } from 'react' -import { MemoryRouter, Route, Switch, useLocation } from 'react-router' -import { makeSuspenseful } from 'utils/makeSuspenseful' +import { useCallback, useState } from 'react' +import { MemoryRouter, Route, Switch, useHistory, useLocation } from 'react-router' +import { TradeInputTab, TradeRoutePaths } from 'components/MultiHopTrade/types' +import { TradeInputHeader } from '../TradeInputHeader' +import { ClaimConfirm } from './ClaimConfirm' +import { ClaimSelect } from './ClaimSelect' +import { ClaimStatus } from './ClaimStatus' import type { ClaimDetails } from './hooks/useArbitrumClaimsByStatus' import { ClaimRoutePaths } from './types' -const ClaimSelect = makeSuspenseful( - lazy(() => - import('./ClaimSelect').then(({ ClaimSelect }) => ({ - default: ClaimSelect, - })), - ), -) - -const ClaimConfirm = makeSuspenseful( - lazy(() => - import('./ClaimConfirm').then(({ ClaimConfirm }) => ({ - default: ClaimConfirm, - })), - ), -) - -const ClaimStatus = makeSuspenseful( - lazy(() => - import('./ClaimStatus').then(({ ClaimStatus }) => ({ - default: ClaimStatus, - })), - ), -) - const ClaimRouteEntries = [ClaimRoutePaths.Select, ClaimRoutePaths.Confirm, ClaimRoutePaths.Status] -export const Claim: React.FC = () => { - return ( - - - - ) -} - -export const ClaimRoutes: React.FC = () => { +export const Claim = ({ isCompact }: { isCompact?: boolean }) => { const location = useLocation() + const history = useHistory() const [activeClaim, setActiveClaim] = useState() const [claimTxHash, setClaimTxHash] = useState() const [claimTxStatus, setClaimTxStatus] = useState() + const handleChangeTab = useCallback( + (newTab: TradeInputTab) => { + if (newTab === TradeInputTab.Trade) { + history.push(TradeRoutePaths.Input) + } + }, + [history], + ) + const renderClaimSelect = useCallback(() => { return }, []) @@ -79,9 +61,15 @@ export const ClaimRoutes: React.FC = () => { }, [activeClaim, claimTxHash, claimTxStatus]) return ( - + - + + { path={ClaimRoutePaths.Status} render={renderClaimStatus} /> - + - + ) } diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/ClaimSelect.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/ClaimSelect.tsx index fc847ac9f75..7ae5fc77482 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/ClaimSelect.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/ClaimSelect.tsx @@ -2,9 +2,8 @@ import { Box, CardBody, Skeleton } from '@chakra-ui/react' import { useCallback, useMemo } from 'react' import { useHistory } from 'react-router' import { ClaimStatus } from 'components/ClaimRow/types' +import { SlideTransition } from 'components/SlideTransition' import { Text } from 'components/Text' -import { selectArbitrumWithdrawTxs } from 'state/slices/selectors' -import { useAppSelector } from 'state/store' import { ClaimRow } from './ClaimRow' import type { ClaimDetails } from './hooks/useArbitrumClaimsByStatus' @@ -18,8 +17,6 @@ type ClaimSelectProps = { export const ClaimSelect: React.FC = ({ setActiveClaim }) => { const history = useHistory() - const arbitrumWithdrawTxs = useAppSelector(selectArbitrumWithdrawTxs) - const handleClaimClick = useCallback( (claim: ClaimDetails) => { setActiveClaim(claim) @@ -28,7 +25,7 @@ export const ClaimSelect: React.FC = ({ setActiveClaim }) => { [history, setActiveClaim], ) - const { claimsByStatus, isLoading } = useArbitrumClaimsByStatus(arbitrumWithdrawTxs) + const { claimsByStatus, isLoading } = useArbitrumClaimsByStatus() const AvailableClaims = useMemo(() => { if (isLoading) return @@ -61,15 +58,17 @@ export const ClaimSelect: React.FC = ({ setActiveClaim }) => { }, [claimsByStatus.Pending, isLoading, handleClaimClick]) return ( - - - - {AvailableClaims} - - - - {PendingClaims} - - + + + + + {AvailableClaims} + + + + {PendingClaims} + + + ) } diff --git a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx index 1110bb62e71..320e698a29d 100644 --- a/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx +++ b/src/components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus.tsx @@ -9,7 +9,7 @@ import { useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { ClaimStatus } from 'components/ClaimRow/types' import { assertUnreachable } from 'lib/utils' -import { selectAssetById } from 'state/slices/selectors' +import { selectArbitrumWithdrawTxs, selectAssetById } from 'state/slices/selectors' import type { Tx } from 'state/slices/txHistorySlice/txHistorySlice' import { useAppSelector } from 'state/store' @@ -36,47 +36,40 @@ export type ClaimDetails = Omit & { type ClaimsByStatus = Record -export const useArbitrumClaimsByStatus = (txs: Tx[]) => { +export const useArbitrumClaimsByStatus = (props?: { skip?: boolean }) => { const translate = useTranslate() const ethAsset = useAppSelector(state => selectAssetById(state, ethAssetId)) + const arbitrumWithdrawTxs = useAppSelector(selectArbitrumWithdrawTxs) const l1Provider = getEthersV5Provider(KnownChainIds.EthereumMainnet) const l2Provider = getEthersV5Provider(KnownChainIds.ArbitrumMainnet) const queries = useMemo(() => { return { - queries: txs.map(tx => { + queries: arbitrumWithdrawTxs.map(tx => { return { queryKey: ['claimStatus', { txid: tx.txid }], - queryFn: async () => { + queryFn: async (): Promise => { const receipt = await l2Provider.getTransactionReceipt(tx.txid) const l2Receipt = new ChildTransactionReceipt(receipt) const events = l2Receipt.getChildToParentEvents() const messages = await l2Receipt.getChildToParentMessages(l1Provider) - const event = events[0] const message = messages[0] - const status = await message.status(l2Provider) const block = (await message.getFirstExecutableBlock(l2Provider))?.toNumber() - const timeRemainingSeconds = await (async () => { if (!block) return - const latestBlock = await l1Provider.getBlock('latest') const historicalBlock = await l1Provider.getBlock( latestBlock.number - AVERAGE_BLOCK_TIME_BLOCKS, ) - const averageBlockTimeSeconds = (latestBlock.timestamp - historicalBlock.timestamp) / AVERAGE_BLOCK_TIME_BLOCKS - const remainingBlocks = block - latestBlock.number - return remainingBlocks * averageBlockTimeSeconds })() - return { event, message, @@ -97,7 +90,6 @@ export const useArbitrumClaimsByStatus = (txs: Tx[]) => { assertUnreachable(status) } })() - return { tx, event, @@ -106,11 +98,16 @@ export const useArbitrumClaimsByStatus = (txs: Tx[]) => { timeRemainingSeconds, } }, - refetchInterval: 60_000, } }), + // Periodically refetch until the status is known to be ChildToParentMessageStatus.EXECUTED + refetchInterval: (latestData: ClaimStatusResult | undefined) => + latestData?.status === ChildToParentMessageStatus.EXECUTED ? false : 60_000, + isEnabled: !Boolean(props?.skip), + staleTime: Infinity, + gcTime: Infinity, } - }, [l1Provider, l2Provider, txs]) + }, [arbitrumWithdrawTxs, props?.skip, l2Provider, l1Provider]) const claimStatuses = useQueries(queries) @@ -120,12 +117,10 @@ export const useArbitrumClaimsByStatus = (txs: Tx[]) => { if (!data) return prev if (!ethAsset) return prev if (!data.tx.transfers.length) return prev - if (data.tx.data?.parser === 'arbitrumBridge') { if (!data.tx.data.value) return prev if (!data.tx.data.destinationAddress) return prev if (!data.tx.data.destinationAssetId) return prev - prev[data.claimStatus].push({ tx: data.tx, accountId: toAccountId({ @@ -144,7 +139,6 @@ export const useArbitrumClaimsByStatus = (txs: Tx[]) => { description: translate('bridge.arbitrum.description'), }) } - return prev }, { diff --git a/src/components/MultiHopTrade/types.ts b/src/components/MultiHopTrade/types.ts index fcdd1caa3b1..6cbe72bb8c2 100644 --- a/src/components/MultiHopTrade/types.ts +++ b/src/components/MultiHopTrade/types.ts @@ -8,6 +8,7 @@ export enum TradeRoutePaths { Quotes = '/trade/quotes', VerifyAddresses = '/trade/verify-addresses', QuoteList = '/trade/quote-list', + Claim = '/trade/claim', } export type GetReceiveAddressArgs = { diff --git a/src/hooks/useBridgeClaimNotification/useBridgeClaimNotification.tsx b/src/hooks/useBridgeClaimNotification/useBridgeClaimNotification.tsx new file mode 100644 index 00000000000..aab78380df7 --- /dev/null +++ b/src/hooks/useBridgeClaimNotification/useBridgeClaimNotification.tsx @@ -0,0 +1,79 @@ +import { Alert, AlertDescription, Button, CloseButton, Flex, useToast } from '@chakra-ui/react' +import type { ResponsiveValue } from '@chakra-ui/system' +import type { Property } from 'csstype' +import { useEffect, useState } from 'react' +import { FaInfoCircle } from 'react-icons/fa' +import { useTranslate } from 'react-polyglot' +import { useHistory } from 'react-router' +import { IconCircle } from 'components/IconCircle' +import { useArbitrumClaimsByStatus } from 'components/MultiHopTrade/components/TradeInput/components/Claim/hooks/useArbitrumClaimsByStatus' +import { TradeRoutePaths } from 'components/MultiHopTrade/types' +import { useWallet } from 'hooks/useWallet/useWallet' + +const flexGap = { base: 2, md: 3 } +const flexDir: ResponsiveValue = { base: 'column', md: 'row' } +const flexAlignItems = { base: 'flex-start', md: 'center' } + +export const useBridgeClaimNotification = () => { + const toast = useToast() + const history = useHistory() + const translate = useTranslate() + const [isDisabled, setIsDisabled] = useState(false) + + const { + state: { deviceId: walletDeviceId }, + } = useWallet() + + const { claimsByStatus, isLoading } = useArbitrumClaimsByStatus({ skip: isDisabled }) + + // Re-enable the notification when wallet changes + useEffect(() => { + setIsDisabled(false) + }, [walletDeviceId]) + + useEffect(() => { + if (isLoading || isDisabled) return + if (claimsByStatus.Available.length === 0) return + + // trigger a toast + toast({ + render: ({ onClose }) => { + const handleCtaClick = () => { + history.push(TradeRoutePaths.Claim) + onClose() + } + + return ( + + + + + + + {translate('bridge.availableClaimsNotification')} + + + + + + + ) + }, + id: 'bridge-claim', + duration: null, + isClosable: true, + position: 'bottom-right', + }) + + // don't spam user + setIsDisabled(true) + }, [claimsByStatus.Available.length, history, isDisabled, isLoading, toast, translate]) +}