Changeset View
Changeset View
Standalone View
Standalone View
cashtab/src/components/Agora/index.tsx
| // Copyright (c) 2024 The Bitcoin developers | // Copyright (c) 2024 The Bitcoin developers | ||||
| // Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||
| // file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||
| import React, { useRef, useState, useEffect, useContext } from 'react'; | import React, { useState, useEffect, useContext } from 'react'; | ||||
| import { Link } from 'react-router'; | |||||
| import { WalletContext, isWalletContextLoaded } from 'wallet/context'; | import { WalletContext, isWalletContextLoaded } from 'wallet/context'; | ||||
| import { toHex } from 'ecash-lib'; | import { toHex } from 'ecash-lib'; | ||||
| import { Alert } from 'components/Common/Atoms'; | import { Alert } from 'components/Common/Atoms'; | ||||
| import Spinner from 'components/Common/Spinner'; | import Spinner from 'components/Common/Spinner'; | ||||
| import { getTokenGenesisInfo } from 'chronik'; | import { getTokenGenesisInfo } from 'chronik'; | ||||
| import { | import { | ||||
| ActiveOffers, | ActiveOffers, | ||||
| OfferTitle, | StatusText, | ||||
| OfferTable, | TokenList, | ||||
| AgoraHeader, | TokenRow, | ||||
| SortSwitch, | TokenRowName, | ||||
| ManageSwitch, | TokenRowTicker, | ||||
| SectionTitle, | |||||
| Wrapper, | |||||
| } from './styled'; | } from './styled'; | ||||
| import { getUserLocale } from 'helpers'; | |||||
| import OrderBook, { OrderBookInfo } from './OrderBook'; | |||||
| import { token as tokenConfig } from 'config/token'; | import { token as tokenConfig } from 'config/token'; | ||||
| import TokenIcon from 'components/Etokens/TokenIcon'; | |||||
| import CashtabCache, { CashtabCachedTokenInfo } from 'config/CashtabCache'; | import CashtabCache, { CashtabCachedTokenInfo } from 'config/CashtabCache'; | ||||
| import { InlineLoader } from 'components/Common/Spinner'; | import { InlineLoader } from 'components/Common/Spinner'; | ||||
| import PrimaryButton from 'components/Common/Buttons'; | import PrimaryButton, { SecondaryButton } from 'components/Common/Buttons'; | ||||
| import Modal from 'components/Common/Modal'; | import Modal from 'components/Common/Modal'; | ||||
| import { FIRMA } from 'constants/tokens'; | import ActionButtonRow from 'components/Common/ActionButtonRow'; | ||||
| interface CashtabActiveOffers { | |||||
| offeredFungibleTokenIds: string[]; | |||||
| offeredFungibleTokenIdsThisWallet: string[]; | |||||
| } | |||||
| interface ServerBlacklistResponse { | interface ServerBlacklistResponse { | ||||
| status: string; | status: string; | ||||
| tokenIds: string[]; | tokenIds: string[]; | ||||
| } | } | ||||
| const askPolitelyForTokenInfo = async ( | const askPolitelyForTokenInfo = async ( | ||||
| promises: Promise<void>[], | promises: Promise<void>[], | ||||
| requestLimit: number, | requestLimit: number, | ||||
| intervalMs: number, | intervalMs: number, | ||||
| ) => { | ) => { | ||||
| if (!Array.isArray(promises) || promises.length === 0) { | if (!Array.isArray(promises) || promises.length === 0) { | ||||
| return; | return; | ||||
| } | } | ||||
| const requests = promises.length; | const batchSize = Math.max(1, requestLimit); | ||||
| const batchSize = Math.floor(requests / requestLimit); | for (let i = 0; i < promises.length; i += batchSize) { | ||||
| const batchCount = Math.floor(requests / batchSize) + 1; | const thisBatch = promises.slice(i, i + batchSize); | ||||
| for (let i = 0; i < batchCount; i++) { | await Promise.allSettled(thisBatch); | ||||
| const batchStart = i * batchSize; | if (i + batchSize < promises.length) { | ||||
| const thisBatch = | |||||
| i === batchCount - 1 | |||||
| ? // The last batch is whatever is left in the array | |||||
| promises.slice(batchStart) | |||||
| : // Other batches are batchsize entries starting from i | |||||
| promises.slice(batchStart, batchStart + batchSize); | |||||
| await Promise.all(thisBatch); | |||||
| // Wait intervalMs before asking again | |||||
| await new Promise(resolve => setTimeout(resolve, intervalMs)); | await new Promise(resolve => setTimeout(resolve, intervalMs)); | ||||
| } | } | ||||
| } | |||||
| }; | }; | ||||
| // Params for batching requests to chronik on the Agora screen | // Params for batching requests to chronik on the Agora screen | ||||
| const POLITE_REQUEST_LIMIT = 25; | const POLITE_REQUEST_LIMIT = 25; | ||||
| const POLITE_INTERVAL_MS = 5000; | const POLITE_INTERVAL_MS = 5000; | ||||
| const Agora: React.FC = () => { | const Agora: React.FC = () => { | ||||
| const userLocale = getUserLocale(navigator); | |||||
| const ContextValue = useContext(WalletContext); | const ContextValue = useContext(WalletContext); | ||||
| if (!isWalletContextLoaded(ContextValue)) { | if (!isWalletContextLoaded(ContextValue)) { | ||||
| // Confirm we have all context required to load the page | // Confirm we have all context required to load the page | ||||
| return null; | return null; | ||||
| } | } | ||||
| const { chronik, agora, cashtabState, updateCashtabState, ecashWallet } = | const { chronik, agora, cashtabState, updateCashtabState, ecashWallet } = | ||||
| ContextValue; | ContextValue; | ||||
| const { cashtabCache } = cashtabState; | const { cashtabCache } = cashtabState; | ||||
| if (!ecashWallet) { | if (!ecashWallet) { | ||||
| return null; | return null; | ||||
| } | } | ||||
| // Note that wallets must be a non-empty array of CashtabWallet[] here, because | // Note that wallets must be a non-empty array of CashtabWallet[] here, because | ||||
| // context is loaded, and App component only renders Onboarding screen if user has no wallet | // context is loaded, and App component only renders Onboarding screen if user has no wallet | ||||
| const pk = toHex(ecashWallet.pk); | const pk = toHex(ecashWallet.pk); | ||||
| // Use a state param to keep track of how many orderbooks we load at once | // Use a state param to keep track of how many token rows we load at once (when loading all offers) | ||||
| const [loadedOrderBooksCount, setLoadedOrderBooksCount] = | const [loadedOrderBooksCount, setLoadedOrderBooksCount] = | ||||
| useState(POLITE_REQUEST_LIMIT); | useState(POLITE_REQUEST_LIMIT); | ||||
| // active agora partial offers organized for rendering this screen | // null means "still loading"; [] means loaded with no listings | ||||
| const [activeOffersCashtab, setActiveOffersCashtab] = | const [myListedTokenIds, setMyListedTokenIds] = useState<null | string[]>( | ||||
| useState<null | CashtabActiveOffers>(null); | null, | ||||
| ); | |||||
| const [chronikQueryError, setChronikQueryError] = useState<boolean>(false); | const [chronikQueryError, setChronikQueryError] = useState<boolean>(false); | ||||
| const [manageMyOffers, setManageMyOffers] = useState<boolean>(false); | |||||
| const orderBookInfoMapRef = useRef<Map<string, OrderBookInfo>>(new Map()); | |||||
| const [allOrderBooksLoaded, setAllOrderBooksLoaded] = | |||||
| useState<boolean>(false); | |||||
| // Agora by default loads a limited whitelist | // Agora by default loads a limited whitelist | ||||
| // But it is possible to load all available offers, if the user is determined | // But it is possible to load all available offers, if the user is determined | ||||
| // and patient | // and patient | ||||
| const [loadAllZeOffers, setLoadAllZeOffers] = useState<boolean>(false); | const [loadAllZeOffers, setLoadAllZeOffers] = useState<boolean>(false); | ||||
| const [isLoadAllPaused, setIsLoadAllPaused] = useState<boolean>(false); | |||||
| const [showConfirmLoadAllModal, setShowConfirmLoadAllModal] = | |||||
| useState<boolean>(false); | |||||
| // State variable to hold the token orderbooks we present to the user | // State variable to hold the token orderbooks we present to the user | ||||
| const [renderedTokenIds, setRenderedTokenIds] = useState<null | string[]>( | const [renderedTokenIds, setRenderedTokenIds] = useState<null | string[]>( | ||||
| null, | null, | ||||
| ); | ); | ||||
| // Boolean to show a confirmation modal for alarming decision to loadAllZeOffers | const [allOfferedTokenIds, setAllOfferedTokenIds] = useState< | ||||
| const [showConfirmLoadAllModal, setShowConfirmLoadAllModal] = | null | string[] | ||||
| useState<boolean>(false); | >(null); | ||||
| // On load, assume all whitelisted tokens have active offers | |||||
| const [whitelistedTokensWithOffers, setWhitelistedTokensWithOffers] = | |||||
| useState<string[]>(tokenConfig.whitelist); | |||||
| interface SortSwitches { | |||||
| byOfferCount: boolean; | |||||
| byTokenId: boolean; | |||||
| } | |||||
| const sortSwitchesOff: SortSwitches = { | |||||
| byOfferCount: false, | |||||
| byTokenId: false, | |||||
| }; | |||||
| // App loads with offers sorted by token ID, as this is the only sort available before | |||||
| // we analyze data available from agora API calls | |||||
| const [switches, setSwitches] = useState<SortSwitches>({ | |||||
| ...sortSwitchesOff, | |||||
| byTokenId: true, | |||||
| }); | |||||
| const sortOrderBooksByOfferCount = () => { | |||||
| if (!orderBookInfoMapRef.current || renderedTokenIds === null) { | |||||
| return; | |||||
| } | |||||
| const sortedTokenIds = [...renderedTokenIds].sort((a, b) => { | |||||
| const offerCountA = | |||||
| orderBookInfoMapRef.current.get(a)?.offerCount || 0; | |||||
| const offerCountB = | |||||
| orderBookInfoMapRef.current.get(b)?.offerCount || 0; | |||||
| return offerCountB - offerCountA; // Sort by descending offer count | |||||
| }); | |||||
| // Update the state with sorted token IDs | |||||
| setRenderedTokenIds(sortedTokenIds); | |||||
| }; | |||||
| const sortOrderBooksByTokenId = () => { | |||||
| if (!orderBookInfoMapRef.current || renderedTokenIds === null) { | |||||
| return; | |||||
| } | |||||
| const sortedTokenIds = [...renderedTokenIds].sort(); | |||||
| // Update the state with sorted token IDs | |||||
| setRenderedTokenIds(sortedTokenIds); | |||||
| }; | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (allOrderBooksLoaded) { | if (!loadAllZeOffers) { | ||||
| if (switches.byOfferCount) { | |||||
| sortOrderBooksByOfferCount(); | |||||
| } else if (switches.byTokenId) { | |||||
| sortOrderBooksByTokenId(); | |||||
| } | |||||
| } | |||||
| }, [allOrderBooksLoaded, switches]); | |||||
| useEffect(() => { | |||||
| if ( | |||||
| activeOffersCashtab === null || | |||||
| allOrderBooksLoaded || | |||||
| !loadAllZeOffers | |||||
| ) { | |||||
| // If we have not yet loaded any offers | |||||
| // Or if we have loaded all the offers | |||||
| // Or if the user is not trying to loadAllZeOffers | |||||
| // Then we do not enter this useEffect | |||||
| return; | return; | ||||
| } | } | ||||
| if (allOfferedTokenIds === null) { | |||||
| const loadMoreOrderBooks = () => { | |||||
| setLoadedOrderBooksCount(prevCount => { | |||||
| const newCount = prevCount + POLITE_REQUEST_LIMIT; | |||||
| // Only increase if there are more to load | |||||
| if ( | |||||
| newCount <= | |||||
| activeOffersCashtab.offeredFungibleTokenIds.length | |||||
| ) { | |||||
| return newCount; | |||||
| } | |||||
| // Clear the interval when all are loaded | |||||
| clearInterval(intervalId); | |||||
| // Use the total when we get there | |||||
| return activeOffersCashtab.offeredFungibleTokenIds.length; | |||||
| }); | |||||
| }; | |||||
| const intervalId = setInterval(loadMoreOrderBooks, POLITE_INTERVAL_MS); | |||||
| // Clean up the interval when component unmounts or when all order books are loaded | |||||
| return () => clearInterval(intervalId); | |||||
| }, [activeOffersCashtab, allOrderBooksLoaded, loadAllZeOffers]); | |||||
| useEffect(() => { | |||||
| if (activeOffersCashtab === null) { | |||||
| return; | return; | ||||
| } | } | ||||
| setRenderedTokenIds(allOfferedTokenIds.slice(0, loadedOrderBooksCount)); | |||||
| // If the user wants to loadAllZeOffers, we do polite loading of activeOffersCashtab.offeredFungibleTokenIds | }, [loadAllZeOffers, allOfferedTokenIds, loadedOrderBooksCount]); | ||||
| // If the user has not manually agreed to loadAllZeOffers, we only show whitelisted tokenIds that have active offers | |||||
| const tokenIdsToRender = loadAllZeOffers | |||||
| ? activeOffersCashtab.offeredFungibleTokenIds.slice( | |||||
| 0, | |||||
| loadedOrderBooksCount, | |||||
| ) | |||||
| : whitelistedTokensWithOffers; | |||||
| setRenderedTokenIds(tokenIdsToRender); | |||||
| }, [ | |||||
| loadAllZeOffers, | |||||
| activeOffersCashtab, | |||||
| loadedOrderBooksCount, | |||||
| whitelistedTokensWithOffers, | |||||
| ]); | |||||
| /** | /** | ||||
| * Specialized helper function to support use of Promise.all in adding new tokens to cache | * Specialized helper function to support use of Promise.all in adding new tokens to cache | ||||
| * While this functionality could be extended to other parts of Cashtab, for now it is | * While this functionality could be extended to other parts of Cashtab, for now it is | ||||
| * only necessary on this screen | * only necessary on this screen | ||||
| * As it is extended, this function should be generalized and refactored out of this screen | * As it is extended, this function should be generalized and refactored out of this screen | ||||
| * Leave it here for now as a model of how to do it. Ensuring the cache (local storage) is properly | * Leave it here for now as a model of how to do it. Ensuring the cache (local storage) is properly | ||||
| * updated with the state may need to be handled differently in a different component | * updated with the state may need to be handled differently in a different component | ||||
| Show All 15 Lines | ): Promise<void> => { | ||||
| }, | }, | ||||
| err => { | err => { | ||||
| reject(err); | reject(err); | ||||
| }, | }, | ||||
| ); | ); | ||||
| }); | }); | ||||
| }; | }; | ||||
| const getListedTokens = async () => { | const getAllListedTokensInBackground = async () => { | ||||
| // Render the whitelist before anything so the screen loads fast | // Get all offered tokens only if user opted in to load all | ||||
| const { whitelist } = tokenConfig; | |||||
| setRenderedTokenIds(whitelist); | |||||
| // Get all offered tokens | |||||
| let offeredFungibleTokenIds: string[]; | let offeredFungibleTokenIds: string[]; | ||||
| try { | try { | ||||
| offeredFungibleTokenIds = await agora.offeredFungibleTokenIds(); | offeredFungibleTokenIds = await agora.offeredFungibleTokenIds(); | ||||
| } catch (err) { | } catch (err) { | ||||
| console.error(`Error getting agora.offeredFungibleTokenIds()`, err); | console.error(`Error getting agora.offeredFungibleTokenIds()`, err); | ||||
| return setChronikQueryError(true); | return setChronikQueryError(true); | ||||
| } | } | ||||
| // Check which tokenIds we need to cache | |||||
| // Note that we get cache info for blacklisted tokenIds | |||||
| const tokenIdsWeNeedToCache = []; | |||||
| for (const tokenId of offeredFungibleTokenIds) { | |||||
| const isCached = | |||||
| typeof cashtabCache.tokens.get(tokenId) !== 'undefined'; | |||||
| if (!isCached) { | |||||
| tokenIdsWeNeedToCache.push(tokenId); | |||||
| } | |||||
| } | |||||
| // Remove unwhitelisted tokens from init whitelist | |||||
| const initRenderedTokens = []; | |||||
| for (const whitelistedTokenId of whitelist) { | |||||
| if (offeredFungibleTokenIds.includes(whitelistedTokenId)) { | |||||
| initRenderedTokens.push(whitelistedTokenId); | |||||
| } | |||||
| } | |||||
| // Handle token caching for initially rendered tokens | |||||
| addUncachedTokensToCacheAndUpdateCache(initRenderedTokens); | |||||
| // Set this now and do the rest of our calcs below in the background, as we only need them | |||||
| // if the user wants to see all | |||||
| setWhitelistedTokensWithOffers(initRenderedTokens); | |||||
| let blacklist: string[]; | let blacklist: string[]; | ||||
| try { | try { | ||||
| const serverBlacklistResponse: ServerBlacklistResponse = await ( | const serverBlacklistResponse: ServerBlacklistResponse = await ( | ||||
| await fetch(`${tokenConfig.blacklistServerUrl}/blacklist`) | await fetch(`${tokenConfig.blacklistServerUrl}/blacklist`) | ||||
| ).json(); | ).json(); | ||||
| blacklist = serverBlacklistResponse.tokenIds; | blacklist = serverBlacklistResponse.tokenIds; | ||||
| if (!Array.isArray(blacklist)) { | if (!Array.isArray(blacklist)) { | ||||
| throw new Error('Error parsing server response'); | throw new Error('Error parsing server response'); | ||||
| Show All 16 Lines | const getAllListedTokensInBackground = async () => { | ||||
| offeredFungibleTokenIds.length - | offeredFungibleTokenIds.length - | ||||
| noBlacklistedOfferedFungibleTokenIds.length; | noBlacklistedOfferedFungibleTokenIds.length; | ||||
| console.info( | console.info( | ||||
| `${activeBlacklistedOffers} blacklisted offer${ | `${activeBlacklistedOffers} blacklisted offer${ | ||||
| activeBlacklistedOffers === 1 ? '' : 's' | activeBlacklistedOffers === 1 ? '' : 's' | ||||
| }`, | }`, | ||||
| ); | ); | ||||
| // 2. Get all offers listed from the active wallet | // Sort noBlacklistedOfferedFungibleTokenIds by tokenId | ||||
| // This keeps the order fixed for every user | |||||
| // TODO sort by trading volume | |||||
| noBlacklistedOfferedFungibleTokenIds.sort(); | |||||
| setAllOfferedTokenIds(noBlacklistedOfferedFungibleTokenIds); | |||||
| // Handy to check this in Cashtab | |||||
| console.info( | |||||
| `${noBlacklistedOfferedFungibleTokenIds.length} non-blacklisted tokens with active listings.`, | |||||
| ); | |||||
| }; | |||||
| const getMyListingsInBackground = async () => { | |||||
| let activeOffersByPubKey; | let activeOffersByPubKey; | ||||
| let offeredFungibleTokenIdsThisWallet: Set<string> | string[] = | const offeredFungibleTokenIdsThisWallet: Set<string> = new Set(); | ||||
| new Set(); | |||||
| try { | try { | ||||
| activeOffersByPubKey = await agora.activeOffersByPubKey(pk); | activeOffersByPubKey = await agora.activeOffersByPubKey(pk); | ||||
| // Just get the tokenIds as the Orderbook will load and prepare the offers by tokenId | |||||
| for (const activeOffer of activeOffersByPubKey) { | for (const activeOffer of activeOffersByPubKey) { | ||||
| if (activeOffer.variant.type === 'PARTIAL') { | if (activeOffer.variant.type === 'PARTIAL') { | ||||
| offeredFungibleTokenIdsThisWallet.add( | offeredFungibleTokenIdsThisWallet.add( | ||||
| activeOffer.token.tokenId, | activeOffer.token.tokenId, | ||||
| ); | ); | ||||
| } | } | ||||
| } | } | ||||
| } catch (err) { | } catch (err) { | ||||
| console.error(`Error getting agora.activeOffersByPubKey()`, err); | console.error(`Error getting agora.activeOffersByPubKey()`, err); | ||||
| return setChronikQueryError(true); | return setChronikQueryError(true); | ||||
| } | } | ||||
| // Sort noBlacklistedOfferedFungibleTokenIds by tokenId | const myListed = Array.from(offeredFungibleTokenIdsThisWallet).sort(); | ||||
| // This keeps the order fixed for every user | setMyListedTokenIds(myListed); | ||||
| // TODO sort by trading volume | addUncachedTokensToCacheAndUpdateCache(myListed); | ||||
| noBlacklistedOfferedFungibleTokenIds.sort(); | |||||
| offeredFungibleTokenIdsThisWallet = Array.from( | |||||
| offeredFungibleTokenIdsThisWallet, | |||||
| ).sort(); | |||||
| setActiveOffersCashtab({ | |||||
| offeredFungibleTokenIds: noBlacklistedOfferedFungibleTokenIds, | |||||
| offeredFungibleTokenIdsThisWallet: | |||||
| offeredFungibleTokenIdsThisWallet, | |||||
| }); | |||||
| // Handy to check this in Cashtab | |||||
| console.info( | |||||
| `${noBlacklistedOfferedFungibleTokenIds.length} non-blacklisted tokens with active listings.`, | |||||
| ); | |||||
| // Even though we load with a limitied whitelist, we still cache all tokens in the background | |||||
| // Handle token caching for all tokens | |||||
| addUncachedTokensToCacheAndUpdateCache(tokenIdsWeNeedToCache); | |||||
| }; | }; | ||||
| const addUncachedTokensToCacheAndUpdateCache = async ( | const addUncachedTokensToCacheAndUpdateCache = async ( | ||||
| tokenIdsWeNeedToCache: string[], | tokenIdsWeNeedToCache: string[], | ||||
| ) => { | ) => { | ||||
| const tokenInfoPromises = []; | const tokenInfoPromises = []; | ||||
| for (const tokenId of Array.from(tokenIdsWeNeedToCache)) { | for (const tokenId of Array.from(tokenIdsWeNeedToCache)) { | ||||
| if (typeof cashtabCache.tokens.get(tokenId) !== 'undefined') { | |||||
| continue; | |||||
| } | |||||
| tokenInfoPromises.push( | tokenInfoPromises.push( | ||||
| returnGetAndCacheTokenInfoPromise(cashtabCache, tokenId), | returnGetAndCacheTokenInfoPromise(cashtabCache, tokenId), | ||||
| ); | ); | ||||
| } | } | ||||
| try { | try { | ||||
| await askPolitelyForTokenInfo( | await askPolitelyForTokenInfo( | ||||
| tokenInfoPromises, | tokenInfoPromises, | ||||
| POLITE_REQUEST_LIMIT, | POLITE_REQUEST_LIMIT, | ||||
| Show All 19 Lines | const Agora: React.FC = () => { | ||||
| useEffect(() => { | useEffect(() => { | ||||
| // Update offers when the wallet changes and the new pk has loaded | // Update offers when the wallet changes and the new pk has loaded | ||||
| if (pk === null) { | if (pk === null) { | ||||
| // Do nothing if pk has not yet been set | // Do nothing if pk has not yet been set | ||||
| return; | return; | ||||
| } | } | ||||
| // Reset when pk changes | const { whitelist } = tokenConfig; | ||||
| setActiveOffersCashtab(null); | // Render whitelist immediately and cache only these token infos on load | ||||
| getListedTokens(); | setRenderedTokenIds(whitelist); | ||||
| setMyListedTokenIds(null); | |||||
| setAllOfferedTokenIds(null); | |||||
| setLoadAllZeOffers(false); | |||||
| setIsLoadAllPaused(false); | |||||
| setLoadedOrderBooksCount(POLITE_REQUEST_LIMIT); | |||||
| addUncachedTokensToCacheAndUpdateCache(whitelist); | |||||
| getMyListingsInBackground(); | |||||
| }, [pk]); | }, [pk]); | ||||
| const intervalIdRef = useRef<NodeJS.Timeout | null>(null); | |||||
| useEffect(() => { | useEffect(() => { | ||||
| if (activeOffersCashtab === null || renderedTokenIds === null) { | if ( | ||||
| // Do nothing until we have loaded tokens to render | allOfferedTokenIds === null || | ||||
| !loadAllZeOffers || | |||||
| isLoadAllPaused | |||||
| ) { | |||||
| return; | return; | ||||
| } | } | ||||
| if (!loadAllZeOffers) { | const loadMoreTokens = () => { | ||||
| // If we are not loading all of the offers, create the timeout based on renderedTokenIds | setLoadedOrderBooksCount(prevCount => { | ||||
| intervalIdRef.current = setInterval(() => { | const newCount = prevCount + POLITE_REQUEST_LIMIT; | ||||
| const currentSize = orderBookInfoMapRef.current.size; | if (newCount <= allOfferedTokenIds.length) { | ||||
| if (currentSize === renderedTokenIds.length) { | return newCount; | ||||
| // We have loaded all offer info, can enable search using this info | |||||
| setAllOrderBooksLoaded(true); | |||||
| // Clear the interval when orderBookInfoMap is loaded | |||||
| if (intervalIdRef.current !== null) { | |||||
| clearInterval(intervalIdRef.current); | |||||
| intervalIdRef.current = null; // Reset the ref to null | |||||
| } | |||||
| } | |||||
| // Check every 5 seconds. For whitelisted tokenIds, we do not expect to do this more than once | |||||
| // But the whitelist is expected to grow, so mb we will get there | |||||
| }, 5000); | |||||
| // Cleanup the interval when the component unmounts | |||||
| return () => { | |||||
| if (intervalIdRef.current) { | |||||
| clearInterval(intervalIdRef.current); | |||||
| } | } | ||||
| return allOfferedTokenIds.length; | |||||
| }); | |||||
| }; | }; | ||||
| } | |||||
| if ( | const intervalId = setInterval(loadMoreTokens, POLITE_INTERVAL_MS); | ||||
| allOrderBooksLoaded == true && | return () => clearInterval(intervalId); | ||||
| orderBookInfoMapRef.current.size < | }, [allOfferedTokenIds, loadAllZeOffers, isLoadAllPaused]); | ||||
| activeOffersCashtab.offeredFungibleTokenIds.length | |||||
| ) { | |||||
| // Reset allOrderBooksLoaded if all order books are in fact not loaded | |||||
| // Expected behavior if the user elects to loadAllZeOffers | |||||
| setAllOrderBooksLoaded(false); | |||||
| } | |||||
| // Start the interval when the component mounts | |||||
| intervalIdRef.current = setInterval(() => { | |||||
| const currentSize = orderBookInfoMapRef.current.size; | |||||
| if ( | |||||
| currentSize === | |||||
| activeOffersCashtab.offeredFungibleTokenIds.length | |||||
| ) { | |||||
| // We have loaded all offer info, can enable search using this info | |||||
| setAllOrderBooksLoaded(true); | |||||
| // Clear the interval when orderBookInfoMap is loaded | useEffect(() => { | ||||
| if (intervalIdRef.current !== null) { | if (!loadAllZeOffers || allOfferedTokenIds !== null) { | ||||
| clearInterval(intervalIdRef.current); | return; | ||||
| intervalIdRef.current = null; // Reset the ref to null | |||||
| } | |||||
| } | } | ||||
| }, 5000); // Check every 5 seconds. This can take > 30s. | getAllListedTokensInBackground(); | ||||
| }, [loadAllZeOffers, allOfferedTokenIds]); | |||||
| // Cleanup the interval when the component unmounts | const handleStopLoadingAllOffers = () => { | ||||
| return () => { | setIsLoadAllPaused(true); | ||||
| if (intervalIdRef.current) { | |||||
| clearInterval(intervalIdRef.current); | |||||
| } | |||||
| }; | }; | ||||
| }, [activeOffersCashtab, loadAllZeOffers, renderedTokenIds]); | |||||
| const handleContinueLoadingAllOffers = () => { | |||||
| setIsLoadAllPaused(false); | |||||
| }; | |||||
| const totalOfferedTokenCount = | |||||
| allOfferedTokenIds === null ? 0 : allOfferedTokenIds.length; | |||||
| const renderedTokenCount = | |||||
| !loadAllZeOffers || renderedTokenIds === null | |||||
| ? 0 | |||||
| : renderedTokenIds.length; | |||||
| const isFetchingAllOffers = loadAllZeOffers && allOfferedTokenIds === null; | |||||
| const isLoadAllCompleted = | |||||
| loadAllZeOffers && | |||||
| allOfferedTokenIds !== null && | |||||
| renderedTokenCount === totalOfferedTokenCount; | |||||
| const loadAllProgressPercent = | |||||
| totalOfferedTokenCount === 0 | |||||
| ? 0 | |||||
| : Math.floor((renderedTokenCount / totalOfferedTokenCount) * 100); | |||||
| return ( | return ( | ||||
| <> | <Wrapper> | ||||
| <ActionButtonRow variant="agora" activeIndex={0} /> | |||||
| {chronikQueryError ? ( | {chronikQueryError ? ( | ||||
| <ActiveOffers title="Chronik Query Error"> | <ActiveOffers title="Chronik Query Error"> | ||||
| <Alert> | <Alert> | ||||
| Error querying listed tokens. Please try again later. | Error querying listed tokens. Please try again later. | ||||
| </Alert> | </Alert> | ||||
| </ActiveOffers> | </ActiveOffers> | ||||
| ) : ( | ) : ( | ||||
| <> | <> | ||||
| {renderedTokenIds === null ? ( | {showConfirmLoadAllModal && ( | ||||
| <Spinner title="Loading active offers" /> | |||||
| ) : ( | |||||
| <> | |||||
| {showConfirmLoadAllModal && | |||||
| activeOffersCashtab !== null && ( | |||||
| <Modal | <Modal | ||||
| title={`Load all agora offers?`} | title="Load all offers?" | ||||
| description={`We have ${activeOffersCashtab.offeredFungibleTokenIds.length.toLocaleString( | description="This will query and render a large marketplace list and may be slow. Continue?" | ||||
| userLocale, | |||||
| { | |||||
| minimumFractionDigits: 0, | |||||
| maximumFractionDigits: 0, | |||||
| }, | |||||
| )} listings. This will take a long time and the screen will be slow.`} | |||||
| handleOk={() => { | handleOk={() => { | ||||
| // Manually update the sort switch to sort by tokenId | |||||
| // We need to update orderbookMap with all of the offers | |||||
| // before we can offer sort by other params | |||||
| setSwitches({ | |||||
| ...sortSwitchesOff, | |||||
| byTokenId: true, | |||||
| }); | |||||
| // Start loading all the offers | |||||
| setLoadAllZeOffers(true); | setLoadAllZeOffers(true); | ||||
| // Hide this modal | |||||
| setShowConfirmLoadAllModal(false); | setShowConfirmLoadAllModal(false); | ||||
| }} | }} | ||||
| handleCancel={() => | handleCancel={() => | ||||
| setShowConfirmLoadAllModal(false) | setShowConfirmLoadAllModal(false) | ||||
| } | } | ||||
| showCancelButton | |||||
| /> | /> | ||||
| )} | )} | ||||
| <AgoraHeader> | {renderedTokenIds === null ? ( | ||||
| <h2>Token Offers</h2> | <Spinner title="Loading active offers" /> | ||||
| <div> | ) : ( | ||||
| {!manageMyOffers && ( | |||||
| <> | <> | ||||
| Sort by: | |||||
| <SortSwitch | |||||
| disabled={false} | |||||
| active={switches.byTokenId} | |||||
| title="Sort by TokenId" | |||||
| onClick={() => | |||||
| setSwitches({ | |||||
| ...sortSwitchesOff, | |||||
| byTokenId: true, | |||||
| }) | |||||
| } | |||||
| > | |||||
| TokenID | |||||
| </SortSwitch> | |||||
| <SortSwitch | |||||
| disabled={!allOrderBooksLoaded} | |||||
| active={switches.byOfferCount} | |||||
| title="Sort by Offer Count" | |||||
| onClick={ | |||||
| allOrderBooksLoaded | |||||
| ? () => | |||||
| setSwitches({ | |||||
| ...sortSwitchesOff, | |||||
| byOfferCount: true, | |||||
| }) | |||||
| : undefined | |||||
| } | |||||
| > | |||||
| Offers | |||||
| {!allOrderBooksLoaded && ( | |||||
| <div> | |||||
| <InlineLoader title="Loading OrderBook info..." /> | |||||
| </div> | |||||
| )} | |||||
| </SortSwitch> | |||||
| </> | |||||
| )} | |||||
| <ManageSwitch | |||||
| title="Toggle Active Offers" | |||||
| onClick={() => { | |||||
| setManageMyOffers( | |||||
| () => !manageMyOffers, | |||||
| ); | |||||
| }} | |||||
| > | |||||
| {!manageMyOffers | |||||
| ? 'My Listings' | |||||
| : 'All Offers'} | |||||
| </ManageSwitch> | |||||
| </div> | |||||
| </AgoraHeader> | |||||
| <ActiveOffers title="Active Offers"> | <ActiveOffers title="Active Offers"> | ||||
| {manageMyOffers ? ( | {renderedTokenIds === null ? ( | ||||
| <> | |||||
| <OfferTitle> | |||||
| Manage your listings | |||||
| </OfferTitle> | |||||
| {activeOffersCashtab === null ? ( | |||||
| <InlineLoader /> | <InlineLoader /> | ||||
| ) : activeOffersCashtab | ) : ( | ||||
| .offeredFungibleTokenIdsThisWallet | (() => { | ||||
| .length > 0 ? ( | const myOfferIds = | ||||
| <OfferTable> | myListedTokenIds === null | ||||
| {activeOffersCashtab.offeredFungibleTokenIdsThisWallet.map( | ? [] | ||||
| offeredTokenId => { | : myListedTokenIds; | ||||
| const otherTokenIds = | |||||
| renderedTokenIds.filter( | |||||
| id => !myOfferIds.includes(id), | |||||
| ); | |||||
| const renderTokenRow = ( | |||||
| tokenId: string, | |||||
| ) => { | |||||
| const cached = | |||||
| cashtabCache.tokens.get( | |||||
| tokenId, | |||||
| ); | |||||
| const name = | |||||
| cached?.genesisInfo | |||||
| ?.tokenName ?? tokenId; | |||||
| const ticker = | |||||
| cached?.genesisInfo | |||||
| ?.tokenTicker ?? '—'; | |||||
| return ( | return ( | ||||
| <OrderBook | <TokenRow | ||||
| key={ | key={tokenId} | ||||
| offeredTokenId | as={Link} | ||||
| } | to={`/token/${tokenId}`} | ||||
| tokenId={ | > | ||||
| offeredTokenId | <TokenIcon | ||||
| } | size={64} | ||||
| userLocale={ | tokenId={tokenId} | ||||
| userLocale | |||||
| } | |||||
| /> | /> | ||||
| <TokenRowName> | |||||
| {name} | |||||
| <TokenRowTicker> | |||||
| {ticker} | |||||
| </TokenRowTicker> | |||||
| </TokenRowName> | |||||
| </TokenRow> | |||||
| ); | ); | ||||
| }, | }; | ||||
| )} | |||||
| </OfferTable> | return ( | ||||
| <> | |||||
| {myListedTokenIds === null ? ( | |||||
| <> | |||||
| <SectionTitle> | |||||
| Your Listings | |||||
| </SectionTitle> | |||||
| <InlineLoader title="Loading your listings..." /> | |||||
| </> | |||||
| ) : ( | ) : ( | ||||
| <p> | myOfferIds.length > 0 && ( | ||||
| You do not have any listed | <> | ||||
| tokens | <SectionTitle> | ||||
| </p> | Your Listings | ||||
| </SectionTitle> | |||||
| <TokenList> | |||||
| {myOfferIds.map( | |||||
| renderTokenRow, | |||||
| )} | )} | ||||
| </TokenList> | |||||
| </> | </> | ||||
| ) : ( | ) | ||||
| )} | |||||
| {myListedTokenIds !== null && | |||||
| myOfferIds.length === 0 && ( | |||||
| <> | <> | ||||
| {renderedTokenIds !== null && | <SectionTitle> | ||||
| renderedTokenIds.length > 0 ? ( | Your Listings | ||||
| <OfferTable | </SectionTitle> | ||||
| renderedOfferCount={ | <p> | ||||
| renderedTokenIds.length | You have no | ||||
| } | active listings. | ||||
| </p> | |||||
| </> | |||||
| )} | |||||
| <div | |||||
| style={{ | |||||
| marginTop: | |||||
| myOfferIds.length > | |||||
| 0 | |||||
| ? 24 | |||||
| : 0, | |||||
| }} | |||||
| > | > | ||||
| {renderedTokenIds.map( | <div | ||||
| offeredTokenId => { | style={{ | ||||
| return ( | display: 'flex', | ||||
| <OrderBook | flexWrap: 'wrap', | ||||
| key={ | alignItems: | ||||
| offeredTokenId | 'center', | ||||
| } | gap: 8, | ||||
| tokenId={ | marginBottom: 8, | ||||
| offeredTokenId | }} | ||||
| } | > | ||||
| userLocale={ | <SectionTitle | ||||
| userLocale | style={{ | ||||
| } | margin: 0, | ||||
| orderBookInfoMap={ | }} | ||||
| orderBookInfoMapRef.current | > | ||||
| } | Token Offers | ||||
| /** | </SectionTitle> | ||||
| * We want to use Websockets | </div> | ||||
| * - When we manage our own offers (see above) | {otherTokenIds.length > | ||||
| * - When we only show whitelisted offers | 0 ? ( | ||||
| * | <TokenList> | ||||
| * We DO NOT want to use websockets | {otherTokenIds.map( | ||||
| * - When we display all offers | renderTokenRow, | ||||
| * | |||||
| * We may be able to use websockets for all | |||||
| * rendered OrderBooks after we get lazy loading right | |||||
| */ | |||||
| noWebsocket={ | |||||
| loadAllZeOffers | |||||
| } | |||||
| priceInFiat={ | |||||
| offeredTokenId === | |||||
| FIRMA.tokenId | |||||
| } | |||||
| /> | |||||
| ); | |||||
| }, | |||||
| )} | )} | ||||
| </OfferTable> | </TokenList> | ||||
| ) : ( | ) : !loadAllZeOffers ? ( | ||||
| <> | |||||
| {!loadAllZeOffers ? ( | |||||
| <p> | <p> | ||||
| No whitelisted tokens | No whitelisted | ||||
| are currently listed for | tokens are currently | ||||
| sale. Try loading all | listed for sale. Try | ||||
| offers. | loading all offers. | ||||
| </p> | </p> | ||||
| ) : ( | ) : ( | ||||
| <p> | <p> | ||||
| No tokens are currently | No tokens are | ||||
| listed for sale. | currently listed for | ||||
| sale. | |||||
| </p> | </p> | ||||
| )} | )} | ||||
| </div> | |||||
| </> | </> | ||||
| )} | ); | ||||
| </> | })() | ||||
| )} | )} | ||||
| </ActiveOffers> | </ActiveOffers> | ||||
| {!loadAllZeOffers && !manageMyOffers && ( | {!loadAllZeOffers && ( | ||||
| <PrimaryButton | <PrimaryButton | ||||
| style={{ | style={{ | ||||
| marginTop: '12px', | marginTop: '12px', | ||||
| }} | }} | ||||
| disabled={activeOffersCashtab === null} | |||||
| onClick={() => | onClick={() => | ||||
| setShowConfirmLoadAllModal(true) | setShowConfirmLoadAllModal(true) | ||||
| } | } | ||||
| > | > | ||||
| {activeOffersCashtab === null ? ( | Load all offers | ||||
| </PrimaryButton> | |||||
| )} | |||||
| {loadAllZeOffers && ( | |||||
| <div | |||||
| style={{ | |||||
| marginTop: '12px', | |||||
| padding: '12px', | |||||
| borderRadius: '8px', | |||||
| background: 'rgba(255,255,255,0.05)', | |||||
| }} | |||||
| > | |||||
| {isFetchingAllOffers && ( | |||||
| <div | |||||
| style={{ | |||||
| margin: '8px 0 0 0', | |||||
| display: 'flex', | |||||
| alignItems: 'center', | |||||
| gap: '8px', | |||||
| }} | |||||
| > | |||||
| <InlineLoader /> | <InlineLoader /> | ||||
| <StatusText style={{ margin: 0 }}> | |||||
| Fetching full offer list... | |||||
| </StatusText> | |||||
| </div> | |||||
| )} | |||||
| {allOfferedTokenIds !== null && ( | |||||
| <> | |||||
| {isLoadAllCompleted ? ( | |||||
| <StatusText | |||||
| style={{ | |||||
| margin: '8px 0 0 0', | |||||
| }} | |||||
| > | |||||
| All{' '} | |||||
| {totalOfferedTokenCount.toLocaleString()}{' '} | |||||
| tokens with offers loaded | |||||
| </StatusText> | |||||
| ) : ( | ) : ( | ||||
| 'Load all offers' | <> | ||||
| {isLoadAllPaused && ( | |||||
| <StatusText | |||||
| style={{ | |||||
| margin: '8px 0 6px 0', | |||||
| }} | |||||
| > | |||||
| Loading paused | |||||
| </StatusText> | |||||
| )} | )} | ||||
| </PrimaryButton> | <StatusText | ||||
| style={{ | |||||
| margin: '8px 0 6px 0', | |||||
| }} | |||||
| > | |||||
| Loading{' '} | |||||
| {renderedTokenCount} of{' '} | |||||
| {totalOfferedTokenCount}{' '} | |||||
| tokens ( | |||||
| {loadAllProgressPercent} | |||||
| %) | |||||
| </StatusText> | |||||
| <div | |||||
| style={{ | |||||
| width: '100%', | |||||
| height: '8px', | |||||
| borderRadius: | |||||
| '999px', | |||||
| background: | |||||
| 'rgba(255,255,255,0.15)', | |||||
| overflow: 'hidden', | |||||
| }} | |||||
| > | |||||
| <div | |||||
| style={{ | |||||
| width: `${loadAllProgressPercent}%`, | |||||
| height: '100%', | |||||
| background: | |||||
| 'linear-gradient(90deg,#01a0e0 0%,#0671c0 50%,#224da8 100%)', | |||||
| }} | |||||
| /> | |||||
| </div> | |||||
| </> | |||||
| )} | )} | ||||
| </> | </> | ||||
| )} | )} | ||||
| {!isLoadAllCompleted && ( | |||||
| <SecondaryButton | |||||
| style={{ marginTop: '10px' }} | |||||
| onClick={ | |||||
| isLoadAllPaused | |||||
| ? handleContinueLoadingAllOffers | |||||
| : handleStopLoadingAllOffers | |||||
| } | |||||
| > | |||||
| {isLoadAllPaused | |||||
| ? 'Continue loading offers' | |||||
| : 'Stop loading all offers'} | |||||
| </SecondaryButton> | |||||
| )} | |||||
| </div> | |||||
| )} | |||||
| </> | </> | ||||
| )} | )} | ||||
| </> | </> | ||||
| )} | |||||
| </Wrapper> | |||||
| ); | ); | ||||
| }; | }; | ||||
| export default Agora; | export default Agora; | ||||