diff --git a/packages/state/query/queries/chain.ts b/packages/state/query/queries/chain.ts index 1aefe5463..735660229 100644 --- a/packages/state/query/queries/chain.ts +++ b/packages/state/query/queries/chain.ts @@ -1,9 +1,10 @@ -import { queryOptions } from '@tanstack/react-query' +import { QueryClient, queryOptions } from '@tanstack/react-query' import { ModuleAccount } from '@dao-dao/types/protobuf/codegen/cosmos/auth/v1beta1/auth' import { cosmWasmClientRouter, cosmosProtoRpcClientRouter, + isValidBech32Address, } from '@dao-dao/utils' /** @@ -47,21 +48,16 @@ const fetchChainModuleAddress = async ({ } /** - * Check whether or not the address is a chain module, optionally with a - * specific name. + * Fetch the module name associated with the specified address. Returns null if + * not a module account. */ -export const isAddressModule = async ({ +const fetchChainModuleName = async ({ chainId, address, - moduleName, }: { chainId: string address: string - /** - * If defined, check that the module address matches the specified name. - */ - moduleName?: string -}): Promise => { +}): Promise => { const client = await cosmosProtoRpcClientRouter.connect(chainId) try { @@ -70,33 +66,84 @@ export const isAddressModule = async ({ }) if (!account) { - return false + return null } + // If not decoded automatically... if (account.typeUrl === ModuleAccount.typeUrl) { - const moduleAccount = ModuleAccount.decode(account.value) - return !moduleName || moduleAccount.name === moduleName + return ModuleAccount.decode(account.value).name - // If already decoded automatically. + // If decoded automatically... } else if (account.$typeUrl === ModuleAccount.typeUrl) { - return ( - !moduleName || (account as unknown as ModuleAccount).name === moduleName - ) + return (account as unknown as ModuleAccount).name } } catch (err) { + // If no account found, return null. if ( err instanceof Error && - (err.message.includes('not found: key not found') || - err.message.includes('decoding bech32 failed')) + err.message.includes('not found: key not found') ) { - return false + return null } // Rethrow other errors. throw err } - return false + return null +} + +/** + * Check whether or not the address is a chain module, optionally with a + * specific name. + */ +export const isAddressModule = async ( + queryClient: QueryClient, + { + chainId, + address, + moduleName, + }: { + chainId: string + address: string + /** + * If defined, check that the module address matches the specified name. + */ + moduleName?: string + } +): Promise => { + if (!isValidBech32Address(address)) { + return false + } + + try { + const name = await queryClient.fetchQuery( + chainQueries.moduleName({ + chainId, + address, + }) + ) + + // Null if not a module. + if (!name) { + return false + } + + // If name to check provided, check it. Otherwise, return true. + return !moduleName || name === moduleName + } catch (err) { + // If invalid address, return false. Should never happen because of the + // check at the beginning, but just in case. + if ( + err instanceof Error && + err.message.includes('decoding bech32 failed') + ) { + return false + } + + // Rethrow other errors. + throw err + } } /** @@ -122,14 +169,26 @@ export const chainQueries = { queryKey: ['chain', 'moduleAddress', options], queryFn: () => fetchChainModuleAddress(options), }), + /** + * Fetch the module name associated with the specified address. Returns null + * if not a module account. + */ + moduleName: (options: Parameters[0]) => + queryOptions({ + queryKey: ['chain', 'moduleName', options], + queryFn: () => fetchChainModuleName(options), + }), /** * Check whether or not the address is a chain module, optionally with a * specific name. */ - isAddressModule: (options: Parameters[0]) => + isAddressModule: ( + queryClient: QueryClient, + options: Parameters[1] + ) => queryOptions({ queryKey: ['chain', 'isAddressModule', options], - queryFn: () => isAddressModule(options), + queryFn: () => isAddressModule(queryClient, options), }), /** * Fetch the timestamp for a given block height. diff --git a/packages/state/query/queries/contract.ts b/packages/state/query/queries/contract.ts index afe2b63e0..94bcf4212 100644 --- a/packages/state/query/queries/contract.ts +++ b/packages/state/query/queries/contract.ts @@ -210,6 +210,17 @@ export const contractQueries = { ...options, nameOrNames: ContractName.PolytoneProxy, }), + /** + * Check if a contract is a cw1-whitelist. + */ + isCw1Whitelist: ( + queryClient: QueryClient, + options: Omit[1], 'nameOrNames'> + ) => + contractQueries.isContract(queryClient, { + ...options, + nameOrNames: ContractName.Cw1Whitelist, + }), /** * Fetch contract instantiation time. */ diff --git a/packages/state/query/queries/contracts/Cw1Whitelist.ts b/packages/state/query/queries/contracts/Cw1Whitelist.ts new file mode 100644 index 000000000..b73bbbf65 --- /dev/null +++ b/packages/state/query/queries/contracts/Cw1Whitelist.ts @@ -0,0 +1,132 @@ +import { QueryClient, UseQueryOptions } from '@tanstack/react-query' + +import { AdminListResponse } from '@dao-dao/types/contracts/Cw1Whitelist' +import { cosmWasmClientRouter } from '@dao-dao/utils' + +import { Cw1WhitelistQueryClient } from '../../../contracts/Cw1Whitelist' +import { contractQueries } from '../contract' +import { indexerQueries } from '../indexer' + +export const cw1WhitelistQueryKeys = { + contract: [ + { + contract: 'cw1Whitelist', + }, + ] as const, + address: (contractAddress: string) => + [ + { + ...cw1WhitelistQueryKeys.contract[0], + address: contractAddress, + }, + ] as const, + adminList: (contractAddress: string, args?: Record) => + [ + { + ...cw1WhitelistQueryKeys.address(contractAddress)[0], + method: 'admin_list', + ...(args && { args }), + }, + ] as const, + /** + * If this is a cw1-whitelist, return the admins. Otherwise, return null. + */ + adminsIfCw1Whitelist: ( + contractAddress: string, + args?: Record + ) => + [ + { + ...cw1WhitelistQueryKeys.address(contractAddress)[0], + method: 'adminsIfCw1Whitelist', + ...(args && { args }), + }, + ] as const, +} +export const cw1WhitelistQueries = { + adminList: ( + queryClient: QueryClient, + { chainId, contractAddress, options }: Cw1WhitelistAdminListQuery + ): UseQueryOptions => ({ + queryKey: cw1WhitelistQueryKeys.adminList(contractAddress), + queryFn: async () => { + let indexerNonExistent = false + try { + const adminList = await queryClient.fetchQuery( + indexerQueries.queryContract(queryClient, { + chainId, + contractAddress, + formula: 'cw1Whitelist/adminList', + }) + ) + if (adminList) { + return adminList + } else { + indexerNonExistent = true + } + } catch (error) { + console.error(error) + } + + if (indexerNonExistent) { + throw new Error('Admin list not found') + } + + // If indexer query fails, fallback to contract query. + return new Cw1WhitelistQueryClient( + await cosmWasmClientRouter.connect(chainId), + contractAddress + ).adminList() + }, + ...options, + }), + /** + * If this is a cw1-whitelist, return the admins. Otherwise, return null. + */ + adminsIfCw1Whitelist: ( + queryClient: QueryClient, + { + chainId, + contractAddress, + options, + }: Cw1WhitelistAdminsIfCw1WhitelistQuery + ): UseQueryOptions => ({ + queryKey: cw1WhitelistQueryKeys.adminsIfCw1Whitelist(contractAddress), + queryFn: async () => { + const isCw1Whitelist = await queryClient.fetchQuery( + contractQueries.isCw1Whitelist(queryClient, { + chainId, + address: contractAddress, + }) + ) + if (!isCw1Whitelist) { + return null + } + + return ( + await queryClient.fetchQuery( + cw1WhitelistQueries.adminList(queryClient, { + chainId, + contractAddress, + }) + ) + ).admins + }, + ...options, + }), +} +export interface Cw1WhitelistReactQuery { + chainId: string + contractAddress: string + options?: Omit< + UseQueryOptions, + 'queryKey' | 'queryFn' | 'initialData' + > & { + initialData?: undefined + } +} +export interface Cw1WhitelistAdminListQuery + extends Cw1WhitelistReactQuery {} + +export interface Cw1WhitelistAdminsIfCw1WhitelistQuery + extends Cw1WhitelistReactQuery {} diff --git a/packages/state/query/queries/contracts/index.ts b/packages/state/query/queries/contracts/index.ts index 3173e5d3c..a451496e3 100644 --- a/packages/state/query/queries/contracts/index.ts +++ b/packages/state/query/queries/contracts/index.ts @@ -1,3 +1,4 @@ +export * from './Cw1Whitelist' export * from './DaoDaoCore' export * from './PolytoneNote' export * from './PolytoneProxy' diff --git a/packages/state/query/queries/index.ts b/packages/state/query/queries/index.ts index ae21a922d..cee7cc5a6 100644 --- a/packages/state/query/queries/index.ts +++ b/packages/state/query/queries/index.ts @@ -6,3 +6,4 @@ export * from './contract' export * from './dao' export * from './indexer' export * from './polytone' +export * from './profile' diff --git a/packages/state/query/queries/profile.ts b/packages/state/query/queries/profile.ts new file mode 100644 index 000000000..f5e2671f0 --- /dev/null +++ b/packages/state/query/queries/profile.ts @@ -0,0 +1,334 @@ +import { + QueryClient, + UseQueryOptions, + queryOptions, + skipToken, +} from '@tanstack/react-query' + +import { + ChainId, + PfpkProfile, + ResolvedProfile, + UnifiedProfile, +} from '@dao-dao/types' +import { + MAINNET, + PFPK_API_BASE, + STARGAZE_NAMES_CONTRACT, + cosmWasmClientRouter, + getChainForChainId, + imageUrlFromStargazeIndexerNft, + makeEmptyPfpkProfile, + makeEmptyUnifiedProfile, + processError, + toBech32Hash, + transformBech32Address, +} from '@dao-dao/utils' + +import { stargazeIndexerClient, stargazeTokenQuery } from '../../graphql' + +/** + * Fetch unified profile information for any wallet. + */ +export const fetchProfileInfo = async ( + queryClient: QueryClient, + { + chainId, + address, + }: { + chainId: string + address: string + } +): Promise => { + const profile = makeEmptyUnifiedProfile(chainId, address) + if (!address) { + return profile + } + + const pfpkProfile = await queryClient.fetchQuery( + profileQueries.pfpk({ + address, + }) + ) + // Copy PFPK profile info into unified profile. + profile.uuid = pfpkProfile.uuid + profile.nonce = pfpkProfile.nonce + profile.name = pfpkProfile.name + profile.nft = pfpkProfile.nft + profile.chains = pfpkProfile.chains + + // Use profile address for Stargaze if set, falling back to transforming the + // address (which is unreliable due to different chains using different HD + // paths). + const stargazeAddress = + profile.chains[ChainId.StargazeMainnet]?.address || + transformBech32Address(address, ChainId.StargazeMainnet) + + // Load Stargaze name as backup if no PFPK name set. + if (!profile.name) { + const stargazeName = await queryClient + .fetchQuery( + profileQueries.stargazeName({ + address: stargazeAddress, + }) + ) + .catch(() => null) + if (stargazeName) { + profile.name = + stargazeName + '.' + getChainForChainId(chainId).bech32_prefix + profile.nameSource = 'stargaze' + } + } + + // Set `imageUrl` to PFPK image, defaulting to fallback image. + profile.imageUrl = pfpkProfile?.nft?.imageUrl || profile.backupImageUrl + + // Load Stargaze name image if no PFPK image. + if (!pfpkProfile?.nft?.imageUrl) { + const stargazeNameImage = await queryClient + .fetchQuery( + profileQueries.stargazeNameImage(queryClient, { + address: stargazeAddress, + }) + ) + .catch(() => null) + if (stargazeNameImage) { + profile.imageUrl = stargazeNameImage + } + } + + return profile +} + +/** + * Fetch PFPK profile information for any wallet. + */ +export const fetchPfpkProfileInfo = async ({ + bech32Hash, +}: { + bech32Hash: string +}): Promise => { + if (!bech32Hash) { + return makeEmptyPfpkProfile() + } + + try { + const response = await fetch(PFPK_API_BASE + `/bech32/${bech32Hash}`) + if (response.ok) { + return await response.json() + } else { + console.error(await response.json().catch(() => response.statusText)) + } + } catch (err) { + console.error(err) + } + + return makeEmptyPfpkProfile() +} + +/** + * Fetch Stargaze name for a wallet adderss. + */ +export const fetchStargazeName = async ({ + address, +}: { + address: string +}): Promise => { + if (!address) { + return null + } + + const client = await cosmWasmClientRouter.connect( + MAINNET ? ChainId.StargazeMainnet : ChainId.StargazeTestnet + ) + + try { + return await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { + name: { address }, + }) + } catch {} + + return null +} + +/** + * Fetch Stargaze name's image associated an address. + */ +export const fetchStargazeNameImage = async ( + queryClient: QueryClient, + { + address, + }: { + address: string + } +): Promise => { + const name = await queryClient.fetchQuery( + profileQueries.stargazeName({ address }) + ) + if (!name) { + return null + } + + const chainId = MAINNET ? ChainId.StargazeMainnet : ChainId.StargazeTestnet + const client = await cosmWasmClientRouter.connect(chainId) + + // Get NFT associated with name. + let response + try { + response = await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { + image_n_f_t: { name }, + }) + } catch { + return null + } + + // If NFT exists, get image associated with NFT. + try { + const { data } = await stargazeIndexerClient.query({ + query: stargazeTokenQuery, + variables: { + collectionAddr: response.collection, + tokenId: response.token_id, + }, + }) + if (data?.token) { + return imageUrlFromStargazeIndexerNft(data.token) || null + } + } catch (err) { + console.error(err) + } + + return null +} + +/** + * Search for profiles by name prefix. + */ +export const searchProfilesByNamePrefix = async ({ + chainId, + namePrefix, +}: { + chainId: string + namePrefix: string +}): Promise => { + if (namePrefix.length < 3) { + return [] + } + + // Load profiles from PFPK API. + let profiles: ResolvedProfile[] = [] + try { + const response = await fetch( + PFPK_API_BASE + `/search/${chainId}/${namePrefix}` + ) + if (response.ok) { + const { profiles: _profiles } = (await response.json()) as { + profiles: ResolvedProfile[] + } + profiles = _profiles + } else { + console.error(await response.json()) + } + } catch (err) { + console.error(processError(err)) + } + + return profiles +} + +export const profileQueries = { + /** + * Fetch unified profile. + */ + unified: ( + queryClient: QueryClient, + // If undefined, query will be disabled. + options?: Parameters[1] + ) => + queryOptions({ + queryKey: [ + { + category: 'profile', + name: 'unified', + options: options && { + ...options, + // Add this to match pfpk query key so we can invalidate and thus + // refetch both at once. + bech32Hash: toBech32Hash(options.address), + }, + }, + ], + queryFn: options + ? () => fetchProfileInfo(queryClient, options) + : skipToken, + }), + /** + * Fetch PFPK profile. + */ + pfpk: ( + /** + * Redirects address queries to bech32 hash queries. + * + * If undefined, query will be disabled. + */ + options?: { address: string } | { bech32Hash: string } + ): UseQueryOptions< + PfpkProfile, + Error, + PfpkProfile, + [ + { + category: 'profile' + name: 'pfpk' + options: { bech32Hash: string } | undefined + } + ] + > => + // Redirect address queries to bech32 hash queries. + options && 'address' in options + ? profileQueries.pfpk({ + bech32Hash: toBech32Hash(options.address), + }) + : queryOptions({ + queryKey: [ + { + category: 'profile', + name: 'pfpk', + options, + }, + ], + queryFn: options ? () => fetchPfpkProfileInfo(options) : skipToken, + }), + /** + * Fetch Stargaze name for a wallet adderss. + */ + stargazeName: (options: Parameters[0]) => + queryOptions({ + queryKey: ['profile', 'stargazeName', options], + queryFn: () => fetchStargazeName(options), + }), + /** + * Fetch Stargaze name's image associated an address. + */ + stargazeNameImage: ( + queryClient: QueryClient, + options: Parameters[1] + ) => + queryOptions({ + queryKey: ['profile', 'stargazeNameImage', options], + queryFn: () => fetchStargazeNameImage(queryClient, options), + }), + /** + * Search for profiles by name prefix. + */ + searchByNamePrefix: ( + /** + * If undefined, query will be disabled. + */ + options?: Parameters[0] + ) => + queryOptions({ + queryKey: ['profile', 'searchByNamePrefix', options], + queryFn: options ? () => searchProfilesByNamePrefix(options) : skipToken, + }), +} diff --git a/packages/state/recoil/atoms/refresh.ts b/packages/state/recoil/atoms/refresh.ts index 82aae626b..37e9493f9 100644 --- a/packages/state/recoil/atoms/refresh.ts +++ b/packages/state/recoil/atoms/refresh.ts @@ -50,13 +50,6 @@ export const refreshWalletStargazeNftsAtom = atomFamily({ default: 0, }) -// Change this to refresh the profile for a wallet. The argument is the address' -// bech32 data hash. -export const refreshWalletProfileAtom = atomFamily({ - key: 'refreshWalletProfile', - default: 0, -}) - // Change this to refresh native token staking info for the given address. export const refreshNativeTokenStakingInfoAtom = atomFamily< number, diff --git a/packages/state/recoil/selectors/chain.ts b/packages/state/recoil/selectors/chain.ts index 90ea27b37..c249e1e4b 100644 --- a/packages/state/recoil/selectors/chain.ts +++ b/packages/state/recoil/selectors/chain.ts @@ -67,7 +67,6 @@ import { } from '@dao-dao/utils' import { SearchGovProposalsOptions } from '../../indexer' -import { isAddressModule } from '../../query/queries/chain' import { refreshBlockHeightAtom, refreshGovProposalsAtom, @@ -1238,15 +1237,6 @@ export const moduleNameForAddressSelector = selectorFamily< }, }) -// Check whether or not the address is a module account. -export const isAddressModuleSelector = selectorFamily< - boolean, - Parameters[0] ->({ - key: 'isAddressModule', - get: (options) => () => isAddressModule(options), -}) - // Get bonded and unbonded tokens. Bonded tokens represent all possible // governance voting power. export const chainStakingPoolSelector = selectorFamily>({ diff --git a/packages/state/recoil/selectors/dao.ts b/packages/state/recoil/selectors/dao.ts index 3bd924a34..f58424162 100644 --- a/packages/state/recoil/selectors/dao.ts +++ b/packages/state/recoil/selectors/dao.ts @@ -4,19 +4,15 @@ import { ContractVersion, ContractVersionInfo, DaoDropdownInfo, - DaoParentInfo, Feature, LazyDaoCardProps, WithChainId, } from '@dao-dao/types' -import { ConfigResponse as CwCoreV1ConfigResponse } from '@dao-dao/types/contracts/CwCore.v1' -import { ConfigResponse as DaoCoreV2ConfigResponse } from '@dao-dao/types/contracts/DaoCore.v2' import { DAO_CORE_CONTRACT_NAMES, INACTIVE_DAO_NAMES, VETOABLE_DAOS_ITEM_KEY_PREFIX, getChainGovernanceDaoDescription, - getConfiguredChainConfig, getDisplayNameForChainId, getFallbackImage, getImageUrlForChainId, @@ -26,12 +22,7 @@ import { parseContractVersion, } from '@dao-dao/utils' -import { isAddressModuleSelector } from './chain' -import { - contractInfoSelector, - contractVersionSelector, - isDaoSelector, -} from './contract' +import { contractInfoSelector, contractVersionSelector } from './contract' import { DaoCoreV2Selectors } from './contracts' import { queryContractIndexerSelector } from './indexer' @@ -223,120 +214,6 @@ export const daoVetoableDaosSelector = selectorFamily< }), }) -/** - * Attempt to fetch the info needed to describe a parent DAO. Returns undefined - * if not a DAO nor the chain gov module account. - */ -export const daoParentInfoSelector = selectorFamily< - DaoParentInfo | undefined, - WithChainId<{ - parentAddress: string - /** - * To determine if the parent has registered the child, pass the child. This - * will set `registeredSubDao` appropriately. Otherwise, if undefined, - * `registeredSubDao` will be set to false. - */ - childAddress?: string - }> ->({ - key: 'daoParentInfo', - get: - ({ chainId, parentAddress, childAddress }) => - ({ get }) => { - // If address is a DAO contract... - if ( - get( - isDaoSelector({ - chainId, - address: parentAddress, - }) - ) - ) { - const parentAdmin = get( - DaoCoreV2Selectors.adminSelector({ - chainId, - contractAddress: parentAddress, - params: [], - }) - ) - const { - info: { version }, - } = get( - contractInfoSelector({ - chainId, - contractAddress: parentAddress, - }) - ) - const parentVersion = parseContractVersion(version) - - if (parentVersion) { - const { - name, - image_url, - }: CwCoreV1ConfigResponse | DaoCoreV2ConfigResponse = get( - // Both v1 and v2 have a config query. - DaoCoreV2Selectors.configSelector({ - chainId, - contractAddress: parentAddress, - params: [], - }) - ) - - // Check if parent has registered the child DAO as a SubDAO. - const registeredSubDao = - childAddress && - isFeatureSupportedByVersion(Feature.SubDaos, parentVersion) - ? get( - DaoCoreV2Selectors.listAllSubDaosSelector({ - contractAddress: parentAddress, - chainId, - }) - ).some(({ addr }) => addr === childAddress) - : false - - return { - chainId, - coreAddress: parentAddress, - coreVersion: parentVersion, - name, - imageUrl: image_url || getFallbackImage(parentAddress), - admin: parentAdmin ?? '', - registeredSubDao, - parentDao: null, - } - } - - // If address is the chain's x/gov module account... - } else if ( - get( - isAddressModuleSelector({ - chainId, - address: parentAddress, - moduleName: 'gov', - }) - ) - ) { - const chainConfig = getConfiguredChainConfig(chainId) - return ( - chainConfig && { - chainId, - coreAddress: chainConfig.name, - coreVersion: ContractVersion.Gov, - name: getDisplayNameForChainId(chainId), - imageUrl: getImageUrlForChainId(chainId), - admin: '', - registeredSubDao: - !!childAddress && - !!getSupportedChainConfig(chainId)?.subDaos?.includes( - childAddress - ), - parentDao: null, - } - ) - } - }, -}) - /** * Retrieve all potential SubDAOs of the DAO from the indexer. */ diff --git a/packages/state/recoil/selectors/index.ts b/packages/state/recoil/selectors/index.ts index a7820615c..55c52c7d5 100644 --- a/packages/state/recoil/selectors/index.ts +++ b/packages/state/recoil/selectors/index.ts @@ -13,7 +13,6 @@ export * from './indexer' export * from './misc' export * from './nft' export * from './osmosis' -export * from './profile' export * from './proposal' export * from './skip' export * from './token' diff --git a/packages/state/recoil/selectors/profile.ts b/packages/state/recoil/selectors/profile.ts deleted file mode 100644 index dc9ccdedd..000000000 --- a/packages/state/recoil/selectors/profile.ts +++ /dev/null @@ -1,309 +0,0 @@ -import uniq from 'lodash.uniq' -import { noWait, selectorFamily, waitForAll } from 'recoil' - -import { - ChainId, - PfpkProfile, - ResolvedProfile, - UnifiedProfile, - WithChainId, -} from '@dao-dao/types' -import { - EMPTY_PFPK_PROFILE, - MAINNET, - PFPK_API_BASE, - STARGAZE_NAMES_CONTRACT, - getChainForChainId, - makeEmptyUnifiedProfile, - objectMatchesStructure, - processError, - toBech32Hash, - transformBech32Address, -} from '@dao-dao/utils' - -import { refreshWalletProfileAtom } from '../atoms/refresh' -import { cosmWasmClientForChainSelector } from './chain' -import { nftCardInfoSelector } from './nft' - -export const searchProfilesByNamePrefixSelector = selectorFamily< - ResolvedProfile[], - WithChainId<{ namePrefix: string }> ->({ - key: 'searchProfilesByNamePrefix', - get: - ({ namePrefix, chainId }) => - async ({ get }) => { - if (namePrefix.length < 3) { - return [] - } - - // Load profiles from PFPK API. - let profiles: ResolvedProfile[] = [] - try { - const response = await fetch( - PFPK_API_BASE + `/search/${chainId}/${namePrefix}` - ) - if (response.ok) { - const { profiles: _profiles } = (await response.json()) as { - profiles: ResolvedProfile[] - } - profiles = _profiles - } else { - console.error(await response.json()) - } - } catch (err) { - console.error(processError(err)) - } - - // Add refresher dependencies. - if (profiles.length > 0) { - get( - waitForAll( - profiles.map((hit) => - refreshWalletProfileAtom(toBech32Hash(hit.address)) - ) - ) - ) - } - - return profiles - }, -}) - -/** - * Get profile from PFPK given a wallet address on any chain. - */ -export const pfpkProfileSelector = selectorFamily({ - key: 'pfpkProfile', - get: - (walletAddress) => - ({ get }) => - get( - pfpkProfileForBech32HashSelector( - walletAddress && toBech32Hash(walletAddress) - ) - ), -}) - -/** - * Get profile from PFPK given a wallet address's bech32 hash. - */ -export const pfpkProfileForBech32HashSelector = selectorFamily< - PfpkProfile, - string ->({ - key: 'pfpkProfileForBech32Hash', - get: - (bech32Hash) => - async ({ get }) => { - if (!bech32Hash) { - return { ...EMPTY_PFPK_PROFILE } - } - - get(refreshWalletProfileAtom(bech32Hash)) - - try { - const response = await fetch(PFPK_API_BASE + `/bech32/${bech32Hash}`) - if (response.ok) { - const profile: PfpkProfile = await response.json() - - // If profile found, add refresher dependencies for the other chains - // in the profile. This ensures that the profile will update for all - // other chains when any of the other chains update the profile. - if (profile?.chains) { - get( - waitForAll( - uniq( - Object.values(profile.chains).map(({ address }) => - toBech32Hash(address) - ) - ).map((bech32Hash) => refreshWalletProfileAtom(bech32Hash)) - ) - ) - } - - return profile - } else { - console.error(await response.json().catch(() => response.statusText)) - } - } catch (err) { - console.error(err) - } - - return { ...EMPTY_PFPK_PROFILE } - }, -}) - -// Get name for address from Stargaze Names. -export const stargazeNameSelector = selectorFamily({ - key: 'stargazeName', - get: - (walletAddress) => - async ({ get }) => { - if (!walletAddress) { - return - } - - get(refreshWalletProfileAtom(walletAddress)) - - const client = get( - cosmWasmClientForChainSelector( - MAINNET ? ChainId.StargazeMainnet : ChainId.StargazeTestnet - ) - ) - - try { - return await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { - name: { address: walletAddress }, - }) - } catch {} - }, -}) - -// Get image for address from Stargaze Names. -export const stargazeNameImageForAddressSelector = selectorFamily< - string | undefined, - string ->({ - key: 'stargazeNameImageForAddress', - get: - (walletAddress) => - async ({ get }) => { - // Get name associated with address. - const name = get(stargazeNameSelector(walletAddress)) - if (!name) { - return - } - - const chainId = MAINNET - ? ChainId.StargazeMainnet - : ChainId.StargazeTestnet - const client = get(cosmWasmClientForChainSelector(chainId)) - - // Get NFT associated with name. - let response - try { - response = await client.queryContractSmart(STARGAZE_NAMES_CONTRACT, { - image_n_f_t: { name }, - }) - } catch { - return - } - - // If NFT exists, get image associated with NFT. - if ( - objectMatchesStructure(response, { - collection: {}, - token_id: {}, - }) - ) { - const { imageUrl } = get( - nftCardInfoSelector({ - chainId, - collection: response.collection, - tokenId: response.token_id, - }) - ) - - return imageUrl - } - }, -}) - -export const profileSelector = selectorFamily< - UnifiedProfile, - WithChainId<{ address: string }> ->({ - key: 'profile', - get: - ({ address, chainId }) => - ({ get }) => { - const profile = makeEmptyUnifiedProfile(chainId, address) - if (!address) { - return profile - } - - get(refreshWalletProfileAtom(toBech32Hash(address))) - - const pfpkProfile = get(pfpkProfileSelector(address)) - if (pfpkProfile) { - profile.uuid = pfpkProfile.uuid - profile.nonce = pfpkProfile.nonce - profile.name = pfpkProfile.name - profile.nft = pfpkProfile.nft - profile.chains = pfpkProfile.chains - } - - // Load Stargaze name as backup if no PFPK name set. - if (!profile.name) { - const stargazeNameLoadable = get( - noWait( - stargazeNameSelector( - // Use profile address for Stargaze if set, falling back to - // transforming the address (which is unreliable due to different - // chains using different HD paths). - profile.chains[ChainId.StargazeMainnet]?.address || - transformBech32Address(address, ChainId.StargazeMainnet) - ) - ) - ) - if ( - stargazeNameLoadable.state === 'hasValue' && - stargazeNameLoadable.contents - ) { - profile.name = - stargazeNameLoadable.contents + - '.' + - getChainForChainId(chainId).bech32_prefix - profile.nameSource = 'stargaze' - } - } - - // Set `imageUrl` to PFPK image, defaulting to fallback image. - profile.imageUrl = pfpkProfile?.nft?.imageUrl || profile.backupImageUrl - - // If NFT present from PFPK, get image from token once loaded. - if (pfpkProfile?.nft) { - // Don't wait for NFT info to load. When it loads, it will update. - const nftInfoLoadable = get( - noWait( - nftCardInfoSelector({ - collection: pfpkProfile.nft.collectionAddress, - tokenId: pfpkProfile.nft.tokenId, - chainId: pfpkProfile.nft.chainId, - }) - ) - ) - - // Set `imageUrl` if defined, overriding PFPK image and backup. - if ( - nftInfoLoadable.state === 'hasValue' && - nftInfoLoadable.contents?.imageUrl - ) { - profile.imageUrl = nftInfoLoadable.contents.imageUrl - } - - // Load Stargaze name image if no PFPK image. - } else if (profile.nameSource === 'stargaze') { - const stargazeNameImageLoadable = get( - noWait( - stargazeNameImageForAddressSelector( - // Use profile address for Stargaze if set, falling back to - // transforming the address (which is unreliable due to different - // chains using different HD paths). - profile.chains[ChainId.StargazeMainnet]?.address || - transformBech32Address(address, ChainId.StargazeMainnet) - ) - ) - ) - if ( - stargazeNameImageLoadable.state === 'hasValue' && - stargazeNameImageLoadable.contents - ) { - profile.imageUrl = stargazeNameImageLoadable.contents - } - } - - return profile - }, -}) diff --git a/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx b/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx index d9b78a0a1..afd204a87 100644 --- a/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx +++ b/packages/stateful/actions/core/dao_governance/CreateDao/index.tsx @@ -1,12 +1,7 @@ +import { useQueryClient } from '@tanstack/react-query' import { useCallback } from 'react' -import { constSelector } from 'recoil' -import { daoParentInfoSelector } from '@dao-dao/state/recoil' -import { - DaoEmoji, - useCachedLoadingWithError, - useChain, -} from '@dao-dao/stateless' +import { DaoEmoji, useChain } from '@dao-dao/stateless' import { ActionComponent, ActionKey, @@ -18,19 +13,24 @@ import { import { decodeJsonFromBase64, objectMatchesStructure } from '@dao-dao/utils' import { LinkWrapper } from '../../../../components' +import { useCachedLoadingWithErrorQuery } from '../../../../hooks' +import { daoQueries } from '../../../../queries' import { CreateDaoComponent, CreateDaoData } from './Component' const Component: ActionComponent = (props) => { const { chain_id: chainId } = useChain() // If admin is set, attempt to load parent DAO info. - const parentDao = useCachedLoadingWithError( - props.data.admin - ? daoParentInfoSelector({ - chainId, - parentAddress: props.data.admin, - }) - : constSelector(undefined) + const parentDao = useCachedLoadingWithErrorQuery( + daoQueries.parentInfo( + useQueryClient(), + props.data.admin + ? { + chainId, + parentAddress: props.data.admin, + } + : undefined + ) ) return ( diff --git a/packages/stateful/actions/core/treasury/Spend/index.tsx b/packages/stateful/actions/core/treasury/Spend/index.tsx index b63b32d13..a63dd3e39 100644 --- a/packages/stateful/actions/core/treasury/Spend/index.tsx +++ b/packages/stateful/actions/core/treasury/Spend/index.tsx @@ -1,4 +1,5 @@ import { coin, coins } from '@cosmjs/amino' +import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useState } from 'react' import { useFormContext } from 'react-hook-form' import { constSelector, useRecoilValue } from 'recoil' @@ -69,9 +70,10 @@ import { } from '@dao-dao/utils' import { AddressInput } from '../../../../components' +import { useCachedLoadingWithErrorQuery } from '../../../../hooks' import { useWallet } from '../../../../hooks/useWallet' import { useProposalModuleAdapterCommonContextIfAvailable } from '../../../../proposal-module-adapter/react/context' -import { entitySelector } from '../../../../recoil' +import { entityQueries } from '../../../../queries/entity' import { useTokenBalances } from '../../../hooks/useTokenBalances' import { useActionOptions } from '../../../react' import { @@ -503,13 +505,17 @@ const Component: ActionComponent = (props) => { ]) const [currentEntity, setCurrentEntity] = useState() - const loadingEntity = useCachedLoadingWithError( - validRecipient - ? entitySelector({ - address: recipient, - chainId: toChainId, - }) - : undefined + const queryClient = useQueryClient() + const loadingEntity = useCachedLoadingWithErrorQuery( + entityQueries.info( + queryClient, + validRecipient + ? { + address: recipient, + chainId: toChainId, + } + : undefined + ) ) // Cache last successfully loaded entity. useEffect(() => { diff --git a/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx b/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx index 9fd55e404..610b66c8a 100644 --- a/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx +++ b/packages/stateful/actions/core/treasury/token_swap/stateful/InstantiateTokenSwap.tsx @@ -24,8 +24,8 @@ import { } from '@dao-dao/utils' import { AddressInput, Trans } from '../../../../../components' +import { useEntity } from '../../../../../hooks' import { useWallet } from '../../../../../hooks/useWallet' -import { entitySelector } from '../../../../../recoil' import { useTokenBalances } from '../../../../hooks/useTokenBalances' import { useActionOptions } from '../../../../react' import { InstantiateTokenSwap as StatelessInstantiateTokenSwap } from '../stateless/InstantiateTokenSwap' @@ -237,27 +237,23 @@ const InnerInstantiateTokenSwap: ActionComponent< // Get counterparty entity, which reverse engineers a DAO from its polytone // proxy. - const entityLoading = useCachedLoading( + const { entity } = useEntity( counterpartyAddress && isValidBech32Address(counterpartyAddress, bech32Prefix) - ? entitySelector({ - chainId, - address: counterpartyAddress, - }) - : undefined, - undefined + ? counterpartyAddress + : '' ) // Try to retrieve governance token address, failing if not a cw20-based DAO. const counterpartyDaoGovernanceTokenAddressLoadable = useRecoilValueLoadable( - !entityLoading.loading && - entityLoading.data?.type === EntityType.Dao && + !entity.loading && + entity.data.type === EntityType.Dao && // Only care about loading the governance token if on the chain we're // creating the token swap on. - entityLoading.data.chainId === chainId + entity.data.chainId === chainId ? DaoCoreV2Selectors.tryFetchGovernanceTokenAddressSelector({ chainId, - contractAddress: entityLoading.data.address, + contractAddress: entity.data.address, }) : constSelector(undefined) ) @@ -265,12 +261,12 @@ const InnerInstantiateTokenSwap: ActionComponent< // Load balances as loadables since they refresh automatically on a timer. const counterpartyTokenBalances = useCachedLoading( counterpartyAddress && - !entityLoading.loading && - entityLoading.data && + !entity.loading && + entity.data && counterpartyDaoGovernanceTokenAddressLoadable.state !== 'loading' ? genericTokenBalancesSelector({ - chainId: entityLoading.data.chainId, - address: entityLoading.data.address, + chainId: entity.data.chainId, + address: entity.data.address, cw20GovernanceTokenAddress: counterpartyDaoGovernanceTokenAddressLoadable.state === 'hasValue' ? counterpartyDaoGovernanceTokenAddressLoadable.contents diff --git a/packages/stateful/components/AddressInput.tsx b/packages/stateful/components/AddressInput.tsx index d36d476a2..3b50953d9 100644 --- a/packages/stateful/components/AddressInput.tsx +++ b/packages/stateful/components/AddressInput.tsx @@ -1,3 +1,4 @@ +import { useQueries, useQueryClient } from '@tanstack/react-query' import Fuse from 'fuse.js' import { useMemo } from 'react' import { FieldValues, Path, useFormContext } from 'react-hook-form' @@ -5,14 +6,11 @@ import { useTranslation } from 'react-i18next' import { waitForNone } from 'recoil' import { useDeepCompareMemoize } from 'use-deep-compare-effect' -import { - searchDaosSelector, - searchProfilesByNamePrefixSelector, -} from '@dao-dao/state/recoil' +import { profileQueries } from '@dao-dao/state/query' +import { searchDaosSelector } from '@dao-dao/state/recoil' import { AddressInput as StatelessAddressInput, useCachedLoadable, - useCachedLoading, useChain, } from '@dao-dao/stateless' import { AddressInputProps, Entity, EntityType } from '@dao-dao/types' @@ -20,9 +18,11 @@ import { POLYTONE_CONFIG_PER_CHAIN, getAccountAddress, isValidBech32Address, + makeCombineQueryResultsIntoLoadingData, } from '@dao-dao/utils' -import { entitySelector } from '../recoil' +import { useCachedLoadingWithErrorQuery } from '../hooks' +import { entityQueries } from '../queries/entity' import { EntityDisplay } from './EntityDisplay' export const AddressInput = < @@ -45,14 +45,17 @@ export const AddressInput = < // Don't search name if it's an address. !isValidBech32Address(formValue, currentChain.bech32_prefix) - const searchProfilesLoadable = useCachedLoadable( - hasFormValue && props.type !== 'contract' - ? searchProfilesByNamePrefixSelector({ - chainId: currentChain.chain_id, - namePrefix: formValue, - }) - : undefined + const searchProfilesLoading = useCachedLoadingWithErrorQuery( + profileQueries.searchByNamePrefix( + hasFormValue && props.type !== 'contract' + ? { + chainId: currentChain.chain_id, + namePrefix: formValue, + } + : undefined + ) ) + // Search DAOs on current chains and all polytone-connected chains so we can // find polytone accounts. const searchDaosLoadable = useCachedLoadable( @@ -76,13 +79,14 @@ export const AddressInput = < : undefined ) - const loadingEntities = useCachedLoading( - waitForNone([ - ...(searchProfilesLoadable.state === 'hasValue' - ? searchProfilesLoadable.contents.map(({ address }) => - entitySelector({ - address, + const queryClient = useQueryClient() + const loadingEntities = useQueries({ + queries: [ + ...(!searchProfilesLoading.loading && !searchProfilesLoading.errored + ? searchProfilesLoading.data.map(({ address }) => + entityQueries.info(queryClient, { chainId: currentChain.chain_id, + address, }) ) : []), @@ -90,7 +94,7 @@ export const AddressInput = < ? searchDaosLoadable.contents.flatMap((loadable) => loadable.state === 'hasValue' ? loadable.contents.map(({ chainId, id: address }) => - entitySelector({ + entityQueries.info(queryClient, { chainId, address, }) @@ -98,34 +102,38 @@ export const AddressInput = < : [] ) : []), - ]), - [] - ) - - const entities = loadingEntities.loading - ? [] - : // Only show entities that are on the current chain or are DAOs with - // accounts (polytone probably) on the current chain. - loadingEntities.data - .filter( - (entity) => - entity.state === 'hasValue' && - (entity.contents.chainId === currentChain.chain_id || - (entity.contents.type === EntityType.Dao && - getAccountAddress({ - accounts: entity.contents.daoInfo.accounts, - chainId: currentChain.chain_id, - }))) - ) - .map((entity) => entity.contents as Entity) + ], + combine: useMemo( + () => + makeCombineQueryResultsIntoLoadingData({ + firstLoad: 'none', + transform: (entities) => + // Only show entities that are on the current chain or are DAOs with + // accounts (polytone probably) on the current chain. + entities.filter( + (entity) => + entity.chainId === currentChain.chain_id || + (entity.type === EntityType.Dao && + getAccountAddress({ + accounts: entity.daoInfo.accounts, + chainId: currentChain.chain_id, + })) + ), + }), + [currentChain.chain_id] + ), + }) // Use Fuse to search combined profiles and DAOs by name so that is most // relevant (as opposed to just sticking DAOs after profiles). const fuse = useMemo( - () => new Fuse(entities, { keys: ['name'] }), + () => + new Fuse(loadingEntities.loading ? [] : loadingEntities.data, { + keys: ['name'], + }), // Only reinstantiate fuse when entities deeply changes. // eslint-disable-next-line react-hooks/exhaustive-deps - useDeepCompareMemoize([entities]) + useDeepCompareMemoize([loadingEntities]) ) const searchedEntities = useMemo( () => (hasFormValue ? fuse.search(formValue).map(({ item }) => item) : []), @@ -143,9 +151,9 @@ export const AddressInput = < entities: searchedEntities, loading: (props.type !== 'contract' && - (searchProfilesLoadable.state === 'loading' || - (searchProfilesLoadable.state === 'hasValue' && - searchProfilesLoadable.updating))) || + (searchProfilesLoading.loading || + (!searchProfilesLoading.errored && + searchProfilesLoading.updating))) || (props.type !== 'wallet' && (searchDaosLoadable.state === 'loading' || (searchDaosLoadable.state === 'hasValue' && @@ -154,10 +162,7 @@ export const AddressInput = < (loadable) => loadable.state === 'loading' ))))) || loadingEntities.loading || - !!loadingEntities.updating || - loadingEntities.data.some( - (loadable) => loadable.state === 'loading' - ), + !!loadingEntities.updating, } : undefined } diff --git a/packages/stateful/components/pages/Account.tsx b/packages/stateful/components/pages/Account.tsx index 8138d5c96..cb656239a 100644 --- a/packages/stateful/components/pages/Account.tsx +++ b/packages/stateful/components/pages/Account.tsx @@ -1,20 +1,20 @@ import { fromBech32 } from '@cosmjs/encoding' +import { useQueryClient } from '@tanstack/react-query' import { NextPage } from 'next' import { NextSeo } from 'next-seo' import { useRouter } from 'next/router' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { profileQueries } from '@dao-dao/state/query' import { averageColorSelector, - profileSelector, walletHexPublicKeySelector, } from '@dao-dao/state/recoil' import { ChainProvider, Account as StatelessAccount, useCachedLoadable, - useCachedLoading, useCachedLoadingWithError, useThemeContext, } from '@dao-dao/stateless' @@ -29,6 +29,7 @@ import { transformBech32Address, } from '@dao-dao/utils' +import { useCachedLoadingQuery } from '../../hooks' import { ButtonLink } from '../ButtonLink' import { PageHeaderContent } from '../PageHeaderContent' import { SuspenseLoader } from '../SuspenseLoader' @@ -68,8 +69,8 @@ export const Account: NextPage = () => { }) ) - const profile = useCachedLoading( - profileSelector({ + const profile = useCachedLoadingQuery( + profileQueries.unified(useQueryClient(), { chainId: configuredChain.chainId, address: accountAddress, }), diff --git a/packages/stateful/hooks/useEntity.ts b/packages/stateful/hooks/useEntity.ts index 8ea9614d1..5dfda27a4 100644 --- a/packages/stateful/hooks/useEntity.ts +++ b/packages/stateful/hooks/useEntity.ts @@ -1,7 +1,8 @@ import { fromBech32 } from '@cosmjs/encoding' +import { useQueryClient } from '@tanstack/react-query' import { useMemo } from 'react' -import { useCachedLoading, useChain } from '@dao-dao/stateless' +import { useChain } from '@dao-dao/stateless' import { Entity, EntityType, LoadingData } from '@dao-dao/types' import { getConfiguredChains, @@ -9,7 +10,8 @@ import { makeEmptyUnifiedProfile, } from '@dao-dao/utils' -import { entitySelector } from '../recoil' +import { entityQueries } from '../queries/entity' +import { useCachedLoadingQuery } from './useCachedLoadingQuery' export type UseEntityReturn = { /** @@ -52,14 +54,17 @@ export const useEntity = (address: string): UseEntityReturn => { return currentChainId }, [address, currentBech32Prefix, currentChainId]) - const entity = useCachedLoading( - address - ? entitySelector({ - chainId, - address, - }) - : undefined, - // Should never error as it uses loadables internally. + const entity = useCachedLoadingQuery( + entityQueries.info( + useQueryClient(), + address + ? { + chainId, + address, + } + : undefined + ), + // Should never error but just in case... { type: EntityType.Wallet, chainId, diff --git a/packages/stateful/hooks/useManageProfile.ts b/packages/stateful/hooks/useManageProfile.ts index d206c4f3b..9d676baa5 100644 --- a/packages/stateful/hooks/useManageProfile.ts +++ b/packages/stateful/hooks/useManageProfile.ts @@ -1,10 +1,9 @@ import { toHex } from '@cosmjs/encoding' +import { useQueries, useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { waitForNone } from 'recoil' -import { profileSelector } from '@dao-dao/state' -import { useCachedLoading, useCachedLoadingWithError } from '@dao-dao/stateless' +import { profileQueries } from '@dao-dao/state' import { AddChainsFunction, AddChainsStatus, @@ -18,11 +17,13 @@ import { PFPK_API_BASE, SignedBody, getDisplayNameForChainId, + makeCombineQueryResultsIntoLoadingData, makeEmptyUnifiedProfile, makeManuallyResolvedPromise, signOffChainAuth, } from '@dao-dao/utils' +import { useCachedLoadingQuery } from './useCachedLoadingQuery' import { useCfWorkerAuthPostRequest } from './useCfWorkerAuthPostRequest' import { useRefreshProfile } from './useRefreshProfile' import { useWallet } from './useWallet' @@ -145,8 +146,8 @@ export const useManageProfile = ({ loadAccount: true, }) - const profile = useCachedLoading( - profileSelector({ + const profile = useCachedLoadingQuery( + profileQueries.unified(useQueryClient(), { chainId: walletChainId, address, }), @@ -387,16 +388,22 @@ export const useManageProfile = ({ ).filter( (chainWallet) => !!chainWallet.isWalletConnected && !!chainWallet.address ) - const otherChainWalletProfiles = useCachedLoadingWithError( - waitForNone( - otherConnectedChainWallets.map((chainWallet) => - profileSelector({ - chainId: chainWallet.chainId, - address: chainWallet.address!, - }) - ) - ) - ) + const queryClient = useQueryClient() + const otherChainWalletProfiles = useQueries({ + queries: otherConnectedChainWallets.map((chainWallet) => + profileQueries.unified(queryClient, { + chainId: chainWallet.chainId, + address: chainWallet.address!, + }) + ), + combine: useMemo( + () => + makeCombineQueryResultsIntoLoadingData({ + firstLoad: 'none', + }), + [] + ), + }) const merge: UseManageProfileReturn['merge'] = useMemo(() => { // Get all profiles attached to this wallet that are different from the @@ -404,21 +411,17 @@ export const useManageProfile = ({ const profilesToMerge = currentChainWallet && !profile.loading && - !otherChainWalletProfiles.loading && - !otherChainWalletProfiles.errored - ? otherChainWalletProfiles.data.flatMap((loadable) => { - const chainProfile = loadable.valueMaybe() + !otherChainWalletProfiles.loading + ? otherChainWalletProfiles.data.flatMap((chainProfile) => { if ( - // If not yet loaded, ignore. - !chainProfile || // If profile exists, UUID matches current chain wallet profile // and this chain wallet has been added to the profile, ignore. If // profile does not exist or chain has not been explicitly added, // we want to merge it into the current profile. - (chainProfile.uuid && - chainProfile.uuid === profile.data.uuid && - profile.data.chains[chainProfile.source.chainId]?.address === - chainProfile.source.address) + chainProfile.uuid && + chainProfile.uuid === profile.data.uuid && + profile.data.chains[chainProfile.source.chainId]?.address === + chainProfile.source.address ) { return [] } diff --git a/packages/stateful/hooks/useProfile.ts b/packages/stateful/hooks/useProfile.ts index 33b0c47a7..974424203 100644 --- a/packages/stateful/hooks/useProfile.ts +++ b/packages/stateful/hooks/useProfile.ts @@ -1,5 +1,6 @@ -import { profileSelector } from '@dao-dao/state' -import { useCachedLoading } from '@dao-dao/stateless' +import { useQueryClient } from '@tanstack/react-query' + +import { profileQueries } from '@dao-dao/state' import { LoadingData, ProfileChain, UnifiedProfile } from '@dao-dao/types' import { MAINNET, @@ -10,6 +11,7 @@ import { toBech32Hash, } from '@dao-dao/utils' +import { useCachedLoadingQuery } from './useCachedLoadingQuery' import { useRefreshProfile } from './useRefreshProfile' import { useWallet } from './useWallet' @@ -103,8 +105,8 @@ export const useProfile = ({ const profileAddress = address || currentAddress - const profile = useCachedLoading( - profileSelector({ + const profile = useCachedLoadingQuery( + profileQueries.unified(useQueryClient(), { chainId: walletChainId, address: profileAddress, }), diff --git a/packages/stateful/hooks/useRefreshProfile.ts b/packages/stateful/hooks/useRefreshProfile.ts index 79b4e2f45..4612dfc77 100644 --- a/packages/stateful/hooks/useRefreshProfile.ts +++ b/packages/stateful/hooks/useRefreshProfile.ts @@ -1,7 +1,8 @@ +import { useQueryClient } from '@tanstack/react-query' import uniq from 'lodash.uniq' -import { useRecoilCallback } from 'recoil' +import { useCallback } from 'react' -import { refreshWalletProfileAtom } from '@dao-dao/state/recoil' +import { useUpdatingRef } from '@dao-dao/stateless' import { LoadingData, UnifiedProfile } from '@dao-dao/types' import { toBech32Hash } from '@dao-dao/utils' @@ -15,28 +16,41 @@ import { toBech32Hash } from '@dao-dao/utils' export const useRefreshProfile = ( address: string | string[], profile: LoadingData -) => - useRecoilCallback( - ({ set }) => - () => { - // Refresh all hashes in the profile(s). This ensures updates made by - // one public key propagate to the other public keys in the profile(s). - const hashes = uniq( - [ - ...[address].flat(), - ...(profile.loading - ? [] - : [profile.data] - .flat() - .flatMap((profile) => - Object.values(profile.chains).map(({ address }) => address) - )), - ].flatMap((address) => toBech32Hash(address) || []) - ) +) => { + const queryClient = useQueryClient() - hashes.forEach((hash) => - set(refreshWalletProfileAtom(hash), (id) => id + 1) - ) - }, - [profile] - ) + // Stabilize reference so callback doesn't change. The latest values will be + // used when refresh is called. + const addressRef = useUpdatingRef(address) + const profileRef = useUpdatingRef(profile) + + return useCallback(() => { + // Refresh all hashes in the profile(s). This ensures updates made by + // one public key propagate to the other public keys in the profile(s). + const hashes = uniq( + [ + ...[addressRef.current].flat(), + ...(profileRef.current.loading + ? [] + : [profileRef.current.data] + .flat() + .flatMap((profile) => + Object.values(profile.chains).map(({ address }) => address) + )), + ].flatMap((address) => toBech32Hash(address) || []) + ) + + hashes.forEach((bech32Hash) => + queryClient.invalidateQueries({ + queryKey: [ + { + category: 'profile', + options: { + bech32Hash, + }, + }, + ], + }) + ) + }, [addressRef, profileRef, queryClient]) +} diff --git a/packages/stateful/queries/dao.ts b/packages/stateful/queries/dao.ts index f6f94e96b..d0b2d0031 100644 --- a/packages/stateful/queries/dao.ts +++ b/packages/stateful/queries/dao.ts @@ -96,111 +96,102 @@ export const fetchDaoInfo = async ( const supportedFeatures = getSupportedFeatures(coreVersion) const [ - // Not allowed to fail. - [ - votingModuleInfo, - created, - proposalModules, - _items, - polytoneProxies, - accounts, - ], - // Allowed to fail. - [parentDaoResponse, isActiveResponse, activeThresholdResponse], + parentDao, + votingModuleInfo, + created, + proposalModules, + _items, + polytoneProxies, + accounts, + isActive, + activeThreshold, ] = await Promise.all([ - // Not allowed to fail. - Promise.all([ - // Check if indexer returned this already. - 'votingModuleInfo' in state - ? ({ info: state.votingModuleInfo } as InfoResponse) - : queryClient.fetchQuery( - contractQueries.info(queryClient, { - chainId, - address: state.voting_module, - }) - ), - // Check if indexer returned this already. - 'createdAt' in state && state.createdAt - ? Date.parse(state.createdAt) - : queryClient - .fetchQuery( - contractQueries.instantiationTime(queryClient, { - chainId, - address: coreAddress, - }) - ) - .catch(() => null), - queryClient.fetchQuery( - daoQueries.proposalModules(queryClient, { - chainId, - coreAddress, - }) - ), - queryClient.fetchQuery( - daoDaoCoreQueries.listAllItems(queryClient, { - chainId, - contractAddress: coreAddress, - }) - ), - // Check if indexer returned this already. - 'polytoneProxies' in state && state.polytoneProxies - ? (state.polytoneProxies as PolytoneProxies) - : queryClient.fetchQuery( - polytoneQueries.proxies(queryClient, { - chainId, - address: coreAddress, - }) - ), - queryClient.fetchQuery( - accountQueries.list(queryClient, { - chainId, - address: coreAddress, - }) - ), - ]), - // May fail. - Promise.allSettled([ - state.admin && state.admin !== coreAddress - ? queryClient.fetchQuery( + state.admin && state.admin !== coreAddress + ? queryClient + .fetchQuery( daoQueries.parentInfo(queryClient, { chainId, parentAddress: state.admin, subDaoAddress: coreAddress, }) ) - : null, - queryClient.fetchQuery( + .catch(() => null) + : null, + // Check if indexer returned this already. + 'votingModuleInfo' in state + ? ({ info: state.votingModuleInfo } as InfoResponse) + : queryClient.fetchQuery( + contractQueries.info(queryClient, { + chainId, + address: state.voting_module, + }) + ), + // Check if indexer returned this already. + 'createdAt' in state && state.createdAt + ? Date.parse(state.createdAt) + : queryClient + .fetchQuery( + contractQueries.instantiationTime(queryClient, { + chainId, + address: coreAddress, + }) + ) + .catch(() => null), + queryClient.fetchQuery( + daoQueries.proposalModules(queryClient, { + chainId, + coreAddress, + }) + ), + queryClient.fetchQuery( + daoDaoCoreQueries.listAllItems(queryClient, { + chainId, + contractAddress: coreAddress, + }) + ), + // Check if indexer returned this already. + 'polytoneProxies' in state && state.polytoneProxies + ? (state.polytoneProxies as PolytoneProxies) + : queryClient.fetchQuery( + polytoneQueries.proxies(queryClient, { + chainId, + address: coreAddress, + }) + ), + queryClient.fetchQuery( + accountQueries.list(queryClient, { + chainId, + address: coreAddress, + }) + ), + + // Some voting modules don't support the active threshold queries, so if the + // queries fail, assume active and no threshold. + queryClient + .fetchQuery( votingModuleQueries.isActive({ chainId, address: state.voting_module, }) - ), - queryClient.fetchQuery( + ) + // If isActive query fails, just assume it is. + .catch(() => true), + queryClient + .fetchQuery( votingModuleQueries.activeThresold(queryClient, { chainId, address: state.voting_module, }) - ), - ]), + ) + .then(({ active_threshold }) => active_threshold || null) + .catch(() => null), ]) const votingModuleContractName = votingModuleInfo.info.contract - // Some voting modules don't support the isActive query, so if the query - // fails, assume active. - const isActive = - isActiveResponse.status !== 'fulfilled' || isActiveResponse.value - const activeThreshold = - (activeThresholdResponse.status === 'fulfilled' && - activeThresholdResponse.value.active_threshold) || - null - // Convert items list into map. const items = Object.fromEntries(_items) - const parentDao = - parentDaoResponse.status === 'fulfilled' ? parentDaoResponse.value : null - return { chainId, coreAddress, @@ -232,6 +223,7 @@ export const fetchDaoParentInfo = async ( chainId, parentAddress, subDaoAddress, + ignoreParents, }: { chainId: string parentAddress: string @@ -240,7 +232,11 @@ export const fetchDaoParentInfo = async ( * This will set `registeredSubDao` appropriately. Otherwise, if undefined, * `registeredSubDao` will be set to false. */ - subDaoAddress: string + subDaoAddress?: string + /** + * Prevent infinite loop if DAO SubDAO loop exists. + */ + ignoreParents?: string[] } ): Promise => { // If address is a DAO contract... @@ -290,6 +286,22 @@ export const fetchDaoParentInfo = async ( ) ).some(({ addr }) => addr === subDaoAddress) + // Recursively fetch parent. + const parentDao = + parentAdmin && parentAdmin !== parentAddress + ? await queryClient + .fetchQuery( + daoQueries.parentInfo(queryClient, { + chainId, + parentAddress: parentAdmin, + subDaoAddress: parentAddress, + // Add address to ignore list to prevent infinite loops. + ignoreParents: [...(ignoreParents || []), parentAddress], + }) + ) + .catch(() => null) + : null + return { chainId, coreAddress: parentAddress, @@ -298,13 +310,12 @@ export const fetchDaoParentInfo = async ( imageUrl: image_url || getFallbackImage(parentAddress), admin: parentAdmin ?? '', registeredSubDao, - // TODO(rq): recursively fetch parent infos but prevent infinite loops - parentDao: null, + parentDao, } } else { // If address is the chain's x/gov module... const isGov = await queryClient.fetchQuery( - chainQueries.isAddressModule({ + chainQueries.isAddressModule(queryClient, { chainId, address: parentAddress, moduleName: 'gov', @@ -385,7 +396,9 @@ export const daoQueries = { */ info: ( queryClient: QueryClient, - // If undefined, query will be disabled. + /** + * If undefined, query will be disabled. + */ options?: Parameters[1] ) => queryOptions({ @@ -397,11 +410,16 @@ export const daoQueries = { */ parentInfo: ( queryClient: QueryClient, - options: Parameters[1] + /** + * If undefined, query will be disabled. + */ + options?: Parameters[1] ) => queryOptions({ queryKey: ['dao', 'parentInfo', options], - queryFn: () => fetchDaoParentInfo(queryClient, options), + queryFn: options + ? () => fetchDaoParentInfo(queryClient, options) + : skipToken, }), /** * Fetch DAO info for all of a DAO's SubDAOs. diff --git a/packages/stateful/queries/entity.ts b/packages/stateful/queries/entity.ts new file mode 100644 index 000000000..4354a61af --- /dev/null +++ b/packages/stateful/queries/entity.ts @@ -0,0 +1,214 @@ +import { QueryClient, queryOptions, skipToken } from '@tanstack/react-query' + +import { + chainQueries, + contractQueries, + cw1WhitelistQueries, + polytoneQueries, + profileQueries, +} from '@dao-dao/state/query' +import { Entity, EntityType } from '@dao-dao/types' +import { + getChainForChainId, + getFallbackImage, + getImageUrlForChainId, + isValidWalletAddress, +} from '@dao-dao/utils' + +import { daoQueries } from './dao' + +/** + * Fetch entity information for any address on any chain. + */ +export const fetchEntityInfo = async ( + queryClient: QueryClient, + { + chainId, + address, + ignoreEntities, + }: { + chainId: string + address: string + /** + * Prevent infinite loop if cw1-whitelist nests itself. + */ + ignoreEntities?: string[] + } +): Promise => { + const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) + + // Check if address is module account. + const moduleName = await queryClient + .fetchQuery( + chainQueries.moduleName({ + chainId, + address, + }) + ) + .catch(() => null) + if (moduleName) { + const entity: Entity = { + type: EntityType.Module, + chainId, + address, + name: moduleName, + imageUrl: getImageUrlForChainId(chainId), + } + return entity + } + + const [ + daoInfo, + entityFromPolytoneProxy, + walletProfile, + cw1WhitelistAdminEntities, + ] = await Promise.all([ + // Attempt to load DAO. + queryClient + .fetchQuery( + contractQueries.isDao(queryClient, { + chainId, + address, + }) + ) + .then((isDao) => + isDao + ? queryClient.fetchQuery( + daoQueries.info(queryClient, { + chainId, + coreAddress: address, + }) + ) + : undefined + ) + .catch(() => undefined), + // Attempt to load polytone proxy. + queryClient + .fetchQuery( + contractQueries.isPolytoneProxy(queryClient, { + chainId, + address, + }) + ) + .then(async (isPolytoneProxy): Promise => { + if (!isPolytoneProxy) { + return + } + + const controller = await queryClient.fetchQuery( + polytoneQueries.reverseLookupProxy(queryClient, { + chainId, + address, + }) + ) + + return { + ...(await queryClient.fetchQuery( + entityQueries.info(queryClient, { + chainId: controller.chainId, + address: controller.remoteAddress, + }) + )), + polytoneProxy: { + chainId, + address, + }, + } + }) + .catch(() => undefined), + // Attempt to load wallet profile. + isValidWalletAddress(address, bech32Prefix) + ? queryClient.fetchQuery( + profileQueries.unified(queryClient, { + chainId, + address, + }) + ) + : undefined, + // Attempt to load cw1-whitelist admins. + queryClient + .fetchQuery( + cw1WhitelistQueries.adminsIfCw1Whitelist(queryClient, { + chainId, + contractAddress: address, + }) + ) + .then((admins): Promise | undefined => { + if (!admins) { + return + } + + return Promise.all( + admins.map((admin) => + ignoreEntities?.includes(admin) + ? // Placeholder entity to prevent infinite loop. + { + chainId, + type: EntityType.Wallet, + address: admin, + name: null, + imageUrl: getFallbackImage(admin), + } + : queryClient.fetchQuery( + entityQueries.info(queryClient, { + chainId, + address: admin, + // Add address to ignore list to prevent infinite loops. + ignoreEntities: [...(ignoreEntities || []), address], + }) + ) + ) + ) + }) + .catch(() => undefined), + ]) + + if (daoInfo) { + return { + type: EntityType.Dao, + daoInfo, + chainId, + address, + name: daoInfo.name, + imageUrl: daoInfo.imageUrl || getFallbackImage(address), + } + } else if (entityFromPolytoneProxy) { + return entityFromPolytoneProxy + } else if (cw1WhitelistAdminEntities) { + return { + type: EntityType.Cw1Whitelist, + chainId, + address, + name: null, + imageUrl: getFallbackImage(address), + entities: cw1WhitelistAdminEntities, + } + } else { + // Default to wallet. + return { + type: EntityType.Wallet, + chainId, + address, + name: walletProfile?.name || null, + imageUrl: walletProfile?.imageUrl || getFallbackImage(address), + profile: walletProfile, + } + } +} + +export const entityQueries = { + /** + * Fetch entity. + */ + info: ( + queryClient: QueryClient, + // If undefined, query will be disabled. + options?: Parameters[1] + ) => + queryOptions({ + queryKey: ['entity', 'info', options], + queryFn: options + ? () => fetchEntityInfo(queryClient, options) + : skipToken, + }), +} diff --git a/packages/stateful/queries/index.ts b/packages/stateful/queries/index.ts index c89863b68..5ea48df29 100644 --- a/packages/stateful/queries/index.ts +++ b/packages/stateful/queries/index.ts @@ -1 +1,2 @@ export * from './dao' +export * from './entity' diff --git a/packages/stateful/recoil/selectors/dao.ts b/packages/stateful/recoil/selectors/dao.ts index 2b3ce56ff..62c0036b8 100644 --- a/packages/stateful/recoil/selectors/dao.ts +++ b/packages/stateful/recoil/selectors/dao.ts @@ -11,26 +11,20 @@ import { DaoVotingCw20StakedSelectors, accountsSelector, contractInfoSelector, - contractInstantiateTimeSelector, contractVersionSelector, daoDropdownInfoSelector, - daoParentInfoSelector, daoTvlSelector, daoVetoableDaosSelector, followingDaosSelector, govProposalsSelector, isDaoSelector, - moduleAddressSelector, nativeDelegatedBalanceSelector, queryWalletIndexerSelector, refreshProposalsIdAtom, - reverseLookupPolytoneProxySelector, } from '@dao-dao/state' import { DaoCardLazyData, - DaoInfo, DaoPageMode, - DaoParentInfo, DaoSource, DaoWithDropdownVetoableProposalList, DaoWithVetoableProposals, @@ -41,11 +35,8 @@ import { } from '@dao-dao/types' import { DaoVotingCw20StakedAdapterId, - getDaoInfoForChainId, getDaoProposalPath, - getFallbackImage, getSupportedChainConfig, - getSupportedFeatures, isConfiguredChainName, } from '@dao-dao/utils' @@ -262,216 +253,6 @@ export const daoCw20GovernanceTokenAddressSelector = selectorFamily< }, }) -// TODO(rq): remove all uses of this and replace with react-query -export const daoInfoSelector = selectorFamily< - DaoInfo, - { - chainId: string - coreAddress: string - } ->({ - key: 'daoInfo', - get: - ({ chainId, coreAddress }) => - ({ get }) => { - // Native chain governance. - if (isConfiguredChainName(chainId, coreAddress)) { - // If chain uses a contract-based DAO, load it instead. - const govContractAddress = - getSupportedChainConfig(chainId)?.govContractAddress - if (govContractAddress) { - coreAddress = govContractAddress - } else { - const govModuleAddress = get( - moduleAddressSelector({ - chainId, - name: 'gov', - }) - ) - const accounts = get( - accountsSelector({ - chainId, - address: govModuleAddress, - }) - ) - - return getDaoInfoForChainId(chainId, accounts) - } - } - - // Otherwise get DAO info from contract. - - const dumpState = get( - DaoCoreV2Selectors.dumpStateSelector({ - contractAddress: coreAddress, - chainId, - params: [], - }) - ) - if (!dumpState) { - throw new Error('DAO failed to dump state.') - } - - const [ - // Non-loadables - [ - coreVersion, - votingModuleInfo, - proposalModules, - created, - _items, - polytoneProxies, - accounts, - ], - // Loadables - [isActiveResponse, activeThresholdResponse], - ] = get( - waitForAll([ - // Non-loadables - waitForAll([ - contractVersionSelector({ - contractAddress: coreAddress, - chainId, - }), - contractInfoSelector({ - contractAddress: dumpState.voting_module, - chainId, - }), - daoCoreProposalModulesSelector({ - coreAddress, - chainId, - }), - contractInstantiateTimeSelector({ - address: coreAddress, - chainId, - }), - DaoCoreV2Selectors.listAllItemsSelector({ - contractAddress: coreAddress, - chainId, - }), - DaoCoreV2Selectors.polytoneProxiesSelector({ - contractAddress: coreAddress, - chainId, - }), - accountsSelector({ - address: coreAddress, - chainId, - }), - ]), - // Loadables - waitForAllSettled([ - // All voting modules use the same active threshold queries, so it's - // safe to use the cw20-staked selector. - DaoVotingCw20StakedSelectors.isActiveSelector({ - contractAddress: dumpState.voting_module, - chainId, - params: [], - }), - DaoVotingCw20StakedSelectors.activeThresholdSelector({ - contractAddress: dumpState.voting_module, - chainId, - params: [], - }), - ]), - ]) - ) - - const votingModuleContractName = - votingModuleInfo?.info.contract || 'fallback' - - // Some voting modules don't support the isActive query, so if the query - // fails, assume active. - const isActive = - isActiveResponse.state === 'hasError' || - (isActiveResponse.state === 'hasValue' && - isActiveResponse.contents.active) - const activeThreshold = - (activeThresholdResponse.state === 'hasValue' && - activeThresholdResponse.contents.active_threshold) || - null - - // Convert items list into map. - const items = _items.reduce( - (acc, [key, value]) => ({ - ...acc, - [key]: value, - }), - {} as Record - ) - - const { admin } = dumpState - - const parentDao: DaoParentInfo | null = - admin && admin !== coreAddress - ? get( - daoParentInfoSelector({ - chainId, - parentAddress: admin, - childAddress: coreAddress, - }) - ) || null - : null - - const daoInfo: DaoInfo = { - chainId, - coreAddress, - coreVersion, - supportedFeatures: getSupportedFeatures(coreVersion), - votingModuleAddress: dumpState.voting_module, - votingModuleContractName, - proposalModules, - name: dumpState.config.name, - description: dumpState.config.description, - imageUrl: dumpState.config.image_url || getFallbackImage(coreAddress), - created: created?.getTime() || null, - isActive, - activeThreshold, - items, - polytoneProxies, - accounts, - parentDao, - admin, - } - - return daoInfo - }, -}) - -export const daoInfoFromPolytoneProxySelector = selectorFamily< - | { - chainId: string - coreAddress: string - info: DaoInfo - } - | undefined, - WithChainId<{ proxy: string }> ->({ - key: 'daoInfoFromPolytoneProxy', - get: - (params) => - ({ get }) => { - const { chainId, address } = - get(reverseLookupPolytoneProxySelector(params)) ?? {} - if (!chainId || !address) { - return - } - - // Get DAO info on source chain. - const info = get( - daoInfoSelector({ - chainId, - coreAddress: address, - }) - ) - - return { - chainId, - coreAddress: address, - info, - } - }, -}) - /** * Proposals which this DAO can currently veto. */ diff --git a/packages/stateful/recoil/selectors/entity.ts b/packages/stateful/recoil/selectors/entity.ts deleted file mode 100644 index 716efa7c5..000000000 --- a/packages/stateful/recoil/selectors/entity.ts +++ /dev/null @@ -1,186 +0,0 @@ -import { - RecoilValueReadOnly, - constSelector, - selectorFamily, - waitForAll, - waitForAllSettled, -} from 'recoil' - -import { - Cw1WhitelistSelectors, - isDaoSelector, - isPolytoneProxySelector, - moduleNameForAddressSelector, - profileSelector, -} from '@dao-dao/state/recoil' -import { Entity, EntityType, WithChainId } from '@dao-dao/types' -import { - getChainForChainId, - getFallbackImage, - getImageUrlForChainId, - isConfiguredChainName, - isValidWalletAddress, -} from '@dao-dao/utils' - -import { daoInfoFromPolytoneProxySelector, daoInfoSelector } from './dao' - -// Load entity from address on chain, whether it's a wallet address, a DAO, or a -// DAO's polytone account. -export const entitySelector: ( - param: WithChainId<{ - address: string - // Prevent infinite loop if cw1-whitelist nests itself. - ignoreEntities?: string[] - }> -) => RecoilValueReadOnly = selectorFamily({ - key: 'entity', - get: - ({ chainId, address, ignoreEntities }) => - ({ get }) => { - const { bech32_prefix: bech32Prefix } = getChainForChainId(chainId) - - // Check if address is module account. - const moduleName = get( - waitForAllSettled([ - moduleNameForAddressSelector({ - chainId, - address, - }), - ]) - )[0].valueMaybe() - if (moduleName) { - const entity: Entity = { - type: EntityType.Module, - chainId, - address, - name: moduleName, - imageUrl: getImageUrlForChainId(chainId), - } - return entity - } - - const [isDao, isPolytoneProxy, cw1WhitelistAdmins] = address - ? get( - waitForAllSettled([ - isConfiguredChainName(chainId, address) - ? constSelector(true) - : isDaoSelector({ - chainId, - address, - }), - isPolytoneProxySelector({ - chainId, - address, - }), - Cw1WhitelistSelectors.adminsIfCw1Whitelist({ - chainId, - contractAddress: address, - }), - ]) - ) - : [] - - const [ - daoInfoLoadable, - daoInfoFromPolytoneProxyLoadable, - profileLoadable, - cw1WhitelistEntitiesLoadable, - ] = get( - waitForAllSettled([ - // Try to load config assuming the address is a DAO. - isDao?.state === 'hasValue' && isDao.contents - ? daoInfoSelector({ - chainId, - coreAddress: address, - }) - : constSelector(undefined), - isPolytoneProxy?.state === 'hasValue' && isPolytoneProxy.contents - ? daoInfoFromPolytoneProxySelector({ - chainId, - proxy: address, - }) - : constSelector(undefined), - // Try to load profile assuming the address is a wallet. - address && isValidWalletAddress(address, bech32Prefix) - ? profileSelector({ - chainId, - address, - }) - : constSelector(undefined), - // Try to load all contained entities for cw1-whitelist. - cw1WhitelistAdmins?.state === 'hasValue' && - cw1WhitelistAdmins.contents - ? waitForAll( - cw1WhitelistAdmins.contents.map((entityAddress) => - ignoreEntities?.includes(entityAddress) - ? // Placeholder entity to prevent infinite loop. - constSelector({ - chainId, - type: EntityType.Wallet, - address: entityAddress, - name: null, - imageUrl: getFallbackImage(entityAddress), - } as const) - : entitySelector({ - chainId, - address: entityAddress, - // Prevent infinite loop if cw1-whitelist nests itself. - ignoreEntities: [...(ignoreEntities || []), address], - }) - ) - ) - : constSelector(undefined), - ]) - ) - - const daoInfo = daoInfoLoadable.valueMaybe() - const daoInfoFromPolytoneProxy = - daoInfoFromPolytoneProxyLoadable.valueMaybe() - const profile = profileLoadable.valueMaybe() - const cw1WhitelistEntities = cw1WhitelistEntitiesLoadable.valueMaybe() - - if (daoInfo) { - return { - type: EntityType.Dao, - daoInfo, - chainId, - address, - name: daoInfo.name, - imageUrl: daoInfo.imageUrl || getFallbackImage(address), - } - } else if (daoInfoFromPolytoneProxy) { - return { - type: EntityType.Dao, - daoInfo: daoInfoFromPolytoneProxy.info, - polytoneProxy: { - chainId, - address, - }, - chainId: daoInfoFromPolytoneProxy.chainId, - address: daoInfoFromPolytoneProxy.coreAddress, - name: daoInfoFromPolytoneProxy.info.name, - imageUrl: - daoInfoFromPolytoneProxy.info.imageUrl || - getFallbackImage(daoInfoFromPolytoneProxy.coreAddress), - } - } else if (cw1WhitelistEntities) { - return { - type: EntityType.Cw1Whitelist, - chainId, - address, - name: null, - imageUrl: getFallbackImage(address), - entities: cw1WhitelistEntities, - } - } else { - return { - type: EntityType.Wallet, - chainId, - address, - name: profile?.name || null, - imageUrl: profile?.imageUrl || getFallbackImage(address), - profile: profile, - } - } - }, -}) diff --git a/packages/stateful/recoil/selectors/index.ts b/packages/stateful/recoil/selectors/index.ts index 5c0012a46..8eb7e6071 100644 --- a/packages/stateful/recoil/selectors/index.ts +++ b/packages/stateful/recoil/selectors/index.ts @@ -1,3 +1,2 @@ export * from './dao' -export * from './entity' export * from './proposal' diff --git a/packages/types/components/EntityDisplay.tsx b/packages/types/components/EntityDisplay.tsx index 7056fb0fe..01ed4e111 100644 --- a/packages/types/components/EntityDisplay.tsx +++ b/packages/types/components/EntityDisplay.tsx @@ -17,6 +17,13 @@ export type Entity = { address: string name: string | null imageUrl: string + /** + * If loaded from a Polytone proxy, this will be set to the proxy. + */ + polytoneProxy?: { + chainId: string + address: string + } } & ( | { type: EntityType.Wallet @@ -28,11 +35,6 @@ export type Entity = { | { type: EntityType.Dao daoInfo: DaoInfo - // If loaded from a DAO's Polytone proxy, this will be set. - polytoneProxy?: { - chainId: string - address: string - } } | { type: EntityType.Cw1Whitelist diff --git a/packages/utils/conversion.ts b/packages/utils/conversion.ts index 69a541b07..adc58b7a9 100644 --- a/packages/utils/conversion.ts +++ b/packages/utils/conversion.ts @@ -249,24 +249,30 @@ export const combineLoadingDataWithErrors = ( */ export const makeCombineQueryResultsIntoLoadingData = ({ - loadAllOnce = true, + firstLoad = 'all', transform = (results: T[]) => results as R, }: { /** - * Whether or not to show loading until all of the results are loaded for - * the first time. If false, will show not loading (just updating) once the - * first result is loaded. Defaults to true. + * Whether or not to show loading until all of the results are loaded, at + * least one result is loaded, or none of the results are loaded. If 'one', + * will show not loading (just updating) once the first result is loaded. If + * 'none', will never show loading. + * + * Defaults to 'all'. */ - loadAllOnce?: boolean + firstLoad?: 'all' | 'one' | 'none' /** * Optional transformation function that acts on combined list of data. */ transform?: (results: T[]) => R }) => (results: UseQueryResult[]): LoadingData => { - const isLoading = loadAllOnce - ? results.some((r) => r.isPending) - : results.every((r) => r.isPending) + const isLoading = + firstLoad === 'all' + ? results.some((r) => r.isPending) + : firstLoad === 'one' + ? results.every((r) => r.isPending) + : false if (isLoading) { return { @@ -280,7 +286,9 @@ export const makeCombineQueryResultsIntoLoadingData = // successfully loaded and returned undefined. isPending will be true if // data is not yet loaded. data: transform( - results.flatMap((r) => (r.isPending ? [] : [r.data as T])) + results.flatMap((r) => + r.isPending || r.isError ? [] : [r.data as T] + ) ), } } diff --git a/packages/utils/nft.ts b/packages/utils/nft.ts index 19969266c..1ae46d7d9 100644 --- a/packages/utils/nft.ts +++ b/packages/utils/nft.ts @@ -73,6 +73,17 @@ export const getNftKey = ( .filter(Boolean) .join(':') +export const imageUrlFromStargazeIndexerNft = ( + token: StargazeNft +): string | undefined => + // The Stargaze API resizes animated images (gifs) into `video/mp4` mimetype, + // which cannot display in an `img` tag. If this is a gif, use the original + // media URL instead of the resized one. + (token.media?.type !== StargazeNftMediaType.AnimatedImage && + token.media?.visualAssets?.lg?.url) || + token.media?.url || + undefined + export const nftCardInfoFromStargazeIndexerNft = ( chainId: string, token: StargazeNft, @@ -92,14 +103,7 @@ export const nftCardInfoFromStargazeIndexerNft = ( href: `${STARGAZE_URL_BASE}/media/${token.collection.contractAddress}/${token.tokenId}`, name: 'Stargaze', }, - imageUrl: - // The Stargaze API resizes animated images (gifs) into `video/mp4` - // mimetype, which cannot display in an `img` tag. If this is a gif, use the - // original media URL instead of the resized one. - (token.media?.type !== StargazeNftMediaType.AnimatedImage && - token.media?.visualAssets?.lg?.url) || - token.media?.url || - undefined, + imageUrl: imageUrlFromStargazeIndexerNft(token), name: token.name || token.tokenId || 'Unknown NFT', description: token.description || undefined, highestOffer: offerToken diff --git a/packages/utils/profile.ts b/packages/utils/profile.ts index 20d279486..b5f3f8f53 100644 --- a/packages/utils/profile.ts +++ b/packages/utils/profile.ts @@ -2,20 +2,20 @@ import { PfpkProfile, UnifiedProfile } from '@dao-dao/types' import { getFallbackImage } from './getFallbackImage' -export const EMPTY_PFPK_PROFILE: PfpkProfile = { +export const makeEmptyPfpkProfile = (): PfpkProfile => ({ uuid: null, // Disallows editing if we don't have correct nonce from server. nonce: -1, name: null, nft: null, chains: {}, -} +}) export const makeEmptyUnifiedProfile = ( chainId: string, address: string ): UnifiedProfile => ({ - ...EMPTY_PFPK_PROFILE, + ...makeEmptyPfpkProfile(), source: { chainId, address,