diff --git a/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx b/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx index d2b2da0570..120af37f16 100644 --- a/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx +++ b/apps/wallet-mobile/src/features/Portfolio/common/MediaDetails/MediaDetails.tsx @@ -1,15 +1,16 @@ import {RouteProp, useRoute} from '@react-navigation/native' import {isString} from '@yoroi/common' import {useExplorers} from '@yoroi/explorers' -import {usePorfolioTokenDiscovery} from '@yoroi/portfolio' +import {usePorfolioTokenDiscovery, usePorfolioTokenTraits} from '@yoroi/portfolio' import {useTheme} from '@yoroi/theme' import {Chain, Portfolio} from '@yoroi/types' import React, {ReactNode, useState} from 'react' import {defineMessages, useIntl} from 'react-intl' import {Linking, SafeAreaView, ScrollView, StyleSheet, TouchableOpacity, useWindowDimensions, View} from 'react-native' -import {CopyButton, FadeIn, Spacer, Text} from '../../../../components' +import {Boundary, CopyButton, FadeIn, Spacer, Text} from '../../../../components' import {Tab, TabPanel, TabPanels, Tabs} from '../../../../components/Tabs' +import {time} from '../../../../kernel/constants' import {useMetrics} from '../../../../kernel/metrics/metricsManager' import {NftRoutes} from '../../../../kernel/navigation' import {useNavigateTo} from '../../../Nfts/common/navigation' @@ -65,7 +66,9 @@ export const MediaDetails = () => { /> -
+ +
+ @@ -88,7 +91,17 @@ const Details = ({activeTab, info, network}: DetailsProps) => { getTokenDiscovery: api.tokenDiscovery, }, { - staleTime: Infinity, + staleTime: time.session, + }, + ) + const {tokenTraits} = usePorfolioTokenTraits( + { + id: info.id, + network, + getTokenTraits: api.tokenTraits, + }, + { + staleTime: time.oneDay, }, ) @@ -98,7 +111,7 @@ const Details = ({activeTab, info, network}: DetailsProps) => { return ( - + @@ -129,7 +142,7 @@ const MetadataRow = ({title, copyText, children}: {title: string; children: Reac const styles = useStyles() return ( - + {title} {copyText !== undefined ? : null} @@ -144,9 +157,10 @@ const MetadataRow = ({title, copyText, children}: {title: string; children: Reac type NftOverviewProps = { info: Portfolio.Token.Info + traits: Portfolio.Token.Traits | undefined network: Chain.SupportedNetworks } -const NftOverview = ({info, network}: NftOverviewProps) => { +const NftOverview = ({info, network, traits}: NftOverviewProps) => { const styles = useStyles() const strings = useStrings() const explorers = useExplorers(network) @@ -171,6 +185,16 @@ const NftOverview = ({info, network}: NftOverviewProps) => { {policyId} + {traits?.traits.map((trait) => ( + + + {trait.value} + + {trait.rarity} + + + ))} + { rowContainer: { paddingVertical: imagePadding, }, - rowTitleContainer: { + rowBetween: { display: 'flex', flexDirection: 'row', alignItems: 'center', diff --git a/apps/wallet-mobile/src/kernel/constants.ts b/apps/wallet-mobile/src/kernel/constants.ts index df5758d5d1..b584a8155c 100644 --- a/apps/wallet-mobile/src/kernel/constants.ts +++ b/apps/wallet-mobile/src/kernel/constants.ts @@ -1,11 +1,13 @@ -export const supportedThemes = Object.freeze({ +import {freeze} from 'immer' + +export const supportedThemes = freeze({ system: 'system', 'default-light': 'default-light', 'default-dark': 'default-dark', }) // NOTE: to be moved into pairing module once it's implemented -export const supportedCurrencies = Object.freeze({ +export const supportedCurrencies = freeze({ ADA: 'ADA', BRL: 'BRL', BTC: 'BTC', @@ -17,7 +19,7 @@ export const supportedCurrencies = Object.freeze({ USD: 'USD', }) -export const configCurrencies = { +export const configCurrencies = freeze({ [supportedCurrencies.ADA]: { decimals: 6, nativeName: 'Cardano', @@ -54,4 +56,14 @@ export const configCurrencies = { decimals: 2, nativeName: 'US Dollar', }, -} +}) + +export const time = freeze({ + oneMinute: 60 * 1e3, + fiveMinutes: 5 * 60 * 1e3, + halfHour: 30 * 60 * 1e3, + oneHour: 60 * 60 * 1e3, + oneDay: 24 * 60 * 60 * 1e3, + // session here means while the wallet is open + session: Infinity, +}) diff --git a/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json b/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json index 580fc42b71..64e0bb0f12 100644 --- a/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json +++ b/apps/wallet-mobile/translations/messages/src/features/Portfolio/common/MediaDetails/MediaDetails.json @@ -4,14 +4,14 @@ "defaultMessage": "!!!NFT Details", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 315, + "line": 339, "column": 9, - "index": 8864 + "index": 9572 }, "end": { - "line": 318, + "line": 342, "column": 3, - "index": 8935 + "index": 9643 } }, { @@ -19,14 +19,14 @@ "defaultMessage": "!!!Overview", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 319, + "line": 343, "column": 12, - "index": 8949 + "index": 9657 }, "end": { - "line": 322, + "line": 346, "column": 3, - "index": 9020 + "index": 9728 } }, { @@ -34,14 +34,14 @@ "defaultMessage": "!!!Metadata", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 323, + "line": 347, "column": 12, - "index": 9034 + "index": 9742 }, "end": { - "line": 326, + "line": 350, "column": 3, - "index": 9105 + "index": 9813 } }, { @@ -49,14 +49,14 @@ "defaultMessage": "!!!NFT Name", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 327, + "line": 351, "column": 11, - "index": 9118 + "index": 9826 }, "end": { - "line": 330, + "line": 354, "column": 3, - "index": 9188 + "index": 9896 } }, { @@ -64,14 +64,14 @@ "defaultMessage": "!!!Created", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 331, + "line": 355, "column": 13, - "index": 9203 + "index": 9911 }, "end": { - "line": 334, + "line": 358, "column": 3, - "index": 9274 + "index": 9982 } }, { @@ -79,14 +79,14 @@ "defaultMessage": "!!!Description", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 335, + "line": 359, "column": 15, - "index": 9291 + "index": 9999 }, "end": { - "line": 338, + "line": 362, "column": 3, - "index": 9368 + "index": 10076 } }, { @@ -94,14 +94,14 @@ "defaultMessage": "!!!Author", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 339, + "line": 363, "column": 10, - "index": 9380 + "index": 10088 }, "end": { - "line": 342, + "line": 366, "column": 3, - "index": 9447 + "index": 10155 } }, { @@ -109,14 +109,14 @@ "defaultMessage": "!!!Fingerprint", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 343, + "line": 367, "column": 15, - "index": 9464 + "index": 10172 }, "end": { - "line": 346, + "line": 370, "column": 3, - "index": 9541 + "index": 10249 } }, { @@ -124,14 +124,14 @@ "defaultMessage": "!!!Policy id", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 347, + "line": 371, "column": 12, - "index": 9555 + "index": 10263 }, "end": { - "line": 350, + "line": 374, "column": 3, - "index": 9627 + "index": 10335 } }, { @@ -139,14 +139,14 @@ "defaultMessage": "!!!Details on", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 351, + "line": 375, "column": 16, - "index": 9645 + "index": 10353 }, "end": { - "line": 354, + "line": 378, "column": 3, - "index": 9722 + "index": 10430 } }, { @@ -154,14 +154,14 @@ "defaultMessage": "!!!Copy metadata", "file": "src/features/Portfolio/common/MediaDetails/MediaDetails.tsx", "start": { - "line": 355, + "line": 379, "column": 16, - "index": 9740 + "index": 10448 }, "end": { - "line": 358, + "line": 382, "column": 3, - "index": 9820 + "index": 10528 } } ] \ No newline at end of file diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts index 34f0260145..1e2cf24ee1 100644 --- a/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts +++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.mocks.ts @@ -3,6 +3,7 @@ import {asyncBehavior} from '@yoroi/common' import {Api, Portfolio} from '@yoroi/types' import {tokenMocks} from '../token.mocks' +import {tokenTraitsMocks} from '../token-traits.mocks' export const responseTokenDiscoveryMocks = asyncBehavior.maker< Api.Response @@ -24,19 +25,32 @@ export const responseTokenInfosMocks = asyncBehavior.maker< emptyRepresentation: null, }) +export const responseTokenTraits = asyncBehavior.maker< + Api.Response +>({ + data: { + tag: 'right', + value: {status: 200, data: tokenTraitsMocks.nftCryptoKitty}, + }, + emptyRepresentation: null, +}) + const success: Portfolio.Api.Api = { tokenDiscovery: responseTokenDiscoveryMocks.success, tokenInfos: responseTokenInfosMocks.success, + tokenTraits: responseTokenTraits.success, } const delayed: Portfolio.Api.Api = { tokenDiscovery: responseTokenDiscoveryMocks.delayed, tokenInfos: responseTokenInfosMocks.delayed, + tokenTraits: responseTokenTraits.delayed, } const loading: Portfolio.Api.Api = { tokenDiscovery: responseTokenDiscoveryMocks.loading, tokenInfos: responseTokenInfosMocks.loading, + tokenTraits: responseTokenTraits.loading, } const error: Portfolio.Api.Api = { @@ -58,11 +72,21 @@ const error: Portfolio.Api.Api = { responseData: {message: 'Bad Request'}, }, }), + tokenTraits: () => + Promise.resolve({ + tag: 'left', + error: { + status: 400, + message: 'Bad Request', + responseData: {message: 'Bad Request'}, + }, + }), } const empty: Portfolio.Api.Api = { tokenDiscovery: responseTokenDiscoveryMocks.empty, tokenInfos: responseTokenInfosMocks.empty, + tokenTraits: responseTokenTraits.empty, } export const portfolioApiMock = freeze( diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts index 1d9790f6ce..c26f22ae44 100644 --- a/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts +++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.test.ts @@ -3,6 +3,7 @@ import {Api, Chain, Portfolio} from '@yoroi/types' import {apiConfig, portfolioApiMaker} from './api-maker' import {DullahanApiCachedIdsRequest} from './types' import {tokenDiscoveryMocks} from '../token-discovery.mocks' +import {tokenMocks} from '../token.mocks' describe('portfolioApiMaker', () => { const mockNetwork: Chain.Network = Chain.Network.Mainnet @@ -24,6 +25,7 @@ describe('portfolioApiMaker', () => { expect(api).toBeDefined() expect(api).toHaveProperty('tokenDiscovery') expect(api).toHaveProperty('tokenInfos') + expect(api).toHaveProperty('tokenTraits') }) it('should return a PortfolioApi object with default fetchData (coverage)', () => { @@ -36,6 +38,7 @@ describe('portfolioApiMaker', () => { expect(api).toBeDefined() expect(api).toHaveProperty('tokenDiscovery') expect(api).toHaveProperty('tokenInfos') + expect(api).toHaveProperty('tokenTraits') }) it('should call the fetchData function with the correct arguments', async () => { @@ -61,8 +64,10 @@ describe('portfolioApiMaker', () => { await api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id) await api.tokenInfos(mockTokenIdsWithCache) + await api.tokenTraits(tokenMocks.nftCryptoKitty.info.id) + + expect(mockRequest).toHaveBeenCalledTimes(3) - expect(mockRequest).toHaveBeenCalledTimes(2) expect(mockRequest).toHaveBeenCalledWith({ method: 'get', url: @@ -83,6 +88,17 @@ describe('portfolioApiMaker', () => { 'Content-Type': 'application/json', }, }) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'get', + url: + apiConfig[Chain.Network.Mainnet].tokenTraits + + '/' + + tokenMocks.nftCryptoKitty.info.id, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) }) it('should return error when returning data is malformed', async () => { @@ -108,8 +124,9 @@ describe('portfolioApiMaker', () => { 'token.id:etag-hash', ] - await api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id) - + const resultDiscovery = await api.tokenDiscovery( + tokenDiscoveryMocks.nftCryptoKitty.id, + ) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ method: 'get', @@ -122,8 +139,18 @@ describe('portfolioApiMaker', () => { 'Content-Type': 'application/json', }, }) + expect(resultDiscovery).toEqual({ + tag: 'left', + error: { + status: -3, + message: 'Failed to transform token discovery response', + responseData: { + ['wrong']: 'data', + }, + }, + }) - const result = await api.tokenInfos(mockTokenIdsWithCache) + const resultTokenInfos = await api.tokenInfos(mockTokenIdsWithCache) expect(mockRequest).toHaveBeenCalledTimes(2) expect(mockRequest).toHaveBeenCalledWith({ method: 'post', @@ -135,7 +162,7 @@ describe('portfolioApiMaker', () => { }, }) - expect(result).toEqual({ + expect(resultTokenInfos).toEqual({ tag: 'left', error: { status: -3, @@ -145,6 +172,32 @@ describe('portfolioApiMaker', () => { }, }, }) + + const resultTraits = await api.tokenTraits( + tokenMocks.nftCryptoKitty.info.id, + ) + expect(mockRequest).toHaveBeenCalledTimes(3) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'get', + url: + apiConfig[Chain.Network.Mainnet].tokenTraits + + '/' + + tokenMocks.nftCryptoKitty.info.id, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) + expect(resultTraits).toEqual({ + tag: 'left', + error: { + status: -3, + message: 'Failed to transform token traits response', + responseData: { + ['wrong']: 'data', + }, + }, + }) }) it('should return the error and not throw', async () => { @@ -169,8 +222,14 @@ describe('portfolioApiMaker', () => { 'token.id:etag-hash', ] - await api.tokenInfos(mockTokenIdsWithCache) - + await expect(api.tokenInfos(mockTokenIdsWithCache)).resolves.toEqual({ + tag: 'left', + value: { + status: 500, + message: 'Internal Server Error', + responseData: {}, + }, + }) expect(mockRequest).toHaveBeenCalledTimes(1) expect(mockRequest).toHaveBeenCalledWith({ method: 'post', @@ -182,7 +241,16 @@ describe('portfolioApiMaker', () => { }, }) - await api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id) + await expect( + api.tokenDiscovery(tokenDiscoveryMocks.nftCryptoKitty.id), + ).resolves.toEqual({ + tag: 'left', + value: { + status: 500, + message: 'Internal Server Error', + responseData: {}, + }, + }) expect(mockRequest).toHaveBeenCalledTimes(2) expect(mockRequest).toHaveBeenCalledWith({ method: 'get', @@ -195,6 +263,66 @@ describe('portfolioApiMaker', () => { 'Content-Type': 'application/json', }, }) + + await expect( + api.tokenTraits(tokenMocks.nftCryptoKitty.info.id), + ).resolves.toEqual({ + tag: 'left', + value: { + status: 500, + message: 'Internal Server Error', + responseData: {}, + }, + }) + expect(mockRequest).toHaveBeenCalledTimes(3) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'get', + url: + apiConfig[Chain.Network.Mainnet].tokenTraits + + '/' + + tokenMocks.nftCryptoKitty.info.id, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) + }) + + it('should return the data on success', async () => { + mockRequest.mockResolvedValue({ + tag: 'right', + value: { + status: 200, + data: tokenMocks.nftCryptoKitty.traits, + }, + }) + const api = portfolioApiMaker({ + network: mockNetwork, + request: mockRequest, + maxIdsPerRequest: 10, + maxConcurrentRequests: 10, + }) + + const result = await api.tokenTraits(tokenMocks.nftCryptoKitty.info.id) + expect(mockRequest).toHaveBeenCalledTimes(1) + expect(mockRequest).toHaveBeenCalledWith({ + method: 'get', + url: + apiConfig[Chain.Network.Mainnet].tokenTraits + + '/' + + tokenMocks.nftCryptoKitty.info.id, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + }) + expect(result).toEqual({ + tag: 'right', + value: { + status: 200, + data: tokenMocks.nftCryptoKitty.traits, + }, + }) }) it('should return error when returning data is malformed token-discovery', async () => { diff --git a/packages/portfolio/src/adapters/dullahan-api/api-maker.ts b/packages/portfolio/src/adapters/dullahan-api/api-maker.ts index 5f235f34f9..e8ff0fb1d1 100644 --- a/packages/portfolio/src/adapters/dullahan-api/api-maker.ts +++ b/packages/portfolio/src/adapters/dullahan-api/api-maker.ts @@ -14,8 +14,10 @@ import { DullahanApiCachedIdsRequest, DullahanApiTokenDiscoveryResponse, DullahanApiTokenInfosResponse, + DullahanApiTokenTraitsResponse, } from './types' import {parseTokenDiscovery} from '../../validators/token-discovery' +import {parseTokenTraits} from '../../validators/token-traits' export const portfolioApiMaker = ({ network, @@ -134,6 +136,48 @@ export const portfolioApiMaker = ({ ) } }, + async tokenTraits(id) { + const response = await request({ + method: 'get', + url: `${config.tokenTraits}/${id}`, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + }) + if (isRight(response)) { + const traits: Portfolio.Token.Traits | undefined = parseTokenTraits( + response.value.data, + ) + + if (!traits) { + return freeze( + { + tag: 'left', + error: { + status: -3, + message: 'Failed to transform token traits response', + responseData: response.value.data, + }, + }, + true, + ) + } + + return freeze( + { + tag: 'right', + value: { + status: response.value.status, + data: traits, + }, + }, + true, + ) + } + + return response + }, }, true, ) @@ -142,22 +186,25 @@ export const portfolioApiMaker = ({ export const apiConfig: ApiConfig = freeze( { mainnet: { - tokenDiscovery: - 'https://dev-yoroi-backend-zero-mainnet.emurgornd.com/tokens/discovery', - tokenInfos: - 'https://dev-yoroi-backend-zero-mainnet.emurgornd.com/tokens/info/multi', + tokenDiscovery: 'https://zero.yoroiwallet.com/tokens/discovery', + tokenInfos: 'https://zero.yoroiwallet.com/tokens/info/multi', + tokenTraits: 'https://zero.yoroiwallet.com/tokens/nft/traits', }, preprod: { tokenDiscovery: 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/discovery', tokenInfos: 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/info/multi', + tokenTraits: + 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/nft/traits', }, sancho: { tokenDiscovery: 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/discovery', tokenInfos: 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/info/multi', + tokenTraits: + 'https://dev-yoroi-backend-zero-preprod.emurgornd.com/tokens/nft/traits', }, }, true, diff --git a/packages/portfolio/src/adapters/dullahan-api/types.ts b/packages/portfolio/src/adapters/dullahan-api/types.ts index 2845f50df7..f76b02b238 100644 --- a/packages/portfolio/src/adapters/dullahan-api/types.ts +++ b/packages/portfolio/src/adapters/dullahan-api/types.ts @@ -6,6 +6,13 @@ export type DullahanApiTokenInfosResponse = Readonly<{ > }> +export type DullahanApiTokenTraitsResponse = Readonly< + Portfolio.Token.Traits & { + collection: string + name: string + } +> + export type DullahanTokenDiscovery = Omit< Portfolio.Token.Discovery, 'supply' diff --git a/packages/portfolio/src/adapters/token-traits.mocks.ts b/packages/portfolio/src/adapters/token-traits.mocks.ts new file mode 100644 index 0000000000..95a1477d56 --- /dev/null +++ b/packages/portfolio/src/adapters/token-traits.mocks.ts @@ -0,0 +1,48 @@ +import {Api, Portfolio} from '@yoroi/types' +import {freeze} from 'immer' + +const nftCryptoKitty: Portfolio.Token.Traits = { + totalItems: 1, + traits: [ + {type: 'rarity', value: 'common', rarity: 'common'}, + {type: 'color', value: 'blue', rarity: 'common'}, + ], +} + +const rnftWhatever: Portfolio.Token.Traits = { + totalItems: 1, + traits: [ + {type: 'rarity', value: 'rare', rarity: 'rare'}, + {type: 'color', value: 'red', rarity: 'rare'}, + ], +} + +const apiResponseTokenTraits: Readonly< + Record<'success' | 'error', Api.Response> +> = freeze( + { + success: { + tag: 'right', + value: { + status: 200, + data: nftCryptoKitty, + }, + }, + error: { + tag: 'left', + error: { + status: 404, + responseData: null, + message: 'Not found', + }, + }, + }, + true, +) + +export const tokenTraitsMocks = freeze({ + nftCryptoKitty, + rnftWhatever, + + apiResponse: apiResponseTokenTraits, +}) diff --git a/packages/portfolio/src/adapters/token.mocks.ts b/packages/portfolio/src/adapters/token.mocks.ts index dce4859abd..b3470fbaec 100644 --- a/packages/portfolio/src/adapters/token.mocks.ts +++ b/packages/portfolio/src/adapters/token.mocks.ts @@ -3,6 +3,7 @@ import {freeze} from 'immer' import * as infos from './token-info.mocks' import * as discoveries from './token-discovery.mocks' import * as balances from './token-balance.mocks' +import * as traits from './token-traits.mocks' export const tokenMocks = freeze( { @@ -10,6 +11,7 @@ export const tokenMocks = freeze( info: infos.tokenInfoMocks.nftCryptoKitty, discovery: discoveries.tokenDiscoveryMocks.nftCryptoKitty, balance: balances.tokenBalanceMocks.nftCryptoKitty, + traits: traits.tokenTraitsMocks.nftCryptoKitty, }, primaryETH: { info: infos.tokenInfoMocks.primaryETH, @@ -21,6 +23,7 @@ export const tokenMocks = freeze( info: infos.tokenInfoMocks.rnftWhatever, discovery: discoveries.tokenDiscoveryMocks.rnftWhatever, balance: balances.tokenBalanceMocks.rnftWhatever, + traits: traits.tokenTraitsMocks.rnftWhatever, }, apiResponse: { tokenInfos: infos.tokenInfoMocks.apiResponseResult, diff --git a/packages/portfolio/src/balance-manager.test.ts b/packages/portfolio/src/balance-manager.test.ts index db674f39f1..7d31fb583a 100644 --- a/packages/portfolio/src/balance-manager.test.ts +++ b/packages/portfolio/src/balance-manager.test.ts @@ -48,6 +48,7 @@ describe('portfolioBalanceManagerMaker', () => { api: { tokenInfos: jest.fn(), tokenDiscovery: jest.fn(), + tokenTraits: jest.fn(), }, } @@ -92,6 +93,7 @@ describe('hydrate', () => { api: { tokenInfos: jest.fn(), tokenDiscovery: jest.fn(), + tokenTraits: jest.fn(), }, } @@ -178,6 +180,7 @@ describe('destroy', () => { api: { tokenInfos: jest.fn(), tokenDiscovery: jest.fn(), + tokenTraits: jest.fn(), }, } const queueDestroy = jest.fn() @@ -258,6 +261,7 @@ describe('primary updates', () => { api: { tokenInfos: jest.fn(), tokenDiscovery: jest.fn(), + tokenTraits: jest.fn(), }, } @@ -413,6 +417,7 @@ describe('sync & refresh', () => { api: { tokenInfos: jest.fn(), tokenDiscovery: jest.fn(), + tokenTraits: jest.fn(), }, } @@ -701,6 +706,7 @@ describe('clear', () => { api: { tokenInfos: jest.fn(), tokenDiscovery: jest.fn(), + tokenTraits: jest.fn(), }, } diff --git a/packages/portfolio/src/index.ts b/packages/portfolio/src/index.ts index ff8c0c81a7..3409c1dce8 100644 --- a/packages/portfolio/src/index.ts +++ b/packages/portfolio/src/index.ts @@ -33,8 +33,10 @@ export * from './validators/token-property-type' export * from './validators/token-source' export * from './validators/token-status' export * from './validators/token-type' +export * from './validators/token-traits' export * from './translators/reactjs/usePortfolioTokenDiscovery' +export * from './translators/reactjs/usePortfolioTokenTraits' export * from './balance-manager' export * from './token-manager' diff --git a/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.test.tsx b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.test.tsx new file mode 100644 index 0000000000..c9b8d9dec1 --- /dev/null +++ b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.test.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import {QueryClient} from 'react-query' +import {Text, View} from 'react-native' +import {render, waitFor} from '@testing-library/react-native' +import {queryClientFixture} from '@yoroi/common' +import {Chain} from '@yoroi/types' + +import {usePorfolioTokenTraits} from './usePortfolioTokenTraits' +import {wrapperMaker} from '../../fixtures/wrapperMaker' +import {tokenMocks} from '../../adapters/token.mocks' +import {tokenTraitsMocks} from '../../adapters/token-traits.mocks' + +describe('usePortfolioTokenTraits', () => { + let queryClient: QueryClient + + beforeEach(() => { + jest.clearAllMocks() + queryClient = queryClientFixture() + }) + + afterEach(() => { + queryClient.clear() + }) + + it('success', async () => { + const mockedGetTokenTraits = jest + .fn() + .mockResolvedValue(tokenTraitsMocks.apiResponse.success) + + const TestComponent = () => { + const {data} = usePorfolioTokenTraits( + { + id: tokenMocks.nftCryptoKitty.info.id, + network: Chain.Network.Mainnet, + getTokenTraits: mockedGetTokenTraits, + }, + { + suspense: true, + }, + ) + return ( + + {JSON.stringify(data?.totalItems)} + + ) + } + const wrapper = wrapperMaker({ + queryClient, + }) + const {getByTestId} = render(, {wrapper}) + + await waitFor(() => { + expect(getByTestId('data')).toBeDefined() + }) + + expect(getByTestId('data').props.children).toEqual(JSON.stringify(1)) + expect(mockedGetTokenTraits).toHaveBeenCalledWith( + tokenMocks.nftCryptoKitty.info.id, + ) + }) + + it('error', async () => { + const mockedGetTokenTraits = jest + .fn() + .mockResolvedValue(tokenTraitsMocks.apiResponse.error) + + const TestComponent = () => { + const {data} = usePorfolioTokenTraits( + { + id: tokenMocks.nftCryptoKitty.info.id, + network: Chain.Network.Mainnet, + getTokenTraits: mockedGetTokenTraits, + }, + { + suspense: true, + }, + ) + return ( + + {JSON.stringify(data?.totalItems)} + + ) + } + const wrapper = wrapperMaker({ + queryClient, + }) + const {getByTestId} = render(, {wrapper}) + + await waitFor(() => { + expect(getByTestId('hasError')).toBeDefined() + }) + }) +}) diff --git a/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.tsx b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.tsx new file mode 100644 index 0000000000..047ac43251 --- /dev/null +++ b/packages/portfolio/src/translators/reactjs/usePortfolioTokenTraits.tsx @@ -0,0 +1,36 @@ +import {isRight} from '@yoroi/common' +import {Chain, Portfolio} from '@yoroi/types' +import {UseQueryOptions, useQuery} from 'react-query' + +export function usePorfolioTokenTraits( + { + id, + getTokenTraits, + network, + }: { + id: Portfolio.Token.Id + getTokenTraits: Portfolio.Api.Api['tokenTraits'] + network: Chain.SupportedNetworks + }, + options?: UseQueryOptions< + Portfolio.Token.Traits, + Error, + Portfolio.Token.Traits, + [Chain.SupportedNetworks, 'usePorfolioTokenTraits', Portfolio.Token.Id] + >, +) { + const query = useQuery({ + queryKey: [network, 'usePorfolioTokenTraits', id], + ...options, + queryFn: async () => { + const response = await getTokenTraits(id) + if (isRight(response)) return response.value.data + throw new Error('usePorfolioTokenTraits') + }, + }) + + return { + ...query, + tokenTraits: query.data, + } +} diff --git a/packages/portfolio/src/validators/token-traits.test.ts b/packages/portfolio/src/validators/token-traits.test.ts new file mode 100644 index 0000000000..76b5fe8fa5 --- /dev/null +++ b/packages/portfolio/src/validators/token-traits.test.ts @@ -0,0 +1,87 @@ +import {isTokenTraits, parseTokenTraits} from './token-traits' + +describe('Token Traits Validator', () => { + describe('isTokenTraits', () => { + it('should return true for a valid token traits object', () => { + const validTokenTraits = { + totalItems: 5, + traits: [ + { + type: 'type1', + value: 'value1', + rarity: 'rarity1', + }, + { + type: 'type2', + value: 'value2', + rarity: 'rarity2', + }, + ], + } + + expect(isTokenTraits(validTokenTraits)).toBe(true) + }) + + it('should return false for an invalid token traits object', () => { + const invalidTokenTraits = { + totalItems: '5', + traits: [ + { + type: 'type1', + value: 'value1', + rarity: 'rarity1', + }, + { + type: 'type2', + value: 'value2', + rarity: 'rarity2', + }, + ], + } + + expect(isTokenTraits(invalidTokenTraits)).toBe(false) + }) + }) + + describe('parseTokenTraits', () => { + it('should return the token traits object if it is valid', () => { + const validTokenTraits = { + totalItems: 5, + traits: [ + { + type: 'type1', + value: 'value1', + rarity: 'rarity1', + }, + { + type: 'type2', + value: 'value2', + rarity: 'rarity2', + }, + ], + } + + expect(parseTokenTraits(validTokenTraits)).toEqual(validTokenTraits) + }) + + it('should return undefined if the token traits object is invalid', () => { + const invalidTokenTraits = { + totalItems: '5', + traits: [ + { + type: 'type1', + value: 'value1', + rarity: 'rarity1', + }, + { + type: 'type2', + value: 'value2', + rarity: 'rarity2', + }, + ], + } + + expect(parseTokenTraits(invalidTokenTraits)).toBeUndefined() + }) + }) +}) diff --git a/packages/portfolio/src/validators/token-traits.ts b/packages/portfolio/src/validators/token-traits.ts new file mode 100644 index 0000000000..a305ca132b --- /dev/null +++ b/packages/portfolio/src/validators/token-traits.ts @@ -0,0 +1,25 @@ +import {Portfolio} from '@yoroi/types' +import {z} from 'zod' + +export const TraitSchema = z.object({ + type: z.string(), + value: z.string(), + rarity: z.string(), +}) + +const TraitsSchema = z.object({ + totalItems: z.number().nonnegative(), + traits: z.array(TraitSchema), + // useful only if client doen't have the info + // collection: z.string().optional(), + // name: z.string().optional(), +}) + +export const isTokenTraits = (data: unknown): data is Portfolio.Token.Traits => + TraitsSchema.safeParse(data).success + +export const parseTokenTraits = ( + data: unknown, +): Portfolio.Token.Traits | undefined => { + return isTokenTraits(data) ? data : undefined +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 9ae57511b1..0baf2e96ba 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -171,6 +171,7 @@ import { PortfolioApi, PortfolioApiTokenDiscoveryResponse, PortfolioApiTokenInfosResponse, + PortfolioApiTokenTraitsResponse, } from './portfolio/api' import { PortfolioEventBalanceManager, @@ -198,6 +199,7 @@ import { import {AppQueueTask, AppQueueTaskManager} from './app/queue-task-manager' import {ExplorersManager} from './explorers/manager' import {ExplorersExplorer} from './explorers/explorer' +import {PortfolioTokenTraits} from './portfolio/traits' export namespace App { export namespace Errors { @@ -454,6 +456,7 @@ export namespace Portfolio { export namespace Api { export type TokenInfosResponse = PortfolioApiTokenInfosResponse export type TokenDiscoveryResponse = PortfolioApiTokenDiscoveryResponse + export type TokenTraitsResponse = PortfolioApiTokenTraitsResponse export type Api = PortfolioApi } @@ -468,6 +471,7 @@ export namespace Portfolio { } export namespace Token { + export type Traits = PortfolioTokenTraits export type Balances = PortfolioTokenBalances export type Amount = PortfolioTokenAmount export type AmountRecords = PortfolioTokenAmountRecords diff --git a/packages/types/src/portfolio/api.ts b/packages/types/src/portfolio/api.ts index 706cfdf323..80a51264f8 100644 --- a/packages/types/src/portfolio/api.ts +++ b/packages/types/src/portfolio/api.ts @@ -6,6 +6,7 @@ import {ApiResponse} from '../api/response' import {PortfolioTokenDiscovery} from './discovery' import {PortfolioTokenInfo} from './info' import {PortfolioTokenId} from './token' +import {PortfolioTokenTraits} from './traits' export type PortfolioApiTokenDiscoveryResponse = PortfolioTokenDiscovery @@ -13,6 +14,8 @@ export type PortfolioApiTokenInfosResponse = { [key: PortfolioTokenId]: ApiResponseRecordWithCache } +export type PortfolioApiTokenTraitsResponse = PortfolioTokenTraits + export type PortfolioApi = Readonly<{ tokenInfos( idsWithETag: ReadonlyArray>, @@ -20,4 +23,7 @@ export type PortfolioApi = Readonly<{ tokenDiscovery( id: PortfolioTokenId, ): Promise>> + tokenTraits( + id: PortfolioTokenId, + ): Promise>> }> diff --git a/packages/types/src/portfolio/traits.ts b/packages/types/src/portfolio/traits.ts new file mode 100644 index 0000000000..7f41651caa --- /dev/null +++ b/packages/types/src/portfolio/traits.ts @@ -0,0 +1,10 @@ +export type PortfolioTokenTrait = { + type: string + value: string + rarity: string +} + +export type PortfolioTokenTraits = { + totalItems: number + traits: Array +}