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

Display tokens and NFTs in horizontal lists #164

Merged
merged 6 commits into from
Aug 8, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
2 changes: 1 addition & 1 deletion ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -764,4 +764,4 @@ SPEC CHECKSUMS:

PODFILE CHECKSUM: ae2d46549a618b46c01e590f7c4c50b1ba74c1bd

COCOAPODS: 1.12.0
COCOAPODS: 1.12.1
612 changes: 558 additions & 54 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"test": "jest"
},
"dependencies": {
"@alephium/sdk": "0.7.0",
"@alephium/web3": "^0.12.2",
"@alephium/sdk": "0.7.2",
"@alephium/web3": "^0.13.0",
"@react-native-async-storage/async-storage": "^1.18.1",
"@react-navigation/bottom-tabs": "^6.5.4",
"@react-navigation/elements": "^1.3.14",
Expand All @@ -41,6 +41,7 @@
"expo-splash-screen": "~0.18.1",
"expo-status-bar": "~1.4.4",
"expo-web-browser": "~12.1.1",
"fetch-retry": "^5.0.6",
"lodash": "^4.17.21",
"lottie-react-native": "^5.1.6",
"lucide-react-native": "^0.216.0",
Expand All @@ -53,6 +54,7 @@
"react-native-aes-crypto": "^2.1.0",
"react-native-gesture-handler": "^2.10.0",
"react-native-get-random-values": "~1.8.0",
"react-native-pager-view": "6.1.2",
"react-native-progress": "^5.0.0",
"react-native-reanimated": "~2.14.4",
"react-native-reanimated-carousel": "^3.0.4",
Expand All @@ -65,8 +67,7 @@
"react-qr-code": "^2.0.7",
"react-redux": "^8.0.1",
"styled-components": "^5.3.5",
"victory-native": "^36.6.10",
"react-native-pager-view": "6.1.2"
"victory-native": "^36.6.10"
},
"devDependencies": {
"@babel/core": "^7.18.6",
Expand All @@ -87,7 +88,7 @@
"jest": "^29.0.1",
"patch-package": "^7.0.0",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
"typescript": "^5.0.4"
},
"engines": {
"node": ">=16.0.0",
Expand Down
79 changes: 76 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ import dayjs from 'dayjs'
import updateLocale from 'dayjs/plugin/updateLocale'
import { isEnrolledAsync } from 'expo-local-authentication'
import { StatusBar } from 'expo-status-bar'
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { difference } from 'lodash'
import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Alert, AppState, AppStateStatus } from 'react-native'
import { RootSiblingParent } from 'react-native-root-siblings'
import { SafeAreaProvider } from 'react-native-safe-area-context'
import { Provider } from 'react-redux'
import { DefaultTheme, ThemeProvider } from 'styled-components/native'

