From d09a79803bd5e65b01ba5633cd835e1faaba897f Mon Sep 17 00:00:00 2001 From: noah Date: Thu, 12 Oct 2023 16:41:04 -0700 Subject: [PATCH 1/3] Added DAO tabs, SubDAOs, and polytone addresses to command modal context. Allow larger command modal with consistent input height. (#1416) --- apps/dapp/pages/dao/[address]/[[...slug]].tsx | 19 +- apps/sda/pages/[address]/[[...slug]].tsx | 3 +- packages/i18n/locales/dog/translation.json | 4 +- packages/i18n/locales/en/translation.json | 5 +- .../components/CommandModalContextView.tsx | 18 +- .../stateful/command/contexts/generic/dao.ts | 116 ---------- .../stateful/command/contexts/generic/dao.tsx | 212 ++++++++++++++++++ .../useFollowingAndFilteredDaosSections.ts | 20 +- packages/stateful/components/SdaLayout.tsx | 3 +- .../stateful/components/dao/CreateDaoForm.tsx | 1 + .../stateful/components/dao/DaoProviders.tsx | 48 +++- packages/stateful/hooks/useDaoTabs.ts | 48 ++-- .../stateful/recoil/selectors/dao/cards.ts | 35 +++ .../components/HorizontalScroller.stories.tsx | 1 + .../components/command/CommandModal.tsx | 2 +- .../components/dao/DaoCard.stories.tsx | 1 + packages/types/command.ts | 25 ++- packages/types/dao.ts | 2 +- packages/types/stateless/DaoCard.tsx | 3 +- 19 files changed, 387 insertions(+), 179 deletions(-) delete mode 100644 packages/stateful/command/contexts/generic/dao.ts create mode 100644 packages/stateful/command/contexts/generic/dao.tsx diff --git a/apps/dapp/pages/dao/[address]/[[...slug]].tsx b/apps/dapp/pages/dao/[address]/[[...slug]].tsx index 7c71a0490..5025580ba 100644 --- a/apps/dapp/pages/dao/[address]/[[...slug]].tsx +++ b/apps/dapp/pages/dao/[address]/[[...slug]].tsx @@ -12,7 +12,6 @@ import { DaoInfoBar, DaoPageWrapper, DaoPageWrapperProps, - DaoWidgets, LinkWrapper, ProfileDaoHomeCard, SuspenseLoader, @@ -22,14 +21,13 @@ import { } from '@dao-dao/stateful' import { useActionForKey } from '@dao-dao/stateful/actions' import { makeGetDaoStaticProps } from '@dao-dao/stateful/server' -import { useWidgets } from '@dao-dao/stateful/widgets' import { DaoDappTabbedHome, useChain, useDaoInfoContext, useDaoNavHelpers, } from '@dao-dao/stateless' -import { ActionKey, DaoPageMode, WidgetLocation } from '@dao-dao/types' +import { ActionKey, DaoPageMode } from '@dao-dao/types' import { SITE_URL, getDaoPath, @@ -152,20 +150,7 @@ const InnerDaoHome = () => { useFollowingDaos(daoInfo.chainId) const following = isFollowing(daoInfo.coreAddress) - // Add home tab with widgets if any widgets exist. - const loadingDaoWidgets = useWidgets({ - // Load widgets before rendering so that home is selected if there are - // widgets. - suspendWhileLoading: true, - // Only load home widgets. - location: WidgetLocation.Home, - }) - const hasHomeWidgets = - !loadingDaoWidgets.loading && loadingDaoWidgets.data.length > 0 - - const tabs = useDaoTabs({ - includeHome: hasHomeWidgets ? DaoWidgets : undefined, - }) + const tabs = useDaoTabs() const firstTabId = tabs[0].id // Pre-fetch tabs. diff --git a/apps/sda/pages/[address]/[[...slug]].tsx b/apps/sda/pages/[address]/[[...slug]].tsx index 1acb30f96..bebdee093 100644 --- a/apps/sda/pages/[address]/[[...slug]].tsx +++ b/apps/sda/pages/[address]/[[...slug]].tsx @@ -7,7 +7,6 @@ import React, { useEffect } from 'react' import { ProfileDaoHomeCard, - SdaDaoHome, SuspenseLoader, useDaoTabs, } from '@dao-dao/stateful' @@ -25,7 +24,7 @@ const DaoHomePage: NextPage = () => { const { coreAddress } = useDaoInfoContext() const { getDaoPath } = useDaoNavHelpers() - const tabs = useDaoTabs({ includeHome: SdaDaoHome }) + const tabs = useDaoTabs() const firstTabId = tabs[0].id // Pre-fetch tabs. diff --git a/packages/i18n/locales/dog/translation.json b/packages/i18n/locales/dog/translation.json index 050b31f3c..4793e691e 100644 --- a/packages/i18n/locales/dog/translation.json +++ b/packages/i18n/locales/dog/translation.json @@ -20,7 +20,7 @@ "continue": "continnue", "copy": "copee", "copyAddressToClipboard": "copee addres 2 clipboar", - "copyDaoAddress": "copee da0 addres", + "copyDaoChainAddress": "copee {{chain}} addres", "copyToClipboard": "copee 2 clipboar", "create": "cre8", "createAProposal": "cre8 a puppozal", @@ -273,7 +273,7 @@ "configureWalletModalExplanation": "u hav keplr installd, but it dont seem lyke uv set oop a wallit. 2 continue, oopen da keplr extenshun n set oop a wallit. 2 oopen da keplr extenshun,press da puzzul icon in da top rite ov ur browzer n den press da keplr buttun. wunce uv dun dat, a new pag will oopen wer ull be abbl 2 create a new accoont. configur ur wallit 2 continue", "considerReturningHome": "considar retorning homm", "copiedAddressToClipboard": "copy adress 2 clipboar", - "copiedDaoAddress": "coppied da0 addres", + "copiedDaoChainAddress": "coppied {{chain}} addres", "copiedLinkToClipboard": "coopied link 2 clibpoard", "copiedToClipboard": "coopied 2 clipboar", "copyWalletAddressTooltip": "copy wallit adress", diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 04d58c3cd..8f1d60275 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -71,7 +71,7 @@ "copy": "Copy", "copyAddress": "Copy address", "copyAddressToClipboard": "Copy address to clipboard", - "copyDaoAddress": "Copy DAO address", + "copyDaoChainAddress": "Copy {{chain}} address", "copyToClipboard": "Copy to clipboard", "create": "Create", "createAProposal": "Create a proposal", @@ -728,7 +728,7 @@ "configureWalletModalExplanation": "You have a wallet extension installed, but it doesn't seem like you've set up a wallet. Create a wallet to continue.", "connectedTo": "Connected to {{name}}", "copiedAddressToClipboard": "Copy address to clipboard", - "copiedDaoAddress": "Copied DAO address", + "copiedDaoChainAddress": "Copied {{chain}} address", "copiedLinkToClipboard": "Copied link to clipboard.", "copiedToClipboard": "Copied to clipboard.", "copyWalletAddressTooltip": "Copy wallet address", @@ -1322,6 +1322,7 @@ "other": "Other", "otherMembers": "Other Members", "owner": "Owner", + "pages": "Pages", "passingThreshold": "Passing threshold", "payout": "Payout", "payroll": "Payroll", diff --git a/packages/stateful/command/components/CommandModalContextView.tsx b/packages/stateful/command/components/CommandModalContextView.tsx index 0384197cc..4d3b8d94c 100644 --- a/packages/stateful/command/components/CommandModalContextView.tsx +++ b/packages/stateful/command/components/CommandModalContextView.tsx @@ -1,5 +1,5 @@ import Fuse from 'fuse.js' -import { useMemo } from 'react' +import { Fragment, useMemo } from 'react' import { CommandModalContextViewLoader, @@ -20,11 +20,17 @@ export interface CommandModalContextViewProps { export const CommandModalContextView = ( props: CommandModalContextViewProps -) => ( - }> - - -) +) => { + const Wrapper = props.contexts[props.contexts.length - 1]?.Wrapper ?? Fragment + + return ( + }> + + + + + ) +} export const InnerCommandModalContextView = ({ filter, diff --git a/packages/stateful/command/contexts/generic/dao.ts b/packages/stateful/command/contexts/generic/dao.ts deleted file mode 100644 index 540235c22..000000000 --- a/packages/stateful/command/contexts/generic/dao.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { - Check, - CheckRounded, - CopyAll, - HomeOutlined, - InboxOutlined, -} from '@mui/icons-material' -import { useEffect, useState } from 'react' -import { useTranslation } from 'react-i18next' -import { useRecoilState } from 'recoil' - -import { navigatingToHrefAtom } from '@dao-dao/state' -import { useDaoNavHelpers } from '@dao-dao/stateless' -import { - CommandModalContextMaker, - CommandModalContextSection, - CommandModalDaoInfo, -} from '@dao-dao/types/command' - -import { useFollowingDaos } from '../../../hooks' - -export const makeGenericDaoContext: CommandModalContextMaker<{ - dao: CommandModalDaoInfo -}> = ({ dao: { chainId, coreAddress, name, imageUrl } }) => { - const useSections = () => { - const { t } = useTranslation() - const { getDaoPath, getDaoProposalPath, router } = useDaoNavHelpers() - - const { isFollowing, setFollowing, setUnfollowing, updatingFollowing } = - useFollowingDaos(chainId) - const following = isFollowing(coreAddress) - - const [copied, setCopied] = useState(false) - // Debounce clearing copied. - useEffect(() => { - const timeout = setTimeout(() => setCopied(false), 2000) - return () => clearTimeout(timeout) - }, [copied]) - - const [navigatingToHref, setNavigatingToHref] = - useRecoilState(navigatingToHrefAtom) - const daoPageHref = getDaoPath(coreAddress) - const createProposalHref = getDaoProposalPath(coreAddress, 'create') - - // Pre-fetch routes. - useEffect(() => { - router.prefetch(daoPageHref) - router.prefetch(createProposalHref) - }, [createProposalHref, daoPageHref, router]) - - const actionsSection: CommandModalContextSection< - { href: string } | { onChoose: () => void } - > = { - name: t('title.actions'), - onChoose: (item) => { - if ('onChoose' in item) { - return item.onChoose() - } - - //! 'href' in item - // Open remote links in new tab. - if (item.href.startsWith('http')) { - window.open(item.href, '_blank') - } else { - // Navigate to local links. - router.push(item.href) - - // If not on destination page, set navigating state. If already there, - // do nothing. - if (router.asPath !== item.href) { - setNavigatingToHref(item.href) - } - } - }, - items: [ - { - name: t('button.goToDaoPage'), - Icon: HomeOutlined, - href: daoPageHref, - loading: navigatingToHref === daoPageHref, - }, - { - name: t('button.createAProposal'), - Icon: InboxOutlined, - href: createProposalHref, - loading: navigatingToHref === createProposalHref, - }, - { - name: copied - ? t('info.copiedDaoAddress') - : t('button.copyDaoAddress'), - Icon: copied ? Check : CopyAll, - onChoose: () => { - navigator.clipboard.writeText(coreAddress) - setCopied(true) - }, - }, - { - name: following ? t('button.unfollow') : t('button.follow'), - Icon: CheckRounded, - onChoose: () => - following ? setUnfollowing(coreAddress) : setFollowing(coreAddress), - loading: updatingFollowing, - }, - ], - } - - return [actionsSection] - } - - return { - name, - imageUrl, - useSections, - } -} diff --git a/packages/stateful/command/contexts/generic/dao.tsx b/packages/stateful/command/contexts/generic/dao.tsx new file mode 100644 index 000000000..19cb63b0e --- /dev/null +++ b/packages/stateful/command/contexts/generic/dao.tsx @@ -0,0 +1,212 @@ +import { + Check, + CheckRounded, + CopyAll, + HomeOutlined, + InboxOutlined, +} from '@mui/icons-material' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useRecoilState } from 'recoil' +import useDeepCompareEffect from 'use-deep-compare-effect' + +import { navigatingToHrefAtom } from '@dao-dao/state' +import { + useCachedLoading, + useDaoInfoContext, + useDaoNavHelpers, +} from '@dao-dao/stateless' +import { ContractVersion } from '@dao-dao/types' +import { + CommandModalContextMaker, + CommandModalContextSection, + CommandModalContextWrapper, + CommandModalDaoInfo, +} from '@dao-dao/types/command' +import { getChainForChainId, getFallbackImage } from '@dao-dao/utils' + +import { DaoProvidersWithoutInfo } from '../../../components' +import { useDaoTabs, useFollowingDaos } from '../../../hooks' +import { subDaoInfosSelector } from '../../../recoil' + +export const makeGenericDaoContext: CommandModalContextMaker<{ + dao: CommandModalDaoInfo +}> = ({ + dao: { chainId, coreAddress, name, imageUrl, polytoneProxies }, + ...options +}) => { + const useSections = () => { + const { t } = useTranslation() + const { getDaoPath, getDaoProposalPath, router } = useDaoNavHelpers() + const { coreVersion } = useDaoInfoContext() + const tabs = useDaoTabs() + + const { isFollowing, setFollowing, setUnfollowing, updatingFollowing } = + useFollowingDaos(chainId) + const following = isFollowing(coreAddress) + + const [copied, setCopied] = useState() + // Debounce clearing copied. + useEffect(() => { + const timeout = setTimeout(() => setCopied(undefined), 2000) + return () => clearTimeout(timeout) + }, [copied]) + + const [navigatingToHref, setNavigatingToHref] = + useRecoilState(navigatingToHrefAtom) + const daoPageHref = getDaoPath(coreAddress) + const createProposalHref = getDaoProposalPath(coreAddress, 'create') + + const subDaosLoading = useCachedLoading( + coreVersion === ContractVersion.V1 + ? // Only v2 DAOs have SubDAOs. Passing undefined here returns an infinite loading state, which is fine because it's never used. + undefined + : subDaoInfosSelector({ + chainId, + coreAddress, + }), + [] + ) + + // Pre-fetch routes. + const routes = [ + daoPageHref, + createProposalHref, + ...tabs.map(({ id }) => getDaoPath(coreAddress, id)), + ] + useDeepCompareEffect(() => { + routes.forEach((url) => router.prefetch(url)) + }, [routes]) + + const chains = [[chainId, coreAddress], ...Object.entries(polytoneProxies)] + + const actionsSection: CommandModalContextSection< + { href: string } | { onChoose: () => void } + > = { + name: t('title.actions'), + onChoose: (item) => { + if ('onChoose' in item) { + return item.onChoose() + } + + //! 'href' in item + // Open remote links in new tab. + if (item.href.startsWith('http')) { + window.open(item.href, '_blank') + } else { + // Navigate to local links. + router.push(item.href) + + // If not on destination page, set navigating state. If already there, + // do nothing. + if (router.asPath !== item.href) { + setNavigatingToHref(item.href) + } + } + }, + items: [ + { + name: t('button.goToDaoPage'), + Icon: HomeOutlined, + href: daoPageHref, + loading: navigatingToHref === daoPageHref, + }, + { + name: t('button.createAProposal'), + Icon: InboxOutlined, + href: createProposalHref, + loading: navigatingToHref === createProposalHref, + }, + { + name: following ? t('button.unfollow') : t('button.follow'), + Icon: CheckRounded, + onChoose: () => + following ? setUnfollowing(coreAddress) : setFollowing(coreAddress), + loading: updatingFollowing, + }, + ...chains.map(([chainId, address]) => ({ + name: + copied === chainId + ? t('info.copiedDaoChainAddress', { + chain: getChainForChainId(chainId).pretty_name, + }) + : t('button.copyDaoChainAddress', { + chain: getChainForChainId(chainId).pretty_name, + }), + Icon: copied === chainId ? Check : CopyAll, + onChoose: () => { + navigator.clipboard.writeText(address) + setCopied(chainId) + }, + })), + ], + } + + const pagesSection: CommandModalContextSection<{ href: string }> = { + name: t('title.pages'), + onChoose: ({ href }) => { + router.push(href) + + // If not on destination page, set navigating state. If already there, + // do nothing. + if (router.asPath !== href) { + setNavigatingToHref(href) + } + }, + items: tabs.map(({ id, label, Icon }) => { + const href = getDaoPath(coreAddress, id) + + return { + name: label, + Icon, + href, + loading: navigatingToHref === href, + } + }), + } + + const subDaosSection: CommandModalContextSection = { + name: t('title.subDaos'), + onChoose: (dao) => + options.openContext( + makeGenericDaoContext({ + ...options, + dao, + }) + ), + loading: subDaosLoading.loading, + items: subDaosLoading.loading + ? [] + : subDaosLoading.data.map( + ({ + chainId, + coreAddress, + name, + imageUrl, + polytoneProxies, + }): CommandModalDaoInfo => ({ + chainId, + coreAddress, + name, + imageUrl: imageUrl || getFallbackImage(coreAddress), + polytoneProxies, + }) + ), + } + + return [actionsSection, pagesSection, subDaosSection] + } + + const Wrapper: CommandModalContextWrapper = ({ children }) => ( + + {children} + + ) + + return { + name, + imageUrl, + useSections, + Wrapper, + } +} diff --git a/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts b/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts index a822730da..935471da5 100644 --- a/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts +++ b/packages/stateful/command/hooks/useFollowingAndFilteredDaosSections.ts @@ -9,7 +9,11 @@ import { CommandModalContextUseSectionsOptions, CommandModalDaoInfo, } from '@dao-dao/types' -import { getFallbackImage, getSupportedChains } from '@dao-dao/utils' +import { + getFallbackImage, + getSupportedChains, + polytoneNoteProxyMapToChainIdMap, +} from '@dao-dao/utils' import { useLoadingFeaturedDaoCardInfos, @@ -74,7 +78,9 @@ export const useFollowingAndFilteredDaosSections = ({ coreAddress: contractAddress, name, imageUrl: image_url || getFallbackImage(contractAddress), - polytoneProxies: Object.values(polytoneProxies || {}), + polytoneProxies: polytoneProxies + ? polytoneNoteProxyMapToChainIdMap(chainId, polytoneProxies) + : {}, // If DAO has no proposals, make it less visible and give it a // tooltip to indicate that it may not be active. ...(proposalCount === 0 && { @@ -88,6 +94,12 @@ export const useFollowingAndFilteredDaosSections = ({ ? [] : featuredDaosLoading.data + // When filter present, use search results. Otherwise use featured DAOs. + const daosLoading = options.filter + ? queryResults.state === 'loading' || + (queryResults.state === 'hasValue' && queryResults.updating) + : featuredDaosLoading.loading || !!featuredDaosLoading.updating + const followingSection: CommandModalContextSection = { name: t('title.following'), onChoose, @@ -102,9 +114,7 @@ export const useFollowingAndFilteredDaosSections = ({ name: t('title.daos'), onChoose, items: daos, - loading: - queryResults.state === 'loading' || - (queryResults.state === 'hasValue' && queryResults.updating), + loading: daosLoading, } return [followingSection, daosSection] diff --git a/packages/stateful/components/SdaLayout.tsx b/packages/stateful/components/SdaLayout.tsx index 8731cdb8f..5e24c5d27 100644 --- a/packages/stateful/components/SdaLayout.tsx +++ b/packages/stateful/components/SdaLayout.tsx @@ -20,7 +20,6 @@ import { import { useDaoTabs, useWallet, useWalletInfo } from '../hooks' import { daoCreatedCardPropsAtom } from '../recoil/atoms/newDao' import { ConnectWallet } from './ConnectWallet' -import { SdaDaoHome } from './dao' import { IconButtonLink } from './IconButtonLink' import { LinkWrapper } from './LinkWrapper' import { SidebarWallet } from './SidebarWallet' @@ -55,7 +54,7 @@ export const SdaLayout = ({ children }: { children: ReactNode }) => { daoCreatedCardPropsAtom ) - const tabs = useDaoTabs({ includeHome: SdaDaoHome }) + const tabs = useDaoTabs() return ( ( ) + +export type DaoProvidersWithoutInfoProps = { + chainId: string + coreAddress: string + children: ReactNode +} + +export const DaoProvidersWithoutInfo = ({ + chainId, + coreAddress, + children, +}: DaoProvidersWithoutInfoProps) => { + const { t } = useTranslation() + const infoLoading = useCachedLoadingWithError( + daoInfoSelector({ + chainId, + coreAddress, + }) + ) + + return ( + } forceFallback={infoLoading.loading}> + {!infoLoading.loading && + (infoLoading.errored ? ( + +
+              {infoLoading.error instanceof Error
+                ? infoLoading.error.message
+                : `${infoLoading.error}`}
+            
+
+ ) : ( + {children} + ))} +
+ ) +} diff --git a/packages/stateful/hooks/useDaoTabs.ts b/packages/stateful/hooks/useDaoTabs.ts index 96f486426..1c0fc0562 100644 --- a/packages/stateful/hooks/useDaoTabs.ts +++ b/packages/stateful/hooks/useDaoTabs.ts @@ -3,32 +3,34 @@ import { FiberSmartRecordOutlined, HomeOutlined, HowToVoteOutlined, + QuestionMark, WebOutlined, } from '@mui/icons-material' -import { ComponentType } from 'react' import { useTranslation } from 'react-i18next' -import { useDaoInfoContext } from '@dao-dao/stateless' -import { DaoTabId, DaoTabWithComponent, WidgetLocation } from '@dao-dao/types' +import { useAppContext, useDaoInfoContext } from '@dao-dao/stateless' +import { + DaoPageMode, + DaoTabId, + DaoTabWithComponent, + WidgetLocation, +} from '@dao-dao/types' import { BrowserTab, + DaoWidgets, ProposalsTab, + SdaDaoHome, SubDaosTab, TreasuryAndNftsTab, } from '../components' import { useVotingModuleAdapter } from '../voting-module-adapter' import { useWidgets } from '../widgets' -export type UseDaoTabsOptions = { - includeHome?: ComponentType -} - -export const useDaoTabs = ({ - includeHome, -}: UseDaoTabsOptions = {}): DaoTabWithComponent[] => { +export const useDaoTabs = (): DaoTabWithComponent[] => { const { t } = useTranslation() + const { mode } = useAppContext() const { components: { extraTabs }, } = useVotingModuleAdapter() @@ -49,18 +51,38 @@ export const useDaoTabs = ({ }): DaoTabWithComponent => ({ id, label: title, - Icon, + // Icon should always be defined for tab widgets, but just in case... + Icon: Icon || QuestionMark, Component: WidgetComponent, }) ) + // Add home tab with widgets if any widgets exist. + const loadingDaoHomeWidgets = useWidgets({ + // In dApp, load widgets before rendering to decide if home with widgets is + // shown so that we know to select home by default when present. In SDA, no + // need to load widgets before rendering since the home is always shown. + suspendWhileLoading: mode === DaoPageMode.Dapp, + // Only load home widgets. + location: WidgetLocation.Home, + }) + const hasHomeWidgets = + !loadingDaoHomeWidgets.loading && loadingDaoHomeWidgets.data.length > 0 + + const HomeTab = + mode === DaoPageMode.Sda + ? SdaDaoHome + : mode === DaoPageMode.Dapp && hasHomeWidgets + ? DaoWidgets + : undefined + return [ - ...(includeHome + ...(HomeTab ? [ { id: DaoTabId.Home, label: t('title.home'), - Component: includeHome, + Component: HomeTab, Icon: HomeOutlined, }, ] diff --git a/packages/stateful/recoil/selectors/dao/cards.ts b/packages/stateful/recoil/selectors/dao/cards.ts index 49f95d8d3..847b5c46c 100644 --- a/packages/stateful/recoil/selectors/dao/cards.ts +++ b/packages/stateful/recoil/selectors/dao/cards.ts @@ -13,6 +13,7 @@ import { DaoCardInfo, DaoCardInfoLazyData, DaoDropdownInfo, + DaoInfo, IndexerDumpState, WithChainId, } from '@dao-dao/types' @@ -38,6 +39,7 @@ import { proposalModuleAdapterProposalCountSelector } from '../../../proposal-mo import { daoCoreProposalModulesSelector, daoCw20GovernanceTokenAddressSelector, + daoInfoSelector, } from './misc' export const daoCardInfoSelector = selectorFamily< @@ -76,6 +78,13 @@ export const daoCardInfoSelector = selectorFamily< contractInstantiateTimeSelector({ address: coreAddress, chainId }) ) + const polytoneProxies = get( + DaoCoreV2Selectors.polytoneProxiesSelector({ + chainId, + contractAddress: coreAddress, + }) + ) + // Get parent DAO if exists. let parentDao: DaoCardInfo['parentDao'] if ( @@ -208,6 +217,7 @@ export const daoCardInfoSelector = selectorFamily< name: config.name, description: config.description, imageUrl: config.image_url || getFallbackImage(coreAddress), + polytoneProxies, established, parentDao, tokenDecimals: 6, @@ -309,6 +319,31 @@ export const subDaoCardInfosSelector = selectorFamily< }, }) +export const subDaoInfosSelector = selectorFamily< + DaoInfo[], + WithChainId<{ coreAddress: string }> +>({ + key: 'subDaoInfos', + get: + ({ coreAddress: contractAddress, chainId }) => + ({ get }) => { + const subdaos = get( + DaoCoreV2Selectors.listAllSubDaosSelector({ + contractAddress, + chainId, + }) + ) + + return get( + waitForAll( + subdaos.map(({ addr }) => + daoInfoSelector({ coreAddress: addr, chainId }) + ) + ) + ) + }, +}) + export const daoDropdownInfoSelector: ( params: WithChainId<{ coreAddress: string diff --git a/packages/stateless/components/HorizontalScroller.stories.tsx b/packages/stateless/components/HorizontalScroller.stories.tsx index 9b3ceae70..e81d84c50 100644 --- a/packages/stateless/components/HorizontalScroller.stories.tsx +++ b/packages/stateless/components/HorizontalScroller.stories.tsx @@ -23,6 +23,7 @@ const makeFeaturedDao = (): DaoCardInfo => ({ description: 'This approach allows us to implement a completely custom component design without writing a single line of custom CSS.', imageUrl: `/placeholders/${(id % 5) + 1}.svg`, + polytoneProxies: {}, established: new Date('May 14, 2022 00:00:00'), tokenSymbol: 'JUNO', showingEstimatedUsdValue: false, diff --git a/packages/stateless/components/command/CommandModal.tsx b/packages/stateless/components/command/CommandModal.tsx index f520011d7..03a3e56e3 100644 --- a/packages/stateless/components/command/CommandModal.tsx +++ b/packages/stateless/components/command/CommandModal.tsx @@ -30,7 +30,7 @@ export const CommandModal = ({ return ( ({ description: 'This approach allows us to implement a completely custom component design without writing a single line of custom CSS.', imageUrl: `/placeholders/${id % 5}.svg`, + polytoneProxies: {}, // Random date in the past 12 months. established: new Date( Date.now() - Math.floor(Math.random() * 12 * 30 * 24 * 60 * 60 * 1000) diff --git a/packages/types/command.ts b/packages/types/command.ts index ca45b6ede..1cf6b130a 100644 --- a/packages/types/command.ts +++ b/packages/types/command.ts @@ -1,7 +1,9 @@ -import { ComponentType } from 'react' +import { ComponentType, ReactNode } from 'react' import { TFunction } from 'react-i18next' -export interface StatefulCommandModalProps { +import { PolytoneProxies } from './dao' + +export type StatefulCommandModalProps = { visible: boolean setVisible: (visible: boolean) => void // Root context maker can take no extra options. @@ -23,13 +25,11 @@ export type CommandModalContextSectionItem< } | { imageUrl?: never - Icon: ComponentType<{ className?: string }> + Icon: ComponentType<{ className: string }> } ) -export interface CommandModalContextSection< - ExtraItemProperties extends {} = {} -> { +export type CommandModalContextSection = { name: string items: CommandModalContextSectionItem[] onChoose: (item: CommandModalContextSectionItem) => void @@ -42,7 +42,7 @@ export interface CommandModalContextSection< loading?: boolean } -export interface CommandModalContextUseSectionsOptions { +export type CommandModalContextUseSectionsOptions = { filter: string } @@ -50,12 +50,17 @@ export type CommandModalContextUseSections = ( options: CommandModalContextUseSectionsOptions ) => CommandModalContextSection[] -export interface CommandModalContext { +export type CommandModalContext = { useSections: CommandModalContextUseSections name: string imageUrl?: string + // If defined, will wrap the context with this component around where + // `useSections` will be called. + Wrapper?: CommandModalContextWrapper } +export type CommandModalContextWrapper = ComponentType<{ children: ReactNode }> + export type CommandModalContextMakerOptions = MakerOptions & { t: TFunction @@ -66,10 +71,10 @@ export type CommandModalContextMaker = ( options: CommandModalContextMakerOptions ) => CommandModalContext -export interface CommandModalDaoInfo { +export type CommandModalDaoInfo = { chainId: string coreAddress: string name: string imageUrl: string - polytoneProxies?: string[] + polytoneProxies: PolytoneProxies } diff --git a/packages/types/dao.ts b/packages/types/dao.ts index faf6183a6..0b23229d8 100644 --- a/packages/types/dao.ts +++ b/packages/types/dao.ts @@ -289,7 +289,7 @@ export type DaoTab = { // ID used in URL hash. id: DaoTabId | string label: string - Icon?: ComponentType<{ className: string }> + Icon: ComponentType<{ className: string }> } export type DaoTabWithComponent = DaoTab & { diff --git a/packages/types/stateless/DaoCard.tsx b/packages/types/stateless/DaoCard.tsx index 98e9b5c05..676998ee7 100644 --- a/packages/types/stateless/DaoCard.tsx +++ b/packages/types/stateless/DaoCard.tsx @@ -1,6 +1,6 @@ import { ComponentType } from 'react' -import { DaoParentInfo } from '../dao' +import { DaoParentInfo, PolytoneProxies } from '../dao' import { LoadingData } from './common' import { IconButtonLinkProps } from './IconButtonLink' import { LinkWrapperProps } from './LinkWrapper' @@ -18,6 +18,7 @@ export interface DaoCardInfo { name: string description: string imageUrl: string + polytoneProxies: PolytoneProxies established?: Date className?: string showIsMember?: boolean From fa16254a1beb937570c16fdfee9969e54a00f9c0 Mon Sep 17 00:00:00 2001 From: noah Date: Thu, 12 Oct 2023 16:42:02 -0700 Subject: [PATCH 2/3] Instantiate2 and Create NFT Collection actions (#1417) --- packages/i18n/locales/en/translation.json | 19 ++ .../ValidatorActions/Component.tsx | 20 +- .../ManageWidgets/Component.tsx | 10 +- .../core/nfts/CreateNftCollection/README.md | 22 ++ .../core/nfts/CreateNftCollection/index.tsx | 146 ++++++++++ .../MintNft/ChooseExistingNftCollection.tsx | 8 +- .../nfts/MintNft/InstantiateNftCollection.tsx | 44 ++- .../actions/core/nfts/MintNft/index.tsx | 16 +- .../InstantiateNftCollection.stories.tsx | 2 - .../stateless/InstantiateNftCollection.tsx | 90 ++---- .../actions/core/nfts/MintNft/types.ts | 13 +- packages/stateful/actions/core/nfts/index.ts | 2 + .../Instantiate/Component.tsx | 2 +- .../smart_contracting/Instantiate/index.tsx | 187 +++++++------ .../Instantiate2/Component.stories.tsx | 52 ++++ .../Instantiate2/Component.tsx | 214 ++++++++++++++ .../smart_contracting/Instantiate2/README.md | 33 +++ .../smart_contracting/Instantiate2/index.tsx | 260 ++++++++++++++++++ .../actions/core/smart_contracting/index.ts | 2 + .../core/treasury/ManageStaking/index.tsx | 3 +- packages/stateful/components/index.ts | 1 + ...InstantiateNftCollectionAction.stories.tsx | 45 +++ .../nft/InstantiateNftCollectionAction.tsx | 61 ++++ packages/stateful/components/nft/index.ts | 1 + .../widgets/widgets/Press/PressEditor.tsx | 184 +++++++------ .../components/actions/ActionsEditor.tsx | 23 +- packages/stateless/components/emoji.tsx | 12 + .../components/inputs/ChainPickerInput.tsx | 18 +- packages/types/actions.ts | 8 +- packages/types/utils.ts | 2 - packages/utils/constants/chains.ts | 6 - packages/utils/validation/index.ts | 3 +- 32 files changed, 1197 insertions(+), 312 deletions(-) create mode 100644 packages/stateful/actions/core/nfts/CreateNftCollection/README.md create mode 100644 packages/stateful/actions/core/nfts/CreateNftCollection/index.tsx create mode 100644 packages/stateful/actions/core/smart_contracting/Instantiate2/Component.stories.tsx create mode 100644 packages/stateful/actions/core/smart_contracting/Instantiate2/Component.tsx create mode 100644 packages/stateful/actions/core/smart_contracting/Instantiate2/README.md create mode 100644 packages/stateful/actions/core/smart_contracting/Instantiate2/index.tsx create mode 100644 packages/stateful/components/nft/InstantiateNftCollectionAction.stories.tsx create mode 100644 packages/stateful/components/nft/InstantiateNftCollectionAction.tsx create mode 100644 packages/stateful/components/nft/index.ts diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 8f1d60275..d3546c842 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -78,6 +78,7 @@ "createAToken": "Create a token", "createAccount": "Create account", "createDAO": "Create DAO", + "createNftCollection": "Create NFT collection", "createSubDao": "Create SubDAO", "delete": "Delete", "deleteDraft": "Delete draft", @@ -226,7 +227,9 @@ "only_passed": "Only passed proposals" }, "emoji": { + "artistPalette": "Artist palette", "baby": "Baby", + "babyAngel": "Baby Angel", "ballotBox": "Ballot box", "bank": "Bank", "bee": "Bee", @@ -289,6 +292,7 @@ "cannotCreateCompensationCycleAlreadyActive": "You cannot create a new compensation cycle while one is already active.", "cannotStakeMoreThanYouHave": "You can't stake or unstake more tokens than you have.", "cannotTxZeroTokens": "You can't stake, unstake, or claim zero tokens.", + "cannotUseCreateNftCollectionOnStargaze": "You cannot create a new NFT Collection via this action on Stargaze. Use the Stargaze Studio app on the DAO's Apps page instead.", "cantSpendMoreThanTreasury": "Can't spend more tokens than are in the treasury ({{amount}} ${{tokenSymbol}}).", "chainNotConnected": "Chain not connected.", "checkInternetOrTryAgain": "Check your internet connection, refresh the page, or try again later.", @@ -322,6 +326,7 @@ "insufficientFunds": "Insufficient funds.", "insufficientWalletBalance": "Insufficient wallet balance (total: {{amount}} ${{tokenSymbol}}).", "invalidActionKeys": "Invalid action keys found: {{keys}}", + "invalidChain": "Invalid chain.", "invalidCosmosMessage": "Invalid <2>Cosmos message", "invalidDate": "Invalid date format. Please use YYYY-MM-DD.", "invalidDateTime": "Invalid date/time format. Please use YYYY-MM-DD HH:MM.", @@ -410,6 +415,7 @@ "allowRevotingTitle": "Allow revoting", "allowedMethods": "Allowed methods", "allowedMethodsDescription": "Comma separated list of smart contract method names to authorize (i.e. set_item, remove_item)", + "allowsTradingAfter": "Allows trading after", "amount": "amount", "attributeName": "Attribute name", "attributeNamePlaceholder": "Community impact", @@ -474,6 +480,8 @@ "existingCollectionAddress": "Existing collection address", "existingTokenSwapContract": "Existing token swap contract", "existingTokens": "Existing Tokens", + "explicitContent": "Explicit content", + "externalLink": "External link", "feeShareContractAddressDescription": "The address to claim fee share for. NOTE: you must be admin or the creator of this smart contract to claim fees for it.", "feeShareToggleWithdrawerAddress": "Custom withdrawer address", "feeShareToggleWithdrawerAddressTooltip_dao": "Customize the address allowed to withdraw fees. Defaults to the DAO.", @@ -524,6 +532,7 @@ "nftCollectionAddress": "NFT collection address", "nftMintInstructions": "Create a unique token ID for the NFT and enter the address of the initial owner below.", "nftUploadMetadataInstructions": "Input the NFT's metadata, and then press 'Upload' to store it on IPFS.", + "oneOneCollection": "1/1 Collection", "onlyMembersExecuteTitle": "Only members execute", "onlyMembersExecuteTooltip": "If enabled, only members may execute passed proposals.", "openDate": "Open date", @@ -565,6 +574,8 @@ "requireProposalDepositTitle": "Require proposal deposit", "requireProposalDepositTooltip": "If enabled, requires that tokens are deposited to create a proposal.", "revokeAuthorizationOption": "Revoke authorization", + "royalties": "Royalties", + "salt": "Salt", "sameAsPublicSubmissionCloseDate": "Same as public submission close date", "selectAnImage": "Select an image", "showAdvancedNftFields": "Show advanced fields", @@ -575,6 +586,7 @@ "smartContractMessageDescription": "The smart contract message to execute.", "spendingAllowance": "Spending allowance", "spendingAllowanceDescription": "The amount of funds allowed to be spent by the authorized account.", + "standardCollection": "Standard Collection", "startDate": "Start date", "subDaosToRecognize": "SubDAOs to recognize", "subDaosToRemove": "SubDAOs to remove", @@ -737,6 +749,9 @@ "createCrossChainAccountDescription": "Create an account for this DAO on another chain.", "createCrossChainAccountExplanation": "This action creates an account on another chain, allowing this DAO to perform actions on that chain.", "createFirstOneQuestion": "Create the first one?", + "createNftCollectionDescription_dao": "Create a new NFT collection controlled by the DAO.", + "createNftCollectionDescription_gov": "Create a new NFT collection controlled by the chain.", + "createNftCollectionDescription_wallet": "Create a new NFT collection controlled by you.", "createPostDescription": "Create a post on the DAO's press.", "createPressContract": "To publish content, you must first create a press contract.", "createStep1": "Pick a DAO type and name it", @@ -805,6 +820,7 @@ "inboxDescription": "Your notification inbox.", "inboxEmailTooltip": "Receive inbox notifications in your email.", "installKeplrMobileOrScanQrCode": "If you don't have Keplr Mobile installed, <2>click here to install it or scan the QR code at the bottom with another device.", + "instantiatePredictableSmartContractActionDescription": "Instantiate a smart contract with a predictable address.", "instantiateSmartContractActionDescription": "Instantiate a smart contract.", "intakeClosesAt": "Intake closes at {{date}}.", "intakeOpensAt": "Intake opens at {{date}}.", @@ -967,6 +983,7 @@ "stakedNftsExplanation": "These have been staked by DAO members to earn voting power.", "stakes": "Stakes", "stakingAddress": "Staking Contract", + "stargazeCreateCollectionFirst": "On Stargaze, you must first create the NFT Collection via a DAO proposal, and then you can mint NFTs.", "startedAt": "Started at", "startsAt": "Starts at", "startsIn": "Starts in", @@ -1190,6 +1207,7 @@ "createAProposal": "Create a proposal", "createASubDao": "Create a SubDAO", "createCrossChainAccount": "Create Cross-Chain Account", + "createNftCollection": "Create NFT Collection", "createPost": "Create Post", "createProposal": "Create proposal", "createValidator": "Create a validator", @@ -1260,6 +1278,7 @@ "inboxConfiguration": "Inbox configuration", "inboxWithCount": "Inbox ({{count}})", "initialTokenDistribution": "Initial Token Distribution", + "instantiatePredictableSmartContract": "Instantiate Predictable Smart Contract", "instantiateSmartContract": "Instantiate Smart Contract", "lastUpdated": "Last updated", "ledger": "Ledger", diff --git a/packages/stateful/actions/core/chain_governance/ValidatorActions/Component.tsx b/packages/stateful/actions/core/chain_governance/ValidatorActions/Component.tsx index f2c1f1cbb..9c94087cb 100644 --- a/packages/stateful/actions/core/chain_governance/ValidatorActions/Component.tsx +++ b/packages/stateful/actions/core/chain_governance/ValidatorActions/Component.tsx @@ -14,7 +14,7 @@ import { InputLabel, SelectInput, } from '@dao-dao/stateless' -import { ActionComponent } from '@dao-dao/types/actions' +import { ActionComponent, ActionContextType } from '@dao-dao/types/actions' import { getChainAddressForActionOptions, getChainForChainId, @@ -97,14 +97,16 @@ export const ValidatorActionsComponent: ActionComponent = ({ return ( <> - - updateChainValues(chainId, validatorActionTypeUrl) - } - /> + {options.context.type === ActionContextType.Dao && ( + + updateChainValues(chainId, validatorActionTypeUrl) + } + /> + )} = ( return } - if (mode === 'set') { + if (mode === 'set' && !widgetId) { selectWidget(availableWidgets[0]) - } else if (mode === 'delete') { + } else if ( + mode === 'delete' && + !existingWidgetsRef.current.some(({ id }) => id === widgetId) + ) { setValue( (fieldNamePrefix + 'id') as 'id', existingWidgetsRef.current[0]?.id ?? '' @@ -93,6 +96,7 @@ export const ManageWidgetsComponent: ActionComponent = ( selectWidget, availableWidgets, isCreating, + widgetId, ]) return ( @@ -182,6 +186,7 @@ export const ManageWidgetsComponent: ActionComponent = (
@@ -205,6 +210,7 @@ export const ManageWidgetsComponent: ActionComponent = (
diff --git a/packages/stateful/actions/core/nfts/CreateNftCollection/README.md b/packages/stateful/actions/core/nfts/CreateNftCollection/README.md new file mode 100644 index 000000000..f6f9d258f --- /dev/null +++ b/packages/stateful/actions/core/nfts/CreateNftCollection/README.md @@ -0,0 +1,22 @@ +# CreateNftCollection + +Create a new NFT collection. + +## Bulk import format + +This is relevant when bulk importing actions, as described in [this +guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). + +### Key + +`createNftCollection` + +### Data format + +```json +{ + "chainId": "", + "name": "", + "symbol": "" +} +``` diff --git a/packages/stateful/actions/core/nfts/CreateNftCollection/index.tsx b/packages/stateful/actions/core/nfts/CreateNftCollection/index.tsx new file mode 100644 index 000000000..7aa703488 --- /dev/null +++ b/packages/stateful/actions/core/nfts/CreateNftCollection/index.tsx @@ -0,0 +1,146 @@ +import { useCallback } from 'react' + +import { ArtistPaletteEmoji, ChainPickerInput } from '@dao-dao/stateless' +import { ChainId } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMaker, + UseDecodedCosmosMsg, + UseDefaults, + UseTransformToCosmos, +} from '@dao-dao/types/actions' +import { + decodePolytoneExecuteMsg, + getChainAddressForActionOptions, + getSupportedChainConfig, + makeWasmMessage, + maybeMakePolytoneExecuteMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { + InstantiateNftCollectionAction, + InstantiateNftCollectionData, +} from '../../../../components' +import { useActionOptions } from '../../../react' + +const Component: ActionComponent = (props) => { + const { context } = useActionOptions() + + return ( + <> + {context.type === ActionContextType.Dao && ( + + )} + + + + ) +} + +export const makeCreateNftCollectionAction: ActionMaker< + InstantiateNftCollectionData +> = (options) => { + const { + t, + chain: { chain_id: currentChainId }, + context, + } = options + + const useDefaults: UseDefaults = () => ({ + chainId: currentChainId, + name: '', + symbol: '', + }) + + const useTransformToCosmos: UseTransformToCosmos< + InstantiateNftCollectionData + > = () => + useCallback(({ chainId, name, symbol }: InstantiateNftCollectionData) => { + if ( + chainId === ChainId.StargazeMainnet || + chainId === ChainId.StargazeTestnet + ) { + throw new Error(t('error.cannotUseCreateNftCollectionOnStargaze')) + } + + const creator = getChainAddressForActionOptions(options, chainId) + + return maybeMakePolytoneExecuteMessage( + currentChainId, + chainId, + makeWasmMessage({ + wasm: { + instantiate: { + admin: creator, + code_id: + getSupportedChainConfig(chainId)?.codeIds.Cw721Base ?? -1, + funds: [], + label: name, + msg: { + minter: creator, + name, + symbol, + }, + }, + }, + }) + ) + }, []) + + const useDecodedCosmosMsg: UseDecodedCosmosMsg< + InstantiateNftCollectionData + > = (msg: Record) => { + let chainId = currentChainId + const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) + if (decodedPolytone.match) { + chainId = decodedPolytone.chainId + msg = decodedPolytone.msg + } + + return objectMatchesStructure(msg, { + wasm: { + instantiate: { + code_id: {}, + label: {}, + msg: { + minter: {}, + name: {}, + symbol: {}, + }, + funds: {}, + }, + }, + }) + ? { + match: true, + data: { + chainId, + name: msg.wasm.instantiate.name, + symbol: msg.wasm.instantiate.symbol, + }, + } + : { + match: false, + } + } + + return { + key: ActionKey.CreateNftCollection, + Icon: ArtistPaletteEmoji, + label: t('title.createNftCollection'), + description: t('info.createNftCollectionDescription', { + context: context.type, + }), + Component, + useDefaults, + useTransformToCosmos, + useDecodedCosmosMsg, + } +} diff --git a/packages/stateful/actions/core/nfts/MintNft/ChooseExistingNftCollection.tsx b/packages/stateful/actions/core/nfts/MintNft/ChooseExistingNftCollection.tsx index d332d6e52..7d75b7d7e 100644 --- a/packages/stateful/actions/core/nfts/MintNft/ChooseExistingNftCollection.tsx +++ b/packages/stateful/actions/core/nfts/MintNft/ChooseExistingNftCollection.tsx @@ -102,11 +102,11 @@ export const ChooseExistingNftCollection: ActionComponent = (props) => { // Indicate contract is ready and store name/symbol for display. setValue( - (props.fieldNamePrefix + 'instantiateMsg') as 'instantiateMsg', + (props.fieldNamePrefix + 'instantiateData') as 'instantiateData', { - // Clone to avoid mutating original. - ...info, - minter: '', + chainId, + name: info.name, + symbol: info.symbol, } ) setValue( diff --git a/packages/stateful/actions/core/nfts/MintNft/InstantiateNftCollection.tsx b/packages/stateful/actions/core/nfts/MintNft/InstantiateNftCollection.tsx index 5488abd24..984a16a4f 100644 --- a/packages/stateful/actions/core/nfts/MintNft/InstantiateNftCollection.tsx +++ b/packages/stateful/actions/core/nfts/MintNft/InstantiateNftCollection.tsx @@ -5,9 +5,12 @@ import { useTranslation } from 'react-i18next' import { useSupportedChainContext } from '@dao-dao/stateless' import { ActionComponent, ActionContextType, ActionKey } from '@dao-dao/types' -import { instantiateSmartContract, processError } from '@dao-dao/utils' +import { + getChainAddressForActionOptions, + instantiateSmartContract, + processError, +} from '@dao-dao/utils' -import { AddressInput } from '../../../../components' import { useWallet } from '../../../../hooks' import { useActionOptions } from '../../../react' import { InstantiateNftCollection as StatelessInstantiateNftCollection } from './stateless/InstantiateNftCollection' @@ -16,22 +19,24 @@ import { MintNftData } from './types' export const InstantiateNftCollection: ActionComponent = (props) => { const { t } = useTranslation() const { watch, setValue } = useFormContext() - const { context } = useActionOptions() - const { address: walletAddress, getSigningCosmWasmClient } = useWallet() - - const [instantiating, setInstantiating] = useState(false) - + const options = useActionOptions() const { chainId, config: { codeIds }, } = useSupportedChainContext() - const instantiateMsg = watch( - (props.fieldNamePrefix + 'instantiateMsg') as 'instantiateMsg' + const [instantiating, setInstantiating] = useState(false) + + const { address: walletAddress, getSigningCosmWasmClient } = useWallet({ + chainId, + }) + + const instantiateData = watch( + (props.fieldNamePrefix + 'instantiateData') as 'instantiateData' ) const onInstantiate = async () => { - if (!instantiateMsg) { + if (!instantiateData) { toast.error(t('error.loadingData')) return } @@ -41,16 +46,26 @@ export const InstantiateNftCollection: ActionComponent = (props) => { return } + if (!codeIds.Cw721Base) { + toast.error(t('error.invalidChain')) + return + } + const signingCosmWasmClient = await getSigningCosmWasmClient() setInstantiating(true) try { + const minter = getChainAddressForActionOptions(options, chainId) const contractAddress = await instantiateSmartContract( signingCosmWasmClient, walletAddress, - codeIds.Cw721Base ? codeIds.Cw721Base : codeIds.Sg721Base ?? -1, - 'NFT Collection', - instantiateMsg + codeIds.Cw721Base, + instantiateData.name, + { + minter, + name: instantiateData.name, + symbol: instantiateData.symbol, + } ) // Update action form data with address. @@ -73,7 +88,7 @@ export const InstantiateNftCollection: ActionComponent = (props) => { toast.success(t('success.nftCollectionContractInstantiated')) // Add display NFT action if in a DAO. - if (props.isCreating && context.type === ActionContextType.Dao) { + if (props.isCreating && options.context.type === ActionContextType.Dao) { props.addAction({ actionKey: ActionKey.ManageCw721, data: { @@ -97,7 +112,6 @@ export const InstantiateNftCollection: ActionComponent = (props) => { options={{ instantiating, onInstantiate, - AddressInput, }} /> ) diff --git a/packages/stateful/actions/core/nfts/MintNft/index.tsx b/packages/stateful/actions/core/nfts/MintNft/index.tsx index a748352e7..fc450d197 100644 --- a/packages/stateful/actions/core/nfts/MintNft/index.tsx +++ b/packages/stateful/actions/core/nfts/MintNft/index.tsx @@ -69,17 +69,19 @@ const Component: ActionComponent = (props) => { className="mb-4" fieldName={props.fieldNamePrefix + 'chainId'} onChange={(chainId) => { - // Update minter and recipient to correct address. + // Update recipient to correct address. const newAddress = getChainAddressForActionOptions(options, chainId) setValue( - (props.fieldNamePrefix + - 'instantiateMsg.minter') as 'instantiateMsg.minter', + (props.fieldNamePrefix + 'mintMsg.owner') as 'mintMsg.owner', newAddress ) + + // Also update instantiate chain ID. setValue( - (props.fieldNamePrefix + 'mintMsg.owner') as 'mintMsg.owner', - newAddress + (props.fieldNamePrefix + + 'instantiateData.chainId') as 'instantiateData.chainId', + chainId ) }} /> @@ -149,8 +151,8 @@ export const makeMintNftAction: ActionMaker = ({ contractChosen: false, collectionAddress: undefined, - instantiateMsg: { - minter: address, + instantiateData: { + chainId: currentChainId, name: '', symbol: '', }, diff --git a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.stories.tsx b/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.stories.tsx index 19d8ee015..231b3e3b1 100644 --- a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.stories.tsx +++ b/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.stories.tsx @@ -1,6 +1,5 @@ import { ComponentMeta, ComponentStory } from '@storybook/react' -import { AddressInput } from '@dao-dao/stateless' import { CHAIN_ID, makeDaoInfo, @@ -49,6 +48,5 @@ Default.args = { options: { onInstantiate: async () => alert('instantiate'), instantiating: false, - AddressInput, }, } diff --git a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.tsx b/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.tsx index e0842ef21..89da41742 100644 --- a/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.tsx +++ b/packages/stateful/actions/core/nfts/MintNft/stateless/InstantiateNftCollection.tsx @@ -1,80 +1,46 @@ import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { - Button, - InputErrorMessage, - InputLabel, - TextInput, - useChain, -} from '@dao-dao/stateless' -import { ActionComponent } from '@dao-dao/types' -import { makeValidateAddress, validateRequired } from '@dao-dao/utils' +import { Button } from '@dao-dao/stateless' +import { ActionComponent, ChainId } from '@dao-dao/types' -import { InstantiateNftCollectionOptions } from '../types' +import { InstantiateNftCollectionAction } from '../../../../../components' +import { InstantiateOptions, MintNftData } from '../types' // Form displayed when the user is instantiating a new NFT collection. -export const InstantiateNftCollection: ActionComponent< - InstantiateNftCollectionOptions -> = ({ - fieldNamePrefix, - errors, - options: { onInstantiate, instantiating, AddressInput }, -}) => { +export const InstantiateNftCollection: ActionComponent = ( + props +) => { const { t } = useTranslation() - const { bech32_prefix: bech32Prefix } = useChain() - const { register, trigger } = useFormContext() + const { trigger, watch } = useFormContext() + const chainId = watch( + (props.fieldNamePrefix + + 'instantiateData.chainId') as 'instantiateData.chainId' + ) return (
-
- - - - - -
- -
- - - - - -
- -
- - - - - -
- - {/* TODO(stargaze): Add Stargaze collection info field on for sg721. */} + + )} +
+
+ +
+ + + +
+ + ) +} diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate2/README.md b/packages/stateful/actions/core/smart_contracting/Instantiate2/README.md new file mode 100644 index 000000000..8e9a0a0d9 --- /dev/null +++ b/packages/stateful/actions/core/smart_contracting/Instantiate2/README.md @@ -0,0 +1,33 @@ +# Instantiate2 + +Instantiate a smart contract with a predictable address. + +## Bulk import format + +This is relevant when bulk importing actions, as described in [this +guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions). + +### Key + +`instantiate2` + +### Data format + +```json +{ + // Optional. If empty, the smart contract will have no admin and thus can + // never be upgraded. + "admin": "", + "codeId": , + "label": "", + "message": "", + "salt": "", + "funds": [ + // Optional. If not provided, no funds will be sent. + { + "denom": "", + "amount": "" + } + ] +} +``` diff --git a/packages/stateful/actions/core/smart_contracting/Instantiate2/index.tsx b/packages/stateful/actions/core/smart_contracting/Instantiate2/index.tsx new file mode 100644 index 000000000..fd30d670f --- /dev/null +++ b/packages/stateful/actions/core/smart_contracting/Instantiate2/index.tsx @@ -0,0 +1,260 @@ +import { instantiate2Address } from '@cosmjs/cosmwasm-stargate' +import { fromHex, toUtf8 } from '@cosmjs/encoding' +import { Coin } from '@cosmjs/stargate' +import JSON5 from 'json5' +import { useCallback } from 'react' +import { useFormContext } from 'react-hook-form' +import { constSelector, useRecoilValueLoadable } from 'recoil' +import { v4 as uuidv4 } from 'uuid' + +import { codeDetailsSelector } from '@dao-dao/state/recoil' +import { + BabyAngelEmoji, + ChainPickerInput, + ChainProvider, +} from '@dao-dao/stateless' +import { TokenType } from '@dao-dao/types' +import { + ActionComponent, + ActionContextType, + ActionKey, + ActionMaker, + UseDecodedCosmosMsg, + UseDefaults, + UseTransformToCosmos, +} from '@dao-dao/types/actions' +import { + convertDenomToMicroDenomWithDecimals, + convertMicroDenomToDenomWithDecimals, + decodePolytoneExecuteMsg, + getNativeTokenForChainId, + makeWasmMessage, + maybeMakePolytoneExecuteMessage, + objectMatchesStructure, +} from '@dao-dao/utils' + +import { useTokenBalances } from '../../../hooks' +import { useActionOptions } from '../../../react' +import { Instantiate2Component as StatelessInstantiate2Component } from './Component' + +// TODO(instantiate2): fix pre-propose msg issue + +type Instantiate2Data = { + chainId: string + admin: string + codeId: number + label: string + message: string + salt: string + funds: { denom: string; amount: number }[] +} + +const Component: ActionComponent = (props) => { + const { + context, + address, + chain: { chain_id: currentChainId, bech32_prefix: bech32Prefix }, + } = useActionOptions() + + const { watch, setValue } = useFormContext() + const chainId = watch((props.fieldNamePrefix + 'chainId') as 'chainId') + const codeId = watch((props.fieldNamePrefix + 'codeId') as 'codeId') + const salt = watch((props.fieldNamePrefix + 'salt') as 'salt') + const funds = watch((props.fieldNamePrefix + 'funds') as 'funds') + + // Load checksum of the contract code. + const codeDetailsLoadable = useRecoilValueLoadable( + chainId && codeId && !isNaN(codeId) + ? codeDetailsSelector({ + chainId, + codeId, + }) + : constSelector(undefined) + ) + + const nativeBalances = useTokenBalances({ + filter: TokenType.Native, + // Load selected tokens when not creating in case they are no longer + // returned in the list of all tokens for the given DAO/wallet after the + // proposal is made. + additionalTokens: props.isCreating + ? undefined + : funds.map(({ denom }) => ({ + chainId, + type: TokenType.Native, + denomOrAddress: denom, + })), + allChains: true, + }) + + const instantiatedAddress = + codeDetailsLoadable.state === 'hasValue' && codeDetailsLoadable.contents + ? instantiate2Address( + fromHex(codeDetailsLoadable.contents.checksum), + address, + toUtf8(salt), + bech32Prefix + ) + : undefined + + return ( + <> + {context.type === ActionContextType.Dao && ( + { + // Reset funds and update admin when switching chain. + setValue((props.fieldNamePrefix + 'funds') as 'funds', []) + setValue( + (props.fieldNamePrefix + 'admin') as 'admin', + chainId === currentChainId + ? address + : context.info.polytoneProxies[chainId] ?? '' + ) + }} + /> + )} + + + + + + ) +} + +export const makeInstantiate2Action: ActionMaker = ({ + t, + address, + chain: { chain_id: currentChainId }, +}) => { + const useDefaults: UseDefaults = () => ({ + chainId: currentChainId, + admin: address, + codeId: 0, + label: '', + message: '{}', + salt: uuidv4(), + funds: [], + }) + + const useTransformToCosmos: UseTransformToCosmos = () => + useCallback( + ({ + chainId, + admin, + codeId, + label, + message, + salt, + funds, + }: Instantiate2Data) => { + let msg + try { + msg = JSON5.parse(message) + } catch (err) { + console.error(`internal error. unparsable message: (${message})`, err) + return + } + + return maybeMakePolytoneExecuteMessage( + currentChainId, + chainId, + makeWasmMessage({ + wasm: { + instantiate2: { + admin: admin || null, + code_id: codeId, + funds: funds.map(({ denom, amount }) => ({ + denom, + amount: convertDenomToMicroDenomWithDecimals( + amount, + getNativeTokenForChainId(chainId).decimals + ).toString(), + })), + label, + msg, + salt, + fix_msg: false, + }, + }, + }) + ) + }, + [] + ) + + const useDecodedCosmosMsg: UseDecodedCosmosMsg = ( + msg: Record + ) => { + let chainId = currentChainId + const decodedPolytone = decodePolytoneExecuteMsg(chainId, msg) + if (decodedPolytone.match) { + chainId = decodedPolytone.chainId + msg = decodedPolytone.msg + } + + return objectMatchesStructure(msg, { + wasm: { + instantiate2: { + code_id: {}, + label: {}, + msg: {}, + funds: {}, + salt: {}, + fix_msg: {}, + }, + }, + }) + ? { + match: true, + data: { + chainId, + admin: msg.wasm.instantiate.admin ?? '', + codeId: msg.wasm.instantiate.code_id, + label: msg.wasm.instantiate.label, + message: JSON.stringify(msg.wasm.instantiate.msg, undefined, 2), + salt: msg.wasm.instantiate.salt, + funds: (msg.wasm.instantiate.funds as Coin[]).map( + ({ denom, amount }) => ({ + denom, + amount: Number( + convertMicroDenomToDenomWithDecimals( + amount, + getNativeTokenForChainId(chainId).decimals + ) + ), + }) + ), + _polytone: decodedPolytone.match + ? { + chainId: decodedPolytone.chainId, + note: decodedPolytone.polytoneConnection, + initiatorMsg: decodedPolytone.initiatorMsg, + } + : undefined, + }, + } + : { + match: false, + } + } + + return { + key: ActionKey.Instantiate2, + Icon: BabyAngelEmoji, + label: t('title.instantiatePredictableSmartContract'), + description: t('info.instantiatePredictableSmartContractActionDescription'), + Component, + useDefaults, + useTransformToCosmos, + useDecodedCosmosMsg, + } +} diff --git a/packages/stateful/actions/core/smart_contracting/index.ts b/packages/stateful/actions/core/smart_contracting/index.ts index 26eb13559..5a12feecf 100644 --- a/packages/stateful/actions/core/smart_contracting/index.ts +++ b/packages/stateful/actions/core/smart_contracting/index.ts @@ -3,6 +3,7 @@ import { ActionCategoryKey, ActionCategoryMaker } from '@dao-dao/types' import { makeExecuteAction } from './Execute' import { makeFeeShareAction } from './FeeShare' import { makeInstantiateAction } from './Instantiate' +import { makeInstantiate2Action } from './Instantiate2' import { makeMigrateAction } from './Migrate' import { makeUpdateAdminAction } from './UpdateAdmin' @@ -14,6 +15,7 @@ export const makeSmartContractingActionCategory: ActionCategoryMaker = ({ description: t('actionCategory.smartContractingDescription'), actionMakers: [ makeInstantiateAction, + makeInstantiate2Action, makeExecuteAction, makeMigrateAction, makeUpdateAdminAction, diff --git a/packages/stateful/actions/core/treasury/ManageStaking/index.tsx b/packages/stateful/actions/core/treasury/ManageStaking/index.tsx index 378360fc6..22450da81 100644 --- a/packages/stateful/actions/core/treasury/ManageStaking/index.tsx +++ b/packages/stateful/actions/core/treasury/ManageStaking/index.tsx @@ -364,10 +364,9 @@ const Component: ActionComponent = (props) => { return ( <> - {context.type === ActionContextType.Dao && ( + {context.type === ActionContextType.Dao && props.isCreating && ( { diff --git a/packages/stateful/components/index.ts b/packages/stateful/components/index.ts index 9a77d15b5..482dac331 100644 --- a/packages/stateful/components/index.ts +++ b/packages/stateful/components/index.ts @@ -1,6 +1,7 @@ export * from './dao' export * from './gov' export * from './inbox' +export * from './nft' export * from './pages' export * from './profile' export * from './wallet' diff --git a/packages/stateful/components/nft/InstantiateNftCollectionAction.stories.tsx b/packages/stateful/components/nft/InstantiateNftCollectionAction.stories.tsx new file mode 100644 index 000000000..8952e2877 --- /dev/null +++ b/packages/stateful/components/nft/InstantiateNftCollectionAction.stories.tsx @@ -0,0 +1,45 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react' + +import { + CHAIN_ID, + makeDaoInfo, + makeDaoProvidersDecorator, + makeReactHookFormDecorator, +} from '@dao-dao/storybook' + +import { + InstantiateNftCollectionAction, + InstantiateNftCollectionData, +} from './InstantiateNftCollectionAction' + +export default { + title: + 'DAO DAO / packages / stateful / components / nft / InstantiateNftCollectionAction', + component: InstantiateNftCollectionAction, + decorators: [ + makeReactHookFormDecorator({ + chainId: CHAIN_ID, + name: '', + symbol: '', + }), + makeDaoProvidersDecorator(makeDaoInfo()), + ], +} as ComponentMeta + +const Template: ComponentStory = ( + args +) => ( +
+ +
+) + +export const Default = Template.bind({}) +Default.args = { + fieldNamePrefix: '', + allActionsWithData: [], + index: 0, + data: {}, + isCreating: true, + errors: {}, +} diff --git a/packages/stateful/components/nft/InstantiateNftCollectionAction.tsx b/packages/stateful/components/nft/InstantiateNftCollectionAction.tsx new file mode 100644 index 000000000..609eb7103 --- /dev/null +++ b/packages/stateful/components/nft/InstantiateNftCollectionAction.tsx @@ -0,0 +1,61 @@ +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { InputErrorMessage, InputLabel, TextInput } from '@dao-dao/stateless' +import { ActionComponent, ChainId } from '@dao-dao/types' +import { validateRequired } from '@dao-dao/utils' + +export type InstantiateNftCollectionData = { + chainId: string + name: string + symbol: string +} + +// Form displayed when the user is instantiating a new NFT collection. +export const InstantiateNftCollectionAction: ActionComponent = ({ + isCreating, + fieldNamePrefix, + errors, +}) => { + const { t } = useTranslation() + const { register, watch } = useFormContext() + + const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId') + + return ( +
+ {chainId === ChainId.StargazeMainnet || + chainId === ChainId.StargazeTestnet ? ( +

+ {t('error.cannotUseCreateNftCollectionOnStargaze')} +

+ ) : ( + <> +
+ + + +
+ +
+ + + +
+ + )} +
+ ) +} diff --git a/packages/stateful/components/nft/index.ts b/packages/stateful/components/nft/index.ts new file mode 100644 index 000000000..90d69dd35 --- /dev/null +++ b/packages/stateful/components/nft/index.ts @@ -0,0 +1 @@ +export * from './InstantiateNftCollectionAction' diff --git a/packages/stateful/widgets/widgets/Press/PressEditor.tsx b/packages/stateful/widgets/widgets/Press/PressEditor.tsx index dc574c9eb..7bc8a370f 100644 --- a/packages/stateful/widgets/widgets/Press/PressEditor.tsx +++ b/packages/stateful/widgets/widgets/Press/PressEditor.tsx @@ -1,24 +1,28 @@ -import { Check } from '@mui/icons-material' -import { useEffect, useState } from 'react' +import { instantiate2Address } from '@cosmjs/cosmwasm-stargate' +import { fromHex, toUtf8 } from '@cosmjs/encoding' +import { useEffect } from 'react' import { useFormContext } from 'react-hook-form' -import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' +import { codeDetailsSelector } from '@dao-dao/state/recoil' import { - Button, + Loader, + useCachedLoading, useDaoInfoContext, useSupportedChainContext, } from '@dao-dao/stateless' -import { WidgetEditorProps } from '@dao-dao/types' +import { ActionKey, WidgetEditorProps } from '@dao-dao/types' import { InstantiateMsg as Cw721InstantiateMsg } from '@dao-dao/types/contracts/Cw721Base' -import { InstantiateMsg as Sg721InstantiateMsg } from '@dao-dao/types/contracts/Sg721Base' -import { instantiateSmartContract, processError } from '@dao-dao/utils' -import { useWallet } from '../../../hooks/useWallet' +import { useActionOptions } from '../../../actions' import { PressData } from './types' export const PressEditor = ({ fieldNamePrefix, + allActionsWithData, + index, + addAction, + isCreating, }: WidgetEditorProps) => { const { t } = useTranslation() @@ -26,71 +30,107 @@ export const PressEditor = ({ config: { codeIds }, } = useSupportedChainContext() const { name: daoName, coreAddress } = useDaoInfoContext() - const { address: walletAddress = '', getSigningCosmWasmClient } = useWallet() + const { + chain: { chain_id: chainId, bech32_prefix: bech32Prefix }, + } = useActionOptions() const { setValue, setError, clearErrors, watch } = useFormContext() + const contract = watch((fieldNamePrefix + 'contract') as 'contract') - const [instantiating, setInstantiating] = useState(false) - const instantiate = async () => { - if (!walletAddress) { - toast.error(t('error.logInToContinue')) + // Ensure instantiate2 action exists with the right fields when editing. + const codeId = codeIds.Cw721Base || -1 + const codeDetailsLoading = useCachedLoading( + codeId > -1 + ? codeDetailsSelector({ + chainId, + codeId, + }) + : undefined, + undefined + ) + const salt = 'DAO DAO Press' + const instantiate2ActionExists = + isCreating && + allActionsWithData.some( + ({ actionKey, data }) => + actionKey === ActionKey.Instantiate2 && + data.chainId === chainId && + data.codeId === codeId && + data.salt === salt + ) + // Matches the address of the instantiate2 action. + const generatedContractAddress = + codeDetailsLoading.loading || !codeDetailsLoading.data + ? undefined + : instantiate2Address( + fromHex(codeDetailsLoading.data.checksum), + coreAddress, + toUtf8(salt), + bech32Prefix + ) + + useEffect(() => { + // If not creating, data not loaded yet, or action exists and contract + // already set, do nothing. + if ( + !isCreating || + !addAction || + codeDetailsLoading.loading || + !codeDetailsLoading.data || + !generatedContractAddress || + (instantiate2ActionExists && contract === generatedContractAddress) + ) { return } - const signingCosmWasmClient = await getSigningCosmWasmClient() - - setInstantiating(true) - try { - const name = `${daoName}'s Press` - - const contractAddress = codeIds.Cw721Base - ? await instantiateSmartContract( - signingCosmWasmClient, - walletAddress, - codeIds.Cw721Base, - name, - { - minter: coreAddress, - name: name, - symbol: 'PRESS', - } as Cw721InstantiateMsg - ) - : codeIds.Sg721Base - ? // TODO(stargaze): test this - await instantiateSmartContract( - signingCosmWasmClient, - walletAddress, - codeIds.Sg721Base, - name, + const name = `${daoName}'s Press` + // Otherwise add the instantiate2 action. + setValue( + (fieldNamePrefix + 'contract') as 'contract', + generatedContractAddress + ) + addAction( + { + actionKey: ActionKey.Instantiate2, + data: { + chainId, + admin: coreAddress, + codeId, + label: name, + message: JSON.stringify( { - collection_info: { - creator: coreAddress, - description: `${name} on DAO DAO`, - image: '', - }, minter: coreAddress, - name: name, + name, symbol: 'PRESS', - } as Sg721InstantiateMsg - ) - : undefined - - // Should never happen. - if (!contractAddress) { - throw new Error(t('error.loadingData')) - } - - setValue((fieldNamePrefix + 'contract') as 'contract', contractAddress) - - toast.success(t('success.created')) - } catch (err) { - console.error(err) - toast.error(processError(err)) - } finally { - setInstantiating(false) - } - } + } as Cw721InstantiateMsg, + null, + 2 + ), + salt, + funds: [], + }, + }, + index + ) + }, [ + addAction, + bech32Prefix, + chainId, + codeDetailsLoading, + codeId, + codeIds.Cw721Base, + contract, + coreAddress, + daoName, + isCreating, + fieldNamePrefix, + index, + instantiate2ActionExists, + salt, + setValue, + generatedContractAddress, + ]) // Prevent action from being submitted if the contract has not yet been // created. @@ -104,21 +144,5 @@ export const PressEditor = ({ } }, [setError, clearErrors, t, contract, fieldNamePrefix]) - return ( -
-

