diff --git a/apps/mobile/src/contexts/TokenStateManagerContext.tsx b/apps/mobile/src/contexts/TokenStateManagerContext.tsx index 801ba04b02..ff3ea20728 100644 --- a/apps/mobile/src/contexts/TokenStateManagerContext.tsx +++ b/apps/mobile/src/contexts/TokenStateManagerContext.tsx @@ -127,15 +127,14 @@ export function TokenStateManagerProvider({ children }: PropsWithChildren) { const environment = useRelayEnvironment(); const FragmentResource = getFragmentResourceForEnvironment(environment); const incrementTokenRetryKey = useCallback( - (tokenId: string) => { + (tokenIdentifiers: string | string[]) => { addBreadcrumb({ message: 'Trying to clear the Relay FragmentResource cache', level: 'info', }); - // Wrapping this in a try catch since we have no idea - // if Relay wil introduce a breaking change here. - // This was copy-pasted from the web `NftErrorContext.tsx` + const tokenIdsArray = Array.isArray(tokenIdentifiers) ? tokenIdentifiers : [tokenIdentifiers]; + try { FragmentResource._cache._map.clear(); } catch (e) { @@ -146,13 +145,16 @@ export function TokenStateManagerProvider({ children }: PropsWithChildren) { setTokens((previous) => { const next = { ...previous }; - const token = { ...(next[tokenId] ?? defaultTokenState()) }; - token.isFailed = false; - token.isLoading = false; - token.isPolling = false; - token.refreshingMetadata = false; - token.retryKey++; - next[tokenId] = token; + tokenIdsArray.forEach((tokenId) => { + const token = { ...(next[tokenId] ?? defaultTokenState()) }; + token.isFailed = false; + token.isLoading = false; + token.isPolling = false; + token.refreshingMetadata = false; + token.retryKey++; + next[tokenId] = token; + }); + return next; }); }, @@ -217,9 +219,7 @@ export function TokenStateManagerProvider({ children }: PropsWithChildren) { TokenStateManagerContextType['clearTokenFailureState'] >( (tokenIds: string[]) => { - for (const tokenId of tokenIds) { - incrementTokenRetryKey(tokenId); - } + incrementTokenRetryKey(tokenIds); }, [incrementTokenRetryKey] ); diff --git a/apps/mobile/src/screens/NftSelectorScreen/NftSelectorPickerGrid.tsx b/apps/mobile/src/screens/NftSelectorScreen/NftSelectorPickerGrid.tsx index 151012d4de..9425f8b92d 100644 --- a/apps/mobile/src/screens/NftSelectorScreen/NftSelectorPickerGrid.tsx +++ b/apps/mobile/src/screens/NftSelectorScreen/NftSelectorPickerGrid.tsx @@ -4,8 +4,8 @@ import { ResizeMode } from 'expo-av'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { View, ViewProps } from 'react-native'; import SkeletonPlaceholder from 'react-native-skeleton-placeholder'; -import { useFragment, useLazyLoadQuery } from 'react-relay'; -import { graphql } from 'relay-runtime'; +import { useFragment, useLazyLoadQuery, useRelayEnvironment } from 'react-relay'; +import { fetchQuery, graphql } from 'relay-runtime'; import { TokenFailureBoundary } from '~/components/Boundaries/TokenFailureBoundary/TokenFailureBoundary'; import { Button } from '~/components/Button'; @@ -27,6 +27,7 @@ import { NftSelectorPickerGridTokensFragment$data, NftSelectorPickerGridTokensFragment$key, } from '~/generated/NftSelectorPickerGridTokensFragment.graphql'; +import { NftSelectorPickerGridTokensQuery } from '~/generated/NftSelectorPickerGridTokensQuery.graphql'; import { LoginStackNavigatorProp } from '~/navigation/types'; import { NetworkChoice, @@ -89,7 +90,7 @@ export function NftSelectorPickerGrid({ ); const tokenRefs = removeNullValues(query.viewer?.user?.tokens); - const tokens = useFragment( + const tokensData = useFragment( graphql` fragment NftSelectorPickerGridTokensFragment on Token @relay(plural: true) { dbid @@ -120,12 +121,55 @@ export function NftSelectorPickerGrid({ tokenRefs ); + const { isSyncing, isSyncingCreatedTokens } = useSyncTokensActions(); + + const relayEnvironment = useRelayEnvironment(); + + useEffect(() => { + let intervalId: number | undefined; + + if (isSyncing) { + const fetchTokens = async () => { + const tokensQuery = graphql` + query NftSelectorPickerGridTokensQuery { + viewer { + ... on Viewer { + user { + tokens { + dbid + creationTime + ...NftSelectorPickerGridTokensFragment + } + } + } + } + } + `; + + await fetchQuery( + relayEnvironment, + tokensQuery, + {} + ).toPromise(); + }; + + fetchTokens(); + intervalId = window.setInterval(fetchTokens, 5000); + } + + return () => { + if (intervalId !== undefined) { + clearInterval(intervalId); + } + }; + }, [isSyncing, relayEnvironment]); + const { openManageWallet } = useManageWalletActions(); // [GAL-4202] this logic could be consolidated across web editor + web selector + mobile selector // but also don't overdo it if there's sufficient differentiation between web and mobile UX const filteredTokens = useMemo(() => { - return tokens + return tokensData .filter((token) => { const isSpam = token.definition?.contract?.isSpam || token.isSpamByUser; @@ -154,7 +198,7 @@ export function NftSelectorPickerGrid({ searchCriteria.networkFilter, searchCriteria.ownerFilter, searchCriteria.searchQuery, - tokens, + tokensData, ]); const sortedTokens = useMemo(() => { @@ -220,8 +264,6 @@ export function NftSelectorPickerGrid({ return groups; }, [sortedTokens]); - const { isSyncing, isSyncingCreatedTokens } = useSyncTokensActions(); - // TODO: this logic is messy and shared with web; should be refactored const handleRefresh = useCallback(() => { if (!ownsWalletFromSelectedChainFamily) { @@ -245,12 +287,16 @@ export function NftSelectorPickerGrid({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [searchCriteria.networkFilter, searchCriteria.ownerFilter]); - type Row = { groups: Group[] }; + type Row = { groups: Group[]; id: string }; const rows = useMemo(() => { const rows: Row[] = []; for (let i = 0; i < Object.keys(groups).length; i += 3) { - rows.push({ groups: Object.values(groups).slice(i, i + 3) }); + const groupSlice = Object.values(groups).slice(i, i + 3); + const sliceKey = groupSlice.map((group) => group.address).join('-'); + const id = `row-${sliceKey}`; + + rows.push({ groups: groupSlice, id }); } return rows; @@ -298,9 +344,6 @@ export function NftSelectorPickerGrid({ const isRefreshing = isSyncing || isSyncingCreatedTokens; - if (isRefreshing) { - return ; - } const user = query?.viewer?.user; if (!user?.primaryWallet) { return ( @@ -320,6 +363,10 @@ export function NftSelectorPickerGrid({ ); } + if (isRefreshing && !rows.length) { + return ; + } + if (!rows.length) { return ( @@ -333,6 +380,7 @@ export function NftSelectorPickerGrid({ return ( item.id} renderItem={renderItem} data={rows} estimatedItemSize={200} diff --git a/apps/web/src/components/NftSelector/NftSelector.tsx b/apps/web/src/components/NftSelector/NftSelector.tsx index d0d0cab25c..6f139c1a21 100644 --- a/apps/web/src/components/NftSelector/NftSelector.tsx +++ b/apps/web/src/components/NftSelector/NftSelector.tsx @@ -1,11 +1,10 @@ import { Suspense, useCallback, useEffect, useMemo } from 'react'; -import { graphql, useFragment, useLazyLoadQuery } from 'react-relay'; +import { graphql, useLazyLoadQuery } from 'react-relay'; import { useSyncCreatedTokensForExistingContract } from 'src/hooks/api/tokens/useSyncCreatedTokensForExistingContract'; import styled from 'styled-components'; import { usePostComposerContext } from '~/contexts/postComposer/PostComposerContext'; import { NftSelectorQuery } from '~/generated/NftSelectorQuery.graphql'; -import { NftSelectorViewerFragment$key } from '~/generated/NftSelectorViewerFragment.graphql'; import useSyncTokens from '~/hooks/api/tokens/useSyncTokens'; import { ChevronLeftIcon } from '~/icons/ChevronLeftIcon'; import { RefreshIcon } from '~/icons/RefreshIcon'; @@ -56,55 +55,49 @@ function NftSelectorInner({ onSelectToken, headerText, preSelectedContract, even viewer { ... on Viewer { - ...NftSelectorViewerFragment - } - } - ...NftSelectorTokensQueryFragment - } - `, - {} - ); + user { + dbid + + tokens(ownershipFilter: [Creator, Holder]) { + __typename - const viewer = useFragment( - graphql` - fragment NftSelectorViewerFragment on Viewer { - user { - dbid - - tokens(ownershipFilter: [Creator, Holder]) { - __typename - - dbid - creationTime - definition { - name - chain - contract { dbid - name - isSpam + creationTime + definition { + name + chain + contract { + dbid + name + isSpam + } + } + + isSpamByUser + + ownerIsHolder + ownerIsCreator + + ...useTokenSearchResultsFragment + ...NftSelectorTokensFragment + + # Needed for when we select a token, we want to have this already in the cache + # eslint-disable-next-line relay/must-colocate-fragment-spreads + ...PostComposerTokenFragment } } - - isSpamByUser - - ownerIsHolder - ownerIsCreator - - ...useTokenSearchResultsFragment - ...NftSelectorTokensFragment - - # Needed for when we select a token, we want to have this already in the cache - # eslint-disable-next-line relay/must-colocate-fragment-spreads - ...PostComposerTokenFragment } } + ...NftSelectorTokensQueryFragment } `, - query.viewer + {} ); - const tokens = useMemo(() => removeNullValues(viewer?.user?.tokens), [viewer?.user?.tokens]); + const tokens = useMemo( + () => removeNullValues(query.viewer?.user?.tokens), + [query.viewer?.user?.tokens] + ); const { searchQuery, setSearchQuery, tokenSearchResults, isSearching } = useTokenSearchResults< (typeof tokens)[0] >({ @@ -217,7 +210,7 @@ function NftSelectorInner({ onSelectToken, headerText, preSelectedContract, even const ownsWalletFromSelectedChainFamily = doesUserOwnWalletFromChainFamily(network, query); - const isRefreshDisabledAtUserLevel = isRefreshDisabledForUser(viewer?.user?.dbid ?? ''); + const isRefreshDisabledAtUserLevel = isRefreshDisabledForUser(query?.viewer?.user?.dbid ?? ''); const refreshDisabled = isRefreshDisabledAtUserLevel || !ownsWalletFromSelectedChainFamily || isLocked; @@ -358,7 +351,6 @@ function NftSelectorInner({ onSelectToken, headerText, preSelectedContract, even void; @@ -31,7 +29,6 @@ type Props = { export default function NftSelectorTokens({ selectedFilter, - isLocked, selectedContractAddress, onSelectContract, onSelectToken, @@ -85,10 +82,6 @@ export default function NftSelectorTokens({ ); } - if (isLocked) { - return ; - } - return ( { + let intervalId: number | undefined; + + if (isSyncing) { + const fetchTokens = async () => { + const tokensQuery = graphql` + query NftSelectorViewQuery { + viewer { + ... on Viewer { + user { + dbid + + tokens(ownershipFilter: [Creator, Holder]) { + __typename + + dbid + + ...NftSelectorTokensFragment + + # Needed for when we select a token, we want to have this already in the cache + # eslint-disable-next-line relay/must-colocate-fragment-spreads + ...PostComposerTokenFragment + } + } + } + } + ...NftSelectorTokensQueryFragment + } + `; + + await fetchQuery(relayEnvironment, tokensQuery, {}).toPromise(); + }; + + fetchTokens(); + intervalId = window.setInterval(fetchTokens, 5000); + } + + return () => { + if (intervalId !== undefined) { + clearInterval(intervalId); + } + }; + }, [isSyncing, relayEnvironment]); + const groupedTokens = groupNftSelectorCollectionsByAddress({ ignoreSpam: false, tokenRefs: tokens, }); + const { isLocked } = useSyncTokens(); + const virtualizedListRef = useRef(null); const viewRef = useRef(null); @@ -172,6 +225,10 @@ export function NftSelectorView({ [columnCount, onSelectContract, onSelectToken, rows, selectedContractAddress] ); + if (isLocked && !rows.length) { + return ; + } + if (!rows.length && !hasSearchKeyword) { return ( diff --git a/apps/web/src/contexts/SyncTokensLockContext.tsx b/apps/web/src/contexts/SyncTokensLockContext.tsx index bb11fc0d0b..9b30bb26b7 100644 --- a/apps/web/src/contexts/SyncTokensLockContext.tsx +++ b/apps/web/src/contexts/SyncTokensLockContext.tsx @@ -3,8 +3,11 @@ import { createContext, ReactNode, useCallback, useContext, useMemo, useState } type UnlockFunction = () => void; type SyncTokensLockContextType = { + isSyncing: boolean; isLocked: boolean; lock: () => UnlockFunction; + startSyncing: () => void; + stopSyncing: () => void; }; const SyncTokensLockContext = createContext(undefined); @@ -15,6 +18,7 @@ type Props = { export function SyncTokensLockProvider({ children }: Props) { const [isLocked, setIsLocked] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); const lock = useCallback(() => { setIsLocked((previous) => { @@ -28,22 +32,33 @@ export function SyncTokensLockProvider({ children }: Props) { return () => setIsLocked(false); }, []); + const startSyncing = useCallback(() => { + setIsSyncing(true); + }, []); + + const stopSyncing = useCallback(() => { + setIsSyncing(false); + }, []); + const value = useMemo(() => { return { isLocked, lock, + isSyncing, + startSyncing, + stopSyncing, }; - }, [lock, isLocked]); + }, [isLocked, lock, isSyncing, startSyncing, stopSyncing]); return {children}; } export function useSyncTokensContext() { - const value = useContext(SyncTokensLockContext); + const context = useContext(SyncTokensLockContext); - if (!value) { - throw new Error('Tried to use SyncTokensLockContext without a provider.'); + if (!context) { + throw new Error('useSyncTokensContext must be used within a SyncTokensLockProvider'); } - return value; + return context; } diff --git a/apps/web/src/hooks/api/tokens/useSyncCreatedTokensForExistingContract.ts b/apps/web/src/hooks/api/tokens/useSyncCreatedTokensForExistingContract.ts index 43be61afdc..b45f1a1930 100644 --- a/apps/web/src/hooks/api/tokens/useSyncCreatedTokensForExistingContract.ts +++ b/apps/web/src/hooks/api/tokens/useSyncCreatedTokensForExistingContract.ts @@ -23,8 +23,6 @@ export function useSyncCreatedTokensForExistingContract(): [ # This should be sufficient to capture all the things # we want to refresh. Don't @me when this fails. ...GalleryEditorViewerFragment - # Refresh tokens for post composer - ...NftSelectorViewerFragment ... on Viewer { user { diff --git a/apps/web/src/hooks/api/tokens/useSyncTokens.ts b/apps/web/src/hooks/api/tokens/useSyncTokens.ts index f2adb63e0f..95683783be 100644 --- a/apps/web/src/hooks/api/tokens/useSyncTokens.ts +++ b/apps/web/src/hooks/api/tokens/useSyncTokens.ts @@ -20,7 +20,7 @@ type syncTokensProps = { export default function useSyncTokens() { const { clearTokenFailureState } = useNftErrorContext(); - const { isLocked, lock } = useSyncTokensContext(); + const { isLocked, lock, startSyncing, stopSyncing, isSyncing } = useSyncTokensContext(); const [syncCollectedTokens] = usePromisifiedMutation( graphql` @@ -32,8 +32,6 @@ export default function useSyncTokens() { # This should be sufficient to capture all the things # we want to refresh. Don't @me when this fails. ...GalleryEditorViewerFragment - # Refresh tokens for post composer - ...NftSelectorViewerFragment ... on Viewer { user { @@ -65,8 +63,6 @@ export default function useSyncTokens() { # This should be sufficient to capture all the things # we want to refresh. Don't @me when this fails. ...GalleryEditorViewerFragment - # Refresh tokens for post composer - ...NftSelectorViewerFragment ... on Viewer { user { @@ -113,8 +109,11 @@ export default function useSyncTokens() { }); } } - try { + if (isSyncing) { + return; + } + startSyncing(); if (type === 'Collected') { const response = await syncCollectedTokens({ variables: { @@ -158,13 +157,24 @@ export default function useSyncTokens() { } catch (error) { showFailure(); } finally { + stopSyncing(); unlock(); } }, - [clearTokenFailureState, isLocked, lock, pushToast, syncCollectedTokens, syncCreatedTokens] + [ + clearTokenFailureState, + isLocked, + isSyncing, + lock, + pushToast, + startSyncing, + stopSyncing, + syncCollectedTokens, + syncCreatedTokens, + ] ); return useMemo(() => { - return { isLocked, syncTokens: sync }; - }, [isLocked, sync]); + return { isLocked, syncTokens: sync, isSyncing }; + }, [isLocked, isSyncing, sync]); }