Skip to content

Commit

Permalink
chore(TransactionFeedV2): Trigger haptic feedback when pending transa…
Browse files Browse the repository at this point in the history
…ctions turn to completed (#6154)

### Description
8th PR for RET-1207. Implements triggering of the haptic feedback when
transactions turn from pending to completed.

### Test plan
Added tests to validate the logic of triggering the haptic feedback

### Related issues

- Relates to RET-1207

### Backwards compatibility
Yes

### Network scalability

If a new NetworkId and/or Network are added in the future, the changes
in this PR will:

- [x] Continue to work without code changes, OR trigger a compilation
error (guaranteeing we find it when a new network is added)
  • Loading branch information
sviderock authored Oct 18, 2024
1 parent cdfd8cb commit 89e51c4
Show file tree
Hide file tree
Showing 2 changed files with 145 additions and 11 deletions.
108 changes: 97 additions & 11 deletions src/transactions/feed/TransactionFeedV2.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, render, waitFor, within } from '@testing-library/react-native'
import { act, fireEvent, render, waitFor, within } from '@testing-library/react-native'
import { FetchMock } from 'jest-fetch-mock/types'
import React from 'react'
import Toast from 'react-native-simple-toast'
Expand All @@ -10,24 +10,30 @@ import { getDynamicConfigParams, getFeatureGate, getMultichainFeatures } from 's
import TransactionFeedV2 from 'src/transactions/feed/TransactionFeedV2'
import {
NetworkId,
StandbyTransaction,
TokenTransaction,
TokenTransactionTypeV2,
TransactionStatus,
} from 'src/transactions/types'
import { mockCusdAddress, mockCusdTokenId } from 'test/values'
import { mockCeloTokenId, mockCusdAddress, mockCusdTokenId, mockQRCodeRecipient } from 'test/values'

import BigNumber from 'bignumber.js'
import { Action } from 'redux-saga'
import { ApiReducersKeys } from 'src/redux/apiReducersList'
import { vibrateSuccess } from 'src/styles/hapticFeedback'
import { addStandbyTransaction, transactionConfirmed } from 'src/transactions/actions'
import { transactionFeedV2Api, type TransactionFeedV2Response } from 'src/transactions/api'
import { setupApiStore } from 'src/transactions/apiTestHelpers'
import { RecursivePartial } from 'test/utils'

jest.mock('src/statsig')
jest.mock('react-native-simple-toast')
jest.mock('src/styles/hapticFeedback')

const STAND_BY_TRANSACTION_SUBTITLE_KEY = 'confirmingTransaction'
const mockFetch = fetch as FetchMock

