diff --git a/packages/stateful/components/WalletProvider.tsx b/packages/stateful/components/WalletProvider.tsx index 313329162..cd17f223f 100644 --- a/packages/stateful/components/WalletProvider.tsx +++ b/packages/stateful/components/WalletProvider.tsx @@ -212,7 +212,36 @@ export const WalletProvider = ({ const InnerWalletProvider = ({ children }: PropsWithChildren<{}>) => { useSyncWalletSigner() - const { isWalletDisconnected, walletRepo } = useWallet() + const { isWalletConnected, isWalletDisconnected, walletRepo, wallet } = + useWallet() + + // Refresh connection on wallet change. + useEffect(() => { + if (typeof window === 'undefined' || !isWalletConnected || !wallet) { + return + } + + const refresh = async () => { + // Ensure connection still alive, and disconnect on failure. + try { + await walletRepo.connect(wallet.name) + } catch { + await walletRepo.disconnect(wallet.name).catch(console.error) + } + } + + wallet.connectEventNamesOnWindow?.forEach((eventName) => { + window.addEventListener(eventName, refresh) + }) + + // Clean up on unmount. + return () => { + wallet.connectEventNamesOnWindow?.forEach((eventName) => { + window.removeEventListener(eventName, refresh) + }) + } + }, [isWalletConnected, wallet, walletRepo]) + // Auto-connect to Keplr mobile web if in that context. const isKeplrMobileWeb = useRecoilValue(isKeplrMobileWebAtom) useEffect(() => { diff --git a/packages/stateful/components/dao/tabs/BrowserTab.tsx b/packages/stateful/components/dao/tabs/BrowserTab.tsx index 0fbb6c489..beb47d682 100644 --- a/packages/stateful/components/dao/tabs/BrowserTab.tsx +++ b/packages/stateful/components/dao/tabs/BrowserTab.tsx @@ -1,23 +1,52 @@ import { StdSignDoc } from '@cosmjs/amino' import { DirectSignDoc } from '@cosmos-kit/core' import { useIframe } from '@cosmos-kit/react-lite' -import { useEffect, useRef } from 'react' +import cloneDeep from 'lodash.clonedeep' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { FormProvider, useForm } from 'react-hook-form' +import { useTranslation } from 'react-i18next' +import { useRecoilState, useSetRecoilState } from 'recoil' +import useDeepCompareEffect from 'use-deep-compare-effect' +import { v4 as uuidv4 } from 'uuid' import { TxBody } from '@dao-dao/protobuf/codegen/cosmos/tx/v1beta1/tx' import { + proposalCreatedCardPropsAtom, + proposalDraftsAtom, + refreshProposalsIdAtom, +} from '@dao-dao/state/recoil' +import { + Loader, + Modal, BrowserTab as StatelessBrowserTab, + useChainContext, useDaoInfoContext, - useDaoNavHelpers, } from '@dao-dao/stateless' -import { ActionKey, CosmosMsgFor_Empty } from '@dao-dao/types' import { + BaseNewProposalProps, + CosmosMsgFor_Empty, + PartialCategorizedActionKeyAndData, + PartialCategorizedActionKeyAndDataNoId, + ProposalDraft, +} from '@dao-dao/types' +import { + DaoProposalSingleAdapterId, SITE_URL, aminoTypes, + decodeMessages, decodedStargateMsgToCw, - getDaoProposalSinglePrefill, getFallbackImage, protobufToCwMsg, } from '@dao-dao/utils' + +import { useActionsForMatching } from '../../../actions' +import { + matchAndLoadCommon, + matchAdapter as matchProposalModuleAdapter, +} from '../../../proposal-module-adapter' +import { NewProposalForm as NewSingleChoiceProposalForm } from '../../../proposal-module-adapter/adapters/DaoProposalSingle/types' +import { SuspenseLoader } from '../../SuspenseLoader' + export const BrowserTab = () => { const { name, @@ -26,42 +55,34 @@ export const BrowserTab = () => { coreAddress, polytoneProxies, } = useDaoInfoContext() - const { getDaoProposalPath } = useDaoNavHelpers() - - const propose = (msgs: CosmosMsgFor_Empty[]) => - window.open( - getDaoProposalPath(coreAddress, 'create', { - prefill: getDaoProposalSinglePrefill({ - title: '', - description: '', - actions: [ - { - actionKey: ActionKey.Custom, - data: { - message: JSON.stringify(msgs, undefined, 2), - }, - }, - ], - }), - }), - '_blank' - ) + + const [msgs, setMsgs] = useState() const decodeDirect = (signDocBodyBytes: Uint8Array) => { const encodedMessages = TxBody.decode(signDocBodyBytes).messages const messages = encodedMessages.map((msg) => protobufToCwMsg(msg).msg) - propose(messages) + setMsgs(messages) } const decodeAmino = (signDoc: StdSignDoc) => { const messages = signDoc.msgs.map( (msg) => decodedStargateMsgToCw(aminoTypes.fromAmino(msg)).msg ) - propose(messages) + setMsgs(messages) } const addressForChainId = (chainId: string) => (chainId === currentChainId ? coreAddress : polytoneProxies[chainId]) || '' + const enableAndConnect = (chainIds: string | string[]) => + [chainIds].flat().some((chainId) => addressForChainId(chainId)) + ? { + type: 'success', + } + : { + type: 'error', + error: `Unsupported chains: ${[chainIds].flat().join(', ')}.`, + } + const { wallet, iframeRef } = useIframe({ walletInfo: { prettyName: name, @@ -72,12 +93,10 @@ export const BrowserTab = () => { address: addressForChainId(chainId), }), walletClientOverrides: { - // TODO(iframe): remove // @ts-ignore signAmino: (_chainId: string, _signer: string, signDoc: StdSignDoc) => { decodeAmino(signDoc) }, - // TODO(iframe): remove // @ts-ignore signDirect: ( _chainId: string, @@ -92,24 +111,8 @@ export const BrowserTab = () => { decodeDirect(signDoc.bodyBytes) }, - enable: (chainIds: string | string[]) => - [chainIds].flat().every((chainId) => addressForChainId(chainId)) - ? { - type: 'success', - } - : { - type: 'error', - error: 'Unsupported chain.', - }, - connect: (chainIds: string | string[]) => - [chainIds].flat().every((chainId) => addressForChainId(chainId)) - ? { - type: 'success', - } - : { - type: 'error', - error: 'Unsupported chain.', - }, + enable: enableAndConnect, + connect: enableAndConnect, sign: () => ({ type: 'error', value: 'Unsupported.', @@ -160,5 +163,256 @@ export const BrowserTab = () => { } }, [wallet]) - return + return ( + <> + + {msgs && ( + + )} + + ) +} + +type ActionMatcherAndProposerProps = { + msgs: CosmosMsgFor_Empty[] + setMsgs: (msgs: CosmosMsgFor_Empty[] | undefined) => void +} + +const ActionMatcherAndProposer = ({ + msgs, + setMsgs, +}: ActionMatcherAndProposerProps) => { + const { t } = useTranslation() + const { coreAddress, proposalModules } = useDaoInfoContext() + const { chain } = useChainContext() + + const singleChoiceProposalModule = proposalModules.find( + ({ contractName }) => + matchProposalModuleAdapter(contractName)?.id === + DaoProposalSingleAdapterId + ) + const proposalModuleAdapterCommon = useMemo( + () => + singleChoiceProposalModule && + matchAndLoadCommon(singleChoiceProposalModule, { + chain, + coreAddress, + }), + [chain, coreAddress, singleChoiceProposalModule] + ) + + if (!proposalModuleAdapterCommon) { + throw new Error(t('error.noSingleChoiceProposalModule')) + } + + const { + fields: { newProposalFormTitleKey }, + components: { NewProposal }, + } = proposalModuleAdapterCommon + + const actionsForMatching = useActionsForMatching() + + const decodedMessages = useMemo(() => decodeMessages(msgs), [msgs]) + + // Call relevant action hooks in the same order every time. + const actionData: PartialCategorizedActionKeyAndDataNoId[] = + decodedMessages.map((message) => { + const actionMatch = actionsForMatching + .map(({ category, action }) => ({ + category, + action, + ...action.useDecodedCosmosMsg(message), + })) + .find(({ match }) => match) + + // There should always be a match since custom matches all. This should + // never happen as long as the Custom action exists. + if (!actionMatch?.match) { + throw new Error(t('error.loadingData')) + } + + return { + categoryKey: actionMatch.category.key, + actionKey: actionMatch.action.key, + data: actionMatch.data, + } + }) + + const formMethods = useForm({ + mode: 'onChange', + defaultValues: { + title: '', + description: '', + actionData: actionData.map( + (data): PartialCategorizedActionKeyAndData => ({ + _id: uuidv4(), + ...data, + }) + ), + }, + }) + const proposalData = formMethods.watch() + + // If contents of matched action data change, update form. + useDeepCompareEffect(() => { + formMethods.reset({ + title: proposalData.title, + description: proposalData.description, + actionData: actionData.map( + (data): PartialCategorizedActionKeyAndData => ({ + _id: uuidv4(), + ...data, + }) + ), + }) + }, [actionData]) + + const setProposalCreatedCardProps = useSetRecoilState( + proposalCreatedCardPropsAtom + ) + + const setRefreshProposalsId = useSetRecoilState(refreshProposalsIdAtom) + const refreshProposals = useCallback( + () => setRefreshProposalsId((id) => id + 1), + [setRefreshProposalsId] + ) + + const [drafts, setDrafts] = useRecoilState(proposalDraftsAtom(coreAddress)) + const [draftIndex, setDraftIndex] = useState() + const draft = + draftIndex !== undefined && drafts.length > draftIndex + ? drafts[draftIndex] + : undefined + const deleteDraft = useCallback( + (deleteIndex: number) => { + setDrafts((drafts) => drafts.filter((_, index) => index !== deleteIndex)) + setDraftIndex(undefined) + }, + [setDrafts] + ) + const unloadDraft = () => setDraftIndex(undefined) + + const proposalName = formMethods.watch(newProposalFormTitleKey as any) + const saveDraft = useCallback(() => { + // Already saving to a selected draft. + if (draft) { + return + } + + const newDraft: ProposalDraft = { + name: proposalName, + createdAt: Date.now(), + lastUpdatedAt: Date.now(), + proposal: { + id: proposalModuleAdapterCommon.id, + data: proposalData, + }, + } + + setDrafts([newDraft, ...drafts]) + setDraftIndex(0) + }, [ + draft, + drafts, + proposalData, + proposalModuleAdapterCommon.id, + setDrafts, + proposalName, + ]) + + // Debounce saving draft every 3 seconds. + const [draftSaving, setDraftSaving] = useState(false) + useEffect(() => { + if (draftIndex === undefined) { + return + } + + // Save after 3 seconds. + setDraftSaving(true) + const timeout = setTimeout(() => { + setDrafts((drafts) => + drafts.map((savedDraft, index) => + index === draftIndex + ? { + ...savedDraft, + name: proposalName, + lastUpdatedAt: Date.now(), + proposal: { + id: proposalModuleAdapterCommon.id, + // Deep clone to prevent values from becoming readOnly. + data: cloneDeep(proposalData), + }, + } + : savedDraft + ) + ) + setDraftSaving(false) + }, 3000) + // Debounce. + return () => clearTimeout(timeout) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + // Instance changes every time, so compare stringified verison. + // eslint-disable-next-line react-hooks/exhaustive-deps + JSON.stringify(proposalData), + draftIndex, + setDrafts, + proposalName, + proposalModuleAdapterCommon.id, + ]) + + const onCreateSuccess: BaseNewProposalProps['onCreateSuccess'] = useCallback( + (info) => { + // Show modal. + setProposalCreatedCardProps(info) + + // Delete draft. + if (draftIndex !== undefined) { + deleteDraft(draftIndex) + } + + // Refresh proposals state. + refreshProposals() + + // Close modal. + setMsgs(undefined) + }, + [ + deleteDraft, + draftIndex, + refreshProposals, + setMsgs, + setProposalCreatedCardProps, + ] + ) + + return ( + setMsgs(undefined)} + visible + > + + }> + + + + + ) } diff --git a/packages/stateful/components/wallet/WalletUi.tsx b/packages/stateful/components/wallet/WalletUi.tsx index e5e133e65..143c067bc 100644 --- a/packages/stateful/components/wallet/WalletUi.tsx +++ b/packages/stateful/components/wallet/WalletUi.tsx @@ -1,5 +1,6 @@ import { State, WalletModalProps } from '@cosmos-kit/core' import { useState } from 'react' +import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { Modal, WarningCard } from '@dao-dao/stateless' @@ -94,13 +95,17 @@ export const WalletUi = (props: WalletModalProps) => { ) : ( { + connect={async (wallet) => { // Reset QR State before next connection. setQRState(State.Init) setQRErrorMessage(undefined) // Connect to wallet. - walletRepo.connect(wallet.walletName) + try { + await walletRepo.connect(wallet.walletName) + } catch (err) { + toast.error(err instanceof Error ? err.message : `${err}`) + } }} walletRepo={walletRepo} /> diff --git a/packages/stateful/package.json b/packages/stateful/package.json index 0811c8e03..c92c3b8d4 100644 --- a/packages/stateful/package.json +++ b/packages/stateful/package.json @@ -14,27 +14,27 @@ "@cosmjs/encoding": "^0.31.0", "@cosmjs/proto-signing": "^0.31.0", "@cosmjs/stargate": "^0.31.0", - "@cosmos-kit/coin98": "^2.3.10", - "@cosmos-kit/compass": "^2.3.10", - "@cosmos-kit/core": "^2.6.6", - "@cosmos-kit/cosmos-extension-metamask": "^0.1.6", - "@cosmos-kit/cosmostation": "^2.3.11", - "@cosmos-kit/fin": "^2.3.12", - "@cosmos-kit/frontier": "^2.3.10", - "@cosmos-kit/keplr-extension": "^2.4.9", - "@cosmos-kit/keplr-mobile": "^2.3.10", - "@cosmos-kit/leap": "^2.3.10", - "@cosmos-kit/leap-metamask-cosmos-snap": "^0.2.4", - "@cosmos-kit/ledger": "^2.4.7", - "@cosmos-kit/okxwallet": "^2.2.11", - "@cosmos-kit/omni": "^2.3.10", - "@cosmos-kit/react-lite": "^2.4.12", - "@cosmos-kit/shell": "^2.3.10", - "@cosmos-kit/station": "^2.3.11", - "@cosmos-kit/trust": "^2.3.10", - "@cosmos-kit/vectis": "^2.3.10", - "@cosmos-kit/web3auth": "^2.3.10", - "@cosmos-kit/xdefi-extension": "^2.4.7", + "@cosmos-kit/coin98": "^2.3.12", + "@cosmos-kit/compass": "^2.3.12", + "@cosmos-kit/core": "^2.6.8", + "@cosmos-kit/cosmos-extension-metamask": "^0.1.9", + "@cosmos-kit/cosmostation": "^2.3.13", + "@cosmos-kit/fin": "^2.3.14", + "@cosmos-kit/frontier": "^2.3.12", + "@cosmos-kit/keplr-extension": "^2.4.11", + "@cosmos-kit/keplr-mobile": "^2.3.12", + "@cosmos-kit/leap": "^2.3.12", + "@cosmos-kit/leap-metamask-cosmos-snap": "^0.2.6", + "@cosmos-kit/ledger": "^2.4.9", + "@cosmos-kit/okxwallet": "^2.2.13", + "@cosmos-kit/omni": "^2.3.12", + "@cosmos-kit/react-lite": "^2.4.15", + "@cosmos-kit/shell": "^2.3.12", + "@cosmos-kit/station": "^2.3.13", + "@cosmos-kit/trust": "^2.3.12", + "@cosmos-kit/vectis": "^2.3.12", + "@cosmos-kit/web3auth": "^2.3.12", + "@cosmos-kit/xdefi-extension": "^2.4.9", "@dao-dao/i18n": "2.2.0", "@dao-dao/protobuf": "2.2.0", "@dao-dao/state": "2.2.0", diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx index f8534abee..620535aef 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx @@ -546,7 +546,7 @@ export const NewProposal = ({ ) : ( <> - {drafts.length > 0 && ( + {drafts.length > 0 && !!loadDraft && ( ) : ( <> - {drafts.length > 0 && ( + {drafts.length > 0 && !!loadDraft && ( {