diff --git a/CHANGELOG.md b/CHANGELOG.md index 2498c43..4ae2d11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [[2.1.3](https://github.com/multiversx/mx-sdk-dapp-swap/pull/41)] - 2024-12-04 + +- [Progressive Fetching for Tokens](https://github.com/multiversx/mx-sdk-dapp-swap/pull/40) + ## [[2.1.2](https://github.com/multiversx/mx-sdk-dapp-swap/pull/39)] - 2024-11-12 - [Removed onlySafeTokens](https://github.com/multiversx/mx-sdk-dapp-swap/pull/39) diff --git a/package.json b/package.json index cbd1bc6..bc08ab0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@multiversx/sdk-dapp-swap", - "version": "2.1.2", + "version": "2.1.3", "description": "A library to hold the main logic for swapping between tokens on the MultiversX blockchain", "author": "MultiversX", "license": "GPL-3.0-or-later", diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4b8741f..72429f9 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -5,9 +5,10 @@ export * from './useSwapRoute'; export * from './useUnwrapEgld'; export * from './useQueryWrapper'; export * from './useIsPageVisible'; +export * from './useFilteredTokens'; export * from './useRateCalculator'; export * from './useLazyQueryWrapper'; export * from './useSwapFormHandlers'; export * from './useInputAmountUsdValue'; export * from './useFetchMaintenanceFlag'; - +export * from './useIntersectionObserver'; diff --git a/src/hooks/useFilteredTokens.ts b/src/hooks/useFilteredTokens.ts new file mode 100644 index 0000000..932c565 --- /dev/null +++ b/src/hooks/useFilteredTokens.ts @@ -0,0 +1,197 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useAuthorizationContext } from 'components/SwapAuthorizationProvider'; +import { + FilteredTokensQueryType, + GET_FILTERED_TOKENS, + GET_FILTERED_TOKENS_AND_BALANCE +} from 'queries'; +import { + EsdtType, + FactoryType, + TokensPaginationType, + UserEsdtType +} from 'types'; +import { getSortedTokensByUsdValue, mergeTokens } from 'utils'; +import { useFetchTokenPrices } from './useFetchTokenPrices'; +import { useIntersectionObserver } from './useIntersectionObserver'; +import { useLazyQueryWrapper } from './useLazyQueryWrapper'; + +const DEFAULT_OFFSET = 0; +const DEFAULT_LIMIT = 500; +const DEFAULT_ENABLED_SWAPS = true; +const DEFAULT_PRICE_POLLING = false; +const DEFAULT_IDENTIFIERS: string[] = []; +const DEFAULT_PAGINATION: TokensPaginationType = { first: 20, after: '' }; +const DEFAULT_SEARCH_INPUT = ''; + +interface GetTokensType { + limit?: number; + offset?: number; + identifiers?: string[]; + enabledSwaps?: boolean; + pagination?: TokensPaginationType; + searchInput?: string; +} + +interface UseTokensType { + pricePolling?: boolean; + observerId?: string; + searchInput?: string; + identifiers?: string[]; +} + +export const useFilteredTokens = (options?: UseTokensType) => { + const isInitialLoad = useRef(true); + const { client, isAuthenticated } = useAuthorizationContext(); + + if (!client) { + throw new Error('Swap GraphQL client not initialized'); + } + + const pricePolling = options?.pricePolling ?? DEFAULT_PRICE_POLLING; + const searchInput = options?.searchInput; + + const [pagination, setPagination] = useState({ + first: 20, + after: '' + }); + const [hasMore, setHasMore] = useState(true); + const [currentCursor, setCurrentCursor] = useState(); + const [loadedCursors, setLoadedCursors] = useState>(new Set()); + + const [tokens, setTokens] = useState([]); + const [wrappedEgld, setWrappedEgld] = useState(); + const [swapConfig, setSwapConfig] = useState(); + const [tokensCount, setTokensCount] = useState(); + let ignoreNextHasMore = false; + + const { tokenPrices } = useFetchTokenPrices({ + isPollingEnabled: pricePolling + }); + + const handleOnCompleted = (data?: FilteredTokensQueryType | null) => { + if (!data) return; + + const { wrappingInfo, userTokens, factory, filteredTokens } = data; + const { edges, pageInfo, pageData } = filteredTokens; + + setTokensCount(pageData?.count); + if (factory) setSwapConfig(factory); + + const newWrappedEgld = + wrappingInfo && wrappingInfo.length + ? wrappingInfo[0].wrappedToken + : undefined; + setWrappedEgld(newWrappedEgld); + + if (!edges) return; + + setCurrentCursor(edges[edges.length - 1]?.cursor); + const tokensWithBalance: UserEsdtType[] = edges.map((token) => ({ + ...token.node, + balance: '0', + valueUSD: '0' + })); + + const mergedTokens = mergeTokens(tokensWithBalance, userTokens); + const sortedTokensWithBalance = getSortedTokensByUsdValue({ + tokens: mergedTokens, + wrappedEgld: newWrappedEgld + }); + + setTokens((prevTokens) => mergeTokens(prevTokens, sortedTokensWithBalance)); + + if (!pageInfo?.hasNextPage && !ignoreNextHasMore) { + setHasMore(false); + } else { + ignoreNextHasMore = false; + } + }; + + const { + isError, + isLoading, + execute: getTokensTrigger + } = useLazyQueryWrapper({ + query: isAuthenticated + ? GET_FILTERED_TOKENS_AND_BALANCE + : GET_FILTERED_TOKENS, + queryOptions: { + client, + onCompleted: handleOnCompleted + } + }); + + const getTokens = (options?: GetTokensType, continueFetching?: boolean) => { + const variables = { + limit: options?.limit ?? DEFAULT_LIMIT, + offset: options?.offset ?? DEFAULT_OFFSET, + identifiers: options?.identifiers ?? DEFAULT_IDENTIFIERS, + enabledSwaps: options?.enabledSwaps ?? DEFAULT_ENABLED_SWAPS, + pagination: options?.pagination ?? DEFAULT_PAGINATION, + searchInput: options?.searchInput ?? DEFAULT_SEARCH_INPUT + }; + + getTokensTrigger({ + variables + }); + + if (continueFetching) { + ignoreNextHasMore = true; + } + }; + + const tokensWithUpdatedPrice = useMemo( + () => + tokens.map((token) => { + const tokenPrice = tokenPrices?.find( + ({ identifier }) => identifier === token.identifier + )?.price; + + return { + ...token, + price: tokenPrice ?? token.price + }; + }), + [tokens, tokenPrices] + ); + + useEffect(() => { + if (isInitialLoad.current) { + isInitialLoad.current = false; + return; + } + setPagination({ first: 20, after: '' }); + setLoadedCursors(new Set()); + setHasMore(true); + getTokens({ pagination: { first: 20, after: '' }, searchInput }); + }, [searchInput]); + + useEffect(() => { + if (pagination.after) { + setLoadedCursors((prev) => new Set(prev).add(pagination.after as string)); + getTokens({ pagination, searchInput }); + } + }, [pagination]); + + useIntersectionObserver({ + tokens, + hasMore, + isLoading: isLoading ?? false, + observerId: options?.observerId ?? '', + loadedCursors, + currentCursor: currentCursor ?? '', + setPagination + }); + + return { + swapConfig, + wrappedEgld, + isTokensError: isError, + isTokensLoading: isLoading, + tokens: tokensWithUpdatedPrice, + totalTokensCount: tokensCount, + getTokens, + refetch: getTokensTrigger + }; +}; diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..4a5701e --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,51 @@ +import { Dispatch, SetStateAction, useEffect } from 'react'; +import { TokensPaginationType, UserEsdtType } from 'types'; + +interface UseIntersectionObserverType { + hasMore: boolean; + observerId: string; + isLoading: boolean; + currentCursor: string; + tokens: UserEsdtType[]; + loadedCursors: Set; + setPagination: Dispatch>; +} + +export const useIntersectionObserver = ({ + tokens, + hasMore, + isLoading, + observerId, + loadedCursors, + currentCursor, + setPagination +}: UseIntersectionObserverType) => { + useEffect(() => { + if (!observerId) return; + const element = document.getElementById(observerId); + if (!element) return; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !isLoading) { + // Check if the current cursor is already loaded + if (currentCursor && !loadedCursors.has(currentCursor)) { + const newPagination = { + first: 20, + after: currentCursor + }; + + setPagination(newPagination); + } + } + }, + { threshold: 1.0 } + ); + + observer.observe(element); + + return () => { + observer.disconnect(); + }; + }, [tokens, observerId]); +}; diff --git a/src/queries/tokens/tokens.ts b/src/queries/tokens/tokens.ts index 5a2a88a..02a390c 100644 --- a/src/queries/tokens/tokens.ts +++ b/src/queries/tokens/tokens.ts @@ -1,5 +1,11 @@ import { gql } from '@apollo/client'; -import { EsdtType, FactoryType, UserEsdtType, WrappingInfoType } from 'types'; +import { + EsdtType, + FactoryType, + FilteredTokensType, + UserEsdtType, + WrappingInfoType +} from 'types'; import { esdtAttributes, factoryAttributes, @@ -13,6 +19,13 @@ export interface TokensType { wrappingInfo: WrappingInfoType[]; } +export interface FilteredTokensQueryType { + filteredTokens: FilteredTokensType; + factory: FactoryType; + userTokens?: UserEsdtType[]; + wrappingInfo: WrappingInfoType[]; +} + export const GET_TOKENS = gql` query swapPackageTokens ($identifiers: [String!], $enabledSwaps: Boolean) { tokens(identifiers: $identifiers, enabledSwaps: $enabledSwaps) { @@ -29,6 +42,33 @@ export const GET_TOKENS = gql` } `; +export const GET_FILTERED_TOKENS = gql` +query swapPackageFilteredTokens ($enabledSwaps: Boolean, $pagination: ConnectionArgs, $searchInput: String, $identifiers: [String!]) { + filteredTokens (pagination: $pagination, filters: {searchToken: $searchInput, enabledSwaps: $enabledSwaps, identifiers: $identifiers}) { + edges { + node { + ${esdtAttributes} + } + cursor + } + pageInfo { + hasNextPage + } + pageData { + count + } + } + wrappingInfo { + wrappedToken { + ${esdtAttributes} + } + } + factory { + ${factoryAttributes} + } + } +`; + export const GET_TOKENS_AND_BALANCE = gql` query swapPackageTokensWithBalance ($identifiers: [String!], $offset: Int, $limit: Int, $enabledSwaps: Boolean) { tokens(identifiers: $identifiers, enabledSwaps: $enabledSwaps) { @@ -47,3 +87,33 @@ export const GET_TOKENS_AND_BALANCE = gql` } } `; + +export const GET_FILTERED_TOKENS_AND_BALANCE = gql` + query swapPackageFilteredTokensWithBalance ($identifiers: [String!], $pagination: ConnectionArgs, $searchInput: String, $offset: Int, $limit: Int, $enabledSwaps: Boolean) { + filteredTokens (pagination: $pagination, filters: {searchToken: $searchInput, enabledSwaps: $enabledSwaps, identifiers: $identifiers}) { + edges { + node { + ${esdtAttributes} + } + cursor + } + pageInfo { + hasNextPage + } + pageData { + count + } + } + userTokens (offset: $offset, limit: $limit) { + ${userEsdtAttributes} + } + wrappingInfo { + wrappedToken { + ${esdtAttributes} + } + } + factory { + ${factoryAttributes} + } + } +`; diff --git a/src/types/swap.types.ts b/src/types/swap.types.ts index 70d5e4d..8235377 100644 --- a/src/types/swap.types.ts +++ b/src/types/swap.types.ts @@ -1,5 +1,6 @@ import { RawTransactionType } from '@multiversx/sdk-dapp/types/transactions.types'; import { PairType } from './pairs.types'; +import { UserEsdtType } from './tokens.types'; export interface SwapRouteType { amountIn: string; @@ -36,3 +37,27 @@ export interface SwapFeeDetailsType { burn?: number; lpHolders?: number; } + +export interface TokenType { + node: UserEsdtType; + cursor: string; +} + +export interface FilteredTokensType { + edges?: TokenType[]; + pageData?: EsdtPageDataType; + pageInfo?: EsdtPageInfoType; +} + +export interface EsdtPageInfoType { + startCursor?: string; + endCursor?: string; + hasPreviousPage: boolean; + hasNextPage: boolean; +} + +export interface EsdtPageDataType { + count: number; + limit: number; + offset: number; +} diff --git a/src/types/tokens.types.ts b/src/types/tokens.types.ts index 98309f4..549dad3 100644 --- a/src/types/tokens.types.ts +++ b/src/types/tokens.types.ts @@ -36,3 +36,10 @@ export interface UserEsdtType extends EsdtType { export interface WrappingInfoType { wrappedToken: EsdtType; } + +export interface TokensPaginationType { + before?: string; + after?: string; + first?: number; + last?: number; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index badc293..0cdeab4 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,15 +1,16 @@ -export * from './calculateMinimumReceived'; -export * from './calculateSwapTransactionsFee'; +export * from './roundAmount'; +export * from './removeCommas'; export * from './canParseAmount'; -export * from './createTransactionFromRaw'; -export * from './getBalanceMinusDust'; -export * from './getPairFeeDetails'; -export * from './getSortedTokensByUsdValue'; -export * from './getSwapActionType'; +export * from './mergeTokenArrays'; export * from './getTokenDecimals'; +export * from './getPairFeeDetails'; export * from './getTransactionFee'; -export * from './meaningfulFormatAmount'; -export * from './removeCommas'; -export * from './roundAmount'; +export * from './getSwapActionType'; export * from './translateSwapError'; +export * from './getBalanceMinusDust'; +export * from './meaningfulFormatAmount'; +export * from './calculateMinimumReceived'; +export * from './createTransactionFromRaw'; +export * from './getSortedTokensByUsdValue'; +export * from './calculateSwapTransactionsFee'; export * from './getCorrectAmountsOnTokenChange'; diff --git a/src/utils/mergeTokenArrays.ts b/src/utils/mergeTokenArrays.ts new file mode 100644 index 0000000..3a4eead --- /dev/null +++ b/src/utils/mergeTokenArrays.ts @@ -0,0 +1,35 @@ +import { UserEsdtType } from 'types'; + +export const mergeTokens = ( + tokens: UserEsdtType[] | undefined, + userTokens: UserEsdtType[] | undefined +): UserEsdtType[] => { + const tokensMap = new Map(); + + tokens?.forEach((token) => { + tokensMap.set(token.identifier, { + ...token, + balance: '0', // Default balance + valueUSD: '0' // Default valueUSD + }); + }); + + userTokens?.forEach((userToken) => { + const existingToken = tokensMap.get(userToken.identifier); + + if (existingToken) { + tokensMap.set(userToken.identifier, { + ...existingToken, + balance: userToken.balance ?? '0', + valueUSD: userToken.valueUSD ?? '0' + }); + } else { + // Filter out LP tokens + if (userToken.type !== 'FungibleESDT-LP') { + tokensMap.set(userToken.identifier, userToken); + } + } + }); + + return Array.from(tokensMap.values()); +};