import BigNumber from 'bignumber.js'
import { isEqual } from 'lodash'
import React, { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
ActivityIndicator,
RefreshControl,
SectionList,
StyleSheet,
Text,
View,
} from 'react-native'
import AppAnalytics from 'src/analytics/AppAnalytics'
import { SwapEvents } from 'src/analytics/Events'
import { NotificationVariant } from 'src/components/InLineNotification'
import SectionHead from 'src/components/SectionHead'
import Toast from 'src/components/Toast'
import ActionsCarousel from 'src/home/ActionsCarousel'
import GetStarted from 'src/home/GetStarted'
import NotificationBox from 'src/home/NotificationBox'
import { getLocalCurrencyCode } from 'src/localCurrency/selectors'
import { useDispatch, useSelector } from 'src/redux/hooks'
import { store } from 'src/redux/store'
import { getFeatureGate, getMultichainFeatures } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import colors from 'src/styles/colors'
import { typeScale } from 'src/styles/fonts'
import { vibrateSuccess } from 'src/styles/hapticFeedback'
import { Spacing } from 'src/styles/styles'
import { tokensByIdSelector } from 'src/tokens/selectors'
import { getSupportedNetworkIdsForSwap } from 'src/tokens/utils'
import { useTransactionFeedV2Query } from 'src/transactions/api'
import ClaimRewardFeedItem from 'src/transactions/feed/ClaimRewardFeedItem'
import DepositOrWithdrawFeedItem from 'src/transactions/feed/DepositOrWithdrawFeedItem'
import EarnFeedItem from 'src/transactions/feed/EarnFeedItem'
import NftFeedItem from 'src/transactions/feed/NftFeedItem'
import SwapFeedItem from 'src/transactions/feed/SwapFeedItem'
import TokenApprovalFeedItem from 'src/transactions/feed/TokenApprovalFeedItem'
import TransferFeedItem from 'src/transactions/feed/TransferFeedItem'
import NoActivity from 'src/transactions/NoActivity'
import {
feedFirstPageSelector,
formattedStandByTransactionsSelector,
} from 'src/transactions/selectors'
import { updateFeedFirstPage } from 'src/transactions/slice'
import {
FeeType,
TokenTransactionTypeV2,
TransactionStatus,
type NetworkId,
type TokenExchange,
type TokenTransaction,
} from 'src/transactions/types'
import { groupFeedItemsInSections } from 'src/transactions/utils'
import Logger from 'src/utils/Logger'
import { walletAddressSelector } from 'src/web3/selectors'
type PaginatedData = {
[FIRST_PAGE_CURSOR]: TokenTransaction[]
[endCursor: string]: TokenTransaction[]
}
const FIRST_PAGE_CURSOR = 'FIRST_PAGE'
const POLL_INTERVAL_MS = 10_000 // 10 sec
const TAG = 'transactions/feed/TransactionFeedV2'
function getAllowedNetworksForTransfers() {
return getMultichainFeatures().showTransfers
}
function trackCompletionOfCrossChainSwaps(transactions: TokenExchange[]) {
const tokensById = tokensByIdSelector(store.getState(), getSupportedNetworkIdsForSwap())
for (const tx of transactions) {
const toTokenPrice = tokensById[tx.inAmount.tokenId]?.priceUsd
const fromTokenPrice = tokensById[tx.outAmount.tokenId]?.priceUsd
const networkFee = tx.fees.find((fee) => fee.type === FeeType.SecurityFee)
const networkFeeTokenPrice = networkFee && tokensById[networkFee?.amount.tokenId]?.priceUsd
const appFee = tx.fees.find((fee) => fee.type === FeeType.AppFee)
const appFeeTokenPrice = appFee && tokensById[appFee?.amount.tokenId]?.priceUsd
const crossChainFee = tx.fees.find((fee) => fee.type === FeeType.CrossChainFee)
const crossChainFeeTokenPrice =
crossChainFee && tokensById[crossChainFee?.amount.tokenId]?.priceUsd
AppAnalytics.track(SwapEvents.swap_execute_success, {
swapType: 'cross-chain',
swapExecuteTxId: tx.transactionHash,
toTokenId: tx.inAmount.tokenId,
toTokenAmount: tx.inAmount.value.toString(),
toTokenAmountUsd: toTokenPrice
? BigNumber(tx.inAmount.value).times(toTokenPrice).toNumber()
: undefined,
fromTokenId: tx.outAmount.tokenId,
fromTokenAmount: tx.outAmount.value.toString(),
fromTokenAmountUsd: fromTokenPrice
? BigNumber(tx.outAmount.value).times(fromTokenPrice).toNumber()
: undefined,
networkFeeTokenId: networkFee?.amount.tokenId,
networkFeeAmount: networkFee?.amount.value.toString(),
networkFeeAmountUsd:
networkFeeTokenPrice && networkFee.amount.value
? BigNumber(networkFee.amount.value).times(networkFeeTokenPrice).toNumber()
: undefined,
appFeeTokenId: appFee?.amount.tokenId,
appFeeAmount: appFee?.amount.value.toString(),
appFeeAmountUsd:
appFeeTokenPrice && appFee.amount.value
? BigNumber(appFee.amount.value).times(appFeeTokenPrice).toNumber()
: undefined,
crossChainFeeTokenId: crossChainFee?.amount.tokenId,
crossChainFeeAmount: crossChainFee?.amount.value.toString(),
crossChainFeeAmountUsd:
crossChainFeeTokenPrice && crossChainFee.amount.value
? BigNumber(crossChainFee.amount.value).times(crossChainFeeTokenPrice).toNumber()
: undefined,
})
}
}
/**
* Join allowed networks into a string to help react memoization.
* N.B: This fetch-time filtering does not suffice to prevent non-Celo TXs from appearing
* on the home feed, since they get cached in Redux -- this is just a network optimization.
*/
function useAllowedNetworksForTransfers() {
const allowedNetworks = getAllowedNetworksForTransfers().join(',')
return useMemo(() => allowedNetworks.split(',') as NetworkId[], [allowedNetworks])
}
/**
* This function uses the same deduplication approach as "deduplicateTransactions" function from
* queryHelper but only for a single flattened array instead of two.
* Also, the queryHelper is going to be removed once we fully migrate to TransactionFeedV2,
* so this function would have needed to be moved from queryHelper anyway.
*/
function deduplicateTransactions(transactions: TokenTransaction[]): TokenTransaction[] {
const transactionMap: { [txHash: string]: TokenTransaction } = {}
for (const tx of transactions) {
transactionMap[tx.transactionHash] = tx
}
return Object.values(transactionMap)
}
/**
* If the timestamps are the same, most likely one of the transactions is an approval.
* On the feed we want to show the approval first.
*/
function sortTransactions(transactions: TokenTransaction[]): TokenTransaction[] {
return transactions.sort((a, b) => {
const diff = b.timestamp - a.timestamp
if (diff === 0) {
if (a.type === TokenTransactionTypeV2.Approval) return 1
if (b.type === TokenTransactionTypeV2.Approval) return -1
return 0
}
return diff
})
}
function categorizeTransactions(transactions: TokenTransaction[]) {
const pending: TokenTransaction[] = []
const confirmed: TokenTransaction[] = []
const confirmedHashes: string[] = []
for (const tx of transactions) {
if (tx.status === TransactionStatus.Pending) {
pending.push(tx)
} else {
confirmed.push(tx)
confirmedHashes.push(tx.transactionHash)
}
}
return { pending, confirmed, confirmedHashes }
}
/**
* Every page of paginated data includes a limited amount of transactions within a certain period.
* In standByTransactions we might have transactions from months ago. Whenever we load a new page
* we only want to add those stand by transactions that are within the time period of the new page.
* Otherwise, if we merge all the stand by transactins into the page it will cause more late transactions
* that were already merged to be removed from the top of the list and move them to the bottom.
* This will cause the screen to "shift", which we're trying to avoid.
*
* Note: when merging the first page – stand by transactions might include some new pending transaction.
* In order to include them in the merged list we need to also check if the stand by transaction is newer
* than the max timestamp from the page. But this must only happen for the first page as otherwise any
* following page would include stand by transactions from previous pages.
*/
function mergeStandByTransactionsInRange({
transactions,
standByTransactions,
currentCursor,
isLastPage,
}: {
transactions: TokenTransaction[]
standByTransactions: TokenTransaction[]
currentCursor: keyof PaginatedData
isLastPage: boolean
}): TokenTransaction[] {
/**
* If the data from the first page is empty - there's no successful transactions in the wallet.
* Maybe the user executed a single transaction, it failed and now it's in the standByTransactions.
* In this case we need to show whatever we've got in standByTransactions, until we have some
* paginated data to merge it with.
*/
const isFirstPage = currentCursor === FIRST_PAGE_CURSOR
if (isFirstPage && transactions.length === 0) {
return standByTransactions
}
// return empty array for any page other than the first
if (transactions.length === 0) {
return []
}
const max = transactions[0].timestamp
const min = transactions.at(-1)!.timestamp
const standByInRange = standByTransactions.filter((tx) => {
const inRange = tx.timestamp >= min && tx.timestamp <= max
const newTransaction = isFirstPage && tx.timestamp > max
const veryOldTransaction = isLastPage && tx.timestamp < min
return inRange || newTransaction || veryOldTransaction
})
const deduplicatedTransactions = deduplicateTransactions([...transactions, ...standByInRange])
const sortedTransactions = sortTransactions(deduplicatedTransactions)
return sortedTransactions
}
/**
* In order to properly detect if any of the existing pending transactions turned into completed
* we need to listen to the updates of stand by transactions. Whenever we detect that a completed
* transaction was in pending status on previous render - we consider it a newly completed transaction.
*/
function useNewlyCompletedTransactions(standByTransactions: TokenTransaction[]) {
const [{ hasNewlyCompletedTransactions, newlyCompletedCrossChainSwaps }, setPreviousStandBy] =
useState({
pending: [] as TokenTransaction[],
confirmed: [] as TokenTransaction[],
newlyCompletedCrossChainSwaps: [] as TokenExchange[],
hasNewlyCompletedTransactions: false,
})
useEffect(
function updatePrevStandBy() {
setPreviousStandBy((prev) => {
const { pending, confirmed, confirmedHashes } = categorizeTransactions(standByTransactions)
const newlyCompleted = prev.pending.filter((tx) => {
return confirmedHashes.includes(tx.transactionHash)
})
const newlyCompletedCrossChainSwaps = newlyCompleted.filter(
(tx): tx is TokenExchange => tx.type === TokenTransactionTypeV2.CrossChainSwapTransaction
)
return {
pending,
confirmed,
newlyCompletedCrossChainSwaps,
hasNewlyCompletedTransactions: !!newlyCompleted.length,
}
})
},
[standByTransactions]
)
return {
hasNewlyCompletedTransactions,
newlyCompletedCrossChainSwaps,
}
}
function renderItem({ item: tx }: { item: TokenTransaction }) {
switch (tx.type) {
case TokenTransactionTypeV2.Exchange:
case TokenTransactionTypeV2.SwapTransaction:
case TokenTransactionTypeV2.CrossChainSwapTransaction:
return
case TokenTransactionTypeV2.Sent:
case TokenTransactionTypeV2.Received:
return
case TokenTransactionTypeV2.NftSent:
case TokenTransactionTypeV2.NftReceived:
return
case TokenTransactionTypeV2.Approval:
return
case TokenTransactionTypeV2.Deposit:
case TokenTransactionTypeV2.Withdraw:
case TokenTransactionTypeV2.CrossChainDeposit:
return
case TokenTransactionTypeV2.ClaimReward:
return
case TokenTransactionTypeV2.EarnDeposit:
case TokenTransactionTypeV2.EarnSwapDeposit:
case TokenTransactionTypeV2.EarnWithdraw:
case TokenTransactionTypeV2.EarnClaimReward:
return
}
}
export default function TransactionFeedV2() {
const { t } = useTranslation()
const dispatch = useDispatch()
const showUKCompliantVariant = getFeatureGate(StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT)
const allowedNetworkForTransfers = useAllowedNetworksForTransfers()
const address = useSelector(walletAddressSelector)
const localCurrencyCode = useSelector(getLocalCurrencyCode)
const standByTransactions = useSelector(formattedStandByTransactionsSelector)
const feedFirstPage = useSelector(feedFirstPageSelector)
const { hasNewlyCompletedTransactions, newlyCompletedCrossChainSwaps } =
useNewlyCompletedTransactions(standByTransactions)
const [endCursor, setEndCursor] = useState(undefined)
const [paginatedData, setPaginatedData] = useState({
[FIRST_PAGE_CURSOR]: feedFirstPage,
})
const [status, setStatus] = useState<'loading' | 'error' | 'idle'>('loading')
const [allTransactionsShown, setAllTransactionsShown] = useState(false)
const { data, isFetching, error, refetch } = useTransactionFeedV2Query(
{ address: address!, endCursor, localCurrencyCode },
{ skip: !address, refetchOnMountOrArgChange: true }
)
/**
* This is the same hook as above and it only polls the first page of the feed. Thanks to how
* RTK-Query stores the fetched data, we know that using "useTransactionFeedV2Query" with the
* same arguments in multiple places will always point to the same data. This means that we can
* trigger fetch request here and once data arrives - the same hook above will also get the same data.
*/
useTransactionFeedV2Query(
{ address: address!, localCurrencyCode, endCursor: undefined },
{ skip: !address, pollingInterval: POLL_INTERVAL_MS }
)
useEffect(
// The status state variable is set to loading when a fetch is triggered on
// component mount and on pull to refresh, which allows us to hide the
// refresh spinner on background poll / fetch more pages. This effect will
// dismiss the loader by resetting this variable.
function dismissLoading() {
if (!isFetching) {
setStatus('idle')
}
},
[isFetching]
)
/**
* There are only 2 scenarios when we actually update the paginated data:
*
* 1. Always update the first page. First page will be polled every "POLL_INTERVAL"
* milliseconds. Whenever new data arrives - replace the existing first page data
* with the new data as it might contain some updated information about the transactions
* that are already present or new transactions. The first page should not contain an
* empty array, unless wallet doesn't have any transactions at all.
*
* 2. Data for every page after the first page is only set once. Considering the big enough
* page size (currently 100 transactions per page) all the pending transactions are supposed
* to arrive in the first page so everything after the first page can be considered confirmed
* (completed/failed). For this reason, there's no point in updating the data as its very unlikely to update.
*/
useEffect(
function updatePaginatedData() {
if (isFetching || !data) return
const isLastPage = !data.pageInfo.hasNextPage
const currentCursor = data.pageInfo.hasPreviousPage
? data.pageInfo.startCursor
: FIRST_PAGE_CURSOR
setPaginatedData((prev) => {
const isFirstPage = currentCursor === FIRST_PAGE_CURSOR
const pageDataIsAbsent =
currentCursor !== FIRST_PAGE_CURSOR && // not the first page
currentCursor !== undefined && // it is a page after the first
prev[currentCursor] === undefined // data for this page wasn't stored yet
if (isFirstPage || pageDataIsAbsent) {
const mergedTransactions = mergeStandByTransactionsInRange({
transactions: data.transactions,
standByTransactions,
currentCursor,
isLastPage,
})
return { ...prev, [currentCursor!]: mergedTransactions }
}
return prev
})
},
[isFetching, data, standByTransactions]
)
useEffect(
function hasLoadedAllTransactions() {
if (data && !data.pageInfo.hasNextPage && !data.pageInfo.endCursor) {
setAllTransactionsShown(true)
}
},
[data]
)
useEffect(
function handleError() {
if (error === undefined) return
Logger.warn(TAG, 'Error while fetching transactions', error)
// Suppress errors for background polling results, but show errors when
// data is explicitly requested by the user. Currently, this applies to
// scroll actions for loading additional pages and the initial wallet load
// if no cached transactions exist.
if (('hasAfterCursor' in error && error.hasAfterCursor) || feedFirstPage.length === 0) {
setStatus('error')
}
},
[error, feedFirstPage]
)
useEffect(
function vibrateForNewlyCompletedTransactions() {
const isFirstPage = data?.pageInfo.hasPreviousPage
? data.pageInfo.startCursor
: FIRST_PAGE_CURSOR
if (isFirstPage && hasNewlyCompletedTransactions) {
vibrateSuccess()
}
},
[hasNewlyCompletedTransactions, data?.pageInfo]
)
useEffect(
function trackCrossChainSwaps() {
if (newlyCompletedCrossChainSwaps.length) {
trackCompletionOfCrossChainSwaps(newlyCompletedCrossChainSwaps)
}
},
[newlyCompletedCrossChainSwaps]
)
useEffect(
function updatePersistedFeedFirstPage() {
const isFirstPage = !data?.pageInfo.hasPreviousPage
if (isFirstPage) {
const firstPageData = paginatedData[FIRST_PAGE_CURSOR]
if (!isEqual(firstPageData, feedFirstPage)) {
// Prevent the action from triggering on every polling interval. Only
// dispatch the action when there is a data change, such as new
// transactions. This action initiates side effects, like refreshing
// the token balance, so we avoid dispatching it on every poll to
// reduce unnecessary work.
dispatch(updateFeedFirstPage({ transactions: firstPageData }))
}
}
},
[paginatedData, data?.pageInfo]
)
const sections = useMemo(() => {
const flattenedPages = Object.values(paginatedData).flat()
const deduplicatedTransactions = deduplicateTransactions(flattenedPages)
const sortedTransactions = sortTransactions(deduplicatedTransactions)
const allowedTransactions = sortedTransactions.filter((tx) =>
allowedNetworkForTransfers.includes(tx.networkId)
)
if (allowedTransactions.length === 0) return []
const { pending, confirmed } = categorizeTransactions(allowedTransactions)
return groupFeedItemsInSections(pending, confirmed)
}, [paginatedData, allowedNetworkForTransfers])
function fetchMoreTransactions() {
if (data?.pageInfo.hasNextPage && data?.pageInfo.endCursor) {
setEndCursor(data.pageInfo.endCursor)
}
}
function handleRetryFetch() {
// refetch the transaction feed with the last known cursor, since this
// toast should only be displayed on error fetching next page or initial
// page if no transactions have been fetched before.
setStatus('loading')
return refetch()
}
return (
<>
}
sections={sections}
keyExtractor={(item) => `${item.transactionHash}-${item.timestamp.toString()}`}
keyboardShouldPersistTaps="always"
testID="TransactionList"
scrollEventThrottle={16}
refreshControl={
}
onEndReached={fetchMoreTransactions}
initialNumToRender={20}
ListHeaderComponent={
<>
>
}
ListEmptyComponent={!showUKCompliantVariant ? : }
ListFooterComponent={
<>
{/* prevent loading indicator due to polling from showing at the bottom of the screen */}
{isFetching && !allTransactionsShown && (
)}
{allTransactionsShown && sections.length > 0 && (
{t('transactionFeed.allTransactionsShown')}
)}
>
}
/>
{
setStatus('idle')
}}
/>
>
)
}
const styles = StyleSheet.create({
loadingIcon: {
marginVertical: Spacing.Thick24,
height: 108,
width: 108,
},
centerContainer: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
},
allTransactionsText: {
...typeScale.bodySmall,
color: colors.contentSecondary,
textAlign: 'center',
marginHorizontal: Spacing.Regular16,
marginVertical: Spacing.Thick24,
},
})