import client from '~/api/client'
import { useAppDispatch, useAppSelector } from '~/hooks/redux'
import useInitializeClient from '~/hooks/useInitializeClient'
import useInterval from '~/hooks/useInterval'
import useLoadStoredSettings from '~/hooks/useLoadStoredSettings'
import RootStackNavigation from '~/navigation/RootStackNavigation'
import {
Expand All @@ -40,7 +42,16 @@ import {
rememberActiveWallet
} from '~/persistent-storage/wallets'
import { biometricsDisabled, walletUnlocked } from '~/store/activeWalletSlice'
import {
makeSelectAddressesUnknownTokens,
selectAllAddresses,
syncAddressesData,
syncAddressesHistoricBalances
} from '~/store/addressesSlice'
import { appBecameInactive } from '~/store/appSlice'
import { syncNetworkTokensInfo, syncUnknownTokensInfo } from '~/store/assets/assetsActions'
import { selectIsTokensMetadataUninitialized } from '~/store/assets/assetsSelectors'
import { apiClientInitFailed, apiClientInitSucceeded } from '~/store/networkSlice'
import { store } from '~/store/store'
import { themes } from '~/style/themes'
import { navigateRootStack, resetNavigationState, setNavigationState } from '~/utils/navigation'
Expand Down Expand Up @@ -94,10 +105,72 @@ const Main = ({ children }: { children: ReactNode }) => {
const isCameraOpen = useAppSelector((s) => s.app.isCameraOpen)
const activeWalletMnemonic = useAppSelector((s) => s.activeWallet.mnemonic)
const addressesStatus = useAppSelector((s) => s.addresses.status)
const network = useAppSelector((s) => s.network)
const addresses = useAppSelector(selectAllAddresses)
const assetsInfo = useAppSelector((s) => s.assetsInfo)
const isLoadingTokensMetadata = useAppSelector((s) => s.assetsInfo.loading)
const isSyncingAddressData = useAppSelector((s) => s.addresses.syncingAddressData)
const isTokensMetadataUninitialized = useAppSelector(selectIsTokensMetadataUninitialized)

const selectAddressesUnknownTokens = useMemo(makeSelectAddressesUnknownTokens, [])
const unknownTokens = useAppSelector(selectAddressesUnknownTokens)
const checkedUnknownTokenIds = useAppSelector((s) => s.assetsInfo.checkedUnknownTokenIds)
const unknownTokenIds = unknownTokens.map((token) => token.id)
const newUnknownTokens = difference(unknownTokenIds, checkedUnknownTokenIds)

useInitializeClient()
useLoadStoredSettings()

const initializeClient = useCallback(async () => {
try {
client.init(network.settings.nodeHost, network.settings.explorerApiHost)
const { networkId } = await client.node.infos.getInfosChainParams()
// TODO: Check if connection to explorer also works
dispatch(apiClientInitSucceeded({ networkId, networkName: network.name }))
console.log(`Client initialized. Current network: ${network.name}`)
} catch (e) {
dispatch(apiClientInitFailed())
console.error('Could not connect to network: ', network.name)
console.error(e)
}
}, [network.settings.nodeHost, network.settings.explorerApiHost, network.name, dispatch])

useEffect(() => {
if (network.status === 'connecting') {
initializeClient()
}
}, [initializeClient, network.status])

const shouldInitialize = network.status === 'offline'
useInterval(initializeClient, 2000, !shouldInitialize)

useEffect(() => {
if (network.status === 'online') {
if (assetsInfo.status === 'uninitialized' && !isLoadingTokensMetadata) {
dispatch(syncNetworkTokensInfo())
}
if (addressesStatus === 'uninitialized') {
if (!isSyncingAddressData && addresses.length > 0) {
dispatch(syncAddressesData())
dispatch(syncAddressesHistoricBalances())
}
} else if (addressesStatus === 'initialized') {
if (!isTokensMetadataUninitialized && !isLoadingTokensMetadata && newUnknownTokens.length > 0) {
dispatch(syncUnknownTokensInfo(newUnknownTokens))
}
}
}
}, [
addresses.length,
addressesStatus,
assetsInfo.status,
dispatch,
isLoadingTokensMetadata,
isSyncingAddressData,
isTokensMetadataUninitialized,
network.status,
newUnknownTokens
])

const unlockActiveWallet = useCallback(async () => {
if (activeWalletMnemonic) return

Expand Down
17 changes: 12 additions & 5 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,30 @@ You should have received a copy of the GNU Lesser General Public License
along with the library. If not, see <http://www.gnu.org/licenses/>.
*/

import { ExplorerProvider, NodeProvider, throttledFetch } from '@alephium/web3'
import { ExplorerProvider, NodeProvider } from '@alephium/web3'
import fetchRetry, { RequestInitWithRetry } from 'fetch-retry'

import { defaultNetworkSettings } from '~/persistent-storage/settings'
import { NetworkSettings } from '~/types/settings'

export const exponentialBackoffFetchRetry = fetchRetry(fetch, {
retryOn: [429],
retries: 10,
mvaivre marked this conversation as resolved.
Show resolved Hide resolved
retryDelay: (attempt) => Math.pow(2, attempt) * 1000
}) as (input: RequestInfo | URL, init?: RequestInitWithRetry | undefined) => Promise<Response>

export class Client {
node: NodeProvider
explorer: ExplorerProvider

constructor({ nodeHost, explorerApiHost }: NetworkSettings) {
this.node = new NodeProvider(nodeHost, undefined, throttledFetch(5))
this.explorer = new ExplorerProvider(explorerApiHost, undefined, throttledFetch(5))
this.node = new NodeProvider(nodeHost, undefined, exponentialBackoffFetchRetry)
this.explorer = new ExplorerProvider(explorerApiHost, undefined, exponentialBackoffFetchRetry)
}

init(nodeHost: NetworkSettings['nodeHost'], explorerApiHost: NetworkSettings['explorerApiHost']) {
this.node = new NodeProvider(nodeHost, undefined, throttledFetch(5))
this.explorer = new ExplorerProvider(explorerApiHost, undefined, throttledFetch(5))
this.node = new NodeProvider(nodeHost, undefined, exponentialBackoffFetchRetry)
this.explorer = new ExplorerProvider(explorerApiHost, undefined, exponentialBackoffFetchRetry)
}
}

Expand Down
154 changes: 139 additions & 15 deletions src/components/AddressesTokensList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,41 +16,165 @@ You should have received a copy of the GNU Lesser General Public License
along with the library. If not, see <http://www.gnu.org/licenses/>.
*/

import { useMemo } from 'react'
import { StyleProp, View, ViewStyle } from 'react-native'
import { Asset } from '@alephium/sdk'
import { chunk } from 'lodash'
import { useMemo, useState } from 'react'
import { FlatList, LayoutChangeEvent, StyleProp, View, ViewStyle } from 'react-native'
import styled, { css } from 'styled-components/native'

import AppText from '~/components/AppText'
import Carousel from '~/components/Carousel'
import UnknownTokensListItem, { UnknownTokensEntry } from '~/components/UnknownTokensListItem'
import { useAppSelector } from '~/hooks/redux'
import { makeSelectAddressesAssets, selectAllAddresses } from '~/store/addressesSlice'
import {
makeSelectAddressesCheckedUnknownTokens,
makeSelectAddressesKnownFungibleTokens,
makeSelectAddressesNFTs,
selectAllAddresses
} from '~/store/addressesSlice'
import { BORDER_RADIUS_SMALL } from '~/style/globalStyle'
import { Address } from '~/types/addresses'

import { ScreenSection } from './layout/Screen'
import TokenInfo from './TokenInfo'
import TokenListItem from './TokenListItem'

interface AddressesTokensListProps {
addresses?: Address[]
style?: StyleProp<ViewStyle>
}

const PAGE_SIZE = 3

const AddressesTokensList = ({ addresses: addressesParam, style }: AddressesTokensListProps) => {
const allAddresses = useAppSelector(selectAllAddresses)
const addresses = addressesParam ?? allAddresses
const selectAddressesAssets = useMemo(makeSelectAddressesAssets, [])
const assets = useAppSelector((s) =>
selectAddressesAssets(
s,
addresses.map(({ hash }) => hash)
)
const addressHashes = addresses.map(({ hash }) => hash)
const selectAddressesKnownFungibleTokens = useMemo(makeSelectAddressesKnownFungibleTokens, [])
const knownFungibleTokens = useAppSelector((s) => selectAddressesKnownFungibleTokens(s, addressHashes))
const selectAddressesCheckedUnknownTokens = useMemo(makeSelectAddressesCheckedUnknownTokens, [])
const unknownTokens = useAppSelector((s) => selectAddressesCheckedUnknownTokens(s, addressHashes))
const selectAddressesNFTs = useMemo(makeSelectAddressesNFTs, [])
const nfts = useAppSelector((s) => selectAddressesNFTs(s, addressHashes))

const [carouselItemHeight, setCarouselItemHeight] = useState(258)
const [isCarouselItemHeightAdapted, setIsCarouselItemHeightAdapted] = useState(false)

const entries =
unknownTokens.length > 0
? [
...knownFungibleTokens,
{
numberOfUnknownTokens: unknownTokens.length,
addressHash: addresses.length === 1 ? addresses[0].hash : undefined
}
]
: knownFungibleTokens
const entriesChunked = chunk(entries, PAGE_SIZE)

const onLayoutCarouselItem = (event: LayoutChangeEvent) => {
const newCarouselItemHeight = event.nativeEvent.layout.height

if (!isCarouselItemHeightAdapted || (isCarouselItemHeightAdapted && newCarouselItemHeight > carouselItemHeight)) {
setCarouselItemHeight(newCarouselItemHeight)
setIsCarouselItemHeightAdapted(true)
}
}

const renderCarouselItem = ({ item }: { item: (Asset | UnknownTokensEntry)[] }) => (
<View onLayout={onLayoutCarouselItem}>
{item.map((entry, index) =>
isAsset(entry) ? (
<TokenListItem
key={entry.id}
asset={entry}
hideSeparator={index === knownFungibleTokens.length - 1 || (index + 1) % 3 === 0}
/>
) : (
<UnknownTokensListItem entry={entry} key="unknown-tokens" />
)
)}
</View>
)

return (
<View style={style}>
<ScreenSection>
{assets.map((asset, index) => (
<TokenInfo asset={asset} key={asset.id} isLast={index === assets.length - 1} />
))}
</ScreenSection>
{entriesChunked.length > 1 && (
<>
<ScreenSection>
<TitleRow>
<AppText semiBold size={18}>
Tokens
</AppText>
</TitleRow>
</ScreenSection>
<Carousel
data={entriesChunked}
renderItem={renderCarouselItem}
padding={20}
distance={10}
height={carouselItemHeight}
/>
</>
)}
{entriesChunked.length === 1 && (
<ScreenSection>
<TitleRow>
<AppText semiBold size={18}>
Tokens
</AppText>
</TitleRow>

{renderCarouselItem({ item: entriesChunked[0] })}
</ScreenSection>
)}

{nfts.length > 0 && (
<>
<ScreenSection>
<TitleRow>
<AppText semiBold size={18}>
NFTs
</AppText>
</TitleRow>
</ScreenSection>
<FlatList
horizontal
data={nfts}
renderItem={({ item: nft, index }) => (
<NFTThumbnail source={{ uri: nft.image }} isFirst={index === 0} isLast={index === nfts.length - 1} />
)}
/>
</>
)}
</View>
)
}

export default AddressesTokensList

const TitleRow = styled.View`
padding-bottom: 12px;
border-bottom-width: 1px;
border-color: ${({ theme }) => theme.border.secondary};
`

const NFTThumbnail = styled.Image<{ isFirst: boolean; isLast: boolean }>`
width: 100px;
height: 100px;
border-radius: ${BORDER_RADIUS_SMALL}px;
margin: 16px 10px 16px 0;

${({ isFirst }) =>
isFirst &&
css`
margin-left: 20px;
`}

${({ isLast }) =>
isLast &&
css`
margin-right: 20px;
`}
`

const isAsset = (item: Asset | UnknownTokensEntry): item is Asset => !!(item as Asset).id
4 changes: 2 additions & 2 deletions src/components/Amount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,8 @@ const Amount = ({
<AppText {...props} color={color}>
{integralPart}
</AppText>
{fractionalPart && <AppText {...props} color={fadedColor}>{`.${fractionalPart} `}</AppText>}
{quantitySymbol && <AppText {...props} color={fadedColor}>{`${quantitySymbol} `}</AppText>}
{fractionalPart && <AppText {...props} color={fadedColor}>{`.${fractionalPart}`}</AppText>}
{quantitySymbol && <AppText {...props} color={fadedColor}>{` ${quantitySymbol} `}</AppText>}
{!isUnknownToken && (
<AppText {...props} color={fadeSuffix ? 'secondary' : color}>{` ${suffix ?? 'ALPH'}`}</AppText>
)}
Expand Down
Loading