Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: blazingly fast initial accounts load #7822

Open
wants to merge 24 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
87a2daf
feat: rm request queue
gomesalexandre Sep 26, 2024
44630f9
feat: rm upsertOnFetch
gomesalexandre Sep 26, 2024
ce1172b
feat: don't mark upsertPortfolio as low prio action
gomesalexandre Sep 26, 2024
06ca68f
feat: rm prepare
gomesalexandre Sep 26, 2024
57a04c8
fix: isPortfolioLoaded heuristics
gomesalexandre Sep 26, 2024
190de45
feat: make metadata upsertion blazingly fast
gomesalexandre Sep 26, 2024
3507515
fix: multi-account derp
gomesalexandre Sep 26, 2024
eee8fd7
fix: incremental load
gomesalexandre Sep 26, 2024
10189e6
fix: filter out chains without history for account number, and revert
gomesalexandre Sep 26, 2024
fb7f97d
Merge remote-tracking branch 'origin/develop' into blazingly_fast_ini…
gomesalexandre Sep 27, 2024
da7d405
feat: improve chunks grouping
gomesalexandre Sep 27, 2024
b6dc216
feat: bring back upsertOnFetch
gomesalexandre Sep 27, 2024
2b4defc
feat: fetch txHistory *after* all are done
gomesalexandre Sep 27, 2024
2746284
feat: split getAllTxHistory by AccountIds
gomesalexandre Sep 27, 2024
ed84af5
fix: upsert only per-chain accounts
gomesalexandre Sep 27, 2024
847369c
chore: rm logs and debugger
gomesalexandre Sep 27, 2024
84303c3
feat: revert DegradedStateBanner refetch failed
gomesalexandre Sep 27, 2024
6df58ff
feat: importAccounts revert upsertOnFetch
gomesalexandre Sep 27, 2024
150ae0f
feat: update comment
gomesalexandre Sep 27, 2024
64e8c63
Merge branch 'develop' into blazingly_fast_initial
gomesalexandre Sep 30, 2024
5a3b1ae
feat: rm paranoia
gomesalexandre Sep 30, 2024
ac172d0
feat: opt-chain selector
gomesalexandre Sep 30, 2024
5c094cc
feat: remove chunk terminology and fix grouping of UTXO scriptTypes
gomesalexandre Sep 30, 2024
81b6e9b
feat: set over array for cleanliness
gomesalexandre Sep 30, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/components/Equity/Equity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import {
selectAssetEquityItemsByFilter,
selectAssets,
selectEquityTotalBalance,
selectIsPortfolioLoading,
selectOpportunityApiPending,
selectPortfolioLoading,
selectUnderlyingLpAssetsWithBalancesAndIcons,
} from 'state/slices/selectors'
import { useAppSelector } from 'state/store'
Expand All @@ -41,7 +41,7 @@ const stackDividerStyle = { marginLeft: 14 }

