diff --git a/packages/next-common/components/comment/editor.js b/packages/next-common/components/comment/editor.js index 8df663e9b8..d88883f714 100644 --- a/packages/next-common/components/comment/editor.js +++ b/packages/next-common/components/comment/editor.js @@ -14,10 +14,12 @@ import { usePost } from "next-common/context/post"; import { useCommentActions } from "next-common/sima/context/commentActions"; import { newErrorToast } from "next-common/store/reducers/toastSlice"; import { useDispatch } from "react-redux"; -import Tooltip from "../tooltip"; import { useDetailType } from "next-common/context/page"; import { detailPageCategory } from "next-common/utils/consts/business/category"; import { getRealField } from "next-common/sima/actions/common"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import { WATCH_ONLY_COMMENT_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; const Wrapper = styled.div` margin-top: 48px; @@ -91,6 +93,7 @@ function CommentEditor( const { createPostComment, createCommentReply } = useCommentActions(); const shouldUseSima = useShouldUseSima(replyToComment); + const isWatchOnly = useIsWatchOnly(); const createComment = async (realAddress) => { if (!isMounted()) { @@ -191,18 +194,21 @@ function CommentEditor( Cancel )} - + createComment()} onClickCommentAsProxy={(realAddress) => { createComment(realAddress); }} /> - + ); diff --git a/packages/next-common/components/common/tx/submitButton.jsx b/packages/next-common/components/common/tx/submitButton.jsx new file mode 100644 index 0000000000..abd92655bd --- /dev/null +++ b/packages/next-common/components/common/tx/submitButton.jsx @@ -0,0 +1,22 @@ +import PrimaryButton from "next-common/lib/button/primary"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; + +// Tx submit button. Watch-only accounts are handled here: the button is +// disabled and its tooltip becomes the watch-only hint. +export default function SubmitButton({ + tooltip, + disabled = false, + children, + ...props +}) { + const isWatchOnly = useIsWatchOnly(); + + return ( + + + {children} + + + ); +} diff --git a/packages/next-common/components/common/tx/submitRemoveButton.jsx b/packages/next-common/components/common/tx/submitRemoveButton.jsx new file mode 100644 index 0000000000..1d21ed13bb --- /dev/null +++ b/packages/next-common/components/common/tx/submitRemoveButton.jsx @@ -0,0 +1,29 @@ +import { SystemClose } from "@osn/icons/subsquare"; +import { noop } from "lodash-es"; +import { cn } from "next-common/utils"; +import SubmitButton from "next-common/components/common/tx/submitButton"; + +// ✕ variant of SubmitButton, for buttons that submit a tx. +export default function SubmitRemoveButton({ + tooltip, + disabled = false, + onClick = noop, + className, +}) { + return ( + + + + ); +} diff --git a/packages/next-common/components/common/tx/txSubmissionButton.js b/packages/next-common/components/common/tx/txSubmissionButton.js index c49e92fb1b..da71528b51 100644 --- a/packages/next-common/components/common/tx/txSubmissionButton.js +++ b/packages/next-common/components/common/tx/txSubmissionButton.js @@ -4,6 +4,8 @@ import PrimaryButton from "next-common/lib/button/primary"; import LoadingButton from "next-common/lib/button/loading"; import useTxSubmission from "./useTxSubmission"; import { usePopupOnClose } from "next-common/context/popup"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; export default function TxSubmissionButton({ api, @@ -21,6 +23,7 @@ export default function TxSubmissionButton({ autoClose = true, }) { const onClose = usePopupOnClose(); + const isWatchOnly = useIsWatchOnly(); const { isSubmitting, isWrapping, doSubmit } = useTxSubmission({ api, getTxFunc, @@ -44,13 +47,15 @@ export default function TxSubmissionButton({ {isLoading && loadingText ? ( {loadingText} ) : ( - - {title} - + + + {title} + + )} ); @@ -68,6 +73,7 @@ export function useTxSubmissionButton({ onTxError = noop, onTxStart = noop, }) { + const isWatchOnly = useIsWatchOnly(); const { isSubmitting, isWrapping, doSubmit } = useTxSubmission({ getTxFunc, onFinalized, @@ -86,13 +92,15 @@ export function useTxSubmissionButton({ {isSubmitting && loadingText ? ( {loadingText} ) : ( - - {title} - + + + {title} + + )} ), diff --git a/packages/next-common/components/common/tx/useTxSubmission.js b/packages/next-common/components/common/tx/useTxSubmission.js index c162198cd0..a38f8ed2cb 100644 --- a/packages/next-common/components/common/tx/useTxSubmission.js +++ b/packages/next-common/components/common/tx/useTxSubmission.js @@ -7,6 +7,8 @@ import { wrapTransaction } from "next-common/utils/sendTransaction"; import { useContextApi } from "next-common/context/api"; import { useSendTransaction } from "next-common/hooks/useSendTransaction"; import { useMaybeMultisigCallback } from "./useMaybeMultisigCallback"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import { WATCH_ONLY_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; export default function useTxSubmission({ api, @@ -22,6 +24,7 @@ export default function useTxSubmission({ const defaultApi = useContextApi(); const apiToUse = api || defaultApi; const signerAccount = useSignerAccount(); + const isWatchOnly = useIsWatchOnly(); const { sendTxFunc, isSubmitting } = useSendTransaction(); const { onInBlock: maybeMultisigOnInBlock, @@ -51,6 +54,11 @@ export default function useTxSubmission({ const doSubmit = useCallback( async (...args) => { + if (isWatchOnly) { + dispatch(newErrorToast(WATCH_ONLY_TOOLTIP_TEXT)); + return; + } + if (!apiToUse) { dispatch(newErrorToast("Chain RPC is not connected yet")); return; @@ -87,6 +95,7 @@ export default function useTxSubmission({ [ apiToUse, dispatch, + isWatchOnly, signerAccount, getTx, sendTxFunc, diff --git a/packages/next-common/components/fellowship/applications/create.js b/packages/next-common/components/fellowship/applications/create.js index 9011a745b0..ff9823ca17 100644 --- a/packages/next-common/components/fellowship/applications/create.js +++ b/packages/next-common/components/fellowship/applications/create.js @@ -11,6 +11,9 @@ import { useRouter } from "next/router"; import { useExtensionAccounts } from "next-common/components/popupWithSigner/context"; import AddressCombo from "next-common/components/addressCombo"; import SignerPopupWrapper from "next-common/components/popupWithSigner/signerPopupWrapper"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import { WATCH_ONLY_CREATE_APPLICATION_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; function PageTitle() { return ( @@ -109,6 +112,7 @@ function CreateFellowshipApplicationImpl() { const [contentType, setContentType] = useState("markdown"); const [loading, setLoading] = useState(false); const { ensureLogin } = useEnsureLogin(); + const isWatchOnly = useIsWatchOnly(); const createApplication = async () => { setLoading(true); @@ -154,9 +158,17 @@ function CreateFellowshipApplicationImpl() { setContentType={setContentType} />
- - Create - + + + Create + +
); diff --git a/packages/next-common/components/fellowship/applications/induct.js b/packages/next-common/components/fellowship/applications/induct.js index 27d92d1495..22261c6d92 100644 --- a/packages/next-common/components/fellowship/applications/induct.js +++ b/packages/next-common/components/fellowship/applications/induct.js @@ -1,5 +1,6 @@ import { useCallback } from "react"; import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import { useCoreFellowshipPallet } from "next-common/context/collectives/collectives"; import Tooltip from "next-common/components/tooltip"; import { useFellowshipCanInductMember } from "next-common/hooks/fellowship/useFellowshipCanInductMember"; @@ -48,13 +49,13 @@ function InductButton({ address }) { = 3"} > - Induct - + ); diff --git a/packages/next-common/components/header/connectedAccountDisplay.jsx b/packages/next-common/components/header/connectedAccountDisplay.jsx new file mode 100644 index 0000000000..0e12c3a0dc --- /dev/null +++ b/packages/next-common/components/header/connectedAccountDisplay.jsx @@ -0,0 +1,31 @@ +import { SystemEye } from "@osn/icons/subsquare"; +import { cn } from "next-common/utils"; +import { AddressUser } from "next-common/components/user"; +import Tooltip from "next-common/components/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; + +// Connected account shown in the header. A watch-only account gets an eye badge +// on its avatar: it is absolutely positioned, so the display size is unchanged. +export default function ConnectedAccountDisplay({ address, className }) { + const isWatchOnly = useIsWatchOnly(); + const account = ; + + if (!isWatchOnly) { + return account; + } + + return ( + +
+ {account} + +
+
+ ); +} diff --git a/packages/next-common/components/header/drawer.jsx b/packages/next-common/components/header/drawer.jsx index 7ca5ce5886..91cc068325 100644 --- a/packages/next-common/components/header/drawer.jsx +++ b/packages/next-common/components/header/drawer.jsx @@ -15,7 +15,8 @@ import { walletConnect } from "next-common/utils/consts/connect/index.js"; import { useAccountMenu } from "./useAccountMenu"; import Divider from "next-common/components/styled/layout/divider"; import SwitchAccount from "next-common/components/switchAccount"; -import AddressUser from "next-common/components/user/addressUser"; +import ConnectedAccountDisplay from "./connectedAccountDisplay.jsx"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; import SearchInputWithPopup from "./searchInputWithPopup"; import { useWalletConnect } from "next-common/context/walletconnect"; import Loading from "next-common/components/loading"; @@ -66,14 +67,18 @@ function ProfileMenuItem({ onClick }) { } function ConnectedAccount({ user }) { + const isWatchOnly = useIsWatchOnly(); + if (!user?.address) { return null; } return (
- - Connected + + + {isWatchOnly ? "Watch-only" : "Connected"} +
); } diff --git a/packages/next-common/components/header/headerAccount.js b/packages/next-common/components/header/headerAccount.js index 585d879600..971f6cb358 100644 --- a/packages/next-common/components/header/headerAccount.js +++ b/packages/next-common/components/header/headerAccount.js @@ -11,7 +11,8 @@ import { useLoginPopup } from "next-common/hooks/useLoginPopup.js"; import SecondaryButton from "next-common/lib/button/secondary"; import { SystemProfile } from "@osn/icons/subsquare"; import { useConnectedAccountContext } from "next-common/context/connectedAccount/index.js"; -import { AddressUser, SystemUser } from "../user"; +import { SystemUser } from "../user"; +import ConnectedAccountDisplay from "./connectedAccountDisplay.jsx"; import { useAccountMenu } from "./useAccountMenu.js"; import { walletConnect } from "next-common/utils/consts/connect/index.js"; import { useWalletConnect } from "next-common/context/walletconnect/index.jsx"; @@ -121,7 +122,7 @@ export default function HeaderAccount() { } else { connectBtn = ( setShow(!show)}> - + ); } diff --git a/packages/next-common/components/hydrationCrossChainPopup/index.js b/packages/next-common/components/hydrationCrossChainPopup/index.js index 2d03c02dd4..a5dad4d8ca 100644 --- a/packages/next-common/components/hydrationCrossChainPopup/index.js +++ b/packages/next-common/components/hydrationCrossChainPopup/index.js @@ -6,7 +6,7 @@ import useAddressComboField from "next-common/components/preImages/createPreimag import AdvanceSettings from "next-common/components/summary/newProposalQuickStart/common/advanceSettings"; import { useUser } from "next-common/context/user"; import { useSendTransaction } from "next-common/hooks/useSendTransaction"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import { newErrorToast, newSuccessToast, @@ -153,9 +153,9 @@ function PopupContent() { />
- + Submit - +
); diff --git a/packages/next-common/components/login/web3/index.jsx b/packages/next-common/components/login/web3/index.jsx index 14f07ad827..4f036e5694 100644 --- a/packages/next-common/components/login/web3/index.jsx +++ b/packages/next-common/components/login/web3/index.jsx @@ -5,6 +5,7 @@ import LoginWeb3Substrate from "./substrate"; import { useUnmount } from "react-use"; import LoginWeb3WalletConnect from "./walletconnect"; import LoginWeb3PolkadotVault from "./polkadotVault"; +import LoginWeb3WatchOnly from "./watchOnly"; import { useChainSettings } from "next-common/context/chain"; export default function LoginWeb3({ setIsWeb3 = noop }) { @@ -14,6 +15,7 @@ export default function LoginWeb3({ setIsWeb3 = noop }) { isEVMView, isWalletConnectView, isPolkadotVaultView, + isWatchOnlyView, resetView, } = useWeb3WalletView(); @@ -27,6 +29,7 @@ export default function LoginWeb3({ setIsWeb3 = noop }) { {isWalletConnectView && } {isPolkadotVaultView && } + {isWatchOnlyView && } {chainSettings?.allowWeb2Login && (
diff --git a/packages/next-common/components/login/web3/watchOnly.jsx b/packages/next-common/components/login/web3/watchOnly.jsx new file mode 100644 index 0000000000..87f497eddf --- /dev/null +++ b/packages/next-common/components/login/web3/watchOnly.jsx @@ -0,0 +1,31 @@ +import { ArrowCircleLeft } from "@osn/icons/subsquare"; +import { + WalletGroupTitle, + WalletOptionsWrapper, +} from "next-common/components/wallet/options/styled"; +import WalletOption from "next-common/components/wallet/walletOption"; +import { useWeb3WalletView } from "next-common/hooks/connect/useWeb3WalletView"; +import WatchOnlyAddressForm from "next-common/components/watchOnly/addressForm"; + +export default function LoginWeb3WatchOnly() { + const { setView } = useWeb3WalletView(); + + return ( +
+ + } + title="Back to Substrate" + onClick={() => { + setView("substrate"); + }} + /> + + + Watch-only Address + + +
+ ); +} diff --git a/packages/next-common/components/multisigs/actions/removePopup.jsx b/packages/next-common/components/multisigs/actions/removePopup.jsx index 654d2aca39..cb184d7c8f 100644 --- a/packages/next-common/components/multisigs/actions/removePopup.jsx +++ b/packages/next-common/components/multisigs/actions/removePopup.jsx @@ -6,7 +6,7 @@ import { } from "next-common/store/reducers/toastSlice"; import { useDispatch } from "react-redux"; import SecondaryButton from "next-common/lib/button/secondary"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import Popup from "next-common/components/popup/wrapper/Popup"; import { useMultisigAccounts } from "../context/multisigAccountsContext"; import { getRealField } from "next-common/sima/actions/common"; @@ -88,14 +88,14 @@ export default function RemovePopup({ onClose, multisigAddress }) {
Cancel - Confirm - +
); diff --git a/packages/next-common/components/multisigs/actions/renamePopup.jsx b/packages/next-common/components/multisigs/actions/renamePopup.jsx index f67240d610..b4a8058858 100644 --- a/packages/next-common/components/multisigs/actions/renamePopup.jsx +++ b/packages/next-common/components/multisigs/actions/renamePopup.jsx @@ -5,7 +5,7 @@ import { newSuccessToast, } from "next-common/store/reducers/toastSlice"; import { useDispatch } from "react-redux"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import Popup from "next-common/components/popup/wrapper/Popup"; import TextInputField from "next-common/components/popup/fields/textInputField"; import { useMultisigAccounts } from "../context/multisigAccountsContext"; @@ -106,13 +106,13 @@ export default function RemovePopup({ onClose, multisig }) { {ERROR_MESSAGE.NAME_EXIST} )}
- Save Changes - +
); diff --git a/packages/next-common/components/multisigs/signField/signApprovePopup/popup.jsx b/packages/next-common/components/multisigs/signField/signApprovePopup/popup.jsx index a3ef64fe5e..3fa4801e1e 100644 --- a/packages/next-common/components/multisigs/signField/signApprovePopup/popup.jsx +++ b/packages/next-common/components/multisigs/signField/signApprovePopup/popup.jsx @@ -2,7 +2,7 @@ import SignerWithBalance from "next-common/components/signerPopup/signerWithBala import ProposeTree from "next-common/components/multisigs/signField/signSubmitPopup/proposeTree"; import Popup from "next-common/components/popup/wrapper/Popup"; import { useSignApprove } from "../signApprove"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; function SignApproveInnerPopup({ onClose, multisig }) { const { doSubmit, isDisabled } = useSignApprove(multisig); @@ -12,9 +12,9 @@ function SignApproveInnerPopup({ onClose, multisig }) {
- + Submit - +
); diff --git a/packages/next-common/components/multisigs/signField/signCancelPopup/popup.jsx b/packages/next-common/components/multisigs/signField/signCancelPopup/popup.jsx index 7e0df5137b..afcbf7a0ae 100644 --- a/packages/next-common/components/multisigs/signField/signCancelPopup/popup.jsx +++ b/packages/next-common/components/multisigs/signField/signCancelPopup/popup.jsx @@ -2,7 +2,7 @@ import SignerWithBalance from "next-common/components/signerPopup/signerWithBala import ProposeTree from "next-common/components/multisigs/signField/signSubmitPopup/proposeTree"; import Popup from "next-common/components/popup/wrapper/Popup"; import { useSignCancel } from "../signCancel"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; function SignCancelInnerPopup({ onClose, multisig }) { const { doSubmit, isDisabled } = useSignCancel(multisig); @@ -12,9 +12,9 @@ function SignCancelInnerPopup({ onClose, multisig }) {
- + Cancel - +
); diff --git a/packages/next-common/components/myProxies/operations/removeProxy.jsx b/packages/next-common/components/myProxies/operations/removeProxy.jsx index 5bd04e2f8a..7b175fe366 100644 --- a/packages/next-common/components/myProxies/operations/removeProxy.jsx +++ b/packages/next-common/components/myProxies/operations/removeProxy.jsx @@ -2,8 +2,7 @@ import { useCallback, useState, useEffect } from "react"; import { useContextApi } from "next-common/context/api"; import useRealAddress from "next-common/utils/hooks/useRealAddress"; import useTxSubmission from "next-common/components/common/tx/useTxSubmission"; -import RemoveButton from "next-common/components/removeButton"; -import Tooltip from "next-common/components/tooltip"; +import SubmitRemoveButton from "next-common/components/common/tx/submitRemoveButton"; import { useDispatch } from "react-redux"; import { newSuccessToast } from "next-common/store/reducers/toastSlice"; import { noop } from "lodash-es"; @@ -42,8 +41,10 @@ export default function RemoveProxy({ data, onSubmitted = noop }) { }, [isSubmitting]); return ( - - - + ); } diff --git a/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/createPromotionReferendaAndVotePopup/index.js b/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/createPromotionReferendaAndVotePopup/index.js index ace0cc80d6..1f3ac75508 100644 --- a/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/createPromotionReferendaAndVotePopup/index.js +++ b/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/createPromotionReferendaAndVotePopup/index.js @@ -6,8 +6,7 @@ import useTxSubmission from "next-common/components/common/tx/useTxSubmission"; import { useDispatch } from "react-redux"; import { newSuccessToast } from "next-common/store/reducers/toastSlice"; import { useActiveReferendaContext } from "next-common/context/activeReferenda"; -import PrimaryButton from "next-common/lib/button/primary"; -import Tooltip from "next-common/components/tooltip"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import useMyRank from "../memberPromotionPopup/voteButtons/useMyRank"; import { useChain } from "next-common/context/chain"; import { getPromoteTrackNameFromRank } from "next-common/components/fellowship/core/members/actions/promote/popup"; @@ -85,15 +84,14 @@ export default function CreatePromotionReferendaAndVotePopup({ setSelectedRank={setToRank} />
- - - Create & Vote - - + + Create & Vote +
); diff --git a/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/createPromotionReferendumAndVoteButton.js b/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/createPromotionReferendumAndVoteButton.js index 56107569f9..bac1a1d88c 100644 --- a/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/createPromotionReferendumAndVoteButton.js +++ b/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/createPromotionReferendumAndVoteButton.js @@ -6,12 +6,13 @@ import { useFellowshipProposalSubmissionTxFunc } from "next-common/hooks/fellows import { newSuccessToast } from "next-common/store/reducers/toastSlice"; import { useActiveReferendaContext } from "next-common/context/activeReferenda"; import dynamicPopup from "next-common/lib/dynamic/popup"; -import Tooltip from "next-common/components/tooltip"; import { useChain } from "next-common/context/chain"; import { getPromoteTrackNameFromRank } from "next-common/components/fellowship/core/members/actions/promote/popup"; import useMemberRank from "./useMemberRank"; import { useMyVotesChangedContext } from "../../../context/myVotesChanged"; import SecondaryButton from "next-common/lib/button/secondary"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; const CreatePromotionReferendaAndVotePopup = dynamicPopup(() => import("../../createPromotionReferendaAndVotePopup"), @@ -29,6 +30,7 @@ function CreateReferendumAndVoteButtonImpl({ useState(false); const dispatch = useDispatch(); const { rank: evidenceOwnerRank } = useMemberRank(who); + const isWatchOnly = useIsWatchOnly(); const chain = useChain(); const trackName = getPromoteTrackNameFromRank(chain, evidenceOwnerRank + 1); @@ -67,11 +69,14 @@ function CreateReferendumAndVoteButtonImpl({ return ( <> - - + + {children} - + {showMaybeFastPromotePopup && ( - + + {children} - + ); } diff --git a/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/voteButton.js b/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/voteButton.js index 3292be1ccf..78be405d94 100644 --- a/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/voteButton.js +++ b/packages/next-common/components/overview/accountInfo/components/fellowshipTodoList/todoList/memberPromotionPopup/voteButtons/voteButton.js @@ -7,9 +7,10 @@ import { useRankedCollectivePallet } from "next-common/context/collectives/colle import { useFellowshipMemberRank } from "next-common/hooks/fellowship/useFellowshipMemberRank"; import useRealAddress from "next-common/utils/hooks/useRealAddress"; import useSubFellowshipReferendum from "next-common/hooks/collectives/useSubFellowshipReferendum"; -import Tooltip from "next-common/components/tooltip"; import { getMinRankOfClass } from "next-common/context/post/fellowship/useMaxVoters"; import { isNil, noop } from "lodash-es"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; function VoteButtonImpl({ referendumIndex, @@ -21,6 +22,7 @@ function VoteButtonImpl({ const api = useContextApi(); const collectivePallet = useRankedCollectivePallet(); const realAddress = useRealAddress(); + const isWatchOnly = useIsWatchOnly(); const { rank: myRank, isLoading: isMyRankLoading } = useFellowshipMemberRank( realAddress, collectivePallet, @@ -62,11 +64,14 @@ function VoteButtonImpl({ } return ( - - + + {children} - + ); } diff --git a/packages/next-common/components/pages/components/gov2/votePopup/popupContent.js b/packages/next-common/components/pages/components/gov2/votePopup/popupContent.js index 189fedba66..ad1dfe9d3b 100644 --- a/packages/next-common/components/pages/components/gov2/votePopup/popupContent.js +++ b/packages/next-common/components/pages/components/gov2/votePopup/popupContent.js @@ -10,7 +10,7 @@ import { WarningMessage } from "next-common/components/popup/styled"; import SplitAbstainVoteStatus from "./splitAbstainVoteStatus"; import VStack from "next-common/components/styled/vStack"; import VoteTypeTab, { Aye, Nay, Split, SplitAbstain } from "./tab"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import useSubAddressReferendaVote, { getReferendaDirectVote, } from "next-common/hooks/referenda/useSubMyReferendaVote"; @@ -139,9 +139,9 @@ function VotePanel({ {!isDelegated && ( // Address is not allow to vote directly when it is in delegate mode
- + Submit - +
)} diff --git a/packages/next-common/components/pages/components/referenda/popup/popupContent.js b/packages/next-common/components/pages/components/referenda/popup/popupContent.js index 1d2c26761a..1af569b6f8 100644 --- a/packages/next-common/components/pages/components/referenda/popup/popupContent.js +++ b/packages/next-common/components/pages/components/referenda/popup/popupContent.js @@ -23,6 +23,7 @@ import AyeNaySplitTab from "./ayeNaySplitTab"; import useStandardVote from "./voteHooks/useStandardVote"; import useSplitVote from "./voteHooks/useSplitVote"; import useTxSubmission from "next-common/components/common/tx/useTxSubmission"; +import SubmitButton from "next-common/components/common/tx/submitButton"; export function LoadingPanel() { return ( @@ -118,9 +119,9 @@ function VotePanel({ {!isDelegated && ( // Address is not allow to vote directly when it is in delegate mode
- + Confirm - +
)} diff --git a/packages/next-common/components/paraChainTeleportPopup/index.js b/packages/next-common/components/paraChainTeleportPopup/index.js index f4ab04ffb1..6e246104ef 100644 --- a/packages/next-common/components/paraChainTeleportPopup/index.js +++ b/packages/next-common/components/paraChainTeleportPopup/index.js @@ -6,7 +6,7 @@ import useAddressComboField from "next-common/components/preImages/createPreimag import AdvanceSettings from "next-common/components/summary/newProposalQuickStart/common/advanceSettings"; import { useUser } from "next-common/context/user"; import { useSendTransaction } from "next-common/hooks/useSendTransaction"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import { newErrorToast, newSuccessToast, @@ -93,9 +93,9 @@ function PopupContent() {
- + Submit - +
); diff --git a/packages/next-common/components/paraChainTeleportPopup/teleport.jsx b/packages/next-common/components/paraChainTeleportPopup/teleport.jsx index 0a1ba4f8f1..8193147f14 100644 --- a/packages/next-common/components/paraChainTeleportPopup/teleport.jsx +++ b/packages/next-common/components/paraChainTeleportPopup/teleport.jsx @@ -6,7 +6,7 @@ import useAddressComboField from "next-common/components/preImages/createPreimag import AdvanceSettings from "next-common/components/summary/newProposalQuickStart/common/advanceSettings"; import { useUser } from "next-common/context/user"; import { useSendTransaction } from "next-common/hooks/useSendTransaction"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; import { newErrorToast, newSuccessToast, @@ -19,17 +19,6 @@ import useNativeTransferAmount from "./useNativeTransferAmount"; import PeopleApiProvider from "next-common/context/people/api"; import CoretimeApiProvider from "next-common/context/coretime/api"; import { CollectivesApiProvider } from "next-common/context/collectives/api"; -import Tooltip from "next-common/components/tooltip"; - -function TooltipDisabledGuard({ disabled, children }) { - return disabled ? ( - - {children} - - ) : ( - children - ); -} function PopupContent() { const { onClose } = usePopupParams(); @@ -109,15 +98,18 @@ function PopupContent() {
- - - Submit - - + + Submit +
); diff --git a/packages/next-common/components/people/overview/identity/directIdentityActions.jsx b/packages/next-common/components/people/overview/identity/directIdentityActions.jsx index 1e16d0019f..583feb34b0 100644 --- a/packages/next-common/components/people/overview/identity/directIdentityActions.jsx +++ b/packages/next-common/components/people/overview/identity/directIdentityActions.jsx @@ -5,7 +5,7 @@ import { useDispatch } from "react-redux"; import { newSuccessToast } from "next-common/store/reducers/toastSlice"; import useRealAddress from "next-common/utils/hooks/useRealAddress"; import useTxSubmission from "next-common/components/common/tx/useTxSubmission"; -import RemoveButton from "next-common/components/removeButton"; +import SubmitRemoveButton from "next-common/components/common/tx/submitRemoveButton"; import Tooltip from "next-common/components/tooltip"; import { clearCachedIdentitys } from "next-common/services/identity"; import { useChain } from "next-common/context/chain"; @@ -79,9 +79,11 @@ export function DirectIdentityActions() { - - - + {showSetIdentityPopup && ( setShowSetIdentityPopup(false)} /> diff --git a/packages/next-common/components/people/overview/judgements/cancelRequestJudgement.jsx b/packages/next-common/components/people/overview/judgements/cancelRequestJudgement.jsx index 9277423f76..72bc6321e8 100644 --- a/packages/next-common/components/people/overview/judgements/cancelRequestJudgement.jsx +++ b/packages/next-common/components/people/overview/judgements/cancelRequestJudgement.jsx @@ -1,5 +1,4 @@ -import RemoveButton from "next-common/components/removeButton"; -import Tooltip from "next-common/components/tooltip"; +import SubmitRemoveButton from "next-common/components/common/tx/submitRemoveButton"; import { useDispatch } from "react-redux"; import { newSuccessToast } from "next-common/store/reducers/toastSlice"; import { useContextApi } from "next-common/context/api"; @@ -30,8 +29,10 @@ export default function CancelRequestJudgement({ registrarIndex }) { }); return ( - - - + ); } diff --git a/packages/next-common/components/popup/voteButton.js b/packages/next-common/components/popup/voteButton.js index 407f2c8d8b..5c8c3c8b5a 100644 --- a/packages/next-common/components/popup/voteButton.js +++ b/packages/next-common/components/popup/voteButton.js @@ -3,6 +3,8 @@ import { VoteEnum } from "../../utils/voteEnum"; import { ButtonWrapper } from "./styled"; import SuccessButton from "next-common/lib/button/success"; import DangerButton from "next-common/lib/button/danger"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; export default function VoteButton({ disabled, @@ -10,22 +12,29 @@ export default function VoteButton({ isLoading, doVote, }) { + const isWatchOnly = useIsWatchOnly(); + const isDisabled = disabled || isWatchOnly; + return ( - doVote(false)} - loading={isLoading && loadingState === VoteEnum.Nay} - disabled={disabled || (isLoading && loadingState === VoteEnum.Aye)} - > - Nay - - doVote(true)} - loading={isLoading && loadingState === VoteEnum.Aye} - disabled={disabled || (isLoading && loadingState === VoteEnum.Nay)} - > - Aye - + + doVote(false)} + loading={isLoading && loadingState === VoteEnum.Nay} + disabled={isDisabled || (isLoading && loadingState === VoteEnum.Aye)} + > + Nay + + + + doVote(true)} + loading={isLoading && loadingState === VoteEnum.Aye} + disabled={isDisabled || (isLoading && loadingState === VoteEnum.Nay)} + > + Aye + + ); } diff --git a/packages/next-common/components/popupWithSigner/context/signer.js b/packages/next-common/components/popupWithSigner/context/signer.js index 18e70ac498..bdc7cd7108 100644 --- a/packages/next-common/components/popupWithSigner/context/signer.js +++ b/packages/next-common/components/popupWithSigner/context/signer.js @@ -10,6 +10,8 @@ import useInjectedWeb3 from "next-common/hooks/connect/useInjectedWeb3"; import { useUser } from "next-common/context/user"; import { isSameAddress } from "next-common/utils"; import { findInjectedExtension } from "next-common/hooks/connect/useInjectedWeb3Extension"; +import { useConnectedAccount } from "next-common/context/connectedAccount"; +import { isWatchOnlyAccount } from "next-common/utils/watchOnly"; export const SignerContext = createContext(); @@ -51,6 +53,7 @@ export function useSetSigner() { export function SignerContextProvider({ children, extensionAccounts }) { const user = useUser(); + const connectedAccount = useConnectedAccount(); const userAddress = user?.address; const proxyAddress = user?.proxyAddress; const [selectedProxyAddress, setSelectedProxyAddress] = useState(); @@ -73,23 +76,34 @@ export function SignerContextProvider({ children, extensionAccounts }) { if (!userAddress) { return; } + + const isWatchOnly = isWatchOnlyAccount(connectedAccount); const account = extensionAccounts?.find((item) => isSameAddress(item.address, userAddress), ); - if (!account) { - return; - } + // The connected address may not be owned by any loaded wallet, e.g. a + // watch-only address, a mock account, or an account of a wallet that is no + // longer available. It is still the origin of the transaction. + const connectedAccountInfo = account + ? account + : { + address: userAddress, + meta: { source: connectedAccount?.wallet }, + }; + return { - ...account, - name: account.meta?.name, + ...connectedAccountInfo, + name: connectedAccountInfo.meta?.name, proxyAddress, selectedProxyAddress, multisig, realAddress, + isWatchOnly, }; }, [ userAddress, extensionAccounts, + connectedAccount, proxyAddress, selectedProxyAddress, multisig, diff --git a/packages/next-common/components/popupWithSigner/maybeSignerConnected.js b/packages/next-common/components/popupWithSigner/maybeSignerConnected.js index 4c2682749b..637b496ee5 100644 --- a/packages/next-common/components/popupWithSigner/maybeSignerConnected.js +++ b/packages/next-common/components/popupWithSigner/maybeSignerConnected.js @@ -4,14 +4,18 @@ import { isSameAddress } from "../../utils"; import { SignerContextProvider, usePopupParams } from "./context"; import LoginPopup from "next-common/components/login/popup"; import { isMockAccountAddress } from "next-common/utils/mockAccount"; +import { useConnectedAccount } from "next-common/context/connectedAccount"; +import { isWatchOnlyAccount } from "next-common/utils/watchOnly"; export default function MaybeSignerConnected({ children, extensionAccounts }) { const user = useUser(); + const connectedAccount = useConnectedAccount(); const { onClose } = usePopupParams(); if ( !user?.address || (!isMockAccountAddress(user?.address) && + !isWatchOnlyAccount(connectedAccount) && !extensionAccounts?.find((acc) => isSameAddress(acc.address, user?.address), )) diff --git a/packages/next-common/components/post/postCreate.js b/packages/next-common/components/post/postCreate.js index 2ce3ec9302..dc024d1678 100644 --- a/packages/next-common/components/post/postCreate.js +++ b/packages/next-common/components/post/postCreate.js @@ -18,6 +18,9 @@ import { NeutralPanel } from "../styled/containers/neutralPanel"; import PostLabel from "./postLabel"; import Editor, { useEditorUploading } from "../editor"; import { useEnsureLogin } from "next-common/hooks/useEnsureLogin"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import { WATCH_ONLY_CREATE_POST_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; const Wrapper = styled(NeutralPanel)` color: var(--textPrimary); @@ -77,6 +80,7 @@ export default function PostCreate() { const [selectedLabels, setSelectedLabels] = useState([]); const { ensureLogin } = useEnsureLogin(); const [editorUploading] = useEditorUploading(); + const isWatchOnly = useIsWatchOnly(); const createPost = async () => { setCreating(true); @@ -193,13 +197,17 @@ export default function PostCreate() { /> - - Create - + + Create + + ); diff --git a/packages/next-common/components/removeButton.js b/packages/next-common/components/removeButton.js index 6e9b422e3d..ed334fde7d 100644 --- a/packages/next-common/components/removeButton.js +++ b/packages/next-common/components/removeButton.js @@ -25,6 +25,8 @@ const Wrapper = styled.div` } `; +// Plain ✕ button, for buttons that only trigger a dialog. Use SubmitRemoveButton +// instead when it submits a tx, so watch-only accounts are handled for you. export default function RemoveButton({ disabled, onClick = noop }) { return ( diff --git a/packages/next-common/components/signer.js b/packages/next-common/components/signer.js index 1e2356454d..0556d0c0b8 100644 --- a/packages/next-common/components/signer.js +++ b/packages/next-common/components/signer.js @@ -14,6 +14,8 @@ import useOnChainProxyInfo from "next-common/hooks/useOnChainProxy"; import Tooltip from "./tooltip"; import { MultisigAccount } from "./multisigs/styled"; import SwitchButtonWrapper from "./switchAccountButton"; +import WatchOnlyHint from "next-common/components/watchOnly/hint"; +import useAccountByAddress from "next-common/hooks/useAccountByAddress"; const Wrapper = styled(GreyPanel)` padding: 12px 16px; @@ -106,6 +108,14 @@ function ProxyHintForAddress({ address }) { return ; } +function WatchOnlyHintRow() { + return ( +
+ +
+ ); +} + function MaybeMultisigAccount({ signerAccount }) { const originAccount = useOriginAccount(); @@ -145,7 +155,9 @@ export default function MaybeProxySigner({ )} - {signerAccount?.multisig && supportedMultisig ? ( + {signerAccount?.isWatchOnly ? ( + + ) : signerAccount?.multisig && supportedMultisig ? ( ) : signerAccount?.selectedProxyAddress ? ( @@ -159,10 +171,9 @@ export default function MaybeProxySigner({ export function ConnectedAccountSigner({ extra = null }) { const user = useUser(); - const extensionAccounts = useExtensionAccounts(); - const originAccount = extensionAccounts.find((item) => - isSameAddress(item.address, user?.address), - ); + const signerAccount = useSignerAccount(); + const address = signerAccount?.address || user?.address; + const originAccount = useAccountByAddress(address); return ( diff --git a/packages/next-common/components/signerPopup/index.js b/packages/next-common/components/signerPopup/index.js index 81c913673f..f8eae853a4 100644 --- a/packages/next-common/components/signerPopup/index.js +++ b/packages/next-common/components/signerPopup/index.js @@ -5,6 +5,8 @@ import { PopupButtonWrapper } from "../popup/wrapper"; import { useSignerAccount } from "../popupWithSigner/context"; import SignerWithBalance from "./signerWithBalance"; import { noop } from "lodash-es"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; function PopupContent({ children, @@ -14,6 +16,7 @@ function PopupContent({ }) { const [isLoading, setIsLoading] = useState(false); const signerAccount = useSignerAccount(); + const isWatchOnly = useIsWatchOnly(); const onConfirm = useCallback(async () => { setIsLoading(true); @@ -29,9 +32,15 @@ function PopupContent({ {children} - - {confirmText} - + + + {confirmText} + + ); diff --git a/packages/next-common/components/staking/pools/actions/unbondingPopup.jsx b/packages/next-common/components/staking/pools/actions/unbondingPopup.jsx index 9d654337a6..148a96ce41 100644 --- a/packages/next-common/components/staking/pools/actions/unbondingPopup.jsx +++ b/packages/next-common/components/staking/pools/actions/unbondingPopup.jsx @@ -19,7 +19,7 @@ import { SystemTransfer } from "@osn/icons/subsquare"; import { TitleContainer } from "next-common/components/styled/containers/titleContainer"; import { isNumber } from "lodash-es"; import Signer from "next-common/components/popup/fields/signerField"; -import PrimaryButton from "next-common/lib/button/primary"; +import SubmitButton from "next-common/components/common/tx/submitButton"; function UnbondingPopupContent() { const { unbondingEras = {}, activeEra } = usePopupParams(); @@ -118,7 +118,7 @@ function WithdrawButtonImpl({ era }) { }); return (
- - +
); } diff --git a/packages/next-common/components/switchAccount/index.jsx b/packages/next-common/components/switchAccount/index.jsx index 66c291599e..6327e477c7 100644 --- a/packages/next-common/components/switchAccount/index.jsx +++ b/packages/next-common/components/switchAccount/index.jsx @@ -13,6 +13,8 @@ import { useConnectedAccountContext } from "next-common/context/connectedAccount import { useWeb3Login } from "next-common/hooks/connect/useWeb3Login"; import NoData from "next-common/components/noData"; import { useIsMobile } from "next-common/components/overview/accountInfo/components/accountBalances"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import WatchOnlyAddressForm from "next-common/components/watchOnly/addressForm"; function PopupTitle() { return ( @@ -23,11 +25,9 @@ function PopupTitle() { ); } -function SubTitle() { +function SubTitle({ text = "Select Account" }) { return ( - - Select Account - + {text} ); } @@ -139,15 +139,34 @@ function PopupContent({ onClose }) { } export default function SwitchAccount({ onClose, onOpenLogin }) { + const isWatchOnly = useIsWatchOnly(); + + const popupProps = { + title: , + showCloseIcon: false, + onClose, + className: "flex flex-col space-y-6 p-12 !mb-0 max-sm:!p-6 max-h-[76vh]", + style: { maxWidth: "76vh" }, + }; + + // A watch-only account is not owned by any wallet, so there is no account + // list to pick from. Ask for the address to watch instead. + if (isWatchOnly) { + return ( + +
+ + +
+ + +
+ ); + } + return ( - } - showCloseIcon={false} - onClose={onClose} - className="flex flex-col space-y-6 p-12 !mb-0 max-sm:!p-6 max-h-[76vh]" - style={{ maxWidth: "76vh" }} - > + diff --git a/packages/next-common/components/switchAccountButton.js b/packages/next-common/components/switchAccountButton.js index 0eb7991ca1..215cdc3443 100644 --- a/packages/next-common/components/switchAccountButton.js +++ b/packages/next-common/components/switchAccountButton.js @@ -5,6 +5,7 @@ import { MultisigAccountsProvider } from "./multisigs/context/multisigAccountsCo import SwitchSignerPopup from "./switchSignerPopup"; import useRealAddress from "next-common/utils/hooks/useRealAddress"; import { useContextApi } from "next-common/context/api"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; function SwitchButton() { const user = useUser(); @@ -40,8 +41,10 @@ function SwitchButtonContent({ supportedMultisig }) { export default function SwitchButtonWrapper({ supportedMultisig = true }) { const realAddress = useRealAddress(); const api = useContextApi(); + const isWatchOnly = useIsWatchOnly(); - if (!api) { + // A watch-only account has no signer to switch between. + if (isWatchOnly || !api) { return null; } diff --git a/packages/next-common/components/switchSignerPopup/index.js b/packages/next-common/components/switchSignerPopup/index.js index 7ff66afdd7..ce2dc3d569 100644 --- a/packages/next-common/components/switchSignerPopup/index.js +++ b/packages/next-common/components/switchSignerPopup/index.js @@ -1,9 +1,5 @@ import Popup from "../popup/wrapper/Popup"; -import { - useCallerAddress, - useExtensionAccounts, - useSignerContext, -} from "../popupWithSigner/context"; +import { useCallerAddress, useSignerContext } from "../popupWithSigner/context"; import Account from "../account"; import { ArrowRight } from "@osn/icons/subsquare"; import { usePopupOnClose } from "next-common/context/popup"; @@ -11,11 +7,13 @@ import { useMyProxied } from "next-common/context/proxy"; import Tooltip from "../tooltip"; import tw from "tailwind-styled-components"; import Loading from "../loading"; -import { addressEllipsis, cn, isSameAddress } from "next-common/utils"; -import { useMemo } from "react"; +import { cn, isSameAddress } from "next-common/utils"; import { noop } from "lodash-es"; import MultiSignerAccounts from "./multiSignerAccounts"; import useRealAddress from "next-common/utils/hooks/useRealAddress"; +import useAccountByAddress from "next-common/hooks/useAccountByAddress"; +import WatchOnlyHint from "next-common/components/watchOnly/hint"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; const DisabledAccountItemWrapper = tw.div` flex flex-col gap-[12px] p-[12px] pr-[16px] @@ -101,14 +99,8 @@ function AccountItem({ disabled, account, onClick }) { export function OriginAddress({ selected, onSelect = noop }) { const onClose = usePopupOnClose(); const realAddress = useRealAddress(); - const extensionAccounts = useExtensionAccounts(); - const account = useMemo( - () => - extensionAccounts.find((item) => - isSameAddress(item.address, realAddress), - ), - [extensionAccounts, realAddress], - ); + const account = useAccountByAddress(realAddress); + const isWatchOnly = useIsWatchOnly(); const disabled = isSameAddress(selected, realAddress); @@ -123,27 +115,13 @@ export function OriginAddress({ selected, onSelect = noop }) { onClose(); }} /> + {isWatchOnly && } ); } function ProxyAddress({ disabled, proxyInfo, onClick = noop }) { - const { signerAccount } = useSignerContext(); - const extensionAccounts = useExtensionAccounts(); - - const account = useMemo(() => { - const extensionAccount = extensionAccounts.find((item) => - isSameAddress(item.address, proxyInfo.delegator), - ); - if (extensionAccount) { - return extensionAccount; - } - return { - address: proxyInfo.delegator, - name: addressEllipsis(proxyInfo.delegator), - meta: signerAccount.meta, - }; - }, [proxyInfo, extensionAccounts, signerAccount]); + const account = useAccountByAddress(proxyInfo.delegator); return (
Cancel - Confirm - +
); diff --git a/packages/next-common/components/wallet/options/substrate/singleSig.jsx b/packages/next-common/components/wallet/options/substrate/singleSig.jsx index f6c9bba033..ac5abfad5a 100644 --- a/packages/next-common/components/wallet/options/substrate/singleSig.jsx +++ b/packages/next-common/components/wallet/options/substrate/singleSig.jsx @@ -11,6 +11,7 @@ import { WalletOptionsWrapper } from "../styled"; import shouldEnableEvmWallets from "next-common/utils/shouldEnableEvmWallets"; import WalletConnectWallet from "../../walletConnectWallet"; import PolkadotVault from "../../polkadotVault"; +import WatchOnly from "../../watchOnly"; export default function WalletSubstrateSingleSigOptions({ selectedWallet, @@ -81,6 +82,19 @@ export default function WalletSubstrateSingleSigOptions({ ); } + if (wallet.extensionName === WalletTypes.WATCH_ONLY) { + return ( + { + onSelect(wallet); + }} + selected={selected} + /> + ); + } + return ( } + title={wallet.title} + onClick={() => { + setView("watch-only"); + }} + /> + ); +} diff --git a/packages/next-common/components/watchOnly/addressForm.jsx b/packages/next-common/components/watchOnly/addressForm.jsx new file mode 100644 index 0000000000..bd53c09d56 --- /dev/null +++ b/packages/next-common/components/watchOnly/addressForm.jsx @@ -0,0 +1,69 @@ +import { isAddress } from "@polkadot/util-crypto"; +import { useState } from "react"; +import Input from "next-common/lib/input"; +import PrimaryButton from "next-common/lib/button/primary"; +import { Label } from "next-common/components/login/styled"; +import { useChain } from "next-common/context/chain"; +import { encodeAddressToChain } from "next-common/services/address"; +import { useWeb3Login } from "next-common/hooks/connect/useWeb3Login"; +import WalletTypes from "next-common/utils/consts/walletTypes"; + +export default function WatchOnlyAddressForm({ + buttonText = "Next", + onConnected, +}) { + const chain = useChain(); + const [web3Login, web3Loading] = useWeb3Login(); + const [address, setAddress] = useState(""); + const [error, setError] = useState(""); + + const onConfirm = async () => { + const input = address.trim(); + + if (!input) { + setError("Please fill an address"); + return; + } + + if (!isAddress(input)) { + setError("Invalid address"); + return; + } + + setError(""); + await web3Login({ + account: { address: encodeAddressToChain(input, chain) }, + wallet: WalletTypes.WATCH_ONLY, + }); + onConnected?.(); + }; + + return ( +
+
+ + { + setAddress(e.target.value); + setError(""); + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + onConfirm(); + } + }} + /> +
+ + {buttonText} + +
+ ); +} diff --git a/packages/next-common/components/watchOnly/hint.jsx b/packages/next-common/components/watchOnly/hint.jsx new file mode 100644 index 0000000000..7c2308adbf --- /dev/null +++ b/packages/next-common/components/watchOnly/hint.jsx @@ -0,0 +1,18 @@ +import { SystemEye } from "@osn/icons/subsquare"; +import { cn } from "next-common/utils"; +import { WATCH_ONLY_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; + +export default function WatchOnlyHint({ className }) { + return ( +
+ + {WATCH_ONLY_TOOLTIP_TEXT} +
+ ); +} diff --git a/packages/next-common/components/watchOnly/tooltip.jsx b/packages/next-common/components/watchOnly/tooltip.jsx new file mode 100644 index 0000000000..65f465af32 --- /dev/null +++ b/packages/next-common/components/watchOnly/tooltip.jsx @@ -0,0 +1,48 @@ +import styled, { css } from "styled-components"; +import Tooltip from "next-common/components/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import { WATCH_ONLY_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; + +// Disabled buttons do not emit pointer events, so the wrapper is the hover +// target of the tooltip. `display: grid` keeps the wrapper block-wide with its +// child stretched, so it does not shrink buttons inside flex layouts. +const TriggerWrapper = styled.div` + display: grid; + + button:disabled, + [disabled] { + pointer-events: none; + } + + ${(p) => + p.watchOnly && + css` + cursor: not-allowed; + `} +`; + +// Shows `content`, or the watch-only hint when the connected account is +// watch-only; `watchOnlyContent` replaces the hint for non-tx buttons. +export default function WatchOnlyTooltip({ + children, + content, + watchOnlyContent = WATCH_ONLY_TOOLTIP_TEXT, +}) { + const isWatchOnly = useIsWatchOnly(); + + if (isWatchOnly) { + return ( + + {children} + + ); + } + + return content ? ( + + {children} + + ) : ( + children + ); +} diff --git a/packages/next-common/context/connectedAccount/index.js b/packages/next-common/context/connectedAccount/index.js index c324be7869..35a92bceec 100644 --- a/packages/next-common/context/connectedAccount/index.js +++ b/packages/next-common/context/connectedAccount/index.js @@ -15,6 +15,7 @@ import { clearMyMultisigsData } from "next-common/store/reducers/multisigSlice"; import { useDispatch } from "react-redux"; import { usePageLoading } from "next-common/context/pageLoading"; import { getMockAccountAddress } from "next-common/utils/mockAccount"; +import { isWatchOnlyAccount } from "next-common/utils/watchOnly"; const ConnectedAccountContext = createContext(null); @@ -171,6 +172,11 @@ export function useConnectedAccount() { return connectedAccount; } +export function useIsWatchOnly() { + const connectedAccount = useConnectedAccount(); + return isWatchOnlyAccount(connectedAccount); +} + export function getContextConnectedAccount() { return savedConnectedAccount || ssrConnectedAccount; } diff --git a/packages/next-common/hooks/connect/useSubstrateWallets.js b/packages/next-common/hooks/connect/useSubstrateWallets.js index e5e777ad57..314f5af57d 100644 --- a/packages/next-common/hooks/connect/useSubstrateWallets.js +++ b/packages/next-common/hooks/connect/useSubstrateWallets.js @@ -10,6 +10,7 @@ import { signet, walletConnect, polkadotVaultWallet, + watchOnlyWallet, } from "next-common/utils/consts/connect"; import isEvmChain, { isSupportSubstrateThroughEthereumAddress, @@ -29,6 +30,7 @@ export function useSubstrateWallets() { !isSubstrateThroughEvm && nova, chainSettings?.supportWalletconnect && walletConnect, chainSettings?.supportPolkadotVault && polkadotVaultWallet, + watchOnlyWallet, ].filter(Boolean); let multiSigWallets = []; diff --git a/packages/next-common/hooks/connect/useWeb3WalletView.js b/packages/next-common/hooks/connect/useWeb3WalletView.js index 75d52043b0..6ea2fae879 100644 --- a/packages/next-common/hooks/connect/useWeb3WalletView.js +++ b/packages/next-common/hooks/connect/useWeb3WalletView.js @@ -6,6 +6,7 @@ const VIEWS = { EVM: "evm", WALLETCONNECT: "walletconnect", POLKADOT_VAULT: "polkadot-vault", + WATCH_ONLY: "watch-only", }; const DEFAULT_VIEW = shouldEnableSubstrateWallets() ? VIEWS.SUBSTRATE @@ -20,6 +21,7 @@ export function useWeb3WalletView() { const isEVMView = view === VIEWS.EVM; const isWalletConnectView = view === VIEWS.WALLETCONNECT; const isPolkadotVaultView = view === VIEWS.POLKADOT_VAULT; + const isWatchOnlyView = view === VIEWS.WATCH_ONLY; function resetView() { setView(DEFAULT_VIEW); @@ -30,6 +32,7 @@ export function useWeb3WalletView() { isEVMView, isWalletConnectView, isPolkadotVaultView, + isWatchOnlyView, setView, resetView, }; diff --git a/packages/next-common/hooks/useAccountByAddress.js b/packages/next-common/hooks/useAccountByAddress.js new file mode 100644 index 0000000000..a85ab632c5 --- /dev/null +++ b/packages/next-common/hooks/useAccountByAddress.js @@ -0,0 +1,33 @@ +import { useMemo } from "react"; +import { + useExtensionAccounts, + useSignerAccount, +} from "next-common/components/popupWithSigner/context"; +import { addressEllipsis, isSameAddress } from "next-common/utils"; + +// Resolve an address to the wallet account that owns it. Addresses owned by no +// loaded wallet, e.g. a watch-only or a mock account, fall back to a plain +// account so they can still be displayed and used as an origin. +export default function useAccountByAddress(address) { + const extensionAccounts = useExtensionAccounts(); + const signerAccount = useSignerAccount(); + const meta = signerAccount?.meta; + + return useMemo(() => { + if (!address) { + return null; + } + + const account = extensionAccounts?.find((item) => + isSameAddress(item.address, address), + ); + + return ( + account || { + address, + name: addressEllipsis(address), + meta, + } + ); + }, [address, extensionAccounts, meta]); +} diff --git a/packages/next-common/hooks/useEnsureLogin.js b/packages/next-common/hooks/useEnsureLogin.js index f92986864e..c98537da1a 100644 --- a/packages/next-common/hooks/useEnsureLogin.js +++ b/packages/next-common/hooks/useEnsureLogin.js @@ -14,6 +14,10 @@ import { import { useChain } from "next-common/context/chain"; import { useLoginPopup } from "./useLoginPopup"; import { getCookieConnectedAccount } from "next-common/utils/getCookieConnectedAccount"; +import { + isWatchOnlyAccount, + WATCH_ONLY_LOGIN_REJECTED_TEXT, +} from "next-common/utils/watchOnly"; import { useSignMessage } from "./useSignMessage"; import isShibuya from "next-common/utils/isShibuya"; @@ -31,6 +35,11 @@ export function useEnsureLogin() { const login = useCallback(async () => { const connectedAccount = getCookieConnectedAccount(); + if (isWatchOnlyAccount(connectedAccount)) { + dispatch(newErrorToast(WATCH_ONLY_LOGIN_REJECTED_TEXT)); + return false; + } + setLoading(true); try { let address; diff --git a/packages/next-common/hooks/useSendTransaction.js b/packages/next-common/hooks/useSendTransaction.js index 4424b40121..ff010264d8 100644 --- a/packages/next-common/hooks/useSendTransaction.js +++ b/packages/next-common/hooks/useSendTransaction.js @@ -32,6 +32,8 @@ import { sendWalletConnectTx } from "next-common/utils/sendTransaction/sendWalle import { useWalletConnectBuildPayload } from "next-common/hooks/useWalletConnectBuildPayload"; import { useVaultSigner } from "next-common/context/polkadotVault/vaultSignerProvider"; import { useWalletConnect } from "next-common/context/walletconnect"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import { WATCH_ONLY_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; function shouldSendSignetTx(signerAccount) { return signerAccount?.meta?.source === WalletTypes.SIGNET; @@ -76,6 +78,7 @@ export function useSendTransaction() { const [isSubmitting, setIsSubmitting] = useState(false); const dispatch = useDispatch(); const signerAccount = useSignerAccount(); + const isWatchOnly = useIsWatchOnly(); const setSigner = useSetSigner(); const { sdk: signetSdk } = useSignetSdk(); const { sendVaultTx } = useVaultSigner(); @@ -105,6 +108,11 @@ export function useSendTransaction() { return; } + if (isWatchOnly) { + dispatch(newErrorToast(WATCH_ONLY_TOOLTIP_TEXT)); + return; + } + try { await setSigner(api, signerAccount); } catch (e) { @@ -295,6 +303,7 @@ export function useSendTransaction() { [ buildPayload, dispatch, + isWatchOnly, signerAccount, signetSdk, setSigner, diff --git a/packages/next-common/hooks/useSignMessage.js b/packages/next-common/hooks/useSignMessage.js index 5348403d77..fa9b370a36 100644 --- a/packages/next-common/hooks/useSignMessage.js +++ b/packages/next-common/hooks/useSignMessage.js @@ -12,6 +12,10 @@ import { useWalletConnect } from "next-common/context/walletconnect"; import WalletTypes from "next-common/utils/consts/walletTypes"; import { useVaultSigner } from "next-common/context/polkadotVault/vaultSignerProvider"; import { isMockAccountAddress } from "next-common/utils/mockAccount"; +import { + isWatchOnlyAccount, + WATCH_ONLY_MESSAGE_SIGN_REJECTED_TEXT, +} from "next-common/utils/watchOnly"; // Fixed fake signature returned for mock accounts (bypass real wallet signing) const MOCK_SIGNATURE = "0x" + "00".repeat(64); @@ -28,6 +32,10 @@ export function useSignMessage() { return MOCK_SIGNATURE; } + if (isWatchOnlyAccount({ wallet: walletName })) { + throw new Error(WATCH_ONLY_MESSAGE_SIGN_REJECTED_TEXT); + } + const shouldUseEVMSign = walletName === metamask.extensionName; if (shouldUseEVMSign) { diff --git a/packages/next-common/sima/components/post/postCreate.js b/packages/next-common/sima/components/post/postCreate.js index 2f0cf28e06..cdec793bd3 100644 --- a/packages/next-common/sima/components/post/postCreate.js +++ b/packages/next-common/sima/components/post/postCreate.js @@ -16,6 +16,9 @@ import { getContentField } from "next-common/utils/sima/utils"; import useSignSimaMessage from "next-common/utils/sima/useSignSimaMessage"; import AdvancedForm from "next-common/components/post/advanced/form"; import { getRealField } from "next-common/sima/actions/common"; +import WatchOnlyTooltip from "next-common/components/watchOnly/tooltip"; +import { useIsWatchOnly } from "next-common/context/connectedAccount"; +import { WATCH_ONLY_CREATE_POST_TOOLTIP_TEXT } from "next-common/utils/watchOnly"; const Wrapper = styled(NeutralPanel)` color: var(--textPrimary); @@ -68,6 +71,7 @@ export default function SimaPostCreate() { const advancedForm = useRef(); const [isAdvanced, setIsAdvanced] = useState(false); const [formValue, setFormValue] = useState({}); + const isWatchOnly = useIsWatchOnly(); const createPost = async (proxyAddress) => { setCreating(true); @@ -164,13 +168,17 @@ export default function SimaPostCreate() { /> - + + +
); diff --git a/packages/next-common/utils/consts/connect/index.js b/packages/next-common/utils/consts/connect/index.js index 722c1cf9fb..d563af827e 100644 --- a/packages/next-common/utils/consts/connect/index.js +++ b/packages/next-common/utils/consts/connect/index.js @@ -15,6 +15,7 @@ import { WalletWalletconnect, WalletPolkadotVaultLight, } from "@osn/icons/subsquare"; +import { WalletWatchOnly } from "./walletIcons"; export const polkadotJs = { extensionName: WalletTypes.POLKADOT_JS, @@ -119,6 +120,12 @@ export const polkadotVaultWallet = { logo: WalletPolkadotVaultLight, }; +export const watchOnlyWallet = { + extensionName: WalletTypes.WATCH_ONLY, + title: "Watch-only", + logo: WalletWatchOnly, +}; + export const allWallets = [ polkadotJs, subWallet, @@ -134,4 +141,5 @@ export const allWallets = [ coinbaseWallet, walletConnect, polkadotVaultWallet, + watchOnlyWallet, ]; diff --git a/packages/next-common/utils/consts/connect/walletIcons.jsx b/packages/next-common/utils/consts/connect/walletIcons.jsx new file mode 100644 index 0000000000..7c6f0601d0 --- /dev/null +++ b/packages/next-common/utils/consts/connect/walletIcons.jsx @@ -0,0 +1,8 @@ +import { SystemEye } from "@osn/icons/subsquare"; +import { cn } from "next-common/utils"; + +export function WalletWatchOnly({ className, ...props }) { + return ( + + ); +} diff --git a/packages/next-common/utils/consts/walletTypes.js b/packages/next-common/utils/consts/walletTypes.js index 91c0720fa7..82359fa5e1 100644 --- a/packages/next-common/utils/consts/walletTypes.js +++ b/packages/next-common/utils/consts/walletTypes.js @@ -13,6 +13,7 @@ const WalletTypes = { COINBASE_WALLET: "coinbase wallet", WALLETCONNECT: "walletconnect", POLKADOT_VAULT: "polkadot-vault", + WATCH_ONLY: "watch-only", }; export default WalletTypes; diff --git a/packages/next-common/utils/watchOnly.js b/packages/next-common/utils/watchOnly.js new file mode 100644 index 0000000000..26e20de2e8 --- /dev/null +++ b/packages/next-common/utils/watchOnly.js @@ -0,0 +1,23 @@ +import WalletTypes from "./consts/walletTypes"; + +export const WATCH_ONLY_TOOLTIP_TEXT = + "Watch-only accounts cannot sign transactions."; + +export const WATCH_ONLY_LOGIN_REJECTED_TEXT = + "Watch-only account cannot sign in."; + +export const WATCH_ONLY_MESSAGE_SIGN_REJECTED_TEXT = + "Watch-only account cannot sign messages."; + +export const WATCH_ONLY_CREATE_POST_TOOLTIP_TEXT = + "Watch-only account cannot create a post."; + +export const WATCH_ONLY_CREATE_APPLICATION_TOOLTIP_TEXT = + "Watch-only account cannot create an application."; + +export const WATCH_ONLY_COMMENT_TOOLTIP_TEXT = + "Watch-only account cannot comment."; + +export function isWatchOnlyAccount(account) { + return account?.wallet === WalletTypes.WATCH_ONLY; +}