function mockTransaction(data?: Partial<TokenTransaction>): TokenTransaction {
function mockTransaction(data?: Partial<TokenTransaction | StandbyTransaction>): TokenTransaction {
return {
__typename: 'TokenTransferV3',
networkId: NetworkId['celo-alfajores'],
Expand Down Expand Up @@ -56,23 +62,24 @@ function getNumTransactionItems(sectionList: ReactTestInstance) {

const typedResponse = (response: Partial<TransactionFeedV2Response>) => JSON.stringify(response)

function renderScreen(storeOverrides: RecursivePartial<Omit<RootState, ApiReducersKeys>> = {}) {
function getInitialStore(storeOverrides: RecursivePartial<Omit<RootState, ApiReducersKeys>> = {}) {
const state: typeof storeOverrides = {
web3: { account: '0x00' },
...storeOverrides,
}
const storeRef = setupApiStore(transactionFeedV2Api, state, reducersList)
return storeRef.store
}

function renderScreen(storeOverrides: RecursivePartial<Omit<RootState, ApiReducersKeys>> = {}) {
const store = getInitialStore(storeOverrides)
const tree = render(
<Provider store={storeRef.store}>
<Provider store={store}>
<TransactionFeedV2 />
</Provider>
)

return {
...tree,
store: storeRef.store,
}
return { ...tree, store }
}

beforeEach(() => {
Expand Down Expand Up @@ -308,8 +315,7 @@ describe('TransactionFeedV2', () => {

/**
* For now, there's no way to check for dispatched actions via getActions as we usually do
* as the current setupApiStore doesn't return it. But at least we can make sure that the
* transaction gets removed.
* as the current setupApiStore doesn't return it.
*/
await waitFor(() => expect(store.getState().transactions.standbyTransactions.length).toBe(0))
})
Expand Down Expand Up @@ -389,4 +395,84 @@ describe('TransactionFeedV2', () => {
await waitFor(() => expect(tree.queryByTestId('TransactionList/loading')).toBeFalsy())
await waitFor(() => expect(Toast.showWithGravity).not.toBeCalled())
})

it('should vibrate when there is a pending transaction that turned into completed', async () => {
const standByTransactionHash = '0x02' as string
mockFetch.mockResponseOnce(typedResponse({ transactions: [] })).mockResponseOnce(
typedResponse({
transactions: [mockTransaction({ transactionHash: standByTransactionHash })],
})
)

const { store, ...tree } = renderScreen({
transactions: {
standbyTransactions: [
mockTransaction({
context: { id: standByTransactionHash },
transactionHash: standByTransactionHash,
status: TransactionStatus.Pending,
}),
],
},
})

expect(tree.getByTestId('TransactionList').props.data[0].data.length).toBe(1)

// imitate changing of pending stand by transaction to confirmed
await act(() => {
const changePendingToConfirmed = transactionConfirmed(
standByTransactionHash,
{ status: TransactionStatus.Complete, transactionHash: standByTransactionHash, block: '' },
mockTransaction().timestamp
) as Action
store.dispatch(changePendingToConfirmed)
})

await waitFor(() => {
expect(tree.getByTestId('TransactionList').props.data[0].data.length).toBe(1)
})
expect(vibrateSuccess).toHaveBeenCalledTimes(1)
})

it('should not vibrate when there is a new pending transaction', async () => {
const pendingStandByTransactionHash1 = '0x01' as string
const pendingStandByTransactionHash2 = '0x02' as string
const { store, rerender, ...tree } = renderScreen({
transactions: {
standbyTransactions: [
mockTransaction({
context: { id: pendingStandByTransactionHash1 },
transactionHash: pendingStandByTransactionHash1,
status: TransactionStatus.Pending,
}),
],
},
})

expect(tree.getByTestId('TransactionList').props.data[0].data.length).toBe(1)

await act(() => {
const newPendingTransaction = addStandbyTransaction({
__typename: 'TokenTransferV3',
context: { id: pendingStandByTransactionHash2 },
type: TokenTransactionTypeV2.Sent,
networkId: NetworkId['celo-alfajores'],
amount: {
value: BigNumber(10).negated().toString(),
tokenAddress: mockCusdAddress,
tokenId: mockCusdTokenId,
},
address: mockQRCodeRecipient.address,
metadata: {},
feeCurrencyId: mockCeloTokenId,
transactionHash: pendingStandByTransactionHash2,
}) as Action
store.dispatch(newPendingTransaction)
})

await waitFor(() => {
expect(tree.getByTestId('TransactionList').props.data[0].data.length).toBe(2)
})
expect(vibrateSuccess).not.toHaveBeenCalled()
})
})
48 changes: 48 additions & 0 deletions src/transactions/feed/TransactionFeedV2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { useDispatch, useSelector } from 'src/redux/hooks'
import { getFeatureGate, getMultichainFeatures } from 'src/statsig'
import { StatsigFeatureGates } from 'src/statsig/types'
import colors from 'src/styles/colors'
import { vibrateSuccess } from 'src/styles/hapticFeedback'
import { Spacing } from 'src/styles/styles'
import NoActivity from 'src/transactions/NoActivity'
import { removeDuplicatedStandByTransactions } from 'src/transactions/actions'
Expand Down Expand Up @@ -187,6 +188,42 @@ function useStandByTransactions() {
}, [standByTransactions, allowedNetworkForTransfers])
}

/**
* 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 confirmed
* transaction was in pending status on previous render - we consider it a newly completed transaction.
*/
function useNewlyCompletedTransactions(
standByTransactions: ReturnType<typeof useStandByTransactions>
) {
const [previousStandBy, setPreviousStandBy] = useState({
pending: [] as string[],
confirmed: [] as string[],
hasNewlyCompletedTransactions: false,
})

useEffect(
function updatePrevStandBy() {
setPreviousStandBy((prev) => {
const pendingHashes = standByTransactions.pending.map((tx) => tx.transactionHash)
const confirmedHashes = standByTransactions.confirmed.map((tx) => tx.transactionHash)
const hasNewlyCompletedTransactions = prev.pending.some((hash) => {
return confirmedHashes.includes(hash)
})

return {
pending: pendingHashes,
confirmed: confirmedHashes,
hasNewlyCompletedTransactions,
}
})
},
[standByTransactions]
)

return previousStandBy.hasNewlyCompletedTransactions
}

function renderItem({ item: tx }: { item: TokenTransaction }) {
switch (tx.type) {
case TokenTransactionTypeV2.Exchange:
Expand Down Expand Up @@ -214,6 +251,7 @@ export default function TransactionFeedV2() {
const dispatch = useDispatch()
const address = useSelector(walletAddressSelector)
const standByTransactions = useStandByTransactions()
const newlyCompletedTransactions = useNewlyCompletedTransactions(standByTransactions)
const [endCursor, setEndCursor] = useState(FIRST_PAGE_TIMESTAMP)
const [paginatedData, setPaginatedData] = useState<PaginatedData>({ [FIRST_PAGE_TIMESTAMP]: [] })

Expand Down Expand Up @@ -326,6 +364,16 @@ export default function TransactionFeedV2() {
[data?.transactions]
)

useEffect(
function vibrateForNewCompletedTransactions() {
const isFirstPage = originalArgs?.endCursor === FIRST_PAGE_TIMESTAMP
if (isFirstPage && newlyCompletedTransactions) {
vibrateSuccess()
}
},
[newlyCompletedTransactions, originalArgs]
)

const confirmedTransactions = useMemo(() => {
const flattenedPages = Object.values(paginatedData).flat()
const deduplicatedTransactions = deduplicateTransactions(flattenedPages)
Expand Down

0 comments on commit 89e51c4

Please sign in to comment.