diff --git a/packages/stateful/actions/core/nfts/MintNft/MintNft.tsx b/packages/stateful/actions/core/nfts/MintNft/MintNft.tsx index 5466295b2..eb3be9dd8 100644 --- a/packages/stateful/actions/core/nfts/MintNft/MintNft.tsx +++ b/packages/stateful/actions/core/nfts/MintNft/MintNft.tsx @@ -80,6 +80,7 @@ export const MintNft: ActionComponent = (props) => { creatingCollectionInfoLoading.loading ? undefined : { + key: chainId + collectionAddress + mintMsg.token_id, collection: { address: collectionAddress, name: creatingCollectionInfoLoading.data?.name ?? '', diff --git a/packages/stateful/components/NftCard.tsx b/packages/stateful/components/NftCard.tsx index 69c9799a8..a28710fe6 100644 --- a/packages/stateful/components/NftCard.tsx +++ b/packages/stateful/components/NftCard.tsx @@ -6,7 +6,7 @@ import { NftCard as StatelessNftCard, useCachedLoadingWithError, } from '@dao-dao/stateless' -import { WithChainId } from '@dao-dao/types' +import { LazyNftCardProps } from '@dao-dao/types' import { nftCardInfoSelector, @@ -27,14 +27,9 @@ export const StakedNftCard = (props: ComponentProps) => { return } -export type LazyNftCardProps = WithChainId<{ - collectionAddress: string - tokenId: string - // If passed and the NFT is staked, get staker info from this contract. - stakingContractAddress?: string -}> - export const LazyNftCard = ({ + key, + type = 'owner', collectionAddress, tokenId, stakingContractAddress, @@ -49,12 +44,14 @@ export const LazyNftCard = ({ ) const stakerOrOwner = useCachedLoadingWithError( - nftStakerOrOwnerSelector({ - collectionAddress, - tokenId, - stakingContractAddress, - chainId, - }) + type === 'owner' + ? nftStakerOrOwnerSelector({ + collectionAddress, + tokenId, + stakingContractAddress, + chainId, + }) + : undefined ) const staked = @@ -62,10 +59,15 @@ export const LazyNftCard = ({ !stakerOrOwner.errored && stakerOrOwner.data.staked - const NftCardToUse = staked ? StakedNftCard : NftCardNoCollection + const NftCardToUse = staked + ? StakedNftCard + : type === 'owner' + ? NftCardNoCollection + : NftCard return info.loading || info.errored ? ( { [] ) const nfts = useCachedLoading( - nftCardInfosForDaoSelector({ + lazyNftCardPropsForDaoSelector({ chainId: daoInfo.chainId, coreAddress: daoInfo.coreAddress, governanceCollectionAddress: cw721GovernanceCollectionAddress, @@ -87,10 +87,10 @@ export const TreasuryAndNftsTab = () => { }) return ( - ButtonLink={ButtonLink} FiatDepositModal={DaoFiatDepositModal} - NftCard={NftCard} + NftCard={LazyNftCard} StargazeNftImportModal={StargazeNftImportModal} TokenCard={DaoTokenCard} addCollectionHref={ diff --git a/packages/stateful/recoil/selectors/nft.ts b/packages/stateful/recoil/selectors/nft.ts index 144e587ab..34fee40fc 100644 --- a/packages/stateful/recoil/selectors/nft.ts +++ b/packages/stateful/recoil/selectors/nft.ts @@ -15,7 +15,7 @@ import { } from '@dao-dao/state' import { stakerForNftSelector } from '@dao-dao/state/recoil/selectors/contracts/DaoVotingCw721Staked' import { ChainId, NftCardInfo, WithChainId } from '@dao-dao/types' -import { LoadingNfts, StargazeNft } from '@dao-dao/types/nft' +import { LazyNftCardProps, LoadingNfts, StargazeNft } from '@dao-dao/types/nft' import { MAINNET, STARGAZE_PROFILE_API_TEMPLATE, @@ -100,6 +100,7 @@ export const nftCardInfoWithUriSelector = selectorFamily< const { name = '', description, imageUrl, externalLink } = metadata || {} const info: NftCardInfo = { + key: chainId + collection + tokenId, collection: { address: collection, name: collectionInfo.name, @@ -153,9 +154,92 @@ export const nftCardInfoSelector = selectorFamily< }, }) +export const lazyNftCardPropsForDaoSelector = selectorFamily< + // Map chain ID to DAO-owned NFTs on that chain. + LoadingNfts, + WithChainId<{ + coreAddress: string + // If DAO is using the cw721-staking voting module adapter, it will have an + // NFT governance collection. If this is the case, passing it here makes + // sure we include the collection if it is not in the DAO's cw721 token + // list. + governanceCollectionAddress?: string + }> +>({ + key: 'lazyNftCardPropsForDao', + get: + ({ chainId, coreAddress, governanceCollectionAddress }) => + async ({ get }) => { + const allNfts = get( + DaoCoreV2Selectors.allCw721CollectionsSelector({ + contractAddress: coreAddress, + chainId, + governanceCollectionAddress, + }) + ) + + return Object.entries(allNfts).reduce( + (acc, [chainId, { owner, collectionAddresses }]) => { + collectionAddresses = Array.from(new Set(collectionAddresses)) + + // Get all token IDs owned by the DAO for each collection. + const nftCollectionTokenIds = get( + waitForNone( + collectionAddresses.map((collectionAddress) => + CommonNftSelectors.allTokensForOwnerSelector({ + contractAddress: collectionAddress, + chainId, + owner, + }) + ) + ) + ) + + // Get all lazy props for each collection. + const lazyNftCardProps = collectionAddresses.flatMap( + (collectionAddress, index) => + nftCollectionTokenIds[index].state === 'hasValue' + ? (nftCollectionTokenIds[index].contents as string[]).map( + (tokenId): LazyNftCardProps => ({ + key: chainId + collectionAddress + tokenId, + chainId, + tokenId, + collectionAddress, + type: 'collection', + }) + ) + : [] + ) + + return { + ...acc, + [chainId]: + nftCollectionTokenIds.length > 0 && + nftCollectionTokenIds.every( + (loadable) => loadable.state === 'loading' + ) + ? { + loading: true, + errored: false, + } + : { + loading: false, + errored: false, + updating: nftCollectionTokenIds.some( + (loadable) => loadable.state === 'loading' + ), + data: lazyNftCardProps, + }, + } + }, + {} as LoadingNfts + ) + }, +}) + export const nftCardInfosForDaoSelector = selectorFamily< // Map chain ID to DAO-owned NFTs on that chain. - LoadingNfts, + LoadingNfts, WithChainId<{ coreAddress: string // If DAO is using the cw721-staking voting module adapter, it will have an @@ -233,7 +317,7 @@ export const nftCardInfosForDaoSelector = selectorFamily< }, } }, - {} as LoadingNfts + {} as LoadingNfts ) }, }) @@ -245,7 +329,7 @@ type CollectionWithTokens = { // Retrieve all NFTs for a given wallet address using the indexer. export const walletNftCardInfos = selectorFamily< - LoadingNfts, + LoadingNfts, WithChainId<{ walletAddress: string }> diff --git a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/NftCollectionTab.tsx b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/NftCollectionTab.tsx index 5d986a910..871a38c01 100644 --- a/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/NftCollectionTab.tsx +++ b/packages/stateful/voting-module-adapter/adapters/DaoVotingCw721Staked/components/NftCollectionTab.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next' import { CommonNftSelectors } from '@dao-dao/state/recoil' import { NftsTab, useCachedLoading, useChain } from '@dao-dao/stateless' +import { LazyNftCardProps } from '@dao-dao/types' import { LazyNftCard } from '../../../../components' import { useGovernanceCollectionInfo } from '../hooks' @@ -29,13 +30,16 @@ export const NftCollectionTab = () => { ? { loading: true } : { loading: false, - data: allTokens.data.map((tokenId) => ({ - chainId, - collectionAddress, - tokenId, - stakingContractAddress, - key: collectionAddress + tokenId, - })), + data: allTokens.data.map( + (tokenId): LazyNftCardProps & { key: string } => ({ + chainId, + collectionAddress, + tokenId, + stakingContractAddress, + key: chainId + collectionAddress + tokenId, + type: 'owner', + }) + ), } } /> diff --git a/packages/stateless/components/NftCard.stories.tsx b/packages/stateless/components/NftCard.stories.tsx index ed8f047ad..d0828d993 100644 --- a/packages/stateless/components/NftCard.stories.tsx +++ b/packages/stateless/components/NftCard.stories.tsx @@ -38,6 +38,7 @@ export const makeProps = (): NftCardProps => { id++ return { + key: `${id}`, collection: { address: 'starsCollectionAddress', name: 'French Bulldog', diff --git a/packages/stateless/components/dao/DaoChainTreasuryAndNfts.tsx b/packages/stateless/components/dao/DaoChainTreasuryAndNfts.tsx new file mode 100644 index 000000000..5198a1f36 --- /dev/null +++ b/packages/stateless/components/dao/DaoChainTreasuryAndNfts.tsx @@ -0,0 +1,201 @@ +import { Image } from '@mui/icons-material' +import clsx from 'clsx' +import { ComponentType, useState } from 'react' +import { useTranslation } from 'react-i18next' + +import { + ButtonLinkProps, + DaoChainTreasury, + TokenCardInfo, +} from '@dao-dao/types' +import { + getChainForChainId, + getDisplayNameForChainId, + getSupportedChainConfig, +} from '@dao-dao/utils' + +import { + CopyToClipboard, + GridCardContainer, + NoContent, + PAGINATION_MIN_PAGE, + Pagination, +} from '../../components' +import { useDaoInfoContext } from '../../hooks' +import { Button } from '../buttons' +import { ChainLogo } from '../ChainLogo' +import { DropdownIconButton } from '../icon_buttons' +import { Loader } from '../logo' + +export type DaoChainTreasuryAndNftsProps< + T extends TokenCardInfo, + N extends object +> = { + treasury: DaoChainTreasury + connected: boolean + isMember: boolean + createCrossChainAccountPrefillHref: string + addCollectionHref?: string + setDepositFiatChainId: (chainId: string | undefined) => void + TokenCard: ComponentType + NftCard: ComponentType + ButtonLink: ComponentType +} + +const NFTS_PER_PAGE = 15 + +export const DaoChainTreasuryAndNfts = < + T extends TokenCardInfo, + N extends object +>({ + treasury: { chainId, address, tokens, nfts }, + connected, + isMember, + createCrossChainAccountPrefillHref, + addCollectionHref, + setDepositFiatChainId, + TokenCard, + NftCard, + ButtonLink, +}: DaoChainTreasuryAndNftsProps) => { + const { t } = useTranslation() + const { chainId: daoChainId } = useDaoInfoContext() + + const bech32Prefix = getChainForChainId(chainId).bech32_prefix + // Whether or not the treasury address is defined, meaning it is the current + // chain or a polytone account has already been created on that chain. + const exists = !!address + + const [collapsed, setCollapsed] = useState(false) + const [nftPage, setNftPage] = useState(PAGINATION_MIN_PAGE) + + return ( +
+ {/* header min-height of 3.5rem standardized across all tabs */} +
+
+
+ {exists ? ( + setCollapsed((c) => !c)} + /> + ) : ( +
+
+
+ )} +
+ + + +

+ {getDisplayNameForChainId(chainId)} +

+
+ + {exists ? ( +
+ + + {connected && !!getSupportedChainConfig(chainId)?.kado && ( + + )} +
+ ) : ( + + {t('button.createAccount')} + + )} +
+ + {exists && ( +
+ {tokens.loading ? ( + + ) : ( + tokens.data.length > 0 && ( + + {tokens.data.map((props, index) => ( + + ))} + + ) + )} + + {nfts.loading ? ( + + ) : ( + nfts.data.length > 0 && ( + <> +

+ {nfts.loading + ? t('title.nfts') + : t('title.numNfts', { count: nfts.data.length })} +

+ + + {nfts.data + .slice( + (nftPage - 1) * NFTS_PER_PAGE, + nftPage * NFTS_PER_PAGE + ) + .map((props) => ( + + ))} + + + + + ) + )} + + {!tokens.loading && + tokens.data.length === 0 && + !nfts.loading && + nfts.data.length === 0 && + (chainId === daoChainId ? ( +

{t('info.nothingFound')}

+ ) : ( + // Show NFT add prompt if on current chain. + + ))} +
+ )} +
+ ) +} diff --git a/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.stories.tsx b/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.stories.tsx index 0a1fde524..d12881803 100644 --- a/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.stories.tsx +++ b/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.stories.tsx @@ -19,7 +19,7 @@ export default { } as ComponentMeta const Template: ComponentStory< - typeof TreasuryAndNftsTab + typeof TreasuryAndNftsTab > = (args) => export const Default = Template.bind({}) diff --git a/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.tsx b/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.tsx index f0a0905fb..e18944410 100644 --- a/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.tsx +++ b/packages/stateless/components/dao/tabs/TreasuryAndNftsTab.tsx @@ -1,152 +1,118 @@ -import { Image } from '@mui/icons-material' -import clsx from 'clsx' import { ComponentType, useState } from 'react' import { useTranslation } from 'react-i18next' import { - ButtonLinkProps, + DaoChainTreasury, DaoFiatDepositModalProps, LoadingData, LoadingNfts, - NftCardInfo, TokenCardInfo, TokenType, } from '@dao-dao/types' -import { - getChainForChainId, - getDisplayNameForChainId, - getNativeTokenForChainId, - getSupportedChainConfig, -} from '@dao-dao/utils' +import { getNativeTokenForChainId } from '@dao-dao/utils' import { useDaoInfoContext, useSupportedChainContext } from '../../../hooks' -import { Button } from '../../buttons' -import { ChainLogo } from '../../ChainLogo' -import { CopyToClipboard } from '../../CopyToClipboard' -import { GridCardContainer } from '../../GridCardContainer' -import { DropdownIconButton } from '../../icon_buttons' import { Loader } from '../../logo/Loader' import { ModalProps } from '../../modals/Modal' -import { NoContent } from '../../NoContent' +import { + DaoChainTreasuryAndNfts, + DaoChainTreasuryAndNftsProps, +} from '../DaoChainTreasuryAndNfts' -export interface TreasuryAndNftsTabProps< +export type TreasuryAndNftsTabProps< T extends TokenCardInfo, - N extends NftCardInfo -> { + N extends object +> = { connected: boolean tokens: LoadingData<{ infos: T[] // Map chain ID to loading state. loading: Record }> - TokenCard: ComponentType - nfts: LoadingNfts - NftCard: ComponentType - isMember: boolean - createCrossChainAccountPrefillHref: string - addCollectionHref?: string + nfts: LoadingNfts StargazeNftImportModal: ComponentType> FiatDepositModal: ComponentType - ButtonLink: ComponentType -} +} & Omit< + DaoChainTreasuryAndNftsProps, + 'treasury' | 'setDepositFiatChainId' +> -export const TreasuryAndNftsTab = < - T extends TokenCardInfo, - N extends NftCardInfo ->({ +export const TreasuryAndNftsTab = ({ connected, tokens, - TokenCard, nfts, - NftCard, - isMember, - createCrossChainAccountPrefillHref, - addCollectionHref, FiatDepositModal, - ButtonLink, + createCrossChainAccountPrefillHref, + ...props }: TreasuryAndNftsTabProps) => { const { t } = useTranslation() const { - chain: { chain_id: chainId }, + chain: { chain_id: currentChainId }, config: { polytone = {} }, } = useSupportedChainContext() - const { - chainId: daoChainId, - coreAddress, - polytoneProxies, - } = useDaoInfoContext() + const { coreAddress, polytoneProxies } = useDaoInfoContext() // Tokens and NFTs on the various Polytone-supported chains. const treasuries = [ - [chainId, coreAddress], + [currentChainId, coreAddress], ...Object.keys(polytone).map((chainId): [string, string | undefined] => [ chainId, polytoneProxies[chainId], ]), - ].map( - ([chainId, address]): { - chainId: string - address: string | undefined - tokens: LoadingData - nfts: LoadingData - } => { - const chainNfts = nfts[chainId] - - return { - chainId, - address, - tokens: - tokens.loading || tokens.data.loading[chainId] - ? { loading: true } - : { - loading: false, - updating: tokens.updating, - data: tokens.data.infos - .filter(({ token }) => token.chainId === chainId) - // Sort governance token first, then native currency, then by - // balance. - .sort((a, b) => { - const aValue = a.isGovernanceToken - ? -2 - : a.token.type === TokenType.Native && - a.token.denomOrAddress === - getNativeTokenForChainId(chainId).denomOrAddress - ? -1 - : a.lazyInfo.loading - ? a.unstakedBalance - : a.lazyInfo.data.totalBalance - const bValue = b.isGovernanceToken - ? -2 - : b.token.type === TokenType.Native && - b.token.denomOrAddress === - getNativeTokenForChainId(chainId).denomOrAddress - ? -1 - : b.lazyInfo.loading - ? b.unstakedBalance - : b.lazyInfo.data.totalBalance + ].map(([chainId, address]): DaoChainTreasury => { + const chainNfts = nfts[chainId] - // Put smaller value first if either is negative (prioritized - // token), otherwise sort balances descending. - return aValue < 0 || bValue < 0 - ? aValue - bValue - : bValue - aValue - }), - }, - nfts: !chainNfts - ? { loading: false, data: [] } - : chainNfts.loading || chainNfts.errored + return { + chainId, + address, + tokens: + tokens.loading || tokens.data.loading[chainId] ? { loading: true } - : chainNfts, - } + : { + loading: false, + updating: tokens.updating, + data: tokens.data.infos + .filter(({ token }) => token.chainId === chainId) + // Sort governance token first, then native currency, then by + // balance. + .sort((a, b) => { + const aValue = a.isGovernanceToken + ? -2 + : a.token.type === TokenType.Native && + a.token.denomOrAddress === + getNativeTokenForChainId(chainId).denomOrAddress + ? -1 + : a.lazyInfo.loading + ? a.unstakedBalance + : a.lazyInfo.data.totalBalance + const bValue = b.isGovernanceToken + ? -2 + : b.token.type === TokenType.Native && + b.token.denomOrAddress === + getNativeTokenForChainId(chainId).denomOrAddress + ? -1 + : b.lazyInfo.loading + ? b.unstakedBalance + : b.lazyInfo.data.totalBalance + + // Put smaller value first if either is negative (prioritized + // token), otherwise sort balances descending. + return aValue < 0 || bValue < 0 + ? aValue - bValue + : bValue - aValue + }), + }, + nfts: !chainNfts + ? { loading: false, data: [] } + : chainNfts.loading || chainNfts.errored + ? { loading: true } + : chainNfts, } - ) + }) const [depositFiatChainId, setDepositFiatChainId] = useState< string | undefined >() - const [chainsCollapsed, setChainsCollapsed] = useState( - {} as Record - ) return ( <> @@ -155,136 +121,19 @@ export const TreasuryAndNftsTab = < ) : tokens.data.infos.length ? (
- {treasuries.map(({ chainId, address, tokens, nfts }) => { - const bech32Prefix = getChainForChainId(chainId).bech32_prefix - // Whether or not the treasury address is defined, meaning it is - // the current chain or a polytone account has already been - // created on that chain. - const exists = !!address - - return ( -
- {/* header min-height of 3.5rem standardized across all tabs */} -
-
-
- {exists ? ( - - setChainsCollapsed((prev) => ({ - ...prev, - [chainId]: !prev[chainId], - })) - } - /> - ) : ( -
-
-
- )} -
- - - -

- {getDisplayNameForChainId(chainId)} -

-
- - {exists ? ( -
- - - {connected && !!getSupportedChainConfig(chainId)?.kado && ( - - )} -
- ) : ( - - {t('button.createAccount')} - - )} -
- - {exists && ( -
- {tokens.loading ? ( - - ) : ( - tokens.data.length > 0 && ( - - {tokens.data.map((props, index) => ( - - ))} - - ) - )} - - {nfts.loading ? ( - - ) : ( - nfts.data.length > 0 && ( - <> -

{t('title.nfts')}

- - - {nfts.data.map((props, index) => ( - - ))} - - - ) - )} - - {!tokens.loading && - tokens.data.length === 0 && - !nfts.loading && - nfts.data.length === 0 && - (chainId === daoChainId ? ( -

- {t('info.nothingFound')} -

- ) : ( - // Show NFT add prompt if on current chain. - - ))} -
- )} -
- ) - })} + {treasuries.map((treasury) => ( + + ))}
) : (

{t('info.nothingFound')}

diff --git a/packages/stateless/components/modals/NftSelectionModal.tsx b/packages/stateless/components/modals/NftSelectionModal.tsx index 892fd2fd2..3d7dfe9f1 100644 --- a/packages/stateless/components/modals/NftSelectionModal.tsx +++ b/packages/stateless/components/modals/NftSelectionModal.tsx @@ -29,13 +29,13 @@ import { NoContent } from '../NoContent' import { ButtonPopup } from '../popup' import { Modal } from './Modal' -export interface NftSelectionModalProps +export interface NftSelectionModalProps extends Omit, Required> { - nfts: LoadingDataWithError + nfts: LoadingDataWithError selectedIds: string[] - getIdForNft: (nft: T) => string - onNftClick: (nft: T) => void + getIdForNft: (nft: N) => string + onNftClick: (nft: N) => void onSelectAll?: () => void onDeselectAll?: () => void action: { @@ -56,7 +56,7 @@ export interface NftSelectionModalProps noneDisplay?: ReactNode } -export const NftSelectionModal = ({ +export const NftSelectionModal = ({ nfts, selectedIds, getIdForNft, @@ -72,7 +72,7 @@ export const NftSelectionModal = ({ headerDisplay, noneDisplay, ...modalProps -}: NftSelectionModalProps) => { +}: NftSelectionModalProps) => { const { t } = useTranslation() const showSelectAll = (onSelectAll || onDeselectAll) && @@ -266,19 +266,19 @@ export const NftSelectionModal = ({ ) : nfts.data.length > 0 ? ( - filteredSortedSearchedNfts.map(({ item: nft }) => ( + filteredSortedSearchedNfts.map(({ item }) => ( !action.loading && onNftClick(nft as T), + onClick: () => !action.loading && onNftClick(item as N), }} /> )) diff --git a/packages/types/dao.ts b/packages/types/dao.ts index 97c6b6067..8b3aa4341 100644 --- a/packages/types/dao.ts +++ b/packages/types/dao.ts @@ -20,8 +20,8 @@ import { PercentOrMajorityValue, ProposalModuleAdapter, } from './proposal-module-adapter' -import { DaoCardProps, SuspenseLoaderProps } from './stateless' -import { GenericToken } from './token' +import { DaoCardProps, LoadingData, SuspenseLoaderProps } from './stateless' +import { GenericToken, TokenCardInfo } from './token' import { DurationWithUnits } from './units' import { CodeIdConfig } from './utils' @@ -293,3 +293,10 @@ export type DaoWebSocketChannelInfo = { chainId: string coreAddress: string } + +export type DaoChainTreasury = { + chainId: string + address: string | undefined + tokens: LoadingData + nfts: LoadingData<(N & { key: string })[]> +} diff --git a/packages/types/nft.ts b/packages/types/nft.ts index 904e89ba7..3359700b1 100644 --- a/packages/types/nft.ts +++ b/packages/types/nft.ts @@ -1,5 +1,6 @@ import { ChainId } from './chain' import { ContractInfoResponse } from './contracts/Cw721Base' +import { WithChainId } from './state' import { LoadingDataWithError } from './stateless' export interface StargazeNft { @@ -52,6 +53,7 @@ export type NftUriData = { export type NftCardInfo = { chainId: string + key: string collection: { address: string name: string @@ -78,6 +80,16 @@ export type NftCardInfo = { } // Map chain ID to loading NFTs on that chain. -export type LoadingNfts = Partial< +export type LoadingNfts = Partial< Record> > + +export type LazyNftCardProps = WithChainId<{ + key: string + // Whether to show the collection or the owner/staker. Default is owner. + type?: 'collection' | 'owner' + collectionAddress: string + tokenId: string + // If passed and the NFT is staked, get staker info from this contract. + stakingContractAddress?: string +}>