(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:
parent
16c347e97e
commit
ae8175aae2
|
@ -215,7 +215,7 @@
|
|||
"react-final-form-listeners": "^1.0.2",
|
||||
"react-ga": "3.3.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-redux": "7.2.2",
|
||||
"react-router-dom": "5.2.0",
|
||||
|
|
|
@ -1,39 +1,53 @@
|
|||
import { Loader } from '@gnosis.pm/safe-react-components'
|
||||
import React, { ReactElement } from 'react'
|
||||
import { default as ReactInfiniteScroll, Props as ReactInfiniteScrollProps } from 'react-infinite-scroll-component'
|
||||
import styled from 'styled-components'
|
||||
import React, { createContext, forwardRef, MutableRefObject, ReactElement, ReactNode, useEffect, useState } from 'react'
|
||||
import { InViewHookResponse, useInView } from 'react-intersection-observer'
|
||||
|
||||
import { Overwrite } from 'src/types/helpers'
|
||||
export const INFINITE_SCROLL_CONTAINER = 'infinite-scroll-container'
|
||||
|
||||
export const Centered = styled.div<{ padding?: number }>`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
padding: ${({ padding }) => `${padding}px`};
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
`
|
||||
export const InfiniteScrollContext = createContext<{
|
||||
ref: MutableRefObject<HTMLDivElement | null> | ((instance: HTMLDivElement | null) => void) | null
|
||||
lastItemId?: string
|
||||
setLastItemId: (itemId?: string) => void
|
||||
}>({ setLastItemId: () => {}, ref: null })
|
||||
|
||||
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 (
|
||||
<ReactInfiniteScroll
|
||||
style={{ overflow: 'hidden' }}
|
||||
dataLength={dataLength}
|
||||
next={next}
|
||||
hasMore={hasMore}
|
||||
loader={
|
||||
<Centered>
|
||||
<Loader size="md" />
|
||||
</Centered>
|
||||
}
|
||||
scrollThreshold="120px"
|
||||
scrollableTarget={SCROLLABLE_TARGET_ID}
|
||||
>
|
||||
{props.children}
|
||||
</ReactInfiniteScroll>
|
||||
)
|
||||
return (
|
||||
<InfiniteScrollContext.Provider value={{ ref, lastItemId, setLastItemId }}>
|
||||
{children}
|
||||
</InfiniteScrollContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
InfiniteScrollProvider.displayName = 'InfiniteScrollProvider'
|
||||
|
||||
type InfiniteScrollProps = {
|
||||
children: ReactNode
|
||||
hasMore: boolean
|
||||
next: () => Promise<void>
|
||||
config?: InViewHookResponse
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import { RateLimit } from 'async-sema'
|
||||
|
||||
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 { fetchErc20AndErc721AssetsList, fetchSafeCollectibles } from 'src/logic/tokens/api'
|
||||
import { TokenResult } from 'src/logic/tokens/api/fetchErc20AndErc721AssetsList'
|
||||
import { CollectibleResult } from 'src/logic/tokens/api/fetchSafeCollectibles'
|
||||
import { sameString } from 'src/utils/strings'
|
||||
|
||||
type FetchResult = {
|
||||
erc721Assets: TokenResult[]
|
||||
|
@ -12,67 +12,70 @@ type FetchResult = {
|
|||
}
|
||||
|
||||
class Gnosis {
|
||||
_rateLimit = async (): Promise<void> => {}
|
||||
|
||||
_fetch = async (safeAddress: string): Promise<FetchResult> => {
|
||||
const collectibles: FetchResult = {
|
||||
erc721Assets: [],
|
||||
erc721Tokens: [],
|
||||
}
|
||||
const [assets, tokens] = await Promise.allSettled([
|
||||
fetchErc20AndErc721AssetsList(),
|
||||
fetchSafeCollectibles(safeAddress),
|
||||
])
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { results: assets = [] },
|
||||
} = await fetchErc20AndErc721AssetsList()
|
||||
collectibles.erc721Assets = assets.filter((token) => token.type.toLowerCase() === 'erc721')
|
||||
} catch (e) {
|
||||
console.error('no erc721 assets could be fetched', e)
|
||||
switch (assets.status) {
|
||||
case 'fulfilled':
|
||||
const {
|
||||
data: { results = [] },
|
||||
} = assets.value
|
||||
collectibles.erc721Assets = results.filter((token) => sameString(token.type, 'erc721'))
|
||||
break
|
||||
case 'rejected':
|
||||
console.error('no erc721 assets could be fetched', assets.reason)
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: tokens = [] } = await fetchSafeCollectibles(safeAddress)
|
||||
collectibles.erc721Tokens = tokens
|
||||
} catch (e) {
|
||||
console.error('no erc721 tokens for the current safe', e)
|
||||
switch (tokens.status) {
|
||||
case 'fulfilled':
|
||||
const {
|
||||
data: { results = [] },
|
||||
} = tokens.value
|
||||
collectibles.erc721Tokens = results
|
||||
break
|
||||
case 'rejected':
|
||||
console.error('no erc721 tokens for the current safe', tokens.reason)
|
||||
break
|
||||
}
|
||||
|
||||
return collectibles
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSea class constructor
|
||||
* @param {object} options
|
||||
* @param {number} options.rps - requests per second
|
||||
*/
|
||||
constructor(options: { rps: number }) {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
this._rateLimit = RateLimit(options.rps, { timeUnit: 60 * 1000, uniformDistribution: true })
|
||||
static extractNFTAsset = (asset: TokenResult, nftTokens: NFTTokens): NFTAsset => {
|
||||
const mainAssetAddress = asset.address
|
||||
const numberOfTokens = nftTokens.filter(({ assetAddress }) => sameAddress(assetAddress, mainAssetAddress)).length
|
||||
|
||||
return {
|
||||
address: mainAssetAddress,
|
||||
description: asset.name,
|
||||
image: asset.logoUri || NFTIcon,
|
||||
name: asset.name,
|
||||
numberOfTokens,
|
||||
slug: `${mainAssetAddress}_${asset.name}`,
|
||||
symbol: asset.symbol,
|
||||
}
|
||||
}
|
||||
|
||||
static extractAssets(assets: TokenResult[], nftTokens: NFTTokens): NFTAssets {
|
||||
const extractNFTAsset = (asset: TokenResult): NFTAsset => {
|
||||
const numberOfTokens = nftTokens.filter(({ assetAddress }) => assetAddress === asset.address).length
|
||||
const extractedAssets = {}
|
||||
|
||||
return {
|
||||
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) => {
|
||||
assets.forEach((asset) => {
|
||||
const address = asset.address
|
||||
|
||||
if (acc[address] === undefined) {
|
||||
acc[address] = extractNFTAsset(asset)
|
||||
if (extractedAssets[address] === undefined) {
|
||||
extractedAssets[address] = Gnosis.extractNFTAsset(asset, nftTokens)
|
||||
}
|
||||
})
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
return extractedAssets
|
||||
}
|
||||
|
||||
static extractTokens(tokens: CollectibleResult[]): NFTTokens {
|
||||
|
@ -94,12 +97,11 @@ class Gnosis {
|
|||
*/
|
||||
async fetchCollectibles(safeAddress: string): Promise<Collectibles> {
|
||||
const { erc721Assets, erc721Tokens } = await this._fetch(safeAddress)
|
||||
const nftTokens = Gnosis.extractTokens(erc721Tokens)
|
||||
|
||||
return {
|
||||
nftTokens,
|
||||
nftAssets: Gnosis.extractAssets(erc721Assets, nftTokens),
|
||||
}
|
||||
const nftTokens = Gnosis.extractTokens(erc721Tokens)
|
||||
const nftAssets = Gnosis.extractAssets(erc721Assets, nftTokens)
|
||||
|
||||
return { nftTokens, nftAssets }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import { COLLECTIBLES_SOURCE } from 'src/utils/constants'
|
|||
|
||||
const SOURCES = {
|
||||
opensea: new OpenSea({ rps: 4 }),
|
||||
gnosis: new Gnosis({ rps: 4 }),
|
||||
gnosis: new Gnosis(),
|
||||
mockedopensea: new MockedOpenSea({ rps: 4 }),
|
||||
}
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { batch } from 'react-redux'
|
||||
import { Dispatch } from 'redux'
|
||||
|
||||
import { getConfiguredSource } from 'src/logic/collectibles/sources'
|
||||
|
@ -9,10 +8,8 @@ export const fetchCollectibles = (safeAddress: string) => async (dispatch: Dispa
|
|||
const source = getConfiguredSource()
|
||||
const collectibles = await source.fetchCollectibles(safeAddress)
|
||||
|
||||
batch(() => {
|
||||
dispatch(addNftAssets(collectibles.nftAssets))
|
||||
dispatch(addNftTokens(collectibles.nftTokens))
|
||||
})
|
||||
dispatch(addNftAssets(collectibles.nftAssets))
|
||||
dispatch(addNftTokens(collectibles.nftTokens))
|
||||
} catch (error) {
|
||||
console.log('Error fetching collectibles:', error)
|
||||
}
|
||||
|
|
|
@ -11,7 +11,7 @@ export const gatewayTransactions = (state: AppReduxState): AppReduxState['gatewa
|
|||
return state[GATEWAY_TRANSACTIONS_ID]
|
||||
}
|
||||
|
||||
export const historyTransactions = createSelector(
|
||||
export const historyTransactions = createHashBasedSelector(
|
||||
gatewayTransactions,
|
||||
safeParamAddressFromStateSelector,
|
||||
(gatewayTransactions, safeAddress): StoreStructure['history'] | undefined => {
|
||||
|
|
|
@ -8,6 +8,6 @@ import { AppReduxState } from 'src/store'
|
|||
export const createIsEqualSelector = createSelectorCreator(defaultMemoize, isEqual)
|
||||
|
||||
const hashFn = (gatewayTransactions: AppReduxState['gatewayTransactions'], safeAddress: string): string =>
|
||||
hash(gatewayTransactions[safeAddress])
|
||||
hash(gatewayTransactions[safeAddress] ?? {})
|
||||
|
||||
export const createHashBasedSelector = createSelectorCreator(memoize as any, hashFn)
|
||||
|
|
|
@ -11,10 +11,10 @@ export type TokenResult = {
|
|||
type: string
|
||||
}
|
||||
|
||||
export const fetchErc20AndErc721AssetsList = async (): Promise<AxiosResponse<{ results: TokenResult[] }>> => {
|
||||
export const fetchErc20AndErc721AssetsList = (): Promise<AxiosResponse<{ results: TokenResult[] }>> => {
|
||||
const url = getTokensServiceBaseUrl()
|
||||
|
||||
return axios.get<{ results: TokenResult[] }>(`${url}/`, {
|
||||
return axios.get<{ results: TokenResult[] }, AxiosResponse<{ results: TokenResult[] }>>(`${url}/`, {
|
||||
params: {
|
||||
limit: 3000,
|
||||
},
|
||||
|
|
|
@ -16,9 +16,11 @@ export type CollectibleResult = {
|
|||
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 url = `${getSafeServiceBaseUrl(address)}/collectibles/`
|
||||
|
||||
return axios.get(url)
|
||||
return axios.get<CollectibleResult[], AxiosResponse<{ results: CollectibleResult[] }>>(url)
|
||||
}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { backOff } from 'exponential-backoff'
|
||||
import { List, Map } from 'immutable'
|
||||
import { batch } from 'react-redux'
|
||||
import { Dispatch } from 'redux'
|
||||
|
||||
import {
|
||||
|
@ -84,11 +83,9 @@ const fetchSafeTokens = (safeAddress: string) => async (
|
|||
balances.keySeq().toSet().subtract(blacklistedTokens),
|
||||
)
|
||||
|
||||
batch(() => {
|
||||
dispatch(updateSafe({ address: safeAddress, activeTokens, balances, ethBalance }))
|
||||
dispatch(setCurrencyBalances(safeAddress, currencyList))
|
||||
dispatch(addTokens(tokens))
|
||||
})
|
||||
dispatch(updateSafe({ address: safeAddress, activeTokens, balances, ethBalance }))
|
||||
dispatch(setCurrencyBalances(safeAddress, currencyList))
|
||||
dispatch(addTokens(tokens))
|
||||
} catch (err) {
|
||||
console.error('Error fetching active token list', err)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -1,46 +1,35 @@
|
|||
import { Loader } from '@gnosis.pm/safe-react-components'
|
||||
import React, { ReactElement } from 'react'
|
||||
import React, { ReactElement, useContext } from 'react'
|
||||
|
||||
import { InfiniteScroll, SCROLLABLE_TARGET_ID } from 'src/components/InfiniteScroll'
|
||||
import { usePagedHistoryTransactions } from './hooks/usePagedHistoryTransactions'
|
||||
import {
|
||||
SubTitle,
|
||||
ScrollableTransactionsContainer,
|
||||
StyledTransactions,
|
||||
StyledTransactionsGroup,
|
||||
Centered,
|
||||
} from './styled'
|
||||
import { TransactionDetails } from 'src/logic/safe/store/models/types/gateway.d'
|
||||
import { TxsInfiniteScrollContext } from 'src/routes/safe/components/Transactions/GatewayTransactions/TxsInfiniteScroll'
|
||||
import { formatWithSchema } from 'src/utils/date'
|
||||
import { sameString } from 'src/utils/strings'
|
||||
import { StyledTransactions, StyledTransactionsGroup, SubTitle } from './styled'
|
||||
import { TxHistoryRow } from './TxHistoryRow'
|
||||
import { TxLocationContext } from './TxLocationProvider'
|
||||
import { formatWithSchema } from 'src/utils/date'
|
||||
|
||||
export const HistoryTxList = (): ReactElement => {
|
||||
const { count, hasMore, next, transactions } = usePagedHistoryTransactions()
|
||||
export const HistoryTxList = ({ transactions }: { transactions: TransactionDetails['transactions'] }): ReactElement => {
|
||||
const { lastItemId, setLastItemId } = useContext(TxsInfiniteScrollContext)
|
||||
|
||||
if (count === 0) {
|
||||
return (
|
||||
<Centered>
|
||||
<Loader size="md" />
|
||||
</Centered>
|
||||
)
|
||||
const [, lastTransactionsGroup] = transactions[transactions.length - 1]
|
||||
const lastTransaction = lastTransactionsGroup[lastTransactionsGroup.length - 1]
|
||||
|
||||
if (!sameString(lastItemId, lastTransaction.id)) {
|
||||
setLastItemId(lastTransaction.id)
|
||||
}
|
||||
|
||||
return (
|
||||
<TxLocationContext.Provider value={{ txLocation: 'history' }}>
|
||||
<ScrollableTransactionsContainer id={SCROLLABLE_TARGET_ID}>
|
||||
<InfiniteScroll dataLength={transactions.length} next={next} hasMore={hasMore}>
|
||||
{transactions?.map(([timestamp, txs]) => (
|
||||
<StyledTransactionsGroup key={timestamp}>
|
||||
<SubTitle size="lg">{formatWithSchema(Number(timestamp), 'MMM d, yyyy')}</SubTitle>
|
||||
<StyledTransactions>
|
||||
{txs.map((transaction) => (
|
||||
<TxHistoryRow key={transaction.id} transaction={transaction} />
|
||||
))}
|
||||
</StyledTransactions>
|
||||
</StyledTransactionsGroup>
|
||||
))}
|
||||
</InfiniteScroll>
|
||||
</ScrollableTransactionsContainer>
|
||||
{transactions?.map(([timestamp, txs]) => (
|
||||
<StyledTransactionsGroup key={timestamp}>
|
||||
<SubTitle size="lg">{formatWithSchema(Number(timestamp), 'MMM d, yyyy')}</SubTitle>
|
||||
<StyledTransactions>
|
||||
{txs.map((transaction) => (
|
||||
<TxHistoryRow key={transaction.id} transaction={transaction} />
|
||||
))}
|
||||
</StyledTransactions>
|
||||
</StyledTransactionsGroup>
|
||||
))}
|
||||
</TxLocationContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -2,15 +2,15 @@ import { Loader, Title } from '@gnosis.pm/safe-react-components'
|
|||
import React, { ReactElement } from 'react'
|
||||
import style from 'styled-components'
|
||||
|
||||
import { InfiniteScroll, SCROLLABLE_TARGET_ID } from 'src/components/InfiniteScroll'
|
||||
import Img from 'src/components/layout/Img'
|
||||
import { usePagedQueuedTransactions } from './hooks/usePagedQueuedTransactions'
|
||||
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 { 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`
|
||||
display: flex;
|
||||
|
@ -19,11 +19,11 @@ const NoTransactions = style.div`
|
|||
`
|
||||
|
||||
export const QueueTransactions = (): ReactElement => {
|
||||
const { count, loading, hasMore, next, transactions } = usePagedQueuedTransactions()
|
||||
const { count, isLoading, hasMore, next, transactions } = usePagedQueuedTransactions()
|
||||
|
||||
// `loading` is, actually `!transactions`
|
||||
// added the `transaction` verification to prevent `Object is possibly 'undefined'` error
|
||||
if (loading || !transactions) {
|
||||
if (isLoading || !transactions) {
|
||||
return (
|
||||
<Centered>
|
||||
<Loader size="md" />
|
||||
|
@ -42,19 +42,17 @@ export const QueueTransactions = (): ReactElement => {
|
|||
|
||||
return (
|
||||
<TxActionProvider>
|
||||
<ScrollableTransactionsContainer id={SCROLLABLE_TARGET_ID}>
|
||||
<InfiniteScroll dataLength={count} next={next} hasMore={hasMore}>
|
||||
{/* Next list */}
|
||||
<TxLocationContext.Provider value={{ txLocation: 'queued.next' }}>
|
||||
{transactions.next.count !== 0 && <QueueTxList transactions={transactions.next.transactions} />}
|
||||
</TxLocationContext.Provider>
|
||||
<TxsInfiniteScroll next={next} hasMore={hasMore} isLoading={isLoading}>
|
||||
{/* Next list */}
|
||||
<TxLocationContext.Provider value={{ txLocation: 'queued.next' }}>
|
||||
{transactions.next.count !== 0 && <QueueTxList transactions={transactions.next.transactions} />}
|
||||
</TxLocationContext.Provider>
|
||||
|
||||
{/* Queue list */}
|
||||
<TxLocationContext.Provider value={{ txLocation: 'queued.queued' }}>
|
||||
{transactions.queue.count !== 0 && <QueueTxList transactions={transactions.queue.transactions} />}
|
||||
</TxLocationContext.Provider>
|
||||
</InfiniteScroll>
|
||||
</ScrollableTransactionsContainer>
|
||||
{/* Queue list */}
|
||||
<TxLocationContext.Provider value={{ txLocation: 'queued.queued' }}>
|
||||
{transactions.queue.count !== 0 && <QueueTxList transactions={transactions.queue.transactions} />}
|
||||
</TxLocationContext.Provider>
|
||||
</TxsInfiniteScroll>
|
||||
<ActionModal />
|
||||
</TxActionProvider>
|
||||
)
|
||||
|
|
|
@ -2,6 +2,7 @@ import { Icon, Link, Text } from '@gnosis.pm/safe-react-components'
|
|||
import React, { Fragment, ReactElement, useContext } from 'react'
|
||||
|
||||
import { Transaction, TransactionDetails } from 'src/logic/safe/store/models/types/gateway.d'
|
||||
import { sameString } from 'src/utils/strings'
|
||||
import {
|
||||
DisclaimerContainer,
|
||||
GroupedTransactions,
|
||||
|
@ -14,6 +15,7 @@ import {
|
|||
import { TxHoverProvider } from './TxHoverProvider'
|
||||
import { TxLocationContext } from './TxLocationProvider'
|
||||
import { TxQueueRow } from './TxQueueRow'
|
||||
import { TxsInfiniteScrollContext } from './TxsInfiniteScroll'
|
||||
|
||||
const TreeView = ({ firstElement }: { firstElement: boolean }): ReactElement => {
|
||||
return <p className="tree-lines">{firstElement ? <span className="first-node" /> : null}</p>
|
||||
|
@ -52,8 +54,8 @@ type QueueTransactionProps = {
|
|||
transactions: Transaction[]
|
||||
}
|
||||
|
||||
const QueueTransaction = ({ nonce, transactions }: QueueTransactionProps): ReactElement => {
|
||||
return transactions.length > 1 ? (
|
||||
const QueueTransaction = ({ nonce, transactions }: QueueTransactionProps): ReactElement =>
|
||||
transactions.length > 1 ? (
|
||||
<GroupedTransactionsCard>
|
||||
<TxHoverProvider>
|
||||
<Disclaimer nonce={nonce} />
|
||||
|
@ -70,7 +72,6 @@ const QueueTransaction = ({ nonce, transactions }: QueueTransactionProps): React
|
|||
) : (
|
||||
<TxQueueRow transaction={transactions[0]} />
|
||||
)
|
||||
}
|
||||
|
||||
type QueueTxListProps = {
|
||||
transactions: TransactionDetails['transactions']
|
||||
|
@ -80,6 +81,14 @@ export const QueueTxList = ({ transactions }: QueueTxListProps): ReactElement =>
|
|||
const { txLocation } = useContext(TxLocationContext)
|
||||
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 (
|
||||
<StyledTransactionsGroup>
|
||||
<SubTitle size="lg">{title}</SubTitle>
|
||||
|
|
|
@ -11,7 +11,7 @@ import {
|
|||
isSettingsChangeTxInfo,
|
||||
Transaction,
|
||||
} 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 { KNOWN_MODULES } from 'src/utils/constants'
|
||||
import { sameString } from 'src/utils/strings'
|
||||
|
@ -21,6 +21,7 @@ import { TransactionStatusProps } from './hooks/useTransactionStatus'
|
|||
import { TxTypeProps } from './hooks/useTransactionType'
|
||||
import { StyledGroupedTransactions, StyledTransaction } from './styled'
|
||||
import { TokenTransferAmount } from './TokenTransferAmount'
|
||||
import { TxsInfiniteScrollContext } from './TxsInfiniteScroll'
|
||||
import { TxLocationContext } from './TxLocationProvider'
|
||||
import { CalculatedVotes } from './TxQueueCollapsed'
|
||||
|
||||
|
@ -89,7 +90,7 @@ const TooltipContent = styled.div`
|
|||
`
|
||||
|
||||
type TxCollapsedProps = {
|
||||
transaction?: Transaction
|
||||
transaction: Transaction
|
||||
isGrouped?: boolean
|
||||
nonce?: number
|
||||
type: TxTypeProps
|
||||
|
@ -112,6 +113,7 @@ export const TxCollapsed = ({
|
|||
status,
|
||||
}: TxCollapsedProps): ReactElement => {
|
||||
const { txLocation } = useContext(TxLocationContext)
|
||||
const { ref, lastItemId } = useContext(TxsInfiniteScrollContext)
|
||||
|
||||
const willBeReplaced = transaction?.txStatus === 'WILL_BE_REPLACED' ? ' will-be-replaced' : ''
|
||||
|
||||
|
@ -161,8 +163,9 @@ export const TxCollapsed = ({
|
|||
</div>
|
||||
)
|
||||
|
||||
// attaching ref to a div element as it was causing troubles to add a `ref` to a FunctionComponent
|
||||
const txCollapsedStatus = (
|
||||
<div className="tx-status">
|
||||
<div className="tx-status" ref={sameString(lastItemId, transaction.id) ? ref : null}>
|
||||
{transaction?.txStatus === 'PENDING' || transaction?.txStatus === 'PENDING_FAILED' ? (
|
||||
<CircularProgressPainter color={status.color}>
|
||||
<CircularProgress size={14} color="inherit" />
|
||||
|
|
|
@ -32,22 +32,26 @@ export const TxCollapsedActions = ({ transaction }: TxCollapsedActionsProps): Re
|
|||
return (
|
||||
<>
|
||||
<Tooltip title={transaction.txStatus === 'AWAITING_EXECUTION' ? 'Execute' : 'Confirm'} placement="top">
|
||||
<IconButton
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleConfirmButtonClick}
|
||||
disabled={disabledActions}
|
||||
onMouseEnter={handleOnMouseEnter}
|
||||
onMouseLeave={handleOnMouseLeave}
|
||||
>
|
||||
<Icon type={transaction.txStatus === 'AWAITING_EXECUTION' ? 'rocket' : 'check'} color="primary" size="sm" />
|
||||
</IconButton>
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
type="button"
|
||||
onClick={handleConfirmButtonClick}
|
||||
disabled={disabledActions}
|
||||
onMouseEnter={handleOnMouseEnter}
|
||||
onMouseLeave={handleOnMouseLeave}
|
||||
>
|
||||
<Icon type={transaction.txStatus === 'AWAITING_EXECUTION' ? 'rocket' : 'check'} color="primary" size="sm" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{canCancel && (
|
||||
<Tooltip title="Cancel" placement="top">
|
||||
<IconButton size="small" type="button" onClick={handleCancelButtonClick} disabled={isPending}>
|
||||
<Icon type="circleCross" color="error" size="sm" />
|
||||
</IconButton>
|
||||
<span>
|
||||
<IconButton size="small" type="button" onClick={handleCancelButtonClick} disabled={isPending}>
|
||||
<Icon type="circleCross" color="error" size="sm" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</>
|
||||
|
|
|
@ -12,5 +12,14 @@ export const TxHistoryCollapsed = ({ transaction }: { transaction: Transaction }
|
|||
const info = useAssetInfo(transaction.txInfo)
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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'
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
|
||||
import { loadPagedHistoryTransactions } from 'src/logic/safe/store/actions/transactions/fetchTransactions/loadGatewayTransactions'
|
||||
|
@ -12,17 +12,20 @@ type PagedTransactions = {
|
|||
transactions: TransactionDetails['transactions']
|
||||
hasMore: boolean
|
||||
next: () => Promise<void>
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export const usePagedHistoryTransactions = (): PagedTransactions => {
|
||||
const { count, transactions } = useHistoryTransactions()
|
||||
|
||||
const dispatch = useDispatch()
|
||||
const safeAddress = useSelector(safeParamAddressFromStateSelector)
|
||||
const dispatch = useRef(useDispatch())
|
||||
const safeAddress = useRef(useSelector(safeParamAddressFromStateSelector))
|
||||
const [hasMore, setHasMore] = useState(true)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const next = async () => {
|
||||
const results = await loadPagedHistoryTransactions(safeAddress)
|
||||
const next = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
const results = await loadPagedHistoryTransactions(safeAddress.current)
|
||||
|
||||
if (!results) {
|
||||
setHasMore(false)
|
||||
|
@ -36,11 +39,12 @@ export const usePagedHistoryTransactions = (): PagedTransactions => {
|
|||
}
|
||||
|
||||
if (values) {
|
||||
dispatch(addHistoryTransactions({ safeAddress, values, isTail: true }))
|
||||
dispatch.current(addHistoryTransactions({ safeAddress: safeAddress.current, values, isTail: true }))
|
||||
} else {
|
||||
setHasMore(false)
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
return { count, transactions, hasMore, next }
|
||||
return { count, transactions, hasMore, next, isLoading }
|
||||
}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { QueueTransactionsInfo, useQueueTransactions } from './useQueueTransacti
|
|||
|
||||
type PagedQueuedTransactions = {
|
||||
count: number
|
||||
loading: boolean
|
||||
isLoading: boolean
|
||||
transactions?: QueueTransactionsInfo
|
||||
hasMore: boolean
|
||||
next: () => Promise<void>
|
||||
|
@ -46,7 +46,7 @@ export const usePagedQueuedTransactions = (): PagedQueuedTransactions => {
|
|||
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 }
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@ import { Item } from '@gnosis.pm/safe-react-components/dist/navigation/Tab'
|
|||
import React, { ReactElement, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import { HistoryTxList } from './HistoryTxList'
|
||||
import { HistoryTransactions } from './HistoryTransactions'
|
||||
import { QueueTransactions } from './QueueTransactions'
|
||||
import { Breadcrumb, ContentWrapper, Wrapper } from './styled'
|
||||
|
||||
|
@ -27,7 +27,7 @@ const GatewayTransactions = (): ReactElement => {
|
|||
<Tab items={items} onChange={setTab} selectedTab={tab} />
|
||||
<ContentWrapper>
|
||||
{tab === 'queue' && <QueueTransactions />}
|
||||
{tab === 'history' && <HistoryTxList />}
|
||||
{tab === 'history' && <HistoryTransactions />}
|
||||
</ContentWrapper>
|
||||
</Wrapper>
|
||||
)
|
||||
|
|
|
@ -91,7 +91,6 @@ export const StyledTransactions = styled.div`
|
|||
overflow: hidden;
|
||||
width: 100%;
|
||||
|
||||
// '^' to prevent applying rules to the 'Actions' accordion components
|
||||
& > .MuiAccordion-root {
|
||||
&:first-child {
|
||||
border-top: none;
|
||||
|
@ -485,6 +484,11 @@ export const Centered = styled.div<{ padding?: number }>`
|
|||
align-items: center;
|
||||
`
|
||||
|
||||
export const HorizontallyCentered = styled(Centered)<{ isVisible: boolean }>`
|
||||
visibility: ${({ isVisible }) => (isVisible ? 'visible' : 'hidden')};
|
||||
height: auto;
|
||||
`
|
||||
|
||||
export const StyledAccordionSummary = styled(AccordionSummary)`
|
||||
height: 52px;
|
||||
.tx-nonce {
|
||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -17191,13 +17191,6 @@ react-hotkeys@2.0.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "4.0.1"
|
||||
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"
|
||||
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:
|
||||
version "16.13.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
|
|
Loading…
Reference in New Issue