export const Equity = ({ assetId, accountId }: EquityProps) => {
const translate = useTranslate()
const portfolioLoading = useSelector(selectPortfolioLoading)
const portfolioLoading = useSelector(selectIsPortfolioLoading)
const opportunitiesLoading = useAppSelector(selectOpportunityApiPending)
const isLoading = portfolioLoading || opportunitiesLoading
const assets = useAppSelector(selectAssets)
Expand Down
4 changes: 2 additions & 2 deletions src/components/Layout/Header/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { useWallet } from 'hooks/useWallet/useWallet'
import { isUtxoAccountId } from 'lib/utils/utxo'
import { portfolio } from 'state/slices/portfolioSlice/portfolioSlice'
import {
selectEnabledWalletAccountIds,
selectPortfolioDegradedState,
selectShowSnapsModal,
selectWalletAccountIds,
selectWalletId,
} from 'state/slices/selectors'
import { useAppDispatch } from 'state/store'
Expand Down Expand Up @@ -98,7 +98,7 @@ export const Header = memo(() => {
)

const currentWalletId = useSelector(selectWalletId)
const walletAccountIds = useSelector(selectWalletAccountIds)
const walletAccountIds = useSelector(selectEnabledWalletAccountIds)
const hasUtxoAccountIds = useMemo(
() => walletAccountIds.some(accountId => isUtxoAccountId(accountId)),
[walletAccountIds],
Expand Down
4 changes: 2 additions & 2 deletions src/components/Modals/FiatRamps/views/FiatForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { parseAddressInputWithChainId } from 'lib/address/address'
import { useGetFiatRampsQuery } from 'state/apis/fiatRamps/fiatRamps'
import {
selectAssetsSortedByMarketCapUserCurrencyBalanceAndName,
selectEnabledWalletAccountIds,
selectHighestMarketCapFeeAsset,
selectPortfolioAccountMetadata,
selectWalletAccountIds,
selectWalletConnectedChainIds,
} from 'state/slices/selectors'
import { useAppSelector } from 'state/store'
Expand All @@ -35,7 +35,7 @@ export const FiatForm: React.FC<FiatFormProps> = ({
fiatRampAction,
accountId: selectedAccountId,
}) => {
const walletAccountIds = useSelector(selectWalletAccountIds)
const walletAccountIds = useSelector(selectEnabledWalletAccountIds)
const portfolioAccountMetadata = useSelector(selectPortfolioAccountMetadata)
const sortedAssets = useSelector(selectAssetsSortedByMarketCapUserCurrencyBalanceAndName)
const [accountId, setAccountId] = useState<AccountId | undefined>(selectedAccountId)
Expand Down
4 changes: 2 additions & 2 deletions src/components/Modals/Nfts/NftModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { nft, nftApi, useGetNftCollectionQuery } from 'state/apis/nft/nftApi'
import { selectNftById, selectNftCollectionById } from 'state/apis/nft/selectors'
import { chainIdToOpenseaNetwork } from 'state/apis/nft/utils'
import { getMediaType } from 'state/apis/zapper/validators'
import { selectWalletAccountIds, selectWalletId } from 'state/slices/common-selectors'
import { selectEnabledWalletAccountIds, selectWalletId } from 'state/slices/common-selectors'
import { selectAssetById } from 'state/slices/selectors'
import { useAppDispatch, useAppSelector } from 'state/store'
import { breakpoints } from 'theme/theme'
Expand Down Expand Up @@ -105,7 +105,7 @@ export const NftModal: React.FC<NftModalProps> = ({ nftAssetId }) => {
const modalHeaderBg = useColorModeValue('gray.50', 'gray.785')
const [isLargerThanMd] = useMediaQuery(`(min-width: ${breakpoints['md']})`)
const walletId = useAppSelector(selectWalletId)
const accountIds = useAppSelector(selectWalletAccountIds)
const accountIds = useAppSelector(selectEnabledWalletAccountIds)

useGetNftCollectionQuery(
{ accountIds, collectionId: nftItem.collectionId },
Expand Down
8 changes: 5 additions & 3 deletions src/components/Modals/Settings/ClearCache.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { RawText } from 'components/Text'
import { reloadWebview } from 'context/WalletProvider/MobileWallet/mobileMessageHandlers'
import { useWallet } from 'hooks/useWallet/useWallet'
import { isMobile as isMobileApp } from 'lib/globals'
import { selectWalletAccountIds } from 'state/slices/selectors'
import { selectEnabledWalletAccountIds } from 'state/slices/selectors'
import { txHistory, txHistoryApi } from 'state/slices/txHistorySlice/txHistorySlice'
import { persistor, useAppDispatch, useAppSelector } from 'state/store'

Expand Down Expand Up @@ -56,7 +56,7 @@ const ClearCacheButton = ({

export const ClearCache = ({ appHistory }: ClearCacheProps) => {
const dispatch = useAppDispatch()
const requestedAccountIds = useAppSelector(selectWalletAccountIds)
const requestedAccountIds = useAppSelector(selectEnabledWalletAccountIds)
const translate = useTranslate()
const history = useHistory()
const { disconnect } = useWallet()
Expand All @@ -81,7 +81,9 @@ export const ClearCache = ({ appHistory }: ClearCacheProps) => {
const handleClearTxHistory = useCallback(() => {
dispatch(txHistory.actions.clear())
dispatch(txHistoryApi.util.resetApiState())
dispatch(txHistoryApi.endpoints.getAllTxHistory.initiate(requestedAccountIds))
requestedAccountIds.forEach(requestedAccountId =>
dispatch(txHistoryApi.endpoints.getAllTxHistory.initiate(requestedAccountId)),
)
}, [dispatch, requestedAccountIds])

return (
Expand Down
4 changes: 2 additions & 2 deletions src/components/Nfts/hooks/useNfts.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { useMemo } from 'react'
import { useGetNftUserTokensQuery } from 'state/apis/nft/nftApi'
import { selectWalletAccountIds } from 'state/slices/common-selectors'
import { selectEnabledWalletAccountIds } from 'state/slices/common-selectors'
import { useAppSelector } from 'state/store'

export const useNfts = () => {
const requestedAccountIds = useAppSelector(selectWalletAccountIds)
const requestedAccountIds = useAppSelector(selectEnabledWalletAccountIds)

const { isUninitialized, isLoading, isFetching, data } = useGetNftUserTokensQuery(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ import {
useGetZapperUniV2PoolAssetIdsQuery,
} from 'state/apis/zapper/zapperApi'
import {
selectEnabledWalletAccountIds,
selectEvmAccountIds,
selectPortfolioAccounts,
selectPortfolioAssetIds,
selectPortfolioLoadingStatus,
selectWalletAccountIds,
} from 'state/slices/selectors'
import { useAppDispatch } from 'state/store'

export const useFetchOpportunities = () => {
const dispatch = useAppDispatch()
const portfolioLoadingStatus = useSelector(selectPortfolioLoadingStatus)
const requestedAccountIds = useSelector(selectWalletAccountIds)
const requestedAccountIds = useSelector(selectEnabledWalletAccountIds)
const evmAccountIds = useSelector(selectEvmAccountIds)
const portfolioAssetIds = useSelector(selectPortfolioAssetIds)
const portfolioAccounts = useSelector(selectPortfolioAccounts)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Asset } from '@shapeshiftoss/types'
import { useMemo } from 'react'
import { selectPortfolioLoading } from 'state/slices/selectors'
import { selectIsPortfolioLoading } from 'state/slices/selectors'
import { useAppSelector } from 'state/store'

import { useGetPopularAssetsQuery } from '../hooks/useGetPopularAssetsQuery'
Expand All @@ -17,7 +17,7 @@ export const DefaultAssetList = ({
popularAssets,
onAssetClick,
}: DefaultAssetListProps) => {
const isPortfolioLoading = useAppSelector(selectPortfolioLoading)
const isPortfolioLoading = useAppSelector(selectIsPortfolioLoading)
const { isLoading: isPopularAssetIdsLoading } = useGetPopularAssetsQuery()

const groupIsLoading = useMemo(() => {
Expand Down
181 changes: 112 additions & 69 deletions src/context/AppProvider/AppContext.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { usePrevious, useToast } from '@chakra-ui/react'
import type { AccountId, ChainId } from '@shapeshiftoss/caip'
import { fromAccountId } from '@shapeshiftoss/caip'
import type { LedgerOpenAppEventArgs } from '@shapeshiftoss/chain-adapters'
import { emitter } from '@shapeshiftoss/chain-adapters'
Expand Down Expand Up @@ -33,11 +34,11 @@ import { preferences } from 'state/slices/preferencesSlice/preferencesSlice'
import {
selectAccountIdsByChainId,
selectAssetIds,
selectEnabledWalletAccountIds,
selectPortfolioAssetIds,
selectPortfolioLoadingStatus,
selectSelectedCurrency,
selectSelectedLocale,
selectWalletAccountIds,
} from 'state/slices/selectors'
import { txHistoryApi } from 'state/slices/txHistorySlice/txHistorySlice'
import { useAppDispatch, useAppSelector } from 'state/store'
Expand All @@ -59,7 +60,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
const { supportedChains } = usePlugins()
const wallet = useWallet().state.wallet
const assetIds = useSelector(selectAssetIds)
const requestedAccountIds = useSelector(selectWalletAccountIds)
const requestedAccountIds = useSelector(selectEnabledWalletAccountIds)
const portfolioLoadingStatus = useSelector(selectPortfolioLoadingStatus)
const portfolioAssetIds = useSelector(selectPortfolioAssetIds)
const routeAssetId = useRouteAssetId()
Expand Down Expand Up @@ -144,19 +145,16 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
// This ensures that we have fresh portfolio data, but accounts added through account management are not accidentally blown away.
if (hasManagedAccounts) {
requestedAccountIds.forEach(accountId => {
dispatch(
portfolioApi.endpoints.getAccount.initiate(
{ accountId, upsertOnFetch: true },
{ forceRefetch: true },
),
)
dispatch(portfolioApi.endpoints.getAccount.initiate({ accountId, upsertOnFetch: true }))
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No forceRefetch here. If this is the first call, this will fetch, else leverage RTK caching to avoid spew spew

})

return
}

if (!wallet || isLedger(wallet)) return

const walletId = await wallet.getDeviceID()

let chainIds = supportedChains.filter(chainId => {
return walletSupportsChain({
chainId,
Expand Down Expand Up @@ -192,64 +190,121 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
Object.assign(accountMetadataByAccountId, accountIdsAndMetadata)

const { getAccount } = portfolioApi.endpoints
const accountPromises = accountIds.map(accountId =>
dispatch(getAccount.initiate({ accountId }, { forceRefetch: true })),
)

const accountResults = await Promise.allSettled(accountPromises)
// Chunks of AccountIds
// For UTXOs, each chain gets its own chunk to detect chain activity over other scriptTypes
// For others, no need to use chunks, but for the sake of consistency, we keep the same structure
const accountNumberAccountIdChunks = (
_accountIds: AccountId[],
): Record<ChainId, AccountId[]> => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion]: Rename this to accountNumberAccountIdsByChainId or similar and update the rest of the vernacular to match.

These aren't really chunks - chunking typically refers to batching an array into multiple smaller arrays.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ab-solutely 💯: 5c094cc

return _accountIds.reduce(
(acc, _accountId) => {
const { chainId } = fromAccountId(_accountId)

if (isUtxoChainId(chainId)) {
acc[chainId] = [_accountId]
return acc
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion]: This path shouldnt be required if there arent multiple utxo account ids for a given chainId. I.e if there is no collision on chainId we can rely on the flow below this statement to correctly group accountIds by chainId - even if there is only 1 per chainId.

If the intent is to "overwrite" i.e "last write wins" then leave as-is

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are absolutely right! Reading this again with fresh eyes, this looks blatantly wrong. A UTXO ChainId may and will most likely contain multiple AccountIds (one per scriptType), and we need them all in one "chunk" (terminology removed btw!) to detect activity across scriptTypes, that effectively voided it.
5c094cc


if (!acc[chainId]) {
acc[chainId] = []
}
acc[chainId].push(_accountId)

return acc
},
{} as Record<ChainId, AccountId[]>,
)
}

let chainIdsWithActivity: string[] = []
accountResults.forEach((res, idx) => {
if (res.status === 'rejected') return

const { data: account } = res.value
if (!account) return

const accountId = accountIds[idx]
const { chainId } = fromAccountId(accountId)

const { hasActivity } = account.accounts.byId[accountId]

const accountNumberHasChainActivity = !isUtxoChainId(chainId)
? hasActivity
: // For UTXO AccountIds, we need to check if *any* of the scriptTypes have activity, not only the current one
// else, we might end up with partial account data, with only the first 1 or 2 out of 3 scriptTypes
// being upserted for BTC and LTC
accountResults.some((res, _idx) => {
if (res.status === 'rejected') return false
const { data: account } = res.value
if (!account) return false
const accountId = accountIds[_idx]
const { chainId: _chainId } = fromAccountId(accountId)
if (chainId !== _chainId) return false
return account.accounts.byId[accountId].hasActivity
})

// don't add accounts with no activity past account 0
if (accountNumber > 0 && !accountNumberHasChainActivity)
return delete accountMetadataByAccountId[accountId]

// unique set to handle utxo chains with multiple account types per account
chainIdsWithActivity = Array.from(new Set([...chainIdsWithActivity, chainId]))

dispatch(portfolio.actions.upsertPortfolio(account))
// Chunks of AccountIds promises for accountNumber/AccountId combination
// This allows every run of AccountIds per chain/accountNumber to run in parallel vs. all sequentally, so
// we can run each chain's side effects immediately
const accountNumberAccountIdsPromises = Object.values(
accountNumberAccountIdChunks(accountIds),
).map(async accountIds => {
const results = await Promise.allSettled(
accountIds.map(async id => {
const result = await dispatch(getAccount.initiate({ accountId: id }))
return result
}),
)

results.forEach((res, idx) => {
if (res.status === 'rejected') return

const { data: account } = res.value
if (!account) return

const accountId = accountIds[idx]
const { chainId } = fromAccountId(accountId)

const { hasActivity } = account.accounts.byId[accountId]

const accountNumberHasChainActivity = !isUtxoChainId(chainId)
? hasActivity
: // For UTXO AccountIds, we need to check if *any* of the scriptTypes have activity, not only the current one
// else, we might end up with partial account data, with only the first 1 or 2 out of 3 scriptTypes
// being upserted for BTC and LTC
results.some((res, _idx) => {
if (res.status === 'rejected') return false
const { data: account } = res.value
if (!account) return false
const accountId = accountIds[_idx]
const { chainId: _chainId } = fromAccountId(accountId)
if (chainId !== _chainId) return false
return account.accounts.byId[accountId].hasActivity
})

// don't add accounts with no activity past account 0
if (accountNumber > 0 && !accountNumberHasChainActivity) {
chainIdsWithActivity = chainIdsWithActivity.filter(_chainId => _chainId !== chainId)
delete accountMetadataByAccountId[accountId]
} else {
// unique set to handle utxo chains with multiple account types per account
chainIdsWithActivity = Array.from(new Set([...chainIdsWithActivity, chainId]))

dispatch(portfolio.actions.upsertPortfolio(account))
const chainIdAccountMetadata = Object.entries(accountMetadataByAccountId).reduce(
(acc, [accountId, metadata]) => {
const { chainId: _chainId } = fromAccountId(accountId)
if (chainId === _chainId) {
acc[accountId] = metadata
}
return acc
},
{} as AccountMetadataById,
)
Comment on lines +262 to +271
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[preferably blocking]: Untested but if possible this would be nicer:

Suggested change
const chainIdAccountMetadata = Object.entries(accountMetadataByAccountId).reduce(
(acc, [accountId, metadata]) => {
const { chainId: _chainId } = fromAccountId(accountId)
if (chainId === _chainId) {
acc[accountId] = metadata
}
return acc
},
{} as AccountMetadataById,
)
const chainIdAccountMetadata = {
[chainId]: accountMetadataByAccountId[toAccountId({ chainId, account })]
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left as-is as this will lose the intent of this (keeping only the AccountIds - note plural here - for the current ChainId) and won't generalize to UTXOs (this would only keep the last AccountId for UTXO ChainIds)

dispatch(
portfolio.actions.upsertAccountMetadata({
accountMetadataByAccountId: chainIdAccountMetadata,
walletId,
}),
)
for (const accountId of Object.keys(accountMetadataByAccountId)) {
dispatch(portfolio.actions.enableAccountId(accountId))
}
}
})

return results
})

chainIds = chainIdsWithActivity
}
await Promise.allSettled(accountNumberAccountIdsPromises)

dispatch(
portfolio.actions.upsertAccountMetadata({
accountMetadataByAccountId,
walletId: await wallet.getDeviceID(),
}),
)

for (const accountId of Object.keys(accountMetadataByAccountId)) {
dispatch(portfolio.actions.enableAccountId(accountId))
chainIds = chainIdsWithActivity
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion]: For both performance and readability, chainIdsWithActivity would be much better suited as a const chainIdsWithActivity: Set<ChainId> = new Set() instead of a let chainIdsWithActivity: string[] = []. This way, above you can mutate the set rather than reassigning a new array at every step:

chainIdsWithActivity.delete(chainId)
chainIdsWithActivity.add(chainId)

instead of

chainIdsWithActivity = chainIdsWithActivity.filter(_chainId => _chainId !== chainId)
chainIdsWithActivity = Array.from(new Set([...chainIdsWithActivity, chainId]))

and then at the end you can assign chainIds = Array.from(chainIdsWithActivity)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Much cleaner indeed! Went with keeping everything as a set and doing in-place Array.from() for the one place where chainIds is required by another API (deriveAccountIdsAndMetadata) to be an array:

81b6e9b and confirmed we're still happy! https://jam.dev/c/6625fa90-5f30-4cd1-9acb-b46a9ad82dad

}
} finally {
dispatch(portfolio.actions.setIsAccountMetadataLoading(false))
// Only fetch and upsert Tx history once all are loaded, otherwise big main thread rug
const { getAllTxHistory } = txHistoryApi.endpoints

await Promise.all(
requestedAccountIds.map(requestedAccountId =>
dispatch(getAllTxHistory.initiate(requestedAccountId)),
),
)
Comment on lines +293 to +300
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed Tx history is still fetched on account management new accounts toggle

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

really nice, so much better for readability than relying on separate useEffect

}
})()
}, [
Expand All @@ -271,18 +326,6 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => {
)
}, [dispatch, portfolioLoadingStatus])

// once portfolio is done loading, fetch all transaction history
useEffect(() => {
;(async () => {
if (!requestedAccountIds.length) return
if (portfolioLoadingStatus === 'loading') return

const { getAllTxHistory } = txHistoryApi.endpoints

await dispatch(getAllTxHistory.initiate(requestedAccountIds))
})()
}, [dispatch, requestedAccountIds, portfolioLoadingStatus])

const marketDataPollingInterval = 60 * 15 * 1000 // refetch data every 15 minutes
useQueries({
queries: portfolioAssetIds.map(assetId => ({
Expand Down
Loading
Loading