(Fix) Transctions infinite scroll (#1931)

* install `react-intersection-observer` dependency

- also, remove `react-infinite-scroll-component`

* refactor `InfiniteScroll` to be used with `react-intersection-observer`

* build an infinite scroll wrapper for transactions based on `InfiniteScroll`

* recover `TxsInfiniteScrollContext` information to identify the last item in a list

- a new component was created for History transactions: `HistoryTransactions` as a wrapper

* refactor lists to use `TxsInfiniteScrollContext` and identify the last item in the list

* allow to pass config to the InfiniteScroll component

 - also changed default bottom margin so the txs loading starts a bit earlier

* fix memory consumption issue based on nft retrieval/update data

* delay `lastItemId` set to next tick, to prevent multiple updates during the same render phase

* Set triggerOnce to infinitescroll

Co-authored-by: nicosampler <nf.dominguez.87@gmail.com>
Co-authored-by: nicolas <nicosampler@users.noreply.github.com>
Co-authored-by: Daniel Sanchez <daniel.sanchez@gnosis.pm>
This commit is contained in:
Fernando 2021-02-26 18:46:31 -03:00 committed by GitHub
parent 16c347e97e
commit ae8175aae2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 276 additions and 194 deletions

View File

@ -215,7 +215,7 @@
"react-final-form-listeners": "^1.0.2", "react-final-form-listeners": "^1.0.2",
"react-ga": "3.3.0", "react-ga": "3.3.0",
"react-hot-loader": "4.13.0", "react-hot-loader": "4.13.0",
"react-infinite-scroll-component": "^5.1.0", "react-intersection-observer": "^8.31.0",
"react-qr-reader": "^2.2.1", "react-qr-reader": "^2.2.1",
"react-redux": "7.2.2", "react-redux": "7.2.2",
"react-router-dom": "5.2.0", "react-router-dom": "5.2.0",

View File

@ -1,39 +1,53 @@
import { Loader } from '@gnosis.pm/safe-react-components' import React, { createContext, forwardRef, MutableRefObject, ReactElement, ReactNode, useEffect, useState } from 'react'
import React, { ReactElement } from 'react' import { InViewHookResponse, useInView } from 'react-intersection-observer'
import { default as ReactInfiniteScroll, Props as ReactInfiniteScrollProps } from 'react-infinite-scroll-component'
import styled from 'styled-components'
import { Overwrite } from 'src/types/helpers' export const INFINITE_SCROLL_CONTAINER = 'infinite-scroll-container'
export const Centered = styled.div<{ padding?: number }>` export const InfiniteScrollContext = createContext<{
width: 100%; ref: MutableRefObject<HTMLDivElement | null> | ((instance: HTMLDivElement | null) => void) | null
height: 100%; lastItemId?: string
display: flex; setLastItemId: (itemId?: string) => void
padding: ${({ padding }) => `${padding}px`}; }>({ setLastItemId: () => {}, ref: null })
justify-content: center;
align-items: center;
`
export const SCROLLABLE_TARGET_ID = 'scrollableDiv' export const InfiniteScrollProvider = forwardRef<HTMLDivElement, { children: ReactNode }>(
({ children }, ref): ReactElement => {
const [lastItemId, _setLastItemId] = useState<string>()
type InfiniteScrollProps = Overwrite<ReactInfiniteScrollProps, { loader?: ReactInfiniteScrollProps['loader'] }> const setLastItemId = (itemId?: string) => {
setTimeout(() => _setLastItemId(itemId), 0)
}
export const InfiniteScroll = ({ dataLength, next, hasMore, ...props }: InfiniteScrollProps): ReactElement => { return (
return ( <InfiniteScrollContext.Provider value={{ ref, lastItemId, setLastItemId }}>
<ReactInfiniteScroll {children}
style={{ overflow: 'hidden' }} </InfiniteScrollContext.Provider>
dataLength={dataLength} )
next={next} },
hasMore={hasMore} )
loader={
<Centered> InfiniteScrollProvider.displayName = 'InfiniteScrollProvider'
<Loader size="md" />
</Centered> type InfiniteScrollProps = {
} children: ReactNode
scrollThreshold="120px" hasMore: boolean
scrollableTarget={SCROLLABLE_TARGET_ID} next: () => Promise<void>
> config?: InViewHookResponse
{props.children} }
</ReactInfiniteScroll>
) export const InfiniteScroll = ({ children, hasMore, next, config }: InfiniteScrollProps): ReactElement => {
const { ref, inView } = useInView({
threshold: 0,
root: document.querySelector(`#${INFINITE_SCROLL_CONTAINER}`),
rootMargin: '0px 0px 200px 0px',
triggerOnce: true,
...config,
})
useEffect(() => {
if (inView && hasMore) {
next()
}
}, [inView, hasMore, next])
return <InfiniteScrollProvider ref={ref}>{children}</InfiniteScrollProvider>
} }

View File

@ -1,10 +1,10 @@
import { RateLimit } from 'async-sema'
import { Collectibles, NFTAsset, NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/collectibles.d' import { Collectibles, NFTAsset, NFTAssets, NFTTokens } from 'src/logic/collectibles/sources/collectibles.d'
import { sameAddress } from 'src/logic/wallets/ethAddresses'
import NFTIcon from 'src/routes/safe/components/Balances/assets/nft_icon.png' import NFTIcon from 'src/routes/safe/components/Balances/assets/nft_icon.png'
import { fetchErc20AndErc721AssetsList, fetchSafeCollectibles } from 'src/logic/tokens/api' import { fetchErc20AndErc721AssetsList, fetchSafeCollectibles } from 'src/logic/tokens/api'
import { TokenResult } from 'src/logic/tokens/api/fetchErc20AndErc721AssetsList' import { TokenResult } from 'src/logic/tokens/api/fetchErc20AndErc721AssetsList'
import { CollectibleResult } from 'src/logic/tokens/api/fetchSafeCollectibles' import { CollectibleResult } from 'src/logic/tokens/api/fetchSafeCollectibles'
import { sameString } from 'src/utils/strings'
type FetchResult = { type FetchResult = {
erc721Assets: TokenResult[] erc721Assets: TokenResult[]
@ -12,67 +12,70 @@ type FetchResult = {
} }
class Gnosis { class Gnosis {
_rateLimit = async (): Promise<void> => {}
_fetch = async (safeAddress: string): Promise<FetchResult> => { _fetch = async (safeAddress: string): Promise<FetchResult> => {
const collectibles: FetchResult = { const collectibles: FetchResult = {
erc721Assets: [], erc721Assets: [],
erc721Tokens: [], erc721Tokens: [],
} }
const [assets, tokens] = await Promise.allSettled([
fetchErc20AndErc721AssetsList(),
fetchSafeCollectibles(safeAddress),
])
try { switch (assets.status) {
const { case 'fulfilled':
data: { results: assets = [] }, const {
} = await fetchErc20AndErc721AssetsList() data: { results = [] },
collectibles.erc721Assets = assets.filter((token) => token.type.toLowerCase() === 'erc721') } = assets.value
} catch (e) { collectibles.erc721Assets = results.filter((token) => sameString(token.type, 'erc721'))
console.error('no erc721 assets could be fetched', e) break
case 'rejected':
console.error('no erc721 assets could be fetched', assets.reason)
break
} }
try { switch (tokens.status) {
const { data: tokens = [] } = await fetchSafeCollectibles(safeAddress) case 'fulfilled':
collectibles.erc721Tokens = tokens const {
} catch (e) { data: { results = [] },
console.error('no erc721 tokens for the current safe', e) } = tokens.value
collectibles.erc721Tokens = results
break
case 'rejected':
console.error('no erc721 tokens for the current safe', tokens.reason)
break
} }
return collectibles return collectibles
} }
/** static extractNFTAsset = (asset: TokenResult, nftTokens: NFTTokens): NFTAsset => {
* OpenSea class constructor const mainAssetAddress = asset.address
* @param {object} options const numberOfTokens = nftTokens.filter(({ assetAddress }) => sameAddress(assetAddress, mainAssetAddress)).length
* @param {number} options.rps - requests per second
*/ return {
constructor(options: { rps: number }) { address: mainAssetAddress,
// eslint-disable-next-line no-underscore-dangle description: asset.name,
this._rateLimit = RateLimit(options.rps, { timeUnit: 60 * 1000, uniformDistribution: true }) image: asset.logoUri || NFTIcon,
name: asset.name,
numberOfTokens,
slug: `${mainAssetAddress}_${asset.name}`,
symbol: asset.symbol,
}
} }
static extractAssets(assets: TokenResult[], nftTokens: NFTTokens): NFTAssets { static extractAssets(assets: TokenResult[], nftTokens: NFTTokens): NFTAssets {
const extractNFTAsset = (asset: TokenResult): NFTAsset => { const extractedAssets = {}
const numberOfTokens = nftTokens.filter(({ assetAddress }) => assetAddress === asset.address).length
return { assets.forEach((asset) => {
address: asset.address,
description: asset.name,
image: asset.logoUri || NFTIcon,
name: asset.name,
numberOfTokens,
slug: `${asset.address}_${asset.name}`,
symbol: asset.symbol,
}
}
return assets.reduce((acc, asset) => {
const address = asset.address const address = asset.address
if (acc[address] === undefined) { if (extractedAssets[address] === undefined) {
acc[address] = extractNFTAsset(asset) extractedAssets[address] = Gnosis.extractNFTAsset(asset, nftTokens)
} }
})
return acc return extractedAssets
}, {})
} }
static extractTokens(tokens: CollectibleResult[]): NFTTokens { static extractTokens(tokens: CollectibleResult[]): NFTTokens {
@ -94,12 +97,11 @@ class Gnosis {
*/ */
async fetchCollectibles(safeAddress: string): Promise<Collectibles> { async fetchCollectibles(safeAddress: string): Promise<Collectibles> {
const { erc721Assets, erc721Tokens } = await this._fetch(safeAddress) const { erc721Assets, erc721Tokens } = await this._fetch(safeAddress)
const nftTokens = Gnosis.extractTokens(erc721Tokens)
return { const nftTokens = Gnosis.extractTokens(erc721Tokens)
nftTokens, const nftAssets = Gnosis.extractAssets(erc721Assets, nftTokens)
nftAssets: Gnosis.extractAssets(erc721Assets, nftTokens),
} return { nftTokens, nftAssets }
} }
} }

View File

@ -5,7 +5,7 @@ import { COLLECTIBLES_SOURCE } from 'src/utils/constants'
const SOURCES = { const SOURCES = {
opensea: new OpenSea({ rps: 4 }), opensea: new OpenSea({ rps: 4 }),
gnosis: new Gnosis({ rps: 4 }), gnosis: new Gnosis(),
mockedopensea: new MockedOpenSea({ rps: 4 }), mockedopensea: new MockedOpenSea({ rps: 4 }),
} }

View File

@ -1,4 +1,3 @@
import { batch } from 'react-redux'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { getConfiguredSource } from 'src/logic/collectibles/sources' import { getConfiguredSource } from 'src/logic/collectibles/sources'
@ -9,10 +8,8 @@ export const fetchCollectibles = (safeAddress: string) => async (dispatch: Dispa
const source = getConfiguredSource() const source = getConfiguredSource()
const collectibles = await source.fetchCollectibles(safeAddress) const collectibles = await source.fetchCollectibles(safeAddress)
batch(() => { dispatch(addNftAssets(collectibles.nftAssets))
dispatch(addNftAssets(collectibles.nftAssets)) dispatch(addNftTokens(collectibles.nftTokens))
dispatch(addNftTokens(collectibles.nftTokens))
})
} catch (error) { } catch (error) {
console.log('Error fetching collectibles:', error) console.log('Error fetching collectibles:', error)
} }

View File

@ -11,7 +11,7 @@ export const gatewayTransactions = (state: AppReduxState): AppReduxState['gatewa
return state[GATEWAY_TRANSACTIONS_ID] return state[GATEWAY_TRANSACTIONS_ID]
} }
export const historyTransactions = createSelector( export const historyTransactions = createHashBasedSelector(
gatewayTransactions, gatewayTransactions,
safeParamAddressFromStateSelector, safeParamAddressFromStateSelector,
(gatewayTransactions, safeAddress): StoreStructure['history'] | undefined => { (gatewayTransactions, safeAddress): StoreStructure['history'] | undefined => {

View File

@ -8,6 +8,6 @@ import { AppReduxState } from 'src/store'
export const createIsEqualSelector = createSelectorCreator(defaultMemoize, isEqual) export const createIsEqualSelector = createSelectorCreator(defaultMemoize, isEqual)
const hashFn = (gatewayTransactions: AppReduxState['gatewayTransactions'], safeAddress: string): string => const hashFn = (gatewayTransactions: AppReduxState['gatewayTransactions'], safeAddress: string): string =>
hash(gatewayTransactions[safeAddress]) hash(gatewayTransactions[safeAddress] ?? {})
export const createHashBasedSelector = createSelectorCreator(memoize as any, hashFn) export const createHashBasedSelector = createSelectorCreator(memoize as any, hashFn)

View File

@ -11,10 +11,10 @@ export type TokenResult = {
type: string type: string
} }
export const fetchErc20AndErc721AssetsList = async (): Promise<AxiosResponse<{ results: TokenResult[] }>> => { export const fetchErc20AndErc721AssetsList = (): Promise<AxiosResponse<{ results: TokenResult[] }>> => {
const url = getTokensServiceBaseUrl() const url = getTokensServiceBaseUrl()
return axios.get<{ results: TokenResult[] }>(`${url}/`, { return axios.get<{ results: TokenResult[] }, AxiosResponse<{ results: TokenResult[] }>>(`${url}/`, {
params: { params: {
limit: 3000, limit: 3000,
}, },

View File

@ -16,9 +16,11 @@ export type CollectibleResult = {
uri: string | null uri: string | null
} }
export const fetchSafeCollectibles = async (safeAddress: string): Promise<AxiosResponse<CollectibleResult[]>> => { export const fetchSafeCollectibles = async (
safeAddress: string,
): Promise<AxiosResponse<{ results: CollectibleResult[] }>> => {
const address = checksumAddress(safeAddress) const address = checksumAddress(safeAddress)
const url = `${getSafeServiceBaseUrl(address)}/collectibles/` const url = `${getSafeServiceBaseUrl(address)}/collectibles/`
return axios.get(url) return axios.get<CollectibleResult[], AxiosResponse<{ results: CollectibleResult[] }>>(url)
} }

View File

@ -1,6 +1,5 @@
import { backOff } from 'exponential-backoff' import { backOff } from 'exponential-backoff'
import { List, Map } from 'immutable' import { List, Map } from 'immutable'
import { batch } from 'react-redux'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { import {
@ -84,11 +83,9 @@ const fetchSafeTokens = (safeAddress: string) => async (
balances.keySeq().toSet().subtract(blacklistedTokens), balances.keySeq().toSet().subtract(blacklistedTokens),
) )
batch(() => { dispatch(updateSafe({ address: safeAddress, activeTokens, balances, ethBalance }))
dispatch(updateSafe({ address: safeAddress, activeTokens, balances, ethBalance })) dispatch(setCurrencyBalances(safeAddress, currencyList))
dispatch(setCurrencyBalances(safeAddress, currencyList)) dispatch(addTokens(tokens))
dispatch(addTokens(tokens))
})
} catch (err) { } catch (err) {
console.error('Error fetching active token list', err) console.error('Error fetching active token list', err)
} }

View File

@ -0,0 +1,25 @@
import { Loader } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react'
import { usePagedHistoryTransactions } from './hooks/usePagedHistoryTransactions'
import { Centered } from './styled'
import { HistoryTxList } from './HistoryTxList'
import { TxsInfiniteScroll } from './TxsInfiniteScroll'
export const HistoryTransactions = (): ReactElement => {
const { count, hasMore, next, transactions, isLoading } = usePagedHistoryTransactions()
if (count === 0) {
return (
<Centered>
<Loader size="md" />
</Centered>
)
}
return (
<TxsInfiniteScroll next={next} hasMore={hasMore} isLoading={isLoading}>
<HistoryTxList transactions={transactions} />
</TxsInfiniteScroll>
)
}

View File

@ -1,46 +1,35 @@
import { Loader } from '@gnosis.pm/safe-react-components' import React, { ReactElement, useContext } from 'react'
import React, { ReactElement } from 'react'
import { InfiniteScroll, SCROLLABLE_TARGET_ID } from 'src/components/InfiniteScroll' import { TransactionDetails } from 'src/logic/safe/store/models/types/gateway.d'
import { usePagedHistoryTransactions } from './hooks/usePagedHistoryTransactions' import { TxsInfiniteScrollContext } from 'src/routes/safe/components/Transactions/GatewayTransactions/TxsInfiniteScroll'
import { import { formatWithSchema } from 'src/utils/date'
SubTitle, import { sameString } from 'src/utils/strings'
ScrollableTransactionsContainer, import { StyledTransactions, StyledTransactionsGroup, SubTitle } from './styled'
StyledTransactions,
StyledTransactionsGroup,
Centered,
} from './styled'
import { TxHistoryRow } from './TxHistoryRow' import { TxHistoryRow } from './TxHistoryRow'
import { TxLocationContext } from './TxLocationProvider' import { TxLocationContext } from './TxLocationProvider'
import { formatWithSchema } from 'src/utils/date'
export const HistoryTxList = (): ReactElement => { export const HistoryTxList = ({ transactions }: { transactions: TransactionDetails['transactions'] }): ReactElement => {
const { count, hasMore, next, transactions } = usePagedHistoryTransactions() const { lastItemId, setLastItemId } = useContext(TxsInfiniteScrollContext)
if (count === 0) { const [, lastTransactionsGroup] = transactions[transactions.length - 1]
return ( const lastTransaction = lastTransactionsGroup[lastTransactionsGroup.length - 1]
<Centered>
<Loader size="md" /> if (!sameString(lastItemId, lastTransaction.id)) {
</Centered> setLastItemId(lastTransaction.id)
)
} }
return ( return (
<TxLocationContext.Provider value={{ txLocation: 'history' }}> <TxLocationContext.Provider value={{ txLocation: 'history' }}>
<ScrollableTransactionsContainer id={SCROLLABLE_TARGET_ID}> {transactions?.map(([timestamp, txs]) => (
<InfiniteScroll dataLength={transactions.length} next={next} hasMore={hasMore}> <StyledTransactionsGroup key={timestamp}>
{transactions?.map(([timestamp, txs]) => ( <SubTitle size="lg">{formatWithSchema(Number(timestamp), 'MMM d, yyyy')}</SubTitle>
<StyledTransactionsGroup key={timestamp}> <StyledTransactions>
<SubTitle size="lg">{formatWithSchema(Number(timestamp), 'MMM d, yyyy')}</SubTitle> {txs.map((transaction) => (
<StyledTransactions> <TxHistoryRow key={transaction.id} transaction={transaction} />
{txs.map((transaction) => ( ))}
<TxHistoryRow key={transaction.id} transaction={transaction} /> </StyledTransactions>
))} </StyledTransactionsGroup>
</StyledTransactions> ))}
</StyledTransactionsGroup>
))}
</InfiniteScroll>
</ScrollableTransactionsContainer>
</TxLocationContext.Provider> </TxLocationContext.Provider>
) )
} }

View File

@ -2,15 +2,15 @@ import { Loader, Title } from '@gnosis.pm/safe-react-components'
import React, { ReactElement } from 'react' import React, { ReactElement } from 'react'
import style from 'styled-components' import style from 'styled-components'
import { InfiniteScroll, SCROLLABLE_TARGET_ID } from 'src/components/InfiniteScroll'
import Img from 'src/components/layout/Img' import Img from 'src/components/layout/Img'
import { usePagedQueuedTransactions } from './hooks/usePagedQueuedTransactions'
import { ActionModal } from './ActionModal' import { ActionModal } from './ActionModal'
import { TxActionProvider } from './TxActionProvider'
import { TxLocationContext } from './TxLocationProvider'
import { QueueTxList } from './QueueTxList'
import { ScrollableTransactionsContainer, Centered } from './styled'
import NoTransactionsImage from './assets/no-transactions.svg' import NoTransactionsImage from './assets/no-transactions.svg'
import { usePagedQueuedTransactions } from './hooks/usePagedQueuedTransactions'
import { QueueTxList } from './QueueTxList'
import { Centered } from './styled'
import { TxActionProvider } from './TxActionProvider'
import { TxsInfiniteScroll } from './TxsInfiniteScroll'
import { TxLocationContext } from './TxLocationProvider'
const NoTransactions = style.div` const NoTransactions = style.div`
display: flex; display: flex;
@ -19,11 +19,11 @@ const NoTransactions = style.div`
` `
export const QueueTransactions = (): ReactElement => { export const QueueTransactions = (): ReactElement => {
const { count, loading, hasMore, next, transactions } = usePagedQueuedTransactions() const { count, isLoading, hasMore, next, transactions } = usePagedQueuedTransactions()
// `loading` is, actually `!transactions` // `loading` is, actually `!transactions`
// added the `transaction` verification to prevent `Object is possibly 'undefined'` error // added the `transaction` verification to prevent `Object is possibly 'undefined'` error
if (loading || !transactions) { if (isLoading || !transactions) {
return ( return (
<Centered> <Centered>
<Loader size="md" /> <Loader size="md" />
@ -42,19 +42,17 @@ export const QueueTransactions = (): ReactElement => {
return ( return (
<TxActionProvider> <TxActionProvider>
<ScrollableTransactionsContainer id={SCROLLABLE_TARGET_ID}> <TxsInfiniteScroll next={next} hasMore={hasMore} isLoading={isLoading}>
<InfiniteScroll dataLength={count} next={next} hasMore={hasMore}> {/* Next list */}
{/* Next list */} <TxLocationContext.Provider value={{ txLocation: 'queued.next' }}>
<TxLocationContext.Provider value={{ txLocation: 'queued.next' }}> {transactions.next.count !== 0 && <QueueTxList transactions={transactions.next.transactions} />}
{transactions.next.count !== 0 && <QueueTxList transactions={transactions.next.transactions} />} </TxLocationContext.Provider>
</TxLocationContext.Provider>
{/* Queue list */} {/* Queue list */}
<TxLocationContext.Provider value={{ txLocation: 'queued.queued' }}> <TxLocationContext.Provider value={{ txLocation: 'queued.queued' }}>
{transactions.queue.count !== 0 && <QueueTxList transactions={transactions.queue.transactions} />} {transactions.queue.count !== 0 && <QueueTxList transactions={transactions.queue.transactions} />}
</TxLocationContext.Provider> </TxLocationContext.Provider>
</InfiniteScroll> </TxsInfiniteScroll>
</ScrollableTransactionsContainer>
<ActionModal /> <ActionModal />
</TxActionProvider> </TxActionProvider>
) )

View File

@ -2,6 +2,7 @@ import { Icon, Link, Text } from '@gnosis.pm/safe-react-components'
import React, { Fragment, ReactElement, useContext } from 'react' import React, { Fragment, ReactElement, useContext } from 'react'
import { Transaction, TransactionDetails } from 'src/logic/safe/store/models/types/gateway.d' import { Transaction, TransactionDetails } from 'src/logic/safe/store/models/types/gateway.d'
import { sameString } from 'src/utils/strings'
import { import {
DisclaimerContainer, DisclaimerContainer,
GroupedTransactions, GroupedTransactions,
@ -14,6 +15,7 @@ import {
import { TxHoverProvider } from './TxHoverProvider' import { TxHoverProvider } from './TxHoverProvider'
import { TxLocationContext } from './TxLocationProvider' import { TxLocationContext } from './TxLocationProvider'
import { TxQueueRow } from './TxQueueRow' import { TxQueueRow } from './TxQueueRow'
import { TxsInfiniteScrollContext } from './TxsInfiniteScroll'
const TreeView = ({ firstElement }: { firstElement: boolean }): ReactElement => { const TreeView = ({ firstElement }: { firstElement: boolean }): ReactElement => {
return <p className="tree-lines">{firstElement ? <span className="first-node" /> : null}</p> return <p className="tree-lines">{firstElement ? <span className="first-node" /> : null}</p>
@ -52,8 +54,8 @@ type QueueTransactionProps = {
transactions: Transaction[] transactions: Transaction[]
} }
const QueueTransaction = ({ nonce, transactions }: QueueTransactionProps): ReactElement => { const QueueTransaction = ({ nonce, transactions }: QueueTransactionProps): ReactElement =>
return transactions.length > 1 ? ( transactions.length > 1 ? (
<GroupedTransactionsCard> <GroupedTransactionsCard>
<TxHoverProvider> <TxHoverProvider>
<Disclaimer nonce={nonce} /> <Disclaimer nonce={nonce} />
@ -70,7 +72,6 @@ const QueueTransaction = ({ nonce, transactions }: QueueTransactionProps): React
) : ( ) : (
<TxQueueRow transaction={transactions[0]} /> <TxQueueRow transaction={transactions[0]} />
) )
}
type QueueTxListProps = { type QueueTxListProps = {
transactions: TransactionDetails['transactions'] transactions: TransactionDetails['transactions']
@ -80,6 +81,14 @@ export const QueueTxList = ({ transactions }: QueueTxListProps): ReactElement =>
const { txLocation } = useContext(TxLocationContext) const { txLocation } = useContext(TxLocationContext)
const title = txLocation === 'queued.next' ? 'NEXT TRANSACTION' : 'QUEUE' const title = txLocation === 'queued.next' ? 'NEXT TRANSACTION' : 'QUEUE'
const { lastItemId, setLastItemId } = useContext(TxsInfiniteScrollContext)
const [, lastTransactionsGroup] = transactions[transactions.length - 1]
const lastTransaction = lastTransactionsGroup[lastTransactionsGroup.length - 1]
if (txLocation === 'queued.queued' && !sameString(lastItemId, lastTransaction.id)) {
setLastItemId(lastTransaction.id)
}
return ( return (
<StyledTransactionsGroup> <StyledTransactionsGroup>
<SubTitle size="lg">{title}</SubTitle> <SubTitle size="lg">{title}</SubTitle>

View File

@ -11,7 +11,7 @@ import {
isSettingsChangeTxInfo, isSettingsChangeTxInfo,
Transaction, Transaction,
} from 'src/logic/safe/store/models/types/gateway.d' } from 'src/logic/safe/store/models/types/gateway.d'
import { TxCollapsedActions } from 'src/routes/safe/components/Transactions/GatewayTransactions/TxCollapsedActions' import { TxCollapsedActions } from './TxCollapsedActions'
import { formatDateTime, formatTime, formatTimeInWords } from 'src/utils/date' import { formatDateTime, formatTime, formatTimeInWords } from 'src/utils/date'
import { KNOWN_MODULES } from 'src/utils/constants' import { KNOWN_MODULES } from 'src/utils/constants'
import { sameString } from 'src/utils/strings' import { sameString } from 'src/utils/strings'
@ -21,6 +21,7 @@ import { TransactionStatusProps } from './hooks/useTransactionStatus'
import { TxTypeProps } from './hooks/useTransactionType' import { TxTypeProps } from './hooks/useTransactionType'
import { StyledGroupedTransactions, StyledTransaction } from './styled' import { StyledGroupedTransactions, StyledTransaction } from './styled'
import { TokenTransferAmount } from './TokenTransferAmount' import { TokenTransferAmount } from './TokenTransferAmount'
import { TxsInfiniteScrollContext } from './TxsInfiniteScroll'
import { TxLocationContext } from './TxLocationProvider' import { TxLocationContext } from './TxLocationProvider'
import { CalculatedVotes } from './TxQueueCollapsed' import { CalculatedVotes } from './TxQueueCollapsed'
@ -89,7 +90,7 @@ const TooltipContent = styled.div`
` `
type TxCollapsedProps = { type TxCollapsedProps = {
transaction?: Transaction transaction: Transaction
isGrouped?: boolean isGrouped?: boolean
nonce?: number nonce?: number
type: TxTypeProps type: TxTypeProps
@ -112,6 +113,7 @@ export const TxCollapsed = ({
status, status,
}: TxCollapsedProps): ReactElement => { }: TxCollapsedProps): ReactElement => {
const { txLocation } = useContext(TxLocationContext) const { txLocation } = useContext(TxLocationContext)
const { ref, lastItemId } = useContext(TxsInfiniteScrollContext)
const willBeReplaced = transaction?.txStatus === 'WILL_BE_REPLACED' ? ' will-be-replaced' : '' const willBeReplaced = transaction?.txStatus === 'WILL_BE_REPLACED' ? ' will-be-replaced' : ''
@ -161,8 +163,9 @@ export const TxCollapsed = ({
</div> </div>
) )
// attaching ref to a div element as it was causing troubles to add a `ref` to a FunctionComponent
const txCollapsedStatus = ( const txCollapsedStatus = (
<div className="tx-status"> <div className="tx-status" ref={sameString(lastItemId, transaction.id) ? ref : null}>
{transaction?.txStatus === 'PENDING' || transaction?.txStatus === 'PENDING_FAILED' ? ( {transaction?.txStatus === 'PENDING' || transaction?.txStatus === 'PENDING_FAILED' ? (
<CircularProgressPainter color={status.color}> <CircularProgressPainter color={status.color}>
<CircularProgress size={14} color="inherit" /> <CircularProgress size={14} color="inherit" />

View File

@ -32,22 +32,26 @@ export const TxCollapsedActions = ({ transaction }: TxCollapsedActionsProps): Re
return ( return (
<> <>
<Tooltip title={transaction.txStatus === 'AWAITING_EXECUTION' ? 'Execute' : 'Confirm'} placement="top"> <Tooltip title={transaction.txStatus === 'AWAITING_EXECUTION' ? 'Execute' : 'Confirm'} placement="top">
<IconButton <span>
size="small" <IconButton
type="button" size="small"
onClick={handleConfirmButtonClick} type="button"
disabled={disabledActions} onClick={handleConfirmButtonClick}
onMouseEnter={handleOnMouseEnter} disabled={disabledActions}
onMouseLeave={handleOnMouseLeave} onMouseEnter={handleOnMouseEnter}
> onMouseLeave={handleOnMouseLeave}
<Icon type={transaction.txStatus === 'AWAITING_EXECUTION' ? 'rocket' : 'check'} color="primary" size="sm" /> >
</IconButton> <Icon type={transaction.txStatus === 'AWAITING_EXECUTION' ? 'rocket' : 'check'} color="primary" size="sm" />
</IconButton>
</span>
</Tooltip> </Tooltip>
{canCancel && ( {canCancel && (
<Tooltip title="Cancel" placement="top"> <Tooltip title="Cancel" placement="top">
<IconButton size="small" type="button" onClick={handleCancelButtonClick} disabled={isPending}> <span>
<Icon type="circleCross" color="error" size="sm" /> <IconButton size="small" type="button" onClick={handleCancelButtonClick} disabled={isPending}>
</IconButton> <Icon type="circleCross" color="error" size="sm" />
</IconButton>
</span>
</Tooltip> </Tooltip>
)} )}
</> </>

View File

@ -12,5 +12,14 @@ export const TxHistoryCollapsed = ({ transaction }: { transaction: Transaction }
const info = useAssetInfo(transaction.txInfo) const info = useAssetInfo(transaction.txInfo)
const status = useTransactionStatus(transaction) const status = useTransactionStatus(transaction)
return <TxCollapsed nonce={nonce} type={type} info={info} time={transaction.timestamp} status={status} /> return (
<TxCollapsed
nonce={nonce}
type={type}
info={info}
time={transaction.timestamp}
status={status}
transaction={transaction}
/>
)
} }

View File

@ -0,0 +1,27 @@
import { Loader } from '@gnosis.pm/safe-react-components'
import React, { ReactElement, ReactNode } from 'react'
import { INFINITE_SCROLL_CONTAINER, InfiniteScroll } from 'src/components/InfiniteScroll'
import { HorizontallyCentered, ScrollableTransactionsContainer } from './styled'
type TxsInfiniteScrollProps = {
children: ReactNode
next: () => Promise<void>
hasMore: boolean
isLoading: boolean
}
export const TxsInfiniteScroll = ({ children, next, hasMore, isLoading }: TxsInfiniteScrollProps): ReactElement => {
return (
<InfiniteScroll next={next} hasMore={hasMore}>
<ScrollableTransactionsContainer id={INFINITE_SCROLL_CONTAINER}>
{children}
<HorizontallyCentered isVisible={isLoading}>
<Loader size="md" />
</HorizontallyCentered>
</ScrollableTransactionsContainer>
</InfiniteScroll>
)
}
export { InfiniteScrollContext as TxsInfiniteScrollContext } from 'src/components/InfiniteScroll'

View File

@ -1,4 +1,4 @@
import { useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux' import { useDispatch, useSelector } from 'react-redux'
import { loadPagedHistoryTransactions } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions' import { loadPagedHistoryTransactions } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions'
@ -12,17 +12,20 @@ type PagedTransactions = {
transactions: TransactionDetails['transactions'] transactions: TransactionDetails['transactions']
hasMore: boolean hasMore: boolean
next: () => Promise<void> next: () => Promise<void>
isLoading: boolean
} }
export const usePagedHistoryTransactions = (): PagedTransactions => { export const usePagedHistoryTransactions = (): PagedTransactions => {
const { count, transactions } = useHistoryTransactions() const { count, transactions } = useHistoryTransactions()
const dispatch = useDispatch() const dispatch = useRef(useDispatch())
const safeAddress = useSelector(safeParamAddressFromStateSelector) const safeAddress = useRef(useSelector(safeParamAddressFromStateSelector))
const [hasMore, setHasMore] = useState(true) const [hasMore, setHasMore] = useState(true)
const [isLoading, setIsLoading] = useState(false)
const next = async () => { const next = useCallback(async () => {
const results = await loadPagedHistoryTransactions(safeAddress) setIsLoading(true)
const results = await loadPagedHistoryTransactions(safeAddress.current)
if (!results) { if (!results) {
setHasMore(false) setHasMore(false)
@ -36,11 +39,12 @@ export const usePagedHistoryTransactions = (): PagedTransactions => {
} }
if (values) { if (values) {
dispatch(addHistoryTransactions({ safeAddress, values, isTail: true })) dispatch.current(addHistoryTransactions({ safeAddress: safeAddress.current, values, isTail: true }))
} else { } else {
setHasMore(false) setHasMore(false)
} }
} setIsLoading(false)
}, [])
return { count, transactions, hasMore, next } return { count, transactions, hasMore, next, isLoading }
} }

View File

@ -8,7 +8,7 @@ import { QueueTransactionsInfo, useQueueTransactions } from './useQueueTransacti
type PagedQueuedTransactions = { type PagedQueuedTransactions = {
count: number count: number
loading: boolean isLoading: boolean
transactions?: QueueTransactionsInfo transactions?: QueueTransactionsInfo
hasMore: boolean hasMore: boolean
next: () => Promise<void> next: () => Promise<void>
@ -46,7 +46,7 @@ export const usePagedQueuedTransactions = (): PagedQueuedTransactions => {
count = transactions.next.count + transactions.queue.count count = transactions.next.count + transactions.queue.count
} }
const loading = typeof transactions === 'undefined' || typeof count === 'undefined' const isLoading = typeof transactions === 'undefined' || typeof count === 'undefined'
return { count, loading, transactions, hasMore, next: nextPage } return { count, isLoading, transactions, hasMore, next: nextPage }
} }

View File

@ -3,7 +3,7 @@ import { Item } from '@gnosis.pm/safe-react-components/dist/navigation/Tab'
import React, { ReactElement, useState } from 'react' import React, { ReactElement, useState } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import { HistoryTxList } from './HistoryTxList' import { HistoryTransactions } from './HistoryTransactions'
import { QueueTransactions } from './QueueTransactions' import { QueueTransactions } from './QueueTransactions'
import { Breadcrumb, ContentWrapper, Wrapper } from './styled' import { Breadcrumb, ContentWrapper, Wrapper } from './styled'
@ -27,7 +27,7 @@ const GatewayTransactions = (): ReactElement => {
<Tab items={items} onChange={setTab} selectedTab={tab} /> <Tab items={items} onChange={setTab} selectedTab={tab} />
<ContentWrapper> <ContentWrapper>
{tab === 'queue' && <QueueTransactions />} {tab === 'queue' && <QueueTransactions />}
{tab === 'history' && <HistoryTxList />} {tab === 'history' && <HistoryTransactions />}
</ContentWrapper> </ContentWrapper>
</Wrapper> </Wrapper>
) )

View File

@ -91,7 +91,6 @@ export const StyledTransactions = styled.div`
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
// '^' to prevent applying rules to the 'Actions' accordion components
& > .MuiAccordion-root { & > .MuiAccordion-root {
&:first-child { &:first-child {
border-top: none; border-top: none;
@ -485,6 +484,11 @@ export const Centered = styled.div<{ padding?: number }>`
align-items: center; align-items: center;
` `
export const HorizontallyCentered = styled(Centered)<{ isVisible: boolean }>`
visibility: ${({ isVisible }) => (isVisible ? 'visible' : 'hidden')};
height: auto;
`
export const StyledAccordionSummary = styled(AccordionSummary)` export const StyledAccordionSummary = styled(AccordionSummary)`
height: 52px; height: 52px;
.tx-nonce { .tx-nonce {

View File

@ -17191,13 +17191,6 @@ react-hotkeys@2.0.0:
dependencies: dependencies:
prop-types "^15.6.1" prop-types "^15.6.1"
react-infinite-scroll-component@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/react-infinite-scroll-component/-/react-infinite-scroll-component-5.1.0.tgz#3c0043cd17c6857c25d3ca7aa4171e0c33ce8023"
integrity sha512-ZQ7lYlLByil1ZVdvt57UJeBIj/tSOwKyds+B0LIm/xGmxzJJZwW/hYGKKs74sHO3LoIDwcq2ci6YXqrkWSalLQ==
dependencies:
throttle-debounce "^2.1.0"
react-inspector@^4.0.0: react-inspector@^4.0.0:
version "4.0.1" version "4.0.1"
resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-4.0.1.tgz#0f888f78ff7daccbc7be5d452b20c96dc6d5fbb8" resolved "https://registry.yarnpkg.com/react-inspector/-/react-inspector-4.0.1.tgz#0f888f78ff7daccbc7be5d452b20c96dc6d5fbb8"
@ -17207,6 +17200,11 @@ react-inspector@^4.0.0:
is-dom "^1.0.9" is-dom "^1.0.9"
prop-types "^15.6.1" prop-types "^15.6.1"
react-intersection-observer@^8.31.0:
version "8.31.0"
resolved "https://registry.yarnpkg.com/react-intersection-observer/-/react-intersection-observer-8.31.0.tgz#0ed21aaf93c4c0475b22b0ccaba6169076d01605"
integrity sha512-XraIC/tkrD9JtrmVA7ypEN1QIpKc52mXBH1u/bz/aicRLo8QQEJQAMUTb8mz4B6dqpPwyzgjrr7Ljv/2ACDtqw==
react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.9.0: react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0, react-is@^16.8.1, react-is@^16.9.0:
version "16.13.1" version "16.13.1"
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"