- {contract - ? t('info.createdPressContract') - : t('info.createPressContract')} -

- - {contract ? ( - - ) : ( - - )} -
- ) + return <>{!contract && } } diff --git a/packages/stateless/components/actions/ActionsEditor.tsx b/packages/stateless/components/actions/ActionsEditor.tsx index 4a84586a2..6508d4a7c 100644 --- a/packages/stateless/components/actions/ActionsEditor.tsx +++ b/packages/stateless/components/actions/ActionsEditor.tsx @@ -78,8 +78,8 @@ export const ActionsEditor = ({ const loadedAction = actionKey && loadedActions[actionKey] // Get category from loaded action if key is undefined. It should only be - // undefined if the action data is loaded from a duplicate/prefill or - // bulk import. + // undefined if the action data is loaded from a duplicate/prefill or bulk + // import. const category = categoryKey ? categories.find((c) => c.key === categoryKey) : loadedAction?.category @@ -123,7 +123,7 @@ export const ActionsEditor = ({ data, }) } else { - // or create new group if previously adjacent group is for a different + // Or create new group if previously adjacent group is for a different // action. acc.push({ category, @@ -207,15 +207,24 @@ export const ActionEditor = ({ control, }) const addAction = useCallback( - (data: Omit) => - append({ + ( + data: Partial, + insertIndex?: number + ) => { + const actionData: PartialCategorizedActionKeyAndData = { // See `CategorizedActionKeyAndData` comment in // `packages/types/actions.ts` for an explanation of why we need to // append with a unique ID. _id: uuidv4(), + // Allow overriding ID if passed. ...data, - }), - [append] + } + + return insertIndex !== undefined + ? insert(insertIndex, actionData) + : append(actionData) + }, + [append, insert] ) // All categorized actions from the form. diff --git a/packages/stateless/components/emoji.tsx b/packages/stateless/components/emoji.tsx index 5e61ef7ae..94a619a09 100644 --- a/packages/stateless/components/emoji.tsx +++ b/packages/stateless/components/emoji.tsx @@ -71,6 +71,14 @@ export const ImageEmoji = () => ( ) +export const CameraEmoji = () => ( + +) + +export const ArtistPaletteEmoji = () => ( + +) + export const RobotEmoji = () => ( ) @@ -83,6 +91,10 @@ export const BabyEmoji = () => ( ) +export const BabyAngelEmoji = () => ( + +) + export const WhaleEmoji = () => ( ) diff --git a/packages/stateless/components/inputs/ChainPickerInput.tsx b/packages/stateless/components/inputs/ChainPickerInput.tsx index 50dce5707..8411d436c 100644 --- a/packages/stateless/components/inputs/ChainPickerInput.tsx +++ b/packages/stateless/components/inputs/ChainPickerInput.tsx @@ -15,6 +15,7 @@ export type ChainPickerInputProps = { labelMode?: 'chain' | 'token' disabled?: boolean onChange?: (chainId: string) => void + excludeChainIds?: string[] className?: string } @@ -23,6 +24,7 @@ export const ChainPickerInput = ({ labelMode = 'chain', disabled, onChange, + excludeChainIds, className, }: ChainPickerInputProps) => { const { @@ -47,13 +49,15 @@ export const ChainPickerInput = ({ chainId, // Other chains with Polytone. ...polytoneChains, - ].map((chainId) => ({ - label: - labelMode === 'chain' - ? getDisplayNameForChainId(chainId) - : getNativeTokenForChainId(chainId).symbol, - value: chainId, - }))} + ] + .filter((chainId) => !excludeChainIds?.includes(chainId)) + .map((chainId) => ({ + label: + labelMode === 'chain' + ? getDisplayNameForChainId(chainId) + : getNativeTokenForChainId(chainId).symbol, + value: chainId, + }))} setValue={setValue} watch={watch} /> diff --git a/packages/types/actions.ts b/packages/types/actions.ts index 3c8dcd9f6..42f27e76d 100644 --- a/packages/types/actions.ts +++ b/packages/types/actions.ts @@ -29,12 +29,14 @@ export enum ActionKey { ManageStaking = 'manageStaking', ManageCw20 = 'manageCw20', ManageCw721 = 'manageCw721', + CreateNftCollection = 'createNftCollection', TransferNft = 'transferNft', MintNft = 'mintNft', BurnNft = 'burnNft', ManageSubDaos = 'manageSubDaos', UpdateInfo = 'updateInfo', Instantiate = 'instantiate', + Instantiate2 = 'instantiate2', Execute = 'execute', Migrate = 'migrate', UpdateAdmin = 'updateAdmin', @@ -117,7 +119,9 @@ export type ActionComponentProps = { errors: FieldErrors // Adds a new action to the form. addAction: ( - action: Omit + action: Partial, + // If omitted, the action will be appened to the end of the list. + insertIndex?: number ) => void // Removes this action from the form. remove: () => void @@ -180,7 +184,7 @@ export interface Action { // Programmatic actions cannot be chosen or removed by the user. This is used // for actions should only be controlled by code. The user should not be able // to modify it at all, which also means the user cannot pick this action or - // go back to the category action picker. This includes both`hideFromPicker` + // go back to the category action picker. This includes both `hideFromPicker` // and `notReusable`, while also preventing the user from going back to the // category action picker or removing the action. programmaticOnly?: boolean diff --git a/packages/types/utils.ts b/packages/types/utils.ts index 2b5b9b222..cad24e18f 100644 --- a/packages/types/utils.ts +++ b/packages/types/utils.ts @@ -24,8 +24,6 @@ export interface CodeIdConfig { Cw4Group: number // https://github.com/CosmWasm/cw-nfts Cw721Base?: number - // https://github.com/public-awesome/launchpad/tree/main/contracts/collections/sg721-base - Sg721Base?: number // https://github.com/DA0-DA0/dao-contracts Cw20Stake: number diff --git a/packages/utils/constants/chains.ts b/packages/utils/constants/chains.ts index 722841c0e..911706264 100644 --- a/packages/utils/constants/chains.ts +++ b/packages/utils/constants/chains.ts @@ -209,9 +209,6 @@ export const SUPPORTED_CHAINS: Partial> = // https://github.com/CosmWasm/cw-plus Cw20Base: -1, // v0.16 Cw4Group: 83, // v0.16 - // https://github.com/public-awesome/launchpad/tree/main/contracts/collections/sg721-base - // https://github.com/public-awesome/stargaze-tools/blob/main/config.example.js - Sg721Base: 41, // ContractVersion.V210 // https://github.com/DA0-DA0/dao-contracts/releases/tag/v2.1.0 @@ -387,9 +384,6 @@ export const SUPPORTED_CHAINS: Partial> = // https://github.com/CosmWasm/cw-plus Cw20Base: -1, // v0.16 Cw4Group: 2887, // v0.16 - // https://github.com/public-awesome/launchpad/tree/main/contracts/collections/sg721-base - // https://github.com/public-awesome/stargaze-tools/blob/main/config.example.js - Sg721Base: 2595, // ContractVersion.V210 // https://github.com/DA0-DA0/dao-contracts/releases/tag/v2.1.0 diff --git a/packages/utils/validation/index.ts b/packages/utils/validation/index.ts index 2e42f7582..4a0933dea 100644 --- a/packages/utils/validation/index.ts +++ b/packages/utils/validation/index.ts @@ -59,8 +59,9 @@ export const validateUrlWithIpfs = (v: string | undefined) => 'Invalid image URL: must start with https or ipfs.' export const makeValidateDate = - (t: TFunction, time = false) => + (t: TFunction, time = false, required = true) => (v: string | undefined) => + (!required && !v) || (v && !isNaN(Date.parse(v))) || t(time ? 'error.invalidDateTime' : 'error.invalidDate') From 0ef522ec2eec95747337d07773bd3a0506ea0656 Mon Sep 17 00:00:00 2001 From: noah Date: Mon, 16 Oct 2023 17:27:51 -0700 Subject: [PATCH 3/3] Improve custom action UX (#1419) --- packages/i18n/locales/en/translation.json | 2 + .../core/advanced/Custom/Component.tsx | 84 +++++++++++++++++-- .../actions/core/advanced/Custom/index.tsx | 6 +- .../components/popup/FilterableItemPopup.tsx | 7 +- packages/utils/messages/protobuf.ts | 7 +- 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index d3546c842..95d61c50e 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -113,6 +113,7 @@ "installKeplr": "Install Keplr", "instantiate": "Instantiate", "loadDraft": "Load draft", + "loadMessageTemplate": "Load message template", "loadMore": "Load more", "loadSaves": "Load saves", "logIn": "Log in", @@ -965,6 +966,7 @@ "searchForChain": "Search for a chain...", "searchForToken": "Search for a token...", "searchForWidget": "Search for a widget...", + "searchMessages": "Search messages", "searchNftsPlaceholder": "Find an NFT...", "searchValidatorsPlaceholder": "Find a validator...", "selfRelayDescription": "One or more messages in this proposal require self-relaying across chains since automatic relayers do not exist or are inactive right now.", diff --git a/packages/stateful/actions/core/advanced/Custom/Component.tsx b/packages/stateful/actions/core/advanced/Custom/Component.tsx index 8fae34009..1c2b32c5d 100644 --- a/packages/stateful/actions/core/advanced/Custom/Component.tsx +++ b/packages/stateful/actions/core/advanced/Custom/Component.tsx @@ -1,30 +1,93 @@ import { Check, Close } from '@mui/icons-material' import JSON5 from 'json5' +import { useMemo } from 'react' import { useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' -import { CodeMirrorInput } from '@dao-dao/stateless' +import { + CodeMirrorInput, + FilterableItemPopup, + useChain, +} from '@dao-dao/stateless' +import { ChainId } from '@dao-dao/types' import { ActionComponent } from '@dao-dao/types/actions' import { + PROTOBUF_TYPES, makeStargateMessage, makeWasmMessage, validateCosmosMsg, } from '@dao-dao/utils' +export type CustomData = { + message: string +} + export const CustomComponent: ActionComponent = ({ fieldNamePrefix, errors, isCreating, }) => { const { t } = useTranslation() - const { control } = useFormContext() + const { control, setValue } = useFormContext() + const { chain_id: chainId } = useChain() + + const types = useMemo( + () => + PROTOBUF_TYPES.filter( + ([type]) => + // Only show protobuf message types. + type.split('.').pop()?.startsWith('Msg') && + // Only show osmosis message types on Osmosis chains. + (!type.startsWith('/osmosis') || + chainId === ChainId.OsmosisMainnet || + chainId === ChainId.OsmosisTestnet) + ), + [chainId] + ) return ( <> + {isCreating && ( + ({ + key, + label: key, + type, + }))} + labelClassName="break-words whitespace-normal" + onSelect={({ key, type }) => + setValue( + (fieldNamePrefix + 'message') as 'message', + JSON.stringify( + { + stargate: { + typeUrl: key, + // Decoding empty data returns default. + value: type.decode(new Uint8Array()), + }, + }, + null, + 2 + ) + ) + } + searchPlaceholder={t('info.searchMessages')} + trigger={{ + type: 'button', + props: { + className: 'self-start', + variant: 'secondary', + children: t('button.loadMessageTemplate'), + }, + }} + /> + )} + { @@ -34,14 +97,15 @@ export const CustomComponent: ActionComponent = ({ } catch (e: any) { return e.message as string } - if (msg.wasm) { - msg = makeWasmMessage(msg) - } - if (msg.stargate) { - msg = makeStargateMessage(msg) - } try { + if (msg.wasm) { + msg = makeWasmMessage(msg) + } + if (msg.stargate) { + msg = makeStargateMessage(msg) + } + validateCosmosMsg(msg) } catch (err) { return err instanceof Error ? err.message : `${err}` @@ -77,3 +141,5 @@ export const CustomComponent: ActionComponent = ({ ) } + +const FILTERABLE_KEYS = ['label'] diff --git a/packages/stateful/actions/core/advanced/Custom/index.tsx b/packages/stateful/actions/core/advanced/Custom/index.tsx index ea133edd9..f23b3ebdc 100644 --- a/packages/stateful/actions/core/advanced/Custom/index.tsx +++ b/packages/stateful/actions/core/advanced/Custom/index.tsx @@ -11,11 +11,7 @@ import { } from '@dao-dao/types/actions' import { makeStargateMessage, makeWasmMessage } from '@dao-dao/utils' -import { CustomComponent as Component } from './Component' - -interface CustomData { - message: string -} +import { CustomComponent as Component, CustomData } from './Component' const useDefaults: UseDefaults = () => ({ message: '{}', diff --git a/packages/stateless/components/popup/FilterableItemPopup.tsx b/packages/stateless/components/popup/FilterableItemPopup.tsx index 40139b766..3bb9edc92 100644 --- a/packages/stateless/components/popup/FilterableItemPopup.tsx +++ b/packages/stateless/components/popup/FilterableItemPopup.tsx @@ -43,6 +43,7 @@ export interface FilterableItemPopupProps< onSelect: (item: T, index: number) => void searchPlaceholder?: string listClassName?: string + labelClassName?: string closeOnSelect?: boolean getKeydownEventListener?: ( open: boolean, @@ -57,6 +58,7 @@ export const FilterableItemPopup = ({ onSelect, searchPlaceholder, listClassName, + labelClassName, closeOnSelect = true, getKeydownEventListener, }: FilterableItemPopupProps) => { @@ -257,12 +259,13 @@ export const FilterableItemPopup = ({
{item.selected && ( - + )}

diff --git a/packages/utils/messages/protobuf.ts b/packages/utils/messages/protobuf.ts index d9f557aec..b0f0bfdcb 100644 --- a/packages/utils/messages/protobuf.ts +++ b/packages/utils/messages/protobuf.ts @@ -453,14 +453,15 @@ export const decodedStargateMsgToCw = ({ } } -export const typesRegistry = new Registry([ +export const PROTOBUF_TYPES: ReadonlyArray<[string, GeneratedType]> = [ ...cosmosProtoRegistry, ...cosmwasmProtoRegistry, - ['/google.protobuf.Timestamp', google.protobuf.Timestamp], + ['/google.protobuf.Timestamp', google.protobuf.Timestamp as GeneratedType], ...junoProtoRegistry, ...osmosisProtoRegistry, ...ibcProtoRegistry, -] as ReadonlyArray<[string, GeneratedType]>) +] +export const typesRegistry = new Registry(PROTOBUF_TYPES) export const aminoTypes = new AminoTypes({ ...cosmosAminoConverters,